clownfish/scripts/comment-router-core.mjs
2026-04-29 00:09:46 -07:00

419 lines
16 KiB
JavaScript

export const REPAIR_INTENTS = new Set(["fix_ci", "address_review", "rebase", "clawsweeper_auto_repair"]);
export const MERGE_INTENTS = new Set(["clawsweeper_auto_merge"]);
export const AUTOMERGE_JOB_SOURCE = "pr_automerge";
export const DEFAULT_ALLOWED_REPOSITORY_PERMISSIONS = ["admin", "maintain", "write"];
export function repoSlug(repo) {
return String(repo ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function automergeClusterId(repo, issueNumber) {
return `automerge-${repoSlug(repo)}-${Number(issueNumber)}`;
}
export function automergeJobBranch(repo, issueNumber) {
return `clownfish/${automergeClusterId(repo, issueNumber)}`;
}
export function automergeJobPath(repo, issueNumber) {
const owner = String(repo ?? "").split("/")[0] || "openclaw";
return `jobs/${owner}/inbox/${automergeClusterId(repo, issueNumber)}.md`;
}
export function renderAutomergeJob({ repo, issueNumber, title = null }) {
const clusterId = automergeClusterId(repo, issueNumber);
const branch = automergeJobBranch(repo, issueNumber);
const ref = `#${Number(issueNumber)}`;
const prUrl = `https://github.com/${repo}/pull/${Number(issueNumber)}`;
const safeTitle = String(title ?? `PR ${ref}`).trim() || `PR ${ref}`;
return `---
repo: ${repo}
cluster_id: ${clusterId}
mode: autonomous
allowed_actions:
- comment
- label
- fix
- raise_pr
blocked_actions:
- close
- merge
require_human_for:
- close
- merge
canonical:
- ${ref}
candidates:
- ${ref}
cluster_refs:
- ${ref}
allow_instant_close: false
allow_fix_pr: true
allow_merge: false
allow_unmerged_fix_close: false
allow_post_merge_close: false
require_fix_before_close: true
security_policy: central_security_only
security_sensitive: false
target_branch: ${branch}
source: ${AUTOMERGE_JOB_SOURCE}
---
# Clownfish automerge repair candidate
Maintainer opted ${ref} into Clownfish automerge.
Source PR: ${prUrl}
Title: ${safeTitle}
Clownfish should use this job only for the bounded ClawSweeper review/fix loop:
- If ClawSweeper requests changes, returns \`needs-human\`, or finds failing checks/rebase work, and the PR branch is safe to update, emit a fix artifact with \`repair_strategy: "repair_contributor_branch"\` and \`source_prs: ["${prUrl}"]\`.
- If the PR branch cannot be safely updated, emit a narrow credited replacement only when the artifact can preserve the original contributor credit; otherwise return \`needs_human\`.
- Do not merge, close, or bypass review gates from the worker. The comment router owns final merge only after a passing ClawSweeper verdict for the exact current head.
- Keep repair scope limited to actionable ClawSweeper findings, failing relevant checks, and required review feedback on this PR.
`;
}
export function automergeGateBlockReason(env = process.env) {
if (env.CLOWNFISH_ALLOW_MERGE !== "1") return "merge requires CLOWNFISH_ALLOW_MERGE=1";
if (env.CLOWNFISH_ALLOW_AUTOMERGE !== "1") return "automerge requires CLOWNFISH_ALLOW_AUTOMERGE=1";
return "";
}
export function isMaintainerCommandAllowed({
authorAssociation,
repositoryPermission = null,
allowedAssociations,
allowedRepositoryPermissions = DEFAULT_ALLOWED_REPOSITORY_PERMISSIONS,
}) {
const association = String(authorAssociation ?? "").trim().toUpperCase();
const associationSet = new Set([...allowedAssociations].map((value) => String(value).trim().toUpperCase()).filter(Boolean));
if (associationSet.has(association)) return true;
const permission = String(repositoryPermission ?? "").trim().toLowerCase();
const permissionSet = new Set(
[...allowedRepositoryPermissions].map((value) => String(value).trim().toLowerCase()).filter(Boolean),
);
return permission ? permissionSet.has(permission) : false;
}
export function buildAutomergeMergeArgs({ issueNumber, repo, expectedHeadSha }) {
const args = ["pr", "merge", String(issueNumber), "--repo", repo, "--squash"];
if (expectedHeadSha && expectedHeadSha !== "unknown") {
args.push("--match-head-commit", expectedHeadSha);
}
return args;
}
export function parseCommand(body) {
for (const line of String(body ?? "").split(/\r?\n/)) {
const slash = line.match(/^\s*\/clownfish(?:\s+(.+))?\s*$/i);
if (slash) return commandFromText("slash", slash[1] ?? "status");
const mention = line.match(/^\s*@openclaw-clownfish(?:\[bot\])?(?:\s+(.+))?\s*$/i);
if (mention) return commandFromText("mention", mention[1] ?? "status");
}
return null;
}
export function parseTrustedAutomation(comment, { trustedAuthors = new Set() } = {}) {
const author = String(comment?.user?.login ?? "").toLowerCase();
if (!trustedAuthors.has(author)) return null;
const body = String(comment?.body ?? "");
const verdict = clawsweeperMarker(body, "verdict");
const actionMarker = clawsweeperMarker(body, "action");
if (verdict && ["pass", "approved", "no-changes"].includes(verdict.action)) {
return trustedMerge({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
marker: verdict,
});
}
if (verdict && verdict.action === "needs-human") {
return trustedRepair({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
marker: verdict,
});
}
if (verdict && verdict.action === "human-review") {
return trustedHumanReview({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
marker: verdict,
});
}
if (actionMarker && ["fix-required", "repair-required", "address-review", "fix-ci"].includes(actionMarker.action)) {
return trustedRepair({
author,
reason: `structured ClawSweeper marker: ${actionMarker.action}${markerReasonSuffix(actionMarker.attrs)}`,
marker: actionMarker,
});
}
if (verdict && ["needs-changes", "changes-requested", "fix-required", "repair-required"].includes(verdict.action)) {
return trustedRepair({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
marker: verdict,
});
}
if (looksLikeActionableClawSweeperReview(body)) {
return trustedRepair({ author, reason: "ClawSweeper review comment asks to keep repairing this PR" });
}
return null;
}
export function renderResponse(command, dispatched) {
const marker = `<!-- clownfish-command:${command.comment_id}:${command.intent}:${command.target?.head_sha ?? "na"} -->`;
if (command.intent === "help") {
return [
marker,
"Clownfish is here and listening for maintainer commands.",
"",
"Supported commands: `/clownfish status`, `/clownfish fix ci`, `/clownfish address review`, `/clownfish rebase`, `/clownfish automerge`, `/clownfish explain`, `/clownfish stop`.",
"",
"I only act for maintainers, or for trusted ClawSweeper feedback on a Clownfish PR or PR opted into `clownfish:automerge`.",
].join("\n");
}
if (["status", "explain"].includes(command.intent)) {
return [marker, renderStatusBody(command)].join("\n");
}
if (command.intent === "stop") {
return [
marker,
"Got it. Clownfish will leave this item for human review.",
"",
"I kept the regular `clownfish` label on it and paused the automation trail until a maintainer asks again.",
].join("\n");
}
if (command.intent === "automerge") {
return [
marker,
dispatched?.clawsweeper
? "Clownfish automerge is enabled for this PR."
: "Clownfish could not enable automerge for this PR.",
"",
dispatched?.clawsweeper
? `I added \`clownfish:automerge\` and asked ClawSweeper to review this head. If ClawSweeper requests changes, I will repair the branch and ask for another review, up to the configured round limit.`
: `Reason: ${command.reason ?? "automerge requires a pull request"}.`,
"",
"A maintainer can pause this with `/clownfish stop`.",
].join("\n");
}
if (command.intent === "clawsweeper_auto_repair") {
const fromNeedsHuman = String(command.repair_reason ?? "").includes("needs-human");
return [
marker,
fromNeedsHuman
? "Thanks, ClawSweeper. Clownfish is continuing the automerge repair loop for this PR."
: "Thanks, ClawSweeper. Clownfish picked up the repair feedback.",
"",
`Source: \`${command.trusted_bot_author ?? command.author ?? "trusted automation"}\``,
`Feedback: ${command.repair_reason ?? "ClawSweeper requested another repair pass."}`,
`Action: dispatched \`${dispatched.workflow}\` for \`${dispatched.job_path}\` in \`${dispatched.mode}\` mode.`,
`Model: \`${dispatched.model}\``,
"",
"I will update this PR branch, or open a safe credited replacement, if the repair worker finds a narrow fix.",
].join("\n");
}
if (command.intent === "clawsweeper_auto_merge") {
return [
marker,
dispatched?.merge?.status === "executed"
? "Thanks, ClawSweeper. Clownfish merged this PR after the passing review."
: "Thanks, ClawSweeper. Clownfish saw the passing review, but did not merge yet.",
"",
`Source: \`${command.trusted_bot_author ?? command.author ?? "trusted automation"}\``,
`Feedback: ${command.repair_reason ?? "ClawSweeper reported a passing review."}`,
...(dispatched?.merge?.reason ? [`Merge status: ${dispatched.merge.reason}`] : []),
...(dispatched?.merge?.merged_at ? [`Merged at: ${dispatched.merge.merged_at}`] : []),
"",
dispatched?.merge?.status === "executed"
? "The automerge loop is complete."
: "I left the PR open for the remaining gate instead of bypassing it.",
].join("\n");
}
if (command.intent === "clawsweeper_needs_human") {
return [
marker,
"Clownfish is pausing automerge for human review.",
"",
`Source: \`${command.trusted_bot_author ?? command.author ?? "trusted automation"}\``,
`Reason: ${command.repair_reason ?? "ClawSweeper requested human review."}`,
"",
"I kept the regular `clownfish` label on it and left the final call with a maintainer.",
].join("\n");
}
if (!dispatched) {
return [
marker,
"Clownfish did not dispatch a repair worker for this one.",
"",
`Reason: ${command.reason ?? "unsupported command or target"}.`,
"",
"Supported repair commands work on existing Clownfish PRs and PRs opted into `clownfish:automerge`: `/clownfish fix ci`, `/clownfish address review`, `/clownfish rebase`.",
"A maintainer can opt a PR in with `/clownfish automerge` and I can take another pass.",
].join("\n");
}
return [
marker,
"Clownfish picked this up.",
"",
`Command: \`${command.command}\``,
`Action: dispatched \`${dispatched.workflow}\` for \`${dispatched.job_path}\` in \`${dispatched.mode}\` mode.`,
`Model: \`${dispatched.model}\``,
"",
"I will keep the change narrow and update the PR branch if the repair worker finds a safe fix.",
].join("\n");
}
function commandFromText(trigger, value) {
const command = String(value ?? "status").trim().replace(/\s+/g, " ").toLowerCase();
const intent = normalizeIntent(command);
return { trigger, command, intent };
}
function normalizeIntent(command) {
if (!command || command === "status") return "status";
if (["help", "?"].includes(command)) return "help";
if (["explain", "why"].includes(command)) return "explain";
if (["fix ci", "fix-ci", "ci", "repair ci", "repair checks", "fix checks"].includes(command)) return "fix_ci";
if (["address review", "address-review", "fix review", "review"].includes(command)) return "address_review";
if (["rebase", "update branch", "sync"].includes(command)) return "rebase";
if (["automerge", "auto merge", "merge when clean", "merge when ready", "automerge on"].includes(command)) {
return "automerge";
}
if (["stop", "pause", "human review", "handoff"].includes(command)) return "stop";
return "help";
}
function trustedRepair({ author, reason, marker = null }) {
return {
trigger: "trusted_bot",
command: "clawsweeper auto repair",
intent: "clawsweeper_auto_repair",
trusted_bot: true,
trusted_bot_author: author,
automation_source: "clawsweeper",
repair_reason: reason,
expected_head_sha: marker?.attrs?.sha ?? null,
finding_id: marker?.attrs?.finding ?? null,
};
}
function trustedMerge({ author, reason, marker = null }) {
return {
trigger: "trusted_bot",
command: "clawsweeper auto merge",
intent: "clawsweeper_auto_merge",
trusted_bot: true,
trusted_bot_author: author,
automation_source: "clawsweeper",
repair_reason: reason,
expected_head_sha: marker?.attrs?.sha ?? null,
finding_id: marker?.attrs?.finding ?? null,
};
}
function trustedHumanReview({ author, reason, marker = null }) {
return {
trigger: "trusted_bot",
command: "clawsweeper needs human",
intent: "clawsweeper_needs_human",
trusted_bot: true,
trusted_bot_author: author,
automation_source: "clawsweeper",
repair_reason: reason,
expected_head_sha: marker?.attrs?.sha ?? null,
finding_id: marker?.attrs?.finding ?? null,
};
}
function clawsweeperMarker(body, kind) {
const marker = String(body ?? "").match(
new RegExp(`<!--\\s*clawsweeper-${kind}:\\s*([a-z0-9_-]+)([^>]*)-->`, "i"),
);
if (!marker) return null;
return {
action: marker[1].toLowerCase(),
attrs: markerAttributes(marker[2] ?? ""),
};
}
function markerAttributes(input) {
const attrs = {};
for (const match of String(input ?? "").matchAll(/([a-z0-9_-]+)=("[^"]*"|'[^']*'|[^\s>]+)/gi)) {
const raw = match[2] ?? "";
attrs[match[1].toLowerCase()] = raw.replace(/^["']|["']$/g, "");
}
return attrs;
}
function markerReasonSuffix(attrs) {
const parts = [];
if (attrs?.finding) parts.push(`finding=${attrs.finding}`);
if (attrs?.sha) parts.push(`sha=${attrs.sha}`);
return parts.length ? ` (${parts.join(" ")})` : "";
}
function looksLikeActionableClawSweeperReview(body) {
const text = String(body ?? "").toLowerCase();
if (!text.includes("clawsweeper") && !text.includes("codex review:")) return false;
if (
[
"no actionable",
"no issues found",
"looks good",
"safe to merge",
"approved",
"nothing to fix",
"no findings",
].some((phrase) => text.includes(phrase))
) {
return false;
}
return [
/keep this pr open/s,
/needs? (?:maintainer )?follow[- ]up/s,
/still (?:has|lacks|needs|fails|failing|blocked|broken|missing)/s,
/unresolved review/s,
/failing checks?/s,
/actionable (?:review )?finding/s,
/please (?:fix|address|rebase)/s,
].some((pattern) => pattern.test(text));
}
function renderStatusBody(command) {
const target = command.target ?? {};
const lines = ["Clownfish status:"];
if (target.kind === "pull_request") {
lines.push(`- PR: #${command.issue_number}`);
lines.push(`- Branch: \`${target.branch ?? "unknown"}\``);
lines.push(`- Clownfish PR: ${target.is_clownfish_pr ? "yes" : "no"}`);
if (target.automerge_job_path) lines.push(`- Automerge job: \`${target.automerge_job_path}\``);
if (target.job_path) lines.push(`- Job: \`${target.job_path}\``);
if (target.merge_state_status) lines.push(`- Merge state: \`${target.merge_state_status}\``);
if (target.review_decision) lines.push(`- Review decision: \`${target.review_decision}\``);
lines.push(`- Checks: ${formatCounts(target.checks?.counts) || "none"}`);
if (target.checks?.blockers?.length) lines.push(`- Check blockers: ${target.checks.blockers.slice(0, 5).join(", ")}`);
} else {
lines.push(`- Issue: #${command.issue_number}`);
lines.push(`- State: \`${target.state ?? "unknown"}\``);
lines.push("- Existing PR repair: not applicable until a Clownfish PR exists.");
}
return lines.join("\n");
}
function formatCounts(counts) {
return Object.entries(counts ?? {})
.map(([key, value]) => `${key}:${value}`)
.join(" ");
}