diff options
| author | Joe Mou <dev@mou.fo> | 2026-09-21 17:55:06 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-09-25 01:43:53 -0400 |
| commit | 499bba129c842c00c1a28d85bc044ab609aee44e (patch) | |
| tree | bb69fa242a20e3c285fb6c41092b59f207f6855d | |
| parent | 3204ecf1c48e730f02fb83d7c09d2c778a6f6bdd (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>
| -rw-r--r-- | CLAUDE.md | 3 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | public/static/style.css | 36 | ||||
| -rw-r--r-- | src/app.test.ts | 156 | ||||
| -rw-r--r-- | src/app.ts | 26 | ||||
| -rw-r--r-- | src/scraper.test.ts | 131 | ||||
| -rw-r--r-- | src/scraper.ts | 232 | ||||
| -rw-r--r-- | views/_discussionComment.eta | 37 | ||||
| -rw-r--r-- | views/discussion.eta | 29 | ||||
| -rw-r--r-- | views/discussions.eta | 39 | ||||
| -rw-r--r-- | views/layouts/repo.eta | 2 |
11 files changed, 694 insertions, 5 deletions
@@ -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 @@ -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: ""at least" .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&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>"at least" \.DS_Store was added back<\/h2>/); + assert.match( + html, + /#88\s+in <a href="\/jj-vcs\/jj\/discussions\/categories\/q-a">Q&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( @@ -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">↓ 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> |
