6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-09-26

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

はじめに

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

TCA (The Composable Architecture) とは?

iOSDC2020 でも今城さんや稲見さんが TCA について触れられていたり、同じく今城さんの Qiita 記事があったりするので、詳しい説明は省きたいと思います。一応自分も TCA の作者さんが公開されている A Tour of the Composable Architectureをなぞってみたような記事を前に書いたりもしました。

作ろうとしたもの

作ろうとしたのは、こちらの記事で紹介されていますが、 ↓ の画像のように、人ごとの今までのレビュー回数を表示するようなアプリになります。
スクリーンショット 2020-09-26 16.11.57.png

GitHub の API

自分が勘違いしてしまった GitHub の API は List Pull Requestというものになります。
具体的に何を勘違いしてしまったかというと、こちらのレスポンスとして返却される requested_reviewers という key があるのですが、これプルリクでレビュワーに指定された人が取得できるのではなく、プルリクでレビュワーに指定されたけどまだレビューしていない人を取得できるものであるという罠にはめられました。
実装している最中の自分は完全にプルリクのレビュワーを取得できるぞとウキウキしながら開発していました。

作ったもの

実際に作ったものは ↓ のようなものになります。回数だけ見れれば良いと思ったので、UI は非常に質素です。
「Get Reviewer Times Button」というボタンを押すと、GitHub API から取得した requested_reviewers をもとに「人 レビュー回数」のような形で表示するアプリになっています。
↓ は Previews のものを撮影していますが、実際は API を叩いて取得するためローディングインジケーターも表示されるようにはなっています。
bf33fa6c53aa0afe60c9d2176fb79ff6.gif
一応、綺麗ではないですが、コードも GitHub にあげました。

ざっくりコードの紹介

構成は ↓ のように作りました。(TCA 作者さんの Search リポジトリを真似しました)

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

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

GitHubAPIClient.swift の model 部分

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 部分

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 型を利用していることかと思います。
基本的に副作用が発生する部分において Effect を利用する形となっています。
ここでは eraseToEffect を使用して Effect 型を返しています。

ContentView.swift の State, Action, Environment 部分

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 部分

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 の isLoadingtrue にしつつ、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 部分

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 が空」かつ「 isLoadingtrue 」なら ProgressView を表示し、そうでないなら、 reviewer.login (レビュワーの名前)と reviewerTimes[reviewer.login] (レビュワーの名前に対応するレビュー回数)をリストで表示する

おわりに

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

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?