From 6090cc85fa338546206e58cd91750bba4a36a135 Mon Sep 17 00:00:00 2001 From: dawn Date: Tue, 20 Oct 2026 20:19:39 +0300 Subject: [PATCH] e2e: check spindle webhooks and pull request pipelines through the stack Signed-off-by: dawn --- e2e/harness/api.ts | 12 +- e2e/harness/flow.ts | 5 +- e2e/harness/git.ts | 15 +- e2e/harness/run.ts | 11 +- e2e/harness/shared.ts | 17 ++ e2e/harness/webhooks.ts | 176 +++++++++++++++++++++ e2e/specs/merges.spec.ts | 3 +- e2e/specs/pipelines.spec.ts | 11 +- e2e/specs/pull-pipelines.spec.ts | 122 +++++++++++++++ e2e/specs/repo-settings.spec.ts | 25 ++- e2e/specs/webhooks.spec.ts | 257 +++++++++++++++++++++++++++++++ 11 files changed, 621 insertions(+), 33 deletions(-) create mode 100644 e2e/harness/webhooks.ts create mode 100644 e2e/specs/pull-pipelines.spec.ts create mode 100644 e2e/specs/webhooks.spec.ts diff --git a/e2e/harness/api.ts b/e2e/harness/api.ts index f4b70abfa..15378a966 100644 --- a/e2e/harness/api.ts +++ b/e2e/harness/api.ts @@ -69,6 +69,11 @@ export const serviceDo = async ( json }); +export const spindleGet = (who: Account, nsid: string, params: Record) => + serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { params }); +export const spindlePost = (who: Account, nsid: string, json: object) => + serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { json }); + // the knot takes a service token once, so every retry mints a new one const serviceReply = (who: Account, aud: string, nsid: string, input: object) => retry429(() => serviceDo(who, config.knotUrl, aud, nsid, { json: input })); @@ -323,10 +328,11 @@ const PATCH = "org.tangled.review.patch"; export const pulls = { open: async ( who: Account, - repo: string, + repo: Did, title: string, targetBranch: string, - range: { base: string; head: string } + range: { base: string; head: string }, + sourceBranch: string ): Promise<{ ticket: Written; patch: Written }> => { const ticket = await knotCall(who, repo, "com.atproto.repo.createRecord", { collection: TICKET, @@ -339,6 +345,8 @@ export const pulls = { ticket: partsOf(ticket.uri).rkey, base: { oid: range.base }, head: { oid: range.head }, + // web resubmits from the source branch, a patch without one has no resubmit button + source: { repo, branch: sourceBranch }, createdAt: new Date().toISOString() } satisfies Patch }); diff --git a/e2e/harness/flow.ts b/e2e/harness/flow.ts index 1e370b595..affbe53c6 100644 --- a/e2e/harness/flow.ts +++ b/e2e/harness/flow.ts @@ -107,7 +107,10 @@ class Flow { }); } - push(files: Record, options: { branch?: string; base?: string } = {}) { + push( + files: Record, + options: { branch?: string; base?: string; options?: string[] } = {} + ) { return this.add("push", (run) => pushFiles(run, files, options)); } diff --git a/e2e/harness/git.ts b/e2e/harness/git.ts index 76d27606e..d87e714bb 100644 --- a/e2e/harness/git.ts +++ b/e2e/harness/git.ts @@ -122,7 +122,11 @@ export const push = async ( who: Account, repoDid: string, files: Record, - { branch = "main", base }: { branch?: string; base?: string } = {} + { + branch = "main", + base, + options = [] + }: { branch?: string; base?: string; options?: string[] } = {} ) => inScratch(async (dir) => { const { ssh, remote } = await gitTo(who, repoDid); @@ -152,7 +156,14 @@ export const push = async ( await git(dir, ssh, "add", "-A"); await git(dir, ssh, "commit", "-q", "-m", "e2e"); const sha = (await git(dir, ssh, "rev-parse", "HEAD")).stdout.trim(); - await git(dir, ssh, "push", remote, `${branch}:${branch}`); + await git( + dir, + ssh, + "push", + ...options.flatMap((option) => ["-o", option]), + remote, + `${branch}:${branch}` + ); return sha; }); diff --git a/e2e/harness/run.ts b/e2e/harness/run.ts index bba2160b7..410570b72 100644 --- a/e2e/harness/run.ts +++ b/e2e/harness/run.ts @@ -239,11 +239,15 @@ export class Run implements Ctx { export const pushFiles = async ( run: Run, files: Record, - options: { branch?: string; base?: string } + options: { branch?: string; base?: string; options?: string[] } ) => { const { actor, repo } = run; const branch = options.branch ?? repo.defaultBranch; - const sha = await push(actor, repo.did, files, { branch, base: options.base }); + const sha = await push(actor, repo.did, files, { + branch, + base: options.base, + options: options.options + }); run.pushed = { sha, branch }; repo.heads[branch] = sha; const uri = selfUri(repo.did, refsId.nsid); @@ -279,7 +283,8 @@ export const openPull = async ( run.repo.did, options.title ?? unique("a pull"), into, - range + range, + from ); // the patch goes first at teardown, since it names a ticket that has to outlive it run.adopt(pull, opened.ticket); diff --git a/e2e/harness/shared.ts b/e2e/harness/shared.ts index 5b583625b..920c12525 100644 --- a/e2e/harness/shared.ts +++ b/e2e/harness/shared.ts @@ -10,6 +10,7 @@ import { alerts, commentId, commentMenu, + declarationId, confirmDelete, discussion, editLabels, @@ -21,7 +22,9 @@ import { page, patchId, pullId, + record, refsId, + renameForm, rkeyOf, replacing, resolve, @@ -310,3 +313,17 @@ export const deletesIssue = (flow: Flow) => .click(confirmDelete) .sends(ticketId, knot, replacing(ticketId), "delete") .trace(ticketId, through(knot, hydrant, bobbin)); + +// a rename writes the new declaration before deleting the old one, so the new one needs its own slot +const movedDeclarationId = record("org.tangled.repo.declaration"); + +export const renamesTo = (name: (ctx: Ctx) => string) => (flow: Flow) => + flow + .open(renameForm) + .fill(testid("name"), name) + .click(testid("rename")) + .sends(movedDeclarationId, pds, (ctx) => ({ rkey: name(ctx) }), "create") + .trace(movedDeclarationId, through(pds, hydrant, bobbin)) + .trace(manifestId, through(knot, hydrant, bobbin)) + .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") + .trace(declarationId, through(pds, hydrant, bobbin)); diff --git a/e2e/harness/webhooks.ts b/e2e/harness/webhooks.ts new file mode 100644 index 000000000..0c7453ce4 --- /dev/null +++ b/e2e/harness/webhooks.ts @@ -0,0 +1,176 @@ +import { expect } from "@playwright/test"; +import { createHmac } from "node:crypto"; +import { createServer, type IncomingHttpHeaders } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { spindleGet, spindlePost } from "./api"; +import type { Account } from "./config"; +import { sleep, stamp, until } from "./net"; +import { resolve, type Ctx, type Lazy } from "./vocabulary"; + +export const WEBHOOKS = "org.tangled.temp.webhook"; + +export interface Delivery { + method?: string; + headers: IncomingHttpHeaders; + raw: string; + body: any; +} + +// spindle and this process share the stack's network, and dev mode lets it dial loopback +const inboxes = new Map(); +let serving: Promise | undefined; + +const serve = () => + (serving ??= new Promise((ready) => { + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + const inbox = inboxes.get(req.url ?? ""); + if (!inbox) return void res.writeHead(404).end(); + let body: unknown; + try { + body = JSON.parse(raw); + } catch {} + inbox.push({ method: req.method, headers: req.headers, raw, body }); + res.end("ok"); + }); + }); + // the worker owns this server, a test run shouldn't wait on it + server.unref(); + server.listen(0, "127.0.0.1", () => ready((server.address() as AddressInfo).port)); + })); + +interface Hook { + id: number; + url: string; + owner: Account; + secret?: string; + inbox: Delivery[]; + // how many deliveries the flow has accounted for so far + taken: number; +} + +const hooks = new WeakMap(); +const hookOf = (ctx: Ctx) => { + const hook = hooks.get(ctx); + if (!hook) throw new Error("this step needs a webhook step before it"); + return hook; +}; + +export const hookOn = + (events: string[], options: { secret?: string; active?: boolean } = {}) => + async (ctx: Ctx) => { + const url = `http://127.0.0.1:${await serve()}/${stamp()}`; + const inbox: Delivery[] = []; + inboxes.set(new URL(url).pathname, inbox); + // spindle takes a while to learn a new repo + const id = await until("spindle should take the webhook", 15_000, async () => { + const made = await spindlePost(ctx.actor, `${WEBHOOKS}.createWebhook`, { + repoDid: ctx.repo.did, + url, + events, + ...options + }); + return [ + made.status === 200, + `it answered ${made.status} ${JSON.stringify(made.body)}`, + made.body?.id + ]; + }); + hooks.set(ctx, { id, url, owner: ctx.actor, secret: options.secret, inbox, taken: 0 }); + }; + +export const hookUrl = (ctx: Ctx) => hookOf(ctx).url; + +export const dropsHook = async (ctx: Ctx) => { + const hook = hookOf(ctx); + await spindlePost(hook.owner, `${WEBHOOKS}.deleteWebhook`, { + repoDid: ctx.repo.did, + id: hook.id + }); +}; + +interface Logged { + deliveryId: string; + webhookId: number; + event: string; + url: string; + success: boolean; + requestBody?: string; + responseCode?: number; +} + +const logOf = async (ctx: Ctx, hook: Hook) => { + const reply = await spindleGet(hook.owner, `${WEBHOOKS}.getDeliveriesForWebhooks`, { + repoDid: ctx.repo.did, + ids: String(hook.id), + limit: "100" + }); + expect(reply.status, `spindle answered ${JSON.stringify(reply.body)}`).toBe(200); + return reply.body.deliveries as Logged[]; +}; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +// the next delivery the flow hasn't accounted for has to be this event, so a repeat shows up as the wrong event +export const delivers = + (event: string, userAgent: Lazy, check: (body: any, ctx: Ctx) => void) => + async (ctx: Ctx) => { + const hook = hookOf(ctx); + await until(`the receiver should get ${event}`, 30_000, async () => [ + hook.inbox.length > hook.taken, + `it has ${hook.inbox.length} deliveries, ${hook.taken} already checked` + ]); + const got = hook.inbox[hook.taken++]; + const { headers, body } = got; + + expect(got.method).toBe("POST"); + expect(headers["x-tangled-event"]).toBe(event); + expect(headers["content-type"]).toBe("application/json"); + expect(headers["user-agent"]).toBe(resolve(userAgent, ctx)); + expect(headers["x-tangled-hook-id"]).toBe(String(hook.id)); + expect(headers["x-tangled-delivery"]).toMatch(UUID); + expect(headers["x-tangled-repo"]).toBe(body.repository.full_name); + expect(headers["x-tangled-signature-256"]).toBe( + hook.secret + ? `sha256=${createHmac("sha256", hook.secret).update(got.raw).digest("hex")}` + : undefined + ); + expect(body.repository.full_name).toMatch(new RegExp(`^${hook.owner.did}/.+`)); + expect(body.repository.owner).toEqual({ did: hook.owner.did }); + check(body, ctx); + + const deliveryId = headers["x-tangled-delivery"]; + const logged = await until( + `spindle's delivery log should list ${deliveryId}`, + 15_000, + async () => { + const entry = (await logOf(ctx, hook)).find((one) => one.deliveryId === deliveryId); + return [Boolean(entry), `it has no such delivery`, entry]; + } + ); + expect(logged).toMatchObject({ + webhookId: hook.id, + event, + url: hook.url, + success: true, + responseCode: 200, + requestBody: got.raw + }); + }; + +// a stray second delivery comes within a moment of the first, so give it that long +export const nothingMore = async (ctx: Ctx) => { + const hook = hookOf(ctx); + await sleep(3000); + expect( + hook.inbox.map((one) => one.headers["x-tangled-event"]), + "the receiver got more than the flow expected" + ).toHaveLength(hook.taken); + expect(await logOf(ctx, hook), "spindle logged more than the flow expected").toHaveLength( + hook.taken + ); +}; diff --git a/e2e/specs/merges.spec.ts b/e2e/specs/merges.spec.ts index 52b0f87c2..537feeb69 100644 --- a/e2e/specs/merges.spec.ts +++ b/e2e/specs/merges.spec.ts @@ -78,7 +78,8 @@ const openFromForkPoint = (title?: string) => async (ctx: Ctx) => { ctx.repo.did, title ?? unique("a pull"), branches.into, - { base: marked(ctx, "fork point"), head: ctx.repo.heads[branches.from] } + { base: marked(ctx, "fork point"), head: ctx.repo.heads[branches.from] }, + branches.from ); ctx.adopt(pullId, opened.ticket); ctx.adopt(patchId, opened.patch); diff --git a/e2e/specs/pipelines.spec.ts b/e2e/specs/pipelines.spec.ts index f639a0031..e41ed1bd8 100644 --- a/e2e/specs/pipelines.spec.ts +++ b/e2e/specs/pipelines.spec.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; -import { serviceDo } from "../harness/api"; -import { type Account, config } from "../harness/config"; +import { spindleGet, spindlePost } from "../harness/api"; +import { config } from "../harness/config"; import { type Ctx, type Target, @@ -21,6 +21,7 @@ import { } from "../harness/flow"; import { stamp, until } from "../harness/net"; import { saveFor } from "../harness/services"; +import { WEBHOOKS } from "../harness/webhooks"; import { acceptConfirm, finished, @@ -52,12 +53,6 @@ const landsOn = (what: string, at: (ctx: Ctx) => string) => (ctx: Ctx) => expect(ctx.page).toHaveURL(`${config.webUrl}${at(ctx)}`, { timeout: 8000 }) ); -const spindleGet = (who: Account, nsid: string, params: Record) => - serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { params }); -const spindlePost = (who: Account, nsid: string, json: object) => - serviceDo(who, config.spindleUrl, config.spindleDid, nsid, { json }); - -const WEBHOOKS = "org.tangled.temp.webhook"; const SECRETS = "org.tangled.secret"; let hookUrl = ""; diff --git a/e2e/specs/pull-pipelines.spec.ts b/e2e/specs/pull-pipelines.spec.ts new file mode 100644 index 000000000..63d48688e --- /dev/null +++ b/e2e/specs/pull-pipelines.spec.ts @@ -0,0 +1,122 @@ +import { expect } from "@playwright/test"; + +import { config } from "../harness/config"; +import { + type Ctx, + bobbin, + flow, + hydrant, + knot, + page, + patchId, + pullId, + rkeyOf, + testid, + through +} from "../harness/flow"; +import { get, sleep, until, xrpc } from "../harness/net"; +import { + base, + branches, + deletesBranch, + merges, + onBranches, + pipelineFor, + workflow, + workflowPath +} from "../harness/shared"; + +// only a close or a merge runs this one, the default types leave both out +const doneWorkflow = `engine: dummy +when: + - event: pull_request + branch: ["main"] + types: [closed, merged] +steps: + - name: bye + command: echo "done" +`; +const donePath = ".tangled/workflows/done.yml"; + +const head = (ctx: Ctx) => ctx.repo.heads[branches.from]; + +interface Pipeline { + commit: string; + trigger?: { $type?: string }; + workflows?: { id: string }[]; +} + +const pullPipelines = async (ctx: Ctx, sha: string) => { + const { status, body } = await get( + xrpc(config.spindleUrl, "org.tangled.ci.queryPipelines", { + repo: ctx.repo.did, + commits: sha + }) + ); + expect(status, `spindle answered ${JSON.stringify(body)}`).toBe(200); + return ((body.pipelines ?? []) as Pipeline[]).filter( + (p) => p.commit === sha && p.trigger?.$type?.endsWith(".event.pullRequest") + ); +}; + +const runs = (id: string, sha: (ctx: Ctx) => string) => (ctx: Ctx) => + until(`spindle should run ${id} for the pull at ${sha(ctx)}`, 30_000, async () => { + const found = await pullPipelines(ctx, sha(ctx)); + const ran = found.some((p) => p.workflows?.some((w) => w.id === id)); + return [ran, `it listed ${found.length} pull request pipelines for the head`]; + }); + +// spindle reads the stream within a second or two, so this long without a pipeline means none +const runsNothing = (sha: (ctx: Ctx) => string) => async (ctx: Ctx) => { + await sleep(15_000); + expect(await pullPipelines(ctx, sha(ctx)), "pull request pipelines for the head").toEqual([]); +}; + +const withDone = (name: string) => + flow(name) + .actor("owner") + .repo({ spindle: config.spindleDid }) + .push({ ...base, [workflowPath]: workflow, [donePath]: doneWorkflow }) + .push({ "e2e/feature.txt": "feature\n" }, { branch: branches.from, base: branches.into }) + .mirrored() + .pull(pullId, patchId, branches) + .step("spindle runs the default workflow for the opened pull", runs("ci.yml", head)); + +onBranches("opening a pull request runs a pull_request pipeline for its head", { ci: true }) + .pull(pullId, patchId, branches) + .step("spindle has a pull request pipeline for the head", pipelineFor("pullRequest", head)); + +withDone("closing a pull request runs its types: [closed] workflow") + .open(page.pull(pullId)) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .step("spindle runs the close workflow at the head", runs("done.yml", head)); + +withDone("a merged pull request runs its types: [merged] workflow") + .open(page.pull(pullId)) + .use(merges) + .step("spindle runs the merge workflow at the head", runs("done.yml", head)); + +// the head is then only kept by the knot's keep ref +withDone("closing a pull request whose branch is gone still runs at the old head") + .step("delete the source branch", deletesBranch(branches.from)) + .open(page.pull(pullId)) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .step("spindle runs the close workflow at the old head", runs("done.yml", head)); + +onBranches("a pull from someone who isn't a collaborator runs nothing", { ci: true }) + .actor("author") + .pull(pullId, patchId, branches) + .step("no pipeline for the opened pull", runsNothing(head)) + .open(page.pull(pullId)) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .changeState(pullId, "open", testid("changeState"), testid("requestClose")) + .step("no pipeline after the close and the reopen", runsNothing(head)) + .actor("owner") + .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) + .mirrored() + .actor("author") + .open(page.pull(pullId)) + .click(testid("resubmit")) + .sends(patchId, knot, (ctx) => ({ record: { ticket: rkeyOf(ctx, pullId) } }), "create") + .trace(patchId, through(knot, hydrant, bobbin)) + .step("no pipeline for the resubmitted head", runsNothing(head)); diff --git a/e2e/specs/repo-settings.spec.ts b/e2e/specs/repo-settings.spec.ts index 81ed9ee8e..4a5bbd3a6 100644 --- a/e2e/specs/repo-settings.spec.ts +++ b/e2e/specs/repo-settings.spec.ts @@ -7,7 +7,6 @@ import { bobbin, component, type Ctx, - declarationId, exactLink, flow, hydrant, @@ -15,8 +14,6 @@ import { manifestId, not, page, - pds, - record, renameForm, saveButton, settingsAt, @@ -33,7 +30,14 @@ import { } from "../harness/flow"; import { headOf } from "../harness/git"; import { get, selfUri, until, xrpc } from "../harness/net"; -import { base, finished, manifestOf, saveFinishes, topicsOnKnot } from "../harness/shared"; +import { + base, + finished, + manifestOf, + renamesTo, + saveFinishes, + topicsOnKnot +} from "../harness/shared"; let renamedTo = ""; const renamedPath = (ctx: Ctx) => `/${ctx.actor.handle}/${renamedTo}`; @@ -42,24 +46,13 @@ const repoName = (name: () => string) => tab.getByRole("link", { name: name(), exact: true }).first() ); -// a rename writes the new declaration before deleting the old one, so the new one needs its own slot -const movedDeclarationId = record("org.tangled.repo.declaration"); - flow("renaming a repo made today moves it to the new name") .actor("owner") .repo() .step("pick the new name", async (ctx) => { renamedTo = `${ctx.repo.name}-renamed`; }) - - .open(renameForm) - .fill(testid("name"), () => renamedTo) - .click(testid("rename")) - .sends(movedDeclarationId, pds, () => ({ rkey: renamedTo }), "create") - .trace(movedDeclarationId, through(pds, hydrant, bobbin)) - .trace(manifestId, through(knot, hydrant, bobbin)) - .sends(declarationId, pds, (ctx) => ({ rkey: ctx.repo.name }), "delete") - .trace(declarationId, through(pds, hydrant, bobbin)) + .use(renamesTo(() => renamedTo)) .step("the rename finishes on the new settings page", async (ctx) => finished( ctx, diff --git a/e2e/specs/webhooks.spec.ts b/e2e/specs/webhooks.spec.ts new file mode 100644 index 000000000..fba8da59c --- /dev/null +++ b/e2e/specs/webhooks.spec.ts @@ -0,0 +1,257 @@ +import { expect } from "@playwright/test"; + +import { spindleGet } from "../harness/api"; +import { config } from "../harness/config"; +import { + type Ctx, + bobbin, + flow, + hydrant, + knot, + page, + patchId, + pullId, + record, + rkeyOf, + settingsAt, + target, + testid, + through, + unique +} from "../harness/flow"; +import { + base, + branches, + finished, + merges, + onBranches, + pipelineFor, + renamesTo, + workflow, + workflowPath +} from "../harness/shared"; +import { WEBHOOKS, delivers, dropsHook, hookOn, hookUrl, nothingMore } from "../harness/webhooks"; + +const secret = "e2e-webhook-secret"; + +// the base push has to be through spindle before the hook exists, or it would deliver that push too +const onHookedRepo = (name: string, events: string[], options: { secret?: string } = {}) => + flow(name) + .actor("owner") + .repo({ spindle: config.spindleDid }) + .push({ ...base, [workflowPath]: workflow }) + .step( + "spindle has a pipeline for the first push", + pipelineFor("push", (ctx) => ctx.pushed.sha) + ) + .step("point a webhook at the receiver", hookOn(events, options)); + +const tips = new WeakMap(); +const rememberTip = async (ctx: Ctx) => { + tips.set(ctx, ctx.repo.heads[ctx.repo.defaultBranch]); +}; + +const pushDelivered = delivers( + "push", + (ctx) => `Tangled-Hook/${ctx.pushed.sha.slice(0, 7)}`, + (body, ctx) => { + expect(body).toMatchObject({ + ref: `refs/heads/${ctx.repo.defaultBranch}`, + before: tips.get(ctx), + after: ctx.pushed.sha, + pusher: { did: ctx.actor.did }, + repository: { name: ctx.repo.name } + }); + } +); + +onHookedRepo("pushing delivers one signed push webhook", ["push"], { secret }) + .step("remember the branch tip", rememberTip) + .push({ "e2e/more.txt": "more\n" }) + .step("the receiver got the push", pushDelivered) + .step("nothing else arrived", nothingMore) + .step("take the webhook away", dropsHook); + +onHookedRepo("a push that skips ci still delivers an unsigned push webhook", ["push"]) + .step("remember the branch tip", rememberTip) + .push({ "e2e/more.txt": "more\n" }, { options: ["skip-ci"] }) + .step("the receiver got the push", pushDelivered) + .step("nothing else arrived", nothingMore) + .step("take the webhook away", dropsHook); + +const hookSwitch = target("the webhook switch", (tab, ctx) => + testid("webhookSwitch", { url: hookUrl(ctx) }).locate(tab, ctx) +); + +onHookedRepo("a webhook switched off gets nothing", ["push"], { secret }) + .open(settingsAt("hooks")) + .click(hookSwitch) + .step("the switch goes off", async (ctx) => { + await expect(hookSwitch.locate(ctx.page, ctx)).not.toBeChecked(); + }) + .step("spindle lists it inactive", async (ctx) => { + const listed = await spindleGet(ctx.actor, `${WEBHOOKS}.listWebhooks`, { + repoDid: ctx.repo.did + }); + expect(listed.body.webhooks).toMatchObject([{ url: hookUrl(ctx), active: false }]); + }) + .push({ "e2e/more.txt": "more\n" }) + .step( + "spindle has a pipeline for the push", + pipelineFor("push", (ctx) => ctx.pushed.sha) + ) + .step("nothing arrived", nothingMore) + .step("take the webhook away", dropsHook); + +let renamedTo = ""; + +flow("renaming a repo delivers one repository:renamed webhook") + .actor("owner") + .repo({ spindle: config.spindleDid }) + .step("point a webhook at the receiver", hookOn(["repository:renamed"], { secret })) + .step("pick the new name", async (ctx) => { + renamedTo = `${ctx.repo.name}-renamed`; + }) + .use(renamesTo(() => renamedTo)) + .step("the rename finishes on the new settings page", (ctx) => + finished( + ctx, + "the rename did not finish", + ctx.page.waitForURL(`${config.webUrl}/${ctx.actor.handle}/${renamedTo}/settings`, { + timeout: 5000 + }) + ) + ) + .step( + "the receiver got the rename", + delivers("repository:renamed", "Tangled-Hook/rename", (body, ctx) => { + expect(body).toMatchObject({ + old_name: ctx.repo.name, + new_name: renamedTo, + sender: { did: ctx.actor.did }, + repository: { name: renamedTo, full_name: `${ctx.actor.did}/${renamedTo}` } + }); + }) + ) + .step("nothing else arrived", nothingMore) + .step("take the webhook away", dropsHook); + +const pullEvents = [ + "pull_request:created", + "pull_request:resubmitted", + "pull_request:merged", + "pull_request:closed", + "pull_request:reopened" +]; +const pullTitle = unique("a pull to hook"); + +// who the page acts as changes with the step, so a pull names its author and its sender apart +const pullDelivered = ( + action: string, + who: (ctx: Ctx) => string, + state: string, + round: number, + sha: (ctx: Ctx) => string +) => + delivers(`pull_request:${action}`, "Tangled-Hook/pull_request", (body, ctx) => { + expect(body).toMatchObject({ + action, + sender: { did: who(ctx) }, + pull_request: { + title: pullTitle, + state, + target_branch: branches.into, + round_number: round, + owner: { did: author }, + source: { sha: sha(ctx) } + } + }); + }); +let author = ""; + +const resubmittedId = record("org.tangled.review.patch"); + +// the author opens it so the payload's owner and sender can be told apart once the owner acts +const openedPull = (name: string) => + onBranches(name, { ci: true }) + .step( + "spindle has a pipeline for the first push", + pipelineFor("push", (ctx) => ctx.repo.heads[branches.into]) + ) + .step("point a webhook at the receiver", hookOn(pullEvents, { secret })) + .actor("author") + .step("remember the author", async (ctx) => { + author = ctx.actor.did; + }) + .pull(pullId, patchId, { ...branches, title: pullTitle }) + .step( + "the receiver got the new pull", + pullDelivered( + "created", + (ctx) => ctx.actor.did, + "open", + 1, + (ctx) => ctx.repo.heads[branches.from] + ) + ); + +openedPull("pull request webhooks follow a pull from opening to merging") + .actor("owner") + .open(page.pull(pullId)) + .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) + .step( + "the receiver got the close", + pullDelivered( + "closed", + (ctx) => ctx.actor.did, + "closed", + 1, + (ctx) => ctx.repo.heads[branches.from] + ) + ) + .changeState(pullId, "open", testid("changeState"), testid("requestClose")) + .step( + "the receiver got the reopen", + pullDelivered( + "reopened", + (ctx) => ctx.actor.did, + "open", + 1, + (ctx) => ctx.repo.heads[branches.from] + ) + ) + .use(merges) + .step( + "the receiver got the merge", + pullDelivered( + "merged", + (ctx) => ctx.actor.did, + "merged", + 1, + (ctx) => ctx.repo.heads[branches.from] + ) + ) + .step("nothing else arrived", nothingMore) + .step("take the webhook away", dropsHook); + +openedPull("resubmitting a pull delivers pull_request:resubmitted") + .actor("owner") + .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) + .mirrored() + .actor("author") + .open(page.pull(pullId)) + .click(testid("resubmit")) + .sends(resubmittedId, knot, (ctx) => ({ record: { ticket: rkeyOf(ctx, pullId) } }), "create") + .trace(resubmittedId, through(knot, hydrant, bobbin)) + .step( + "the receiver got the resubmit", + pullDelivered( + "resubmitted", + (ctx) => ctx.actor.did, + "open", + 2, + (ctx) => ctx.repo.heads[branches.from] + ) + ) + .step("nothing else arrived", nothingMore) + .step("take the webhook away", dropsHook); -- 2.54.0