name: Auto response

on:
  issues:
    types: [opened, edited, labeled]
  issue_comment:
    types: [created]
  pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
    types: [labeled]

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions: {}

jobs:
  auto-response:
    permissions:
      issues: write
      pull-requests: write
    runs-on: blacksmith-16vcpu-ubuntu-2404
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        continue-on-error: true
        with:
          app-id: "2729701"
          private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
      - uses: actions/create-github-app-token@v2
        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@v8
        with:
          github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
          script: |
            // Labels prefixed with "r:" are auto-response triggers.
            const activePrLimit = 10;
            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: no-ci-pr",
                close: true,
                message:
                  "Please don't make PRs for test failures on main.\n\n" +
                  "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
                  "Thank you.",
              },
              {
                label: "r: too-many-prs",
                close: true,
                message:
                  `Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` +
                  "Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.",
              },
              {
                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 activePrLimitLabel = "r: too-many-prs";
            const activePrLimitOverrideLabel = "r: too-many-prs-override";
            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 spamLabel = "r: spam";
            const dirtyLabel = "dirty";
            const badBarnacleLabel = "bad-barnacle";
            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) {
              // `bad-barnacle` exempts PRs that Barnacle incorrectly marked dirty.
              if (labelSet.has(dirtyLabel) && !labelSet.has(badBarnacleLabel)) {
                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(spamLabel)) {
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  state: "closed",
                });
                await github.rest.issues.lock({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pullRequest.number,
                  lock_reason: "spam",
                });
                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(spamLabel)) {
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                state: "closed",
                state_reason: "not_planned",
              });
              await github.rest.issues.lock({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: issue.number,
                lock_reason: "spam",
              });
              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;
            }

            if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
              labelSet.delete(activePrLimitLabel);
            }

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