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

Settings › Webhooks pauses and resumes a hook with one button, as the terminal's `p` does #10115

Merged opened this

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

Merged

mason/forge:mason/10103-settings-webhooks-pauses-and-resumes-a-h → main 2 commits fork

  • Merged. mason/10103-settings-webhooks-pauses-and-resumes-a-h was merged into main as 800aaf1.

Review

Sign in to review this change.
4 files changed +171 −9
docs/features/webhook-pause.md +9 −4
@@ -1,17 +1,22 @@
11 # Pausing a webhook
22
3 A repository admin pauses and resumes a webhook from the terminal (#8082).
3 A repository admin pauses and resumes a webhook from the terminal (#8082)
4 or from Settings › Webhooks (#10103).
45
56 - **Where.** The palette's **Webhooks** (`:` → `webhooks`) lists the
67 repository's hooks. `p` pauses the selected hook, or resumes it when it is
78 paused. Anyone who does not administer the repository is not offered the
8 screen.
9 screen. On the web, each row of `/:owner/:name/settings/hooks` has
10 **Pause**, or **Resume** when paused: a same-origin POST to
11 `…/settings/hooks/:id/pause` or `…/resume`. Anyone who does not
12 administer the repository gets 404, from the page and from the post.
913 - **What paused means.** The row reads `paused` in place of its last delivery.
1014 A paused hook gets no deliveries: new events are not queued for it, and
1115 deliveries already queued (including ones waiting to retry) stay `pending`
1216 until it is resumed, then go out. Events that came while it was paused are
1317 not backfilled.
1418 - **Trail.** Each change is a `webhook.paused` / `webhook.resumed` event.
15 - **Code.** `pauseWebhook` in `src/actions/repos.ts`; `setWebhookActive` and
19 - **Code.** `pauseWebhook` in `src/actions/repos.ts`, called by the
20 terminal and by `webhookPause` / `webhookResume` in `src/web/pages/hooks.ts`; `setWebhookActive` and
1621 the `w.active = 1` filters in `src/core/webhooks.ts`.
17 - **Not yet.** No button on the web settings page and no MCP tool.
22 - **Not yet.** No MCP tool.
src/http/server.ts +11 −1
@@ -384,7 +384,15 @@ import {
384384 } from "../web/pages/settings.ts";
385385 import { dangerPage, repoDelete } from "../web/pages/danger.ts";
386386 import { detachSubmit } from "../web/pages/detach.ts";
387 import { hooksPage, webhookCreate, webhookDelete, webhookPing, webhookRedeliver } from "../web/pages/hooks.ts";
387 import {
388 hooksPage,
389 webhookCreate,
390 webhookDelete,
391 webhookPause,
392 webhookPing,
393 webhookRedeliver,
394 webhookResume,
395 } from "../web/pages/hooks.ts";
388396 import {
389397 crewAdd,
390398 crewRemove,
@@ -1502,6 +1510,8 @@ export function buildRouter(): Router<Handler> {
15021510 router.add("POST", "/:owner/:name/settings/hooks/:id/delete", webhookDelete);
15031511 router.add("POST", "/:owner/:name/settings/hooks/:id/deliveries/:delivery/redeliver", webhookRedeliver);
15041512 router.add("POST", "/:owner/:name/settings/hooks/:id/ping", webhookPing);
1513 router.add("POST", "/:owner/:name/settings/hooks/:id/pause", webhookPause);
1514 router.add("POST", "/:owner/:name/settings/hooks/:id/resume", webhookResume);
15051515 router.add("GET", "/:owner/:name/settings/danger", dangerPage);
15061516 router.add("POST", "/:owner/:name/settings/delete", repoDelete);
15071517 router.add("POST", "/:owner/:name/settings/detach", detachSubmit);
src/web/pages/hooks.ts +32 −4
@@ -2,10 +2,12 @@
22 * Settings › Webhooks, admin-only: the hooks that carry this repository's
33 * events out, the last deliveries of each, and the form that adds one. A
44 * hook's signing secret is shown once, in the flash after it is created,
5 * and never again.
5 * and never again. Pause quiets a failing hook where it is seen, as the
6 * terminal's `p` does (`pauseWebhook`); a paused row reads `paused` in
7 * place of its last delivery.
68 */
79
8 import { createWebhook, deleteWebhook } from "../../actions/repos.ts";
10 import { createWebhook, deleteWebhook, pauseWebhook } from "../../actions/repos.ts";
911 import { notePhase, timePhase } from "../../core/route_timing.ts";
1012 import { getWebhook, listWebhooks, pingWebhook, recentDeliveries, redeliver } from "../../core/webhooks.ts";
1113 import type { Ctx } from "../../http/context.ts";
@@ -62,10 +64,13 @@ export function hooksPage(ctx: Ctx): Response {
6264 meta: html`<span class="mono">${hook.kinds}</span> · ${dot(hook.active ? "success" : "attn")}${hook.active
6365 ? "active"
6466 : "paused"} · added ${time(hook.createdAt)}${when(!hook.hasSecret, () => html` · unsigned`)}${when(
65 deliveries.length === 0,
67 hook.active && deliveries.length === 0,
6668 () => html` · No deliveries yet.`,
6769 )}`,
68 trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/ping">
70 trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/${hook.active ? "pause" : "resume"}">
71 ${btn(hook.active ? "Pause" : "Resume", { size: "sm" })}
72 </form>
73 <form method="post" action="${base}/settings/hooks/${hook.id}/ping">
6974 ${btn("Send ping", { size: "sm" })}
7075 </form>
7176 ${confirmAction({
@@ -192,3 +197,26 @@ export async function webhookPing(ctx: Ctx): Promise<Response> {
192197 : { tone: "danger", message: `Too many pings: try again in ${pinged.error.retryAfter} s.` },
193198 );
194199 }
200
201 /**
202 * POST …/settings/hooks/:id/pause and …/resume: one route each, so a
203 * repeated post leaves the hook as it asked, never toggled back.
204 */
205 function webhookSetPaused(paused: boolean) {
206 return async (ctx: Ctx): Promise<Response> => {
207 const gate = await adminForm(ctx);
208 if (gate instanceof Response) return gate;
209 const { repo } = gate;
210 const done = pauseWebhook(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "", paused);
211 return flashTo(
212 ctx,
213 `${repoBase(repo)}/settings/hooks`,
214 done.ok
215 ? { message: paused ? "Webhook paused: it gets no deliveries until resumed." : "Webhook resumed." }
216 : { tone: "danger", message: "That webhook no longer exists." },
217 );
218 };
219 }
220
221 export const webhookPause = webhookSetPaused(true);
222 export const webhookResume = webhookSetPaused(false);
test/web-hook-pause.test.ts added +119
@@ -0,0 +1,119 @@
1 /** Settings › Webhooks pauses and resumes a hook with one button (#10103). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createWebhook, getWebhook } from "../src/core/webhooks.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let repoId = "";
20 let hook = "";
21 let admin = "";
22 let reader = "";
23 const cells = process.env["FORGE_CELLS"];
24
25 beforeAll(async () => {
26 process.env["FORGE_CELLS"] = "off";
27 dataDir = await mkdtemp(join(tmpdir(), "forge-web-hook-pause-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 const mk = async (username: string, first: boolean) => {
31 const made = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username,
35 email: `${username}@forge.local`,
36 password: "a-good-passphrase",
37 adminIfFirst: first,
38 });
39 if (!made.ok) throw new Error(String(made.error));
40 return made.value;
41 };
42 const alice = await mk("alice", true);
43 const bob = await mk("bob", false);
44 const repo = await createRepo(app, alice, { name: "a", visibility: "public" });
45 if (!repo.ok) throw new Error("repo");
46 repoId = repo.value.id;
47 const made = createWebhook(app.db, { repoId, url: "https://a.example/h" });
48 if (!made.ok) throw new Error("hook");
49 hook = made.value.webhook.id;
50 admin = `forge_session=${createSession(app.db, alice.id).token}`;
51 reader = `forge_session=${createSession(app.db, bob.id).token}`;
52 });
53 afterAll(async () => {
54 await servers.stop();
55 app.close();
56 if (cells === undefined) delete process.env["FORGE_CELLS"];
57 else process.env["FORGE_CELLS"] = cells;
58 await rm(dataDir, { recursive: true, force: true });
59 });
60
61 const page = (cookie: string) => fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } });
62 const post = (verb: "pause" | "resume", cookie: string, origin = servers.url) =>
63 fetch(`${servers.url}/alice/a/settings/hooks/${hook}/${verb}`, {
64 method: "POST",
65 redirect: "manual",
66 headers: { cookie, origin },
67 });
68 const active = () => getWebhook(app.db, repoId, hook)!.active;
69 const events = (kind: string) =>
70 app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM event WHERE kind = ?1").get(kind)!.n;
71
72 test("an active hook's row offers Pause; posting it pauses the hook and the row reads paused with Resume", async () => {
73 const before = await (await page(admin)).text();
74 expect(before).toContain(`action="/alice/a/settings/hooks/${hook}/pause"`);
75 expect(before).toMatch(/<button[^>]*>\s*Pause\s*<\/button>/);
76 expect(before).toContain("No deliveries yet.");
77 expect(before).not.toContain(`/settings/hooks/${hook}/resume"`);
78
79 const res = await post("pause", admin);
80 expect(res.status).toBe(303);
81 expect(res.headers.get("location")).toBe("/alice/a/settings/hooks");
82 expect(active()).toBeFalsy();
83 expect(events("webhook.paused")).toBe(1);
84
85 const after = await (await page(admin)).text();
86 expect(after).toContain(`action="/alice/a/settings/hooks/${hook}/resume"`);
87 expect(after).toMatch(/<button[^>]*>\s*Resume\s*<\/button>/);
88 expect(after).toContain("paused");
89 expect(after).not.toContain("No deliveries yet.");
90 expect(after).not.toContain(`/settings/hooks/${hook}/pause"`);
91 });
92
93 test("Resume makes it active again and the row offers Pause once more", async () => {
94 const res = await post("resume", admin);
95 expect(res.status).toBe(303);
96 expect(active()).toBeTruthy();
97 expect(events("webhook.resumed")).toBe(1);
98 const html = await (await page(admin)).text();
99 expect(html).toContain(`action="/alice/a/settings/hooks/${hook}/pause"`);
100 });
101
102 test("a reader neither sees the page nor can post it: 404, and the hook is untouched", async () => {
103 expect((await page(reader)).status).toBe(404);
104 expect((await post("pause", reader)).status).toBe(404);
105 expect((await post("pause", "")).status).toBe(404);
106 expect(active()).toBeTruthy();
107 });
108
109 test("a cross-site post is refused and a missing hook is a message", async () => {
110 expect((await post("pause", admin, "https://evil.example")).status).toBe(403);
111 expect(active()).toBeTruthy();
112 const missing = await fetch(`${servers.url}/alice/a/settings/hooks/nope/pause`, {
113 method: "POST",
114 redirect: "manual",
115 headers: { cookie: admin, origin: servers.url },
116 });
117 expect(missing.status).toBe(303);
118 expect(events("webhook.paused")).toBe(1);
119 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs