mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
b080dd1e76
* refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
// Finds duplicate PRs after merge and closes overlapping candidates.
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
function normalizeDuplicatePrListInput(value) {
|
|
if (
|
|
typeof value !== "string" &&
|
|
typeof value !== "number" &&
|
|
typeof value !== "boolean" &&
|
|
typeof value !== "bigint"
|
|
) {
|
|
return undefined;
|
|
}
|
|
const normalized = String(value).trim();
|
|
return normalized || undefined;
|
|
}
|
|
const DEFAULT_LABELS = ["duplicate", "close:duplicate", "dedupe:child"];
|
|
// Duplicate PR closure performs multiple sequential gh API reads and writes.
|
|
// Keep enough headroom for GitHub latency while preventing one stalled request
|
|
// from blocking the surrounding workflow job.
|
|
const GH_COMMAND_TIMEOUT_MS = 60_000;
|
|
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.`;
|
|
}
|
|
/**
|
|
* Parses comma-separated PR numbers from CLI/env input.
|
|
*/
|
|
export function parsePrNumberList(value) {
|
|
const text = normalizeDuplicatePrListInput(value) ?? "";
|
|
return [
|
|
...new Set(
|
|
text
|
|
.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);
|
|
}),
|
|
),
|
|
];
|
|
}
|
|
/**
|
|
* Parses duplicate PR close workflow arguments.
|
|
*/
|
|
export function parseArgs(argv, env = process.env) {
|
|
let help = false;
|
|
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") {
|
|
help = true;
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
if (!help && !args.landedPr) {
|
|
throw new Error("--landed-pr is required");
|
|
}
|
|
if (!help && args.duplicates.length === 0) {
|
|
throw new Error("--duplicates is required");
|
|
}
|
|
return help ? Object.assign(args, { help: true }) : args;
|
|
}
|
|
export function defaultRunGh(args, options = {}, params = {}) {
|
|
const execFileSyncImpl = params.execFileSyncImpl ?? execFileSync;
|
|
return execFileSyncImpl("gh", args, {
|
|
encoding: "utf8",
|
|
killSignal: "SIGKILL",
|
|
stdio: options.input ? ["pipe", "pipe", "inherit"] : ["ignore", "pipe", "inherit"],
|
|
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
...(options.input ? { input: options.input } : {}),
|
|
});
|
|
}
|
|
function issueRefsFromPr(pr) {
|
|
const refs = new Set();
|
|
const issues = Array.isArray(pr.closingIssuesReferences) ? pr.closingIssuesReferences : [];
|
|
for (const issue of issues) {
|
|
if (isRecord(issue) && 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));
|
|
}
|
|
/**
|
|
* Parses changed hunk ranges from unified diff text.
|
|
*/
|
|
export function parseUnifiedDiffRanges(diffText) {
|
|
const ranges = new Map();
|
|
let currentPath = null;
|
|
const text = typeof diffText === "string" ? diffText : "";
|
|
for (const line of text.split("\n")) {
|
|
const pathMatch = /^diff --git a\/.+ b\/(.+)$/u.exec(line);
|
|
const changedPath = pathMatch?.[1];
|
|
if (changedPath) {
|
|
currentPath = changedPath;
|
|
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;
|
|
}
|
|
/**
|
|
* Reports whether two PR diffs touch overlapping hunks.
|
|
*/
|
|
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) {
|
|
const files = Array.isArray(pr.files) ? pr.files : [];
|
|
return new Set(
|
|
files.flatMap((file) => (isRecord(file) && typeof file.path === "string" ? [file.path] : [])),
|
|
);
|
|
}
|
|
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("; ");
|
|
}
|
|
/**
|
|
* Builds the close/skip plan for duplicate PR candidates.
|
|
*/
|
|
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) ?? "");
|
|
const [owner, name] = repo.split("/");
|
|
const commit = isRecord(landed.mergeCommit) ? landed.mergeCommit.oid : undefined;
|
|
const commitRef =
|
|
commit && owner && name
|
|
? `https://github.com/${owner}/${name}/commit/${commit}`
|
|
: "the merge commit";
|
|
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: `Thanks for the fix. This is now covered by the landed #${landed.number} / commit ${commitRef}.
|
|
|
|
Evidence: ${formatEvidence(evidence)}.
|
|
|
|
Closing #${candidate.number} as a duplicate.`,
|
|
evidence,
|
|
};
|
|
});
|
|
}
|
|
function loadPr(repo, number, runGh) {
|
|
const pullRequest = JSON.parse(
|
|
runGh([
|
|
"pr",
|
|
"view",
|
|
String(number),
|
|
"--repo",
|
|
repo,
|
|
"--json",
|
|
"number,title,body,state,mergedAt,mergeCommit,closingIssuesReferences,files,url",
|
|
]),
|
|
);
|
|
if (!isRecord(pullRequest)) {
|
|
throw new Error(`Invalid GitHub response for PR #${number}`);
|
|
}
|
|
const prNumber = pullRequest.number;
|
|
const state = pullRequest.state;
|
|
if (typeof prNumber !== "number" || typeof state !== "string") {
|
|
throw new Error(`Invalid GitHub response for PR #${number}`);
|
|
}
|
|
return Object.assign({}, pullRequest, { number: prNumber, state });
|
|
}
|
|
function loadDiff(repo, number, runGh) {
|
|
return runGh(["pr", "diff", String(number), "--repo", repo, "--color=never"]);
|
|
}
|
|
/**
|
|
* Applies labels/comments/closes for planned duplicate PR actions.
|
|
*/
|
|
export function applyClosePlan({ labels = DEFAULT_LABELS, plan, repo, runGh }) {
|
|
for (const item of plan) {
|
|
if (!("comment" in item) || typeof item.comment !== "string") {
|
|
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]);
|
|
}
|
|
}
|
|
/**
|
|
* Runs the duplicate PR close workflow.
|
|
*/
|
|
export function runDuplicateCloseWorkflow(args, runGh = defaultRunGh) {
|
|
if (!args.landedPr) {
|
|
throw new Error("--landed-pr is required");
|
|
}
|
|
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 ("reason" in item) {
|
|
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 (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
try {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if ("help" in args) {
|
|
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);
|
|
}
|
|
}
|