一、前言

在前面的学习中,已经逐步完成了下面这条链路:

Git diff
  ↓
OpenCodeReview
  ↓
JSON 结构化结果
  ↓
风险分级
  ↓
LangGraph 工作流
  ↓
GitHub Actions
  ↓
Artifact 与 Job Summary

S11 已经可以在 GitHub Actions 中自动运行 Code Review Agent,并将 review-result.jsonreview-report.md 和历史记录上传为 Artifact。

但是,这个流程还有一个很明显的问题:开发者必须进入 Actions 页面,再查看 Job Summary 或下载 Artifact,才能看到完整审查结果。审查结果还没有真正回到 Pull Request 的协作页面。

因此,S12 要补上最后一段反馈链路:

review-result.json
  ↓
生成 PR Markdown 评论
  ↓
调用 GitHub REST API
  ↓
创建或更新 PR 评论

这一节不再增加新的 LLM 能力,而是重点解决 Agent 如何与真实的软件开发流程集成。


二、本篇要完成什么

本篇最终需要实现以下功能:

  1. 读取 S10 生成的 review-result.json
  2. 将风险统计和审查意见转换为 Markdown。
  3. 为每条问题生成可点击的 GitHub 源码行链接。
  4. 使用 GitHub REST API 将 Markdown 发布到 PR。
  5. 第一次运行时创建评论,后续运行时更新同一条评论。
  6. 使用 GitHub Actions 内置的 GITHUB_TOKEN,不额外创建 Personal Access Token。
  7. 限制评论长度,避免大量审查结果导致 API 请求失败。
  8. 防止模型输出中的 @用户名 意外通知 GitHub 用户。
  9. 修正 Actions 干净工作区无法被 S10 检测为 Git diff 的问题。
  10. 为评论构建和重复评论识别增加单元测试。

这次实现之后,完整链路变为:

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_readcode_searchcode_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}"

这里有几个细节:

  1. 将 Windows 路径分隔符 \ 转换为 /
  2. 使用 quote() 对空格等特殊字符编码。
  3. 使用 PR head SHA,而不是容易移动的分支名。
  4. 单行生成 #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 摘要。

程序使用两个条件识别旧评论:

  1. 正文包含固定 marker。
  2. 评论作者是 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 有两个关键特点:

  1. 文件内容仍然保持 PR head 的版本。
  2. 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"

这里增加两个条件:

  1. 当前事件必须是 pull_request
  2. 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 = 2
  • Critical = 1
  • Warning = 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 摘要评论,但仍有明确边界:

  1. 只自动处理同仓库分支 PR。
  2. 不发布逐行 inline comment。
  3. 最多在摘要中展示 10 条问题。
  4. 不根据 Critical 自动阻止合并。
  5. 不判断某条问题是否已被开发者修复。
  6. 不记录评论从 created 到 updated 的长期指标。
  7. 实际 LLM 质量仍由 OpenCodeReview、模型和规则配置共同决定。
  8. 当前工作流依赖 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
  • linesidestart_linestart_side
  • outdated comment 的产生原因
  • 同一问题的指纹和去重
  • 无效行号的降级策略
  • 批量提交一次 Pull Request Review

S12 生成的 pathstart_lineend_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 反馈组成的完整工程链路。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐