aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-09-21 17:55:06 -0400
committerJoe Mou <dev@mou.fo>2026-09-25 01:43:53 -0400
commit499bba129c842c00c1a28d85bc044ab609aee44e (patch)
treebb69fa242a20e3c285fb6c41092b59f207f6855d /src
parent3204ecf1c48e730f02fb83d7c09d2c778a6f6bdd (diff)
Add discussions
Discussions are served from GitHub's older server-rendered HTML, with no embeddedData JSON to read, so the scraper walks the DOM the way the pull request and wiki code does. A discussion's timeline opens with its own post and continues with its comments, each carrying its replies nested inside it, which is where the recursion in _discussionComment.eta comes from. Categories, upvotes, and the answer a Q&A discussion is marked with all come along. Only what GitHub renders up front is shown: neither the next page of discussions nor the replies it hides behind a "Show N previous replies" button are fetched, so a comment says how many replies it is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-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
4 files changed, 545 insertions, 0 deletions
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,