diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index ac48b106..7b85d4b7 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -11,7 +11,7 @@ on: - review_requested - synchronize issue_comment: - # 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。 + # 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。 # issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。 types: - created @@ -27,7 +27,7 @@ permissions: jobs: pr-agent: - name: PR-Agent review and describe + name: PR-Agent inline review # PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。 if: >- github.event.sender.type != 'Bot' && @@ -38,8 +38,6 @@ jobs: github.event.issue.pull_request != null && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) && ( - github.event.comment.body == '/review' || - startsWith(github.event.comment.body, '/review ') || github.event.comment.body == '/describe' || startsWith(github.event.comment.body, '/describe ') || github.event.comment.body == '/improve' || @@ -49,13 +47,362 @@ jobs: ) ) ) + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}-${{ github.event_name == 'issue_comment' && (github.event.comment.body == '/ask' || startsWith(github.event.comment.body, '/ask ')) && github.event.comment.id || 'write' }} + cancel-in-progress: false runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 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 }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + 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 os + 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 = os.environ.get("EVENT_HEAD_SHA") or 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) + + cjk_count = len(re.findall(r"[\u4e00-\u9fff]", body)) + latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", body)) + + 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: Check PR head before side effects + id: run_state + if: 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_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + run: | + set -euo pipefail + current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + is_stale=false + if [ -n "${RUN_HEAD_SHA}" ] && [ "${current_head_sha}" != "${RUN_HEAD_SHA}" ]; then + echo "PR head changed from ${RUN_HEAD_SHA} to ${current_head_sha}; skip stale PR-Agent run." + is_stale=true + fi + echo "is_stale=${is_stale}" >> "${GITHUB_OUTPUT}" + + - name: Prepare PR-Agent description markers + id: prepare_description + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != '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_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + run: | + set -euo pipefail + current_body="$(mktemp)" + latest_body="$(mktemp)" + latest_response="$(mktemp)" + next_body="$(mktemp)" + payload="$(mktemp)" + etag_file="$(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 摘要" + run_head_sha = os.environ.get("RUN_HEAD_SHA") or "" + run_marker = f"\n" if run_head_sha else "" + agent_block = f"## {summary_heading}\n\n{start}\n{run_marker}{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{run_marker}{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 + can_describe=true + if ! cmp -s "${current_body}" "${next_body}"; then + gh api -i "repos/${REPO}/pulls/${PR_NUMBER}" > "${latest_response}" + python3 - "${latest_response}" "${latest_body}" "${etag_file}" <<'PY' + import json + import re + import sys + from pathlib import Path + + raw = Path(sys.argv[1]).read_text() + parts = re.split(r"\r?\n\r?\n", raw) + headers = parts[-2] if len(parts) > 1 else "" + body = parts[-1] + etag = "" + last_modified = "" + for line in headers.splitlines(): + lower = line.lower() + if lower.startswith("etag:"): + etag = line.split(":", 1)[1].strip() + elif lower.startswith("last-modified:"): + last_modified = line.split(":", 1)[1].strip() + Path(sys.argv[2]).write_text(json.loads(body).get("body") or "") + condition_header = "" + if etag and not etag.lower().startswith("w/"): + condition_header = f"If-Match: {etag}" + elif last_modified: + condition_header = f"If-Unmodified-Since: {last_modified}" + Path(sys.argv[3]).write_text(condition_header) + PY + if ! cmp -s "${current_body}" "${latest_body}"; then + echo "PR body changed while preparing PR-Agent markers; skip placeholder update." + can_describe=false + else + 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 + condition_header="$(cat "${etag_file}")" + if [ -z "${condition_header}" ]; then + echo "No conditional validator available; skip placeholder update." + can_describe=false + else + gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" -H "${condition_header}" --input "${payload}" >/dev/null + body_changed=true + fi + fi + fi + echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}" + echo "can_describe=${can_describe}" >> "${GITHUB_OUTPUT}" + + - name: Check PR head before PR-Agent + id: pragent_head + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + run: | + set -euo pipefail + current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + is_stale=false + if [ -n "${RUN_HEAD_SHA}" ] && [ "${current_head_sha}" != "${RUN_HEAD_SHA}" ]; then + echo "PR head changed from ${RUN_HEAD_SHA} to ${current_head_sha}; skip stale PR-Agent run." + is_stale=true + fi + echo "is_stale=${is_stale}" >> "${GITHUB_OUTPUT}" + + - name: Check existing PR-Agent review summary + id: review_history + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + EVENT_NAME: ${{ github.event_name }} + CAN_DESCRIBE: ${{ steps.prepare_description.outputs.can_describe }} + run: | + set -euo pipefail + can_describe="${CAN_DESCRIBE:-true}" + should_improve=true + should_publish_existing_summary=false + push_commands='["/describe", "/improve"]' + if [ "${can_describe}" = "false" ]; then + push_commands='["/improve"]' + fi + if [ "${EVENT_NAME}" = "pull_request_target" ]; then + existing_summary_id="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"\")) and (.body | contains(\"/commit/${RUN_HEAD_SHA}\"))) | .id" | tail -n 1)" + existing_inline_id="" + if [ -z "${existing_summary_id}" ]; then + comments="$(mktemp)" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}" + existing_inline_id="$(python3 - "${comments}" "${RUN_HEAD_SHA}" <<'PY' + import json + import re + import sys + from pathlib import Path + + comments_path = Path(sys.argv[1]) + head_sha = sys.argv[2] + marker_re = re.compile(r"^\s*\s*", re.IGNORECASE) + + def is_pr_agent_suggestion(body: str) -> bool: + return bool(marker_re.search(body or "")) + + for line in comments_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("commit_id") == head_sha + and is_pr_agent_suggestion(item.get("body") or "") + ): + print(item.get("id") or "") + break + PY + )" + fi + if [ -n "${existing_summary_id}" ] || [ -n "${existing_inline_id}" ]; then + should_improve=false + if [ -z "${existing_summary_id}" ] && [ -n "${existing_inline_id}" ]; then + should_publish_existing_summary=true + fi + if [ "${can_describe}" = "false" ]; then + push_commands='[]' + else + push_commands='["/describe"]' + fi + echo "PR-Agent code review summary already exists for ${RUN_HEAD_SHA}; skip duplicate inline review." + fi + fi + echo "should_improve=${should_improve}" >> "${GITHUB_OUTPUT}" + echo "should_publish_existing_summary=${should_publish_existing_summary}" >> "${GITHUB_OUTPUT}" + echo "push_commands=${push_commands}" >> "${GITHUB_OUTPUT}" + + - name: Snapshot PR-Agent review state + id: inline_state_before + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' && + ( + ( + github.event_name == 'pull_request_target' && + ( + steps.review_history.outputs.should_improve == 'true' || + steps.review_history.outputs.should_publish_existing_summary == 'true' + ) + ) || + ( + 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_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + 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)" + review_ids="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/reviews?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | .id' | jq -sc '.')" + review_ids_b64="$(printf '%s' "${review_ids}" | base64 -w0)" + echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}" + echo "review_ids_b64=${review_ids_b64}" >> "${GITHUB_OUTPUT}" + - name: Run PR-Agent id: pragent + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' && + !( + steps.prepare_description.outputs.can_describe == 'false' && + github.event_name == 'issue_comment' && + ( + github.event.comment.body == '/describe' || + startsWith(github.event.comment.body, '/describe ') + ) + ) + timeout-minutes: 40 # 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。 - uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca + uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 env: # PR-Agent 使用该 token 读取 PR 元数据并发布评论。 GITHUB_TOKEN: ${{ github.token }} @@ -72,52 +419,682 @@ jobs: config.fallback_models: '["gpt-5.4"]' config.reasoning_effort: "xhigh" config.ai_timeout: "900" - config.response_language: "zh-CN" + 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"]' - # pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。 - github_action_config.auto_review: "true" - github_action_config.auto_describe: "true" - github_action_config.auto_improve: "false" + # PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。 + github_action_config.auto_review: "false" + github_action_config.auto_describe: ${{ steps.prepare_description.outputs.can_describe != 'false' }} + github_action_config.auto_improve: ${{ steps.review_history.outputs.should_improve }} - # 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。 - github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]' + # 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: ${{ steps.review_history.outputs.push_commands }} # 保留 action outputs,便于后续 workflow 编排或排查。 github_action_config.enable_output: "true" - # /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。 + # /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. - # /review 输出策略,聚焦维护者需要处理的风险和缺口。 - pr_reviewer.extra_instructions: | - 请用中文输出。 - 优先指出 P0/P1 风险,避免纠结纯格式问题。 - 重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。 - pr_reviewer.num_max_findings: "5" - pr_reviewer.persistent_comment: "true" - pr_reviewer.publish_output_no_suggestions: "true" - pr_reviewer.require_tests_review: "true" - pr_reviewer.require_security_review: "true" - pr_reviewer.require_estimate_effort_to_review: "true" - pr_reviewer.require_can_be_split_review: "true" - pr_reviewer.require_todo_scan: "false" - pr_reviewer.enable_review_labels_effort: "false" - pr_reviewer.enable_review_labels_security: "true" - - # /improve 和 /ask 的手动命令策略。 + # /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. + Start every suggestion 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: "7" - pr_code_suggestions.commitable_code_suggestions: "false" + pr_code_suggestions.suggestions_score_threshold: "8" + pr_code_suggestions.num_code_suggestions_per_chunk: "2" + 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" - # pr_reviewer.num_max_findings: "3" + + - name: Mark PR-Agent inline comments + if: >- + always() && + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' && + steps.inline_state_before.outputs.inline_ids_b64 != '' + 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 }} + BEFORE_REVIEW_IDS_B64: ${{ steps.inline_state_before.outputs.review_ids_b64 }} + PR_AGENT_INLINE_MARKER: "" + run: | + set -euo pipefail + before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)" + before_review_ids="$(printf '%s' "${BEFORE_REVIEW_IDS_B64:-W10=}" | base64 -d)" + comments="$(mktemp)" + reviews="$(mktemp)" + patches="$(mktemp)" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/reviews?per_page=100" --jq '.[] | @json' > "${reviews}" + python3 - "${before_ids}" "${before_review_ids}" "${comments}" "${reviews}" "${patches}" <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + before_ids = set(json.loads(sys.argv[1] or "[]")) + before_review_ids = set(json.loads(sys.argv[2] or "[]")) + comments_path = Path(sys.argv[3]) + reviews_path = Path(sys.argv[4]) + patches_path = Path(sys.argv[5]) + marker = os.environ["PR_AGENT_INLINE_MARKER"] + marker_re = re.compile(r"^\s*\s*", re.IGNORECASE) + risk_prefixes = ("🔴 **High Risk**:", "🟡 **Medium Risk**:", "🔵 **Low Risk**:") + + def is_pr_agent_inline_body(body: str) -> bool: + visible = (body or "").lstrip() + return visible.startswith("**Suggestion:**") or visible.startswith(risk_prefixes) + + new_review_ids = set() + for line in reviews_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("id") not in before_review_ids + ): + new_review_ids.add(item["id"]) + + patches = [] + for line in comments_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + body = item.get("body") or "" + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("id") not in before_ids + and item.get("pull_request_review_id") in new_review_ids + and is_pr_agent_inline_body(body) + and not marker_re.search(body) + ): + patches.append({"id": item["id"], "body": f"{marker}\n{body}"}) + + patches_path.write_text("\n".join(json.dumps(item, ensure_ascii=False) for item in patches)) + PY + + if [ ! -s "${patches}" ]; then + echo "No PR-Agent inline comments to mark." + exit 0 + fi + patch_failed=false + while IFS= read -r patch || [ -n "${patch}" ]; do + [ -z "${patch}" ] && continue + comment_id="$(printf '%s' "${patch}" | jq -r '.id')" + payload="$(mktemp)" + err="$(mktemp)" + printf '%s' "${patch}" | jq '{body:.body}' > "${payload}" + if ! gh api --method PATCH "repos/${REPO}/pulls/comments/${comment_id}" --input "${payload}" >/dev/null 2>"${err}"; then + cat "${err}" >&2 + echo "PR-Agent inline comment ${comment_id} could not be marked." + patch_failed=true + fi + done < "${patches}" + if [ "${patch_failed}" = "true" ]; then + exit 1 + fi + + - name: Clean stale PR-Agent inline comments + id: post_pragent_state + if: >- + always() && + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' && + steps.inline_state_before.outputs.inline_ids_b64 != '' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }} + BEFORE_REVIEW_IDS_B64: ${{ steps.inline_state_before.outputs.review_ids_b64 }} + PR_AGENT_INLINE_MARKER: "" + run: | + set -euo pipefail + current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + is_stale=false + if [ -n "${RUN_HEAD_SHA}" ] && [ "${current_head_sha}" != "${RUN_HEAD_SHA}" ]; then + is_stale=true + fi + echo "is_stale=${is_stale}" >> "${GITHUB_OUTPUT}" + if [ "${is_stale}" != "true" ]; then + exit 0 + fi + + before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)" + before_review_ids="$(printf '%s' "${BEFORE_REVIEW_IDS_B64:-W10=}" | base64 -d)" + comments="$(mktemp)" + reviews="$(mktemp)" + delete_ids="$(mktemp)" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/reviews?per_page=100" --jq '.[] | @json' > "${reviews}" + python3 - "${before_ids}" "${before_review_ids}" "${comments}" "${reviews}" "${delete_ids}" "${RUN_HEAD_SHA}" <<'PY' + import json + import os + import sys + from pathlib import Path + + before_ids = set(json.loads(sys.argv[1] or "[]")) + before_review_ids = set(json.loads(sys.argv[2] or "[]")) + comments_path = Path(sys.argv[3]) + reviews_path = Path(sys.argv[4]) + delete_path = Path(sys.argv[5]) + head_sha = sys.argv[6] + marker = os.environ["PR_AGENT_INLINE_MARKER"] + + def is_pr_agent_suggestion(item: dict) -> bool: + return (item.get("body") or "").lstrip().startswith(marker) + + new_review_ids = set() + for line in reviews_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("id") not in before_review_ids + ): + new_review_ids.add(item["id"]) + + delete_ids = [] + for line in comments_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("id") not in before_ids + and item.get("pull_request_review_id") in new_review_ids + and is_pr_agent_suggestion(item) + ): + delete_ids.append(str(item["id"])) + + delete_path.write_text("\n".join(delete_ids)) + PY + + if [ ! -s "${delete_ids}" ]; then + echo "No stale PR-Agent inline comments to clean." + exit 0 + fi + delete_failed=false + while IFS= read -r comment_id || [ -n "${comment_id}" ]; do + [ -z "${comment_id}" ] && continue + err="$(mktemp)" + if ! gh api --method DELETE "repos/${REPO}/pulls/comments/${comment_id}" >/dev/null 2>"${err}"; then + if grep -qiE "HTTP 404|Not Found" "${err}"; then + echo "PR-Agent inline comment ${comment_id} was already removed; continue." + else + cat "${err}" >&2 + echo "PR-Agent inline comment ${comment_id} could not be deleted." + delete_failed=true + fi + fi + done < "${delete_ids}" + if [ "${delete_failed}" = "true" ]; then + exit 1 + fi + + - name: Publish PR-Agent code review summary + if: >- + steps.pr_language.outputs.skip_pr_agent != 'true' && + steps.run_state.outputs.is_stale != 'true' && + steps.pragent_head.outputs.is_stale != 'true' && + steps.post_pragent_state.outputs.is_stale != 'true' && + ( + ( + github.event_name == 'pull_request_target' && + ( + steps.review_history.outputs.should_improve == 'true' || + steps.review_history.outputs.should_publish_existing_summary == 'true' + ) + ) || + ( + 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 }} + BEFORE_REVIEW_IDS_B64: ${{ steps.inline_state_before.outputs.review_ids_b64 }} + RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }} + USE_EXISTING_INLINE_SUMMARY: ${{ github.event_name == 'pull_request_target' && steps.review_history.outputs.should_publish_existing_summary == 'true' }} + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }} + SUMMARY_MODEL: gpt-5.5 + PR_AGENT_INLINE_MARKER: "" + run: | + set -euo pipefail + before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)" + before_review_ids="$(printf '%s' "${BEFORE_REVIEW_IDS_B64:-W10=}" | base64 -d)" + pr_info="$(mktemp)" + comments="$(mktemp)" + reviews="$(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}" + gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/reviews?per_page=100" --jq '.[] | @json' > "${reviews}" + + python3 - "${before_ids}" "${before_review_ids}" "${comments}" "${reviews}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + before_ids = set(json.loads(sys.argv[1] or "[]")) + before_review_ids = set(json.loads(sys.argv[2] or "[]")) + comments_path = Path(sys.argv[3]) + reviews_path = Path(sys.argv[4]) + output_path = Path(sys.argv[5]) + head_sha = sys.argv[6] + pr_url = sys.argv[7] + commit_url = sys.argv[8] + short_sha = sys.argv[9] + marker = os.environ["PR_AGENT_INLINE_MARKER"] + use_existing_inline_summary = (os.environ.get("USE_EXISTING_INLINE_SUMMARY") or "").lower() == "true" + marker_re = re.compile( + r"^\s*\s*", + re.IGNORECASE, + ) + + def normalize_suggestion_body(body: str) -> str: + return marker_re.sub("", body or "", count=1).strip() + + def has_inline_marker(item: dict) -> bool: + return bool(marker_re.search(item.get("body") or "")) + + def has_current_run_marker(item: dict) -> bool: + return (item.get("body") or "").lstrip().startswith(marker) + + comments = [] + for line in comments_path.read_text().splitlines(): + if line.strip(): + comments.append(json.loads(line)) + + new_review_ids = set() + for line in reviews_path.read_text().splitlines(): + if not line.strip(): + continue + item = json.loads(line) + if ( + item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("id") not in before_review_ids + and item.get("commit_id") == head_sha + ): + new_review_ids.add(item["id"]) + + if use_existing_inline_summary: + new_comments = [ + item for item in comments + if item.get("user", {}).get("login") == "github-actions[bot]" + and item.get("commit_id") == head_sha + and has_inline_marker(item) + ] + else: + 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 + and item.get("pull_request_review_id") in new_review_ids + and has_current_run_marker(item) + ] + suggestions = [] + for item in new_comments: + body = normalize_suggestion_body(item.get("body") or "") + 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 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, IndexError, TypeError, 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 = "\n".join([ + marker, + "## Code Review", + "", + summary.strip(), + "", + f'{commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]})', + "", + ]) + payload_path.write_text(json.dumps({"body": body}, ensure_ascii=False)) + PY + + latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + if [ "${latest_head_sha}" != "${HEAD_SHA}" ]; then + echo "PR head changed from ${HEAD_SHA} to ${latest_head_sha}; skip stale code review summary." + exit 0 + fi + + new_comment="$(mktemp)" + gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" --input "${payload}" > "${new_comment}" + new_comment_id="$(jq -r '.id' "${new_comment}")" + new_comment_created_at="$(jq -r '.created_at' "${new_comment}")" + 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(\"\"))) and (.created_at < \"${new_comment_created_at}\" or (.created_at == \"${new_comment_created_at}\" and .id < ${new_comment_id}))) | .id")" + + if [ -z "${comment_ids}" ]; then + echo "No previous PR-Agent code review summary to clean." + exit 0 + fi + + delete_failed=false + while IFS= read -r comment_id; do + [ -z "${comment_id}" ] && continue + err="$(mktemp)" + if ! gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null 2>"${err}"; then + if grep -qiE "HTTP 404|Not Found" "${err}"; then + echo "PR-Agent summary comment ${comment_id} was already removed; continue." + else + cat "${err}" >&2 + echo "PR-Agent summary comment ${comment_id} could not be deleted." + delete_failed=true + fi + fi + done <<< "${comment_ids}" + if [ "${delete_failed}" = "true" ]; then + exit 1 + fi + + - name: Restore PR-Agent description markers on failure + if: >- + always() && + 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_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }} + 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)" + latest_body="$(mktemp)" + latest_response="$(mktemp)" + payload="$(mktemp)" + etag_file="$(mktemp)" + + if [ ! -e "${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}" + current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" + run_head_sha="${RUN_HEAD_SHA:-}" + is_stale=false + if [ -n "${run_head_sha}" ] && [ "${current_head_sha}" != "${run_head_sha}" ]; then + is_stale=true + fi + + python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" "${run_head_sha}" "${is_stale}" <<'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]) + run_head_sha = sys.argv[5] + is_stale = sys.argv[6] == "true" + + start = "" + end = "" + run_marker = f"" if run_head_sha else "" + 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]] + current_has_marker = bool(run_marker and run_marker in current_section) + current_has_placeholder = "pr_agent:summary" in current_section + if not current_has_placeholder and not current_has_marker: + print("No visible PR-Agent summary placeholder to restore.") + raise SystemExit(0) + if current_has_placeholder and current_section != placeholder_section: + print("Current PR-Agent summary block changed; skip restore.") + raise SystemExit(0) + + if current_has_marker and not current_has_placeholder and not is_stale: + next_section = current_section.replace(f"{run_marker}\n", "").replace(run_marker, "") + next_body = current_body[:current_block[0]] + next_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)) + raise SystemExit(0) + + restored_section = "" + if backup_block: + backup_section = backup_body[backup_block[0]:backup_block[1]] + if "pr_agent:summary" not in backup_section: + restored_section = backup_section + 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 -i "repos/${REPO}/pulls/${PR_NUMBER}" > "${latest_response}" + python3 - "${latest_response}" "${latest_body}" "${etag_file}" <<'PY' + import json + import re + import sys + from pathlib import Path + + raw = Path(sys.argv[1]).read_text() + parts = re.split(r"\r?\n\r?\n", raw) + headers = parts[-2] if len(parts) > 1 else "" + body = parts[-1] + etag = "" + last_modified = "" + for line in headers.splitlines(): + lower = line.lower() + if lower.startswith("etag:"): + etag = line.split(":", 1)[1].strip() + elif lower.startswith("last-modified:"): + last_modified = line.split(":", 1)[1].strip() + Path(sys.argv[2]).write_text(json.loads(body).get("body") or "") + condition_header = "" + if etag and not etag.lower().startswith("w/"): + condition_header = f"If-Match: {etag}" + elif last_modified: + condition_header = f"If-Unmodified-Since: {last_modified}" + Path(sys.argv[3]).write_text(condition_header) + PY + if ! cmp -s "${current_body}" "${latest_body}"; then + echo "PR body changed while preparing restore payload; skip restore." + exit 0 + fi + condition_header="$(cat "${etag_file}")" + if [ -z "${condition_header}" ]; then + echo "No conditional validator available; skip restore." + exit 0 + fi + gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" -H "${condition_header}" --input "${payload}" >/dev/null + fi diff --git a/docs/pr-agent.md b/docs/pr-agent.md index 73b2fed2..f89b0d2f 100644 --- a/docs/pr-agent.md +++ b/docs/pr-agent.md @@ -1,15 +1,6 @@ # PR-Agent 使用说明 -本仓库通过 GitHub Actions 运行开源 PR-Agent,用于自动生成 PR 说明和进行 AI Review。 - -## Secrets - -在仓库的 `Settings -> Secrets and variables -> Actions -> Repository secrets` 中配置: - -- `OPENAI_KEY`:OpenAI 或 OpenAI 兼容服务的 API Key。 -- `OPENAI_API_BASE`:OpenAI 兼容接口的 API Base,通常需要包含 `/v1`,以服务商文档为准。 - -`GITHUB_TOKEN` 使用 GitHub Actions 自动注入的 `${{ github.token }}`,不需要手工添加。 +本仓库通过 GitHub Actions 运行开源 PR-Agent,用于自动维护 PR 摘要、发布行内代码审查建议,并在每轮审查后发布一条简短的 Code Review 总结评论。 ## 触发方式 @@ -31,14 +22,19 @@ workflow 设置了最小可用权限: 没有开启 `contents: write`。当前配置不让 PR-Agent 往仓库推代码或提交 changelog,因此不需要内容写权限。 -默认自动执行: +## 自动行为 -- `/review`:检查 PR 风险、潜在 bug、安全问题、测试缺口和可维护性问题。 -- `/describe`:生成或更新 PR 描述、变更摘要和文件说明。 +PR 事件默认自动执行: -默认不自动执行: +- `/describe`:更新 PR Body 中的 `PR-Agent 摘要` / `PR-Agent Summary` 标记区域,保留用户原始描述。 +- `/improve`:发布 GitHub 行内代码审查建议,不发布 PR-Agent 建议表格。 -- `/improve`:给出代码改进建议。这个工具更容易产生噪音和额外成本,建议先用评论命令手动触发。 +workflow 会在 `/improve` 后发布一条普通 PR 评论: + +- 评论标题为 `## Code Review`。 +- 如果本轮有新增行内建议,会基于这些建议生成自然语言总结。 +- 如果本轮没有新增行内建议,直接发布无更多反馈的简短总结。 +- 发布新总结后会删除上一条 PR-Agent Code Review 总结评论,避免评论堆叠,同时保留新的通知事件。 ## 常用评论命令 @@ -51,7 +47,6 @@ workflow 设置了最小可用权限: - `FIRST_TIME_CONTRIBUTOR`:首次向仓库贡献 PR 的用户。 ```text -/review /describe /improve /ask 这次改动有没有遗漏权限校验? @@ -59,35 +54,23 @@ workflow 设置了最小可用权限: 评论触发依赖 `issue_comment` 事件。普通 issue 评论、Bot 评论、非允许身份评论、以及不以允许命令开头的评论都会跳过。 -## 配置来源 +## 输出约定 -PR-Agent 配置集中在 `.github/workflows/pr-agent.yml` 的 `env` 中维护。 +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 总结评论。 -- `config.model = "gpt-5.5"`:默认使用 GPT-5.5。 -- `config.fallback_models = ["gpt-5.4"]`:主模型不可用时降级到 GPT-5.4。 -- `config.reasoning_effort = "xhigh"`:使用更高审查推理强度。 -- `config.ai_timeout = "900"`:模型调用最长等待 900 秒。 -- `config.response_language = "zh-CN"`:让 PR-Agent 默认中文输出。 -- `config.large_patch_policy = "clip"`:大 PR 截断分析,不直接跳过。 -- `config.ignore_pr_title` / `config.ignore_pr_labels`:跳过自动生成 PR 或带 `skip pr-agent` 标签的 PR。 -- `pr_reviewer.extra_instructions`:要求中文输出,优先指出 P0/P1 风险,并关注安全、权限、状态一致性、异步/缓存、副作用和测试缺口。 -- `pr_reviewer.require_security_review = true`:要求输出安全审查部分。 -- `pr_reviewer.require_tests_review = true`:要求输出测试审查部分。 -- `pr_reviewer.enable_review_labels_effort = false`:不添加 `Review effort x/5` 工作量标签。 -- `pr_reviewer.enable_review_labels_security = true`:保留明确安全风险标签。 -- `pr_description.generate_ai_title = false`:默认不改 PR 标题。 -- `pr_description.publish_labels = false`:默认不添加 PR 类型标签。 -- `pr_description.enable_pr_diagram = false`:默认不生成图表。 -- `pr_code_suggestions.focus_only_on_problems = true`:手动 `/improve` 时优先输出问题型建议。 -- `pr_code_suggestions.suggestions_score_threshold = 7`:过滤低置信度建议。 +行内建议可使用风险前缀: -标签来源: - -- `/review` 可添加安全标签和工作量标签;当前只保留安全标签,关闭工作量标签。 -- `/describe` 可按 PR 类型添加 `Bug fix`、`Tests`、`Bug fix with tests`、`Enhancement`、`Documentation`、`Other` 等标签;当前 `pr_description.publish_labels = false`,不会添加类型标签。 -- 自定义标签默认未启用。 +- `🔴 **High Risk**:`:高风险问题。 +- `🟡 **Medium Risk**:`:中风险问题。 +- `🔵 **Low Risk**:`:低风险问题。 可按需再启用的工具配置: @@ -98,34 +81,6 @@ PR-Agent 配置集中在 `.github/workflows/pr-agent.yml` 的 `env` 中维护。 ## 安全边界 -PR-Agent Action 会读取 `OPENAI_KEY`,因此依赖的 Docker 镜像在 workflow 中固定版本号和 digest, -不使用浮动的 `latest` 或仅依赖可变 tag。 +PR-Agent 依赖的 Docker 镜像在 workflow 中固定版本号和 digest,不使用浮动的 `latest` 或仅依赖可变 tag。 -当前使用 `pull_request_target` 支持 fork PR 自动审查,但 workflow 不 checkout 或执行来自 fork 的代码, -只运行固定 digest 的 PR-Agent 容器并通过 GitHub API 读取 PR diff。`issue_comment` 属于 base repo -事件,因此评论命令只允许指定身份触发。 - -API Key 建议使用低额度、可轮换的专用 key。`OPENAI_API_BASE` 本身通常不是敏感信息,但继续按 secret 管理可以避免暴露服务商信息。 - -## 调整自动行为 - -自动行为在 workflow 的 `env` 中控制: - -```yaml -config.model: "gpt-5.5" -config.fallback_models: '["gpt-5.4"]' -config.reasoning_effort: "xhigh" -config.ai_timeout: "900" -config.response_language: "zh-CN" -github_action_config.auto_review: "true" -github_action_config.auto_describe: "true" -github_action_config.auto_improve: "false" -github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]' -pr_description.generate_ai_title: "false" -pr_description.publish_labels: "false" -pr_description.enable_pr_diagram: "false" -pr_reviewer.enable_review_labels_effort: "false" -pr_reviewer.enable_review_labels_security: "true" -``` - -如果需要自动运行 `/improve`,把 `github_action_config.auto_improve` 改为 `"true"`。建议先观察手动 `/improve` 的质量和成本,再决定是否开启。 +当前使用 `pull_request_target` 支持 fork PR 自动审查,但 workflow 不 checkout 或执行来自 fork 的代码,只运行固定 digest 的 PR-Agent 容器并通过 GitHub API 读取 PR diff。`issue_comment` 属于 base repo 事件,因此评论命令只允许指定身份触发。