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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5979-a-repository-s-upstream-follow-card-show → main 2 commits fork 3 threads · 3 unresolved
src/actions/upstream_follow.ts +15 −1
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | noteCheck, |
| 35 | 35 | putFollow, |
| 36 | 36 | upstreamUrl, |
| 37 | type FollowCheck, | |
| 37 | 38 | type UpstreamFollow, |
| 38 | 39 | } from "../core/upstream_follow.ts"; |
| 39 | 40 | import { getUserById, type User } from "../core/users.ts"; |
| @@ -302,7 +303,7 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr | ||
| 302 | 303 | const merged = await mergeable(dir, main, staged); |
| 303 | 304 | if (!merged.ok) { |
| 304 | 305 | 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 }); | |
| 306 | 307 | convergeConflict(app, repo, by, follow, staged, merged.error.files); |
| 307 | 308 | return ok({ tip: staged, behind, outcome: "conflict" }); |
| 308 | 309 | } |
| @@ -449,6 +450,19 @@ export function standing(f: UpstreamFollow): string { | ||
| 449 | 450 | return f.behind === 0 ? "up to date" : `${f.behind} behind`; |
| 450 | 451 | } |
| 451 | 452 | |
| 453 | /** One line of the record: `2 h ago · 3 behind · merged`. */ | |
| 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 | ||
| 452 | 466 | /** What "Check upstream now" found, in a sentence. */ |
| 453 | 467 | export function checkedWords(c: Checked): string { |
| 454 | 468 | 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( | ||
| 142 | 142 | input.parentUrl ?? "", |
| 143 | 143 | now, |
| 144 | 144 | ); |
| 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 []. |
||
| 146 | 147 | db.query( |
| 147 | 148 | "UPDATE repo_upstream SET checked_at = NULL, tip = '', behind = 0, error = '' WHERE repo_id = ?1", |
| 148 | 149 | ).run(input.repoId); |
| 150 | } | |
| 149 | 151 | return getFollow(db, input.repoId)!; |
| 150 | 152 | } |
| 151 | 153 | |
| @@ -161,6 +163,8 @@ export function noteCheck( | ||
| 161 | 163 | readonly pendingId?: string | null; |
| 162 | 164 | /** A proposal was opened by this check. */ |
| 163 | 165 | readonly opened?: boolean; |
| 166 | /** How many files conflict, when the error is "conflict". */ | |
| 167 | readonly files?: number; | |
| 164 | 168 | }, |
| 165 | 169 | ): void { |
| 166 | 170 | db.query( |
| @@ -179,4 +183,49 @@ export function noteCheck( | ||
| 179 | 183 | reading.pendingId ?? null, |
| 180 | 184 | reading.opened === true ? 1 : 0, |
| 181 | 185 | ); |
| 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); | |
| 182 | 231 | } |
src/tui/screens/about.ts +3 −1
| @@ -25,6 +25,7 @@ import { listRefs } from "../../git/read.ts"; | ||
| 25 | 25 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 26 | 26 | import { showProvenance } from "../../actions/provenance.ts"; |
| 27 | 27 | import { |
| 28 | checkLine, | |
| 28 | 29 | checkedWords, |
| 29 | 30 | checkUpstreamNow, |
| 30 | 31 | FOLLOW_REFUSALS, |
| @@ -33,7 +34,7 @@ import { | ||
| 33 | 34 | standing, |
| 34 | 35 | upstreamLabel, |
| 35 | 36 | } from "../../actions/upstream_follow.ts"; |
| 36 | import { getFollow } from "../../core/upstream_follow.ts"; | |
| 37 | import { getFollow, listChecks } from "../../core/upstream_follow.ts"; | |
| 37 | 38 | import { |
| 38 | 39 | getProvenance, |
| 39 | 40 | shownTrail, |
| @@ -356,6 +357,7 @@ export function upstreamRows( | ||
| 356 | 357 | "upstream", |
| 357 | 358 | `${upstreamLabel(follow.url)} ${follow.branch}${checked} · ${standing(follow)}`, |
| 358 | 359 | ], |
| 360 | ...listChecks(f.db, repo.id).map((c): [string, string] => ["", checkLine(c, ago(c.at))]), | |
| 359 | 361 | ]; |
| 360 | 362 | } |
| 361 | 363 |
src/web/pages/upstream-follow.ts +13 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | checkedWords, |
| 10 | checkLine, | |
| 10 | 11 | checkUpstreamNow, |
| 11 | 12 | clockWords, |
| 12 | 13 | FOLLOW_REFUSALS, |
| @@ -16,14 +17,14 @@ import { | ||
| 16 | 17 | upstreamLabel, |
| 17 | 18 | } from "../../actions/upstream_follow.ts"; |
| 18 | 19 | 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"; | |
| 20 | 21 | import type { Ctx } from "../../http/context.ts"; |
| 21 | 22 | import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; |
| 22 | 23 | import { field, readForm } from "../form.ts"; |
| 23 | 24 | import { html, when, type Html } from "../html.ts"; |
| 24 | 25 | import { icon } from "../icons.ts"; |
| 25 | 26 | 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"; | |
| 27 | 28 | |
| 28 | 29 | /** HH:MM in UTC, with the instant for machines and the title for people. */ |
| 29 | 30 | function clock(iso: string): Html { |
| @@ -46,6 +47,15 @@ export function followingLine(ctx: Ctx, repo: Repo): Html | null { | ||
| 46 | 47 | </p>`; |
| 47 | 48 | } |
| 48 | 49 | |
| 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
|
||
| 55 | ${checks.map((c) => row({ title: checkLine(c, timeAgo(c.at)) }))} | |
| 56 | </ul>`; | |
| 57 | } | |
| 58 | ||
| 49 | 59 | /** The settings card: on or off, the URL and branch, and "Check upstream now". */ |
| 50 | 60 | export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null { |
| 51 | 61 | if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null; |
| @@ -85,6 +95,7 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 85 | 95 | f.checkedAt === null ? "" : html` · last checked ${clock(f.checkedAt)}` |
| 86 | 96 | }${f.error === "" ? "" : html` · ${f.error}`} · ${f.opened} proposed so far</p>`, |
| 87 | 97 | )} |
| 98 | ${historyList(ctx, repo)} | |
| 88 | 99 | <div class="form-actions">${btn("Save")}</div> |
| 89 | 100 | </form> |
| 90 | 101 | ${when( |
test/tui-upstream.test.ts +22 −1
| @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createRepo } from "../src/actions/repos.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import type { Repo } from "../src/core/repos.ts"; |
| 11 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 12 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -105,6 +105,27 @@ describe("the upstream in the terminal", () => { | ||
| 105 | 105 | } |
| 106 | 106 | }); |
| 107 | 107 | |
| 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 | ||
| 108 | 129 | test("an admin sees the row, checks now, and turns following off and on", async () => { |
| 109 | 130 | const h = boot(as(alice)); |
| 110 | 131 | 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 | }); |
Add a test line for outcome uptodate ("0 behind · up to date") and for detail "1" ("conflict in 1 file"); neither branch is covered.