fix(github): exempt maintainers from barnacle candidate labels

This commit is contained in:
Vincent Koc
2026-04-25 18:49:29 -07:00
parent 105785a1be
commit 5469740170
2 changed files with 219 additions and 1 deletions

View File

@@ -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({

View File

@@ -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<string, unknown>, 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<typeof file>[]) {
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]),
}),
);
});
});