Skip to content
2,270 changes: 2,270 additions & 0 deletions docs/post-pr-gate-spec.md

Large diffs are not rendered by default.

2,271 changes: 2,271 additions & 0 deletions docs/superpowers/plans/2026-05-22-post-pr-gate.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions e2e/helpers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,27 @@ export async function openPR(
branch: string,
title: string,
body = "",
options?: { draft?: boolean },
): Promise<{ number: number; url: string }> {
const { data } = await octokit.pulls.create({
...ownerRepo,
head: branch,
base: "main",
title,
body,
...(options?.draft !== undefined ? { draft: options.draft } : {}),
});
return { number: data.number, url: data.html_url };
}

export async function reopenPR(prNumber: number): Promise<void> {
await octokit.pulls.update({
...ownerRepo,
pull_number: prNumber,
state: "open",
});
}

export async function getPRFiles(
prNumber: number,
): Promise<Array<{ filename: string; status: string }>> {
Expand Down Expand Up @@ -197,3 +207,20 @@ export async function deleteFile(
// File doesn't exist, nothing to delete
}
}

export async function listCheckRuns(
headSha: string,
): Promise<Array<{ id: number; name: string; status: string; conclusion: string | null }>> {
const { data } = await octokit.checks.listForRef({ ...ownerRepo, ref: headSha });
return data.check_runs.map((c) => ({
id: c.id,
name: c.name,
status: c.status,
conclusion: c.conclusion ?? null,
}));
}

export async function getPRHeadSha(prNumber: number): Promise<string> {
const { data } = await octokit.pulls.get({ ...ownerRepo, pull_number: prNumber });
return data.head.sha;
}
46 changes: 46 additions & 0 deletions e2e/tier2/us20-gate-pr-title-pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";
import { waitFor } from "../helpers/wait.js";

describe("US-20: post-pr-gate pr-title-format pass", () => {
const ticketKey = `AWT-${Date.now()}-pass`;
const branchName = `blazebot/${ticketKey.toLowerCase()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("marks the pr-title-format check as success", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "x", "feat: seed");
const pr = await openPR(branchName, "feat: add new feature", "smoke");
prNumber = pr.number;

const sha = await getPRHeadSha(pr.number);
const checks = await waitFor(
async () => {
const runs = await listCheckRuns(sha);
const titleCheck = runs.find((r) => r.name === "blazebot / pr-title-format");
return titleCheck?.status === "completed" ? runs : null;
},
{
description: "completed pr-title-format success check",
timeoutMs: 120_000,
intervalMs: 5_000,
},
);

const titleCheck = checks.find((r) => r.name === "blazebot / pr-title-format");
expect(titleCheck?.conclusion).toBe("success");
});
});
46 changes: 46 additions & 0 deletions e2e/tier2/us21-gate-pr-title-fail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";
import { waitFor } from "../helpers/wait.js";

describe("US-21: post-pr-gate pr-title-format — fail", () => {
const ticketKey = `AWT-${Date.now()}-fail`;
const branchName = `blazebot/${ticketKey.toLowerCase()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("marks the pr-title-format check as failure for a non-conventional title", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "x", "feat: seed");
const pr = await openPR(branchName, "just doing stuff", "smoke");
prNumber = pr.number;

const sha = await getPRHeadSha(pr.number);
const checks = await waitFor(
async () => {
const runs = await listCheckRuns(sha);
const c = runs.find((r) => r.name === "blazebot / pr-title-format");
return c?.status === "completed" ? runs : null;
},
{
description: "completed pr-title-format failure check",
timeoutMs: 120_000,
intervalMs: 5_000,
},
);

const titleCheck = checks.find((r) => r.name === "blazebot / pr-title-format");
expect(titleCheck?.conclusion).toBe("failure");
});
});
34 changes: 34 additions & 0 deletions e2e/tier2/us22-gate-skips-non-bot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";

describe("US-22: post-pr-gate skips non-blazebot branches", () => {
const branchName = `manual/test-${Date.now()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("does not create blazebot check runs for a non-bot branch", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, "gate-fixtures/manual.md", "x", "chore: seed");
const pr = await openPR(branchName, "feat: manual change", "smoke");
prNumber = pr.number;

const sha = await getPRHeadSha(pr.number);
await new Promise((r) => setTimeout(r, 30_000));

const runs = await listCheckRuns(sha);
const blazebotChecks = runs.filter((r) => r.name.startsWith("blazebot / "));
expect(blazebotChecks).toHaveLength(0);
});
});
87 changes: 87 additions & 0 deletions e2e/tier2/us23-gate-force-push-cancel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";
import { waitFor } from "../helpers/wait.js";

describe("US-23: post-pr-gate cancels previous run on force-push", () => {
const ticketKey = `AWT-${Date.now()}-force`;
const branchName = `blazebot/${ticketKey.toLowerCase()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("cancels old check runs when a new commit is pushed", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "first", "feat: seed");
const pr = await openPR(branchName, "feat: add thing", "smoke");
prNumber = pr.number;

const firstSha = await getPRHeadSha(pr.number);

await waitFor(
async () => {
const runs = await listCheckRuns(firstSha);
return runs.some((r) => r.name === "blazebot / pr-title-format") ? runs : null;
},
{
description: "first pr-title-format check run",
timeoutMs: 60_000,
intervalMs: 3_000,
},
);

await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "second", "feat: update");

const newSha = await waitFor(
async () => {
const sha = await getPRHeadSha(pr.number);
return sha !== firstSha ? sha : null;
},
{
description: "PR head SHA to change after push",
timeoutMs: 30_000,
intervalMs: 2_000,
},
);

const oldRuns = await waitFor(
async () => {
const runs = await listCheckRuns(firstSha);
const check = runs.find((r) => r.name === "blazebot / pr-title-format");
return check?.conclusion === "cancelled" ? runs : null;
},
{
description: "old pr-title-format check to be cancelled",
timeoutMs: 60_000,
intervalMs: 3_000,
},
);
expect(oldRuns).toBeTruthy();

const newRuns = await waitFor(
async () => {
const runs = await listCheckRuns(newSha);
const check = runs.find((r) => r.name === "blazebot / pr-title-format");
return check?.status === "completed" ? runs : null;
},
{
description: "new pr-title-format check to complete",
timeoutMs: 120_000,
intervalMs: 5_000,
},
);

const newCheck = newRuns.find((r) => r.name === "blazebot / pr-title-format");
expect(newCheck?.conclusion).toBe("success");
});
});
56 changes: 56 additions & 0 deletions e2e/tier2/us24-gate-reopened-same-sha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
reopenPR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";
import { waitFor } from "../helpers/wait.js";

describe("US-24: post-pr-gate ignores reopened with same SHA", () => {
const ticketKey = `AWT-${Date.now()}-reopen`;
const branchName = `blazebot/${ticketKey.toLowerCase()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("does not create a second pr-title-format check run on reopen", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "x", "feat: seed");
const pr = await openPR(branchName, "feat: add thing", "smoke");
prNumber = pr.number;
const sha = await getPRHeadSha(pr.number);

await waitFor(
async () => {
const runs = await listCheckRuns(sha);
const check = runs.find((r) => r.name === "blazebot / pr-title-format");
return check?.status === "completed" ? runs : null;
},
{
description: "initial pr-title-format check to complete",
timeoutMs: 120_000,
intervalMs: 5_000,
},
);

const beforeCount = (await listCheckRuns(sha))
.filter((r) => r.name === "blazebot / pr-title-format").length;
expect(beforeCount).toBe(1);

await closePR(pr.number);
await reopenPR(pr.number);
await new Promise((r) => setTimeout(r, 30_000));

const afterCount = (await listCheckRuns(sha))
.filter((r) => r.name === "blazebot / pr-title-format").length;
expect(afterCount).toBe(1);
});
});
49 changes: 49 additions & 0 deletions e2e/tier2/us25-gate-onfailure-cascade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterAll, describe, expect, it } from "vitest";
import {
createBranch,
createOrUpdateFile,
openPR,
closePR,
deleteBranch,
listCheckRuns,
getPRHeadSha,
} from "../helpers/github.js";
import { waitFor } from "../helpers/wait.js";

describe("US-25: post-pr-gate cascades remaining steps to cancelled on hard failure", () => {
const ticketKey = `AWT-${Date.now()}-cascade`;
const branchName = `blazebot/${ticketKey.toLowerCase()}`;
let prNumber: number | undefined;

afterAll(async () => {
if (prNumber) await closePR(prNumber);
await deleteBranch(branchName);
});

it("marks the second step as cancelled when the first fails with onFailure: fail", async () => {
await createBranch(branchName, "main");
await createOrUpdateFile(branchName, `gate-fixtures/${ticketKey}.md`, "x", "feat: seed");
// Title does NOT match the strict pattern → first step fails → second is cancelled.
const pr = await openPR(branchName, "chore: bump deps", "smoke");
prNumber = pr.number;

const sha = await getPRHeadSha(pr.number);
const runs = await waitFor(
async () => {
const r = await listCheckRuns(sha);
const second = r.find((c) => c.name === "blazebot / pr-title-format-permissive");
return second?.status === "completed" ? r : null;
},
{
description: "completed pr-title-format-permissive cascade check",
timeoutMs: 120_000,
intervalMs: 5_000,
},
);

const strict = runs.find((r) => r.name === "blazebot / pr-title-format-strict");
const permissive = runs.find((r) => r.name === "blazebot / pr-title-format-permissive");
expect(strict?.conclusion).toBe("failure");
expect(permissive?.conclusion).toBe("cancelled");
});
});
Loading
Loading