mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 11:58:07 +00:00
468 lines
15 KiB
JavaScript
468 lines
15 KiB
JavaScript
// Shared PR context and evidence policy for GitHub checks and label decisions.
|
|
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
|
|
|
|
/** 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";
|
|
export const MAINTAINER_TEAM_SLUG = "maintainer";
|
|
export const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
|
|
export const GITHUB_API_RESPONSE_BODY_MAX_BYTES = 1024 * 1024;
|
|
|
|
export const CLAWSWEEPER_PROOF_VERDICT_STATUS = "clawsweeper_exact_head_pass";
|
|
const CLAWSWEEPER_BOT_LOGINS = new Set(["clawsweeper[bot]", "openclaw-clawsweeper[bot]"]);
|
|
|
|
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;
|
|
|
|
function escapeRegex(text) {
|
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function createTimeoutError(label, timeoutMs) {
|
|
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
error.code = "ETIMEDOUT";
|
|
return error;
|
|
}
|
|
|
|
function createTooLargeGitHubApiBodyError(label, maxBytes) {
|
|
const error = new Error(`${label} response body exceeded ${maxBytes} bytes`);
|
|
error.code = "ETOOBIG";
|
|
return error;
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function readBoundedGitHubApiJson(
|
|
response,
|
|
label,
|
|
maxBytes = GITHUB_API_RESPONSE_BODY_MAX_BYTES,
|
|
options = {},
|
|
) {
|
|
const text = await readBoundedResponseText(response, label, maxBytes, {
|
|
...options,
|
|
createTooLargeError: () => createTooLargeGitHubApiBodyError(label, maxBytes),
|
|
});
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async function cancelGitHubApiResponseBody(response) {
|
|
await response.body?.cancel?.().catch(() => undefined);
|
|
}
|
|
|
|
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 stripHtmlComments(text) {
|
|
return maskHtmlComments(text);
|
|
}
|
|
|
|
function isAutomationUser(user = {}, fallbackLogin = "") {
|
|
const login = user?.login ?? fallbackLogin;
|
|
return user?.type === "Bot" || /\[bot\]$/i.test(login) || login.startsWith("app/");
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
|
|
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 cancelGitHubApiResponseBody(response);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function hasAuthoredPullRequestSection(heading, body = "") {
|
|
const headingPattern = new RegExp(`^#{2,6}\\s+${escapeRegex(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*)?(?:\\*\\*)?${escapeRegex(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 isAnyLegacyProofFieldLine(line) {
|
|
return legacyProofFieldLineValue(line) !== 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 || isAnyLegacyProofFieldLine(lineLocal))
|
|
) {
|
|
break;
|
|
}
|
|
valueLines.push(lineLocal);
|
|
fenceMarker = nextFenceMarker(lineLocal, fenceMarker);
|
|
}
|
|
return valueLines.join("\n").trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function stripMarkdownFenceMarkers(value) {
|
|
return stripHtmlComments(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);
|
|
}
|
|
|
|
function result(status, reason, details = {}) {
|
|
return {
|
|
status,
|
|
reason,
|
|
applies: ["passed", "missing", "insufficient"].includes(status),
|
|
passed: ["passed", "skipped", CLAWSWEEPER_PROOF_VERDICT_STATUS].includes(status),
|
|
...details,
|
|
};
|
|
}
|
|
|
|
function extractMarkerField(marker, name) {
|
|
const match = marker.match(new RegExp(`\\b${escapeRegex(name)}=([^\\s>]+)`, "i"));
|
|
return match?.[1] ?? "";
|
|
}
|
|
|
|
function isTrustedClawSweeperComment(comment) {
|
|
const appSlug = String(
|
|
comment?.performed_via_github_app?.slug ?? comment?.performedViaGithubApp?.slug ?? "",
|
|
).toLowerCase();
|
|
if (appSlug === "clawsweeper") {
|
|
return true;
|
|
}
|
|
// GitHub can omit performed_via_github_app on issue comments while still
|
|
// returning a reserved ClawSweeper App bot identity.
|
|
const login = String(comment?.user?.login ?? "").toLowerCase();
|
|
const userType = String(comment?.user?.type ?? "");
|
|
return CLAWSWEEPER_BOT_LOGINS.has(login) && userType === "Bot";
|
|
}
|
|
|
|
export function hasClawSweeperExactHeadProof({ pullRequest, comments = [] } = {}) {
|
|
const pullNumber = String(pullRequest?.number ?? "");
|
|
const headSha = String(pullRequest?.head?.sha ?? pullRequest?.head_sha ?? "").toLowerCase();
|
|
if (!pullNumber || !/^[0-9a-f]{40}$/i.test(headSha)) {
|
|
return false;
|
|
}
|
|
|
|
for (const comment of comments) {
|
|
if (!isTrustedClawSweeperComment(comment)) {
|
|
continue;
|
|
}
|
|
const body = String(comment?.body ?? "");
|
|
const markers = body.match(/<!--\s*clawsweeper-verdict:pass\b[\s\S]*?-->/gi) ?? [];
|
|
for (const marker of markers) {
|
|
const item = extractMarkerField(marker, "item");
|
|
const sha = extractMarkerField(marker, "sha").toLowerCase();
|
|
if (item === pullNumber && sha === headSha) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function evaluateClawSweeperExactHeadProof({ pullRequest, comments = [] } = {}) {
|
|
if (hasClawSweeperExactHeadProof({ pullRequest, comments })) {
|
|
return result(
|
|
CLAWSWEEPER_PROOF_VERDICT_STATUS,
|
|
"ClawSweeper accepted the PR evidence for the exact PR head.",
|
|
);
|
|
}
|
|
return result("insufficient", "No exact-head ClawSweeper proof verdict was found.");
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
export function labelsForPullRequestContext(evaluation) {
|
|
if (evaluation.status === "missing" || evaluation.status === "insufficient") {
|
|
return [NEEDS_PR_CONTEXT_LABEL];
|
|
}
|
|
return [];
|
|
}
|