From 83107bf4471c6c1b5b8b7bf4047e52eb9b32f1c3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:41:18 +0800 Subject: [PATCH] ci(pr-agent): publish native code reviews (#6110) --- .github/workflows/pr-agent.yml | 917 ++++++++++++++++----------------- docs/pr-agent.md | 84 +-- 2 files changed, 459 insertions(+), 542 deletions(-) diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index dae53fbf..63472d98 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -1,4 +1,4 @@ -name: PR Agent +name: PR-Agent on: pull_request_target: @@ -11,8 +11,7 @@ on: - review_requested - synchronize issue_comment: - # 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。 - # issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。 + # 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。 types: - created - edited @@ -22,16 +21,17 @@ permissions: contents: read # 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。 pull-requests: write - # PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。 + # 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 == 'pull_request_target' + ) || ( github.event_name == 'issue_comment' && github.event.issue.pull_request != null && @@ -39,545 +39,498 @@ jobs: ( 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 == '/review' || + startsWith(github.event.comment.body, '/review ') || 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 }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }} 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 + - name: Capture PR context + id: pr_context 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' + pull="$(mktemp)" + gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}" + python3 - "${pull}" >> "${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 []} + pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + title = pull.get("title") or "" + body = pull.get("body") or "" + labels = {item.get("name", "") for item in pull.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: + if cjk_count >= 4 or latin_words < 8: response_language = "zh-CN" summary_heading = "PR-Agent 摘要" - summary_language = "中文" - elif latin_words >= 8: + else: 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"head_sha={pull['head']['sha']}") + print(f"changed_files={pull.get('changed_files') or 0}") 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' && + steps.pr_context.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 ') - ) - ) + 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 }} + SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }} + CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }} 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' + python3 - "${payload}" <<'PY' + import json import os import re + import subprocess import sys - from pathlib import Path - current_path = Path(sys.argv[1]) - next_path = Path(sys.argv[2]) - - body = current_path.read_text() + pull = json.loads(subprocess.check_output( + ["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"], + text=True, + )) + body = pull.get("body") or "" 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, - ) - + heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要" + changed_files = int(os.environ.get("CHANGED_FILES") or 0) + block = f"## {heading}\n\n{start}\npr_agent:summary\n{end}" 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 ') - ) - ) + end_index = body.find(end, start_index + len(start)) if start_index >= 0 else -1 + owned_block = re.compile( + r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*" + r".*?\s*" ) - 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 ') + if changed_files == 0: + updated = owned_block.sub("", body) + if updated != body: + updated = updated.rstrip() + elif start_index >= 0 and end_index >= 0: + body = re.sub( + r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*(?=)", + f"## {heading}\n\n", + body, ) - ) - ) - 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)) + end_index = body.find(end, start_index + len(start)) + updated = body[:start_index] + f"{start}\npr_agent:summary\n{end}" + body[end_index + len(end):] + elif start_index >= 0 or end in body: + updated = body + elif body.strip(): + updated = f"{body.rstrip()}\n\n{block}\n" + else: + updated = f"{block}\n" + if updated != body: + with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump({"body": updated}, handle, ensure_ascii=False) PY if [ -s "${payload}" ]; then gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null fi + + - name: Update PR description + if: >- + steps.pr_context.outputs.skip_pr_agent != 'true' && + steps.pr_context.outputs.changed_files != '0' && + ( + github.event_name == 'pull_request_target' || + github.event.comment.body == '/describe' || + startsWith(github.event.comment.body, '/describe ') + ) + uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 + env: + GITHUB_TOKEN: ${{ github.token }} + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }} + config.model: 'gpt-5.6-terra' + config.fallback_models: '["gpt-5.5", "gpt-5.4"]' + config.custom_model_max_tokens: '1050000' + config.reasoning_effort: 'medium' + config.ai_timeout: '900' + config.response_language: ${{ steps.pr_context.outputs.response_language }} + config.large_patch_policy: 'clip' + config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]' + config.ignore_pr_labels: '["skip pr-agent"]' + github_action_config.auto_review: 'false' + github_action_config.auto_describe: 'true' + github_action_config.auto_improve: 'false' + 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"]' + 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. + Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks. + Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes. + Avoid file lists and local command transcripts. + + - name: Remove unfilled PR-Agent description marker + if: >- + always() && + steps.pr_context.outputs.skip_pr_agent != 'true' && + ( + github.event_name == 'pull_request_target' || + 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 }} + run: | + set -euo pipefail + payload="$(mktemp)" + python3 - "${payload}" <<'PY' + import json + import os + import re + import subprocess + import sys + + pull = json.loads(subprocess.check_output( + ["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"], + text=True, + )) + body = pull.get("body") or "" + placeholder = "\npr_agent:summary\n" + owned_block = re.compile( + r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*" + r"\s*pr_agent:summary\s*\s*" + ) + if placeholder in body: + updated = owned_block.sub("", body).rstrip() + if updated != body: + with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump({"body": updated}, handle, ensure_ascii=False) + PY + if [ -s "${payload}" ]; then + gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null + fi + + - name: Analyze PR review + id: review_analysis + if: >- + steps.pr_context.outputs.skip_pr_agent != 'true' && + ( + github.event_name == 'pull_request_target' || + github.event.comment.body == '/review' || + startsWith(github.event.comment.body, '/review ') + ) + uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 + env: + GITHUB_TOKEN: ${{ github.token }} + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }} + config.model: ${{ github.event_name == 'issue_comment' && 'gpt-5.6-sol' || 'gpt-5.6-terra' }} + config.fallback_models: '["gpt-5.5", "gpt-5.4"]' + config.custom_model_max_tokens: '1050000' + config.reasoning_effort: 'xhigh' + config.ai_timeout: '900' + config.response_language: ${{ steps.pr_context.outputs.response_language }} + config.large_patch_policy: 'clip' + config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]' + config.ignore_pr_labels: '["skip pr-agent"]' + config.publish_output: 'false' + github_action_config.auto_review: 'true' + github_action_config.auto_describe: 'false' + github_action_config.auto_improve: 'false' + github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]' + github_action_config.handle_push_trigger: 'true' + github_action_config.push_commands: '["/review"]' + github_action_config.enable_output: 'true' + pr_reviewer.num_max_findings: '4' + pr_reviewer.require_score_review: 'false' + pr_reviewer.require_tests_review: 'false' + pr_reviewer.require_security_review: 'false' + pr_reviewer.require_estimate_effort_to_review: 'false' + pr_reviewer.require_estimate_contribution_time_cost: 'false' + pr_reviewer.require_can_be_split_review: 'false' + pr_reviewer.require_todo_scan: 'false' + pr_reviewer.require_ticket_analysis_review: 'false' + pr_reviewer.enable_review_labels_effort: 'false' + pr_reviewer.enable_review_labels_security: 'false' + pr_reviewer.extra_instructions: | + Return key_issues_to_review only for concrete behavior defects introduced by this pull request. + Each finding must identify the affected behavior, a reachable trigger, and the existing contract or invariant it violates. + Use issue_content to state the smallest correction boundary, not a code patch. + Do not report style preferences, comments, refactors, architecture alternatives, speculative races, extra hardening, optional tests, or hypothetical concerns. + Return no findings when the evidence is incomplete. + + - name: Publish review comments and summary + if: >- + steps.pr_context.outputs.skip_pr_agent != 'true' && + steps.review_analysis.outcome == 'success' && + ( + github.event_name == 'pull_request_target' || + github.event.comment.body == '/review' || + startsWith(github.event.comment.body, '/review ') + ) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + REVIEWED_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} + CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }} + RESPONSE_LANGUAGE: ${{ steps.pr_context.outputs.response_language }} + REVIEW_JSON: ${{ steps.review_analysis.outputs.review }} + run: | + set -euo pipefail + current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + if [ "${current_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then + echo "PR head changed during analysis; skip stale review publication." + exit 0 + fi + + review_payload="$(mktemp)" + python3 - "${review_payload}" <<'PY' + import hashlib + import json + import os + import re + import subprocess + import sys + from urllib.parse import quote + + review_raw = os.environ.get("REVIEW_JSON") or "{}" + review = json.loads(review_raw) + if not review_raw.strip() or review == {}: + if int(os.environ.get("CHANGED_FILES") or 0): + raise SystemExit("Review analysis produced no structured output for a non-empty PR.") + review = {} + + repo = os.environ["REPO"] + number = os.environ["PR_NUMBER"] + head_sha = os.environ["REVIEWED_HEAD_SHA"] + language = os.environ.get("RESPONSE_LANGUAGE") or "zh-CN" + + def paged(endpoint): + result = json.loads(subprocess.check_output( + ["gh", "api", "--paginate", "--slurp", endpoint], text=True + )) + if result and all(isinstance(page, list) for page in result): + return [item for page in result for item in page] + return result + + files = paged(f"repos/{repo}/pulls/{number}/files?per_page=100") + comments = paged(f"repos/{repo}/pulls/{number}/comments?per_page=100") + reviews = paged(f"repos/{repo}/pulls/{number}/reviews?per_page=100") + + hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@") + changed_lines = {} + for file_data in files: + path = str(file_data.get("filename") or "") + line = None + lines = set() + for patch_line in (file_data.get("patch") or "").splitlines(): + hunk = hunk_pattern.match(patch_line) + if hunk: + line = int(hunk.group(1)) + continue + if line is None or patch_line.startswith("\\"): + continue + if patch_line.startswith("+") and not patch_line.startswith("+++"): + lines.add(line) + line += 1 + elif patch_line.startswith("-") and not patch_line.startswith("---"): + continue + else: + line += 1 + changed_lines[path] = lines + + def fingerprint(path, line): + normalized = "\n".join((path, str(line))) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + + current_fingerprints = set() + current_locations = set() + marker_pattern = re.compile(r"") + for comment in comments: + if comment.get("user", {}).get("login") != "github-actions[bot]": + continue + if comment.get("line") is None: + continue + match = marker_pattern.search(str(comment.get("body") or "")) + if match: + current_fingerprints.add(match.group(1)) + path = str(comment.get("path") or "") + try: + line = int(comment.get("line") or 0) + except (TypeError, ValueError): + line = 0 + if path and line > 0: + current_locations.add((path, line)) + + def code_url(path, line): + return f"https://github.com/{repo}/blob/{head_sha}/{quote(path, safe='/')}#L{line}" + + issues = review.get("key_issues_to_review") or [] + findings = [] + seen = set() + for issue in issues: + if not isinstance(issue, dict): + continue + path = str(issue.get("relevant_file") or "").strip() + header = str(issue.get("issue_header") or "").strip() + content = str(issue.get("issue_content") or "").strip() + try: + line = int(issue.get("start_line") or 0) + except (TypeError, ValueError): + line = 0 + if not path or not header or not content or line < 1: + continue + finding_key = (path, line, header.lower(), " ".join(content.split()).lower()) + if finding_key in seen: + continue + seen.add(finding_key) + findings.append({ + "path": path, + "line": line, + "header": header, + "content": content, + "fingerprint": fingerprint(path, line), + }) + + new_comments = [] + for finding in findings: + if finding["line"] not in changed_lines.get(finding["path"], set()): + continue + if finding["fingerprint"] in current_fingerprints or (finding["path"], finding["line"]) in current_locations: + continue + new_comments.append({ + "path": finding["path"], + "line": finding["line"], + "side": "RIGHT", + "body": "\n".join([ + f"", + f"**{finding['header']}**", + "", + finding["content"], + ]), + }) + + marker = "" + short_sha = head_sha[:7] + commit_url = f"https://github.com/{repo}/commit/{head_sha}" + chinese = language == "zh-CN" + lines = [marker, "## PR-Agent Code Review", ""] + if findings: + for finding in findings: + location = f"{finding['path']}:{finding['line']}" + concise = " ".join(finding["content"].split())[:360] + separator = ":" if chinese else ":" + lines.append(f"- [{location}]({code_url(finding['path'], finding['line'])}){separator} **{finding['header']}** - {concise}") + elif chinese: + lines.append("本次变更无需提出审查意见,暂无其他反馈。") + else: + lines.append("There are no review comments for the current changes. I have no additional feedback to provide.") + lines.extend([ + "", + f"审查提交:[{short_sha}]({commit_url})" if chinese else f"Reviewed commit: [{short_sha}]({commit_url})", + "", + ]) + payload = { + "body": "\n".join(lines), + "commit_id": head_sha, + "event": "COMMENT", + } + if new_comments: + payload["comments"] = new_comments + has_matching_summary = not new_comments and any( + existing.get("user", {}).get("login") == "github-actions[bot]" + and existing.get("commit_id") == head_sha + and str(existing.get("body") or "") == payload["body"] + for existing in reviews + ) + # 同一提交的手工重审仍会完成分析;完全相同的结果不重复发布 Review。 + if not has_matching_summary: + with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False) + PY + + latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + if [ "${latest_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then + echo "PR head changed while rendering review; skip stale review publication." + exit 0 + fi + + if [ -s "${review_payload}" ]; then + gh api --method POST "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --input "${review_payload}" >/dev/null + fi + old_summary_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body | startswith(\"\")) or (.body | startswith(\"\")) or (.body | startswith(\"\")) or (.body | startswith(\"\")))) | .id")" + while IFS= read -r comment_id; do + [ -z "${comment_id}" ] && continue + gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null + done <<< "${old_summary_ids}" + + - name: Answer PR question + if: >- + steps.pr_context.outputs.skip_pr_agent != 'true' && + github.event_name == 'issue_comment' && + ( + github.event.comment.body == '/ask' || + startsWith(github.event.comment.body, '/ask ') + ) + uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 + env: + GITHUB_TOKEN: ${{ github.token }} + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }} + config.model: 'gpt-5.6-terra' + config.fallback_models: '["gpt-5.5", "gpt-5.4"]' + config.custom_model_max_tokens: '1050000' + config.reasoning_effort: 'high' + config.ai_timeout: '900' + config.response_language: ${{ steps.pr_context.outputs.response_language }} + config.large_patch_policy: 'clip' + config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]' + config.ignore_pr_labels: '["skip pr-agent"]' + github_action_config.auto_review: 'false' + github_action_config.auto_describe: 'false' + github_action_config.auto_improve: 'false' diff --git a/docs/pr-agent.md b/docs/pr-agent.md index 646252f4..a13def77 100644 --- a/docs/pr-agent.md +++ b/docs/pr-agent.md @@ -1,86 +1,50 @@ # PR-Agent 使用说明 -本仓库通过 GitHub Actions 运行开源 PR-Agent,用于自动维护 PR 摘要、发布行内代码审查建议,并在每轮审查后发布一条简短的 Code Review 总结评论。 +本仓库通过 GitHub Actions 运行 PR-Agent,帮助贡献者维护 PR 摘要、获取代码审查结果和提出 PR 相关问题。 -## 触发方式 +## 自动执行 -`.github/workflows/pr-agent.yml` 监听: +同仓分支和来自 fork 的 PR 都会自动执行 PR-Agent。 -- `pull_request_target`:PR 打开、重新打开、标记 ready、请求 review、推送新 commit 时自动运行。 -- `issue_comment`:允许身份在 PR 评论里写允许的命令时手动运行。 +PR 在以下场景会自动处理: -PR 事件会自动执行受控审查,包含同仓 PR 和 fork PR。允许身份也可以在 PR 评论中使用允许的命令触发受控审查。 -允许身份包括 `OWNER`、`MEMBER`、`COLLABORATOR`、`CONTRIBUTOR` 和 `FIRST_TIME_CONTRIBUTOR`。 +- 打开或重新打开 PR。 +- 将草稿 PR 标记为可审查。 +- 请求审查。 +- 每次推送新的 commit。 -## Workflow 权限 +PR 带有 `skip pr-agent` 标签,或标题以 `[Auto]`、`Auto` 开头时,自动和手工路径都会跳过。 -workflow 设置了最小可用权限: +## 手工命令 -- `contents: read`:读取仓库内容和 PR diff。 -- `pull-requests: write`:更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。 -- `issues: write`:PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。 - -没有开启 `contents: write`。当前配置不让 PR-Agent 往仓库推代码或提交 changelog,因此不需要内容写权限。 - -## 自动行为 - -PR 事件默认自动执行: - -- `/describe`:更新 PR Body 中的 `PR-Agent 摘要` / `PR-Agent Summary` 标记区域,保留用户原始描述。 -- `/improve`:发布 GitHub 行内代码审查建议,不发布 PR-Agent 建议表格。 - -workflow 会在 `/improve` 后发布一条普通 PR 评论: - -- 评论标题为 `## Code Review`。 -- 如果本轮有新增行内建议,会基于这些建议生成自然语言总结。 -- 如果本轮没有新增行内建议,直接发布无更多反馈的简短总结。 -- 下一次运行前会删除上一条 PR-Agent Code Review 总结评论,避免评论堆叠,同时保留新的通知事件。 - -## 常用评论命令 - -以下身份可在 PR 评论中使用: - -- `OWNER`:仓库所有者。 -- `MEMBER`:组织仓库中的组织成员。 -- `COLLABORATOR`:仓库协作者。 -- `CONTRIBUTOR`:曾经向仓库提交并合入过代码的贡献者。 -- `FIRST_TIME_CONTRIBUTOR`:首次向仓库贡献 PR 的用户。 +在 PR 的普通讨论评论中使用以下命令: ```text /describe -/improve +/review /ask 这次改动有没有遗漏权限校验? ``` -评论触发依赖 `issue_comment` 事件。普通 issue 评论、Bot 评论、非允许身份评论、以及不以允许命令开头的评论都会跳过。 +- `/describe`:更新 PR Body 内按语言显示的 `PR-Agent 摘要` 或 `PR-Agent Summary`,并保留贡献者原有的 PR 描述。 +- `/review`:发起一次代码审查。 +- `/ask ...`:就当前 PR 提问,回复会发布在普通 PR 评论中。 -## 输出约定 +手工命令仅允许以下 GitHub 身份关联的用户使用:`OWNER`、`MEMBER`、`COLLABORATOR`、`CONTRIBUTOR`、`FIRST_TIME_CONTRIBUTOR`。 -PR-Agent 配置集中在 `.github/workflows/pr-agent.yml` 中维护。公开说明只描述用户可见行为: +新建的合法命令评论会触发执行;编辑后仍为合法命令的评论也会触发。编辑普通讨论评论不会调用模型。 -- 根据 PR 标题和用户原始描述自动选择中文或英文;无法识别时默认中文。 -- 保留用户原始 PR 描述,只更新 PR Body 中的 PR-Agent 标记区域。 -- 不使用 PR-Agent 的 Reviewer Guide 输出。 -- 不输出 PR Type、额外标签、图表或 describe 评论。 -- 只发布值得维护者处理的问题型行内建议。 -- 行内建议可使用 GitHub suggestion 形式,便于直接采纳。 -- 没有建议时不发布 PR-Agent 建议表格,只保留简短的 Code Review 总结评论。 +## 审查结果 -行内建议可使用风险前缀: +`/describe` 的结果位于 PR Body 中按语言显示的 `PR-Agent 摘要` 或 `PR-Agent Summary` 区域,用于概览本次变更。 -- `🔴 **High Risk**:`:高风险问题。 -- `🟡 **Medium Risk**:`:中风险问题。 -- `🔵 **Low Risk**:`:低风险问题。 +`/review` 和自动审查会通过原生 GitHub Review 发布,结果位于 Review 页签,标题固定为 `PR-Agent Code Review`。审查摘要包含可点击的 `文件:行号` 链接;可定位到本次变更的具体问题会在对应代码行以行内评论呈现,无法行内定位的问题仍通过摘要中的链接呈现。 -可按需再启用的工具配置: +审查不会额外创建专用的摘要评论。未发现需要处理的问题时,Review 会显示: -- `[pr_update_changelog]`:配合 `/update_changelog` 生成 changelog 建议。 -- `[pr_add_docs]`:配合 `/add_docs` 生成文档建议。 -- `[pr_test]`:配合 `/test` 生成测试建议;它不会替代仓库自己的测试命令。 -- `[pr_questions]`:配合 `/ask ...` 回答 PR 相关问题。 +> 本次变更无需提出审查意见,暂无其他反馈。 ## 安全边界 -PR-Agent 依赖的 Docker 镜像在 workflow 中固定版本号和 digest,不使用浮动的 `latest` 或仅依赖可变 tag。 +workflow 使用固定 digest 的 PR-Agent 容器镜像,不使用浮动标签。自动审查通过 `pull_request_target` 在目标仓库上下文中读取 PR 信息,但不会 checkout 或执行 PR 分支代码。 -当前使用 `pull_request_target` 支持 PR 自动审查,但 workflow 不 checkout 或执行 PR 分支代码,只运行固定 digest 的 PR-Agent 容器并通过 GitHub API 读取 PR diff。`issue_comment` 属于 base repo 事件,因此评论命令只允许指定身份触发。 +权限保持最小化:只授予读取仓库内容所需的 `contents: read`,以及更新 PR Body、发布 Review 和回复 PR 评论所需的写权限;不会向仓库推送代码或创建提交。