Files
MoviePilot/.github/workflows/pr-agent.yml
2026-07-13 18:41:18 +08:00

537 lines
24 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
types:
- opened
- reopened
- ready_for_review
- review_requested
- synchronize
issue_comment:
# 手动命令只在 PR 评论中有意义;编辑后的命令也可重新触发。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动问答需要该权限。
issues: write
jobs:
pr-agent:
if: >-
github.event.sender.type != 'Bot' &&
(
(
github.event_name == 'pull_request_target'
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Capture PR context
id: pr_context
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
pull="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pull}"
python3 - "${pull}" >> "${GITHUB_OUTPUT}" <<'PY'
import json
import re
import sys
from pathlib import Path
pull = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
title = pull.get("title") or ""
body = pull.get("body") or ""
labels = {item.get("name", "") for item in pull.get("labels") or []}
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
body = re.sub(
r"<!-- 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:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
config.model: 'gpt-5.6-terra'
config.fallback_models: '["gpt-5.5", "gpt-5.4"]'
config.custom_model_max_tokens: '1050000'
config.reasoning_effort: 'medium'
config.ai_timeout: '900'
config.response_language: ${{ steps.pr_context.outputs.response_language }}
config.large_patch_policy: 'clip'
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
github_action_config.auto_review: 'false'
github_action_config.auto_describe: 'true'
github_action_config.auto_improve: 'false'
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: 'true'
github_action_config.push_commands: '["/describe"]'
pr_description.generate_ai_title: 'false'
pr_description.publish_labels: 'false'
pr_description.publish_description_as_comment: 'false'
pr_description.publish_description_as_comment_persistent: 'false'
pr_description.enable_pr_diagram: 'false'
pr_description.enable_pr_type: 'false'
pr_description.enable_help_text: 'false'
pr_description.enable_help_comment: 'false'
pr_description.enable_semantic_files_types: 'false'
pr_description.collapsible_file_list: 'adaptive'
pr_description.add_original_user_description: 'true'
pr_description.use_description_markers: 'true'
pr_description.final_update_message: 'false'
pr_description.extra_instructions: |
Match the configured response language.
Summarize the change goal, key implementation details, compatibility impact, tests, and notable risks.
Use 2-4 bullets for small pull requests and 4-8 bullets for larger changes.
Avoid file lists and local command transcripts.
- name: Remove unfilled PR-Agent description marker
if: >-
always() &&
steps.pr_context.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
payload="$(mktemp)"
python3 - "${payload}" <<'PY'
import json
import os
import re
import subprocess
import sys
pull = json.loads(subprocess.check_output(
["gh", "api", f"repos/{os.environ['REPO']}/pulls/{os.environ['PR_NUMBER']}"],
text=True,
))
body = pull.get("body") or ""
placeholder = "<!-- 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'