封面图片

第2节 GitHub Actions集成:PR自动审查机器人

一句话总结:实操一个PR审查工作流——PR触发→headless循环读diff→按项目规范审查→输出评论/建议;secrets与密钥管理、防自我审批。这一节把第1节的headless原子第一次挂到真实的git事件上。


本文导航


一、为什么PR审查是Agent落地的第一站

第1节的headless原子到手,第一个挂载点我选了PR审查。理由有三个,每个都很现实。

第一,输入是天然的。PR的diff是现成的、结构化的、范围有限的——不用喂整个仓库,几百到几千行diff就是完整上下文,不会爆token。

第二,输出是低风险的。审查的产物是评论和建议,不是代码本身。Agent说错了,最坏结果是一条不靠谱的评论被人类reviewer嘲笑两句;它不像自动修bug那样改了代码直接进主干。

第三,反馈是即时的。评论发出去,人点没用/有用,下个PR马上能验证提示词改得好不好。迭代周期以小时计,而不是以季度计。

人力账也算得过来。我们组平均每天14个PR,人工首轮审查每个约8分钟,一天就是近两小时的首席工程师时间,其中大量花在"命名不规范、缺测试、异常裸奔"这类机械问题上。AI把机械问题拦在首轮,人只看它标出的major和它没把握的部分。一个月后的内部数据:人工平均审查时长从8.2分钟降到3.1分钟,AI评论的采纳率(人点了resolved或按建议改了代码)稳定在62%。

维度纯人工审查AI首筛+人工终审
响应速度看人什么时候有空PR推上去约30秒出评论
机械问题(命名/风格/缺测试)占用大量注意力AI全包
深层逻辑/架构问题人的强项AI常看不出来,靠人兜底
一致性因人因心情而异同一规则每次一致执行
成本每天~2小时人力每PR约$0.03
责任明确在人AI只有建议权,责任仍在人

整条链路长这样:

有问题

无问题

PR创建/更新

GitHub Actions触发

checkout拉代码

git diff origin/main...HEAD

headless审查循环
读diff+对照AGENTS.md

输出Review JSON

post_review.py解析

发PR评论

发通过评论

等待人类最终裁决

注意最后一站是"等待人类最终裁决"——AI审查员只有建议权,没有批准权。这不是技术限制,是治理设计,第五节展开讲。

先把效果摆出来。这是机器人在我们仓库一次真实PR上的评论:

🤖 AI Review 完成(1个问题,2条建议)

❌ [major] src/api/pay.py:42
   金额计算用了float,浮点误差会导致对账失败。项目规范要求金额一律用Decimal(见AGENTS.md第12条)。

💡 [minor] src/api/pay.py:58
   except Exception吞掉了异常没有日志,建议logger.exception保留现场。

💡 [nit] tests/test_pay.py
   新增分支缺测试覆盖:余额不足的边界情况。

人看完这条评论,改了float,剩下的点了个resolved。全程AI出活23秒,人类决策40秒。这就是这一节要造的东西。


二、Workflow YAML逐行拆解

完整YAML先上,然后逐块讲。这是我在两个仓库跑过半年、改了四版的稳定版:

# .github/workflows/ai-review.yml
name: AI PR Review

on:
  pull_request:
    types: [opened, synchronize]     # 新开PR + 已有PR推新commit都触发
  workflow_dispatch: {}              # 手动触发按钮,调试期救命用

# 同一个PR连推3个commit时,取消旧任务只跑最新的,省钱省时间
concurrency:
  group: ai-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read                     # 只读代码
  pull-requests: write               # 写PR评论

jobs:
  review:
    runs-on: ubuntu-latest
    timeout-minutes: 10              # 整个job的物理兜底
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0             # 关键!拉全量历史才能diff出PR全部改动

      - name: Setup uv
        uses: astral-sh/setup-uv@v5
        with:
          python-version: "3.12"

      - name: Install claude CLI
        run: npm install -g @anthropic-ai/claude-code

      - name: AI Review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          # 三点diff:origin/main到HEAD的净改动,不受main上新提交干扰
          git diff origin/main...HEAD > /tmp/pr.diff

          # diff太大直接放弃,防止上下文爆炸烧钱
          LINES=$(wc -l < /tmp/pr.diff)
          if [ "$LINES" -gt 3000 ]; then
            echo "diff过大(${LINES}行),跳过AI审查" && exit 0
          fi

          cat /tmp/pr.diff | claude -p "审查这个PR的diff。检查:
          1. 代码质量(命名/结构/异常处理)
          2. 潜在bug(空指针/边界/并发/资源泄漏)
          3. 是否符合AGENTS.md约定(先读仓库根目录AGENTS.md)
          输出JSON:{approved: bool, issues: [{severity, line, comment}]}"
          --output-format json --allowedTools "Read,Grep" --max-turns 15 > review.json

          # 转换为PR评论
          uv run python scripts/post_review.py review.json

逐块说四个容易翻车的点。

fetch-depth: 0。checkout默认浅克隆只拉一个commit,git diff origin/main...HEAD 会因为找不到merge-base而报错或者diff出天量无关改动。我第一版没加这行,机器人把仓库三年的历史diff当成了PR内容,评论里出现了2019年的代码,团队笑了一周。代价是克隆变慢,大仓库可以折中用 fetch-depth: 0 换 fetch-depth: 50(够覆盖PR的base即可)。

concurrency 块。开发者连推三个commit,Actions会排队跑三次审查,浪费两倍钱且评论乱序。加这个块后旧任务自动取消,同PR永远只有最新一次审查在跑。

diff的3000行闸门。有一次进来一个自动生成的30000行diff(protobuf更新),Agent读了15轮也没读完,白烧$3。加闸门后超限直接跳过并留一句说明,把AI留给值得看的代码。

--allowedTools "Read,Grep"。审查只需要读和搜,绝不能给Edit——审查机器人理论上不该改代码。最小权限在CI里不是口号,是一道实打实的防线。

配置项我的选择踩坑后的理由
fetch-depth0(或50)默认浅克隆导致三点diff失效
concurrency按PR号分组+取消旧任务连推commit时省钱防乱序
timeout-minutes10job级兜底,防CLI挂死
触发类型opened+synchronizereopened按需,closed不要(diff已无意义)
allowedToolsRead,Grep审查只读不写
diff闸门3000行防生成代码类巨型diff烧钱

⚠️ 踩坑提示:workflow_dispatch: {} 这行调试期一定要加。没有它,每次改提示词都得真开一个PR来触发,改十次开十个PR,同事们会想打人。有手动触发,改完点一下Run workflow就能验证。


三、post_review.py:把审查结果变成PR评论

Agent输出的JSON只是半成品,要变成人类可读的PR评论,中间还隔着一个转换层。这一层的工程要求和第1节一样:pydantic校验、日志落盘、失败可追溯。

# scripts/post_review.py
"""把AI审查JSON转成PR评论:pydantic校验 + 全字段日志 + 防自我审批"""
import json
import os
import sys
import subprocess
from datetime import datetime
from pathlib import Path
from logging import getLogger, StreamHandler, FileHandler, Formatter, INFO

from pydantic import BaseModel, Field, field_validator

# ---- 日志基座:控制台+文件双输出,按月分割 ----
LOG_DIR = Path("logs"); LOG_DIR.mkdir(exist_ok=True)
logger = getLogger(f"post_review.{datetime.now():%Y%m}")
if not logger.handlers:
    logger.setLevel(INFO)
    fmt = Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s",
                    datefmt="%Y-%m-%d %H:%M:%S")
    for h in (StreamHandler(),
              FileHandler(LOG_DIR / f"post_review-{datetime.now():%Y-%m}.log",
                          encoding="utf-8")):
        h.setFormatter(fmt); logger.addHandler(h)


class Issue(BaseModel):
    """单条审查意见"""
    severity: str = Field(pattern="^(major|minor|nit)$")   # 枚举收紧,防模型自由发挥
    line: str = ""                                          # 行号或文件路径
    comment: str

    @field_validator("comment")
    @classmethod
    def meaningful(cls, v: str) -> str:
        assert len(v.strip()) >= 5, "comment过短,疑似模型敷衍"
        return v.strip()


class Review(BaseModel):
    """整份审查结果:approved必须显式给出"""
    approved: bool
    issues: list[Issue] = Field(default_factory=list)

    @property
    def emoji_header(self) -> str:
        n_major = sum(1 for i in self.issues if i.severity == "major")
        return (f"🤖 AI Review 完成({n_major}个问题,"
                f"{len(self.issues) - n_major}条建议)")


def render_comment(review: Review) -> str:
    """Review对象 → Markdown评论正文"""
    lines = [review.emoji_header, ""]
    icon = {"major": "❌", "minor": "💡", "nit": "📝"}
    for i in review.issues:
        lines.append(f"{icon[i.severity]} [{i.severity}] {i.line}")
        lines.append(f"   {i.comment}\n")
    lines.append("---")
    lines.append("由AI辅助生成,仅供参考,最终以人工Review为准。" if not review.approved
                 else "本次审查未发现major问题,等待人工确认合并。")
    return "\n".join(lines)


def post_comment(pr_number: int, body: str):
    """gh CLI发PR评论(GH_TOKEN来自workflow注入)"""
    subprocess.run(["gh", "pr", "comment", str(pr_number), "--body", body],
                   check=True, env={**os.environ})


def main():
    review = Review.model_validate(json.loads(Path(sys.argv[1]).read_text("utf-8")))
    logger.info("审查解析成功:approved=%s issues=%d",
                review.approved, len(review.issues))

    # 防自我审批铁律:机器人只评论,永远不approve
    if review.approved and not review.issues:
        logger.info("AI认为可合,但按红线规则不执行approve,仅发提示评论")

    comment = render_comment(review)
    post_comment(int(os.environ["PR_NUMBER"]), comment)
    logger.info("评论已发送到PR #%s,长度%d", os.environ["PR_NUMBER"], len(comment))


if __name__ == "__main__":
    main()

CI里跑起来的真实日志(Actions控制台输出):

2026-09-29 03:12:44 | INFO | post_review.202609 | 审查解析成功:approved=False issues=3
2026-09-29 03:12:45 | INFO | post_review.202609 | 评论已发送到PR #456,长度487

(这次approved=False、issues=3,"AI认为可合"的分支日志没触发——那行只在模型判全绿时出现,是防自我审批的提示性日志,不是审批动作。)

pydantic在两个地方救过我。severity 用 Field(pattern=...) 收紧成三个枚举值之后,模型输出 “critical”、“严重” 这类自由发挥会直接校验失败进入重试,而不是带着脏数据进评论渲染;comment 的最小长度校验拦住过一次模型偷懒输出 “ok”。

还有个细节:发评论用的 GH_TOKEN 是Actions自带的 GITHUB_TOKEN,不用自己建token;而 ANTHROPIC_API_KEY 才是真secret。两者的权限边界下一节说。

⚠️ 踩坑提示:subprocess.run 传 --body 时千万别用shell字符串拼接。评论正文里有反引号、有 $ 符号、有换行,拼进shell命令会被解释执行。用列表参数形式让subprocess直接传参,正文原样到达。


四、密钥管理与权限最小化

CI里的密钥管理,我的原则就一句话:密钥只住在两个地方——GitHub Secrets的加密存储,和运行时进程的环境变量。代码、日志、评论里永远不出现。

这套workflow用到两个token,职责截然不同:

Token来源权限泄漏影响
ANTHROPIC_API_KEY手动配置的Secret调LLM API烧钱盗刷账单,上限=信用卡额度
GITHUB_TOKENActions自动注入本仓库PR写权限恶意PR评论,范围限本仓库

配置Secret的路径是 GitHub仓库 → Settings → Secrets and variables → Actions → New repository secret,名字叫 ANTHROPIC_API_KEY,值是 sk-ant-...。配好之后任何人在日志里都只能看到 ${{ secrets.ANTHROPIC_API_KEY }} 字面量。

三条安全原则,每条都对应一次真实事故:

# 原则1:密钥不进代码、不进commit、不进截图
# 原则2:最小权限——permissions块显式收紧(默认token权限可能是读写的)
permissions:
  contents: read          # 审查不需要写代码
  pull-requests: write    # 只需要写评论

# 原则3:定期轮换——我设了90天日历提醒,换key只要30秒

事故一,日志泄密。早期我在调试时顺手 echo $ANTHROPIC_API_KEY | head -c 10 想确认环境变量注入成功——GitHub Actions会把匹配secret模式的字符串自动替换成 ***,但截断后的前10个字符不匹配完整secret模式,明文漏进了日志。正确做法是只打印末尾几位:echo "...${ANTHROPIC_API_KEY: -6}"。这也正是本系列所有LLM调用日志只记"key末尾6位"的原因——既是可追溯性,也是安全性。

事故二,权限过大。没写 permissions 块时,默认GITHUB_TOKEN带着仓库写权限。虽然这个workflow没用上,但"给了用不上的权限"本身就是风险敞口。显式声明最小权限后,即使脚本被注入恶意命令,它也改不了代码。

事故三,fork PR的坑。来自fork的PR默认拿不到你的secrets(GitHub的保护机制),审查job会静默失败或key为空。要么接受"fork PR不审查",要么用 pull_request_target(危险,需要checkout显式指定ref,容易引入代码执行漏洞)。我的选择是前者,简单安全。

场景做法备注
确认key已注入打印末6位别打印前10位
控制token权限permissions块显式声明默认权限可能过大
fork来的PR跳过AI审查secrets天然不可用
key轮换90天提醒换key不影响workflow
本地调试.env文件+gitignore.env永远不进git

五、防自我审批:AI的治理红线

这一节是整个第2节最重要的一段话。

设想一条自动流水线:Agent修bug→Agent开PR→Agent审查→Agent批准→自动合并。看起来高效闭环,实际上是个自己批自己作业的学生——没有任何独立校验环节。第4节会讲判定器与执行者分离的原理,这里先给治理层面的落法:AI审查员永远只有评论权,没有approve权。

落到配置就是三道锁:

要求人工approve

禁止机器人approve

人工检查后批准

写评论可以
approve做不到

AI Review评论

分支保护规则

人类Reviewer

允许合并

CODEOWNERS
关键路径指定负责人

AI账号只读权限
permissions: pull-requests: write

第一道锁在workflow层:post_review.py 里根本没有调用approve API的代码路径,AI想批也没有手。

第二道锁在分支保护层:仓库设置 Branch protection rule,勾选 Require pull request reviews before merging,Required approvals = 1。就算某天有人给机器人加了approve能力,分支保护也会拒收机器人的approval(可以在规则里设置排除bot账号的approval计数)。

第三道锁在责任层:CODEOWNERS 文件把关键路径绑定到具体的人:

# .github/CODEOWNERS
/src/api/pay.py    @finance-team
/src/auth/         @security-team
*.sql              @dba-team

支付代码的PR,AI评论完还得等财务组的人点头。自动化提高的是审查带宽,不是免除责任。

治理措施防什么成本
AI无approve代码路径机器人自我审批零成本,纯纪律
分支保护required reviews绕过人工直接合并每PR多几分钟
CODEOWNERS强制负责人关键路径无人把关一次性配置
concurrency+timeout恶意/失控烧钱一次性配置
diff闸门巨型diff拖垮审查一次性配置

⚠️ 踩坑提示:别让AI评论里出现" LGTM,可以合并"这类措辞——曾有新同事把AI的"未发现major问题"当成批准信号点了merge。我的做法是在评论模板末尾固定加一句"仅供参考,最终以人工Review为准",并在AGENTS.md里写明:AI评论不是批准。措辞也是安全设计。


六、审查提示词设计与效果度量

机器人搭起来只是开始,真正决定它好不好用的是提示词。我的审查提示词迭代了四版,每一版的坑都值得摆出来。

v1版最天真:"审查这个diff,找出问题。“结果它像个捧场观众,十个PR九个说没问题——没有标准的审查等于没有审查。v2版加了检查清单,误报开始出现:它把 dataclass 的可变默认值标成major,把正常的walrus运算符标成"风格混乱”。v3版我做了两件事:给severity下严格定义(major=会造成数据错误/安全漏洞/崩溃;minor=确定的坏味道;nit=可改可不改),并要求它引用AGENTS.md条目编号。v4版(也就是第二节的YAML里那版)效果稳定:采纳率62%,major误报率7%。

# scripts/review_metrics.py
"""统计AI审查的采纳率与误报率:从PR评论反应拉数据,pydantic校验"""
import json
import subprocess
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel

LOG = Path(f"logs/review_metrics-{datetime.now():%Y-%m}.jsonl")


class CommentStat(BaseModel):
    """一条AI评论的结局档案"""
    pr_number: int
    issues_total: int
    issues_resolved: int      # 人点了resolved = 采纳
    issues_dismissed: int     # 人点 dismissed = 误报
    reviewed_at: str

    @property
    def adopt_rate(self) -> float:
        return self.issues_resolved / max(self.issues_total, 1)


def collect(pr_number: int) -> CommentStat:
    """gh拉取该PR上AI评论的反应数(👍=resolved,👎=dismissed)"""
    raw = subprocess.run(
        ["gh", "api", f"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments",
         "--jq", ".[] | select(.user.login=='github-actions[bot]')"],
        capture_output=True, text=True, check=True).stdout
    reactions = json.loads(raw or "[]")
    stat = CommentStat(
        pr_number=pr_number, issues_total=len(reactions),
        issues_resolved=sum(r.get("reactions", {}).get("+1", 0) for r in reactions),
        issues_dismissed=sum(r.get("reactions", {}).get("-1", 0) for r in reactions),
        reviewed_at=datetime.now().isoformat(timespec="seconds"))
    with LOG.open("a", encoding="utf-8") as f:          # 追加到月度JSONL,可追溯
        f.write(stat.model_dump_json() + "\n")
    return stat


def monthly_report() -> str:
    """月度采纳率报告:一行一句,直接贴Slack"""
    rows = [CommentStat.model_validate(json.loads(l)) for l in LOG.read_text("utf-8").splitlines()]
    total = sum(r.issues_total for r in rows)
    resolved = sum(r.issues_resolved for r in rows)
    return (f"本月AI审查{len(rows)}个PR共{total}条意见,"
            f"采纳{resolved}条,采纳率{resolved / max(total, 1):.0%}")


if __name__ == "__main__":
    print(monthly_report())

月末跑一次的真实输出:

$ uv run python scripts/review_metrics.py --report
本月AI审查168个PR共402条意见,采纳249条,采纳率62%

两个设计细节。第一,采纳信号用评论的emoji反应:reviewer觉得评论有用点👍,觉得误报点👎,零额外成本拿到标注数据——这是整个度量体系的巧思,人不愿意填表,但愿意点表情。第二,metrics本身落JSONL按月归档,配合第1节的全字段调用日志,"AI审查值不值"这个问题永远可以用数据回答,而不是开会吵架。

提示词版本关键改动采纳率major误报率教训
v1"找出问题"一句话~10%极低没标准的审查=没有审查
v2加检查清单41%23%清单不定义severity就会乱标
v3severity严格定义+引用规范条目55%11%引用条目让它有据可依
v4v3+先读AGENTS.md再审62%7%项目规范入库是质变的一步

⚠️ 踩坑提示:不要在提示词里写"尽量多提意见"。模型会服从到令人发指——它开始给每个函数都提"建议增加类型注解",包括本来就是类型注解定义的那几行。宁可让它保守:"只报告你有较高把握的问题,没把握就不要说。"审查机器人的信誉建立在不说废话上,一次连续三个PR全是废话,人们就开始无视它,然后它就死了。


小结

  • 选PR审查做第一站:输入天然结构化(diff)、输出低风险(评论)、反馈即时(下个PR验证),是Agent落地的黄金场景。
  • YAML四道保险:fetch-depth: 0 保证三点diff可用、concurrency 防重复任务烧钱、3000行diff闸门防巨型输入、--allowedTools "Read,Grep" 最小权限只读不写。
  • 转换层工程规范:pydantic的 Field(pattern=...) 收紧枚举、最小长度校验防敷衍、评论经gh CLI列表参数发送(杜绝shell注入)、日志按月落盘。
  • 密钥三原则:密钥只住Secrets和环境变量、permissions显式最小化、90天轮换;日志永远只打印key末6位。
  • 防自我审批三道锁:代码路径禁approve、分支保护要求人工review、CODEOWNERS锁定关键路径负责人——AI提升审查带宽,不免除人的责任。

下节预告

PR是"人来触发"的自动化,下一节把它升级成"时间来触发":第11章第3节《凌晨2点的数字实习生:定时分诊循环全实现》——cron定时扫描Issue、LLM三分类、能修的自动起草、早晨简报,你睡觉时Agent在上班。


如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连!
本系列持续更新中,关注不迷路~


文章编号:第11章第2节 | 总进度:77/120 | 预计阅读时间:16分钟

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐