Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 110 additions & 6 deletions src/repair/pr-repair-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Candidate = {
headRefName?: string;
mergeStateStatus?: string;
reviewDecision?: string;
labels?: LooseRecord[];
statusCheckRollup?: JsonValue[];
comments?: LooseRecord[];
reviews?: LooseRecord[];
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -257,6 +267,7 @@ function fetchOpenPullRequests({
"headRefName",
"mergeStateStatus",
"reviewDecision",
"labels",
"statusCheckRollup",
"comments",
"reviews",
Expand All @@ -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);
Expand Down Expand Up @@ -308,20 +324,92 @@ 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,
url: pr.url,
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<LooseRecord>([
Expand Down Expand Up @@ -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();
Expand Down
60 changes: 60 additions & 0 deletions test/repair/pr-repair-intake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down