From d0be08a9a4ec04e4fc251bcef8f51b88660fda29 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 11:44:47 -0700 Subject: [PATCH] fix(github): action manual Barnacle triage labels Human-applied Barnacle triage candidate labels now trigger the intended auto-response while bot-applied heuristic candidates remain passive. --- scripts/github/barnacle-auto-response.mjs | 128 +++++++++++++++++++- test/scripts/barnacle-auto-response.test.ts | 117 +++++++++++++++++- 2 files changed, 238 insertions(+), 7 deletions(-) diff --git a/scripts/github/barnacle-auto-response.mjs b/scripts/github/barnacle-auto-response.mjs index 2c2c4c31129..c4fcd47afc4 100644 --- a/scripts/github/barnacle-auto-response.mjs +++ b/scripts/github/barnacle-auto-response.mjs @@ -2,6 +2,9 @@ export const activePrLimit = 10; +const thirdPartyExtensionMessage = + "Please publish this as a third-party plugin on [ClawHub](https://clawhub.ai) instead of adding it to the core repo. Docs: https://docs.openclaw.ai/plugin and https://docs.openclaw.ai/tools/clawhub"; + export const rules = [ { label: "r: skill", @@ -39,8 +42,7 @@ export const rules = [ { label: "r: third-party-extension", close: true, - message: - "Please publish this as a third-party plugin on [ClawHub](https://clawhub.ai) instead of adding it to the core repo. Docs: https://docs.openclaw.ai/plugin and https://docs.openclaw.ai/tools/clawhub", + message: thirdPartyExtensionMessage, }, { label: "r: moltbook", @@ -185,6 +187,55 @@ const candidateLabelValues = Object.values(candidateLabels); 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 candidateActionRules = [ + { + label: candidateLabels.dirtyCandidate, + close: true, + message: noisyPrMessage, + }, + { + label: candidateLabels.externalPluginCandidate, + close: true, + message: thirdPartyExtensionMessage, + }, + { + label: candidateLabels.riskyInfra, + close: true, + message: + "Closing this PR because it changes infra/CI/release/ops plumbing without maintainer context and validation. That surface is high-blast-radius; open an issue/RFC or get owner approval before sending a patch.", + }, + { + label: candidateLabels.docsDiscoverability, + close: true, + message: + "Closing this PR because docs discoverability and community-plugin listing changes should go through ClawHub or a maintainer-owned docs plan, not drive-by core churn.", + }, + { + label: candidateLabels.lowSignalDocs, + close: true, + message: + "Closing this PR because the docs-only change is too low-signal for the core repo. Please reopen or resubmit with a concrete OpenClaw docs gap and linked context.", + }, + { + label: candidateLabels.testOnlyNoBug, + close: true, + message: + "Closing this PR because it only changes tests without a linked bug, owner request, or behavior change. Test-only PRs need a concrete regression or maintainer-requested gap.", + }, + { + label: candidateLabels.refactorOnly, + close: true, + message: + "Closing this PR because it is refactor/cleanup-only without maintainer context. We avoid churn in core unless it unlocks a concrete fix, architecture change, or owned cleanup.", + }, + { + label: candidateLabels.blankTemplate, + close: true, + message: + "Closing this PR because the template is mostly blank and does not describe a concrete OpenClaw problem, fix, or test plan. Please reopen or resubmit with the missing context filled in.", + }, +]; + const normalizeLogin = (login) => login.toLowerCase(); export function extractIssueFormValue(body, field) { @@ -643,6 +694,67 @@ async function applyPullRequestCandidateLabels(github, context, core, pullReques ); } +function isAutomationActor(context) { + const sender = context.payload.sender; + const login = sender?.login ?? context.actor ?? ""; + return sender?.type === "Bot" || /\[bot\]$/i.test(login); +} + +function candidateActionRuleForLabelSet(labelSet, preferredLabel = "") { + const preferredRule = candidateActionRules.find( + (rule) => rule.label === preferredLabel && labelSet.has(rule.label), + ); + if (preferredRule) { + return preferredRule; + } + return candidateActionRules.find((rule) => labelSet.has(rule.label)); +} + +async function applyPullRequestCandidateAction({ + github, + context, + pullRequest, + labelSet, + hasTriggerLabel, + isLabelEvent, +}) { + if (isAutomationActor(context)) { + return false; + } + + const eventLabel = context.payload.label?.name ?? ""; + const isCandidateLabelEvent = isLabelEvent && candidateLabelValues.includes(eventLabel); + if (!hasTriggerLabel && !isCandidateLabelEvent) { + return false; + } + + const rule = candidateActionRuleForLabelSet( + labelSet, + isCandidateLabelEvent ? eventLabel : undefined, + ); + if (!rule) { + return false; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + body: rule.message, + }); + + if (rule.close) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + } + + return true; +} + async function removeLabels(github, context, issueNumber, labels, labelSet) { for (const label of labels) { if (!labelSet.has(label)) { @@ -869,6 +981,18 @@ export async function runBarnacleAutoResponse({ github, context, core = console }); return; } + + const handledCandidateAction = await applyPullRequestCandidateAction({ + github, + context, + pullRequest, + labelSet, + hasTriggerLabel, + isLabelEvent, + }); + if (handledCandidateAction) { + return; + } } if (issue && labelSet.has(spamLabel)) { diff --git a/test/scripts/barnacle-auto-response.test.ts b/test/scripts/barnacle-auto-response.test.ts index a8823970f47..5a331a0d7dd 100644 --- a/test/scripts/barnacle-auto-response.test.ts +++ b/test/scripts/barnacle-auto-response.test.ts @@ -44,14 +44,20 @@ function file(filename: string, status = "modified") { }; } -function barnacleContext(pullRequest: Record, labels: string[] = []) { +function barnacleContext( + pullRequest: Record, + labels: string[] = [], + options: Record = {}, +) { return { repo: { owner: "openclaw", repo: "openclaw", }, payload: { - action: "opened", + action: options.action ?? "opened", + label: options.label, + sender: options.sender, pull_request: { number: 123, title: "Cleanup plugin docs", @@ -70,7 +76,10 @@ function barnacleContext(pullRequest: Record, labels: string[] function barnacleGithub(files: ReturnType[]) { const calls = { addLabels: [] as Array<{ issue_number: number; labels: string[] }>, + createComment: [] as Array<{ issue_number: number; body: string }>, + lock: [] as Array<{ issue_number: number; lock_reason?: string }>, removeLabel: [] as Array<{ issue_number: number; name: string }>, + update: [] as Array<{ issue_number: number; state?: string }>, }; const github = { paginate: async () => files, @@ -79,7 +88,9 @@ function barnacleGithub(files: ReturnType[]) { addLabels: async (params: { issue_number: number; labels: string[] }) => { calls.addLabels.push(params); }, - createComment: async () => undefined, + createComment: async (params: { issue_number: number; body: string }) => { + calls.createComment.push(params); + }, createLabel: async () => undefined, getLabel: async (params: { name: string }) => ({ data: { @@ -89,11 +100,15 @@ function barnacleGithub(files: ReturnType[]) { managedLabelSpecs[params.name as keyof typeof managedLabelSpecs]?.description ?? "", }, }), - lock: async () => undefined, + lock: async (params: { issue_number: number; lock_reason?: string }) => { + calls.lock.push(params); + }, removeLabel: async (params: { issue_number: number; name: string }) => { calls.removeLabel.push(params); }, - update: async () => undefined, + update: async (params: { issue_number: number; state?: string }) => { + calls.update.push(params); + }, updateLabel: async () => undefined, }, pulls: { @@ -195,6 +210,30 @@ describe("barnacle-auto-response", () => { expect(labels).not.toContain(candidateLabels.dirtyCandidate); }); + it("does not classify a linked core plugin auto-enable fix as an external plugin candidate", () => { + const labels = classifyPullRequestCandidateLabels( + pr( + "Fix duplicate plugin auto-enable entries", + [ + "- Problem: openclaw doctor --fix adds duplicate installed plugin entries", + "- Why it matters: users get noisy config churn", + "- What changed: respect manifest-provided channel auto-loads", + "", + "Fixes #37548", + "", + "This touches external plugin install state but fixes core config repair behavior.", + ].join("\n"), + ), + [ + file("src/config/plugin-auto-enable.shared.ts"), + file("src/config/plugin-auto-enable.channels.test.ts"), + file("src/config/plugin-auto-enable.test-helpers.ts"), + ], + ); + + expect(labels).not.toContain(candidateLabels.externalPluginCandidate); + }); + it("does not add candidate labels to maintainer-authored PRs", async () => { const { calls, github } = barnacleGithub([ file("ui/src/app.ts"), @@ -272,5 +311,73 @@ describe("barnacle-auto-response", () => { labels: expect.arrayContaining([candidateLabels.dirtyCandidate]), }), ); + expect(calls.createComment).toEqual([]); + expect(calls.update).toEqual([]); + }); + + it("actions manually applied candidate labels", async () => { + const { calls, github } = barnacleGithub([file("extensions/example/openclaw.plugin.json")]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext({}, [candidateLabels.externalPluginCandidate], { + action: "labeled", + label: { name: candidateLabels.externalPluginCandidate }, + sender: { login: "maintainer", type: "User" }, + }), + core: { + info: () => undefined, + }, + }); + + expect(calls.createComment).toContainEqual( + expect.objectContaining({ + body: expect.stringContaining("ClawHub"), + }), + ); + expect(calls.update).toContainEqual(expect.objectContaining({ state: "closed" })); + }); + + it("keeps bot-applied candidate labels passive", async () => { + const { calls, github } = barnacleGithub([file("extensions/example/openclaw.plugin.json")]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext({}, [candidateLabels.externalPluginCandidate], { + action: "labeled", + label: { name: candidateLabels.externalPluginCandidate }, + sender: { login: "openclaw-bot[bot]", type: "Bot" }, + }), + core: { + info: () => undefined, + }, + }); + + expect(calls.createComment).toEqual([]); + expect(calls.update).toEqual([]); + }); + + it("actions existing candidate labels when a maintainer adds trigger-response", async () => { + const { calls, github } = barnacleGithub([file("src/gateway/foo.test.ts")]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext({}, [candidateLabels.testOnlyNoBug, "trigger-response"], { + action: "labeled", + label: { name: "trigger-response" }, + sender: { login: "maintainer", type: "User" }, + }), + core: { + info: () => undefined, + }, + }); + + expect(calls.removeLabel).toContainEqual(expect.objectContaining({ name: "trigger-response" })); + expect(calls.createComment).toContainEqual( + expect.objectContaining({ + body: expect.stringContaining("only changes tests"), + }), + ); + expect(calls.update).toContainEqual(expect.objectContaining({ state: "closed" })); }); });