
团队里的代码评审一度快把我搞疯了。周一早上打开 GitHub一个 PR 下面挂着 46 条评论。仔细一看机器人重复发了 9 条一模一样的提示有 4 条已经过时、对应的代码早就改掉了真正想提醒团队注意的“接口兼容性风险”被淹没在刷屏里根本没人看见。这不是工具能力不行而是评审链路上的自动化缺少一次系统化的梳理。于是我把这套东西自己重做了一遍项目名字就叫open-code-review。它是一套开源、可插拔、轻量的代码评审工具核心干三件事自动拉取 Pull Request 的完整变更、按规则集执行检查、把结果以行内评论和 Check Run 的形式精确回写到对应位置并且能接入现有 CI/CD 做质量门禁。它不是要替代人而是把“必须拦在合入之前”的问题提前交给机器把人的精力留给真正需要判断力的设计评审。这篇文章不聊概念只讲实现——从架构设计、规则引擎、接入实操到排错过程把我踩过的坑和验证过的方案完整过一遍。适合正在折腾团队工程效能、想自建评审基础设施的开发者也适合对“工具如何嵌入研发流程”感兴趣的读者。1. 为什么自己写评审工具现有方案让人憋屈的地方1.1 团队评审最典型的三个困境先说第一个评审流于形式。GitHub 自带的人工评审流程本质上是“让人去点 Approve”没有约束力也没有筛选机制。大部分 Reviewer 会重点看自己关心的文件其它文件浏览一遍就点了通过。PR 越长review 质量越低这是人的注意力规律决定的。第二个规范散落在文档里没有变成执行逻辑。团队约定了“新增接口必须带兼容性说明”“禁止裸 print”“TODO 不许合入 master”但这些规范写在 Wiki 里靠人记靠人提醒执行效果全凭运气。第三个自动化工具要么太专、要么太重、要么是黑盒。ESLint、Checkstyle 这类工具只覆盖特定语言审查点集中在代码风格SonarQube 功能全面但部署、配置、规则治理的复杂度对中小团队来说是负担商业 AI Reviewer 直接读 diff 给建议能力强但我们是私有仓库为主代码外传这件事团队有顾虑而且它的问题在于“结论不可解释、规则不可定制”团队想去掉某类误报时能做的事情很有限。当时我整理了一下团队真正需要的是这么个东西能自托管、代码不出内网配置可版本化规则像写代码一样透明并且能对每一次评审结果给出可追溯的审计证据。市面上的工具没有完全对得上的。自己写一个成本反而可控。1.2 现成工具能力对比Open Code Review 的定位为了说服团队我做了一次直接的能力对比表格当时贴在内部 wiki 上维度GitHub 内置 ReviewSonarQubeESLint 等 linter商业 AI Reviewopen-code-review部署方式平台自带自建、重CI 内轻量SaaS自建、轻量审查触发人工定时/流水线流水线PR 事件Webhook / CLI行内评论人工部分支持不支持支持支持规则可编程不可需要插件体系语言相关有限任意语言插件数据是否出内网否否否出网否可审计性差强弱弱强接入成本零高中低低-中看完这个表就清楚了open-code-review 的核心价值不是某一个单点能力而是把“行内评论 任意可编程规则 内网自托管 结果可审计”组合在一起并且保持整体的轻量。它不做 SonarQube 那种全量代码扫描只盯变更所以速度可以做到很快它也不局限于某种语言因为规则是独立进程用任何语言都能写。1.3 设计目标规则即代码评审即流水线我从第一天就定了四条铁律后面所有功能都没敢越界。第一规则即代码。规则不是配置文件里的一组布尔开关而是一个个独立函数或独立服务。团队把评审标准写进代码仓库规则有测试、有版本、有 Code Review。这样治理“评审规则”本身也变成了工程实践。第二评审即流水线。从事件触发、变更收集、规则执行到结果回写全程是管道式处理每个步骤可观测、可重放。出问题时我能从事件 ID 一路追踪到某条评论是谁在什么版本产生的。第三结论可仲裁。机器给了阻塞结论人要有渠道推翻。项目里所有问题都带 fingerprint人工确认误报后可以加入 ignore 列表这些记录本身就是规则治理的数据基础。第四部署是一个进程。没有数据库集群、没有消息队列默认 SQLite 存储所有服务可以压缩成一个容器。复杂的部署方案不是能力是负担。2. 评审流水线的核心设计从 Webhook 到行内评论2.1 一条评审从触发到回写的完整链路整个系统可以理解为一个单向流水线数据按这种顺序流动平台事件触发比如 GitHub App 收到pull_request事件动作是opened或synchronize。服务端先验签确认请求来自平台而不是伪造请求。根据事件里的 Pull Request 信息调用平台 API 拿到完整文件列表和每个文件的 diff。对 diff 做结构化解析拆成“文件 变更块 行号”的索引结构。把这个结构交给规则引擎引擎按规则声明依次执行。所有规则的产出汇总成统一的问题列表Issue。对问题列表做去重、基线过滤、严重等级裁定。通过 Checks API 创建一个 Check Run结果一方面以 annotations 形式挂在对应行另一方面把需要人工讨论的问题转成普通 Review Comment。Check Run 结束时设置结论success或failure这就是后续质量门禁的依据。这条链路里最容易出问题的有两个位置diff 解析和行号换算以及问题去重。前者错了评论会挂到错误的行甚至错误的文件后者错了机器人自己就会变成噪声源。2.2 新旧行号换算所有评审工具最容易错的地方GitHub 的评论 API 接收的line参数必须是 PR 中“新文件”head 分支里的真实行号。但 diff 里每一段长这样 -10,6 12,8 export function handleEvent(...) const foo 1; const newLog buildLog(); if (!newLog.isValid()) { throw new Error(invalid log); } return newLog; -10,6 12,8 表示旧文件从第 10 行开始、共 6 行新文件从第 12 行开始、共 8 行。新增行以开头删除行以-开头上下文行以空格开头。换算到新文件行号的规则是这样的新文件的起始行号是这里的12然后从上到下计数遇到上下文行新行号加 1遇到行新行号加 1 且这一行就是新增行的目标行号遇到-行新行号不动。按这个规则上面例子里if (!newLog.isValid())对应的新行号是上下文const foo 1;→ 12上下文const newLog buildLog();→ 13新增行if (!newLog.isValid()) {→ 14新增行throw new Error(...)→ 15上下文return newLog;→ 16我在第一次实现时漏掉了删除行不计数这个规则结果只要 diff 里前面有删除行后面所有评论的行号全部偏移。反馈到界面上就是“评论挂在了错误的代码上行”信噪比断崖式下降。这一步值得单独写二十行测试用例含删除行、含多 hunk、文件末尾新增全都要覆盖。2.3 去重与版本管理如何避免每次 push 都刷屏一个 PR 从诞生到合入通常会 push 十几轮。如果机器人每次都对同一个问题发一条评论评论区的体验会急剧恶化。这个问题不解决工具就会被团队主动关掉。我的做法是给每个问题生成一个稳定的指纹sha256(规则ID 相对文件路径 新文件行号 消息正文指紋)。指纹稳定意味着无论 push 多少次只要同样的代码问题没有真正被修复指纹都不会变。系统里维护一张历史表同一次评审如果发现指纹已经存在就不再发新评论。已修复的问题怎么处理GitHub 本身会对旧评论打上 outdated 标记折叠到“已过时”区域。这个机制已经足够好我们需要做的只是保证新问题不发重复评论、已关停的问题不重新激活。还有一个细节用户重新打开一个 PR 时评审历史应该保留。所以指纹表的索引设计成(仓库ID, PR号, fingerprint)而不是全局唯一。这样同一个问题在不同 PR 里可以各发一次但在同一个 PR 的多次 push 之间不会刷屏。2.4 数据模型与存储评审报告怎么落库我默认用 SQLite 起步单机跑几万个仓库的评审记录也没压力。核心表结构按这种思路设计events记录每个 webhook 事件的事件 ID、类型、仓库、PR 号、处理状态、时间戳。这是审计链路的第一环。runs一次完整的评审运行记录对应的 event、触发方式、规则版本、开始/结束时间、最终结论。issues评审发现的问题包含规则 ID、文件、新旧行号、严重等级、消息内容、指纹。baselines基线表记录某个仓库在某个时间点之前存在的存量问题指纹。存量问题默认不阻塞合入只提示新增问题才进入质量门禁。这套模型不复杂但保证了每一个评审结论都可以回溯从一条评论反查到它属于哪次 run那次 run 用的规则版本是什么执行的规则源码改了没有。在团队对评审结论产生争议时这份审计数据比任何 PPT 都管用。3. 接入仓库的完整实操30 分钟内跑通第一次评审3.1 动手前先想清楚的三件事第一跑在哪。open-code-review 可以单二进制直接跑也可以用 Docker Compose 起整套。如果团队代码托管在云上建议直接把服务部署在生产环境相邻的容器平台如果是纯内网 GitLab服务就放在内网保证代码不出网。第二token 策略。这个经常有人踩坑。GitHub App 的安装令牌最长只有 1 小时而且要在代码里用私钥动态签发个人访问令牌虽然方便但权限边界不清晰我从不推荐。下面实操里以 GitHub App 为准。第三规则和仓库的关系。前几个仓库先不要急着挂一堆规则配置好默认规则、跑通链路再加语言相关的检查。一上来就追求大而全只会让“误报治理”变成劝退现场。3.2 服务端初始化与配置我用 Docker Compose 管理服务docker-compose.yml核心部分很简单version: 3.9 services: open-code-review: image: ghcr.io/yourorg/open-code-review:latest ports: - 8080:8080 environment: OCR_WEBHOOK_SECRET: your-webhook-secret OCR_GITHUB_APP_ID: 123456 OCR_GITHUB_APP_PRIVATE_KEY_PATH: /run/secrets/private-key.pem volumes: - ./data:/data - ./secrets:/run/secrets command: [serve, --listen, 0.0.0.0:8080, --store, /data/ocr.db]注意两点OCR_WEBHOOK_SECRET是平台 webhook 配置里填的 secret这是验签的关键私钥文件通过 volume 挂载不要打进镜像更不能提交到 Git。启动后访问/healthz能返回200 OK就说明服务活了。3.3 GitHub App 创建与权限配置在 GitHub 任意组织或账号下创建 GitHub App需要配置的地方逐项说Webhook URL填https://你的域名/webhook/github需要公网可达纯内网场景可以用内网 DNS。Webhook secret填一串随机字符串记住它服务端OCR_WEBHOOK_SECRET要填一样的。Permissions关键Pull requests: Read/WriteChecks: Read/WriteContents: Read。少一个权限评论或 Check Run 的回写就会 403。Subscribe to events勾选Pull request。如果后面要支持 push 后的增量检查还需要勾Push和Pull request review comment。Private keys生成私钥并下载 PEM 文件服务端靠它给 JWT 签名换 token。创建完成到仓库安装路径是 Settings → Applications → Configure或者组织级别的 Settings。3.4 仓库里的标准配置文件每个接入仓库的根目录放一个.open-code-review.yml我用的典型配置如下version: 1 rules: enabled: - no-todo-comments - no-secrets-in-diff - python-print-check disabled: [] custom: - path: ./.ocr-rules/py-api-check.py name: api-compat-check baseline: enabled: true start_date: 2025-01-01 severity: blocking: - critical - high non_blocking: - medium - lowbaseline启用后截止到start_date之前就存在的问题全部计入存量基线评审结论不会因历史债阻塞新需求新增问题则完全按规则执行。severity定义了哪些等级能拦住合入。3.5 从空 PR 到评审意见落地一次最小验证我推荐按这套流程做第一次端到端验证建一个测试仓库装好 App。提交初始代码故意在文件里放三个问题一个TODO注释、一行代码里的明文密码、一个print语句。推送代码并创建 Pull Request。等大约 10 秒打开 PR 页面你会看到三个结果机器人评论出现在对应代码行、Checks 区域出现一个名为open-code-review的 Check Run、结论为failed。如果评论没有出现优先查两个地方一是docker logs看事件有没有进来二是服务端日志里有没有验签失败或 token 相关的 403 报错。绝大多数第一次接入的失败都发生在鉴权环节。3.6 本地调试三板斧调试时不可能每次都真发一个 PR我平时靠三种方式mock 事件项目里提供ocr mock-pr子命令传入一个本地 diff 文件就能在本地完整跑一遍规则引擎产出 JSON 格式的问题列表。dry-run 模式服务端加--dry-run启动时所有回写行为只记录日志不调用 GitHub API。这样跑流程不会污染真实仓库。日志级别切到 debug能看到每个事件的解析耗时、每条规则的执行耗时、每个问题的指纹计算细节。这三个手段加起来可以从“PR 页面表现异常”直接定位到“是哪一步的输入不对”。4. 规则引擎与规则编写把团队规范真正落进自动化4.1 哪些规矩值得交给机器做得久了我总结出一个筛选标准确定性足够强、不需要上下文判断的问题才有资格做成规则。比如禁止事项TODO合入、密钥硬编码、禁止调用的废弃 API。一致性事项日志格式、异常包裹方式、命名约定。易错事项忘记判空、返回值未检查、事务未提交。反过来架构层面的合理性、模块边界是否清晰、方案选型是否合适这些交给人类评审员。规则引擎做得再好也不可能替代“带有人味的设计讨论”。4.2 规则的结构与执行生命周期我设计的规则核心是一个函数输入是评审上下文输出是问题列表。用 Python 写一个最简单规则长这样import re def check(ctx): issues [] for file in ctx.changed_files: if not file.path.endswith(.py): continue for line_number, line_text in file.new_lines: if re.search(r\bprint\s*\(.\), line_text): issues.append(ctx.make_issue( rule_idpython-print-check, severitymedium, filefile.path, lineline_number, message生产代码里禁止使用裸 print请改为 logging, )) return issues规则生命周期就是四个阶段apply 阶段引擎根据规则声明的glob或语言标签决定是否在本文件执行。check 阶段函数体真正执行的阶段拿到的是结构化数据不直接操作 git 命令。aggregate 阶段跨文件聚合比如“本次改动引入了 3 个高危问题”可以在这一步生成汇总结论。post_publish 阶段对发布前的内容做二次处理比如把某些 mirror 的规则翻译成团队术语。这样一个简单的函数式接口任何语言都能实现共识协议只要正确输出 JSON。这个“规则即外部进程”的协议让团队可以用自己熟悉的语言写检查器这是可扩展的关键。4.3 三种规则粒度怎么选根据检查目标规则要落在不同粒度上我一般分三层文件级只关心“这个文件里出现了什么”比如禁止goto、密钥格式检测。这类规则实现最简单。变更级关心“这次开发新增了什么”比如“新增的 Python 文件必须带 license 头”。它依赖 diff 信息不依赖 AST。语义级需要真正解析代码比如“函数新增了参数所有调用方都要更新”。这类通常要接 AST 或 LSP。接入顺序建议从文件和变更级开始。语义级的规则虽然价值高但误报风险也高对团队信任是考验。先把简单规则跑稳再逐步上难度是这类项目能持续用的核心经验。4.4 误报治理比规则本身更重要的工程误报的伤害不在于“多一条评论”而在于团队养成了无视机器人评论的习惯。一旦习惯了忽略真正重要的提醒也会一起被忽略。我采用的治理手段按优先级来分级所有问题分 critical / high / medium / low。只有前两级能阻塞合入medium 和 low 只展示不阻塞。降级比删除规则更能保留信息量。忽略指令允许在消息中附带ignore: rule_id或者文件中写ocr-ignore-next-line。人工仲裁过的忽略会进入 ignore 表。上下文白名单比如“在 test 目录下运行 print 是允许的”这通过配置里的glob_overrides实现。基线存量问题只计数不阻塞保障团队不会被历史债一次性击穿。4.5 评分与质量门禁的计算方式开放代码评审项目最终需要一个“结论”来驱动自动化的合并策略。我用的评分模型很简单score sum(weight(severity) * count(new_issues_by_severity))权重表我默认设置为critical 10high 5medium 2low 0.5。再加一个threshold概念阈值是浮点值score threshold则 Check Run 成功否则失败。默认阈值是 2也就是一个高优先级问题就会卡住合入。这个模型的好处是直白、可调。团队嫌太严就把high权重改低嫌太松就把阈值降成 0。规则治理说白了就是调权重和阈值的过程只有数据透明吵架才有依据。5. 质量门禁接入 CI让评审结果真正拦住合并5.1 机器评审结果凭什么拦住合入如果评审结果只停留在“建议”那自动化就白做了。质量门禁本质上是把“机器结论”接进现有分支保护规则里让不符合要求的代码在合入前被拦下。GitHub 的实现路径是 Branch Protection 规则里面有一项叫 “Require status checks to pass before merging”把open-code-review加进必过的 check 列表即可。这个 check 只有在问题全部处理修复或人工忽略后才会转绿其他人看到的状态是可信的。5.2 GitHub Actions 里的轻量接入方式如果不想单独维护服务端也可以不跑常驻服务直接用 GitHub Actions 调度。仓库里.github/workflows/review.yml这样写name: open-code-review on: pull_request: types: [opened, synchronize] jobs: review: runs-on: ubuntu-latest permissions: pull-requests: write checks: write contents: read steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - name: Run open-code-review uses: yourorg/open-code-review-actionv1 env: OCR_COMMENT_MODE: review OCR_STRICT: truefetch-depth: 0这行很重要。要让规则引擎感知到变更行号必须拿到完整 git 历史默认的单分支浅克隆会让 diff 计算完全失效。这也是初学者最容易忽略的点。Access token 由 GitHub Actions 自动注入走GITHUB_TOKEN即可不需要手动配 secrets。5.3 Check Run 状态循环与交互细节实现 Check Run 时有几个值得记下的细节创建 Check Run 时status是in_progress写完结论后一定要更新为completed。conclusion可选success或failure只要repository开放了require branches to be up to date失败的 check 在 UI 上会被红色拦截。Check Run 支持annotations每条 annotation 能精确到某文件的某个行号。这是“行内提示”最轻量的实现不需要发评论。发评论和发 annotation 的区别在于annotation 粘在 Check 里不进入评论区不打扰人Review Comment 会进评论区适合需要讨论的问题。我的策略是阻塞类问题同时发 annotation 和评论非阻塞只发 annotation。5.4 机器门禁和人工评审的分工必须说清楚质量门禁不是替代评审人而是保证低等级错误不消耗人的注意力。出现critical或high问题时机器直接拦下指出问题位置和规则依据人工只需要看这一条意见并决策没有任何机器意见时评审人可以把全部精力放在架构和设计讨论上。长此以往团队对“机器人说什么需要看、说什么不用看”会形成肌肉记忆。这才是引入自动化评审工具的最大收益——省下来的注意力都被投入到了真正有价值的设计讨论里。6. 实战排错与经验沉淀这一路填平的坑6.1 权限与 403排查链路示例现象Check Run 创建成功但评论 API 返回 403。我当时的排查链路是这么走的先看日志确认 403 报错来自/repos/{owner}/{repo}/pulls/{pull_number}/comments这个接口。检查 App 权限配置。发现Pull requests权限被设成了Read而写评论需要Read/Write。把权限改为Read/Write重新安装 App 使配置生效问题解决。这种问题往往发生在用户改了权限之后忘了重新安装。GitHub App 的权限变更不会自动热更新必须在安装页面点一次“Update”。6.2 Webhook 验签失败现象docker logs里全是signature verification failed。排查时发现我在服务端用同一个 secret 验签但 GitHub 发过来的X-Hub-Signature-256始终不一致。最后核对配置发现 webhook 配置页面保存的 secret 和OCR_WEBHOOK_SECRET差了一个换行符——配置是从环境变量文件复制过去的文件末尾多了个\n。这类低级错误很常见。验签必须保证两端字符串完全一致最稳妥的做法是用echo -n your-secret生成配置避免 shell 默认追加换行。GitHub Webhook 使用 HMAC-SHA256 计算签名sha256_hex(secret, request_body)得到的值必须和 header 里的十六进制字符串完全相等。6.3 大仓库评审超时与并发控制超过 2000 个变更文件的 PR串行执行规则会跑到 10 分钟以上GitHub 的 webhook 超时和 Check Run 更新时间窗口都不允许。我的优化分了三步规则执行并发化默认开 8 个 worker。代价是单个问题列表的顺序不稳定但问题列表最终会按文件路径排序因此不会影响用户体验。对 diff 超过 50MB 的 PR直接降级为“只跑文件级规则”并显式输出一行摘要说明。引入 result cache同一仓库同一 commit 的结果直接复用避免同步事件导致重复计算。6.4 评论行号漂移问题这是我在 2.2 节重点讲过的坑这里补充一个真实案例有个规则在文件头部新增了几行 license 注释后后续所有评论的行号全部向前偏移了几行。原因就是规则实现里没有正确处理“删除行不参与新行号计数”的逻辑。修复后我加了一类专门测“删除行场景”的回归测试。这个问题的排查技巧是在 mock PR 模式下打开问题列表看有没有一个容易被人忽视的new_line字段手动和 diff 里的 hunk 头换算一遍通常立刻能找到原因。6.5 从一次线上故障看系统设计的可取之处有一次生产环境的一家仓库突然发来了海量旧 PR 的同步事件系统在很短时间内被事件风暴打满。当时唯一的备份是日志里的 trace ID靠它逐个还原了事件模型。这让我意识到事件驱动系统里最值钱的就是 trace。从那以后我规定所有日志的关键路径必须带event_id和run_id错误处理逻辑必须有“失败后可重放”的保障。open-code-review 后来的所有 webhook 消费者都是幂等的同一个事件即使被重复推送两遍最终只产生一次有效评审。最后再分享一个小技巧我在团队里维护 open-code-review 这一年多最大的收获不是代码本身而是“规则治理”慢慢变成了一种团队协作方式。有一个经验值得单独拿出来说给每个规则保留一份历史统计数据。规则上线三个月后你一定会面临“这个规则是不是太严了”的讨论。到那时候把统计拉出来——“这条规则一共拦下 47 个问题其中 9 个是误报被人工忽略剩余 38 个里有 12 次被别人确认为规范不明确的代码” ——比任何口头争论都有说服力。量化结果摆在那里规则该放宽还是收紧答案不言自明。工具本身不会让代码变好它只是让团队能更专注地讨论真正重要的事。如果这套思路对你有启发拿一个仓库试跑从两条规则开始让数据慢慢说话。