mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
ci(pr-agent): use shared review runner (#6127)
This commit is contained in:
+11
-495
@@ -2,8 +2,7 @@ name: PR-Agent
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
|
||||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- reopened
|
- reopened
|
||||||
@@ -11,17 +10,13 @@ on:
|
|||||||
- review_requested
|
- review_requested
|
||||||
- synchronize
|
- synchronize
|
||||||
issue_comment:
|
issue_comment:
|
||||||
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
|
|
||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
- edited
|
- edited
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# 读取仓库内容和 PR diff。
|
|
||||||
contents: read
|
contents: read
|
||||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
# PR 评论在 GitHub API 中属于 issue comments,手动问答需要该权限。
|
|
||||||
issues: write
|
issues: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -29,21 +24,11 @@ jobs:
|
|||||||
if: >-
|
if: >-
|
||||||
github.event.sender.type != 'Bot' &&
|
github.event.sender.type != 'Bot' &&
|
||||||
(
|
(
|
||||||
(
|
github.event_name == 'pull_request_target' ||
|
||||||
github.event_name == 'pull_request_target'
|
|
||||||
) ||
|
|
||||||
(
|
(
|
||||||
github.event_name == 'issue_comment' &&
|
github.event_name == 'issue_comment' &&
|
||||||
github.event.issue.pull_request != null &&
|
github.event.issue.pull_request != null &&
|
||||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
|
||||||
(
|
|
||||||
github.event.comment.body == '/describe' ||
|
|
||||||
startsWith(github.event.comment.body, '/describe ') ||
|
|
||||||
github.event.comment.body == '/review' ||
|
|
||||||
startsWith(github.event.comment.body, '/review ') ||
|
|
||||||
github.event.comment.body == '/ask' ||
|
|
||||||
startsWith(github.event.comment.body, '/ask ')
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -53,484 +38,15 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Capture PR context
|
- name: Run PR Review
|
||||||
id: pr_context
|
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pull="$(mktemp)"
|
|
||||||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}"
|
|
||||||
python3 - "${pull}" >> "${GITHUB_OUTPUT}" <<'PY'
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
||||||
title = pull.get("title") or ""
|
|
||||||
body = pull.get("body") or ""
|
|
||||||
labels = {item.get("name", "") for item in pull.get("labels") or []}
|
|
||||||
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
|
|
||||||
body = re.sub(
|
|
||||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
|
|
||||||
" ",
|
|
||||||
body,
|
|
||||||
flags=re.DOTALL,
|
|
||||||
)
|
|
||||||
text = f"{title}\n{body}"
|
|
||||||
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
|
|
||||||
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
|
|
||||||
if cjk_count >= 4 or latin_words < 8:
|
|
||||||
response_language = "zh-CN"
|
|
||||||
summary_heading = "PR-Agent 摘要"
|
|
||||||
else:
|
|
||||||
response_language = "en-US"
|
|
||||||
summary_heading = "PR-Agent Summary"
|
|
||||||
print(f"head_sha={pull['head']['sha']}")
|
|
||||||
print(f"changed_files={pull.get('changed_files') or 0}")
|
|
||||||
print(f"response_language={response_language}")
|
|
||||||
print(f"summary_heading={summary_heading}")
|
|
||||||
print(f"skip_pr_agent={skip_pr_agent}")
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Prepare PR-Agent description markers
|
|
||||||
if: >-
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
github.event.comment.body == '/describe' ||
|
|
||||||
startsWith(github.event.comment.body, '/describe ')
|
|
||||||
)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
SUMMARY_HEADING: ${{ steps.pr_context.outputs.summary_heading }}
|
|
||||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
payload="$(mktemp)"
|
|
||||||
python3 - "${payload}" <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
pull = json.loads(subprocess.check_output(
|
|
||||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
|
||||||
text=True,
|
|
||||||
))
|
|
||||||
body = pull.get("body") or ""
|
|
||||||
start = "<!-- pr-agent-summary:start -->"
|
|
||||||
end = "<!-- pr-agent-summary:end -->"
|
|
||||||
heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
|
|
||||||
changed_files = int(os.environ.get("CHANGED_FILES") or 0)
|
|
||||||
block = f"## {heading}\n\n{start}\npr_agent:summary\n{end}"
|
|
||||||
start_index = body.find(start)
|
|
||||||
end_index = body.find(end, start_index + len(start)) if start_index >= 0 else -1
|
|
||||||
owned_block = re.compile(
|
|
||||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
|
||||||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->\s*"
|
|
||||||
)
|
|
||||||
if changed_files == 0:
|
|
||||||
updated = owned_block.sub("", body)
|
|
||||||
if updated != body:
|
|
||||||
updated = updated.rstrip()
|
|
||||||
elif start_index >= 0 and end_index >= 0:
|
|
||||||
body = re.sub(
|
|
||||||
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
|
|
||||||
f"## {heading}\n\n",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
start_index = body.find(start)
|
|
||||||
end_index = body.find(end, start_index + len(start))
|
|
||||||
updated = body[:start_index] + f"{start}\npr_agent:summary\n{end}" + body[end_index + len(end):]
|
|
||||||
elif start_index >= 0 or end in body:
|
|
||||||
updated = body
|
|
||||||
elif body.strip():
|
|
||||||
updated = f"{body.rstrip()}\n\n{block}\n"
|
|
||||||
else:
|
|
||||||
updated = f"{block}\n"
|
|
||||||
if updated != body:
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
||||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
|
||||||
PY
|
|
||||||
if [ -s "${payload}" ]; then
|
|
||||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update PR description
|
|
||||||
if: >-
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
steps.pr_context.outputs.changed_files != '0' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
github.event.comment.body == '/describe' ||
|
|
||||||
startsWith(github.event.comment.body, '/describe ')
|
|
||||||
)
|
|
||||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||||
config.model: 'gpt-5.6-terra'
|
PRR_AUTO_REVIEW_SCOPE: all
|
||||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
|
||||||
config.custom_model_max_tokens: '1050000'
|
PRR_DISABLED_COMMANDS: '["/improve"]'
|
||||||
config.reasoning_effort: 'medium'
|
PRR_SKIP_LABEL: skip pr-agent
|
||||||
config.ai_timeout: '900'
|
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
|
||||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
config.response_language: zh-CN
|
||||||
config.large_patch_policy: 'clip'
|
|
||||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
|
||||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
|
||||||
github_action_config.auto_review: 'false'
|
|
||||||
github_action_config.auto_describe: 'true'
|
|
||||||
github_action_config.auto_improve: 'false'
|
|
||||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
|
||||||
github_action_config.handle_push_trigger: 'true'
|
|
||||||
github_action_config.push_commands: '["/describe"]'
|
|
||||||
pr_description.generate_ai_title: 'false'
|
|
||||||
pr_description.publish_labels: 'false'
|
|
||||||
pr_description.publish_description_as_comment: 'false'
|
|
||||||
pr_description.publish_description_as_comment_persistent: 'false'
|
|
||||||
pr_description.enable_pr_diagram: 'false'
|
|
||||||
pr_description.enable_pr_type: 'false'
|
|
||||||
pr_description.enable_help_text: 'false'
|
|
||||||
pr_description.enable_help_comment: 'false'
|
|
||||||
pr_description.enable_semantic_files_types: 'false'
|
|
||||||
pr_description.collapsible_file_list: 'adaptive'
|
|
||||||
pr_description.add_original_user_description: 'true'
|
|
||||||
pr_description.use_description_markers: 'true'
|
|
||||||
pr_description.final_update_message: 'false'
|
|
||||||
pr_description.extra_instructions: |
|
|
||||||
Match the configured response language.
|
|
||||||
Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks.
|
|
||||||
Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes.
|
|
||||||
Avoid file lists and local command transcripts.
|
|
||||||
|
|
||||||
- name: Remove unfilled PR-Agent description marker
|
|
||||||
if: >-
|
|
||||||
always() &&
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
github.event.comment.body == '/describe' ||
|
|
||||||
startsWith(github.event.comment.body, '/describe ')
|
|
||||||
)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
payload="$(mktemp)"
|
|
||||||
python3 - "${payload}" <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
pull = json.loads(subprocess.check_output(
|
|
||||||
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
|
|
||||||
text=True,
|
|
||||||
))
|
|
||||||
body = pull.get("body") or ""
|
|
||||||
placeholder = "<!-- pr-agent-summary:start -->\npr_agent:summary\n<!-- pr-agent-summary:end -->"
|
|
||||||
owned_block = re.compile(
|
|
||||||
r"(?ims)^##\s+(?:PR-Agent\s+摘要|PR-Agent\s+Summary)\s*\n\s*"
|
|
||||||
r"<!-- pr-agent-summary:start -->\s*pr_agent:summary\s*<!-- pr-agent-summary:end -->\s*"
|
|
||||||
)
|
|
||||||
if placeholder in body:
|
|
||||||
updated = owned_block.sub("", body).rstrip()
|
|
||||||
if updated != body:
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
||||||
json.dump({"body": updated}, handle, ensure_ascii=False)
|
|
||||||
PY
|
|
||||||
if [ -s "${payload}" ]; then
|
|
||||||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Analyze PR review
|
|
||||||
id: review_analysis
|
|
||||||
if: >-
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
github.event.comment.body == '/review' ||
|
|
||||||
startsWith(github.event.comment.body, '/review ')
|
|
||||||
)
|
|
||||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
|
||||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
|
||||||
config.model: ${{ github.event_name == 'issue_comment' && 'gpt-5.6-sol' || 'gpt-5.6-terra' }}
|
|
||||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
|
||||||
config.custom_model_max_tokens: '1050000'
|
|
||||||
config.reasoning_effort: 'xhigh'
|
|
||||||
config.ai_timeout: '900'
|
|
||||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
|
||||||
config.large_patch_policy: 'clip'
|
|
||||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
|
||||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
|
||||||
config.publish_output: 'false'
|
|
||||||
github_action_config.auto_review: 'true'
|
|
||||||
github_action_config.auto_describe: 'false'
|
|
||||||
github_action_config.auto_improve: 'false'
|
|
||||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
|
|
||||||
github_action_config.handle_push_trigger: 'true'
|
|
||||||
github_action_config.push_commands: '["/review"]'
|
|
||||||
github_action_config.enable_output: 'true'
|
|
||||||
pr_reviewer.num_max_findings: '4'
|
|
||||||
pr_reviewer.require_score_review: 'false'
|
|
||||||
pr_reviewer.require_tests_review: 'false'
|
|
||||||
pr_reviewer.require_security_review: 'false'
|
|
||||||
pr_reviewer.require_estimate_effort_to_review: 'false'
|
|
||||||
pr_reviewer.require_estimate_contribution_time_cost: 'false'
|
|
||||||
pr_reviewer.require_can_be_split_review: 'false'
|
|
||||||
pr_reviewer.require_todo_scan: 'false'
|
|
||||||
pr_reviewer.require_ticket_analysis_review: 'false'
|
|
||||||
pr_reviewer.enable_review_labels_effort: 'false'
|
|
||||||
pr_reviewer.enable_review_labels_security: 'false'
|
|
||||||
pr_reviewer.extra_instructions: |
|
|
||||||
Return key_issues_to_review only for concrete behavior defects introduced by this pull request.
|
|
||||||
Each finding must identify the affected behavior, a reachable trigger, and the existing contract or invariant it violates.
|
|
||||||
Use issue_content to state the smallest correction boundary, not a code patch.
|
|
||||||
Do not report style preferences, comments, refactors, architecture alternatives, speculative races, extra hardening, optional tests, or hypothetical concerns.
|
|
||||||
Return no findings when the evidence is incomplete.
|
|
||||||
|
|
||||||
- name: Publish review comments and summary
|
|
||||||
if: >-
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
steps.review_analysis.outcome == 'success' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'pull_request_target' ||
|
|
||||||
github.event.comment.body == '/review' ||
|
|
||||||
startsWith(github.event.comment.body, '/review ')
|
|
||||||
)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
|
||||||
REVIEWED_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
|
||||||
CHANGED_FILES: ${{ steps.pr_context.outputs.changed_files }}
|
|
||||||
RESPONSE_LANGUAGE: ${{ steps.pr_context.outputs.response_language }}
|
|
||||||
REVIEW_JSON: ${{ steps.review_analysis.outputs.review }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
current_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
|
||||||
if [ "${current_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
|
||||||
echo "PR head changed during analysis; skip stale review publication."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
review_payload="$(mktemp)"
|
|
||||||
python3 - "${review_payload}" <<'PY'
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
review_raw = os.environ.get("REVIEW_JSON") or "{}"
|
|
||||||
review = json.loads(review_raw)
|
|
||||||
if not review_raw.strip() or review == {}:
|
|
||||||
if int(os.environ.get("CHANGED_FILES") or 0):
|
|
||||||
raise SystemExit("Review analysis produced no structured output for a non-empty PR.")
|
|
||||||
review = {}
|
|
||||||
|
|
||||||
repo = os.environ["REPO"]
|
|
||||||
number = os.environ["PR_NUMBER"]
|
|
||||||
head_sha = os.environ["REVIEWED_HEAD_SHA"]
|
|
||||||
language = os.environ.get("RESPONSE_LANGUAGE") or "zh-CN"
|
|
||||||
|
|
||||||
def paged(endpoint):
|
|
||||||
result = json.loads(subprocess.check_output(
|
|
||||||
["gh", "api", "--paginate", "--slurp", endpoint], text=True
|
|
||||||
))
|
|
||||||
if result and all(isinstance(page, list) for page in result):
|
|
||||||
return [item for page in result for item in page]
|
|
||||||
return result
|
|
||||||
|
|
||||||
files = paged(f"repos/{repo}/pulls/{number}/files?per_page=100")
|
|
||||||
comments = paged(f"repos/{repo}/pulls/{number}/comments?per_page=100")
|
|
||||||
reviews = paged(f"repos/{repo}/pulls/{number}/reviews?per_page=100")
|
|
||||||
|
|
||||||
hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
||||||
changed_lines = {}
|
|
||||||
for file_data in files:
|
|
||||||
path = str(file_data.get("filename") or "")
|
|
||||||
line = None
|
|
||||||
lines = set()
|
|
||||||
for patch_line in (file_data.get("patch") or "").splitlines():
|
|
||||||
hunk = hunk_pattern.match(patch_line)
|
|
||||||
if hunk:
|
|
||||||
line = int(hunk.group(1))
|
|
||||||
continue
|
|
||||||
if line is None or patch_line.startswith("\\"):
|
|
||||||
continue
|
|
||||||
if patch_line.startswith("+") and not patch_line.startswith("+++"):
|
|
||||||
lines.add(line)
|
|
||||||
line += 1
|
|
||||||
elif patch_line.startswith("-") and not patch_line.startswith("---"):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
line += 1
|
|
||||||
changed_lines[path] = lines
|
|
||||||
|
|
||||||
def fingerprint(path, line):
|
|
||||||
normalized = "\n".join((path, str(line)))
|
|
||||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
||||||
|
|
||||||
current_fingerprints = set()
|
|
||||||
current_locations = set()
|
|
||||||
marker_pattern = re.compile(r"<!-- pr-agent-review:([0-9a-f]{16}) -->")
|
|
||||||
for comment in comments:
|
|
||||||
if comment.get("user", {}).get("login") != "github-actions[bot]":
|
|
||||||
continue
|
|
||||||
if comment.get("line") is None:
|
|
||||||
continue
|
|
||||||
match = marker_pattern.search(str(comment.get("body") or ""))
|
|
||||||
if match:
|
|
||||||
current_fingerprints.add(match.group(1))
|
|
||||||
path = str(comment.get("path") or "")
|
|
||||||
try:
|
|
||||||
line = int(comment.get("line") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
line = 0
|
|
||||||
if path and line > 0:
|
|
||||||
current_locations.add((path, line))
|
|
||||||
|
|
||||||
def code_url(path, line):
|
|
||||||
return f"https://github.com/{repo}/blob/{head_sha}/{quote(path, safe='/')}#L{line}"
|
|
||||||
|
|
||||||
issues = review.get("key_issues_to_review") or []
|
|
||||||
findings = []
|
|
||||||
seen = set()
|
|
||||||
for issue in issues:
|
|
||||||
if not isinstance(issue, dict):
|
|
||||||
continue
|
|
||||||
path = str(issue.get("relevant_file") or "").strip()
|
|
||||||
header = str(issue.get("issue_header") or "").strip()
|
|
||||||
content = str(issue.get("issue_content") or "").strip()
|
|
||||||
try:
|
|
||||||
line = int(issue.get("start_line") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
line = 0
|
|
||||||
if not path or not header or not content or line < 1:
|
|
||||||
continue
|
|
||||||
finding_key = (path, line, header.lower(), " ".join(content.split()).lower())
|
|
||||||
if finding_key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(finding_key)
|
|
||||||
findings.append({
|
|
||||||
"path": path,
|
|
||||||
"line": line,
|
|
||||||
"header": header,
|
|
||||||
"content": content,
|
|
||||||
"fingerprint": fingerprint(path, line),
|
|
||||||
})
|
|
||||||
|
|
||||||
new_comments = []
|
|
||||||
for finding in findings:
|
|
||||||
if finding["line"] not in changed_lines.get(finding["path"], set()):
|
|
||||||
continue
|
|
||||||
if finding["fingerprint"] in current_fingerprints or (finding["path"], finding["line"]) in current_locations:
|
|
||||||
continue
|
|
||||||
new_comments.append({
|
|
||||||
"path": finding["path"],
|
|
||||||
"line": finding["line"],
|
|
||||||
"side": "RIGHT",
|
|
||||||
"body": "\n".join([
|
|
||||||
f"<!-- pr-agent-review:{finding['fingerprint']} -->",
|
|
||||||
f"**{finding['header']}**",
|
|
||||||
"",
|
|
||||||
finding["content"],
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
|
|
||||||
marker = "<!-- pr-agent-review-summary -->"
|
|
||||||
short_sha = head_sha[:7]
|
|
||||||
commit_url = f"https://github.com/{repo}/commit/{head_sha}"
|
|
||||||
chinese = language == "zh-CN"
|
|
||||||
lines = [marker, "## PR-Agent Code Review", ""]
|
|
||||||
if findings:
|
|
||||||
for finding in findings:
|
|
||||||
location = f"{finding['path']}:{finding['line']}"
|
|
||||||
concise = " ".join(finding["content"].split())[:360]
|
|
||||||
separator = ":" if chinese else ":"
|
|
||||||
lines.append(f"- [{location}]({code_url(finding['path'], finding['line'])}){separator} **{finding['header']}** - {concise}")
|
|
||||||
elif chinese:
|
|
||||||
lines.append("本次变更无需提出审查意见,暂无其他反馈。")
|
|
||||||
else:
|
|
||||||
lines.append("There are no review comments for the current changes. I have no additional feedback to provide.")
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
f"审查提交:[{short_sha}]({commit_url})" if chinese else f"Reviewed commit: [{short_sha}]({commit_url})",
|
|
||||||
"",
|
|
||||||
])
|
|
||||||
payload = {
|
|
||||||
"body": "\n".join(lines),
|
|
||||||
"commit_id": head_sha,
|
|
||||||
"event": "COMMENT",
|
|
||||||
}
|
|
||||||
if new_comments:
|
|
||||||
payload["comments"] = new_comments
|
|
||||||
has_matching_summary = not new_comments and any(
|
|
||||||
existing.get("user", {}).get("login") == "github-actions[bot]"
|
|
||||||
and existing.get("commit_id") == head_sha
|
|
||||||
and str(existing.get("body") or "") == payload["body"]
|
|
||||||
for existing in reviews
|
|
||||||
)
|
|
||||||
# 同一提交的手工重审仍会完成分析;完全相同的结果不重复发布 Review。
|
|
||||||
if not has_matching_summary:
|
|
||||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(payload, handle, ensure_ascii=False)
|
|
||||||
PY
|
|
||||||
|
|
||||||
latest_head_sha="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')"
|
|
||||||
if [ "${latest_head_sha}" != "${REVIEWED_HEAD_SHA}" ]; then
|
|
||||||
echo "PR head changed while rendering review; skip stale review publication."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -s "${review_payload}" ]; then
|
|
||||||
gh api --method POST "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --input "${review_payload}" >/dev/null
|
|
||||||
fi
|
|
||||||
old_summary_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-update-notification -->\")) or (.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-lab:review -->\")))) | .id")"
|
|
||||||
while IFS= read -r comment_id; do
|
|
||||||
[ -z "${comment_id}" ] && continue
|
|
||||||
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
|
|
||||||
done <<< "${old_summary_ids}"
|
|
||||||
|
|
||||||
- name: Answer PR question
|
|
||||||
if: >-
|
|
||||||
steps.pr_context.outputs.skip_pr_agent != 'true' &&
|
|
||||||
github.event_name == 'issue_comment' &&
|
|
||||||
(
|
|
||||||
github.event.comment.body == '/ask' ||
|
|
||||||
startsWith(github.event.comment.body, '/ask ')
|
|
||||||
)
|
|
||||||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
|
||||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
|
||||||
config.model: 'gpt-5.6-terra'
|
|
||||||
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
|
|
||||||
config.custom_model_max_tokens: '1050000'
|
|
||||||
config.reasoning_effort: 'high'
|
|
||||||
config.ai_timeout: '900'
|
|
||||||
config.response_language: ${{ steps.pr_context.outputs.response_language }}
|
|
||||||
config.large_patch_policy: 'clip'
|
|
||||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
|
||||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
|
||||||
github_action_config.auto_review: 'false'
|
|
||||||
github_action_config.auto_describe: 'false'
|
|
||||||
github_action_config.auto_improve: 'false'
|
|
||||||
|
|||||||
+8
-10
@@ -1,6 +1,6 @@
|
|||||||
# PR-Agent 使用说明
|
# PR-Agent 使用说明
|
||||||
|
|
||||||
本仓库通过 GitHub Actions 运行 PR-Agent,帮助贡献者维护 PR 摘要、获取代码审查结果和提出 PR 相关问题。
|
本仓库通过 PR Review Runner 运行 PR-Agent,帮助贡献者维护 PR 摘要、获取代码审查结果和提出 PR 相关问题。
|
||||||
|
|
||||||
## 自动执行
|
## 自动执行
|
||||||
|
|
||||||
@@ -25,26 +25,24 @@ PR 带有 `skip pr-agent` 标签,或标题以 `[Auto]`、`Auto` 开头时,
|
|||||||
/ask 这次改动有没有遗漏权限校验?
|
/ask 这次改动有没有遗漏权限校验?
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/describe`:更新 PR Body 内按语言显示的 `PR-Agent 摘要` 或 `PR-Agent Summary`,并保留贡献者原有的 PR 描述。
|
- `/describe`:更新 PR Body 内的 `PR-Agent 摘要`,并保留贡献者原有的 PR 描述。
|
||||||
- `/review`:发起一次代码审查。
|
- `/review`:发起一次代码审查。
|
||||||
- `/ask ...`:就当前 PR 提问,回复会发布在普通 PR 评论中。
|
- `/ask ...`:就当前 PR 提问,回复会发布在普通 PR 评论中。
|
||||||
|
|
||||||
|
本仓库禁用 `/improve` 及其等价别名。其他命令是否可用由 runner 所包含的 PR-Agent 能力决定。
|
||||||
|
|
||||||
手工命令仅允许以下 GitHub 身份关联的用户使用:`OWNER`、`MEMBER`、`COLLABORATOR`、`CONTRIBUTOR`、`FIRST_TIME_CONTRIBUTOR`。
|
手工命令仅允许以下 GitHub 身份关联的用户使用:`OWNER`、`MEMBER`、`COLLABORATOR`、`CONTRIBUTOR`、`FIRST_TIME_CONTRIBUTOR`。
|
||||||
|
|
||||||
新建的合法命令评论会触发执行;编辑后仍为合法命令的评论也会触发。编辑普通讨论评论不会调用模型。
|
新建的合法命令评论会触发执行;编辑后仍为合法命令的评论也会触发。编辑普通讨论评论不会调用模型。
|
||||||
|
|
||||||
## 审查结果
|
## 审查结果
|
||||||
|
|
||||||
`/describe` 的结果位于 PR Body 中按语言显示的 `PR-Agent 摘要` 或 `PR-Agent Summary` 区域,用于概览本次变更。
|
本仓库固定使用中文生成 PR-Agent 内容。`/describe` 的结果位于 PR Body 的 `PR-Agent 摘要` 区域,用于概览本次变更。
|
||||||
|
|
||||||
`/review` 和自动审查会通过原生 GitHub Review 发布,结果位于 Review 页签,标题固定为 `PR-Agent Code Review`。审查摘要包含可点击的 `文件:行号` 链接;可定位到本次变更的具体问题会在对应代码行以行内评论呈现,无法行内定位的问题仍通过摘要中的链接呈现。
|
`/review` 和自动审查会通过原生 GitHub Review 发布,结果位于 Review 页签,标题固定为 `PR-Agent Code Review`。可定位到本次变更的问题会在对应代码行以行内评论呈现,并使用 high、medium 或 low 风险标识。
|
||||||
|
|
||||||
审查不会额外创建专用的摘要评论。未发现需要处理的问题时,Review 会显示:
|
Review 摘要会自然概括本次变更和整体审查结论,不会复制行内评论。未发现需要处理的问题时,摘要会概括变更并自然说明暂无其他反馈。审查不会额外创建专用的 issue comment 摘要。
|
||||||
|
|
||||||
> 本次变更无需提出审查意见,暂无其他反馈。
|
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
workflow 使用固定 digest 的 PR-Agent 容器镜像,不使用浮动标签。自动审查通过 `pull_request_target` 在目标仓库上下文中读取 PR 信息,但不会 checkout 或执行 PR 分支代码。
|
自动审查通过 `pull_request_target` 在目标仓库上下文中读取 PR 信息,并使用共享 runner 的 `latest` 镜像,但不会 checkout 或执行 PR 分支代码。权限保持最小化:只授予读取仓库内容所需的 `contents: read`,以及更新 PR Body、发布 Review 和回复 PR 评论所需的写权限;不会向仓库推送代码或创建提交。
|
||||||
|
|
||||||
权限保持最小化:只授予读取仓库内容所需的 `contents: read`,以及更新 PR Body、发布 Review 和回复 PR 评论所需的写权限;不会向仓库推送代码或创建提交。
|
|
||||||
|
|||||||
Reference in New Issue
Block a user