fix(triage): classify low-signal prs

This commit is contained in:
Vincent Koc
2026-04-25 17:19:50 -07:00
parent be1d656514
commit 727e0e013e

View File

@@ -6,7 +6,7 @@ on:
issue_comment:
types: [created]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
types: [labeled]
types: [opened, edited, synchronize, reopened, labeled]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -94,6 +94,89 @@ jobs:
},
];
const managedLabelSpecs = {
"r: skill": {
color: "5319E7",
description: "Auto-close: skills should be published on ClawHub, not added to core.",
},
"r: support": {
color: "0E8A16",
description: "Auto-close: support requests belong in Discord or support docs.",
},
"r: no-ci-pr": {
color: "D93F0B",
description: "Auto-close: PR only chasing known main CI/test failures.",
},
"r: too-many-prs": {
color: "D93F0B",
description: "Auto-close: author has more than ten active PRs.",
},
"r: too-many-prs-override": {
color: "C2E0C6",
description: "Maintainer override for the active-PR limit auto-close.",
},
"r: testflight": {
color: "D93F0B",
description: "Auto-close: TestFlight access/request issues are off-topic here.",
},
"r: third-party-extension": {
color: "5319E7",
description: "Auto-close: third-party plugins/capabilities belong on ClawHub.",
},
"r: moltbook": {
color: "B60205",
description: "Auto-close and lock: Moltbook is off-topic for OpenClaw.",
},
"r: spam": {
color: "B60205",
description: "Auto-close and lock spam.",
},
dirty: {
color: "B60205",
description: "Maintainer-applied auto-close for dirty/unrelated PR branches.",
},
"bad-barnacle": {
color: "E99695",
description: "Suppress Barnacle automation on this issue or PR.",
},
"trigger-response": {
color: "FBCA04",
description: "Maintainer trigger to rerun Barnacle auto-response on an item.",
},
"triage: low-signal-docs": {
color: "C5DEF5",
description: "Candidate: docs-only change looks low signal; maintainer review needed.",
},
"triage: docs-discoverability": {
color: "C5DEF5",
description: "Candidate: docs discoverability/listing change may belong elsewhere.",
},
"triage: test-only-no-bug": {
color: "C5DEF5",
description: "Candidate: test-only change has no linked bug or behavior evidence.",
},
"triage: refactor-only": {
color: "C5DEF5",
description: "Candidate: refactor/cleanup-only PR without maintainer context.",
},
"triage: blank-template": {
color: "C5DEF5",
description: "Candidate: PR template appears mostly untouched.",
},
"triage: dirty-candidate": {
color: "C5DEF5",
description: "Candidate: broad unrelated surfaces; may need splitting or cleanup.",
},
"triage: risky-infra": {
color: "C5DEF5",
description: "Candidate: infra/CI/release change needs maintainer review.",
},
"triage: external-plugin-candidate": {
color: "C5DEF5",
description: "Candidate: plugin/capability may belong on ClawHub.",
},
};
const maintainerTeam = "maintainer";
const pingWarningMessage =
"Please dont spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
@@ -143,13 +226,26 @@ jobs:
return "";
};
const ensureLabelExists = async (name, color, description) => {
const ensureLabelSynced = async (name, color, description) => {
try {
await github.rest.issues.getLabel({
const current = await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
});
const currentDescription = current.data.description ?? "";
if (
current.data.color.toLowerCase() !== color.toLowerCase() ||
currentDescription !== description
) {
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name,
color,
description,
});
}
} catch (error) {
if (error?.status !== 404) {
throw error;
@@ -164,6 +260,12 @@ jobs:
}
};
const syncManagedLabels = async () => {
for (const [name, spec] of Object.entries(managedLabelSpecs)) {
await ensureLabelSynced(name, spec.color, spec.description);
}
};
const syncBugSubtypeLabel = async (issue, labelSet) => {
if (!labelSet.has("bug")) {
return;
@@ -176,7 +278,7 @@ jobs:
}
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
await ensureLabelSynced(targetLabel, targetSpec.color, targetSpec.description);
for (const subtypeLabel of bugSubtypeLabels) {
if (subtypeLabel === targetLabel) {
@@ -359,7 +461,12 @@ jobs:
}
const isLabelEvent = context.payload.action === "labeled";
if (!hasTriggerLabel && !isLabelEvent) {
const isPrCandidateEvent =
pullRequest &&
["opened", "edited", "synchronize", "reopened", "labeled"].includes(
context.payload.action,
);
if (!hasTriggerLabel && !isLabelEvent && !isPrCandidateEvent) {
return;
}
@@ -403,15 +510,277 @@ jobs:
const spamLabel = "r: spam";
const dirtyLabel = "dirty";
const badBarnacleLabel = "bad-barnacle";
const candidateLabels = {
blankTemplate: "triage: blank-template",
lowSignalDocs: "triage: low-signal-docs",
docsDiscoverability: "triage: docs-discoverability",
testOnlyNoBug: "triage: test-only-no-bug",
refactorOnly: "triage: refactor-only",
dirtyCandidate: "triage: dirty-candidate",
riskyInfra: "triage: risky-infra",
externalPluginCandidate: "triage: external-plugin-candidate",
};
const noisyPrMessage =
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
const hasLinkedReference = (text) =>
/(?:#\d+|github\.com\/openclaw\/openclaw\/(?:issues|pull)\/\d+)/i.test(
text,
);
const hasFilledTemplateLine = (body, field) => {
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`^\\s*-\\s*${escapedField}:\\s*\\S`,
"im",
);
return regex.test(body);
};
const hasMostlyBlankTemplate = (body) => {
if (!body) {
return true;
}
const emptyFields = [
"Problem",
"Why it matters",
"What changed",
"What did NOT change",
"Root cause",
"Target test or file",
].filter((field) => {
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(
`^\\s*-\\s*${escapedField}(?: \\([^)]*\\))?:\\s*$`,
"im",
);
return regex.test(body);
}).length;
const hasTemplateIntro = body.includes(
"Describe the problem and fix in 25 bullets",
);
const emptyClosingRef = /^\s*-\s*(?:Closes|Related)\s+#\s*$/im.test(
body,
);
return hasTemplateIntro && emptyFields >= 3 && emptyClosingRef;
};
const hasConcreteBehaviorContext = (body, text) => {
if (hasLinkedReference(text)) {
return true;
}
if (
hasFilledTemplateLine(body, "Problem") &&
hasFilledTemplateLine(body, "Why it matters") &&
hasFilledTemplateLine(body, "What changed")
) {
return true;
}
return /\b(repro|regression|root cause|crash|bug|failure|failing|broken|behavior|scenario|fixes?)\b/i.test(
text,
);
};
const hasClearDesignContext = (body, text) =>
hasConcreteBehaviorContext(body, text) ||
/\b(rfc|design|architecture|migration|maintainer request|owner request|requested by maintainer|approved by maintainer|beta blocker)\b/i.test(
text,
);
const isMarkdownOrDocsFile = (filename) =>
/^docs\//.test(filename) ||
/\.mdx?$/i.test(filename) ||
/(^|\/)(README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE)\.md$/i.test(
filename,
);
const isTestLikeFile = (filename) =>
/(^|\/)(__tests__|fixtures?|snapshots?)(\/|$)/i.test(filename) ||
/(^|\/)test\/helpers\//i.test(filename) ||
/(^|\/)src\/test-utils\//i.test(filename) ||
/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(filename) ||
/\.(?:snap|snapshot)$/i.test(filename);
const isInfraLikeFile = (filename) =>
/^\.github\/(?:workflows|actions)\//.test(filename) ||
/^scripts\//.test(filename) ||
/^Dockerfile(?:\.|$)/.test(filename) ||
/^docker\//.test(filename) ||
/(^|\/)(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|bun\.lockb?|actionlint\.yaml|dependabot\.yml)$/i.test(
filename,
) ||
/\brelease\b/i.test(filename);
const surfacesForFile = (filename) => {
const surfaces = new Set();
if (/\.generated\/|generated|\.snap$/i.test(filename)) {
surfaces.add("generated");
}
if (filename.startsWith("ui/")) {
surfaces.add("ui");
} else if (filename.startsWith("src/gateway/")) {
surfaces.add("src/gateway");
} else if (filename.startsWith("src/plugins/")) {
surfaces.add("src/plugins");
} else if (filename.startsWith("extensions/")) {
surfaces.add("extensions");
} else if (filename.startsWith("apps/")) {
surfaces.add("apps");
} else if (filename.startsWith(".github/")) {
surfaces.add(".github");
} else if (filename.startsWith("docs/") || /\.mdx?$/i.test(filename)) {
surfaces.add("docs");
} else if (filename.startsWith("scripts/")) {
surfaces.add("scripts");
} else {
surfaces.add("other");
}
return [...surfaces];
};
const listPullRequestFiles = async (pullRequest) =>
github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const addMissingLabels = async (issueNumber, labels, labelSet) => {
const missingLabels = labels.filter((label) => !labelSet.has(label));
if (missingLabels.length === 0) {
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: missingLabels,
});
for (const label of missingLabels) {
labelSet.add(label);
}
core.info(`Added candidate labels to #${issueNumber}: ${missingLabels.join(", ")}`);
};
const applyPullRequestCandidateLabels = async (pullRequest, labelSet) => {
const files = await listPullRequestFiles(pullRequest);
if (files.length === 0) {
return;
}
const filenames = files.map((file) => file.filename);
const body = pullRequest.body ?? "";
const text = `${pullRequest.title ?? ""}\n${body}`;
const lowerText = text.toLowerCase();
const linkedReference = hasLinkedReference(text);
const blankTemplate = hasMostlyBlankTemplate(body);
const concreteBehaviorContext = hasConcreteBehaviorContext(body, text);
const clearDesignContext = hasClearDesignContext(body, text);
const labelsToAdd = [];
if (blankTemplate) {
labelsToAdd.push(candidateLabels.blankTemplate);
}
const docsOnly = filenames.every(isMarkdownOrDocsFile);
const docsSignal =
/\b(add|adds|update|updates|fix|fixes|improve|cleanup|clean up|typo|readme|docs?|documentation|translation|translate)\b/i.test(
text,
);
const discoverabilityDocs = filenames.some((filename) =>
/^(README(?:\.[^.]+)?\.md|docs\/plugins\/community\.md|docs\/start\/showcase\.md)$/i.test(
filename,
),
);
if (docsOnly && !linkedReference && (blankTemplate || docsSignal)) {
labelsToAdd.push(candidateLabels.lowSignalDocs);
}
if (
docsOnly &&
!linkedReference &&
(discoverabilityDocs ||
/\b(community plugin|plugin listing|discoverability|showcase|clawhub)\b/i.test(
text,
))
) {
labelsToAdd.push(candidateLabels.docsDiscoverability);
}
const testOnly = filenames.every(isTestLikeFile);
const lowSignalTestTitle =
/\b(add|adds|added|improve|increase|boost|expand|fix|stabilize|update)\b.*\b(test|tests|coverage|flaky|flake|snapshot|fixtures?)\b/i.test(
pullRequest.title ?? "",
) ||
/\b(test|tests|coverage|flaky|flake)\b.*\b(add|increase|improve|fix|update|stabilize)\b/i.test(
pullRequest.title ?? "",
);
if (
testOnly &&
!linkedReference &&
!concreteBehaviorContext &&
lowSignalTestTitle
) {
labelsToAdd.push(candidateLabels.testOnlyNoBug);
}
if (
!linkedReference &&
!concreteBehaviorContext &&
/\b(refactor|cleanup|clean up|rename|formatting|style-only|style only)\b/i.test(
text,
)
) {
labelsToAdd.push(candidateLabels.refactorOnly);
}
if (
filenames.every(isInfraLikeFile) &&
!linkedReference &&
!clearDesignContext
) {
labelsToAdd.push(candidateLabels.riskyInfra);
}
const addsPluginManifest = files.some(
(file) =>
file.status === "added" &&
/^extensions\/[^/]+\/openclaw\.plugin\.json$/i.test(
file.filename,
),
);
if (
!clearDesignContext &&
(addsPluginManifest ||
/\b(third[- ]party|external plugin|community plugin|clawhub)\b/i.test(
lowerText,
))
) {
labelsToAdd.push(candidateLabels.externalPluginCandidate);
}
const surfaces = new Set(filenames.flatMap(surfacesForFile));
if (surfaces.size >= 4 && !clearDesignContext) {
labelsToAdd.push(candidateLabels.dirtyCandidate);
}
await addMissingLabels(
pullRequest.number,
[...new Set(labelsToAdd)],
labelSet,
);
};
await syncManagedLabels();
if (pullRequest) {
if (labelSet.has(badBarnacleLabel)) {
core.info(`Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`);
return;
}
await applyPullRequestCandidateLabels(pullRequest, labelSet);
if (labelSet.has(dirtyLabel)) {
await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -427,22 +796,6 @@ jobs:
});
return;
}
const labelCount = labelSet.size;
if (labelCount > 20) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: noisyPrMessage,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
if (labelSet.has(spamLabel)) {
await github.rest.issues.update({
owner: context.repo.owner,