Files
eSIM-Tools/.github/workflows/ai-issue-smart-reply.yml
Abner f897ae5212 feat: 优化 AI 智能回复工作流的请求逻辑与 JSON 格式处理
- 移除冗余的 `tools` 和 `tool_choice` 参数,简化请求结构,提升代码可维护性
- 统一使用 `response_format: {"type": "json_object"}` 确保所有请求返回标准 JSON 格式
- 在用户提示末尾明确添加禁止输出 `json` 代码块标记的说明,避免解析错误
- 简化重试逻辑,移除基于模式的条件判断,统一处理消息内容追加
- 将测试用例名称从 `chat_tools`/`chat_json` 调整为更语义化的 `chat_json`/`chat_plain`
2026-05-05 11:45:03 +08:00

1470 lines
63 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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重要你必须输出包含以下全部字段的 JSONsummary, 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 = "<!-- ai-issue-smart-reply:v4 -->";
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("<details>");
lines.push("<summary>📌 使用说明</summary>");
lines.push("");
lines.push("| 操作 | 方法 |");
lines.push("|------|------|");
lines.push("| 🔄 重新分析 | 在评论区输入 `/ai-analyze` |");
lines.push("| 🏷️ 自动标签 | 分析结果会自动添加分类标签 |");
lines.push("| ⚠️ 需人工复核 | 低置信度分析会自动标记 `needs-review` |");
lines.push("| 🔗 重复检测 | 自动搜索相似 Issue 并关联 |");
lines.push("");
lines.push("</details>");
lines.push("");
lines.push("_<sub>🤖 此评论由 AI 自动化工作流生成 | 结构化输出 + 新评论模式 + 标签白名单 + 代码上下文检索 + 错误模式分析 + AI Agent 可消费方案</sub>_");
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"