GASを使ってGithubのプルリク状態をSlackに通知する

## はじめに
皆さん、こんにちは！
最近（また）コーヒーホリックになりつつあるエンジニアの@atokです。

皆さんは、複数のGithubリポジトリでプルリクエストが溜まってしまうような経験をしたことはないでしょうか。
私の所属している開発チーム"[Habitat Hub](https://www.habitat-hub.com/)"では、時期によって複数のプロジェクトを管理しています。もちろんプロジェクト毎にそれぞれGithubのリポジトリが存在する訳なのですが、
- どれくらいプルリクが溜まっているのかがぱっと見でわからない
- どのプルリクにだれがレビュー者として入っているのかがわからない

というような原因から、プルリクエストを出したけど検知されずに時間が経過してしまうことがしばしばありました。
Jiraのチケットを使ったタスク管理で、これまでに
- Jiraのチケットのステータスを「レビュー」に変更した際、Slackに通知させる（自動）
- それぞれがSlackでレビュー依頼を出す（手動）

などの運用を試してはみたものの、それだけではプルリクが溜まってしまう問題は回避できませんでした。

そこで今回は、SlashコマンドからGithubのAPIを呼んで、対象のリポジトリのプルリクエストの一覧をSlack上のチャンネルに通知する仕組みを構築したのでご紹介します。
GASの汎用的な設定や内容についても記載しているので、皆さんのお役にたてば幸いです。

## GASとは
Google Apps Script (GAS) は、Gmail や Google スプレッドシートなどの Google サービスを簡単に自動化したり拡張したりできる便利なツールです。JavaScript に基づいているので、簡単にスクリプトを書いてウェブアプリを作成したり、日常の作業を効率化したりできます。また、他のサービスとも簡単に連携できる柔軟性も持ち合わせています。

## 構築した仕組み
以下のように、Slashコマンドをトリガーとして、対象のリポジトリのプルリクエストの一覧をSlack上のチャンネルに通知します。

![revie-pr-list-image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/2e0345e9-ed53-6842-6247-de37d163efc9.png)

サンプルソースは[git](https://github.com/atoook/qiita_article_gas_getPrList/blob/main/getOpenPrs.gs)上に公開していますので、参照してみてください。


## Slack/GASの設定
- Slashコマンドを作る
    - [slack api](https://api.slack.com/)からSlackワークスペースにログインし、「Create New App」からアプリを作成します
    - 以下の画面では、「From scratch」を選びます
    ![Screenshot 2024-07-21 at 22.06.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/baae27ba-9b26-e7d7-20f2-1361c794c699.png)

    - サイドバーより「Slash Commands」を選択し、新規のSlashコマンドを作成します
![Screenshot 2024-07-21 at 22.00.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/273075df-c718-e39e-fc53-61e112db2ba6.png)


- BOTの作成とアプリのインストール
　以下の手順で行います。
    - 権限を付与
        - 「OAuth Tokens for Your Workspace」を設定
    - AppをSlackにインストール

[こちら](https://qiita.com/guchimina/items/6858e041353f9a202f4c)の記事の「スコープの設定」~「AppをSlackにインストール」の通りに進めてください。

## Githubの設定
今回は、以下のAPIを使用します。
https://docs.github.com/ja/rest/pulls/pulls?apiVersion=2022-11-28

こちらのAPIを呼び出すには、Githubでトークンの設定が必要です。まずはGithubで「Personal Access Token」を作成しましょう。

「Personal Access Token」は、Githubアカウントの
「Settings」>「Developer Settings」配下にある以下の画面から設定します。

![personal_access_tokens_設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/adb848dc-12e1-6b6c-8d5d-4a61a8175f10.png)


「Generate new token」から設定画面に遷移し、任意の名称、説明を記述します。

![Screenshot 2024-07-21 at 18.13.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/4362937f-5278-f985-af2c-f7469bf3717e.png)

情報を取得したいリポジトリ（複数選択可）を選択します。

![Screenshot 2024-07-21 at 18.14.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/cb04ece3-843a-5f96-d457-8940cdf69c40.png)


「Repository permissions」で選択したリポジトリに対する権限の設定を行います。

![Screenshot 2024-07-21 at 18.37.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/811b6b55-bbe2-da99-db3b-d8bd2d5936f0.png)

以下のRead権限を設定します。

- Commit statuses
- Contents
- Deployments
- Discussions
- Metadata
- Pull requests

選択したら、「Generate token」ボタンを押下して、トークンを生成します。
※トークンはこの画面でしか表示されないので、ここでコピーしておきます。

![Screenshot 2024-07-21 at 18.23.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/a496b775-a908-9ed6-13e9-29e1fee70d4e.png)


## GASの作成と設定

### Google Apps Scriptをつくる
GoogleドライブからGoogle Apps Scriptをつくります。
まずは[サンプルソース](https://github.com/atoook/qiita_article_gas_getPrList/blob/main/getOpenPrs.gs)の内容をコピペして保存しましょう。
保存後は、Google Apps Scriptを「種類：<strong>ウェブアプリ</strong>」でデプロイします。
デプロイすると、「WEBアプリURL」が取得できます。
![Screenshot 2024-07-21 at 22.35.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/db329cda-fe8d-42af-e66e-8b708e43e709.png)

取得した「WEBアプリURL」を、Slashコマンドに紐づけて登録します。
設定箇所は、slack apiの「slash comamnds」の設定画面の以下です。
![Screenshot 2024-07-21 at 22.39.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/fb475e52-66b6-97b0-5b67-ff39b958b8a2.png)

※この「WEBアプリURL」は、デプロイの度に更新されます。デプロイした際には設定の更新を忘れないようにしてください。

### ライブラリ(SlackApp)の追加

同じく[こちら](https://qiita.com/guchimina/items/6858e041353f9a202f4c)の記事の「ライブラリ(SlackApp)の追加」の章が参考になります。

### スクリプトプロパティの設定

サイドバーの「プロジェクトの設定」から設定画面に遷移できます。

![Screenshot 2024-07-21 at 18.41.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/7fd2b1f0-6566-4afe-8ccd-57cd8121e738.png)

### 設定するプロパティについて

| プロパティ名 | 設定値 |
| -- | -- |
| BOT_TOKEN | slack apiの「BOT User OAuth Token」を設定(下部画像参照) |
| PR_NOTIFICATION_TOKEN |  Github上で生成した「Personal Access Token」を設定 |
| SPREADSHEET_ID | ログをスプレッドシートに残すためのスプレッドシートのIDを設定 |
| VERIFICATION_TOKEN | slack apiの「Verification Token」の値を設定(下部画像参照)|

「BOT User OAuth Token」の記載箇所：
![Screenshot 2024-07-21 at 23.08.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/c33e3d0e-db33-ce08-4d68-62b4706528e9.png)


「Verification Token」の記載箇所：
![Screenshot 2024-07-21 at 18.53.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/1e4f006e-7166-0497-c54a-5686f79c5550.png)


#### スプレッドシートのIDの補足
スプレッドシート ID は URL から抽出できます。たとえば、URL
<code<foo>https</foo>://docs.google.com/spreadsheets/d/abc1234567/edit#gid=0</code>
のスプレッドシート ID は「abc1234567」です　（[参照](https://developers.google.com/apps-script/reference/spreadsheet/spreadsheet-app?hl=ja#openbyidid)）

##### 利用箇所
printデバッグするためにGASで以下のような関数を作っています。
色々なプロジェクトでそのまま使えるので便利です。

``` javascript
/**
 * 実行ログを記録する(デバッグ用。「スプレッドシートの指定したシート」に出力。)
 */
function writeLogsInSpreadSheet(text) {
  const SHEET_NAME = "Logs";
  const SPREADSHEET_ID =
    PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID");
  const sheet =
    SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
  let lastRow = sheet.getLastRow();
  sheet.getRange(lastRow + 1, 1).setValue(text);
}
```

設定するプロパティは以上です。


## GASの実装

[サンプルソース](https://github.com/atoook/qiita_article_gas_getPrList/blob/main/getOpenPrs.gs)の実装について、絞って説明します。

### doPostメソッドについて
doPostはSlashコマンドから呼び出されるメインの処理になります。
ここでは、指定のSlashコマンドから呼び出されたVARIFICATION_TOKENの一致するリクエストに対して、メインの処理を実行しています。

[Slack公式ドキュメント](https://api.slack.com/interactivity/slash-commands#responding_basic_receipt)によると、Slashコマンドがリクエストを送信してから、3000ms以内にレスポンスが返されないと、タイムアウトエラーが発生してしまいます。
> This confirmation must be received by Slack within 3000 milliseconds of the original request being sent, otherwise an operation_timeout error will be displayed to the user.

そのため、レスポンスに時間がかかるような処理を行う場合は、
- 3000ms以内に返すように処理を工夫
- 非同期実行で処理を行う
上記のいずれかの方法を取る必要があります。

APIをなるべくまとめて呼出するなど試してしてみたものの、タイムアウトエラーの発生が完全には防げなかったので、「非同期実行で処理を行う」方針としました。

なお、タイムアウト対策は[こちら](https://zenn.dev/cahchachakari/articles/ae9de2b919a0e6) の記事を参考にさせていただきました。

<details><summary>doPostメソッドの実装</summary><div>

```javascript
function doPost(e) {
  // SlackのAPI設定画面の「Basic information > App Credentials > Verification」に設定したトークンを、GASのプロパティファイルから取得。
  const VARIFICATION_TOKEN =
    PropertiesService.getScriptProperties().getProperty("VERIFICATION_TOKEN");
  // slash_commandを受け付けた場合の処理
  const COMMAND = "/your_target_slash_command_name";
  const CHANNEL_NAME = "your_target_channel_name";
  if (e.parameter.command === COMMAND) {
    if (e.parameter.token !== VARIFICATION_TOKEN) {
      return ContentService.createTextOutput("不正なリクエストです。");
    }
    
    deleteTriggers();
    ScriptApp.newTrigger("callMain") //発火させたいメソッド名
      .timeBased() //時間主導型のトリガー
      .after(10) //ミリ秒で設定
      .create();

    return ContentService.createTextOutput(
      `PRリスト取得処理を非同期で実行中... \n　結果を #${CHANNEL_NAME} で確認してください。(約1分程度かかります)`
    );

    // 以下のように返却しようとすると、タイムアウトになってしまうことが多い（対象のレポジトリ数やPR数による）
    // return ContentService.createTextOutput(`${main(true)}`);
  }
}
```
</div></details>


※COMMANDにはSlashコマンドに指定したコマンドの命名を、CHANNEL_NAMEには、BOTに投稿させるSlackのチャンネル名を設定してください。


### mainメソッドについて
mainメソッド内では、以下の処理を行っています。
- プルリクエスト一覧を取得するAPIを呼出する（fetchPrList）
- 取得した一覧から、OPEN状態のプルリクエストのURLを取得する（editResponse）
- 取得したURLを元にそれぞれのプルリクエストの情報を取得するAPIを呼出する（createRequestObject）
- 取得したプルリクエストの情報から、「targetKeysToExtract」に指定した情報を抽出する（editExtractDatas）
- 抽出した情報からメッセージ内容を組み立てて呼び出し元に返す

※OWNERには対象レポジトリのオーナー名を、targetReposには、対象のレポジトリ名（複数可）を設定します。
<details><summary>mainメソッドの実装</summary><div>

```javascript
function main(isSlashCommand) {
  //GithubのPersonal Access Tokenに設定したトークンを、GASのプロパティファイルから取得。これをヘッダのAuthorizationに渡す
  const PR_NOTIFICATION_TOKEN =
    PropertiesService.getScriptProperties().getProperty(
      "PR_NOTIFICATION_TOKEN"
    );
  const OWNER = "your_target_repos_owner_name";
  const targetRepos = ["your_target_repo_1", "your_target_repo_2"];
  const targetKeysToExtract = [
    "draft",
    "number",
    "title",
    "html_url",
    "user",
    "requested_reviewers",
  ];

  //メッセージ表示用の設定
  const targetKeysForDisplay = [
    "title",
    "html_url",
    "user",
    "requested_reviewers",
  ];
  const targetTextForDisplay = ["", "", "担当者 : ", "レビュワー : "];
  const textMapByKey = associateKeyWithText(
    targetKeysForDisplay,
    targetTextForDisplay
  );

  const results = {};
  targetRepos.forEach((repo) => {
    const response = fetchPrList(PR_NOTIFICATION_TOKEN, OWNER, repo);
    const extractDatas = editResponse(response, targetKeysToExtract);
    results[repo] = extractDatas;
  });

  //レビューの明細取得のAPI呼び出しはまとめて実行
  const reviewDetailUrLs = [];
  targetRepos.forEach((repo) => {
    results[repo].forEach((result) => {
      reviewDetailUrLs.push(
        createRequestObject(PR_NOTIFICATION_TOKEN, OWNER, repo, result.number)
      );
    });
  });
  const reviewDetails = UrlFetchApp.fetchAll(reviewDetailUrLs);

  targetRepos.forEach((repo) => {
    for (let i = 0; i < results[repo].length; i++) {
      editExtractDatas(reviewDetails[i], results[repo][i]);
    }
  });

  const message = createMessageText(
    results,
    targetKeysForDisplay,
    textMapByKey
  );

  if (isSlashCommand) {
    return message;
  }

  postToSlack(message);
}
```
</div></details>


### 動作イメージ

- 自身のチャット画面でSlashコマンドを実行する
![Screenshot 2024-07-22 at 0.24.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/c8328720-8ddc-af3b-23e7-dff1bd340c51.png)
- 指定したチャンネルにBOTから情報が投稿される
![Screenshot 2024-07-22 at 0.26.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/150636/bcad026d-eacd-9ac0-9f37-47f59e11fe5e.png)

こんな感じです！
一覧形式で表示されるので、各リポジトリのPRの状況が把握しやすくなりました。

※毎日特定の時間に結果をさせたい場合などには、時間ベースのイベントでcallMainメソッドを呼び出すよう設定することで同様の結果が得られます。

## さいごに

これまでも何度かGASを使って、簡単なツールを作ってきましたが、今回は特にタイムアウト問題の解消に苦戦しました。色々と制約はありますが、GASはやりたいことを手っ取り早く実現するためには非常に便利です。今後も何か良い活用方法があれば、シェアしていきたいと思います。


## 公式ドキュメント
- https://api.slack.com/interactivity/slash-commands#responding_basic_receipt
- https://docs.github.com/ja/rest/pulls/pulls?apiVersion=2022-11-28

## 関連記事
- https://qiita.com/guchimina/items/6858e041353f9a202f4c
- https://zenn.dev/cahchachakari/articles/ae9de2b919a0e6