From ff5ada9b0c83f3ceae22813f54355c01dab147c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Fri, 12 Jun 2026 18:01:21 +0100 Subject: [PATCH 1/7] Add repair-only PR intake --- package.json | 3 +- src/repair/pr-repair-intake.ts | 369 +++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/repair/pr-repair-intake.ts diff --git a/package.json b/package.json index 0b798a8f86..08c6537c8f 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "crabbox:hydrate": "crabbox actions hydrate", "crabbox:run": "crabbox run", "crabbox:stop": "crabbox stop", - "crabbox:warmup": "crabbox warmup" + "crabbox:warmup": "crabbox warmup", + "repair:pr-intake": "node dist/repair/pr-repair-intake.js" }, "devDependencies": { "@types/node": "^25.9.2", diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts new file mode 100644 index 0000000000..e6ed63a4b4 --- /dev/null +++ b/src/repair/pr-repair-intake.ts @@ -0,0 +1,369 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import type { JsonValue, LooseRecord } from "./json-types.js"; +import { ghJson } from "./github-cli.js"; +import { renderJobIntentFrontmatter } from "./job-intent.js"; +import { parseArgs, parseJob, repoRoot, validateJob } from "./lib.js"; +import { slug } from "./text-utils.js"; + +type Signal = { + kind: string; + detail: string; + source?: string; +}; + +type Candidate = { + number: number; + title: string; + url: string; + baseRefName?: string; + headRefName?: string; + mergeStateStatus?: string; + reviewDecision?: string; + statusCheckRollup?: JsonValue[]; + comments?: LooseRecord[]; + reviews?: LooseRecord[]; + updatedAt?: string; +}; + +const args = parseArgs(process.argv.slice(2)); +const repo = stringArg("repo", ""); +const author = stringArg("author", ""); +const limit = numberArg("limit", 50); +const outDirArg = stringArg("out-dir", stringArg("out_dir", "")); +const dryRun = truthy(args["dry-run"] ?? args.dry_run); +const force = truthy(args.force); +const includeComments = !truthy(args["no-comments"] ?? args.no_comments); +const minSignals = numberArg("min-signals", 1); + +if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) die("--repo owner/name is required"); +if (!author.trim()) die("--author is required"); + +const owner = repo.split("/")[0] ?? "unknown"; +const outDir = path.resolve(repoRoot(), outDirArg || `jobs/${owner}/inbox`); +const prs = fetchOpenPullRequests({ repo, author, limit }); +const results = prs + .map((pr) => candidateResult(pr)) + .filter((result) => result.signals.length >= minSignals); + +if (!dryRun) fs.mkdirSync(outDir, { recursive: true }); + +const written: LooseRecord[] = []; +for (const result of results) { + const clusterId = slug(`repair-pr-${repo.replace("/", "-")}-${result.number}`); + const jobPath = path.join(outDir, `${clusterId}.md`); + const relativeJobPath = path.relative(repoRoot(), jobPath); + const branch = `clawsweeper/${clusterId}`; + const body = renderJob({ result, clusterId, branch }); + if (dryRun) { + written.push({ + status: "planned", + job: relativeJobPath, + number: result.number, + signals: result.signals, + }); + continue; + } + if (fs.existsSync(jobPath) && !force) { + written.push({ + status: "exists", + job: relativeJobPath, + number: result.number, + signals: result.signals, + }); + continue; + } + fs.writeFileSync(jobPath, body, "utf8"); + const parsed = parseJob(jobPath); + const errors = validateJob(parsed); + if (errors.length > 0) die(`generated invalid job ${relativeJobPath}:\n- ${errors.join("\n- ")}`); + written.push({ + status: "written", + job: relativeJobPath, + number: result.number, + signals: result.signals, + }); +} + +console.log( + JSON.stringify( + { + status: "ok", + repo, + author, + scanned: prs.length, + candidates: results.length, + dry_run: dryRun, + jobs: written, + }, + null, + 2, + ), +); + +function fetchOpenPullRequests({ + repo, + author, + limit, +}: { + repo: string; + author: string; + limit: number; +}) { + return ghJson([ + "pr", + "list", + "--repo", + repo, + "--author", + author, + "--state", + "open", + "--limit", + String(limit), + "--json", + [ + "number", + "title", + "url", + "baseRefName", + "headRefName", + "mergeStateStatus", + "reviewDecision", + "statusCheckRollup", + "comments", + "reviews", + "updatedAt", + ].join(","), + ]); +} + +function candidateResult(pr: Candidate) { + const signals: Signal[] = []; + const mergeState = String(pr.mergeStateStatus ?? "").toUpperCase(); + if (["DIRTY", "UNKNOWN", "BLOCKED"].includes(mergeState)) { + signals.push({ kind: "merge_state", detail: `mergeStateStatus=${mergeState}` }); + } + + const reviewDecision = String(pr.reviewDecision ?? "").toUpperCase(); + if (reviewDecision === "CHANGES_REQUESTED") { + signals.push({ kind: "review_decision", detail: "reviewDecision=CHANGES_REQUESTED" }); + } + + for (const check of pr.statusCheckRollup ?? []) { + const signal = checkSignal(check); + if (signal) signals.push(signal); + } + + if (includeComments) { + for (const comment of pr.comments ?? []) { + const signal = actionableTextSignal("comment", comment); + if (signal) signals.push(signal); + } + for (const review of pr.reviews ?? []) { + const state = String(review.state ?? "").toUpperCase(); + if (state === "CHANGES_REQUESTED") { + signals.push({ + kind: "review_changes_requested", + detail: compact( + `review by ${loginOf(review.author)} requested changes: ${review.body ?? ""}`, + ), + source: String(review.url ?? ""), + }); + continue; + } + const signal = actionableTextSignal("review", review); + if (signal) signals.push(signal); + } + } + + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseRefName: pr.baseRefName ?? "main", + headRefName: pr.headRefName ?? "", + updatedAt: pr.updatedAt ?? "", + signals: dedupeSignals(signals).slice(0, 12), + }; +} + +function checkSignal(check: JsonValue): Signal | null { + const record = objectRecord(check); + const conclusion = String(record.conclusion ?? record.state ?? "").toUpperCase(); + const status = String(record.status ?? "").toUpperCase(); + const name = String(record.name ?? record.context ?? record.workflowName ?? "check"); + if ( + ["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(conclusion) + ) { + return { kind: "check_failed", detail: `${name}: conclusion=${conclusion}` }; + } + if (["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"].includes(status)) { + return { kind: "check_failed", detail: `${name}: status=${status}` }; + } + return null; +} + +function actionableTextSignal(kind: string, entry: LooseRecord): Signal | null { + const author = loginOf(entry.author); + const body = String(entry.body ?? ""); + const text = body.toLowerCase(); + if (!body.trim()) return null; + if ( + /no actionable comments were generated|no actionable comments|looks good|approved/i.test(body) + ) + return null; + const actionable = [ + /changes? requested/, + /needs? changes?/, + /please (fix|address|update|change|rebase)/, + /must (fix|address|update|change)/, + /merge conflict/, + /conflicts? with/, + /dirty/, + /failing checks?/, + /ci (failed|failure|is failing)/, + /not mergeable/, + /actionable comment/, + /blocking/, + ].some((pattern) => pattern.test(text)); + if (!actionable) return null; + return { + kind: `${kind}_actionable`, + detail: compact(`${kind} by ${author}: ${body}`), + source: String(entry.url ?? ""), + }; +} + +function renderJob({ result, clusterId, branch }: LooseRecord) { + const ref = `#${result.number}`; + const sourcePr = String(result.url); + const prompt = renderPrompt(result); + return `--- +repo: ${repo} +cluster_id: ${clusterId} +mode: autonomous +${renderJobIntentFrontmatter("repair_cluster")} +allowed_actions: + - comment + - label + - fix + - raise_pr +blocked_actions: + - close + - merge +require_human_for: + - merge +canonical: + - ${ref} +candidates: + - ${ref} +cluster_refs: + - ${ref} +allow_instant_close: false +allow_fix_pr: true +allow_merge: false +allow_unmerged_fix_close: false +allow_post_merge_close: false +require_fix_before_close: true +security_policy: central_security_only +security_sensitive: false +target_branch: ${branch} +source: pr-repair-intake +--- + +# Repair-only PR intake for ${repo}${ref} + +This job was created by deterministic repair-only intake. It does not represent a full ClawSweeper review verdict and must not close or merge the source PR. + +## Operator Prompt + +${prompt} + +## Related Refs + +- ${ref} +- ${sourcePr} + +## Likely Files + +- inspect the source PR diff and review comments + +## Validation + +- inspect source PR comments, reviews, mergeability, and checks +- run the narrowest repo-native validation for the touched surface + +## Guardrails + +- Do not merge. +- Do not close the source PR. +- Prefer repairing the contributor branch when maintainable; otherwise prepare a replacement/follow-up repair branch. +- Preserve contributor credit and source PR links. +`; +} + +function renderPrompt(result: LooseRecord) { + const lines = [ + `Repair source PR ${result.url} (${repo}#${result.number}): ${result.title}`, + "", + "The PR has objective repair signals and should be made merge-ready if possible.", + "Use read-only GitHub inspection to review the PR diff, checks, comments, reviews, and latest head/base state before editing.", + "Emit or apply the narrowest repair that addresses the concrete signals below.", + "", + "Required fix artifact fields/hints:", + '- repair_strategy: "repair_contributor_branch" when the contributor branch can be updated safely; otherwise use the existing replacement-branch flow.', + `- source_prs: ["${result.url}"]`, + "", + "Repair signals:", + ...result.signals.map( + (signal: Signal) => + `- ${signal.kind}: ${signal.detail}${signal.source ? ` (${signal.source})` : ""}`, + ), + ]; + return lines.join("\n"); +} + +function dedupeSignals(signals: Signal[]) { + const seen = new Set(); + return signals.filter((signal) => { + const key = `${signal.kind}:${signal.detail}:${signal.source ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function loginOf(value: JsonValue): string { + const record = objectRecord(value); + return String(record.login ?? record.name ?? "unknown"); +} + +function objectRecord(value: JsonValue): LooseRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as LooseRecord) : {}; +} + +function compact(value: string, max = 500) { + const text = value.replace(/\s+/g, " ").trim(); + return text.length <= max ? text : `${text.slice(0, max - 20)} ... ${text.slice(-15)}`; +} + +function stringArg(name: string, fallback: string) { + const value = args[name] ?? args[name.replace(/-/g, "_")]; + return typeof value === "string" ? value : fallback; +} + +function numberArg(name: string, fallback: number) { + const value = Number(args[name] ?? args[name.replace(/-/g, "_")] ?? fallback); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +function truthy(value: JsonValue): boolean { + return value === true || value === "true" || value === "1" || value === 1; +} + +function die(message: string): never { + console.error(message); + process.exit(1); +} From 56be41d795eddfddfead7fe57e68e05ef54502dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Fri, 12 Jun 2026 18:57:02 +0100 Subject: [PATCH 2/7] Drop unsupported Codex ephemeral flag from repair --- src/repair/execute-fix-artifact.ts | 6 ------ src/repair/run-worker.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/src/repair/execute-fix-artifact.ts b/src/repair/execute-fix-artifact.ts index 18cbcd84ad..5be80cf828 100644 --- a/src/repair/execute-fix-artifact.ts +++ b/src/repair/execute-fix-artifact.ts @@ -2014,7 +2014,6 @@ function editValidatePrepareMerge({ ...codexConfigArgs(), "--output-last-message", summaryPath, - "--ephemeral", "--json", "-", ], @@ -2387,7 +2386,6 @@ function runCodexBaseReconcile({ ...codexConfigArgs(), "--output-last-message", summaryPath, - "--ephemeral", "--json", "-", ], @@ -2468,7 +2466,6 @@ function runCodexWritePreflight() { ...codexConfigArgs(), "--output-last-message", summaryPath, - "--ephemeral", "--json", "--skip-git-repo-check", "-", @@ -2819,7 +2816,6 @@ function runCodexReview({ schemaPath, "--output-last-message", outputPath, - "--ephemeral", "--json", "-", ], @@ -2932,7 +2928,6 @@ function runCodexReviewFix({ fixArtifact, targetDir, mode, review, attempt }: Lo ...codexConfigArgs(), "--output-last-message", path.join(workRoot, `${mode}-codex-review-fix-${attempt}.md`), - "--ephemeral", "--json", "-", ], @@ -3011,7 +3006,6 @@ function runCodexValidationFix({ ...codexConfigArgs(), "--output-last-message", path.join(workRoot, `${mode}-codex-validation-fix-${attempt}.md`), - "--ephemeral", "--json", "-", ], diff --git a/src/repair/run-worker.ts b/src/repair/run-worker.ts index 7159a97a6f..2bdf198629 100755 --- a/src/repair/run-worker.ts +++ b/src/repair/run-worker.ts @@ -227,7 +227,6 @@ function runCodex({ path.join(repoRoot(), "schema", "repair", "codex-result.schema.json"), "--output-last-message", outputPath, - "--ephemeral", "--json", "-", ]; From 4d54a576216d5d5c25976c27bd606513c8436a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Fri, 12 Jun 2026 20:07:52 +0100 Subject: [PATCH 3/7] Refine repair-only PR intake signals --- src/repair/pr-repair-intake.ts | 83 ++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts index e6ed63a4b4..17d30c769f 100644 --- a/src/repair/pr-repair-intake.ts +++ b/src/repair/pr-repair-intake.ts @@ -35,8 +35,29 @@ const outDirArg = stringArg("out-dir", stringArg("out_dir", "")); const dryRun = truthy(args["dry-run"] ?? args.dry_run); const force = truthy(args.force); const includeComments = !truthy(args["no-comments"] ?? args.no_comments); +const includeReviewOnly = truthy( + args["include-review-comments-only"] ?? args.include_review_comments_only, +); const minSignals = numberArg("min-signals", 1); +const reviewThreadsQuery = ` +query($owner:String!, $name:String!, $number:Int!) { + repository(owner:$owner, name:$name) { + pullRequest(number:$number) { + reviewThreads(first:100) { + nodes { + isResolved + isOutdated + comments(first:10) { + nodes { author { login } body url } + } + } + } + } + } +} +`; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) die("--repo owner/name is required"); if (!author.trim()) die("--author is required"); @@ -140,31 +161,35 @@ function fetchOpenPullRequests({ } function candidateResult(pr: Candidate) { - const signals: Signal[] = []; + const blockingSignals: Signal[] = []; + const contextSignals: Signal[] = []; const mergeState = String(pr.mergeStateStatus ?? "").toUpperCase(); if (["DIRTY", "UNKNOWN", "BLOCKED"].includes(mergeState)) { - signals.push({ kind: "merge_state", detail: `mergeStateStatus=${mergeState}` }); + blockingSignals.push({ kind: "merge_state", detail: `mergeStateStatus=${mergeState}` }); } const reviewDecision = String(pr.reviewDecision ?? "").toUpperCase(); if (reviewDecision === "CHANGES_REQUESTED") { - signals.push({ kind: "review_decision", detail: "reviewDecision=CHANGES_REQUESTED" }); + blockingSignals.push({ kind: "review_decision", detail: "reviewDecision=CHANGES_REQUESTED" }); } for (const check of pr.statusCheckRollup ?? []) { const signal = checkSignal(check); - if (signal) signals.push(signal); + if (signal) blockingSignals.push(signal); } + const reviewThreadSignals = unresolvedReviewThreadSignals(pr.number); + blockingSignals.push(...reviewThreadSignals); + if (includeComments) { for (const comment of pr.comments ?? []) { const signal = actionableTextSignal("comment", comment); - if (signal) signals.push(signal); + if (signal) contextSignals.push(signal); } for (const review of pr.reviews ?? []) { const state = String(review.state ?? "").toUpperCase(); if (state === "CHANGES_REQUESTED") { - signals.push({ + contextSignals.push({ kind: "review_changes_requested", detail: compact( `review by ${loginOf(review.author)} requested changes: ${review.body ?? ""}`, @@ -174,7 +199,7 @@ function candidateResult(pr: Candidate) { continue; } const signal = actionableTextSignal("review", review); - if (signal) signals.push(signal); + if (signal) contextSignals.push(signal); } } @@ -185,7 +210,49 @@ function candidateResult(pr: Candidate) { baseRefName: pr.baseRefName ?? "main", headRefName: pr.headRefName ?? "", updatedAt: pr.updatedAt ?? "", - signals: dedupeSignals(signals).slice(0, 12), + signals: dedupeSignals([ + ...blockingSignals, + ...(blockingSignals.length > 0 || includeReviewOnly ? contextSignals : []), + ]).slice(0, 12), + }; +} + +function unresolvedReviewThreadSignals(number: number): Signal[] { + try { + const data = ghJson([ + "api", + "graphql", + "-f", + `owner=${repo.split("/")[0]}`, + "-f", + `name=${repo.split("/")[1]}`, + "-F", + `number=${number}`, + "-f", + `query=${reviewThreadsQuery}`, + ]); + const pullRequest = objectRecord(objectRecord(objectRecord(data.data).repository).pullRequest); + const threads = objectRecord(objectRecord(pullRequest.reviewThreads)).nodes; + if (!Array.isArray(threads)) return []; + return threads + .map((thread) => reviewThreadSignal(objectRecord(thread))) + .filter((signal): signal is Signal => Boolean(signal)); + } catch { + return []; + } +} + +function reviewThreadSignal(thread: LooseRecord): Signal | null { + if (thread.isResolved || thread.isOutdated) return null; + const comments = objectRecord(thread.comments).nodes; + if (!Array.isArray(comments) || comments.length === 0) { + return { kind: "review_thread_unresolved", detail: "unresolved current review thread" }; + } + const latest = objectRecord(comments[comments.length - 1]); + return { + kind: "review_thread_unresolved", + detail: compact(`unresolved review thread by ${loginOf(latest.author)}: ${latest.body ?? ""}`), + source: String(latest.url ?? ""), }; } From f4a1239a49cc9e36b3f368debd9d113561ec3744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Mon, 15 Jun 2026 12:22:20 +0100 Subject: [PATCH 4/7] Fix PR intake durability and signal gating --- src/repair/git-publish.ts | 2 +- src/repair/pr-repair-intake.ts | 2 +- test/repair/git-publish.test.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/repair/git-publish.ts b/src/repair/git-publish.ts index 7530fa3e84..18bebfbbb6 100644 --- a/src/repair/git-publish.ts +++ b/src/repair/git-publish.ts @@ -259,7 +259,7 @@ function shouldPreserveStateOnlyFile( rel: string, sourceHasPath: (path: string) => boolean, ): boolean { - if (path === "jobs") return /^[^/]+\/inbox\/(?:automerge|issue|self-heal)-.+\.md$/.test(rel); + if (path === "jobs") return /^[^/]+\/inbox\/(?:automerge|issue|self-heal|repair-pr)-.+\.md$/.test(rel); const publishedPath = joinedPublishPath(path, rel); if (!publishedPath.startsWith("records/")) return false; const counterpart = recordCounterpartPath(publishedPath); diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts index 17d30c769f..7394804c4e 100644 --- a/src/repair/pr-repair-intake.ts +++ b/src/repair/pr-repair-intake.ts @@ -164,7 +164,7 @@ function candidateResult(pr: Candidate) { const blockingSignals: Signal[] = []; const contextSignals: Signal[] = []; const mergeState = String(pr.mergeStateStatus ?? "").toUpperCase(); - if (["DIRTY", "UNKNOWN", "BLOCKED"].includes(mergeState)) { + if (["DIRTY", "BLOCKED"].includes(mergeState)) { blockingSignals.push({ kind: "merge_state", detail: `mergeStateStatus=${mergeState}` }); } diff --git a/test/repair/git-publish.test.ts b/test/repair/git-publish.test.ts index 9b6f9a8cfd..37e02ce102 100644 --- a/test/repair/git-publish.test.ts +++ b/test/repair/git-publish.test.ts @@ -338,6 +338,10 @@ test("publishMainCommit preserves state-only queued jobs on broad jobs publishes "state self-heal job\n", ); write(path.join(state, "jobs/steipete/inbox/issue-steipete-oracle-241.md"), "state issue job\n"); + write( + path.join(state, "jobs/openclaw/inbox/repair-pr-openclaw-clawsweeper-290.md"), + "state repair-pr job\n", + ); write(path.join(state, "jobs/openclaw/inbox/ordinary.md"), "state ordinary job\n"); run("git", ["add", "."], state); run("git", ["commit", "-m", "initial state"], state); @@ -394,6 +398,19 @@ test("publishMainCommit preserves state-only queued jobs on broad jobs publishes ), "state issue job\n", ); + assert.equal( + run( + "git", + [ + "--git-dir", + origin, + "show", + "state:jobs/openclaw/inbox/repair-pr-openclaw-clawsweeper-290.md", + ], + root, + ), + "state repair-pr job\n", + ); assert.equal( run("git", ["--git-dir", origin, "show", "state:jobs/openclaw/inbox/new.md"], root), "local job\n", From c8d747e2fa559656f5120f21f48c2b7a8254faaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Carreiro?= Date: Mon, 15 Jun 2026 12:55:20 +0100 Subject: [PATCH 5/7] Fix PR intake signal and work-kind semantics --- src/repair/action-session.ts | 4 +- src/repair/job-intent.ts | 2 + src/repair/pr-repair-intake.ts | 4 +- test/repair/action-session.test.ts | 2 + test/repair/job-intent.test.ts | 1 + test/repair/pr-repair-intake.test.ts | 117 +++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 test/repair/pr-repair-intake.test.ts diff --git a/src/repair/action-session.ts b/src/repair/action-session.ts index 87d50b9f55..bfaec7e44e 100644 --- a/src/repair/action-session.ts +++ b/src/repair/action-session.ts @@ -16,8 +16,10 @@ export function actionWorkKind(frontmatter: LooseRecord): ActionWorkKind { } if ( frontmatter.job_intent === "automerge_pr" || + frontmatter.job_intent === "pr_repair" || frontmatter.source === "pr_automerge" || - String(frontmatter.cluster_id ?? "").startsWith("automerge-") + String(frontmatter.cluster_id ?? "").startsWith("automerge-") || + String(frontmatter.cluster_id ?? "").startsWith("repair-pr-") ) { return "pr_repair"; } diff --git a/src/repair/job-intent.ts b/src/repair/job-intent.ts index fa9e1729e1..9c89cc7b79 100644 --- a/src/repair/job-intent.ts +++ b/src/repair/job-intent.ts @@ -4,6 +4,7 @@ import type { JsonValue, LooseRecord } from "./json-types.js"; export const REPAIR_JOB_INTENTS = [ "repair_cluster", "automerge_pr", + "pr_repair", "clawsweeper_self_rebase", "implement_issue", "commit_finding", @@ -48,6 +49,7 @@ export function repairJobIntentForFrontmatter(frontmatter: LooseRecord): RepairJ export function workerLaneForRepairJobIntent(intent: RepairJobIntent): WorkerLane { if (intent === "automerge_pr") return "automerge_repair"; + if (intent === "pr_repair") return "automerge_repair"; if (intent === "clawsweeper_self_rebase") return "automerge_repair"; if (intent === "implement_issue") return "issue_implementation"; return "repair"; diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts index 7394804c4e..c4176982bf 100644 --- a/src/repair/pr-repair-intake.ts +++ b/src/repair/pr-repair-intake.ts @@ -262,7 +262,7 @@ function checkSignal(check: JsonValue): Signal | null { const status = String(record.status ?? "").toUpperCase(); const name = String(record.name ?? record.context ?? record.workflowName ?? "check"); if ( - ["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"].includes(conclusion) + ["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"].includes(conclusion) ) { return { kind: "check_failed", detail: `${name}: conclusion=${conclusion}` }; } @@ -311,7 +311,7 @@ function renderJob({ result, clusterId, branch }: LooseRecord) { repo: ${repo} cluster_id: ${clusterId} mode: autonomous -${renderJobIntentFrontmatter("repair_cluster")} +${renderJobIntentFrontmatter("pr_repair")} allowed_actions: - comment - label diff --git a/test/repair/action-session.test.ts b/test/repair/action-session.test.ts index 5f777a8d7e..2bc5e63937 100644 --- a/test/repair/action-session.test.ts +++ b/test/repair/action-session.test.ts @@ -14,7 +14,9 @@ test("action session classifies issue implementation and PR repair work", () => "issue_to_pr", ); assert.equal(actionWorkKind({ job_intent: "automerge_pr" }), "pr_repair"); + assert.equal(actionWorkKind({ job_intent: "pr_repair" }), "pr_repair"); assert.equal(actionWorkKind({ cluster_id: "automerge-openclaw-openclaw-123" }), "pr_repair"); + assert.equal(actionWorkKind({ cluster_id: "repair-pr-openclaw-clawsweeper-290" }), "pr_repair"); assert.equal(actionWorkKind({ job_intent: "repair_cluster" }), "repair_cluster"); }); diff --git a/test/repair/job-intent.test.ts b/test/repair/job-intent.test.ts index fd27e940e2..c683c743d1 100644 --- a/test/repair/job-intent.test.ts +++ b/test/repair/job-intent.test.ts @@ -25,6 +25,7 @@ test("frontmatter job intent owns worker lane selection", () => { "low_signal_pr_cleanup", ); assert.equal(workerLaneForRepairJobIntent("automerge_pr"), "automerge_repair"); + assert.equal(workerLaneForRepairJobIntent("pr_repair"), "automerge_repair"); assert.equal(workerLaneForRepairJobIntent("implement_issue"), "issue_implementation"); assert.equal(workerLaneForRepairJobIntent("low_signal_pr_cleanup"), "repair"); }); diff --git a/test/repair/pr-repair-intake.test.ts b/test/repair/pr-repair-intake.test.ts new file mode 100644 index 0000000000..85561cde16 --- /dev/null +++ b/test/repair/pr-repair-intake.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; + +const scriptPath = path.resolve("dist/repair/pr-repair-intake.js"); + +test("pr repair intake ignores cancelled-only checks", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-pr-intake-")); + const bin = path.join(root, "bin"); + fs.mkdirSync(bin); + writeFakeGh(bin, [ + { + number: 290, + title: "cancelled only", + url: "https://github.com/openclaw/clawsweeper/pull/290", + mergeStateStatus: "CLEAN", + reviewDecision: "", + statusCheckRollup: [{ name: "notify", conclusion: "CANCELLED", status: "COMPLETED" }], + comments: [], + reviews: [], + updatedAt: "2026-06-15T00:00:00Z", + }, + ]); + + const output = runIntake(root, ["--dry-run"]); + const parsed = JSON.parse(output); + assert.equal(parsed.scanned, 1); + assert.equal(parsed.candidates, 0); + assert.deepEqual(parsed.jobs, []); +}); + +test("pr repair intake writes PR repair jobs for failed checks", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-pr-intake-")); + const bin = path.join(root, "bin"); + const outDir = path.join(root, "jobs", "openclaw", "inbox"); + fs.mkdirSync(bin); + writeFakeGh(bin, [ + { + number: 291, + title: "failed check", + url: "https://github.com/openclaw/clawsweeper/pull/291", + mergeStateStatus: "CLEAN", + reviewDecision: "", + statusCheckRollup: [{ name: "pnpm check", conclusion: "FAILURE", status: "COMPLETED" }], + comments: [], + reviews: [], + updatedAt: "2026-06-15T00:00:00Z", + }, + ]); + + const output = runIntake(root, ["--out-dir", outDir]); + const parsed = JSON.parse(output); + assert.equal(parsed.candidates, 1); + assert.equal(parsed.jobs[0].status, "written"); + assert.equal(parsed.jobs[0].job, path.relative(process.cwd(), path.join(outDir, "repair-pr-openclaw-clawsweeper-291.md"))); + + const job = fs.readFileSync(path.join(outDir, "repair-pr-openclaw-clawsweeper-291.md"), "utf8"); + assert.match(job, /^job_intent: pr_repair$/m); + assert.match(job, /pnpm check: conclusion=FAILURE/); +}); + +function runIntake(root: string, extraArgs: string[]): string { + return execFileSync( + process.execPath, + [ + scriptPath, + "--repo", + "openclaw/clawsweeper", + "--author", + "Jhacarreiro", + "--limit", + "10", + "--no-comments", + ...extraArgs, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + PATH: `${path.join(root, "bin")}${path.delimiter}${process.env.PATH ?? ""}`, + }, + encoding: "utf8", + }, + ); +} + +function writeFakeGh(bin: string, prs: unknown[]) { + const gh = path.join(bin, "gh"); + fs.writeFileSync( + gh, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "pr" && args[1] === "list") { + process.stdout.write(${JSON.stringify(JSON.stringify(prs))}); + process.exit(0); +} +if (args[0] === "api" && args[1] === "graphql") { + process.stdout.write(JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] } + } + } + } + })); + process.exit(0); +} +console.error("unexpected gh args", args.join(" ")); +process.exit(1); +`, + { mode: 0o755 }, + ); +} From b5073863ddb6217e16ace320180501d37ca361b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 08:12:24 -0400 Subject: [PATCH 6/7] docs: note repair-only PR intake --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f41dbe1c..ee4d057529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ checkpoint, and status-only commits are intentionally omitted. ### Added +- Added repair-only PR intake that scans an author's open pull requests for actionable failures and creates durable PR-repair jobs. Thanks @Jhacarreiro. - Added automatic issue-build lifecycle comments and dashboard cards with issue titles, queued/planning/building/completed/blocked history, live worker links, Actions runs, and generated PR drill-down. - Show issue and pull request titles alongside target numbers on active dashboard worker cards and worker detail links. - Added comprehensive documentation for steerable repair automation, covering issue-to-PR and PR-repair intake, GitCrawl Actions consumption, deduplication, opt-out labels, GitHub App token boundaries, durable Codex thread resumption, CrabFleet steering, worker budgets, completion gates, dashboards, and failure recovery. @@ -64,6 +65,7 @@ checkpoint, and status-only commits are intentionally omitted. ### Changed +- Removed the unsupported ephemeral-session flag from repair Codex subprocess invocations. Thanks @Jhacarreiro. - Enabled automatic implementation plus bounded durable-report backfill for eligible open issues; general viable implementation remains limited to public sibling repositories, while separately gated strict-bug and vision-fit lanes can backfill `openclaw/openclaw`. Codex discovers viable implementation and validation strategy, while deterministic security, opt-out, source-state, quota, report-revision receipt, queued-job, and PR/cluster deduplication gates remain. - Increased quiet scheduled review capacity from 48 to 64 workers, switched scheduled backfill to three-item shards to reduce setup and tail-idle overhead, and made seven-day review freshness an explicit scheduler priority. - Doubled the global Codex worker budget to 128 with proportional reserves, added job-level dashboard error and recovery rates, and moved the bounded failed-review retry backstop to hourly. From b58b79937f146321e7403d21153aa69f4165592c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 08:28:53 -0400 Subject: [PATCH 7/7] fix(repair): validate PR intake trust contract --- src/repair/execute-fix-validation.ts | 35 ++++++++- src/repair/git-publish.ts | 3 +- src/repair/pr-repair-intake.ts | 4 +- test/repair/execute-fix-validation.test.ts | 82 ++++++++++++++++++++++ test/repair/pr-repair-intake.test.ts | 5 +- 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/repair/execute-fix-validation.ts b/src/repair/execute-fix-validation.ts index 95701dc9ba..80796b8683 100644 --- a/src/repair/execute-fix-validation.ts +++ b/src/repair/execute-fix-validation.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { JsonValue, LooseRecord } from "./json-types.js"; import { GITHUB_PR_TITLE_MAX_LENGTH } from "./pr-title.js"; +import { slug } from "./text-utils.js"; const REPAIR_STRATEGIES = new Set([ "repair_contributor_branch", @@ -187,7 +188,11 @@ function isTrustedIssueImplementation({ job, fixArtifact }: LooseRecord): boolea function isTrustedAdoptedBranchRepair({ job, fixArtifact }: LooseRecord): boolean { const frontmatter = job.frontmatter ?? {}; if (fixArtifact.repair_strategy !== "repair_contributor_branch") return false; - if (frontmatter.source !== "pr_autofix" && frontmatter.source !== "pr_automerge") return false; + const trustedSource = + frontmatter.source === "pr_autofix" || + frontmatter.source === "pr_automerge" || + isTrustedPrRepairIntake(frontmatter, fixArtifact); + if (!trustedSource) return false; if (frontmatter.allow_fix_pr !== true) return false; if (!Array.isArray(frontmatter.allowed_actions) || !frontmatter.allowed_actions.includes("fix")) { return false; @@ -204,6 +209,34 @@ function isTrustedAdoptedBranchRepair({ job, fixArtifact }: LooseRecord): boolea }); } +function isTrustedPrRepairIntake(frontmatter: LooseRecord, fixArtifact: LooseRecord): boolean { + if (frontmatter.source !== "pr-repair-intake" || frontmatter.job_intent !== "pr_repair") { + return false; + } + const repo = String(frontmatter.repo ?? "") + .trim() + .toLowerCase(); + if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repo)) return false; + if (!Array.isArray(fixArtifact.source_prs) || fixArtifact.source_prs.length !== 1) return false; + const sourcePr = String(fixArtifact.source_prs[0] ?? "").toLowerCase(); + const sourceMatch = sourcePr.match( + new RegExp( + `^https://github\\.com/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/pull/([1-9]\\d*)$`, + ), + ); + if (!sourceMatch) return false; + const sourceRef = `#${sourceMatch[1]}`; + for (const key of ["canonical", "candidates", "cluster_refs"]) { + const refs = frontmatter[key]; + if (!Array.isArray(refs) || refs.length !== 1 || refs[0] !== sourceRef) return false; + } + const expectedClusterId = slug(`repair-pr-${repo.replace("/", "-")}-${sourceMatch[1]}`); + return ( + frontmatter.cluster_id === expectedClusterId && + frontmatter.target_branch === `clawsweeper/${expectedClusterId}` + ); +} + function readSiblingJson(resultPath: string, name: string): LooseRecord | null { const file = path.join(path.dirname(resultPath), name); if (!fs.existsSync(file)) return null; diff --git a/src/repair/git-publish.ts b/src/repair/git-publish.ts index 18bebfbbb6..14496bb39b 100644 --- a/src/repair/git-publish.ts +++ b/src/repair/git-publish.ts @@ -259,7 +259,8 @@ function shouldPreserveStateOnlyFile( rel: string, sourceHasPath: (path: string) => boolean, ): boolean { - if (path === "jobs") return /^[^/]+\/inbox\/(?:automerge|issue|self-heal|repair-pr)-.+\.md$/.test(rel); + if (path === "jobs") + return /^[^/]+\/inbox\/(?:automerge|issue|self-heal|repair-pr)-.+\.md$/.test(rel); const publishedPath = joinedPublishPath(path, rel); if (!publishedPath.startsWith("records/")) return false; const counterpart = recordCounterpartPath(publishedPath); diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts index c4176982bf..2ad86dee11 100644 --- a/src/repair/pr-repair-intake.ts +++ b/src/repair/pr-repair-intake.ts @@ -261,9 +261,7 @@ function checkSignal(check: JsonValue): Signal | null { const conclusion = String(record.conclusion ?? record.state ?? "").toUpperCase(); const status = String(record.status ?? "").toUpperCase(); const name = String(record.name ?? record.context ?? record.workflowName ?? "check"); - if ( - ["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"].includes(conclusion) - ) { + if (["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"].includes(conclusion)) { return { kind: "check_failed", detail: `${name}: conclusion=${conclusion}` }; } if (["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"].includes(status)) { diff --git a/test/repair/execute-fix-validation.test.ts b/test/repair/execute-fix-validation.test.ts index e9bd97907d..8347f0e3a2 100644 --- a/test/repair/execute-fix-validation.test.ts +++ b/test/repair/execute-fix-validation.test.ts @@ -75,6 +75,88 @@ test("autonomous scope validation allows trusted adopted PR branch refreshes", ( assert.equal(block, null); }); +test("autonomous scope validation allows repair-intake adopted branch refreshes", () => { + const block = validate( + { + frontmatter: { + repo: "openclaw/openclaw", + cluster_id: "repair-pr-openclaw-openclaw-74742", + canonical: ["#74742"], + candidates: ["#74742"], + cluster_refs: ["#74742"], + source: "pr-repair-intake", + job_intent: "pr_repair", + allow_fix_pr: true, + allowed_actions: ["fix", "raise_pr"], + target_branch: "clawsweeper/repair-pr-openclaw-openclaw-74742", + }, + }, + { + ...broadBranchRepairArtifact(), + source_prs: ["https://github.com/openclaw/openclaw/pull/74742"], + }, + ); + + assert.equal(block, null); +}); + +test("autonomous scope validation blocks repair-intake branch/source mismatches", () => { + const block = validate( + { + frontmatter: { + repo: "openclaw/openclaw", + cluster_id: "repair-pr-openclaw-openclaw-74742", + canonical: ["#74742"], + candidates: ["#74742"], + cluster_refs: ["#74742"], + source: "pr-repair-intake", + job_intent: "pr_repair", + allow_fix_pr: true, + allowed_actions: ["fix", "raise_pr"], + target_branch: "clawsweeper/repair-pr-openclaw-openclaw-74742", + }, + }, + { + ...broadBranchRepairArtifact(), + source_prs: ["https://github.com/openclaw/openclaw/pull/74743"], + }, + ); + + assert.match(block.reason, /too broad for autonomous execution/); +}); + +test("autonomous scope validation accepts generated truncated repair-intake clusters", () => { + const repo = `${"a".repeat(39)}/${"b".repeat(90)}`; + const sourcePr = `https://github.com/${repo}/pull/74742`; + const clusterId = `repair-pr-${repo.replace("/", "-")}-74742` + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120); + const block = validate( + { + frontmatter: { + repo, + cluster_id: clusterId, + canonical: ["#74742"], + candidates: ["#74742"], + cluster_refs: ["#74742"], + source: "pr-repair-intake", + job_intent: "pr_repair", + allow_fix_pr: true, + allowed_actions: ["fix", "raise_pr"], + target_branch: `clawsweeper/${clusterId}`, + }, + }, + { + ...broadBranchRepairArtifact(), + source_prs: [sourcePr], + }, + ); + + assert.equal(block, null); +}); + test("autonomous scope validation allows reviewed issue implementations", () => { const block = validate( { diff --git a/test/repair/pr-repair-intake.test.ts b/test/repair/pr-repair-intake.test.ts index 85561cde16..517d8723f8 100644 --- a/test/repair/pr-repair-intake.test.ts +++ b/test/repair/pr-repair-intake.test.ts @@ -55,7 +55,10 @@ test("pr repair intake writes PR repair jobs for failed checks", () => { const parsed = JSON.parse(output); assert.equal(parsed.candidates, 1); assert.equal(parsed.jobs[0].status, "written"); - assert.equal(parsed.jobs[0].job, path.relative(process.cwd(), path.join(outDir, "repair-pr-openclaw-clawsweeper-291.md"))); + assert.equal( + parsed.jobs[0].job, + path.relative(process.cwd(), path.join(outDir, "repair-pr-openclaw-clawsweeper-291.md")), + ); const job = fs.readFileSync(path.join(outDir, "repair-pr-openclaw-clawsweeper-291.md"), "utf8"); assert.match(job, /^job_intent: pr_repair$/m);