Public: anyone can read this; the crew can change it.
The terminal shows a repository's upstream, checks it now, and lets an admin turn following on or off #4361
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3559-the-terminal-shows-a-repository-s-upstre → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +214
−2
README.md +7 −2
| @@ -1799,8 +1799,13 @@ A repository can follow an outside git branch (`repo_upstream`, https or | ||
| 1799 | 1799 | http only, no credentials in the address). The clock checks each following |
| 1800 | 1800 | repository every 30 minutes (`checkDue`); the result is the follow row: |
| 1801 | 1801 | upstream tip, commits behind, the pull carrying the last proposal, and |
| 1802 | `error`. There is no button or page for it yet, so that row is where a | |
| 1803 | check is seen. A check fetches only when the tip moved, into | |
| 1802 | `error`. In the terminal, a repository's About card (`I`) shows an | |
| 1803 | `upstream` row (address, branch, last check, standing; `off` when following | |
| 1804 | is off) to an admin of the repository and to nobody else. `u` checks now, | |
| 1805 | while following is on, through the settings page's own action. `f` turns | |
| 1806 | following on or off through `setFollowUpstream`; it keeps the saved address | |
| 1807 | and branch, and when none is saved it refuses with "set the upstream address | |
| 1808 | in the web settings first" (the address is set on the settings page). A check fetches only when the tip moved, into | |
| 1804 | 1809 | `refs/upstream-follow/<branch>` (not a branch), then: |
| 1805 | 1810 | |
| 1806 | 1811 | - **Nothing new:** nothing happens. |
src/tui/screens/about.ts +79
| @@ -24,6 +24,16 @@ import { repoDir } from "../../git/repos.ts"; | ||
| 24 | 24 | import { listRefs } from "../../git/read.ts"; |
| 25 | 25 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 26 | 26 | import { showProvenance } from "../../actions/provenance.ts"; |
| 27 | import { | |
| 28 | checkedWords, | |
| 29 | checkUpstreamNow, | |
| 30 | FOLLOW_REFUSALS, | |
| 31 | followShown, | |
| 32 | setFollowUpstream, | |
| 33 | standing, | |
| 34 | upstreamLabel, | |
| 35 | } from "../../actions/upstream_follow.ts"; | |
| 36 | import { getFollow } from "../../core/upstream_follow.ts"; | |
| 27 | 37 | import { |
| 28 | 38 | getProvenance, |
| 29 | 39 | shownTrail, |
| @@ -72,10 +82,16 @@ export function aboutScreen( | ||
| 72 | 82 | f.user?.kind !== "agent" && |
| 73 | 83 | repo !== undefined && |
| 74 | 84 | getProvenance(f.db, repo.id) !== undefined; |
| 85 | // The following card's rule: an admin's, and only while it is on or to be turned on. | |
| 86 | const mayFollow = (): boolean => | |
| 87 | may() && repo !== undefined && repo.origin === null; | |
| 88 | const checking = (): boolean => | |
| 89 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; | |
| 75 | 90 | const factRows = (): [string, string][] => [ |
| 76 | 91 | ...archivedRow(repo), |
| 77 | 92 | ...aboutFacts(f, src, facts, ref), |
| 78 | 93 | ...trailRows(f, repo), |
| 94 | ...upstreamRows(f, repo), | |
| 79 | 95 | ...cell, |
| 80 | 96 | ]; |
| 81 | 97 | let rows = factRows(); |
| @@ -125,6 +141,12 @@ export function aboutScreen( | ||
| 125 | 141 | ), |
| 126 | 142 | ] |
| 127 | 143 | : []), |
| 144 | ...(mayFollow() | |
| 145 | ? [ | |
| 146 | ...(checking() ? [binding(["u"], "check upstream now")] : []), | |
| 147 | binding(["f"], checking() ? "stop following upstream" : "follow upstream"), | |
| 148 | ] | |
| 149 | : []), | |
| 128 | 150 | // A site admin also reaches the repository's private egress grants. |
| 129 | 151 | ...(f.user?.isAdmin === true && repo !== undefined |
| 130 | 152 | ? [binding(["e"], "egress"), binding(["c"], "connections")] |
| @@ -244,6 +266,39 @@ export function aboutScreen( | ||
| 244 | 266 | ctx.invalidate(); |
| 245 | 267 | return true; |
| 246 | 268 | } |
| 269 | if (e.key === "u" && repo !== undefined && mayFollow() && checking()) { | |
| 270 | const at = repo; | |
| 271 | ctx.toast("checking the upstream…", "default"); | |
| 272 | void checkUpstreamNow(f.app, f.user, at) | |
| 273 | .then((done) => { | |
| 274 | rows = factRows(); | |
| 275 | if (done.ok) ctx.toast(checkedWords(done.value), "success"); | |
| 276 | else ctx.toast(FOLLOW_REFUSALS[done.error], "danger"); | |
| 277 | }) | |
| 278 | .catch(() => ctx.toast("that could not be checked", "danger")) | |
| 279 | .finally(() => ctx.invalidate()); | |
| 280 | return true; | |
| 281 | } | |
| 282 | if (e.key === "f" && repo !== undefined && mayFollow()) { | |
| 283 | const on = !checking(); | |
| 284 | const done = setFollowUpstream(f.app, f.user, repo, { | |
| 285 | url: "", | |
| 286 | branch: "", | |
| 287 | enabled: on, | |
| 288 | }); | |
| 289 | if (done.ok) | |
| 290 | ctx.toast(on ? `following ${upstreamLabel(done.value.url)}` : "no longer following the upstream", "success"); | |
| 291 | else | |
| 292 | ctx.toast( | |
| 293 | done.error === "invalid_url" | |
| 294 | ? "set the upstream address in the web settings first" | |
| 295 | : FOLLOW_REFUSALS[done.error], | |
| 296 | "danger", | |
| 297 | ); | |
| 298 | rows = factRows(); | |
| 299 | ctx.invalidate(); | |
This relies on a blank url meaning keep the saved one, which is a convention inside setFollowUpstream. The test would catch a change to it, but a short comment would make the dependency visible. |
||
| 300 | return true; | |
| 301 | } | |
| 247 | 302 | if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) { |
| 248 | 303 | ctx.push(egressScreen(f, ctx, repo)); |
| 249 | 304 | return true; |
| @@ -280,6 +335,30 @@ export function trailRows( | ||
| 280 | 335 | ]; |
| 281 | 336 | } |
| 282 | 337 | |
| 338 | /** | |
| 339 | * The upstream row, as the web About line has it: for an admin only, with | |
| 340 | * the address, branch and last check while following is on, and "off" when | |
| 341 | * it is not. | |
| 342 | */ | |
| 343 | export function upstreamRows( | |
| 344 | f: Session, | |
| 345 | repo: Repo | undefined, | |
| 346 | ): [string, string][] { | |
| 347 | if (repo === undefined || repo.origin !== null) return []; | |
| 348 | if (f.user === undefined || !canAdmin(f.db, repo, f.user)) return []; | |
| 349 | const follow = getFollow(f.db, repo.id); | |
| 350 | if (follow === undefined || !follow.enabled) | |
| 351 | return [["upstream", "off"]]; | |
| 352 | const checked = | |
| 353 | follow.checkedAt === null ? "" : ` · checked ${ago(follow.checkedAt)}`; | |
| 354 | return [ | |
| 355 | [ | |
| 356 | "upstream", | |
| 357 | `${upstreamLabel(follow.url)} ${follow.branch}${checked} · ${standing(follow)}`, | |
| 358 | ], | |
| 359 | ]; | |
| 360 | } | |
| 361 | ||
| 283 | 362 | /** One line when an admin has put the repository away. */ |
| 284 | 363 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 285 | 364 | if (repo === undefined || repo.archivedAt === null) return []; |
test/tui-upstream.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** The upstream in the terminal (#3559): About shows it, an admin checks it and turns it on and off. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 9 | import { putFollow, getFollow } from "../src/core/upstream_follow.ts"; | |
| 10 | import type { Repo } from "../src/core/repos.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 17 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("the upstream in the terminal", () => { | |
| 22 | let dataDir: string; | |
| 23 | let forge: Forge; | |
| 24 | let alice: User; | |
| 25 | let bob: User; | |
| 26 | let repo: Repo; | |
| 27 | ||
| 28 | function boot(identity: TuiIdentity) { | |
| 29 | const terminal = headlessTerminal(120, 40); | |
| 30 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 31 | app.start(); | |
| 32 | const press = async (...keys: string[]): Promise<void> => { | |
| 33 | for (const key of keys) | |
| 34 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 35 | await settleApp(app); | |
| 36 | }; | |
| 37 | return { app, press, text: () => app.screen.toText() }; | |
| 38 | } | |
| 39 | ||
| 40 | async function openAbout(h: ReturnType<typeof boot>): Promise<void> { | |
| 41 | await settleApp(h.app); | |
| 42 | await h.press("/"); | |
| 43 | for (const ch of "armature") await h.press(ch); | |
| 44 | await h.press("enter"); | |
| 45 | await h.press("enter"); | |
| 46 | await settleUntil(h.app, "alice/armature"); | |
| 47 | await h.press("I"); | |
| 48 | await settleUntil(h.app, "clone (http)"); | |
| 49 | } | |
| 50 | ||
| 51 | const as = (user: User | undefined): TuiIdentity => ({ | |
| 52 | sshUsername: user?.username ?? "git", | |
| 53 | remoteAddress: "127.0.0.1", | |
| 54 | fingerprint: "SHA256:test", | |
| 55 | ...(user === undefined ? {} : { user }), | |
| 56 | }); | |
| 57 | ||
| 58 | beforeAll(async () => { | |
| 59 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-upstream-")); | |
| 60 | forge = createApp({ dataDir, port: 0 }); | |
| 61 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 62 | if (!a.ok) throw new Error("signup failed"); | |
| 63 | alice = a.value; | |
| 64 | const b = await createUser(forge.db, { | |
| 65 | origin: "seed", | |
| 66 | trusted: true, | |
| 67 | username: "bob", | |
| 68 | email: "[email protected]", | |
| 69 | password: "a-long-passphrase", | |
| 70 | }); | |
| 71 | if (!b.ok) throw new Error("signup failed"); | |
| 72 | bob = b.value; | |
| 73 | const made = await createRepo(forge, alice, { name: "armature", visibility: "public" }); | |
| 74 | if (!made.ok) throw new Error("repo failed"); | |
| 75 | repo = made.value; | |
| 76 | putFollow(forge.db, { | |
| 77 | repoId: repo.id, | |
| 78 | url: "https://example.invalid/owner/armature.git", | |
| 79 | branch: "main", | |
| 80 | enabled: true, | |
| 81 | byId: alice.id, | |
| 82 | originId: "", | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | forge.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a reader sees no row and no keys", async () => { | |
| 92 | for (const who of [undefined, bob]) { | |
| 93 | const h = boot(as(who)); | |
| 94 | try { | |
| 95 | await openAbout(h); | |
| 96 | expect(h.text()).not.toContain("upstream"); | |
| 97 | expect(h.text()).not.toContain("example.invalid"); | |
| 98 | await h.press("f"); | |
| 99 | await h.press("u"); | |
| 100 | expect(getFollow(forge.db, repo.id)?.enabled).toBe(true); | |
| 101 | expect(h.text()).not.toContain("checking the upstream"); | |
| 102 | } finally { | |
| 103 | h.app.stop(); | |
| 104 | } | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | test("an admin sees the row, checks now, and turns following off and on", async () => { | |
| 109 | const h = boot(as(alice)); | |
| 110 | try { | |
| 111 | await openAbout(h); | |
| 112 | expect(h.text()).toMatch(/upstream +example\.invalid\/owner\/armature main/); | |
| 113 | expect(h.text()).toContain("check upstream now"); | |
| 114 | await h.press("u"); | |
| 115 | await settleUntil(h.app, "could not be read"); | |
| 116 | expect(getFollow(forge.db, repo.id)?.error).not.toBe(""); | |
| 117 | await h.press("f"); | |
| 118 | expect(getFollow(forge.db, repo.id)?.enabled).toBe(false); | |
| 119 | expect(h.text()).toMatch(/upstream +off/); | |
| 120 | expect(h.text()).not.toContain("check upstream now"); | |
| 121 | await h.press("f"); | |
| 122 | expect(getFollow(forge.db, repo.id)?.enabled).toBe(true); | |
| 123 | expect(h.text()).toMatch(/upstream +example\.invalid/); | |
| 124 | } finally { | |
| 125 | h.app.stop(); | |
| 126 | } | |
| 127 | }); | |
| 128 | }); |
new above
offline
With no saved address,
invalid_urlbecomes "set the upstream address in the web settings first". That is good wording, but README.md does not mention it. Put it in the README paragraph above.