批量 PR Review —— refs/pull/*/head + 并行 Codex,批量审查
简介
如果你管理着一个活跃的开源项目或大型企业代码库,你可能遇到过这样的场景:
- 周一早上回来,发现仓库里有 15 个新的 PR 等着你审查
- 发布窗口临近,需要快速审查一批紧急修复 PR
- 团队规模扩大,每天的 PR 数量从几个增长到几十个
逐个审查这些 PR 是极其低效的。 即便每个 PR 只需要 30 分钟,15 个 PR 也需要 7.5 小时。
批量自动化审查是解决方案。 利用 GitHub 的 refs/pull/*/head 引用和 Codex CLI 的并行能力,我们可以在几分钟内完成初步审查,然后人工只需关注 AI 标记的"需要关注"的 PR。
本文将教你如何构建一个高效的批量 PR 审查系统。
一、GitHub Pull Request 引用机制
1.1 什么是 refs/pull?
GitHub 为每个仓库提供了一个特殊的引用空间 refs/pull/,包含了所有 PR 的信息:
# 查看所有 PR 引用
git ls-remote https://github.com/org/repo.git | grep refs/pull
# 输出示例:
# abc123 refs/pull/1/head # PR #1 的最新提交
# def456 refs/pull/1/merge # PR #1 的合并提交
# ghi789 refs/pull/2/head # PR #2 的最新提交
# jkl012 refs/pull/3/head # PR #3 的最新提交1.2 refs/pull 的两种引用
refs/pull/{N}/head → PR 分支的最新提交(作者提交的代码)
refs/pull/{N}/merge → 与目标分支的预合并提交(GitHub 计算的合并结果)审查场景下通常使用 /head:
┌──────────────────────────────────────────────┐
│ /head vs /merge 对比 │
├──────────────────────────────────────────────┤
│ /head │
│ - 包含作者提交的实际代码 │
│ - 反映 PR 的真实变更 │
│ - 适用于代码审查 │
│ │
│ /merge │
│ - GitHub 自动计算的合并结果 │
│ - 用于检查合并是否冲突 │
│ - 适用于合并前验证 │
│ - 如果 PR 未开启自动合并,可能不存在 │
└──────────────────────────────────────────────┘1.3 获取 PR 内容
# 方法 1:使用 git fetch 获取特定 PR
git fetch origin refs/pull/123/head:pr-123
git checkout pr-123
# 方法 2:直接查看 diff
git fetch origin refs/pull/123/head
git diff HEAD..FETCH_HEAD
# 方法 3:使用 GitHub API
curl -s https://api.github.com/repos/org/repo/pulls/123/files
# 方法 4:使用 gh CLI
gh pr view 123 --json files,title,body
gh pr diff 1231.4 批量获取 PR 列表
# 使用 gh CLI 获取所有开放 PR
gh pr list --state open --json number,title,author,labels
# 获取前 20 个 PR
gh pr list --state open --limit 20 --json number,title,author
# 获取特定作者的所有 PR
gh pr list --state open --author username --json number,title
# 获取带有特定 label 的 PR
gh pr list --state open --label "needs-review" --json number,title二、批量审查架构设计
2.1 系统架构
┌─────────────────────────────────────────────────────────┐
│ 批量 PR 审查系统 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ PR 发现 │───→│ 任务分发 │───→│ Codex 集群 │ │
│ │ │ │ │ │ │ │
│ │ gh pr │ │ 按优先级 │ │ 并行 Codex │ │
│ │ list │ │ 排序 │ │ 实例池 │ │
│ └──────────┘ └───────────┘ └──────┬───────┘ │
│ │ │
│ ┌────▼───────┐ │
│ │ 结果聚合 │ │
│ │ │ │
│ │ 报告生成 │ │
│ │ 优先级排序 │ │
│ └────┬───────┘ │
│ │ │
│ ┌────▼───────┐ │
│ │ 人工审核 │ │
│ │ │ │
│ │ 只看红色 │ │
│ │ 标记的 PR │ │
│ └────────────┘ │
└─────────────────────────────────────────────────────────┘2.2 工作流程
1. 发现 PR → gh pr list 获取所有待审查 PR
2. 过滤 → 按 label/author/size 过滤
3. 排序 → 按优先级排序(安全 > Bug > Feature)
4. 分发 → 为每个 PR 创建临时工作目录
5. 审查 → 并行启动 Codex 实例审查每个 PR
6. 聚合 → 收集所有审查结果
7. 报告 → 生成汇总报告
8. 清理 → 删除临时文件三、实战:批量 PR 审查系统
3.1 核心代码
import json
import os
import time
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class PRInfo:
"""PR 信息"""
number: int
title: str
author: str
labels: List[str] = field(default_factory=list)
body: str = ""
files: List[dict] = field(default_factory=list)
review_status: str = "pending"
review_result: Optional[dict] = None
@dataclass
class BatchReviewConfig:
"""批量审查配置"""
repo: str
base_dir: str = "/opt/data/batch-pr-reviews"
max_parallel: int = 3
timeout_per_pr: int = 600
pr_limit: int = 20
label_filter: Optional[str] = None
author_filter: Optional[str] = None
class BatchPRReviewer:
"""批量 PR 审查器"""
def __init__(self, config: BatchReviewConfig):
self.config = config
self.base_dir = config.base_dir
self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.session_dir = f"{self.base_dir}/session-{self.timestamp}"
self.review_prompt = self._build_review_prompt()
def _build_review_prompt(self) -> str:
"""构建审查 prompt"""
return """你是资深代码审查专家。请对以下 PR 进行审查。
审查重点:
1. 安全问题:SQL 注入、XSS、CSRF、命令注入、路径遍历、敏感数据泄露
2. 逻辑错误:边界条件、空值处理、类型错误
3. 代码质量:可读性、复杂度、重复代码、命名
4. 最佳实践:错误处理、资源管理、API 设计
5. 性能:N+1 查询、不必要的循环、内存泄漏
输出格式:
## 审查结果
### 严重程度
[🔴 严重 / 🟡 警告 / 🟢 通过]
### 问题列表
[具体问题描述,包含文件路径和行号]
### 修复建议
[具体的修复建议]
### 合并建议
[可以合并 / 需要修改 / 需要讨论]"""
def discover_prs(self) -> List[PRInfo]:
"""发现待审查的 PR"""
print(f"📋 正在获取 PR 列表...")
# 构建 gh pr list 命令
cmd_parts = [
f"gh pr list",
f"--state open",
f"--limit {self.config.pr_limit}",
f"--json number,title,author,labels,body,files"
]
if self.config.label_filter:
cmd_parts.append(f"--label {self.config.label_filter}")
cmd = " ".join(cmd_parts)
result = terminal(command=cmd, timeout=60)
if result.exit_code != 0:
print(f"❌ 获取 PR 列表失败: {result.output}")
return []
pr_data = json.loads(result.output)
prs = []
for data in pr_data:
pr = PRInfo(
number=data["number"],
title=data["title"],
author=data["author"]["login"],
labels=[l["name"] for l in data.get("labels", [])],
body=data.get("body", ""),
files=data.get("files", [])
)
prs.append(pr)
print(f"✅ 发现 {len(prs)} 个待审查 PR")
return prs
def prioritize_prs(self, prs: List[PRInfo]) -> List[PRInfo]:
"""按优先级排序 PR"""
def priority_score(pr: PRInfo) -> int:
score = 0
# 安全相关 PR 优先级最高
security_labels = {"security", "vulnerability", "cve", "hotfix"}
if any(l.lower() in security_labels for l in pr.labels):
score += 100
# Bug 修复次之
bug_labels = {"bug", "fix", "bugfix"}
if any(l.lower() in bug_labels for l in pr.labels):
score += 50
# 紧急标签
if any("urgent" in l.lower() or "critical" in l.lower() for l in pr.labels):
score += 75
# 文件数少的优先(快速审查)
score += max(0, 20 - len(pr.files))
return score
sorted_prs = sorted(prs, key=priority_score, reverse=True)
print("\n📊 PR 优先级排序:")
for i, pr in enumerate(sorted_prs):
score = priority_score(pr)
label_str = ", ".join(pr.labels) if pr.labels else "无标签"
print(f" {i+1}. PR #{pr.number}: {pr.title[:50]} (优先级: {score}, 标签: {label_str})")
return sorted_prs
def review_single_pr(self, pr: PRInfo) -> dict:
"""审查单个 PR"""
pr_dir = f"{self.session_dir}/pr-{pr.number}"
print(f"\n{'='*50}")
print(f"🔍 开始审查 PR #{pr.number}: {pr.title}")
print(f"{'='*50}")
# 创建 PR 工作目录
terminal(command=f"mkdir -p {pr_dir}", timeout=10)
# 使用 gh pr checkout 检出 PR(一步克隆+检出)
clone_checkout = terminal(
command=f"cd {self.session_dir} && gh pr checkout {pr.number} --clone pr-{pr.number}",
timeout=180
)
if clone_checkout.exit_code != 0:
print(f"❌ PR #{pr.number} 检出失败: {clone_checkout.output}")
return {
"pr_number": pr.number,
"status": "failed",
"error": clone_checkout.output
}
# 获取 PR diff
diff = terminal(
command=f"cd {pr_dir} && git diff --stat HEAD~1..HEAD",
timeout=30
)
print(f"变更: {diff.output.strip()}")
# 构建定制化的 prompt
prompt = self.review_prompt + f"""
PR 信息:
- 标题: {pr.title}
- 作者: {pr.author}
- 标签: {', '.join(pr.labels)}
- 描述: {pr.body[:300] if pr.body else '无'}"""
# 启动 Codex 审查
session = terminal(
command=f"cd {pr_dir} && codex exec -p \"{prompt}\"",
background=True,
pty=True,
timeout=self.config.timeout_per_pr,
notify_on_complete=True
)
print(f"🤖 Codex 审查已启动 (Session: {session.session_id})")
# 等待审查完成
result = process(
action="wait",
session_id=session.session_id,
timeout=self.config.timeout_per_pr
)
# 获取审查输出
log = process(
action="log",
session_id=session.session_id,
limit=500
)
# 保存审查报告
report_path = f"{pr_dir}/review-report.md"
with open(report_path, 'w') as f:
f.write(f"# PR #{pr.number} 审查报告\n\n")
f.write(f"- 标题: {pr.title}\n")
f.write(f"- 作者: {pr.author}\n")
f.write(f"- 时间: {datetime.now().isoformat()}\n\n")
f.write(log)
print(f"📝 审查报告已保存: {report_path}")
return {
"pr_number": pr.number,
"status": "completed",
"exit_code": result.exit_code,
"report_path": report_path,
"review_output": log
}
def run_batch_review(self) -> dict:
"""执行批量审查"""
# 创建会话目录
terminal(command=f"mkdir -p {self.session_dir}", timeout=10)
# 发现 PR
prs = self.discover_prs()
if not prs:
print("没有需要审查的 PR")
return {"total": 0, "results": []}
# 排序
sorted_prs = self.prioritize_prs(prs)
# 并行审查
print(f"\n🚀 启动并行审查(最多 {self.config.max_parallel} 个并发)...")
results = []
active_sessions = {}
# 启动第一批
prs_to_review = list(sorted_prs)
while prs_to_review or active_sessions:
# 填充到最大并发数
while len(active_sessions) < self.config.max_parallel and prs_to_review:
pr = prs_to_review.pop(0)
session = terminal(
command=f"cd {self.session_dir} && gh pr checkout {pr.number} --clone pr-{pr.number} && cd pr-{pr.number} && codex exec -p \"{self.review_prompt} PR信息: 标题={pr.title}, 作者={pr.author}, 标签={', '.join(pr.labels)}\"",
background=True,
pty=True,
timeout=self.config.timeout_per_pr,
notify_on_complete=True
)
active_sessions[session.session_id] = pr
print(f" 🚀 PR #{pr.number} -> Session {session.session_id}")
if not active_sessions:
break
# 等待任意一个完成
time.sleep(10)
for sid, pr in list(active_sessions.items()):
status = process(action="poll", session_id=sid)
if status.status != "running":
# 获取结果
log = process(action="log", session_id=sid, limit=500)
pr_dir = f"{self.session_dir}/pr-{pr.number}"
report_path = f"{pr_dir}/review-report.md"
with open(report_path, 'w') as f:
f.write(f"# PR #{pr.number} 审查报告\n\n")
f.write(f"- 标题: {pr.title}\n")
f.write(f"- 作者: {pr.author}\n\n")
f.write(log)
results.append({
"pr_number": pr.number,
"title": pr.title,
"status": "completed",
"report_path": report_path,
"output_preview": log[:500]
})
print(f" ✅ PR #{pr.number} 审查完成")
del active_sessions[sid]
# 生成汇总报告
summary = self.generate_summary(results)
return {
"total": len(sorted_prs),
"completed": len(results),
"results": results,
"summary_path": summary
}
def generate_summary(self, results: List[dict]) -> str:
"""生成汇总报告"""
summary_path = f"{self.session_dir}/BATCH-REVIEW-SUMMARY.md"
with open(summary_path, 'w') as f:
f.write(f"# 批量 PR 审查汇总报告\n\n")
f.write(f"生成时间: {datetime.now().isoformat()}\n")
f.write(f"审查数量: {len(results)}\n\n")
f.write("## 审查结果概览\n\n")
f.write("| PR # | 标题 | 状态 | 报告 |\n")
f.write("|------|------|------|------|\n")
for r in results:
f.write(f"| #{r['pr_number']} | {r['title'][:40]} | {r['status']} | [报告]({r['report_path']}) |\n")
f.write(f"\n## 详细审查结果\n\n")
for r in results:
f.write(f"---\n\n")
f.write(f"### PR #{r['pr_number']}: {r['title']}\n\n")
if r.get("output_preview"):
f.write(f"{r['output_preview']}\n\n")
print(f"\n📊 汇总报告已生成: {summary_path}")
return summary_path
def cleanup(self):
"""清理临时文件"""
print(f"\n🗑️ 清理临时文件...")
# 保留报告,只删除克隆的仓库
# terminal(command=f"find {self.session_dir} -name '.git' -type d -exec rm -rf {{}} \\;")
print(f"📁 审查报告保留在: {self.session_dir}")3.2 使用示例
# 配置
config = BatchReviewConfig(
repo="org/my-project",
base_dir="/opt/data/batch-pr-reviews",
max_parallel=3, # 同时审查 3 个 PR
timeout_per_pr=600, # 每个 PR 最多 10 分钟
pr_limit=20, # 最多审查 20 个 PR
label_filter="needs-review" # 只审查标记为 needs-review 的 PR
)
# 创建审查器
reviewer = BatchPRReviewer(config)
# 执行批量审查
result = reviewer.run_batch_review()
# 查看汇总
print(f"\n{'='*60}")
print(f"📊 批量审查完成")
print(f"{'='*60}")
print(f"总 PR 数: {result['total']}")
print(f"已完成: {result['completed']}")
print(f"汇总报告: {result['summary_path']}")四、高级功能
4.1 基于 refs/pull 的直接 diff 审查
对于简单审查,可以不克隆整个仓库,直接获取 diff:
def quick_diff_review(pr_number: int) -> str:
"""快速获取 PR diff 并进行审查(不克隆仓库)"""
review_dir = f"/opt/data/quick-review-{pr_number}"
terminal(command=f"mkdir -p {review_dir}", timeout=10)
# 直接获取 PR diff
diff = terminal(
command=f"gh pr diff {pr_number}",
timeout=60
)
# 保存 diff 到文件
diff_path = f"{review_dir}/pr-{pr_number}.diff"
with open(diff_path, 'w') as f:
f.write(diff.output)
# 让 Codex 基于 diff 文件进行审查
prompt = f"""请审查以下 PR 的 diff 内容。
审查重点:
1. 安全问题
2. 逻辑错误
3. 代码质量
4. 潜在 Bug
diff 文件路径: {diff_path}
请读取 diff 文件并进行审查。"""
session = terminal(
command=f"cd {review_dir} && codex exec -p \"{prompt}\"",
background=True,
pty=True,
timeout=300,
notify_on_complete=True
)
result = process(action="wait", session_id=session.session_id, timeout=300)
log = process(action="log", session_id=session.session_id)
return log4.2 审查结果自动分类
def classify_review(review_output: str) -> str:
"""根据审查输出自动分类"""
output_lower = review_output.lower()
# 检查严重程度标记
if "🔴" in review_output or "严重" in output_lower or "critical" in output_lower:
return "🔴 需要立即关注"
if "🟡" in review_output or "警告" in output_lower or "warning" in output_lower:
return "🟡 建议修改后合并"
if "🟢" in review_output or "通过" in output_lower or "approved" in output_lower:
return "🟢 可以合并"
# 基于关键词判断
security_keywords = ["注入", "xss", "csrf", "泄露", "injection", "vulnerability"]
if any(kw in output_lower for kw in security_keywords):
return "🔴 存在安全问题"
if "需要修改" in output_lower or "需要修复" in output_lower or "request changes" in output_lower:
return "🟡 需要修改"
if "可以合并" in output_lower or "approve" in output_lower:
return "🟢 可以合并"
return "⚪ 需要人工判断"4.3 自动发布审查评论
def auto_comment_batch(results: List[dict]):
"""批量发布审查评论到各个 PR"""
for r in results:
pr_number = r["pr_number"]
report_path = r["report_path"]
# 生成简洁的评论
comment_cmd = f"""gh pr comment {pr_number} --body "$(cat << 'EOF'
# 🤖 AI 代码审查报告
> 此审查由 Codex CLI 自动生成,仅供参考。
## 审查结果
{classify_review(open(report_path).read())}
详细报告请查看完整输出。
---
*AI Reviewer | {datetime.now().strftime('%Y-%m-%d %H:%M')}*
EOF
)"""
result = terminal(command=comment_cmd, timeout=30)
if result.exit_code == 0:
print(f"💬 评论已发布到 PR #{pr_number}")
else:
print(f"❌ 评论发布失败 PR #{pr_number}: {result.output}")五、完整端到端示例
#!/usr/bin/env python3
"""
批量 PR 审查工具
用法: python batch_review.py --limit 10 --parallel 3 --label needs-review
"""
import argparse
import json
import sys
from datetime import datetime
def main():
parser = argparse.ArgumentParser(description="批量 PR 审查工具")
parser.add_argument("--limit", type=int, default=20, help="最大审查 PR 数量")
parser.add_argument("--parallel", type=int, default=3, help="最大并行数")
parser.add_argument("--timeout", type=int, default=600, help="每个 PR 超时(秒)")
parser.add_argument("--label", type=str, default=None, help="按标签过滤")
parser.add_argument("--author", type=str, default=None, help="按作者过滤")
parser.add_argument("--comment", action="store_true", help="自动发布评论")
args = parser.parse_args()
print(f"🤖 批量 PR 审查工具")
print(f"{'='*40}")
print(f"PR 限制: {args.limit}")
print(f"并行数: {args.parallel}")
print(f"超时: {args.timeout}s")
print(f"标签过滤: {args.label or '无'}")
print(f"自动评论: {'是' if args.comment else '否'}")
print(f"{'='*40}\n")
# 配置
config = BatchReviewConfig(
repo="org/my-project",
max_parallel=args.parallel,
timeout_per_pr=args.timeout,
pr_limit=args.limit,
label_filter=args.label,
author_filter=args.author
)
# 执行
reviewer = BatchPRReviewer(config)
result = reviewer.run_batch_review()
# 输出汇总
print(f"\n{'='*40}")
print(f"📊 审查汇总")
print(f"{'='*40}")
print(f"总 PR 数: {result['total']}")
print(f"已完成: {result['completed']}")
print(f"汇总报告: {result['summary_path']}")
# 自动评论
if args.comment and result['completed'] > 0:
print(f"\n💬 正在发布评论...")
auto_comment_batch(result['results'])
print(f"\n✅ 批量审查完成!")
if __name__ == "__main__":
main()六、性能优化建议
6.1 并发控制
┌──────────────────────────────────────────────────┐
│ 并发数调优建议 │
├──────────────────────────────────────────────────┤
│ │
│ 1-2 个实例: │
│ - 适合资源受限的环境 │
│ - 适合大型 PR(复杂代码库) │
│ │
│ 3-5 个实例: │
│ - 推荐的默认值 │
│ - 平衡速度和资源消耗 │
│ - 适合大多数场景 │
│ │
│ 5+ 个实例: │
│ - 需要充足的 CPU 和内存 │
│ - 注意 GitHub API 速率限制 │
│ - 注意 Codex API 速率限制 │
│ │
│ 监控指标: │
│ - CPU 使用率 │
│ - 内存使用 │
│ - 磁盘 I/O │
│ - API 调用频率 │
└──────────────────────────────────────────────────┘6.2 速率限制处理
def handle_rate_limit():
"""处理 GitHub API 和 Codex API 的速率限制"""
# 检查 GitHub API 速率限制
rate_check = terminal(
command="gh api rate_limit",
timeout=30
)
# 如果接近限制,暂停
if rate_check.exit_code == 0:
rate_data = json.loads(rate_check.output)
remaining = rate_data.get("resources", {}).get("core", {}).get("remaining", 0)
if remaining < 10:
print("⚠️ GitHub API 速率限制即将到达,等待重置...")
time.sleep(60)6.3 增量审查
只审查有变更的 PR,跳过已经审查过的:
def get_already_reviewed(session_dir: str) -> set:
"""获取已经审查过的 PR 编号"""
reviewed = set()
import glob
for report in glob.glob(f"{session_dir}/pr-*/review-report.md"):
pr_num = int(report.split("pr-")[1].split("/")[0])
reviewed.add(pr_num)
return reviewed
# 在 discover_prs 后过滤
already_reviewed = get_already_reviewed(self.session_dir)
prs_to_review = [pr for pr in prs if pr.number not in already_reviewed]
print(f"跳过 {len(already_reviewed)} 个已审查的 PR")总结
本文深入讲解了批量 PR 审查的完整方案:
- refs/pull 机制:理解 GitHub 的 PR 引用空间,
/headvs/merge - 批量审查架构:从发现、排序、分发到聚合的完整流程
- 实战代码:完整的
BatchPRReviewer类,支持并行审查、报告生成 - 高级功能:快速 diff 审查、自动分类、自动评论、增量审查
- 性能优化:并发控制、速率限制处理、资源监控
核心要点:refs/pull/*/head + gh pr checkout --clone + 并行 Codex 实例 = 高效的批量 PR 审查系统。让 AI 做第一轮筛选,人类 Reviewer 只需关注标记为"需要关注"的 PR,审查效率可以提升 5-10 倍。
下篇预告
下一篇:《Codex CLI 与 CI/CD 集成 —— 在 GitHub Actions 中运行 Codex》
我们将学习如何将 Codex CLI 集成到 CI/CD 流水线中,实现自动化的代码修复、文档生成、依赖更新等任务。包括 GitHub Actions 配置、触发条件、错误处理和结果提交。敬请期待!