🌊

GITHUB_TOKENを使ってPR上に複数のVRTレポートをコメントするreg-suitプラグインを作った

2024/08/14に公開

こんにちは!CastingONEの大沼です。

始めに

以前弊社ではstorycap + reg-suitでVRTを行っていることを紹介しました。

https://zenn.dev/castingone_dev/articles/2f3bfe9712c621

この時複数のStorybookのVRTレポートを一つのPR上にコメントすることが既存のプラグイン(reg-notify-github-plugin)では実現することができず自前で作っておりました。
しかしreg-suitはプラグインで動かしていく仕組みが用意されているのでプラグインとして作った方が良いのでは?と思い、プラグインとして作ってみましたので記事にまとめました。

今回作ったもの

今回プラグインを作るに当たって、以下の機能を満たすように意識しました。

  • GITHUB_TOKENのみでPR上にコメントできるようにする
    • reg-notify-github-pluginではGitHub Appとして登録する必要があって少し手間だったため、標準で取得できるGITHUB_TOKENのみでコメントできるようにしたい
  • 複数のVRTレポートコメントができるようにする
    • そもそも自作するに至った理由なのでこれは必須

実際に作ってみたプラグインは以下になります。実運用ではここから更に調整されていますが、ベースは変わらないので参考にしていただければと思います。また実装に関する説明はこちらのコードをベースに話したいと思います。

https://github.com/TakanoriOnuma/trial-storybook-vrt/tree/main/plugins/reg-notify-github-by-token-plugin

GITHUB_TOKENを使ってPR上に複数のVRTレポートをコメントするreg-suitプラグインの作成

npm workspacesで同じリポジトリでプラグインを提供する環境を用意する

まず今回作るプラグインのアクセス方法がパッと見node_modulesに入っているパッケージ名を指定する必要があるように見えます。

regconfig.jsonの設定例
{
  "core": {
    "workingDir": ".reg",
    "actualDir": "__screenshots__",
    "thresholdRate": 0,
    "ximgdiff": {
      "invocationType": "client"
    },
    "enableAntialias": true
  },
  "plugins": {
    "reg-keygen-git-hash-plugin": true,
    "reg-notify-github-plugin": {
      "clientId": "${CLIENT_ID}"
    },
    "reg-publish-gcs-plugin": {
      "bucketName": "trial-numa-storybook-vrt",
    }
  }
}

わざわざnpmパッケージを公開しなければいけないような雰囲気ですが、npm workspacesにするとworkspacesのコードがnode_modulesにシンボリックリンクが貼られるので、この仕組みを利用して作成したプラグインを使用したいと思います。

package.json
{
  "name": "trial-storybook-vrt",
  "workspaces": [
    "plugins/*"
  ]
}
plugins/reg-notify-github-by-token-plugin/package.json
{
  "name": "@reg-plugins/reg-notify-github-by-token-plugin",
  "private": true,
  "main": "dist/index.js"
}

このように書くことで dist/index.js にビルドしたプラグインを使用することができます。今回入力するパラメータも合わせて書くと、以下のように設定します。

自作プラグインを設定する
 {
   "core": {
     "workingDir": ".reg",
     "actualDir": "__screenshots__",
     "thresholdRate": 0,
     "ximgdiff": {
       "invocationType": "client"
     },
     "enableAntialias": true
   },
   "plugins": {
     "reg-keygen-git-hash-plugin": true,
-    "reg-notify-github-plugin": {
-      "clientId": "${CLIENT_ID}"
-    },
+    "@reg-plugins/reg-notify-github-by-token-plugin": {
+      "GITHUB_TOKEN": "${GITHUB_TOKEN}",
+      "GITHUB_REPO_OWNER": "${GITHUB_REPO_OWNER}",
+      "GITHUB_REPO_NAME": "${GITHUB_REPO_NAME}",
+      "GITHUB_PR_NUMBER": "${GITHUB_PR_NUMBER}",
+      "section": "trial"
+    },
     "reg-publish-gcs-plugin": {
       "bucketName": "trial-numa-storybook-vrt",
     }
   }
 }

プラグインの大枠を実装する

ここから実際にプラグインを作っていきます。reg-notify-github-pluginの実装を見ていると、以下のような構成をするようです。

index.ts
import type { NotifierPluginFactory } from 'reg-suit-interface';

import { GitHubNotifierPlugin } from './github-notifier-plugin';

const factory: NotifierPluginFactory = () => {
  return {
    // notifierに通知するロジックインスタンスを渡す
    notifier: new GitHubNotifierPlugin(),
  };
};

export default factory;
github-notifier-plugin.ts
import {
  NotifierPlugin,
  NotifyParams,
  PluginCreateOptions,
} from 'reg-suit-interface';

export interface GitHubPluginOption {
  // regconfig.jsonで設定したパラメータの型
}

export class GitHubNotifierPlugin
  implements NotifierPlugin<GitHubPluginOption>
{
  init(config: PluginCreateOptions<GitHubPluginOption>) {
    const {
      logger, // reg-suit用のロガー
      options, // regconfig.jsonで設定したパラメータ
    } = config;
  }

  async notify(params: NotifyParams): Promise<any> {
    // 実際に通知する処理を書く
  }
}

これを参考に、github-notifier-plugin.tsを以下のようなコードにしました。PR上のコメントを操作するメソッドは2つのやり方があったので抽象クラスを用意して、そのインターフェースに合うように2パターンの実装を用意しました。詳細については次のセクションで説明します。コメントを既にしているかの判定はNetlifyのGitHub Actionsを参考にしました。識別用のメッセージにpackagesなどの区分を含めることで複数のVRTのレポートコメントをできるようにしました。
https://github.com/nwtgck/actions-netlify/blob/v3.0.0/src/main.ts#L9-L15
https://github.com/nwtgck/actions-netlify/blob/v3.0.0/src/main.ts#L28-L36

github-notifier-plugin.ts
import {
  NotifierPlugin,
  NotifyParams,
  PluginCreateOptions,
  Logger,
  ComparisonResult,
} from 'reg-suit-interface';

import {
  AbstractPrCommentManager,
  // 下のどちらかを使ってPRのコメントを操作する
  PrCommentManagerByOctokit,
  // PrCommentManagerByFetchApi,
} from './PrCommentManager';

import { generateReportMessage } from './generateReportMessage';

export interface GitHubPluginOption {
  /** PRにコメントするためのGitHub Token */
  GITHUB_TOKEN: string;
  /** リポジトリのオーナー */
  GITHUB_REPO_OWNER: string;
  /** リポジトリ名 */
  GITHUB_REPO_NAME: string;
  /** コメント対象のPR番号 */
  GITHUB_PR_NUMBER: string;
  /** 区分(複数のVRTを実行する際に識別するための文字) */
  section?: string;
}

export class GitHubNotifierPlugin
  implements NotifierPlugin<GitHubPluginOption>
{
  private _logger!: Logger;
  private _GITHUB_TOKEN!: string;
  private _GITHUB_REPO_OWNER!: string;
  private _GITHUB_REPO_NAME!: string;
  private _GITHUB_PR_NUMBER!: number;
  private _section?: string;

  init(config: PluginCreateOptions<GitHubPluginOption>) {
    const { logger, options } = config;
    this._logger = logger;
    this._GITHUB_TOKEN = options.GITHUB_TOKEN;
    this._GITHUB_REPO_OWNER = options.GITHUB_REPO_OWNER;
    this._GITHUB_REPO_NAME = options.GITHUB_REPO_NAME;
    this._GITHUB_PR_NUMBER = parseInt(options.GITHUB_PR_NUMBER, 10);
    this._section = options.section;
  }

  async notify(params: NotifyParams): Promise<any> {
    if (Number.isNaN(this._GITHUB_PR_NUMBER)) {
      this._logger.info('PR番号が不正なため、コメントをスキップします。');
      return;
    }
    const { comparisonResult, reportUrl } = params;

    const prManager: AbstractPrCommentManager = new PrCommentManagerByOctoKit({
      GITHUB_TOKEN: this._GITHUB_TOKEN,
      GITHUB_REPO_NAME: this._GITHUB_REPO_NAME,
      GITHUB_REPO_OWNER: this._GITHUB_REPO_OWNER,
    });

    const comments = await prManager.fetchComments(this._GITHUB_PR_NUMBER);

    /** 既にコメントしているかを判別するためのメッセージ */
    const identifierMessage = `<!-- REG SUIT COMMENT GENERATED BY REG NOTIFY GITHUB PLUGIN${this._section ? ` - Section: ${this._section}` : ''} -->`;

    const existingComment = comments.find((comment) => {
      return comment.body?.includes(identifierMessage);
    });

    const reportMessage = generateReportMessage({
      section: this._section,
      comparisonResult,
      reportUrl,
    });
    const message = `${identifierMessage}\n${reportMessage}`;

    if (existingComment) {
      const result = await prManager.updateComment(existingComment.id, message);
      this._logger.info(
        'VRTレポートコメントを更新しました: ' + result.html_url,
      );
    } else {
      const result = await prManager.createComment(
        this._GITHUB_PR_NUMBER,
        message,
      );
      this._logger.info(
        'VRTレポートコメントを作成しました: ' + result.html_url,
      );
    }
  }
}

generateReportMessageのコードはそこまで重要な内容ではないため折りたたみで記載します。詳細が気になる方は開いてご参照ください。

generateReportMessage.ts
generateReportMessage.ts
/**
 * レポートの内容を生成する
 */
const generateReportMessage = ({
  section,
  comparisonResult,
  reportUrl,
}: {
  section?: string;
  comparisonResult: ComparisonResult;
  reportUrl?: string;
}) => {
  const { newItems, diffItems, deletedItems, passedItems } = comparisonResult;

  const vrtLabel = section ? `${section}のVRT` : 'VRT';

  if (
    newItems.length === 0 &&
    diffItems.length === 0 &&
    deletedItems.length === 0
  ) {
    return `${vrtLabel}に差分はありません :sparkles:`;
  }

  const messages = [`${vrtLabel}に差分があります。`];
  messages.push(
    '| :red_circle: Changed | :white_circle: New | :black_circle: Deleted | :large_blue_circle: Passing |',
    '| --- | --- | --- | --- |',
    `| ${diffItems.length} | ${newItems.length} | ${deletedItems.length} | ${passedItems.length} |`,
  );

  if (reportUrl) {
    messages.push(
      '',
      `[レポート](${reportUrl})を確認してください。`,
      '- [ ] 差分に問題ないことを確認しました',
    );
  }
  return messages.join('\n');
};

PR上のコメントを操作するクラスを実装する

最後にPR上にコメントを作ったり更新したりするクラスを作りたいと思います。前セクションでも軽く触れましたが2パターン実装方法があります。

  • @octokit/restを使ってやり取りするパターン
    • ライブラリを使用することで簡単に実装できる
    • ビルド時にライブラリコードを同梱させる必要があるため成果物のコードが増える
      • dependencyに含めることでも解決できるが、意外とパッケージのサイズが大きい
  • fetch APIで直接アクセスするパターン
    • 直接APIリクエストするため少し手間だが、ライブラリコードの同梱が不要なため成果物が最小限で抑えられる

それぞれメリットデメリットがあるため、両方のパターンを試せるようにしました。コードの切り替えを行いやすくするため、まずは抽象クラスを用意します。
レスポンスの型は結構特殊で、@octokit/typesEndpointsの中にありますが、メソッド名 APIパスという形式で定義されているため、その形式で参照するようにしています。

https://github.com/octokit/types.ts/blob/v13.5.0/src/generated/Endpoints.ts#L107-L121

AbstractPrCommentManager.ts
import type { Endpoints } from '@octokit/types';

export type FetchCommentsResponse =
  Endpoints['GET /repos/{owner}/{repo}/issues/{issue_number}/comments']['response'];

export type CreateCommentResponse =
  Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response'];

export type UpdateCommentResponse =
  Endpoints['PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}']['response'];

export type InitialParams = {
  GITHUB_TOKEN: string;
  GITHUB_REPO_OWNER: string;
  GITHUB_REPO_NAME: string;
};

export abstract class AbstractPrCommentManager {
  /** PRにコメントするためのGitHub Token */
  protected _GITHUB_TOKEN: string;
  /** リポジトリのオーナー */
  protected _GITHUB_REPO_OWNER: string;
  /** リポジトリ名 */
  protected _GITHUB_REPO_NAME: string;

  constructor({
    GITHUB_TOKEN,
    GITHUB_REPO_NAME,
    GITHUB_REPO_OWNER,
  }: InitialParams) {
    this._GITHUB_TOKEN = GITHUB_TOKEN;
    this._GITHUB_REPO_NAME = GITHUB_REPO_NAME;
    this._GITHUB_REPO_OWNER = GITHUB_REPO_OWNER;
  }

  /**
   * 該当のPRのコメント一覧を取得する
   * @param prNumber - PR番号
   */
  abstract fetchComments(
    prNumber: number,
  ): Promise<FetchCommentsResponse['data']>;

  /**
   * 該当のPRにコメントを追加する
   * @param prNumber - PR番号
   * @param body - コメント内容
   */
  abstract createComment(
    prNumber: number,
    body: string,
  ): Promise<CreateCommentResponse['data']>;

  /**
   * 該当のコメントを更新する
   * @param commentId - 更新対象のコメントID
   * @param body - コメント内容
   */
  abstract updateComment(
    commentId: number,
    body: string,
  ): Promise<UpdateCommentResponse['data']>;
}

この抽象クラスをベースにまずはoctokit/restを使ったパターンは以下のコードになります。それぞれのメソッドはライブラリで用意されているものなので、単純にそれを呼ぶだけで済みます。

PrCommentManagerByOctokit.ts
import { Octokit } from '@octokit/rest';
import {
  AbstractPrCommentManager,
  InitialParams,
} from './AbstractPrCommentManager';

/** octokit/restを使ってGitHubのPRコメントを管理する */
export class PrCommentManagerByOctokit extends AbstractPrCommentManager {
  private _octokit: Octokit;

  constructor(params: InitialParams) {
    super(params);
    this._octokit = new Octokit({ auth: this._GITHUB_TOKEN });
  }

  async fetchComments(prNumber: number) {
    const { data } = await this._octokit.issues.listComments({
      owner: this._GITHUB_REPO_OWNER,
      repo: this._GITHUB_REPO_NAME,
      issue_number: prNumber,
    });
    return data;
  }

  async createComment(prNumber: number, body: string) {
    const { data } = await this._octokit.issues.createComment({
      owner: this._GITHUB_REPO_OWNER,
      repo: this._GITHUB_REPO_NAME,
      issue_number: prNumber,
      body,
    });
    return data;
  }

  async updateComment(commentId: number, body: string) {
    const { data } = await this._octokit.issues.updateComment({
      owner: this._GITHUB_REPO_OWNER,
      repo: this._GITHUB_REPO_NAME,
      comment_id: commentId,
      body,
    });
    return data;
  }
}

fetch APIのパターンは以下のようになります。Endpointsの型がメソッド名 APIパスをキーにして持っているため、その形式に沿った形で定数を定義してから実際のfullPathを算出してリクエストを投げるようにしました。

PrCommentManagerByFetchApi.ts
import {
  AbstractPrCommentManager,
  InitialParams,
} from './AbstractPrCommentManager';
import type { Endpoints } from '@octokit/types';

const GITHUB_API_DOMAIN = 'https://api.github.com';

/** fetch APIを使ってGitHubのPRコメントを管理する */
export class PrCommentManagerByFetchApi extends AbstractPrCommentManager {
  private _requestHeaders: HeadersInit;

  constructor(params: InitialParams) {
    super(params);

    this._requestHeaders = {
      Authorization: `token ${this._GITHUB_TOKEN}`,
    };
  }

  async fetchComments(prNumber: number) {
    const methodType = 'GET' as const;
    const templateUrl =
      '/repos/{owner}/{repo}/issues/{issue_number}/comments' as const;
    const fullPath =
      GITHUB_API_DOMAIN +
      templateUrl
        .replace('{owner}', this._GITHUB_REPO_OWNER)
        .replace('{repo}', this._GITHUB_REPO_NAME)
        .replace('{issue_number}', prNumber.toString());

    const response = await fetch(fullPath, {
      method: methodType,
      headers: this._requestHeaders,
    }).then(async (res) => {
      if (!res.ok) {
        throw new Error('Failed to fetch comments:' + res.statusText);
      }
      const data: Endpoints[`${typeof methodType} ${typeof templateUrl}`]['response']['data'] =
        await res.json();
      return data;
    });

    return response;
  }

  async createComment(prNumber: number, body: string) {
    const methodType = 'POST' as const;
    const templateUrl =
      '/repos/{owner}/{repo}/issues/{issue_number}/comments' as const;
    const fullPath =
      GITHUB_API_DOMAIN +
      templateUrl
        .replace('{owner}', this._GITHUB_REPO_OWNER)
        .replace('{repo}', this._GITHUB_REPO_NAME)
        .replace('{issue_number}', prNumber.toString());

    const response = await fetch(fullPath, {
      method: methodType,
      body: JSON.stringify({ body }),
      headers: this._requestHeaders,
    }).then(async (res) => {
      if (!res.ok) {
        throw new Error('Failed to create comment:' + res.statusText);
      }
      const data: Endpoints[`${typeof methodType} ${typeof templateUrl}`]['response']['data'] =
        await res.json();
      return data;
    });

    return response;
  }

  async updateComment(commentId: number, body: string) {
    const methodType = 'PATCH' as const;
    const templateUrl =
      '/repos/{owner}/{repo}/issues/comments/{comment_id}' as const;
    const fullPath =
      GITHUB_API_DOMAIN +
      templateUrl
        .replace('{owner}', this._GITHUB_REPO_OWNER)
        .replace('{repo}', this._GITHUB_REPO_NAME)
        .replace('{comment_id}', commentId.toString());

    const response = await fetch(fullPath, {
      method: methodType,
      body: JSON.stringify({ body }),
      headers: this._requestHeaders,
    }).then(async (res) => {
      if (!res.ok) {
        throw new Error('Failed to update comment:' + res.statusText);
      }
      const data: Endpoints[`${typeof methodType} ${typeof templateUrl}`]['response']['data'] =
        await res.json();
      return data;
    });

    return response;
  }
}

GitHub Actionsに組み込む

後はGitHub Actionsで以下のように設定したら完成です。

プラグインを事前にビルドしてからVRTを実行する
jobs:
  vrt:
    permissions:
      contents: read
      # PRコメントを書き込めるように権限を付与する
      pull-requests: write

    steps:
      # セットアップは省略
      # プラグインを事前にビルドする
      - name: Build Plugin
        run: npm run -w @reg-plugins/reg-notify-github-by-token-plugin build

      # 必要なパラメータをenvに渡してvrtを実行する
      - name: Run Reg-Suit
        run: npm run storybook-vrt:reg-suit
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPO_OWNER: ${{ github.repository_owner }}
          GITHUB_REPO_NAME: ${{ github.event.repository.name }}
          GITHUB_PR_NUMBER: ${{ github.event.number }}

終わりに

以上がGITHUB_TOKENを使ってPR上に複数のVRTレポートをコメントするreg-suitプラグインの実装内容でした。実運用では完全にパッケージ化していますのでinstallするだけで楽に使用できるようになりました😊
reg-suitはかなり汎用的に作られており、また拡張性にも富んでいるため、何かカスタマイズしたい時の参考になれば幸いです。

Discussion