Public: anyone can read this; the crew can change it.
The terminal's Deploys screen lists the app's open pulls with their check word and preview address #6158
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6074-the-terminal-s-deploys-screen-lists-the → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +90
−3
src/actions/apps.ts +27
| @@ -12,6 +12,8 @@ import { mayView } from "../core/sealed.ts"; | ||
| 12 | 12 | import { requireVerified } from "./verify.ts"; |
| 13 | 13 | import { requireAppRoom } from "./app_limit.ts"; |
| 14 | 14 | import type { App } from "../app.ts"; |
| 15 | import { checkWord, type CheckWord } from "../apps/check_word.ts"; | |
| 16 | import { openPulls } from "../apps/door_pulls.ts"; | |
| 15 | 17 | import { goldenPath, goldenPathLine } from "../apps/golden_path.ts"; |
| 16 | 18 | import { appVitals, type AppVitals } from "../apps/vitals.ts"; |
| 17 | 19 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| @@ -511,6 +513,31 @@ export function appPace(app: App, actor: User | undefined, repo: Repo): string { | ||
| 511 | 513 | return record === undefined ? "" : goldenPathLine(goldenPath(app.db, record)); |
| 512 | 514 | } |
| 513 | 515 | |
| 516 | export interface AppPullLine { | |
| 517 | readonly number: number; | |
| 518 | readonly title: string; | |
| 519 | readonly check: CheckWord; | |
| 520 | /** The pull's preview address, or `undefined` when the forge serves none. */ | |
| 521 | readonly preview: string | undefined; | |
| 522 | } | |
| 523 | ||
| 524 | /** | |
| 525 | * The app's open pulls with their check word and preview address, for a | |
| 526 | * signed-in viewer who may read the repository; a guest gets none. | |
| 527 | */ | |
| 528 | export function appPullLines(app: App, actor: User | undefined, repo: Repo): AppPullLine[] { | |
| 529 | if (actor === undefined) return []; | |
| 530 | const record = getAppByRepo(app.db, repo.id); | |
| 531 | if (record === undefined) return []; | |
| 532 | const runtime = appsRuntime(app); | |
| 533 | return openPulls(app.db, repo, actor).map((p) => ({ | |
| 534 | number: p.number, | |
| 535 | title: p.title, | |
| 536 | check: checkWord(p.run, p.headOid), | |
| 537 | preview: runtime?.urlFor(record, previewEnv(p.number)), | |
| 538 | })); | |
| 539 | } | |
| 540 | ||
| 514 | 541 | export type EnvError = "not_found" | "no_runtime" | "no_such_env"; |
| 515 | 542 | |
| 516 | 543 | /** The URL to open an environment at — via a grant for a private app. */ |
src/tui/screens/deploys.ts +31 −3
| @@ -5,9 +5,13 @@ | ||
| 5 | 5 | * a pull or an issue shows holds one. This is the list that does, read |
| 6 | 6 | * through `listRepoRuns` (so it is gated like every other run read) and |
| 7 | 7 | * joined to `deploy_pull` in one grouped read for `carries #3 #5`. |
| 8 | * | |
| 9 | * Below the deploys, a signed-in viewer sees the app's open pulls, one line | |
| 10 | * each: `#N title · check <word> · <preview address or none>`. A guest sees | |
| 11 | * none. | |
| 8 | 12 | */ |
| 9 | 13 | |
| 10 | import { appPace, appVisitors } from "../../actions/apps.ts"; | |
| 14 | import { appPace, appPullLines, appVisitors, type AppPullLine } from "../../actions/apps.ts"; | |
| 11 | 15 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 12 | 16 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 13 | 17 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| @@ -35,6 +39,12 @@ function readDeploys(f: Session, repo: Repo): Run[] { | ||
| 35 | 39 | return listed.ok ? listed.value : []; |
| 36 | 40 | } |
| 37 | 41 | |
| 42 | /** `#N title · check <word> · <preview address or none>` */ | |
| 43 | function pullLine(p: AppPullLine): string { | |
| 44 | const title = p.title.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "").trim(); | |
| 45 | return `#${p.number} ${title} · check ${p.check} · ${p.preview ?? "none"}`; | |
| 46 | } | |
| 47 | ||
| 38 | 48 | export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 39 | 49 | const list = new RunList(f); |
| 40 | 50 | let carries = new Map<string, number[]>(); |
| @@ -42,6 +52,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 42 | 52 | let visitors = ""; |
| 43 | 53 | let trend = ""; |
| 44 | 54 | let pace = ""; |
| 55 | let pulls: AppPullLine[] = []; | |
| 45 | 56 | |
| 46 | 57 | const read = (): void => { |
| 47 | 58 | const runs = readDeploys(f, repo); |
| @@ -54,6 +65,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 54 | 65 | visitors = seen === undefined ? "" : visitorsLine(seen); |
| 55 | 66 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| 56 | 67 | pace = appPace(f.app, f.user, repo); |
| 68 | pulls = appPullLines(f.app, f.user, repo); | |
| 57 | 69 | loaded = true; |
| 58 | 70 | ctx.invalidate(); |
| 59 | 71 | }; |
| @@ -65,12 +77,28 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 65 | 77 | keys: [ |
| 66 | 78 | binding(["j", "k"], "move"), |
| 67 | 79 | binding(["enter"], "open the log"), |
| 68 | binding(["r"], "refresh"), | |
| 80 | binding(["r"], "refresh (the open pulls below the deploys: check, preview)"), | |
| 69 | 81 | ], |
| 70 | 82 | load: async () => read(), |
| 71 | 83 | render(area, s) { |
| 72 | 84 | const c = ctx.theme.c; |
| 73 | const [head, body] = split(area, "v", [len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1)), fill(1)]); | |
| 85 | const [head, upper, tail] = split(area, "v", [ | |
| 86 | len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1)), | |
| 87 | fill(1), | |
| 88 | len(pulls.length === 0 ? 0 : pulls.length + 1), | |
| 89 | ]); | |
| 90 | const body = upper; | |
| 91 | pulls.forEach((p, i) => | |
| 92 | s.text( | |
| 93 | tail!.x, | |
| 94 | tail!.y + 1 + i, | |
| 95 | truncate(pullLine(p), tail!.w), | |
| 96 | style(c.mutedFg, c.bg), | |
| 97 | tail!.x + tail!.w, | |
| 98 | ), | |
| 99 | ); | |
| 100 | if (pulls.length > 0) | |
| 101 | s.text(tail!.x, tail!.y, truncate("Open pulls", tail!.w), style(c.mutedFg, c.bg), tail!.x + tail!.w); | |
| 74 | 102 | s.text( |
| 75 | 103 | head!.x, |
| 76 | 104 | head!.y, |
test/tui-deploys.test.ts +32
| @@ -141,3 +141,35 @@ test("an admin sees the app's visitors and speed above the deploys, and a reader | ||
| 141 | 141 | if (!c.ok) throw new Error("signup failed"); |
| 142 | 142 | expect(await screenText(c.value, r.value)).not.toContain("visitors"); |
| 143 | 143 | }); |
| 144 | ||
| 145 | test("the app's open pulls list below the deploys with check word and preview, and a guest sees none", async () => { | |
| 146 | const r = await createRepo(forge, alice, { name: "kiosk", visibility: "public" }); | |
| 147 | if (!r.ok) throw new Error("repo failed"); | |
| 148 | const made = createAppRecord(forge.db, { repoId: r.value.id, slug: "kiosk", access: "public" }); | |
| 149 | if (!made.ok) throw new Error(made.error); | |
| 150 | const id = crypto.randomUUID(); | |
| 151 | forge.db | |
| 152 | .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?, ?, 4, 'pull', 'Add a lamp', ?)") | |
| 153 | .run(id, r.value.id, alice.id); | |
| 154 | forge.db | |
| 155 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', 'lamp', 'b', 'h4')") | |
| 156 | .run(id); | |
| 157 | forge.db | |
| 158 | .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, 'h4', 'ok')") | |
| 159 | .run(crypto.randomUUID(), r.value.id, id); | |
| 160 | expect(await screenText(alice, r.value)).toContain("#4 Add a lamp · check green · none"); | |
| 161 | ||
| 162 | const { app, session } = createForgeApp( | |
| 163 | forge, | |
| 164 | { sshUsername: "guest", remoteAddress: "127.0.0.1" }, | |
| 165 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 166 | ); | |
| 167 | app.start(); | |
| 168 | await settleApp(app); | |
| 169 | app.push(deploysScreen(session, app.ctx, r.value)); | |
| 170 | await settleApp(app); | |
| 171 | const text = app.screen.toText(); | |
| 172 | app.stop(); | |
| 173 | expect(text).not.toContain("Add a lamp"); | |
| 174 | expect(text).not.toContain("Open pulls"); | |
| 175 | }); |
new above
offline
The
const body = upperalias exists only to keep the later code unchanged. Destructure[head, body, tail]directly. When there are no pulls the tail is len(0), so nothing changes.