detekt の特定の Issue がプルリク差分から検出されたら、CI を失敗にする（シェル芸）

# 経緯

Kotlin 言語で作られた大規模なプロジェクトがあり、その中に不適切な実装（該当 Issue）が1種類ですが多くの箇所にありました。それらを修正したいですが、すべてを修正する工数は取れなそうです。よって新機能開発や機能改修のついでに、そこも修正できるチーム開発体制を作ろうと思います。Kotlin の静的解析ツール [detekt](https://github.com/detekt/detekt) に該当 Issue を検出させて、[reviewdog](https://github.com/reviewdog/reviewdog) や [Danger](https://github.com/danger/danger) でプルリク差分にそれがあればコメントで指摘する [GitHub Actions](https://docs.github.com/ja/actions) の設定を行いました。しかしコメントも見落とされる可能性があるので、プルリク差分の中に該当 Issue があれば CI を失敗にして、強制力を持たせてみようと思います。

## 注意点

- 言うまでも無く、このような設定を行うときは、チームの合意を取りましょう。
- この記事の内容は個人の見解です。私の所属先でこのような設定を行っているわけではないです。

# detekt について

今回、プルリク差分の中にあれば指摘したい該当 Issue の検出は detekt で行います。detekt の設定やカスタムルールの開発方法の説明は、こちらの記事に譲ります。

https://medium.com/@emrekoc/detekt-static-code-analyzer-for-android-c4c9bfd42a8c

https://medium.com/@emrekoc/detekt-for-android-how-to-make-your-own-custom-rule-2861eb60e4ba

実際に検出したい該当 Issue は違いますが、上記の記事でカスタムルールのサンプルとして紹介されていた ContextOrder という Issue がプルリク差分にあれば、CI を失敗にしてみようと思います。


```kotlin
// これは context: Context が第1引数なので良し
fun okFunc(context: Context, bar: String) {
    // 何かの実装
}

// これは context: Context が第2引数なので detekt の Issue になる。
// これがプルリク差分の中にあれば CI が失敗になるように設定することが、この記事の本題。
fun ngFunc(bar: String, context: Context) {
    // 何かの実装
}
```

# 関連して Konsist の紹介

今回は**プルリク差分**の中に該当 Issue があれば CI を失敗にしますが、**プロジェクト全体に1件でも**該当 Issue があれば CI を失敗にしたい場合は、[Konsist](https://github.com/LemonAppDev/konsist) が使えます。Konsist は JVM 環境の単体テストで[実行時型情報](https://ja.wikipedia.org/wiki/%E5%AE%9F%E8%A1%8C%E6%99%82%E5%9E%8B%E6%83%85%E5%A0%B1)をチェックするツールです。Issue があれば単体テストを失敗にすることで CI を失敗に出来ます。

## Konsist の使用例

https://qiita.com/tfandkusu/items/5319b755ab2f1293359a

# 表題の設定方法

detekt の特定の Issue がプルリク差分から検出されたら、CI を失敗にするためには、[レポートを SARIF 形式で出力](https://detekt.dev/docs/introduction/reporting/#sarif)したあとに、いくつかの UNIX コマンドを組み合わせます。それらをひとつずつ紹介します。

## レポートの出力

Gradle でレポートを出力します。

```sh
./gradlew detekt
```

## find コマンド

マルチモジュール構成かつレポートを Gradle で統合していない場合は、レポートファイルがモジュールごとに出力されるので、find コマンドでそれらを列挙します。

```sh
find . -type f -name detekt.sarif
```

```
./kgsios/build/reports/detekt/detekt.sarif
./common/build/reports/detekt/detekt.sarif
./detekt-extensions/build/reports/detekt/detekt.sarif
./feature/home/build/reports/detekt/detekt.sarif
./androidApp/build/reports/detekt/detekt.sarif
./data/remote/build/reports/detekt/detekt.sarif
```

今回は `-exec` 引数も使っています。次の jq コマンドの `{} +` に列挙したファイルを渡しています。
([コメント](https://qiita.com/tfandkusu/items/a74dfd19a4df55a719f6#comment-752969459acff42611a9)で教えて頂きました。ありがとうございます。)

### 関連記事
[find の -exec optionの末尾につく \; と + の違い。](https://qiita.com/ironsand/items/0aed2ffca295706cab69)



## jq コマンド

SARIF 形式は JSON ファイルです。detekt が出力した SARIF ファイルはこのような形式です。(大幅に省略しています)

```json
{
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "results": [
        {
          "level": "warning",
          "locations": [
            {
              "physicalLocation": {
                "artifactLocation": {
                  "uri": "file:///Users/takada/work/KmmGithubSearch/androidApp/src/main/java/com/tfandkusu/kgs/MainActivity.kt"
                },
                "region": {
                  "endColumn": 6,
                  "endLine": 24,
                  "startColumn": 5,
                  "startLine": 22
                }
              }
            }
          ],
          "message": {
            "text": "Context must be the first parameter"
          },
          "ruleId": "detekt.extra-rules.ContextOrder"
        }
      ]
    }
  ]
}
```

よって [jq コマンド](https://jqlang.github.io/jq/)で該当 Issue が含まれている URI 一覧を得ます。

```sh
jq -r ".runs[].results[] | select(.ruleId == \"detekt.extra-rules.ContextOrder\") | .locations[].physicalLocation.artifactLocation.uri" {} + # {} + は入力 SARIF ファイル名
```

`-r` は `--raw-output` です。出力に `""` が含まれなくなります。

`.runs[].results[]` の中から `ruleId` が `detekt.extra-rules.ContextOrder` の要素を探して、その `.locations[].physicalLocation.artifactLocation.uri` を得ています。

URI 形式で出力されます。

```
file:///Users/takada/work/KmmGithubSearch/feature/home/src/commonMain/kotlin/com/tfandkusu/kgs/feature/home/HomeActionCreator.kt
file:///Users/takada/work/KmmGithubSearch/androidApp/src/main/java/com/tfandkusu/kgs/MainActivity.kt
```


## sed コマンド

sed コマンドによる文字列置き換えで、該当 Issue を含むファイルの URI をリポジトリルートからの相対パスに変換します。 

```sh
sed "s|file://$PWD/||"
```

`$PWD` 環境変数でカレントディレクトリを取得しています。

`file:///Users/takada/takada/KmmGithubSearch/androidApp/src/main/java/com/tfandkusu/kgs/MainActivity.kt`
↓
`androidApp/src/main/java/com/tfandkusu/kgs/MainActivity.kt`

## sort コマンド

並び替えコマンドです。あとで join コマンドを使うたためにファイル一覧を昇順にします。

## これまでの処理をパイプでつないで、テキストファイルに保存する

これまでの処理をパイプでつないで、テキストファイルに保存します。

```sh
find . -type f -name detekt.sarif -exec \
jq -r ".runs[].results[] | select(.ruleId == \"detekt.extra-rules.ContextOrder\") | .locations[].physicalLocation.artifactLocation.uri" {} + | \
sed "s|file://$PWD/||" | \
sort > error_files.txt
```

## git コマンド、sort コマンド

main ブランチと差分があるファイル一覧を得て、昇順にしてからテキストファイルに保存します。

```sh
git diff --name-only origin/main | sort > change_files.txt
```

（ここで「プルリク差分」とは main ブランチとの差分と定義しています）

## join コマンド

これまでのステップで作成された、`error_files.txt` と `change_files.txt` の共通する行を取得します。

```sh
join error_files.txt change_files.txt
```

この結果が空ならばプルリク差分には該当 Issue 無し。そうで無ければ該当 Issue 有りとなります。

## grep コマンド

grep コマンドを使い、結果が空か否かを判定します。

```sh
! join error_files.txt change_files.txt | grep '.'
```
`grep '.'` ですべての文字列にマッチします。grep コマンドはマッチしたら成功、マッチしなければ失敗ですが、今回は結果を反転させたいです。そのため先頭に `!` を付けています。

## GitHub Actions のワークフローで、これまでのコマンドを実行する

GitHub Actions のワークフローでこれまでのコマンドを実行する設定はこのようになります。

```check.yml
name: check
on:
  push:
    branches:
      - main
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - uses: actions/setup-java@v3
        with:
          distribution: 'adopt'
          java-version: '17'
      - run: ./gradlew deteKt
      - run: |
          find . -type f -name detekt.sarif -exec \
          jq -r ".runs[].results[] | select(.ruleId == \"detekt.extra-rules.ContextOrder\") | .locations[].physicalLocation.artifactLocation.uri" {} + | \
          sed "s|file://$PWD/||" | \
          sort > error_files.txt
      - run: git diff --name-only origin/main | sort > change_files.txt
      - name: ContextOrder 違反チェック
        run: "! join error_files.txt change_files.txt | grep '.'"
```

最後のコマンドについては `name:` フィールドを設定することで、CI 失敗理由を分かりやすくしています。また `!` は YAML ファイルにおいてタグを表すので、ダブルクオテーションで囲んでいます。

# まとめ

今回は新機能開発や機能改修のついでに、プルリク差分にある既存の問題点を修正する強制力を CI に持たせる設定方法（所謂シェル芸）を紹介しました。問題点は detekt で Issue として検出できるようにしておき、SARIF 形式のレポートファイルから jq, sed コマンドで該当 Issue を含むファイル一覧を得ます。git コマンドで main ブランチとの差分ファイル一覧を得たら、共通の行があるかを join コマンドで確認して、あれば CI を失敗にすることで、表題の設定ができることを解説しました。