从 0 学习 Alibaba Open Code Review(十二):把 AI Review 结果自动发布到 GitHub PR
一、前言
在前面的学习中,已经逐步完成了下面这条链路:
Git diff
↓
OpenCodeReview
↓
JSON 结构化结果
↓
风险分级
↓
LangGraph 工作流
↓
GitHub Actions
↓
Artifact 与 Job Summary
S11 已经可以在 GitHub Actions 中自动运行 Code Review Agent,并将 review-result.json、review-report.md 和历史记录上传为 Artifact。
但是,这个流程还有一个很明显的问题:开发者必须进入 Actions 页面,再查看 Job Summary 或下载 Artifact,才能看到完整审查结果。审查结果还没有真正回到 Pull Request 的协作页面。
因此,S12 要补上最后一段反馈链路:
review-result.json
↓
生成 PR Markdown 评论
↓
调用 GitHub REST API
↓
创建或更新 PR 评论
这一节不再增加新的 LLM 能力,而是重点解决 Agent 如何与真实的软件开发流程集成。
二、本篇要完成什么
本篇最终需要实现以下功能:
- 读取 S10 生成的
review-result.json。 - 将风险统计和审查意见转换为 Markdown。
- 为每条问题生成可点击的 GitHub 源码行链接。
- 使用 GitHub REST API 将 Markdown 发布到 PR。
- 第一次运行时创建评论,后续运行时更新同一条评论。
- 使用 GitHub Actions 内置的
GITHUB_TOKEN,不额外创建 Personal Access Token。 - 限制评论长度,避免大量审查结果导致 API 请求失败。
- 防止模型输出中的
@用户名意外通知 GitHub 用户。 - 修正 Actions 干净工作区无法被 S10 检测为 Git diff 的问题。
- 为评论构建和重复评论识别增加单元测试。
这次实现之后,完整链路变为:
Pull Request
↓
GitHub Actions
↓
准备 PR base/head diff
↓
S10 LangGraph Code Review Agent
↓
OpenCodeReview 调用 LLM 和工具
↓
review-result.json
↓
build_pr_comment.py
↓
pr-comment.md
↓
post_pr_comment.py
↓
PR Summary Comment
三、S11 为什么还不算完整闭环
S11 的输出主要有两个入口:
- GitHub Actions Job Summary
- GitHub Actions Artifact
它们适合保存详细结果和排查工作流,但不处于代码审查的主要协作页面。
Pull Request 页面才是开发者查看变更、讨论问题和确认修改的位置。将结果发布到 PR 后,审查意见和代码变更处于同一个上下文中,使用者不需要在多个页面之间跳转。
因此,S11 和 S12 的职责并不冲突:
| 模块 | 主要用途 |
|---|---|
| Job Summary | 快速查看本次 Actions 执行摘要 |
| Artifact | 保存完整 JSON、Markdown 和历史记录 |
| PR Comment | 在代码协作页面展示最重要的审查结果 |
PR 评论不应该替代 Artifact。评论需要保持简洁,而 Artifact 可以保留完整数据。
四、摘要评论和逐行评论有什么区别
GitHub PR 中常见的自动评论可以分为两种。
4.1 PR 摘要评论
摘要评论显示在 PR 的 Conversation 区域,内容可以包含:
- 审查状态
- 文件数量
- 风险统计
- 问题列表
- Actions 运行链接
它使用的是 Issue Comments API,因为 GitHub 中 Pull Request 同时也是一种 Issue。
相关接口如下:
GET /repos/{owner}/{repo}/issues/{number}/comments
POST /repos/{owner}/{repo}/issues/{number}/comments
PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}
4.2 逐行评论
逐行评论直接显示在 Files changed 页面具体代码行旁边,需要处理:
- PR diff 中的文件路径
- 评论位于新增行还是旧行
- diff position 或 line/side 参数
- 评论行是否仍存在
- PR 更新后评论是否过期
- 多条评论是否组成一次 review
它使用 Pull Request Review Comments API,定位逻辑明显更复杂。
S12 先实现摘要评论。这样可以先把数据解析、API 调用、权限控制和幂等更新跑通,再在后续版本中实现逐行定位。
五、项目目录结构
S12 的目录如下:
s12
├── .github
│ └── workflows
│ └── ai-code-review-comment.yml
├── docs
│ └── pr-comment-design.md
├── scripts
│ ├── build_pr_comment.py
│ └── post_pr_comment.py
├── tests
│ └── test_pr_comment.py
├── .gitignore
└── README.md
各文件职责如下:
| 文件 | 职责 |
|---|---|
build_pr_comment.py |
将 OCR JSON 转换为 Markdown 评论 |
post_pr_comment.py |
查询、创建或更新 GitHub PR 评论 |
test_pr_comment.py |
验证评论格式、风险统计和重复评论识别 |
ai-code-review-comment.yml |
在 PR 事件中串联完整自动化流程 |
pr-comment-design.md |
记录 API、幂等策略和安全边界 |
.gitignore |
排除本地输出和 Python 缓存 |
这里特意把“构建评论”和“发布评论”拆成两个脚本,而不是写在同一个文件中。
原因是两个模块的性质不同:
build_pr_comment.py
输入:JSON
输出:Markdown
特点:确定性、无网络、容易测试
post_pr_comment.py
输入:Markdown + GitHub 上下文
输出:远程 PR 评论
特点:有网络、有权限、有外部状态
这种拆分使 Markdown 格式可以在本地反复调整,而不需要每次都向 GitHub 发送请求。
六、输入数据来自哪里
S12 不直接调用 LLM,它消费 S10 LangGraph Agent 生成的结果:
s10/open-code-review-agent-langgraph/outputs/review-result.json
本节主要使用以下字段:
{
"status": "success",
"summary": {
"files_reviewed": 2,
"comments": 2
},
"risk_summary": {
"Critical": 1,
"Warning": 1,
"Suggestion": 0
},
"comments": [
{
"severity": "Critical",
"path": "s01/src/user.js",
"start_line": 37,
"end_line": 37,
"content": "SQL Injection Vulnerability..."
}
]
}
S12 不需要理解 OpenCodeReview 内部是如何调用 file_read、code_search 和 code_comment 的。那些 Agent 调用过程已经由前面的模块完成。
S12 只依赖结构化输出契约:
status
summary
risk_summary
comments[]
这说明 JSON 输出不仅用于保存结果,也建立了上游 Agent 与下游工程模块之间的边界。
七、实现 build_pr_comment.py
build_pr_comment.py 的职责是把结构化数据转换为适合 PR 页面展示的 Markdown。
它不读取 GitHub Token,也不发送 HTTP 请求,因此可以被看作一个确定性转换模块:
review-result.json + repository + sha
↓
build_comment()
↓
pr-comment.md
7.1 定义评论标记和风险顺序
核心常量如下:
COMMENT_MARKER = "<!-- open-code-review-agent:pr-summary -->"
SEVERITIES = ("Critical", "Warning", "Suggestion")
SEVERITY_ICONS = {
"Critical": "[CRITICAL]",
"Warning": "[WARNING]",
"Suggestion": "[SUGGESTION]",
}
COMMENT_MARKER 是 HTML 注释,在 GitHub 渲染后的评论中不可见,但可以通过 API 读取到。
它的作用不是展示,而是给程序一个稳定的评论身份:
<!-- open-code-review-agent:pr-summary -->
如果只根据评论标题 AI Code Review 查找,用户自己写的评论也可能被误识别。固定标记更适合机器判断。
7.2 读取并校验 JSON
读取函数如下:
def load_result(path: Path) -> dict[str, Any]:
if not path.is_file():
raise FileNotFoundError(f"Review result does not exist: {path}")
result = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(result, dict):
raise ValueError("review-result.json must contain a JSON object")
return result
这里使用 utf-8-sig,既能读取普通 UTF-8,也能兼容带 BOM 的 JSON 文件。
读取后还要确认顶层结构是对象。仅仅 json.loads() 成功并不代表数据结构符合程序预期,例如 JSON 数组同样是合法 JSON,但不能作为本项目的审查结果。
7.3 对 comments 做类型保护
def normalize_comments(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
外部数据不能直接假设类型永远正确。如果 comments 缺失、为 null 或包含非对象元素,格式化模块应该尽量生成可读结果,而不是在循环中出现难以理解的异常。
7.4 风险统计兜底
正常情况下,风险统计由 S10 的风险分级节点生成。但为了降低模块耦合,S12 在 risk_summary 缺失时也可以从 comments 重新统计:
def normalize_risk_summary(value, comments):
if isinstance(value, dict):
return {
severity: to_non_negative_int(value.get(severity))
for severity in SEVERITIES
}
result = {severity: 0 for severity in SEVERITIES}
for comment in comments:
severity = str(comment.get("severity", "Suggestion"))
result[severity if severity in result else "Suggestion"] += 1
return result
这里采用了一个明确的降级规则:无法识别的 severity 归入 Suggestion,而不是让程序失败。
7.5 防止意外触发 @mention
审查内容来自 LLM,可能包含类似下面的文本:
Please ask @owner to verify this change.
如果原样发布到 GitHub,可能真的通知某个用户。自动化输出不应该因为模型生成的普通文本随意触发通知。
因此 single_line() 做了替换:
def single_line(value: Any, max_length: int = 500) -> str:
text = " ".join(str(value or "").split())
text = text.replace("@", "@\u200b")
if len(text) <= max_length:
return text
return text[: max_length - 3].rstrip() + "..."
是零宽空格。页面上看起来仍然接近 @owner,但 GitHub 不再把它识别为真正的用户提及。
这个函数还完成两件事:
- 将多行内容压缩成单行,避免列表格式被破坏。
- 将单条问题限制为 500 个字符,避免评论无限增长。
7.6 生成文件和行号标签
def line_label(comment: dict[str, Any]) -> str:
path = str(comment.get("path") or "unknown")
start = to_non_negative_int(comment.get("start_line"))
end = to_non_negative_int(comment.get("end_line")) or start
if start <= 0:
return path
if end > start:
return f"{path}:{start}-{end}"
return f"{path}:{start}"
它会生成三种形式:
src/user.js
src/user.js:37
src/user.js:36-39
即使 OCR 没有返回有效行号,也仍然可以展示文件路径。
7.7 生成 GitHub 源码链接
GitHub 文件链接结构为:
https://github.com/{owner}/{repo}/blob/{sha}/{path}#L{start}-L{end}
代码如下:
def source_link(comment, repository, sha):
path = str(comment.get("path") or "").replace("\\", "/").lstrip("/")
start = to_non_negative_int(comment.get("start_line"))
end = to_non_negative_int(comment.get("end_line")) or start
if not repository or not sha or not path:
return None
encoded_path = quote(path, safe="/")
fragment = ""
if start > 0:
fragment = f"#L{start}"
if end > start:
fragment += f"-L{end}"
return f"https://github.com/{repository}/blob/{sha}/{encoded_path}{fragment}"
这里有几个细节:
- 将 Windows 路径分隔符
\转换为/。 - 使用
quote()对空格等特殊字符编码。 - 使用 PR head SHA,而不是容易移动的分支名。
- 单行生成
#L37,多行生成#L36-L39。
使用确定的 commit SHA 后,即使分支继续提交,历史评论中的链接仍然指向当次审查的代码版本。
7.8 组合完整 Markdown
核心函数是 build_comment():
def build_comment(
result: dict[str, Any],
repository: str | None = None,
sha: str | None = None,
run_url: str | None = None,
max_findings: int = 10,
) -> str:
comments = normalize_comments(result.get("comments"))
risk_summary = normalize_risk_summary(result.get("risk_summary"), comments)
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
shown_comments = comments[: max(0, max_findings)]
评论首先展示整体信息:
## AI Code Review
Status: `success` | Files reviewed: **2** | Findings: **2**
| Severity | Count |
|---|---:|
| Critical | 1 |
| Warning | 1 |
| Suggestion | 0 |
然后展示最多 max_findings 条问题:
for index, comment in enumerate(shown_comments, start=1):
severity = str(comment.get("severity", "Suggestion"))
if severity not in SEVERITIES:
severity = "Suggestion"
label = line_label(comment)
link = source_link(comment, repository, sha)
location = f"[`{label}`]({link})" if link else f"`{label}`"
content = single_line(comment.get("content") or "No description provided.")
如果问题总数超过展示上限,评论会提示剩余内容位于 Artifact:
Another 6 finding(s) are available in the workflow artifact.
最后增加 Actions 链接:
footer_parts = ["Generated by the OpenCodeReview Agent workflow"]
if run_url:
footer_parts.append(f"[View workflow run]({run_url})")
7.9 为什么还要限制总长度
除了限制单条问题和问题数量,程序还对最终正文设置 60000 字符上限:
body = "\n".join(lines).rstrip() + "\n"
if len(body) > 60_000:
raise ValueError("Generated PR comment is too large; lower --max-findings")
这是一种主动失败策略。与其让 GitHub API 返回不直观的请求错误,不如在构建阶段给出明确原因,并提示降低 --max-findings。
八、实现 post_pr_comment.py
post_pr_comment.py 负责远程副作用:读取 Markdown、识别 PR、查询历史评论,并调用 GitHub API。
它的决策流程如下:
读取 pr-comment.md
↓
校验 marker 和长度
↓
读取 repository 和 PR number
↓
GET 历史评论
↓
找到 marker?
├─ 否 → POST 创建评论
└─ 是 → PATCH 更新评论
8.1 为什么没有引入 PyGithub
本节只需要三个 REST API,请求结构很简单。因此使用 Python 标准库:
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
这样 S12 不需要新增 Python 依赖,也降低了 Actions 环境安装失败的可能性。
当 API 交互逐渐复杂,例如需要创建完整 review、处理分页链接和检查 rate limit 时,再考虑使用成熟 SDK 会更合适。
8.2 封装 GitHubClient
客户端初始化代码:
class GitHubClient:
def __init__(self, token: str, api_url: str = "https://api.github.com") -> None:
self.token = token
self.api_url = api_url.rstrip("/")
api_url 默认是 GitHub.com,同时可以通过 GITHUB_API_URL 替换,方便适配 GitHub Enterprise Server。
统一请求函数设置以下请求头:
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.token}",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "open-code-review-agent",
"Content-Type": "application/json",
}
其中:
Authorization携带 Actions 内置 Token。Accept明确使用 GitHub JSON 格式。X-GitHub-Api-Version固定 API 行为版本。User-Agent是 GitHub API 请求要求的一部分。
8.3 统一处理 API 错误
except HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
raise GitHubApiError(
f"GitHub API returned HTTP {exc.code}: {details}"
) from exc
except URLError as exc:
raise GitHubApiError(
f"Unable to reach GitHub API: {exc.reason}"
) from exc
这里区分了两类问题:
HTTPError:请求到达 GitHub,但权限、参数或资源状态不正确。URLError:网络连接、DNS 或 TLS 等问题导致无法完成请求。
错误信息中不会打印 Token。
8.4 查询历史评论和分页
def list_issue_comments(self, repository: str, issue_number: int):
comments = []
for page in range(1, 11):
query = urlencode({"per_page": 100, "page": page})
data, _ = self.request(
"GET",
f"/repos/{repository}/issues/{issue_number}/comments?{query}",
)
comments.extend(item for item in data if isinstance(item, dict))
if len(data) < 100:
break
return comments
GitHub API 默认分页。如果只请求第一页,当 PR 评论很多时,旧的 Agent 评论可能位于后面的页面,程序就会错误地再次创建评论。
当前实现每页读取 100 条,最多读取 10 页,也就是最多检查 1000 条评论。对于普通 PR 已经足够,同时避免无限请求。
8.5 创建和更新评论
创建评论:
def create_issue_comment(self, repository, issue_number, body):
data, _ = self.request(
"POST",
f"/repos/{repository}/issues/{issue_number}/comments",
{"body": body},
)
return require_object(data)
更新评论:
def update_issue_comment(self, repository, comment_id, body):
data, _ = self.request(
"PATCH",
f"/repos/{repository}/issues/comments/{comment_id}",
{"body": body},
)
return require_object(data)
注意两个接口的 URL 不同:
创建:issues/{PR_NUMBER}/comments
更新:issues/comments/{COMMENT_ID}
更新接口使用的是评论 ID,而不是 PR 编号。
九、如何获得 PR 编号
GitHub Actions 会将事件完整内容写入 GITHUB_EVENT_PATH 指向的 JSON 文件。
脚本先尝试读取:
pull_request = event.get("pull_request")
number = pull_request.get("number")
实现代码如下:
def read_pull_request_number(event_path: str | None) -> int | None:
if not event_path:
return None
path = Path(event_path)
if not path.is_file():
return None
event = json.loads(path.read_text(encoding="utf-8"))
pull_request = event.get("pull_request") if isinstance(event, dict) else None
if isinstance(pull_request, dict):
return positive_int(pull_request.get("number"))
return positive_int(event.get("number")) if isinstance(event, dict) else None
同时命令行支持显式传入:
--repository owner/repository
--pr-number 12
因此它既能运行在 GitHub Actions,也能在本地通过 --dry-run 验证。
十、幂等更新是怎么实现的
自动化流程可能在以下事件中重复运行:
- PR 创建
- PR 新增 commit
- PR 重新打开
- Draft PR 转为 Ready for review
- 手动重新运行 Actions
如果每次都创建新评论,一个 PR 很快就会出现多条内容相似的机器人评论。
幂等的含义是:相同类型的操作执行多次,外部最终状态仍然保持稳定。对于本节来说,就是一个 PR 始终只有一条 AI Review 摘要。
程序使用两个条件识别旧评论:
- 正文包含固定 marker。
- 评论作者是
github-actions[bot]。
def find_existing_comment(comments, marker, author):
for comment in comments:
body = str(comment.get("body") or "")
user = comment.get("user") if isinstance(comment.get("user"), dict) else {}
login = str(user.get("login") or "")
if marker in body and (
not author or login.casefold() == author.casefold()
):
return comment
return None
检查作者可以避免用户在自己的评论中写入相同 marker 后被程序误选。
最终决策代码为:
comments = client.list_issue_comments(repository, pr_number)
existing = find_existing_comment(comments, args.marker, author)
if existing:
comment_id = positive_int(existing.get("id"))
saved = client.update_issue_comment(repository, comment_id, body)
action = "updated"
else:
saved = client.create_issue_comment(repository, pr_number, body)
action = "created"
这就是完整的 create-or-update 模式。
十一、为什么发布前还要再次校验
post_pr_comment.py 不会盲目发布文件内容,它会检查:
if args.marker not in body:
print("Error: comment body does not contain the configured marker")
return 2
if len(body) > 65_000:
print("Error: comment body exceeds the configured limit")
return 2
虽然构建器已经做过长度限制,但发布器仍然独立校验。这是因为发布器可能被其他命令直接调用,输入文件不一定来自当前构建器。
两个模块都保护自己的输入边界,可以降低错误调用带来的风险。
十二、GITHUB_OUTPUT 的作用
成功创建或更新评论后,脚本会把结果写入 GitHub Actions 的步骤输出文件:
def append_github_output(action: str, comment: dict[str, Any]) -> None:
output_path = os.environ.get("GITHUB_OUTPUT")
if not output_path:
return
with open(output_path, "a", encoding="utf-8") as output:
output.write(f"action={action}\n")
output.write(f"comment_id={comment.get('id', '')}\n")
output.write(f"comment_url={comment.get('html_url', '')}\n")
产生的步骤输出包括:
action=created
comment_id=123456789
comment_url=https://github.com/owner/repo/pull/12#issuecomment-123456789
后续步骤可以通过以下方式读取:
${{ steps.publish-comment.outputs.action }}
${{ steps.publish-comment.outputs.comment_url }}
当前工作流暂时不消费这些输出,但先保留结构,后面可以用于通知、统计或 Job Summary。
十三、GitHub Actions 触发条件
工作流文件为:
s12/.github/workflows/ai-code-review-comment.yml
触发配置如下:
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
事件含义:
| 事件 | 含义 |
|---|---|
opened |
PR 第一次创建 |
synchronize |
PR 分支推送了新 commit |
reopened |
已关闭 PR 被重新打开 |
ready_for_review |
Draft PR 转为正式审查状态 |
workflow_dispatch |
手动触发工作流 |
其中 synchronize 最重要。开发者修复问题并推送后,Agent 会重新审查并更新原来的评论。
十四、最小权限设计
S11 只生成 Artifact,因此使用只读权限。S12 需要写 PR 对话区评论,所以增加:
permissions:
contents: read
pull-requests: read
issues: write
权限用途如下:
| 权限 | 用途 |
|---|---|
contents: read |
检出和读取仓库代码 |
pull-requests: read |
读取 PR 上下文 |
issues: write |
创建和更新 PR Conversation 评论 |
因为摘要评论使用 Issue Comments API,所以真正控制写入的是 issues: write。
工作流没有申请 contents: write,因为本节不需要修改代码、创建 commit 或推送分支。
十五、为什么使用 GITHUB_TOKEN
发布步骤配置如下:
- name: Create or update PR comment
id: publish-comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
python -B s12/scripts/post_pr_comment.py \
--body-file "${REVIEW_OUTPUT_DIR}/pr-comment.md"
${{ github.token }} 是 GitHub Actions 为当前 Job 自动创建的临时 Token。
它具有几个优点:
- 不需要人工创建 PAT。
- 权限由 workflow 的
permissions明确控制。 - 生命周期只覆盖当前工作流运行。
- GitHub 会自动隐藏日志中的 Token 值。
LLM Token 与 GitHub Token 是两类不同凭据:
| 凭据 | 用途 |
|---|---|
OCR_LLM_AUTH_TOKEN |
OpenCodeReview 调用模型服务 |
GITHUB_TOKEN |
工作流调用 GitHub API |
两者不能混用。
十六、需要特别注意 fork PR
工作流会检出 PR 代码并运行仓库中的 Python 脚本,同时需要读取 LLM Secrets。如果直接对任意 fork PR 开放,恶意提交可能修改脚本并尝试读取密钥。
因此 Job 增加条件:
if: github.event_name == 'workflow_dispatch' ||
github.event.pull_request.head.repo.full_name == github.repository
它表示:
- 手动运行可以执行。
- PR 自动运行时,只处理来源于当前仓库分支的 PR。
- fork 仓库提交的 PR 不运行这个含密钥的 Job。
这里继续使用 pull_request,没有改为 pull_request_target。
pull_request_target 可以访问目标仓库 Secrets,但如果它随后检出并执行不可信 PR 代码,就会形成严重的密钥泄露风险。对于当前项目,宁可跳过 fork PR,也不在高权限上下文中执行 PR 分支代码。
十七、Actions 干净工作区问题
这一节在串联 S10 时发现了一个重要问题。
S10 的 Git 检测代码使用:
git status --porcelain
这个命令适合本地未提交变更。可是 GitHub Actions 执行 actions/checkout 后,工作区通常是干净的:
git status --short
# 没有输出
PR 确实包含代码差异,但这些差异已经存在于 commit 中,不属于“未提交改动”。如果直接运行 S10,就可能得到:
No Git changes detected by the LangGraph wrapper.
这意味着 Actions 虽然执行成功,却没有真正审查 PR 内容。
17.1 本节采用的处理方式
先明确检出 PR head:
- name: Checkout pull request head
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha || github.sha }}
然后在一次性 Runner 中执行:
- name: Materialize pull request changes
if: github.event_name == 'pull_request'
run: |
git cat-file -e "${PR_BASE_SHA}^{commit}"
git reset --soft "${PR_BASE_SHA}"
git status --short
git reset --soft base_sha 有两个关键特点:
- 文件内容仍然保持 PR head 的版本。
- HEAD 移动到 base,base 到 head 的差异会表现为已暂存变更。
因此,S10 的 git status --porcelain 能够看到 PR 变化,OpenCodeReview 也能读取对应 diff。
这个操作发生在 GitHub 提供的一次性 Runner 中,不会修改开发者本地仓库。
17.2 为什么设置 fetch-depth: 0
fetch-depth: 0
表示拉取完整历史。这样 PR_BASE_SHA 对应的 commit 一定可访问,下面的校验才能通过:
git cat-file -e "${PR_BASE_SHA}^{commit}"
如果只进行浅克隆,base commit 可能不在本地,git reset --soft 就会失败。
十八、并发控制
工作流继续使用:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
当一个 PR 连续推送多个 commit 时,旧的审查任务可能还没有完成。此配置会取消同一个 PR 上仍在执行的旧任务,只保留最新一次。
这可以避免:
- 旧结果晚于新结果发布。
- 同一个 PR 同时消耗多次 LLM 调用。
- 评论内容在旧版本和新版本之间来回覆盖。
十九、工作流环境变量
核心环境变量如下:
env:
REVIEW_OUTPUT_DIR: ${{ github.workspace }}/s12/outputs/ai-code-review
OCR_LLM_URL: ${{ secrets.OCR_LLM_URL }}
OCR_LLM_AUTH_TOKEN: ${{ secrets.OCR_LLM_AUTH_TOKEN }}
OCR_LLM_MODEL: ${{ secrets.OCR_LLM_MODEL }}
OCR_LLM_USE_ANTHROPIC: ${{ secrets.OCR_LLM_USE_ANTHROPIC }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
REVIEW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
它们可以分成三组:
19.1 模型配置
OCR_LLM_URL
OCR_LLM_AUTH_TOKEN
OCR_LLM_MODEL
OCR_LLM_USE_ANTHROPIC
19.2 Git diff 上下文
PR_BASE_SHA
PR_HEAD_SHA
19.3 输出和可追踪信息
REVIEW_OUTPUT_DIR
REVIEW_RUN_URL
REVIEW_RUN_URL 最终会出现在 PR 评论底部,使用者可以从评论直接进入对应 Actions 运行记录。
二十、运行 LangGraph Agent
工作流仍然复用 S10 的 Agent:
python -B s10/open-code-review-agent-langgraph/agent_graph.py \
--repo "${GITHUB_WORKSPACE}" \
--output-dir "${REVIEW_OUTPUT_DIR}" \
--ocr-bin ocr \
--exclude "outputs/**" \
--exclude "outputs-review-result.json" \
--exclude "s09/**" \
--exclude "s10/**" \
--exclude "s11/**" \
--exclude "s12/outputs/**" \
--print-trace
S12 没有复制一套 Agent 代码,而是复用已经验证过的 S10 工作流。
这里体现了分层关系:
S10:审查和风险分级
S11:CI 执行和 Artifact
S12:PR 评论反馈
--exclude 用于避免历史学习目录和生成结果进入本次审查,减少无关输入和 Token 消耗。
二十一、构建 PR 评论步骤
当 Agent 生成 JSON 后,工作流执行:
- name: Build pull request comment
if: always() && hashFiles('s12/outputs/ai-code-review/review-result.json') != ''
run: |
python -B s12/scripts/build_pr_comment.py \
--input "${REVIEW_OUTPUT_DIR}/review-result.json" \
--output "${REVIEW_OUTPUT_DIR}/pr-comment.md" \
--repository "${GITHUB_REPOSITORY}" \
--sha "${PR_HEAD_SHA}" \
--run-url "${REVIEW_RUN_URL}" \
--max-findings 10
这里使用 always(),表示即使前面的步骤状态异常,只要 JSON 已经存在,仍尝试生成可查看的评论文件。
但是 always() 并不意味着无条件执行,后面还检查:
review-result.json 是否存在
这样可以避免输入缺失时产生额外错误,掩盖真正的问题。
二十二、发布 PR 评论步骤
- name: Create or update PR comment
id: publish-comment
if: github.event_name == 'pull_request' &&
hashFiles('s12/outputs/ai-code-review/pr-comment.md') != ''
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
python -B s12/scripts/post_pr_comment.py \
--body-file "${REVIEW_OUTPUT_DIR}/pr-comment.md"
这里增加两个条件:
- 当前事件必须是
pull_request。 pr-comment.md必须存在。
workflow_dispatch 没有天然的 PR 编号,因此手动运行时只生成审查文件和 Artifact,不自动发布 PR 评论。
二十三、上传 Artifact
即使已经将摘要发布到 PR,仍然保留完整输出:
- name: Upload review artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ai-code-review-pr-${{ github.event.pull_request.number || github.run_number }}
path: |
s12/outputs/ai-code-review/review-result.json
s12/outputs/ai-code-review/review-report.md
s12/outputs/ai-code-review/pr-comment.md
s12/outputs/ai-code-review/history/**
if-no-files-found: warn
retention-days: 14
各文件用途如下:
| 文件 | 用途 |
|---|---|
review-result.json |
机器可处理的结构化结果 |
review-report.md |
完整 Markdown 审查报告 |
pr-comment.md |
实际发送到 PR 的摘要正文 |
history/** |
保存本次工作流执行记录 |
当 PR 评论格式或内容出现问题时,可以下载 pr-comment.md 与实际评论进行对比。
二十四、本地生成 PR 评论
本节使用 S10 已经生成的真实 JSON 进行验证。
在练习仓库根目录执行:
python -B .\s12\scripts\build_pr_comment.py `
--input .\s10\open-code-review-agent-langgraph\outputs\review-result.json `
--output .\s12\outputs\pr-comment.md `
--repository alibaba/open-code-review `
--sha abc123 `
--run-url https://github.com/alibaba/open-code-review/actions/runs/123 `
--max-findings 10
实际输出:
PR comment written to: ...\s12\outputs\pr-comment.md
生成的核心内容如下:
<!-- open-code-review-agent:pr-summary -->
## AI Code Review
Status: `success` | Files reviewed: **2** | Findings: **2**
| Severity | Count |
|---|---:|
| Critical | 1 |
| Warning | 1 |
| Suggestion | 0 |
### Findings
1. **[CRITICAL]** `s01/src/user.js:37`
SQL Injection Vulnerability...
2. **[WARNING]** `s01/src/user.js:36-39`
The updateUserEmail function is defined but not exported...
真实生成的评论长度为:
1177 characters
这说明以下内容已经从真实 OCR JSON 中正确提取:
files_reviewed = 2Critical = 1Warning = 1- 两条审查意见
- 文件路径和行号
二十五、使用 dry-run 验证发布参数
本地没有 GitHub Actions 的事件文件和临时 Token,因此不能直接模拟完整的远程发布环境。
发布脚本提供 --dry-run:
python -B .\s12\scripts\post_pr_comment.py `
--body-file .\s12\outputs\pr-comment.md `
--repository alibaba/open-code-review `
--pr-number 12 `
--dry-run
实际输出:
Dry run successful.
Repository: alibaba/open-code-review
PR number: 12
Comment characters: 1177
dry-run 会验证:
- Markdown 文件存在。
- 文件包含正确 marker。
- 评论长度没有超过限制。
- repository 参数有效。
- PR number 是正整数。
它不会:
- 读取
GITHUB_TOKEN。 - 访问 GitHub API。
- 创建或更新真实评论。
因此可以在没有远程副作用的情况下检查发布输入。
二十六、单元测试
测试文件为:
s12/tests/test_pr_comment.py
运行命令:
python -B -m unittest discover -s .\s12\tests -v
实际结果:
test_builds_summary_and_source_link ... ok
test_derives_risk_counts_when_summary_is_missing ... ok
test_finds_only_marker_comment_from_expected_author ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
26.1 测试 Markdown 与源码链接
测试输入故意使用带空格的路径:
"path": "src/user file.js"
然后验证生成链接包含:
src/user%20file.js#L7-L9
这证明 URL 编码和多行链接都按预期工作。
26.2 测试风险统计兜底
测试在没有 risk_summary 时传入:
{"comments": [
{"severity": "Warning"},
{"severity": "unknown"}
]}
预期统计为:
Warning = 1
Suggestion = 1
26.3 测试评论作者过滤
测试构造两条都含 marker 的评论:
- 一条来自普通用户。
- 一条来自
github-actions[bot]。
程序必须选中机器人评论,不能修改普通用户评论。
二十七、如何在 GitHub 仓库启用
GitHub 只识别仓库根目录下的工作流:
.github/workflows/*.yml
当前文件位于:
s12/.github/workflows/ai-code-review-comment.yml
这是为了按章节保存学习代码。需要真正运行时,应将它复制到仓库根目录:
.github/workflows/ai-code-review-comment.yml
然后在 GitHub 仓库中配置:
Settings
↓
Secrets and variables
↓
Actions
↓
New repository secret
需要的 Secrets:
| Secret | 是否必需 | 用途 |
|---|---|---|
OCR_LLM_URL |
是 | LLM API 地址 |
OCR_LLM_AUTH_TOKEN |
是 | LLM API 认证 Token |
OCR_LLM_MODEL |
否 | 指定模型名称 |
OCR_LLM_USE_ANTHROPIC |
否 | 切换兼容协议 |
不需要配置 GITHUB_TOKEN。它由 Actions 自动提供。
还要确认仓库的 Actions 权限没有禁止工作流写评论。相关设置通常位于:
Settings
↓
Actions
↓
General
↓
Workflow permissions
二十八、一次完整运行会发生什么
假设 PR 第一次创建,执行顺序如下:
1. GitHub 触发 pull_request.opened
2. Actions 检出 PR head
3. 校验 LLM Secrets
4. 将 base/head 差异准备为 staged diff
5. 安装 Node.js、Python、OCR 和 LangGraph
6. 配置 OpenCodeReview LLM Provider
7. 运行 S10 Agent
8. 生成 review-result.json 和 review-report.md
9. 生成 pr-comment.md
10. 查询 PR 历史评论
11. 未找到 marker,POST 创建新评论
12. 上传 Artifact
当 PR 再次推送 commit 时:
1. GitHub 触发 pull_request.synchronize
2. 重新审查最新 head
3. 重新生成 pr-comment.md
4. 查询 PR 历史评论
5. 找到 marker 和 github-actions[bot]
6. PATCH 更新原评论
所以 PR 页面不会不断增加新的 AI Review 摘要。
二十九、常见问题排查
29.1 工作流没有出现
原因通常是 YAML 仍位于:
s12/.github/workflows
必须放到仓库根目录:
.github/workflows
29.2 显示 Missing required secret
检查以下 Secrets 是否配置:
OCR_LLM_URL
OCR_LLM_AUTH_TOKEN
Secret 名称区分字符,必须与 workflow 完全一致。
29.3 Agent 显示 No Git changes detected
检查 Materialize pull request changes 步骤的 git status --short 是否有输出。
同时确认:
fetch-depth是否为0。PR_BASE_SHA是否存在。- 工作流是否由
pull_request事件触发。 - exclude 是否错误排除了全部 PR 文件。
29.4 API 返回 403
重点检查:
permissions:
issues: write
还要检查仓库或组织级 Actions 策略是否覆盖了 workflow 权限。
29.5 每次都会创建新评论
检查实际评论原始正文是否包含:
<!-- open-code-review-agent:pr-summary -->
并确认评论作者是否为:
github-actions[bot]
如果运行在特殊 GitHub 环境,机器人登录名可能不同,可以通过 --comment-author 调整。
29.6 评论中的源码链接打不开
检查构建参数:
--repository owner/repository
--sha PR_HEAD_SHA
同时确认 OCR 返回的 path 是相对仓库根目录的路径。
29.7 fork PR 没有运行
这是当前版本的安全策略,不是工作流故障。fork PR 不会获得仓库 LLM Secrets,当前 Job 也明确限制为同仓库分支。
三十、本节的工程设计要点
这一节代码不只是调用一个评论 API,还包含几个重要的工程思想。
30.1 确定性模块与副作用模块分离
JSON → Markdown
是确定性转换,可以快速测试。
Markdown → GitHub API
涉及网络和权限,需要独立错误处理。
30.2 使用稳定标识实现幂等
隐藏 marker 相当于评论的业务主键,使程序能够执行 create-or-update。
30.3 最小权限
工作流只增加真正需要的 issues: write,没有给仓库内容写权限。
30.4 不信任外部输出
虽然 JSON 来自自己的 Agent,格式化器仍然检查数据类型、未知 severity、评论长度和用户提及。
30.5 保留完整审计数据
PR 只显示摘要,完整 JSON、报告和历史记录继续保存为 Artifact。
30.6 CI 环境和本地环境不同
本地开发通常审查未提交 diff,Actions 审查的是 commit 之间的 diff。S12 对 base/head 做显式处理,避免工作流“运行成功但没有审查内容”。
三十一、当前版本的边界
S12 已完成 PR 摘要评论,但仍有明确边界:
- 只自动处理同仓库分支 PR。
- 不发布逐行 inline comment。
- 最多在摘要中展示 10 条问题。
- 不根据 Critical 自动阻止合并。
- 不判断某条问题是否已被开发者修复。
- 不记录评论从 created 到 updated 的长期指标。
- 实际 LLM 质量仍由 OpenCodeReview、模型和规则配置共同决定。
- 当前工作流依赖 S10 的目录结构。
明确这些边界很重要。一个能够稳定工作的摘要反馈链路,比一次加入过多功能更容易验证和继续演进。
三十二、下一节可以继续学习什么
下一阶段可以实现逐行评论与 Guardrails,重点解决“审查意见是否真的能安全地定位到 PR diff”。
推荐流程:
OCR comments
↓
Comment Locator
↓
验证 path 和 line 是否属于本次 diff
↓
Duplicate Filter
↓
Verifier
↓
GitHub Pull Request Review API
↓
Inline Comments
需要重点学习:
- unified diff hunk 结构
- GitHub Review Comments API
line、side、start_line、start_side- outdated comment 的产生原因
- 同一问题的指纹和去重
- 无效行号的降级策略
- 批量提交一次 Pull Request Review
S12 生成的 path、start_line 和 end_line 已经为这一阶段提供了输入,但不能直接发送。下一步必须先建立定位校验层。
三十三、本篇总结
本篇在已有 Code Review Agent 的基础上完成了 PR 反馈闭环:
GitHub PR
↓
准备真实 PR diff
↓
OpenCodeReview + LangGraph
↓
review-result.json
↓
Markdown Builder
↓
GitHub Publisher
↓
Create or Update PR Comment
本节完成的核心内容包括:
- 将 OCR JSON 转换为结构清晰的 PR Markdown。
- 生成固定 commit SHA 对应的源码行链接。
- 使用隐藏 marker 和作者过滤实现幂等更新。
- 使用 GitHub Actions 内置 Token 调用 REST API。
- 使用最小权限写入 PR Conversation。
- 限制评论长度和
@mention。 - 处理 GitHub Actions 干净工作区与本地 diff 检测的差异。
- 使用 dry-run 和单元测试验证核心逻辑。
- 保留 Artifact 作为完整结果和执行记录。
到这里,这个项目已经不只是一个调用 OpenCodeReview CLI 的脚本,而是形成了输入检测、Agent 审查、结构化输出、风险分级、状态编排、CI 执行和 PR 反馈组成的完整工程链路。
更多推荐




所有评论(0)