From 8cb3d70b0ec3f5f445b2e41bb330bb58854ddfd5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:03:33 +0200 Subject: [PATCH] feat: add P1 open-PR consolidation --- SKILL.md | 16 +- references/consolidate-prs.md | 71 ++++++++ scripts/lib/delivery-workflow-profiles.mjs | 10 + scripts/lib/pr-consolidation.mjs | 182 +++++++++++++++++++ scripts/lib/skill-router.mjs | 7 +- scripts/lib/workflow-mode.mjs | 1 + tests/unit/pr-consolidation-routing.test.mjs | 34 ++++ tests/unit/pr-consolidation.test.mjs | 122 +++++++++++++ 8 files changed, 432 insertions(+), 11 deletions(-) create mode 100644 references/consolidate-prs.md create mode 100644 scripts/lib/pr-consolidation.mjs create mode 100644 tests/unit/pr-consolidation-routing.test.mjs create mode 100644 tests/unit/pr-consolidation.test.mjs diff --git a/SKILL.md b/SKILL.md index 2ce02d00..57ae974b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,16 +1,11 @@ --- name: github-delivery description: > - Primary skill for the complete GitHub issue and pull-request lifecycle: - create PRDs, break down and triage issues, run QA intake, prepare agent briefs - and refactor plans, research issues, create linked PRs, list repository open - work, deliver external tracker work items, manage stacked PRs, watch and make - PRs merge-ready, resolve conflicts, run full bug/security/spec review, simplify - safely, supersede obsolete PRs, take over unresponsive PRs, report status, - merge with thanks, and close linked issues. Prefer this over thin babysit/watcher - skills. Watch MUST run scripts/ship-gate.mjs every wake. Default mutation mode - is read-only. Do not use for local pre-PR debugging, non-GitHub product planning, - or skill authoring. + 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. --- # GitHub Delivery @@ -50,6 +45,7 @@ simplify preparation first, then enter the merge workflow. | Create PR for issue #N (bounded preflight → implement → pre-open bug/security gate); link + merge-ready | `references/create-pr-for-issue.md` | | 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` | | 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/consolidate-prs.md b/references/consolidate-prs.md new file mode 100644 index 00000000..4f649268 --- /dev/null +++ b/references/consolidate-prs.md @@ -0,0 +1,71 @@ + +Policy modules: +- policy-kernel +- evidence +- reviews +- publication + + +# Consolidate competing PRs + +**Trigger:** “triage competing PRs”, “find overlapping PR implementations”, “which PRs duplicate each other?”, “consolidate these PRs”, or equivalent requests to identify competing implementations. + +## Goal + +Identify PRs that may represent competing implementations without guessing a canonical winner or closing anything during analysis. + +This workflow is read-only. If the user later selects a canonical PR and authorizes superseding the others, delegate each network-visible change to `references/supersede-pr.md` and its existing mutation gates. + +## Evidence + +Collect complete PR identity and changed-file evidence for the requested repository/scope. Use `scripts/lib/pr-consolidation.mjs` to form candidate clusters. + +High-confidence competing-implementation evidence: + +- the same durable work-item identity plus substantial overlap of non-noise changed files on PRs targeting the same repository and base. + +Medium-confidence candidate evidence: + +- the same durable work-item identity without substantial implementation overlap; or +- substantial overlap of non-noise changed files on PRs targeting the same repository and base. + +A shared work-item key alone means the PRs are related. It does not prove one replaces the other. One ticket may intentionally ship through multiple complementary PRs. + +Do not treat shared README/lockfile/changelog changes alone as competing implementation evidence. Do not cluster across different bases; those may be legitimate ports/backports. + +Title similarity, author identity, branch-name similarity, and AI-generated semantic guesses are leads only. They are not sufficient to close or supersede a PR. + +## Canonical selection + +The analyser deliberately returns `canonicalPr: null` and `selectionRequired: true` for every candidate cluster. + +A consolidation plan is valid only when: + +1. the cluster was proven by the current analysis; +2. the canonical PR is explicitly selected and belongs to that cluster; +3. every PR proposed for supersede has direct substantial implementation-overlap evidence with the selected canonical PR; +4. current live PR identity still matches the analysed repository/base/head evidence. + +A transitive A-B-C cluster is not enough to let A supersede C when A and C lack direct supersede-grade evidence. + +The plan may then identify the other PRs as candidates for `delegate_supersede_pr`. It does not perform those mutations itself. + +## Failure rules + +Fail closed when PR enumeration is incomplete, repository/base identity is missing, the requested cluster is not present in the current analysis, canonical selection is absent/invalid, or direct supersede-grade evidence is missing for any proposed replacement. + +Do not infer that a PR is obsolete merely because another PR is newer, greener, authored by a maintainer, shares a tracker item, or has more reviews. + +## Output + +Report candidate clusters with: + +- PR numbers and links; +- confidence (`high` or `medium`); +- exact evidence that connected each pair; +- whether an edge is strong enough for supersede planning; +- `canonical: not selected` until the user or an already-authorized workflow provides one. + +## Provenance + +The clustering concept was informed by `OutThisLife/brooklyn-skills` `pr-triage` (MIT, copyright Brooklyn Nicholson). GitHub Delivery keeps analysis separate from its existing supersede mutation workflow and uses deterministic repository/base/work-item/file evidence rather than copying Brooklyn's workflow text. diff --git a/scripts/lib/delivery-workflow-profiles.mjs b/scripts/lib/delivery-workflow-profiles.mjs index b4ffd5e4..f4ef179e 100644 --- a/scripts/lib/delivery-workflow-profiles.mjs +++ b/scripts/lib/delivery-workflow-profiles.mjs @@ -104,6 +104,15 @@ const WORK_ITEM_GRAPH = Object.freeze({ ...TERMINAL, }); +const CONSOLIDATE_GRAPH = Object.freeze({ + ROUTE: ["PREFLIGHT"], + PREFLIGHT: ["COLLECT", "DONE"], + COLLECT: ["ANALYZE"], + ANALYZE: ["REPORT"], + REPORT: ["DONE"], + ...TERMINAL, +}); + const MERGE_GRAPH = Object.freeze({ ROUTE: ["PREFLIGHT"], PREFLIGHT: ["PREPARE", "DONE"], @@ -170,6 +179,7 @@ const PROFILE_DEFINITIONS = Object.freeze({ "create-pr-for-issue": { graph: CREATE_PR_GRAPH, mutation: "maintainer" }, "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" }, "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/pr-consolidation.mjs b/scripts/lib/pr-consolidation.mjs new file mode 100644 index 00000000..5a1c3c1e --- /dev/null +++ b/scripts/lib/pr-consolidation.mjs @@ -0,0 +1,182 @@ +const NOISE_PATH_RE = /(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|CHANGELOG\.md|README\.md)$/i; + +function positiveInteger(value, name) { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) throw new Error(`${name}_invalid`); + return number; +} + +function normalizedRepo(value, name) { + const text = String(value ?? "").trim(); + if (!/^[^/\s]+\/[^/\s]+$/.test(text)) throw new Error(`${name}_invalid`); + return text.toLowerCase(); +} + +function normalizeWorkItemKey(value) { + const text = String(value ?? "").trim().toUpperCase(); + return /^[A-Z][A-Z0-9]*-\d+$/.test(text) ? text : null; +} + +function normalizePullRequest(raw = {}) { + const number = positiveInteger(raw.number, "pr_number"); + const repository = normalizedRepo(raw.repository, "pr_repository"); + const base = String(raw.base ?? "").trim(); + const headRepository = normalizedRepo(raw.headRepository ?? raw.repository, "pr_head_repository"); + const head = String(raw.head ?? "").trim(); + if (!base) throw new Error("pr_base_required"); + if (!head) throw new Error("pr_head_required"); + const files = [...new Set((raw.files || []).map((entry) => String(entry).trim()).filter(Boolean))].sort(); + return { + number, + repository, + base, + headRepository, + head, + title: String(raw.title ?? ""), + workItemKey: normalizeWorkItemKey(raw.workItemKey), + files, + }; +} + +function overlap(left, right) { + const leftFiles = new Set(left.files.filter((path) => !NOISE_PATH_RE.test(path))); + const rightFiles = new Set(right.files.filter((path) => !NOISE_PATH_RE.test(path))); + const shared = [...leftFiles].filter((path) => rightFiles.has(path)); + const denominator = Math.min(leftFiles.size, rightFiles.size); + return { + shared, + ratio: denominator === 0 ? 0 : shared.length / denominator, + }; +} + +function substantialImplementationOverlap(files) { + return files.shared.length >= 2 && files.ratio >= 0.6; +} + +function pairEvidence(left, right) { + if (left.repository !== right.repository || left.base !== right.base) return null; + const files = overlap(left, right); + if (left.workItemKey && left.workItemKey === right.workItemKey) { + const supersedeGrade = substantialImplementationOverlap(files); + return { + kind: "same_work_item", + confidence: supersedeGrade ? "high" : "medium", + workItemKey: left.workItemKey, + sharedFiles: files.shared, + overlapRatio: files.ratio, + supersedeGrade, + }; + } + if (substantialImplementationOverlap(files)) { + return { + kind: "changed_file_overlap", + confidence: "medium", + workItemKey: null, + sharedFiles: files.shared, + overlapRatio: files.ratio, + supersedeGrade: true, + }; + } + return null; +} + +function connectedComponents(pulls, edges) { + const adjacency = new Map(pulls.map((pull) => [pull.number, new Set()])); + for (const edge of edges) { + adjacency.get(edge.left).add(edge.right); + adjacency.get(edge.right).add(edge.left); + } + const visited = new Set(); + const components = []; + for (const pull of pulls) { + if (visited.has(pull.number) || adjacency.get(pull.number).size === 0) continue; + const stack = [pull.number]; + const numbers = []; + visited.add(pull.number); + while (stack.length) { + const number = stack.pop(); + numbers.push(number); + for (const next of adjacency.get(number)) { + if (visited.has(next)) continue; + visited.add(next); + stack.push(next); + } + } + components.push(numbers.sort((a, b) => a - b)); + } + return components; +} + +function supersedeEvidenceBetween(cluster, left, right) { + return cluster.evidence.some((edge) => + edge.evidence.supersedeGrade === true && + ((edge.left === left && edge.right === right) || + (edge.left === right && edge.right === left)), + ); +} + +export function analyzePrConsolidation(rawPulls = []) { + const pulls = rawPulls.map(normalizePullRequest); + const numbers = new Set(); + for (const pull of pulls) { + if (numbers.has(pull.number)) throw new Error(`duplicate_pr_number:${pull.number}`); + numbers.add(pull.number); + } + + const edges = []; + for (let i = 0; i < pulls.length; i += 1) { + for (let j = i + 1; j < pulls.length; j += 1) { + const evidence = pairEvidence(pulls[i], pulls[j]); + if (!evidence) continue; + edges.push({ left: pulls[i].number, right: pulls[j].number, evidence }); + } + } + + const clusters = connectedComponents(pulls, edges).map((members) => { + const memberSet = new Set(members); + const clusterEdges = edges.filter((edge) => memberSet.has(edge.left) && memberSet.has(edge.right)); + const confidence = clusterEdges.some((edge) => edge.evidence.confidence === "high") ? "high" : "medium"; + return { + members, + confidence, + evidence: clusterEdges, + canonicalPr: null, + selectionRequired: true, + }; + }); + + return { + state: clusters.length ? "candidates" : "none", + pulls, + clusters, + }; +} + +export function planPrConsolidation({ analysis, clusterMembers, canonicalPr } = {}) { + if (analysis?.state !== "candidates") throw new Error("consolidation_candidates_required"); + const members = [...new Set((clusterMembers || []).map((value) => positiveInteger(value, "cluster_pr")))].sort((a, b) => a - b); + if (members.length < 2) throw new Error("consolidation_cluster_too_small"); + const canonical = positiveInteger(canonicalPr, "canonical_pr"); + if (!members.includes(canonical)) throw new Error("canonical_pr_not_in_cluster"); + const matchingCluster = analysis.clusters.find((cluster) => + cluster.members.length === members.length && cluster.members.every((number, index) => number === members[index]), + ); + if (!matchingCluster) throw new Error("consolidation_cluster_not_proven"); + + const superseded = members.filter((number) => number !== canonical); + const unproven = superseded.filter((number) => !supersedeEvidenceBetween(matchingCluster, canonical, number)); + if (unproven.length > 0) { + throw new Error(`canonical_pr_missing_supersede_evidence:${canonical}:${unproven.join(",")}`); + } + + return { + state: "planned", + canonicalPr: canonical, + supersede: superseded.map((number) => ({ + pr: number, + action: "delegate_supersede_pr", + canonicalPr: canonical, + })), + evidence: matchingCluster.evidence, + }; +} diff --git a/scripts/lib/skill-router.mjs b/scripts/lib/skill-router.mjs index b68448f1..86e6a835 100644 --- a/scripts/lib/skill-router.mjs +++ b/scripts/lib/skill-router.mjs @@ -23,11 +23,13 @@ const FOLLOW_UP_ISSUE_REQUEST = /\bfollow[- ]?up\s+(?:issue|ticket)\b/; const CREATE_PR_FOR_ISSUE_REQUEST = /\b(?:create|open)\b[\s\S]*\b(?:pr|pull request)\b[\s\S]*\b(?:issue|#\d+)\b/; const IMPLEMENT_ISSUE_REQUEST = /\b(?:implement|fix|address|solve|resolve)\b[\s\S]{0,180}\b(?:issue|#\d+)\b|\b(?:issue|#\d+)\b[\s\S]{0,180}\b(?:implement|fix|address|solve|resolve)\b/; const CREATE_PR_REQUEST = /\b(?:create|open|make)\b[\s\S]{0,120}\b(?:pr|pull request)\b/; +const RESEARCH_ISSUE_REQUEST = /\b(?:research|investigate)\b[\s\S]*\b(?:issue|issues|#\d+)\b/; const OPEN_WORK_REQUEST = /\b(?:what do i have open|what(?:'s| is) in review|show (?:me )?my open (?:prs|pull requests)|list (?:me )?my open (?:prs|pull requests)|open (?:pr|pull request) standup|open[- ]work standup|my open work)\b/; const WORK_ITEM_KEY = /\b[A-Z][A-Z0-9]*-\d+\b/i; const WORK_ITEM_STATUS_REQUEST = /\b(?:what(?:'s| is) left|status|where is|where's|inspect|check|show me)\b/; 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 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/; @@ -99,6 +101,9 @@ export function routeShippingGithubPrompt(prompt) { if (isOpenWorkRequest(text)) { return result("references/open-work-status.md", "read-only", []); } + if (CONSOLIDATE_PR_REQUEST.test(text) && !RESEARCH_ISSUE_REQUEST.test(text)) { + return result("references/consolidate-prs.md", "read-only", []); + } if (isWorkItemRequest(text)) { const readOnly = WORK_ITEM_STATUS_REQUEST.test(text) && !WORK_ITEM_DELIVERY_REQUEST.test(text); return result( @@ -135,7 +140,7 @@ export function routeShippingGithubPrompt(prompt) { return result("references/create-pr-for-issue.md", "maintainer"); } if (CREATE_PR_REQUEST.test(text) && !PR_REFERENCE.test(text)) return result("references/create-pr-from-local-work.md", "maintainer", ["push_code", "create_pr"]); - if (/\b(research|investigate)\b[\s\S]*\b(issue|issues|#\d+)\b/.test(text)) return result("references/research-issue.md", "review"); + if (RESEARCH_ISSUE_REQUEST.test(text)) return result("references/research-issue.md", "review"); const issueCreationAction = issueCreationActionForPrompt(text); if (issueCreationAction) return result("references/issue-workflows.md", "maintainer", [issueCreationAction]); diff --git a/scripts/lib/workflow-mode.mjs b/scripts/lib/workflow-mode.mjs index c203f286..e2ab501b 100644 --- a/scripts/lib/workflow-mode.mjs +++ b/scripts/lib/workflow-mode.mjs @@ -3,6 +3,7 @@ // is a workflow violation, never a publication excuse. const WORKFLOW_MUTATION_MODES = Object.freeze({ + "references/consolidate-prs.md": ["read-only"], "references/create-pr-for-issue.md": ["maintainer"], "references/create-pr-from-local-work.md": ["maintainer"], "references/fix-pr-bots.md": ["maintainer"], diff --git a/tests/unit/pr-consolidation-routing.test.mjs b/tests/unit/pr-consolidation-routing.test.mjs new file mode 100644 index 00000000..e61821cf --- /dev/null +++ b/tests/unit/pr-consolidation-routing.test.mjs @@ -0,0 +1,34 @@ +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 competing PR analysis read-only", () => { + const route = routeShippingGithubPrompt("triage the competing PRs in this repo"); + assert.equal(route.workflow, "references/consolidate-prs.md"); + assert.equal(route.mutationMode, "read-only"); + assert.deepEqual(route.explicitActions, []); +}); + +test("issue research keeps precedence when PR duplicates are only research evidence", () => { + const route = routeShippingGithubPrompt( + "Research issues #88 and #91 on the latest development branch — still real bugs? already fixed? open PRs? duplicates? priority; comment on each issue", + ); + assert.equal(route.workflow, "references/research-issue.md"); + assert.equal(route.mutationMode, "review"); + assert.deepEqual(route.explicitActions, []); +}); + +test("consolidation route never grants maintainer mutation mode", () => { + assert.equal(validateWorkflowMutationMode({ workflow: "references/consolidate-prs.md", mutationMode: "read-only" }).valid, true); + assert.equal(validateWorkflowMutationMode({ workflow: "references/consolidate-prs.md", mutationMode: "maintainer" }).valid, false); +}); + +test("consolidation controller terminates after analysis/report", () => { + const profile = resolveDeliveryWorkflowProfile("consolidate-prs"); + assert.equal(profile.mutation, "read-only"); + assert.deepEqual(profile.graph.ANALYZE, ["REPORT"]); + assert.deepEqual(profile.graph.REPORT, ["DONE"]); +}); diff --git a/tests/unit/pr-consolidation.test.mjs b/tests/unit/pr-consolidation.test.mjs new file mode 100644 index 00000000..02591ca3 --- /dev/null +++ b/tests/unit/pr-consolidation.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { analyzePrConsolidation, planPrConsolidation } from "../../scripts/lib/pr-consolidation.mjs"; + +function pr(number, overrides = {}) { + return { + number, + repository: "acme/widgets", + base: "main", + headRepository: "acme/widgets", + head: `feature/${number}`, + title: `PR ${number}`, + files: [`src/${number}.mjs`], + ...overrides, + }; +} + +test("same work-item identity creates a candidate cluster but not automatic supersede confidence", () => { + const result = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42" }), + pr(11, { workItemKey: "eng-42" }), + ]); + assert.equal(result.state, "candidates"); + assert.deepEqual(result.clusters[0].members, [10, 11]); + assert.equal(result.clusters[0].confidence, "medium"); + assert.equal(result.clusters[0].evidence[0].evidence.supersedeGrade, false); + assert.equal(result.clusters[0].canonicalPr, null); + assert.equal(result.clusters[0].selectionRequired, true); +}); + +test("same work item plus substantial implementation overlap is high-confidence evidence", () => { + const result = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/b.mjs"] }), + pr(11, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/b.mjs", "src/c.mjs"] }), + ]); + assert.equal(result.clusters[0].confidence, "high"); + assert.equal(result.clusters[0].evidence[0].evidence.supersedeGrade, true); +}); + +test("uses substantial non-noise file overlap only as medium-confidence evidence", () => { + const result = analyzePrConsolidation([ + pr(10, { files: ["src/a.mjs", "src/b.mjs", "README.md"] }), + pr(11, { files: ["src/a.mjs", "src/b.mjs", "src/c.mjs", "README.md"] }), + ]); + assert.equal(result.clusters[0].confidence, "medium"); + assert.equal(result.clusters[0].evidence[0].evidence.kind, "changed_file_overlap"); + assert.equal(result.clusters[0].evidence[0].evidence.supersedeGrade, true); +}); + +test("does not cluster PRs across different bases", () => { + const result = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42", base: "main" }), + pr(11, { workItemKey: "ENG-42", base: "release/1.x" }), + ]); + assert.equal(result.state, "none"); +}); + +test("does not treat lockfile/readme overlap alone as competing implementation evidence", () => { + const result = analyzePrConsolidation([ + pr(10, { files: ["README.md", "package-lock.json"] }), + pr(11, { files: ["README.md", "package-lock.json"] }), + ]); + assert.equal(result.state, "none"); +}); + +test("same work-item identity alone cannot produce a supersede plan", () => { + const analysis = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42" }), + pr(11, { workItemKey: "ENG-42" }), + ]); + assert.throws( + () => planPrConsolidation({ analysis, clusterMembers: [10, 11], canonicalPr: 11 }), + /canonical_pr_missing_supersede_evidence:11:10/, + ); +}); + +test("a consolidation plan requires an explicit canonical PR with direct implementation overlap", () => { + const analysis = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/b.mjs"] }), + pr(11, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/b.mjs", "src/c.mjs"] }), + ]); + const plan = planPrConsolidation({ analysis, clusterMembers: [10, 11], canonicalPr: 11 }); + assert.equal(plan.canonicalPr, 11); + assert.deepEqual(plan.supersede, [{ pr: 10, action: "delegate_supersede_pr", canonicalPr: 11 }]); + assert.throws( + () => planPrConsolidation({ analysis, clusterMembers: [10, 11], canonicalPr: 12 }), + /canonical_pr_not_in_cluster/, + ); +}); + +test("canonical selection cannot supersede a member connected only transitively", () => { + const analysis = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/common.mjs"] }), + pr(11, { workItemKey: "ENG-42", files: ["src/a.mjs", "src/common.mjs", "src/b.mjs", "src/c.mjs"] }), + pr(12, { files: ["src/b.mjs", "src/c.mjs"] }), + ]); + assert.deepEqual(analysis.clusters[0].members, [10, 11, 12]); + assert.throws( + () => planPrConsolidation({ analysis, clusterMembers: [10, 11, 12], canonicalPr: 10 }), + /canonical_pr_missing_supersede_evidence:10:12/, + ); + + const directlyConnectedPlan = planPrConsolidation({ + analysis, + clusterMembers: [10, 11, 12], + canonicalPr: 11, + }); + assert.deepEqual(directlyConnectedPlan.supersede.map((entry) => entry.pr), [10, 12]); +}); + +test("cannot manufacture a consolidation cluster that analysis did not prove", () => { + const analysis = analyzePrConsolidation([ + pr(10, { workItemKey: "ENG-42" }), + pr(11, { workItemKey: "ENG-42" }), + pr(12), + ]); + assert.throws( + () => planPrConsolidation({ analysis, clusterMembers: [10, 12], canonicalPr: 10 }), + /consolidation_cluster_not_proven/, + ); +});