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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7873-enter-on-a-webhook-in-the-terminal-s-hoo → main 3 commits fork 4 threads · 4 unresolved
2 files
changed +186
−5
src/tui/screens/hooks.ts +47 −5
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 5 | * characters) and adds it through `createWebhook`; `d` removes the selected one after a confirm through | |
| 6 | 6 | * `deleteWebhook`. The words of a refusal are MCP `hooks`'s. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 13 | import type { Command } from "../app/palette.ts"; |
| 14 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -45,6 +45,43 @@ function lastDelivery(f: Session, hook: Webhook): string { | ||
| 45 | 45 | return `last ${d.kind}: ${d.state}${how}`; |
| 46 | 46 | } |
| 47 | 47 | |
| 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 | ||
| 48 | 85 | export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 49 | 86 | const state = new ListState(); |
| 50 | 87 | 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 { | ||
| 113 | 150 | binding(["j", "k"], "move"), |
| 114 | 151 | binding(["a"], "add a webhook"), |
| 115 | 152 | binding(["d"], "remove the selected one"), |
| 153 | binding(["enter"], "its deliveries"), | |
| 116 | 154 | ], |
| 117 | 155 | render(area, s) { |
| 118 | 156 | const c = ctx.theme.c; |
| @@ -138,7 +176,11 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 138 | 176 | if (state.key(e, hooks().length)) return true; |
| 139 | 177 | if (e.ctrl || e.alt || !allowed()) return false; |
| 140 | 178 | 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") { | |
| 142 | 184 | const hook = hooks()[state.index]; |
| 143 | 185 | if (hook === undefined) return false; |
| 144 | 186 | 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 | }); |
new above
offline
This comment line is now very long; rewrap it.