(追記)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をなぞってみたような記事を前に書いたりもしました。
作ろうとしたもの
作ろうとしたのは、こちらの記事で紹介されていますが、 ↓ の画像のように、人ごとの今までのレビュー回数を表示するようなアプリになります。
GitHub の API
自分が勘違いしてしまった GitHub の API は List Pull Requestというものになります。
具体的に何を勘違いしてしまったかというと、こちらのレスポンスとして返却される requested_reviewers
という key があるのですが、これプルリクでレビュワーに指定された人が取得できるのではなく、プルリクでレビュワーに指定されたけどまだレビューしていない人を取得できるものであるという罠にはめられました。
実装している最中の自分は完全にプルリクのレビュワーを取得できるぞとウキウキしながら開発していました。
作ったもの
実際に作ったものは ↓ のようなものになります。回数だけ見れれば良いと思ったので、UI は非常に質素です。
「Get Reviewer Times Button」というボタンを押すと、GitHub API から取得した requested_reviewers
をもとに「人 レビュー回数」のような形で表示するアプリになっています。
↓ は Previews のものを撮影していますが、実際は API を叩いて取得するためローディングインジケーターも表示されるようにはなっています。
一応、綺麗ではないですが、コードも GitHub にあげました。
ざっくりコードの紹介
構成は ↓ のように作りました。(TCA 作者さんの Search リポジトリを真似しました)
\ReviewerTimes
|---- ContentView.swift // TCA の State, Action, Environment, Reducer と View 定義が含まれている
|---- GitHubAPIClient.swift // レスポンスをパースするための Model や API との通信部分が含まれている
|---- ReviewerTimesApp.swift // ContentView の初期化をしている部分
それぞれ軽く紹介しようと思います。
GitHubAPIClient.swift の model 部分
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 部分
// 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 部分
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 部分
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 部分
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 の勉強にもなったので良いのかなと思っています。
まだまだ勉強してより深く理解していきたいと思います!