mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-09 21:37:47 +08:00
584 lines
26 KiB
YAML
584 lines
26 KiB
YAML
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:
|
||
# 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 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:
|
||
name: PR-Agent inline review
|
||
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 == '/improve' ||
|
||
startsWith(github.event.comment.body, '/improve ') ||
|
||
github.event.comment.body == '/ask' ||
|
||
startsWith(github.event.comment.body, '/ask ')
|
||
)
|
||
)
|
||
)
|
||
concurrency:
|
||
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: 20
|
||
steps:
|
||
- name: Detect PR review language
|
||
id: pr_language
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
REPO: ${{ github.repository }}
|
||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||
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 re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
pr = json.loads(Path(sys.argv[1]).read_text())
|
||
title = pr.get("title") or ""
|
||
body = pr.get("body") or ""
|
||
labels = {item.get("name", "") for item in pr.get("labels") or []}
|
||
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
|
||
head_sha = pr.get("head", {}).get("sha") or ""
|
||
|
||
body = re.sub(
|
||
r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*"
|
||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
|
||
" ",
|
||
body,
|
||
flags=re.IGNORECASE | re.DOTALL,
|
||
)
|
||
body = re.sub(
|
||
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
|
||
" ",
|
||
body,
|
||
flags=re.DOTALL,
|
||
)
|
||
body = re.sub(r"```.*?```", " ", 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:
|
||
response_language = "zh-CN"
|
||
summary_heading = "PR-Agent 摘要"
|
||
summary_language = "中文"
|
||
elif latin_words >= 8:
|
||
response_language = "en-US"
|
||
summary_heading = "PR-Agent Summary"
|
||
summary_language = "English"
|
||
else:
|
||
response_language = "zh-CN"
|
||
summary_heading = "PR-Agent 摘要"
|
||
summary_language = "中文"
|
||
|
||
print(f"response_language={response_language}")
|
||
print(f"summary_heading={summary_heading}")
|
||
print(f"summary_language={summary_language}")
|
||
print(f"skip_pr_agent={skip_pr_agent}")
|
||
print(f"head_sha={head_sha}")
|
||
PY
|
||
|
||
- name: Prepare PR-Agent description markers
|
||
id: prepare_description
|
||
if: >-
|
||
steps.pr_language.outputs.skip_pr_agent != 'true' &&
|
||
(
|
||
github.event_name == 'pull_request_target' ||
|
||
(
|
||
github.event_name == 'issue_comment' &&
|
||
(
|
||
github.event.comment.body == '/describe' ||
|
||
startsWith(github.event.comment.body, '/describe ')
|
||
)
|
||
)
|
||
)
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
REPO: ${{ github.repository }}
|
||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||
SUMMARY_HEADING: ${{ steps.pr_language.outputs.summary_heading }}
|
||
run: |
|
||
set -euo pipefail
|
||
current_body="$(mktemp)"
|
||
next_body="$(mktemp)"
|
||
payload="$(mktemp)"
|
||
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
|
||
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
|
||
|
||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
|
||
cp "${current_body}" "${body_backup}"
|
||
python3 - "${current_body}" "${next_body}" <<'PY'
|
||
import os
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
current_path = Path(sys.argv[1])
|
||
next_path = Path(sys.argv[2])
|
||
|
||
body = current_path.read_text()
|
||
start = "<!-- pr-agent-summary:start -->"
|
||
end = "<!-- pr-agent-summary:end -->"
|
||
placeholder = "pr_agent:summary"
|
||
summary_heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
|
||
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",
|
||
body,
|
||
)
|
||
|
||
start_index = body.find(start)
|
||
end_index = body.find(end)
|
||
if start_index >= 0 and end_index > start_index:
|
||
next_body = body[: start_index + len(start)] + f"\n{placeholder}\n" + body[end_index:]
|
||
else:
|
||
separator = "\n\n" if body.strip() else ""
|
||
next_body = body.rstrip() + separator + agent_block
|
||
|
||
next_path.write_text(next_body)
|
||
PY
|
||
cp "${next_body}" "${placeholder_body}"
|
||
|
||
body_changed=false
|
||
if ! cmp -s "${current_body}" "${next_body}"; then
|
||
python3 - "${next_body}" "${payload}" <<'PY'
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
body = Path(sys.argv[1]).read_text()
|
||
Path(sys.argv[2]).write_text(json.dumps({"body": body}, ensure_ascii=False))
|
||
PY
|
||
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||
body_changed=true
|
||
fi
|
||
echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}"
|
||
|
||
- name: Snapshot PR-Agent inline comments
|
||
id: inline_state_before
|
||
if: >-
|
||
steps.pr_language.outputs.skip_pr_agent != 'true' &&
|
||
(
|
||
github.event_name == 'pull_request_target' ||
|
||
(
|
||
github.event_name == 'issue_comment' &&
|
||
(
|
||
github.event.comment.body == '/improve' ||
|
||
startsWith(github.event.comment.body, '/improve ')
|
||
)
|
||
)
|
||
)
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
REPO: ${{ github.repository }}
|
||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||
run: |
|
||
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)"
|
||
echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}"
|
||
|
||
- name: Run PR-Agent
|
||
id: pragent
|
||
if: steps.pr_language.outputs.skip_pr_agent != 'true'
|
||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
|
||
env:
|
||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||
GITHUB_TOKEN: ${{ github.token }}
|
||
|
||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||
|
||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||
|
||
# 模型、输出语言和大 diff 处理策略。
|
||
config.model: "gpt-5.5"
|
||
config.fallback_models: '["gpt-5.4"]'
|
||
config.reasoning_effort: "xhigh"
|
||
config.ai_timeout: "900"
|
||
config.response_language: ${{ steps.pr_language.outputs.response_language }}
|
||
config.large_patch_policy: "clip"
|
||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||
|
||
# PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。
|
||
github_action_config.auto_review: "false"
|
||
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: '["/describe", "/improve"]'
|
||
|
||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||
github_action_config.enable_output: "true"
|
||
|
||
# /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符。
|
||
pr_description.generate_ai_title: "false"
|
||
pr_description.publish_labels: "false"
|
||
pr_description.publish_description_as_comment: "false"
|
||
pr_description.publish_description_as_comment_persistent: "false"
|
||
pr_description.enable_pr_diagram: "false"
|
||
pr_description.enable_pr_type: "false"
|
||
pr_description.enable_help_text: "false"
|
||
pr_description.enable_help_comment: "false"
|
||
pr_description.enable_semantic_files_types: "false"
|
||
pr_description.collapsible_file_list: "adaptive"
|
||
pr_description.add_original_user_description: "true"
|
||
pr_description.use_description_markers: "true"
|
||
pr_description.final_update_message: "false"
|
||
pr_description.extra_instructions: |
|
||
Match the configured response language.
|
||
Generate a moderately detailed PR summary covering the change goal, key implementation details, configuration or compatibility impact, tests, and notable risks.
|
||
Use 2-4 bullets for small PRs; use 4-8 bullets for feature or multi-file PRs.
|
||
Avoid low-value file lists and local command transcripts.
|
||
|
||
# /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理。
|
||
pr_code_suggestions.extra_instructions: |
|
||
Match the configured response language.
|
||
Only provide substantive issues that maintainers should address; avoid style-only, preference-only, or low-value suggestions.
|
||
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: "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"
|
||
|
||
# 可选成本和噪音控制:
|
||
# github_action_config.auto_improve: "true"
|
||
# config.verbosity_level: "1"
|
||
|
||
- name: Publish PR-Agent code review summary
|
||
if: >-
|
||
steps.pr_language.outputs.skip_pr_agent != 'true' &&
|
||
(
|
||
github.event_name == 'pull_request_target' ||
|
||
(
|
||
github.event_name == 'issue_comment' &&
|
||
(
|
||
github.event.comment.body == '/improve' ||
|
||
startsWith(github.event.comment.body, '/improve ')
|
||
)
|
||
)
|
||
)
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
REPO: ${{ github.repository }}
|
||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||
BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }}
|
||
RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }}
|
||
SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }}
|
||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||
SUMMARY_MODEL: gpt-5.5
|
||
run: |
|
||
set -euo pipefail
|
||
before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)"
|
||
pr_info="$(mktemp)"
|
||
comments="$(mktemp)"
|
||
review_data="$(mktemp)"
|
||
payload="$(mktemp)"
|
||
|
||
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
|
||
CURRENT_HEAD_SHA="$(jq -r '.head.sha' "${pr_info}")"
|
||
HEAD_SHA="${RUN_HEAD_SHA:-${CURRENT_HEAD_SHA}}"
|
||
if [ "${CURRENT_HEAD_SHA}" != "${HEAD_SHA}" ]; then
|
||
echo "PR head changed from ${HEAD_SHA} to ${CURRENT_HEAD_SHA}; skip stale code review summary."
|
||
exit 0
|
||
fi
|
||
short_sha="${HEAD_SHA:0:7}"
|
||
pr_url="https://github.com/${REPO}/pull/${PR_NUMBER}"
|
||
commit_url="https://github.com/${REPO}/commit/${HEAD_SHA}"
|
||
|
||
gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}"
|
||
|
||
python3 - "${before_ids}" "${comments}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY'
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
before_ids = set(json.loads(sys.argv[1] or "[]"))
|
||
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_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 = (item.get("body") or "").strip()
|
||
first_line = next((line.strip() for line in body.splitlines() if line.strip()), "")
|
||
suggestions.append({
|
||
"path": item.get("path"),
|
||
"line": item.get("line") or item.get("start_line"),
|
||
"url": item.get("html_url"),
|
||
"summary": first_line[:500],
|
||
"body": body[:1500],
|
||
})
|
||
|
||
output_path.write_text(json.dumps({
|
||
"pr_url": pr_url,
|
||
"commit_url": commit_url,
|
||
"short_sha": short_sha,
|
||
"suggestions": suggestions,
|
||
}, ensure_ascii=False))
|
||
PY
|
||
|
||
python3 - "${review_data}" "${payload}" <<'PY'
|
||
import json
|
||
import os
|
||
import textwrap
|
||
import urllib.error
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
review_data = json.loads(Path(os.sys.argv[1]).read_text())
|
||
payload_path = Path(os.sys.argv[2])
|
||
suggestions = review_data["suggestions"]
|
||
marker = "<!-- pr-agent-code-review-summary -->"
|
||
summary_language = os.environ.get("SUMMARY_LANGUAGE") or "中文"
|
||
use_chinese = summary_language != "English"
|
||
|
||
def fallback_summary() -> str:
|
||
if not suggestions:
|
||
if use_chinese:
|
||
return "已审查本次变更,未发现需要进一步反馈或调整的问题。"
|
||
return "Reviewed this change and found no further feedback or required adjustments."
|
||
if use_chinese:
|
||
lines = [f"本轮代码审查新增 {len(suggestions)} 条行内建议,建议优先查看以下位置:"]
|
||
else:
|
||
noun = "suggestion" if len(suggestions) == 1 else "suggestions"
|
||
lines = [f"This review added {len(suggestions)} inline {noun}. Consider reviewing these locations first:"]
|
||
for item in suggestions[:5]:
|
||
location = f"{item.get('path')}:{item.get('line')}" if item.get("line") else str(item.get("path"))
|
||
summary = item.get("summary") or "查看行内建议"
|
||
url = item.get("url")
|
||
if use_chinese:
|
||
lines.append(f"- [{location}]({url}):{summary}" if url else f"- {location}:{summary}")
|
||
else:
|
||
lines.append(f"- [{location}]({url}): {summary}" if url else f"- {location}: {summary}")
|
||
if len(suggestions) > 5:
|
||
if use_chinese:
|
||
lines.append(f"- 其余 {len(suggestions) - 5} 条请在 Files changed 的行内评论中查看。")
|
||
else:
|
||
lines.append(f"- Review the remaining {len(suggestions) - 5} inline comments in Files changed.")
|
||
return "\n".join(lines)
|
||
|
||
def llm_summary() -> str | None:
|
||
if not suggestions:
|
||
return None
|
||
|
||
api_base = (os.environ.get("OPENAI_API_BASE") or "").rstrip("/")
|
||
api_key = os.environ.get("OPENAI_KEY") or ""
|
||
model = os.environ.get("SUMMARY_MODEL") or "gpt-5.5"
|
||
if not api_base or not api_key:
|
||
return None
|
||
|
||
if use_chinese:
|
||
task = (
|
||
"你是独立的代码审查摘要助手。只基于本轮已经发布的行内审查意见做简短汇总,"
|
||
"不新增审查结论,不替维护者判断 PR 是否可以合并。请用中文 Markdown 输出:"
|
||
"第一段说明本轮审查已完成,并已在行内留下需要关注的建议;如有建议,"
|
||
"概括 1-3 个主要关注点,使用“建议关注”“可能影响”“可优先查看”等中立表述。"
|
||
"不要输出表格,不要复述所有文件,不要使用“可以合并”“不建议合并”“阻塞合并”"
|
||
"“先处理后再合入”等合并裁决措辞。"
|
||
)
|
||
system_prompt = "你是严谨、中立的代码审查摘要助手。输出中文 Markdown,简洁自然。"
|
||
else:
|
||
task = (
|
||
"You are an independent code review summarizer. Summarize only the inline review comments "
|
||
"already posted in this run; do not add new review conclusions or decide whether the PR should merge. "
|
||
"Write concise Markdown in English. Start by noting that review completed and inline suggestions "
|
||
"were left; then summarize 1-3 main points using neutral phrasing such as \"consider\", "
|
||
"\"may affect\", or \"worth reviewing\". Do not output tables, list every file, or use merge-gate "
|
||
"wording such as \"ready to merge\", \"do not merge\", \"blocks merging\", or \"must be fixed before merge\"."
|
||
)
|
||
system_prompt = "You are a rigorous, neutral code review summarizer. Write concise English Markdown."
|
||
|
||
user_content = {
|
||
"task": task,
|
||
"suggestions": suggestions[:10],
|
||
}
|
||
|
||
request_body = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": json.dumps(user_content, ensure_ascii=False)},
|
||
],
|
||
"temperature": 0.2,
|
||
}
|
||
request = urllib.request.Request(
|
||
f"{api_base}/chat/completions",
|
||
data=json.dumps(request_body).encode("utf-8"),
|
||
headers={
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=60) as response:
|
||
data = json.loads(response.read().decode("utf-8"))
|
||
content = data["choices"][0]["message"]["content"].strip()
|
||
return content or None
|
||
except (KeyError, 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 = 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
|
||
|
||
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
|
||
|
||
while IFS= read -r comment_id; do
|
||
[ -z "${comment_id}" ] && continue
|
||
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
|
||
done <<< "${comment_ids}"
|
||
|
||
- name: Restore PR-Agent description markers on failure
|
||
if: >-
|
||
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: |
|
||
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)"
|
||
payload="$(mktemp)"
|
||
|
||
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}"
|
||
python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY'
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
backup_body = Path(sys.argv[1]).read_text()
|
||
placeholder_body = Path(sys.argv[2]).read_text()
|
||
current_body = Path(sys.argv[3]).read_text()
|
||
payload_path = Path(sys.argv[4])
|
||
|
||
start = "<!-- pr-agent-summary:start -->"
|
||
end = "<!-- pr-agent-summary:end -->"
|
||
heading_re = re.compile(r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*")
|
||
|
||
def find_block(body: str) -> tuple[int, int] | None:
|
||
start_index = body.find(start)
|
||
end_index = body.find(end)
|
||
if start_index < 0 or end_index <= start_index:
|
||
return None
|
||
end_index += len(end)
|
||
heading_start = start_index
|
||
prefix = body[:start_index]
|
||
matches = list(heading_re.finditer(prefix))
|
||
if matches:
|
||
last = matches[-1]
|
||
if prefix[last.end():].strip() == "":
|
||
heading_start = last.start()
|
||
return heading_start, end_index
|
||
|
||
current_block = find_block(current_body)
|
||
placeholder_block = find_block(placeholder_body)
|
||
backup_block = find_block(backup_body)
|
||
if not current_block or not placeholder_block:
|
||
print("No PR-Agent summary block to restore.")
|
||
raise SystemExit(0)
|
||
|
||
current_section = current_body[current_block[0]:current_block[1]]
|
||
placeholder_section = placeholder_body[placeholder_block[0]:placeholder_block[1]]
|
||
if "pr_agent:summary" not in current_section:
|
||
print("No visible PR-Agent summary placeholder to restore.")
|
||
raise SystemExit(0)
|
||
if current_section != placeholder_section:
|
||
print("Current PR-Agent summary block changed; skip restore.")
|
||
raise SystemExit(0)
|
||
|
||
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 --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
|
||
fi
|