mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
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.
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -44,14 +44,20 @@ function file(filename: string, status = "modified") {
|
||||
};
|
||||
}
|
||||
|
||||
function barnacleContext(pullRequest: Record<string, unknown>, labels: string[] = []) {
|
||||
function barnacleContext(
|
||||
pullRequest: Record<string, unknown>,
|
||||
labels: string[] = [],
|
||||
options: Record<string, unknown> = {},
|
||||
) {
|
||||
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<string, unknown>, labels: string[]
|
||||
function barnacleGithub(files: ReturnType<typeof file>[]) {
|
||||
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<typeof file>[]) {
|
||||
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<typeof file>[]) {
|
||||
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" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user