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", }); }