openclaw/scripts/github/real-behavior-proof-policy.mjs
Peter Steinberger 7cbaa6b36f
refactor(scripts): deslop shared tooling flows (#162824)
Consolidate script lifecycle and helper ownership while preserving CLI, generated-output, cleanup, and guard contracts. Reject inherited object keys in model-matrix option admission with the existing unknown-argument diagnostic.

Validation: 90 full sibling suites; original-fails parser regression; byte-identical native outputs; changed checks; SDK surface and API comparison; both zero-cycle checks; independent isolated review.
2026-10-01 21:30:19 +00:00

435 lines
13 KiB
JavaScript

// Shared PR context and evidence policy for GitHub checks and label decisions.
import {
createBoundedResponseTooLargeError,
readBoundedResponseText,
} from "../lib/bounded-response.mjs";
import { escapeRegExp } from "../lib/regexp.mjs";
import { createTimeoutError } from "../lib/timeout-error.mjs";
/** @typedef {Record<string, unknown>} PullRequest */
/**
* @typedef {object} Evaluation
* @property {string} status
* @property {string} reason
* @property {boolean} applies
* @property {boolean} passed
* @property {string[]} missingSections
*/
/** ClawSweeper-owned labels that OpenClaw preserves but does not mutate. */
export const PROOF_OVERRIDE_LABEL = "proof: override";
export const PROOF_SUFFICIENT_LABEL = "proof: sufficient";
export const NEEDS_PR_CONTEXT_LABEL = "triage: needs-pr-context";
const MAINTAINER_TEAM_SLUG = "maintainer";
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
const GITHUB_API_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
const privilegedAuthorAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
// Existing open PRs still use the previous structured section. Remove these
// fallbacks once those PRs no longer need body revalidation.
const legacyProofFields = {
evidence: {
names: [
"Evidence after fix",
"After-fix evidence",
"Evidence link or embedded proof",
"Evidence",
],
},
problem: {
names: ["Behavior or issue addressed", "Issue addressed", "Behavior addressed"],
},
};
const legacyProofFieldNames = [
...legacyProofFields.problem.names,
"Real environment tested",
"Environment tested",
"Real setup tested",
"Exact steps or command run after this patch",
"Exact steps or command run after the patch",
"Exact steps or command run after fix",
"Steps run after the patch",
"Command run after the patch",
...legacyProofFields.evidence.names,
"Observed result after fix",
"Observed result after the fix",
"Observed result",
"What was not tested",
"Not tested",
"Before evidence",
"Before evidence optional",
];
const missingValueRegex =
/^(?:n\/?a|none|not applicable|tbd|todo|unknown|unsure|none provided|no evidence|not tested|untested|did not test|didn't test|could not test|couldn't test|-|(?:-{3,}|\*{3,}|_{3,})|\[[^\]]*\])\.?$/i;
async function withGitHubApiTimeout(label, timeoutMs, run) {
const boundedTimeoutMs = Math.max(1, timeoutMs);
const controller = new AbortController();
const timeoutError = createTimeoutError(label, boundedTimeoutMs);
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, boundedTimeoutMs);
timeout.unref?.();
});
try {
return await Promise.race([run(controller.signal), timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
/**
* @param {Response} response
* @param {string} label
* @param {number} [maxBytes]
* @param {{ timeoutMs?: number }} [options]
* @returns {Promise<unknown>}
*/
export async function readBoundedGitHubApiJson(
response,
label,
maxBytes = GITHUB_API_RESPONSE_BODY_MAX_BYTES,
options = {},
) {
const text = await readBoundedResponseText(response, label, maxBytes, {
...options,
createTooLargeError: () =>
createBoundedResponseTooLargeError(`${label} response body exceeded ${maxBytes} bytes`),
});
return JSON.parse(text);
}
function normalizeLineEndings(text = "") {
return text.replace(/\r\n?/g, "\n");
}
function maskHtmlComments(text) {
let commentOpen = false;
let fenceMarker = "";
return text
.split("\n")
.map((line) => {
if (fenceMarker) {
fenceMarker = nextFenceMarker(line, fenceMarker);
return line;
}
let maskedLine = line;
if (commentOpen) {
const end = maskedLine.indexOf("-->");
if (end < 0) {
return maskedLine.replace(/[^\n]/g, " ");
}
maskedLine = `${maskedLine.slice(0, end + 3).replace(/[^\n]/g, " ")}${maskedLine.slice(end + 3)}`;
commentOpen = false;
}
if (nextFenceMarker(maskedLine, "")) {
fenceMarker = nextFenceMarker(maskedLine, "");
return maskedLine;
}
let offset = 0;
while (offset < maskedLine.length) {
const start = maskedLine.indexOf("<!--", offset);
if (start < 0) {
break;
}
const end = maskedLine.indexOf("-->", start + 4);
if (end < 0) {
maskedLine = `${maskedLine.slice(0, start)}${maskedLine
.slice(start)
.replace(/[^\n]/g, " ")}`;
commentOpen = true;
break;
}
maskedLine = `${maskedLine.slice(0, start)}${maskedLine
.slice(start, end + 3)
.replace(/[^\n]/g, " ")}${maskedLine.slice(end + 3)}`;
offset = end + 3;
}
return maskedLine;
})
.join("\n");
}
function isAutomationUser(user = {}, fallbackLogin = "") {
const login = user?.login ?? fallbackLogin;
return user?.type === "Bot" || /\[bot\]$/i.test(login) || login.startsWith("app/");
}
function isExternalPullRequest(pullRequest) {
if (!pullRequest) {
return false;
}
if (isAutomationUser(pullRequest.user)) {
return false;
}
const authorAssociation = String(
pullRequest.author_association ?? pullRequest.authorAssociation ?? "",
).toUpperCase();
return !privilegedAuthorAssociations.has(authorAssociation);
}
/**
* @param {{
* token?: string,
* org?: string,
* login?: string,
* teamSlug?: string,
* fetch?: typeof globalThis.fetch,
* timeoutMs?: number,
* }} [params]
* @returns {Promise<boolean>}
*/
export async function isMaintainerTeamMember({
token,
org,
login,
teamSlug = MAINTAINER_TEAM_SLUG,
fetch = globalThis.fetch,
timeoutMs = DEFAULT_GITHUB_API_TIMEOUT_MS,
} = {}) {
if (!token || !org || !login) {
return false;
}
const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`;
const response = await withGitHubApiTimeout(
`maintainer membership lookup for ${login}`,
timeoutMs,
(signal) =>
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal,
}),
);
try {
if (response.status === 404) {
return false;
}
if (!response.ok) {
throw new Error(`Team membership lookup failed: ${response.status}`);
}
const body = await withGitHubApiTimeout(
`maintainer membership response for ${login}`,
timeoutMs,
(signal) =>
readBoundedGitHubApiJson(
response,
`maintainer membership response for ${login}`,
undefined,
{
signal,
},
),
);
return body?.state === "active";
} finally {
await response.body?.cancel?.().catch(() => undefined);
}
}
function nextFenceMarker(line, fenceMarker) {
const fence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
const marker = fence?.[1] ?? "";
const suffix = fence?.[2] ?? "";
if (!fenceMarker && marker) {
return marker;
}
if (
fenceMarker &&
marker[0] === fenceMarker[0] &&
marker.length >= fenceMarker.length &&
suffix.trim() === ""
) {
return "";
}
return fenceMarker;
}
function markdownHeadingLevel(line) {
return line.match(/^(#{1,6})\s+\S/)?.[1].length ?? 0;
}
function extractMarkdownSections(headingRegex, body = "") {
// Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR
// bodies the same way as locally-authored Markdown.
const normalizedBody = normalizeLineEndings(body);
const headingBody = maskHtmlComments(normalizedBody);
const sections = [];
const matcher = new RegExp(headingRegex.source, headingRegex.flags.replaceAll("g", ""));
let fenceMarker = "";
let sectionHeadingLevel = 0;
let sectionStart = -1;
let lineStart = 0;
for (const line of headingBody.split("\n")) {
const match = !fenceMarker ? line.match(matcher) : null;
const headingLevel = !fenceMarker ? markdownHeadingLevel(line) : 0;
if (sectionStart >= 0 && headingLevel > 0 && headingLevel <= sectionHeadingLevel) {
sections.push(normalizedBody.slice(sectionStart, lineStart === 0 ? 0 : lineStart - 1).trim());
sectionStart = -1;
sectionHeadingLevel = 0;
}
if (match) {
sectionStart = lineStart + (match.index ?? 0) + match[0].length;
sectionHeadingLevel = headingLevel;
}
fenceMarker = nextFenceMarker(line, fenceMarker);
lineStart += line.length + 1;
}
if (sectionStart >= 0) {
sections.push(normalizedBody.slice(sectionStart).trim());
}
return sections;
}
/**
* @param {string} heading
* @param {string} [body]
*/
export function hasAuthoredPullRequestSection(heading, body = "") {
const headingPattern = new RegExp(`^#{2,6}\\s+${escapeRegExp(heading)}\\b[^\\n]*$`, "im");
return !isMissingValue(extractMarkdownSections(headingPattern, body).at(-1) ?? "");
}
function extractLegacyProofSections(body = "") {
return extractMarkdownSections(/^#{2,6}\s+real behavior proof\b[^\n]*$/im, body);
}
function fieldLineRegex(name) {
return new RegExp(
`^\\s*(?:[-*]\\s*)?(?:\\*\\*)?${escapeRegExp(name)}(?:\\s*\\([^)]*\\))?(?:\\*\\*)?\\s*:\\s*(.*)$`,
"i",
);
}
function legacyProofFieldLineValue(line) {
const matchingName = legacyProofFieldNames.find((name) => fieldLineRegex(name).test(line));
const match = matchingName ? line.match(fieldLineRegex(matchingName)) : null;
return match?.[1] ?? null;
}
function extractFieldValue(section, field) {
const lines = maskHtmlComments(normalizeLineEndings(section)).split("\n");
let fenceMarker = "";
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const matchingName = !fenceMarker
? field.names.find((name) => fieldLineRegex(name).test(line))
: null;
if (!matchingName) {
const fenceLine = !fenceMarker ? (legacyProofFieldLineValue(line) ?? line) : line;
fenceMarker = nextFenceMarker(fenceLine, fenceMarker);
continue;
}
const match = line.match(fieldLineRegex(matchingName));
const valueLines = [match?.[1] ?? ""];
fenceMarker = nextFenceMarker(valueLines[0], "");
for (let next = index + 1; next < lines.length; next += 1) {
const lineLocal = lines[next];
if (
!fenceMarker &&
(markdownHeadingLevel(lineLocal) > 0 || legacyProofFieldLineValue(lineLocal) !== null)
) {
break;
}
valueLines.push(lineLocal);
fenceMarker = nextFenceMarker(lineLocal, fenceMarker);
}
return valueLines.join("\n").trim();
}
return "";
}
function stripMarkdownFenceMarkers(value) {
return maskHtmlComments(normalizeLineEndings(value))
.split("\n")
.filter((line) => !/^ {0,3}(?:`{3,}|~{3,})(?:.*)?$/.test(line))
.join("\n")
.trim();
}
function isMissingValue(value) {
const trimmed = stripMarkdownFenceMarkers(value).replace(/^\s*[-*]\s+/, "");
if (!trimmed) {
return true;
}
return missingValueRegex.test(trimmed);
}
/**
* @param {string} status
* @param {string} reason
* @param {Partial<Evaluation>} [details]
* @returns {Evaluation}
*/
function result(status, reason, details = {}) {
return {
status,
reason,
applies: ["passed", "missing", "insufficient"].includes(status),
passed: ["passed", "skipped"].includes(status),
...details,
};
}
/**
* @param {{ pullRequest?: PullRequest }} [params]
* @returns {Evaluation}
*/
export function evaluatePullRequestContext({ pullRequest } = {}) {
if (!isExternalPullRequest(pullRequest)) {
return result("skipped", "Maintainer, collaborator, or bot PRs do not require this gate.");
}
const body = pullRequest?.body ?? "";
const latestLegacyProof = extractLegacyProofSections(body).at(-1) ?? "";
const hasAuthoredProblem = hasAuthoredPullRequestSection("What Problem This Solves", body);
const hasLegacyProblem = !isMissingValue(
extractFieldValue(latestLegacyProof, legacyProofFields.problem),
);
const hasAuthoredEvidence = hasAuthoredPullRequestSection("Evidence", body);
const hasLegacyEvidence = !isMissingValue(
extractFieldValue(latestLegacyProof, legacyProofFields.evidence),
);
const missingSections = [];
if (!hasAuthoredProblem && !hasLegacyProblem) {
missingSections.push("What Problem This Solves");
}
if (!hasAuthoredEvidence && !hasLegacyEvidence) {
missingSections.push("Evidence");
}
if (missingSections.length > 0) {
return result(
"missing",
`External PRs must include authored ${missingSections.join(" and ")} sections.`,
{ missingSections },
);
}
return result("passed", "External PR includes problem context and evidence.");
}
/**
* @param {Evaluation} evaluation
* @returns {string[]}
*/
export function labelsForPullRequestContext(evaluation) {
if (evaluation.status === "missing" || evaluation.status === "insufficient") {
return [NEEDS_PR_CONTEXT_LABEL];
}
return [];
}