Mac のメニューバーで PR の状況を把握する

仕事をしていると 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 というアプリを使っている。

xbarapp.com

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)
}