diff --git a/SKILL.md b/SKILL.md index 57ae974b..51bf3b59 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,10 +2,10 @@ name: github-delivery description: > GitHub issue/PR lifecycle skill: PRDs, triage/QA, research, linked/open-work - PRs, external tracker delivery, competing PR analysis, stacks, review/fix/ - simplify/security, conflicts, watch/status, supersede/overtake, merge/closure. - Watch MUST run scripts/ship-gate.mjs every wake. Default mode is read-only. - Not for local pre-PR debugging, non-GitHub planning, or skill authoring. + PRs, external tracker delivery, competing PR analysis, backports/ports, stacks, + review/fix/simplify/security, conflicts, watch/status, supersede/overtake, + merge/closure. Watch MUST run scripts/ship-gate.mjs every wake. Default mode is + read-only. Not for local pre-PR debugging, non-GitHub planning, or skill authoring. --- # GitHub Delivery @@ -46,6 +46,7 @@ simplify preparation first, then enter the merge workflow. | List my open PRs / what’s in review / repository open-work standup (read-only overview) | `references/open-work-status.md` | | Inspect or deliver external work item ENG-42 through the GitHub lifecycle | `references/work-item-delivery.md` | | Find competing / overlapping PR implementations (analysis only) | `references/consolidate-prs.md` | +| Backport / port PR #N to one or more target base branches | `references/multi-base-delivery.md` | | Full review on PR #N (or a list); babysit to green + verdict | `references/full-review-pr.md` | | Spec and Standards review on PR #N | `references/spec-standards-review.md` | | Simplify / clean up / deduplicate PR #N without behavior changes | `references/simplify-pr.md` | diff --git a/references/multi-base-delivery.md b/references/multi-base-delivery.md new file mode 100644 index 00000000..6a0c01ea --- /dev/null +++ b/references/multi-base-delivery.md @@ -0,0 +1,85 @@ + +Policy modules: +- policy-kernel +- mutation +- evidence +- git +- ci +- reviews +- publication +- releases +- stacks (when actual stack topology is detected) + + +# Multi-base delivery + +**Trigger:** “backport PR #42 to release/1.x”, “port PR #42 to release/1.x and release/2.x”, or equivalent requests to carry one verified source change to one or more other base branches. + +## Goal + +Create and track one independent port per requested target base without confusing parallel ports with stacked PR topology. + +A port is derived from one exact source PR head. Each target gets its own branch and pull request against that target base. A successful port to one base does not imply success on another base. + +## Authority boundary + +Porting authority permits only the requested implementation/publication work. It does not automatically grant merge authority for the source PR or any port PR. + +If the user explicitly requests the port PRs to be merged, each merge still uses the normal `references/merge-pr.md` current-head gates. A release/backport label, source-PR merge, tracker state, or provenance marker never substitutes for merge authority. + +## Planning + +1. Resolve the canonical repository and source PR. +2. Re-read the source PR and pin its exact current `headRefOid` before planning. Do not plan from a remembered SHA. +3. Resolve the requested target bases exactly. Reject the source base as a target. +4. Run `planMultiBaseDelivery(...)` to create one independent `parallel-port` entry for each target. +5. Treat all requested targets as required unless the user or repository policy explicitly marks some as optional. +6. Preserve the generated provenance marker in each port PR body. The marker binds repository, source PR, source head SHA, and target base. + +## Applying a port + +For each target independently: + +1. Create/reset only the dedicated generated port branch for that target. +2. Apply the source change onto the target base using the repository-appropriate port/backport mechanism. Resolve conflicts semantically; do not blindly choose source or target versions. +3. Run target-base-appropriate local verification. +4. Publish the branch through the normal `push_code` broker path. +5. Reuse the P0 exact duplicate/idempotency preflight before `create_pr`. +6. Create the PR against the exact target base and include the provenance marker in the body. +7. Run the normal review/status workflow for that port PR. + +Ports are independent. Do not make the `release/2.x` port PR target the `release/1.x` port branch merely because both came from the same source. That would create a stack and change merge semantics. + +## Verification and completion + +Use `summarizeMultiBaseDelivery(...)` against live port PR evidence. + +A port is recognized only when: + +- its target base matches exactly; and +- its body contains the exact provenance marker for the pinned source head and target base. + +Multiple matching PRs for one target are an ambiguity/error, not a reason to pick the newest one. + +Overall delivery is `complete` only when every required target is verified merged. Otherwise report the exact required targets that remain `missing` or `open`. + +If the source PR merges but required ports remain incomplete, do not mark the associated work item or delivery request fully done. + +## Failure rules + +Fail closed when: + +- source repository/PR/head identity is incomplete; +- the source head changes before the port plan is applied; +- a target base is invalid or missing; +- duplicate port PRs exist for one provenance identity; +- a port PR targets the wrong base; +- provenance is missing/stale; +- target verification fails; +- required port state cannot be read authoritatively. + +Report partial success per target. Do not hide a failed target behind successful sibling ports. + +## Provenance + +The multi-target delivery idea was informed by `OutThisLife/brooklyn-skills` delivery patterns (MIT, copyright Brooklyn Nicholson). GitHub Delivery implements ports as head-bound, independently gated PRs and deliberately keeps them distinct from stacked-PR topology. diff --git a/scripts/lib/delivery-workflow-profiles.mjs b/scripts/lib/delivery-workflow-profiles.mjs index f4ef179e..d43efd9c 100644 --- a/scripts/lib/delivery-workflow-profiles.mjs +++ b/scripts/lib/delivery-workflow-profiles.mjs @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { parsePolicyDependencies, @@ -113,6 +113,18 @@ const CONSOLIDATE_GRAPH = Object.freeze({ ...TERMINAL, }); +const MULTI_BASE_GRAPH = Object.freeze({ + ROUTE: ["PREFLIGHT"], + PREFLIGHT: ["PLAN", "DONE"], + PLAN: ["APPLY"], + APPLY: ["LOCAL_VERIFY"], + LOCAL_VERIFY: ["PUBLISH", "DONE"], + PUBLISH: ["VERIFY_PORTS"], + VERIFY_PORTS: ["FINAL_GATE", "DONE"], + FINAL_GATE: ["DONE"], + ...TERMINAL, +}); + const MERGE_GRAPH = Object.freeze({ ROUTE: ["PREFLIGHT"], PREFLIGHT: ["PREPARE", "DONE"], @@ -180,6 +192,7 @@ const PROFILE_DEFINITIONS = Object.freeze({ "open-work-status": { graph: OPEN_WORK_GRAPH, mutation: "read-only" }, "work-item-delivery": { graph: WORK_ITEM_GRAPH, mutation: "profile-dependent" }, "consolidate-prs": { graph: CONSOLIDATE_GRAPH, mutation: "read-only" }, + "multi-base-delivery": { graph: MULTI_BASE_GRAPH, mutation: "maintainer" }, "full-review-pr": { graph: REVIEW_GRAPH, mutation: "review" }, "spec-standards-review": { graph: REVIEW_GRAPH, mutation: "review" }, "simplify-pr": { graph: REVIEW_GRAPH, mutation: "maintainer" }, diff --git a/scripts/lib/multi-base-delivery.mjs b/scripts/lib/multi-base-delivery.mjs new file mode 100644 index 00000000..aab3492b --- /dev/null +++ b/scripts/lib/multi-base-delivery.mjs @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; + +function requiredText(value, name) { + const text = String(value ?? "").trim(); + if (!text) throw new Error(`${name}_required`); + return text; +} + +function positiveInteger(value, name) { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) throw new Error(`${name}_invalid`); + return number; +} + +function repoName(value) { + const text = requiredText(value, "repo"); + if (!/^[^/\s]+\/[^/\s]+$/.test(text)) throw new Error("repo_invalid"); + return text; +} + +function exactSha(value, name = "head") { + const text = requiredText(value, name).toLowerCase(); + if (!/^[0-9a-f]{40,64}$/.test(text)) throw new Error(`${name}_invalid`); + return text; +} + +function validRef(value, name) { + const ref = requiredText(value, name); + const components = ref.split("/"); + const invalidCharacter = ["~", "^", ":", "?", "*", "[", "\\"].some((character) => ref.includes(character)); + const invalidComponent = components.some((component) => + !component || component.startsWith(".") || component.endsWith(".lock"), + ); + if ( + /[\x00-\x20\x7f]/.test(ref) || invalidCharacter || invalidComponent || + ref === "@" || ref.startsWith("-") || ref.includes("..") || ref.includes("@{") || ref.endsWith(".") + ) { + throw new Error(`${name}_invalid`); + } + return ref; +} + +function slug(value) { + return String(value).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "target"; +} + +function shortHash(value) { + return createHash("sha256").update(String(value), "utf8").digest("hex").slice(0, 10); +} + +function portBranch(sourcePr, targetBase) { + return `github-delivery/port-${sourcePr}-to-${slug(targetBase)}-${shortHash(targetBase)}`; +} + +function markerPayload({ repository, sourcePr, sourceHeadSha, targetBase }) { + return `${repository}\u0000${sourcePr}\u0000${sourceHeadSha}\u0000${targetBase}`; +} + +export function portProvenanceMarker(input = {}) { + const repository = repoName(input.repository); + const sourcePr = positiveInteger(input.sourcePr, "source_pr"); + const sourceHeadSha = exactSha(input.sourceHeadSha, "source_head"); + const targetBase = validRef(input.targetBase, "target_base"); + const digest = createHash("sha256").update(markerPayload({ repository, sourcePr, sourceHeadSha, targetBase })).digest("hex"); + return ``; +} + +export function planMultiBaseDelivery({ repository, sourcePr, sourceHeadSha, sourceBase, targetBases = [], requiredBases = null } = {}) { + const repo = repoName(repository); + const pr = positiveInteger(sourcePr, "source_pr"); + const head = exactSha(sourceHeadSha, "source_head"); + const base = validRef(sourceBase, "source_base"); + const targets = [...new Set((targetBases || []).map((value) => validRef(value, "target_base")))]; + if (targets.length === 0) throw new Error("target_bases_required"); + if (targets.includes(base)) throw new Error(`target_base_matches_source:${base}`); + + const requiredSet = requiredBases === null + ? new Set(targets) + : new Set((requiredBases || []).map((value) => validRef(value, "required_base"))); + for (const required of requiredSet) { + if (!targets.includes(required)) throw new Error(`required_base_not_targeted:${required}`); + } + + const ports = targets.map((targetBase) => ({ + repository: repo, + sourcePr: pr, + sourceHeadSha: head, + sourceBase: base, + targetBase, + required: requiredSet.has(targetBase), + branch: portBranch(pr, targetBase), + provenanceMarker: portProvenanceMarker({ repository: repo, sourcePr: pr, sourceHeadSha: head, targetBase }), + topology: "parallel-port", + })); + + return { + schemaVersion: 1, + kind: "github-delivery/multi-base-plan", + repository: repo, + sourcePr: pr, + sourceHeadSha: head, + sourceBase: base, + ports, + }; +} + +export function verifyPortPullRequest(port, observed = {}) { + const number = positiveInteger(observed.number, "port_pr"); + const targetBase = requiredText(observed.base, "port_base"); + const body = String(observed.body ?? ""); + if (targetBase !== port.targetBase) { + return { state: "mismatch", number, targetBase, reason: `port_base_mismatch:${targetBase}` }; + } + if (!body.includes(port.provenanceMarker)) { + return { state: "mismatch", number, targetBase, reason: "port_provenance_missing" }; + } + return { + state: "verified", + number, + targetBase, + merged: observed.merged === true, + url: observed.url ? String(observed.url) : null, + }; +} + +export function summarizeMultiBaseDelivery({ plan, observedPullRequests = [] } = {}) { + if (plan?.kind !== "github-delivery/multi-base-plan") throw new Error("multi_base_plan_required"); + const observations = new Map(); + const invalid = []; + + for (const observed of observedPullRequests || []) { + const body = String(observed?.body ?? ""); + const matching = plan.ports.filter((port) => body.includes(port.provenanceMarker)); + if (matching.length === 0) continue; + if (matching.length > 1) { + invalid.push({ + number: Number(observed?.number) || null, + reason: "port_provenance_ambiguous", + targetBases: matching.map((port) => port.targetBase), + }); + continue; + } + + const port = matching[0]; + const verification = verifyPortPullRequest(port, observed); + if (verification.state !== "verified") { + invalid.push({ + number: verification.number, + reason: verification.reason, + expectedTargetBase: port.targetBase, + observedTargetBase: verification.targetBase, + }); + continue; + } + if (observations.has(port.targetBase)) throw new Error(`duplicate_port_pr:${port.targetBase}`); + observations.set(port.targetBase, verification); + } + + const ports = plan.ports.map((port) => { + const observed = observations.get(port.targetBase) || null; + return { + ...port, + observed, + state: !observed ? "missing" : observed.merged ? "merged" : "open", + }; + }); + const requiredIncomplete = ports.filter((port) => port.required && port.state !== "merged"); + return { + state: invalid.length > 0 ? "invalid" : requiredIncomplete.length === 0 ? "complete" : "incomplete", + ports, + invalid, + requiredIncomplete: requiredIncomplete.map((port) => port.targetBase), + }; +} diff --git a/scripts/lib/skill-router.mjs b/scripts/lib/skill-router.mjs index 86e6a835..aef7b7c3 100644 --- a/scripts/lib/skill-router.mjs +++ b/scripts/lib/skill-router.mjs @@ -30,6 +30,7 @@ const WORK_ITEM_STATUS_REQUEST = /\b(?:what(?:'s| is) left|status|where is|where const WORK_ITEM_DELIVERY_REQUEST = /\b(?:ship|deliver|work on|implement|fix|finish|complete|take)\b|\b(?:create|open)\b[\s\S]{0,80}\b(?:pr|pull request)\b/; const WORK_ITEM_PUBLICATION_REQUEST = /\b(?:ship|deliver)\b|\b(?:create|open)\b[\s\S]{0,80}\b(?:pr|pull request)\b/; const CONSOLIDATE_PR_REQUEST = /\b(?:consolidate|cluster|triage|competing|overlapping|duplicate)\b[\s\S]{0,120}\b(?:prs|pull requests)\b|\b(?:prs|pull requests)\b[\s\S]{0,120}\b(?:competing|overlapping|duplicates?)\b/; +const MULTI_BASE_REQUEST = /\b(?:backport|back-port|port)\b[\s\S]{0,180}\b(?:pr|pull request)\s*#?\d+\b|\b(?:pr|pull request)\s*#?\d+\b[\s\S]{0,180}\b(?:backport|back-port|port)\b/; const DELIVERY_NAME = /\bgithub[- ]?delivery\b/; const DELIVERY_UPDATE = /\b(update|upgrade)\b[\s\S]*\bgithub[- ]?delivery\b|\bgithub[- ]?delivery\b[\s\S]*\b(update|upgrade|latest stable release)\b/; const DELIVERY_CONFIG = /\b(set ?up|install|configure|configuration|settings?|protection mode|windows hello)\b[\s\S]*\bgithub[- ]?delivery\b|\bgithub[- ]?delivery\b[\s\S]*\b(set ?up|install|configure|configuration|settings?|protection mode|windows hello)\b/; @@ -49,6 +50,14 @@ function workItemDeliveryActions(text) { return [...new Set(actions)]; } +function multiBaseDeliveryActions(text) { + const actions = ["push_code", "create_pr"]; + if (hasExplicitMergeIntent(text)) { + actions.push("merge_pr", "post_comment", "post_issue_comment", "close_linked_issue"); + } + return actions; +} + function unquotedText(text) { return text.replace(/"[^"\n]*"|`[^`\n]*`|'[^'\n]*'/g, " "); } function mergeText(text) { return unquotedText(text).replace(MERGE_READY_PHRASE, ""); } @@ -104,6 +113,9 @@ export function routeShippingGithubPrompt(prompt) { if (CONSOLIDATE_PR_REQUEST.test(text) && !RESEARCH_ISSUE_REQUEST.test(text)) { return result("references/consolidate-prs.md", "read-only", []); } + if (MULTI_BASE_REQUEST.test(text)) { + return result("references/multi-base-delivery.md", "maintainer", multiBaseDeliveryActions(text)); + } if (isWorkItemRequest(text)) { const readOnly = WORK_ITEM_STATUS_REQUEST.test(text) && !WORK_ITEM_DELIVERY_REQUEST.test(text); return result( diff --git a/scripts/lib/workflow-mode.mjs b/scripts/lib/workflow-mode.mjs index e2ab501b..932d3f7e 100644 --- a/scripts/lib/workflow-mode.mjs +++ b/scripts/lib/workflow-mode.mjs @@ -9,6 +9,7 @@ const WORKFLOW_MUTATION_MODES = Object.freeze({ "references/fix-pr-bots.md": ["maintainer"], "references/full-review-pr.md": ["review", "maintainer"], "references/merge-pr.md": ["maintainer"], + "references/multi-base-delivery.md": ["maintainer"], "references/open-work-status.md": ["read-only"], "references/overtake-pr.md": ["maintainer"], "references/re-review-pr.md": ["review"], diff --git a/tests/unit/multi-base-delivery-routing.test.mjs b/tests/unit/multi-base-delivery-routing.test.mjs new file mode 100644 index 00000000..442ee591 --- /dev/null +++ b/tests/unit/multi-base-delivery-routing.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveDeliveryWorkflowProfile } from "../../scripts/lib/delivery-workflow-profiles.mjs"; +import { routeShippingGithubPrompt } from "../../scripts/lib/skill-router.mjs"; +import { validateWorkflowMutationMode } from "../../scripts/lib/workflow-mode.mjs"; + +test("routes a backport request to multi-base delivery with publication authority only", () => { + const route = routeShippingGithubPrompt("backport PR #42 to release/1.x"); + assert.equal(route.workflow, "references/multi-base-delivery.md"); + assert.equal(route.mutationMode, "maintainer"); + assert.deepEqual(route.explicitActions, ["push_code", "create_pr"]); +}); + +test("explicit merge wording adds merge authority for the port workflow", () => { + const route = routeShippingGithubPrompt("backport PR #42 to release/1.x and merge it"); + assert.equal(route.workflow, "references/multi-base-delivery.md"); + assert.ok(route.explicitActions.includes("push_code")); + assert.ok(route.explicitActions.includes("create_pr")); + assert.ok(route.explicitActions.includes("merge_pr")); +}); + +test("a merge discussion does not accidentally authorize port merges", () => { + const route = routeShippingGithubPrompt("backport PR #42 to release/1.x, but should we merge it?"); + assert.equal(route.workflow, "references/multi-base-delivery.md"); + assert.equal(route.explicitActions.includes("merge_pr"), false); +}); + +test("multi-base delivery is maintainer-only", () => { + assert.equal(validateWorkflowMutationMode({ workflow: "references/multi-base-delivery.md", mutationMode: "maintainer" }).valid, true); + assert.equal(validateWorkflowMutationMode({ workflow: "references/multi-base-delivery.md", mutationMode: "read-only" }).valid, false); + assert.equal(validateWorkflowMutationMode({ workflow: "references/multi-base-delivery.md", mutationMode: "autonomous" }).valid, false); +}); + +test("multi-base controller verifies ports before the final gate", () => { + const profile = resolveDeliveryWorkflowProfile("multi-base-delivery"); + assert.equal(profile.mutation, "maintainer"); + assert.deepEqual(profile.graph.PUBLISH, ["VERIFY_PORTS"]); + assert.deepEqual(profile.graph.VERIFY_PORTS, ["FINAL_GATE", "DONE"]); +}); diff --git a/tests/unit/multi-base-delivery.test.mjs b/tests/unit/multi-base-delivery.test.mjs new file mode 100644 index 00000000..c3a7c92b --- /dev/null +++ b/tests/unit/multi-base-delivery.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + planMultiBaseDelivery, + portProvenanceMarker, + summarizeMultiBaseDelivery, + verifyPortPullRequest, +} from "../../scripts/lib/multi-base-delivery.mjs"; + +const HEAD = "a".repeat(40); + +function plan(overrides = {}) { + return planMultiBaseDelivery({ + repository: "acme/widgets", + sourcePr: 42, + sourceHeadSha: HEAD, + sourceBase: "main", + targetBases: ["release/1.x", "release/2.x"], + ...overrides, + }); +} + +test("plans one independent port per target base", () => { + const result = plan(); + assert.deepEqual(result.ports.map((port) => port.targetBase), ["release/1.x", "release/2.x"]); + assert.ok(result.ports.every((port) => port.topology === "parallel-port")); + assert.notEqual(result.ports[0].branch, result.ports[1].branch); +}); + +test("different target refs remain branch-distinct even when their readable slugs collide", () => { + const result = plan({ targetBases: ["release/1.x", "release-1.x"] }); + assert.notEqual(result.ports[0].branch, result.ports[1].branch); + assert.match(result.ports[0].branch, /^github-delivery\/port-42-to-release-1\.x-[0-9a-f]{10}$/); + assert.match(result.ports[1].branch, /^github-delivery\/port-42-to-release-1\.x-[0-9a-f]{10}$/); +}); + +test("all targets are required by default, but optional targets can be declared", () => { + assert.ok(plan().ports.every((port) => port.required)); + const result = plan({ requiredBases: ["release/1.x"] }); + assert.equal(result.ports.find((port) => port.targetBase === "release/1.x").required, true); + assert.equal(result.ports.find((port) => port.targetBase === "release/2.x").required, false); +}); + +test("source base cannot be targeted as a port", () => { + assert.throws(() => plan({ targetBases: ["main"] }), /target_base_matches_source/); +}); + +test("invalid git-style target refs fail closed", () => { + for (const target of ["release//1.x", "release/../main", "release/.hidden", "release/build.lock", "release/bad~name", "-release", "@"]) { + assert.throws(() => plan({ targetBases: [target] }), /target_base_invalid/); + } +}); + +test("required bases must be part of the target set", () => { + assert.throws(() => plan({ requiredBases: ["release/3.x"] }), /required_base_not_targeted/); +}); + +test("provenance marker is stable and bound to source head and target base", () => { + const one = portProvenanceMarker({ repository: "acme/widgets", sourcePr: 42, sourceHeadSha: HEAD, targetBase: "release/1.x" }); + const two = portProvenanceMarker({ repository: "acme/widgets", sourcePr: 42, sourceHeadSha: HEAD, targetBase: "release/2.x" }); + const stale = portProvenanceMarker({ repository: "acme/widgets", sourcePr: 42, sourceHeadSha: "b".repeat(40), targetBase: "release/1.x" }); + assert.notEqual(one, two); + assert.notEqual(one, stale); + assert.equal(one, portProvenanceMarker({ repository: "acme/widgets", sourcePr: 42, sourceHeadSha: HEAD, targetBase: "release/1.x" })); +}); + +test("port verification requires exact target base and provenance", () => { + const port = plan().ports[0]; + assert.equal(verifyPortPullRequest(port, { number: 50, base: port.targetBase, body: port.provenanceMarker }).state, "verified"); + assert.equal(verifyPortPullRequest(port, { number: 50, base: "release/9.x", body: port.provenanceMarker }).state, "mismatch"); + assert.equal(verifyPortPullRequest(port, { number: 50, base: port.targetBase, body: "no marker" }).state, "mismatch"); +}); + +test("wrong-base provenance is surfaced as invalid instead of disappearing as missing", () => { + const delivery = plan({ targetBases: ["release/1.x"] }); + const port = delivery.ports[0]; + const result = summarizeMultiBaseDelivery({ + plan: delivery, + observedPullRequests: [ + { number: 50, base: "release/9.x", body: port.provenanceMarker, merged: false }, + ], + }); + + assert.equal(result.state, "invalid"); + assert.deepEqual(result.requiredIncomplete, ["release/1.x"]); + assert.deepEqual(result.invalid, [{ + number: 50, + reason: "port_base_mismatch:release/9.x", + expectedTargetBase: "release/1.x", + observedTargetBase: "release/9.x", + }]); +}); + +test("one PR carrying multiple port markers is invalid evidence", () => { + const delivery = plan(); + const result = summarizeMultiBaseDelivery({ + plan: delivery, + observedPullRequests: [{ + number: 50, + base: "release/1.x", + body: delivery.ports.map((port) => port.provenanceMarker).join("\n"), + merged: false, + }], + }); + + assert.equal(result.state, "invalid"); + assert.deepEqual(result.invalid[0], { + number: 50, + reason: "port_provenance_ambiguous", + targetBases: ["release/1.x", "release/2.x"], + }); +}); + +test("required ports keep delivery incomplete until each one is merged", () => { + const delivery = plan(); + const first = delivery.ports[0]; + const second = delivery.ports[1]; + + const incomplete = summarizeMultiBaseDelivery({ + plan: delivery, + observedPullRequests: [ + { number: 50, base: first.targetBase, body: first.provenanceMarker, merged: true }, + { number: 51, base: second.targetBase, body: second.provenanceMarker, merged: false }, + ], + }); + assert.equal(incomplete.state, "incomplete"); + assert.deepEqual(incomplete.invalid, []); + assert.deepEqual(incomplete.requiredIncomplete, ["release/2.x"]); + + const complete = summarizeMultiBaseDelivery({ + plan: delivery, + observedPullRequests: [ + { number: 50, base: first.targetBase, body: first.provenanceMarker, merged: true }, + { number: 51, base: second.targetBase, body: second.provenanceMarker, merged: true }, + ], + }); + assert.equal(complete.state, "complete"); + assert.deepEqual(complete.invalid, []); +}); + +test("duplicate port PRs for one target fail closed", () => { + const delivery = plan({ targetBases: ["release/1.x"] }); + const port = delivery.ports[0]; + assert.throws(() => summarizeMultiBaseDelivery({ + plan: delivery, + observedPullRequests: [ + { number: 50, base: port.targetBase, body: port.provenanceMarker, merged: false }, + { number: 51, base: port.targetBase, body: port.provenanceMarker, merged: false }, + ], + }), /duplicate_port_pr/); +});