From 184c4e3788d1fc3e52d343c5e1b659c32ed2e16c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 18:41:22 +0100 Subject: [PATCH] ci: add duplicate PR cleanup workflow --- .github/workflows/duplicate-after-merge.yml | 59 ++++ docs/ci.md | 13 + scripts/close-duplicate-prs-after-merge.mjs | 303 ++++++++++++++++++ .../close-duplicate-prs-after-merge.test.ts | 242 ++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 .github/workflows/duplicate-after-merge.yml create mode 100644 scripts/close-duplicate-prs-after-merge.mjs create mode 100644 test/scripts/close-duplicate-prs-after-merge.test.ts diff --git a/.github/workflows/duplicate-after-merge.yml b/.github/workflows/duplicate-after-merge.yml new file mode 100644 index 00000000000..ad9e14446c5 --- /dev/null +++ b/.github/workflows/duplicate-after-merge.yml @@ -0,0 +1,59 @@ +name: Duplicate PRs After Merge + +on: + workflow_dispatch: + inputs: + landed_pr: + description: "Merged PR number that supersedes the duplicates" + required: true + type: string + duplicate_prs: + description: "Comma or whitespace separated duplicate PR numbers to close" + required: true + type: string + apply: + description: "When true, label/comment/close; otherwise dry-run only" + required: true + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: duplicate-after-merge-${{ github.event.inputs.landed_pr }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + GH_TOKEN: ${{ github.token }} + +jobs: + close-duplicates: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Close confirmed duplicates + env: + APPLY: ${{ inputs.apply }} + DUPLICATE_PRS: ${{ inputs.duplicate_prs }} + LANDED_PR: ${{ inputs.landed_pr }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + args=( + --repo "$REPO" + --landed-pr "$LANDED_PR" + --duplicates "$DUPLICATE_PRS" + ) + + if [[ "$APPLY" == "true" ]]; then + args+=(--apply) + fi + + node scripts/close-duplicate-prs-after-merge.mjs "${args[@]}" diff --git a/docs/ci.md b/docs/ci.md index bee4aca8e6c..997fde96171 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -19,6 +19,19 @@ Telegram lane as parallel jobs. The live jobs use the `qa-live-shared` environment, and the Telegram lane uses Convex leases. `OpenClaw Release Checks` also runs the same QA Lab lanes before release approval. +The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for +post-land duplicate cleanup. It defaults to dry-run and only closes explicitly +listed PRs when `apply=true`. Before mutating GitHub, it verifies that the +landed PR is merged and that each duplicate has either a shared referenced issue +or overlapping changed hunks. + +```bash +gh workflow run duplicate-after-merge.yml \ + -f landed_pr=70532 \ + -f duplicate_prs='70530,70592' \ + -f apply=true +``` + ## Job Overview | Job | Purpose | When it runs | diff --git a/scripts/close-duplicate-prs-after-merge.mjs b/scripts/close-duplicate-prs-after-merge.mjs new file mode 100644 index 00000000000..bab96a39605 --- /dev/null +++ b/scripts/close-duplicate-prs-after-merge.mjs @@ -0,0 +1,303 @@ +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_LABELS = ["duplicate", "close:duplicate", "dedupe:child"]; + +function usage() { + return `Usage: node scripts/close-duplicate-prs-after-merge.mjs --landed-pr --duplicates [--repo owner/repo] [--apply] + +Closes explicit duplicate PRs after a landed PR, after verifying the landed PR is merged and +each duplicate has either a shared referenced issue or overlapping changed hunks. Defaults to dry-run.`; +} + +export function parsePrNumberList(value) { + return [ + ...new Set( + String(value ?? "") + .split(/[\s,]+/u) + .map((part) => part.trim().replace(/^#/u, "")) + .filter(Boolean) + .map((part) => { + if (!/^\d+$/u.test(part)) { + throw new Error(`Invalid PR number: ${part}`); + } + return Number(part); + }), + ), + ]; +} + +export function parseArgs(argv, env = process.env) { + const args = { + apply: false, + duplicates: [], + labels: DEFAULT_LABELS, + landedPr: undefined, + repo: env.GITHUB_REPOSITORY || "openclaw/openclaw", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = () => { + index += 1; + if (index >= argv.length) { + throw new Error(`Missing value for ${arg}`); + } + return argv[index]; + }; + + if (arg === "--apply") { + args.apply = true; + } else if (arg === "--dry-run") { + args.apply = false; + } else if (arg === "--repo") { + args.repo = next(); + } else if (arg === "--landed-pr") { + args.landedPr = parsePrNumberList(next())[0]; + } else if (arg === "--duplicates") { + args.duplicates = parsePrNumberList(next()); + } else if (arg === "--labels") { + args.labels = next() + .split(/[\s,]+/u) + .map((label) => label.trim()) + .filter(Boolean); + } else if (arg === "--help" || arg === "-h") { + args.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!args.help && !args.landedPr) { + throw new Error("--landed-pr is required"); + } + if (!args.help && args.duplicates.length === 0) { + throw new Error("--duplicates is required"); + } + + return args; +} + +function ghJson(args, runGh) { + return JSON.parse(runGh(args)); +} + +function defaultRunGh(args, options = {}) { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: options.input ? ["pipe", "pipe", "inherit"] : ["ignore", "pipe", "inherit"], + ...(options.input ? { input: options.input } : {}), + }); +} + +function issueRefsFromPr(pr) { + const refs = new Set(); + for (const issue of pr.closingIssuesReferences ?? []) { + if (typeof issue?.number === "number") { + refs.add(issue.number); + } + } + + const text = `${pr.title ?? ""}\n${pr.body ?? ""}`; + for (const match of text.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/giu)) { + refs.add(Number(match[1])); + } + return refs; +} + +function intersectSets(left, right) { + return [...left].filter((value) => right.has(value)); +} + +export function parseUnifiedDiffRanges(diffText) { + const ranges = new Map(); + let currentPath = null; + + for (const line of String(diffText ?? "").split("\n")) { + const pathMatch = /^diff --git a\/.+ b\/(.+)$/u.exec(line); + if (pathMatch) { + currentPath = pathMatch[1]; + if (!ranges.has(currentPath)) { + ranges.set(currentPath, []); + } + continue; + } + + const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/u.exec(line); + if (!hunkMatch || !currentPath) { + continue; + } + const start = Number(hunkMatch[1]); + const length = hunkMatch[2] === undefined ? 1 : Number(hunkMatch[2]); + const end = Math.max(start, start + Math.max(length, 1) - 1); + ranges.get(currentPath).push({ start, end }); + } + + return ranges; +} + +export function hasOverlappingHunks(leftRanges, rightRanges) { + for (const [path, left] of leftRanges) { + const right = rightRanges.get(path) ?? []; + for (const leftRange of left) { + for (const rightRange of right) { + if (leftRange.start <= rightRange.end && rightRange.start <= leftRange.end) { + return true; + } + } + } + } + return false; +} + +function filePaths(pr) { + return new Set((pr.files ?? []).map((file) => file.path).filter(Boolean)); +} + +function formatEvidence(evidence) { + const parts = []; + if (evidence.sharedIssues.length > 0) { + parts.push(`shared issue(s): ${evidence.sharedIssues.map((issue) => `#${issue}`).join(", ")}`); + } + if (evidence.overlappingHunks) { + parts.push("overlapping changed hunks"); + } + if (evidence.sharedFiles.length > 0) { + parts.push(`shared file(s): ${evidence.sharedFiles.join(", ")}`); + } + return parts.join("; "); +} + +function buildCloseComment({ candidate, evidence, landed, repo }) { + const [owner, name] = repo.split("/"); + const commit = landed.mergeCommit?.oid; + const commitRef = + commit && owner && name + ? `https://github.com/${owner}/${name}/commit/${commit}` + : "the merge commit"; + return `Thanks for the fix. This is now covered by the landed #${landed.number} / commit ${commitRef}. + +Evidence: ${formatEvidence(evidence)}. + +Closing #${candidate.number} as a duplicate.`; +} + +export function buildDuplicateClosePlan({ candidates, diffs, landed, repo }) { + if (landed.state !== "MERGED" || !landed.mergedAt) { + throw new Error(`#${landed.number} is not merged`); + } + + const landedIssues = issueRefsFromPr(landed); + const landedFiles = filePaths(landed); + const landedRanges = parseUnifiedDiffRanges(diffs.get(landed.number) ?? ""); + + return candidates.map((candidate) => { + if (candidate.state !== "OPEN") { + return { + action: "skip", + candidate, + reason: `#${candidate.number} is ${candidate.state}`, + }; + } + + const sharedFiles = intersectSets(landedFiles, filePaths(candidate)).toSorted((left, right) => + left.localeCompare(right), + ); + const sharedIssues = intersectSets(landedIssues, issueRefsFromPr(candidate)).toSorted( + (left, right) => left - right, + ); + const overlappingHunks = hasOverlappingHunks( + landedRanges, + parseUnifiedDiffRanges(diffs.get(candidate.number) ?? ""), + ); + const evidence = { overlappingHunks, sharedFiles, sharedIssues }; + + if (sharedIssues.length === 0 && !overlappingHunks) { + throw new Error( + `Refusing to close #${candidate.number}: no shared issue and no overlapping changed hunks with #${landed.number}`, + ); + } + + return { + action: "close", + candidate, + comment: buildCloseComment({ candidate, evidence, landed, repo }), + evidence, + }; + }); +} + +function loadPr(repo, number, runGh) { + return ghJson( + [ + "pr", + "view", + String(number), + "--repo", + repo, + "--json", + "number,title,body,state,mergedAt,mergeCommit,closingIssuesReferences,files,url", + ], + runGh, + ); +} + +function loadDiff(repo, number, runGh) { + return runGh(["pr", "diff", String(number), "--repo", repo, "--color=never"]); +} + +export function applyClosePlan({ labels = DEFAULT_LABELS, plan, repo, runGh }) { + for (const item of plan) { + if (item.action !== "close") { + continue; + } + const number = String(item.candidate.number); + const labelArgs = labels.flatMap((label) => ["--add-label", label]); + if (labelArgs.length > 0) { + runGh(["pr", "edit", number, "--repo", repo, ...labelArgs]); + } + runGh(["pr", "comment", number, "--repo", repo, "--body", item.comment]); + runGh(["pr", "close", number, "--repo", repo]); + } +} + +export function runDuplicateCloseWorkflow(args, runGh = defaultRunGh) { + const landed = loadPr(args.repo, args.landedPr, runGh); + const candidates = args.duplicates.map((number) => loadPr(args.repo, number, runGh)); + const diffs = new Map([[landed.number, loadDiff(args.repo, landed.number, runGh)]]); + for (const candidate of candidates) { + diffs.set(candidate.number, loadDiff(args.repo, candidate.number, runGh)); + } + + const plan = buildDuplicateClosePlan({ candidates, diffs, landed, repo: args.repo }); + for (const item of plan) { + if (item.action === "skip") { + console.log(`skip #${item.candidate.number}: ${item.reason}`); + } else { + console.log(`close #${item.candidate.number}: ${formatEvidence(item.evidence)}`); + } + } + + if (!args.apply) { + console.log("dry-run only; pass --apply to label/comment/close duplicate PRs"); + return plan; + } + + applyClosePlan({ labels: args.labels, plan, repo: args.repo, runGh }); + return plan; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + process.exit(0); + } + runDuplicateCloseWorkflow(args); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error(usage()); + process.exit(1); + } +} diff --git a/test/scripts/close-duplicate-prs-after-merge.test.ts b/test/scripts/close-duplicate-prs-after-merge.test.ts new file mode 100644 index 00000000000..2ef8f51474e --- /dev/null +++ b/test/scripts/close-duplicate-prs-after-merge.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { + applyClosePlan, + buildDuplicateClosePlan, + parseArgs, + parsePrNumberList, + parseUnifiedDiffRanges, + runDuplicateCloseWorkflow, +} from "../../scripts/close-duplicate-prs-after-merge.mjs"; + +function pr(params: { + body?: string; + files?: string[]; + mergedAt?: string | null; + mergeCommit?: string; + number: number; + state?: string; + title?: string; +}) { + return { + body: params.body ?? "", + closingIssuesReferences: [], + files: (params.files ?? ["ui/src/ui/chat/grouped-render.ts"]).map((path) => ({ path })), + mergeCommit: params.mergeCommit ? { oid: params.mergeCommit } : null, + mergedAt: params.mergedAt ?? null, + number: params.number, + state: params.state ?? "OPEN", + title: params.title ?? `PR ${params.number}`, + url: `https://github.com/openclaw/openclaw/pull/${params.number}`, + }; +} + +describe("close duplicate PRs after merge", () => { + it("parses comma, whitespace, and hash-prefixed PR lists", () => { + expect(parsePrNumberList("#70530, 70592\n70530")).toEqual([70530, 70592]); + }); + + it("parses hunk ranges from unified diffs", () => { + const ranges = parseUnifiedDiffRanges(`diff --git a/a.ts b/a.ts +@@ -10,2 +20,4 @@ ++x +diff --git a/b.ts b/b.ts +@@ -1 +5 @@ +-a ++b`); + + expect(ranges.get("a.ts")).toEqual([{ start: 20, end: 23 }]); + expect(ranges.get("b.ts")).toEqual([{ start: 5, end: 5 }]); + }); + + it("allows duplicate closure with overlapping hunks even without an explicit issue ref", () => { + const landed = pr({ + body: "Fixes #70491", + mergeCommit: "6415e35", + mergedAt: "2026-04-23T17:13:32Z", + number: 70532, + state: "MERGED", + }); + const candidate = pr({ number: 70530 }); + const diffs = new Map([ + [ + 70532, + `diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts +@@ -402,8 +402,11 @@`, + ], + [ + 70530, + `diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts +@@ -402,8 +402,11 @@`, + ], + ]); + + const plan = buildDuplicateClosePlan({ + candidates: [candidate], + diffs, + landed, + repo: "openclaw/openclaw", + }); + + expect(plan).toMatchObject([ + { + action: "close", + candidate: { number: 70530 }, + evidence: { + overlappingHunks: true, + sharedFiles: ["ui/src/ui/chat/grouped-render.ts"], + sharedIssues: [], + }, + }, + ]); + }); + + it("allows duplicate closure with a shared issue ref even when hunks drift", () => { + const landed = pr({ + body: "Fixes #70491", + mergeCommit: "6415e35", + mergedAt: "2026-04-23T17:13:32Z", + number: 70532, + state: "MERGED", + }); + const candidate = pr({ body: "Closes #70491", number: 70592 }); + const diffs = new Map([ + [ + 70532, + `diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts +@@ -402,8 +402,11 @@`, + ], + [ + 70592, + `diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts +@@ -286,8 +286,11 @@`, + ], + ]); + + const plan = buildDuplicateClosePlan({ + candidates: [candidate], + diffs, + landed, + repo: "openclaw/openclaw", + }); + + expect(plan[0]).toMatchObject({ + action: "close", + evidence: { + overlappingHunks: false, + sharedIssues: [70491], + }, + }); + }); + + it("refuses candidates without shared issue or overlapping hunks", () => { + const landed = pr({ + body: "Fixes #70491", + mergeCommit: "6415e35", + mergedAt: "2026-04-23T17:13:32Z", + number: 70532, + state: "MERGED", + }); + const candidate = pr({ body: "Fixes #1", number: 1 }); + const diffs = new Map([ + [70532, "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@"], + [1, "diff --git a/a.ts b/a.ts\n@@ -99 +99 @@"], + ]); + + expect(() => + buildDuplicateClosePlan({ + candidates: [candidate], + diffs, + landed, + repo: "openclaw/openclaw", + }), + ).toThrow("Refusing to close #1"); + }); + + it("dry-runs through gh reads without mutating", () => { + const calls: string[][] = []; + const responses = new Map([ + [ + "pr view 70532 --repo openclaw/openclaw --json number,title,body,state,mergedAt,mergeCommit,closingIssuesReferences,files,url", + JSON.stringify( + pr({ + body: "Fixes #70491", + mergeCommit: "6415e35", + mergedAt: "2026-04-23T17:13:32Z", + number: 70532, + state: "MERGED", + }), + ), + ], + [ + "pr view 70592 --repo openclaw/openclaw --json number,title,body,state,mergedAt,mergeCommit,closingIssuesReferences,files,url", + JSON.stringify(pr({ body: "Closes #70491", number: 70592 })), + ], + [ + "pr diff 70532 --repo openclaw/openclaw --color=never", + "diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts\n@@ -402,8 +402,11 @@", + ], + [ + "pr diff 70592 --repo openclaw/openclaw --color=never", + "diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts\n@@ -286,8 +286,11 @@", + ], + ]); + const runGh = (args: string[]) => { + calls.push(args); + const key = args.join(" "); + const response = responses.get(key); + if (response === undefined) { + throw new Error(`unexpected gh call: ${key}`); + } + return response; + }; + + const args = parseArgs(["--landed-pr", "70532", "--duplicates", "70592"], { + GITHUB_REPOSITORY: "openclaw/openclaw", + }); + const plan = runDuplicateCloseWorkflow(args, runGh); + + expect(plan).toHaveLength(1); + expect(calls.map((call) => call.slice(0, 2).join(" "))).toEqual([ + "pr view", + "pr view", + "pr diff", + "pr diff", + ]); + }); + + it("applies labels, comment, and close commands for close actions", () => { + const calls: string[][] = []; + applyClosePlan({ + labels: ["duplicate", "close:duplicate"], + plan: [ + { + action: "close", + candidate: pr({ number: 70592 }), + comment: "closing", + evidence: { overlappingHunks: false, sharedFiles: [], sharedIssues: [70491] }, + }, + ], + repo: "openclaw/openclaw", + runGh: (args: string[]) => { + calls.push(args); + return ""; + }, + }); + + expect(calls).toEqual([ + [ + "pr", + "edit", + "70592", + "--repo", + "openclaw/openclaw", + "--add-label", + "duplicate", + "--add-label", + "close:duplicate", + ], + ["pr", "comment", "70592", "--repo", "openclaw/openclaw", "--body", "closing"], + ["pr", "close", "70592", "--repo", "openclaw/openclaw"], + ]); + }); +});