aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--public/static/style.css13
-rw-r--r--src/app.test.ts37
-rw-r--r--src/scraper.test.ts35
-rw-r--r--src/scraper.ts74
-rw-r--r--views/_issueState.eta1
-rw-r--r--views/issues.eta7
-rw-r--r--views/pulls.eta4
8 files changed, 149 insertions, 23 deletions
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, /<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>