code-review-graph 的核心并不是某一套现成 API,而是把代码评审过程中散落的关联关系,重新组织成可查询、可可视化、可追溯的图结构。一次代码评审通常会同时产生 Pull Request、提交、评论、被修改文件、评审意见和多个参与者。这些对象之间的关系天然是一张有向图:开发者创建 PR,PR 修改文件,评审人提交 Review,Review 中包含评论,评论又指向具体代码行。如果只把数据保存成关系表,查单个对象的属性很容易;一旦需要回答“哪个文件被最多 PR 修改且被反复评审”“哪些评审人经常共同参与评审”“一个 PR 从创建到合入到底经历了哪些阶段”,SQL 的 join 会越写越复杂,图却能沿着边直接找到答案。
下面以一个最小可运行的 code-review-graph 工程为线索,从 GitHub REST API 拉取 PR 和 Review 数据,把数据转换成图节点和边,再用 Python 完成查询和 HTML 可视化。学习环境使用 networkx 和 pyvis,生产环境可以平滑替换为 Neo4j 等图数据库。
1. 为什么要把代码评审建模成图
1.1 代码评审中天然存在图结构
评审不是一个孤立的审批动作。一个典型的 GitHub PR 流程会同时产生以下对象:
- 创建者创建 PR。
- PR 包含一个或多个提交。
- PR 修改一个或多个文件。
- 被请求的评审人对 PR 提交 Review。
- Review 可以包含多行评论,评论指向具体文件路径或提交。
- 评论之间可能存在回复关系。
这些对象之间的连接关系,比对象本身的属性更有价值。例如,“文件src/core/engine.py在最近 10 个 PR 中被修改,并被 6 位评审人评论过”这个信息,如果只在关系表里按行存,需要多次 join;但把它建模成图,就是一个从File节点出发,经过PullRequest、Review到User节点的两跳路径问题。
图结构的优势在于,它能直接把“人、代码、评审动作”绑定在一起。这里的节点是参与评审的实体,边是实体之间的动作或依赖。边本身还可以带上时间、状态、类型等属性,方便后续做路径分析。
1.2 图模型比关系模型更适合表达评审路径
关系模型擅长等值查询:给定 PR 号,查它的标题、创建人、状态;给定文件名,查它被哪些 PR 修改。这些操作在关系数据库里很直接。
但评审分析更需要的是路径查询和聚合关系。典型问题包括:
- PR 的作者是不是经常和同一个评审人协作?
- 一个文件在被合入前,经历过多少次 Review 状态变化?
- 评论链路中,哪些评论引发了后续修改?
- 一个模块的变更,是否总是集中在少数几个人手里?
这些问题在关系模型里要么需要多表 join,要么需要递归查询,要么需要在应用层做大量拼接。图模型则把“连接”作为一等公民,查询一条路径时只需要沿边遍历,逻辑上更接近业务问题的原始表达。
下面用一个表格对比两种模型在评审场景里的差异:
| 维度 | 关系模型 | 图模型 |
|---|---|---|
| 关联深度 | 多表 join,SQL 越来越复杂 | 沿边遍历,多跳查询直观 |
| 路径分析 | 需要递归 CTE 或应用层拼接 | 原生支持路径遍历 |
| 动态新增关系 | 需要新增外键或关联表 | 增加一种边类型即可 |
| 可视化解释 | 需要前端拼接关系 | 图结构天然可渲染 |
| 适合数据规模 | 任意规模 | 大规模依赖图数据库性能 |
这里并不是说关系模型不能做评审分析,而是说当分析目标从“查属性”转向“查关系”时,图模型的数据组织方式更容易维护,也更容易和可视化工具对接。
1.3 适用读者和最终效果
这篇文章适合以下几类读者:
- 希望把 GitHub 评审数据做成内部看板的研发效能工程师。
- 需要分析跨模块评审瓶颈、文件热点的技术负责人。
- 想用图数据库或图算法解决工程问题的开发者。
- 刚接触 networkx、Neo4j 或图建模的学生。
跑完这个最小工程后,你能得到一份可以保存为 JSON 或 HTML 的评审关系图,并且能回答以下问题:
- 哪些文件被评审频率最高?
- 哪些用户承担了最多的 Review?
- 一个文件修改后,评审链路大概多长?
先弄清楚数据从哪里来,再谈图建模,否则后续所有分析都会受字段缺失和数据清洗不彻底的影响。
2. 评审数据从哪里来,字段如何设计
2.1 基于 GitHub 评审事件的最小数据集合
在从零构建 code-review-graph 时,不需要一开始就拉全 GitHub 的所有事件,只需要覆盖评审链路中的核心对象。以下是最小数据集合:
| 对象 | 关键字段 | 用途 |
|---|---|---|
| PullRequest | number, title, state, user.login, created_at, merged_at, requested_reviewers | 评审的基本单元 |
| Review | id, user.login, submitted_at, state, commit_id | 记录评审人、时间和结论 |
| ReviewComment | id, user.login, path, line, created_at, in_reply_to_id | 行内评论和回复关系 |
| Commit | sha, commit.author.date, commit.author.name | 关联提交时间线 |
| Repo | owner, name, default_branch | 区分数据来源仓库 |
| File | filename, additions, deletions, changes | 记录被修改文件及改动规模 |
这些字段已经足够构建出一张有价值的评审图。PR 节点是核心枢纽,Review 和 File 分别从人和代码两个维度连接到 PR 上。Commit 可以用来补充时间线。
2.2 字段设计与归一化
从 API 拿到的原始 JSON 字段通常没有直接建模成图,需要先做归一化。否则同一个用户,可能因为大小写不同被当成两个节点;同一个文件,可能因为路径格式差异出现重复。
字段归一化建议遵循以下规则:
- 所有 ID 统一转成字符串,避免后续数字和字符串类型混用。
- 时间字段统一为 ISO 8601,并保存成 UTC 时间。
- 用户名在存储时转成小写作为唯一 ID,展示名单独保留原始大小写。
- 文件路径统一使用仓库内绝对路径,不包含
a/、b/前缀。 - ReviewComment 如果
in_reply_to_id存在,说明它是某条评论的回复,不要当作独立评论对待。
这些规则看起来琐碎,但直接决定图数据质量。图查询的结果是否可信,首先取决于节点 ID 是否稳定。
2.3 三种采集方式对比
构建评审图的数据来源可以有多种方式,不同方式适合不同阶段:
| 方式 | 使用场景 | 优点 | 注意 |
|---|---|---|---|
| GitHub REST API 拉取 | 一次性构建历史评审图 | 实现简单,思路直观 | 有速率限制,需要做分页和缓存 |
| Webhook 订阅 | 实时增量更新 | 能持续接收新事件 | 需要独立服务接收并落库 |
| 企业导出归档 | 跨仓库大规模分析 | 可离线批量处理 | 不同平台格式差异大,清洗成本高 |
对学习环境来说,用 REST API 拉取一个小型仓库、几百个 PR 就足够了。生产环境如果仓库数量多,建议用 Webhook 增量同步,同时保留全量重建的能力。
3. 设计 code review graph 的节点和边
3.1 节点类型与属性
评审图最基础的节点类型包括用户、PR、Review、文件、提交和仓库。节点属性应该保持精简,不要让图承载所有原始数据。
| 节点类型 | 属性示例 | 说明 |
|---|---|---|
| User | id, login, display_name | 评审参与人 |
| PullRequest | id, number, title, state, created_at, merged_at | 一次评审单元 |
| Repo | id, owner, name | 仓库信息 |
| File | id, path | 被修改文件路径 |
| Commit | id, sha, message, authored_at, author_id | 提交记录 |
| Review | id, state, submitted_at | 评审记录 |
| ReviewComment | id, body, path, line, created_at | 行内评论 |
节点 ID 需要唯一稳定。比如 User 节点用user:login,File 节点用file:owner/repo:path,PullRequest 节点用pr:owner/repo:number。如果只用login或path作为 ID,将来接入多个仓库时很容易冲突。
3.2 边类型与方向
边是评审图最有价值的部分。边应表达明确动作,并带上方向。
| 边类型 | 方向 | 含义 |
|---|---|---|
| AUTHORED | User -> PullRequest | 用户创建了 PR |
| MODIFIES | PullRequest -> File | PR 修改了文件 |
| SUBMITTED | User -> Review | 用户提交了评审 |
| REVIEWS | Review -> PullRequest | 评审作用于 PR |
| COMMENTS_ON | ReviewComment -> File | 评论指向文件 |
| REQUESTS | User -> User | 请求某人评审 |
| HAS_COMMIT | PullRequest -> Commit | PR 包含该提交 |
这些边已经能覆盖大部分评审分析问题。如果需要分析评论回复关系,还可以增加REPLIES_TO边,方向从回复评论指向原评论。
设计边时要注意:一条边只承担一个语义,不要把一个边同时既表达“创建”又表达“修改”。多种动作混杂在一个边类型里,后续查询时会很难处理。
3.3 一份最小图数据示例
下面是一份最小的图数据 JSON 示例,展示三个节点和两条边的关系结构。
{ "nodes": [ {"id": "user:alice", "type": "User", "login": "alice"}, {"id": "pr:test-repo:42", "type": "PullRequest", "number": 42, "state": "merged"}, {"id": "file:test-repo:src/core/engine.py", "type": "File", "path": "src/core/engine.py"} ], "edges": [ {"source": "user:alice", "target": "pr:test-repo:42", "type": "AUTHORED"}, {"source": "pr:test-repo:42", "target": "file:test-repo:src/core/engine.py", "type": "MODIFIES"} ] }这份数据可以直接导入 networkx,也可以转换成 Cypher 语句写入 Neo4j。关键点是节点 ID 必须全局唯一,边通过 source 和 target 引用节点 ID。
4. 环境准备:先搭建一个可运行的图分析环境
4.1 技术选型:内存图还是图数据库
在 code-review-graph 的最初版本里,可以选择内存图,也可以选择图数据库。两者的取舍主要看数据量和查询复杂度。
| 维度 | networkx | Neo4j |
|---|---|---|
| 数据量 | 适合几千节点以内 | 适合百万级节点 |
| 查询方式 | Python 代码遍历 | Cypher 查询 |
| 持久化 | 需要手动序列化 | 原生持久化 |
| 部署成本 | 进程内,零额外服务 | 独立服务,需要运维 |
| 学习成本 | 低 | 较高 |
| 生产可用性 | 适合分析和原型 | 适合持续服务 |
学习环境推荐先用 networkx,因为它安装简单,方便打印节点和边,也能直接输出可视化数据。生产环境如果要做跨仓库、长期分析,建议用 Neo4j 或 NebulaGraph 这类原生图数据库。
4.2 创建 Python 虚拟环境并安装依赖
这里以一个独立的虚拟环境为例。创建目录并安装依赖:
mkdir code-review-graph cd code-review-graph python -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install networkx requests python-dateutil pyvis如果在 Windows 下激活虚拟环境,命令是:
.venv\Scripts\activate安装完成后,可以检查版本:
python -c "import networkx, requests; print(networkx.__version__, requests.__version__)"4.3 环境检查清单
开始写代码前,先按这份清单检查环境,避免后边排错浪费时间。
- Python 版本大于等于 3.9。
- 已配置
GITHUB_TOKEN环境变量,且具有仓库的读取权限。 - 当前网络可以访问 GitHub API,或者已经配置企业 GitHub Enterprise 的 API 地址。
- 已安装 networkx、requests、pyvis。
- 如果要本地可视化,确保浏览器可以打开 HTML 文件。
- 如果使用 Neo4j,检查 Neo4j 服务是否启动,端口 7687 是否可用。
5. 编码实现:从 GitHub API 到图对象
5.1 用 GitHub REST API 拉取 PR 和评审
GitHub REST API 的接口路径比较稳定。以拉取仓库 PR 为例,核心接口是:
GET /repos/{owner}/{repo}/pulls GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews GET /repos/{owner}/{repo}/pulls/{pull_number}/files在代码中,最好把请求逻辑封装成函数,方便复用。
import os import requests API_BASE = os.getenv("GITHUB_API_BASE", "https://api.github.com") TOKEN = os.getenv("GITHUB_TOKEN", "") HEADERS = { "Authorization": f"Bearer {TOKEN}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } def fetch_all_pull_requests(owner, repo, state="all", max_pages=5): pulls = [] for page in range(1, max_pages + 1): url = f"{API_BASE}/repos/{owner}/{repo}/pulls" params = { "state": state, "per_page": 100, "page": page, } resp = requests.get(url, headers=HEADERS, params=params, timeout=30) resp.raise_for_status() page_data = resp.json() pulls.extend(page_data) if len(page_data) < params["per_page"]: break return pulls def fetch_reviews(owner, repo, pull_number): url = f"{API_BASE}/repos/{owner}/{repo}/pulls/{pull_number}/reviews" resp = requests.get(url, headers=HEADERS, timeout=30) resp.raise_for_status() return resp.json() def fetch_pull_request_files(owner, repo, pull_number): url = f"{API_BASE}/repos/{owner}/{repo}/pulls/{pull_number}/files" resp = requests.get(url, headers=HEADERS, timeout=30) resp.raise_for_status() return resp.json()请求时设置了per_page=100,这是 GitHub API 分页时单页返回数量的上限。max_pages用于控制拉取范围,防止一次性拉太多请求触发速率限制。
在实际使用中,不要对仓库的每一个 PR 都立即请求 reviews 和 files,否则请求数会等于 PR 数的三倍。可以先拉出 PR 列表,再只对最近、最大或抽样后的 PR 请求详情。
5.2 将 API 数据转换成图节点和边
拿到 API 数据后,下一步是构建 networkx 有向图。节点 ID 要带上类型前缀,避免不同类型节点重名。
import networkx as nx from collections import Counter def build_graph(owner, repo, pulls): G = nx.DiGraph() for pr in pulls: pr_id = f"pr:{owner}/{repo}:{pr['number']}" pr_author = pr["user"]["login"] if pr.get("user") else "unknown" user_id = f"user:{pr_author}" G.add_node(user_id, type="User", login=pr_author) G.add_node( pr_id, type="PullRequest", number=pr["number"], title=pr.get("title"), state=pr.get("state"), created_at=pr.get("created_at"), merged_at=pr.get("merged_at"), ) G.add_edge(user_id, pr_id, type="AUTHORED") for file_info in fetch_pull_request_files(owner, repo, pr["number"]): file_id = f"file:{owner}/{repo}:{file_info['filename']}" G.add_node(file_id, type="File", path=file_info["filename"]) G.add_edge(pr_id, file_id, type="MODIFIES") for review in fetch_reviews(owner, repo, pr["number"]): reviewer = review["user"]["login"] if review.get("user") else "unknown" reviewer_id = f"user:{reviewer}" review_id = f"review:{review['id']}" G.add_node(reviewer_id, type="User", login=reviewer) G.add_node( review_id, type="Review", state=review.get("state"), submitted_at=review.get("submitted_at"), ) G.add_edge(reviewer_id, review_id, type="SUBMITTED") G.add_edge(review_id, pr_id, type="REVIEWS") return G这段代码展示了核心建模思路,但还不能直接用于大数据量仓库。原因是fetch_pull_request_files和fetch_reviews对每个 PR 都会发起额外请求,在真实分析时建议先本地落盘一份 API 数据,再从本地文件构建图。
5.3 关键参数说明
| 参数 | 默认值 | 影响 |
|---|---|---|
| state | open | 控制拉取 PR 状态,建议使用 all 做全量分析 |
| per_page | 100 | 单页返回数量,越大请求次数越少 |
| max_pages | 5 | 控制拉取范围,防止请求爆炸 |
| GITHUB_API_BASE | https://api.github.com | 企业版环境需要替换为自己的 API 地址 |
| GITHUB_TOKEN | 空 | 不配置时匿名请求速率限制很低 |
5.4 执行示例
完成图构建后,可以先打印节点和边数量,确认数据已经进入图模型。
pulls = fetch_all_pull_requests("owner", "repo", state="all", max_pages=2) G = build_graph("owner", "repo", pulls) print(f"nodes: {G.number_of_nodes()}") print(f"edges: {G.number_of_edges()}")输出类似:
nodes: 123 edges: 205这里的数字只是示例。真正的输出取决于仓库规模和max_pages的取值。
6. 查询和可视化:让图产生价值
6.1 用图查询发现评审瓶颈
图建好之后,可以用 networkx 查询热度和关系。下面这段代码找出了被最多 PR 修改的文件,以及提交评审最多的用户。
from collections import Counter # 找被最多 PR 修改的文件 file_review_count = Counter() for pr_id, file_id, edge_data in G.edges(data=True): if edge_data.get("type") == "MODIFIES": file_review_count[file_id] += 1 top_files = file_review_count.most_common(10) # 找提交评审最多的用户 reviewer_counter = Counter() for reviewer_id, review_id, edge_data in G.edges(data=True): if edge_data.get("type") == "SUBMITTED": reviewer_counter[reviewer_id] += 1 top_reviewers = reviewer_counter.most_common(10)如果数据已经导入 Neo4j,同样的问题可以用 Cypher 表达,语法更适合多跳查询:
MATCH (u:User)-[:SUBMITTED]->(r:Review)-[:REVIEWS]->(pr:PullRequest)-[:MODIFIES]->(f:File) RETURN f.path, count(DISTINCT pr) AS pr_count, count(r) AS review_count ORDER BY review_count DESC LIMIT 10;这里f.path是文件路径,pr_count表示关联的 PR 数,review_count表示评审次数。从这个结果能快速看出哪些文件在评审中最受关注。
6.2 用 pyvis 生成关系可视化页面
networkx 适合做分析,但直接输出 HTML 需要用 pyvis。pyvis 可以把 networkx 的图对象转成交互式网页。
from pyvis.network import Network net = Network(height="750px", width="100%", directed=True) for node, attr in G.nodes(data=True): net.add_node(node, label=node, title=str(attr.get("type", ""))) for u, v, edge_data in G.edges(data=True): net.add_edge(u, v, title=edge_data.get("type", "")) net.show("review_graph.html")节点较多时,可以先筛选度最高的 200 个节点,再可视化。否则生成的 HTML 文件会很大,浏览器渲染也会卡顿。
6.3 输出结果示例
分析结果可以整理成表格,例如:
Top files by review attention: file:owner/repo:src/core/engine.py PR: 12 Review: 18 file:owner/repo:src/api/auth.py PR: 8 Review: 14 file:owner/repo:tests/test_auth.py PR: 6 Review: 9这种输出适合放在内部看板,也适合用来定位模块负责人的评审压力。
7. 从报错到数据异常:常见问题排查
7.1 GitHub API 返回 403 或 rate limit
现象:
HTTP 403 { "message": "API rate limit exceeded for user..." }可能原因:
- 未配置
GITHUB_TOKEN。 - 匿名请求的速率限制非常低。
- 对每个 PR 都请求 reviews 和 files,请求数膨胀。
检查方式:
- 查看响应头
X-RateLimit-Remaining和X-RateLimit-Limit。 - 打印实际请求 URL 和状态码。
- 检查
Authorization头是否被正确设置。
处理建议:
- 配置
GITHUB_TOKEN,将 token 放入环境变量。 - 先拉取 PR 列表,再对抽样 PR 请求详情。
- 一旦拉取过,把原始 API 数据保存为本地 JSON,后续从本地读取。
7.2 节点和边重复,图越来越大
现象:
- 同一个用户出现两个节点,比如
alice和Alice。 - 同一个 PR 文件被添加多次边。
- 图节点数量增长远超预期。
可能原因:
- 节点 ID 没有统一规范化。
- 没有在添加边前检查边是否已存在。
- 每次运行都重新构建图,没有合并历史数据。
检查方式:
- 统计同类型节点数量,和预期对比。
- 打印一部分节点 ID 和属性,观察大小写或格式差异。
- 检查
G.edges()中是否有重复的(u, v, type)组合。
处理建议:
- 定义统一的节点 ID 生成规则,例如
user:、pr:、file:前缀。 - 用户名统一转小写,文件路径统一去掉
a/和b/前缀。 - 在构建图之前先做去重,而不是在构建后清理。
7.3 时间字段解析异常
现象:
datetime比较时报错。- 时区混用导致按天统计不准确。
可能原因:
- GitHub 返回的是 ISO 8601 字符串,如
2023-09-14T08:30:00Z。 - 直接使用字符串比较,导致排序错误。
- 没有把所有时间统一到 UTC。
检查方式:
- 打印
created_at和submitted_at字段的原始值。 - 检查是否有
+08:00或Z等时区后缀混合出现。
处理建议:
- 使用
datetime.fromisoformat或python-dateutil的parser.parse解析时间。 - 解析后统一转换到 UTC 时区。
- 存储时使用 ISO 字符串,展示时再转换为本地时区。
7.4 可视化时页面卡死
现象:
- 打开
review_graph.html后浏览器卡顿或白屏。 - 生成的 HTML 文件超过几十 MB。
可能原因:
- 图里包含数千个节点和上万条边。
- 每个节点都添加了完整 title 和 label,造成 HTML 体积过大。
检查方式:
- 打印
G.number_of_nodes()和G.number_of_edges()。 - 查看 HTML 文件大小。
处理建议:
- 只选择度高、PageRank 排名靠前的节点进行可视化。
- 将节点数量控制在 200 到 500 之间。
- 生产环境可以用 Gephi 或 Neo4j Bloom 渲染更大规模图。
8. 从最小工程到生产级评审分析
8.1 分层架构设计
生产级的 code-review-graph 不应只在内存里跑。建议按以下层次拆分:
| 层次 | 职责 | 技术选项 |
|---|---|---|
| 采集层 | 拉取 PR、Review、Comment、Commit 数据 | GitHub API、Webhook |
| 加工层 | 字段清洗、节点 ID 生成、边去重 | Python、Spark |
| 存储层 | 保存图结构 | Neo4j、NebulaGraph、networkx + JSON |
| 查询分析层 | 提供查询接口 | Cypher、Gremlin、Python |
| 展示层 | 呈现关系图和指标 | pyvis、Gephi、内部看板 |
采集层和存储层分离后,Webhook 增量数据可以先写消息队列,再由加工层异步写入图数据库,避免每次分析都全量拉取 API。
8.2 增量同步与历史重建
增量同步需要记住一个游标。GitHub PR 对象的updated_at字段可以作为增量更新依据,Webhook 事件也可以携带时间戳。
建议记录两个状态:
last_sync_time:最后一次增量同步时间。last_full_build_time:最后一次全量构建时间。
每次增量任务开始时,拉取updated_at > last_sync_time的 PR,再对新 PR 请求 reviews 和 files。产生新边和新节点后,统一 merge 到图存储中。
全量重建用于修正历史数据错误。生产环境建议定期执行全量构建,并和增量结果做对账。
8.3 权限、隐私与审计
评审数据包含代码路径、评论内容和评审人信息,不能不加控制地公开。内部工具至少要做到:
- 只有项目成员能查看对应仓库的评审图。
- 对评论正文做脱敏或只保留统计信息。
- 保留数据在哪个时间点从哪个 API 拉取的审计日志。
- 删除个人账号数据时,能够通过用户 ID 级联清理相关节点和边。
图数据库的权限控制比关系数据库复杂,因为可能通过多条路径关联到敏感数据。上线前要按角色测试访问范围。
8.4 用图持续改进研发流程
评审图本质上是研发流程的观测数据。从图中可以得到几类关键指标:
- 文件热点:被修改多且被评审多的文件,可能需要拆分模块。
- 评审集中度:少数人承担大量评审,团队风险高。
- 评审响应时间:从 PR 创建到首次 Review 的时间,看流程是否阻塞。
- 协作网络:评审人之间是否形成稳定配合,是否存在单人孤岛。
把这些指标做成趋势图,比只看单个 PR 的评审状态更有价值。code-review-graph 的最终收益,不是生成一张看起来复杂的关系大图,而是让评审数据变成可以直接回答工程问题的结构。
先从小仓库、少量 PR 开始,把数据采集、图建模、查询可视化跑通,再逐步接入更多仓库和自动化分析。实际项目中,最容易被低估的是数据质量和关联关系的一致性:字段不统一、节点重复、时区混乱,都会让图查询结果失真。先把这些基础问题解决,图分析才能真正服务于研发流程改进。