name: PR Agent on: pull_request_target: # PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。 # pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。 types: - opened - reopened - ready_for_review - review_requested - synchronize issue_comment: # 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。 # issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。 types: - created - edited permissions: # 读取仓库内容和 PR diff。 contents: read # 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。 pull-requests: write # PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。 issues: write jobs: pr-agent: name: PR-Agent inline review if: >- github.event.sender.type != 'Bot' && ( github.event_name == 'pull_request_target' || ( github.event_name == 'issue_comment' && github.event.issue.pull_request != null && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) && ( github.event.comment.body == '/describe' || startsWith(github.event.comment.body, '/describe ') || github.event.comment.body == '/improve' || startsWith(github.event.comment.body, '/improve ') || github.event.comment.body == '/ask' || startsWith(github.event.comment.body, '/ask ') ) ) ) concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Detect PR review language id: pr_language env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} run: | set -euo pipefail pr_info="$(mktemp)" gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}" python3 - "${pr_info}" >> "${GITHUB_OUTPUT}" <<'PY' import json import re import sys from pathlib import Path pr = json.loads(Path(sys.argv[1]).read_text()) title = pr.get("title") or "" body = pr.get("body") or "" labels = {item.get("name", "") for item in pr.get("labels") or []} skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false" head_sha = pr.get("head", {}).get("sha") or "" body = re.sub( r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*" r".*?", " ", body, flags=re.IGNORECASE | re.DOTALL, ) body = re.sub( r".*?", " ", body, flags=re.DOTALL, ) body = re.sub(r"```.*?```", " ", body, flags=re.DOTALL) text = f"{title}\n{body}" cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text)) latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text)) if cjk_count >= 4: response_language = "zh-CN" summary_heading = "PR-Agent 摘要" summary_language = "中文" elif latin_words >= 8: response_language = "en-US" summary_heading = "PR-Agent Summary" summary_language = "English" else: response_language = "zh-CN" summary_heading = "PR-Agent 摘要" summary_language = "中文" print(f"response_language={response_language}") print(f"summary_heading={summary_heading}") print(f"summary_language={summary_language}") print(f"skip_pr_agent={skip_pr_agent}") print(f"head_sha={head_sha}") PY - name: Prepare PR-Agent description markers id: prepare_description if: >- steps.pr_language.outputs.skip_pr_agent != 'true' && ( github.event_name == 'pull_request_target' || ( github.event_name == 'issue_comment' && ( github.event.comment.body == '/describe' || startsWith(github.event.comment.body, '/describe ') ) ) ) env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} SUMMARY_HEADING: ${{ steps.pr_language.outputs.summary_heading }} run: | set -euo pipefail current_body="$(mktemp)" next_body="$(mktemp)" payload="$(mktemp)" body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md" placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md" gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}" cp "${current_body}" "${body_backup}" python3 - "${current_body}" "${next_body}" <<'PY' import os import re import sys from pathlib import Path current_path = Path(sys.argv[1]) next_path = Path(sys.argv[2]) body = current_path.read_text() start = "" end = "" placeholder = "pr_agent:summary" summary_heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要" agent_block = f"## {summary_heading}\n\n{start}\n{placeholder}\n{end}\n" body = re.sub( r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*(?=)", f"## {summary_heading}\n\n", body, ) start_index = body.find(start) end_index = body.find(end) if start_index >= 0 and end_index > start_index: next_body = body[: start_index + len(start)] + f"\n{placeholder}\n" + body[end_index:] else: separator = "\n\n" if body.strip() else "" next_body = body.rstrip() + separator + agent_block next_path.write_text(next_body) PY cp "${next_body}" "${placeholder_body}" body_changed=false if ! cmp -s "${current_body}" "${next_body}"; then python3 - "${next_body}" "${payload}" <<'PY' import json import sys from pathlib import Path body = Path(sys.argv[1]).read_text() Path(sys.argv[2]).write_text(json.dumps({"body": body}, ensure_ascii=False)) PY gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null body_changed=true fi echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}" - name: Snapshot PR-Agent inline comments id: inline_state_before if: >- steps.pr_language.outputs.skip_pr_agent != 'true' && ( github.event_name == 'pull_request_target' || ( github.event_name == 'issue_comment' && ( github.event.comment.body == '/improve' || startsWith(github.event.comment.body, '/improve ') ) ) ) env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} run: | set -euo pipefail inline_ids="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | .id' | jq -sc '.')" inline_ids_b64="$(printf '%s' "${inline_ids}" | base64 -w0)" echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}" - name: Run PR-Agent id: pragent if: steps.pr_language.outputs.skip_pr_agent != 'true' # 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。 uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 env: # PR-Agent 使用该 token 读取 PR 元数据并发布评论。 GITHUB_TOKEN: ${{ github.token }} # 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。 # 该 key 只传给 PR-Agent 运行时,不写入仓库。 OPENAI_KEY: ${{ secrets.OPENAI_KEY }} # 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。 OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }} # 模型、输出语言和大 diff 处理策略。 config.model: "gpt-5.5" config.fallback_models: '["gpt-5.4"]' config.reasoning_effort: "xhigh" config.ai_timeout: "900" config.response_language: ${{ steps.pr_language.outputs.response_language }} config.large_patch_policy: "clip" config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]' config.ignore_pr_labels: '["skip pr-agent"]' # PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。 github_action_config.auto_review: "false" github_action_config.auto_describe: "true" github_action_config.auto_improve: "true" # synchronize 由 push_commands 单独处理;每次 push 更新摘要和行内 Review,旧行评由 GitHub 标记 outdated。 github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]' github_action_config.handle_push_trigger: "true" github_action_config.push_commands: '["/describe", "/improve"]' # 保留 action outputs,便于后续 workflow 编排或排查。 github_action_config.enable_output: "true" # /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符。 pr_description.generate_ai_title: "false" pr_description.publish_labels: "false" pr_description.publish_description_as_comment: "false" pr_description.publish_description_as_comment_persistent: "false" pr_description.enable_pr_diagram: "false" pr_description.enable_pr_type: "false" pr_description.enable_help_text: "false" pr_description.enable_help_comment: "false" pr_description.enable_semantic_files_types: "false" pr_description.collapsible_file_list: "adaptive" pr_description.add_original_user_description: "true" pr_description.use_description_markers: "true" pr_description.final_update_message: "false" pr_description.extra_instructions: | Match the configured response language. Generate a moderately detailed PR summary covering the change goal, key implementation details, configuration or compatibility impact, tests, and notable risks. Use 2-4 bullets for small PRs; use 4-8 bullets for feature or multi-file PRs. Avoid low-value file lists and local command transcripts. # /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理。 pr_code_suggestions.extra_instructions: | Match the configured response language. Only provide substantive issues that maintainers should address; avoid style-only, preference-only, or low-value suggestions. For prioritized issues, start the suggestion body with one of these Markdown prefixes: 🔴 **High Risk**:, 🟡 **Medium Risk**:, or 🔵 **Low Risk**:. pr_code_suggestions.focus_only_on_problems: "true" pr_code_suggestions.suggestions_score_threshold: "3" pr_code_suggestions.num_code_suggestions_per_chunk: "3" pr_code_suggestions.commitable_code_suggestions: "true" pr_code_suggestions.publish_output_no_suggestions: "false" pr_questions.use_conversation_history: "true" # 可选成本和噪音控制: # github_action_config.auto_improve: "true" # config.verbosity_level: "1" - name: Publish PR-Agent code review summary if: >- steps.pr_language.outputs.skip_pr_agent != 'true' && ( github.event_name == 'pull_request_target' || ( github.event_name == 'issue_comment' && ( github.event.comment.body == '/improve' || startsWith(github.event.comment.body, '/improve ') ) ) ) env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }} RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }} OPENAI_KEY: ${{ secrets.OPENAI_KEY }} OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }} SUMMARY_MODEL: gpt-5.5 run: | set -euo pipefail before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)" pr_info="$(mktemp)" comments="$(mktemp)" review_data="$(mktemp)" payload="$(mktemp)" gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}" CURRENT_HEAD_SHA="$(jq -r '.head.sha' "${pr_info}")" HEAD_SHA="${RUN_HEAD_SHA:-${CURRENT_HEAD_SHA}}" if [ "${CURRENT_HEAD_SHA}" != "${HEAD_SHA}" ]; then echo "PR head changed from ${HEAD_SHA} to ${CURRENT_HEAD_SHA}; skip stale code review summary." exit 0 fi short_sha="${HEAD_SHA:0:7}" pr_url="https://github.com/${REPO}/pull/${PR_NUMBER}" commit_url="https://github.com/${REPO}/commit/${HEAD_SHA}" gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}" python3 - "${before_ids}" "${comments}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY' import json import sys from pathlib import Path before_ids = set(json.loads(sys.argv[1] or "[]")) comments_path = Path(sys.argv[2]) output_path = Path(sys.argv[3]) head_sha = sys.argv[4] pr_url = sys.argv[5] commit_url = sys.argv[6] short_sha = sys.argv[7] comments = [] for line in comments_path.read_text().splitlines(): if line.strip(): comments.append(json.loads(line)) new_comments = [ item for item in comments if item.get("user", {}).get("login") == "github-actions[bot]" and item.get("id") not in before_ids and item.get("commit_id") == head_sha ] suggestions = [] for item in new_comments: body = (item.get("body") or "").strip() first_line = next((line.strip() for line in body.splitlines() if line.strip()), "") suggestions.append({ "path": item.get("path"), "line": item.get("line") or item.get("start_line"), "url": item.get("html_url"), "summary": first_line[:500], "body": body[:1500], }) output_path.write_text(json.dumps({ "pr_url": pr_url, "commit_url": commit_url, "short_sha": short_sha, "suggestions": suggestions, }, ensure_ascii=False)) PY python3 - "${review_data}" "${payload}" <<'PY' import json import os import textwrap import urllib.error import urllib.request from pathlib import Path review_data = json.loads(Path(os.sys.argv[1]).read_text()) payload_path = Path(os.sys.argv[2]) suggestions = review_data["suggestions"] marker = "" summary_language = os.environ.get("SUMMARY_LANGUAGE") or "中文" use_chinese = summary_language != "English" def fallback_summary() -> str: if not suggestions: if use_chinese: return "已审查本次变更,未发现需要进一步反馈或调整的问题。" return "Reviewed this change and found no further feedback or required adjustments." if use_chinese: lines = [f"本轮代码审查新增 {len(suggestions)} 条行内建议,建议优先查看以下位置:"] else: noun = "suggestion" if len(suggestions) == 1 else "suggestions" lines = [f"This review added {len(suggestions)} inline {noun}. Consider reviewing these locations first:"] for item in suggestions[:5]: location = f"{item.get('path')}:{item.get('line')}" if item.get("line") else str(item.get("path")) summary = item.get("summary") or "查看行内建议" url = item.get("url") if use_chinese: lines.append(f"- [{location}]({url}):{summary}" if url else f"- {location}:{summary}") else: lines.append(f"- [{location}]({url}): {summary}" if url else f"- {location}: {summary}") if len(suggestions) > 5: if use_chinese: lines.append(f"- 其余 {len(suggestions) - 5} 条请在 Files changed 的行内评论中查看。") else: lines.append(f"- Review the remaining {len(suggestions) - 5} inline comments in Files changed.") return "\n".join(lines) def llm_summary() -> str | None: if not suggestions: return None api_base = (os.environ.get("OPENAI_API_BASE") or "").rstrip("/") api_key = os.environ.get("OPENAI_KEY") or "" model = os.environ.get("SUMMARY_MODEL") or "gpt-5.5" if not api_base or not api_key: return None if use_chinese: task = ( "你是独立的代码审查摘要助手。只基于本轮已经发布的行内审查意见做简短汇总," "不新增审查结论,不替维护者判断 PR 是否可以合并。请用中文 Markdown 输出:" "第一段说明本轮审查已完成,并已在行内留下需要关注的建议;如有建议," "概括 1-3 个主要关注点,使用“建议关注”“可能影响”“可优先查看”等中立表述。" "不要输出表格,不要复述所有文件,不要使用“可以合并”“不建议合并”“阻塞合并”" "“先处理后再合入”等合并裁决措辞。" ) system_prompt = "你是严谨、中立的代码审查摘要助手。输出中文 Markdown,简洁自然。" else: task = ( "You are an independent code review summarizer. Summarize only the inline review comments " "already posted in this run; do not add new review conclusions or decide whether the PR should merge. " "Write concise Markdown in English. Start by noting that review completed and inline suggestions " "were left; then summarize 1-3 main points using neutral phrasing such as \"consider\", " "\"may affect\", or \"worth reviewing\". Do not output tables, list every file, or use merge-gate " "wording such as \"ready to merge\", \"do not merge\", \"blocks merging\", or \"must be fixed before merge\"." ) system_prompt = "You are a rigorous, neutral code review summarizer. Write concise English Markdown." user_content = { "task": task, "suggestions": suggestions[:10], } request_body = { "model": model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": json.dumps(user_content, ensure_ascii=False)}, ], "temperature": 0.2, } request = urllib.request.Request( f"{api_base}/chat/completions", data=json.dumps(request_body).encode("utf-8"), headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", ) try: with urllib.request.urlopen(request, timeout=60) as response: data = json.loads(response.read().decode("utf-8")) content = data["choices"][0]["message"]["content"].strip() return content or None except (KeyError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError): return None summary = llm_summary() or fallback_summary() commit_label = "审查提交:" if use_chinese else "Reviewed commit:" body = textwrap.dedent(f"""\ {marker} ## Code Review {summary} {commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]}) """) payload_path.write_text(json.dumps({"body": body}, ensure_ascii=False)) PY new_comment_id="$(gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" --input "${payload}" --jq '.id')" comment_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.id != ${new_comment_id} and .user.login == \"github-actions[bot]\" and ((.body | startswith(\"\")) or (.body | startswith(\"\")))) | .id")" if [ -z "${comment_ids}" ]; then echo "No previous PR-Agent code review summary to clean." exit 0 fi while IFS= read -r comment_id; do [ -z "${comment_id}" ] && continue gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null done <<< "${comment_ids}" - name: Restore PR-Agent description markers on failure if: >- failure() && steps.prepare_description.outputs.body_changed == 'true' && steps.pr_language.outputs.skip_pr_agent != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} run: | set -euo pipefail body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md" placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md" current_body="$(mktemp)" payload="$(mktemp)" if [ ! -s "${body_backup}" ] || [ ! -s "${placeholder_body}" ]; then echo "No PR body backup found." exit 0 fi gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}" python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY' import json import re import sys from pathlib import Path backup_body = Path(sys.argv[1]).read_text() placeholder_body = Path(sys.argv[2]).read_text() current_body = Path(sys.argv[3]).read_text() payload_path = Path(sys.argv[4]) start = "" end = "" heading_re = re.compile(r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*") def find_block(body: str) -> tuple[int, int] | None: start_index = body.find(start) end_index = body.find(end) if start_index < 0 or end_index <= start_index: return None end_index += len(end) heading_start = start_index prefix = body[:start_index] matches = list(heading_re.finditer(prefix)) if matches: last = matches[-1] if prefix[last.end():].strip() == "": heading_start = last.start() return heading_start, end_index current_block = find_block(current_body) placeholder_block = find_block(placeholder_body) backup_block = find_block(backup_body) if not current_block or not placeholder_block: print("No PR-Agent summary block to restore.") raise SystemExit(0) current_section = current_body[current_block[0]:current_block[1]] placeholder_section = placeholder_body[placeholder_block[0]:placeholder_block[1]] if "pr_agent:summary" not in current_section: print("No visible PR-Agent summary placeholder to restore.") raise SystemExit(0) if current_section != placeholder_section: print("Current PR-Agent summary block changed; skip restore.") raise SystemExit(0) restored_section = backup_body[backup_block[0]:backup_block[1]] if backup_block else "" next_body = current_body[:current_block[0]] + restored_section + current_body[current_block[1]:] next_body = re.sub(r"\n{4,}", "\n\n\n", next_body).rstrip() + "\n" payload_path.write_text(json.dumps({"body": next_body}, ensure_ascii=False)) PY if [ -s "${payload}" ]; then gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null fi