From 499bba129c842c00c1a28d85bc044ab609aee44e Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 21 Sep 2026 17:55:06 -0400 Subject: 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 --- CLAUDE.md | 3 + README.md | 8 +- public/static/style.css | 36 ++++++- src/app.test.ts | 156 +++++++++++++++++++++++++++++ src/app.ts | 26 +++++ src/scraper.test.ts | 131 ++++++++++++++++++++++++ src/scraper.ts | 232 +++++++++++++++++++++++++++++++++++++++++++ views/_discussionComment.eta | 37 +++++++ views/discussion.eta | 29 ++++++ views/discussions.eta | 39 ++++++++ views/layouts/repo.eta | 2 + 11 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 views/_discussionComment.eta create mode 100644 views/discussion.eta create mode 100644 views/discussions.eta 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: "

Good point.

", + 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, /]*value="hooks">/); + assert.match(html, /