diff options
| author | Joe Mou <dev@mou.fo> | 2026-09-21 22:15:24 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-09-25 01:43:53 -0400 |
| commit | 1514865ec8911c26612e68048f84c6c8be92449d (patch) | |
| tree | 07a24c2b0536e4fd3b69c1cbc2a424e9ee25aa8a | |
| parent | fd1c054c3c10dceb1a8a355c74a0d9e9077aae19 (diff) | |
Show issue and pull request status and labels in their lists
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | public/static/style.css | 13 | ||||
| -rw-r--r-- | src/app.test.ts | 37 | ||||
| -rw-r--r-- | src/scraper.test.ts | 35 | ||||
| -rw-r--r-- | src/scraper.ts | 74 | ||||
| -rw-r--r-- | views/_issueState.eta | 1 | ||||
| -rw-r--r-- | views/issues.eta | 7 | ||||
| -rw-r--r-- | views/pulls.eta | 4 |
8 files changed, 149 insertions, 23 deletions
@@ -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, /<span class="pill state state-closed">Closed<\/span>/); + assert.match(issues, /<span class="pill state state-open">Open<\/span>/); + assert.match(issues, /<a href="\/cli\/cli\/issues\/10000">\s+Forking is disabled/); + assert.match(issues, /<span class="pill-dot" style="background: #d73a4a"><\/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, /<span class="pill state state-merged">Merged<\/span>/); + assert.match(pulls, /<span class="pill state state-draft">Draft<\/span>/); + assert.match(pulls, /<a href="\/cli\/cli\/pull\/10000">/); + }); + 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 @@ +<span class="pill state state-<%= it.state.toLowerCase() %>"><%= it.state.charAt(0) + it.state.slice(1).toLowerCase() %></span> 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) { %> <tr> <td>#<%= issue.number %></td> + <td><%~ include("./_issueState.eta", { state: issue.state }) %></td> <td> <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/issues/<%= issue.number %>"> <%= issue.title %> </a> - </td> - <td> - <% if (issue.commentCount > 0) { %> - <img src="/static/icons/link.svg" alt="comments" class="icon"> <%= issue.commentCount %> + <% for (const label of issue.labels) { %> + <%~ include("./_issueLabel.eta", { label }) %> <% } %> </td> <td class="timestamp"><time title="<%= issue.createdAt %>"><%= issue.createdAt.split("T")[0] %></time></td> 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) { %> <tr> <td>#<%= pull.number %></td> + <td><%~ include("./_issueState.eta", { state: pull.state }) %></td> <td> <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/pull/<%= pull.number %>"> <%= pull.title %> </a> + <% for (const label of pull.labels) { %> + <%~ include("./_issueLabel.eta", { label }) %> + <% } %> </td> <td class="timestamp"><time title="<%= pull.createdAt %>"><%= pull.createdAt.split("T")[0] %></time></td> </tr> |
