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

Enter on a webhook in the terminal's Hooks screen opens its last 20 deliveries, each with its state, status and why it failed #7881

Merged opened this

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

Merged

scout/forge:scout/7873-enter-on-a-webhook-in-the-terminal-s-hoo → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/7873-enter-on-a-webhook-in-the-terminal-s-hoo was merged into main as acba8eb.

Review

Sign in to review this change.
2 files changed +186 −5
src/tui/screens/hooks.ts +47 −5
@@ -1,14 +1,14 @@
11 /**
22 * A repository's webhooks in the terminal, an admin's: each hook's url,
3 * kinds and last delivery, never its secret. `a` asks for url, kinds and a
4 * secret (typed masked, 16–200 characters) and adds it through
5 * `createWebhook`; `d` removes the selected one after a confirm through
3 * kinds and last delivery, never its secret. `enter` opens its last 20
4 * deliveries. `a` asks for url, kinds and a secret (typed masked, 16–200
outdated

This comment line is now very long; rewrap it.

5 * characters) and adds it through `createWebhook`; `d` removes the selected one after a confirm through
66 * `deleteWebhook`. The words of a refusal are MCP `hooks`'s.
77 */
88
99 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
1010 import { canAdmin, type Repo } from "../../core/repos.ts";
11 import { listWebhooks, recentDeliveries, type Webhook } from "../../core/webhooks.ts";
11 import { listWebhooks, recentDeliveries, type DeliveryInfo, type Webhook } from "../../core/webhooks.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
1313 import type { Command } from "../app/palette.ts";
1414 import { binding } from "../app/keys.ts";
@@ -45,6 +45,43 @@ function lastDelivery(f: Session, hook: Webhook): string {
4545 return `last ${d.kind}: ${d.state}${how}`;
4646 }
4747
48 /** One hook's last 20 deliveries, newest first: kind · state · status or reason · attempts. */
49 function deliveriesScreen(f: Session, ctx: Ctx, hook: Webhook, allowed: () => boolean): Screen {
50 const state = new ListState();
51 let all: DeliveryInfo[] = [];
52 return {
53 title: "Deliveries",
54 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
55 render(area, s) {
56 if (!allowed()) {
57 ctx.pop();
58 return;
outdated

Reads deliveries with no admin re-check (a demoted admin keeps seeing them while the screen is open). Gate with the same allowed() check and pop if it fails.

59 }
60 const c = ctx.theme.c;
61 const [head, body] = split(area, "v", [len(1), fill(1)]);
62 s.text(head!.x, head!.y, truncate(clean(hook.url), head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
63 all = recentDeliveries(f.db, hook.id, 20);
64 if (all.length === 0) {
65 emptyState(s, body!, c, { title: "No deliveries yet." });
66 return;
67 }
68 const rows: ListRow[] = all.map((d) => ({
69 title: `${d.kind} · ${d.state} · ${d.lastError !== null ? clean(d.lastError) : (d.lastStatus ?? "—")} · ${d.attempts}`,
70 tone: d.state === "ok" ? "success" : d.state === "failed" ? "danger" : "attn",
71 }));
72 drawList(s, body!, c, state, rows);
73 },
74 key(e) {
75 if (e.ctrl || e.alt) return false;
outdated

This repeats the DB query on every key. Share one helper or cache per frame.

76 if (!allowed() || e.key === "esc" || e.key === "q") {
77 ctx.pop();
78 return true;
79 }
80 return state.key(e, all.length);
81 },
82 };
83 }
84
4885 export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4986 const state = new ListState();
5087 const allowed = (): boolean => f.user !== undefined && canAdmin(f.db, repo, f.user);
@@ -113,6 +150,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
113150 binding(["j", "k"], "move"),
114151 binding(["a"], "add a webhook"),
115152 binding(["d"], "remove the selected one"),
153 binding(["enter"], "its deliveries"),
116154 ],
117155 render(area, s) {
118156 const c = ctx.theme.c;
@@ -138,7 +176,11 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
138176 if (state.key(e, hooks().length)) return true;
139177 if (e.ctrl || e.alt || !allowed()) return false;
140178 if (e.key === "a") ask();
141 else if (e.key === "d") {
179 else if (e.key === "enter") {
180 const hook = hooks()[state.index];
181 if (hook === undefined) return false;
182 ctx.push(deliveriesScreen(f, ctx, hook, allowed));
183 } else if (e.key === "d") {
142184 const hook = hooks()[state.index];
143185 if (hook === undefined) return false;
144186 remove(hook);
test/tui-hooks-deliveries.test.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * Enter on a webhook in the terminal's Hooks screen opens its last 20
3 * deliveries (#7873): state, status and why it failed, never a secret;
4 * esc or q returns to the same row; losing admin closes it.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { createWebhook, enqueueForEvent, listWebhooks } from "../src/core/webhooks.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { hooksScreen } from "../src/tui/screens/hooks.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20 import { proveAll } from "./helpers/verified.ts";
21
22 quietLogs();
23
24 const SECRET = "s3cret-signing-key-0123456789";
25 const KEYS: Record<string, string> = { enter: "\r", esc: "\x1b" };
26
27 let dir: string;
28 let app: App;
29 let alice: User;
30 let bob: User;
31 let repo: Repo;
32
33 beforeAll(async () => {
34 dir = await mkdtemp(join(tmpdir(), "forge-tui-deliveries-"));
35 app = createApp({ dataDir: dir, port: 0 });
36 const mk = async (username: string, first = false) => {
37 const r = await createUser(app.db, {
38 origin: "seed",
39 trusted: true,
40 username,
41 email: `${username}@forge.local`,
42 password: "a-good-passphrase",
43 adminIfFirst: first,
44 });
45 if (!r.ok) throw new Error(r.error);
46 return r.value;
47 };
48 alice = await mk("alice", true);
49 bob = await mk("bob");
50 proveAll(app);
51 const made = await createRepo(app, alice, { name: "lobby", visibility: "public" });
52 if (!made.ok) throw new Error(String(made.error));
53 repo = made.value;
54
55 const mkHook = (url: string) => {
56 const r = createWebhook(app.db, { repoId: repo.id, url, kinds: "*", secret: SECRET });
57 if (!r.ok) throw new Error(r.error);
58 };
59 mkHook("https://example.com/a");
60 mkHook("https://example.com/b");
61 enqueueForEvent(app.db, app.bus.append({ kind: "push", repoId: repo.id, actorId: alice.id, payload: {} }));
62 enqueueForEvent(app.db, app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: alice.id, payload: {} }));
63 mkHook("https://example.com/c");
64 const rows = app.db
65 .query<{ id: string }, [string]>("SELECT id FROM webhook_delivery WHERE webhook_id = ?1 ORDER BY id")
66 .all(listWebhooks(app.db, repo.id)[1]!.id);
67 app.db.run("UPDATE webhook_delivery SET state='ok', attempts=1, last_status=204 WHERE id = ?1", [rows[0]!.id]);
68 app.db.run("UPDATE webhook_delivery SET state='failed', attempts=3, last_error='refused: private address' WHERE id = ?1", [
69 rows[1]!.id,
70 ]);
71 });
72 afterAll(async () => {
73 app.db.close();
74 await rm(dir, { recursive: true, force: true });
75 });
76
77 function open() {
78 const terminal = headlessTerminal(120, 40);
79 const { app: tui, session } = createForgeApp(
80 app,
81 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice },
82 { terminal, exitHooks: false },
83 );
84 tui.start();
85 tui.ctx.push(hooksScreen(session, tui.ctx, repo));
86 const frames: string[] = [];
87 return {
88 session,
89 frames,
90 text: (): string => {
91 const t = tui.screen.toText();
92 frames.push(t);
93 return t;
94 },
95 feed: async (...keys: string[]) => {
96 for (const k of keys) {
97 terminal.feed(KEYS[k] ?? k);
98 if (k === "esc") await new Promise<void>((r) => setTimeout(r, 40));
99 await settleApp(tui);
100 }
101 },
102 stop: () => tui.stop?.(),
103 };
104 }
105
106 test("enter lists a hook's deliveries with status and reason; esc and q return to the same row", async () => {
107 const h = open();
108 await h.feed("j", "enter");
109 const t = h.text();
110 expect(t).toContain("Deliveries");
111 expect(t).toMatch(/push · ok · 204 · 1/);
112 expect(t).toMatch(/issue\.opened · failed · refused: private address · 3/);
113 expect(h.frames.join("\n")).not.toContain(SECRET);
114
115 for (const back of ["esc", "q"]) {
116 await h.feed(back);
117 expect(h.text()).not.toContain("Deliveries");
118 await h.feed("enter");
119 expect(h.text()).toContain("issue.opened · failed");
120 }
121 h.stop();
122 });
123
124 test("a hook with no deliveries says so", async () => {
125 const h = open();
126 await h.feed("j", "j", "enter");
127 expect(h.text()).toContain("No deliveries yet.");
128 h.stop();
129 });
130
131 test("losing admin closes the deliveries instead of showing them", async () => {
132 const h = open();
133 await h.feed("j", "enter");
134 expect(h.text()).toContain("refused: private address");
135 (h.session as { user: User }).user = bob;
136 await h.feed("j");
137 expect(h.text()).not.toContain("refused: private address");
138 h.stop();
139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs