From 2f6615d2ee9d0d9db0f4ad1e13f71d1c0df8166d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 17:41:23 -0700 Subject: [PATCH] fix(triage): extract barnacle workflow --- .github/workflows/auto-response.yml | 856 +------------------ scripts/github/barnacle-auto-response.mjs | 873 ++++++++++++++++++++ scripts/test-projects.test-support.mjs | 2 + test/scripts/barnacle-auto-response.test.ts | 122 +++ 4 files changed, 1009 insertions(+), 844 deletions(-) create mode 100644 scripts/github/barnacle-auto-response.mjs create mode 100644 test/scripts/barnacle-auto-response.test.ts diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 823a8c04d91..f079f8d79b0 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -5,7 +5,7 @@ on: types: [opened, edited, labeled] issue_comment: types: [created] - pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; trusted base checkout only, no untrusted PR code execution types: [opened, edited, synchronize, reopened, labeled] env: @@ -20,10 +20,15 @@ permissions: {} jobs: auto-response: permissions: + contents: read issues: write pull-requests: write runs-on: ubuntu-24.04 steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + persist-credentials: false - uses: actions/create-github-app-token@v3 id: app-token continue-on-error: true @@ -36,852 +41,15 @@ jobs: with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - - name: Handle labeled items + - name: Run Barnacle auto-response uses: actions/github-script@v9 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | - // Labels prefixed with "r:" are auto-response triggers. - const activePrLimit = 10; - const rules = [ - { - label: "r: skill", - close: true, - message: - "Thanks for the contribution! New skills should be published on [ClawHub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.", - }, - { - label: "r: support", - close: true, - message: - "Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.", - }, - { - label: "r: no-ci-pr", - close: true, - message: - "Please don't make PRs for test failures on main.\n\n" + - "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + - "Thank you.", - }, - { - label: "r: too-many-prs", - close: true, - message: - `Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` + - "Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.", - }, - { - label: "r: testflight", - close: true, - commentTriggers: ["testflight"], - message: "Not available, build from source.", - }, - { - 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", - }, - { - label: "r: moltbook", - close: true, - lock: true, - lockReason: "off-topic", - commentTriggers: ["moltbook"], - message: - "OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.", - }, - ]; - - 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 don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd"; - const mentionRegex = /@([A-Za-z0-9-]+)/g; - const maintainerCache = new Map(); - const normalizeLogin = (login) => login.toLowerCase(); - const bugSubtypeLabelSpecs = { - regression: { - color: "D93F0B", - description: "Behavior that previously worked and now fails", - }, - "bug:crash": { - color: "B60205", - description: "Process/app exits unexpectedly or hangs", - }, - "bug:behavior": { - color: "D73A4A", - description: "Incorrect behavior without a crash", - }, - }; - const bugTypeToLabel = { - "Regression (worked before, now fails)": "regression", - "Crash (process/app exits or hangs)": "bug:crash", - "Behavior bug (incorrect output/state without crash)": "bug:behavior", - }; - const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs); - - const extractIssueFormValue = (body, field) => { - if (!body) { - return ""; - } - const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp( - `(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`, - "i", - ); - const match = body.match(regex); - if (!match) { - return ""; - } - for (const line of match[1].split("\n")) { - const trimmed = line.trim(); - if (trimmed) { - return trimmed; - } - } - return ""; - }; - - const ensureLabelSynced = async (name, color, description) => { - try { - 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; - } - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name, - color, - description, - }); - } - }; - - 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; - } - - const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type"); - const targetLabel = bugTypeToLabel[selectedBugType]; - if (!targetLabel) { - return; - } - - const targetSpec = bugSubtypeLabelSpecs[targetLabel]; - await ensureLabelSynced(targetLabel, targetSpec.color, targetSpec.description); - - for (const subtypeLabel of bugSubtypeLabels) { - if (subtypeLabel === targetLabel) { - continue; - } - if (!labelSet.has(subtypeLabel)) { - continue; - } - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: subtypeLabel, - }); - labelSet.delete(subtypeLabel); - } catch (error) { - if (error?.status !== 404) { - throw error; - } - } - } - - if (!labelSet.has(targetLabel)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [targetLabel], - }); - labelSet.add(targetLabel); - } - }; - - const isMaintainer = async (login) => { - if (!login) { - return false; - } - const normalized = normalizeLogin(login); - if (maintainerCache.has(normalized)) { - return maintainerCache.get(normalized); - } - let isMember = false; - try { - const membership = await github.rest.teams.getMembershipForUserInOrg({ - org: context.repo.owner, - team_slug: maintainerTeam, - username: normalized, - }); - isMember = membership?.data?.state === "active"; - } catch (error) { - if (error?.status !== 404) { - throw error; - } - } - maintainerCache.set(normalized, isMember); - return isMember; - }; - - const countMaintainerMentions = async (body, authorLogin) => { - if (!body) { - return 0; - } - const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : ""; - if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) { - return 0; - } - - const haystack = body.toLowerCase(); - const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`; - if (haystack.includes(teamMention)) { - return 3; - } - - const mentions = new Set(); - for (const match of body.matchAll(mentionRegex)) { - mentions.add(normalizeLogin(match[1])); - } - if (normalizedAuthor) { - mentions.delete(normalizedAuthor); - } - - let count = 0; - for (const login of mentions) { - if (await isMaintainer(login)) { - count += 1; - } - } - return count; - }; - - const triggerLabel = "trigger-response"; - const activePrLimitLabel = "r: too-many-prs"; - const activePrLimitOverrideLabel = "r: too-many-prs-override"; - const target = context.payload.issue ?? context.payload.pull_request; - if (!target) { - return; - } - - const labelSet = new Set( - (target.labels ?? []) - .map((label) => (typeof label === "string" ? label : label?.name)) - .filter((name) => typeof name === "string"), + const { pathToFileURL } = require("node:url"); + const moduleUrl = pathToFileURL( + `${process.env.GITHUB_WORKSPACE}/scripts/github/barnacle-auto-response.mjs`, ); + const { runBarnacleAutoResponse } = await import(moduleUrl.href); - const issue = context.payload.issue; - const pullRequest = context.payload.pull_request; - const comment = context.payload.comment; - if (comment) { - const authorLogin = comment.user?.login ?? ""; - if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) { - return; - } - - const commentBody = comment.body ?? ""; - const responses = []; - const mentionCount = await countMaintainerMentions(commentBody, authorLogin); - if (mentionCount >= 3) { - responses.push(pingWarningMessage); - } - - const commentHaystack = commentBody.toLowerCase(); - const commentRule = rules.find((item) => - (item.commentTriggers ?? []).some((trigger) => - commentHaystack.includes(trigger), - ), - ); - if (commentRule) { - responses.push(commentRule.message); - } - - if (responses.length > 0) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: target.number, - body: responses.join("\n\n"), - }); - } - return; - } - - if (issue) { - const action = context.payload.action; - if (action === "opened" || action === "edited") { - const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim(); - const authorLogin = issue.user?.login ?? ""; - const mentionCount = await countMaintainerMentions( - issueText, - authorLogin, - ); - if (mentionCount >= 3) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: pingWarningMessage, - }); - } - - await syncBugSubtypeLabel(issue, labelSet); - } - } - - const hasTriggerLabel = labelSet.has(triggerLabel); - if (hasTriggerLabel) { - labelSet.delete(triggerLabel); - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: target.number, - name: triggerLabel, - }); - } catch (error) { - if (error?.status !== 404) { - throw error; - } - } - } - - const isLabelEvent = context.payload.action === "labeled"; - const isPrCandidateEvent = - pullRequest && - ["opened", "edited", "synchronize", "reopened", "labeled"].includes( - context.payload.action, - ); - if (!hasTriggerLabel && !isLabelEvent && !isPrCandidateEvent) { - return; - } - - if (issue) { - const title = issue.title ?? ""; - const body = issue.body ?? ""; - const haystack = `${title}\n${body}`.toLowerCase(); - const hasMoltbookLabel = labelSet.has("r: moltbook"); - const hasTestflightLabel = labelSet.has("r: testflight"); - const hasSecurityLabel = labelSet.has("security"); - if (title.toLowerCase().includes("security") && !hasSecurityLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ["security"], - }); - labelSet.add("security"); - } - if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ["r: testflight"], - }); - labelSet.add("r: testflight"); - } - if (haystack.includes("moltbook") && !hasMoltbookLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ["r: moltbook"], - }); - labelSet.add("r: moltbook"); - } - } - - const invalidLabel = "invalid"; - 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 2–5 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, - 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, - repo: context.repo.repo, - issue_number: pullRequest.number, - state: "closed", - }); - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - lock_reason: "spam", - }); - return; - } - if (labelSet.has(invalidLabel)) { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - state: "closed", - }); - return; - } - } - - if (issue && labelSet.has(spamLabel)) { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: "closed", - state_reason: "not_planned", - }); - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - lock_reason: "spam", - }); - return; - } - - if (issue && labelSet.has(invalidLabel)) { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: "closed", - state_reason: "not_planned", - }); - return; - } - - if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) { - labelSet.delete(activePrLimitLabel); - } - - const rule = rules.find((item) => labelSet.has(item.label)); - if (!rule) { - return; - } - - const issueNumber = target.number; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: rule.message, - }); - - if (rule.close) { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: "closed", - }); - } - - if (rule.lock) { - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - lock_reason: rule.lockReason ?? "resolved", - }); - } + await runBarnacleAutoResponse({ github, context, core }); diff --git a/scripts/github/barnacle-auto-response.mjs b/scripts/github/barnacle-auto-response.mjs new file mode 100644 index 00000000000..b897aa5ff5f --- /dev/null +++ b/scripts/github/barnacle-auto-response.mjs @@ -0,0 +1,873 @@ +// Barnacle owns deterministic GitHub triage and auto-response behavior. + +export const activePrLimit = 10; + +export const rules = [ + { + label: "r: skill", + close: true, + message: + "Thanks for the contribution! New skills should be published on [ClawHub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.", + }, + { + label: "r: support", + close: true, + message: + "Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.", + }, + { + label: "r: no-ci-pr", + close: true, + message: + "Please don't make PRs for test failures on main.\n\n" + + "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + + "Thank you.", + }, + { + label: "r: too-many-prs", + close: true, + message: + `Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` + + "Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.", + }, + { + label: "r: testflight", + close: true, + commentTriggers: ["testflight"], + message: "Not available, build from source.", + }, + { + 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", + }, + { + label: "r: moltbook", + close: true, + lock: true, + lockReason: "off-topic", + commentTriggers: ["moltbook"], + message: + "OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.", + }, +]; + +export 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.", + }, +}; + +export 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", +}; + +export const bugSubtypeLabelSpecs = { + regression: { + color: "D93F0B", + description: "Behavior that previously worked and now fails", + }, + "bug:crash": { + color: "B60205", + description: "Process/app exits unexpectedly or hangs", + }, + "bug:behavior": { + color: "D73A4A", + description: "Incorrect behavior without a crash", + }, +}; + +const bugTypeToLabel = { + "Regression (worked before, now fails)": "regression", + "Crash (process/app exits or hangs)": "bug:crash", + "Behavior bug (incorrect output/state without crash)": "bug:behavior", +}; +const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs); + +const maintainerTeam = "maintainer"; +const pingWarningMessage = + "Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd"; +const mentionRegex = /@([A-Za-z0-9-]+)/g; +const triggerLabel = "trigger-response"; +const activePrLimitLabel = "r: too-many-prs"; +const activePrLimitOverrideLabel = "r: too-many-prs-override"; +const invalidLabel = "invalid"; +const spamLabel = "r: spam"; +const dirtyLabel = "dirty"; +const badBarnacleLabel = "bad-barnacle"; +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 normalizeLogin = (login) => login.toLowerCase(); + +export function extractIssueFormValue(body, field) { + if (!body) { + return ""; + } + const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp( + `(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`, + "i", + ); + const match = body.match(regex); + if (!match) { + return ""; + } + for (const line of match[1].split("\n")) { + const trimmed = line.trim(); + if (trimmed) { + return trimmed; + } + } + return ""; +} + +export function hasLinkedReference(text) { + return /(?:#\d+|github\.com\/openclaw\/openclaw\/(?:issues|pull)\/\d+)/i.test(text); +} + +export function hasFilledTemplateLine(body, field) { + const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`^\\s*-\\s*${escapedField}:\\s*\\S`, "im"); + return regex.test(body); +} + +export function 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 2–5 bullets"); + const emptyClosingRef = /^\s*-\s*(?:Closes|Related)\s+#\s*$/im.test(body); + return hasTemplateIntro && emptyFields >= 3 && emptyClosingRef; +} + +function stripPullRequestTemplateBoilerplate(text) { + return text + .replace(/^#{2,3}\s+.*$/gm, "") + .replace(/^-\s*\[[ xX]\]\s+.*$/gm, "") + .replace(/^-\s*(?:Closes|Related)\s+#\s*$/gim, "") + .replace( + /^-\s*(?:Problem|Why it matters|What changed|What did NOT change|Root cause|Missing detection \/ guardrail|Contributing context|Target test or file|Scenario the test should lock in|Why this is the smallest reliable guardrail|Existing test that already covers this|If no new test is added, why not|Verified scenarios|Edge cases checked|What you did \*\*not\*\* verify|Risk|Mitigation):\s*$/gim, + "", + ) + .replace(/Describe the problem and fix in 2–5 bullets:/g, "") + .replace( + /For bug fixes or regressions, explain why this happened, not just what changed\. Otherwise write `N\/A`\. If the cause is unclear, write `Unknown`\./g, + "", + ) + .replace( + /For bug fixes or regressions, name the smallest reliable test coverage that should catch this\. Otherwise write `N\/A`\./g, + "", + ); +} + +export function hasConcreteBehaviorContext(body, text) { + if (hasLinkedReference(text)) { + return true; + } + if ( + hasFilledTemplateLine(body, "Problem") && + hasFilledTemplateLine(body, "Why it matters") && + hasFilledTemplateLine(body, "What changed") + ) { + return true; + } + const signalText = stripPullRequestTemplateBoilerplate(text); + return /\b(repro|regression|root cause|crash|bug|failure|failing|broken|behavior|scenario|fixes?)\b/i.test( + signalText, + ); +} + +export function hasClearDesignContext(body, text) { + if (hasConcreteBehaviorContext(body, text)) { + return true; + } + const signalText = stripPullRequestTemplateBoilerplate(text); + return /\b(rfc|design|architecture|migration|maintainer request|owner request|requested by maintainer|approved by maintainer|beta blocker)\b/i.test( + signalText, + ); +} + +export function isMarkdownOrDocsFile(filename) { + return ( + filename.startsWith("docs/") || + /\.mdx?$/i.test(filename) || + /(^|\/)(README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE)\.md$/i.test(filename) + ); +} + +export function isTestLikeFile(filename) { + return ( + /(^|\/)(__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) + ); +} + +export function isInfraLikeFile(filename) { + return ( + /^\.github\/(?:workflows|actions)\//.test(filename) || + filename.startsWith("scripts/") || + /^Dockerfile(?:\.|$)/.test(filename) || + filename.startsWith("docker/") || + /(^|\/)(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|bun\.lockb?|actionlint\.yaml|dependabot\.yml)$/i.test( + filename, + ) || + /\brelease\b/i.test(filename) + ); +} + +export function 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]; +} + +export function classifyPullRequestCandidateLabels(pullRequest, files) { + 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 = blankTemplate + ? linkedReference + : hasConcreteBehaviorContext(body, text); + const clearDesignContext = blankTemplate ? linkedReference : 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); + } + + return [...new Set(labelsToAdd)]; +} + +async function ensureLabelSynced(github, context, name, color, description) { + try { + 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; + } + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color, + description, + }); + } +} + +async function syncManagedLabels(github, context) { + for (const [name, spec] of Object.entries(managedLabelSpecs)) { + await ensureLabelSynced(github, context, name, spec.color, spec.description); + } +} + +async function syncBugSubtypeLabel(github, context, issue, labelSet) { + if (!labelSet.has("bug")) { + return; + } + + const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type"); + const targetLabel = bugTypeToLabel[selectedBugType]; + if (!targetLabel) { + return; + } + + const targetSpec = bugSubtypeLabelSpecs[targetLabel]; + await ensureLabelSynced(github, context, targetLabel, targetSpec.color, targetSpec.description); + + for (const subtypeLabel of bugSubtypeLabels) { + if (subtypeLabel === targetLabel) { + continue; + } + if (!labelSet.has(subtypeLabel)) { + continue; + } + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: subtypeLabel, + }); + labelSet.delete(subtypeLabel); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + + if (!labelSet.has(targetLabel)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [targetLabel], + }); + labelSet.add(targetLabel); + } +} + +function createMaintainerChecker(github, context) { + const maintainerCache = new Map(); + return async (login) => { + if (!login) { + return false; + } + const normalized = normalizeLogin(login); + if (maintainerCache.has(normalized)) { + return maintainerCache.get(normalized); + } + let isMember = false; + try { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: maintainerTeam, + username: normalized, + }); + isMember = membership?.data?.state === "active"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + maintainerCache.set(normalized, isMember); + return isMember; + }; +} + +async function countMaintainerMentions(body, authorLogin, isMaintainer, owner) { + if (!body) { + return 0; + } + const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : ""; + if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) { + return 0; + } + + const haystack = body.toLowerCase(); + const teamMention = `@${owner.toLowerCase()}/${maintainerTeam}`; + if (haystack.includes(teamMention)) { + return 3; + } + + const mentions = new Set(); + for (const match of body.matchAll(mentionRegex)) { + mentions.add(normalizeLogin(match[1])); + } + if (normalizedAuthor) { + mentions.delete(normalizedAuthor); + } + + let count = 0; + for (const login of mentions) { + if (await isMaintainer(login)) { + count += 1; + } + } + return count; +} + +async function listPullRequestFiles(github, context, pullRequest) { + return github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + per_page: 100, + }); +} + +async function addMissingLabels(github, context, core, 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(", ")}`); +} + +async function applyPullRequestCandidateLabels(github, context, core, pullRequest, labelSet) { + const files = await listPullRequestFiles(github, context, pullRequest); + await addMissingLabels( + github, + context, + core, + pullRequest.number, + classifyPullRequestCandidateLabels(pullRequest, files), + labelSet, + ); +} + +export async function runBarnacleAutoResponse({ github, context, core = console }) { + const target = context.payload.issue ?? context.payload.pull_request; + if (!target) { + return; + } + + const labelSet = new Set( + (target.labels ?? []) + .map((label) => (typeof label === "string" ? label : label?.name)) + .filter((name) => typeof name === "string"), + ); + + const issue = context.payload.issue; + const pullRequest = context.payload.pull_request; + const comment = context.payload.comment; + const isMaintainer = createMaintainerChecker(github, context); + + if (comment) { + const authorLogin = comment.user?.login ?? ""; + if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) { + return; + } + + const commentBody = comment.body ?? ""; + const responses = []; + const mentionCount = await countMaintainerMentions( + commentBody, + authorLogin, + isMaintainer, + context.repo.owner, + ); + if (mentionCount >= 3) { + responses.push(pingWarningMessage); + } + + const commentHaystack = commentBody.toLowerCase(); + const commentRule = rules.find((item) => + (item.commentTriggers ?? []).some((trigger) => commentHaystack.includes(trigger)), + ); + if (commentRule) { + responses.push(commentRule.message); + } + + if (responses.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + body: responses.join("\n\n"), + }); + } + return; + } + + if (issue) { + const action = context.payload.action; + if (action === "opened" || action === "edited") { + const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim(); + const authorLogin = issue.user?.login ?? ""; + const mentionCount = await countMaintainerMentions( + issueText, + authorLogin, + isMaintainer, + context.repo.owner, + ); + if (mentionCount >= 3) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: pingWarningMessage, + }); + } + + await syncBugSubtypeLabel(github, context, issue, labelSet); + } + } + + const hasTriggerLabel = labelSet.has(triggerLabel); + if (hasTriggerLabel) { + labelSet.delete(triggerLabel); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: target.number, + name: triggerLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + + const isLabelEvent = context.payload.action === "labeled"; + const isPrCandidateEvent = + pullRequest && + ["opened", "edited", "synchronize", "reopened", "labeled"].includes(context.payload.action); + if (!hasTriggerLabel && !isLabelEvent && !isPrCandidateEvent) { + return; + } + + if (issue) { + const title = issue.title ?? ""; + const body = issue.body ?? ""; + const haystack = `${title}\n${body}`.toLowerCase(); + const hasMoltbookLabel = labelSet.has("r: moltbook"); + const hasTestflightLabel = labelSet.has("r: testflight"); + const hasSecurityLabel = labelSet.has("security"); + if (title.toLowerCase().includes("security") && !hasSecurityLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["security"], + }); + labelSet.add("security"); + } + if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["r: testflight"], + }); + labelSet.add("r: testflight"); + } + if (haystack.includes("moltbook") && !hasMoltbookLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["r: moltbook"], + }); + labelSet.add("r: moltbook"); + } + } + + await syncManagedLabels(github, context); + + if (pullRequest) { + if (labelSet.has(badBarnacleLabel)) { + core.info( + `Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`, + ); + return; + } + + await applyPullRequestCandidateLabels(github, context, core, pullRequest, labelSet); + + if (labelSet.has(dirtyLabel)) { + 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, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + lock_reason: "spam", + }); + return; + } + if (labelSet.has(invalidLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + } + + if (issue && labelSet.has(spamLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + lock_reason: "spam", + }); + return; + } + + if (issue && labelSet.has(invalidLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + return; + } + + if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) { + labelSet.delete(activePrLimitLabel); + } + + const rule = rules.find((item) => labelSet.has(item.label)); + if (!rule) { + return; + } + + const issueNumber = target.number; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: rule.message, + }); + + if (rule.close) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: "closed", + }); + } + + if (rule.lock) { + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + lock_reason: rule.lockReason ?? "resolved", + }); + } +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 8e646445def..be50d2708f8 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -225,6 +225,7 @@ const PRECISE_SOURCE_TEST_TARGETS = new Map([ ], ]); const TOOLING_SOURCE_TEST_TARGETS = new Map([ + ["scripts/github/barnacle-auto-response.mjs", ["test/scripts/barnacle-auto-response.test.ts"]], ["scripts/changed-lanes.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]], @@ -248,6 +249,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]], ]); const TOOLING_TEST_TARGETS = new Map([ + ["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]], ["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]], ["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]], [ diff --git a/test/scripts/barnacle-auto-response.test.ts b/test/scripts/barnacle-auto-response.test.ts new file mode 100644 index 00000000000..404060cea5c --- /dev/null +++ b/test/scripts/barnacle-auto-response.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + candidateLabels, + classifyPullRequestCandidateLabels, + managedLabelSpecs, +} from "../../scripts/github/barnacle-auto-response.mjs"; + +const blankTemplateBody = [ + "## Summary", + "", + "Describe the problem and fix in 2–5 bullets:", + "", + "- Problem:", + "- Why it matters:", + "- What changed:", + "- What did NOT change (scope boundary):", + "", + "## Linked Issue/PR", + "", + "- Closes #", + "- Related #", + "", + "## Root Cause (if applicable)", + "", + "- Root cause:", + "", + "## Regression Test Plan (if applicable)", + "", + "- Target test or file:", +].join("\n"); + +function pr(title: string, body = blankTemplateBody) { + return { + title, + body, + }; +} + +function file(filename: string, status = "modified") { + return { + filename, + status, + }; +} + +describe("barnacle-auto-response", () => { + it("keeps Barnacle-owned labels documented and ClawHub spelled correctly", () => { + expect(managedLabelSpecs["r: skill"].description).toContain("ClawHub"); + expect(managedLabelSpecs["r: skill"].description).not.toContain("Clawdhub"); + expect(managedLabelSpecs.dirty.description).toContain("dirty/unrelated"); + expect(managedLabelSpecs["r: support"].description).toContain("support requests"); + expect(managedLabelSpecs["r: third-party-extension"].description).toContain("ClawHub"); + expect(managedLabelSpecs["r: too-many-prs"].description).toContain("ten active PRs"); + + for (const label of Object.values(candidateLabels)) { + expect(managedLabelSpecs[label]).toBeDefined(); + expect(managedLabelSpecs[label].description).toMatch(/^Candidate:/); + } + }); + + it("labels docs-only discoverability churn without closing it", () => { + const labels = classifyPullRequestCandidateLabels(pr("Update README translation"), [ + file("README.md"), + ]); + + expect(labels).toEqual( + expect.arrayContaining([ + candidateLabels.blankTemplate, + candidateLabels.lowSignalDocs, + candidateLabels.docsDiscoverability, + ]), + ); + }); + + it("does not treat template boilerplate as behavior evidence for test-only churn", () => { + const labels = classifyPullRequestCandidateLabels(pr("Add test coverage"), [ + file("src/gateway/foo.test.ts"), + ]); + + expect(labels).toEqual( + expect.arrayContaining([candidateLabels.blankTemplate, candidateLabels.testOnlyNoBug]), + ); + }); + + it("uses linked issues as context and suppresses low-signal docs labels", () => { + const labels = classifyPullRequestCandidateLabels( + pr("Update docs", `${blankTemplateBody}\n\nRelated #12345`), + [file("docs/plugins/community.md")], + ); + + expect(labels).not.toContain(candidateLabels.lowSignalDocs); + expect(labels).not.toContain(candidateLabels.docsDiscoverability); + }); + + it("warns on broad high-surface PRs instead of auto-closing them as dirty", () => { + const labels = classifyPullRequestCandidateLabels(pr("Cleanup plugin docs"), [ + file("ui/src/app.ts"), + file("src/gateway/server.ts"), + file("extensions/slack/src/index.ts"), + file("docs/plugins/community.md"), + ]); + + expect(labels).toContain(candidateLabels.dirtyCandidate); + }); + + it("suppresses dirty-candidate when the PR has concrete behavior context", () => { + const body = [ + "- Problem: gateway crashes when plugin metadata is missing", + "- Why it matters: users lose the running session", + "- What changed: add a guard around metadata loading", + ].join("\n"); + + const labels = classifyPullRequestCandidateLabels(pr("Fix gateway crash", body), [ + file("ui/src/app.ts"), + file("src/gateway/server.ts"), + file("extensions/slack/src/index.ts"), + file("docs/plugins/community.md"), + ]); + + expect(labels).not.toContain(candidateLabels.dirtyCandidate); + }); +});