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"