import { expect } from "@playwright/test"; import { button, commitRow, type Ctx, link, mergeButton, not, pullId, target, testid, text, unique, where } from "../harness/flow"; import { spindle } from "../harness/services"; import { answerWith, branches, forkedAhead, merges, opensForkPull, reloadsUntil, resubmitsForkPatch, waitsForApproval, targetTookTheFeature, upstreamPull } from "../harness/shared"; import { delivers, dropsHook, hookOn, nothingMore } from "../harness/webhooks"; const forkTitle = unique("a fix from my fork"); const forkHead = (ctx: Ctx) => ctx.repo.heads[branches.from]; const upstreamDid = (ctx: Ctx) => ctx.upstream.did; const completed = testid("ticketStatePill", { state: "completed" }); const upstreamMerged = where( "the upstream's merged pulls", (ctx) => `${ctx.upstream.path}/pulls?state=completed` ); forkedAhead("merging a fork pull request moves the upstream's target to the fork's commit") .use(opensForkPull(forkTitle)) .actor("owner") .open(upstreamPull) .use(merges) .knotStored(pullId, "completed") .step( "the upstream's target branch took the fork's commit", targetTookTheFeature((ctx) => ctx.actor, { fromFork: true }) ) .checkpoint("the merge moved the upstream's target branch") .reload() .sees(completed) .open(upstreamMerged) .sees(link(forkTitle)); // the mirror misses a fork's head for well under a second, too short to hit on purpose const mergeCheckRoute = "**/xrpc/sh.tangled.git.temp2.mergeCheck*"; const mergeCommitRoute = "**/xrpc/sh.tangled.git.mergeCommit"; const lacksTheHead = (route: string) => (ctx: Ctx) => answerWith(route, 404, "RevisionNotFound", `commit not found: ${forkHead(ctx)}`)(ctx); const mirrorWait = testid("mirrorWait"); forkedAhead("a fork pull request waits to merge until the mirror has the fork's head") .use(opensForkPull(forkTitle)) .actor("owner") .open(upstreamPull) .step("the mirror doesn't have the fork's head yet", lacksTheHead(mergeCheckRoute)) .reload() .sees(testid("mirrorWait", { state: "waiting" })) .step("the merge waits for the mirror", (ctx) => expect(mergeButton.locate(ctx.page, ctx)).toBeDisabled() ) .step("the mirror catches up", (ctx) => ctx.page.unroute(mergeCheckRoute)) .step("the check tries again by itself", (ctx) => expect(mirrorWait.locate(ctx.page, ctx)).toHaveCount(0, { timeout: 10_000 }) ) .step("the merge loses the race once", lacksTheHead(mergeCommitRoute)) .click(mergeButton) .sees( testid("errorAlert"), "The mirror hasn't caught up with this pull yet, try again in a moment." ) .step("the mirror catches up again", (ctx) => ctx.page.unroute(mergeCommitRoute)) .use(merges) .knotStored(pullId, "completed") .step( "the upstream's target branch took the fork's commit", targetTookTheFeature((ctx) => ctx.actor, { fromFork: true }) ); const pushedFile = testid("diffFileName", { path: "e2e/more.txt" }); // no round labels here, so the commit rows tell the rounds apart forkedAhead("resubmitting a fork pull request after pushing to the fork's branch") .use(opensForkPull(forkTitle)) .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) .mirrored() .open(upstreamPull) .sees(not(pushedFile)) .sees(testid("resubmit")) .use(resubmitsForkPatch) .reload() .step("the new file shows up", reloadsUntil(pushedFile)) .sees(pushedFile) .sees(commitRow(2)) .sees(not(commitRow(3))); const approveCi = button("Approve CI"); forkedAhead("a fork pull request from someone who isn't a collaborator waits for approval", { ci: true }) .use(opensForkPull(forkTitle)) .step("the opened pull's pipeline is blocked", waitsForApproval(forkHead, upstreamDid)) .push({ "e2e/more.txt": "more\n" }, { branch: branches.from }) .mirrored() .open(upstreamPull) .use(resubmitsForkPatch) .reload() .changeState(pullId, "closed", testid("requestClose"), testid("changeState")) .changeState(pullId, "open", testid("changeState"), testid("requestClose")) .step( "still blocked after the resubmit, the close and the reopen", waitsForApproval(forkHead, upstreamDid) ) .sees(not(approveCi)) .actor("owner") .open(upstreamPull) .click(approveCi) .click(button("Approve CI run")) .sees(text("CI approved")) .step("spindle runs a workflow at the fork's head", (ctx) => spindle.finishedWorkflow(ctx.upstream.did, forkHead(ctx)) ); // the hook belongs to the upstream, but the flow's repo is the fork const onUpstream = (step: (ctx: Ctx) => Promise) => async (ctx: Ctx) => { const fork = ctx.repo; ctx.repo = ctx.upstream; try { await step(ctx); } finally { ctx.repo = fork; } }; const forks = new WeakMap(); const rememberFork = async (ctx: Ctx) => { forks.set(ctx, { did: ctx.repo.did, sha: forkHead(ctx), forker: ctx.actor.did }); }; const pullEvents = ["pull_request:created", "pull_request:merged"]; const forkPullDelivered = (action: string, state: string) => onUpstream( delivers(`pull_request:${action}`, "Tangled-Hook/pull_request", (body, ctx) => { const fork = forks.get(ctx); if (!fork) throw new Error("this step needs the fork remembered first"); expect(body).toMatchObject({ action, sender: { did: ctx.actor.did }, pull_request: { title: forkTitle, state, target_branch: branches.into, round_number: 1, owner: { did: fork.forker }, source: { repo: fork.did, branch: branches.from, sha: fork.sha } } }); }) ); forkedAhead("fork pull request webhooks name the fork as the source", { ci: true }) .step("remember the fork", rememberFork) .actor("owner") .step("spindle has a pipeline for the first push", (ctx) => spindle.hasPipeline(ctx.upstream.did, ctx.upstream.heads[branches.into], "push") ) .step( "point a webhook at the upstream", onUpstream(hookOn(pullEvents, { secret: "e2e-webhook-secret" })) ) .actor("forker") .use(opensForkPull(forkTitle)) .step("the receiver got the new pull", forkPullDelivered("created", "open")) .actor("owner") .open(upstreamPull) .use(merges) .step("the receiver got the merge", forkPullDelivered("merged", "merged")) .step("nothing else arrived", onUpstream(nothingMore)) .step("take the webhook away", onUpstream(dropsHook));