仕事をしていると PR のレビュー依頼に一瞬で気づきたいので、メールや slack 連携などの通知を設定することになると思う。ただ、それだけだと一瞬で気づいたけど今は手が離せないので10分後くらいに見よう...と思ったまま忘れてしまうということが起こるのでなんらかの工夫が必要で、自分はメニューバーに関係する PR 一覧を表示している。
具体的には、以下のように、
- 自分がレビューするべき PR の数
- 自分が出していてマージされていない PR の数
をメニューバーに常に表示し、それをクリックすると PR へのリンクのリストが登場するようになっている(仕事の様子を公開するわけにはいかないのでダミーデータにしています)。
リストは3つのセクションに分けていて、
- 自分がレビューするべき PR すべて
- 自分が出してマージされていない PR すべて
- 自分が出してマージされた PR 直近3件
をそれぞれ表示している。それぞれの PR の行をクリックするとブラウザで PR が開くようになっている。
人間には無意識のうちに Mac のメニューバーをちらちら見ているという性質があるので、レビューするべき PR の件数が増えていたら気づいてすぐにリンクをクリックしてレビューしにいくことができる。 PR がマージされるまで件数は減らないのでレビューを忘れてしまうということもない。
もともとはレビューを早くすることと忘れないことが目的だったが、自分が出している・出してマージした直近の PR にすぐ飛べるのも意外とよい。タスクをやっていて、ちょっと前の PR であそこはどう実装したんだっけとか、さっきの PR はもうレビューされているかなとか、気になったときにメニューバーからシュッと飛べるのが便利。
PR にはそれぞれレビューと CI のステータスを絵文字で表示して、自分の PR の CI が落ちていたり change requested されていたら直す、 CI とレビューが両方通っていたらマージするなど、取るべきアクションに気づくことができるようにしている。
表示には xbar というアプリを使っている。
xbar では好きなプログラミング言語のスクリプトを定期実行し標準出力をメニューバーに表示するということができる。専用の記法に則って標準出力することでセクション分けやリンクの設定も可能。自分は gh
コマンドで PR 一覧をいい感じの条件でクエリして Swift でこねていて、そのスクリプトを貼っておくのでよかったら参考にしてください。ただ、 gh
の使い方には改善点がありそうなのと、1つのレポジトリからクエリする前提になっているので複数レポジトリを見たい場合はもろもろ調整する必要があると思います。
#!/usr/bin/env swift import Foundation struct User: Decodable, Equatable, Hashable { let login: String } struct Comment: Decodable, Equatable { let author: User } struct Review: Decodable, Equatable { let author: User } enum StatusCheck: Decodable { enum Status { case success case inProgress case failure case unknown } struct CheckRun: Decodable { var workflowName: String // ref: https://docs.github.com/ja/graphql/reference/enums#checkstatusstate var status: String // ref: https://docs.github.com/ja/graphql/reference/enums#checkconclusionstate var conclusion: String? } struct StatusContext: Decodable { var context: String // ref: https://docs.github.com/ja/graphql/reference/enums#statusstate var state: String } case checkRun(CheckRun) case statusContext(StatusContext) enum CodingKeys: String, CodingKey { case typename = "__typename" } enum Typename: String, Decodable { case checkRun = "CheckRun" case statusContext = "StatusContext" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(Typename.self, forKey: .typename) switch type { case .checkRun: self = try .checkRun(CheckRun(from: decoder)) case .statusContext: self = try .statusContext(StatusContext(from: decoder)) } } var status: Status { switch self { case .checkRun(let checkRun): switch checkRun.status { case "COMPLETED": guard let conclusion = checkRun.conclusion else { return .unknown } switch conclusion { case "SUCCESS": return .success default: return .failure } default: return .inProgress } case .statusContext(let statusContext): switch statusContext.state { case "SUCCESS": return .success case "PENDING": return .inProgress default: return .failure } } } } struct PullRequest: Decodable { let number: Int let title: String let createdAt: Date let author: User let comments: [Comment] let reviewRequests: [User] let reviews: [Review] let statusCheckRollup: [StatusCheck] let isDraft: Bool let reviewDecision: String let url: String var escapedTitle: String { title.replacingOccurrences(of: "|", with: "/") } var reviewStateIcon: String { if isDraft { return "📝" } switch reviewDecision { case "APPROVED": return "✅" case "REVIEW_REQUIRED": if comments.filter({ $0.author != author }).isEmpty { return "🟡" } else { return "💬" } case "CHANGES_REQUESTED": return "❗" default: return "" } } var ciStateIcon: String { guard !statusCheckRollup.isEmpty else { return "❓" } let statuses = statusCheckRollup.map(\.status) if statuses.contains(.unknown) { return "❓" } else if statuses.contains(.failure) { return "❗️" } else if statuses.contains(.inProgress) { return "🟡" } else { return "✅" } } var reviewers: [String] { let reviewers = Set(reviewRequests + reviews.map(\.author)).subtracting(.init([author])) return Array(reviewers).map(\.login) } } func shell(_ command: String) throws -> String { let task = Process() let pipe = Pipe() task.standardOutput = pipe task.standardError = pipe task.arguments = ["-c", command] task.executableURL = URL(fileURLWithPath: "/bin/zsh") task.standardInput = nil try task.run() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8)! return output } func pullRequests(query: String, state: String = "open", limit: Int = 30) throws -> [PullRequest] { let json = try shell("/opt/homebrew/bin/gh pr list --repo <ORGANIZATION>/<REPOSITORY> --limit \(limit) --state \(state) --search \"\(query)\" --json number,title,author,createdAt,url,comments,reviews,reviewRequests,reviewDecision,statusCheckRollup,isDraft") return try jsonDecoder.decode([PullRequest].self, from: json.data(using: .utf8)!) } func unique(_ array1: [PullRequest], _ array2: [PullRequest]) -> [PullRequest] { let uniqued = (array1 + array2).reduce(into: [Int: PullRequest]()) { (dict, pullRequest) in dict[pullRequest.number] = pullRequest }.values return Array(uniqued) } let jsonDecoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() do { let reviewRequestedPullRequests = try unique( pullRequests(query: "review-requested:@me", state: "open"), pullRequests(query: "reviewed-by:@me -author:@me", state: "open") ).sorted(by: { $0.number < $1.number }) let inProgressPullRequests = try pullRequests(query: "author:@me", state: "open") let recentlyMergedPullRequests = try pullRequests(query: "author:@me", state: "merged", limit: 3) print("\(reviewRequestedPullRequests.count) review / \(inProgressPullRequests.count) in-progress") if !reviewRequestedPullRequests.isEmpty { print("---") print("review") for pullRequest in reviewRequestedPullRequests { print("\(pullRequest.reviewStateIcon) \(pullRequest.ciStateIcon) \(pullRequest.author.login): #\(pullRequest.number) \(pullRequest.escapedTitle) | href=\(pullRequest.url)") } } if !inProgressPullRequests.isEmpty { print("---") print("in-progress") for pullRequest in inProgressPullRequests { let reviewersString = pullRequest.reviewers.isEmpty ? "" : "(\(pullRequest.reviewers.joined(separator: ", ")))" print("\(pullRequest.reviewStateIcon) \(pullRequest.ciStateIcon) #\(pullRequest.number) \(pullRequest.escapedTitle) \(reviewersString) | href=\(pullRequest.url)") } } print("---") print("recently merged | href=https://github.com/<ORGANIZATION>/<REPOSITORY>/pulls?q=is%3Apr+is%3Amerged+author%3A%40me") for pullRequest in recentlyMergedPullRequests { print("#\(pullRequest.number) \(pullRequest.escapedTitle) | href=\(pullRequest.url)") } } catch { print(error) }