🔖

GitHub Apps名義でPullRequestを作らせる

2025/03/11に公開

記事では、GitHub App を使用して、リポジトリへのコミットとプルリクエスト (PR) 作成を自動化する方法を紹介します。

背景

私たちは、開発プロセスを効率化するために、GitHub App を導入して、以下のタスクを自動化することにしました。

  • 特定のユーザー(bot)名義でのコミット
  • PR の作成
  • PR コメントのチェックと対応

前提条件

  • GitHub アカウント
  • Node.js 環境
  • GitHub App の作成権限

手順

1. GitHub App の作成

まず、GitHub App を作成します。

  1. GitHub.com → Settings → Developer settings → GitHub Apps へ移動

  2. "New GitHub App" をクリック

  3. 以下の設定で作成:

    • GitHub App name: happyo-bot(または他のご希望の名前)
    • Homepage URL: リポジトリのURL
    • Webhook: Active のチェックを外す
    • Repository permissions:
      • Contents: Read and write
      • Pull requests: Read and write
    • Where can this GitHub App be installed?: Only on this account
  4. アプリ作成後、以下の手順を実行します:

    • Generate a private keyボタンをクリックして秘密鍵をダウンロード
    • Install Appボタンをクリックし、リポジトリにインストール

2. 認証情報の設定

GitHub App の App ID と秘密鍵を安全に保管します。

  • App ID: 1172985 (例)
  • 秘密鍵:
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAuHYR2bjl...
-----END RSA PRIVATE KEY-----

秘密鍵は、ソースコードにコミットせず、環境変数として扱うことを推奨します。

3. 必要なパッケージのインストール

以下のパッケージをインストールします。

npm install @octokit/auth-app @octokit/rest

4. コミットとPR作成スクリプトの作成 (scripts/commit-and-create-pr.js)

以下のスクリプトを作成し、scripts/commit-and-create-pr.js として保存します。

import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import fs from 'fs';

const appId = [YOUR_APP_ID]; // GitHub AppのApp ID
const privateKey = fs.readFileSync('.github-app-key.pem', 'utf8');

async function commitAndCreatePR() {
  // Initialize auth
  const auth = createAppAuth({
    appId,
    privateKey,
  });

  // Get installation ID
  const appOctokit = new Octokit({
    authStrategy: createAppAuth,
    auth: {
      appId,
      privateKey,
    },
  });

  const { data: installations } = await appOctokit.rest.apps.listInstallations();
  const installationId = installations[0].id;

  // Get installation token
  const { token } = await auth({
    type: 'installation',
    installationId,
  });

  // Create Octokit instance with installation token
  const octokit = new Octokit({
    auth: token,
  });

  // Get current branch name and commit message from env
  const branch = process.env.BRANCH_NAME;
  const message = process.env.COMMIT_MESSAGE;
  if (!branch || !message) {
    console.error('BRANCH_NAME and COMMIT_MESSAGE environment variables are required');
    process.exit(1);
  }

  try {
    // Get the latest commit SHA on the current branch
    const { data: ref } = await octokit.rest.git.getRef({
      owner: 'hiragram',
      repo: 'happyo',
      ref: `heads/${branch}`,
    });
    const latestCommitSha = ref.object.sha;

    // Get the current tree
    const { data: commit } = await octokit.rest.git.getCommit({
      owner: 'hiragram',
      repo: 'happyo',
      commit_sha: latestCommitSha,
    });
    const treeSha = commit.tree.sha;

    // Create the new tree
    const files = process.env.FILES ? process.env.FILES.split(',') : [];
    const newTree = [];
    
    for (const file of files) {
      const content = fs.readFileSync(file, 'utf8');
      const { data: blob } = await octokit.rest.git.createBlob({
        owner: 'hiragram',
        repo: 'happyo',
        content,
        encoding: 'utf-8',
      });
      newTree.push({
        path: file,
        mode: '100644',
        type: 'blob',
        sha: blob.sha,
      });
    }

    const { data: tree } = await octokit.rest.git.createTree({
      owner: 'hiragram',
      repo: 'happyo',
      base_tree: treeSha,
      tree: newTree,
    });

    // Create the commit
    const { data: newCommit } = await octokit.rest.git.createCommit({
      owner: 'hiragram',
      repo: 'happyo',
      message,
      tree: tree.sha,
      parents: [latestCommitSha],
      author: {
        name: 'hiragram-bot',
        email: 'hiragram+bot@gmail.com',
      },
      committer: {
        name: 'hiragram-bot',
        email: 'hiragram+bot@gmail.com',
      },
    });

    // Update the reference
    await octokit.rest.git.updateRef({
      owner: 'hiragram',
      repo: 'happyo',
      ref: `heads/${branch}`,
      sha: newCommit.sha,
    });

    console.log(`Changes committed: ${newCommit.sha}`);

    // Create PR if PR_TITLE is set
    if (process.env.PR_TITLE) {
      const { data: pr } = await octokit.rest.pulls.create({
        owner: 'hiragram',
        repo: 'happyo',
        title: process.env.PR_TITLE,
        head: `hiragram:${branch}`,
        base: 'main',
        body: process.env.PR_BODY || '',
      });

      console.log(`Pull request created: ${pr.html_url}`);
    }
  } catch (error) {
    console.error('Error:', error.message);
    process.exit(1);
  }
}

commitAndCreatePR().catch(console.error);

5. PRコメントチェックスクリプトの作成 (scripts/check-pr-comments.js)

以下のスクリプトを作成し、scripts/check-pr-comments.js として保存します。

import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
import fs from 'fs';

const appId = [YOUR_APP_ID]; // GitHub AppのApp ID
const privateKey = fs.readFileSync('.github-app-key.pem', 'utf8');

async function checkPRComments() {
  // Initialize auth
  const auth = createAppAuth({
    appId,
    privateKey,
  });

  // Get installation ID
  const appOctokit = new Octokit({
    authStrategy: createAppAuth,
    auth: {
      appId,
      privateKey,
    },
  });

  const { data: installations } = await appOctokit.rest.apps.listInstallations();
  const installationId = installations[0].id;

  // Get installation token
  const { token } = await auth({
    type: 'installation',
    installationId,
  });

  // Create Octokit instance with installation token
  const octokit = new Octokit({
    auth: token,
  });

  const prNumber = process.env.PR_NUMBER;
  if (!prNumber) {
    console.error('PR_NUMBER environment variable is required');
    process.exit(1);
  }

  try {
    // Get PR comments
    const { data: comments } = await octokit.rest.issues.listComments({
      owner: 'hiragram',
      repo: 'happyo',
      issue_number: prNumber,
    });

    // Get the PR details to know the head branch
    const { data: pr } = await octokit.rest.pulls.get({
      owner: 'hiragram',
      repo: 'happyo',
      pull_number: prNumber,
    });

    const branch = pr.head.ref;

    // Process each comment
    for (const comment of comments) {
      // Skip bot's own comments
      if (comment.user.type === 'Bot') continue;

      // Simple heuristic: If comment ends with "?" it's a question
      const isQuestion = comment.body.trim().endsWith('?');
      
      // Check if comment contains specific keywords indicating a request for changes
      const isChangeRequest = comment.body.toLowerCase().includes('please change') ||
                            comment.body.toLowerCase().includes('should be') ||
                            comment.body.toLowerCase().includes('should be') ||
                            comment.body.toLowerCase().includes('needs to be') ||
                            comment.body.toLowerCase().includes('修正してください') ||
                            comment.body.toLowerCase().includes('変更してください');

      if (isQuestion) {
        // Reply to question
        await octokit.rest.issues.createComment({
          owner: 'hiragram',
          repo: 'happyo',
          issue_number: prNumber,
          body: `@${comment.user.login} ご質問ありがとうございます。内容を確認させていただき、対応を検討いたします。`,
        });
      }

      if (isChangeRequest) {
        // Reply to change request
        await octokit.rest.issues.createComment({
          owner: 'hiragram',
          repo: 'happyo',
          issue_number: prNumber,
          body: `@${comment.user.login} 修正リクエストありがとうございます。内容を確認の上、対応させていただきます。`,
        });
      }
    }

    // Check review comments (inline comments)
    const { data: reviewComments } = await octokit.rest.pulls.listReviewComments({
      owner: 'hiragram',
      repo: 'happyo',
      pull_number: prNumber,
    });

    for (const comment of reviewComments) {
      // Skip bot's own comments
      if (comment.user.type === 'Bot') continue;

      // Reply to review comments
      await octokit.rest.pulls.createReplyForReviewComment({
        owner: 'hiragram',
        repo: 'happyo',
        pull_number: prNumber,
        comment_id: comment.id,
        body: `コメントありがとうございます。内容を確認の上、対応させていただきます。`,
      });
    }

    console.log('Comments processed successfully');

  } catch (error) {
    console.error('Error:', error.message);
    process.exit(1);
  }
}

checkPRComments().catch(console.error);

6. .gitignore の設定

秘密鍵をリポジトリにコミットしないように、.gitignore ファイルに以下の行を追加します。

*.pem

7. 開発フローの変更

今後は、以下の手順でコミットとPR作成を行います。

  1. 変更をコミットする。
  2. 以下の環境変数を設定する。
    • BRANCH_NAME: ブランチ名
    • COMMIT_MESSAGE: コミットメッセージ
    • FILES: 変更したファイル (カンマ区切り)
  3. node scripts/commit-and-create-pr.js を実行する。

PR のコメントを確認するには、以下の手順を実行します。

  1. 以下の環境変数を設定する。
    • PR_NUMBER: PR の番号
  2. node scripts/check-pr-comments.js を実行する。

まとめ

GitHub App を使用することで、リポジトリへのコミットとPR 作成を効率的に自動化できます。
このブログ記事が、GitHub App の導入を検討している方の参考になれば幸いです。

Discussion