AI智能体循环工程-第11章第2节-自动化与无人值守-GitHubActions集成PR自动审查机器人

第2节 GitHub Actions集成:PR自动审查机器人
一句话总结:实操一个PR审查工作流——PR触发→headless循环读diff→按项目规范审查→输出评论/建议;secrets与密钥管理、防自我审批。这一节把第1节的headless原子第一次挂到真实的git事件上。
本文导航
- 一、为什么PR审查是Agent落地的第一站
- 二、Workflow YAML逐行拆解
- 三、post_review.py:把审查结果变成PR评论
- 四、密钥管理与权限最小化
- 五、防自我审批:AI的治理红线
- 六、审查提示词设计与效果度量
- 小结
一、为什么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只有建议权,责任仍在人 |
整条链路长这样:
注意最后一站是"等待人类最终裁决"——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-depth | 0(或50) | 默认浅克隆导致三点diff失效 |
| concurrency | 按PR号分组+取消旧任务 | 连推commit时省钱防乱序 |
| timeout-minutes | 10 | job级兜底,防CLI挂死 |
| 触发类型 | opened+synchronize | reopened按需,closed不要(diff已无意义) |
| allowedTools | Read,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_TOKEN | Actions自动注入 | 本仓库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权。
落到配置就是三道锁:
第一道锁在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就会乱标 |
| v3 | severity严格定义+引用规范条目 | 55% | 11% | 引用条目让它有据可依 |
| v4 | v3+先读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分钟
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)