CoverageをPull RequestにコメントするActionsを作った

これは、[GitHub Actions Advent Calendar 2021](https://qiita.com/advent-calendar/2021/github-actions)の10日目の記事です。

こんにちは、つい先日(12月初めに)無料で使える中品質なテキスト音声合成ソフトウェア、[VOICEVOX](https://voicevox.hiroshiba.jp)のOSS版レビュワーになった、Yちゃんです。
レビュワーになった・レビュワーに選んでもらえた経緯はいろいろあると思うのですが、いろんな形で貢献できたことが一つあるかなと思います。

そんな中で、私がGitHub Actionsを用いて貢献した例を一つ紹介していこうかなと思います。

## CI整備をするIssueを引き受けた

VOICEVOXには主に、音声ライブラリを保持する[コア](https://github.com/VOICEVOX/voicevox_core)、コアを用いて音声合成に必要な処理を仲介する[エンジン](https://github.com/VOICEVOX/voicevox_engine)、エンジンの処理結果を用いてUIを構成する[エディタ](https://github.com/VOICEVOX/voicevox)の3つのパーツがあります。
コアは版権等々の問題により、完全にOSSではありませんが、それ以外の部分は完全にOSSです。
その中で、エンジンに[テストを書いてCIを整備するIssue](https://github.com/VOICEVOX/voicevox_engine/issues/16)が立ち、私はそれを引き受けました。
現状、まだテストは完全ではありませんが、かなり進められたと思います(大体80%ぐらい進めました)。

## テスト整備のモチベーション

テストの整備は、ソフトウェア改善のための貢献ではなく、ソフトウェアの品質を保つための貢献になります。
ユーザーから見て変化が見えない貢献となり、とても地道な作業になります。
そのため、モチベーションが保ちにくく、取り組む人も少ないというところがあります。
そこで、私は一つの指標としてテストのCoverageを用い、それをモチベーションにすることにしました(この手法はよく用いられると思います)。
ということで、[Coverageがどの程度なのかを可視化したいというIssue](https://github.com/VOICEVOX/voicevox_engine/issues/137)を立てました。

## Coverageの可視化(バッジ化)とPull Requestに対するコメント

Issueに対応する形の[Pull Request](https://github.com/VOICEVOX/voicevox_engine/pull/143)を立てました。
また、同時に機能実装やテスト実装でどの程度増減するのかをコメントしてくれる形のものも追加しました。
これは、Pull Requestがマージされる前に、どの程度テストが実装されているかを確認できる良い指標となると踏んだので、追加したものです。
バッジ化はよかったのですが、Pull Requestに対するコメントに関してが、地獄の始まりでした...

### Pull RequestをトリガーとしたActionsはいろいろ制限がかかる

これを失念していました...
何と、リポジトリ所有者以外からのPull Requestには、GitHub ActionsがPull Requestに対してコメントする権限がないのです！
また、その権限を別で持たせたPersonal TokenをActions secretsに登録したとしてもアクセス権限がありません。

自分のリポジトリにPRするのはうまく動いたのに、オリジナルリポジトリへのPRでは動かないといった状況に少し躓きました。

### `pull_request_target`トリガーで対応できるものの...

`pull_request`トリガーがセキュリティ的にアクセスできないものが存在するという中で、これを使うというのはセキュリティ的に穴を作ってしまいかねません。
なので、別の手段を用いる必要がありました。
それが`workflow_run`トリガーです。

## `workflow_run`トリガーでPull Requestにコメントする

`workflow_run`トリガーは、指定したGitHub Actionsのワークフローが開始(正確にはリクエストされた、`requested`)もしくは完了(`completed`)した際に走るワークフローになります。
このトリガーで起動されたワークフローは、Pull Requestへの書き込み権限や、Secretsなどへのアクセス権限を持ちます。また、`workflow_run`がトリガーとなるActionsを書き換えるようなPull Requestが来ても、それはマージされるまで反映されません(`pull_request`トリガーによって実行されるワークフローは、新しくPull Requestが作成された際に、実行して結果を知ることができますが、`workflow_run`ではそれができません)。よって、セキュリティリスクも防ぐことができ、安全です。
ということで、これを使ってPull Requestにコメントを行うActionsを書いていきましょう。

### コメントしたいPull Requestの番号と内容をテキスト化し、`artifacts`としてアップロード

残念ながら、`workflow_run`だけでは、それまでに行われてきた作業情報を引き継ぐことができません。
というわけで、それまでの作業情報を保存し、`artifacts`としてアップロードする必要があります。
今回の場合を例にして、実際に実行可能なGitHub Actionsのワークフローを書いていきます。
重要な点等は、`test.yaml`の中にコメントとして書いています。

※実際に使われているワークフローとは差分があります。実際に使われているものは[こちら](https://github.com/VOICEVOX/voicevox_engine/blob/master/.github/workflows/test.yml)をご覧下さい。


```yaml:test.yaml
name: test

on:
  pull_request

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Set up Python 3.8
        uses: actions/setup-python@v2
        with:
          python-version: 3.8

      # 今回は、テストに最低限必要なpytestとcoverallsだけを入れます。
      # 本来であれば、requirements.txtとかもインストールしなければなりませんが、
      # 今回は省略します。
      - name: Install dependencies
        run: |
          python -m pip install pytest coveralls

      # Coverageを取得しながらテストを回す。
      - name: Run pytest and get coverage
        run: |
          coverage run --omit=test/* -m pytest

      # 次のPull Requestコメント用ワークフローのために、Coverageレポートを作成します。
      # ここで重要なのは、"report/pr_num.txt"です。
      # workflow_runで実行されたワークフローは、
      # 自分がどのようなタイプのワークフローの後に呼び出されたのかしか記録していないため、
      # 単体ではコメントすべきPull Requestはわかりません。
      # なので、Pull Requestの番号(github.event.number)を記録しておきます。
      - name: Create coverage result
        run: |
          mkdir report
          coverage report > report/report.txt
          echo ${{ github.event.number }} > report/pr_num.txt

      # workflow_runで呼び出されるワークフローのために、
      # 作成したレポートとPull Request番号のファイルをartifactsとしてアップロードします。
      - name: Upload coverage result
        uses: actions/upload-artifact@v2
        with:
          name: report
          path: report/
```

これで、コメントしたい情報をまとめて`artifacts`にアップロードすることができます。

### `workflow_run`で実行されるワークフローを作り、Pull Requestにコメントする。

もうここまでくれば後は簡単です。
先ほどのワークフローで作成された`artifacts`を取得し、そのデータをもとにPull Requestにコメントするだけになります。
こちらも、重要な点等のコメントはファイル内に記載しています。

※実際に使われているワークフローとは差分があります。実際に使われているものは[こちら](https://github.com/VOICEVOX/voicevox_engine/blob/master/.github/workflows/coverage-comment.yml)をご覧下さい。


```yaml:pr-comment.yaml
name: Pull Request Comment

# トリガーとなるワークフローのnameとtypes(requested/completed)を指定します。
# 今回は、テストが終わってから実行してほしいので、completedを使います。
on:
  workflow_run:
    workflows:
      - test
    types:
      - completed

jobs:
  comment:
    runs-on: ubuntu-latest
    # このワークフローが呼ばれるまでの情報は何も保持されないと言いましたが、
    # 一応このワークフローを呼んだワークフローの実行タイプや成功したか否かなど、
    # 一部の情報は取得できるので、これらを実行条件とします。
    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
    steps:
      - name: Download coverage report
        uses: actions/github-script@v5.0.0
        with:
          # GitHub Script(Octokit)を使って、
          # artifactsがアップロードされている場所の情報を取得し、
          # その後実際に作った情報をダウンロードします。
          script: |
            const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: ${{ github.event.workflow_run.id }},
            })
            const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
              return artifact.name == 'report'
            })[0]
            const download = await github.rest.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: matchArtifact.id,
               archive_format: 'zip',
            })
            const fs = require('fs')
            fs.writeFileSync('${{github.workspace}}/report.zip', Buffer.from(download.data))

      # 取得した情報の解凍
      - name: Unzip report
        run: unzip report.zip

      # 取得した情報のコメント
      # 今回は整形などせず、コンソール上で得られたそのままをコメントします
      # ここで"secrets.GITHUB_TOKEN"が必要となります。
      # Coverageの計測は、Pull Requestが更新されるたびに行われますが、
      # 一度コメントしたら、Coverage情報の更新は、そのコメントを編集する形にします。
      - name: Comment to Pull Requests
        uses: actions/github-script@v5.0.0
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const fs = require('fs')
            const report = fs.readFileSync('report.txt', 'utf8').toString()
            const issue_number = Number(fs.readFileSync('pr_num.txt'))
            const body = `## Coverage Result\n\n<details>\n<summary>Resultを開く</summary>\n\n${report}\n</details>`
            let listComments = await github.rest.issues.listComments({
              issue_number,
              owner: context.repo.owner,
              repo: context.repo.repo,
            })
            listComments = listComments.data.filter((comment) => {
              return comment.body.includes('Coverage Result') && comment.user.login.includes('github-actions')
            })
            if (listComments.length === 0) {
              github.rest.issues.createComment({
                issue_number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body,
              })
            } else {
              github.rest.issues.updateComment({
                comment_id: listComments[0].id,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body,
              })
            }
```

これで、実際にPull RequestにCoverageのデータを掲載することができます。
今回はデータの整形はしていませんが、データを整形していい感じにすると[こんな感じ](https://github.com/VOICEVOX/voicevox_engine/pull/151#issuecomment-950380971)になります。

## まとめ

Pull RequestにGitHub Actionsからコメントをしたいときは、`workflow_run`と`artifacts`、後は`github-script`を使って、いい感じにしよう！

## 感想

[VOICEVOXがOrganizationになってから](https://twitter.com/hiho_karuta/status/1465397712652681216?s=20)、[Coverallsが勝手にコメントしてくれるようになった](https://github.com/VOICEVOX/voicevox_engine/pull/214#issuecomment-985108672)ので、せっかく頑張ったのにちょっと悲しい...。