GitHub ActionsでSlackに投稿

割とハマってだるいので今回はサンプル程度に PR の一覧を取得して個別に Slack に投げるものを作ってみます

PR が 2 個ある場合、出力イメージはこんな感じ。2 投稿に分けて投稿します

API トークンの入手

まずは Slack API を叩くためのトークンをゲットします

  1. Create an appからアプリを作成
  2. 左のメニューから Features -> OAuth & Permissions
  3. Scopes を設定
    1. 今回は Bot Token Scopes を chat:write とします
  4. 左のメニューから Settings -> Install App to Your Team でアプリをインストール
  5. トークンが吐き出されるのでメモする

GitHub Actions Workflows の作成

SECRET の設定

  1. Slack API トークンをリポジトリの Secrets に突っ込んでおきます
  2. 名前は一旦 SLACK_TOKEN とします

Workflows の作成

前提

  • PR 一覧の取得には actions/github-script を利用します
    • GitHub 内部の情報を抜いたり、JS で処理を組みたいときに重宝します
    • API リファレンスが読みやすいので、使うのにはあんま苦労しないと思います
  • Slack API を叩くのには curl を利用します
    • actions/github-script から叩くのは多分難しいです

ベースの作成

これに肉付けをしていきます

name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:

PR 一覧の取得

List pull requestsにある通りに進めていきます

- uses: actions/github-script@v6
  id: set-result
  with:
    result-encoding: string
    script: |
      const { data: respPulls } = await github.rest.pulls.list({
        owner: context.repo.owner,
        repo: context.repo.repo,
      });

      console.log(respPulls);

PR 一覧の加工

こんなデータを取る感じで組んでいきます
API 仕様は List pull requests を参照

type PullRequest = {
  id: number;
  reviewers: string[];
};

先ほど取得した respPulls を上記の型付けになるように加工します

const getReviewersName = (requested_reviewers) => {
  return requested_reviewers.map((reviewers) => {
    return reviewers.login;
  });
};

const getPullRequests = (pulls) => {
  return pulls.map((pull) => {
    return {
      id: pull.number,
      reviewers: getReviewersName(pull.requested_reviewers),
    };
  });
};

const pulls = getPullRequests(respPulls);

Slack に投げるメッセージの作成

こんなメッセージを PR の数分組んでいきます

なお実際に Slack でメンションを作る場合は GitHub のスクリーン名と Slack のユーザー ID の突き合わせ処理が別途必要です。やり方は別途後述します

@foo @bar
https://example.com/pulls/1

やっていること

  • 上記のフォーマットでメッセージを作成
  • シェルスクリプトで配列として扱うために Base64 にエンコード
    • 改行コードが混ざっていると扱いづらいので
  • エンコードした文字列をスペース区切り文字列として連結
    • AAA BBB CCC ... みたいな
  • 最後に Workflows の戻り値として設定しています
const encodedMessages = pulls.reduce((messages, pull) => {
  const reviewersBuff = pull.reviewers
    .reduce((acc, cur) => {
      return `${acc}${cur} `;
    }, '')
    .replace(/ $/, '');

  const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

  const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
  const encodedMessage = Buffer.from(message).toString('Base64');

  return `${messages}${encodedMessage} `;
}, '');

return encodedMessages;

curl を利用して Slack API を叩く

やっていること

  • encodedMessages=(${{steps.set-result.outputs.result}})
    • 前項で作った文字列を配列として取得しています
  • for message in ${encodedMessages[@]}
    • foreach 的なやつです
    • 改行コードがこの時点で存在すると上手くいきません
  • decoded_mes=$(echo ${message} | base64 -di)
    • ここで Base64 エンコードをデコードします
  • postSlack "$decoded_mes"
    • 別引数にならないように "" で固めます
  • curl 叩いてるところ
    • -d の中をヒアドキュメントで展開するのが味噌です
    • 単純に文字列として扱うと変数展開が起きて JSON が壊れます
      - run: |
        postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
        EOF
        }

        encodedMessages=(${{steps.set-result.outputs.result}})

        for message in ${encodedMessages[@]}
        do
            decoded_mes=$(echo ${message} | base64 -di)
            postSlack "$decoded_mes"
        done

コード全体

name: Post to slack example
on:
  workflow_dispatch:
jobs:
  post-slack:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v6
        id: set-result
        with:
          result-encoding: string
          script: |
            const getReviewersName = (requested_reviewers) => {
              return requested_reviewers.map((reviewers) => {
                return reviewers.login;
              });
            };

            const getPullRequests = (pulls) => {
              return pulls.map((pull) => {
                return {
                  id: pull.number,
                  reviewers: getReviewersName(pull.requested_reviewers),
                }
              });
            }

            const { data: respPulls } = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
            });

            const pulls = getPullRequests(respPulls);

            const encodedMessages = pulls.reduce((messages, pull) => {
              const reviewersBuff = pull.reviewers.reduce((acc, cur) => {
                return `${acc}${cur} `
              }, '').replace(/ $/, '');

              const reviewers = reviewersBuff === '' ? 'レビュアー未設定' : reviewersBuff;

              const message = `${reviewers}\\nhttps://example.com/pulls/${pull.id}`;
              const encodedMessage = Buffer.from(message).toString('Base64');

              return `${messages}${encodedMessage} `
            }, '');

            return encodedMessages;
      - run: |
          postSlack() {
            local mes=$1

            curl -sS https://slack.com/api/chat.postMessage \
                -H 'Authorization: Bearer ${{ secrets.SLACK_TOKEN }}' \
                -H 'Content-Type: application/json; charset=UTF-8' \
                -d @- <<EOF
                {
                    token: "${{ secrets.SLACK_TOKEN }}",
                    channel: "#api-test",
                    text: "$mes"
                }
          EOF
          }

          encodedMessages=(${{steps.set-result.outputs.result}})

          for message in ${encodedMessages[@]}
          do
              decoded_mes=$(echo ${message} | base64 -di)
              postSlack "$decoded_mes"
          done

Appendix:Slack にメンションを投げる方法

Slack へ実際にメンションを投げるのはユーザー ID を指定する必要があります
参考:Formatting text for app surfaces

"text": "<@U024BE7LH> Hello" のようにすることでメンションを投げられます

ユーザー ID は Slack アプリから相手のプロフィールを開き、そこにあるハンバーガーメニューみたいなやつから取れます。一応取得用の API もあります