503 lines
14 KiB
JavaScript
503 lines
14 KiB
JavaScript
/* @vitest-environment node */
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
candidateLabels,
|
|
classifyPullRequestCandidateLabels,
|
|
managedLabelSpecs,
|
|
runBarnacleAutoResponse,
|
|
} from "./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:",
|
|
"",
|
|
"## Linked Issue/PR",
|
|
"",
|
|
"- Closes #",
|
|
"- Related #",
|
|
"",
|
|
"## Root Cause",
|
|
"",
|
|
"- Root cause:",
|
|
"",
|
|
"## Test Plan",
|
|
"",
|
|
"- Target test or file:",
|
|
].join("\n");
|
|
|
|
function pr(title, body = blankTemplateBody) {
|
|
return {
|
|
title,
|
|
body,
|
|
};
|
|
}
|
|
|
|
function file(filename, status = "modified") {
|
|
return {
|
|
filename,
|
|
status,
|
|
};
|
|
}
|
|
|
|
function barnacleContext(pullRequest, labels = [], options = {}) {
|
|
return {
|
|
repo: {
|
|
owner: "openclaw",
|
|
repo: "clawhub",
|
|
},
|
|
payload: {
|
|
action: options.action ?? "opened",
|
|
label: options.label,
|
|
sender: options.sender,
|
|
pull_request: {
|
|
number: 123,
|
|
title: "Cleanup docs",
|
|
body: blankTemplateBody,
|
|
author_association: "CONTRIBUTOR",
|
|
user: {
|
|
login: "contributor",
|
|
},
|
|
labels: labels.map((name) => ({ name })),
|
|
...pullRequest,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function barnacleIssueContext(issue, labels = [], options = {}) {
|
|
return {
|
|
repo: {
|
|
owner: "openclaw",
|
|
repo: "clawhub",
|
|
},
|
|
payload: {
|
|
action: options.action ?? "opened",
|
|
label: options.label,
|
|
sender: options.sender,
|
|
issue: {
|
|
number: 456,
|
|
title: "ClawHub issue",
|
|
body: "",
|
|
state: "open",
|
|
user: {
|
|
login: "reporter",
|
|
},
|
|
labels: labels.map((name) => ({ name })),
|
|
...issue,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function barnacleGithub(files) {
|
|
const calls = {
|
|
addLabels: [],
|
|
createComment: [],
|
|
createLabel: [],
|
|
lock: [],
|
|
removeLabel: [],
|
|
update: [],
|
|
updateLabel: [],
|
|
};
|
|
const github = {
|
|
paginate: async () => files,
|
|
rest: {
|
|
issues: {
|
|
addLabels: async (params) => {
|
|
calls.addLabels.push(params);
|
|
},
|
|
createComment: async (params) => {
|
|
calls.createComment.push(params);
|
|
},
|
|
createLabel: async (params) => {
|
|
calls.createLabel.push(params);
|
|
},
|
|
getLabel: async (params) => ({
|
|
data: {
|
|
color: managedLabelSpecs[params.name]?.color ?? "C5DEF5",
|
|
description: managedLabelSpecs[params.name]?.description ?? "",
|
|
},
|
|
}),
|
|
lock: async (params) => {
|
|
calls.lock.push(params);
|
|
},
|
|
removeLabel: async (params) => {
|
|
calls.removeLabel.push(params);
|
|
},
|
|
update: async (params) => {
|
|
calls.update.push(params);
|
|
},
|
|
updateLabel: async (params) => {
|
|
calls.updateLabel.push(params);
|
|
},
|
|
},
|
|
pulls: {
|
|
listFiles: async () => files,
|
|
},
|
|
repos: {
|
|
getCollaboratorPermissionLevel: async () => ({
|
|
data: {
|
|
role_name: "read",
|
|
},
|
|
}),
|
|
},
|
|
teams: {
|
|
getMembershipForUserInOrg: async () => {
|
|
const error = new Error("not found");
|
|
error.status = 404;
|
|
throw error;
|
|
},
|
|
},
|
|
},
|
|
};
|
|
return { calls, github };
|
|
}
|
|
|
|
describe("barnacle-auto-response", () => {
|
|
it("keeps Barnacle-owned labels documented for ClawHub", () => {
|
|
expect(managedLabelSpecs["r: support"].description).toContain("support requests");
|
|
expect(managedLabelSpecs["r: direct-skill-content"].description).toContain("published");
|
|
expect(managedLabelSpecs["r: third-party-skill-issue"].description).toContain("publisher");
|
|
expect(managedLabelSpecs["r: paid-skill"].description).toContain("paid skills");
|
|
|
|
for (const label of Object.values(candidateLabels)) {
|
|
expect(managedLabelSpecs[label]).toBeDefined();
|
|
expect(managedLabelSpecs[label].description).toMatch(/^Candidate:/);
|
|
}
|
|
});
|
|
|
|
it("labels low-signal docs without closing on bot-applied labels", async () => {
|
|
const labels = classifyPullRequestCandidateLabels(pr("Update README translation"), [
|
|
file("README.md"),
|
|
]);
|
|
|
|
expect(labels).toEqual(
|
|
expect.arrayContaining([candidateLabels.blankTemplate, candidateLabels.lowSignalDocs]),
|
|
);
|
|
|
|
const { calls, github } = barnacleGithub([file("README.md")]);
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleContext({}, [candidateLabels.lowSignalDocs], {
|
|
action: "labeled",
|
|
label: { name: candidateLabels.lowSignalDocs },
|
|
sender: { login: "github-actions[bot]", type: "Bot" },
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.update).toEqual([]);
|
|
expect(calls.createComment).toEqual([]);
|
|
});
|
|
|
|
it("labels direct skill content as a ClawHub publishing bypass", () => {
|
|
const labels = classifyPullRequestCandidateLabels(pr("Add useful browser skill"), [
|
|
file("skills/browser/SKILL.md", "added"),
|
|
]);
|
|
|
|
expect(labels).toContain(candidateLabels.directSkillContent);
|
|
});
|
|
|
|
it("does not treat repo-local developer skills as published skill content", () => {
|
|
const labels = classifyPullRequestCandidateLabels(pr("Update Convex helper skill"), [
|
|
file(".agents/skills/convex/SKILL.md", "modified"),
|
|
]);
|
|
|
|
expect(labels).not.toContain(candidateLabels.directSkillContent);
|
|
});
|
|
|
|
it("ignores unchecked PR template checklist entries when classifying refactors", () => {
|
|
const body = [
|
|
"## Summary",
|
|
"- Adds GET /api/v1/stars and a list-stars CLI command.",
|
|
"",
|
|
"## Type of change",
|
|
"- [x] New feature",
|
|
"- [ ] Refactor",
|
|
"- [ ] Documentation",
|
|
"",
|
|
"## Test Plan",
|
|
"- bun run test",
|
|
].join("\n");
|
|
const labels = classifyPullRequestCandidateLabels(pr("feat: add stars API", body), [
|
|
file("src/routes/api/v1/stars.ts"),
|
|
file("packages/clawhub/src/commands/list-stars.ts"),
|
|
]);
|
|
|
|
expect(labels).not.toContain(candidateLabels.refactorOnly);
|
|
});
|
|
|
|
it("uses linked issues as context and suppresses low-signal docs labels", () => {
|
|
const labels = classifyPullRequestCandidateLabels(
|
|
pr("Update docs", `${blankTemplateBody}\n\nRelated #123`),
|
|
[file("docs/skill-format.md")],
|
|
);
|
|
|
|
expect(labels).not.toContain(candidateLabels.lowSignalDocs);
|
|
});
|
|
|
|
it("warns on broad high-surface PRs instead of auto-closing them immediately", async () => {
|
|
const { calls, github } = barnacleGithub([
|
|
file("src/routes/index.tsx"),
|
|
file("convex/skills.ts"),
|
|
file("server/routes/og/skill.png.tsx"),
|
|
file("docs/skill-format.md"),
|
|
]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleContext({}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toContainEqual(
|
|
expect.objectContaining({
|
|
labels: expect.arrayContaining([candidateLabels.dirtyCandidate]),
|
|
}),
|
|
);
|
|
expect(calls.createComment).toEqual([]);
|
|
expect(calls.update).toEqual([]);
|
|
});
|
|
|
|
it("does not add candidate labels to maintainer-authored PRs", async () => {
|
|
const { calls, github } = barnacleGithub([
|
|
file("src/routes/index.tsx"),
|
|
file("convex/skills.ts"),
|
|
file("server/routes/og/skill.png.tsx"),
|
|
file("docs/skill-format.md"),
|
|
]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleContext({
|
|
author_association: "OWNER",
|
|
user: {
|
|
login: "maintainer",
|
|
},
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toEqual([]);
|
|
});
|
|
|
|
it("actions manually applied candidate labels", async () => {
|
|
const { calls, github } = barnacleGithub([file("skills/browser/SKILL.md", "added")]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleContext({}, [candidateLabels.directSkillContent], {
|
|
action: "labeled",
|
|
label: { name: candidateLabels.directSkillContent },
|
|
sender: { login: "maintainer", type: "User" },
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.createComment).toContainEqual(
|
|
expect.objectContaining({
|
|
body: expect.stringContaining("published through ClawHub"),
|
|
}),
|
|
);
|
|
expect(calls.update).toContainEqual(expect.objectContaining({ state: "closed" }));
|
|
});
|
|
|
|
it("closes explicit support-labeled issues", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: {
|
|
repo: {
|
|
owner: "openclaw",
|
|
repo: "clawhub",
|
|
},
|
|
payload: {
|
|
action: "labeled",
|
|
issue: {
|
|
number: 456,
|
|
title: "Need help installing a skill",
|
|
body: "",
|
|
user: {
|
|
login: "user",
|
|
},
|
|
labels: [{ name: "r: support" }],
|
|
},
|
|
},
|
|
},
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.createComment).toContainEqual(
|
|
expect.objectContaining({
|
|
issue_number: 456,
|
|
body: expect.stringContaining("community support server"),
|
|
}),
|
|
);
|
|
expect(calls.update).toContainEqual(
|
|
expect.objectContaining({
|
|
issue_number: 456,
|
|
state: "closed",
|
|
state_reason: "not_planned",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("closes third-party skill issues when maintainers apply the manual label", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleIssueContext(
|
|
{
|
|
title: "CamelCamelCamel Alerts Skill",
|
|
body: "This published skill still needs publisher-side cleanup.",
|
|
},
|
|
["r: third-party-skill-issue"],
|
|
{
|
|
action: "labeled",
|
|
label: { name: "r: third-party-skill-issue" },
|
|
sender: { login: "maintainer", type: "User" },
|
|
},
|
|
),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.createComment).toContainEqual(
|
|
expect.objectContaining({
|
|
issue_number: 456,
|
|
body: expect.stringContaining("third-party skill"),
|
|
}),
|
|
);
|
|
expect(calls.update).toContainEqual(
|
|
expect.objectContaining({
|
|
issue_number: 456,
|
|
state: "closed",
|
|
state_reason: "not_planned",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("routes direct skill submission issues through the publishing guidance rule", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleIssueContext({
|
|
title: "Awesome-Gaussian-Skills: Universal AI Agent Skill Pack",
|
|
body: [
|
|
"**Can this skill be added?**",
|
|
"",
|
|
"Project URL: https://github.com/example/awesome-gaussian-skills",
|
|
"",
|
|
"Please give it a try and star the repo.",
|
|
].join("\n"),
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toContainEqual(
|
|
expect.objectContaining({
|
|
labels: expect.arrayContaining(["r: direct-skill-content"]),
|
|
}),
|
|
);
|
|
expect(calls.createComment).toContainEqual(
|
|
expect.objectContaining({
|
|
body: expect.stringContaining("published through ClawHub"),
|
|
}),
|
|
);
|
|
expect(calls.update).toContainEqual(expect.objectContaining({ state: "closed" }));
|
|
});
|
|
|
|
it("labels rescan review requests for the dedicated rescan guidance workflow", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleIssueContext({
|
|
title: "[Skill Review Request] claw-calendar marked as suspicious.llm_suspicious",
|
|
body: [
|
|
"Please re-review this ClawHub skill after metadata fixes.",
|
|
"The skill is still flagged suspicious by the scanner.",
|
|
"Skill URL: https://clawhub.ai/openclaw/claw-calendar",
|
|
].join("\n"),
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toContainEqual(
|
|
expect.objectContaining({
|
|
labels: expect.arrayContaining(["r: rescan-guidance"]),
|
|
}),
|
|
);
|
|
expect(calls.createComment).toEqual([]);
|
|
expect(calls.update).toEqual([]);
|
|
});
|
|
|
|
it("labels obvious security reports even when security is not in the title", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleIssueContext({
|
|
title: "Skill package exposes plaintext credentials",
|
|
body: "A published skill appears to hardcode a live API token in the package.",
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toContainEqual(
|
|
expect.objectContaining({
|
|
labels: expect.arrayContaining(["security"]),
|
|
}),
|
|
);
|
|
expect(calls.createComment).toEqual([]);
|
|
expect(calls.update).toEqual([]);
|
|
});
|
|
|
|
it("keeps the existing title-based security issue label behavior", async () => {
|
|
const { calls, github } = barnacleGithub([]);
|
|
|
|
await runBarnacleAutoResponse({
|
|
github,
|
|
context: barnacleIssueContext({
|
|
title: "Security report for published skill",
|
|
body: "Please review.",
|
|
}),
|
|
core: {
|
|
info: () => undefined,
|
|
},
|
|
});
|
|
|
|
expect(calls.addLabels).toContainEqual(
|
|
expect.objectContaining({
|
|
labels: expect.arrayContaining(["security"]),
|
|
}),
|
|
);
|
|
});
|
|
});
|