aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-09-21 22:15:24 -0400
committerJoe Mou <dev@mou.fo>2026-09-25 01:43:53 -0400
commit1514865ec8911c26612e68048f84c6c8be92449d (patch)
tree07a24c2b0536e4fd3b69c1cbc2a424e9ee25aa8a /src
parentfd1c054c3c10dceb1a8a355c74a0d9e9077aae19 (diff)
Show issue and pull request status and labels in their lists
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/app.test.ts37
-rw-r--r--src/scraper.test.ts35
-rw-r--r--src/scraper.ts74
3 files changed, 128 insertions, 18 deletions
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 };