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

Merged opened this

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

Merged

scout/forge:scout/3559-the-terminal-shows-a-repository-s-upstre → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3559-the-terminal-shows-a-repository-s-upstre was merged into main as b4a234b.

Review

Sign in to review this change.
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
17991799 http only, no credentials in the address). The clock checks each following
18001800 repository every 30 minutes (`checkDue`); the result is the follow row:
18011801 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
18041809 `refs/upstream-follow/<branch>` (not a branch), then:
18051810
18061811 - **Nothing new:** nothing happens.
src/tui/screens/about.ts +79
@@ -24,6 +24,16 @@ import { repoDir } from "../../git/repos.ts";
2424 import { listRefs } from "../../git/read.ts";
2525 import { updateRepoSettings } from "../../actions/repos.ts";
2626 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";
2737 import {
2838 getProvenance,
2939 shownTrail,
@@ -72,10 +82,16 @@ export function aboutScreen(
7282 f.user?.kind !== "agent" &&
7383 repo !== undefined &&
7484 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;
7590 const factRows = (): [string, string][] => [
7691 ...archivedRow(repo),
7792 ...aboutFacts(f, src, facts, ref),
7893 ...trailRows(f, repo),
94 ...upstreamRows(f, repo),
7995 ...cell,
8096 ];
8197 let rows = factRows();
@@ -125,6 +141,12 @@ export function aboutScreen(
125141 ),
126142 ]
127143 : []),
144 ...(mayFollow()
145 ? [
146 ...(checking() ? [binding(["u"], "check upstream now")] : []),
147 binding(["f"], checking() ? "stop following upstream" : "follow upstream"),
148 ]
149 : []),
128150 // A site admin also reaches the repository's private egress grants.
129151 ...(f.user?.isAdmin === true && repo !== undefined
130152 ? [binding(["e"], "egress"), binding(["c"], "connections")]
@@ -244,6 +266,39 @@ export function aboutScreen(
244266 ctx.invalidate();
245267 return true;
246268 }
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",

With no saved address, invalid_url becomes "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.

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 }
247302 if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) {
248303 ctx.push(egressScreen(f, ctx, repo));
249304 return true;
@@ -280,6 +335,30 @@ export function trailRows(
280335 ];
281336 }
282337
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
283362 /** One line when an admin has put the repository away. */
284363 function archivedRow(repo: Repo | undefined): [string, string][] {
285364 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs