
代码评审这件事很多团队都是在“走过场”。PR 挂一天临上线前有人点个 approve评论区的意见基本围绕“这里多个空格”“变量名换一下”真正该把住的架构问题、异常分支、安全隐患反而没人提。我团队当时就是这个状态所以我花了几个周末基于 open-code-review 这个方向搭了一套开源的代码评审自动化工具把机械性的检查全部下沉给机器让人力集中在真正的逻辑评审上。这篇文章把我从零到落地的完整过程、底层设计思路和踩过的坑都写出来给同样想摆脱“形式化评审”的团队一个可复用的参考。1. 为什么我会自己动手做一套open-code-review1.1 商业评审平台解决不了的那部分问题之前团队用的是代码托管平台自带的 Merge Request 评审功能理论上该有的都有评论、讨论、审批流、状态检查。但实际用下来有三个问题始终绕不过去。第一评审标准完全依赖个人经验。同一个团队里有人认认真真看每一行有人只看 title 就 approve最终效果方差极大。更麻烦的是这些评审经验只能靠嘴传递换个人规则就变了根本沉淀不下来。第二机械性检查占用了太多人工注意力。缩进、命名、明显的内存泄漏、调用了废弃 API这些东西完全可以用程序自动判断却让评审人一遍遍在评论区重复。第三平台绑定太深。评审历史、规则配置、通知逻辑全部锁在一家托管平台上哪天想换平台这些东西全部作废成本高到不敢动。1.2 open-code-review 的目标把评审经验变成可执行的规则我想要的不是再做一个“在线评审页面”而是一套能嵌入现有流程的自动化引擎。核心思路是这样的每个评审意见本质上都是一条“代码变化是否符合预期”的判断而这类判断中有相当比例是机械的、可形式化的。open-code-review 要做的就是把这些机械判断从人脑里抽出来固化成规则让机器在每次提交时自动跑一遍。这套方案应该满足四个条件规则必须可定制、可沉淀团队里有经验的人能把他的检查清单直接转成规则文件必须能跑在私有化环境代码和数据不出内网结果必须结构化能对接任意 CI 系统和托管平台而不是绑死某一家最后它必须能区分“机械检查”和“人工评审”的边界只做机器擅长的部分把需要上下文理解的判断留给人类。明确了这四点整个项目的技术架构就有了方向。1.3 技术选型轻量、可解释、易扩展选型时我定了几条原则。主语言用 Python因为做静态分析生态最成熟AST 库、lint 工具链都很全团队里人也熟。运行时不做常驻服务而是以 CLI 为核心这样天然适应各种 CI 场景不需要专门维护一个在线服务。规则引擎必须是声明式的规则文件用 YAML 写让不熟悉代码的工程师也能看懂规则在干什么。同时保留一段 Python 接口给需要写复杂逻辑的人留口子。这里要解释一下我为什么刻意不做常驻服务。代码评审工具如果做成 Web 服务就要考虑鉴权、数据存储、高可用、部署升级整套运维负担非常大。而做成 CLI每次评审其实就是“输入一个 diff输出一份报告”这种无状态设计可以轻松被 CI 调度本地开发时也能直接跑调试成本低很多。实际用下来这个决策让项目落地速度快了不止一倍。2. 整体架构从一个提交到一份评审报告要经过哪些环节2.1 核心链路与模块划分一套完整的自动化评审过程我把它拆成六个环节变更获取从代码托管平台或本地 Git 仓库拉取本次提交涉及的变更文件。差异解析解析 git diff得到每个文件的增删行、上下文、行号映射。语言识别根据文件后缀和内容识别语言类型决定后续加载哪些解析器。规则扫描对每个变更文件执行匹配的规则集产出原始问题列表。结果聚合按文件、规则、严重级别聚合问题过滤基线噪音生成结构化报告。反馈分发把报告推送到代码托管平台的评论和状态检查或输出到 stdout 供 CI 消费。这六个环节在代码里对应六个独立的包彼此通过标准的数据结构通信。这样设计是为了让每个环节都能单独测试也能单独替换实现。2.2 各模块的职责与数据流模块之间的数据流我是这样定义的。变更获取模块输出一个ChangeSet对象里面包含了仓库地址、目标分支、当前 commit SHA、文件列表。差异解析模块消费这个对象产出FileDiff列表每个 FileDiff 包含文件路径、变更类型、hunk 列表hunk 里又有具体的行号和行内容。规则扫描模块只认FileDiff不关心 diff 是从哪里来的这样本地调试时可以很方便地喂一个 diff 文件给它。用表格看会更清楚模块输入输出核心职责变更获取Webhook 事件 / 命令行参数ChangeSet确定本次评审的范围差异解析ChangeSetFileDiff[]结构化描述代码变化语言识别FileDiff语言标签决定规则匹配集合规则扫描FileDiff 规则集Finding[]执行静态检查结果聚合Finding[]Report清洗、过滤、分级、汇总反馈分发Report评论 / 状态 / 文件让结果可消费2.3 为什么把“评审结果”设计成中间产物一个很容易被忽略的设计点是评审结果的表示方式。我没有直接让规则扫描完就去调平台 API 发评论而是让结果聚合模块先产出一份 JSON 报告文件。这份报告是整套系统的事实来源source of truth后续所有动作都是基于这份报告派生出来的。这么做有四个好处。第一可缓存。同样的 commit SHA 跑过一次评审后报告文件可以直接复用省掉大量重复计算。第二可审计。任何人任何时候都能打开一份历史报告看当时引擎到底报了哪些问题而不是只看到评论区的只言片语。第三可移植。同一份报告今天想推送到 GitHub 的评论明天想换成 GitLab 的讨论只需要写一个新的反馈分发器核心扫描逻辑完全不用动。第四便于本地复现。开发者可以本地跑一遍得到报告文件配合 diff 精确复现 CI 上产生的每一条提示调试体验比看 CI 日志好得多。这个设计在后来的迭代中被证明是收益最高的决策。3. diff解析与语法识别最容易翻车的底层模块3.1 解析 unified diff 的边界问题如果只处理“正常的” diff解析本身不难——无非是读 hunk 头然后区分、-、空格开头的行。但真实世界的 diff 充满了边角情况我上线后第一个月几乎都在填这些坑。第一个坑是行号偏移。一个 hunk 头长这样 -12,6 12,8 表示原文件从第 12 行开始共 6 行新文件从第 12 行开始共 8 行。但如果一个文件有多个 hunk每个 hunk 的起始行号是相对上一个 hunk 累计的必须用变量持续追踪而不是简单累加。刚开始实现时我把每个 hunk 都当成独立起点结果多 hunk 文件的提示行号全部错位直接导致评论定位到错误位置。第二个坑是新增文件。新增文件的 diff 没有-行全部是行hunk 头的原文件起始行号往往写的是0,0。如果解析器假设“原文件至少有一行”这里就会解析失败。第三个坑是重命名检测。Git 默认会对相似度超过一定阈值的重命名文件显示为rename from/rename to这类 diff 里可能根本没有实际内容变化但你的评审规则可能想检查“重命名是否影响了引用路径”。我最终实现的解析器核心逻辑是这样处理的def parse_unified_diff(diff_text: str) - list[Hunk]: hunks [] current_hunk None old_line 0 new_line 0 for raw_line in diff_text.splitlines(): if raw_line.startswith(): if current_hunk: hunks.append(current_hunk) match HUNK_HEADER_RE.match(raw_line) if not match: continue old_start int(match.group(1)) new_start int(match.group(3)) current_hunk Hunk(old_startold_start, new_startnew_start) old_line old_start new_line new_start elif current_hunk is not None: if raw_line.startswith(): current_hunk.lines.append((new_line, raw_line[1:], add)) new_line 1 elif raw_line.startswith(-): current_hunk.lines.append((old_line, raw_line[1:], del)) old_line 1 elif raw_line.startswith(): continue else: current_hunk.lines.append((old_line, raw_line[1:], ctx)) old_line 1 new_line 1 if current_hunk is not None: hunks.append(current_hunk) return hunks这段代码虽然简单但已经能覆盖绝大多数真实场景。重点是old_line和new_line必须独立维护遇到空格开头的上下文行时两者都要自增这是最容易写错的地方。3.2 上下文保留策略评审要看到“为什么”只给规则引擎报“某文件第 50 行新增了一行”很多规则是没有办法做判断的。比如你想检查“新增代码是否调用了已废弃的接口”你需要知道这行代码所在的函数、类甚至文件里 import 了哪些符号。这就是上下文的重要性。我的做法是让差异解析模块在生成 FileDiff 时顺便保留每个 hunk 前后各 10 行的上下文。这些上下文不会作为“变更行”参与规则匹配但会作为完整代码片段传入 AST 解析器让规则能拿到函数边界、类定义等结构信息。代价是内存占用略高但对现代机器来说九牛一毛换来的是规则准确率的大幅提升。我见过一些工具只喂“变更行”给模型或规则引擎结果规则经常误报本质原因就是上下文缺失。3.3 语言类型识别与解析器按需加载仓库里通常混着多种语言后端 Python、前端 TypeScript、脚本文件 Shell、配置文件 JSON/YAML。我在语言识别模块维护了一张映射表优先看文件后缀再看文件首行特征比如 Shell 脚本的 shebangYAML 文件首行的---。解析器按需加载是性能的关键。如果每跑一次评审就把 Python、TS、Java、Go 的全部 AST 解析库加载一遍启动时间能到十几秒CI 根本受不了。我改成懒加载识别出某文件是 Python就只加载 Python 相关的解析器没有对应语言的规则连解析器都不加载。实际效果是冷启动从 8 秒压到了 1.5 秒左右对 CI 来说处于可以接受的范围。另外建议把解析器版本锁死。AST 解析器升级经常带来语义变化同样的代码在新版本下 AST 结构可能不同如果规则没有对应调整会出现“昨天没报今天报了”的诡异情况团队信任度会瞬间崩掉。我现在每次升级解析器都会在回归样例集上全量跑一遍确认无一例外才会合并。4. 规则引擎的分层设计与自定义规则实战4.1 三层规则架构从聚合 Lint 到自定义检查规则引擎是 open-code-review 的核心我把它设计成三层。第一层是现有工具的聚合层。ESLint、Pylint、Ruff、golangci-lint 这些工具本身已经很强我做的不是重复造轮子而是把它们的输出统一转成 Finding 结构。每个语言预置一个聚合器比如 Python 的规则扫描到.py文件时调用 Ruff 做全量检查然后把 Ruff 输出里只属于“本次变更行”的部分挑出来。这里“只属于本次变更行”是关键全量 lint 会把历史遗留问题一股脑报出来噪音极大后面会专门讲。第二层是基于 AST 的自定义检查层。这一层用 Python 直接写检查函数针对团队自己的代码规范做定制。比如我们团队有个规矩所有对外提供的 HTTP 接口必须做入参校验这个检查用正则根本做不了但通过 AST 很容易判断函数上有没有挂对应的校验装饰器。第三层是元规则层。这一层的规则不针对具体代码行而是针对整个提交的模式。比如“单个 PR 文件数超过 20 个就提示拆分”或者“提交信息里没有关联的需求编号就警告”再或者“新增文件的文件大小超过 500KB 就失败”。元规则在结果聚合阶段执行输入是整个 ChangeSet 和初步的 Finding 列表。4.2 一个自定义规则的真实案例堵住异常吞掉我用一个真实规则来说明自定义检查层的玩法。当时团队线上出过一个问题某个接口出错时异常被吞掉日志里完全没有记录排查花了一天。事后我们定了一条规范except 分支里不允许只写 pass 或者注释至少要记一条日志。用 AST 实现这个规则只需要以下核心逻辑。import ast class ExceptPassChecker: name no-bare-pass-in-except message 异常处理块不允许只写 pass至少要记录日志 def check(self, tree: ast.AST, diff: FileDiff) - list[Finding]: findings [] for node in ast.walk(tree): if not isinstance(node, ast.ExceptHandler): continue body_statements [s for s in node.body if not isinstance(s, ast.Pass)] if not body_statements: line node.lineno if diff.contains_change_line(line): findings.append(Finding( ruleself.name, messageself.message, lineline, severityerror, )) return findings这个检查器用ast.walk遍历整棵 AST找到所有ExceptHandler节点如果 body 里除去pass后没有其他语句就判定违规。注意我加了diff.contains_change_line(line)这个过滤条件只有当违规行确实属于本次新增的代码时才报告。这个过滤器把大量历史问题挡在外面也是降噪的关键机制。你没看错这台检查器还能发现except: pass这种补救质量的写法。因为body_statements只过滤掉了ast.Pass如果开发者写了except: pass但中间有任何其它语句就不会被判定。组合规则、取反规则都可以在这层轻松扩展。4.3 规则配置中心与命中解释规则全部由 YAML 配置驱动规则文件长这样rules: - name: no-bare-pass-in-except enabled: true severity: error languages: [python] - name: forbid-console-log enabled: true severity: warning languages: [javascript, typescript] - name: pr-size-limit enabled: true severity: warning meta: true max_files: 20每条规则都要求写清楚message这是非常重要的一点。我规定所有内置规则的 message 必须回答三个问题哪里有问题、为什么这是问题、应该怎么做。比如“except 块只写 pass 会吞掉异常导致线上问题无法排查建议在 except 块内至少调用一次日志记录”。规则命中时报告里会带上完整 message开发者在代码托管平台看到评论时不需要去翻规则文档就能理解。另外我给规则加了解释字段机制如果一条规则命中报告会自动附带规则文档链接。这个链接指向仓库里的 docs 目录进一步阐述规则的背景案例和适用边界。千万不能小看这个设计这是提高团队接受度最重要的一环。人只有理解了规则背后的“为什么”才愿意遵守它。5. 接入代码托管平台与CI让机器人自动贴评论5.1 机器人账号与 Token 权限最小化扫描引擎做好之后下一步就是把它装到流水线上。我建议用专用机器人账号接入而不是用团队成员的私人账号这样权限可以精确收敛出问题也容易追溯。以 GitHub 为例我注册了一个机器人账号申请了独立的 Fine-grained token权限只开三项Contents 的 Read、Pull requests 的 Read 和 Write、Checks 的 Write。这三项权限足够机器人看到 diff、发布评论、创建 commit status但碰不到代码仓库的配置也改不了分支保护规则。很多人图省事直接给机器人开全部权限一旦 token 泄露仓库就裸奔了这个风险不值得冒。5.2 评论与状态检查的配合先提示后门禁机器人把报告推回平台时我同时使用两种机制评论和 commit status。初次接入阶段只发评论不开 status 检查。评论里按严重级别分组列出问题每组最多显示前 10 条剩下的折叠起来提示“完整报告见附件”。这样做不会刷屏开发者也不会因为被打扰而抵触工具。规则稳定运行两到四周后再开启 commit status。status 的逻辑很简单如果本次变更命中任何error级别问题status 设为 failure只有warning和info时设为 success但会在评论里给出提示。这样做的目的是让机器先“提醒”而不是直接“拦截”等团队适应了、规则准确率上来了再逐步把更多规则升级为 error。直接一步到位开 status 拦截大概率会引起团队反感这个节奏控制非常重要。5.3 幂等处理同一个 MR 被推送十次怎么办Git 的工作流决定了同一个 MR 会被反复推送每次 push 都会触发 webhook。如果引擎不做幂等处理机器人会在评论区刷出一堆重复评论开发者体验会非常糟糕。我的处理方案是用commit SHA做唯一键。引擎执行完扫描后先把报告 JSON 存到对象存储或 CI 的缓存目录里key 就是 SHA。反馈分发器每次执行前先查一下这个 SHA 是否已经产出过报告有就直接加载不再重新扫描。评论发布前同样先查询机器人是否已经针对这个 SHA 评论过评论过就只做状态更新不新增评论。这里还有一个坑MR 的评论如果不做清理即使幂等也会出现新旧意见重叠。我的方案是在发布新评论之前先检索机器人账号在该 MR 下发的评论把上一轮的评论标记为“已过期”并折叠。GitHub 支持评论的 edit 操作我把旧评论的内容替换成“本轮扫描结果已更新请查看最新评论”保留历史记录但不再占据首屏。这套机制上线后评论区彻底清爽了。5.4 不依赖托管平台 API 的降级方案如果团队用的平台不支持机器人调用 API或者网络环境严格到无法访问平台 API此时需要降级方案。我的做法是让反馈分发器支持多种输出模式除了推送评论外还能输出 Markdown 报告、展示 JSON 报告或者只输出一份可以在 CI 日志里阅读的纯文本摘要。CI 里的典型用法是通过命令行工具直接跑open-code-review scan \ --repo . \ --base origin/main \ --head HEAD \ --format json \ --output report.json这条命令不依赖任何平台 API只需要本地 Git 仓库就能完成一次完整评审。团队可以在 MR 描述里手动贴报告也可以在 CI 日志里查看。虽然体验不如机器人自动评论但在极端环境下至少保证工具链可用。我在本地开发时也基本用它比等待 webhook 触发快得多。6. 误报治理与效果调优上线前后的实战对比6.1 噪音来源引擎上线第一周被喷了三十次引擎刚上线那周评论区被开发者骂惨了。我统计了所有负面反馈把噪音来源分成四类。第一类是历史问题被重复报告。某个文件本来就有 50 个历史 lint 问题开发者只改了一行结果 50 个问题全被机器人重新评论了一遍。第二类是第三方代码被误扫。依赖目录、生成的 protobuf 代码、锁文件这些不是人写的代码规则却一视同仁。第三类是规则本身误判。AST 规则覆盖不到全部语义场景比如误把assert当成无害代码或者把正常使用的console.log在非调试场景下当成错误。第四类是评论刷屏。一个 MR 命中 200 条问题机器人一次性全部评论开发者打开 MR 直接崩溃。6.2 基线机制只报告增量问题针对第一类噪音我实现了基线过滤机制。核心思路是以目标分支的最新提交作为基线基线里已经存在的问题一律不报只报告本次新增的、或者本次修改行上引入的问题。实现时需要注意性能。基线扫描是相对昂贵的操作我给引擎加了一层缓存以“目标分支名 最新基线的 commit SHA”作为缓存 key把基线结果缓存到本地或者 CI 缓存目录。目标分支没动过基线结果就复用不需要重新扫描。实际项目中一次全仓基线扫描耗时约 30 秒缓存命中后这部分时间可以节省掉。6.3 文件忽略列表与语义降噪针对第二类噪音做法简单直接维护一个全局忽略列表。我内置了常见需要跳过的基础配置比如node_modules、vendor、dist、build、*.pb.go、*.min.js、锁文件等等。团队也可以在配置中心里按需追加自己仓库的特殊目录。针对规则误判我引入了“语义降噪”机制。核心是规则分级和忽略标记并存。规则分成三级error代表团队明确不允许出现的问题warning代表建议性意见info只做提示。规则在 YAML 里配置后如果某条规则误报率长期偏高可以先把它从 error 降到 warning而不是直接删除规则。同时我支持在代码行内写忽略注解比如# open-code-review-ignore: rule-name开发者对某条规则的不同意见可以通过这种方式显式沟通方便后续规则审计。我统计过上线一个月后的数据变化去除基线噪音后报告的无效问题数量下降了约 85%加上文件忽略列表后这个问题进一步缩到原来的 5% 左右。这个数据是让我有信心继续迭代的关键。6.4 规则分级落地后的效果对比上线整治前后几个关键数据指标整治前整治后每 MR 平均报告问题数9611开发者标记为“无效”的问题占比72%12%机器人评论被折叠占比60%15%error 级别问题的及时修复率不足 10%86%这些数字不是说规则变少了而是规则变准了。开发者不再把机器人当成噪音源之后才开始真正关心它说了什么。到了这个阶段open-code-review 才真正从“被人嫌弃的机器人”变成了“团队里最严格的评审人”。7. 私有化部署与工程化落地中的坑7.1 内网环境的依赖安装问题我们团队有一部分代码仓库跑在隔离的内网环境不能直接访问外部镜像源。open-code-review 的引擎依赖了不少 AST 解析库在隔离环境里安装依赖是个很头疼的问题。我的处理方式是把所有依赖打包成 wheelhouse 目录随项目一起走内部制品库发布。安装时直接用pip install --no-index --find-links./wheelhouse -r requirements.txt这里有两个坑必须提醒。第一wheelhouse 不能只构建一次因为间接依赖也会更新要定期在能联网的机器上重新生成否则新成员拉下来很可能缺某个库。第二内网环境经常有多个 Python 版本共存同一个 wheel 在 Python 3.9 和 3.11 下的兼容性不完全一样构建 wheelhouse 时最好指定目标 Python 版本避免高版本构建的 wheel 在低版本环境装上后报语法错误。7.2 与既有 CI 系统的集成姿势我们团队用的 CI 是自建的底层是通用的流水线平台。集成 open-code-review 时我没有让流水线直接调用引擎命令而是包了一层薄薄的调度脚本。这样做的原因是流水线的触发条件、失败重试、日志收集都需要统一处理直接把命令写死在流水线配置里后期维护成本很高。调度脚本做的事情有四件检查传入参数是否完整仓库地址、目标分支、commit SHA判断本次变更是否包含需要扫描的文件如果没有就直接跳过省掉无谓的时间准备好缓存目录和报告输出目录调用引擎命令然后根据退出码和报告内容决定本次流水线是否失败。脚本只有几十行但把引擎和 CI 解耦了引擎版本升级时流水线配置完全不用动。这里还有一个建议不要让 CI 的工作目录在每次构建时都完整 clone 一遍仓库。我们用了增量拉取只拉取目标分支的差量提交然后基于git diff计算变更集。大型仓库全量 clone 可能要几分钟增量拉取只需要几秒钟。这个优化在仓库体积大、并发构建多的时候尤为明显。7.3 资源占用与扫描耗时值得关注的三个点open-code-review 不会常驻内存但扫描过程需要关注三个资源点。第一个是 AST 解析的内存占用。大文件比如几千行的 Python 文件生成 AST 树可能占用几十 MB 内存如果并发执行多个语言的文件解析内存会飙升。我把扫描改成按文件分批执行每批最多 20 个文件批与批之间主动释放内存实测峰值内存从 1.2GB 降到了 400MB 左右。第二个是进程的冷启动时间。每次扫描都重新启动 Python 解释器、加载规则库冷启动一般在 1 秒到 2 秒。如果对耗时敏感可以在 CI 镜像里预编译所有依赖并用PYTHONSTARTUP预热进一步压缩启动时间。第三个是规则文档和报告的存储。报告 JSON 如果没清理日积月累也会占用不少空间。我加了一个简单的定时清理策略只保留最近 30 天的报告更早的归档到冷存储。这个不属于核心逻辑但部署久了会发现很有必要。7.4 推广节奏从提示到门禁的分阶段策略做完技术方案后真正决定成败的是推广节奏。直接在所有仓库一次性打开 error 门禁几乎必然会翻车。我是按三步走的。第一步选择 2 到 3 个活跃度高、成员配合度好的仓库做试点只开 info 和 warning不拦截任何流水线。这一步的目标是收集规则噪音校准规则准确率。第二步试点运行 2 周后和试点团队的成员开一次复盘会逐条查看被标记为无效的规则该修正修正该降级降级。然后把一套经过校准的规则模板逐步扩展到更多仓库。第三步当规则准确率稳定到 85% 以上时再在核心业务仓库开启 error 级别的门禁同时明确告诉团队 error 会有哪些规则给出文档链接保留申诉渠道。整个过程我花了大约两个月最终团队对机器人的态度从最初的反感变成了“被机器人查出问题会有点不好意思但确实避免了把问题带上线”。这种变化是我觉得做这件事最值得的部分。如果你也想在团队里落地类似的方案我的建议是不要一上来就追求大而全的规则库先从两三条团队最痛、最明确的规范开始让机器人跑起来再逐步扩展。评审工具的准确率永远优先于覆盖面一个说错话的机器人和一个不说话的平台一样不值得信任。