From 1514865ec8911c26612e68048f84c6c8be92449d Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Mon, 21 Sep 2026 22:15:24 -0400 Subject: Show issue and pull request status and labels in their lists Co-Authored-By: Claude Opus 5 --- README.md | 1 - public/static/style.css | 13 +++++++++ src/app.test.ts | 37 +++++++++++++++++++++++++ src/scraper.test.ts | 35 +++++++++++++++++++++++ src/scraper.ts | 74 +++++++++++++++++++++++++++++++++++++------------ views/_issueState.eta | 1 + views/issues.eta | 7 ++--- views/pulls.eta | 4 +++ 8 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 views/_issueState.eta diff --git a/README.md b/README.md index cb9f863..7aef205 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ Implemented: Not implemented: - Pull request and commit details -- Issue and pull request status and labels in their lists - Code search (GitHub requires signing in) - Pagination (only the first page of issues, pull requests, discussions, commits, releases, branches, and tags is shown, and likewise only the replies diff --git a/public/static/style.css b/public/static/style.css index c36a263..cf3fb75 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -32,6 +32,11 @@ oklch(70% 0.15 150) ); + --merged-color: light-dark( + oklch(45% 0.2 310), + oklch(70% 0.2 310) + ); + --pl-c: light-dark(#6e7781, #8b949e); --pl-c1: light-dark(#0550ae, #79c0ff); --pl-e: light-dark(#6639ba, #d2a8ff); @@ -417,6 +422,14 @@ pre.box { color: var(--success-color); } +.state-merged { + color: var(--merged-color); +} + +.state-draft { + opacity: .7; +} + /* A timeline entry that is not a comment, so it is a line rather than a box. */ .issue-event { display: flex; diff --git a/src/app.test.ts b/src/app.test.ts index 2211adf..59a2c61 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -409,6 +409,43 @@ describe("issues", () => { hiddenItems: 0, }; + it("should render the state and labels of every row of a list", () => { + const rows = [ + { + number: 10000, + title: "Forking is disabled", + state: "CLOSED", + createdAt: "2024-12-03T23:01:40Z", + labels: [label], + }, + { + number: 10001, + title: "Add a flag", + state: "OPEN", + createdAt: "2024-12-04T10:00:00Z", + labels: [], + }, + ]; + + const issues = eta.render("issues.eta", { repo, issues: rows }); + assert.match(issues, /Closed<\/span>/); + assert.match(issues, /Open<\/span>/); + assert.match(issues, /\s+Forking is disabled/); + assert.match(issues, /<\/span>bug/); + + // A pull request is also merged or a draft, and links to its own page. + const pulls = eta.render("pulls.eta", { + repo, + pulls: [ + { ...rows[0], state: "MERGED" }, + { ...rows[1], state: "DRAFT" }, + ], + }); + assert.match(pulls, /Merged<\/span>/); + assert.match(pulls, /Draft<\/span>/); + assert.match(pulls, //); + }); + it("should render the issue above its timeline", () => { const html = eta.render("issue.eta", issue); diff --git a/src/scraper.test.ts b/src/scraper.test.ts index b8797bf..a8debd7 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -156,7 +156,9 @@ describe("GitHub scraper", () => { const issue417 = data.issues.find((issue) => issue.number === 417); assert.ok(issue417); assert.strictEqual(issue417.title, "Chats"); + assert.strictEqual(issue417.state, "CLOSED"); assert.ok(data.issues.every((issue) => issue.title !== "")); + assert.ok(data.issues.every((issue) => issue.state === "CLOSED")); }); it("should fetch pull requests", async () => { @@ -167,6 +169,8 @@ describe("GitHub scraper", () => { const pr411 = data.pulls.find((pull) => pull.number === 411); assert.ok(pr411); assert.strictEqual(pr411.title, "update node version"); + assert.ok(data.pulls.some((pull) => pull.state === "MERGED")); + assert.ok(data.pulls.every((pull) => pull.state === "MERGED" || pull.state === "CLOSED")); }); it("should fetch commits", async () => { @@ -502,6 +506,37 @@ describe("GitHub scraper", () => { }); describe("issues", () => { + it("should fetch the labels an issue list row wears", async () => { + const data = await getGitHubIssues("cli", "cli", { q: "is:issue label:bug" }); + + assert.ok(data.issues.length > 0); + assert.ok(data.issues.every((issue) => issue.labels.some((label) => label.name === "bug"))); + + const bug = data.issues[0].labels.find((label) => label.name === "bug"); + assert.ok(bug); + assert.strictEqual(bug.color, "d73a4a"); + assert.strictEqual(bug.description, "Something isn't working"); + }); + + // A pull request list is HTML, so its labels are colored channel by channel + // and described in a tooltip rather than spelled out in a payload. + it("should fetch the labels a pull request list row wears", async () => { + const data = await getGitHubPulls("cli", "cli", { q: "is:pr label:dependencies" }); + + assert.ok(data.pulls.length > 0); + const dependencies = data.pulls[0].labels.find((label) => label.name === "dependencies"); + assert.ok(dependencies); + assert.strictEqual(dependencies.color, "0366d6"); + assert.strictEqual(dependencies.description, "Pull requests that update a dependency file"); + }); + + it("should fetch the state of a draft pull request", async () => { + const data = await getGitHubPulls("cli", "cli", { q: "is:pr is:open draft:true" }); + + assert.ok(data.pulls.length > 0); + assert.ok(data.pulls.every((pull) => pull.state === "DRAFT")); + }); + it("should fetch an issue with its comments", async () => { const data = await getGitHubIssue("actions", "deploy-pages", 402); diff --git a/src/scraper.ts b/src/scraper.ts index 3670034..19b8318 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -50,9 +50,7 @@ interface IssueNode { state: string; labels?: { edges: { - node: { - name: string; - }; + node: IssueLabelNode; }[]; }; } @@ -331,11 +329,20 @@ export interface GitHubBlob extends GitHubNav { htmlContent: string | null; } +export interface IssueLabel { + name: string; + // GitHub's own hex color for the label, without a leading "#". + color: string; + description: string | null; +} + interface Issue { number: number; title: string; + // OPEN or CLOSED for an issue; a pull request is also DRAFT or MERGED. state: string; createdAt: string; + labels: IssueLabel[]; numReplies?: number; numPRs?: number; } @@ -350,13 +357,6 @@ export interface GitHubPulls extends GitHubCommon { q?: string; } -export interface IssueLabel { - name: string; - // GitHub's own hex color for the label, without a leading "#". - color: string; - description: string | null; -} - export interface IssueComment { // What GitHub's permalinks point at, e.g. "issuecomment-3090316111". anchor: string; @@ -772,6 +772,35 @@ export async function getGitHubRaw( return response; } +function parseIssueLabel(label: IssueLabelNode): IssueLabel { + return { name: label.name, color: label.color, description: label.description ?? null }; +} + +// A label in a list row wears its color as the RGB channels GitHub's own CSS +// mixes with, rather than as the hex its issue payloads carry, and keeps its +// description in a tooltip pointing back at it. +function parseRowLabel(label: Element, row: Element): IssueLabel { + const style = getAttributeValue(label, "style") ?? ""; + const channels: number[] = []; + for (const channel of ["r", "g", "b"]) { + const value = new RegExp(`--label-${channel}:\\s*(\\d+)`).exec(style); + if (value) channels.push(parseInt(value[1], 10)); + } + + const id = getAttributeValue(label, "id"); + const tooltip = id ? cssSelect.selectOne(`tool-tip[for="${id}"]`, row) : null; + + return { + name: getAttributeValue(label, "data-name") ?? textContent(label).trim(), + // GitHub's own default gray, for a label whose color it did not spell out. + color: + channels.length === 3 + ? channels.map((c) => c.toString(16).padStart(2, "0")).join("") + : "ededed", + description: tooltip ? textContent(tooltip).trim() || null : null, + }; +} + export async function getGitHubIssues( owner: string, repo: string, @@ -793,9 +822,15 @@ export async function getGitHubIssues( } const issues: Issue[] = issuesQuery.result.data.repository.search.edges.map((edge) => { - const { number, title, titleHtml, state, createdAt } = edge.node; + const { number, title, titleHtml, state, createdAt, labels } = edge.node; const finalTitle = (title || titleHtml || "").replace(/<[^>]+>/g, ""); - return { number, title: finalTitle, state, createdAt }; + return { + number, + title: finalTitle, + state, + createdAt, + labels: (labels?.edges ?? []).map((label) => parseIssueLabel(label.node)), + }; }); return { repo: { owner, name: repo }, issues, q }; @@ -814,10 +849,6 @@ function issueAssociation(association: string | undefined): string | null { return words.charAt(0).toUpperCase() + words.slice(1); } -function parseIssueLabel(label: IssueLabelNode): IssueLabel { - return { name: label.name, color: label.color, description: label.description ?? null }; -} - function parseIssueComment(node: IssueTimelineNode): IssueComment { return { anchor: node.url?.split("#")[1] ?? "", @@ -1008,9 +1039,16 @@ export async function getGitHubPulls( ? "MERGED" : ariaLabel.includes("Closed") ? "CLOSED" - : "OPEN"; + : ariaLabel.includes("Draft") + ? "DRAFT" + : "OPEN"; + + const labels: IssueLabel[] = []; + for (const label of cssSelect.selectAll("a.IssueLabel", row) as unknown as Element[]) { + labels.push(parseRowLabel(label, row)); + } - pulls.push({ number, title, state, createdAt }); + pulls.push({ number, title, state, createdAt, labels }); } return { repo: { owner, name: repo }, pulls, q }; diff --git a/views/_issueState.eta b/views/_issueState.eta new file mode 100644 index 0000000..7ac5ad7 --- /dev/null +++ b/views/_issueState.eta @@ -0,0 +1 @@ +<%= it.state.charAt(0) + it.state.slice(1).toLowerCase() %> diff --git a/views/issues.eta b/views/issues.eta index 754afb8..784557b 100644 --- a/views/issues.eta +++ b/views/issues.eta @@ -4,14 +4,13 @@ <% for (const issue of it.issues) { %> #<%= issue.number %> + <%~ include("./_issueState.eta", { state: issue.state }) %> <%= issue.title %> - - - <% if (issue.commentCount > 0) { %> - comments <%= issue.commentCount %> + <% for (const label of issue.labels) { %> + <%~ include("./_issueLabel.eta", { label }) %> <% } %> diff --git a/views/pulls.eta b/views/pulls.eta index 697f6b1..bdd6b67 100644 --- a/views/pulls.eta +++ b/views/pulls.eta @@ -4,10 +4,14 @@ <% for (const pull of it.pulls) { %> #<%= pull.number %> + <%~ include("./_issueState.eta", { state: pull.state }) %> <%= pull.title %> + <% for (const label of pull.labels) { %> + <%~ include("./_issueLabel.eta", { label }) %> + <% } %> -- cgit v1.3.1