From fe9d37357a8b2d0857c92f9bd67bc55600778be7 Mon Sep 17 00:00:00 2001 From: lixin Date: Fri, 20 Mar 2026 14:42:55 +0800 Subject: [PATCH 1/2] Improve GitHub auto-response bot --- .github/ISSUE_TEMPLATE/bug_report.yml | 11 + .github/ISSUE_TEMPLATE/bug_report_cn.yml | 11 + .github/auto-response-config.json | 243 ++++++++ .github/scripts/auto-response.cjs | 730 +++++++++++++++++++++++ .github/workflows/auto-response.yml | 355 +---------- CONTRIBUTING.md | 1 + 6 files changed, 1004 insertions(+), 347 deletions(-) create mode 100644 .github/auto-response-config.json create mode 100644 .github/scripts/auto-response.cjs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6c8389e..0d72f1e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,6 +16,17 @@ body: placeholder: When running X command, error Y occurs... validations: required: true + - type: dropdown + id: bug_type + attributes: + label: Bug type + options: + - Regression (worked before, now fails) + - Crash (process/app exits or hangs) + - Behavior bug (incorrect output/state without crash) + - Security issue + validations: + required: true - type: textarea id: repro attributes: diff --git a/.github/ISSUE_TEMPLATE/bug_report_cn.yml b/.github/ISSUE_TEMPLATE/bug_report_cn.yml index 2ab6d80..e6a1f7c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_cn.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_cn.yml @@ -16,6 +16,17 @@ body: placeholder: 执行某命令时出现错误... validations: required: true + - type: dropdown + id: bug_type + attributes: + label: 问题类型 + options: + - 回归问题(以前正常,现在失败) + - 崩溃问题(进程退出或卡死) + - 行为错误(结果或状态错误,但未崩溃) + - 安全问题 + validations: + required: true - type: textarea id: repro attributes: diff --git a/.github/auto-response-config.json b/.github/auto-response-config.json new file mode 100644 index 0000000..d6b3f75 --- /dev/null +++ b/.github/auto-response-config.json @@ -0,0 +1,243 @@ +{ + "links": { + "discussions": "https://github.com/AI-Shell-Team/aish/discussions", + "documentation": "https://github.com/AI-Shell-Team/aish/blob/main/README.md", + "contributing": "https://github.com/AI-Shell-Team/aish/blob/main/CONTRIBUTING.md" + }, + "welcome": { + "issue": { + "marker": "issue-welcome", + "body": "Thanks for opening this issue. A maintainer will review it when available.\n\nBefore that, please make sure the report includes clear reproduction steps, expected behavior, actual behavior, and environment details.\n\nContribution guide: {{contributing}}", + "first_time_suffix": "It looks like this is your first issue in this repository. Thanks for contributing." + }, + "pull_request": { + "marker": "pr-welcome", + "body": "Thanks for the pull request. A maintainer will review it when available.\n\nPlease keep the PR focused, explain the why in the description, and make sure local checks pass before requesting review.\n\nContribution guide: {{contributing}}", + "first_time_suffix": "It looks like this is your first pull request in this repository. Thanks for contributing." + } + }, + "spam": { + "mention_threshold": 2, + "issue": { + "marker": "issue-mention-warning", + "body": "Please avoid spamming mentions in issues. Be concise and specific about the problem." + }, + "comment": { + "marker": "comment-mention-warning", + "body": "Please avoid spamming mentions. Be patient, or use GitHub Discussions for help." + } + }, + "label_rules": [ + { + "label": "r: support", + "marker": "label-support", + "close": true, + "body": "Please use GitHub Discussions for support questions. You can also check the documentation: https://github.com/AI-Shell-Team/aish/blob/main/README.md" + }, + { + "label": "r: duplicate", + "marker": "label-duplicate", + "close": true, + "body": "This issue has been marked as a duplicate. Please continue the discussion in the linked issue." + }, + { + "label": "r: wontfix", + "marker": "label-wontfix", + "close": true, + "body": "This issue has been marked as \"wontfix\". This means we reviewed it and decided not to implement the requested feature or fix. Thank you for your understanding." + }, + { + "label": "r: question", + "marker": "label-question", + "close": true, + "body": "This appears to be a question rather than a bug report or feature request. Please use GitHub Discussions for questions." + } + ], + "guards": { + "trigger_label": "trigger-response", + "invalid_label": "invalid", + "dirty_label": "dirty", + "security_label": "security", + "pull_request_max_labels": 20, + "dirty_pull_request": { + "marker": "pr-dirty-close", + "body": "Closing this PR because it looks dirty (too many unrelated or unexpected changes). Please recreate the PR from a clean branch." + } + }, + "template_checks": { + "issue": { + "marker": "issue-template-incomplete", + "body": "This issue looks incomplete. Please update it with the missing information below so maintainers can triage it effectively.\n\nMissing items:\n{{missing_items}}", + "missing_template_body": "This issue does not appear to use one of the repository templates. Please edit it using the bug report or feature request structure so maintainers can triage it effectively.\n\nContribution guide: {{contributing}}", + "resolved_body": "Template check passed. Thanks for filling in the missing issue details." + }, + "pull_request": { + "marker": "pr-template-incomplete", + "body": "This pull request description looks incomplete. Please update the missing sections below before review.\n\nMissing items:\n{{missing_items}}", + "resolved_body": "Template check passed. Thanks for updating the pull request description." + } + }, + "issue_classification": { + "max_labels_to_add": 3, + "exclusive_groups": [ + { + "name": "interaction-surface", + "labels": ["core", "pty", "cli", "builtin"] + }, + { + "name": "agent-surface", + "labels": ["agent", "skills", "tools"] + }, + { + "name": "operations-surface", + "labels": ["config", "packaging", "dependencies", "ci-cd", "observability"] + }, + { + "name": "content-surface", + "labels": ["docs", "i18n", "tests"] + }, + { + "name": "security-surface", + "labels": ["security", "sandbox"] + } + ], + "rules": [ + { + "label": "core", + "color": "1D76DB", + "description": "Core runtime and shared library issue", + "priority": 30, + "group": "interaction-surface", + "keywords": ["core", "runtime", "crash on startup", "shared logic", "主流程", "核心逻辑", "基础能力"] + }, + { + "label": "pty", + "color": "0052CC", + "description": "PTY or interactive process issue", + "priority": 80, + "group": "interaction-surface", + "keywords": ["pty", "tty", "resize", "interactive", "vim", "top", "less", "curses", "终端大小", "交互程序", "tty 模式"] + }, + { + "label": "agent", + "color": "FBCA04", + "description": "Agent or LLM workflow issue", + "priority": 70, + "group": "agent-surface", + "keywords": ["agent", "llm", "model", "prompt", "tool call", "function call", "streaming", "代理", "模型", "提示词", "工具调用", "流式"] + }, + { + "label": "skills", + "color": "BFDADC", + "description": "Skills-related issue", + "priority": 85, + "group": "agent-surface", + "keywords": ["skill", "skills", "技能", "skill hot reload", "技能热加载"] + }, + { + "label": "tools", + "color": "C5DEF5", + "description": "Tool integration issue", + "priority": 75, + "group": "agent-surface", + "keywords": ["tool", "tools", "function tool", "file tool", "bash tool", "工具", "工具调用", "文件工具", "终端工具"] + }, + { + "label": "security", + "color": "D93F0B", + "description": "Security-related issue", + "priority": 90, + "group": "security-surface", + "keywords": ["security", "vulnerability", "sandbox escape", "secret", "credential", "token leak", "安全", "漏洞", "沙箱逃逸", "密钥泄露"] + }, + { + "label": "sandbox", + "color": "B60205", + "description": "Sandbox-related issue", + "priority": 100, + "group": "security-surface", + "keywords": ["sandbox", "seccomp", "isolation", "mount", "namespaces", "sandboxd", "沙箱", "隔离", "挂载", "命名空间"] + }, + { + "label": "config", + "color": "5319E7", + "description": "Configuration-related issue", + "priority": 65, + "group": "operations-surface", + "keywords": ["config", "configuration", "settings", "provider", "endpoint", "base url", ".env", "env var", "api base", "配置", "设置", "环境变量", "提供商", "端点"] + }, + { + "label": "cli", + "color": "6F42C1", + "description": "CLI and shell UX issue", + "priority": 60, + "group": "interaction-surface", + "keywords": ["cli", "command", "shell", "terminal", "prompt", "command line", "命令行", "终端", "shell 模式", "提示符"] + }, + { + "label": "i18n", + "color": "006B75", + "description": "Internationalization-related issue", + "priority": 45, + "group": "content-surface", + "keywords": ["translation", "locale", "i18n", "l10n", "language", "翻译", "多语言", "本地化", "语言包"] + }, + { + "label": "docs", + "color": "0E8A16", + "description": "Documentation-related issue", + "priority": 55, + "group": "content-surface", + "keywords": ["docs", "documentation", "readme", "guide", "tutorial", "typo", "文档", "说明", "教程", "拼写"] + }, + { + "label": "packaging", + "color": "D4C5F9", + "description": "Packaging or installation issue", + "priority": 70, + "group": "operations-surface", + "keywords": ["install", "installer", "package", "deb", "bundle", "build.sh", "打包", "安装", "deb 包", "构建包"] + }, + { + "label": "tests", + "color": "C2E0C6", + "description": "Test coverage or test failure issue", + "priority": 50, + "group": "content-surface", + "keywords": ["test", "tests", "pytest", "failing test", "flaky", "测试", "测试失败", "不稳定测试"] + }, + { + "label": "ci-cd", + "color": "0052CC", + "description": "CI/CD workflow issue", + "priority": 60, + "group": "operations-surface", + "keywords": ["github actions", "workflow", "ci", "pipeline", "release", "artifact", "持续集成", "工作流", "发布流程", "产物"] + }, + { + "label": "dependencies", + "color": "0366D6", + "description": "Dependency management issue", + "priority": 68, + "group": "operations-surface", + "keywords": ["dependency", "dependencies", "uv.lock", "pyproject", "version bump", "upgrade", "依赖", "版本升级", "锁文件"] + }, + { + "label": "observability", + "color": "F9D0C4", + "description": "Logging or observability issue", + "priority": 58, + "group": "operations-surface", + "keywords": ["log", "logging", "trace", "telemetry", "langfuse", "日志", "追踪", "遥测"] + }, + { + "label": "builtin", + "color": "E99695", + "description": "Built-in command or workflow issue", + "priority": 72, + "group": "interaction-surface", + "keywords": ["builtin", "built-in", "slash command", "built in command", "内置命令", "内建命令"] + } + ] + } +} \ No newline at end of file diff --git a/.github/scripts/auto-response.cjs b/.github/scripts/auto-response.cjs new file mode 100644 index 0000000..fed9e04 --- /dev/null +++ b/.github/scripts/auto-response.cjs @@ -0,0 +1,730 @@ +const fs = require("fs"); +const path = require("path"); + +function loadConfig() { + const configPath = path.join(process.cwd(), ".github", "auto-response-config.json"); + return JSON.parse(fs.readFileSync(configPath, "utf8")); +} + +function uniqueLabelNames(labels) { + return new Set( + (labels ?? []) + .map((label) => (typeof label === "string" ? label : label?.name)) + .filter((name) => typeof name === "string" && name.length > 0), + ); +} + +function extractIssueFormValue(body, field) { + if (!body) { + return ""; + } + + const fields = Array.isArray(field) ? field : [field]; + for (const currentField of fields) { + const escapedField = currentField.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp( + `(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`, + "i", + ); + const match = body.match(regex); + if (!match) { + continue; + } + for (const line of match[1].split("\n")) { + const trimmed = line.trim(); + if (trimmed) { + return trimmed; + } + } + } + + return ""; +} + +function extractMarkdownSection(body, heading) { + if (!body) { + return ""; + } + + const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp( + `(?:^|\\n)##\\s+${escapedHeading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s+|$)`, + "i", + ); + const match = body.match(regex); + return match ? match[1].trim() : ""; +} + +function hasMeaningfulContent(value) { + if (!value) { + return false; + } + + const normalized = value + .replace(//g, "") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + if (normalized.length === 0) { + return false; + } + + const substantiveLines = normalized.filter((line) => { + if (["-", "none", "n/a", "na", "无"].includes(line.toLowerCase())) { + return false; + } + if (/^- \[ \]/.test(line)) { + return false; + } + if (/^backward compatible\? \(yes\/no\)$/i.test(line)) { + return false; + } + if (/^config changes\? \(yes\/no/i.test(line)) { + return false; + } + if (/^向后兼容\? \(是\/否\)$/i.test(line)) { + return false; + } + if (/^配置变更\? \(是\/否/i.test(line)) { + return false; + } + return true; + }); + + return substantiveLines.length > 0; +} + +function hasCheckedCheckbox(sectionBody) { + return /- \[[xX]\]/.test(sectionBody ?? ""); +} + +function formatMissingItems(items) { + return items.map((item) => `- ${item}`).join("\n"); +} + +function renderTemplate(template, replacements) { + return Object.entries(replacements).reduce( + (result, [key, value]) => result.replaceAll(`{{${key}}}`, value), + template, + ); +} + +function normalizeText(value) { + return (value ?? "").toLowerCase(); +} + +async function run({ github, context }) { + const config = loadConfig(); + const mentionRegex = /@([A-Za-z0-9-]+)/g; + 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", + }, + "bug:security": { + color: "d93f0b", + description: "Security vulnerability", + }, + }; + 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", + "Security issue": "bug:security", + "回归问题(以前正常,现在失败)": "regression", + "崩溃问题(进程退出或卡死)": "bug:crash", + "行为错误(结果或状态错误,但未崩溃)": "bug:behavior", + "安全问题": "bug:security", + }; + const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs); + const target = context.payload.issue ?? context.payload.pull_request; + + if (!target) { + return; + } + + const issue = context.payload.issue; + const pullRequest = context.payload.pull_request; + const comment = context.payload.comment; + const labelSet = uniqueLabelNames(target.labels); + const repoContext = { + owner: context.repo.owner, + repo: context.repo.repo, + }; + const commentsCache = new Map(); + + async function ensureLabelExists(name, color, description) { + try { + const existing = await github.rest.issues.getLabel({ + ...repoContext, + name, + }); + + if ( + (color && existing.data.color?.toLowerCase() !== color.toLowerCase()) || + (description && (existing.data.description ?? "") !== description) + ) { + await github.rest.issues.updateLabel({ + ...repoContext, + name, + new_name: name, + color: color ?? existing.data.color, + description: description ?? existing.data.description, + }); + } + } catch (error) { + if (error?.status !== 404) { + throw error; + } + await github.rest.issues.createLabel({ + ...repoContext, + name, + color, + description, + }); + } + } + + async function listComments(issueNumber) { + if (!commentsCache.has(issueNumber)) { + const comments = await github.paginate(github.rest.issues.listComments, { + ...repoContext, + issue_number: issueNumber, + per_page: 100, + }); + commentsCache.set(issueNumber, comments); + } + return commentsCache.get(issueNumber); + } + + function markerTag(marker) { + return ``; + } + + async function hasMarker(issueNumber, marker) { + const comments = await listComments(issueNumber); + return comments.some((item) => (item.body ?? "").includes(markerTag(marker))); + } + + async function findMarkedComment(issueNumber, marker) { + const comments = await listComments(issueNumber); + return comments.find((item) => (item.body ?? "").includes(markerTag(marker))); + } + + async function createComment(issueNumber, marker, body) { + const commentBody = `${body}\n\n${markerTag(marker)}`; + const created = await github.rest.issues.createComment({ + ...repoContext, + issue_number: issueNumber, + body: commentBody, + }); + const comments = commentsCache.get(issueNumber); + if (comments) { + comments.push(created.data); + } + } + + async function upsertComment(issueNumber, marker, body) { + const taggedBody = `${body}\n\n${markerTag(marker)}`; + const existing = await findMarkedComment(issueNumber, marker); + if (!existing) { + await createComment(issueNumber, marker, body); + return "created"; + } + + if ((existing.body ?? "") === taggedBody) { + return "unchanged"; + } + + const updated = await github.rest.issues.updateComment({ + ...repoContext, + comment_id: existing.id, + body: taggedBody, + }); + existing.body = updated.data.body; + return "updated"; + } + + async function createCommentOnce(issueNumber, marker, body) { + if (!marker) { + await createComment(issueNumber, `adhoc-${Date.now()}`, body); + return true; + } + if (await hasMarker(issueNumber, marker)) { + return false; + } + await createComment(issueNumber, marker, body); + return true; + } + + async function removeLabel(issueNumber, name) { + try { + await github.rest.issues.removeLabel({ + ...repoContext, + issue_number: issueNumber, + name, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + + async function syncBugSubtypeLabel(currentIssue) { + if (!labelSet.has("bug")) { + return; + } + + const selectedBugType = extractIssueFormValue(currentIssue.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 || !labelSet.has(subtypeLabel)) { + continue; + } + await removeLabel(currentIssue.number, subtypeLabel); + labelSet.delete(subtypeLabel); + } + + if (!labelSet.has(targetLabel)) { + await github.rest.issues.addLabels({ + ...repoContext, + issue_number: currentIssue.number, + labels: [targetLabel], + }); + labelSet.add(targetLabel); + } + } + + async function syncIssueClassificationLabels(currentIssue) { + const classificationConfig = config.issue_classification ?? {}; + const rules = classificationConfig.rules ?? []; + if (rules.length === 0) { + return; + } + + const searchableText = normalizeText(`${currentIssue.title ?? ""}\n${currentIssue.body ?? ""}`); + const maxLabelsToAdd = classificationConfig.max_labels_to_add ?? 3; + const exclusiveGroups = new Map(); + for (const group of classificationConfig.exclusive_groups ?? []) { + for (const label of group.labels ?? []) { + exclusiveGroups.set(label, group.name); + } + } + + const occupiedGroups = new Set( + Array.from(labelSet) + .map((label) => exclusiveGroups.get(label)) + .filter(Boolean), + ); + const matchedRules = []; + + for (const rule of rules) { + if (labelSet.has(rule.label)) { + continue; + } + const keywords = rule.keywords ?? []; + const matchedKeywords = keywords.filter((keyword) => searchableText.includes(normalizeText(keyword))); + if (matchedKeywords.length === 0) { + continue; + } + + matchedRules.push({ + ...rule, + matchedKeywordCount: matchedKeywords.length, + }); + } + + matchedRules.sort((left, right) => { + const priorityDelta = (right.priority ?? 0) - (left.priority ?? 0); + if (priorityDelta !== 0) { + return priorityDelta; + } + + const keywordDelta = right.matchedKeywordCount - left.matchedKeywordCount; + if (keywordDelta !== 0) { + return keywordDelta; + } + + return left.label.localeCompare(right.label); + }); + + const labelsToAdd = []; + for (const rule of matchedRules) { + if (labelsToAdd.length >= maxLabelsToAdd) { + break; + } + + const groupName = rule.group ?? exclusiveGroups.get(rule.label); + if (groupName && occupiedGroups.has(groupName)) { + continue; + } + + await ensureLabelExists(rule.label, rule.color, rule.description); + labelsToAdd.push(rule.label); + labelSet.add(rule.label); + if (groupName) { + occupiedGroups.add(groupName); + } + } + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + ...repoContext, + issue_number: currentIssue.number, + labels: labelsToAdd, + }); + } + } + + async function isFirstRepositoryItem(login, kind, currentNumber) { + if (!login) { + return false; + } + + const qualifier = kind === "pull_request" ? "is:pr" : "is:issue"; + const response = await github.rest.search.issuesAndPullRequests({ + q: `repo:${repoContext.owner}/${repoContext.repo} ${qualifier} author:${login}`, + per_page: 5, + }); + const otherItems = (response.data.items ?? []).filter((item) => item.number !== currentNumber); + const totalCount = response.data.total_count ?? otherItems.length; + return otherItems.length === 0 && totalCount <= 1; + } + + function buildWelcomeBody(kind, isFirstTime) { + const welcomeConfig = kind === "pull_request" ? config.welcome.pull_request : config.welcome.issue; + const parts = [ + renderTemplate(welcomeConfig.body, { + contributing: config.links.contributing, + }), + ]; + + if (isFirstTime && welcomeConfig.first_time_suffix) { + parts.push(welcomeConfig.first_time_suffix); + } + + return parts.join("\n\n"); + } + + function findMissingIssueFields(currentIssue) { + if (!currentIssue) { + return []; + } + + const body = currentIssue.body ?? ""; + const inferredKind = inferIssueTemplateKind(currentIssue); + const requiredFieldSets = [ + { + kind: "bug", + labels: ["bug"], + fields: [ + ["Summary", "问题描述"], + ["Bug type", "问题类型"], + ["Steps to reproduce", "复现步骤"], + ["Expected behavior", "期望行为"], + ["Actual behavior", "实际行为"], + ["AISH version", "AISH 版本"], + ["Operating system", "操作系统"], + ["Install method", "安装方式"], + ], + }, + { + kind: "feature", + labels: ["enhancement", "feature-request"], + fields: [ + ["Summary", "功能概述"], + ["Use case", "使用场景"], + ["Proposed solution", "期望方案"], + ], + }, + ]; + + const selectedRule = requiredFieldSets.find( + (rule) => rule.kind === inferredKind || rule.labels.some((name) => labelSet.has(name)), + ); + if (!selectedRule) { + return []; + } + + return selectedRule.fields + .filter((aliases) => !hasMeaningfulContent(extractIssueFormValue(body, aliases))) + .map((aliases) => aliases[0]); + } + + function inferIssueTemplateKind(currentIssue) { + const title = normalizeText(currentIssue.title); + const body = currentIssue.body ?? ""; + + if (labelSet.has("bug") || title.startsWith("[bug]")) { + return "bug"; + } + if (labelSet.has("enhancement") || labelSet.has("feature-request") || title.startsWith("[feature]")) { + return "feature"; + } + if ( + extractIssueFormValue(body, ["Steps to reproduce", "复现步骤"]) || + extractIssueFormValue(body, ["Expected behavior", "期望行为"]) + ) { + return "bug"; + } + if ( + extractIssueFormValue(body, ["Use case", "使用场景"]) || + extractIssueFormValue(body, ["Proposed solution", "期望方案"]) + ) { + return "feature"; + } + return null; + } + + function issueLooksUntemplated(currentIssue) { + return !inferIssueTemplateKind(currentIssue); + } + + function findMissingPullRequestSections(currentPullRequest) { + if (!currentPullRequest) { + return []; + } + + const body = currentPullRequest.body ?? ""; + const missing = []; + const requiredSections = [ + ["Summary", "概述"], + ["User-visible Changes", "用户可见变更"], + ["Compatibility", "兼容性"], + ["Testing", "测试验证"], + ]; + + for (const aliases of requiredSections) { + const sectionBody = aliases.map((heading) => extractMarkdownSection(body, heading)).find(Boolean) ?? ""; + if (!hasMeaningfulContent(sectionBody)) { + missing.push(aliases[0]); + } + } + + const changeTypeSection = ["Change Type", "改动类型"] + .map((heading) => extractMarkdownSection(body, heading)) + .find(Boolean) ?? ""; + if (!hasCheckedCheckbox(changeTypeSection)) { + missing.push("Change Type"); + } + + const scopeSection = ["Scope", "涉及范围"] + .map((heading) => extractMarkdownSection(body, heading)) + .find(Boolean) ?? ""; + if (!hasCheckedCheckbox(scopeSection)) { + missing.push("Scope"); + } + + return missing; + } + + async function maybeWarnIncompleteIssue(currentIssue) { + const templateConfig = config.template_checks.issue; + if (issueLooksUntemplated(currentIssue)) { + await upsertComment( + currentIssue.number, + templateConfig.marker, + renderTemplate(templateConfig.missing_template_body, { + contributing: config.links.contributing, + }), + ); + return; + } + + const missingFields = findMissingIssueFields(currentIssue); + if (missingFields.length === 0) { + if (await hasMarker(currentIssue.number, templateConfig.marker)) { + await upsertComment(currentIssue.number, templateConfig.marker, templateConfig.resolved_body); + } + return; + } + + await upsertComment( + currentIssue.number, + templateConfig.marker, + renderTemplate(templateConfig.body, { + missing_items: formatMissingItems(missingFields), + }), + ); + } + + async function maybeWarnIncompletePullRequest(currentPullRequest) { + const templateConfig = config.template_checks.pull_request; + const missingSections = findMissingPullRequestSections(currentPullRequest); + if (missingSections.length === 0) { + if (await hasMarker(currentPullRequest.number, templateConfig.marker)) { + await upsertComment(currentPullRequest.number, templateConfig.marker, templateConfig.resolved_body); + } + return; + } + + await upsertComment( + currentPullRequest.number, + templateConfig.marker, + renderTemplate(templateConfig.body, { + missing_items: formatMissingItems(missingSections), + }), + ); + } + + if (comment) { + const authorLogin = comment.user?.login ?? ""; + if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) { + return; + } + + const mentions = (comment.body ?? "").match(mentionRegex) || []; + if (mentions.length > config.spam.mention_threshold) { + await createCommentOnce( + target.number, + config.spam.comment.marker, + config.spam.comment.body.replace( + "GitHub Discussions", + `[GitHub Discussions](${config.links.discussions})`, + ), + ); + } + return; + } + + const action = context.payload.action; + + if (issue) { + if (action === "opened") { + const isFirstIssue = await isFirstRepositoryItem(issue.user?.login ?? "", "issue", issue.number); + await createCommentOnce(issue.number, config.welcome.issue.marker, buildWelcomeBody("issue", isFirstIssue)); + } + + if (action === "opened" || action === "edited" || action === "reopened") { + const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim(); + const mentions = issueText.match(mentionRegex) || []; + const authorLogin = issue.user?.login ?? ""; + + if (mentions.length > config.spam.mention_threshold && authorLogin !== context.repo.owner) { + await createCommentOnce(issue.number, config.spam.issue.marker, config.spam.issue.body); + } + + await syncIssueClassificationLabels(issue); + await syncBugSubtypeLabel(issue); + await maybeWarnIncompleteIssue(issue); + } + + const title = issue.title ?? ""; + if (title.toLowerCase().includes("security") && !labelSet.has(config.guards.security_label)) { + await github.rest.issues.addLabels({ + ...repoContext, + issue_number: issue.number, + labels: [config.guards.security_label], + }); + labelSet.add(config.guards.security_label); + } + } + + if (pullRequest && action === "opened") { + const isFirstPullRequest = await isFirstRepositoryItem( + pullRequest.user?.login ?? "", + "pull_request", + pullRequest.number, + ); + await createCommentOnce( + pullRequest.number, + config.welcome.pull_request.marker, + buildWelcomeBody("pull_request", isFirstPullRequest), + ); + } + + if (pullRequest && (action === "opened" || action === "edited" || action === "reopened")) { + await maybeWarnIncompletePullRequest(pullRequest); + } + + const hasTriggerLabel = labelSet.has(config.guards.trigger_label); + if (hasTriggerLabel) { + labelSet.delete(config.guards.trigger_label); + await removeLabel(target.number, config.guards.trigger_label); + } + + const isLabelEvent = action === "labeled"; + if (!hasTriggerLabel && !isLabelEvent) { + return; + } + + if (pullRequest) { + if (labelSet.has(config.guards.dirty_label) || labelSet.size > config.guards.pull_request_max_labels) { + await createCommentOnce( + pullRequest.number, + config.guards.dirty_pull_request.marker, + config.guards.dirty_pull_request.body, + ); + await github.rest.issues.update({ + ...repoContext, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + + if (labelSet.has(config.guards.invalid_label)) { + await github.rest.issues.update({ + ...repoContext, + issue_number: pullRequest.number, + state: "closed", + }); + return; + } + } + + if (issue && labelSet.has(config.guards.invalid_label)) { + await github.rest.issues.update({ + ...repoContext, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + return; + } + + const rule = config.label_rules.find((item) => labelSet.has(item.label)); + if (!rule) { + return; + } + + await createCommentOnce(target.number, rule.marker, rule.body); + + if (rule.close) { + await github.rest.issues.update({ + ...repoContext, + issue_number: target.number, + state: "closed", + state_reason: "not_planned", + }); + } + + if (rule.lock) { + await github.rest.issues.lock({ + ...repoContext, + issue_number: target.number, + lock_reason: rule.lockReason ?? "resolved", + }); + } +} + +module.exports = { + run, +}; \ No newline at end of file diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 794d087..eab76a3 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -2,368 +2,29 @@ name: Auto response on: issues: - types: [opened, edited, labeled] + types: [opened, edited, reopened, labeled] issue_comment: types: [created] pull_request_target: - types: [labeled] + types: [opened, edited, reopened, labeled] permissions: {} jobs: auto-response: permissions: + contents: read issues: write pull-requests: write runs-on: ubuntu-latest steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Handle labeled items and comments uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - // Auto-response rules for labels prefixed with "r:" - const rules = [ - { - label: "r: support", - close: true, - message: - "Please use [GitHub Discussions](https://github.com/AI-Shell-Team/aish/discussions) for support questions. " + - "You can also check the documentation at https://github.com/AI-Shell-Team/aish/blob/main/README.md", - }, - { - label: "r: duplicate", - close: true, - message: - "This issue has been marked as a duplicate. Please continue the discussion in the linked issue.", - }, - { - label: "r: wontfix", - close: true, - message: - "This issue has been marked as \"wontfix\". " + - "This means we have reviewed the issue and decided not to implement the requested feature or fix. " + - "Thank you for your understanding.", - }, - { - label: "r: question", - close: true, - message: - "This appears to be a question rather than a bug report or feature request. " + - "Please use [GitHub Discussions](https://github.com/AI-Shell-Team/aish/discussions) for questions.", - }, - ]; - - const mentionRegex = /@([A-Za-z0-9-]+)/g; - 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", - }, - "bug:security": { - color: "d93f0b", - description: "Security vulnerability", - }, - }; - 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", - "Security issue": "bug:security", - }; - 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 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; - - // Handle comments - if (comment) { - const authorLogin = comment.user?.login ?? ""; - if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) { - return; - } - - const commentBody = comment.body ?? ""; - const responses = []; - - // Check for spam mentions (more than 2 maintainer mentions) - const mentions = (commentBody.match(mentionRegex) || []).length; - if (mentions > 2) { - responses.push( - "Please avoid spamming mentions. Be patient, or use [GitHub Discussions](https://github.com/AI-Shell-Team/aish/discussions) for help." - ); - } - - 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; - } - - // Handle issues - 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 ?? ""; - - // Check for spam mentions in issue - const mentions = (issueText.match(mentionRegex) || []).length; - if (mentions > 2 && authorLogin !== context.repo.owner) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: "Please avoid spamming mentions in issues. Be concise and specific about the problem.", - }); - } - - await syncBugSubtypeLabel(issue, labelSet); - } - - // Auto-add security label for security-related titles - const title = issue.title ?? ""; - if (title.toLowerCase().includes("security") && !labelSet.has("security")) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ["security"], - }); - labelSet.add("security"); - } - } - - // Handle trigger label - 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; - } - - // Handle invalid/dirty labels - const invalidLabel = "invalid"; - const dirtyLabel = "dirty"; - const noisyPrMessage = - "Closing this PR because it looks dirty (too many unrelated or unexpected changes). " + - "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; - } - - // Handle auto-response rules - 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", - state_reason: "not_planned", - }); - } - - if (rule.lock) { - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - lock_reason: rule.lockReason ?? "resolved", - }); - } + const { run } = require("./.github/scripts/auto-response.cjs"); + await run({ github, context, core }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8ddc96..71dfb13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Welcome to make Shell smarter! - Code PRs run lint, tests, and cross-platform smoke checks. - Packaging-related PRs additionally run Linux bundle build and install smoke checks. +- `Auto response` is the repository's community bot for Issues and PRs. Reply text lives in `.github/auto-response-config.json`, and runtime logic lives in `.github/scripts/auto-response.cjs`. - `Release Metadata` is the shared release action that normalizes stable version inputs, validates repository version state, and uploads both markdown and JSON metadata artifacts. - `make prepare-release-files VERSION=X.Y.Z [DATE=YYYY-MM-DD]` updates `pyproject.toml`, `src/aish/__init__.py`, `uv.lock`, and inserts a dated release section at the top of `CHANGELOG.md`. - Prepare release files locally in a normal PR, merge that PR into `main`, then run `Release Preparation` as the preflight validation for the target stable version. From 8749165b09d06bddd378005198d9241b49c9d2e8 Mon Sep 17 00:00:00 2001 From: lixin Date: Fri, 20 Mar 2026 17:18:45 +0800 Subject: [PATCH 2/2] fix github action nodejs warning --- .github/actions/release-metadata/action.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release-preparation.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/release-metadata/action.yml b/.github/actions/release-metadata/action.yml index b426829..c9a0b57 100644 --- a/.github/actions/release-metadata/action.yml +++ b/.github/actions/release-metadata/action.yml @@ -46,7 +46,7 @@ runs: --json-file release-metadata.json - name: Upload release metadata artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ inputs.artifact_prefix }}-${{ steps.metadata.outputs.version }} path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0b34b5..2df33b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up build environment run: ./packaging/scripts/setup-ci-env.sh @@ -92,7 +92,7 @@ jobs: run: make build - name: Upload Python package artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: python-package path: dist/* @@ -115,7 +115,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 @@ -158,7 +158,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up build environment run: ./packaging/scripts/setup-ci-env.sh @@ -192,7 +192,7 @@ jobs: grep -q '^ExecStart=/usr/local/bin/aish-sandbox$' "$INSTALL_ROOT/etc/systemd/system/aish-sandbox.service" - name: Upload bundle artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: aish-linux-${{ matrix.arch }} path: | diff --git a/.github/workflows/release-preparation.yml b/.github/workflows/release-preparation.yml index 93d6bb3..f00d345 100644 --- a/.github/workflows/release-preparation.yml +++ b/.github/workflows/release-preparation.yml @@ -26,7 +26,7 @@ jobs: release_notes: ${{ steps.metadata.outputs.release_notes }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up build environment run: ./packaging/scripts/setup-ci-env.sh @@ -114,7 +114,7 @@ jobs: grep -q '^ExecStart=/usr/local/bin/aish-sandbox$' "$INSTALL_ROOT/etc/systemd/system/aish-sandbox.service" - name: Upload dry-run artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: release-preparation-aish-linux-${{ matrix.arch }} path: artifacts/${{ matrix.arch }}/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d06e8d4..ef2c0cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: release_notes: ${{ steps.metadata.outputs.release_notes }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -87,7 +87,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up build environment run: ./packaging/scripts/setup-ci-env.sh