1. 为什么一个团队最终会想自建 Code Review 工具
1.1 从Code Review到"Code Review流水线",我们经历了什么
先交代背景。我们是一个15人左右的研发团队,维护着4个核心业务仓库,每个仓库平均每天有8到12个Pull Request需要过审。在很长一段时间里,Code Review完全依赖人肉:谁有空谁看,谁写的代码谁自己盯。结果就是周三下午的PR堆积成山,Review意见五花八门,有的评论到下周都没人回应。
后来我们试过商业代码评审托管平台,也试过各种开源的机器人、Lint增强工具,比如直接把lint结果推到PR评论里的那些方案。坦白讲,这类工具解决了一部分"低级问题自动化"的需求,但用起来总有种隔靴搔痒的感觉。商业SaaS服务对仓库代码权限要求很高,内网部署的成本也不低,而且规则引擎基本被厂商锁死,想定制一条贴合我们团队规范的检查项,往往要等版本更新或者绕很大的弯。
真正让我们下决心自研的导火索是一次事故。有个同事把环境变量的key直接硬编码到了公共工具类里,Lint规则没覆盖到,人工Review也没人注意到,结果配置项泄漏到了前端产物中。这件事让我意识到:人工评审的注意力是稀缺资源,不该浪费在"变量名拼写""空指针防御"这种静态可判定的问题上,而应该集中在架构、数据流和业务语义上。于是就有了open-code-review这个项目。
1.2 我们自己定义的三个核心原则
项目启动之前,我们内部连续开了三次讨论会,最终把目标收敛成了三条原则。后续所有功能和取舍都围绕这三条展开。
第一,规则必须可插拔、可开源。我们不想再造一个封闭的审查系统。每一条检查逻辑都应当是一个独立插件,既能开箱即用,也能让任何团队按自己的规范去改,改完还能回馈给社区。
第二,优先做"确定性检查",LLM辅助评论只能做补充。我们承认大模型在理解代码语义上的能力,但它会幻觉,会给出无根据的建议。如果让大模型主导评审,团队很快会陷入"AI说应该这样改"的无效争论。我们坚持静态分析、复杂度计算、变更范围检查这类可验证的结果优先,LLM只负责给出基于 diff 上下文的"观察性评论",并且明确在评论中标注置信度。
第三,所有结果必须可追溯。每条评论都要能说清楚出自哪条规则、哪个配置项、哪个版本。这样任何一条误报,团队都能直接定位到规则本身,而不是靠运气去猜。
这个项目从立项到第一个可用版本,我们用了大概三周。整体代码量并不大,核心引擎加上默认插件,大概3500行Python。它没有漂亮的管理后台,也没有复杂的Web界面,它解决的是最实际的问题:每次提交代码时,自动把值得关注的diff点讲给评审人听,而不是直接指挥评审人改代码。
2. 核心架构:把"审阅动作"拆成可插拔的流水线
2.1 事件驱动的基础结构
早期我们想得很简单:监听Webhook推送,拿到PR的diff,跑一遍规则,然后把结果写回评论。但真正动手设计时发现,"拿到diff"只是开始。一次完整的代码审阅,至少包含变更内容获取、变更解析、规则匹配、结果聚合、评论推送这几个环节。如果把这些逻辑全部塞进一个函数里,那后续每加一条规则都要动主流程,维护成本会迅速失控。
所以我做第一版原型的时候,就把整个处理过程拆成了一条流水线:collect -> parse -> analyze -> aggregate -> report。每个环节之间用数据对象传递上下文,互不感知内部实现。比如collect环节只负责从Git托管平台拉取完整的diff数据,把它统一转换成内部定义的ChangeSet对象;parse环节再根据语言类型把代码块拆成语法树;之后的analyze环节不用关心diff是从哪里来的,只要处理结构化数据就行。
这个抽象带来的直接好处是:我们后来要支持GitLab和Gitea的时候,只需要新增一个collect适配器,后面四个环节一行代码都没改。如果你也在做类似的工具,建议第一版就坚持这个方向,千万不要图省事把远程API调用直接写在规则里。
2.2 流水线五阶段的职责划分
每个阶段承担的任务是严格分离的,我用一张表来说明各阶段的核心职责和产出物。
| 阶段 | 输入 | 核心职责 | 输出 |
|---|---|---|---|
| collect | Webhook事件 / CLI参数 | 对接Git平台API或本地命令,获取变更集元信息与完整diff | ChangeSet |
| parse | ChangeSet | 按文件后缀命中对应解析器,构建AST或结构化的行号映射 | ParsedFile集合 |
| analyze | ParsedFile集合 | 按注册的ReviewRule逐一运行,产出候选问题 | ReviewFinding列表 |
| aggregate | ReviewFinding列表 | 去重、按文件与行号排序、过滤噪音、计算置信度 | AggregatedResult |
| report | AggregatedResult | 生成评论内容、推送至平台、记录审计日志 | ReportReceipt |
你可能注意到了,我把"聚合"单独抽成了一个阶段。这是被实际教训逼出来的。一开始analyze阶段直接把结果推给report,结果发现多个规则可能命中同一行代码,比如一行超长且复杂度过高的代码,会被可读性规则和圈复杂度规则同时报告,PR里就会出现两条内容高度重叠的评论,非常吵。聚合阶段做的事就是合并同类项:同文件同行号的finding,会合并成一条综合评论,并列出所有命中的规则ID。
2.3 插件协议:一天学会的ReviewRule接口
插件协议是整个项目的地基。我的目标是让团队里任何一名工程师,花十分钟看完示例插件,就能写出自己的规则。接口设计得非常简单:
@dataclass class ReviewFinding: file_path: str line: int severity: str # error / warning / info rule_id: str message: str suggestion: str = "" code_snippet: str = "" class ReviewRule(ABC): rule_id: str = "" description: str = "" severity: str = "info" @abstractmethod def check(self, changeset: ChangeSet, parsed_files: list[ParsedFile]) -> list[ReviewFinding]: pass一条规则就是一个类,实现check方法即可。规则只面向数据和上下文,不需要关心评论是怎么发出去的。我们团队有个后端同事,之前完全没接触过这类工具,照着示例插件半小时就写了一个"禁止新增print调试代码"的规则并成功跑通了全链路。
这里有个设计细节值得分享:Changeset里除了每个文件的diff,还会保留本次PR新增/删除/修改的文件完整路径列表。因为很多规则不需要解析语法树,只需要看路径和数据特征。比如"禁止在migration目录新增文件时忘记修改版本记录"这种团队规范,用路径匹配就能实现,完全不需要解析代码。把这类规则的门槛压低,能显著提高团队参与度。
3. 内置检查器的设计取舍与实现要点
3.1 静态问题:从AST提取上下文,而不是堆正则
第一类内置检查器处理的是传统的静态问题:未使用的导入、不安全的字符串格式化、明显的资源泄漏隐患、可疑的宽泛异常捕获等。这类问题Lint工具也覆盖了一部分,但我们的定位不是替代Lint工具,而是做"Lint工具不做的那部分"。
举一个具体的例子。Python的except Exception在很多Lint工具里默认是放行的,但它经常掩盖真实的编码错误。我们要识别的不是这种语句本身,而是"在明知循环体内部会抛出特定异常的情况下,仍用broad except包住并继续循环"的模式。这种模式用正则很难稳定匹配,必须解析AST,拿到try块内的子节点集合、except类型列表、循环上下文,然后做组合判断。
实现上我们基于ast模块,对每个函数体构建作用域链,标记变量定义和使用位置。AST方案比正则的体积大不少,但胜在可控:所有能判定的结论都是精确的,不存在"撞运气式"的命中。
class BroadExceptRule(ReviewRule): rule_id = "py-broad-except-in-loop" severity = "warning" def check(self, changeset, parsed_files): findings = [] for pf in parsed_files: if not pf.language == "python": continue tree = pf.ast_tree for node in ast.walk(tree): if not isinstance(node, (ast.For, ast.While)): continue for sub in ast.walk(node): if isinstance(sub, ast.Try): for handler in sub.handlers: if handler.type is None: findings.append(self._finding( pf.path, handler, "循环体内捕获了所有异常,容易掩盖潜在错误。请改为捕获明确异常类型。" )) return findings这段代码很直白,但我用了两个小技巧:第一,只解析本次PR变更涉及的文件,不做全仓库扫描,性能上有保障;第二,定位到行号之后,评论里会附上原始的异常处理代码片段,这样评审人不用跳转文件就能理解上下文。
3.2 变更风险评估:让评审人先看最危险的地方
第二类检查器解决的问题很现实:PR里面有20个文件,到底从哪看起?人工评审最怕的就是把注意力平均分配到所有文件上,结果在无伤大雅的工具类改动上花了20分钟,真正有风险的公共模块改动却草草略过。
我们实现了一个变更风险评估器,输入是本次PR的所有变更文件,输出是一个风险排序。核心算法不复杂,主要考虑三个维度:修改文件的影响范围、修改行的新增代码量、代码自身复杂度。影响范围通过依赖关系分析来计算:一个工具函数被多少个模块引用,它的改动就会牵连多少个潜在调用点。这部分我们直接调用现有依赖分析库,没有重复造轮子。
风险排序输出会以摘要表的形式出现在PR第一条评论里:
| 风险级别 | 文件 | 建议优先评审 |
|---|---|---|
| 高 | core/auth/token_manager.py | 是 |
| 中 | api/v1/order_handler.py | 是 |
| 低 | utils/date_parser.py | 否 |
我自己的使用体会是,这个功能比很多花哨的自动化检查都管用。因为它不是直接告诉作者"你写得不对",而是帮助评审人更高效地分配自己的注意力。评审人还是拥有最终判断权,但聚焦准确度提高了很多。
3.3 测试变更匹配:盯住"改了一堆实现却没改测试"的情况
第三类检查器是纯规则层面的,专门处理测试与实现的匹配关系。我们定义了一条硬性规则:任何对src/目录下业务逻辑的修改,如果同一个PR中没有对应tests/目录下的测试变更,则产生一条warning级别的finding。这条规则虽然简单,但效果出奇得好。
实现思路是用文件路径做映射。每个业务文件,我们都维护对应测试路径的候选集合。比如src/services/order.py对应的测试可能是tests/services/test_order.py,也可能是tests/unit/test_order_service.py。我们在配置里允许团队维护一份路径映射表,同时提供默认启发式规则:把src/替换为tests/,把文件名改为test_前缀。命中任意一个候选就认为该文件有测试覆盖了。
刚开始我以为这条规则会被团队抵触,毕竟它剥夺了"我先提交代码再补测试"的自由。但实际落地后,大家的反馈是:它减少了一大类"忘了补测试"的低级失误。因为人工Review的时候,很少有人会逐个对比这个PR里改了哪些实现文件、有没有漏掉对应的测试文件。机器在这方面比人可靠得多。
3.4 LLM辅助评论:可控的关键在于"范围约束"
关于AI生成评论这件事,我们内部讨论过很多轮。结论是:可以用,但必须约束使用范围,而且不能冒充权威。目前我们的实现是配置一个可选的LLM分析器,它只被允许做三件事:根据diff解释代码意图、指出潜在的数据一致性问题、给出改进建议。它不允许做的是:改写代码、断言代码有bug、评价代码风格。
范围约束是怎么实现的?不是靠模型自觉,而是靠我们给它的prompt模板加硬性要求。它的输入不是整个仓库,而是当前PR的diff和变更风险评估器选出的"高风险文件列表"。输出也会经过一道过滤程序:如果返回内容中没有引用具体的行号或具体的函数名,这条评论就会被丢弃。因为我们发现,LLM输出的大而化之的评论,比如"函数体较长,建议拆分成多个函数"这类废话,对于团队来说没有任何信息量,只会制造噪音。
我觉得现在很多AI Code Review工具被吐槽,核心原因就是缺少这种"范围约束"的设计。模型很强大,但不加约束地让它在PR里长篇大论,最终会让团队成员养成"CR页面全是骂人的机器人"的条件反射。
4. 接入Git平台和CI:不跑一遍看不出来的坑
4.1 GitHub App模式的权限模型
先说说我们用的GitHub。open-code-review的远程对接优先实现了GitHub App模式,而不是传统的Personal Access Token。原因是权限边界更清晰。App可以精确控制它能读哪些仓库、不能读哪些仓库,并且访问token是短时效的,需要定期刷新,相比PAT长期挂在一个机器人账号下的模式,安全性高不少。
但App模式有个入口门槛:回调域名必须支持HTTPS。如果你是本地调试,可以用gh repo配合smee.io这种Webhook转发服务来做内网穿透。我们早期就是这么干的,把GitHub的Webhook事件转发到本地局域网的开发机端口上,代码改完保存一下配置就能测试整个流程。
权限配置这一块容易被忽视的是Pull requests: read/write和Checks: read/write的区分。如果你只想让机器人发评论,而不需要它参与CI检查结果面板,那就只需要开Pull requests的写权限。评论走的是Issues API,而真正的Checks面板需要额外的权限,我们第一版没开,后来发现评论内容偶尔会被GitHub折叠到"Show resolved"里,还以为是权限问题排查了半天,最后发现其实是评论被作者误点掉了。这里提醒一下:评论被作者"resolved"之后,机器人下一次运行到同一行时也记得重新发评论的幂等处理。
4.2 不依赖平台的统一处理方案:git命令直接生成diff
做完GitHub适配后,我们内部发现还有一个更普遍的需求:不少仓库不在GitHub上,而是放在公司内网GitLab上。GitLab的API风格和GitHub很不一样,Webhook的payload结构也不一样,如果每种平台都写一套collect适配器,工作量有点大。
后来我们想了一个取巧的办法:不依赖平台API获取diff,而是用git命令直接从本地克隆目录里生成变更数据。在collect阶段,工具会先定位到仓库的本地路径,执行git fetch拉取最新远程分支,然后运行git diff对比目标分支和基础分支。只要机器上有git,无论是GitHub、GitLab还是Gitea,甚至是纯内网仓库,都能拿到同样的diff数据。平台API只负责两件事:接收Webhook事件、推送评论结果。
git fetch origin git diff --unified=5 origin/main...origin/feature/xxx > /tmp/change.diff这个方案让我们把平台适配的工作量从"每个平台三套API"降到了"每个平台只有评论接口一套API"。内网GitLab的token权限只需要read_api和write_api两个scope就够了。对于没有开放API的SVN仓库,理论上也可以直接调用svn diff走同样的流程,不过我们暂未实测。
4.3 评论风暴与频率限制:rate limit是最现实的敌人
上线第一周,我们就迎来了一个实际问题:GitHub API的rate limit被打爆了。原因很笨——我们给每个PR一次性推送了50多条评论,每个评论一次API调用,再加上获取issue评论列表做幂等检查的调用,一个PR就能吃掉上百次配额。GitHub的Token配额每小时只有5000次,看起来很多,但四个仓库跑一天,很快就见底了。
解决思路有两个,我们两个都做了。第一是评论合并:把同一文件的多个finding合并成一组评论,每条评论用列表形式列出找到的所有问题,而不是一个finding发一条。这样评论数量从50条降到5条左右,评审人体验反而更好,不用在信息流里翻几十条机器人消息。第二是按文件分块推送:每个文件最多产生一条评论,绝不为同一文件发多条。
合并评论的具体格式是:
review-botreported 3 findings on
core/auth/token_manager.py:
- L42: [warning] 硬编码密钥,建议从配置系统读取
- L58: [error] 过长的try块,建议提取为独立函数
- L67: [info] 变量命名与上下文不符,建议改为
expired_at
这样每条评论都聚焦在一个文件上,而且附带文件内的问题清单。评审人点开PR页面,不用滚动就能看到所有问题的大致分布,体验比原来好了一个数量级。
4.4 评论的幂等性:让机器人反复评论同一行是灾难
代码评审机器人最容易被吐槽的场景,就是作者push了新commit来修复问题,机器人却在旧代码的同一行又评论了一遍。用户一脸懵:我明明已经改了啊?问题出在我们的评论写入流程缺乏"基于commit sha去重"的机制。
解决方案其实很直白。我们在每条评论的body末尾埋入了一个隐藏标记,格式类似<!-- review-comment-id: {rule_id}:{file_path}:{start_line}:{commit_sha} -->。每次推送评论前,先通过GitHub API读取该PR已有评论列表,解析这些marker,如果发现相同rule_id、相同文件、相同行号、但marker里的commit_sha不等于当前head commit的sha,就会先调用resolve接口把旧评论标记为"已过时",然后再决定是否需要发新评论。
这种做法带来的效果是:评论列表始终反映最新commit的状态。过时的评论不会被删除,但会明确显示"已过时",保留修改历史。如果作者在最新commit已经修复了问题,那么就没必要再发新评论,旧评论保持过时状态即可。
这里面还有一个细节:GitHub的评论列表API默认不会返回resolved状态,需要额外加state=all参数。这个坑让我们排查了小半天,后来在API文档的小字里才看到。如果你也在做类似集成,建议直接把state=all写死在查询逻辑里,别用默认值。
5. 落地数据、团队反馈与后续规划
5.1 运行两个月后的实际效果
open-code-review在团队内正式运行了大概两个月,我统计了一些数据,说几个对团队有意义的:
- 静态问题和规范问题占比从人工评审总数的38%下降到11%。也就是说,过去人工评审要花三分之一以上的时间去挑拼写、命名、异常处理的毛病,现在这些大部分由机器人提前筛掉了。
- PR平均首次评审响应时间从6.2小时降到了1.5小时。机器人几乎在push后30秒内就给出首轮自动化反馈,作者不需要等人类评审人上线。
- 训练集外的自定义规则数达到了17条,全部由团队同事通过插件协议自己编写,维护成本和代码评审工具本身完全解耦。
第一个数据说明自动化确实解放了人力;第二个数据说明速度提升不只是因为快,而是通过减少来回沟通轮次实现的;第三个数据才是这个项目长期走下来的真正底气——如果规则只能由核心维护者添加,那这个工具迟早会僵化。
5.2 团队吐槽最多的问题:误报、噪音、信任感
工具跑起来之后,团队反馈最多的三个问题也很有代表性。
第一个是误报。比如我们不能理解业务上下文,有时候会把"刻意处理历史脏数据的兼容逻辑"判成"逻辑分支过多"。解决方式是给每条规则都加了suppress指令,作者可以在代码中使用特定注释让该条finding沉默,并且silence记录会保留在评论里,后人看的时候能知道"这里为什么被放过了"。
第二个是评论噪音带来的"狼来了"效应。刚开始规则比较激进,报告了很多低严重级别的风格建议,没过多久团队就养成了"见到review-bot的评论就点resolved"的习惯。这个教训很深刻:自动评审工具最怕的不是漏报,而是过度报告导致的信任崩塌。后来我们做了一次大调整,把所有info级别的问题默认从PR评论中去掉,只保留warning和error,并建议团队把"风格类建议"转到离线报告中去看。
第三个问题是LLM分析器偶尔会脑补出bug,比如把正确的并发逻辑硬说有竞态条件。我们最终加了"LLM评论必须引用具体行号与变量名才能保留"的过滤器,之后脑补类评论少了很多。如果要在你的团队里上LLM评审,我的建议是:宁可让它完全闭嘴,也不要让它给出无法验证的断言。
5.3 后续规划:规则集市、review策略和学习型阈值
关于这个项目接下来的方向,我们思考了三件事。
第一件事是把插件体系真正做成一个"规则集市",团队之间可以分享自己写的检查规则。比如同一套技术栈的不同团队,经常会遇到完全相同的"团队规范类"问题,这些规则积累下来对社区的价值比单纯的静态分析工具更大。
第二件事是支持更复杂的review策略,比如根据文件的风险等级动态决定"是否需要至少两个人类评审人"、"是否需要测试变更匹配率达到100%才能合入"。目前这些策略更多依赖人工判断,我们希望把评审的门禁规则也变成可配置的流水线节点。
第三件事是给阈值系统加入学习能力。目前"复杂度超过多少算高"是写死的常量,但不同模块的合理标准是不一样的——配置类文件天然复杂度低,而调度引擎复杂度高一些也合理。我们想用历史合入代码为基线,自动计算每个模块的复杂度分布,然后根据偏离程度来给出告警。这个思路还在验证阶段,但方向我觉得是对的。
最后再分享一个小技巧。如果你也想在团队里推这类工具,不要一上来就追求规则数量多。先让它安安静静地跑上一到两周,只报告确定性的、严重级别为error的问题,让团队把它当成一个可靠的基础设施。等信任建立起来之后,再慢慢放宽规则范围和严重级别。我踩过的坑就是一次性放太多规则,导致前两周团队都在疯狂点"resolved",而不是真的去改代码。工具的价值从来不在于评论多,而在于每一句话都值得被认真对待。