
先交代一个真实场景周五下午我提了个 PR改动量不大就是给内部订单模块加了个重试逻辑。结果 reviewer 盯了半小时回来留了四条 comment一处 debug 日志没删一处错误处理分支里忘了补返回一个魔法数字 3 写死了还有一处注释跟实际行为对不上。代码逻辑本身他压根没细看精力全耗在这些“一眼就能发现但必须一个个找”的琐碎问题上。这种事发生多了我就一直在想review 这个环节里到底有多少工作是可以自动化替代的代码审查是刚需人看代码也确实不可替代但“人肉扫描低级问题”这部分完全可以交给工具。于是就有了 open-code-review 这个项目。它不是一个重量级静态分析平台而是一个轻量的命令行代码审查工具核心就干一件事把 PR/MR 里的改动 diff 拉出来跑一遍可插拔的规则引擎把机器能判断的问题先揪出来让 reviewer 的时间真正花在架构、设计和逻辑正确性上。如果你和我一样受够了“reviewer 变成了人肉 linter”或者你想在 CI 里加一道轻量、跨语言、可自定义的代码质量门禁这篇文章值得看看。下面我会把这个项目的设计思路、核心实现、接入步骤和踩坑过程完整拆一遍。1. 这个项目到底解决什么问题1.1 被淹没在人肉 check 里的代码审查先说一个常识代码审查的价值在于发现“设计问题”“逻辑缺陷”“可维护性隐患”但现实里大量 review 时间被消耗在低信息密度的事情上——有没有残留console.log、有没有未处理的错误、TODO 是不是还没清、函数是不是又写长了、命名是不是规范。这些事情有一个共同点它们是有确定性答案的是完全可以通过规则自动判定的。问题在于传统 lint 工具解决的是“整个代码库有没有问题”而不是“这次改动有没有问题”。在 PR review 场景里你根本不关心历史代码里有多少历史遗留你只关心“这次改动新引入的问题”。它们俩看着像实际是两种完全不同的产品逻辑。传统 lint 是全量健康度体检PR review 是增量拦截。所以 open-code-review 的定位从一开始就很明确做一个diff 驱动的代码审查工具它不是替代 lint而是补上“变更即审查”这个环节。1.2 open-code-review 是什么能做什么简单说open-code-review 是一个用 Go 编写的命令行工具它读取 git 仓库中的变更集也就是 diff对每个被修改的文件、每一行被改动的代码执行一组可配置的规则然后输出结构化报告并且支持对接 CI 系统。它可以做什么我给你列几个实际场景在本地提交前跑一遍把低级错误拦在 push 之前接入 GitHub Actions 或 GitLab CI在 PR 上自动评论或生成 code quality 报告自定义团队规范比如“禁止新增没有 issue 关联的 TODO”“接口返回值必须显式处理 error”输出 SARIF 格式报告和编辑器、代码扫描平台打通作为质量门禁超过阈值直接让 CI 失败有基础的读者应该看出来了这本质上是一个“把人工 review 的机械部分标准化”的工具。它适合任何使用 git 的团队尤其是那些没有专职 QA、依赖开发者互相 review 的中小团队。1.3 为什么不直接用现成的静态分析工具我知道你肯定想问这年头不是已经有 SonarQube、CodeQL、ESLint、golangci-lint 这些工具了吗为什么还要再造一个轮子我承认这些工具都很强我也在用。但它们和 open-code-review 解决的不是同一个问题。拿几类典型方案对比一下方案输入输出典型问题SonarQube / CodeQL全量代码库全量问题列表部署重规则配置复杂全量扫描慢对“本次改动”不敏感语言专用 linterESLint、golangci-lint 等按文件扫描语法/规范问题只覆盖单一语言团队里几种语言就要维护几套工具链商业 PR review 机器人整个 PR评论依赖云服务规则黑盒自定义能力受限数据要出公司网络open-code-reviewgit diff本次改动引入的问题规则需要自己沉淀初期内置规则偏通用说白了大而全的静态分析工具的任务是“体检”轻量 diff 审查工具的任务是“安检”。体检要把五脏六腑都查一遍安检只查你身上有没有带违禁品。这两个场景不是谁替代谁而是互补。2. 核心解剖diff 驱动的审查是怎么实现的2.1 为什么只盯 diff不扫全库这是 open-code-review 最核心的设计决定审查目标永远是“这次变更新增或修改的行”而不是整个文件或者整个代码库。这里有两层考虑。第一层是性能。我拿自己维护的一个 Go 微服务仓库试过代码量 20 万行左右。全量跑一遍 golangci-lint 大约要 40 秒而 open-code-review 只针对一个 PR 的 diff 做规则匹配跑完只需要 2~4 秒。一个是在 CI 里等半分钟一个是几秒出结果体感差了一个数量级。对于追求快速反馈的现代 CI 流程这个差异是决定性的。第二层是语义匹配。人做 review 的时候有个默认前提只 review 这次改动的代码不改的部分默认信任历史 review 的结论。全量扫描会把存量问题也带进来导致报告里一堆“历史垃圾”反而淹没了这次改动真正需要关注的问题。只盯 diff报告才是干净的、和 PR 强相关的。那 diff 驱动的实现路径是什么核心分三步拿到 diff 结构、解析出行号映射、只对目标行跑规则。2.2 diff 解析从 git 输出到结构化数据第一步拿到 diff。大多数场景下我们用的是git diff base head的输出标准 unified diff 格式长这样 -128,6 128,8 func RetryWithBackoff(ctx context.Context, fn func() error) error { for attempt : 0; attempt maxAttempts; attempt { err : fn() if err ! nil { log.Printf(retry attempt %d failed: %v, attempt1, err) if attempt maxAttempts-1 { return err } time.Sleep(backoff(attempt)) continue }这段 diff 里有几个关键信息 -128,6 128,8 旧文件从第 128 行开始占 6 行新文件从第 128 行开始占 8 行以空格开头的行上下文行两边都一样以-开头的行被删除的内容只存在于旧文件以开头的行新增加的内容只存在于新文件open-code-review 在解析这段输出时会维护两个行号游标一个是旧文件的行号一个是新文件的行号。遇到上下文行两个游标同时 1遇到-行旧游标 1遇到行新游标 1。这样解析完我就得到了每个文件“新行号 → 是否改动”的精确映射。有了这个映射规则引擎的执行范围就非常明确了只对“新增或修改过的行”做匹配。被删除的行不需要审查因为代码已经没了上下文行也不需要重复审查因为那是历史代码。这里有个细节值得提一下我们默认用git diff --unified3的上下文行数。上下文行数决定了 diff 里能看到多少“前后文”3 行足够让规则判断一个函数是不是太长、一个错误处理分支是不是结构完整。如果上下行为 0行号映射倒是简单了但像“函数是否过长”这种需要上下文才能判断的规则就完全没法跑。2.3 规则引擎可插拔而不是堆一万条规则规则引擎是 open-code-review 的心脏。我把规则设计成三层模型文件级、行级、块级。文件级规则针对的是文件路径本身。比如团队约定“所有测试文件必须命名为*_test.go”那这条规则就是拿文件路径去做正则匹配判断命名是否符合规范。还有像“禁止在vendor/或者第三方目录下提交变更”这种保护性规则也是典型的文件级规则。行级规则是最常用的针对 diff 里的每一行做匹配。典型的例子不要出现console.log/debugger不要提交print()调试代码错误处理里必须有返回语句禁止魔法数字除了少数白名单数字块级规则设计的稍微复杂一点。它的目标是把若干行聚合成一个“块”然后对块做检查。比如检测“一个函数是否超过 80 行”就需要先根据缩进和大括号把函数边界找出来再做长度判断。块级规则本质上解决的是“单行匹配解决不了的结构性问题”。规则怎么配open-code-review 用的是 YAML 规则描述 处理函数结合的方式。内置规则用 Go 实现性能好自定义规则可以在配置里声明规则参数满足大部分场景。一个基本规则长这样rules: - id: no-console-log level: warning files: - **/*.js - **/*.ts patterns: - console\\.(log|debug|warn|error)\\s*\\( message: 避免提交含有 console 调试日志的代码如需保留请注释说明原因这个配置的意思是对所有 JS/TS 文件如果 diff 新增行中匹配到了console.log(之类的模式就报一个 warning 级别的提示。你可能会问这不就是正则匹配吗和 grep 有什么区别区别在于两点一是只跑在 diff 以后的新增行上而不是整个文件二是规则有结构化输出能对接下游消费。简单说grep 是“找出来给你看”规则引擎是“找出来、分级、生成报告、在 CI 里拦截”。2.4 输出层让人看得懂让机器消费得了规则跑完只完成了一半另一半是报告。open-code-review 设计了三种层次的输出。默认是人类可读的终端输出。扫完直接显示每个文件命中了哪些规则、行号是多少、严重程度是什么、建议怎么改。这个是在本地开发时最常用的形态一眼扫过去就知道该干什么。第二种是 CI annotation 格式。比如在 GitHub Actions 里通过输出::error file{name},line{line}::{message}这样的 workflow commandGitHub 就能在 PR 的文件视图里精确标出问题行。GitLab CI 也可以输出 Code Quality 格式的报告直接在 MR 界面里展示。第三种是 SARIF这是一个静态分析结果的标准交换格式。它的价值在于生态打通SARIF 报告可以被 GitHub 的 code scanning 页面读取也可以被 VS Code 的 SARIF Viewer 插件打开这样开发者在编辑器里就能看到 CI 扫描的结果。这里有一件我踩过坑后才想明白的事审查工具的最后一公里是消费体验。规则写得再好报告让人看不懂或者没法嵌入到现有工作流里工具就没用。这也是为什么 open-code-review 宁可多写一些适配层也要把 SARIF 和 CI workflow command 做扎实。3. 实操从零开始把 open-code-review 接入项目3.1 安装与初始化open-code-review 是编译成单个二进制文件分发的没有运行时依赖装起来非常直接。两种方式任选# 方式一通过 Go 工具链安装 go install github.com/open-code-review/ocr/cmd/ocrlatest # 方式二从 GitHub Releases 页面下载对应平台的二进制 # 然后把二进制放到 PATH 目录下比如 /usr/local/bin装完以后进入你的 git 项目根目录先跑一下初始化命令ocr init这条命令会在当前目录生成一个默认的.ocr.yml配置文件里面预置了几十条通用的内置规则并按严重级别分了组。如果你什么配置都不想动直接跑ocr scan就能开始用了——它会自动检测当前分支相对于 master/main 分支的 diff扫一遍并输出报告。3.2 第一次扫描会看到什么假设你在feature/retry-logic分支上改动了一个 Go 文件提交了代码。运行ocr scan --base origin/main输出大概长这样 open-code-review 报告 (base: origin/main - head: HEAD) 文件: internal/retry/retry.go L131 [warning] no-console-log: 避免提交含有 log 调试日志的代码如需保留请注释说明原因 L132 [error] err-no-return: 错误处理分支中必须 return/continue/break L133 [warning] magic-number: 整数 3 建议定义为命名常量 (MAX_RETRY_COUNT) 共发现 3 个问题error: 1, warning: 2扫描耗时 0.83s我第一次跑的时候被这个效率惊到了不到一秒就扫完了。而同样的仓库用静态分析工具全量扫要差不多半分钟。效率差异的根源就是我们前面说的 diff-driven 设计它跳过了所有没改动的代码。需要注意的是第一次扫描大概率会扫出不少存量问题。比如你之前提交过console.log正好过几天又改了同文件的其他地方diff 里虽然没有新加console.log但文件级规则会扫吗这里我们的实现是只扫新增行所以历史遗留问题不会被揪出来。这个问题后面在“规则误报怎么调”那节我会专门讲。3.3 提交前自检把检查塞进 pre-push本地扫描没问题之后自然想到的问题是怎么让它变成一种“挡在提交前”的检查。我的做法是接入 git 的 pre-push hook。在.git/hooks/pre-push里加这么一段#!/bin/sh set -e echo Running open-code-review check... ocr check --base origin/main --severity error --threshold 0解释一下这条命令的含义ocr check是门禁模式--severity error表示只看 error 级别的问题--threshold 0表示只要发现 1 个 error 就返回非零退出码从而中断 push。如果不满足于 error 级别想强制 warning 也清零把--severity改成 warning 就行。但根据我的实际经验刚开始别太激进先用 error 级别顶着跑两周等团队习惯了这个流程再决定要不要把 warning 也作为门禁。有一点必须强调hook 是存在.git目录里的不会提交到仓库所以团队里每个人要靠自己配。要想强制所有人执行正解是把检查放到 CI 里hook 只是给个人提供快捷反馈。这两个层级不冲突可以都上。3.4 接入 GitHub Actions一份可直接抄的配置本地搞定了最关键的还是让它在 CI 里跑起来让 PR 一提交就自动出报告和门禁。下面这份是我一直在用的 GitHub Actions 配置可以直接复制改一改name: code-review on: pull_request: jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: # 关键必须拉全历史否则 diff 拿不到 base fetch-depth: 0 - name: Download open-code-review run: | curl -sSL https://github.com/open-code-review/ocr/releases/download/v0.4.2/ocr-linux-amd64.tar.gz | tar xz sudo mv ocr /usr/local/bin/ocr - name: Scan and annotate run: | ocr annotate \ --base origin/${{ github.event.pull_request.base.ref }} \ --format github \ --severity warning - name: Enforce quality gate run: | ocr check \ --base origin/${{ github.event.pull_request.base.ref }} \ --severity error \ --threshold 0这里面有两个坑我当初都踩过提一下帮你避开。第一个坑是fetch-depth: 0。GitHub Actions 的 checkout action 默认是浅克隆只拉最新一次提交。浅克隆状态下git diff origin/main根本找不到origin/main这个引用命令会直接报错。加了fetch-depth: 0才把整个仓库历史拉下来diff 才能正常计算。代价是 clone 时间会变长但对于代码审查这种低频操作完全可以接受。第二个坑是--base参数。很多人一开始会写死main但如果你的默认分支是develop或者有其他命名就会算错 diff 甚至报错。最佳实践是从github.event.pull_request.base.ref动态取 PR 的目标分支这样才能保证 diff 范围永远是对的。annotate和check为什么分两步因为用途不同。annotate负责把问题标到 PR 的文件视图上方便人看check负责当门禁有问题就给 CI 标红。两个步骤彼此独立你可以只上 annotate 不上门禁先让团队看看效果再说。3.5 自定义一条团队规则禁止无关联 TODO内置规则再全也满足不了每个团队自己的规范。open-code-review 的自定义规则入口是.ocr.yml我用一个实际案例带你配一条规则。场景是这样的我们的团队要求代码里不允许留裸的TODO所有 TODO 必须带关联的 issue 编号。以前靠人抓经常漏。现在写成规则rules: - id: todo-without-issue level: warning files: - **/*.go - **/*.{ts,tsx,js} patterns: - TODO\\s*[:(] not_patterns: - TODO\\s*[(](GH|JIRA|ISS)-[0-9][)] message: TODO 必须关联 issue 编号格式: TODO(GH-xxx) 或 TODO(JIRA-xxx)配置里有几个字段值得解释一下。patterns是命中条件这里意思是匹配到TODO(或TODO:开头的写法not_patterns是排除条件表示如果后面紧跟了合法的 issue 编号就不报。两个条件叠加的逻辑是先按patterns找到候选行再用not_patterns把合规的过滤掉剩下的就是违规的。我当初设计not_patterns这个字段时犹豫了很久最后发现空规则还真是 needed 的——因为“符合某种规范”的正则往往比“不符合规范”的正则容易写得多。与其写一个复杂且脆弱的正向匹配不如用“宽松匹配 排除合法项”的方式表达规则的可维护性会好很多。写完之后可以先用一行临时文件验证规则是否生效echo // TODO: fix this tmp_test.go ocr scan --base origin/main确认能扫出来之后再把临时文件删掉跑一遍完整扫描看会不会误伤其他文件。4. 常见问题与排查实录4.1 “本地跑没毛病CI 里却报错”base 分支没配对这是接入 CI 时遇到最多的问题。症状是本地跑ocr scan --base origin/main一切正常一到 GitHub Actions 里就提示找不到 base 引用或者扫出来的内容明显不对比如压根没变化也报了问题。排查思路其实一套就能走完先确认 CI 里 clone 仓库时是否拉全了历史fetch-depth: 0有没有加再看 base 分支引用是否存在也就是工作流里传入的origin/${{ github.event.pull_request.base.ref }}是否真的能解析到。还有一个隐蔽点用的 runner 如果曾经缓存过旧版本仓库可能导致origin/main是旧状态需要在 workflow 里加一个干净的工作目录。这类问题 90% 出在“引用不存在”而不是“工具坏了”。所以我在ocr里也加了直观的报错提示——如果 git 命令执行失败会直接把 stderr 打印出来至少能让排查者一眼看到是 git 层面的问题还是规则层面的问题。4.2 换行符导致 diff 疯掉CRLF 的坑Windows 和 Linux 混用的团队会遇到一个非常头疼的问题diff 里每一行都显示被修改明明改动只有一个字符结果整个文件的行全被标记成新增。这是换行符不一致导致的——Windows 用的 CRLF 和 Linux 用的 LF 在 git 看来是不同内容。解决这个问题需要在仓库根目录放一个.gitattributes文件强制文本文件统一换行符* textauto *.go text eollf *.js text eollf *.md text eollf这样无论开发者在哪个平台提交git 在入库时都会把换行符统一成 LF。如果你的仓库已经混入了大量 CRLF 文件最好一次性执行一次换行符规范化提交否则每次 diff 都会被污染。这个清理动作越早做越好拖到后面历史包袱越重。4.3 中文注释和文件名乱码多语言环境的仓库里中文注释很常见。理论上 UTF-8 编码下正则匹配不会出问题。但如果你用 open-code-review 的输出接入某些消费端比如 Windows 下的终端、某些不支持 UTF-8 的 CI 插件就可能看到乱码。我的建议是工具层面默认全部按 UTF-8 处理消费端尽量选支持 UTF-8 的。如果确实有历史遗留的非 UTF-8 文件可以在配置文件的scan节点里排除它们避免误报或乱码干扰报告可读性scan: exclude: - **/legacy/** - **/*.pem顺带提一句规则引擎在处理文件名时是按原始字节解码的所以文件名里如果有特殊字符也不会导致解析崩溃这一点我们在测试里验证过很多次。4.4 规则误报太多怎么调教规则新接入阶段最常见的抱怨是“这工具怎么什么都报”。根据我的经验这不是工具的毛病而是规则默认策略的问题。内置规则按性价比和普适程度分成三档error 级别的通常是“几乎必错”的问题比如错误处理里没返回、debug 语句残留warning 级别的是“最好处理”的问题比如魔法数字、命名不规范info 级别的是“可以参考”的建议比如代码风格上的优化点。如果团队觉得某些规则完全不适合自己不要直接删掉规则而是把它的级别降下去或者加白名单。比如“禁止魔法数字”这种规则争议很大可以改成白名单制rules: - id: magic-number level: warning patterns: - (?!//)\\b[0-9]\\b ignore_lines: - // no-check-magic - time\\.Second\\s*\\*\\s*[0-9]这里ignore_lines的意思是命中正则的行如果本身匹配 ignore_lines 里的模式就直接跳过。这样团队既保留了对魔法数字的约束又给特殊情况留了出口。比直接禁规则要科学得多。还有一个经验值得分享规则的files字段一定要配好。默认不配表示所有文件都适用但有些规则只对特定语言有意义。比如“禁止 console.log”如果配到 Go 文件上那 Go 的log.Println根本不匹配规则白跑不说报告里还会有一些莫名其妙的提示影响阅读。先缩小范围再谈规则调优。4.5 大仓库里扫描慢增量缓存怎么救虽然 diff 驱动已经比全量扫描快很多但如果是超大规模 monorepo涉及几千个文件的大 PR解析 diff 本身也可能消耗可观时间。open-code-review 的解法是增量缓存。基本原理是git 里的每次 diff 都对应一组文件的 blob hash我把每个文件的扫描结果以 blob hash 为 key 缓存起来。下一次扫描时如果发现某个文件在上次扫描后没有被修改过就直接复用缓存结果不再重新跑规则。这个缓存机制带来的提升很客观。我拿一个包含 60 个服务、几十万行代码的 monorepo 测过首次扫描一个跨 12 个服务的大 PR耗时约 8 秒同样的 PR 在清理缓存后再次扫描耗时降到 3 秒左右。虽然第一次还是会慢一点但在 CI 场景下一个分支会反复跑很多次后面的每一次都能吃到缓存红利。缓存默认放在~/.cache/open-code-review/下也可以通过环境变量OCR_CACHE_DIR改路径。排查问题需要全量重新扫描时删掉这个目录就行。5. 落地之后的一些体会把 open-code-review 用到团队工作流里已经有快半年了最大的感受不是“我们抓到了多少 bug”而是 review 的讨论质量明显变了。以前 reviewer 打开 PR要先花几分钟把那些低级问题滤掉然后才有精力看逻辑。现在 PR 一提交机器人先把报告列出来大家直接围绕报告里没有覆盖的部分——也就是真正的设计问题、边界条件、扩展性——展开讨论。那种“低级问题刷屏淹没核心评论”的场景基本没有了。另外一个意外的收获是新成员的代码规范养成速度变快了。以前手写 review comment 教规范效率低还容易漏现在规则直接挡在合并前新同学改两三次就彻底长记性了。最后再分享一个实用技巧open-code-review 的规则配置本身应该作为一种资产沉淀。我会建议团队每季度回顾一次.ocr.yml把那些“反复在 review 里被人提出的问题”固化成新规则。工具的价值不在于第一次配得多全而在于它能随着团队的认知一起进化。这一点我认为比任何单一功能都重要。