GitHub Actionsでプルリクエストの変更行数を制限する

# モチベーション
プルリクエストの変更が多いとレビューが辛い…
→　GitHub Actionsを使えば機械的に弾けるよね
ということで、PR作成時に、変更行数（増分）が指定した値よりも多かったときは自動的に警告してくれるworkflowを作りました。

GitHub Actionsについての基本的な説明は公式ドキュメントに任せます。
[Understanding GitHub Actions - GitHub Docs](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions)
[Reusing workflows - GitHub Docs](https://docs.github.com/en/actions/using-workflows/reusing-workflows)

> 1000行追加したときに怒ってくれるgithub-actionsボットの図
> ![PRがデカイと自動的に怒ってくれるgithub-actionsくん.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/11920/95ce1ddc-9578-c3c6-579b-84a6627b8579.png)

# コード
``` .github/workflows/limit_changed_lines.yml
name: Limit number of changed lines

on:
  workflow_call:
    inputs:
      limit:
        description: Limit of changes
        required: true
        type: number
      extension:
        description: >
          File extensions that you want to count changes. 
          Do not include "." before extensions.
          Use regular expressions if you want to specify multiple extensions.
          (example) kt|yml|gradle
        required: true
        type: string
      exclude_dir:
        description: >
          Directories where you don't want to count changes.
          Do not add "/" at the end of directory name.
        type: string
        required: false

env:
  LIMIT: ${{ inputs.limit }}
  EXT: ${{ inputs.extension }}
  EXCLUDE: ${{ inputs.exclude_dir }}
  URL: ${{ github.event.pull_request.comments_url }}
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  limit-changed-lines:
    name: Limit changed lines in the pull request
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          ref: ${{ github.base_ref }}

      - run: echo BASE=`git rev-parse HEAD` >> $GITHUB_ENV

      - uses: actions/checkout@v3

      - name: Count number of changed lines
        id: count_changes
        run: |
          changed=$(git diff $BASE --numstat \
            | if [ "$EXCLUDE" != "" ]; then grep -vP "^(\d+\s+)+\/?($EXCLUDE)\/"; else cat; fi \
            | grep -P ".*\.($EXT)$" \
            | awk '{ additions+=$1 } END { printf "%d", additions }')

          echo "CHANGED=$changed" >> $GITHUB_ENV

          if [ $changed -gt $LIMIT ]; then
            echo "::warning::Exceeds limit. (Limit: $LIMIT, Changed: $changed)"
            exit 1
          fi

      - name: Add comment
        run: |
          TEXT=":sparkles:OK:sparkles:"
          curl \
            --silent \
            -X POST \
            $URL \
            -H "Content-Type: application/json" \
            -H "Authorization: token $GITHUB_TOKEN" \
            --data "{ \"body\": \"$TEXT\"}"

      - name: Add error comment if exceeds limit
        if: failure() && steps.count_changes.outcome == 'failure'
        env:
          CHANGED: ${{ env.CHANGED }}
        run: |
          TITLE="**The number of changed lines exceeds the limit.**"
          INFO=":hammer_and_wrench:LIMIT: $LIMIT, :fire:Changed: $CHANGED"
          curl \
            --silent \
            -X POST \
            $URL \
            -H "Content-Type: application/json" \
            -H "Authorization: token $GITHUB_TOKEN" \
            --data "{ \"body\": \"$TITLE\n$INFO\"}"
          exit 0
```

# 使い方
リポジトリの.github/workflows以下に先程のファイルを設置して、PR時に発火する適当なworkflowを用意して、好きなタイミングで呼びます。
```
# 構成例
.github
└── workflows
    ├── limit_changed_lines.yml
    └── main.yml
```

``` .github/workflows/main.yml
name: On pull request
on:
  pull_request:

jobs:
  limit-changes:
    uses: ./.github/workflows/limit_changed_lines.yml
    with:
      limit: 100
      extension: kt|java
      exclude_dir: app/src/test
```
のようにします。

# 解説
パラメータの受け渡しなどでファイル全体としてはちょっと大きいですが、処理の流れとしては
1. main.ymlがPR作成時に発火する
1. limit_changed_lines.ymlが走る
1. `git diff --numstat`でPR先との差分を取得する
1. `grep`で拡張子やディレクトリなどを絞り込む
1. `awk`で集計する
1. 集計結果を`curl`でPOSTする
という感じです。

# ハマリポイント
## github.base_refとのdiffがうまくとれない
最初はブランチ間でのdiffを次のようにして取ろうとしていましたが、エラーが出てうまくいってませんでした。
```yml
run: |
  git diff origin/${{ github.base_ref }} --numstat
```
```
fatal: ambiguous argument 'origin/main': unknown revision or path not in the working tree.
```
workflow内で`git checkout`をハンドリングしてくれるactions/checkout@v3ですが、デフォルトでは高速化に重きが置かれていてヒストリーやブランチ、タグ等は取得しないようになっています。
> Number of commits to fetch. 0 indicates all history for all branches and tags.
> Default: 1
> fetch-depth: ''
> [actions/checkout: Action for checking out a repo](https://github.com/actions/checkout)

今回は`fetch-depth: 0`にして全部取ってくる必要はないなと思ったので、PR先のブランチのコミットハッシュを環境変数に保存しています。
[Environment variables - GitHub Docs](https://docs.github.com/en/actions/learn-github-actions/environment-variables)

```yml
# PR先のブランチをチェックアウト
- uses: actions/checkout@v3
  with:
    ref: ${{ github.base_ref }}

# コミットハッシュを環境変数に保存
- run: echo BASE=`git rev-parse HEAD` >> $GITHUB_ENV

# PRを作成したブランチにチェックアウト
- uses: actions/checkout@v3

# コミットハッシュを元にdiff取得
- run: |
    git diff $BASE --numstat 
```

## ローカルマシンとGitHub Actions上でgrepの動作が違う
変更行数の対象外のディレクトリに存在するファイルかどうかを判定するためにgrepを使っていますが、手元のMacとGitHub Actionsが実行されるUbuntu上では微妙に動作が違いました。

```git_diff.sh
$ git diff --numstat
# +       -       ファイル名
  8       2       .github/workflows/limit_changed_lines.yml
```
```grep.sh
# Ubuntuでは\dは期待通りに動かない
grep -vE "^(\d+\s+)+\/?($EXCLUDE)\/"

# -Eではなく-Pを指定する https://stackoverflow.com/a/6901221
grep -vP "^(\d+\s+)+\/?($EXCLUDE)\/"
```
# 感想
git関係の処理にはライブラリ等がいくらでもありそうでしたが、アップデートとかがめんどくさそうだしシェルで数行だしな〜と思ってやっていたらgrepの違いでハマったのでなんとも言えない気持ちです。

ともあれ、ちゃんと動いてbotからコメントが来たのは嬉しさがあります。