Skip to content

第 19 章 · 实战三:代码审查 Agent

本章目标:构建一个 PR 触发的自动代码审查 Agent——在沙箱里跑测试验证改动,按安全/性能/命名/测试覆盖四维检查单产出结构化评审意见,分级输出 GitHub Review,并掌握防噪音策略让开发者愿意看它的评论。

19.1 目标与触发链路

第 17 章的 Triage Agent 处理 bug 报告,本章的 Reviewer Agent 处理每一次 Pull Request

text
PR 打开/更新 → webhook 触发 → 拉取 diff(分片过滤)
           → 沙箱检出代码 + 跑测试
           → 四维检查单审查(安全/性能/命名/测试覆盖)
           → 分级评审意见(blocker / suggestion / praise)
           → 提交 GitHub Review(blocker 才阻止合并)

与分诊实战的关键差异:审查是高频、低容忍噪音的场景——一条无用的机器人评论就会让团队关掉整个集成。因此本章一半篇幅在“怎么审”,另一半在“怎么不乱说话”。

触发入口用 Flue 的 Channels 接收 GitHub webhook,路由进 Agent 会话:

typescript
// channels/github.ts — PR 事件入口:验证签名后派发给 Reviewer 会话
import { dispatch } from '@flue/runtime';

export async function onPullRequest(payload: any, signature: string) {
  // 先验签再处理,防伪造请求(密钥来自环境变量,不硬编码)
  verifyWebhookSignature(payload, signature, process.env.GH_WEBHOOK_SECRET);

  // 只关心打开与更新两种动作;关闭/合并不触发审查
  if (!['opened', 'synchronize'].includes(payload.action)) return;

  const pr = payload.pull_request.number;
  const repo = payload.repository.full_name;
  // 派发进 Agent 会话:把 PR 编号作为任务上下文传入
  await dispatch('reviewer', `审查 ${repo} 的 PR #${pr}`, {
    env: { REPO: repo },   // 注入给工具层使用
  });
}

19.2 工具层:tools/review.ts(一):拉取 diff

三个类型化工具覆盖审查所需的全部外部动作。先看只读的 diff 获取:

typescript
// tools/review.ts — 工具一:拉取 PR 变更内容
'use flue-tool';
import { defineTool } from '@flue/runtime';
import { exec } from '@flue/runtime/node';

// 拉取 PR 的变更文件列表与 diff 片段
export const getDiff = defineTool({
  name: 'get_diff',
  description: '获取 PR 变更文件列表;指定 path 时返回该文件的 diff 内容',
  inputSchema: {
    type: 'object',
    properties: {
      pr: { type: 'number', description: 'PR 编号' },
      path: { type: 'string', description: '可选,只看某个文件的 diff' },
    },
    required: ['pr'],
  },
  async execute({ pr, path }: { pr: number; path?: string }) {
    // gh api 只读拉取,不做任何写操作
    if (path) {
      const { stdout } = await exec(`gh api repos/${process.env.REPO}/pulls/${pr}/files --jq '.[] | select(.filename=="${path}") | .patch'`);
      return { file: path, patch: stdout };
    }
    const { stdout } = await exec(
      `gh api repos/${process.env.REPO}/pulls/${pr}/files --jq '.[] | {file: .filename, adds: .additions, dels: .deletions}'`
    );
    return { files: stdout.split('\n').filter(Boolean).map(JSON.parse) };
  },
});

再实现两个有副作用的动作:跑测试验证改动、提交分级评审。

typescript
// tools/review.ts(续)— 工具二与三:跑测试、发 Review
'use flue-tool';
import { defineTool } from '@flue/runtime';
import { exec } from '@flue/runtime/node';

// 在沙箱里执行测试套件,验证改动是否破坏现有行为
export const runTests = defineTool({
  name: 'run_tests',
  description: '在本地沙箱运行 npm test,返回通过/失败摘要',
  inputSchema: { type: 'object', properties: {}, required: [] },
  async execute() {
    const { code, stdout } = await exec('npm test -- --reporter=dot', { timeout: 300_000 });
    // 把原始日志压成结构化结论,省掉模型阅读全量日志的 token
    const failed = code !== 0;
    const failedFiles = stdout.split('\n').filter(l => l.startsWith('FAIL')).slice(0, 10);
    return { passed: !failed, failedFiles };
  },
});

// 以 GitHub Review 形式提交分级评审意见
export const postReview = defineTool({
  name: 'post_review',
  description: '提交 PR Review:body 为总评,comments 为逐行意见,event 决定 APPROVE 或 REQUEST_CHANGES',
  inputSchema: {
    type: 'object',
    properties: {
      pr: { type: 'number' },
      body: { type: 'string', description: '总评(Markdown)' },
      event: { type: 'string', enum: ['APPROVE', 'REQUEST_CHANGES'] },
      comments: {
        type: 'array',
        description: '逐行意见列表',
        items: {
          type: 'object',
          properties: {
            path: { type: 'string' },
            line: { type: 'number' },
            body: { type: 'string' },
          },
          required: ['path', 'line', 'body'],
        },
      },
    },
    required: ['pr', 'body', 'event'],
  },
  async execute({ pr, body, event, comments = [] }: any) {
    // 唯一的写操作入口:所有评论必须走这里,便于审计与限流
    await exec(`gh api repos/${process.env.REPO}/pulls/${pr}/reviews -f body=${JSON.stringify(body)} -f event=${event} -f comments=${JSON.stringify(comments)}`);
    return { posted: true, inlineCount: comments.length };
  },
});

19.3 审查技能:skills/review-checklist/SKILL.md

把团队的审查标准固化为可复用知识包,而不是散落在提示词里:

markdown
# 代码审查检查单

按以下四个维度逐一检查 diff,每个维度至少给出"通过"或具体问题。

## 一、安全(最高优先级)
- 用户输入是否直接拼接进 SQL / shell 命令 / HTML?(注入类漏洞)
- 新增的 API 端点是否校验了认证与权限?
- 密钥、token 是否被硬编码或写入日志?
- 依赖升级是否引入了已知 CVE?(可用 npm audit 快查)

## 二、性能
- 循环内是否有 N+1 查询或重复计算?
- 大集合操作是否考虑了分页 / 流式处理?
- 同步阻塞调用是否会卡住事件循环?

## 三、命名与可读性
- 命名是否表达意图而非实现?(`flag2` 是坏味道)
- 函数是否超过 50 行 / 圈复杂度明显过高?
- 与项目既有风格是否一致?

## 四、测试覆盖
- 新增逻辑是否有对应测试?
- 边界条件(空输入、超长输入、并发)是否覆盖?
- 若改动破坏了现有测试,必须指出失败用例名。

## 输出纪律
- 每条意见标注级别:[blocker] 必须修复 / [suggestion] 建议改进 / [praise] 值得肯定
- 不确定的问题用疑问句提出,不要断言
- 总评不超过 200 字,先说结论再说细节

注意 SKILL.md 里同时写了怎么检查怎么说——后者正是自动审查最容易翻车的地方。

19.4 组装审查 Agent

typescript
// agents/reviewer.ts — PR 自动审查 Agent
'use agent';
import { useModel, useSandbox, useSkill, useTool } from '@flue/runtime';
import { local } from '@flue/runtime/node';
import checklist from '../skills/review-checklist/SKILL.md';
import { getDiff, runTests, postReview } from '../tools/review.ts';

export function Reviewer() {
  // 审查需要较强推理能力,但不需要最强模型——检查单已经收敛了行为
  useModel('anthropic/claude-sonnet-4-6');
  // 必须有沙箱:runTests 要真实执行未知代码
  useSandbox(local());
  useSkill(checklist);
  useTool(getDiff);
  useTool(runTests);
  useTool(postReview);

  return `
你是严格的代码审查者。对当前 PR 执行以下流程:

1. 调用 get_diff 获取变更文件清单;
2. 跳过 lock 文件与自动生成文件后,逐个查看核心文件的完整 diff;
3. 调用 run_tests 验证改动后测试套件的状态;
4. 按 review-checklist 的四个维度逐项检查,每维度给出结论;
5. 汇总为 Review 并调用 post_review 提交:
   - 存在任何 blocker → event 用 REQUEST_CHANGES
   - 否则 → APPROVE,总评里列出 suggestion 与 praise。

硬性规则:
- 只评论 diff 中实际出现的代码,不要臆测未修改的文件;
- 每条意见必须引用具体行号与问题代码片段;
- 测试失败时优先报告失败用例名,再推测原因。
`;
}

19.5 分级评审:让 blocker 才挡路

分级的核心价值是区分信号强度。落地时约定:

级别含义对合并的影响
[blocker]安全漏洞、破坏测试、数据丢失风险REQUEST_CHANGES,必须处理
[suggestion]可读性、性能优化空间、更惯用的写法显示在 Review 里,作者自行决定
[praise]巧妙的设计、高质量的测试正向反馈,降低"机器人都挑刺"的抵触感
typescript
// scripts/review-local.ts — 本地试跑:对一个已有 PR 执行审查
import { createRuntime } from '@flue/runtime/node';

const runtime = createRuntime();
const session = await runtime.startAgent('./agents/reviewer.ts');

// 先用一个历史 PR 干跑,观察输出质量再接入 webhook
const result = await session.prompt(`审查 PR #42,仓库 ${process.env.REPO}`);
console.log(result.text);

// 干跑通过后再允许 postReview 真实提交(工具内部已限流)
await runtime.shutdown();

上线前务必先用历史 PR 干跑一周:统计 blocker 的准确率,误报率高就回头改检查单,而不是直接挂到主干流程上。

19.6 防噪音:三条硬约束

自动审查失败的常见原因不是漏报,而是刷屏导致被静音。三条约束写进指令与工具层:

typescript
// tools/review-filter.ts — 在工具层强制过滤,比指望模型自觉更可靠
'use flue-tool';
import { defineTool } from '@flue/runtime';

const SKIP_PATTERNS = [
  /package-lock\.json$/, /pnpm-lock\.yaml$/, /yarn\.lock$/,   // lock 文件
  /\.snap$/, /__snapshots__\//,                                 // 快照
  /dist\/|build\/|\.min\./,                                     // 构建产物
];

// 判断文件是否值得人工级审查
export const isReviewable = (file: string) =>
  !SKIP_PATTERNS.some(p => p.test(file));

// 大 diff 分片:单文件超过 400 行变更时按 hunk 分批审查
export const shouldSplit = (adds: number, dels: number) => adds + dels > 400;

export const splitByHunks = defineTool({
  name: 'split_by_hunks',
  description: '超大 diff 按变更块切分,返回带上下文行号的分片列表',
  inputSchema: {
    type: 'object',
    properties: { path: { type: 'string' }, pr: { type: 'number' } },
    required: ['path', 'pr'],
  },
  async execute({ path, pr }: { path: string; pr: number }) {
    const raw = await getDiff.execute({ pr, path });
    const patch = String((raw as any).patch ?? '');
    // 按 @@ 行切分 hunks,每片自带行号上下文
    return patch.split(/\n(?=@@)/).map((hunk, i) => ({ id: i, hunk }));
  },
});

配合 Agent 指令里的两条软约束:

  1. 总量上限:单次 Review 内联意见 ≤ 10 条,超出的合并进总评一句话带过;
  2. 沉默权:某维度完全通过时不产出该维度的意见——"没话说"本身就是有效结论。

本章小结

  • 审查链路:webhook 触发 → getDiff 过滤 → 沙箱 runTests → 四维检查单 → postReview 分级提交;
  • 检查单 Skill 同时定义「怎么检查」与「怎么说」,是控制评审质量的核心资产;
  • 只有 blocker 触发 REQUEST_CHANGES,suggestion/praise 保持团队对机器人的信任;
  • 防噪音靠工具层硬过滤(lock 文件/快照/构建产物)+ 分片审查 + 内联意见总量上限;
  • 上线前用历史 PR 干跑校准误报率,再接入主干流程。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 为什么 postReview 要设计成唯一的写操作入口?

2. 关于三级评审,哪一级会阻止 PR 合并?

3. 为什么 lock 文件要在工具层直接跳过,而不是写在提示词里让模型自己判断?

4. 上线前用历史 PR"干跑"的主要目的是什么?

🛠️ 动手实践

  1. 为 Reviewer 增加"重复意见记忆":同一文件上次审查已提过的 suggestion 不再重复(提示:用会话持久化保存上次的意见哈希)。
  2. isReviewable 增加 .env 与迁移文件白名单规则,并思考这两类文件应该"跳过"还是"换更严格的方式审查"。
  3. 用一个开源仓库的历史 PR 干跑 10 次,统计 blocker 准确率,据此修订你的 SKILL.md。

返回课程导学