diff --git a/src/repair/pr-repair-intake.ts b/src/repair/pr-repair-intake.ts index d14212b555..99d03d92fc 100644 --- a/src/repair/pr-repair-intake.ts +++ b/src/repair/pr-repair-intake.ts @@ -22,6 +22,7 @@ type Candidate = { headRefName?: string; mergeStateStatus?: string; reviewDecision?: string; + labels?: LooseRecord[]; statusCheckRollup?: JsonValue[]; comments?: LooseRecord[]; reviews?: LooseRecord[]; @@ -109,8 +110,10 @@ function runAuthorWideIntake() { function runRepoIntake(targetRepo: string, outDir: string): LooseRecord { const prs = fetchOpenPullRequests({ repo: targetRepo, author, limit }); - const results = prs - .map((pr) => candidateResult(targetRepo, pr)) + const evaluated = prs.map((pr) => candidateResult(targetRepo, pr)); + const requiresHuman = evaluated.filter((result) => result.triage?.action === "requires_human"); + const results = evaluated + .filter((result) => result.triage?.action !== "requires_human") .filter((result) => result.signals.length >= minSignals); if (!dryRun) fs.mkdirSync(outDir, { recursive: true }); @@ -159,6 +162,13 @@ function runRepoIntake(targetRepo: string, outDir: string): LooseRecord { author, scanned: prs.length, candidates: results.length, + requires_human: requiresHuman.map((result) => ({ + number: result.number, + url: result.url, + reason: result.triage?.reason, + repairable_signals: result.triage?.repairable_signals ?? [], + metadata_signals: result.triage?.metadata_signals ?? [], + })), dry_run: dryRun, jobs: written, }; @@ -257,6 +267,7 @@ function fetchOpenPullRequests({ "headRefName", "mergeStateStatus", "reviewDecision", + "labels", "statusCheckRollup", "comments", "reviews", @@ -278,6 +289,11 @@ function candidateResult(targetRepo: string, pr: Candidate) { blockingSignals.push({ kind: "review_decision", detail: "reviewDecision=CHANGES_REQUESTED" }); } + for (const label of pr.labels ?? []) { + const signal = labelSignal(label); + if (signal) blockingSignals.push(signal); + } + for (const check of pr.statusCheckRollup ?? []) { const signal = checkSignal(check); if (signal) blockingSignals.push(signal); @@ -308,6 +324,11 @@ function candidateResult(targetRepo: string, pr: Candidate) { } } + const signals = dedupeSignals([ + ...blockingSignals, + ...(blockingSignals.length > 0 || includeReviewOnly ? contextSignals : []), + ]).slice(0, 12); + return { number: pr.number, title: pr.title, @@ -315,13 +336,80 @@ function candidateResult(targetRepo: string, pr: Candidate) { baseRefName: pr.baseRefName ?? "main", headRefName: pr.headRefName ?? "", updatedAt: pr.updatedAt ?? "", - signals: dedupeSignals([ - ...blockingSignals, - ...(blockingSignals.length > 0 || includeReviewOnly ? contextSignals : []), - ]).slice(0, 12), + triage: triageDecision(pr, signals), + signals, + }; +} + +function triageDecision(pr: Candidate, signals: Signal[]) { + if (signals.length === 0) return null; + + const repairable = signals.filter((signal) => isRepairableSignal(pr, signal, signals)); + if (repairable.length > 0) return null; + + const metadata = signals.filter((signal) => isHumanOrMetadataSignal(pr, signal, signals)); + if (metadata.length === 0) return null; + + return { + action: "requires_human", + reason: + "No repairable blocker was detected. Remaining signals look like human/review workflow or stale metadata, so do not create an automatic repair job.", + repairable_signals: repairable, + metadata_signals: metadata, }; } +function isRepairableSignal(pr: Candidate, signal: Signal, allSignals: Signal[]) { + switch (signal.kind) { + case "check_failed": + case "review_decision": + case "review_thread_unresolved": + case "review_changes_requested": + case "review_actionable": + return true; + case "merge_state": + return /mergeStateStatus=(DIRTY|BLOCKED)/i.test(signal.detail); + case "comment_actionable": + return !hasProofSufficientLabel(pr) || hasHardRepairableSignal(allSignals); + default: + return false; + } +} + +function hasHardRepairableSignal(signals: Signal[]) { + return signals.some((signal) => { + if ( + [ + "check_failed", + "review_decision", + "review_thread_unresolved", + "review_changes_requested", + "review_actionable", + ].includes(signal.kind) + ) + return true; + if (signal.kind === "merge_state") + return /mergeStateStatus=(DIRTY|BLOCKED)/i.test(signal.detail); + return false; + }); +} + +function isHumanOrMetadataSignal(pr: Candidate, signal: Signal, allSignals: Signal[]) { + if (["clawsweeper_status", "clawsweeper_rating", "clawsweeper_merge_risk"].includes(signal.kind)) + return true; + if (signal.kind === "merge_state" && !isRepairableSignal(pr, signal, allSignals)) return true; + if (signal.kind === "comment_actionable" && !isRepairableSignal(pr, signal, allSignals)) + return true; + return false; +} + +function hasProofSufficientLabel(pr: Candidate) { + return (pr.labels ?? []).some( + (label) => + String(objectRecord(label).name ?? label ?? "").toLowerCase() === "proof: sufficient", + ); +} + function unresolvedReviewThreadSignals(targetRepo: string, number: number): Signal[] { try { const data = ghJson([ @@ -361,6 +449,22 @@ function reviewThreadSignal(thread: LooseRecord): Signal | null { }; } +function labelSignal(label: JsonValue): Signal | null { + const record = objectRecord(label); + const name = String(record.name ?? label ?? ""); + const normalized = name.toLowerCase(); + if (!normalized.trim()) return null; + if (normalized.startsWith("status:") && normalized.includes("needs proof")) + return { kind: "clawsweeper_status", detail: "label=" + name }; + if (normalized.startsWith("status:") && normalized.includes("waiting on author")) + return { kind: "clawsweeper_status", detail: "label=" + name }; + if (normalized.startsWith("rating:") && normalized.includes("unranked krab")) + return { kind: "clawsweeper_rating", detail: "label=" + name }; + if (normalized.startsWith("merge-risk:")) + return { kind: "clawsweeper_merge_risk", detail: "label=" + name }; + return null; +} + function checkSignal(check: JsonValue): Signal | null { const record = objectRecord(check); const conclusion = String(record.conclusion ?? record.state ?? "").toUpperCase(); diff --git a/test/repair/pr-repair-intake.test.ts b/test/repair/pr-repair-intake.test.ts index e61dfdf80b..0f694a115d 100644 --- a/test/repair/pr-repair-intake.test.ts +++ b/test/repair/pr-repair-intake.test.ts @@ -121,6 +121,66 @@ test("pr repair intake supports author-wide open PR discovery", () => { assert.equal(fs.existsSync(path.join(outDir, "openclaw-unavailable")), false); }); +test("pr repair intake routes metadata-only ClawSweeper labels to human review", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-pr-intake-")); + const bin = path.join(root, "bin"); + fs.mkdirSync(bin); + writeFakeGh(bin, [ + { + number: 292, + title: "metadata only", + url: "https://github.com/openclaw/clawsweeper/pull/292", + mergeStateStatus: "CLEAN", + reviewDecision: "", + labels: [{ name: "status: 📣 needs proof" }, { name: "rating: 🧂 unranked krab" }], + statusCheckRollup: [], + 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, []); + assert.equal(parsed.requires_human[0].number, 292); + assert.match(parsed.requires_human[0].reason, /No repairable blocker/); + assert.deepEqual( + parsed.requires_human[0].metadata_signals.map((signal: { kind: string }) => signal.kind), + ["clawsweeper_status", "clawsweeper_rating"], + ); +}); + +test("pr repair intake still writes jobs when objective repair blockers exist", () => { + 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: 293, + title: "metadata plus failed check", + url: "https://github.com/openclaw/clawsweeper/pull/293", + mergeStateStatus: "CLEAN", + reviewDecision: "", + labels: [{ name: "status: 📣 needs proof" }], + 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.deepEqual(parsed.requires_human, []); + assert.equal(parsed.jobs[0].status, "written"); + assert.equal(parsed.jobs[0].number, 293); +}); + function runIntake(root: string, extraArgs: string[]): string { return execFileSync( process.execPath,