GitHub GraphQL APIをOctokitで使う方法 – PRのデータ取得

GitHub GraphQL APIをOctkitで使う方法 Git
スポンサーリンク

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/graphqlgraphql.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, titlePR識別
stateステータス(OPEN/CLOSED/MERGED)
createdAt, mergedAtリードタイム計算
additions, deletionsPRサイズ分析
authorPR作成者
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つあります。

  1. 再利用性 – 同じクエリを異なるリポジトリに使える
  2. 安全性 – クエリインジェクションを防止
  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      // ← 次はここから取得
}

これを使って:

  1. hasNextPage でループを続けるか判断
  2. 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を叩く方法を解説しました。

ポイント
  1. GraphQLの基本 – 1回のクエリで必要なデータをすべて取得
  2. ページネーション – 100件制限に対応するカーソルベース実装
  3. 型安全 – TypeScriptの型定義でレスポンスを安全に扱う
関連記事

この実装を使った高速化の事例は、以下の記事で詳しく解説しています

コメント

タイトルとURLをコピーしました