1. 为什么我要把 code review 做成一个“开放项目”
代码评审这件事,说大不大,说小不小。团队里真正把 review 做好的,十家里未必有两家。我见过太多团队把 review 挂在嘴边,实际执行起来要么流于形式,要么变成挑刺大会,要么干脆“LGTM”走人。这个问题不是工具的问题,是流程设计的问题。
我做的这个open-code-review,初衷很简单:把代码评审从“几个人关起门来看 diff”变成一套开放的、可沉淀、可复用的工程实践。所谓“开放”,有三层意思:一是流程开放,任何人可以参与评审;二是工具开放,选型尽量用开源、可自托管的方案;三是结果开放,评审结论和讨论记录能留存、能检索、能形成团队知识库。
这篇文章就把我的完整设计思路、工具选型、落地过程和踩坑记录整理出来。代码评审是通用问题,无论你用的是 GitHub、GitLab 还是 Gitea,是三五人小团队还是几十人中型团队,这套思路都能直接套用。
2. 整体设计与核心思路拆解
2.1 先把“代码评审”拆出三个可执行的动作
一个有效的 code review 流程,拆到底就是三件事:自动检查、人机协作、结论跟踪。
自动检查解决的是“低级问题别来烦人”的问题。格式、静态检查、重复代码、明显的 bug pattern,机器做得比人好,而且永远不会累。我见过不少团队,reviewer 把大量时间花在“这里缺个空格”“这个变量名不好”上,真正该想的架构问题反而没人聊。自动检查跑在前面,就是把人的注意力解放出来。
人机协作解决的是“机器覆盖不了的问题”。业务逻辑是否正确、接口设计是否合理、有没有更好的实现路径、对现有代码有没有潜在影响——这些事需要人来看。但如果完全没有辅助,reviewer 面对几百行 diff,很容易看漏。这里我引入了一部分 AI 辅助分析,让机器先做一轮“重点关注区域”标注,把高风险代码块和高复杂度函数挑出来,人再带着问题去 review,效率比盲看高一截。
结论跟踪解决的是“评审说了等于没说”的问题。评审意见提出来,是改了还是没改?改得对不对?是不是引入了新问题?如果这些没有跟踪,不少意见就会被“已解决”三个字敷衍掉。我在流程里强制要求每条评论必须关联 issue 或任务,合并前由机器人检查是否所有阻塞项都已关闭。
2.2 选型思路:为什么选 Gitea + 自定义机器人,而不是直接上商业工具
工具选型上,我对比过几个方案。GitHub 自带 pull request review 功能很完善,但对中小团队来说,私有仓库要付费,而且服务器在海外,速度不稳定。GitLab 功能最全,从 issue 到 CI 到 review 一条龙,但资源占用也最重,小服务器跑起来费劲。商业方案比如 Some 平台的收费模式又不太适合小团队。
我最后选了 Gitea 作为代码托管和评审载体,搭配一套自写的机器人脚本做流程控制。Gitea 轻量、部署快、资源占用小,一台 2 核 4G 的服务器就能跑得动,支持 pull request 和 review 评论,有 Webhook 可以对接外部服务,这就够了。真正重要的流程控制逻辑,我全部放在机器人脚本里,这样不依赖特定平台,以后换平台只需要改 Webhook 接入层,核心逻辑不用动。
2.3 评审规则设计:用规则来定义“什么样的代码不能合入”
没有评判标准的 code review 一定混乱。张三认为这个命名不行,李四觉得无所谓,最终结果全看谁嗓门大。所以我在项目里先把“硬性规则”沉底,用程序表达出来。
我定了几条硬指标,任何一条不满足,合并请求直接阻塞:
- 测试覆盖率不低于 80%,新增代码覆盖必须覆盖核心分支;
- 所有评审评论状态必须为 resolved 或 approved;
- 静态检查零 error 级别问题;
- 更新后的代码必须重新跑过 CI 并通过。
这些规则放在一个review_rules.yml配置文件中,机器人每次触发 Webhook 时读取规则,逐条检查,然后以评论形式把结果发到 PR 下方。这样做有个好处:规则是透明的、可追溯的。任何一条被阻塞,开发者在 PR 界面就能看到具体是哪条规则没通过,不需要跑去问管理员。
3. 核心细节解析与实操要点
3.1 自动检查流程:三段式流水线
自动检查我分三段跑:提交前、PR 触发时、合并前。
提交前检查挂在 Git 的 pre-commit hook 上,做最基础的格式和 lint 检查。这一层不求全,只求快,让开发者提交代码时立刻反馈。
PR 触发时的检查是主力,由 Webhook 驱动,流程是这样跑的:
# webhook 收到 pull_request 事件后 # 1. 拉取代码 git fetch origin pull/$PR_NUMBER/head:review/$PR_NUMBER # 2. 运行静态分析 docker run --rm -v $(pwd):/workspace review-tool:latest \ --severity=error --format=sarif # 3. 计算覆盖率增量 python scripts/coverage_diff.py \ --base origin/main --head review/$PR_NUMBER \ --report coverage.xml --output coverage_diff.json # 4. 运行自定义规则检查 python scripts/check_rules.py \ --config review_rules.yml --pr $PR_NUMBER每步产生结构化输出,最后汇成一份 JSON 报告发给机器人,由机器人统一渲染成 Markdown 评论贴在 PR 上。这样做的好处是原始数据不会丢,后续如果要接别的分析工具,只要复用 JSON 报告就行。
合并前检查是最后一道闸门,它在 pull_request review 提交时触发,确认所有评审评论都已解决、规则检查通过、目标分支没有新的冲突。如果都满足,自动打上ready-to-merge标签,否则保持blocked。
3.2 AI 辅助评审:让机器先看一遍,人再带着问题看
纯粹靠人工 review 大规模 diff,漏查率其实不低。我调研了一下,业内普遍认为人看两百行以上的 diff 时,注意力曲线会明显下滑。为了解决这个问题,我在流程里接入了一个本地部署的代码分析模型,专门做三件事:变更热点识别、风险函数标注、评审建议生成。
变更热点识别是把 diff 按文件、按函数拆开,计算每个函数的圈复杂度变化和改动量。改动量不大但复杂度飙高的函数,往往是重构隐患,值得 reviewer 重点看。风险函数标注是在已有历史缺陷数据的基础上,把与历史缺陷位置相邻的改动区域标出来,提示“这里以前出过事,这次改的时候小心”。
模型返回的结果会和静态检查结果合并到同一个报告中。但我刻意没做“AI 自动批准”的功能——AI 只负责提示,不负责决策。理由很简单:模型会出错,而评审的最终责任在人。让 AI 辅助提示、人做决策,既提高了效率,又不把责任推给机器。
3.3 规则引擎:把“感觉”变成代码
团队里最常见的评审争论,都源于“感觉”。我觉得这个函数太长、我觉得这个命名不合适、我觉得这里应该拆成两个函数。这些“感觉”没有不好,但不可执行。
我在open-code-review里设计了一套轻量规则引擎,把能量化的“感觉”全部变成代码。函数太长?限制 80 行。圈复杂度太高?限制 15。变量命名没意义?用正则检查禁用词。测试覆盖率不够?按包单独设置阈值。
规则引擎的配置长这样:
rules: - name: function_length check: slash-metrics params: max_lines: 80 level: error message: "函数体不能超过80行,当前XX行" - name: complexity_limit check: slash-metrics params: max_complexity: 15 level: warning message: "圈复杂度不能超过15,当前XX" - name: no_magic_number check: regex-pattern params: regex: "(?<![A-Za-z_])\\d{4,}(?![A-Za-z_])" exclude_files: ["test/", "docs/"] level: warning message: "检测到疑似魔法数字,建议提取为常量"规则文件本身就是评审标准的沉淀。新人来了不用学习“我们团队的惯例”,直接看配置文件就知道什么能过、什么不能过。这比任何 wiki 文档都直观,因为它是真实执行的代码。
4. 实操过程与核心环节实现
4.1 环境准备:一次能跑通的最小部署
先说部署环境。我用了一台 2C4G 的云服务器,系统装的 Ubuntu 22.04,Docker 和 Docker Compose 提前装好。整体架构分三个容器:Gitea 本体、Webhook 接收器、机器人执行器。三个服务用 compose 编排,数据目录挂载到宿主机持久化。
核心的 compose 配置大概是这样:
services: gitea: image: gitea/gitea:latest ports: - "3000:3000" volumes: - ./data/gitea:/data environment: - GITEA__DATABASE__DB_TYPE=sqlite3 - GITEA__SERVICE__ENABLE_REGISTRATION=false restart: unless-stopped webhook-receiver: build: ./webhook-receiver environment: - BIND_ADDRESS=:8080 - EXECUTOR_ADDRESS=http://executor:9090 depends_on: - executor restart: unless-stopped executor: build: ./executor environment: - TOKEN_FOR_GITEA=${GITEA_API_TOKEN} restart: unless-stoppedGitea 本身配置不多,sqlite 数据库对中小团队完全够用,不需要单独跑 MySQL。Webhook 接收器我写的是一个不到 200 行的 Python 服务,只做一件事:验证签名、解析事件、分发任务到 executor。executor 才是真正干活的——拉代码、跑检查、回传结果。
4.2 从零到一:接入一个 PR 的完整流程
部署完成之后,第一次真正接入一个 PR,流程跑起来是这样的:
第一步,开发者在 Gitea 上发起 pull request。Gitea 自动触发pull_request事件,Webhook 接收器拿到事件后先验证签名,确认来源是 Gitea 本身。
第二步,executor 收到任务,开始跑检查。第一步是拉代码和执行静态分析,这个过程大概需要一两分钟。检查跑完后,生成一份含所有 error 和 warning 的报告。
第三步,机器人把报告贴到 PR 下面。如果只看自动检查的结果,会看到类似这样的评论:
## 自动检查结果 ### 静态分析 - [x] 无 error 级别问题 - [ ] warning: `user_service.py:42` 圈复杂度 18,超出阈值 15 ### 测试覆盖率 - 总覆盖率: 82.3% - 新增代码覆盖率: 74.1% (需 ≥ 80%) > 合并请求已阻塞。 > 未满足:新增代码覆盖率低于阈值。第四步,开发者看到阻塞原因,补测试或者调整代码,重新推送到 PR 分支,检查自动重跑。通过后,机器人更新状态,把阻塞标记解除。
第五步,有权限的 reviewer 进行人工评审,留下评论。所有评论必须要么被回应,要么被解决,合并前机器人会再检查一遍。
4.3 自定义规则的接入与调优
接入一条新规则的过程,其实就是在配置里加一段标准结构。但调优的过程值得多说两句,因为规则定得不好会适得其反。
我一开始定的函数长度限制是 300 行,结果发现这个阈值太宽松,完全拦不住问题代码。后来改成 80 行,又太严,老代码大量触发。最后想了个办法:规则分两种级别跑,error 用于必须强制执行的,warning 用于指导性和渐进性的。
新规则先以 warning 级别上线,跑一周观察触发率。如果触发率过高(比如超过 50% 的 PR 都会触发),说明阈值定得不合理,需要调整或者这段代码有其合理性,在配置里加白名单即可。如果一周都没触发,说明这条规则可能太宽松或者覆盖率太低。
这种渐进式上线的思路,比一次定死要灵活得多,实际问题实际排查,规则不会成为团队负担。
5. 常见问题与排查技巧实录
5.1 Webhook 收不到事件是怎么回事
这是接入过程中出现频率最高的问题。现象是 PR 创建了,机器人那边毫无反应,日志里什么也没有。
排查路径我按住顺序走:
第一步,检查 Gitea 的 Webhook 配置页面,看最近的投递记录。如果显示投递成功但接收方没有反应,问题大概率在接收方的接口签名验证上。
第二步,看 webhook-receiver 的日志。如果日志里出现signature verification failed,那就核对一下 Gitea 端的 secret 是否跟配置一致。Gitea 的 Webhook 签名是 HMAC-SHA256,但注意它不会发送原始的 X-Gitea-Signature 头里的原始签名,而是整个 payload 的摘要,代码实现时要注意按照官方文档的参数来做哈希。
第三步,如果签名没问题,看是否被防火墙挡了。很多云服务器默认只对外开放 80/443,其他端口外部访问不通。需要确认 webhook 接收器能否向外网提供服务,或者用 Nginx 反代到域名。
5.2 问题一:机器人反复提醒同一个问题,明明已经改了
这个坑说起来也简单:机器人拉代码时用了缓存,没有正确切到最新 commit。注册 executor 拉取代码时,我把git fetch写成了git pull,在某些情况下会停留在旧分支上。
解决方法是拉取时强制更新到目标分支的最新 commit:
git fetch origin "pull/$PR_NUMBER/head:review/$PR_NUMBER" --force git checkout "review/$PR_NUMBER" git reset --hard "origin/$PR_NUMBER"这个问题的隐蔽性在于,偶尔能跑对,偶尔跑不对。不是每次都错,排查起来就费劲。我的建议是在 executor 启动时把 Git 本地仓库完整删掉重建,虽然慢一点,但能保证每次都从干净状态开始。实测下来,宁可多花几秒拉取,也不要踩缓存这种不确定的坑。
5.3 问题二:AI 评审建议质量波动大,怎么限制
接入 AI 评审后,头两周效果还行,但后面出现了不少质量低下、重复的建议,反复评论同一个位置,或者建议不切实际,反而干扰核心评审。
我的解决办法是加了两层限制:
第一层是位置去重。同一文件的同一行,如果先后有多个分析工具都产生了建议,就只保留置信度最高的那一条,其他丢弃。
第二层是阈值控制。模型输出的每条建议都附带一个置信度分,低于 0.7 的直接过滤掉。置信度分不是模型凭空生成的,是我用一批人工标注的“有效/无效建议”数据训练出来的分类器来计算的,效果比直接用模型自带的分数稳定一些。
这两层过滤加了以后,建议数量少了一半左右,但剩下的基本都是值得看的。
5.4 问题三:新增代码覆盖率怎么才叫“算得准”
覆盖率计算在这里也是一个容易踩坑的点。很多团队用全局覆盖率,这样新增代码即使没有任何覆盖,全局覆盖率也可能因为存量代码很高而被拉高。
我实现的逻辑是:先跑coverage.py的--fail-under=0模式生成完整的覆盖率 XML,然后用 diff 文件里变更的行号去匹配覆盖率数据,计算“新增代码中实际被覆盖的行数 / 新增代码总行数”。
核心逻辑大致这样处理:
import coverage from coverage.xmlreport import XmlReporter # 读取 diff 行号 changed_lines = parse_diff("changes.diff") # 读取覆盖率 XML cx = coverage.CoverageData().read_file("coverage.xml") analyzed_lines = set() covered_lines = set() for filename in cx.measured_files(): for line, count in cx.lines(filename).items(): if line in changed_lines.get(filename, set()): analyzed_lines.add((filename, line)) if count > 0: covered_lines.add((filename, line)) ratio = len(covered_lines) / len(analyzed_lines) if analyzed_lines else 1.0这里有个细节:coverage.py 计算行号时,和 Git diff 里的行号可能因为空行、装饰器、多行字符串的换算差异而错位。如果发现覆盖率数字忽高忽低,先检查是不是行号对齐的问题。我自己写过一版按函数 map 对齐的方法,比较稳定,可以定期手动校正一次也可以接受。
5.5 常见问题速查表
| 症状 | 可能原因 | 快速处理 |
|---|---|---|
| Webhook 一直不触发 | 签名验证失败或网络不通 | 先看日志,确认验证头,再检查端口 |
| 机器人评论重复 | 代码没有更新到最新 commit | 删除本地仓库重建,强制 fetch |
| AI 建议质量低 | 没有去重和阈值过滤 | 加位置去重,用分过滤 |
| 覆盖率忽高忽低 | 行号映射错位 | 改用函数级别对齐计算 |
| PR 合并后被再次打开 | 分支保护规则和合并检查冲突 | 观察分支保护配置中 merge 条件的位置 |
6. 经验总结与演进方向
我跑了open-code-review小半年,体会最深的不是自动化流程省了多少时间,而是流程变“透明”了。以前代码评审靠人盯,盯多盯少全看自觉;现在规则摆在明面上,哪些必须过、哪些建议参考,机器会主动告诉你。新人也更容易上手,不用老员工一个个口传身教。
还有一个小经验想分享:规则别一次加太多。我一开始同时在配置里面挂了 12 条自定义规则,结果第一个月全团队都在忙着应付规则,真正有深度的设计讨论反而变少了。后来砍到只留 6 条不可商量的硬规则和 3 条渐进式 warning,讨论氛围才恢复正常。工具应该服务人,而不是统治人,这句话放在 code review 流程里尤其适用。
后续我打算再加两个扩展:一个是把评审意见按模块聚类,沉淀出团队的高频问题列表,每个月自动生成一份“代码健康报告”;另一个是接入更多编程语言的静态检查器。目前对内主力是 Python 和 Go,但团队现在也有 Java 服务在迁移,支持面得逐步扩大。这套流程本身是模块化的,加新语言主要工作量在写新的 check 适配器上,规则引擎不用动。