From 7b4617f7abb17ae6009352f96d9c33ab54708a02 Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:03:21 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20remove:=20=E7=A7=BB=E9=99=A4=20A?= =?UTF-8?q?I=20Issue=20=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=A3=80=E6=B5=8B=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 `.github/workflows/ai-issue-smart-reply.yml`,该文件包含约 1469 行的复杂 AI 分诊逻辑 - 新增 `.github/workflows/nomore-spam.yml`,在 issue 打开和 PR 打开时自动检测并关闭垃圾信息 - 新工作流支持通过仓库 secrets 配置自定义 AI 端点、API 密钥和模型名称 - 新工作流启用文件变更分析,可设置分析深度、标签白名单和用户黑名单 --- .github/workflows/ai-issue-smart-reply.yml | 1469 -------------------- .github/workflows/nomore-spam.yml | 30 + 2 files changed, 30 insertions(+), 1469 deletions(-) delete mode 100644 .github/workflows/ai-issue-smart-reply.yml create mode 100644 .github/workflows/nomore-spam.yml diff --git a/.github/workflows/ai-issue-smart-reply.yml b/.github/workflows/ai-issue-smart-reply.yml deleted file mode 100644 index ee53c9a..0000000 --- a/.github/workflows/ai-issue-smart-reply.yml +++ /dev/null @@ -1,1469 +0,0 @@ -name: 🤖 AI Issue Smart Reply - -on: - issues: - types: [opened] - issue_comment: - types: [created] - workflow_dispatch: - inputs: - target_type: - description: '目标类型' - required: true - type: choice - options: - - issue - - pr - issue_number: - description: 'Issue 或 PR 编号(如 42)' - required: true - type: string - -permissions: - contents: write - issues: write - pull-requests: read - -concurrency: - group: ai-issue-${{ github.event.issue.number || github.event.inputs.issue_number }} - cancel-in-progress: false - -jobs: - ai-smart-reply: - if: | - vars.AI_ISSUE_REPLY_ENABLED == 'true' && ( - github.event_name == 'issues' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/ai-analyze')) - ) - runs-on: ubuntu-latest - timeout-minutes: 20 - - env: - AI_AUTO_LABEL: ${{ vars.AI_AUTO_LABEL || 'true' }} - AI_ENABLE_DUPLICATE_CHECK: ${{ vars.AI_ENABLE_DUPLICATE_CHECK || 'true' }} - AI_ENABLE_PR_SEARCH: ${{ vars.AI_ENABLE_PR_SEARCH || 'true' }} - AI_ENABLE_COMMIT_SEARCH: ${{ vars.AI_ENABLE_COMMIT_SEARCH || 'true' }} - AI_ONLY_TEMPLATE_TYPES: ${{ vars.AI_ONLY_TEMPLATE_TYPES || 'false' }} - AI_MAX_CONTEXT_CHARS: ${{ vars.AI_MAX_CONTEXT_CHARS || '50000' }} - AI_MAX_DUP_CANDIDATES: ${{ vars.AI_MAX_DUP_CANDIDATES || '80' }} - AI_MAX_PR_CANDIDATES: ${{ vars.AI_MAX_PR_CANDIDATES || '20' }} - AI_MAX_COMMIT_CANDIDATES: ${{ vars.AI_MAX_COMMIT_CANDIDATES || '20' }} - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 100 - - - name: Install dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y jq python3 ripgrep - - - name: Prepare issue payload - id: issue - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - INPUT_NUM: ${{ github.event.issue.number || github.event.inputs.issue_number }} - TARGET_TYPE: ${{ github.event.inputs.target_type || 'issue' }} - AUTO_TITLE: ${{ github.event.issue.title }} - AUTO_BODY: ${{ github.event.issue.body || '' }} - AUTO_AUTHOR: ${{ github.event.issue.user.login }} - AUTO_URL: ${{ github.event.issue.html_url }} - EVENT_NAME: ${{ github.event_name }} - COMMENT_BODY: ${{ github.event.comment.body || '' }} - shell: bash - run: | - set -euo pipefail - mkdir -p .ai_runtime - - python3 - <<'PY' - import json, os, pathlib, subprocess - - input_num = os.environ['INPUT_NUM'] - target_type = os.environ['TARGET_TYPE'] - auto_title = os.environ.get('AUTO_TITLE', '') - event_name = os.environ.get('EVENT_NAME', '') - comment_body = os.environ.get('COMMENT_BODY', '') - - re_analyze = False - - if event_name == 'issue_comment': - # 评论触发:通过 gh CLI 获取 issue 数据 - cmd = ['gh', 'issue', 'view', input_num, - '--json', 'number,title,body,author,url'] - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - d = json.loads(result.stdout) - author = d.get('author', {}) - payload = { - "number": d['number'], - "title": d.get('title', ''), - "body": d.get('body', '') or '', - "author": author.get('login', '') if isinstance(author, dict) else str(author), - "url": d.get('url', ''), - } - re_analyze = True - elif auto_title: - # 自动触发:使用事件环境变量 - payload = { - "number": int(input_num), - "title": auto_title, - "body": os.environ.get('AUTO_BODY', ''), - "author": os.environ.get('AUTO_AUTHOR', ''), - "url": os.environ.get('AUTO_URL', ''), - } - else: - # 手动触发:通过 gh CLI 获取数据 - cmd = ['gh', 'pr' if target_type == 'pr' else 'issue', 'view', input_num, - '--json', 'number,title,body,author,url'] - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - d = json.loads(result.stdout) - author = d.get('author', {}) - payload = { - "number": d['number'], - "title": d.get('title', ''), - "body": d.get('body', '') or '', - "author": author.get('login', '') if isinstance(author, dict) else str(author), - "url": d.get('url', ''), - } - - payload["re_analyze"] = re_analyze - - pathlib.Path('.ai_runtime/issue.json').write_text( - json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8' - ) - print(f"成功获取 {target_type} #{input_num}: {payload['title']}") - PY - - echo "payload=$(cat .ai_runtime/issue.json | jq -c .)" >> "$GITHUB_OUTPUT" - echo "issue_number=$(jq -r '.number' .ai_runtime/issue.json)" >> "$GITHUB_OUTPUT" - echo "re_analyze=$(jq -r '.re_analyze' .ai_runtime/issue.json)" >> "$GITHUB_OUTPUT" - - - name: React to /ai-analyze comment - if: github.event_name == 'issue_comment' && steps.issue.outputs.re_analyze == 'true' - uses: actions/github-script@v7 - env: - ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} - COMMENT_ID: ${{ github.event.comment.id }} - with: - script: | - const issue_number = parseInt(process.env.ISSUE_NUMBER, 10) || context.issue.number; - const comment_id = parseInt(process.env.COMMENT_ID, 10); - if (comment_id) { - await github.rest.reactions.createForIssueComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id, - content: 'rocket' - }); - } - - - name: Stage 1 - classify and rewrite query - id: rewrite - shell: bash - env: - LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: ${{ secrets.LLM_MODEL }} - run: | - set -euo pipefail - - cat > .ai_runtime/rewrite_schema.json <<'JSON' - { - "name": "issue_rewrite", - "parameters": { - "type": "object", - "properties": { - "summary": { "type": "string" }, - "issue_type": { - "type": "string", - "enum": ["bug", "enhancement", "question", "documentation", "needs_more_info"] - }, - "search_queries": { - "type": "array", - "items": { "type": "string" } - }, - "keywords": { - "type": "array", - "items": { "type": "string" } - }, - "components": { - "type": "array", - "items": { "type": "string" } - }, - "likely_paths": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["summary", "issue_type", "search_queries", "keywords", "components", "likely_paths"] - } - } - JSON - - cat > .ai_runtime/rewrite_system_prompt.txt <<'EOF' - 你负责把 Issue 重写成适合仓库检索的查询。 - 输出必须是严格 JSON。 - search_queries 控制在 3-6 条,keywords 控制在 5-12 条。 - EOF - - python3 -c " - import json, pathlib - issue = json.loads(pathlib.Path('.ai_runtime/issue.json').read_text(encoding='utf-8')) - pathlib.Path('.ai_runtime/rewrite_input_prompt.txt').write_text( - '请分析这个 Issue,并输出适合仓库知识库/代码库检索的查询。\n\n标题:\n' + issue.get('title','') + '\n\n正文:\n' + issue.get('body',''), - encoding='utf-8' - ) - " - - python3 - <<'PY' - import json, os, pathlib, urllib.request, urllib.error - - base_url = os.environ['LLM_BASE_URL'].rstrip('/') - api_key = os.environ['LLM_API_KEY'] - model = os.environ['LLM_MODEL'] - system_prompt = pathlib.Path('.ai_runtime/rewrite_system_prompt.txt').read_text(encoding='utf-8') - input_prompt = pathlib.Path('.ai_runtime/rewrite_input_prompt.txt').read_text(encoding='utf-8') - schema = json.loads(pathlib.Path('.ai_runtime/rewrite_schema.json').read_text(encoding='utf-8')) - - candidates = [ - (f"{base_url}/chat/completions", { - "model": model, - "temperature": 0.2, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": system_prompt + "\n你必须直接输出 JSON 对象,不要输出 markdown。"}, - {"role": "user", "content": input_prompt}, - ], - }, 'chat_json'), - (f"{base_url}/chat/completions", { - "model": model, - "temperature": 0.2, - "messages": [ - {"role": "system", "content": system_prompt + "\n你必须直接输出 JSON 对象,不要输出 markdown 或代码块。"}, - {"role": "user", "content": input_prompt + "\n\n请直接输出 JSON,不要包含 ```json 代码块标记。"}, - ], - }, 'chat_plain'), - ] - - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'github-actions-ai-triage/1.0', - } - - last_err = None - content = '' - mode_used = '' - required_fields = {"summary", "issue_type", "search_queries", "keywords", "components", "likely_paths"} - - def validate_rewrite(s): - try: - obj = json.loads(s) if isinstance(s, str) else s - return required_fields.issubset(set(obj.keys())) - except Exception: - return False - - for attempt in range(2): - for url, payload, mode in candidates: - if attempt > 0: - extra = "\n\n重要:必须输出包含 summary, issue_type, search_queries, keywords, components, likely_paths 全部字段的 JSON。" - payload = dict(payload) - msgs = [dict(m) for m in payload['messages']] - msgs[-1] = dict(msgs[-1]) - msgs[-1]['content'] = msgs[-1].get('content', '') + extra - payload['messages'] = msgs - - data = json.dumps(payload, ensure_ascii=False).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers, method='POST') - try: - with urllib.request.urlopen(req, timeout=180) as resp: - text = resp.read().decode('utf-8', errors='replace') - obj = json.loads(text) - raw = ((obj.get('choices') or [{}])[0].get('message') or {}).get('content') or '' - # 尝试从 markdown 代码块中提取 JSON - if raw and not raw.strip().startswith('{'): - import re - m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.S) - if m: - raw = m.group(1).strip() - if raw and validate_rewrite(raw): - content = raw - mode_used = mode - pathlib.Path('.ai_runtime/rewrite_raw.txt').write_text(content, encoding='utf-8') - break - except urllib.error.HTTPError as e: - err_body = e.read().decode('utf-8', errors='replace')[:500] - last_err = f'HTTP {e.code}: {err_body}' - print(f'[attempt {attempt+1}] {mode} 失败: {last_err}') - import time; time.sleep(2) - except Exception as e: - last_err = e - print(f'[attempt {attempt+1}] {mode} 异常: {e}') - if content: - break - - if not content: - raise RuntimeError(f'LLM rewrite call failed: {last_err}') - - pathlib.Path('.ai_runtime/issue_rewrite.txt').write_text(content, encoding='utf-8') - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'mode_used={mode_used}\n') - PY - - python3 .github/scripts/write_output.py issue_rewrite .ai_runtime/issue_rewrite.txt - - - name: Normalize rewrite result - id: rewrite_norm - shell: bash - run: | - set -euo pipefail - - cat > .ai_runtime/rewrite_raw.txt <<'EOF' - ${{ steps.rewrite.outputs.issue_rewrite }} - EOF - - python3 - <<'PY' - import json, re, pathlib - - raw = pathlib.Path(".ai_runtime/rewrite_raw.txt").read_text(encoding="utf-8", errors="ignore").strip() - - def parse_json(s): - try: - return json.loads(s) - except Exception: - m = re.search(r'\{.*\}', s, flags=re.S) - return json.loads(m.group(0)) if m else {} - - d = parse_json(raw) if raw else {} - issue_type = d.get("issue_type") - if issue_type not in {"bug","enhancement","question","documentation","needs_more_info"}: - issue_type = "needs_more_info" - - def clean_list(v, n): - if not isinstance(v, list): - return [] - out = [] - seen = set() - for x in v: - if isinstance(x, str): - s = x.strip() - if s and s.lower() not in seen: - seen.add(s.lower()) - out.append(s) - if len(out) >= n: - break - return out - - result = { - "summary": d.get("summary") if isinstance(d.get("summary"), str) else "", - "issue_type": issue_type, - "search_queries": clean_list(d.get("search_queries"), 6), - "keywords": clean_list(d.get("keywords"), 12), - "components": clean_list(d.get("components"), 8), - "likely_paths": clean_list(d.get("likely_paths"), 8) - } - - pathlib.Path(".ai_runtime/rewrite.json").write_text( - json.dumps(result, ensure_ascii=False, indent=2), - encoding="utf-8" - ) - print(json.dumps(result, ensure_ascii=False)) - PY - - echo "json=$(cat .ai_runtime/rewrite.json | jq -c .)" >> "$GITHUB_OUTPUT" - - - name: Optional filter by issue type - if: env.AI_ONLY_TEMPLATE_TYPES == 'true' - shell: bash - run: | - set -euo pipefail - TYPE="$(jq -r '.issue_type' .ai_runtime/rewrite.json)" - case "$TYPE" in - bug|enhancement|documentation) - echo "continue" - ;; - *) - echo "Skipping non-target issue type: $TYPE" - exit 78 - ;; - esac - - - name: Retrieve context from repository scan - id: context_scan - shell: bash - run: | - set -euo pipefail - - python3 - <<'PY' - import json, os, pathlib, re - - TEXT_EXTS = { - ".md", ".mdx", ".rst", ".txt", ".js", ".jsx", ".ts", ".tsx", ".py", ".go", - ".java", ".rs", ".php", ".rb", ".yml", ".yaml", ".json", ".toml", ".sh", ".vue" - } - IGNORE_DIRS = { - ".git", "node_modules", "dist", "build", "coverage", ".next", ".nuxt", - "__pycache__", ".venv", "venv", "vendor", "target", "out", ".ai_runtime" - } - - rewrite = json.loads(pathlib.Path(".ai_runtime/rewrite.json").read_text(encoding="utf-8")) - issue = json.loads(pathlib.Path(".ai_runtime/issue.json").read_text(encoding="utf-8")) - - # 构建查询词 - query_terms = [] - for key in ("search_queries", "keywords", "components", "likely_paths"): - query_terms.extend(rewrite.get(key, [])) - query_terms.append(issue.get("title", "")) - query_terms = [t for t in query_terms if isinstance(t, str) and t.strip()] - - def tokenize(text): - return set(re.findall(r"[A-Za-z0-9_./:#-]{3,}", text.lower())) - - q_tokens = tokenize(" ".join(query_terms)) - max_chars = int(os.environ.get("AI_MAX_CONTEXT_CHARS", "50000")) - - root = pathlib.Path(".") - rows = [] - - for path in root.rglob("*"): - if not path.is_file(): - continue - rel = path.relative_to(root).as_posix() - if any(part in rel for part in IGNORE_DIRS): - continue - if path.suffix.lower() not in TEXT_EXTS and not path.name.startswith("README"): - continue - try: - text = path.read_text(encoding="utf-8", errors="ignore") - except Exception: - continue - - lower = text.lower() - score = 0 - for t in query_terms: - tl = t.lower() - if tl in lower: - score += 2 - if tl in rel.lower(): - score += 4 - if score <= 0: - continue - - lines = text.splitlines() - for i, line in enumerate(lines): - ls = line.lower() - hit = sum(1 for t in query_terms if t.lower() in ls) - if hit <= 0: - continue - start = max(0, i - 18) - end = min(len(lines), i + 19) - block = "\n".join(f"{j+1:>5}: {lines[j]}" for j in range(start, end)) - rows.append({ - "path": rel, - "start_line": start + 1, - "end_line": end, - "text": block, - "score": score + hit * 5 - }) - - rows.sort(key=lambda x: (-x["score"], x["path"], x["start_line"])) - - # 输出去重后的结果 - parts = [] - cur = 0 - seen = set() - for row in rows: - key = (row["path"], row["start_line"], row["end_line"]) - if key in seen: - continue - seen.add(key) - block = f"--- FILE: {row['path']} (lines {row['start_line']}-{row['end_line']}) ---\n{row['text']}\n" - if cur + len(block) > max_chars: - break - parts.append(block) - cur += len(block) - - pathlib.Path(".ai_runtime/final_context.txt").write_text("\n".join(parts), encoding="utf-8") - print(f"扫描完成: {len(rows)} 个匹配块, {len(parts)} 个输出, {cur} 字符") - PY - - python3 .github/scripts/write_output.py context .ai_runtime/final_context.txt - - - name: Fetch duplicate candidates - if: env.AI_ENABLE_DUPLICATE_CHECK == 'true' - id: dupes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - gh issue list \ - --state all \ - --limit "${AI_MAX_DUP_CANDIDATES}" \ - --json number,title,body,state,updatedAt,url \ - > .ai_runtime/issues_raw.json - - python3 - <<'PY' - import json, pathlib, re - - rewrite = json.loads(pathlib.Path(".ai_runtime/rewrite.json").read_text(encoding="utf-8")) - queries = " ".join(rewrite.get("search_queries", [])) + " " + " ".join(rewrite.get("keywords", [])) - q = set(re.findall(r"[a-z0-9_./:-]{3,}", queries.lower())) - - items = json.loads(pathlib.Path(".ai_runtime/issues_raw.json").read_text(encoding="utf-8")) - scored = [] - for it in items: - text = ((it.get("title") or "") + "\n" + (it.get("body") or "")).lower() - toks = set(re.findall(r"[a-z0-9_./:-]{3,}", text)) - inter = len(q & toks) - if inter == 0: - continue - union = max(len(q | toks), 1) - score = inter * 2 + (inter / union) * 100 - scored.append({ - "number": it["number"], - "title": it.get("title", ""), - "state": it.get("state", ""), - "url": it.get("url", ""), - "score": round(score, 2), - }) - - scored.sort(key=lambda x: (-x["score"], x["number"])) - top = scored[:12] - pathlib.Path(".ai_runtime/dupes.txt").write_text( - "\n".join([f"#{x['number']} [{x['state']}] score={x['score']}: {x['title']}" for x in top]), - encoding="utf-8" - ) - PY - - python3 .github/scripts/write_output.py candidates .ai_runtime/dupes.txt - - - name: Prepare duplicate candidates fallback - if: env.AI_ENABLE_DUPLICATE_CHECK != 'true' - id: dupes_disabled - shell: bash - run: | - set -euo pipefail - echo "No duplicate candidates collected." > .ai_runtime/dupes.txt - python3 .github/scripts/write_output.py candidates .ai_runtime/dupes.txt - - - name: Fetch recent PR candidates - if: env.AI_ENABLE_PR_SEARCH == 'true' - id: prs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - - gh pr list \ - --state all \ - --limit "${AI_MAX_PR_CANDIDATES}" \ - --json number,title,body,mergedAt,state,url \ - > .ai_runtime/prs_raw.json - - python3 - <<'PY' - import json, pathlib, re - - rewrite = json.loads(pathlib.Path(".ai_runtime/rewrite.json").read_text(encoding="utf-8")) - q = set(re.findall(r"[a-z0-9_./:-]{3,}", " ".join(rewrite.get("search_queries", []) + rewrite.get("keywords", [])).lower())) - items = json.loads(pathlib.Path(".ai_runtime/prs_raw.json").read_text(encoding="utf-8")) - out = [] - for it in items: - text = ((it.get("title") or "") + "\n" + (it.get("body") or "")).lower() - toks = set(re.findall(r"[a-z0-9_./:-]{3,}", text)) - inter = len(q & toks) - if inter <= 0: - continue - out.append((inter, it)) - out.sort(key=lambda x: -x[0]) - - lines = [] - for _, it in out[:8]: - lines.append(f"PR #{it['number']} [{it.get('state','')}]: {it.get('title','')} {it.get('url','')}") - pathlib.Path(".ai_runtime/prs.txt").write_text("\n".join(lines), encoding="utf-8") - PY - - python3 .github/scripts/write_output.py candidates .ai_runtime/prs.txt - - - name: Prepare PR candidates fallback - if: env.AI_ENABLE_PR_SEARCH != 'true' - id: prs_disabled - shell: bash - run: | - set -euo pipefail - echo "No PR candidates collected." > .ai_runtime/prs.txt - python3 .github/scripts/write_output.py candidates .ai_runtime/prs.txt - - - name: Fetch recent commit candidates - if: env.AI_ENABLE_COMMIT_SEARCH == 'true' - id: commits - shell: bash - run: | - set -euo pipefail - - python3 - <<'PY' - import json, pathlib, subprocess - - rewrite = json.loads(pathlib.Path(".ai_runtime/rewrite.json").read_text(encoding="utf-8")) - keywords = rewrite.get("keywords", [])[:8] - - try: - log = subprocess.check_output( - ["git", "log", "--pretty=format:%H%x09%s", "-n", "200"], - text=True - ) - except Exception: - log = "" - - lines = [] - for row in log.splitlines(): - if "\t" not in row: - continue - sha, subject = row.split("\t", 1) - score = 0 - lower = subject.lower() - for k in keywords: - if k.lower() in lower: - score += 1 - if score > 0: - lines.append((score, f"{sha[:12]} {subject}")) - - lines.sort(key=lambda x: -x[0]) - pathlib.Path(".ai_runtime/commits.txt").write_text( - "\n".join([x[1] for x in lines[:10]]), - encoding="utf-8" - ) - PY - - python3 .github/scripts/write_output.py candidates .ai_runtime/commits.txt - - - name: Prepare commit candidates fallback - if: env.AI_ENABLE_COMMIT_SEARCH != 'true' - id: commits_disabled - shell: bash - run: | - set -euo pipefail - echo "No commit candidates collected." > .ai_runtime/commits.txt - python3 .github/scripts/write_output.py candidates .ai_runtime/commits.txt - - - name: Prepare final duplicate candidates - id: final_dupes - shell: bash - run: | - set -euo pipefail - if [ -s .ai_runtime/dupes.txt ]; then - cp .ai_runtime/dupes.txt .ai_runtime/final_dupes.txt - else - echo "No duplicate candidates collected." > .ai_runtime/final_dupes.txt - fi - python3 .github/scripts/write_output.py candidates .ai_runtime/final_dupes.txt - - - name: Prepare final PR candidates - id: final_prs - shell: bash - run: | - set -euo pipefail - if [ -s .ai_runtime/prs.txt ]; then - cp .ai_runtime/prs.txt .ai_runtime/final_prs.txt - else - echo "No PR candidates collected." > .ai_runtime/final_prs.txt - fi - python3 .github/scripts/write_output.py candidates .ai_runtime/final_prs.txt - - - name: Prepare final commit candidates - id: final_commits - shell: bash - run: | - set -euo pipefail - if [ -s .ai_runtime/commits.txt ]; then - cp .ai_runtime/commits.txt .ai_runtime/final_commits.txt - else - echo "No commit candidates collected." > .ai_runtime/final_commits.txt - fi - python3 .github/scripts/write_output.py candidates .ai_runtime/final_commits.txt - - - name: Collect enriched context for deeper analysis - id: enriched - shell: bash - run: | - set -euo pipefail - - python3 - <<'PY' - import json, os, pathlib, re, subprocess - - issue = json.loads(pathlib.Path('.ai_runtime/issue.json').read_text(encoding='utf-8')) - rewrite = json.loads(pathlib.Path('.ai_runtime/rewrite.json').read_text(encoding='utf-8')) - - # --- 1. 从 issue body 提取错误模式 --- - body = issue.get('body', '') or '' - error_patterns = [] - - # 提取堆栈跟踪(支持多种语言格式) - stacktrace_re = re.compile( - r'(?:(?:Traceback|Exception|Error|at\s+\S+\s*\(|File\s+".*?"|.*?Error:.*|.*?Exception:.*|' - r'(?:Caused by|Caused):.*|.*?\.java:\d+|.*?\.ts:\d+|.*?\.js:\d+|.*?\.py:\d+|' - r'(?:errno|EACCES|ENOENT|ECONNREFUSED|ETIMEDOUT)\s.*|' - r'(?:SIGTERM|SIGKILL|SIGSEGV).*|' - r'(?:fatal|panic|crash|FATAL|PANIC|CRASH)\s.*))', - re.MULTILINE | re.IGNORECASE - ) - for m in stacktrace_re.finditer(body): - error_patterns.append(m.group(0).strip()) - - # 提取代码块内容 - code_blocks = re.findall(r'```[\w]*\n(.*?)```', body, re.DOTALL) - for block in code_blocks[:5]: - stripped = block.strip() - if stripped and len(stripped) < 2000: - error_patterns.append(f"[code block] {stripped}") - - # 提取 URL(可能指向日志、截图等) - urls = re.findall(r'https?://[^\s\)\]>]+', body) - - # 去重并限制数量 - seen = set() - unique_errors = [] - for ep in error_patterns: - key = ep[:100].lower() - if key not in seen: - seen.add(key) - unique_errors.append(ep) - error_patterns = unique_errors[:15] - - # --- 2. 读取 likely_paths 指向的文件内容(关键词定位 + 上下文窗口) --- - likely_paths = rewrite.get('likely_paths', []) - search_terms = [] - for key in ("search_queries", "keywords", "components"): - search_terms.extend(rewrite.get(key, [])) - search_terms = [t for t in search_terms if isinstance(t, str) and len(t) >= 3] - - file_contents = {} - max_file_chars = 8000 # 每个文件最多 8000 字符 - context_lines = 40 # 关键词命中行前后各取 40 行 - - for fp in likely_paths[:6]: - p = pathlib.Path(fp) - if not p.is_file(): - p = pathlib.Path('.') / fp - if not (p.is_file() and p.stat().st_size < 500_000): - continue - try: - content = p.read_text(encoding='utf-8', errors='replace') - except Exception: - continue - - if len(content) <= max_file_chars: - file_contents[fp] = content - continue - - # 关键词定位:找到匹配行,提取上下文窗口 - lines = content.splitlines() - matched_ranges = [] - for i, line in enumerate(lines): - ll = line.lower() - if any(t.lower() in ll for t in search_terms): - start = max(0, i - context_lines) - end = min(len(lines), i + context_lines + 1) - matched_ranges.append((start, end)) - - if matched_ranges: - # 合并重叠区间 - matched_ranges.sort() - merged = [matched_ranges[0]] - for s, e in matched_ranges[1:]: - if s <= merged[-1][1]: - merged[-1] = (merged[-1][0], max(merged[-1][1], e)) - else: - merged.append((s, e)) - - # 提取合并后的区间内容 - parts = [] - cur = 0 - for s, e in merged: - if s > cur: - parts.append(f"... [跳过 {s - cur} 行] ...") - parts.append("\n".join(lines[s:e])) - cur = e - if cur < len(lines): - parts.append(f"... [剩余 {len(lines) - cur} 行] ...") - result = "\n".join(parts) - if len(result) > max_file_chars: - result = result[:max_file_chars] + f'\n... [截断,共 {len(content)} 字符]' - file_contents[fp] = result - else: - # 无关键词命中,回退到读取前半部分 - file_contents[fp] = content[:max_file_chars] + f'\n... [截断,共 {len(content)} 字符]' - - # --- 3. 获取相关文件的 git 历史 --- - git_history = {} - for fp in list(file_contents.keys())[:4]: - try: - log = subprocess.check_output( - ['git', 'log', '--pretty=format:%h %ad %s', '--date=short', '-n', '5', '--', fp], - text=True, stderr=subprocess.DEVNULL - ) - if log.strip(): - git_history[fp] = log.strip().splitlines() - except Exception: - pass - - # --- 4. 获取仓库结构概览(顶层目录) --- - repo_structure = [] - try: - for item in sorted(pathlib.Path('.').iterdir()): - if item.name.startswith('.'): - continue - if item.is_dir(): - repo_structure.append(f"{item.name}/") - else: - repo_structure.append(item.name) - except Exception: - pass - - enriched = { - "error_patterns": error_patterns, - "code_blocks_from_issue": [b.strip() for b in code_blocks[:5] if b.strip()], - "urls_from_issue": urls[:10], - "file_contents": file_contents, - "git_history": git_history, - "repo_structure": repo_structure[:30], - } - - pathlib.Path('.ai_runtime/enriched_context.json').write_text( - json.dumps(enriched, ensure_ascii=False, indent=2), - encoding='utf-8' - ) - - # 摘要统计 - print(f"错误模式: {len(error_patterns)} 条") - print(f"读取文件: {len(file_contents)} 个") - print(f"Git 历史: {len(git_history)} 个文件") - print(f"Issue URL: {len(urls)} 个") - PY - - - name: Stage 2 - maintainer-grade triage - id: triage - shell: bash - env: - LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: ${{ secrets.LLM_MODEL }} - run: | - set -euo pipefail - - cat > .ai_runtime/triage_schema.json <<'JSON' - { - "name": "issue_response", - "parameters": { - "type": "object", - "properties": { - "summary": { "type": "string" }, - "classification": { - "type": "string", - "enum": ["bug", "enhancement", "question", "documentation", "needs_more_info"] - }, - "support_status": { - "type": "string", - "enum": ["supported", "partially_supported", "not_supported", "already_fixed_unreleased", "needs_more_info"] - }, - "analysis": { "type": "string" }, - "solution": { "type": "string" }, - "workaround": { "type": "string" }, - "error_pattern_summary": { "type": "string" }, - "related_files": { - "type": "array", - "items": { "type": "string" } - }, - "actionable_steps": { - "type": "array", - "items": { "type": "string" } - }, - "roadmap": { "type": "string" }, - "needs_human_followup": { "type": "boolean" }, - "duplicate_confidence": { - "type": "string", - "enum": ["low", "medium", "high"] - }, - "duplicate_issues": { - "type": "array", - "items": { "type": "string" } - }, - "labels": { - "type": "array", - "items": { - "type": "string", - "enum": ["bug", "enhancement", "question", "documentation", "duplicate", "help wanted", "needs more info"] - } - } - }, - "required": ["summary", "classification", "support_status", "analysis", "solution", "workaround", "error_pattern_summary", "related_files", "actionable_steps", "needs_human_followup", "duplicate_confidence", "duplicate_issues", "labels"] - } - } - JSON - - python3 - <<'PY' - import pathlib - prompt = ( - "你是一位资深仓库维护者。根据提供的 Issue 信息、代码上下文和历史数据,做出专业判断。\n\n" - "## 输出要求\n\n" - "你必须输出严格的 JSON 对象,包含以下字段(缺一不可):\n\n" - "- summary: 一句话总结 Issue 核心问题(必填)\n" - "- classification: 分类,必须是 bug/enhancement/question/documentation/needs_more_info 之一(必填)\n" - "- support_status: 支持状态,必须是 supported/partially_supported/not_supported/already_fixed_unreleased/needs_more_info 之一(必填)\n" - "- analysis: 详细分析问题根因,引用具体的代码路径和逻辑(必填,至少 2 句话)\n" - "- solution: 详细的修复方案,必须包含:受影响的文件路径、具体的函数/方法名、需要修改的代码逻辑描述、修改后的预期行为。格式示例:\"在 `packages/backend/src/auth/service.ts` 的 `validateToken()` 方法中,第 42 行的过期检查逻辑需要改为...\"(必填,至少 2 句话)\n" - "- workaround: 用户可立即执行的临时解决方案,不依赖代码修改或重新部署。例如:修改配置文件参数、设置环境变量、重启服务、手动执行命令等。如果确实没有临时方案则为空字符串(必填)\n" - "- error_pattern_summary: 错误模式总结,描述从 Issue 中提取的错误类型、发生位置和调用链路(如果存在错误信息则必填,否则为空字符串)\n" - "- related_files: 相关文件列表,格式如 [\"packages/backend/src/auth/service.ts\"],列出与问题直接相关的源代码文件(必填,至少 1 个,最多 8 个)\n" - "- actionable_steps: 可执行步骤列表,每步必须足够具体以供 AI 编程助手直接执行。格式要求:引用文件路径(如 `src/foo.ts`)、函数名(如 `handleLogin()`)、行号范围、以及需要执行的具体操作(如「将第 15 行的 `==` 改为 `===`」「在 `X` 函数末尾添加空值检查」)。禁止模糊描述如「检查相关代码」「修复问题」。(必填,至少 1 步,最多 6 步)\n" - "- roadmap: 如果 not_supported,描述实现路线;否则为空字符串\n" - "- needs_human_followup: 仅当证据明显不足或问题涉及业务决策时设为 true(必填)\n" - "- duplicate_confidence: 与已有 Issue 的重复程度 low/medium/high(必填)\n" - '- duplicate_issues: 可能重复的 Issue 编号列表,格式如 ["#123"](必填)\n' - "- labels: 1-3 个标签,只能从 bug/enhancement/question/documentation/duplicate/help wanted/needs more info 中选择(必填)\n\n" - "## 分析增强指引\n\n" - "当「Enriched Context」部分提供以下信息时,你必须充分利用:\n\n" - "1. **错误模式 (error_patterns)**:从 Issue 正文中提取的堆栈跟踪、错误消息、异常信息。" - " 分析错误类型、发生位置、调用链路,定位根因。\n" - "2. **代码块 (code_blocks_from_issue)**:Issue 中附带的代码片段。" - " 检查代码逻辑问题、类型错误、API 误用等。\n" - "3. **相关文件内容 (file_contents)**:仓库中与 Issue 相关的源代码文件。" - " 逐文件分析代码逻辑,引用具体的函数名、行号和代码路径。\n" - "4. **Git 历史 (git_history)**:相关文件的最近提交记录。" - " 检查是否有最近的变更可能导致了问题,或已有修复但未发布。\n" - "5. **仓库结构 (repo_structure)**:仓库顶层目录结构。" - " 帮助判断 Issue 涉及的模块和组件。\n\n" - "## 判断原则\n\n" - "- 如果代码上下文中有明确的相关文件和逻辑,大胆给出分析和方案\n" - "- analysis 中必须引用具体的文件路径(如 `packages/backend/src/auth/service.ts`)和函数名\n" - "- solution 必须写成「AI 编程助手可直接执行」的粒度:文件路径 + 函数名 + 行号 + 具体修改内容 + 预期结果\n" - "- actionable_steps 必须写成可直接执行的指令,每步包含:目标文件、定位方式(行号/函数名/代码模式)、具体操作、验证方式\n" - "- workaround 必须提供用户可立即执行的临时规避方法,不依赖代码修改(如配置变更、环境变量、手动操作步骤)\n" - "- 只有在完全无法判断时才设 needs_human_followup=true\n" - "- labels 必须从允许的列表中选择,不要自创标签\n" - "- 输出必须是严格 JSON,不要输出 markdown 或其他文本" - ) - pathlib.Path('.ai_runtime/triage_system_prompt.txt').write_text(prompt, encoding='utf-8') - PY - - python3 - <<'PY' - import json, pathlib - issue = json.loads(pathlib.Path('.ai_runtime/issue.json').read_text(encoding='utf-8')) - rewrite_json = pathlib.Path('.ai_runtime/rewrite.json').read_text(encoding='utf-8') - context = pathlib.Path('.ai_runtime/final_context.txt').read_text(encoding='utf-8') if pathlib.Path('.ai_runtime/final_context.txt').exists() else '' - dupes = pathlib.Path('.ai_runtime/final_dupes.txt').read_text(encoding='utf-8') if pathlib.Path('.ai_runtime/final_dupes.txt').exists() else '' - prs = pathlib.Path('.ai_runtime/final_prs.txt').read_text(encoding='utf-8') if pathlib.Path('.ai_runtime/final_prs.txt').exists() else '' - commits = pathlib.Path('.ai_runtime/final_commits.txt').read_text(encoding='utf-8') if pathlib.Path('.ai_runtime/final_commits.txt').exists() else '' - - # 读取富化上下文 - enriched_str = '' - enriched_path = pathlib.Path('.ai_runtime/enriched_context.json') - if enriched_path.exists(): - enriched = json.loads(enriched_path.read_text(encoding='utf-8')) - parts = [] - if enriched.get('error_patterns'): - parts.append("[错误模式 - 从 Issue 提取]\n" + "\n---\n".join(enriched['error_patterns'])) - if enriched.get('code_blocks_from_issue'): - parts.append("[Issue 中的代码块]\n" + "\n---\n".join(enriched['code_blocks_from_issue'])) - if enriched.get('file_contents'): - fc_lines = [] - for fpath, content in enriched['file_contents'].items(): - fc_lines.append(f"### {fpath}\n```\n{content}\n```") - parts.append("[相关文件内容]\n" + "\n\n".join(fc_lines)) - if enriched.get('git_history'): - gh_lines = [] - for fpath, history in enriched['git_history'].items(): - gh_lines.append(f"### {fpath}\n" + "\n".join(history)) - parts.append("[文件 Git 历史]\n" + "\n\n".join(gh_lines)) - if enriched.get('repo_structure'): - parts.append("[仓库结构]\n" + "\n".join(enriched['repo_structure'])) - enriched_str = "\n\n".join(parts) - - prompt = f"""[Issue] - 标题: - {issue.get('title','')} - - 正文: - {issue.get('body','')} - - [Stage 1 Rewrite] - {rewrite_json} - - [Retrieved Context] - {context} - - [Enriched Context] - {enriched_str} - - [Duplicate Candidates] - {dupes} - - [PR Candidates] - {prs} - - [Commit Candidates] - {commits}""" - pathlib.Path('.ai_runtime/triage_input_prompt.txt').write_text(prompt, encoding='utf-8') - PY - - python3 - <<'PY' - import json, os, pathlib, urllib.request, urllib.error - - base_url = os.environ['LLM_BASE_URL'].rstrip('/') - api_key = os.environ['LLM_API_KEY'] - model = os.environ['LLM_MODEL'] - system_prompt = pathlib.Path('.ai_runtime/triage_system_prompt.txt').read_text(encoding='utf-8') - input_prompt = pathlib.Path('.ai_runtime/triage_input_prompt.txt').read_text(encoding='utf-8') - schema = json.loads(pathlib.Path('.ai_runtime/triage_schema.json').read_text(encoding='utf-8')) - - candidates = [ - (f"{base_url}/chat/completions", { - "model": model, - "temperature": 0.2, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": system_prompt + "\n你必须直接输出 JSON 对象,不要输出 markdown。"}, - {"role": "user", "content": input_prompt}, - ], - }, 'chat_json'), - (f"{base_url}/chat/completions", { - "model": model, - "temperature": 0.2, - "messages": [ - {"role": "system", "content": system_prompt + "\n你必须直接输出 JSON 对象,不要输出 markdown 或代码块。"}, - {"role": "user", "content": input_prompt + "\n\n请直接输出 JSON,不要包含 ```json 代码块标记。"}, - ], - }, 'chat_plain'), - ] - - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'github-actions-ai-triage/1.0', - } - - last_err = None - content = '' - mode_used = '' - required_fields = {"summary", "classification", "support_status", "analysis", "solution", - "error_pattern_summary", "related_files", "actionable_steps", - "needs_human_followup", "duplicate_confidence", "duplicate_issues", "labels"} - - def validate_triage(s): - """检查内容是否包含 triage 必需字段""" - try: - obj = json.loads(s) if isinstance(s, str) else s - return required_fields.issubset(set(obj.keys())) - except Exception: - return False - - # 最多重试 2 次(首次 + 1 次重试) - for attempt in range(2): - for url, payload, mode in candidates: - # 重试时加入更明确的格式要求 - if attempt > 0: - extra = "\n\n重要:你必须输出包含以下全部字段的 JSON:summary, classification, support_status, analysis, solution, error_pattern_summary, related_files, actionable_steps, needs_human_followup, duplicate_confidence, duplicate_issues, labels。缺少任何字段都会导致解析失败。" - payload = dict(payload) - msgs = [dict(m) for m in payload['messages']] - msgs[-1] = dict(msgs[-1]) - msgs[-1]['content'] = msgs[-1].get('content', '') + extra - payload['messages'] = msgs - - data = json.dumps(payload, ensure_ascii=False).encode('utf-8') - req = urllib.request.Request(url, data=data, headers=headers, method='POST') - try: - with urllib.request.urlopen(req, timeout=240) as resp: - text = resp.read().decode('utf-8', errors='replace') - obj = json.loads(text) - raw = ((obj.get('choices') or [{}])[0].get('message') or {}).get('content') or '' - # 尝试从 markdown 代码块中提取 JSON - if raw and not raw.strip().startswith('{'): - import re - m = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.S) - if m: - raw = m.group(1).strip() - if raw and validate_triage(raw): - content = raw - mode_used = mode - pathlib.Path('.ai_runtime/triage_raw.txt').write_text(content, encoding='utf-8') - break - elif raw: - last_err = f'LLM 返回内容缺少必需字段: {required_fields - set(json.loads(raw).keys()) if isinstance(raw, str) else "parse error"}' - except urllib.error.HTTPError as e: - err_body = e.read().decode('utf-8', errors='replace')[:500] - last_err = f'HTTP {e.code}: {err_body}' - print(f'[attempt {attempt+1}] {mode} 失败: {last_err}') - import time; time.sleep(2) - except Exception as e: - last_err = e - print(f'[attempt {attempt+1}] {mode} 异常: {e}') - if content: - break - print(f'第 {attempt+1} 次尝试未通过验证,准备重试...') - - if not content: - raise RuntimeError(f'LLM triage call failed after retries: {last_err}') - - pathlib.Path('.ai_runtime/issue_response.txt').write_text(content, encoding='utf-8') - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'mode_used={mode_used}\n') - PY - - python3 .github/scripts/write_output.py issue_response .ai_runtime/issue_response.txt - - - name: Normalize triage result - id: triage_norm - shell: bash - run: | - set -euo pipefail - - cat > .ai_runtime/triage_raw.txt <<'EOF' - ${{ steps.triage.outputs.issue_response }} - EOF - - python3 - <<'PY' - import json, pathlib, re - - raw = pathlib.Path(".ai_runtime/triage_raw.txt").read_text(encoding="utf-8", errors="ignore").strip() - - def parse_json(s): - try: - return json.loads(s) - except Exception: - m = re.search(r'\{.*\}', s, flags=re.S) - return json.loads(m.group(0)) if m else {} - - d = parse_json(raw) if raw else {} - - allowed_labels = {"bug","enhancement","question","documentation","duplicate","help wanted","needs more info"} - allowed_class = {"bug","enhancement","question","documentation","needs_more_info"} - allowed_support = {"supported","partially_supported","not_supported","already_fixed_unreleased","needs_more_info"} - allowed_dup = {"low","medium","high"} - - def s(v, default=""): - return v if isinstance(v, str) else default - - def b(v, default=False): - return v if isinstance(v, bool) else default - - def arr(v): - return v if isinstance(v, list) else [] - - labels = [] - seen = set() - for x in arr(d.get("labels")): - if isinstance(x, str): - t = x.strip().lower() - if t in allowed_labels and t not in seen: - labels.append(t) - seen.add(t) - - classification = s(d.get("classification"), "needs_more_info") - if classification not in allowed_class: - classification = "needs_more_info" - - if classification != "needs_more_info" and classification not in labels and len(labels) < 3: - labels.insert(0, classification) - - result = { - "summary": s(d.get("summary"), "AI 未能稳定生成摘要。"), - "classification": classification, - "support_status": s(d.get("support_status"), "needs_more_info"), - "analysis": s(d.get("analysis"), "AI 未能稳定生成分析,请维护者人工复核。"), - "solution": s(d.get("solution"), "暂无稳定自动建议。"), - "workaround": s(d.get("workaround"), ""), - "error_pattern_summary": s(d.get("error_pattern_summary"), ""), - "related_files": [x.strip() for x in arr(d.get("related_files")) if isinstance(x, str) and x.strip()][:8], - "actionable_steps": [x.strip() for x in arr(d.get("actionable_steps")) if isinstance(x, str) and x.strip()][:6], - "roadmap": s(d.get("roadmap"), ""), - "needs_human_followup": b(d.get("needs_human_followup"), False), - "duplicate_confidence": s(d.get("duplicate_confidence"), "low"), - "duplicate_issues": [x.strip() for x in arr(d.get("duplicate_issues")) if isinstance(x, str) and re.fullmatch(r"#\d+", x.strip())], - "labels": labels[:3] - } - - # 分析或方案为 fallback 默认值时,自动标记需要人工复核 - if result["analysis"] == "AI 未能稳定生成分析,请维护者人工复核。" or \ - result["solution"] == "暂无稳定自动建议。": - result["needs_human_followup"] = True - - if result["support_status"] not in allowed_support: - result["support_status"] = "needs_more_info" - if result["duplicate_confidence"] not in allowed_dup: - result["duplicate_confidence"] = "low" - if result["duplicate_confidence"] == "high" and result["duplicate_issues"] and "duplicate" not in result["labels"] and len(result["labels"]) < 3: - result["labels"].append("duplicate") - - pathlib.Path(".ai_runtime/triage.json").write_text( - json.dumps(result, ensure_ascii=False, indent=2), - encoding="utf-8" - ) - print(json.dumps(result, ensure_ascii=False)) - PY - - echo "json=$(cat .ai_runtime/triage.json | jq -c .)" >> "$GITHUB_OUTPUT" - - - name: Create or update AI comment and labels - uses: actions/github-script@v7 - env: - AI_AUTO_LABEL: ${{ env.AI_AUTO_LABEL }} - RESULT_JSON: ${{ steps.triage_norm.outputs.json }} - ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} - with: - script: | - const marker = ""; - const result = JSON.parse(process.env.RESULT_JSON || "{}"); - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue_number = parseInt(process.env.ISSUE_NUMBER, 10) || context.issue.number; - - function supportText(s) { - switch (s) { - case "supported": - return "✅ 当前仓库上下文显示:该行为已支持。"; - case "partially_supported": - return "🟡 当前仓库上下文显示:该行为仅部分支持。"; - case "not_supported": - return "⚠️ 当前仓库上下文显示:当前暂不支持。"; - case "already_fixed_unreleased": - return "🛠️ 当前仓库上下文显示:可能已修复,但未正式发布。"; - default: - return "❓ 当前证据不足,需要更多信息。"; - } - } - - function classificationEmoji(c) { - switch (c) { - case "bug": return "🐛"; - case "enhancement": return "✨"; - case "question": return "❓"; - case "documentation": return "📝"; - default: return "🔍"; - } - } - - const lines = [ - marker, - "### 🤖 AI Issue 智能分析", - "", - `> **分类**: ${classificationEmoji(result.classification)} ${result.classification || "unknown"} | **状态**: ${supportText(result.support_status)}`, - "", - "---", - "", - "#### 📋 摘要", - result.summary || "暂无", - "" - ]; - - // 错误模式分析(仅 bug 类型显示) - if (result.error_pattern_summary && result.classification === "bug") { - lines.push("#### 🔍 错误模式"); - lines.push(result.error_pattern_summary); - lines.push(""); - } - - // 相关文件 - if (Array.isArray(result.related_files) && result.related_files.length > 0) { - lines.push("#### 📁 相关文件"); - for (const f of result.related_files) { - lines.push(`- \`${f}\``); - } - lines.push(""); - } - - lines.push("#### 🔬 分析"); - lines.push(result.analysis || "暂无"); - lines.push(""); - - lines.push("#### 💡 建议方案"); - lines.push(result.solution || "暂无"); - lines.push(""); - - // 临时解决方案(workaround) - if (result.workaround) { - lines.push("#### 🩹 临时解决方案"); - lines.push(result.workaround); - lines.push(""); - } - - // 可执行步骤 - if (Array.isArray(result.actionable_steps) && result.actionable_steps.length > 0) { - lines.push("#### 🎯 可执行步骤"); - for (let i = 0; i < result.actionable_steps.length; i++) { - lines.push(`${i + 1}. ${result.actionable_steps[i]}`); - } - lines.push(""); - } - - if (result.roadmap && result.support_status === "not_supported") { - lines.push("#### 🗺️ 实现路线"); - lines.push(result.roadmap); - lines.push(""); - } - - if (Array.isArray(result.duplicate_issues) && result.duplicate_issues.length > 0) { - lines.push("#### 🔗 可能重复的 Issue"); - lines.push(`- **置信度**: ${result.duplicate_confidence || "low"}`); - lines.push(`- **候选**: ${result.duplicate_issues.join("、")}`); - lines.push(""); - } - - if (result.needs_human_followup) { - lines.push("#### ⚠️ 维护建议"); - lines.push("建议维护者人工复核后再做最终结论。"); - lines.push(""); - } - - lines.push("---"); - lines.push("
"); - lines.push("📌 使用说明"); - lines.push(""); - lines.push("| 操作 | 方法 |"); - lines.push("|------|------|"); - lines.push("| 🔄 重新分析 | 在评论区输入 `/ai-analyze` |"); - lines.push("| 🏷️ 自动标签 | 分析结果会自动添加分类标签 |"); - lines.push("| ⚠️ 需人工复核 | 低置信度分析会自动标记 `needs-review` |"); - lines.push("| 🔗 重复检测 | 自动搜索相似 Issue 并关联 |"); - lines.push(""); - lines.push("
"); - lines.push(""); - lines.push("_🤖 此评论由 AI 自动化工作流生成 | 结构化输出 + 新评论模式 + 标签白名单 + 代码上下文检索 + 错误模式分析 + AI Agent 可消费方案_"); - - const body = lines.join("\n"); - - // 始终创建新评论(不再编辑旧评论,保留历史分析记录) - const comments = await github.paginate( - github.rest.issues.listComments, - { owner, repo, issue_number, per_page: 100 } - ); - - const existing = comments.find( - c => typeof c.body === "string" && c.body.includes(marker) - ); - - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body - }); - - if (process.env.AI_AUTO_LABEL === "true") { - const issue = await github.rest.issues.get({ - owner, - repo, - issue_number - }); - - const existingLabels = new Set( - (issue.data.labels || []).map(l => typeof l === "string" ? l : l.name) - ); - - const allowed = new Set([ - "bug", - "enhancement", - "question", - "documentation", - "duplicate", - "help wanted", - "needs more info", - "needs-review" - ]); - - // AI 管理的标签:重新分析时先移除这些旧标签 - const aiManagedLabels = [ - "bug", "enhancement", "question", "documentation", - "duplicate", "needs more info", "needs-review" - ]; - - // 重新分析时移除旧的 AI 管理标签(无论是否有旧评论) - const toRemove = aiManagedLabels.filter(x => existingLabels.has(x)); - if (toRemove.length > 0) { - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number, - name: toRemove - }); - } catch (e) { - // 部分标签可能不存在,忽略错误 - } - } - - // 计算新标签 - const desired = Array.isArray(result.labels) ? result.labels : []; - let toAdd = desired - .map(x => String(x).trim().toLowerCase()) - .filter(x => allowed.has(x)) - .filter((x, i, arr) => arr.indexOf(x) === i); - - // 置信度阈值:低置信度时添加 needs-review - if (result.needs_human_followup) { - if (!toAdd.includes("needs more info")) { - toAdd.push("needs-review"); - } - } - - // 过滤掉已存在的 - toAdd = toAdd.filter(x => !existingLabels.has(x)); - - if (toAdd.length > 0) { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number, - labels: toAdd - }); - } - } - - - name: Step summary - if: always() - shell: bash - run: | - { - echo "## AI Issue Smart Reply" - echo "" - echo "- Issue: #$(jq -r '.number' .ai_runtime/issue.json 2>/dev/null || echo 'unknown')" - echo "- Auto label: ${AI_AUTO_LABEL}" - echo "- Duplicate check: ${AI_ENABLE_DUPLICATE_CHECK}" - echo "- PR search: ${AI_ENABLE_PR_SEARCH}" - echo "- Commit search: ${AI_ENABLE_COMMIT_SEARCH}" - echo "" - echo "### Rewrite" - echo '```json' - cat .ai_runtime/rewrite.json 2>/dev/null || echo '{}' - echo '```' - echo "" - echo "### Enriched Context" - echo '```json' - python3 -c " - import json, pathlib - p = pathlib.Path('.ai_runtime/enriched_context.json') - if p.exists(): - d = json.loads(p.read_text()) - print(json.dumps({ - 'error_patterns': len(d.get('error_patterns', [])), - 'file_contents': list(d.get('file_contents', {}).keys()), - 'git_history': list(d.get('git_history', {}).keys()), - }, ensure_ascii=False, indent=2)) - else: - print('{}') - " 2>/dev/null || echo '{}' - echo '```' - echo "" - echo "### Triage" - echo '```json' - cat .ai_runtime/triage.json 2>/dev/null || echo '{}' - echo '```' - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/nomore-spam.yml b/.github/workflows/nomore-spam.yml new file mode 100644 index 0000000..fc3f0e0 --- /dev/null +++ b/.github/workflows/nomore-spam.yml @@ -0,0 +1,30 @@ +name: NoMore Spam + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + contents: read + issues: write + pull-requests: write + models: read + +jobs: + spam-detection: + runs-on: ubuntu-latest + + steps: + - name: Detect and close spam + uses: JohnsonRan/nomore-spam@main + with: + github-token: ${{ github.token }} + ai-base-url: ${{ secrets.AI_BASE_URL }} # 可选:自定义API端点(去除 /chat/completions) + ai-api-key: ${{ secrets.AI_API_KEY }} # 可选:自定义API密钥 + ai-model: ${{ secrets.AI_MODEL }} # 可选:自定义模型名称 + labels: 'bug,enhancement,question' + analyze-file-changes: 'true' + max-analysis-depth: 'normal' + blacklist: ${{ secrets.BLACKLIST }} # 可选:黑名单用户列表 \ No newline at end of file