AI 生成代码的关键问题不是能否通过一次编译,而是变更能否被稳定地审查、验证和回滚。本文把 Git Diff、测试选择、风险分级和 CI 门禁串成一条变更流水线,让每个阻断结果都有依据,也让人机协作留下可追溯记录。
1. 先定义“进入主干”的变更路径
AI 生成代码进入主干时,至少会经过四个阶段:提交、自动分析、人工审查和合并。不要把 AI 当作一个特殊的代码来源,单独增加一套完全不同的流程;更实用的做法是记录生成来源,再使用统一的质量规则处理变更。
一个可执行的路径如下:
- 开发者让 AI 生成或修改代码,并在本地运行格式化、静态检查和相关测试。
- 提交 Pull Request,CI 获取目标分支与当前提交之间的 Git Diff。
- 流水线按照变更文件、代码行数、敏感目录和测试结果计算风险等级。
- 风险较低的变更执行定向测试,风险较高的变更扩大测试范围,并要求额外审批。
- 所有门禁通过后合并;合并记录保留变更摘要、测试范围、风险信号和操作者信息。
这里的重点是“变更”而不是“作者”。AI 生成的代码可以和人工代码使用同一套编译、测试和安全规则,但应额外记录 generated_by_ai、使用的工具、提示词版本或任务编号等元数据。元数据不应替代审查,也不应该成为允许绕过测试的理由。
2. 用 Git Diff 建立变更分级
测试选择的输入应该来自结构化的 Diff 信息,而不是依赖提交消息中的主观描述。至少需要收集以下字段:文件状态、路径、增加和删除的行数、是否包含二进制文件、是否触及配置或迁移目录。
可以先采用四级模型:
| 等级 | 典型变更 | 默认测试 | 合并要求 |
|---|---|---|---|
| L0 | 文档、注释、纯格式调整 | Markdown 检查或不运行业务测试 | 一名审查者 |
| L1 | 单模块业务逻辑、小范围重构 | 受影响模块测试、静态检查 | 一名熟悉模块的审查者 |
| L2 | 公共接口、数据库查询、依赖升级 | 相关模块测试、集成测试、安全检查 | 代码所有者审批 |
| L3 | 认证授权、支付、数据迁移、生产配置 | 全量测试、构建、部署前验证 | 至少两名审查者和变更负责人 |
分级不是为了制造精确的“风险分数”,而是为了把默认动作写清楚。实际项目中还需要处理叠加规则:同时修改公共接口和数据库迁移时,取更高等级;修改文件数量超过阈值时,至少提升一级;删除测试文件、关闭 CI 检查或修改权限策略时,直接进入人工复核。
下面的脚本使用 Python 标准库解析 git diff --name-status 和 git diff --numstat。它不依赖特定 CI 平台,可以在本地或流水线中运行。执行前需要在 Git 仓库中提供两个提交或引用,例如 main...HEAD。
#!/usr/bin/env python3
import json
import subprocess
import sys
from pathlib import PurePosixPath
def git(*args):
result = subprocess.run(
['git', *args],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return result.stdout.splitlines()
def classify(base):
names = git('diff', '--name-status', f'{base}...HEAD')
stats = git('diff', '--numstat', f'{base}...HEAD')
files = []
risk = 0
signals = []
for line in names:
parts = line.split('\t')
status = parts[0]
path = parts[-1]
files.append({'status': status, 'path': path})
suffix = PurePosixPath(path).suffix.lower()
if path.startswith(('auth/', 'payments/', 'migrations/')):
risk = max(risk, 3)
signals.append(f'sensitive_path:{path}')
elif suffix in {'.sql', '.tf', '.yml', '.yaml'}:
risk = max(risk, 2)
signals.append(f'infrastructure_or_schema:{path}')
elif suffix in {'.py', '.go', '.java', '.js', '.ts'}:
risk = max(risk, 1)
if status.startswith('D') and suffix in {'.py', '.go', '.java', '.js', '.ts'}:
risk = max(risk, 2)
signals.append(f'deleted_source:{path}')
added = 0
deleted = 0
for line in stats:
parts = line.split('\t')
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
added += int(parts[0])
deleted += int(parts[1])
if len(files) > 20 or added + deleted > 500:
risk = min(3, max(risk, 2))
signals.append('large_diff')
level = f'L{risk}'
return {
'level': level,
'files_changed': len(files),
'lines_added': added,
'lines_deleted': deleted,
'signals': sorted(set(signals)),
'files': files,
}
if __name__ == '__main__':
if len(sys.argv) != 2:
raise SystemExit('usage: select_change.py BASE_REF')
print(json.dumps(classify(sys.argv[1]), ensure_ascii=False, indent=2))
这个脚本只负责收集事实和给出初始等级,不负责决定是否合并。最终规则应在 CI 中明确表达,避免把隐藏在脚本中的判断变成无法解释的黑盒。
3. 根据影响范围选择测试
测试选择可以分成三层。第一层是始终运行的快速检查,例如 git diff --check、格式检查、类型检查和依赖锁文件校验。第二层是受影响模块测试,根据路径映射到测试目录。第三层是全量测试,通常由高风险信号、较大的 Diff 或定时任务触发。
路径到测试的映射应尽量靠近代码仓库的结构,并且可被审查。例如,修改 services/order/ 时运行 tests/order/;修改共享库时运行依赖它的模块;修改数据库迁移时至少运行数据库集成测试。不要仅依据文件扩展名决定测试范围,因为同一个 .py 文件可能是业务逻辑、脚本或测试夹具。
一个 GitHub Actions 的简化示例如下。假设仓库包含前面的 tools/select_change.py,Python 项目使用 pytest,并且在 pyproject.toml 中声明了测试依赖。
name: change-gate
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
level: ${{ steps.change.outputs.level }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- id: change
shell: bash
run: |
python tools/select_change.py origin/main > change.json
level=$(python -c "import json; print(json.load(open('change.json'))['level'])")
echo "level=$level" >> "$GITHUB_OUTPUT"
cat change.json
- name: Diff whitespace check
run: git diff --check origin/main...HEAD
targeted-tests:
needs: analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install -e '.[test]'
- name: Run tests
run: pytest -q
full-tests:
needs: analyze
if: needs.analyze.outputs.level == 'L2' || needs.analyze.outputs.level == 'L3'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install -e '.[test]'
- run: pytest -q
示例中的 targeted-tests 为了保持完整性运行了全部 pytest。在真实仓库里,可以先根据 Diff 生成测试路径,再把路径传给 pytest;如果无法可靠建立映射,宁可扩大测试范围,也不要宣称测试覆盖了全部影响。测试选择结果应作为 CI 工件保存,包含原始 Diff 引用、选择原因和实际执行的命令。
4. 设计可解释的风险门禁
风险门禁需要回答三个问题:触发了什么信号、需要采取什么动作、谁可以解除阻断。规则应使用稳定且容易复现的输入,不要把模型的自然语言评价直接作为合并条件。
常见的阻断规则包括:
| 风险信号 | 阻断或升级动作 | 解释记录 |
|---|---|---|
| 修改认证、授权或密钥处理代码 | 至少 L3,全量测试,要求代码所有者审批 | 列出具体路径和规则编号 |
| 修改数据库迁移或删除字段 | 阻止直接合并,要求迁移兼容性检查 | 记录迁移文件和上下游版本 |
| 修改生产部署配置 | 执行配置校验和部署预演 | 记录配置差异及环境 |
| 删除或绕过测试、覆盖率门禁 | 直接阻断,除非有明确例外审批 | 记录被删除的检查项 |
| 依赖升级涉及运行时或安全补丁 | 执行依赖扫描和集成测试 | 记录锁文件差异 |
| 大规模新增或删除代码 | 提升一级并要求拆分或额外审查 | 记录行数和文件数 |
门禁状态最好分为 pass、fail 和 manual_review,不要只有一个布尔值。fail 表示客观检查未通过,例如测试失败或存在高危漏洞;manual_review 表示规则发现了需要人工判断的风险,例如一次性迁移脚本。人工解除也要留下审查者、理由、时间和关联工单,而不是直接重新运行流水线覆盖记录。
AI 生成代码还应增加两个审查维度:第一,是否引入了未经确认的外部依赖、网络访问或数据处理路径;第二,是否生成了看似完整但没有测试覆盖的边界逻辑。审查者可以要求 AI 给出变更摘要和假设,但摘要不能代替 Diff。真正的依据仍是代码、测试和运行时行为。
5. 审计、合并与回滚设计
审计记录应能回答“谁在什么时候,以什么上下文,生成并合并了什么”。建议将以下信息写入 Pull Request 或 CI 工件:提交 SHA、目标分支、Diff 统计、风险等级、风险信号、测试命令及结果、审查者、AI 工具名称和版本、生成任务标识,以及人工修改说明。提示词可能包含敏感信息,不应默认写入公开日志;可以保存脱敏后的任务摘要或内部引用。
合并策略同样影响回滚成本。AI 生成的批量变更应尽量拆成职责单一、可独立回滚的提交,例如先增加兼容代码,再切换调用方,最后删除旧路径。涉及数据库时采用向后兼容的展开与收缩步骤,先部署可同时识别新旧格式的代码,再迁移数据,最后删除旧逻辑。不要把代码改动、不可逆数据操作和配置切换塞进一个无法拆解的提交。
合并后还需要观察实际运行结果。对于高风险变更,保留功能开关、灰度比例或快速回滚版本,并把异常指标与发布记录关联。回滚预案至少应写明回滚提交、是否需要反向迁移、如何处理已经写入的新数据,以及谁有权限执行。没有验证过的回滚命令不能算作回滚设计。
总结
AI 生成代码进入主干,核心不是增加一层“AI 专用审查”,而是把生成来源纳入一条可验证的变更流水线:
- 用 Git Diff 收集文件、行数和敏感路径等客观事实。
- 根据变更范围、文件类型和风险信号划分 L0 到 L3 等级。
- 始终运行快速检查,再按影响范围选择定向测试或全量测试。
- 将阻断规则写成可复现的条件,并区分失败与人工复核。
- 保存 Diff、测试、审批和生成上下文,保证后续可以审计。
- 通过小提交、兼容迁移和功能开关降低回滚成本。
这套机制不会保证 AI 生成的代码没有缺陷,但能让缺陷更早暴露,让阻断理由可以解释,也让问题进入主干后仍然有清晰的恢复路径。
评论