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

A repository's upstream-follow card shows its last five checks: when, how far behind, and what happened #5996

Merged opened this

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

Merged

scout/forge:scout/5979-a-repository-s-upstream-follow-card-show → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5979-a-repository-s-upstream-follow-card-show was merged into main as 51c0d04.

Review

Sign in to review this change.
7 files changed +222 −6
src/actions/upstream_follow.ts +15 −1
@@ -34,6 +34,7 @@ import {
3434 noteCheck,
3535 putFollow,
3636 upstreamUrl,
37 type FollowCheck,
3738 type UpstreamFollow,
3839 } from "../core/upstream_follow.ts";
3940 import { getUserById, type User } from "../core/users.ts";
@@ -302,7 +303,7 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr
302303 const merged = await mergeable(dir, main, staged);
303304 if (!merged.ok) {
304305 if (merged.error.kind !== "conflict") return failed("git");
305 noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "conflict" });
306 noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "conflict", files: merged.error.files.length });
306307 convergeConflict(app, repo, by, follow, staged, merged.error.files);
307308 return ok({ tip: staged, behind, outcome: "conflict" });
308309 }
@@ -449,6 +450,19 @@ export function standing(f: UpstreamFollow): string {
449450 return f.behind === 0 ? "up to date" : `${f.behind} behind`;
450451 }
451452
453 /** One line of the record: `2 h ago · 3 behind · merged`. */

Add a test line for outcome uptodate ("0 behind · up to date") and for detail "1" ("conflict in 1 file"); neither branch is covered.

454 export function checkLine(c: FollowCheck, ago: string): string {
455 const what =
456 c.outcome === "conflict"
457 ? `conflict in ${c.detail} file${c.detail === "1" ? "" : "s"}`
458 : c.outcome === "error"
459 ? `error: ${c.detail}`
460 : c.outcome === "uptodate"
461 ? "up to date"
462 : "merged";
463 return `${ago} · ${c.behind} behind · ${what}`;
464 }
465
452466 /** What "Check upstream now" found, in a sentence. */
453467 export function checkedWords(c: Checked): string {
454468 if (c.outcome === "none") return "Up to date: nothing new upstream.";
src/core/migrations/20261025090000-upstream-check-history.ts added +15
@@ -0,0 +1,15 @@
1 // The record of a followed upstream's checks (#5979): one row per check, the
2 // last 20 kept per repository by `noteCheck`. `outcome` is merged, uptodate,
3 // conflict or error; `detail` is the conflict's file count or the error's
4 // first line. A new table, so a repository with no rows shows only its row.
5 export default `
6 CREATE TABLE IF NOT EXISTS repo_upstream_check (
7 id INTEGER PRIMARY KEY AUTOINCREMENT,
8 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
9 at TEXT NOT NULL,
10 behind INTEGER NOT NULL DEFAULT 0,
11 outcome TEXT NOT NULL,
12 detail TEXT NOT NULL DEFAULT ''
13 );
14 CREATE INDEX IF NOT EXISTS repo_upstream_check_repo ON repo_upstream_check (repo_id, id);
15 `;
src/core/upstream_follow.ts +50 −1
@@ -142,10 +142,12 @@ export function putFollow(
142142 input.parentUrl ?? "",
143143 now,
144144 );
145 if (moved)
145 if (moved) {
146 db.query("DELETE FROM repo_upstream_check WHERE repo_id = ?1").run(input.repoId);

This clears the history when the followed URL or branch changes, which is the right call, but no test would fail without it. Add one: note a check, call putFollow with a new url, and expect listChecks(...) to equal [].

146147 db.query(
147148 "UPDATE repo_upstream SET checked_at = NULL, tip = '', behind = 0, error = '' WHERE repo_id = ?1",
148149 ).run(input.repoId);
150 }
149151 return getFollow(db, input.repoId)!;
150152 }
151153
@@ -161,6 +163,8 @@ export function noteCheck(
161163 readonly pendingId?: string | null;
162164 /** A proposal was opened by this check. */
163165 readonly opened?: boolean;
166 /** How many files conflict, when the error is "conflict". */
167 readonly files?: number;
164168 },
165169 ): void {
166170 db.query(
@@ -179,4 +183,49 @@ export function noteCheck(
179183 reading.pendingId ?? null,
180184 reading.opened === true ? 1 : 0,
181185 );
186 recordCheck(db, repoId, reading);
187 }
188
189 /** How many checks are kept per repository. */
190 export const HISTORY_KEPT = 20;
191
192 export interface FollowCheck {
193 readonly at: string;
194 readonly behind: number;
195 readonly outcome: "merged" | "uptodate" | "conflict" | "error";
196 /** The conflict's file count, or the error's first line; "" otherwise. */
197 readonly detail: string;
198 }
199
200 function recordCheck(
201 db: Database,
202 repoId: string,
203 r: { readonly at: string; readonly behind?: number; readonly error: string; readonly files?: number },
204 ): void {
205 const outcome =
206 r.error === "conflict" ? "conflict" : r.error !== "" ? "error" : (r.behind ?? 0) === 0 ? "uptodate" : "merged";
207 const detail =
208 outcome === "conflict"
209 ? String(r.files ?? 0)
210 : outcome === "error"
211 ? (r.error.split("\n").find((l) => l.trim() !== "") ?? "").slice(0, 200)
212 : "";
213 // A failed check read no count: it carries the last one the row holds.
214 db.query(
215 `INSERT INTO repo_upstream_check (repo_id, at, behind, outcome, detail)
216 VALUES (?1, ?2, COALESCE(?3, (SELECT behind FROM repo_upstream WHERE repo_id = ?1), 0), ?4, ?5)`,
217 ).run(repoId, r.at, r.behind ?? null, outcome, detail);
218 db.query(
219 `DELETE FROM repo_upstream_check WHERE repo_id = ?1 AND id NOT IN
220 (SELECT id FROM repo_upstream_check WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?2)`,
221 ).run(repoId, HISTORY_KEPT);
222 }
223
224 /** The latest checks of a repository, newest first. */
225 export function listChecks(db: Database, repoId: string, limit = 5): FollowCheck[] {
226 return db
227 .query<FollowCheck, [string, number]>(
228 "SELECT at, behind, outcome, detail FROM repo_upstream_check WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?2",
229 )
230 .all(repoId, limit);
182231 }
src/tui/screens/about.ts +3 −1
@@ -25,6 +25,7 @@ import { listRefs } from "../../git/read.ts";
2525 import { updateRepoSettings } from "../../actions/repos.ts";
2626 import { showProvenance } from "../../actions/provenance.ts";
2727 import {
28 checkLine,
2829 checkedWords,
2930 checkUpstreamNow,
3031 FOLLOW_REFUSALS,
@@ -33,7 +34,7 @@ import {
3334 standing,
3435 upstreamLabel,
3536 } from "../../actions/upstream_follow.ts";
36 import { getFollow } from "../../core/upstream_follow.ts";
37 import { getFollow, listChecks } from "../../core/upstream_follow.ts";
3738 import {
3839 getProvenance,
3940 shownTrail,
@@ -356,6 +357,7 @@ export function upstreamRows(
356357 "upstream",
357358 `${upstreamLabel(follow.url)} ${follow.branch}${checked} · ${standing(follow)}`,
358359 ],
360 ...listChecks(f.db, repo.id).map((c): [string, string] => ["", checkLine(c, ago(c.at))]),
359361 ];
360362 }
361363
src/web/pages/upstream-follow.ts +13 −2
@@ -7,6 +7,7 @@
77
88 import {
99 checkedWords,
10 checkLine,
1011 checkUpstreamNow,
1112 clockWords,
1213 FOLLOW_REFUSALS,
@@ -16,14 +17,14 @@ import {
1617 upstreamLabel,
1718 } from "../../actions/upstream_follow.ts";
1819 import { canAdmin, type Repo } from "../../core/repos.ts";
19 import { getFollow } from "../../core/upstream_follow.ts";
20 import { getFollow, listChecks } from "../../core/upstream_follow.ts";
2021 import type { Ctx } from "../../http/context.ts";
2122 import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
2223 import { field, readForm } from "../form.ts";
2324 import { html, when, type Html } from "../html.ts";
2425 import { icon } from "../icons.ts";
2526 import { flashTo } from "../layout.ts";
26 import { btn, card, field as formField } from "../ui.ts";
27 import { btn, card, field as formField, row, timeAgo } from "../ui.ts";
2728
2829 /** HH:MM in UTC, with the instant for machines and the title for people. */
2930 function clock(iso: string): Html {
@@ -46,6 +47,15 @@ export function followingLine(ctx: Ctx, repo: Repo): Html | null {
4647 </p>`;
4748 }
4849
50 /** The last five checks, newest first; nothing when there are none. */
51 function historyList(ctx: Ctx, repo: Repo): Html | null {
52 const checks = listChecks(ctx.app.db, repo.id, 5);
53 if (checks.length === 0) return null;
54 return html`<ul class="list" data-following-history>
outdated

.hint is the form field's hint line in docs/DESIGN.md, not a list style. Use the named list/row component here (e.g. ul.list > li > .row with the line in .row-meta), or name a new class in docs/design/areas/<area>.md. The terminal's upstreamRows in src/tui/screens/about.ts needs the same five lines too.

55 ${checks.map((c) => row({ title: checkLine(c, timeAgo(c.at)) }))}
56 </ul>`;
57 }
58
4959 /** The settings card: on or off, the URL and branch, and "Check upstream now". */
5060 export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
5161 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null;
@@ -85,6 +95,7 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
8595 f.checkedAt === null ? "" : html` · last checked ${clock(f.checkedAt)}`
8696 }${f.error === "" ? "" : html` · ${f.error}`} · ${f.opened} proposed so far</p>`,
8797 )}
98 ${historyList(ctx, repo)}
8899 <div class="form-actions">${btn("Save")}</div>
89100 </form>
90101 ${when(
test/tui-upstream.test.ts +22 −1
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createRepo } from "../src/actions/repos.ts";
88 import { createApp, type App as Forge } from "../src/app.ts";
9 import { putFollow, getFollow } from "../src/core/upstream_follow.ts";
9 import { putFollow, getFollow, noteCheck } from "../src/core/upstream_follow.ts";
1010 import type { Repo } from "../src/core/repos.ts";
1111 import { createUser, type User } from "../src/core/users.ts";
1212 import { createForgeApp } from "../src/tui/forge.ts";
@@ -105,6 +105,27 @@ describe("the upstream in the terminal", () => {
105105 }
106106 });
107107
108 test("an admin sees the last five checks under the row; a reader sees none", async () => {
109 for (let i = 1; i <= 6; i++)
110 noteCheck(forge.db, repo.id, { at: new Date().toISOString(), behind: i, error: "" });
111 const h = boot(as(alice));
112 try {
113 await openAbout(h);
114 expect(h.text()).toContain("6 behind · merged");
115 expect(h.text()).toContain("2 behind · merged");
116 expect(h.text()).not.toContain("1 behind · merged");
117 } finally {
118 h.app.stop();
119 }
120 const r = boot(as(bob));
121 try {
122 await openAbout(r);
123 expect(r.text()).not.toContain("behind · merged");
124 } finally {
125 r.app.stop();
126 }
127 });
128
108129 test("an admin sees the row, checks now, and turns following off and on", async () => {
109130 const h = boot(as(alice));
110131 try {
test/upstream-follow-history.test.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * A followed upstream's record (#5979): every check is kept, the last 20 per
3 * repository; the settings card lists the last 5, newest first, each worded.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { checkLine } from "../src/actions/upstream_follow.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { listChecks, noteCheck, putFollow } from "../src/core/upstream_follow.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 let dataDir = "";
17 let app: App;
18 let servers: Servers;
19 let repoId = "";
20 let userId = "";
21 let cookie = "";
22 const hoursAgo = (h: number): string => new Date(Date.now() - h * 3_600_000).toISOString();
23
24 beforeAll(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-follow-history-"));
26 app = createApp({ dataDir, port: 0 });
27 const made = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!made.ok) throw new Error(JSON.stringify(made.error));
36 userId = made.value.id;
37 const repo = await createRepo(app, made.value, { name: "square", visibility: "public" });
38 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
39 repoId = repo.value.id;
40 putFollow(app.db, {
41 repoId,
42 url: "https://example.com/up/stream.git",
43 branch: "main",
44 enabled: true,
45 byId: userId,
46 originId: "",
47 });
48 servers = await serve(app);
49 const res = await fetch(`${servers.url}/signin`, {
50 method: "POST",
51 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
52 redirect: "manual",
53 });
54 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
55 }, 30_000);
56
57 afterAll(async () => {
58 servers?.stop();
59 app?.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 const card = async (): Promise<string> =>
64 (await fetch(`${servers.url}/alice/square/settings`, { headers: { cookie } })).text();
65
66 test("a repository with no history shows only today's line", async () => {
67 expect(listChecks(app.db, repoId)).toEqual([]);
68 expect(await card()).not.toContain("data-following-history");
69 });
70
71 test("25 checks keep 20 and show 5, newest first, each outcome worded", async () => {
72 for (let i = 1; i <= 25; i++) noteCheck(app.db, repoId, { at: hoursAgo(26 - i), behind: i, error: "" });
73 expect(listChecks(app.db, repoId, 100)).toHaveLength(20);
74 const shown = listChecks(app.db, repoId);
75 expect(shown.map((c) => c.behind)).toEqual([25, 24, 23, 22, 21]);
76
77 noteCheck(app.db, repoId, { at: hoursAgo(2), behind: 3, error: "conflict", files: 2 });
78 noteCheck(app.db, repoId, { at: hoursAgo(1), error: "The upstream could not be read: nope\nsecond line" });
79 noteCheck(app.db, repoId, { at: hoursAgo(1), behind: 4, error: "" });
80 const [merged, failed, conflict] = listChecks(app.db, repoId);
81 expect(checkLine(merged!, "2h ago")).toBe("2h ago · 4 behind · merged");
82 expect(checkLine(failed!, "1h ago")).toBe("1h ago · 3 behind · error: The upstream could not be read: nope");
83 expect(checkLine(conflict!, "2h ago")).toBe("2h ago · 3 behind · conflict in 2 files");
84
85 const page = await card();
86 expect(page.match(/row-title"[^>]*>[^<]*· \d+ behind ·/g)).toHaveLength(5);
87 expect(page).toContain("data-following-history");
88 expect(page).toContain("· conflict in 2 files");
89 expect(page.indexOf("4 behind · merged")).toBeLessThan(page.indexOf("conflict in 2 files"));
90 });
91
92 test("pointing the follow at another upstream forgets the record", () => {
93 noteCheck(app.db, repoId, { at: hoursAgo(1), behind: 1, error: "" });
94 expect(listChecks(app.db, repoId).length).toBeGreaterThan(0);
95 putFollow(app.db, {
96 repoId,
97 url: "https://example.com/other/stream.git",
98 branch: "main",
99 enabled: true,
100 byId: userId,
101 originId: "",
102 });
103 expect(listChecks(app.db, repoId)).toEqual([]);
104 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs