diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index 7b85d4b7..dae53fbf 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -28,7 +28,6 @@ permissions: jobs: pr-agent: name: PR-Agent inline review - # PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。 if: >- github.event.sender.type != 'Bot' && ( @@ -48,10 +47,10 @@ 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 + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 20 steps: - name: Detect PR review language id: pr_language @@ -59,14 +58,12 @@ jobs: 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 @@ -76,7 +73,7 @@ jobs: 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 "" + head_sha = pr.get("head", {}).get("sha") or "" body = re.sub( r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*" @@ -93,8 +90,9 @@ jobs: ) 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)) + text = f"{title}\n{body}" + cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text)) + latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text)) if cjk_count >= 4: response_language = "zh-CN" @@ -116,29 +114,10 @@ jobs: 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' || ( @@ -154,15 +133,11 @@ jobs: 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" @@ -182,9 +157,7 @@ jobs: 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" + 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", @@ -194,7 +167,7 @@ jobs: 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:] + 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 @@ -204,40 +177,8 @@ jobs: 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' + python3 - "${next_body}" "${payload}" <<'PY' import json import sys from pathlib import Path @@ -245,125 +186,17 @@ jobs: 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 + gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null + body_changed=true 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 + - name: Snapshot PR-Agent inline comments 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 == 'pull_request_target' || ( github.event_name == 'issue_comment' && ( @@ -376,31 +209,15 @@ jobs: 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 + if: steps.pr_language.outputs.skip_pr_agent != 'true' # 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。 uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825 env: @@ -426,13 +243,13 @@ jobs: # 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 }} + 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: ${{ steps.review_history.outputs.push_commands }} + github_action_config.push_commands: '["/describe", "/improve"]' # 保留 action outputs,便于后续 workflow 编排或排查。 github_action_config.enable_output: "true" @@ -461,10 +278,10 @@ jobs: 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**:. + 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: "8" - pr_code_suggestions.num_code_suggestions_per_chunk: "2" + 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" @@ -473,214 +290,11 @@ jobs: # github_action_config.auto_improve: "true" # config.verbosity_level: "1" - - 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 == 'pull_request_target' || ( github.event_name == 'issue_comment' && ( @@ -694,21 +308,16 @@ jobs: 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)" @@ -724,76 +333,34 @@ jobs: 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' + python3 - "${before_ids}" "${comments}" "${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_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_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) - ] + 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 = normalize_suggestion_body(item.get("body") or "") + 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"), @@ -814,6 +381,7 @@ jobs: python3 - "${review_data}" "${payload}" <<'PY' import json import os + import textwrap import urllib.error import urllib.request from pathlib import Path @@ -908,91 +476,58 @@ jobs: 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): + 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 = "\n".join([ - marker, - "## Code Review", - "", - summary.strip(), - "", - f'{commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]})', - "", - ]) + 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 - 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")" + 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 - 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 + gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null done <<< "${comment_ids}" - if [ "${delete_failed}" = "true" ]; then - exit 1 - fi - name: Restore PR-Agent description markers on failure if: >- - always() && + 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_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 + 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}" - 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' + python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY' import json import re import sys @@ -1002,12 +537,9 @@ jobs: 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: @@ -1034,67 +566,18 @@ jobs: 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: + if "pr_agent:summary" not in current_section: print("No visible PR-Agent summary placeholder to restore.") raise SystemExit(0) - if current_has_placeholder and current_section != placeholder_section: + if 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 + restored_section = backup_body[backup_block[0]:backup_block[1]] if backup_block else "" next_body = current_body[:current_block[0]] + restored_section + current_body[current_block[1]:] next_body = re.sub(r"\n{4,}", "\n\n\n", next_body).rstrip() + "\n" payload_path.write_text(json.dumps({"body": next_body}, ensure_ascii=False)) PY if [ -s "${payload}" ]; then - gh api -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 + gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null fi diff --git a/docs/pr-agent.md b/docs/pr-agent.md index f89b0d2f..646252f4 100644 --- a/docs/pr-agent.md +++ b/docs/pr-agent.md @@ -34,7 +34,7 @@ workflow 会在 `/improve` 后发布一条普通 PR 评论: - 评论标题为 `## Code Review`。 - 如果本轮有新增行内建议,会基于这些建议生成自然语言总结。 - 如果本轮没有新增行内建议,直接发布无更多反馈的简短总结。 -- 发布新总结后会删除上一条 PR-Agent Code Review 总结评论,避免评论堆叠,同时保留新的通知事件。 +- 下一次运行前会删除上一条 PR-Agent Code Review 总结评论,避免评论堆叠,同时保留新的通知事件。 ## 常用评论命令 @@ -58,7 +58,7 @@ workflow 会在 `/improve` 后发布一条普通 PR 评论: PR-Agent 配置集中在 `.github/workflows/pr-agent.yml` 中维护。公开说明只描述用户可见行为: -- 根据用户原始 PR 描述自动选择中文或英文;无法识别时默认中文。 +- 根据 PR 标题和用户原始描述自动选择中文或英文;无法识别时默认中文。 - 保留用户原始 PR 描述,只更新 PR Body 中的 PR-Agent 标记区域。 - 不使用 PR-Agent 的 Reviewer Guide 输出。 - 不输出 PR Type、额外标签、图表或 describe 评论。 @@ -83,4 +83,4 @@ PR-Agent 配置集中在 `.github/workflows/pr-agent.yml` 中维护。公开说 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 事件,因此评论命令只允许指定身份触发。 +当前使用 `pull_request_target` 支持 PR 自动审查,但 workflow 不 checkout 或执行 PR 分支代码,只运行固定 digest 的 PR-Agent 容器并通过 GitHub API 读取 PR diff。`issue_comment` 属于 base repo 事件,因此评论命令只允许指定身份触发。