mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
467 lines
17 KiB
YAML
467 lines
17 KiB
YAML
name: Auto response
|
||
|
||
on:
|
||
issues:
|
||
types: [opened, edited, labeled]
|
||
issue_comment:
|
||
types: [created]
|
||
pull_request_target:
|
||
types: [labeled]
|
||
|
||
permissions: {}
|
||
|
||
jobs:
|
||
auto-response:
|
||
permissions:
|
||
issues: write
|
||
pull-requests: write
|
||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||
steps:
|
||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||
id: app-token
|
||
continue-on-error: true
|
||
with:
|
||
app-id: "2729701"
|
||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||
id: app-token-fallback
|
||
if: steps.app-token.outcome == 'failure'
|
||
with:
|
||
app-id: "2971289"
|
||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||
- name: Handle labeled items
|
||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||
with:
|
||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||
script: |
|
||
// Labels prefixed with "r:" are auto-response triggers.
|
||
const rules = [
|
||
{
|
||
label: "r: skill",
|
||
close: true,
|
||
message:
|
||
"Thanks for the contribution! New skills should be published to [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: testflight",
|
||
close: true,
|
||
commentTriggers: ["testflight"],
|
||
message: "Not available, build from source.",
|
||
},
|
||
{
|
||
label: "r: third-party-extension",
|
||
close: true,
|
||
message:
|
||
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
|
||
},
|
||
{
|
||
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 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 ensureLabelExists = async (name, color, description) => {
|
||
try {
|
||
await github.rest.issues.getLabel({
|
||
owner: context.repo.owner,
|
||
repo: context.repo.repo,
|
||
name,
|
||
});
|
||
} 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 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 ensureLabelExists(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 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;
|
||
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";
|
||
if (!hasTriggerLabel && !isLabelEvent) {
|
||
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 dirtyLabel = "dirty";
|
||
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.";
|
||
|
||
if (pullRequest) {
|
||
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;
|
||
}
|
||
const labelCount = labelSet.size;
|
||
if (labelCount > 20) {
|
||
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(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(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;
|
||
}
|
||
|
||
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",
|
||
});
|
||
}
|