GitHub GraphQL APIを使って、リポジトリのPRデータを効率的に取得する方法を解説します。
REST APIでは複数回のリクエストが必要だったデータも、GraphQLなら1回のクエリで取得可能です。この記事では、Octokitを使ったクエリの実行から、TypeScriptでの型安全な実装まで、実際に動くコードとともに解説します。
なお、この実装を使って実際に7分→20秒の高速化を達成しました。詳細は「GitHub REST APIからGraphQLへ移行して7分→20秒に高速化した話」をご覧ください。
準備
必要なもの
- Node.js環境
- GitHub Personal Access Token(
repoスコープ) - Octokitライブラリ
インストール
npm install @octokit/graphql基本的な初期化
import { graphql } from "@octokit/graphql";
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token your-github-token`,
},
});@octokit/graphqlはgraphql.defaults()を使って認証情報を設定したクライアントを作成できます。このgraphqlWithAuthを使ってクエリを実行します。
基本のGraphQLクエリを理解する
最もシンプルなクエリ
まずは最小限のクエリから始めましょう。例としてReactのリポジトリを対象にしています。
query {
repository(owner: "facebook", name: "react") {
pullRequests(first: 10) {
nodes {
number
title
state
}
}
}
}ざっくり下記のような構造になってます。
1. query { ... }
- GraphQLのクエリ操作を宣言
- データの取得(読み取り専用)を示す
mutation(書き込み)やsubscription(イベント通知)とは異なる
2. repository(owner: "facebook", name: "react")
- GitHubの特定のリポジトリを指定
owner: リポジトリの所有者(この場合は “facebook”)name: リポジトリ名(この場合は “react”)
3. pullRequests(first: 3)
- そのリポジトリのプルリクエストを取得
first: 3: 最初の3件のPRを取得- GitHub GraphQL APIでは最大100件
4. nodes { ... }
- 取得するデータを指定
- 欲しいデータはここに記述する
これをOctokitで実行すると:
const response = await graphqlWithAuth(`
query {
repository(owner: "facebook", name: "react") {
pullRequests(first: 10) {
nodes {
number
title
state
}
}
}
}
`);
console.log(response.repository.pullRequests.nodes);
// [
// {
// number: 1,
// title: 'Run each test in its own <iframe>',
// state: 'MERGED'
// },
// {
// number: 2,
// title: '[docs] Fix button links on bottom of home',
// state: 'MERGED'
// },
// {
// number: 3,
// title: '[docs] Fix couple minor typos/spelling',
// state: 'MERGED'
// }
// ]
REST APIとの違い
REST APIでは、PRの変更行数を取得するには2段階のリクエストが必要でした。
【REST API】
1. GET /repos/{owner}/{repo}/pulls → 一覧取得(変更行数なし)
2. GET /repos/{owner}/{repo}/pulls/123 → 詳細取得(変更行数あり)GraphQLでは、1回のクエリで両方取得できます。
pullRequests(first: 10) {
nodes {
number
title
additions # REST APIでは別リクエストが必要だった
deletions # REST APIでは別リクエストが必要だった
}
}実用的なクエリを設計する
PR分析に必要なデータ
PRのスループット分析をするなら、以下のデータが必要です。
| データ | 用途 |
|---|---|
| number, title | PR識別 |
| state | ステータス(OPEN/CLOSED/MERGED) |
| createdAt, mergedAt | リードタイム計算 |
| additions, deletions | PRサイズ分析 |
| author | PR作成者 |
| reviews | レビュー数 |
完成形のクエリ
query GetPullRequests(
$owner: String!
$repo: String!
$first: Int!
$after: String
) {
repository(owner: $owner, name: $repo) {
pullRequests(
first: $first
after: $after
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
number
title
state
createdAt
mergedAt
author {
login
}
additions
deletions
changedFiles
reviews {
totalCount
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}query GetPullRequests(...) は変数を使う時の書き方で、GetPullRequestsの部分の命名はなんでもOKです。
変数(Variables)を使う理由
クエリに値を直接埋め込むのではなく、変数を使います。
// ❌ 値を直接埋め込む(非推奨)
const query = `
query {
repository(owner: "${owner}", name: "${repo}") { ... }
}
`;
// ✅ 変数を使う(推奨)
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) { ... }
}
`;
const response = await graphqlWithAuth(query, {
owner: "facebook",
repo: "react",
});変数を使うメリットは3つあります。
- 再利用性 – 同じクエリを異なるリポジトリに使える
- 安全性 – クエリインジェクションを防止
- 可読性 – クエリ構造と値が分離される
TypeScriptで型安全に実装する
レスポンスの型定義
GraphQLのレスポンスに対応する型を定義します。
interface GitHubGraphQLPullRequest {
number: number;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
createdAt: string;
mergedAt: string | null;
author: {
login: string;
} | null; // 削除されたユーザーはnull
additions: number;
deletions: number;
changedFiles: number;
reviews: {
totalCount: number;
};
}
interface GitHubGraphQLResponse {
repository: {
pullRequests: {
nodes: GitHubGraphQLPullRequest[];
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
};
}型を使った安全な実装
octokit.graphqlはジェネリクスに対応しているため、型パラメータを渡せます。
const response = await graphqlWithAuth<GitHubGraphQLResponse>(
QUERY,
{ owner, repo, first: 100, after: cursor }
);
// 型補完が効く
response.repository.pullRequests.nodes.forEach(pr => {
console.log(pr.number); // number型
console.log(pr.state); // "OPEN" | "CLOSED" | "MERGED"
});ただし、ループ内で直接型パラメータ付きのGraphQL呼び出しを行うと、TypeScriptの型推論で循環参照のような問題が発生することがあります。
そのため、別関数に切り出すパターンが有効です:
// 別関数に切り出すこと
async function fetchPullRequestsPage(
graphqlWithAuth: typeof graphql,
owner: string,
repo: string,
cursor: string | null
): Promise<GitHubGraphQLResponse> {
return await graphqlWithAuth<GitHubGraphQLResponse>(QUERY, {
owner,
repo,
first: 100,
after: cursor,
});
}
// ループ内でも型解決ができる
const response = await fetchPullRequestsPage(graphqlWithAuth, owner, repo, cursor);
ページネーションを実装する
なぜページネーションが必要か
GitHub GraphQL APIは1回のリクエストで最大100件までしか取得できません。165件のPRがあるリポジトリなら、2回のリクエストが必要です。
カーソルベースのページネーション
GitHubはカーソルベースのページネーションを採用しています。
const QUERY = `
query GetPullRequests($owner: String!, $repo: String!, $first: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: $first, after: $after) {
nodes {
number
title
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
async function getAllPullRequests(
token: string,
owner: string,
repo: string
) {
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
const allPRs = [];
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const response = await fetchPullRequestsPage(
graphqlWithAuth,
owner,
repo,
cursor
);
const { nodes, pageInfo } = response.repository.pullRequests;
allPRs.push(...nodes);
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
}
return allPRs;
}
async function fetchPullRequestsPage(
graphqlWithAuth: typeof graphql,
owner: string,
repo: string,
cursor: string | null
): Promise<GitHubGraphQLResponse> {
return await graphqlWithAuth<GitHubGraphQLResponse>(PULL_REQUESTS_QUERY, {
owner,
repo,
first: 100,
after: cursor,
});
}pageInfo はページネーションの制御情報を返します:
pageInfo: {
hasNextPage: boolean, // ← まだ続きがあるか
endCursor: string // ← 次はここから取得
}これを使って:
hasNextPageでループを続けるか判断endCursorを次のリクエストのafterに渡す
という感じで取得していきます。
日付フィルタによる早期終了
特定の期間のPRだけが必要な場合、古いPRに到達した時点でループを終了できます。nodeにcreatedAtを追加して日付で判定します。
async function getPullRequestsSince(
token: string,
owner: string,
repo: string,
sinceDate: Date
) {
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
const allPRs = [];
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const response = await fetchPullRequestsPage(
graphqlWithAuth,
owner,
repo,
cursor
);
const { nodes, pageInfo } = response.repository.pullRequests;
for (const pr of nodes) {
const createdAt = new Date(pr.createdAt);
// PRは作成日降順なので、古いPRに到達したら終了
if (createdAt < sinceDate) {
return allPRs;
}
allPRs.push(pr);
}
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
}
return allPRs;
}完成コード
ここまでの内容をまとめた完成形です。
import { graphql } from "@octokit/graphql";
// 型定義
interface GitHubGraphQLPullRequest {
number: number;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
createdAt: string;
mergedAt: string | null;
author: { login: string } | null;
additions: number;
deletions: number;
changedFiles: number;
reviews: { totalCount: number };
}
interface GitHubGraphQLResponse {
repository: {
pullRequests: {
nodes: GitHubGraphQLPullRequest[];
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
};
};
}
interface PullRequest {
number: number;
title: string;
author: string;
state: "open" | "closed" | "merged";
createdAt: Date;
mergedAt?: Date;
additions: number;
deletions: number;
}
// クエリ定義
const PULL_REQUESTS_QUERY = `
query GetPullRequests($owner: String!, $repo: String!, $first: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequests(
first: $first
after: $after
orderBy: { field: CREATED_AT, direction: DESC }
) {
nodes {
number
title
state
createdAt
mergedAt
author { login }
additions
deletions
changedFiles
reviews { totalCount }
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
// メイン関数
async function getPullRequests(
token: string,
owner: string,
repo: string,
sinceDate?: Date
): Promise<PullRequest[]> {
const graphqlWithAuth = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
const allPRs: PullRequest[] = [];
let hasNextPage = true;
let cursor: string | null = null;
while (hasNextPage) {
const response = await fetchPullRequestsPage(
graphqlWithAuth,
owner,
repo,
cursor
);
const { nodes, pageInfo } = response.repository.pullRequests;
for (const gqlPR of nodes) {
const createdAt = new Date(gqlPR.createdAt);
// 日付フィルタによる早期終了
if (sinceDate && createdAt < sinceDate) {
return allPRs;
}
// ドメインモデルに変換
let state: "open" | "closed" | "merged" = "open";
if (gqlPR.state === "MERGED") state = "merged";
else if (gqlPR.state === "CLOSED") state = "closed";
allPRs.push({
number: gqlPR.number,
title: gqlPR.title,
author: gqlPR.author?.login ?? "unknown",
state,
createdAt,
mergedAt: gqlPR.mergedAt ? new Date(gqlPR.mergedAt) : undefined,
additions: gqlPR.additions,
deletions: gqlPR.deletions,
});
}
hasNextPage = pageInfo.hasNextPage;
cursor = pageInfo.endCursor;
}
return allPRs;
}
async function fetchPullRequestsPage(
graphqlWithAuth: typeof graphql,
owner: string,
repo: string,
cursor: string | null
): Promise<GitHubGraphQLResponse> {
return await graphqlWithAuth<GitHubGraphQLResponse>(PULL_REQUESTS_QUERY, {
owner,
repo,
first: 100,
after: cursor,
});
}
まとめ
この記事では、OctokitでGitHub GraphQL APIを叩く方法を解説しました。
ポイント
- GraphQLの基本 – 1回のクエリで必要なデータをすべて取得
- ページネーション – 100件制限に対応するカーソルベース実装
- 型安全 – TypeScriptの型定義でレスポンスを安全に扱う
関連記事
この実装を使った高速化の事例は、以下の記事で詳しく解説しています



コメント