TCA を使って Pull Panda の代わりになるレビュワー回数可視化アプリを作ってみようとしたけど無理でした

**（追記）GraphQL の API を使えば行けるっぽいことが判明しました。**

## はじめに
色々便利だった Pull Panda が GitHub に買収されて、Pull Panda の今まで一人当たりどれくらいレビューしたのか見れる機能が使えなくなってしまいましたね。
その機能を使って、「このくらいレビューしたのか〜」という達成感を得たいという人は割と結構いるのではないでしょうか。  
実際自分の周りにそういう方がいたこともあり、最近 SwiftUI と TCA (The Composable Architecture) 勉強してるし、GitHub の API もあるっぽいし作ってみようと思って作り切りました。  
結果、GitHub の API 仕様を勘違いしてしまい、**想定と異なるものができてしまった**ため、非常に悲しいです。今回はその供養としてこの記事を書こうと思います。
あともし、レビュー回数可視化できるサービスとか知っている方がいらっしゃれば教えていただけますと幸いです。

## TCA (The Composable Architecture) とは？
iOSDC2020 でも今城さんや稲見さんが TCA について触れられていたり、同じく今城さんの [Qiita 記事](https://qiita.com/yimajo/items/77c204ab091223f9cb14)があったりするので、詳しい説明は省きたいと思います。一応自分も TCA の作者さんが公開されている [A Tour of the Composable Architecture](https://www.pointfree.co/collections/composable-architecture/a-tour-of-the-composable-architecture)をなぞってみたような[記事](https://qiita.com/kalupas226/items/2c9680396b039fc7499b)を前に書いたりもしました。

## 作ろうとしたもの
作ろうとしたのは、[こちらの記事](https://qiita.com/zizi4n5/items/55bc265d89b5f9f41547)で紹介されていますが、 ↓ の画像のように、人ごとの今までのレビュー回数を表示するようなアプリになります。
![スクリーンショット 2020-09-26 16.11.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/8e0ccd72-1791-be97-b042-0bcccd5cbd8c.png)

## GitHub の API
自分が勘違いしてしまった GitHub の API は [List Pull Request](https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#list-pull-requests)というものになります。
具体的に何を勘違いしてしまったかというと、こちらのレスポンスとして返却される `requested_reviewers` という key があるのですが、これプルリクでレビュワーに指定された人が取得できるのではなく、**プルリクでレビュワーに指定されたけどまだレビューしていない人**を取得できるものであるという罠にはめられました。
実装している最中の自分は完全にプルリクのレビュワーを取得できるぞとウキウキしながら開発していました。

## 作ったもの
実際に作ったものは ↓ のようなものになります。回数だけ見れれば良いと思ったので、UI は非常に質素です。
「Get Reviewer Times Button」というボタンを押すと、GitHub API から取得した `requested_reviewers` をもとに「人 レビュー回数」のような形で表示するアプリになっています。
↓ は Previews のものを撮影していますが、実際は API を叩いて取得するためローディングインジケーターも表示されるようにはなっています。
![bf33fa6c53aa0afe60c9d2176fb79ff6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/6fe0ac42-e8c4-2fb5-ad79-9442c91026ca.gif)
一応、綺麗ではないですが、コードも [GitHub](https://github.com/kalupas226/ReviewerTimes) にあげました。

## ざっくりコードの紹介
構成は ↓ のように作りました。（TCA 作者さんの [Search リポジトリ](https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/Search)を真似しました）

```
\ReviewerTimes
|---- ContentView.swift // TCA の State, Action, Environment, Reducer と View 定義が含まれている
|---- GitHubAPIClient.swift // レスポンスをパースするための Model や API との通信部分が含まれている
|---- ReviewerTimesApp.swift // ContentView の初期化をしている部分
```

それぞれ軽く紹介しようと思います。

### GitHubAPIClient.swift の model 部分

```swift:GitHubAPIClient.swift
import ComposableArchitecture
import Foundation

// MARK: API models
struct RequestedReviewers: Decodable, Equatable {
    let requestedReviewers: [Reviewer]
    
    struct Reviewer: Decodable, Equatable, Hashable {
        let login: String
    }
    
    private enum CodingKeys: String, CodingKey {
        case requestedReviewers = "requested_reviewers"
    }
}
```
こちらはシンプルなモデルです。
特に説明するほどでも無さそうですが、`requested_reviewers` というパラメーターだけ受け取れれば良いのでこのように定義しています。
`login` にはレビュワーの名前が入ってきます。

### GitHubAPIClient の APIClient 部分

```swift:GitHubAPIClient.swift
// MARK: API client interface
struct GitHubAPIClient {
    var reviewers: () -> Effect<[RequestedReviewers], Failure>
    
    struct Failure: Error, Equatable {}
}

// MARK: Live API implementation
// API(https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#list-pull-requests)
extension GitHubAPIClient {
    static let live = GitHubAPIClient(
        reviewers: { () -> Effect<[RequestedReviewers], Failure> in
            // your owner
            let owner = ""
            // your repo
            let repo = ""
            var components = URLComponents(string: "https://api.github.com/repos/\(owner)/\(repo)/pulls")!
            components.queryItems = [URLQueryItem(name: "state", value: "all"), URLQueryItem(name: "per_page", value: "100")]
            
            var request = URLRequest(url: components.url!)
            request.httpMethod = "GET"
            request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
            // enter your token
            request.setValue("", forHTTPHeaderField: "Authorization")
            
            return URLSession.shared.dataTaskPublisher(for: request)
                .map { data, _ in data }
                .decode(type: [RequestedReviewers].self, decoder: JSONDecoder())
                .mapError { _ in Failure() }
                .eraseToEffect()
        }
    )
}
```
APIClient も普通に URLSession を利用して通信しているだけになります。
少し特殊な部分といえば、Composable Architecture で利用されている [Effect](https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Effect.swift) 型を利用していることかと思います。
基本的に副作用が発生する部分において Effect を利用する形となっています。
ここでは eraseToEffect を使用して Effect 型を返しています。

### ContentView.swift の State, Action, Environment 部分

```swift:ContentView.swift
import ComposableArchitecture
import SwiftUI

struct ReviewerState: Equatable {
    var reviewerTimes: [String: Int] = [:]
    var reviewers: [RequestedReviewers.Reviewer] = []
    var isLoading = false
}

enum ReviewerAction: Equatable {
    case reviewersResponse(Result<[RequestedReviewers], GitHubAPIClient.Failure>)
    case getReviewers
}

struct ReviewerEnvironment {
    var githubApiClient: GitHubAPIClient
    var mainQueue: AnySchedulerOf<DispatchQueue>
}
```
次に Composable Architecture の State, Action, Environment を定義している部分になります。
State では `reviewerTimes` という[レビュワー名: レビュー回数]を表す Dictionary 、 `reviewers` というレスポンスとして返却されるレビュワーの情報を入れておくための変数、最後に画面上のボタンを押してからレスポンスが返ってくるまでの判定を行うために `isLoading` を定義しています。

Action では、 `.getReviewers` という実際に API へのリクエストを発火させるアクションと、 `.getReviewers` の結果によって発火させられる `reviewersResponse` を定義しています。

最後に Environment では、 APIClient と DispatchQueue を外部から差し込めるように定義しています。

### ContentView.swift の Reducer 部分

```swift:ContentView.swift
let reviewerReducer = Reducer<ReviewerState, ReviewerAction, ReviewerEnvironment> { state, action, environment in
    switch action {
    // ここ汚いですが許してください
    case let .reviewersResponse(.success(responses)):
        var reviewers: [RequestedReviewers.Reviewer] = []
        for response in responses {
            reviewers.append(contentsOf: response.requestedReviewers)
        }
        let orderedSet: NSOrderedSet = NSOrderedSet(array: reviewers)
        state.reviewers = orderedSet.array as! [RequestedReviewers.Reviewer]

        var reviewerTimes: [String: Int] = [:]
        for reviewer in reviewers {
            reviewerTimes[reviewer.login, default: 0] += 1
        }
        state.reviewerTimes = reviewerTimes
        state.isLoading = false
        return .none
        
    case .getReviewers:
        state.isLoading = true
        return environment.githubApiClient
            .reviewers()
            .receive(on: environment.mainQueue)
            .catchToEffect()
            .map(ReviewerAction.reviewersResponse)
        
    case let .reviewersResponse(.failure(error)):
        state.reviewerTimes = [:]
        return .none
    }
}
```

Reducer は自分が State を雑に定義してしまったせいで、若干複雑になってしまってはいますが、特に難しいことはしていません。
まず、`.getReviewers` Action が View から送られると、 Reducer 内の `case .getReviewers` の部分が動作します。
ここでは、State の `isLoading` を `true` にしつつ、APIClient を呼び出して `reviewersResponse` アクションを結果によって発火させています。
API 通信の結果が success なら `case let .reviewersResponse(.success(responses)):` に入り、 `failure` なら `case let .reviewersResponse(.failure(error)):` に入るようになっています。
`success` の方はごちゃごちゃ色々してしまっていますが、レビュワーとそのレビュワーに対応するレビュー回数を変数に入れようとしています。
`success` でも `failure` でも `return .none` としているのは、Reducer は Effect を return する必要があり、Effect を使用しない場所では基本的に `return .none` とするからになります。

### ContentView.swift の View 部分

```swift:ContentView.swift
struct ContentView: View {
    let store: Store<ReviewerState, ReviewerAction>

    var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack {
                Button(action: {
                    viewStore.send(.getReviewers)
                }) {
                    Text("Get Reviewer Times Button")
                }
                if viewStore.reviewers.isEmpty && viewStore.isLoading {
                    ProgressView()
                    Spacer()
                } else {
                    List {
                        ForEach(viewStore.reviewers, id: \.self) { reviewer in
                            HStack {
                                Text(reviewer.login)
                                Text("\(viewStore.reviewerTimes[reviewer.login]!)")
                            }
                        }
                    }
                }
            }
        }
    }
}
```
View 部分は単純です。
store を定義して、 Composable Architecture の　`WithViewStore` 経由で以下のようなことをしています。

- 画面上の「Get Reviewer Times Button」というボタンが押されたら、 `send` を使って `.getReviewers` アクションを発火させる
- もし「 `reviewers` が空」かつ「 `isLoading` が `true` 」なら ProgressView を表示し、そうでないなら、 `reviewer.login` （レビュワーの名前）と `reviewerTimes[reviewer.login]` （レビュワーの名前に対応するレビュー回数）をリストで表示する

## おわりに
ちょっとやっつけで作ってしまったのに加え、本当に欲しいものはできなかったので悲しい気持ちです。
しかし、SwiftUI と TCA の勉強にもなったので良いのかなと思っています。
まだまだ勉強してより深く理解していきたいと思います！