Public: anyone can read this; the crew can change it.

OG cards for a repo and an issue say their state and what blocks them #2052

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2045-og-cards-for-a-repo-and-an-issue-say-the → main 1 commit fork

  • Merged. scout/2045-og-cards-for-a-repo-and-an-issue-say-the was merged into main as d6ff6f7.

Review

Sign in to review this change.
2 files changed +93 −3
src/web/pages/og.ts +50 −2
@@ -24,6 +24,7 @@ import {
2424 getRepoById,
2525 type Repo,
2626 } from "../../core/repos.ts";
27 import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts";
2728 import { getIssue, type Issue } from "../../core/issues.ts";
2829 import { getPull, type Pull } from "../../core/pulls.ts";
2930 import { mayView } from "../../core/sealed.ts";
@@ -45,11 +46,53 @@ function repoInput(ctx: Ctx, repo: Repo, kind: "repo" | "app"): CardInput {
4546 return {
4647 kind,
4748 title: `${repo.ownerName}/${repo.name}`,
48 lines: repo.description ? [repo.description] : [],
4949 stat: `${n?.openIssues ?? 0} issues · ${n?.openPulls ?? 0} pulls`,
50 lines: [
51 ...(repo.description ? [repo.description] : []),
52 ...(kind === "repo" ? [standing(ctx, repo)] : []),
53 ],
5054 };
5155 }
5256
57 /**
58 * How the open issues an anonymous reader sees stand: how many are ready,
59 * how many wait on an open issue. Readiness is judged on the whole graph,
60 * sealed issues included, so a sealed blocker still holds its dependent;
61 * only the visible issues are counted.
62 */
63 function standing(ctx: Ctx, repo: Repo): string {
64 const graph = repoGraph(ctx.app.db, repo.id, { sealed: true });
65 const visible = new Set(
66 graph.issues.filter((i) => i.state === "open" && !i.sealed).map((i) => i.id),
67 );
68 const open = new Set(
69 graph.issues.filter((i) => i.state === "open").map((i) => i.id),
70 );
71 const ready = readyNow(graph).filter((r) => visible.has(r.issue.id)).length;
72 const blocked = new Set<string>();
73 for (const e of graph.edges)
74 if (visible.has(e.issueId) && open.has(e.blockedById)) blocked.add(e.issueId);
75 for (const h of graph.held) if (visible.has(h.issueId)) blocked.add(h.issueId);
76 return `${ready} ready now · ${blocked.size} blocked`;
77 }
78
79 /** At most this many blockers are named on an issue card. */
80 const NAMED_BLOCKERS = 3;
81
82 /** "ready", or "waits on #A, #B, #C +N": sealed blockers are only counted. */
83 function waiting(ctx: Ctx, repo: Repo, issue: Issue): string {
84 const db = ctx.app.db;
85 const open = dependenciesOf(db, issue.id).blockedBy.filter(
86 (b) => b.state === "open",
87 );
88 if (open.length === 0) return "ready";
89 const named = open.filter((b) => mayView(db, repo, b, undefined));
90 const shown = named.slice(0, NAMED_BLOCKERS).map((b) => `#${b.number}`);
91 const more = open.length - shown.length;
92 if (shown.length === 0) return `waits on ${more} hidden`;
93 return `waits on ${shown.join(", ")}${more > 0 ? ` +${more}` : ""}`;
94 }
95
5396 export type OgKind = "repo" | "app" | "issue" | "pull" | "run";
5497
5598 const RUN_ID = /^[0-9a-z]{1,40}$/;
@@ -79,7 +122,12 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined
79122 return {
80123 kind,
81124 title: item.title,
82 lines: [`${at.repo.ownerName}/${at.repo.name}`],
125 lines: [
126 `${at.repo.ownerName}/${at.repo.name}`,
127 ...(kind === "issue" && item.state === "open"
128 ? [waiting(ctx, at.repo, item)]
129 : []),
130 ],
83131 stat: `#${item.number} · ${item.state}${merged ? " · merged" : ""}`,
84132 };
85133 }
test/og-route.test.ts +43 −1
@@ -6,7 +6,8 @@ import { createApp as createForge, type App } from "../src/app.ts";
66 import { getRepo } from "../src/core/repos.ts";
77 import { createRepo } from "../src/actions/repos.ts";
88 import { createApp } from "../src/core/apps.ts";
9 import { createIssue } from "../src/core/issues.ts";
9 import { addDependency } from "../src/core/dependencies.ts";
10 import { getIssue, createIssue } from "../src/core/issues.ts";
1011 import { createRun } from "../src/core/runs.ts";
1112 import { createUser } from "../src/core/users.ts";
1213 import { serve, type Servers } from "../src/http/server.ts";
@@ -82,6 +83,14 @@ describe("og cards", () => {
8283 app.db
8384 .query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = 4")
8485 .run(idOf("shown").id);
86 mk("shown", "Waits on two", "issue"); // #5
87 mk("shown", "Sealed blocker", "issue", true); // #6
88 mk("shown", "Waits on sealed", "issue"); // #7
89 const sh = idOf("shown").id;
90 const at = (n: number) => getIssue(app.db, sh, n)!;
91 addDependency(app.db, at(5), at(1), undefined);
92 addDependency(app.db, at(5), at(3), undefined);
93 addDependency(app.db, at(7), at(6), undefined);
8594 mk("hidden", "A private issue", "issue"); // #1
8695 const run = (repo: string) =>
8796 createRun(app.db, { repoId: idOf(repo).id, kind: "agent", summary: "did a thing" }).id;
@@ -213,4 +222,37 @@ describe("og cards", () => {
213222 expect((await get(`/og/${path}.svg`)).status).toBe(404);
214223 });
215224 });
225
226 describe("standing", () => {
227 const text = async (path: string) => await (await get(path)).text();
228
229 test("a ready issue says ready", async () => {
230 expect(await text("/og/issue/alice~shown~1.svg")).toContain("ready");
231 });
232
233 test("a blocked issue names visible blockers only, never a sealed one", async () => {
234 const body = await text("/og/issue/alice~shown~5.svg");
235 expect(body).toContain("waits on #1 +1");
236 expect(body).not.toContain(">#3<");
237 expect(body).not.toContain("#3,");
238 expect(body).not.toContain("#3 ");
239 expect(body).not.toContain("Sealed");
240 expect(await text("/og/issue/alice~shown~7.svg")).toContain("waits on 1 hidden");
241 });
242
243 test("a repo says how many are ready and how many blocked", async () => {
244 // open issues: #1 ready, #5 and #7 blocked, #3 and #6 sealed (unseen).
245 expect(await text("/og/repo/alice~shown.svg")).toContain("1 ready now · 2 blocked");
246 });
247
248 test("the etag moves when a shown value does, and only then", async () => {
249 const tag = async () => (await get("/og/issue/alice~shown~5.svg")).headers.get("etag");
250 const before = await tag();
251 expect(await tag()).toBe(before);
252 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = 1").run(
253 getRepo(app.db, "alice", "shown")!.id,
254 );
255 expect(await tag()).not.toBe(before);
256 });
257 });
216258 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs