mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
ci: add duplicate PR cleanup workflow
This commit is contained in:
59
.github/workflows/duplicate-after-merge.yml
vendored
Normal file
59
.github/workflows/duplicate-after-merge.yml
vendored
Normal file
@@ -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[@]}"
|
||||
13
docs/ci.md
13
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 |
|
||||
|
||||
303
scripts/close-duplicate-prs-after-merge.mjs
Normal file
303
scripts/close-duplicate-prs-after-merge.mjs
Normal file
@@ -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 <number> --duplicates <numbers> [--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);
|
||||
}
|
||||
}
|
||||
242
test/scripts/close-duplicate-prs-after-merge.test.ts
Normal file
242
test/scripts/close-duplicate-prs-after-merge.test.ts
Normal file
@@ -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<string, string>([
|
||||
[
|
||||
"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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user