From 546974017038b11eb9210d048de71fea2072bbd4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 18:49:29 -0700 Subject: [PATCH] fix(github): exempt maintainers from barnacle candidate labels --- scripts/github/barnacle-auto-response.mjs | 66 ++++++++- test/scripts/barnacle-auto-response.test.ts | 154 ++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/scripts/github/barnacle-auto-response.mjs b/scripts/github/barnacle-auto-response.mjs index b897aa5ff5f..2c2c4c31129 100644 --- a/scripts/github/barnacle-auto-response.mjs +++ b/scripts/github/barnacle-auto-response.mjs @@ -180,6 +180,8 @@ const invalidLabel = "invalid"; const spamLabel = "r: spam"; const dirtyLabel = "dirty"; const badBarnacleLabel = "bad-barnacle"; +const maintainerAuthorLabel = "maintainer"; +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."; @@ -545,6 +547,32 @@ function createMaintainerChecker(github, context) { }; } +async function isPrivilegedPullRequestAuthor(github, context, pullRequest, labelSet, isMaintainer) { + const authorLogin = pullRequest.user?.login ?? ""; + if (labelSet.has(maintainerAuthorLabel) || pullRequest.author_association === "OWNER") { + return true; + } + if (authorLogin && (await isMaintainer(authorLogin))) { + return true; + } + + try { + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: authorLogin, + }); + const roleName = (permission?.data?.role_name ?? "").toLowerCase(); + return roleName === "admin" || roleName === "maintain"; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + + return false; +} + async function countMaintainerMentions(body, authorLogin, isMaintainer, owner) { if (!body) { return 0; @@ -615,6 +643,27 @@ async function applyPullRequestCandidateLabels(github, context, core, pullReques ); } +async function removeLabels(github, context, issueNumber, labels, labelSet) { + for (const label of labels) { + if (!labelSet.has(label)) { + continue; + } + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label, + }); + labelSet.delete(label); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } +} + export async function runBarnacleAutoResponse({ github, context, core = console }) { const target = context.payload.issue ?? context.payload.pull_request; if (!target) { @@ -764,7 +813,22 @@ export async function runBarnacleAutoResponse({ github, context, core = console return; } - await applyPullRequestCandidateLabels(github, context, core, pullRequest, labelSet); + const isMaintainerAuthoredPullRequest = await isPrivilegedPullRequestAuthor( + github, + context, + pullRequest, + labelSet, + isMaintainer, + ); + if (isMaintainerAuthoredPullRequest) { + await removeLabels(github, context, pullRequest.number, candidateLabelValues, labelSet); + await removeLabels(github, context, pullRequest.number, [activePrLimitLabel], labelSet); + core.info( + `Skipping Barnacle candidate labels for maintainer-authored PR #${pullRequest.number}.`, + ); + } else { + await applyPullRequestCandidateLabels(github, context, core, pullRequest, labelSet); + } if (labelSet.has(dirtyLabel)) { await github.rest.issues.createComment({ diff --git a/test/scripts/barnacle-auto-response.test.ts b/test/scripts/barnacle-auto-response.test.ts index 404060cea5c..a8823970f47 100644 --- a/test/scripts/barnacle-auto-response.test.ts +++ b/test/scripts/barnacle-auto-response.test.ts @@ -3,6 +3,7 @@ import { candidateLabels, classifyPullRequestCandidateLabels, managedLabelSpecs, + runBarnacleAutoResponse, } from "../../scripts/github/barnacle-auto-response.mjs"; const blankTemplateBody = [ @@ -43,6 +44,80 @@ function file(filename: string, status = "modified") { }; } +function barnacleContext(pullRequest: Record, labels: string[] = []) { + return { + repo: { + owner: "openclaw", + repo: "openclaw", + }, + payload: { + action: "opened", + pull_request: { + number: 123, + title: "Cleanup plugin docs", + body: blankTemplateBody, + author_association: "CONTRIBUTOR", + user: { + login: "contributor", + }, + labels: labels.map((name) => ({ name })), + ...pullRequest, + }, + }, + }; +} + +function barnacleGithub(files: ReturnType[]) { + const calls = { + addLabels: [] as Array<{ issue_number: number; labels: string[] }>, + removeLabel: [] as Array<{ issue_number: number; name: string }>, + }; + const github = { + paginate: async () => files, + rest: { + issues: { + addLabels: async (params: { issue_number: number; labels: string[] }) => { + calls.addLabels.push(params); + }, + createComment: async () => undefined, + createLabel: async () => undefined, + getLabel: async (params: { name: string }) => ({ + data: { + color: + managedLabelSpecs[params.name as keyof typeof managedLabelSpecs]?.color ?? "C5DEF5", + description: + managedLabelSpecs[params.name as keyof typeof managedLabelSpecs]?.description ?? "", + }, + }), + lock: async () => undefined, + removeLabel: async (params: { issue_number: number; name: string }) => { + calls.removeLabel.push(params); + }, + update: async () => undefined, + updateLabel: async () => undefined, + }, + pulls: { + listFiles: async () => files, + }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { + role_name: "read", + }, + }), + }, + teams: { + getMembershipForUserInOrg: async () => { + const error = new Error("not found") as Error & { status: number }; + error.status = 404; + throw error; + }, + }, + }, + }; + return { calls, github }; +} + describe("barnacle-auto-response", () => { it("keeps Barnacle-owned labels documented and ClawHub spelled correctly", () => { expect(managedLabelSpecs["r: skill"].description).toContain("ClawHub"); @@ -119,4 +194,83 @@ describe("barnacle-auto-response", () => { expect(labels).not.toContain(candidateLabels.dirtyCandidate); }); + + it("does not add candidate labels to maintainer-authored PRs", async () => { + const { calls, github } = barnacleGithub([ + file("ui/src/app.ts"), + file("src/gateway/server.ts"), + file("extensions/slack/src/index.ts"), + file("docs/plugins/community.md"), + ]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext({ + author_association: "OWNER", + user: { + login: "maintainer", + }, + }), + core: { + info: () => undefined, + }, + }); + + expect(calls.addLabels).toEqual([]); + }); + + it("removes stale Barnacle candidate and PR-limit labels from maintainer-authored PRs", async () => { + const { calls, github } = barnacleGithub([ + file("ui/src/app.ts"), + file("src/gateway/server.ts"), + file("extensions/slack/src/index.ts"), + file("docs/plugins/community.md"), + ]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext( + { + author_association: "OWNER", + user: { + login: "maintainer", + }, + }, + [candidateLabels.dirtyCandidate, "r: too-many-prs"], + ), + core: { + info: () => undefined, + }, + }); + + expect(calls.removeLabel).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: candidateLabels.dirtyCandidate }), + expect.objectContaining({ name: "r: too-many-prs" }), + ]), + ); + }); + + it("still adds candidate labels to broad contributor PRs", async () => { + const { calls, github } = barnacleGithub([ + file("ui/src/app.ts"), + file("src/gateway/server.ts"), + file("extensions/slack/src/index.ts"), + file("docs/plugins/community.md"), + ]); + + await runBarnacleAutoResponse({ + github, + context: barnacleContext({}), + core: { + info: () => undefined, + }, + }); + + expect(calls.addLabels).toContainEqual( + expect.objectContaining({ + labels: expect.arrayContaining([candidateLabels.dirtyCandidate]), + }), + ); + }); });