diff --git a/.github/pr-stack.json b/.github/pr-stack.json index 87b593db18f..24124b08996 100644 --- a/.github/pr-stack.json +++ b/.github/pr-stack.json @@ -16,5 +16,11 @@ "number": 2, "branch": "fork/changes" } + ], + "integrationOverlays": [ + { + "number": 10, + "branch": "t3-discord/f7d37879-desktop-deeplinks" + } ] } diff --git a/.github/workflows/managed-pr-draft-lock.yml b/.github/workflows/managed-pr-draft-lock.yml new file mode 100644 index 00000000000..0ed1a8e5a9c --- /dev/null +++ b/.github/workflows/managed-pr-draft-lock.yml @@ -0,0 +1,35 @@ +name: Managed PR draft lock + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + keep-draft: + name: Keep managed PR draft + runs-on: ubuntu-24.04 + steps: + - name: Restore draft state without changing CI status + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + run: | + manifest="$( + gh api \ + -H 'Accept: application/vnd.github.raw+json' \ + "repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/changes" + )" + if ! jq -e --argjson number "${PR_NUMBER}" \ + '([.pullRequests[], .integrationOverlays[]] | any(.number == $number))' \ + <<<"${manifest}" >/dev/null; then + exit 0 + fi + if [[ "${IS_DRAFT}" != "true" ]]; then + gh pr ready "${PR_NUMBER}" --undo --repo "${REPOSITORY}" + fi diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml index 5d7b3ba583a..035f121a581 100644 --- a/.github/workflows/rebase-pr-stack.yml +++ b/.github/workflows/rebase-pr-stack.yml @@ -1,6 +1,9 @@ name: Rebase fork PR stack on: + pull_request: + types: [opened, reopened, synchronize, converted_to_draft] + branches: [fork/changes] push: branches: - fork/tim @@ -20,8 +23,37 @@ permissions: actions: write jobs: + classify: + name: Classify stack event + runs-on: ubuntu-24.04 + outputs: + run: ${{ steps.classify.outputs.run }} + steps: + - uses: actions/checkout@v6 + with: + ref: fork/changes + fetch-depth: 1 + - id: classify + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [[ "${EVENT_NAME}" != "pull_request" ]]; then + echo "run=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + if jq -e --argjson number "${PR_NUMBER}" \ + '.integrationOverlays | any(.number == $number)' \ + .github/pr-stack.json >/dev/null; then + echo "run=true" >> "${GITHUB_OUTPUT}" + else + echo "run=false" >> "${GITHUB_OUTPUT}" + fi + rebase: name: Rebase and dispatch integration CI + needs: classify + if: needs.classify.outputs.run == 'true' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -55,6 +87,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: node scripts/rebase-pr-stack.ts sync --push + - name: Compose registered integration overlays + run: node scripts/compose-integration-overlays.ts + - name: Dispatch integration CI env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index a8b6e3751e1..4ffa21efb0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,11 @@ branches. contains selected open upstream PRs that we run before upstream accepts them, one provenance commit per source PR. The permanent `fork/changes` PR is based on `fork/candidates`, contains only our private layer, remains open, and is the GitHub/T3 default branch. +- Long-lived upstreamable features may be registered as `integrationOverlays`. They remain parallel + draft PRs based on `fork/changes`; `fork/integration` composes them in manifest order. Never merge + a registered overlay directly. Update its branch, or use + `pnpm fork:stack overlay-start ` and target the child PR at the overlay branch. + Draft state blocks merging while normal green CI remains meaningful. - Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork only after being reviewed and merged into `fork/changes`. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 8e579b4714d..12fe77968eb 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -8,8 +8,9 @@ pingdotgg/t3code:main └── fork/tim selected Tim Smart PRs └── fork/candidates selected open upstream PRs └── fork/changes our private changes - ├── feature PRs - └── fork/integration tested and deployed tip + ├── ordinary feature PRs + ├── registered draft overlays + └── fork/integration changes + overlays, tested/deployed ``` `main` mirrors `pingdotgg/t3code:main`. `fork/tim` is a linear provenance layer with one commit per @@ -17,7 +18,32 @@ selected Tim Smart PR and a permanently open PR against `main`. `fork/candidates upstream-provenance layer with one commit per selected open upstream PR and a permanently open PR against `fork/tim`. `fork/changes` is the GitHub default branch and canonical private layer, with a permanently open PR against `fork/candidates`. -`fork/integration` is generated from both reviewed layers and is used by running instances. +`fork/integration` is generated from the reviewed layers plus registered integration overlays and +is used by running instances. + +## Long-lived integration overlays + +An upstreamable feature may remain as an open PR instead of being merged into `fork/changes`. +Register it under `integrationOverlays` in `.github/pr-stack.json`. Every overlay remains a +**parallel draft PR based on `fork/changes`**; overlays are never based on each other. The stack +workflow rebases overlays when `fork/changes` moves and composes their commits, in manifest order, +only in `fork/integration`. + +Draft state is the merge lock. Normal Fork CI continues to run and can remain green, so health and +merge permission remain separate signals. A trusted workflow automatically returns managed PRs +(#1, #27, #2, and registered overlays) to draft if they are accidentally marked ready. + +```sh +pnpm fork:stack overlay-add 10 +pnpm fork:stack overlay-start 10 feature/deep-link-follow-up +pnpm fork:stack overlay-promote 10 upstream/desktop-deep-links +``` + +To change an overlay, commit directly to its branch or create a child PR with the overlay branch as +its base and merge the child into the overlay PR. Do not put the same change into `fork/changes`. +Landing an overlay is deliberate: remove its manifest entry in the same reviewed change that lands +the implementation in `fork/changes`, then verify that the resulting `fork/integration` tree is +unchanged. ## Updating from upstream diff --git a/scripts/compose-integration-overlays.test.ts b/scripts/compose-integration-overlays.test.ts new file mode 100644 index 00000000000..f0d74273151 --- /dev/null +++ b/scripts/compose-integration-overlays.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { overlayCommitList } from "./compose-integration-overlays.ts"; + +describe("integration overlay composition", () => { + it("keeps overlay commits in oldest-first rev-list order", () => { + expect(overlayCommitList("oldest\nmiddle\nnewest\n")).toEqual(["oldest", "middle", "newest"]); + }); + + it("handles an empty rev-list", () => { + expect(overlayCommitList("")).toEqual([]); + }); +}); diff --git a/scripts/compose-integration-overlays.ts b/scripts/compose-integration-overlays.ts new file mode 100644 index 00000000000..02c00e0865e --- /dev/null +++ b/scripts/compose-integration-overlays.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { readManifest, StackError } from "./rebase-pr-stack.ts"; + +function git(cwd: string, args: ReadonlyArray): string { + const result = NodeChildProcess.spawnSync("git", [...args], { + cwd, + encoding: "utf8", + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" }, + }); + if (result.status !== 0) { + throw new StackError(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +export function overlayCommitList(revListOutput: string): ReadonlyArray { + return revListOutput + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +export function composeIntegration(sourceRoot = process.cwd(), push = true): string { + const manifest = readManifest(sourceRoot); + const originUrl = git(sourceRoot, ["remote", "get-url", "origin"]); + const workDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "compose-overlays-")); + const repoDir = NodePath.join(workDir, "repo"); + NodeFS.mkdirSync(repoDir); + try { + git(repoDir, ["init", "--quiet"]); + git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); + git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); + git(repoDir, ["config", "commit.gpgsign", "false"]); + git(repoDir, ["remote", "add", "origin", originUrl]); + const branches = [ + manifest.forkChangesBranch, + manifest.integrationBranch, + ...manifest.integrationOverlays.map(({ branch }) => branch), + ]; + git(repoDir, [ + "fetch", + "--quiet", + "--no-tags", + "origin", + ...branches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), + ]); + const base = git(repoDir, ["rev-parse", `origin/${manifest.forkChangesBranch}`]); + const previous = git(repoDir, ["rev-parse", `origin/${manifest.integrationBranch}`]); + git(repoDir, ["checkout", "--quiet", "--detach", base]); + for (const overlay of manifest.integrationOverlays) { + const tip = git(repoDir, ["rev-parse", `origin/${overlay.branch}`]); + const ancestor = NodeChildProcess.spawnSync( + "git", + ["merge-base", "--is-ancestor", base, tip], + { cwd: repoDir, encoding: "utf8" }, + ); + if (ancestor.status !== 0) { + throw new StackError( + `Overlay PR #${overlay.number} (${overlay.branch}) is not based on current ${manifest.forkChangesBranch}.`, + ); + } + const commits = overlayCommitList( + git(repoDir, ["rev-list", "--reverse", "--no-merges", `${base}..${tip}`]), + ); + if (commits.length === 0) { + throw new StackError( + `Overlay PR #${overlay.number} has no commits above ${manifest.forkChangesBranch}.`, + ); + } + git(repoDir, ["cherry-pick", ...commits]); + } + const next = git(repoDir, ["rev-parse", "HEAD"]); + if (push && next !== previous) { + git(repoDir, [ + "push", + `--force-with-lease=refs/heads/${manifest.integrationBranch}:${previous}`, + "origin", + `${next}:refs/heads/${manifest.integrationBranch}`, + ]); + } + return next; + } finally { + NodeFS.rmSync(workDir, { recursive: true, force: true }); + } +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; + +if (isMain) { + const push = !process.argv.includes("--dry-run"); + try { + const tip = composeIntegration(process.cwd(), push); + console.log(`${push ? "Updated" : "Would update"} integration to ${tip}.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index c355651b138..5f311057303 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -14,10 +14,12 @@ import { planFeatureBranchUpdate, planLocalSyncWithRemote, registerPullRequest, + registerIntegrationOverlay, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, unregisterTopPullRequest, + unregisterIntegrationOverlay, } from "./fork-stack.ts"; const manifest: StackManifest = { @@ -26,6 +28,7 @@ const manifest: StackManifest = { forkChangesBranch: "fork/changes", integrationBranch: "fork/integration", pullRequests: [], + integrationOverlays: [], }; describe("fork stack helpers", () => { @@ -251,4 +254,34 @@ describe("fork stack helpers", () => { ]); expect(() => unregisterTopPullRequest(stacked, 201)).toThrow(/Only the top PR/); }); + + it("registers only draft overlays based on fork/changes", () => { + const next = registerIntegrationOverlay(manifest, { + number: 10, + state: "OPEN", + headRefName: "feature/deep-links", + baseRefName: "fork/changes", + isDraft: true, + }); + expect(next.integrationOverlays).toEqual([{ number: 10, branch: "feature/deep-links" }]); + expect(() => + registerIntegrationOverlay(manifest, { + number: 11, + state: "OPEN", + headRefName: "feature/ready", + baseRefName: "fork/changes", + isDraft: false, + }), + ).toThrow(/must be a draft/); + expect(() => + registerIntegrationOverlay(manifest, { + number: 12, + state: "OPEN", + headRefName: "feature/wrong-base", + baseRefName: "main", + isDraft: true, + }), + ).toThrow(/expected fork\/changes/); + expect(unregisterIntegrationOverlay(next, 10).integrationOverlays).toEqual([]); + }); }); diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index 6b503355044..290cc1f37c8 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -35,6 +35,7 @@ interface PullRequestView { readonly state: string; readonly headRefName: string; readonly baseRefName: string; + readonly isDraft?: boolean; } interface PullRequestCommitsView { @@ -197,6 +198,52 @@ export function unregisterTopPullRequest(manifest: StackManifest, number: number return { ...manifest, pullRequests: manifest.pullRequests.slice(0, -1) }; } +export function registerIntegrationOverlay( + manifest: StackManifest, + pullRequest: PullRequestView, +): StackManifest { + if (pullRequest.state.toLowerCase() !== "open") { + throw new StackError(`PR #${pullRequest.number} is not open.`); + } + if (!pullRequest.isDraft) { + throw new StackError(`Integration overlay PR #${pullRequest.number} must be a draft.`); + } + if (pullRequest.baseRefName !== manifest.forkChangesBranch) { + throw new StackError( + `Integration overlay PR #${pullRequest.number} is based on ${pullRequest.baseRefName}, expected ${manifest.forkChangesBranch}.`, + ); + } + const managed = [...manifest.pullRequests, ...manifest.integrationOverlays]; + if (managed.some(({ number }) => number === pullRequest.number)) { + throw new StackError(`PR #${pullRequest.number} is already managed.`); + } + if (managed.some(({ branch }) => branch === pullRequest.headRefName)) { + throw new StackError(`Branch ${pullRequest.headRefName} is already managed.`); + } + return { + ...manifest, + integrationOverlays: [ + ...manifest.integrationOverlays, + { number: pullRequest.number, branch: pullRequest.headRefName }, + ], + }; +} + +export function unregisterIntegrationOverlay( + manifest: StackManifest, + number: number, +): StackManifest { + if (!manifest.integrationOverlays.some((overlay) => overlay.number === number)) { + throw new StackError(`PR #${number} is not a registered integration overlay.`); + } + return { + ...manifest, + integrationOverlays: manifest.integrationOverlays.filter( + (overlay) => overlay.number !== number, + ), + }; +} + function writeManifest(sourceRoot: string, manifest: StackManifest): void { NodeFS.writeFileSync( NodePath.join(sourceRoot, MANIFEST_PATH), @@ -215,7 +262,7 @@ function readPullRequest(sourceRoot: string, number: number): PullRequestView { "--repo", FORK_REPOSITORY, "--json", - "number,state,headRefName,baseRefName", + "number,state,headRefName,baseRefName,isDraft", ], sourceRoot, ); @@ -557,6 +604,10 @@ function usage(): string { node scripts/fork-stack.ts promote node scripts/fork-stack.ts adopt node scripts/fork-stack.ts demote + node scripts/fork-stack.ts overlay-add + node scripts/fork-stack.ts overlay-start + node scripts/fork-stack.ts overlay-remove + node scripts/fork-stack.ts overlay-promote node scripts/fork-stack.ts register node scripts/fork-stack.ts unregister node scripts/fork-stack.ts find @@ -578,6 +629,20 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-start" && value && extra.length === 1) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); + if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); + ensureClean(sourceRoot); + run("git", ["fetch", "origin", overlay.branch], sourceRoot); + run("git", ["switch", "-c", extra[0]!, `origin/${overlay.branch}`], sourceRoot); + console.log( + `Created ${extra[0]} from overlay PR #${number}. Open its PR against ${overlay.branch}; merge that child into #${number}.`, + ); + return; + } + if (command === "update") { const tokens = [value, ...extra].filter((token): token is string => token !== undefined); let push = false; @@ -688,6 +753,65 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-promote" && value && extra.length === 1) { + const number = Number(value); + const upstreamBranch = extra[0]!; + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); + if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); + ensureClean(sourceRoot); + const pullRequest = parsePossiblyColoredJson( + run( + "gh", + [ + "pr", + "view", + String(number), + "--repo", + FORK_REPOSITORY, + "--json", + "state,baseRefName,commits", + ], + sourceRoot, + ), + ) as PullRequestCommitsView; + if ( + pullRequest.state.toLowerCase() !== "open" || + pullRequest.baseRefName !== manifest.forkChangesBranch || + pullRequest.commits.length === 0 + ) { + throw new StackError(`Overlay PR #${number} is not an open non-empty fork overlay.`); + } + run( + "git", + [ + "fetch", + manifest.upstreamRemote, + `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, + ], + sourceRoot, + ); + run( + "git", + ["fetch", "origin", `+refs/pull/${number}/head:refs/remotes/origin/pr/${number}`], + sourceRoot, + ); + run( + "git", + ["switch", "-c", upstreamBranch, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], + sourceRoot, + ); + run( + "git", + ["cherry-pick", "--no-commit", ...pullRequest.commits.map(({ oid }) => oid)], + sourceRoot, + ); + console.log( + `Projected open overlay PR #${number} onto ${upstreamBranch}. Remove fork-only assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, + ); + return; + } + if (command === "adopt" && value && extra.length === 1) { const upstreamBranch = value; const privateBranch = extra[0]!; @@ -790,6 +914,25 @@ async function main(args: ReadonlyArray): Promise { return; } + if (command === "overlay-add" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + writeManifest( + sourceRoot, + registerIntegrationOverlay(manifest, readPullRequest(sourceRoot, number)), + ); + console.log(`Registered draft PR #${number} as an integration overlay.`); + return; + } + + if (command === "overlay-remove" && value && extra.length === 0) { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); + writeManifest(sourceRoot, unregisterIntegrationOverlay(manifest, number)); + console.log(`Removed integration overlay PR #${number} from the manifest.`); + return; + } + if ((command === "find" || command === "find-upstream") && value && extra.length === 0) { const repository = command === "find-upstream" ? "pingdotgg/t3code" : FORK_REPOSITORY; const output = run( @@ -824,6 +967,7 @@ async function main(args: ReadonlyArray): Promise { integrationBranch: manifest.integrationBranch, nextBaseBranch: stackParentBranch(manifest), pullRequests: rows, + integrationOverlays: manifest.integrationOverlays, }, undefined, 2, diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index bc868d7849a..9170d61a539 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -129,6 +129,7 @@ function createFixture(options: FixtureOptions = {}): Fixture { { number: 5, branch: "feature/pr-5" }, { number: 6, branch: "feature/pr-6" }, ], + integrationOverlays: [], }; write( NodePath.join(work, ".github", "pr-stack.json"), @@ -419,6 +420,7 @@ describe("rebase-pr-stack", () => { headBranch: branch, headOwner: "patroza", baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + isDraft: true, }), ); @@ -441,6 +443,7 @@ describe("rebase-pr-stack", () => { headBranch: branch, headOwner: "patroza", baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, + isDraft: true, }), ); assert.doesNotThrow(() => @@ -452,6 +455,7 @@ describe("rebase-pr-stack", () => { headBranch: "feature/parallel", headOwner: "patroza", baseBranch: "fork/changes", + isDraft: true, }, ]), ); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index d1c2a53874b..fb1f6f41a1c 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -70,6 +70,7 @@ export interface StackManifest { readonly forkChangesBranch: string; readonly integrationBranch: string; readonly pullRequests: ReadonlyArray; + readonly integrationOverlays: ReadonlyArray; } export interface PullRequestSnapshot { @@ -78,6 +79,7 @@ export interface PullRequestSnapshot { readonly headBranch: string; readonly headOwner: string; readonly baseBranch: string; + readonly isDraft: boolean; } interface RebaseOperation { @@ -264,8 +266,14 @@ export function parseManifest(source: string): StackManifest { throw new StackError("The PR stack manifest is not valid JSON.", { cause }); } assertObject(value, "The PR stack manifest"); - const { upstreamRemote, upstreamBranch, forkChangesBranch, integrationBranch, pullRequests } = - value; + const { + upstreamRemote, + upstreamBranch, + forkChangesBranch, + integrationBranch, + pullRequests, + integrationOverlays = [], + } = value; if ( typeof upstreamRemote !== "string" || upstreamRemote.length === 0 || @@ -275,7 +283,8 @@ export function parseManifest(source: string): StackManifest { forkChangesBranch.length === 0 || typeof integrationBranch !== "string" || integrationBranch.length === 0 || - !Array.isArray(pullRequests) + !Array.isArray(pullRequests) || + !Array.isArray(integrationOverlays) ) { throw new StackError("The PR stack manifest has missing or invalid fields."); } @@ -292,10 +301,23 @@ export function parseManifest(source: string): StackManifest { } return { number: Number(entry.number), branch: entry.branch }; }); + const parsedIntegrationOverlays = integrationOverlays.map((entry, index) => { + assertObject(entry, `integrationOverlays[${index}]`); + if ( + !Number.isSafeInteger(entry.number) || + Number(entry.number) <= 0 || + typeof entry.branch !== "string" || + entry.branch.length === 0 + ) { + throw new StackError(`integrationOverlays[${index}] has an invalid number or branch.`); + } + return { number: Number(entry.number), branch: entry.branch }; + }); - const numbers = new Set(parsedPullRequests.map(({ number }) => number)); - const branches = new Set(parsedPullRequests.map(({ branch }) => branch)); - if (numbers.size !== parsedPullRequests.length || branches.size !== parsedPullRequests.length) { + const managed = [...parsedPullRequests, ...parsedIntegrationOverlays]; + const numbers = new Set(managed.map(({ number }) => number)); + const branches = new Set(managed.map(({ branch }) => branch)); + if (numbers.size !== managed.length || branches.size !== managed.length) { throw new StackError("The PR stack manifest contains duplicate PR numbers or branches."); } if (branches.has(integrationBranch)) { @@ -313,6 +335,7 @@ export function parseManifest(source: string): StackManifest { forkChangesBranch, integrationBranch, pullRequests: parsedPullRequests, + integrationOverlays: parsedIntegrationOverlays, }; } @@ -338,6 +361,9 @@ export function validatePullRequestSnapshots( if (!actual || actual.state !== "open") { throw new StackError(`Manifest PR #${expected.number} is not open.`); } + if (!actual.isDraft) { + throw new StackError(`Managed PR #${expected.number} must remain a draft.`); + } if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { throw new StackError( `PR #${expected.number} is owned by ${actual.headOwner}, expected ${EXPECTED_REPOSITORY.split("/")[0]}.`, @@ -355,6 +381,28 @@ export function validatePullRequestSnapshots( ); } } + for (const expected of manifest.integrationOverlays) { + const actual = pullRequests.find(({ number }) => number === expected.number); + if (!actual || actual.state !== "open") { + throw new StackError(`Integration overlay PR #${expected.number} is not open.`); + } + if (!actual.isDraft) { + throw new StackError(`Integration overlay PR #${expected.number} must remain a draft.`); + } + if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { + throw new StackError(`Integration overlay PR #${expected.number} is not owned by this fork.`); + } + if (actual.headBranch !== expected.branch) { + throw new StackError( + `Integration overlay PR #${expected.number} uses ${actual.headBranch}, expected ${expected.branch}.`, + ); + } + if (actual.baseBranch !== manifest.forkChangesBranch) { + throw new StackError( + `Integration overlay PR #${expected.number} is based on ${actual.baseBranch}, expected ${manifest.forkChangesBranch}.`, + ); + } + } } interface GitHubPullResponse { @@ -366,6 +414,7 @@ interface GitHubPullResponse { readonly repo?: { readonly full_name?: unknown } | null; } | null; readonly base?: { readonly ref?: unknown } | null; + readonly draft?: unknown; } function githubToken(): string { @@ -410,7 +459,7 @@ export async function fetchPullRequestSnapshots( for (const response of openResponses) { if (typeof response.number === "number") byNumber.set(response.number, response); } - for (const { number } of manifest.pullRequests) { + for (const { number } of [...manifest.pullRequests, ...manifest.integrationOverlays]) { if (!byNumber.has(number)) { const value = await githubRequest(`/repos/${EXPECTED_REPOSITORY}/pulls/${number}`); assertObject(value, `GitHub PR #${number}`); @@ -425,12 +474,14 @@ export async function fetchPullRequestSnapshots( const headOwner = response.head?.user?.login; const headRepository = response.head?.repo?.full_name; const baseBranch = response.base?.ref; + const isDraft = response.draft; if ( typeof number !== "number" || typeof state !== "string" || typeof headBranch !== "string" || typeof headOwner !== "string" || - typeof baseBranch !== "string" + typeof baseBranch !== "string" || + typeof isDraft !== "boolean" ) { throw new StackError("GitHub returned an invalid pull request record."); } @@ -441,9 +492,10 @@ export async function fetchPullRequestSnapshots( headBranch, headOwner: typeof headRepository === "string" ? headRepository : headOwner, baseBranch, + isDraft, }; } - return { number, state, headBranch, headOwner, baseBranch }; + return { number, state, headBranch, headOwner, baseBranch, isDraft }; }); }