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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2045-og-cards-for-a-repo-and-an-issue-say-the → main 1 commit fork
2 files
changed +93
−3
src/web/pages/og.ts +50 −2
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | getRepoById, |
| 25 | 25 | type Repo, |
| 26 | 26 | } from "../../core/repos.ts"; |
| 27 | import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts"; | |
| 27 | 28 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 28 | 29 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| 29 | 30 | import { mayView } from "../../core/sealed.ts"; |
| @@ -45,11 +46,53 @@ function repoInput(ctx: Ctx, repo: Repo, kind: "repo" | "app"): CardInput { | ||
| 45 | 46 | return { |
| 46 | 47 | kind, |
| 47 | 48 | title: `${repo.ownerName}/${repo.name}`, |
| 48 | lines: repo.description ? [repo.description] : [], | |
| 49 | 49 | stat: `${n?.openIssues ?? 0} issues · ${n?.openPulls ?? 0} pulls`, |
| 50 | lines: [ | |
| 51 | ...(repo.description ? [repo.description] : []), | |
| 52 | ...(kind === "repo" ? [standing(ctx, repo)] : []), | |
| 53 | ], | |
| 50 | 54 | }; |
| 51 | 55 | } |
| 52 | 56 | |
| 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 | ||
| 53 | 96 | export type OgKind = "repo" | "app" | "issue" | "pull" | "run"; |
| 54 | 97 | |
| 55 | 98 | const RUN_ID = /^[0-9a-z]{1,40}$/; |
| @@ -79,7 +122,12 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined | ||
| 79 | 122 | return { |
| 80 | 123 | kind, |
| 81 | 124 | 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 | ], | |
| 83 | 131 | stat: `#${item.number} · ${item.state}${merged ? " · merged" : ""}`, |
| 84 | 132 | }; |
| 85 | 133 | } |
test/og-route.test.ts +43 −1
| @@ -6,7 +6,8 @@ import { createApp as createForge, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { getRepo } from "../src/core/repos.ts"; |
| 7 | 7 | import { createRepo } from "../src/actions/repos.ts"; |
| 8 | 8 | 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"; | |
| 10 | 11 | import { createRun } from "../src/core/runs.ts"; |
| 11 | 12 | import { createUser } from "../src/core/users.ts"; |
| 12 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -82,6 +83,14 @@ describe("og cards", () => { | ||
| 82 | 83 | app.db |
| 83 | 84 | .query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = 4") |
| 84 | 85 | .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); | |
| 85 | 94 | mk("hidden", "A private issue", "issue"); // #1 |
| 86 | 95 | const run = (repo: string) => |
| 87 | 96 | createRun(app.db, { repoId: idOf(repo).id, kind: "agent", summary: "did a thing" }).id; |
| @@ -213,4 +222,37 @@ describe("og cards", () => { | ||
| 213 | 222 | expect((await get(`/og/${path}.svg`)).status).toBe(404); |
| 214 | 223 | }); |
| 215 | 224 | }); |
| 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 | }); | |
| 216 | 258 | }); |
new above
offline