mooriii's blog

article icon

PRをApproveしたら愛猫のLGTM画像が投稿されるGitHub Appを作った

@keita_kn_web さんが、自身が運営している猫のLGTM画像サイト「LGTMeow」のGitHub Appを作っていたので触発されて作りました。

LGTMeow | 猫好きのためのLGTM画像作成・共有サービスLGTMeowは可愛い猫のLGTM画像を作成して共有できるサービスです。
favicon of https://lgtmeow.comlgtmeow.com
ogp of https://lgtmeow.com/opengraph-image.png

愛猫のLGTM画像サイトの仕組みについては以前書いたブログを参考にしてください。

Next.jsとContentfulで飼い猫のLGTM画像サイトを作成する - mooriii's blog日常や技術に関して気まぐれに投稿する日記
favicon of https://blog.mooriii.com/entry/next-contentful-lgtlatteblog.mooriii.com
ogp of https://blog.mooriii.com/ogps/next-contentful-lgtlatte.png

できたもの

Pull Requestをレビューして「Approve」すると、自動的に猫の画像がコメントされます。

ソースコードはこちらで公開しています。

GitHub - mr04vv/lgtlatteContribute to mr04vv/lgtlatte development by creating an account on GitHub.
favicon of https://github.com/mr04vv/lgtlattegithub.com
ogp of https://opengraph.githubassets.com/c75e6eb7ca96a857e6879301d4936c78dbb8bb8d1253584d6f478826a7e22522/mr04vv/lgtlatte

仕組み

全体の流れ

GitHub上でPRがApproveされるとGitHubがWebhookを送信し、受け取った情報(PRの番号等)をもとにAPIがランダムな画像をPRのコメントとして投稿するという仕組みです。

1. GitHub上でPRをApprove

2. GitHubがWebhookを送信(HTTPリクエスト)

3. Next.js APIがWebhookを受信

4. 署名検証(本当にGitHubからか?)

5. イベントハンドラー起動

6. Contentful APIからランダムに画像を取得

7. GitHub APIでPRにコメント投稿

GitHub Appの作成

GitHub App自体の作成手順は以下の通りです。

  1. https://github.com/settings/apps/new にアクセス
  2. 基本情報を入力
    • Webhook URL: https://lgtlatte.mooriii.com/api/github/webhook
    • Webhook secret: ランダムな文字列を生成
  3. 権限設定
    • Pull requests: Read & write
  4. イベント購読
    • Pull request reviews にチェック
  5. Private Keyをダウンロード
  6. 環境変数を設定
    • GITHUB_APP_ID
    • GITHUB_APP_PRIVATE_KEY
    • GITHUB_WEBHOOK_SECRET

詳しい手順はリポジトリのGITHUB_APP_SETUP.mdに書きました。(正しくはAIに書かせました笑)

実装

基本的に実装はClaude Codeに任せましたが、以下詳細に書いておきます。

必要なパッケージ

WebhookのリクエストがGitHubから来たものか検証するために octokit が提供しているパッケージを利用します。

pnpm add @octokit/app @octokit/webhooks @octokit/rest
pnpm add -D @octokit/webhooks-types

1. GitHub App認証の設定

GitHub Appの認証を管理するモジュールを作成します。

src/lib/github/app.ts
import { App } from "@octokit/app";
 
if (!process.env.GITHUB_APP_ID) {
  throw new Error("GITHUB_APP_ID is not set");
}
 
if (!process.env.GITHUB_APP_PRIVATE_KEY) {
  throw new Error("GITHUB_APP_PRIVATE_KEY is not set");
}
 
// GitHub App instance
export const app = new App({
  appId: process.env.GITHUB_APP_ID,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, "\n"),
});
 
/**
 * Get Octokit instance for a specific installation
 */
export async function getOctokitForInstallation(installationId: number) {
  return await app.getInstallationOctokit(installationId);
}

2. ランダム画像取得

Contentful CMSからランダムにLGTM画像を1枚取得します。

src/lib/queries/randomImage.ts
import {
  AssetCollectionApiDocument,
  type AssetCollectionApiQuery,
} from "@/generated/schema";
import { apolloClient } from "@/lib/apolloClient";
 
export async function getRandomLgtmImage(): Promise<string | null> {
  try {
    const res = await apolloClient.query<AssetCollectionApiQuery>({
      query: AssetCollectionApiDocument,
      fetchPolicy: "no-cache",
    });
 
    const items = res.data.assetCollection?.items ?? [];
 
    if (items.length === 0) {
      return null;
    }
 
    // ランダムに1枚選択
    const randomIndex = Math.floor(Math.random() * items.length);
    const randomItem = items[randomIndex];
 
    return randomItem?.url ?? null;
  } catch (error) {
    console.error("Failed to fetch random LGTM image:", error);
    return null;
  }
}

3. GitHub APIでコメント投稿

PRにコメントを投稿するロジックです。 /repos/{owner}/{repo}/issues/{issue_number}/comments に含まれる owner repo issue_numberはwebhookのリクエストから取得しています。

src/lib/github/comments.ts
import { getOctokitForInstallation } from "./app";
 
export interface CommentParams {
  installationId: number;
  owner: string;
  repo: string;
  issueNumber: number;
  imageUrl: string;
}
 
export async function postLgtmComment({
  installationId,
  owner,
  repo,
  issueNumber,
  imageUrl,
}: CommentParams): Promise<void> {
  try {
    const octokit = await getOctokitForInstallation(installationId);
 
    // Markdownで画像を埋め込み
    await octokit.request(
      "POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
      {
        owner,
        repo,
        issue_number: issueNumber,
        body: `![LGTM](${imageUrl})`,
      }
    );
 
    console.log(
      `Successfully posted LGTM comment to ${owner}/${repo}#${issueNumber}`
    );
  } catch (error) {
    console.error("Failed to post LGTM comment:", error);
    throw error;
  }
}

4. Webhookイベント処理

Approve時の処理ロジックです。

src/lib/github/webhooks.ts
import type { PullRequestReviewEvent } from "@octokit/webhooks-types";
import { getRandomLgtmImage } from "../queries/randomImage";
import { postLgtmComment } from "./comments";
 
export async function handlePullRequestReview(
  payload: PullRequestReviewEvent
): Promise<void> {
  // Approveされた時のみ処理
  if (payload.action !== "submitted" || payload.review.state !== "approved") {
    console.log(
      `Skipping non-approved review: action=${payload.action}, state=${payload.review.state}`
    );
    return;
  }
 
  console.log(
    `Processing approved review from ${payload.review.user.login} on PR #${payload.pull_request.number}`
  );
 
  try {
    // ランダムにLGTM画像を取得
    const imageUrl = await getRandomLgtmImage();
 
    if (!imageUrl) {
      console.error("No LGTM images available");
      return;
    }
 
    // PRにコメント投稿
    await postLgtmComment({
      installationId: payload.installation?.id ?? 0,
      owner: payload.repository.owner.login,
      repo: payload.repository.name,
      issueNumber: payload.pull_request.number,
      imageUrl,
    });
 
    console.log(
      `Successfully posted LGTM image to ${payload.repository.full_name}#${payload.pull_request.number}`
    );
  } catch (error) {
    console.error("Failed to handle pull request review:", error);
    throw error;
  }
}

5. Webhook APIエンドポイント

GitHubからのWebhookを受け取るAPIです。

src/app/api/github/webhook/route.ts
import { Webhooks } from "@octokit/webhooks";
import type { PullRequestReviewEvent } from "@octokit/webhooks-types";
import { NextResponse } from "next/server";
import { handlePullRequestReview } from "@/lib/github/webhooks";
 
if (!process.env.GITHUB_WEBHOOK_SECRET) {
  throw new Error("GITHUB_WEBHOOK_SECRET is not set");
}
 
const webhooks = new Webhooks({
  secret: process.env.GITHUB_WEBHOOK_SECRET,
});
 
// イベントハンドラーを登録
webhooks.on("pull_request_review", async ({ payload }) => {
  await handlePullRequestReview(payload as PullRequestReviewEvent);
});
 
export async function POST(request: Request) {
  try {
    // Webhookヘッダーから情報を取得
    const signature = request.headers.get("x-hub-signature-256");
    const id = request.headers.get("x-github-delivery");
    const event = request.headers.get("x-github-event");
 
    if (!signature || !id || !event) {
      console.error("Missing required webhook headers");
      return NextResponse.json(
        { error: "Missing required headers" },
        { status: 400 }
      );
    }
 
    const body = await request.text();
 
    // 署名検証とハンドラー実行
    await webhooks.verifyAndReceive({
      id,
      name: event as any,
      signature,
      payload: body,
    });
 
    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("Webhook processing error:", error);
 
    if (error instanceof Error && error.message.includes("signature")) {
      return NextResponse.json(
        { error: "Invalid signature" },
        { status: 401 }
      );
    }
 
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

まとめ

今回はGitHub Appを作って、PRがApproveされたら自動的にLGTM画像を投稿する機能を実装しました。

Webhook → 署名検証 → ハンドラー実行 → API呼び出しという流れで、意外とシンプルに実装できました。

公開してるので是非ご活用ください。ではまた。

Build software better, togetherGitHub is where people build software. More than 150 million people use GitHub to discover, fork, and contribute to over 420 million projects.
favicon of https://github.comgithub.com
ogp of https://github.githubassets.com/assets/github-octocat-13c86b8b336d.png

宣伝

我が家の猫のLGTM画像サイトです。ぜひご自由に使ってください。

Lgtlatte - らてのLGTM画像飼い猫らてのLGTM画像を集めました
favicon of https://lgtlatte.mooriii.com/lgtlatte.mooriii.com
ogp of https://images.ctfassets.net/rd8mwctho8md/CvOcilcY3CGMuYLJPqxGL/4866b02e655a7d52c1fb19afcf9eb4c1/ogp.png

この記事をシェアするx icon
アイコン画像
Takuto Mori@_mooriii

Wevoxというサービスのフロントエンジニアをしています。趣味は猫を眺めることです🐱