aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md3
-rw-r--r--README.md8
-rw-r--r--public/static/style.css36
-rw-r--r--src/app.test.ts156
-rw-r--r--src/app.ts26
-rw-r--r--src/scraper.test.ts131
-rw-r--r--src/scraper.ts232
-rw-r--r--views/_discussionComment.eta37
-rw-r--r--views/discussion.eta29
-rw-r--r--views/discussions.eta39
-rw-r--r--views/layouts/repo.eta2
11 files changed, 694 insertions, 5 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index ef7b133..cc10163 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,9 @@ pnpm test
- `GET /:owner/:repo/tree/:branch/:path*` - Directory listing
- `GET /:owner/:repo/blob/:branch/:path` - File content view
- `GET /:owner/:repo/commits/:branch/:path*` - Commit history of the repository, a directory, or a file
+- `GET /:owner/:repo/discussions` - Discussion list, searched with `discussions_q`
+- `GET /:owner/:repo/discussions/categories/:category` - Discussions in one category
+- `GET /:owner/:repo/discussions/:number` - A discussion, its comments, and their replies
- `GET /:owner/:repo/wiki` - Wiki home page
- `GET /:owner/:repo/wiki/:page` - Wiki page (also `/:page/:oid` for a revision)
- `GET /:owner/:repo/wiki/_pages` - Wiki page index
diff --git a/README.md b/README.md
index 45c94cf..e3c2819 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Implemented:
- Directory listing and file browsing (`tree`/`blob`)
- Commit history (but this often results in 429 Too Many Requests on Cloudflare)
- Issue and pull request lists and search
+- Discussions
- Release lists and details
- Wiki
- User and organization profiles
@@ -22,10 +23,11 @@ Not implemented:
- Issue, pull request, and commit details
- Issue and pull request status and labels
- Code search (GitHub requires signing in)
-- Pagination (only the first page of issues, pull requests, commits, releases,
- branches, and tags is shown)
+- Pagination (only the first page of issues, pull requests, discussions,
+ commits, releases, branches, and tags is shown, and likewise only the replies
+ GitHub sends with a discussion rather than behind a "show more" button)
- Blame, contributors, stargazer/fork lists, network graphs, and insights
-- Actions, checks, projects, packages, discussions, and gists
+- Actions, checks, projects, packages, and gists
- Profile tabs other than the overview (repositories, projects, packages,
people, sponsoring)
- Anything requiring an account: signing in, notifications, starring,
diff --git a/public/static/style.css b/public/static/style.css
index ad61fca..e9b43ed 100644
--- a/public/static/style.css
+++ b/public/static/style.css
@@ -27,6 +27,11 @@
oklch(60% 0.15 250)
);
+ --success-color: light-dark(
+ oklch(45% 0.15 150),
+ oklch(70% 0.15 150)
+ );
+
--pl-c: light-dark(#6e7781, #8b949e);
--pl-c1: light-dark(#0550ae, #79c0ff);
--pl-e: light-dark(#6639ba, #d2a8ff);
@@ -310,7 +315,17 @@ pre.box {
}
}
-.overview, .release, .wiki {
+.pill {
+ margin-left: .2rem;
+ padding: 0 .4rem;
+ border: 1px solid var(--border-color);
+ border-radius: 1rem;
+ font-size: .8rem;
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+.overview, .release, .wiki, .comment {
overflow-wrap: break-word;
/* This is a bit convoluted: we want the effect of padding but using margins
@@ -354,7 +369,7 @@ pre.box {
font-weight: bold;
}
-.release > header, .wiki > header {
+.release > header, .wiki > header, .comment > header {
display: flex;
align-items: baseline;
> :first-child {
@@ -385,6 +400,23 @@ pre.box {
font-size: .8rem;
}
+.answered {
+ color: var(--success-color);
+}
+
+.comment.answer {
+ outline: 2px solid var(--success-color);
+ outline-offset: -1px;
+}
+
+.box:has(+ .reply) {
+ margin-bottom: 0;
+}
+
+.reply {
+ margin-top: -1px;
+}
+
.commit {
display: flex;
justify-content: space-between;
diff --git a/src/app.test.ts b/src/app.test.ts
index 450c1cc..3fdacd5 100644
--- a/src/app.test.ts
+++ b/src/app.test.ts
@@ -358,6 +358,162 @@ describe("wiki", () => {
});
});
+describe("discussions", () => {
+ const repo = { owner: "jj-vcs", name: "jj" };
+ const categories = [
+ { slug: "general", name: "General", emoji: "\u{1F4AC}" },
+ { slug: "q-a", name: "Q&A", emoji: "\u{1F64F}" },
+ ];
+ const comment = {
+ anchor: "discussioncomment-2258263",
+ author: "martinvonz",
+ createdAt: "2022-02-26T21:56:47Z",
+ labels: ["Maintainer"],
+ bodyHtml: "<p>Good point.</p>",
+ hiddenReason: null,
+ isAnswer: false,
+ replies: [],
+ hiddenReplies: 0,
+ };
+ const discussion = {
+ repo,
+ number: 88,
+ titleHtml: "&quot;at least&quot; .DS_Store was added back",
+ author: "arxanas",
+ category: categories[1],
+ answerAnchor: null,
+ body: { ...comment, anchor: "discussion-3903561", author: "arxanas" },
+ comments: [comment],
+ };
+
+ it("should link every category beside the list", () => {
+ const html = eta.render("discussions.eta", {
+ repo,
+ categories,
+ category: categories[1],
+ discussions: [],
+ q: "hooks",
+ });
+
+ assert.match(html, /href="\/jj-vcs\/jj\/discussions">All<\/a>/);
+ assert.match(html, /href="\/jj-vcs\/jj\/discussions\/categories\/q-a">\u{1F64F} Q&amp;A<\/a>/u);
+ // The search box keeps the query the list was narrowed by.
+ assert.match(html, /<input id="search-input"[^>]*value="hooks">/);
+ assert.match(html, /<option value="discussions" selected>/);
+ assert.match(html, /No discussions found\./);
+ });
+
+ it("should render a row per discussion", () => {
+ const html = eta.render("discussions.eta", {
+ repo,
+ categories,
+ category: null,
+ discussions: [
+ {
+ number: 3549,
+ titleHtml: "Feedback for <code>advance-branches</code>",
+ author: "emesterhazy",
+ createdAt: "2024-04-20T14:32:15Z",
+ category: categories[0],
+ answered: true,
+ },
+ ],
+ });
+
+ assert.match(html, /href="\/jj-vcs\/jj\/discussions\/3549">Feedback for <code>/);
+ assert.match(html, /href="\/jj-vcs\/jj\/discussions\/categories\/general">General<\/a>/);
+ assert.match(html, /<span class="answered pill">Answered<\/span>/);
+ assert.match(html, /<time title="2024-04-20T14:32:15Z">2024-04-20<\/time>/);
+ });
+
+ it("should render the discussion above its comments", () => {
+ const html = eta.render("discussion.eta", discussion);
+
+ assert.match(html, /<title>jj-vcs\/jj - discussions\/88<\/title>/);
+ assert.match(html, /<h2>&quot;at least&quot; \.DS_Store was added back<\/h2>/);
+ assert.match(
+ html,
+ /#88\s+in <a href="\/jj-vcs\/jj\/discussions\/categories\/q-a">Q&amp;A<\/a>/,
+ );
+ // The author is named on the body comment, not in the header.
+ assert.match(html, /<a href="\/arxanas">arxanas<\/a>/);
+ assert.match(html, /<div class="box comment" id="discussion-3903561">/);
+ // The timestamp is the comment's permalink.
+ assert.match(
+ html,
+ /<a href="#discussioncomment-2258263"><time title="2022-02-26T21:56:47Z">2022-02-26<\/time><\/a>/,
+ );
+ assert.match(html, /<div class="box comment" id="discussioncomment-2258263">/);
+ assert.match(html, /<span class="pill">Maintainer<\/span>/);
+ });
+
+ // The answer is called out both in the header, which links down to it, and
+ // on the comment itself.
+ it("should mark the comment that answered the discussion", () => {
+ const html = eta.render("discussion.eta", {
+ ...discussion,
+ answerAnchor: "discussioncomment-2258263",
+ comments: [{ ...comment, isAnswer: true }],
+ });
+
+ assert.match(html, /<a class="answered pill" href="#discussioncomment-2258263">Answered<\/a>/);
+ assert.match(html, /<div class="box comment answer" id="discussioncomment-2258263">/);
+ assert.match(html, /<span class="answered pill">Answer<\/span>/);
+ });
+
+ it("should run replies on from the comment they answer", () => {
+ const html = eta.render("_discussionComment.eta", {
+ comment: {
+ ...comment,
+ hiddenReplies: 5,
+ replies: [{ ...comment, anchor: "discussioncomment-2258264", author: "arxanas" }],
+ },
+ });
+
+ assert.match(
+ html,
+ /<div class="box reply placeholder">\s+5 earlier\s+replies are only on GitHub\./,
+ );
+ assert.match(html, /<div class="box comment reply" id="discussioncomment-2258264">/);
+ });
+
+ // A comment GitHub collapsed has nothing to show but why it was collapsed.
+ it("should render a hidden comment as its reason", () => {
+ const html = eta.render("_discussionComment.eta", {
+ comment: {
+ ...comment,
+ author: null,
+ createdAt: null,
+ labels: [],
+ bodyHtml: "",
+ hiddenReason: "This comment has been hidden.",
+ },
+ });
+
+ assert.match(html, /<div class="box comment" id="discussioncomment-2258263">/);
+ assert.match(html, /<div class="placeholder">This comment has been hidden\.<\/div>/);
+ assert.doesNotMatch(html, /<header>/);
+ assert.doesNotMatch(html, /ghost/);
+ });
+
+ it("should send a discussions search to the discussion list", async () => {
+ const res = await app.request(
+ "http://cgithub.example/jj-vcs/jj/search?q=hooks&type=discussions",
+ );
+
+ assert.strictEqual(res.status, 302);
+ assert.strictEqual(res.headers.get("location"), "/jj-vcs/jj/discussions?discussions_q=hooks");
+ });
+
+ // GitHub's own actions live where a number goes, so they are not ours.
+ it("should only scrape a discussion path that names a number", async () => {
+ const res = await app.request("http://cgithub.example/jj-vcs/jj/discussions/new");
+
+ assert.strictEqual(res.status, 200);
+ assert.match(await res.text(), /url=https:\/\/github.com\/jj-vcs\/jj\/discussions\/new"/);
+ });
+});
+
describe("global search", () => {
it("should keep the rest of the query around the qualifier", async () => {
const res = await app.request(
diff --git a/src/app.ts b/src/app.ts
index 0b9a7e6..ec3680b 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -5,6 +5,8 @@ import { buildCommit } from "./build.generated.ts";
import {
getGitHubBlob,
getGitHubCommits,
+ getGitHubDiscussion,
+ getGitHubDiscussions,
getGitHubIssues,
getGitHubSidebar,
getGitHubLatestCommit,
@@ -218,6 +220,28 @@ export function createApp(eta: Eta) {
return c.render("pulls.eta", await getGitHubPulls(owner, repo, { q }));
});
+ app.get("/:owner/:repo/discussions", async (c) => {
+ const { owner, repo } = c.req.param();
+ const q = c.req.query("discussions_q");
+ return c.render("discussions.eta", await getGitHubDiscussions(owner, repo, { q }));
+ });
+
+ app.get("/:owner/:repo/discussions/categories/:category", async (c) => {
+ const { owner, repo, category } = c.req.param();
+ const q = c.req.query("discussions_q");
+ return c.render("discussions.eta", await getGitHubDiscussions(owner, repo, { q, category }));
+ });
+
+ // GitHub puts its own actions (new, categories, ...) where a number goes, so
+ // anything that isn't one is not a discussion of ours to render.
+ app.get("/:owner/:repo/discussions/:number", async (c) => {
+ const { owner, repo, number } = c.req.param();
+ if (!/^\d+$/.test(number)) {
+ return redirectToGitHub(c);
+ }
+ return c.render("discussion.eta", await getGitHubDiscussion(owner, repo, Number(number)));
+ });
+
app.get("/:owner/:repo/search", async (c) => {
const { owner, repo } = c.req.param();
const q = c.req.query("q") ?? "";
@@ -227,6 +251,8 @@ export function createApp(eta: Eta) {
return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`);
} else if (type === "pullrequests") {
return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`);
+ } else if (type === "discussions") {
+ return c.redirect(`/${owner}/${repo}/discussions?discussions_q=${encodeURIComponent(q)}`);
}
// Redirect unhandled search types (like code, which requires sign in anyway).
diff --git a/src/scraper.test.ts b/src/scraper.test.ts
index 8dcb0b8..37415b9 100644
--- a/src/scraper.test.ts
+++ b/src/scraper.test.ts
@@ -3,6 +3,8 @@ import { describe, it } from "node:test";
import {
getGitHubBlob,
getGitHubCommits,
+ getGitHubDiscussion,
+ getGitHubDiscussions,
getGitHubIssues,
getGitHubSidebar,
getGitHubLatestCommit,
@@ -498,6 +500,135 @@ describe("GitHub scraper", () => {
});
});
+ describe("discussions", () => {
+ it("should fetch the discussion list with its categories", async () => {
+ const data = await getGitHubDiscussions("jj-vcs", "jj");
+
+ assert.strictEqual(data.repo.owner, "jj-vcs");
+ assert.strictEqual(data.repo.name, "jj");
+ assert.strictEqual(data.q, undefined);
+ assert.strictEqual(data.category, null);
+
+ assert.ok(data.discussions.length > 0);
+ assert.ok(data.discussions.every((discussion) => discussion.number > 0));
+ assert.ok(data.discussions.every((discussion) => discussion.titleHtml !== ""));
+ assert.ok(data.discussions.every((discussion) => discussion.createdAt));
+
+ assert.deepStrictEqual(
+ data.categories.find((category) => category.slug === "q-a"),
+ { slug: "q-a", name: "Q&A", emoji: "\u{1F64F}" },
+ );
+ });
+
+ // The category link in a row carries the filter it would apply as a query,
+ // which is not part of the slug we route on.
+ it("should narrow the list to one category", async () => {
+ const data = await getGitHubDiscussions("jj-vcs", "jj", { category: "q-a" });
+
+ assert.deepStrictEqual(data.category, { slug: "q-a", name: "Q&A", emoji: "\u{1F64F}" });
+ assert.ok(data.discussions.length > 0);
+ assert.ok(data.discussions.every((discussion) => discussion.category?.slug === "q-a"));
+ });
+
+ it("should search the list with q", async () => {
+ const data = await getGitHubDiscussions("jj-vcs", "jj", { q: "advance-branches" });
+
+ assert.strictEqual(data.q, "advance-branches");
+ const feedback = data.discussions.find((discussion) => discussion.number === 3549);
+ assert.ok(feedback);
+ assert.strictEqual(feedback.author, "emesterhazy");
+ assert.strictEqual(feedback.category?.slug, "general");
+ assert.strictEqual(feedback.createdAt, "2024-04-20T14:32:15Z");
+ assert.strictEqual(feedback.answered, false);
+ });
+
+ it("should fetch a discussion with its comments and replies", async () => {
+ const data = await getGitHubDiscussion("jj-vcs", "jj", 3549);
+
+ assert.strictEqual(data.repo.owner, "jj-vcs");
+ assert.strictEqual(data.number, 3549);
+ // Titles are Markdown, so the code span survives into the HTML.
+ assert.strictEqual(
+ data.titleHtml,
+ "Feedback for the experimental <code>advance-branches</code> feature",
+ );
+ assert.strictEqual(data.author, "emesterhazy");
+ assert.deepStrictEqual(data.category, { slug: "general", name: "General", emoji: null });
+ assert.strictEqual(data.answerAnchor, null);
+
+ assert.strictEqual(data.body.anchor, "discussion-6532740");
+ assert.strictEqual(data.body.author, "emesterhazy");
+ assert.strictEqual(data.body.createdAt, "2024-04-20T14:32:15Z");
+ assert.match(data.body.bodyHtml, /Please leave any feedback/);
+
+ assert.ok(data.comments.length > 0);
+ assert.ok(data.comments.every((comment) => comment.anchor.startsWith("discussioncomment-")));
+ // Replies hang off the comment they answer.
+ const threaded = data.comments.filter((comment) => comment.replies.length > 0);
+ assert.ok(threaded.length > 0);
+ // GitHub collapses the older replies of the longest threads.
+ assert.ok(data.comments.some((comment) => comment.hiddenReplies > 0));
+ });
+
+ it("should mark the comment a discussion was answered by", async () => {
+ const data = await getGitHubDiscussion("mozilla", "pdf.js", 18821);
+
+ assert.strictEqual(data.repo.owner, "mozilla");
+ assert.strictEqual(data.repo.name, "pdf.js");
+ assert.strictEqual(data.number, 18821);
+ assert.strictEqual(
+ data.titleHtml,
+ "[Bug]: TypeError Promise.withResolvers is not a function",
+ );
+ assert.strictEqual(data.author, "techpranav");
+ assert.deepStrictEqual(data.category, { slug: "q-a", name: "Q&A", emoji: null });
+
+ assert.strictEqual(data.body.anchor, "discussion-7249716");
+ assert.strictEqual(data.body.createdAt, "2024-09-29T08:35:02Z");
+ assert.strictEqual(data.body.isAnswer, false);
+
+ assert.strictEqual(data.answerAnchor, "discussioncomment-10945480");
+
+ const answer = data.comments.find((comment) => comment.isAnswer);
+ assert.ok(answer);
+ assert.strictEqual(answer.anchor, data.answerAnchor);
+ assert.strictEqual(answer.author, "Snuffleupagus");
+ assert.deepStrictEqual(answer.labels, ["Collaborator"]);
+ assert.strictEqual(answer.replies.length, 1);
+ assert.strictEqual(answer.replies[0].isAnswer, false);
+ });
+
+ // A comment GitHub collapsed is served as its reason alone: signed out,
+ // there is no author, no timestamp, and no body to show.
+ it("should keep the reason a comment was hidden", async () => {
+ const data = await getGitHubDiscussion("mozilla", "pdf.js", 18821);
+
+ const replies = data.comments.flatMap((comment) => comment.replies);
+ const hidden = replies.find((reply) => reply.anchor === "discussioncomment-10945516");
+ assert.ok(hidden);
+ assert.strictEqual(hidden.hiddenReason, "This comment has been hidden.");
+ assert.strictEqual(hidden.author, null);
+ assert.strictEqual(hidden.createdAt, null);
+ assert.strictEqual(hidden.bodyHtml, "");
+
+ // The comment it is a reply to is shown as usual, hidden reply and all.
+ const parent = data.comments.find((comment) =>
+ comment.replies.some((reply) => reply === hidden),
+ );
+ assert.ok(parent);
+ assert.strictEqual(parent.hiddenReason, null);
+ assert.ok(parent.bodyHtml !== "");
+ });
+
+ it("should throw for a repository with discussions turned off", async () => {
+ await assert.rejects(getGitHubDiscussions("torvalds", "linux"), (err) => {
+ assert(err instanceof GitHubHTTPError, "error should be an HTTPError");
+ assert.strictEqual(err.status, 404);
+ return true;
+ });
+ });
+ });
+
describe("owner pages", () => {
it("should fetch user profile", async () => {
const data = await getGitHubOwner("torvalds");
diff --git a/src/scraper.ts b/src/scraper.ts
index fb48700..d1a1763 100644
--- a/src/scraper.ts
+++ b/src/scraper.ts
@@ -284,6 +284,60 @@ export interface GitHubPulls extends GitHubCommon {
q?: string;
}
+interface DiscussionCategory {
+ slug: string;
+ name: string;
+ // Every category has an emoji, but GitHub only prints it in some places.
+ emoji: string | null;
+}
+
+interface DiscussionSummary {
+ number: number;
+ // Titles are Markdown, so they can carry code spans and emoji.
+ titleHtml: string;
+ author: string | null;
+ createdAt: string | null;
+ category: DiscussionCategory | null;
+ answered: boolean;
+}
+
+export interface DiscussionComment {
+ // What GitHub's permalinks point at, e.g. "discussioncomment-9174663".
+ anchor: string;
+ author: string | null;
+ createdAt: string | null;
+ // The badges beside the author: Author, Maintainer, Collaborator, ...
+ labels: string[];
+ bodyHtml: string;
+ // How GitHub words a comment it keeps collapsed, e.g. "This comment has been
+ // hidden." or "This comment was marked as off-topic."; null when the comment
+ // is shown as usual. A collapsed comment arrives with no author or body.
+ hiddenReason: string | null;
+ isAnswer: boolean;
+ replies: DiscussionComment[];
+ // Replies GitHub keeps collapsed behind a "show more" button of its own.
+ hiddenReplies: number;
+}
+
+export interface GitHubDiscussions extends GitHubCommon {
+ discussions: DiscussionSummary[];
+ categories: DiscussionCategory[];
+ // The category the list is narrowed to, if any.
+ category: DiscussionCategory | null;
+ q?: string;
+}
+
+export interface GitHubDiscussion extends GitHubCommon {
+ number: number;
+ titleHtml: string;
+ author: string | null;
+ category: DiscussionCategory | null;
+ // The comment a Q&A discussion is answered by, as an anchor on this page.
+ answerAnchor: string | null;
+ body: DiscussionComment;
+ comments: DiscussionComment[];
+}
+
export interface GitHubCommits extends GitHubNav {
commitGroups: CommitGroup[];
}
@@ -664,6 +718,184 @@ export async function getGitHubPulls(
return { repo: { owner, name: repo }, pulls, q };
}
+// A category link names the category on its own in a discussion's header and
+// in a list row; in the sidebar it wraps an emoji and a label of its own.
+function parseDiscussionCategory(link: Element | null): DiscussionCategory | null {
+ const href = link ? getAttributeValue(link, "href") : undefined;
+ // A list row's category link carries the filter it would apply as a query.
+ const slug = href?.split("?")[0].split("/").pop();
+ if (!link || !slug) return null;
+
+ const label = cssSelect.selectOne(".ActionList-item-label", link) ?? link;
+ const emoji = cssSelect.selectOne("g-emoji", link);
+ return {
+ slug,
+ name: textContent(label).replace(/\s+/g, " ").trim(),
+ emoji: emoji ? textContent(emoji).trim() : null,
+ };
+}
+
+function parseDiscussionCategories(document: AnyNode): DiscussionCategory[] {
+ const categories: DiscussionCategory[] = [];
+ const links = cssSelect.selectAll(
+ 'nav[aria-label="Categories"] a[href*="/discussions/categories/"]',
+ document,
+ ) as unknown as Element[];
+ for (const link of links) {
+ const category = parseDiscussionCategory(link);
+ if (category) categories.push(category);
+ }
+ return categories;
+}
+
+function parseDiscussionSummary(row: Element): DiscussionSummary | null {
+ const link = cssSelect.selectOne('a[data-hovercard-type="discussion"]', row);
+ const href = link ? getAttributeValue(link, "href") : undefined;
+ const number = parseInt(href?.split("/").pop() ?? "", 10);
+ if (!link || !number) return null;
+
+ const time = cssSelect.selectOne("relative-time", row);
+
+ return {
+ number,
+ titleHtml: getInnerHTML(link).trim(),
+ author: selectText('a[aria-label$="(author)"]', row),
+ createdAt: time ? (getAttributeValue(time, "datetime") ?? null) : null,
+ category: parseDiscussionCategory(cssSelect.selectOne('a[aria-label$="(category)"]', row)),
+ // An answered thread is called out in green beside its category.
+ answered: cssSelect.selectOne(".color-fg-success", row) !== null,
+ };
+}
+
+export async function getGitHubDiscussions(
+ owner: string,
+ repo: string,
+ { q, category }: { q?: string; category?: string } = {},
+): Promise<GitHubDiscussions> {
+ const base = category
+ ? `${owner}/${repo}/discussions/categories/${category}`
+ : `${owner}/${repo}/discussions`;
+ const suffix = q ? `?discussions_q=${encodeURIComponent(q)}` : "";
+ const html = await fetchGitHubPage(`${base}${suffix}`);
+ const document = parseDocument(html);
+
+ const discussions: DiscussionSummary[] = [];
+ for (const row of cssSelect.selectAll(
+ "li.js-navigation-item",
+ document,
+ ) as unknown as Element[]) {
+ const summary = parseDiscussionSummary(row);
+ if (summary) discussions.push(summary);
+ }
+
+ const categories = parseDiscussionCategories(document);
+
+ return {
+ repo: { owner, name: repo },
+ discussions,
+ categories,
+ category: categories.find((c) => c.slug === category) ?? null,
+ q,
+ };
+}
+
+// A comment and a reply to it are the same markup, with the replies nested
+// inside the comment they answer.
+function parseDiscussionComment(item: Element): DiscussionComment {
+ const target = cssSelect.selectOne(".discussions-timeline-scroll-target[id]", item);
+ const header = cssSelect.selectOne(".timeline-comment-header-text", item);
+ const time = header ? cssSelect.selectOne("relative-time", header) : null;
+ const body = cssSelect.selectOne(".js-comment-body", item);
+
+ // A comment GitHub collapsed has its reason where the header would be, and
+ // nothing else: no author, no time, no body. The header of a comment shown
+ // as usual comes first, so only a collapsed one owns the first match here;
+ // in the others it belongs to a nested reply.
+ const minimized = cssSelect.selectOne(".minimized-comment .timeline-comment-header-text", item);
+ const hiddenReason =
+ header !== null && header === minimized
+ ? textContent(header).replace(/\s+/g, " ").trim()
+ : null;
+
+ const labels: string[] = [];
+ if (header) {
+ for (const label of cssSelect.selectAll(".Label", header) as unknown as Element[]) {
+ labels.push(textContent(label).replace(/\s+/g, " ").trim());
+ }
+ }
+
+ const replies: DiscussionComment[] = [];
+ for (const reply of cssSelect.selectAll(
+ ".discussion-nested-comment-timeline-item",
+ item,
+ ) as unknown as Element[]) {
+ replies.push(parseDiscussionComment(reply));
+ }
+
+ // Long threads arrive with the older replies behind a "Show 5 previous
+ // replies" button, which needs GitHub's JavaScript to load them.
+ let hiddenReplies = 0;
+ for (const button of cssSelect.selectAll(
+ ".discussion-nested-comment-paging-form .Button-label",
+ item,
+ ) as unknown as Element[]) {
+ const count = /(\d+)/.exec(textContent(button));
+ if (count) hiddenReplies += parseInt(count[1], 10);
+ }
+
+ return {
+ anchor: target ? (getAttributeValue(target, "id") ?? "") : "",
+ author: header ? selectText('a[data-hovercard-type="user"]', header) : null,
+ createdAt: time ? (getAttributeValue(time, "datetime") ?? null) : null,
+ labels,
+ bodyHtml: hiddenReason ? "" : body ? getInnerHTML(body).trim() : "",
+ hiddenReason,
+ isAnswer: cssSelect.selectOne(".discussion-footer-answered-badge", item) !== null,
+ replies,
+ hiddenReplies,
+ };
+}
+
+export async function getGitHubDiscussion(
+ owner: string,
+ repo: string,
+ number: number,
+): Promise<GitHubDiscussion> {
+ const html = await fetchGitHubPage(`${owner}/${repo}/discussions/${number}`);
+ const document = parseDocument(html);
+
+ const header = cssSelect.selectOne("#partial-discussion-header", document) as Element | null;
+ if (!header) {
+ throw new Error("Could not find the discussion header");
+ }
+
+ // The discussion's own post opens the timeline; the rest are its comments.
+ const items = cssSelect.selectAll(".discussion-timeline-item", document) as unknown as Element[];
+ if (items.length === 0) {
+ throw new Error("Could not find the discussion body");
+ }
+ const [body, ...comments] = items.map(parseDiscussionComment);
+
+ const title = cssSelect.selectOne(".js-issue-title", header);
+ // Who opened the discussion, and where, sits under the title; an answer, when
+ // there is one, is in a state badge above it.
+ const meta = cssSelect.selectOne("div.color-fg-muted", header) as Element | null;
+ const answer = cssSelect.selectOne(".js-discussions-goto-answer-button", header);
+
+ return {
+ repo: { owner, name: repo },
+ number,
+ titleHtml: title ? getInnerHTML(title).trim() : "",
+ author: meta ? selectText("a[href]", meta) : null,
+ category: parseDiscussionCategory(
+ meta ? cssSelect.selectOne('a[href*="/discussions/categories/"]', meta) : null,
+ ),
+ answerAnchor: answer ? (getAttributeValue(answer, "href") ?? "").replace(/^#/, "") : null,
+ body,
+ comments,
+ };
+}
+
export async function getGitHubCommits(
owner: string,
repo: string,
diff --git a/views/_discussionComment.eta b/views/_discussionComment.eta
new file mode 100644
index 0000000..9c2cc07
--- /dev/null
+++ b/views/_discussionComment.eta
@@ -0,0 +1,37 @@
+<div class="box comment<%= it.comment.isAnswer ? " answer" : "" %><%= it.reply ? " reply" : "" %>" id="<%= it.comment.anchor %>">
+<% if (it.comment.hiddenReason) { %>
+ <div class="placeholder"><%= it.comment.hiddenReason %></div>
+<% } else { %>
+ <header>
+ <span>
+ <% if (it.comment.author) { %>
+ <a href="/<%= it.comment.author %>"><%= it.comment.author %></a>
+ <% } else { %>
+ <span class="placeholder">ghost</span>
+ <% } %>
+ <% for (const label of it.comment.labels) { %>
+ <span class="pill"><%= label %></span>
+ <% } %>
+ <% if (it.comment.isAnswer) { %>
+ <span class="answered pill">Answer</span>
+ <% } %>
+ </span>
+ <span class="timestamp">
+ <% if (it.comment.createdAt) { %>
+ <a href="#<%= it.comment.anchor %>"><time title="<%= it.comment.createdAt %>"><%= it.comment.createdAt.split("T")[0] %></time></a>
+ <% } %>
+ </span>
+ </header>
+ <%~ it.comment.bodyHtml %>
+<% } %>
+</div>
+
+<% if (it.comment.hiddenReplies > 0) { %>
+ <div class="box reply placeholder">
+ <%= it.comment.hiddenReplies %> earlier
+ <%= it.comment.hiddenReplies === 1 ? "reply is" : "replies are" %> only on GitHub.
+ </div>
+<% } %>
+<% for (const reply of it.comment.replies) { %>
+ <%~ include("./_discussionComment.eta", { comment: reply, reply: true }) %>
+<% } %>
diff --git a/views/discussion.eta b/views/discussion.eta
new file mode 100644
index 0000000..f87189e
--- /dev/null
+++ b/views/discussion.eta
@@ -0,0 +1,29 @@
+<%
+layout("/layouts/repo.eta")
+it.path = `discussions/${it.number}`
+it.searchType = "discussions"
+%>
+
+<div class="box">
+ <h2><%~ it.titleHtml %></h2>
+
+ #<%= it.number %>
+ <% if (it.category) { %>
+ in <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions/categories/<%= it.category.slug %>"><%= it.category.name %></a>
+ <% } %>
+ <% if (it.answerAnchor) { %>
+ <a class="answered pill" href="#<%= it.answerAnchor %>">Answered</a>
+ <% } %>
+</div>
+
+<%~ include("./_discussionComment.eta", { comment: it.body }) %>
+
+<% if (it.answerAnchor) { %>
+ <a class="answered" href="#<%= it.answerAnchor %>">
+ <div class="box">&#x2193; Show answer</div>
+ </a>
+<% } %>
+
+<% for (const comment of it.comments) { %>
+ <%~ include("./_discussionComment.eta", { comment }) %>
+<% } %>
diff --git a/views/discussions.eta b/views/discussions.eta
new file mode 100644
index 0000000..87a6903
--- /dev/null
+++ b/views/discussions.eta
@@ -0,0 +1,39 @@
+<%
+layout("/layouts/repo.eta")
+it.path = it.category ? `discussions/${it.category.slug}` : "discussions"
+it.searchType = "discussions"
+%>
+
+<nav>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions">All</a>
+ <% for (const category of it.categories) { %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions/categories/<%= category.slug %>"><% if (category.emoji) { %><%= category.emoji %> <% } %><%= category.name %></a>
+ <% } %>
+</nav>
+
+<% if (it.discussions.length === 0) { %>
+ <div class="box">No discussions found.</div>
+<% } else { %>
+ <table>
+ <% for (const discussion of it.discussions) { %>
+ <tr>
+ <td>
+ <% if (discussion.category) { %>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions/categories/<%= discussion.category.slug %>"><%= discussion.category.name %></a>
+ <% } %>
+ </td>
+ <td>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions/<%= discussion.number %>"><%~ discussion.titleHtml %></a>
+ <% if (discussion.answered) { %>
+ <span class="answered pill">Answered</span>
+ <% } %>
+ </td>
+ <td class="timestamp">
+ <% if (discussion.createdAt) { %>
+ <time title="<%= discussion.createdAt %>"><%= discussion.createdAt.split("T")[0] %></time>
+ <% } %>
+ </td>
+ </tr>
+ <% } %>
+ </table>
+<% } %>
diff --git a/views/layouts/repo.eta b/views/layouts/repo.eta
index b1fb65c..6381196 100644
--- a/views/layouts/repo.eta
+++ b/views/layouts/repo.eta
@@ -15,6 +15,7 @@ layout("/layouts/base.eta", {
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>">Code</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/issues">Issues</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pulls">Pull requests</a>
+ <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/discussions">Discussions</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases">Releases</a>
<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/wiki">Wiki</a>
<% if (it.branch) { %>
@@ -27,6 +28,7 @@ layout("/layouts/base.eta", {
<option value="code" <%= it.searchType === "code" ? "selected" : "" %>>Code</option>
<option value="issues" <%= it.searchType === "issues" || it.searchType == null ? "selected" : "" %>>Issues</option>
<option value="pullrequests" <%= it.searchType === "pullrequests" ? "selected" : "" %>>Pull requests</option>
+ <option value="discussions" <%= it.searchType === "discussions" ? "selected" : "" %>>Discussions</option>
</select>
<button type="submit">Search</button>
</form>