ci(pr-agent): simplify review workflow (#6082)

This commit is contained in:
InfinityPacer
2026-07-08 12:49:19 +08:00
committed by GitHub
parent eb4ecd990a
commit f3ac69669c
2 changed files with 60 additions and 577 deletions

View File

@@ -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 = "<!-- pr-agent-summary: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"<!-- pr-agent-run-head:{run_head_sha} -->\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*(?=<!-- pr-agent-summary:start -->)",
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(\"<!-- pr-agent-code-review-summary -->\")) 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*pr-agent-inline-comment(?::[^>]*)?\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: "<!-- pr-agent-inline-comment:${{ github.run_id }} -->"
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*pr-agent-inline-comment(?::[^>]*)?\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: "<!-- pr-agent-inline-comment:${{ github.run_id }} -->"
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: "<!-- pr-agent-inline-comment:${{ github.run_id }} -->"
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*pr-agent-inline-comment(?::[^>]*)?\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(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-update-notification -->\"))) 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(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-update-notification -->\")))) | .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 = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
run_marker = f"<!-- pr-agent-run-head:{run_head_sha} -->" 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

View File

@@ -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 事件,因此评论命令只允许指定身份触发。