Public: anyone can read this; the crew can change it.
An admin pauses and resumes a webhook from the terminal's webhook list with `p`, and a paused hook gets no deliveries #8264
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8082-an-admin-pauses-and-resumes-a-webhook-fr → main 4 commits fork 5 threads · 5 unresolved
7 files
changed +208
−7
docs/features/webhook-pause.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Pausing a webhook | |
| 2 | ||
| 3 | A repository admin pauses and resumes a webhook from the terminal (#8082). | |
| 4 | ||
| 5 | - **Where.** The palette's **Webhooks** (`:` → `webhooks`) lists the | |
| 6 | repository's hooks. `p` pauses the selected hook, or resumes it when it is | |
| 7 | paused. Anyone who does not administer the repository is not offered the | |
| 8 | screen. | |
| 9 | - **What paused means.** The row reads `paused` in place of its last delivery. | |
| 10 | A paused hook gets no deliveries: new events are not queued for it, and | |
| 11 | deliveries already queued (including ones waiting to retry) stay `pending` | |
| 12 | until it is resumed, then go out. Events that came while it was paused are | |
| 13 | not backfilled. | |
| 14 | - **Trail.** Each change is a `webhook.paused` / `webhook.resumed` event. | |
| 15 | - **Code.** `pauseWebhook` in `src/actions/repos.ts`; `setWebhookActive` and | |
| 16 | the `w.active = 1` filters in `src/core/webhooks.ts`. | |
| 17 | - **Not yet.** No button on the web settings page and no MCP tool. |
src/actions/repos.ts +21
| @@ -44,6 +44,7 @@ import type { User } from "../core/users.ts"; | ||
| 44 | 44 | import { |
| 45 | 45 | createWebhook as insertWebhook, |
| 46 | 46 | deleteWebhook as removeWebhook, |
| 47 | setWebhookActive, | |
| 47 | 48 | type CreateWebhookError, |
| 48 | 49 | type Webhook, |
| 49 | 50 | } from "../core/webhooks.ts"; |
| @@ -649,6 +650,26 @@ export function deleteWebhook( | ||
| 649 | 650 | return ok(); |
| 650 | 651 | } |
| 651 | 652 | |
| 653 | export function pauseWebhook( | |
| 654 | app: App, | |
| 655 | actor: User | undefined, | |
| 656 | repo: Repo, | |
| 657 | webhookId: string, | |
| 658 | paused: boolean, | |
| 659 | ): Result<void, "not_found"> { | |
| 660 | const admin = requireRepoAdmin(app, actor, repo); | |
| 661 | if (!admin.ok) return admin; | |
| 662 | if (!setWebhookActive(app.db, repo.id, webhookId, !paused)) | |
| 663 | return err("not_found"); | |
| 664 | app.bus.append({ | |
| 665 | kind: paused ? "webhook.paused" : "webhook.resumed", | |
| 666 | repoId: repo.id, | |
| 667 | actorId: admin.value.id, | |
| 668 | payload: { webhookId }, | |
| 669 | }); | |
| 670 | return ok(); | |
| 671 | } | |
| 672 | ||
| 652 | 673 | export type CommitToBranchError = |
| 653 | 674 | | AccessError |
| 654 | 675 | | CommitFilesError |
src/core/webhooks.ts +19 −1
| @@ -133,6 +133,23 @@ export function deleteWebhook( | ||
| 133 | 133 | ); |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | /** | |
| 137 | * Pause or resume a hook. False for a hook of another repository. Nothing | |
| 138 | * is backfilled: events that came while paused were never enqueued. | |
| 139 | */ | |
| 140 | export function setWebhookActive( | |
| 141 | db: Database, | |
| 142 | repoId: string, | |
| 143 | webhookId: string, | |
| 144 | active: boolean, | |
| 145 | ): boolean { | |
| 146 | return ( | |
| 147 | db | |
| 148 | .query("UPDATE webhook SET active = ?3 WHERE id = ?1 AND repo_id = ?2") | |
| 149 | .run(webhookId, repoId, active ? 1 : 0).changes > 0 | |
| 150 | ); | |
| 151 | } | |
| 152 | ||
| 136 | 153 | /** Does a webhook's `kinds` spec match an event kind? */ |
| 137 | 154 | export function kindMatches(spec: string, kind: string): boolean { |
| 138 | 155 | for (const pattern of spec.split(/\s+/).filter(Boolean)) { |
| @@ -210,7 +227,8 @@ export async function deliverDue( | ||
| 210 | 227 | FROM webhook_delivery d |
| 211 | 228 | JOIN webhook w ON w.id = d.webhook_id |
| 212 | 229 | JOIN event e ON e.id = d.event_id |
| 213 | WHERE d.state = 'pending' AND (d.next_try_at IS NULL OR d.next_try_at <= ?1) | |
| 230 | WHERE d.state = 'pending' AND w.active = 1 | |
| 231 | AND (d.next_try_at IS NULL OR d.next_try_at <= ?1) | |
| 214 | 232 | ORDER BY d.id LIMIT 50`, |
| 215 | 233 | ) |
| 216 | 234 | .all(nowIso); |
src/design/activity.ts +2
| @@ -138,6 +138,8 @@ const WORDS: Record<string, string> = { | ||
| 138 | 138 | "label.deleted": "removed a label", |
| 139 | 139 | "webhook.created": "added a webhook", |
| 140 | 140 | "webhook.deleted": "removed a webhook", |
| 141 | "webhook.paused": "paused a webhook", | |
| 142 | "webhook.resumed": "resumed a webhook", | |
| 141 | 143 | "origin.connected": "connected an origin", |
| 142 | 144 | "origin.disconnected": "disconnected an origin", |
| 143 | 145 | "origin.needs_reauth": "needs to reconnect an origin", |
src/tui/screens/hooks.ts +18 −3
| @@ -2,11 +2,17 @@ | ||
| 2 | 2 | * A repository's webhooks in the terminal, an admin's: each hook's url, |
| 3 | 3 | * kinds and last delivery, never its secret. `enter` opens its last 20 |
| 4 | 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 | |
| 5 | * characters) and adds it through `createWebhook`; `p` pauses or resumes | |
outdated
This comment line is now very long. Rewrap it. |
||
| 6 | * the selected one through `pauseWebhook` (a paused row reads `paused` and | |
| 7 | * gets no deliveries); `d` removes the selected one after a confirm through | |
| 6 | 8 | * `deleteWebhook`. The words of a refusal are MCP `hooks`'s. |
| 7 | 9 | */ |
| 8 | 10 | |
| 9 | import { createWebhook, deleteWebhook } from "../../actions/repos.ts"; | |
| 11 | import { | |
| 12 | createWebhook, | |
| 13 | deleteWebhook, | |
| 14 | pauseWebhook, | |
| 15 | } from "../../actions/repos.ts"; | |
| 10 | 16 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 11 | 17 | import { listWebhooks, recentDeliveries, type DeliveryInfo, type Webhook } from "../../core/webhooks.ts"; |
| 12 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -149,6 +155,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 149 | 155 | keys: [ |
| 150 | 156 | binding(["j", "k"], "move"), |
| 151 | 157 | binding(["a"], "add a webhook"), |
| 158 | binding(["p"], "pause or resume the selected one"), | |
| 152 | 159 | binding(["d"], "remove the selected one"), |
| 153 | 160 | binding(["enter"], "its deliveries"), |
| 154 | 161 | ], |
| @@ -168,7 +175,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 168 | 175 | const rows: ListRow[] = all.map((h) => ({ |
| 169 | 176 | title: clean(h.url), |
| 170 | 177 | secondary: `kinds ${clean(h.kinds)}`, |
| 171 | meta: lastDelivery(f, h), | |
| 178 | meta: h.active ? lastDelivery(f, h) : "paused", | |
| 172 | 179 | })); |
| 173 | 180 | drawList(s, body!, c, state, rows); |
| 174 | 181 | }, |
| @@ -180,6 +187,14 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 180 | 187 | const hook = hooks()[state.index]; |
| 181 | 188 | if (hook === undefined) return false; |
outdated
Every failure becomes "no such webhook here", including a refused non-admin. Map the error like the |
||
| 182 | 189 | ctx.push(deliveriesScreen(f, ctx, hook, allowed)); |
| 190 | } else if (e.key === "p") { | |
| 191 | const hook = hooks()[state.index]; | |
| 192 | if (hook === undefined) return false; | |
| 193 | const done = pauseWebhook(f.app, f.user, repo, hook.id, hook.active); | |
| 194 | note = done.ok | |
| 195 | ? `${hook.active ? "paused" : "resumed"} ${clean(hook.url)}` | |
| 196 | : allowed() ? "no such webhook here" : NOT_ADMIN; | |
| 197 | ctx.invalidate(); | |
| 183 | 198 | } else if (e.key === "d") { |
| 184 | 199 | const hook = hooks()[state.index]; |
| 185 | 200 | if (hook === undefined) return false; |
test/tui-hooks.test.ts +59 −3
| @@ -7,14 +7,15 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createRepo, deleteWebhook, pauseWebhook } from "../src/actions/repos.ts"; | |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { listEvents } from "../src/core/events.ts"; | |
| 12 | 13 | import { canAdmin, canWrite, type Repo } from "../src/core/repos.ts"; |
| 13 | 14 | import { createOrg, createTeam, addTeamMember } from "../src/core/orgs.ts"; |
| 14 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | import { enqueueForEvent, listWebhooks } from "../src/core/webhooks.ts"; | |
| 16 | import { createWebhook, enqueueForEvent, getWebhook, listWebhooks } from "../src/core/webhooks.ts"; | |
| 16 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | import { hooksCommands } from "../src/tui/screens/hooks.ts"; | |
| 18 | import { hooksCommands, hooksScreen } from "../src/tui/screens/hooks.ts"; | |
| 18 | 19 | import { repoScreen } from "../src/tui/screens/repo.ts"; |
| 19 | 20 | import { local } from "../src/tui/screens/where.ts"; |
| 20 | 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -125,6 +126,30 @@ describe("webhooks in the terminal", () => { | ||
| 125 | 126 | h.stop(); |
| 126 | 127 | }); |
| 127 | 128 | |
| 129 | test("p pauses and resumes the selected hook; a paused hook is queued nothing", async () => { | |
| 130 | const h = open(alice); | |
| 131 | await palette(h); | |
| 132 | await h.feed("a", ..."https://example.com/pause", "enter", ..."push", "enter", ...SECRET, "enter"); | |
| 133 | const find = () => listWebhooks(app.db, repo.id).find((x) => x.url.endsWith("/pause"))!; | |
| 134 | const row = listWebhooks(app.db, repo.id).findIndex((x) => x.url.endsWith("/pause")); | |
| 135 | for (let i = 0; i < row; i++) await h.feed("j"); | |
| 136 | await h.feed("p"); | |
| 137 | expect(find().active).toBe(false); | |
| 138 | expect(h.text()).toContain("paused"); | |
| 139 | const before = app.db.query<{ n: number }, [string]>("SELECT count(*) n FROM webhook_delivery WHERE webhook_id = ?1").get(find().id)!.n; | |
| 140 | enqueueForEvent(app.db, app.bus.append({ kind: "push", repoId: repo.id, actorId: alice.id, payload: {} })); | |
| 141 | expect(app.db.query<{ n: number }, [string]>("SELECT count(*) n FROM webhook_delivery WHERE webhook_id = ?1").get(find().id)!.n).toBe(before); | |
| 142 | expect(listEvents(app.db, { limit: 1000 }).some((e) => e.kind === "webhook.paused")).toBe(true); | |
| 143 | await h.feed("p"); | |
| 144 | expect(find().active).toBe(true); | |
| 145 | expect(listEvents(app.db, { limit: 1000 }).some((e) => e.kind === "webhook.resumed")).toBe(true); | |
| 146 | enqueueForEvent(app.db, app.bus.append({ kind: "push", repoId: repo.id, actorId: alice.id, payload: {} })); | |
| 147 | expect(app.db.query<{ n: number }, [string]>("SELECT count(*) n FROM webhook_delivery WHERE webhook_id = ?1").get(find().id)!.n).toBe(before + 1); | |
| 148 | await h.feed("d", "right", "enter"); | |
| 149 | expect(listWebhooks(app.db, repo.id).length).toBe(0); | |
| 150 | h.stop(); | |
| 151 | }); | |
Add a case for a non-admin or guest pressing |
||
| 152 | ||
| 128 | 153 | test("a short secret is refused in MCP's words and nothing is added", async () => { |
| 129 | 154 | const h = open(alice); |
| 130 | 155 | await palette(h); |
| @@ -213,4 +238,35 @@ describe("webhooks in the terminal", () => { | ||
| 213 | 238 | expect(hooksCommands(h.session, h.ctx, made.value)).toEqual([]); |
| 214 | 239 | h.stop(); |
| 215 | 240 | }); |
| 241 | ||
| 242 | test("a non-admin pressing p changes nothing, and pauseWebhook refuses them and another repo's admin", async () => { | |
| 243 | const mine = createWebhook(app.db, { repoId: repo.id, url: "https://example.com/neg", kinds: "push", secret: SECRET }); | |
| 244 | if (!mine.ok) throw new Error(mine.error); | |
| 245 | const id = mine.value.webhook.id; | |
| 246 | const paused = () => listEvents(app.db, { limit: 1000 }).filter((e) => e.kind === "webhook.paused").length; | |
| 247 | const before = paused(); | |
| 248 | // bob does not admin the repo: the screen, pushed directly, shows him nothing and p does nothing. | |
| 249 | const h = open(bob); | |
| 250 | h.ctx.push(hooksScreen(h.session, h.ctx, repo)); | |
| 251 | await h.feed("p"); | |
| 252 | expect(h.text()).toContain("Not available"); | |
| 253 | h.stop(); | |
| 254 | expect(getWebhook(app.db, repo.id, id)!.active).toBe(true); | |
| 255 | ||
| 256 | const refused = pauseWebhook(app, bob, repo, id, true); | |
| 257 | expect(refused.ok).toBe(false); | |
| 258 | if (!refused.ok) expect(refused.error).toBe("not_found"); | |
| 259 | const guest = pauseWebhook(app, undefined, repo, id, true); | |
| 260 | expect(guest.ok).toBe(false); | |
| 261 | ||
| 262 | // an admin of another repository, naming this repository's hook there | |
| 263 | const other = await createRepo(app, alice, { name: "elsewhere", visibility: "public" }); | |
| 264 | if (!other.ok) throw new Error(String(other.error)); | |
| 265 | const cross = pauseWebhook(app, alice, other.value, id, true); | |
| 266 | expect(cross.ok).toBe(false); | |
| 267 | if (!cross.ok) expect(cross.error).toBe("not_found"); | |
| 268 | expect(getWebhook(app.db, repo.id, id)!.active).toBe(true); | |
| 269 | expect(paused()).toBe(before); | |
| 270 | expect(deleteWebhook(app, alice, repo, id).ok).toBe(true); | |
| 271 | }); | |
| 216 | 272 | }); |
test/webhooks.test.ts +72
| @@ -13,6 +13,9 @@ import { | ||
| 13 | 13 | createWebhook, |
| 14 | 14 | deliverDue, |
| 15 | 15 | kindMatches, |
| 16 | enqueueForEvent, | |
| 17 | getWebhook, | |
| 18 | setWebhookActive, | |
| 16 | 19 | verifySignature, |
| 17 | 20 | } from "../src/core/webhooks.ts"; |
| 18 | 21 | |
| @@ -169,6 +172,32 @@ describe("webhook delivery", () => { | ||
| 169 | 172 | expect(ok).toBe(true); |
| 170 | 173 | expect(received).toHaveLength(1); |
| 171 | 174 | }); |
| 175 | ||
| 176 | test("a delivery queued before a pause waits, pending, and sends on resume", async () => { | |
| 177 | received = []; | |
| 178 | plainHook(app, repoId, `${receiverUrl}/ok`, "paused.*"); | |
| 179 | const hookId = app.db | |
| 180 | .query<{ id: string }, []>("SELECT id FROM webhook WHERE kinds = 'paused.*'") | |
| 181 | .get()!.id; | |
| 182 | app.bus.append({ kind: "paused.event", repoId, actorId: userId, payload: {} }); | |
| 183 | const state = (): string | undefined => | |
| 184 | app.db | |
| 185 | .query<{ state: string }, []>( | |
| 186 | "SELECT d.state FROM webhook_delivery d JOIN event e ON e.id=d.event_id WHERE e.kind='paused.event'", | |
| 187 | ) | |
| 188 | .get()?.state; | |
| 189 | expect(state()).toBe("pending"); | |
| 190 | ||
| 191 | expect(setWebhookActive(app.db, repoId, hookId, false)).toBe(true); | |
| 192 | expect(await deliverDue(app.db, app.log, new Date(), LOOPBACK)).toBe(0); | |
| 193 | expect(received).toHaveLength(0); | |
| 194 | expect(state()).toBe("pending"); | |
| 195 | ||
| 196 | expect(setWebhookActive(app.db, repoId, hookId, true)).toBe(true); | |
| 197 | expect(await deliverDue(app.db, app.log, new Date(), LOOPBACK)).toBe(1); | |
| 198 | expect(received).toHaveLength(1); | |
| 199 | expect(state()).toBe("ok"); | |
| 200 | }); | |
| 172 | 201 | }); |
| 173 | 202 | |
| 174 | 203 | describe("webhook urls and refused deliveries", () => { |
| @@ -276,3 +305,46 @@ function unwrap<T>( | ||
| 276 | 305 | if (!r.ok) throw new Error(`unwrap: ${String(r.error)}`); |
| 277 | 306 | return r.value; |
| 278 | 307 | } |
| 308 | ||
| 309 | describe("pausing a webhook", () => { | |
| 310 | test("a paused hook enqueues nothing, resumes without backfill, and is scoped to its repo", async () => { | |
| 311 | const dataDir = await mkdtemp(join(tmpdir(), "forge-hook-pause-")); | |
| 312 | const app = createApp({ dataDir, port: 0 }); | |
| 313 | try { | |
| 314 | const user = await createUser(app.db, { origin: "seed", trusted: true, | |
| 315 | username: "pauser", email: "[email protected]", password: "webhooks-are-fun" }); | |
| 316 | if (!user.ok) throw new Error("user"); | |
| 317 | const mk = async (name: string) => { | |
| 318 | const r = await createRepo(app.db, app.reposRoot, { ownerId: user.value.id, name }); | |
| 319 | if (!r.ok) throw new Error("repo"); | |
| 320 | return r.value.id; | |
| 321 | }; | |
| 322 | const repoId = await mk("a"); | |
| 323 | const other = await mk("b"); | |
| 324 | const made = createWebhook(app.db, { repoId, url: "https://example.com/h", kinds: "push" }); | |
| 325 | if (!made.ok) throw new Error("hook"); | |
| 326 | const hookId = made.value.webhook.id; | |
| 327 | const event = (): number => | |
| 328 | app.db.query<{ n: number }, []>("SELECT count(*) n FROM webhook_delivery").get()!.n; | |
| 329 | expect(made.value.webhook.active).toBe(true); | |
| 330 | ||
| 331 | expect(setWebhookActive(app.db, other, hookId, false)).toBe(false); | |
| 332 | expect(getWebhook(app.db, repoId, hookId)!.active).toBe(true); | |
| 333 | ||
| 334 | expect(setWebhookActive(app.db, repoId, hookId, false)).toBe(true); | |
| 335 | expect(getWebhook(app.db, repoId, hookId)!.active).toBe(false); | |
| 336 | const ev = app.bus.append({ kind: "push", repoId, actorId: user.value.id, payload: {} }); | |
| 337 | expect(enqueueForEvent(app.db, ev)).toBe(0); | |
| 338 | expect(event()).toBe(0); | |
| 339 | ||
| 340 | expect(setWebhookActive(app.db, repoId, hookId, true)).toBe(true); | |
| 341 | expect(event()).toBe(0); // nothing backfilled | |
| 342 | const ev2 = app.bus.append({ kind: "push", repoId, actorId: user.value.id, payload: {} }); | |
| 343 | enqueueForEvent(app.db, ev2); | |
| 344 | expect(event()).toBeGreaterThan(0); | |
| 345 | } finally { | |
| 346 | app.close(); | |
| 347 | await rm(dataDir, { recursive: true, force: true }); | |
| 348 | } | |
| 349 | }); | |
| 350 | }); |
new above
offline
Enqueue-time filtering is not enough:
deliverDue(below) does not checkw.active, so already-pending deliveries are still sent. AddAND w.active = 1there, with a test.