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

Merged opened this

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

Merged

scout/forge:scout/6712-since-you-were-here-also-lists-releases → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/6712-since-you-were-here-also-lists-releases was merged into main as 51fe491.

Review

Sign in to review this change.
6 files changed +175 −31
src/tui/screens/dashboard.ts +1 −1
@@ -243,7 +243,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
243243 };
244244
245245 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;
247247 sinceLines = d === undefined ? [] : [d.heading, ...d.lines.map((l) => ` ${l.says}`)];
248248 ctx.invalidate();
249249 };
src/web/studio/home.ts +1 −1
@@ -279,7 +279,7 @@ export async function studioHome(
279279 const before = timePhase(ctx.request, "visit", () =>
280280 isNavigation(ctx.request) ? noteVisit(ctx.app.db, user.id, visitedAt) : lastVisit(ctx.app.db, user.id),
281281 );
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));
283283 const ask = firstSteps ?? everydayCard(ctx, user);
284284 const figures = user.isAdmin && steps === undefined;
285285 const ceilingUsd = forgeCeiling(ctx.app.config);
src/web/studio/since.ts +83 −24
@@ -1,52 +1,111 @@
11 /**
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.
56 */
67
78 import type { Database } from "bun:sqlite";
9 import { canRead, getRepoById, type Repo } from "../../core/repos.ts";
10 import type { User } from "../../core/users.ts";
811 import { digest, headingOf, windowOf, type DigestEvent, type DigestLine } from "../../feed/digest.ts";
912 import { html, type Html } from "../html.ts";
1013 import { listCard, row } from "../ui.ts";
1114
12 interface Row {
13 owner: string;
14 name: string;
15 interface Landed {
16 repo_id: string;
1517 number: number;
1618 merged_at: string;
1719 }
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 }
1830
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

Hard-coded /releases is not scoped to this repo; link to the repo's own release view.

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`,
2670 )
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;
3593 }
3694
3795 /** The heading and lines of the digest since `lastVisit`; no lines, no digest. */
3896 export function sinceDigest(
3997 db: Database,
98 viewer: User | undefined,
4099 lastVisit: number | undefined,
41100 now: number,
42101 ): { heading: string; lines: DigestLine[] } | undefined {
43102 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);
45104 return lines.length === 0 ? undefined : { heading: headingOf(window), lines };
46105 }
47106
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);
50109 if (d === undefined) return html``;
51110 return listCard(
52111 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 @@
66 import { afterAll, beforeAll, expect, test } from "bun:test";
77 import { lastVisit, noteVisit } from "../src/core/home_visit.ts";
88 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";
1010 import { createForgeApp } from "../src/tui/forge.ts";
1111 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
1212 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", () =
4949 expect(lastVisit(f.app.db, id)).toBe(now + 61_000);
5050 });
5151
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 () => {
5353 const db = f.app.db;
5454 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" };
5656 const land = (repoId: string, number: number, ago: number): void => {
5757 const id = `since-${repoId}-${number}`;
5858 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
6161 land(pub.id, 1, 2 * H);
6262 land(pub.id, 2, 5 * 24 * H);
6363 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`);
6668
6769 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);
6870 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
7676 stuckPull("open", "public");
7777 expect(await firstScreen(bob)).toContain("alice/open#1 has sat approved");
7878 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs