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: # 手动命令只在 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: 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 == '/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 }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: - 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 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 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" 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 or latin_words < 8: response_language = "zh-CN" summary_heading = "PR-Agent 摘要" else: response_language = "en-US" summary_heading = "PR-Agent Summary" 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"skip_pr_agent={skip_pr_agent}") PY - name: Prepare PR-Agent description markers if: >- 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 }} SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }} CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }} 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 "" start = "" end = "" 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, 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*" ) 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, ) start_index = body.find(start) 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'