Public: anyone can read this; the crew can change it.
"Since you were here" also lists releases, failed checks, and private repositories the viewer can read #6743
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6712-since-you-were-here-also-lists-releases → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +175
−31
src/tui/screens/dashboard.ts +1 −1
| @@ -243,7 +243,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 243 | 243 | }; |
| 244 | 244 | |
| 245 | 245 | const loadSinceLines = (): void => { |
| 246 | const d = f.user ? sinceDigest(f.app.db, lastVisit(f.app.db, f.user.id), Date.now()) : undefined; | |
| 246 | const d = f.user ? sinceDigest(f.app.db, f.user, lastVisit(f.app.db, f.user.id), Date.now()) : undefined; | |
| 247 | 247 | sinceLines = d === undefined ? [] : [d.heading, ...d.lines.map((l) => ` ${l.says}`)]; |
| 248 | 248 | ctx.invalidate(); |
| 249 | 249 | }; |
src/web/studio/home.ts +1 −1
| @@ -279,7 +279,7 @@ export async function studioHome( | ||
| 279 | 279 | const before = timePhase(ctx.request, "visit", () => |
| 280 | 280 | isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id), |
| 281 | 281 | ); |
| 282 | const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, before, visitedAt)); | |
| 282 | const since = timePhase(ctx.request, "since", () => sinceYouWere(ctx.app.db, user, before, visitedAt)); | |
| 283 | 283 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 284 | 284 | const figures = user.isAdmin && steps === undefined; |
| 285 | 285 | const ceilingUsd = forgeCeiling(ctx.app.config); |
src/web/studio/since.ts +83 −24
| @@ -1,52 +1,111 @@ | ||
| 1 | 1 | /** |
| 2 | * "Since you were here · 2 days": the pulls that landed in public repositories | |
| 3 | * since the viewer last loaded home (`core/home_visit.ts`), as the digest's | |
| 4 | * lines. Nothing landed, no section. Private repositories are never read here. | |
| 2 | * "Since you were here · 2 days": the pulls that landed, the releases and the | |
| 3 | * red gates since the viewer last loaded home (`core/home_visit.ts`), as the | |
| 4 | * digest's lines. Only repositories the viewer can read (`canRead`, as the | |
| 5 | * feed does) count. Nothing happened, no section. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { canRead, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 10 | import type { User } from "../../core/users.ts"; | |
| 8 | 11 | import { digest, headingOf, windowOf, type DigestEvent, type DigestLine } from "../../feed/digest.ts"; |
| 9 | 12 | import { html, type Html } from "../html.ts"; |
| 10 | 13 | import { listCard, row } from "../ui.ts"; |
| 11 | 14 | |
| 12 | interface Row { | |
| 13 | owner: string; | |
| 14 | name: string; | |
| 15 | interface Landed { | |
| 16 | repo_id: string; | |
| 15 | 17 | number: number; |
| 16 | 18 | merged_at: string; |
| 17 | 19 | } |
| 20 | interface Released { | |
| 21 | repo_id: string; | |
| 22 | pull_number: number | null; | |
| 23 | created_at: string; | |
| 24 | } | |
| 25 | interface Red { | |
| 26 | repo_id: string; | |
| 27 | pulls: string; | |
| 28 | finished_at: string; | |
| 29 | } | |
| 18 | 30 | |
| 19 | export function landedSince(db: Database, since: number, now: number): DigestEvent[] { | |
| 20 | const rows = db | |
| 21 | .query<Row, [string, string]>( | |
| 22 | `SELECT u.username AS owner, r.name AS name, i.number AS number, p.merged_at AS merged_at | |
| 23 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id | |
| 24 | WHERE p.merged_at >= ?1 AND p.merged_at <= ?2 AND r.visibility = 'public' | |
| 25 | ORDER BY p.merged_at DESC LIMIT 200`, | |
| 31 | /** Repositories are read through `canRead`, the feed's own check, once each. */ | |
| 32 | function readable(db: Database, viewer: User | undefined): (repoId: string) => Repo | undefined { | |
| 33 | const seen = new Map<string, Repo | undefined>(); | |
| 34 | return (id) => { | |
| 35 | if (!seen.has(id)) { | |
| 36 | const repo = getRepoById(db, id); | |
| 37 | seen.set(id, repo && canRead(db, repo, viewer) ? repo : undefined); | |
| 38 | } | |
| 39 | return seen.get(id); | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | /** A page a person reads: the pull when one is known, else the repository's pulls; never an API URL. */ | |
| 44 | function pageOf(repo: Repo, pull: number | null | undefined): string { | |
| 45 | const base = `/${repo.ownerName}/${repo.name}/pulls`; | |
| 46 | return Number.isInteger(pull) && (pull as number) > 0 ? `${base}/${pull}` : base; | |
| 47 | } | |
| 48 | ||
| 49 | /** The first pull number in a gate's JSON list of them, if it has one. */ | |
| 50 | function firstPull(json: string): number | null { | |
| 51 | try { | |
| 52 | const list: unknown = JSON.parse(json); | |
| 53 | return Array.isArray(list) && typeof list[0] === "number" ? list[0] : null; | |
| 54 | } catch { | |
| 55 | return null; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | /** What happened in the window, in the repositories `viewer` may read: landed pulls, releases, red gates. */ | |
| 60 | export function eventsSince(db: Database, viewer: User | undefined, since: number, now: number): DigestEvent[] { | |
| 61 | const from = new Date(since).toISOString(); | |
| 62 | const to = new Date(now).toISOString(); | |
| 63 | const may = readable(db, viewer); | |
| 64 | const out: DigestEvent[] = []; | |
| 65 | for (const r of db | |
| 66 | .query<Landed, [string, string]>( | |
| 67 | `SELECT r.id AS repo_id, i.number AS number, p.merged_at AS merged_at | |
| 68 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 69 | WHERE p.merged_at >= ?1 AND p.merged_at <= ?2 ORDER BY p.merged_at DESC LIMIT 200`, | |
| 26 | 70 | ) |
| 27 | .all(new Date(since).toISOString(), new Date(now).toISOString()); | |
| 28 | return rows.map((r) => ({ | |
| 29 | repo: `${r.owner}/${r.name}`, | |
| 30 | kind: "landed", | |
| 31 | at: Date.parse(r.merged_at), | |
| 32 | href: `/${r.owner}/${r.name}/pulls/${r.number}`, | |
| 33 | weight: 1, | |
| 34 | })); | |
| 71 | .all(from, to)) { | |
| 72 | const repo = may(r.repo_id); | |
outdated
href is a JSON API URL; a person tapping this row gets raw JSON on a phone. Point to a readable HTML page (the pull or repo pulls page). |
||
| 73 | if (repo) out.push({ repo: `${repo.ownerName}/${repo.name}`, kind: "landed", at: Date.parse(r.merged_at), href: `/${repo.ownerName}/${repo.name}/pulls/${r.number}`, weight: 1 }); | |
| 74 | } | |
| 75 | for (const r of db | |
| 76 | .query<Released, [string, string]>( | |
| 77 | `SELECT repo_id, pull_number, created_at FROM release WHERE ok = 1 AND created_at >= ?1 AND created_at <= ?2 ORDER BY created_at DESC LIMIT 50`, | |
| 78 | ) | |
| 79 | .all(from, to)) { | |
| 80 | const repo = may(r.repo_id); | |
| 81 | if (repo) out.push({ repo: `${repo.ownerName}/${repo.name}`, kind: "released", at: Date.parse(r.created_at), href: pageOf(repo, r.pull_number), weight: 2 }); | |
| 82 | } | |
| 83 | for (const r of db | |
| 84 | .query<Red, [string, string]>( | |
| 85 | `SELECT repo_id, pulls, finished_at FROM gate_result WHERE state = 'red' AND finished_at >= ?1 AND finished_at <= ?2 ORDER BY finished_at DESC LIMIT 50`, | |
| 86 | ) | |
| 87 | .all(from, to)) { | |
| 88 | const repo = may(r.repo_id); | |
| 89 | if (repo) | |
| 90 | out.push({ repo: `${repo.ownerName}/${repo.name}`, kind: "failed", at: Date.parse(r.finished_at), href: pageOf(repo, firstPull(r.pulls)), weight: 3 }); | |
| 91 | } | |
| 92 | return out; | |
| 35 | 93 | } |
| 36 | 94 | |
| 37 | 95 | /** The heading and lines of the digest since `lastVisit`; no lines, no digest. */ |
| 38 | 96 | export function sinceDigest( |
| 39 | 97 | db: Database, |
| 98 | viewer: User | undefined, | |
| 40 | 99 | lastVisit: number | undefined, |
| 41 | 100 | now: number, |
| 42 | 101 | ): { heading: string; lines: DigestLine[] } | undefined { |
| 43 | 102 | const window = windowOf(lastVisit, now); |
| 44 | const lines = digest(landedSince(db, now - window, now), now, window); | |
| 103 | const lines = digest(eventsSince(db, viewer, now - window, now), now, window); | |
| 45 | 104 | return lines.length === 0 ? undefined : { heading: headingOf(window), lines }; |
| 46 | 105 | } |
| 47 | 106 | |
| 48 | export function sinceYouWere(db: Database, lastVisit: number | undefined, now: number): Html { | |
| 49 | const d = sinceDigest(db, lastVisit, now); | |
| 107 | export function sinceYouWere(db: Database, viewer: User | undefined, lastVisit: number | undefined, now: number): Html { | |
| 108 | const d = sinceDigest(db, viewer, lastVisit, now); | |
| 50 | 109 | if (d === undefined) return html``; |
| 51 | 110 | return listCard( |
| 52 | 111 | d.lines.map((l) => row({ title: html`${l.says}`, href: l.href, nav: true })), |
test/home-since-sources.test.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * #6712: the digest reads the viewer's readable repositories (`canRead`), and | |
| 3 | * releases and red gates make lines beside landed pulls, three at most. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { digest } from "../src/feed/digest.ts"; | |
| 7 | import { eventsSince, sinceDigest } from "../src/web/studio/since.ts"; | |
| 8 | import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts"; | |
| 9 | ||
| 10 | const H = 3600 * 1000; | |
| 11 | let f: SnappyFixture; | |
| 12 | beforeAll(async () => { | |
| 13 | f = await snappyFixture(); | |
| 14 | }, 120_000); | |
| 15 | afterAll(async () => { | |
| 16 | await f.close(); | |
| 17 | }); | |
| 18 | ||
| 19 | const iso = (ago: number): string => new Date(Date.now() - ago).toISOString(); | |
| 20 | let n = 0; | |
| 21 | function land(repoId: string, ago: number): void { | |
| 22 | const id = `src-${n}`; | |
| 23 | db().query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'pull', 't', ?4)").run(id, repoId, 7000 + n++, f.owner.id); | |
| 24 | db().query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'h', 'a', 'b', ?2)").run(id, iso(ago)); | |
| 25 | } | |
| 26 | const db = () => f.app.db; | |
| 27 | const notes = () => db().query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'notes'").get()!.id; | |
| 28 | ||
| 29 | test("a member sees a private repository's landed pull; a stranger does not", () => { | |
| 30 | land(notes(), H); | |
| 31 | const mine = eventsSince(db(), f.peer, Date.now() - 3 * H, Date.now()); | |
| 32 | expect(mine.map((e) => e.repo)).toEqual(["pablo/notes"]); | |
| 33 | expect(eventsSince(db(), undefined, Date.now() - 3 * H, Date.now())).toHaveLength(0); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a release and a red gate in the window each make a line; green and old ones do not", () => { | |
| 37 | const repo = f.repos.find((r) => r.visibility === "public")!; | |
| 38 | db().query("INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES ('abc1234', ?1, 1, 1, 'ok', ?2)").run(repo.id, iso(H)); | |
| 39 | const gate = (id: string, state: string, ago: number): void => { | |
| 40 | db() | |
| 41 | .query("INSERT INTO gate_result (id, repo_id, run_id, tip_oid, state, started_at, finished_at) VALUES (?1, ?2, 'r', 't', ?3, ?4, ?4)") | |
| 42 | .run(id, repo.id, state, iso(ago)); | |
| 43 | }; | |
| 44 | gate("g-red", "red", H); | |
| 45 | gate("g-green", "green", H); | |
| 46 | gate("g-old", "red", 9 * 24 * H); | |
| 47 | const events = eventsSince(db(), f.peer, Date.now() - 3 * H, Date.now()).filter((e) => e.repo === `${repo.ownerName}/${repo.name}`); | |
| 48 | expect(events.map((e) => e.kind).sort()).toEqual(["failed", "released"]); | |
| 49 | const d = sinceDigest(db(), f.peer, Date.now() - 2 * H, Date.now())!; | |
| 50 | const line = d.lines.find((l) => l.repo === `${repo.ownerName}/${repo.name}`)!; | |
| 51 | expect(line.says).toContain("1 released"); | |
| 52 | expect(line.says).toContain("1 failed"); | |
| 53 | expect(line.href).toBe(`/${repo.ownerName}/${repo.name}/pulls`); | |
| 54 | expect(line.href).not.toContain("/api/"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a fourth source is cut at three, biggest first", () => { | |
| 58 | const now = Date.now(); | |
| 59 | const ev = (repo: string, weight: number) => ({ repo, kind: "landed" as const, at: now - H, href: `/${repo}`, weight }); | |
| 60 | const lines = digest([ev("a/a", 1), ev("b/b", 4), ev("c/c", 2), ev("d/d", 3)], now); | |
| 61 | expect(lines.map((l) => l.repo)).toEqual(["b/b", "d/d", "c/c"]); | |
| 62 | }); | |
| 63 | ||
| 64 | test("release and failed lines link to readable pages, the pull when known", () => { | |
| 65 | const repo = f.repos.find((r) => r.visibility === "public")!; | |
| 66 | db().query("INSERT INTO gate_result (id, repo_id, run_id, tip_oid, pulls, state, started_at, finished_at) VALUES ('g-pull', ?1, 'r', 't', '[42]', 'red', ?2, ?2)").run(repo.id, iso(H / 2)); | |
| 67 | const hrefs = eventsSince(db(), f.peer, Date.now() - 3 * H, Date.now()).filter((e) => e.repo === `${repo.ownerName}/${repo.name}`).map((e) => e.href); | |
| 68 | expect(hrefs).toContain(`/${repo.ownerName}/${repo.name}/pulls/42`); | |
| 69 | expect(hrefs).toContain(`/${repo.ownerName}/${repo.name}/pulls/1`); | |
| 70 | expect(hrefs.some((h) => h.startsWith("/api/") || h === "/releases")).toBe(false); | |
| 71 | }); |
test/home-since.test.ts +7 −5
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 7 | 7 | import { lastVisit, noteVisit } from "../src/core/home_visit.ts"; |
| 8 | 8 | import { DIGEST_WINDOW_MS, headingOf, windowOf } from "../src/feed/digest.ts"; |
| 9 | import { landedSince } from "../src/web/studio/since.ts"; | |
| 9 | import { eventsSince } from "../src/web/studio/since.ts"; | |
| 10 | 10 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 11 | 11 | import { dashboardScreen } from "../src/tui/screens/dashboard.ts"; |
| 12 | 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -49,10 +49,10 @@ test("a visit is written at most once a minute and answers the one before", () = | ||
| 49 | 49 | expect(lastVisit(f.app.db, id)).toBe(now + 61_000); |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | test("home heads the digest with the window, lists only public landed pulls, and records the visit", async () => { | |
| 52 | test("home heads the digest with the window, lists only landed pulls the viewer can read, and records the visit", async () => { | |
| 53 | 53 | const db = f.app.db; |
| 54 | 54 | const pub = f.repos.find((r) => r.visibility === "public")!; |
| 55 | const priv = f.repos.find((r) => r.visibility === "private")!; | |
| 55 | const priv = { id: f.app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'notes'").get()!.id, name: "notes" }; | |
| 56 | 56 | const land = (repoId: string, number: number, ago: number): void => { |
| 57 | 57 | const id = `since-${repoId}-${number}`; |
| 58 | 58 | db.query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'pull', 't', ?4)").run(id, repoId, 9000 + number, f.owner.id); |
| @@ -61,8 +61,10 @@ test("home heads the digest with the window, lists only public landed pulls, and | ||
| 61 | 61 | land(pub.id, 1, 2 * H); |
| 62 | 62 | land(pub.id, 2, 5 * 24 * H); |
| 63 | 63 | land(priv.id, 3, H); |
| 64 | expect(landedSince(db, Date.now() - 3 * H, Date.now())).toHaveLength(1); | |
| 65 | expect(landedSince(db, Date.now() - 10 * 24 * H, Date.now())).toHaveLength(2); | |
| 64 | expect(eventsSince(db, f.peer, Date.now() - 3 * H, Date.now())).toHaveLength(2); | |
| 65 | expect(eventsSince(db, f.peer, Date.now() - 10 * 24 * H, Date.now())).toHaveLength(3); | |
| 66 | // The admin owner reads the peer's private repository too; keep it out of the owner's page below. | |
| 67 | db.query("DELETE FROM pull WHERE issue_id = ?1").run(`since-${priv.id}-3`); | |
| 66 | 68 | |
| 67 | 69 | db.query("INSERT INTO home_visit (user_id, at) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET at = excluded.at").run(f.owner.id, Date.now() - 3 * H); |
| 68 | 70 | const res = await fetch(`${f.base}/?all=1`, { headers: { cookie: f.ownerCookie } }); |
test/tui-dashboard-alarms.test.ts +12
| @@ -76,3 +76,15 @@ test("a stuck approved pull draws the stuck line for an admin; a non-admin reads | ||
| 76 | 76 | stuckPull("open", "public"); |
| 77 | 77 | expect(await firstScreen(bob)).toContain("alice/open#1 has sat approved"); |
| 78 | 78 | }); |
| 79 | ||
| 80 | test("the dashboard's since-you-were-here shows a release and a failed gate", async () => { | |
| 81 | const db = forge.db; | |
| 82 | const now = new Date().toISOString(); | |
| 83 | db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('shipped', ?1, 'shipped', 'public')", [alice.id]); | |
| 84 | db.run("INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES ('abc1234', 'shipped', 1, 1, 'ok', ?1)", [now]); | |
| 85 | db.run("INSERT INTO gate_result (id, repo_id, run_id, tip_oid, state, started_at, finished_at) VALUES ('g1', 'shipped', 'r', 't', 'red', ?1, ?1)", [now]); | |
| 86 | const text = await firstScreen(bob); | |
| 87 | expect(text).toContain("alice/shipped"); | |
| 88 | expect(text).toContain("1 released"); | |
| 89 | expect(text).toContain("1 failed"); | |
| 90 | }); |
new above
offline
Hard-coded
/releasesis not scoped to this repo; link to the repo's own release view.