通过GitHub API将PR、代码变更与人员关系转化为可查询图谱,无需图数据库即可回答「谁懂这段代码」等工程实际问题。
你接手了一个代码库。
一个叫 checkout.ts 的文件出了问题。
你会问三个问题:
这段代码为什么被改过?
谁真正理解它?
还有哪些文件通常会和它一起改动?
答案可能就在 GitHub 的某个地方。
但它们散落在 Pull Request、Code Review、文件历史记录和人们的记忆里。
在本教程中,我们将把这些点连接起来。
我们将构建一个小型 TypeScript CLI,将真实的 GitHub Pull Request 转化为一张工程图。
最终,你将能够运行这样的命令:
npm run dev -- vercel/ai
npm run dev -- vercel/ai pr 123
npm run dev -- vercel/ai experts "src/example.ts"
npm run dev -- vercel/ai related "src/example.ts"
不需要任何 AI 模型。
只需要 TypeScript、GitHub API,和一个有用的思路:
工程知识在你保留团队已创建事物之间关系的瞬间,变得更有价值。
我们的图将包含四种类型的节点:
我们将用四种关系类型来连接它们:
有趣的是这些连接能让我们发现什么。
person → AUTHORED → pull request → MODIFIED → file
这条路径告诉我们谁有直接修改文件的经验。
这条路径告诉我们谁审查了这次变更:
person → REVIEWED → pull request → MODIFIED → file
而这条路径揭示了一起被修改的文件:
file ← MODIFIED ← pull request → MODIFIED → another file
这就是最实用的图工程。
我们不是在发明新信息。
我们是在让已有的关系变得可见。
要分析的一个公开 GitHub 仓库。
一个可选的 GitHub Token,用于更高的 API 速率限制。
mkdir mini-engineering-graph
cd mini-engineering-graph
npm init -y
npm install --save-dev typescript tsx @types/node
npm pkg set type=module
npm pkg set scripts.dev="tsx src/index.ts"
npm pkg set scripts.test="tsx --test src/graph.test.ts"
mkdir -p src
项目将包含以下文件:
src/types.ts
src/graph.ts
src/github.ts
src/queries.ts
src/index.ts
src/graph.test.ts
要分析私有仓库或提高 GitHub API 配额,请配置一个 Token:
export GITHUB_TOKEN="your_github_token_here"
永远不要提交你的 Token 或将其硬编码到应用程序中。
公开仓库无需 Token 也能工作,但未认证请求限制为每小时 60 次。
默认情况下,我们的项目分析最近最多 10 个已关闭的 Pull Request,并发出最多 21 次 API 请求:
一次请求用于列出 Pull Request。
每个合并的 Pull Request 一次请求,用于列出变更的文件。
每个合并的 Pull Request 一次请求,用于列出 Code Review。
每个 CLI 命令都会重建图,因此重复运行会消耗额外请求。
export type NodeType =
| "repository"
| "pull_request"
| "file"
| "person";
export type RelationshipType =
| "BELONGS_TO"
| "AUTHORED"
| "MODIFIED"
| "REVIEWED";
export type GraphNode = {
id: string;
type: NodeType;
label: string;
metadata: Record<string, unknown>;
};
export type Evidence = {
source: "github";
url: string;
summary: string;
observedAt: string;
};
export type GraphEdge = {
from: string;
to: string;
type: RelationshipType;
evidence: Evidence;
metadata?: Record<string, unknown>;
};
export type GitHubUser = {
login: string;
html_url: string;
};
export type GitHubPullRequest = {
number: number;
title: string;
body: string | null;
html_url: string;
created_at: string;
updated_at: string;
merged_at: string | null;
user: GitHubUser | null;
};
export type GitHubFile = {
filename: string;
status: string;
additions: number;
deletions: number;
};
export type GitHubReview = {
id: number;
state: string;
html_url: string;
submitted_at: string | null;
user: GitHubUser | null;
};
这里有两个重要的概念。
首先,节点和边有不同的职责。
节点代表一个存在的事物:
{
"id": "person:bobby",
"type": "person",
"label": "bobby",
"metadata": {}
}
边代表一种关系:
{
"from": "person:bobby",
"to": "pr:acme/storefront#41",
"type": "AUTHORED"
}
其次,每条边都包含证据。
如果我们的应用程序声称某人发起或审查了一次变更,我们应该能够指向支持该声明的 GitHub 产物。
没有证据,图就可能变成一个精心打磨的猜测集合。
import type {
GraphEdge,
GraphNode,
NodeType,
RelationshipType,
} from "./types.ts";
export class EngineeringGraph {
private readonly nodes = new Map<string, GraphNode>();
private readonly edges = new Map<string, GraphEdge>();
addNode(node: GraphNode): void {
this.nodes.set(node.id, node);
}
addEdge(edge: GraphEdge): void {
if (!this.nodes.has(edge.from) || !this.nodes.has(edge.to)) {
throw new Error(
`Both nodes must exist before adding ${edge.type}`,
);
}
const key = `${edge.from}:${edge.type}:${edge.to}`;
this.edges.set(key, edge);
}
getNode(id: string): GraphNode | undefined {
return this.nodes.get(id);
}
getNodesByType(type: NodeType): GraphNode[] {
return [...this.nodes.values()].filter(
(node) => node.type === type,
);
}
neighbors(
nodeId: string,
relationship: RelationshipType,
direction: "outgoing" | "incoming" = "outgoing",
): Array<{ node: GraphNode; edge: GraphEdge }> {
const matches: Array<{
node: GraphNode;
edge: GraphEdge;
}> = [];
for (const edge of this.edges.values()) {
if (edge.type !== relationship) {
continue;
}
const matchesDirection =
direction === "outgoing"
? edge.from === nodeId
: edge.to === nodeId;
if (!matchesDirection) {
continue;
}
const neighborId =
direction === "outgoing"
? edge.to
: edge.from;
const node = this.nodes.get(neighborId);
if (node) {
matches.push({ node, edge });
}
}
return matches;
}
stats() {
return {
nodes: this.nodes.size,
edges: this.edges.size,
pullRequests: this.getNodesByType("pull_request").length,
files: this.getNodesByType("file").length,
people: this.getNodesByType("person").length,
};
}
}
最重要的方法是 neighbors()。
哪些节点通过特定关系连接到当前节点?
graph.neighbors(
"pr:acme/storefront#41",
"MODIFIED",
);
这会返回该 Pull Request 修改的文件。
反转方向:
graph.neighbors(
"file:acme/storefront:src/checkout.ts",
"MODIFIED",
"incoming",
);
现在你得到的是修改过该文件的 Pull Request。
我们还在添加边之前验证两个节点是否都存在。
一条指向不存在节点的关系不是有用的上下文。它是一个戴着名字标签的 bug。
创建 src/github.ts:
import { EngineeringGraph } from "./graph.ts";
import type {
Evidence,
GitHubFile,
GitHubPullRequest,
GitHubReview,
GitHubUser,
} from "./types.ts";
const API_BASE = "https://api.github.com";
async function github<T>(path: string): Promise<T> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2026-03-10",
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization =
`Bearer ${process.env.GITHUB_TOKEN}`;
}
const response = await fetch(
`${API_BASE}${path}`,
{ headers },
);
if (!response.ok) {
const remaining = response.headers.get(
"x-ratelimit-remaining",
);
const detail = await response.text();
throw new Error(
`GitHub request failed (${response.status}). ` +
`Remaining requests: ${remaining ?? "unknown"}. ` +
detail,
);
}
return (await response.json()) as T;
}
function addPerson(
graph: EngineeringGraph,
user: GitHubUser,
): string {
const personId = `person:${user.login}`;
graph.addNode({
id: personId,
type: "person",
label: user.login,
metadata: {
url: user.html_url,
},
});
return personId;
}
function evidence(
url: string,
summary: string,
observedAt: string,
): Evidence {
return {
source: "github",
url,
summary,
observedAt,
};
}
export async function buildEngineeringGraph(
repository: string,
limit = 10,
): Promise<EngineeringGraph> {
const graph = new EngineeringGraph();
const repositoryId = `repo:${repository}`;
graph.addNode({
id: repositoryId,
type: "repository",
label: repository,
metadata: {
url: `https://github.com/${repository}`,
},
});
这部分内容很多,下面我们逐步讲解重要部分。
## 获取最近关闭的 Pull Request
```typescript
const pulls = await github<GitHubPullRequest[]>(
`/repos/${repository}/pulls` +
`?state=closed` +
`&sort=updated` +
`&direction=desc` +
`&per_page=${limit}`,
);
GitHub 会返回已关闭的 pull request,包括已合并和未合并的变更。
我们只关心实际合入的变更:
const mergedPulls = pulls.filter(
(pull) => pull.merged_at && pull.user,
);
由于是在拉取后做过滤,最终图谱中的 pull request 数量可能少于请求的数量。
生产系统会进行分页,直到收集到所需数量的已合并变更。
对每个 pull request:
graph.addEdge({
from: addPerson(graph, author),
to: pullId,
type: "AUTHORED",
evidence: evidence(
pull.html_url,
`${author.login} authored this change`,
pull.created_at,
),
});
作者不仅仅是附加在 pull request 上的一个字符串。
作者是一个节点,带有关系。
这意味着我们后续可以这样问:
这些答案变成了图遍历,而不是一次性的转换。
每个变更文件都会成为自己的节点:
const fileId =
`file:${repository}:${file.filename}`;
我们在标识符中包含了仓库名称,这样不同项目的文件就不会产生冲突:
file:acme/storefront:src/checkout.ts
file:acme/admin:src/checkout.ts
边上存储了额外的元数据:
metadata: {
status: file.status,
additions: file.additions,
deletions: file.deletions,
}
后续可以用这些信息区分一个微小的文档更新和一个实质性的代码变更。
Review 告诉我们谁审查过某个变更,即使他们不是作者。
这一点很重要,因为原作者并不总是唯一理解某段代码的人。
graph.addEdge({
from: addPerson(graph, review.user),
to: pullId,
type: "REVIEWED",
evidence: evidence(
review.html_url,
`${review.user.login} submitted a ` +
`${review.state} review`,
review.submitted_at,
),
});
我们的图谱按照起始节点、关系类型和目标节点对边进行去重。
如果某人对同一个 pull request 进行了多次 review,后处理的那条会覆盖之前的。
这简化了演示,但同时也意味着我们没有保留完整的 review 时间线。
创建 src/queries.ts:
import { EngineeringGraph } from "./graph.ts";
export function explainPullRequest(
graph: EngineeringGraph,
repository: string,
number: number,
) {
const pullId =
`pr:${repository}#${number}`;
const pull = graph.getNode(pullId);
if (!pull) {
throw new Error(
`Pull request #${number} was not found ` +
`in the indexed history.`,
);
}
return {
title: pull.metadata.title,
reason:
pull.metadata.body ||
"No pull request description was provided.",
url: pull.metadata.url,
authors: graph
.neighbors(
pullId,
"AUTHORED",
"incoming",
)
.map(({ node }) => node.label),
reviewers: graph
.neighbors(
pullId,
"REVIEWED",
"incoming",
)
.map(({ node, edge }) => ({
name: node.label,
state: edge.metadata?.state,
})),
files: graph
.neighbors(
pullId,
"MODIFIED",
)
.map(({ node, edge }) => ({
path: node.label,
additions: edge.metadata?.additions,
deletions: edge.metadata?.deletions,
evidence: edge.evidence.url,
})),
};
}
export function findExperts(
graph: EngineeringGraph,
repository: string,
path: string,
) {
const fileId =
`file:${repository}:${path}`;
const changes = graph.neighbors(
fileId,
"MODIFIED",
"incoming",
);
const people = new Map<
string,
{
name: string;
score: number;
authored: number;
reviewed: number;
evidence: Set<string>;
}
>();
for (const { node: pull } of changes) {
for (const relationship of [
"AUTHORED",
"REVIEWED",
] as const) {
const contributors = graph.neighbors(
pull.id,
relationship,
"incoming",
);
for (const { node: person, edge } of contributors) {
const existing = people.get(person.id) ?? {
name: person.label,
score: 0,
authored: 0,
reviewed: 0,
evidence: new Set<string>(),
};
if (relationship === "AUTHORED") {
existing.score += 3;
existing.authored += 1;
} else {
existing.score += 1;
existing.reviewed += 1;
}
existing.evidence.add(
edge.evidence.url,
);
people.set(
person.id,
existing,
);
}
}
}
return [...people.values()]
.sort(
(left, right) =>
right.score - left.score,
)
.map((person) => ({
...person,
evidence: [...person.evidence],
}));
}
export function findRelatedFiles(
graph: EngineeringGraph,
repository: string,
path: string,
) {
const fileId =
`file:${repository}:${path}`;
const changes = graph.neighbors(
fileId,
"MODIFIED",
"incoming",
);
const related = new Map<
string,
{
path: string;
sharedChanges: number;
evidence: string[];
}
>();
for (const { node: pull } of changes) {
const changedFiles = graph.neighbors(
pull.id,
"MODIFIED",
);
for (const { node: file, edge } of changedFiles) {
if (file.id === fileId) {
continue;
}
const existing = related.get(file.id) ?? {
path: file.label,
sharedChanges: 0,
evidence: [],
};
existing.sharedChanges += 1;
existing.evidence.push(
edge.evidence.url,
);
related.set(
file.id,
existing,
);
}
}
return [...related.values()].sort(
(left, right) =>
right.sharedChanges -
left.sharedChanges,
);
}
这三个查询展示了图谱的价值所在。
explainPullRequest() 函数整合了:
这并不能神奇地恢复一条缺失的解释。
如果拉取请求描述为空,工具会如实说明。
这种诚实很重要。
当源材料不支持时,系统不应该制造虚假信心。
问题 2:谁了解这个文件?
findExperts() 函数从一个文件出发。
它向后追溯到修改过该文件的拉取请求。
然后找到这些拉取请求的作者或审阅者。
我们给出一个简单的评分:
if (relationship === "AUTHORED") {
existing.score += 3;
} else {
existing.score += 1;
}
一次代码提交比一次审阅计分更高。
这是一个启发式方法,而非对专业能力的客观衡量。
一次用心的审阅可能比一次仓促的代码变更揭示出更深的理解。近期性、变更大小、所有权以及对相邻文件的熟悉程度都会改善排名。
但即使是这个简单版本,也回答了一个实际问题:
在改动这个文件之前,我应该找谁?
[
{
"name": "bobby",
"score": 4,
"authored": 1,
"reviewed": 1,
"evidence": [
"https://github.com/acme/storefront/pull/41",
"https://github.com/acme/storefront/pull/40#review-2"
]
},
{
"name": "maya",
"score": 4,
"authored": 1,
"reviewed": 1,
"evidence": [
"https://github.com/acme/storefront/pull/41#review-1",
"https://github.com/acme/storefront/pull/40"
]
}
]
每条推荐都附带证据。
这比让模型猜测谁听起来合格要好得多。
问题 3:随着这个文件变化,还有什么会跟着变?
findRelatedFiles() 函数查找在同一拉取请求中被修改的文件。
[
{
"path": "src/payments.ts",
"sharedChanges": 2,
"evidence": [
"https://github.com/acme/storefront/pull/41/files",
"https://github.com/acme/storefront/pull/40/files"
]
}
]
如果 checkout.ts 和 payments.ts 反复一起变更,这是有用的信息。
共同变更并不等同于已证实的依赖关系。
两个文件出现在同一个拉取请求中可能有多种原因。
它们可能共享一个架构依赖。
它们可能属于同一个功能。
或者有人可能把不相关的清理工作打包进了一个巨大的周五下午拉取请求。
图揭示了一种模式。
确定模式存在的原因需要额外的证据。
第 5 步:构建 CLI
import {
buildEngineeringGraph,
} from "./github.ts";
import {
explainPullRequest,
findExperts,
findRelatedFiles,
} from "./queries.ts";
async function main() {
const [
repository,
command,
...arguments_
] = process.argv.slice(2);
if (
!repository ||
!/^[^/]+\/[^/]+$/.test(repository)
) {
throw new Error(
"Usage: npm run dev -- " +
"owner/repository " +
"[pr|experts|related] [value]",
);
}
const requestedLimit = Number(
process.env.PR_LIMIT ?? "10",
);
if (
!Number.isInteger(requestedLimit) ||
requestedLimit < 1 ||
requestedLimit > 25
) {
throw new Error(
"PR_LIMIT must be an integer " +
"between 1 and 25.",
);
}
console.log(
`Indexing recent merged pull requests ` +
`from ${repository}...`,
);
const graph =
await buildEngineeringGraph(
repository,
requestedLimit,
);
console.log(
"Graph:",
graph.stats(),
);
if (command === "pr") {
const number = Number(
arguments_[0],
);
if (
!Number.isInteger(number) ||
number < 1
) {
throw new Error(
"Provide a valid pull request number.",
);
}
console.dir(
explainPullRequest(
graph,
repository,
number,
),
{ depth: null },
);
return;
}
if (command === "experts") {
const path = arguments_.join(" ");
if (!path) {
throw new Error(
"Provide a file path after experts.",
);
}
console.dir(
findExperts(
graph,
repository,
path,
),
{ depth: null },
);
return;
}
if (command === "related") {
const path = arguments_.join(" ");
if (!path) {
throw new Error(
"Provide a file path after related.",
);
}
console.dir(
findRelatedFiles(
graph,
repository,
path,
),
{ depth: null },
);
return;
}
if (command) {
throw new Error(
`Unknown command: ${command}. ` +
`Use pr, experts, or related.`,
);
}
const pulls = graph.getNodesByType(
"pull_request",
);
if (pulls.length === 0) {
console.log(
"No merged pull requests were found " +
"in the indexed history.",
);
return;
}
const latest = pulls[0];
const changedFile = graph.neighbors(
latest.id,
"MODIFIED",
)[0]?.node.label;
console.log(
"\nIndexed pull requests:",
);
for (const pull of pulls) {
console.log(
`- ${pull.label}`,
);
}
console.log(
`\nTry: npm run dev -- ${repository} ` +
`pr ${latest.metadata.number}`,
);
if (changedFile) {
console.log(
`Try: npm run dev -- ${repository} ` +
`experts "${changedFile}"`,
);
console.log(
`Try: npm run dev -- ${repository} ` +
`related "${changedFile}"`,
);
}
}
main().catch(
(error: unknown) => {
console.error(
error instanceof Error
? error.message
: error,
);
process.exitCode = 1;
},
);
CLI 接受一个仓库和一个可选命令:
npm run dev -- owner/repository
npm run dev -- owner/repository pr 123
npm run dev -- owner/repository experts "src/example.ts"