DEV Community

Bobby Hall Jr
Bobby Hall Jr

Posted on

Build a Mini Engineering Graph With TypeScript and GitHub

You inherit a codebase.

A file called checkout.ts is causing problems.

You ask three questions:

  1. Why was this code changed?
  2. Who actually understands it?
  3. What other files usually change alongside it?

The answers probably exist somewhere in GitHub.

But they are scattered across pull requests, reviews, file histories, and people’s memories.

In this tutorial, we are going to connect those dots.

We will build a small TypeScript CLI that turns real GitHub pull requests into an engineering graph.

By the end, you will be able to run commands like these:

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"
Enter fullscreen mode Exit fullscreen mode

No graph database.

No vector database.

No AI model required.

Just TypeScript, GitHub’s API, and one useful idea:

Engineering knowledge becomes more valuable when you preserve the relationships between the things your team already creates.

Table of Contents

  1. What We Are Building
  2. Project Setup
  3. Step 1: Define the Graph
  4. Step 2: Build an In-Memory Graph
  5. Step 3: Turn GitHub Activity Into Relationships
  6. Step 4: Ask the Graph Useful Questions
  7. Step 5: Build the CLI
  8. Run the Project
  9. Add a Test
  10. What This Project Gets Right
  11. Where It Breaks Down
  12. Where AI Fits
  13. Final Thoughts

What We Are Building

Our graph will contain four types of nodes:

Node type Example
Repository vercel/ai
Pull request #482: Improve streaming response handling
File packages/ai/src/generate-text.ts
Person bobbyhalljr

We will connect them with four relationship types:

Relationship Meaning
BELONGS_TO A pull request belongs to a repository.
AUTHORED A person created a pull request.
MODIFIED A pull request changed a file.
REVIEWED A person reviewed a pull request.

The interesting part is what those connections let us discover.

For example:

person → AUTHORED → pull request → MODIFIED → file
Enter fullscreen mode Exit fullscreen mode

That path tells us who has direct experience changing a file.

This path tells us who reviewed the change:

person → REVIEWED → pull request → MODIFIED → file
Enter fullscreen mode Exit fullscreen mode

And this path reveals files that changed together:

file ← MODIFIED ← pull request → MODIFIED → another file
Enter fullscreen mode Exit fullscreen mode

That is graph engineering at its most practical.

We are not inventing new information.

We are making existing relationships visible.

Project Setup

You will need:

  • Node.js 18 or newer.
  • npm.
  • A public GitHub repository to analyze.
  • An optional GitHub token for a higher API rate limit.

Create the project:

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
Enter fullscreen mode Exit fullscreen mode

The project will contain these files:

src/types.ts
src/graph.ts
src/github.ts
src/queries.ts
src/index.ts
src/graph.test.ts
Enter fullscreen mode Exit fullscreen mode

To analyze private repositories or increase your GitHub API allowance, configure a token:

export GITHUB_TOKEN="your_github_token_here"
Enter fullscreen mode Exit fullscreen mode

Never commit your token or hardcode it into the application.

Public repositories work without one, but unauthenticated requests are limited to 60 requests per hour.

By default, our project analyzes up to 10 recently closed pull requests and makes up to 21 API requests:

  • One request to list pull requests.
  • One request per merged pull request to list changed files.
  • One request per merged pull request to list reviews.

Each CLI command rebuilds the graph, so repeated runs consume additional requests.

Step 1: Define the Graph

Create src/types.ts:

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;
};
Enter fullscreen mode Exit fullscreen mode

There are two important ideas here.

First, nodes and edges have different jobs.

A node represents something that exists:

{
  id: "person:bobby",
  type: "person",
  label: "bobby",
  metadata: {}
}
Enter fullscreen mode Exit fullscreen mode

An edge represents a relationship:

{
  from: "person:bobby",
  to: "pr:acme/storefront#41",
  type: "AUTHORED"
}
Enter fullscreen mode Exit fullscreen mode

Second, every edge includes evidence.

If our application claims someone authored or reviewed a change, we should be able to point to the GitHub artifact that supports that claim.

Without evidence, a graph can become a polished collection of guesses.

Step 2: Build an In-Memory Graph

Create src/graph.ts:

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The most important method is neighbors().

It answers:

Which nodes are connected to this node by a specific relationship?

For example:

graph.neighbors(
  "pr:acme/storefront#41",
  "MODIFIED",
);
Enter fullscreen mode Exit fullscreen mode

That returns files changed by the pull request.

Reverse the direction:

graph.neighbors(
  "file:acme/storefront:src/checkout.ts",
  "MODIFIED",
  "incoming",
);
Enter fullscreen mode Exit fullscreen mode

Now you get pull requests that changed the file.

Same relationship.

Different direction.

Different question.

We also validate that both nodes exist before adding an edge.

A relationship pointing to a missing node is not useful context. It is a bug wearing a name tag.

Step 3: Turn GitHub Activity Into Relationships

Create 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}`,
    },
  });

  const pulls = await github<GitHubPullRequest[]>(
    `/repos/${repository}/pulls` +
      `?state=closed` +
      `&sort=updated` +
      `&direction=desc` +
      `&per_page=${limit}`,
  );

  const mergedPulls = pulls.filter(
    (pull) => pull.merged_at && pull.user,
  );

  for (const pull of mergedPulls) {
    const author = pull.user!;
    const pullId = `pr:${repository}#${pull.number}`;

    graph.addNode({
      id: pullId,
      type: "pull_request",
      label: `#${pull.number}: ${pull.title}`,
      metadata: {
        number: pull.number,
        title: pull.title,
        body: pull.body,
        url: pull.html_url,
        mergedAt: pull.merged_at,
      },
    });

    graph.addEdge({
      from: pullId,
      to: repositoryId,
      type: "BELONGS_TO",
      evidence: evidence(
        pull.html_url,
        "Pull request belongs to repository",
        pull.updated_at,
      ),
    });

    graph.addEdge({
      from: addPerson(graph, author),
      to: pullId,
      type: "AUTHORED",
      evidence: evidence(
        pull.html_url,
        `${author.login} authored this change`,
        pull.created_at,
      ),
    });

    const [files, reviews] = await Promise.all([
      github<GitHubFile[]>(
        `/repos/${repository}/pulls/` +
          `${pull.number}/files?per_page=100`,
      ),
      github<GitHubReview[]>(
        `/repos/${repository}/pulls/` +
          `${pull.number}/reviews?per_page=100`,
      ),
    ]);

    for (const file of files) {
      const fileId =
        `file:${repository}:${file.filename}`;

      graph.addNode({
        id: fileId,
        type: "file",
        label: file.filename,
        metadata: {
          path: file.filename,
        },
      });

      graph.addEdge({
        from: pullId,
        to: fileId,
        type: "MODIFIED",
        evidence: evidence(
          `${pull.html_url}/files`,
          `${file.filename} was ${file.status}`,
          pull.updated_at,
        ),
        metadata: {
          status: file.status,
          additions: file.additions,
          deletions: file.deletions,
        },
      });
    }

    for (const review of reviews) {
      if (
        !review.user ||
        !review.submitted_at ||
        review.state === "PENDING"
      ) {
        continue;
      }

      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,
        ),
        metadata: {
          state: review.state,
        },
      });
    }
  }

  return graph;
}
Enter fullscreen mode Exit fullscreen mode

There is a lot happening here, so let’s walk through the important parts.

Fetch Recently Closed Pull Requests

We start with:

const pulls = await github<GitHubPullRequest[]>(
  `/repos/${repository}/pulls` +
    `?state=closed` +
    `&sort=updated` +
    `&direction=desc` +
    `&per_page=${limit}`,
);
Enter fullscreen mode Exit fullscreen mode

GitHub returns closed pull requests, including both merged and unmerged changes.

We only want changes that actually landed:

const mergedPulls = pulls.filter(
  (pull) => pull.merged_at && pull.user,
);
Enter fullscreen mode Exit fullscreen mode

Because we filter after fetching, the final graph may contain fewer than the requested number of pull requests.

A production system would paginate until it collected the desired number of merged changes.

Create Author Relationships

For each 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,
  ),
});
Enter fullscreen mode Exit fullscreen mode

The author is not merely a string attached to the pull request.

The author is a node with a relationship.

That means we can later ask:

Which other files has this person changed?

Or:

Which people have touched this part of the codebase?

The answers become graph traversals instead of one-off transformations.

Add Changed Files

Every changed file becomes its own node:

const fileId =
  `file:${repository}:${file.filename}`;
Enter fullscreen mode Exit fullscreen mode

We include the repository in the identifier so files from different projects cannot collide:

file:acme/storefront:src/checkout.ts
file:acme/admin:src/checkout.ts
Enter fullscreen mode Exit fullscreen mode

Same filename.

Different context.

Different node.

The edge stores additional metadata:

metadata: {
  status: file.status,
  additions: file.additions,
  deletions: file.deletions,
}
Enter fullscreen mode Exit fullscreen mode

Later, we could use that information to distinguish a tiny documentation update from a substantial code change.

Add Review Relationships

Reviews tell us who examined a change, even when they did not author it.

That matters because the original author is not always the only person who understands a piece of code.

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,
  ),
});
Enter fullscreen mode Exit fullscreen mode

Our graph deduplicates edges by their starting node, relationship type, and destination node.

If someone reviews the same pull request multiple times, the latest processed review replaces the earlier one.

That keeps the demo simple, but it also means we are not preserving the complete review timeline.

Step 4: Ask the Graph Useful Questions

Create 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,
  );
}
Enter fullscreen mode Exit fullscreen mode

These three queries demonstrate why graphs are useful.

Question 1: Why Was This Changed?

The explainPullRequest() function combines:

  • Pull request title.
  • Pull request description.
  • Author.
  • Reviewers.
  • Changed files.
  • Links to supporting evidence.

An example response:

{
  "title": "Prevent duplicate checkout requests",
  "reason": "Checkout timed out because repeated requests saturated the payments API.",
  "url": "https://github.com/acme/storefront/pull/41",
  "authors": ["bobby"],
  "reviewers": [
    {
      "name": "maya",
      "state": "APPROVED"
    }
  ],
  "files": [
    {
      "path": "src/checkout.ts",
      "additions": 24,
      "deletions": 6,
      "evidence": "https://github.com/acme/storefront/pull/41/files"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This does not magically recover a missing explanation.

If the pull request description is empty, the tool says so.

That honesty matters.

A system should not manufacture confidence when the source material does not support it.

Question 2: Who Understands This File?

The findExperts() function starts at a file.

It walks backward to pull requests that modified it.

From there, it finds people who authored or reviewed those pull requests.

We assign a simple score:

if (relationship === "AUTHORED") {
  existing.score += 3;
} else {
  existing.score += 1;
}
Enter fullscreen mode Exit fullscreen mode

An authored change counts more than a review.

This is a heuristic, not an objective measure of expertise.

A thoughtful review may reveal deeper understanding than a rushed code change. Recency, change size, ownership, and familiarity with neighboring files would all improve the ranking.

But even this simple version answers a practical question:

Who should I talk to before touching this file?

Example:

[
  {
    "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"
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

Every recommendation comes with evidence.

That is a better starting point than asking a model to guess who sounds qualified.

Question 3: What Else Changes With This File?

The findRelatedFiles() function looks for files modified in the same pull requests.

Example:

[
  {
    "path": "src/payments.ts",
    "sharedChanges": 2,
    "evidence": [
      "https://github.com/acme/storefront/pull/41/files",
      "https://github.com/acme/storefront/pull/40/files"
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

If checkout.ts and payments.ts repeatedly change together, that is useful information.

But be careful:

Co-change is not the same thing as a proven dependency.

Two files can appear in the same pull request for many reasons.

They might share an architectural dependency.

They might belong to the same feature.

Or somebody might have bundled unrelated cleanup into one giant Friday-afternoon pull request.

The graph reveals a pattern.

Determining why the pattern exists requires additional evidence.

Step 5: Build the CLI

Create src/index.ts:

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;
  },
);
Enter fullscreen mode Exit fullscreen mode

The CLI accepts a repository and an optional command:

npm run dev -- owner/repository
Enter fullscreen mode Exit fullscreen mode
npm run dev -- owner/repository pr 123
Enter fullscreen mode Exit fullscreen mode
npm run dev -- owner/repository experts "src/example.ts"
Enter fullscreen mode Exit fullscreen mode
npm run dev -- owner/repository related "src/example.ts"
Enter fullscreen mode Exit fullscreen mode

When you run the repository command without additional arguments, the application prints real pull request numbers and file paths you can use for the next command.

That saves you from guessing whether a file exists in the indexed history.

Run the Project

Try it against a public repository:

npm run dev -- vercel/ai
Enter fullscreen mode Exit fullscreen mode

You should see output shaped like this:

Indexing recent merged pull requests from vercel/ai...

Graph: {
  nodes: 68,
  edges: 94,
  pullRequests: 8,
  files: 42,
  people: 17
}

Indexed pull requests:
- #12345: Improve streaming response handling
- #12344: Fix tool call parsing
- #12343: Update provider configuration

Try: npm run dev -- vercel/ai pr 12345

Try: npm run dev -- vercel/ai experts "packages/ai/src/example.ts"

Try: npm run dev -- vercel/ai related "packages/ai/src/example.ts"
Enter fullscreen mode Exit fullscreen mode

Those pull request numbers and file paths are illustrative.

Your results will depend on the repository’s current history.

Use the exact commands printed by your own run.

To inspect more pull requests:

PR_LIMIT=20 npm run dev -- vercel/ai
Enter fullscreen mode Exit fullscreen mode

If GitHub returns a rate-limit error, configure a token:

export GITHUB_TOKEN="your_github_token_here"
Enter fullscreen mode Exit fullscreen mode

Then rerun the command:

npm run dev -- vercel/ai
Enter fullscreen mode Exit fullscreen mode

For a personal repository:

npm run dev -- your-github-username/your-repository
Enter fullscreen mode Exit fullscreen mode

You now have a small system that can tell you:

  • What changed.
  • Why the author said it changed.
  • Who authored the change.
  • Who reviewed it.
  • Which files were involved.
  • Which contributors know a file.
  • Which files frequently appear together.

Not bad for a few TypeScript files.

Add a Test

Create src/graph.test.ts:

import assert from "node:assert/strict";
import test from "node:test";

import {
  EngineeringGraph,
} from "./graph.ts";

import {
  findExperts,
} from "./queries.ts";

test(
  "ranks pull request authors above reviewers",
  () => {
    const graph = new EngineeringGraph();

    const evidence = {
      source: "github" as const,
      url: "https://github.com/acme/storefront/pull/12",
      summary: "Verified GitHub relationship",
      observedAt: "2026-08-20T12:00:00Z",
    };

    graph.addNode({
      id: "person:bobby",
      type: "person",
      label: "bobby",
      metadata: {},
    });

    graph.addNode({
      id: "person:maya",
      type: "person",
      label: "maya",
      metadata: {},
    });

    graph.addNode({
      id: "pr:acme/storefront#12",
      type: "pull_request",
      label: "#12: Improve checkout",
      metadata: {},
    });

    graph.addNode({
      id: "file:acme/storefront:src/checkout.ts",
      type: "file",
      label: "src/checkout.ts",
      metadata: {},
    });

    graph.addEdge({
      from: "person:bobby",
      to: "pr:acme/storefront#12",
      type: "AUTHORED",
      evidence,
    });

    graph.addEdge({
      from: "person:maya",
      to: "pr:acme/storefront#12",
      type: "REVIEWED",
      evidence,
    });

    graph.addEdge({
      from: "pr:acme/storefront#12",
      to: "file:acme/storefront:src/checkout.ts",
      type: "MODIFIED",
      evidence,
    });

    const experts = findExperts(
      graph,
      "acme/storefront",
      "src/checkout.ts",
    );

    assert.deepEqual(
      experts.map(
        ({ name, score }) => ({
          name,
          score,
        }),
      ),
      [
        {
          name: "bobby",
          score: 3,
        },
        {
          name: "maya",
          score: 1,
        },
      ],
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Run the test:

npm test
Enter fullscreen mode Exit fullscreen mode

Expected result:

✔ ranks pull request authors above reviewers
ℹ tests 1
ℹ pass 1
ℹ fail 0
Enter fullscreen mode Exit fullscreen mode

This test does not call GitHub.

It builds a tiny graph directly and checks that our traversal and scoring behave as expected.

That separation matters:

  • GitHub ingestion gathers facts.
  • The graph stores relationships.
  • Query functions interpret those relationships.
  • Tests verify interpretation without depending on a network request.

What This Project Gets Right

Several architectural choices make this little project more useful than it might initially appear.

Relationships Are Explicit

We do not bury authorship inside an unstructured paragraph.

We represent it directly:

person → AUTHORED → pull request
Enter fullscreen mode Exit fullscreen mode

That makes the relationship easy to query and explain.

Evidence Stays Attached

Every edge includes a source URL.

When the system recommends someone as a subject-matter expert, it can show the pull requests or reviews behind the recommendation.

That matters when engineers need to trust the result.

The Graph Can Grow

Today we have:

"AUTHORED"
"REVIEWED"
"MODIFIED"
"BELONGS_TO"
Enter fullscreen mode Exit fullscreen mode

Tomorrow we could add:

"DEPENDS_ON"
"DEPLOYED"
"MENTIONED_IN"
"CAUSED"
"RESOLVED"
"OWNS"
Enter fullscreen mode Exit fullscreen mode

The same graph structure can support much richer questions.

Storage Is an Implementation Detail

Our graph lives in memory.

That is appropriate for a small tutorial.

If the project grows, the same ideas can move into:

  • PostgreSQL.
  • A dedicated graph database.
  • A search index.
  • A hybrid system.

The core idea does not depend on a particular database.

Where It Breaks Down

This is a useful prototype.

It is not a production engineering intelligence platform.

Here are the obvious limitations.

It Rebuilds Everything on Every Run

Each CLI command fetches GitHub data again.

For repeated use, you would want:

  • Persistent storage.
  • Cached API responses.
  • Incremental updates.
  • GitHub webhooks.

It Only Fetches the First Page

We request up to 100 files and 100 reviews per pull request.

Larger pull requests would require pagination.

It Only Sees Recently Closed Pull Requests

The graph knows nothing about changes outside its indexed window.

If the original author changed a file two years ago, our default history probably will not find them.

It Does Not Understand Dependencies

Files changing together is a signal.

It does not prove that one file imports another, calls another, or will break when another changes.

A stronger version would parse:

  • Imports.
  • Function calls.
  • Service boundaries.
  • API contracts.
  • Deployment relationships.

It Does Not Model Permissions

Private repositories and sensitive engineering data require access controls.

A production graph must ensure users cannot discover information derived from artifacts they are not authorized to access.

It Does Not Preserve Every Review Event

Multiple reviews by the same person on the same pull request collapse into one relationship.

If review history matters, the graph should represent individual review events as their own nodes.

These limitations are not reasons to avoid building the prototype.

They show where a real product needs to go next.

Where AI Fits

You may have noticed something:

We built a useful intelligence layer without calling a language model.

That is intentional.

Language models are excellent at turning structured context into clear explanations.

They are less reliable when forced to reconstruct missing relationships from disconnected documents.

Once we have the graph, an AI layer can summarize evidence like this:

async function explainFile(
  repository: string,
  path: string,
) {
  const graph = await buildEngineeringGraph(
    repository,
  );

  const experts = findExperts(
    graph,
    repository,
    path,
  );

  const relatedFiles = findRelatedFiles(
    graph,
    repository,
    path,
  );

  const context = {
    file: path,
    experts,
    relatedFiles,
  };

  return {
    prompt: `
Explain what an engineer should know before modifying this file.

Use only the supplied evidence.

Clearly distinguish observed facts from inferred relationships.

Do not claim that files are dependencies merely because they changed together.

Context:

${JSON.stringify(context, null, 2)}
    `.trim(),
  };
}
Enter fullscreen mode Exit fullscreen mode

This prepares a grounded prompt that you can send to whichever model provider your application uses.

The model does not need to invent who reviewed the file.

The graph already knows.

It does not need to guess which pull requests changed the file.

The graph already knows.

Its job becomes explaining connected evidence, not manufacturing context.

That is a much healthier division of labor.

Final Thoughts

The interesting part of graph engineering is not the graph itself.

It is what happens when disconnected engineering events become connected knowledge.

A pull request stops being just a pull request.

It becomes part of a chain:

Someone changed a file for a reason, another engineer reviewed it, and several other files were repeatedly involved in related changes.

Once those relationships exist, you can ask better questions.

You can onboard faster.

You can find the right person before making a risky change.

You can preserve context that would otherwise disappear into GitHub history.

And eventually, you can give AI systems something they usually lack:

A structured understanding of how the software actually fits together.


What critical engineering knowledge is your team losing right now? See what Helix reveals →

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.