Skip to content

Commit d9e138f

Browse files
committed
fix(preflight): consume exact bot comment states
Punchcard-Session: amber-timber-river-t1
1 parent c146a31 commit d9e138f

2 files changed

Lines changed: 544 additions & 13 deletions

File tree

scripts/preflight-external-pr-merge.mjs

Lines changed: 190 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2371,9 +2371,9 @@ function isActionableCommentEvidence(
23712371
const author = String(comment.user?.login ?? comment.author?.login ?? "").toLowerCase();
23722372

23732373
if (isClawSweeperAuthor(author)) {
2374-
const currentReview = body.toLowerCase().split(/<details>/, 1)[0];
2375-
if (hasActionableClawSweeperReviewSignal(currentReview, { view })) return true;
2376-
if (hasClawSweeperReadyReviewSignal(currentReview)) return true;
2374+
const normalized = body.toLowerCase();
2375+
if (hasActionableClawSweeperReviewSignal(normalized, { view })) return true;
2376+
if (hasClawSweeperReadyReviewSignal(normalized)) return true;
23772377
}
23782378
if (isReviewBot({ author: { login: author }, body })) {
23792379
return /found issues|requested changes|changes requested|needs changes?|needs human|do not merge|duplicate|superseded|security/i.test(
@@ -2472,9 +2472,8 @@ function hasActionableApprovedReviewBody(body) {
24722472
}
24732473

24742474
function isBenignAutomationComment({ author, body, pull, view }) {
2475-
const currentReview = String(body).split(/<details>/, 1)[0];
24762475
if (isClawSweeperReviewStartComment({ author, body, pull })) return true;
2477-
if (isClawSweeperAuthor(author) && hasClawSweeperReadyReviewSignal(currentReview)) {
2476+
if (isClawSweeperAuthor(author) && hasClawSweeperReadyReviewSignal(body)) {
24782477
return isClawSweeperReadyReviewComment({ author, body, pull, view });
24792478
}
24802479
if (isStaleAutomationReviewComment({ author, body, pull })) return true;
@@ -2493,6 +2492,7 @@ function isDependencyGuardAutomationComment({ body, pull }) {
24932492
if (!/^<!--\s*openclaw:dependency-graph-guard\s*-->/.test(body)) return false;
24942493
const headSha = String(pull?.head?.sha ?? "").toLowerCase();
24952494
if (!/^[0-9a-f]{40}$/.test(headSha)) return false;
2495+
if (isTrustedDependencyGraphAutomationComment({ body, headSha })) return true;
24962496
if (/### dependency graph change authorized\b/.test(body)) {
24972497
const approvedSha = body.match(/\bapproved sha:\s*`([0-9a-f]{40})`/)?.[1];
24982498
return approvedSha === headSha;
@@ -2503,6 +2503,38 @@ function isDependencyGuardAutomationComment({ body, pull }) {
25032503
return cleared?.[1]?.toLowerCase() === headSha;
25042504
}
25052505

2506+
function isTrustedDependencyGraphAutomationComment({ body, headSha }) {
2507+
const lines = String(body)
2508+
.trim()
2509+
.toLowerCase()
2510+
.split(/\r?\n/)
2511+
.map((line) => line.trim())
2512+
.filter(Boolean);
2513+
if (lines.length !== 7) return false;
2514+
if (lines[0] !== "<!-- openclaw:dependency-graph-guard -->") return false;
2515+
if (lines[1] !== "### dependency graph changes noted") return false;
2516+
if (
2517+
lines[2] !==
2518+
"this pr includes dependency graph changes. the dependency guard is informational because the pr author is a repository admin or a member of `@openclaw/openclaw-secops`."
2519+
) {
2520+
return false;
2521+
}
2522+
const currentSha = lines[3].match(/^- current sha:\s*`([0-9a-f]{40})`$/)?.[1];
2523+
if (currentSha !== headSha) return false;
2524+
if (!/^- trusted actor:\s*@[a-z0-9](?:[a-z0-9-]{0,38})$/.test(lines[4])) return false;
2525+
if (
2526+
!/^- trusted role:\s*`pull request author; (?:repository admin|openclaw-secops)`$/.test(
2527+
lines[5],
2528+
)
2529+
) {
2530+
return false;
2531+
}
2532+
return (
2533+
lines[6] ===
2534+
"security review is still recommended before merge when the dependency graph change is intentional."
2535+
);
2536+
}
2537+
25062538
function isStaleAutomationReviewComment({ author, body, pull }) {
25072539
const headSha = String(pull?.head?.sha ?? "").toLowerCase();
25082540
if (!/^[0-9a-f]{40}$/.test(headSha)) return false;
@@ -2589,8 +2621,18 @@ function isClawSweeperReadyReviewComment({ author, body, pull, view = null }) {
25892621
const hasExactMarker = hasExactHeadClawSweeperReadyMarker({ body: normalized, pull });
25902622
if (!hasExactMarker) return false;
25912623

2592-
const currentReview = normalized.split(/<details>/, 1)[0];
2593-
return !hasActionableClawSweeperReviewSignal(currentReview, { view }) && hasClawSweeperReadyReviewSignal(currentReview);
2624+
const reviewState = parseClawSweeperReviewState({ body: normalized, pull });
2625+
if (reviewState.kind === "malformed") return false;
2626+
if (hasActionableClawSweeperReviewSignal(normalized, { view })) return false;
2627+
if (!hasClawSweeperReadyReviewSignal(normalized)) return false;
2628+
if (reviewState.kind === "legacy") return hasLegacyClawSweeperReadyReviewSignal(normalized);
2629+
if (reviewState.kind === "transitional") return true;
2630+
return (
2631+
reviewState.readiness === "ready" &&
2632+
reviewState.findings === "none" &&
2633+
reviewState.security === "none" &&
2634+
reviewState.beforeMerge === "none"
2635+
);
25942636
}
25952637

25962638
function isStaleClawSweeperReadyReviewComment({ author, body, pull, view = null }) {
@@ -2600,10 +2642,9 @@ function isStaleClawSweeperReadyReviewComment({ author, body, pull, view = null
26002642
const headSha = String(pull?.head?.sha ?? "").toLowerCase();
26012643
if (!marker || !/^[0-9a-f]{40}$/.test(headSha) || marker.sha === headSha) return false;
26022644

2603-
const currentReview = normalized.split(/<details>/, 1)[0];
26042645
return (
2605-
!hasActionableClawSweeperReviewSignal(currentReview, { view }) &&
2606-
hasClawSweeperReadyReviewSignal(currentReview)
2646+
!hasActionableClawSweeperReviewSignal(normalized, { view }) &&
2647+
hasClawSweeperReadyReviewSignal(normalized)
26072648
);
26082649
}
26092650

@@ -2674,13 +2715,32 @@ function hasClawSweeperReadyReviewSignal(body) {
26742715
const firstLine = String(body).split(/\r?\n/, 1)[0].trim();
26752716
return (
26762717
isClawSweeperMaintainerReviewHeader(firstLine) &&
2718+
(hasLegacyClawSweeperReadyReviewSignal(body) ||
2719+
hasCurrentClawSweeperReadyReviewSignal(body))
2720+
);
2721+
}
2722+
2723+
function hasLegacyClawSweeperReadyReviewSignal(body) {
2724+
return (
26772725
/result:\s*ready for maintainer review\./.test(body) &&
26782726
/(review metrics:\*\*\s*none identified|review metrics:\s*none identified|no (?:clawsweeper |automated )?repair(?: job| lane)? is (?:needed|indicated)|no concrete (?:code finding|contributor-facing blocker left)|remaining action is normal maintainer review)/.test(
26792727
body,
26802728
)
26812729
);
26822730
}
26832731

2732+
function hasCurrentClawSweeperReadyReviewSignal(body) {
2733+
const readiness = markdownReviewSection(body, { heading: "merge readiness", level: 2 });
2734+
const beforeMerge = markdownReviewSection(body, { heading: "before merge", level: 2 });
2735+
return (
2736+
readiness.kind === "present" &&
2737+
/(?:^|\n)\s*\*\*ready for maintainer review\*\*(?:\n|$)/.test(readiness.body) &&
2738+
/\bno actionable findings\./.test(readiness.body) &&
2739+
beforeMerge.kind === "present" &&
2740+
isNoneReviewSection(beforeMerge.body)
2741+
);
2742+
}
2743+
26842744
function isClawSweeperAuthor(author) {
26852745
return ["clawsweeper", "clawsweeper[bot]"].includes(author);
26862746
}
@@ -2700,10 +2760,52 @@ function hasActionableClawSweeperReviewSignal(body, { view = null } = {}) {
27002760
reviewSection(body, "proof guidance"),
27012761
) ||
27022762
/\b(?:blocker|must|needs?|required|missing)\b/.test(reviewSection(body, "risk before merge")) ||
2703-
/\bremaining (?:merge )?blocker\b/.test(reviewSection(body, "next step before merge"))
2763+
/\bremaining (?:merge )?blocker\b/.test(reviewSection(body, "next step before merge")) ||
2764+
hasActionableCurrentClawSweeperReviewSignal(body)
27042765
);
27052766
}
27062767

2768+
function hasActionableCurrentClawSweeperReviewSignal(body) {
2769+
for (const heading of ["before merge", "review findings"]) {
2770+
const section = markdownReviewSection(body, { heading, level: 2 });
2771+
if (
2772+
section.kind === "duplicate" ||
2773+
(section.kind === "present" && !isNoneReviewSection(section.body))
2774+
) {
2775+
return true;
2776+
}
2777+
}
2778+
2779+
const securitySections = [
2780+
markdownReviewSection(body, { heading: "security", level: 2 }),
2781+
markdownReviewSection(body, { heading: "security", level: 3 }),
2782+
];
2783+
if (
2784+
securitySections.some(
2785+
(section) =>
2786+
section.kind === "duplicate" ||
2787+
(section.kind === "present" && !isNoneReviewSection(section.body)),
2788+
)
2789+
) {
2790+
return true;
2791+
}
2792+
2793+
for (const label of ["findings", "security"]) {
2794+
const cells = reviewTableResults(body, label);
2795+
if (cells.length > 1 || cells.some((cell) => !isNoneReviewSection(cell))) return true;
2796+
}
2797+
2798+
return String(body)
2799+
.split(/\r?\n/)
2800+
.map((line) => line.trim())
2801+
.filter(Boolean)
2802+
.some((line) =>
2803+
/\b(?:please\s+)?(?:confirm|verify|fix|address|resolve|add|remove|update|change|provide|run)\b.{0,200}\bbefore (?:merge|merging|landing|shipping)\b/.test(
2804+
line,
2805+
),
2806+
);
2807+
}
2808+
27072809
function withoutAdvisoryReviewSections(body) {
27082810
return String(body).replace(
27092811
/\*\*maintainer options:\*\*[\s\S]*?(?=\n\*\*[^*\n]+\*\*\s*(?:\n|$)|$)/gi,
@@ -2723,6 +2825,41 @@ function reviewSection(body, heading) {
27232825
);
27242826
}
27252827

2828+
function markdownReviewSection(body, { heading, level }) {
2829+
const marker = `${"#".repeat(level)} ${String(heading).toLowerCase()}`;
2830+
const lines = String(body).toLowerCase().split(/\r?\n/);
2831+
const indexes = [];
2832+
for (let index = 0; index < lines.length; index += 1) {
2833+
if (lines[index].trim() === marker) indexes.push(index);
2834+
}
2835+
if (indexes.length === 0) return { kind: "missing", body: "" };
2836+
if (indexes.length !== 1) return { kind: "duplicate", body: "" };
2837+
2838+
const section = [];
2839+
for (let index = indexes[0] + 1; index < lines.length; index += 1) {
2840+
const line = lines[index];
2841+
const headingMatch = line.match(/^(#{1,6})\s+\S/);
2842+
if (headingMatch && headingMatch[1].length <= level) break;
2843+
if (level === 2 && /^<details>/i.test(line.trim())) break;
2844+
if (level === 3 && /^<\/details>/i.test(line.trim())) break;
2845+
section.push(line);
2846+
}
2847+
return { kind: "present", body: section.join("\n").trim() };
2848+
}
2849+
2850+
function isNoneReviewSection(body) {
2851+
return /^none[.!]?$/.test(String(body).trim());
2852+
}
2853+
2854+
function reviewTableResults(body, label) {
2855+
const escapedLabel = String(label).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2856+
const pattern = new RegExp(
2857+
`^\\|\\s*\\*\\*${escapedLabel}\\*\\*\\s*\\|\\s*([^|\\r\\n]+?)\\s*\\|`,
2858+
"gim",
2859+
);
2860+
return [...String(body).matchAll(pattern)].map((match) => match[1].trim().toLowerCase());
2861+
}
2862+
27262863
function hasExplicitMergeObjection(body, { view = null } = {}) {
27272864
return String(body)
27282865
.split(/\r?\n/)
@@ -2831,6 +2968,48 @@ function hasExactHeadClawSweeperReadyMarker({ body, pull }) {
28312968
return Boolean(marker && /^[0-9a-f]{40}$/.test(headSha) && marker.sha === headSha);
28322969
}
28332970

2971+
function parseClawSweeperReviewState({ body, pull }) {
2972+
const openers = String(body).match(/<!--\s*clawsweeper-review-version\b/gi) ?? [];
2973+
if (openers.length === 0) return { kind: "legacy" };
2974+
const markers = [
2975+
...String(body).matchAll(/<!--\s*clawsweeper-review-version\s+([^>]*)-->/gi),
2976+
];
2977+
if (openers.length !== 1 || markers.length !== 1) return { kind: "malformed" };
2978+
2979+
const attributes = parseMarkerAttributes(markers[0][1]);
2980+
const headSha = String(pull?.head?.sha ?? "").toLowerCase();
2981+
const pullNumber = String(pull?.number ?? "");
2982+
if (
2983+
!attributes ||
2984+
attributes.v !== "1" ||
2985+
attributes.item !== pullNumber ||
2986+
attributes.sha?.toLowerCase() !== headSha ||
2987+
!/^[0-9a-f]{40}$/.test(headSha)
2988+
) {
2989+
return { kind: "malformed" };
2990+
}
2991+
2992+
// The v1 state tuple is the producer/consumer contract. Missing every state
2993+
// field is transitional; partial tuples and unknown values fail closed.
2994+
const stateKeys = ["readiness", "findings", "security", "before_merge"];
2995+
const presentStateKeys = stateKeys.filter((key) => Object.hasOwn(attributes, key));
2996+
if (presentStateKeys.length === 0) return { kind: "transitional" };
2997+
if (presentStateKeys.length !== stateKeys.length) return { kind: "malformed" };
2998+
if (!["ready", "blocked"].includes(attributes.readiness)) return { kind: "malformed" };
2999+
if (!["none", "actionable"].includes(attributes.findings)) return { kind: "malformed" };
3000+
if (!["none", "actionable"].includes(attributes.security)) return { kind: "malformed" };
3001+
if (!["none", "actionable"].includes(attributes.before_merge)) {
3002+
return { kind: "malformed" };
3003+
}
3004+
return {
3005+
kind: "structured",
3006+
readiness: attributes.readiness,
3007+
findings: attributes.findings,
3008+
security: attributes.security,
3009+
beforeMerge: attributes.before_merge,
3010+
};
3011+
}
3012+
28343013
function parseClawSweeperReadyMarker({ body, pull }) {
28353014
if (/<!--\s*clawsweeper-action:/i.test(body)) return null;
28363015

0 commit comments

Comments
 (0)