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

Merged opened this

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

Merged

scout/forge:scout/8082-an-admin-pauses-and-resumes-a-webhook-fr → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/8082-an-admin-pauses-and-resumes-a-webhook-fr was merged into main as f94e1f8.

Review

Sign in to review this change.
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";
4444 import {
4545 createWebhook as insertWebhook,
4646 deleteWebhook as removeWebhook,
47 setWebhookActive,
4748 type CreateWebhookError,
4849 type Webhook,
4950 } from "../core/webhooks.ts";
@@ -649,6 +650,26 @@ export function deleteWebhook(
649650 return ok();
650651 }
651652
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
652673 export type CommitToBranchError =
653674 | AccessError
654675 | CommitFilesError
src/core/webhooks.ts +19 −1
@@ -133,6 +133,23 @@ export function deleteWebhook(
133133 );
134134 }
135135
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 );

Enqueue-time filtering is not enough: deliverDue (below) does not check w.active, so already-pending deliveries are still sent. Add AND w.active = 1 there, with a test.

151 }
152
136153 /** Does a webhook's `kinds` spec match an event kind? */
137154 export function kindMatches(spec: string, kind: string): boolean {
138155 for (const pattern of spec.split(/\s+/).filter(Boolean)) {
@@ -210,7 +227,8 @@ export async function deliverDue(
210227 FROM webhook_delivery d
211228 JOIN webhook w ON w.id = d.webhook_id
212229 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)
214232 ORDER BY d.id LIMIT 50`,
215233 )
216234 .all(nowIso);
src/design/activity.ts +2
@@ -138,6 +138,8 @@ const WORDS: Record<string, string> = {
138138 "label.deleted": "removed a label",
139139 "webhook.created": "added a webhook",
140140 "webhook.deleted": "removed a webhook",
141 "webhook.paused": "paused a webhook",
142 "webhook.resumed": "resumed a webhook",
141143 "origin.connected": "connected an origin",
142144 "origin.disconnected": "disconnected an origin",
143145 "origin.needs_reauth": "needs to reconnect an origin",
src/tui/screens/hooks.ts +18 −3
@@ -2,11 +2,17 @@
22 * A repository's webhooks in the terminal, an admin's: each hook's url,
33 * kinds and last delivery, never its secret. `enter` opens its last 20
44 * 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
68 * `deleteWebhook`. The words of a refusal are MCP `hooks`'s.
79 */
810
9 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
11 import {
12 createWebhook,
13 deleteWebhook,
14 pauseWebhook,
15 } from "../../actions/repos.ts";
1016 import { canAdmin, type Repo } from "../../core/repos.ts";
1117 import { listWebhooks, recentDeliveries, type DeliveryInfo, type Webhook } from "../../core/webhooks.ts";
1218 import type { Ctx, Screen } from "../app/app.ts";
@@ -149,6 +155,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
149155 keys: [
150156 binding(["j", "k"], "move"),
151157 binding(["a"], "add a webhook"),
158 binding(["p"], "pause or resume the selected one"),
152159 binding(["d"], "remove the selected one"),
153160 binding(["enter"], "its deliveries"),
154161 ],
@@ -168,7 +175,7 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
168175 const rows: ListRow[] = all.map((h) => ({
169176 title: clean(h.url),
170177 secondary: `kinds ${clean(h.kinds)}`,
171 meta: lastDelivery(f, h),
178 meta: h.active ? lastDelivery(f, h) : "paused",
172179 }));
173180 drawList(s, body!, c, state, rows);
174181 },
@@ -180,6 +187,14 @@ export function hooksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
180187 const hook = hooks()[state.index];
181188 if (hook === undefined) return false;
outdated

Every failure becomes "no such webhook here", including a refused non-admin. Map the error like the d branch does. Cover it with a non-admin test.

182189 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();
183198 } else if (e.key === "d") {
184199 const hook = hooks()[state.index];
185200 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";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import { createRepo } from "../src/actions/repos.ts";
10 import { createRepo, deleteWebhook, pauseWebhook } from "../src/actions/repos.ts";
1111 import { createApp, type App } from "../src/app.ts";
12 import { listEvents } from "../src/core/events.ts";
1213 import { canAdmin, canWrite, type Repo } from "../src/core/repos.ts";
1314 import { createOrg, createTeam, addTeamMember } from "../src/core/orgs.ts";
1415 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";
1617 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";
1819 import { repoScreen } from "../src/tui/screens/repo.ts";
1920 import { local } from "../src/tui/screens/where.ts";
2021 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -125,6 +126,30 @@ describe("webhooks in the terminal", () => {
125126 h.stop();
126127 });
127128
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 p: the hook stays active and no event is appended.

152
128153 test("a short secret is refused in MCP's words and nothing is added", async () => {
129154 const h = open(alice);
130155 await palette(h);
@@ -213,4 +238,35 @@ describe("webhooks in the terminal", () => {
213238 expect(hooksCommands(h.session, h.ctx, made.value)).toEqual([]);
214239 h.stop();
215240 });
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 });
216272 });
test/webhooks.test.ts +72
@@ -13,6 +13,9 @@ import {
1313 createWebhook,
1414 deliverDue,
1515 kindMatches,
16 enqueueForEvent,
17 getWebhook,
18 setWebhookActive,
1619 verifySignature,
1720 } from "../src/core/webhooks.ts";
1821
@@ -169,6 +172,32 @@ describe("webhook delivery", () => {
169172 expect(ok).toBe(true);
170173 expect(received).toHaveLength(1);
171174 });
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 });
172201 });
173202
174203 describe("webhook urls and refused deliveries", () => {
@@ -276,3 +305,46 @@ function unwrap<T>(
276305 if (!r.ok) throw new Error(`unwrap: ${String(r.error)}`);
277306 return r.value;
278307 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs