{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "network" : {
          "hosts" : [
            "api.github.com"
          ]
        }
      },
      {
        "secret" : {
          "name" : "GITHUB_TOKEN"
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/PRCockpit",
    "id" : "io.navbytes.stackbar.pr-cockpit",
    "minimumStackbar" : "0.11.0",
    "name" : "PR Cockpit",
    "summary" : "Your pull requests and the ones waiting on your review, with CI and review state.",
    "surfaces" : [
      "menuBar",
      "panel",
      "window",
      "widget",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 120
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.1.1"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "FyJX01d4F25lnWaLdUE9Zm962sHur0x7JUUP4nNIlMS1fvWhb0GcaV6cs463McIv1y7jZSScqiuXiiyLb99VAg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your pull requests and the ones waiting on your review, with CI and review\n/// state, in the menu bar.\n///\n/// One GraphQL request a refresh fetches both lists with their checks, review\n/// decision and merge conflicts. It tells you when someone asks for your\n/// review, and when one of your own PRs is approved, gets changes requested,\n/// or its checks fail. The token is a `GITHUB_TOKEN` secret the host keeps in\n/// the Keychain (a fine-grained token with read access to pull requests is\n/// enough), and every request goes through the host to the one declared\n/// domain.\n@main\nfinal class PRCockpit: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.pr-cockpit\",\n        name: \"PR Cockpit\",\n        version: \"1.1.1\",\n        summary: \"Your pull requests and the ones waiting on your review, with CI and review state.\",\n        surfaces: [.menuBar, .panel, .window, .widget, .settings],\n        triggers: [.launch, .minutes(2), .wake],\n        capabilities: [.network(\"api.github.com\"), .secret(name: \"GITHUB_TOKEN\")],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/PRCockpit\"\n    )\n\n    struct PullRequest: Decodable, Equatable {\n        let number: Int\n        let title: String\n        let url: String\n        let isDraft: Bool\n        let updatedAt: Date\n        let repository: Repository\n        let author: Author?\n        let reviewDecision: String?\n        let mergeable: String?\n        let headRefName: String?\n        let additions: Int?\n        let deletions: Int?\n        let commits: Commits?\n        let comments: Count?\n        /// OPEN, CLOSED or MERGED.\n        let state: String?\n\n        struct Count: Decodable, Equatable { let totalCount: Int }\n\n        struct Repository: Decodable, Equatable { let nameWithOwner: String }\n        struct Author: Decodable, Equatable { let login: String }\n        struct Commits: Decodable, Equatable {\n            struct Node: Decodable, Equatable {\n                struct Commit: Decodable, Equatable {\n                    struct Rollup: Decodable, Equatable { let state: String }\n                    let statusCheckRollup: Rollup?\n                }\n                let commit: Commit\n            }\n            let nodes: [Node]\n        }\n\n        var repo: String { repository.nameWithOwner }\n        var id: String { \"\\(repo)#\\(number)\" }\n\n        /// SUCCESS, FAILURE, ERROR, PENDING, EXPECTED, or nil without checks.\n        var checks: String? { commits?.nodes.last?.commit.statusCheckRollup?.state }\n        var checksFailed: Bool { checks == \"FAILURE\" || checks == \"ERROR\" }\n        /// Approved (or needing no review), checks green, no conflicts.\n        var readyToMerge: Bool {\n            !isDraft && (reviewDecision == \"APPROVED\" || reviewDecision == nil) && checks == \"SUCCESS\" && mergeable == \"MERGEABLE\"\n        }\n    }\n\n    enum Tab: String, CaseIterable {\n        case review = \"To review\"\n        case mine = \"Mine\"\n        case inbox = \"Inbox\"\n    }\n\n    /// One thread from GitHub's notifications inbox (REST).\n    struct InboxItem: Decodable, Equatable {\n        struct Subject: Decodable, Equatable {\n            let title: String\n            let url: String?\n            let type: String\n        }\n        struct Repository: Decodable, Equatable {\n            let full_name: String\n            let html_url: String\n        }\n        let id: String\n        let reason: String\n        let unread: Bool\n        let updated_at: Date\n        let subject: Subject\n        let repository: Repository\n\n        /// The page on github.com: the API URL turned into the web one.\n        var webURL: String {\n            guard let api = subject.url, api.hasPrefix(\"https://api.github.com/repos/\") else {\n                return subject.type == \"Release\" ? repository.html_url + \"/releases\" : repository.html_url\n            }\n            return \"https://github.com/\" + api.dropFirst(\"https://api.github.com/repos/\".count)\n                .replacingOccurrences(of: \"/pulls/\", with: \"/pull/\")\n                .replacingOccurrences(of: \"/commits/\", with: \"/commit/\")\n        }\n\n        var reasonText: String {\n            switch reason {\n            case \"mention\": return \"mentioned you\"\n            case \"team_mention\": return \"mentioned your team\"\n            case \"review_requested\": return \"review requested\"\n            case \"assign\": return \"assigned to you\"\n            case \"author\": return \"you opened it\"\n            case \"comment\": return \"new comment\"\n            case \"ci_activity\": return \"CI activity\"\n            case \"state_change\": return \"state changed\"\n            case \"subscribed\", \"manual\": return \"watching\"\n            default: return reason.replacingOccurrences(of: \"_\", with: \" \")\n            }\n        }\n\n        var symbol: String {\n            switch subject.type {\n            case \"PullRequest\": return \"arrow.triangle.pull\"\n            case \"Issue\": return \"smallcircle.filled.circle\"\n            case \"Release\": return \"tag\"\n            case \"Discussion\": return \"bubble.left.and.bubble.right\"\n            case \"CheckSuite\": return \"checkmark.seal\"\n            default: return \"bell\"\n            }\n        }\n    }\n\n    private struct Reply: Decodable {\n        struct Data: Decodable {\n            /// Search results, skipping any node that isn't a pull request.\n            struct Search: Decodable {\n                let nodes: [PullRequest]\n                private enum Keys: String, CodingKey { case nodes }\n                private struct Skip: Decodable {}\n\n                init(from decoder: Decoder) throws {\n                    var list = try decoder.container(keyedBy: Keys.self).nestedUnkeyedContainer(forKey: .nodes)\n                    var found: [PullRequest] = []\n                    while !list.isAtEnd {\n                        if try list.decodeNil() { continue }\n                        if let pr = try? list.decode(PullRequest.self) { found.append(pr) } else { _ = try list.decode(Skip.self) }\n                    }\n                    nodes = found\n                }\n            }\n            let review: Search\n            let mine: Search\n            /// Yours closed or merged in the last two days.\n            let closed: Search?\n        }\n        struct Problem: Decodable { let message: String }\n        let data: Data?\n        let errors: [Problem]?\n    }\n\n    /// One search of GitHub, for the Search Pull Requests command.\n    private struct SearchReply: Decodable {\n        struct Data: Decodable { let found: Reply.Data.Search }\n        let data: Data?\n        let errors: [Reply.Problem]?\n    }\n\n    static let fragment = \"\"\"\n    fragment pr on PullRequest { number title url isDraft updatedAt state repository { nameWithOwner } author { login } \\\n    reviewDecision mergeable headRefName additions deletions comments { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } }\n    \"\"\"\n\n    var toReview: [PullRequest] = []\n    var mine: [PullRequest] = []\n    var lastError: String?\n    var updatedAt: Date?\n    @Stored(\"tab\") var tab = Tab.review.rawValue\n    @Stored(\"includeTeams\") var includeTeams = true\n    @Stored(\"includeDrafts\") var includeDrafts = false\n    @Stored(\"notify\") var notifyChanges = true\n    @Stored(\"history\") var history: [Double] = []\n    /// When you last looked at each pull request (by URL), to mark new activity.\n    @Stored(\"seen\") var seen: [String: Date] = [:]\n    @Stored(\"seenStarted\") var seenStarted = false\n    /// Hidden until they change: URL to the update time they were snoozed at.\n    @Stored(\"snoozed\") var snoozed: [String: Date] = [:]\n    @Stored(\"hiddenRepos\") var hiddenRepos: [String] = []\n    @Stored(\"showInbox\") var showInbox = false\n    var inbox: [InboxItem] = []\n    var inboxError: String?\n    var recentlyClosed: [PullRequest] = []\n\n    func refresh() async throws {\n        guard let token = try await Stackbar.secret(\"GITHUB_TOKEN\") else {\n            lastError = \"Add a GitHub token to see your pull requests.\"\n            return\n        }\n        let requested = includeTeams ? \"review-requested:@me\" : \"user-review-requested:@me\"\n        let draft = includeDrafts ? \"\" : \" draft:false\"\n        let since = ISO8601DateFormatter.string(from: Date().addingTimeInterval(-2 * 86400), timeZone: .gmt, formatOptions: [.withFullDate])\n        let query = \"\"\"\n        query { \\\n        review: search(query: \"is:pr is:open archived:false \\(requested)\\(draft)\", type: ISSUE, first: 40) { nodes { ...pr } } \\\n        mine: search(query: \"is:pr is:open archived:false author:@me\", type: ISSUE, first: 40) { nodes { ...pr } } \\\n        closed: search(query: \"is:pr is:closed archived:false author:@me closed:>=\\(since)\", type: ISSUE, first: 20) { nodes { ...pr } } \\\n        } \\(Self.fragment)\n        \"\"\"\n        do {\n            let body = try JSONEncoder().encode([\"query\": query])\n            let response = try await Stackbar.http(HTTPRequest(url: \"https://api.github.com/graphql\", method: \"POST\", headers: [\n                \"Authorization\": \"Bearer \\(token)\", \"Content-Type\": \"application/json\", \"Accept\": \"application/json\",\n            ], body: body))\n            guard (200..<300).contains(response.status) else {\n                throw HostError(response.status == 401 ? \"GitHub rejected the token (401); set a new GITHUB_TOKEN\" : \"GitHub answered \\(response.status): \\(String(decoding: response.body.prefix(200), as: UTF8.self))\")\n            }\n            let decoder = JSONDecoder()\n            decoder.dateDecodingStrategy = .iso8601\n            let reply = try decoder.decode(Reply.self, from: response.body)\n            guard let data = reply.data else { throw HostError(reply.errors?.map(\\.message).joined(separator: \"; \") ?? \"GitHub sent no data\") }\n            update(review: data.review.nodes, mine: data.mine.nodes, closed: data.closed?.nodes ?? [])\n            lastError = nil\n        } catch {\n            lastError = \"\\(error)\"\n        }\n        if showInbox { await refreshInbox(token: token) }\n    }\n\n    /// GitHub's notifications inbox. The REST API takes a classic token with\n    /// the `notifications` scope; fine-grained tokens can't read it.\n    func refreshInbox(token: String) async {\n        do {\n            let response = try await Stackbar.http(HTTPRequest(url: \"https://api.github.com/notifications?per_page=40\", headers: [\n                \"Authorization\": \"Bearer \\(token)\", \"Accept\": \"application/vnd.github+json\", \"X-GitHub-Api-Version\": \"2022-11-28\",\n            ]))\n            guard (200..<300).contains(response.status) else {\n                throw HostError(response.status == 403 || response.status == 404\n                    ? \"GitHub won't share notifications with this token. The inbox needs a classic token with the notifications scope.\"\n                    : \"GitHub answered \\(response.status) for notifications\")\n            }\n            let decoder = JSONDecoder()\n            decoder.dateDecodingStrategy = .iso8601\n            let items = try decoder.decode([InboxItem].self, from: response.body)\n            if notifyChanges, !inbox.isEmpty || inboxError != nil {\n                let known = Set(inbox.map(\\.id))\n                for item in items where item.unread && !known.contains(item.id) && (item.reason == \"mention\" || item.reason == \"team_mention\") {\n                    Stackbar.notify(\"Mentioned: \\(item.subject.title)\", body: item.repository.full_name, actions: [NotificationAction(\"Open\", url: item.webURL)])\n                }\n            }\n            inbox = items.sorted { $0.updated_at > $1.updated_at }\n            inboxError = nil\n        } catch {\n            inboxError = \"\\(error)\"\n        }\n    }\n\n    func markRead(_ item: InboxItem) async {\n        guard let token = try? await Stackbar.secret(\"GITHUB_TOKEN\") else { return }\n        inbox.removeAll { $0.id == item.id }\n        _ = try? await Stackbar.http(HTTPRequest(url: \"https://api.github.com/notifications/threads/\\(item.id)\", method: \"PATCH\", headers: [\n            \"Authorization\": \"Bearer \\(token)\", \"Accept\": \"application/vnd.github+json\",\n        ]))\n    }\n\n    func inboxRow(_ item: InboxItem) -> Row {\n        var row = Row(item.subject.title, subtitle: [item.repository.full_name, item.reasonText, Self.ago(item.updated_at)].joined(separator: \" · \"), symbol: item.symbol, key: \"inbox-\\(item.id)\")\n        if item.unread { row = row.badge(\"Unread\", role: .accent) }\n        if item.reason == \"mention\" || item.reason == \"team_mention\" { row = row.badge(\"@\", role: .warning) }\n        return row.action(\"Open\", shortcut: \"cmd+return\") {\n            Stackbar.open(item.webURL)\n            await self.markRead(item)\n        }\n        .action(\"Mark as read\", shortcut: \"cmd+u\") { await self.markRead(item) }\n        .action(\"Copy link\") { Stackbar.copy(item.webURL) }\n    }\n\n    /// Takes the new lists, telling the user what changed since last time.\n    func update(review: [PullRequest], mine newMine: [PullRequest], closed: [PullRequest] = []) {\n        if notifyChanges, updatedAt != nil {\n            let wasOpen = Set(mine.map(\\.id))\n            for pr in closed where wasOpen.contains(pr.id) {\n                Stackbar.notify(pr.state == \"MERGED\" ? \"Merged: \\(pr.title)\" : \"Closed: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n            }\n            let known = Set(toReview.map(\\.id))\n            let added = review.filter { !known.contains($0.id) }\n            if added.count == 1, let pr = added.first {\n                Stackbar.notify(\"Review requested: \\(pr.title)\", body: \"\\(pr.id) by \\(pr.author?.login ?? \"someone\")\", actions: [NotificationAction(\"Review\", url: pr.url + \"/files\")])\n            } else if added.count > 1 {\n                Stackbar.notify(\"\\(added.count) new review requests\", body: added.map(\\.title).prefix(3).joined(separator: \"\\n\"))\n            }\n            let before = Dictionary(mine.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })\n            for pr in newMine {\n                guard let old = before[pr.id] else { continue }\n                if pr.reviewDecision != old.reviewDecision, pr.reviewDecision == \"APPROVED\" {\n                    Stackbar.notify(\"Approved: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                } else if pr.reviewDecision != old.reviewDecision, pr.reviewDecision == \"CHANGES_REQUESTED\" {\n                    Stackbar.notify(\"Changes requested: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                }\n                if pr.checksFailed, !old.checksFailed {\n                    Stackbar.notify(\"Checks failed: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"See checks\", url: pr.url + \"/checks\")])\n                } else if pr.readyToMerge, !old.readyToMerge {\n                    Stackbar.notify(\"Ready to merge: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                }\n            }\n        }\n        recentlyClosed = closed.sorted { $0.updatedAt > $1.updatedAt }\n        toReview = review.sorted { $0.updatedAt > $1.updatedAt }\n        mine = newMine.sorted { $0.updatedAt > $1.updatedAt }\n        let current = Set((toReview + mine).map(\\.url))\n        if !seenStarted {\n            seenStarted = true\n            // First run: what is already there isn't news.\n            seen = Dictionary((toReview + mine).map { ($0.url, $0.updatedAt) }, uniquingKeysWith: { first, _ in first })\n        } else {\n            seen = seen.filter { current.contains($0.key) }\n        }\n        snoozed = snoozed.filter { current.contains($0.key) }\n        history = Array((history + [Double(review.count)]).suffix(48))\n        updatedAt = Date()\n    }\n\n    // MARK: Filtering and attention\n\n    func isShown(_ pr: PullRequest) -> Bool {\n        if hiddenRepos.contains(pr.repo) { return false }\n        if let at = snoozed[pr.url], pr.updatedAt <= at { return false }\n        return true\n    }\n\n    var shownReview: [PullRequest] { toReview.filter(isShown) }\n    var shownMine: [PullRequest] { mine.filter(isShown) }\n    var hiddenCount: Int { toReview.count + mine.count - shownReview.count - shownMine.count }\n\n    func isNew(_ pr: PullRequest) -> Bool {\n        guard let at = seen[pr.url] else { return true }\n        return pr.updatedAt > at\n    }\n\n    func markSeen(_ pr: PullRequest) { seen[pr.url] = pr.updatedAt }\n\n    func open(_ pr: PullRequest, path: String = \"\") {\n        markSeen(pr)\n        Stackbar.open(pr.url + path)\n    }\n\n    // MARK: Words\n\n    static func ago(_ date: Date, now: Date = Date()) -> String {\n        let seconds = now.timeIntervalSince(date)\n        if seconds < 3600 { return \"\\(max(1, Int(seconds / 60)))m\" }\n        if seconds < 86400 { return \"\\(Int(seconds / 3600))h\" }\n        return \"\\(Int(seconds / 86400))d\"\n    }\n\n    func row(_ pr: PullRequest, showAuthor: Bool) -> Row {\n        let size = pr.additions.map { \"+\\($0) −\\(pr.deletions ?? 0)\" }\n        let comments = (pr.comments?.totalCount ?? 0) > 0 ? \"\\(pr.comments?.totalCount ?? 0) comments\" : nil\n        let subtitle = [pr.id, showAuthor ? pr.author.map { \"by \\($0.login)\" } : nil, size, comments, Self.ago(pr.updatedAt)].compactMap { $0 }.joined(separator: \" · \")\n        var row = Row(pr.title, subtitle: subtitle, symbol: isNew(pr) ? \"circle.fill\" : pr.isDraft ? \"pencil.circle\" : \"arrow.triangle.pull\", key: pr.url)\n        if isNew(pr) { row = row.badge(\"New\", role: .accent) }\n        if !showAuthor, pr.readyToMerge { row = row.badge(\"Ready to merge\", role: .success) }\n        switch pr.checks {\n        case \"SUCCESS\": row = row.badge(\"Checks pass\", role: .success)\n        case \"FAILURE\", \"ERROR\": row = row.badge(\"Checks fail\", role: .critical)\n        case \"PENDING\", \"EXPECTED\": row = row.badge(\"Checks running\", role: .warning)\n        default: break\n        }\n        switch pr.reviewDecision {\n        case \"APPROVED\": row = row.badge(\"Approved\", role: .success)\n        case \"CHANGES_REQUESTED\": row = row.badge(\"Changes requested\", role: .critical)\n        case \"REVIEW_REQUIRED\": if !showAuthor { row = row.badge(\"Needs review\", role: .secondary) }\n        default: break\n        }\n        if pr.mergeable == \"CONFLICTING\" { row = row.badge(\"Conflicts\", role: .warning) }\n        if pr.isDraft { row = row.badge(\"Draft\") }\n        row = row.action(\"Open in browser\", shortcut: \"cmd+return\") { self.open(pr) }\n            .action(\"Open files changed\") { self.open(pr, path: \"/files\") }\n            .action(isNew(pr) ? \"Mark as seen\" : \"Mark as new\", shortcut: \"cmd+u\") {\n                if self.isNew(pr) { self.markSeen(pr) } else { self.seen[pr.url] = .distantPast }\n            }\n            .action(\"Snooze until it changes\", shortcut: \"cmd+s\") { self.snoozed[pr.url] = pr.updatedAt }\n            .action(\"Hide \\(pr.repo)\") { if !self.hiddenRepos.contains(pr.repo) { self.hiddenRepos.append(pr.repo) } }\n            .action(\"Copy link\") { Stackbar.copy(pr.url) }\n        if let branch = pr.headRefName {\n            row = row.action(\"Copy branch name\") { Stackbar.copy(branch) }\n        }\n        return row.action(\"Copy as Markdown\") { Stackbar.copy(\"[\\(pr.title) (\\(pr.id))](\\(pr.url))\") }\n    }\n\n    var failingMine: Int { shownMine.filter(\\.checksFailed).count }\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        if lastError != nil && toReview.isEmpty && mine.isEmpty {\n            Label(\"PRs\", symbol: \"exclamationmark.triangle\").role(.warning)\n        } else if failingMine > 0 {\n            Label(\"\\(shownReview.count) · \\(failingMine) failing\", symbol: \"arrow.triangle.pull\").role(.critical)\n        } else {\n            Label(\"\\(shownReview.count)\", symbol: \"arrow.triangle.pull\").role(shownReview.isEmpty ? .secondary : shownReview.contains(where: isNew) ? .accent : .primary)\n        }\n    }\n\n    var panel: ViewContent {\n        VStack(spacing: 8) {\n            Picker(\"Show\", options: tabs.map(label), selection: label(currentTab)) { chosen in\n                self.tab = (self.tabs.first { chosen.hasPrefix($0.rawValue) } ?? .review).rawValue\n            }\n            if let lastError {\n                Text(lastError).role(.warning)\n            }\n            if currentTab == .inbox {\n                if let inboxError { Text(inboxError).style(.caption).role(.warning) }\n                if inbox.isEmpty {\n                    if inboxError == nil { ContentUnavailableView(\"Inbox zero\", symbol: \"tray\", message: \"No GitHub notifications.\") }\n                } else {\n                    List(inbox, searchable: true) { item in inboxRow(item) }\n                }\n            } else if currentTab == .mine {\n                list(shownMine, showAuthor: false, empty: (\"No open pull requests\", \"You have nothing open right now.\"))\n            } else {\n                if history.count > 1 { Chart(\"Review queue\", values: history, kind: .area, role: shownReview.isEmpty ? .success : .accent) }\n                list(shownReview, showAuthor: true, empty: (\"Nothing to review\", \"You're all caught up.\"))\n            }\n            HStack {\n                if (shownReview + shownMine).contains(where: isNew) {\n                    Button(\"Mark all as seen\", symbol: \"checkmark.circle\") { (self.toReview + self.mine).forEach(self.markSeen) }\n                }\n                if hiddenCount > 0 || !hiddenRepos.isEmpty {\n                    Button(\"Show \\(hiddenCount) hidden\", symbol: \"eye\", key: \"unhide\") {\n                        self.snoozed = [:]\n                        self.hiddenRepos = []\n                    }\n                }\n            }\n            if let updatedAt {\n                Text(\"Updated \\(updatedAt.formatted(date: .omitted, time: .shortened))\").style(.caption).role(.secondary)\n            }\n        }\n    }\n\n    /// Stackbar's Settings window, so the panel stays about the pull requests.\n    var settings: ViewContent {\n        Section(\"What to show\") {\n            Toggle(\"Include requests to my teams\", isOn: includeTeams) { on in\n                self.includeTeams = on\n                try await self.refresh()\n            }\n            Toggle(\"Include drafts to review\", isOn: includeDrafts) { on in\n                self.includeDrafts = on\n                try await self.refresh()\n            }\n        }\n        Section(\"Notifications\") {\n            Toggle(\"Notify me about changes\", isOn: notifyChanges) { self.notifyChanges = $0 }\n            Text(\"Review requests, approvals, changes requested, failing checks, ready to merge, merged and closed, and mentions in the inbox.\").style(.caption).role(.secondary)\n        }\n        Section(\"GitHub inbox\") {\n            Toggle(\"Show the notifications inbox\", isOn: showInbox) { on in\n                self.showInbox = on\n                if on { try await self.refresh() } else { self.inbox = [] }\n            }\n            Text(\"Mentions, issues and everything else GitHub notifies you about. Needs a classic token with the notifications scope.\").style(.caption).role(.secondary)\n            if let inboxError { Text(inboxError).style(.caption).role(.warning) }\n        }\n        Section(\"Hidden repositories\") {\n            if hiddenRepos.isEmpty { Text(\"None. Hide one from a pull request's menu.\").role(.secondary) }\n            ForEach(hiddenRepos) { repo in\n                Row(repo, symbol: \"eye.slash\", key: \"hidden-\\(repo)\").action(\"Show again\") { self.hiddenRepos.removeAll { $0 == repo } }\n            }\n        }\n    }\n\n    /// Both lists side by side in sections, since a window has the room.\n    var window: ViewContent {\n        if let lastError { Text(lastError).role(.warning) }\n        List(searchable: true) {\n            Section(\"Waiting on your review (\\(shownReview.count))\") {\n                ForEach(shownReview) { pr in row(pr, showAuthor: true) }\n            }\n            Section(\"Yours (\\(shownMine.count))\") {\n                ForEach(shownMine) { pr in row(pr, showAuthor: false) }\n            }\n        }\n        if !recentlyClosed.isEmpty {\n            Section(\"Closed in the last two days\") {\n                ForEach(recentlyClosed) { pr in\n                    Row(pr.title, subtitle: \"\\(pr.id) · \\(Self.ago(pr.updatedAt))\", symbol: pr.state == \"MERGED\" ? \"arrow.triangle.merge\" : \"xmark.circle\", key: \"closed-\\(pr.url)\")\n                        .badge(pr.state == \"MERGED\" ? \"Merged\" : \"Closed\", role: pr.state == \"MERGED\" ? .success : .secondary)\n                        .action(\"Open in browser\") { Stackbar.open(pr.url) }\n                }\n            }\n        }\n        if !(shownReview + shownMine).isEmpty {\n            Section(\"Overview\") {\n                let states = mineByState\n                if !states.isEmpty {\n                    Chart(\"Your pull requests\", values: states.map { Double($0.count) }, kind: .donut, role: .success, labels: states.map(\\.name))\n                }\n                let ages = reviewAges\n                if ages.contains(where: { $0 > 0 }) {\n                    Chart(\"How long reviews have waited\", values: ages, kind: .bar, role: ages.last ?? 0 > 0 ? .warning : .accent, labels: [\"< 1 day\", \"1–3 days\", \"3–7 days\", \"> 1 week\"])\n                }\n                let repos = Dictionary(grouping: shownReview, by: \\.repo).map { (name: $0.key, count: $0.value.count) }.sorted { $0.count > $1.count }.prefix(6)\n                if repos.count > 1 {\n                    Chart(\"To review, by repository\", values: repos.map { Double($0.count) }, kind: .donut, labels: repos.map { $0.name.split(separator: \"/\").last.map(String.init) ?? $0.name })\n                }\n                if history.count > 1 {\n                    Chart(\"Review queue over time\", values: history, kind: .area)\n                }\n            }\n        }\n    }\n\n    /// Your open pull requests by where they stand, for the overview donut.\n    var mineByState: [(name: String, count: Int)] {\n        func state(_ pr: PullRequest) -> String {\n            if pr.isDraft { return \"Draft\" }\n            if pr.readyToMerge { return \"Ready to merge\" }\n            if pr.checksFailed { return \"Checks failing\" }\n            if pr.reviewDecision == \"CHANGES_REQUESTED\" { return \"Changes requested\" }\n            if pr.reviewDecision == \"APPROVED\" { return \"Approved\" }\n            return \"Waiting for review\"\n        }\n        let order = [\"Ready to merge\", \"Approved\", \"Waiting for review\", \"Changes requested\", \"Checks failing\", \"Draft\"]\n        let counts = Dictionary(grouping: shownMine, by: state).mapValues(\\.count)\n        return order.compactMap { name in counts[name].map { (name, $0) } }\n    }\n\n    /// Review requests by how long since they last changed: under a day,\n    /// 1–3 days, 3–7 days, over a week.\n    var reviewAges: [Double] {\n        var buckets = [0.0, 0, 0, 0]\n        for pr in shownReview {\n            let days = Date().timeIntervalSince(pr.updatedAt) / 86400\n            buckets[days < 1 ? 0 : days < 3 ? 1 : days < 7 ? 2 : 3] += 1\n        }\n        return buckets\n    }\n\n    var widget: ViewContent {\n        LabeledContent(\"To review\", value: \"\\(shownReview.count)\")\n        LabeledContent(\"Mine open\", value: \"\\(shownMine.count)\")\n        if failingMine > 0 { LabeledContent(\"Failing checks\", value: \"\\(failingMine)\").role(.critical) }\n        ForEach(shownReview.prefix(3)) { pr in Text(pr.title).style(.caption) }\n    }\n\n    var tabs: [Tab] { showInbox ? Tab.allCases : [.review, .mine] }\n\n    var currentTab: Tab {\n        let chosen = Tab(rawValue: tab) ?? .review\n        return tabs.contains(chosen) ? chosen : .review\n    }\n\n    func label(_ tab: Tab) -> String {\n        switch tab {\n        case .review: return \"\\(tab.rawValue) (\\(shownReview.count))\"\n        case .mine: return \"\\(tab.rawValue) (\\(shownMine.count))\"\n        case .inbox: return \"\\(tab.rawValue) (\\(inbox.filter(\\.unread).count))\"\n        }\n    }\n\n    @ViewBuilder func list(_ prs: [PullRequest], showAuthor: Bool, empty: (String, String)) -> ViewContent {\n        if prs.isEmpty {\n            if lastError == nil { ContentUnavailableView(empty.0, symbol: \"checkmark.circle\", message: empty.1) }\n        } else {\n            List(prs, searchable: true) { pr in row(pr, showAuthor: showAuthor) }\n        }\n    }\n\n    // MARK: Commands\n\n    var searchResults: [PullRequest] = []\n    var searchError: String?\n\n    var commands: [Command] {\n        Command(\"search\", title: \"Search Pull Requests\", subtitle: \"Yours and to review; type to search GitHub\", symbol: \"magnifyingglass\", keywords: [\"github\", \"pr\", \"pull request\"])\n            .view(search: { invocation in\n                try await self.search(invocation.text)\n            }) { invocation in\n                self.searchView(invocation.text)\n            }\n        Command(\"review-queue\", title: \"Review Queue\", subtitle: \"Pull requests waiting on your review\", symbol: \"arrow.triangle.pull\", keywords: [\"github\", \"pr\"])\n            .view { _ in\n                if self.shownReview.isEmpty {\n                    ContentUnavailableView(\"Nothing to review\", symbol: \"checkmark.circle\", message: self.lastError ?? \"You're all caught up.\")\n                } else {\n                    List(searchable: true) {\n                        ForEach(self.shownReview) { pr in self.row(pr, showAuthor: true) }\n                    }\n                }\n            }\n        Command(\"open-next-review\", title: \"Open Next Review\", symbol: \"arrow.up.forward.square\", keywords: [\"github\", \"pr\"]) { _ in\n            guard let pr = self.shownReview.first else { return .message(\"Nothing to review\") }\n            self.markSeen(pr)\n            return .open(url: pr.url, app: nil)\n        }\n        Command(\"refresh\", title: \"Refresh Pull Requests\", symbol: \"arrow.clockwise\", keywords: [\"github\"]) { _ in\n            try await self.refresh()\n            return .message(self.lastError ?? \"\\(self.shownReview.count) to review, \\(self.shownMine.count) of yours open\")\n        }\n    }\n\n    /// Searches GitHub for pull requests that involve you. Two characters\n    /// or fewer show the lists already fetched instead.\n    func search(_ text: String) async throws {\n        let text = text.trimmingCharacters(in: .whitespaces)\n        guard text.count > 2 else {\n            searchResults = []\n            searchError = nil\n            return\n        }\n        guard let token = try await Stackbar.secret(\"GITHUB_TOKEN\") else {\n            searchError = \"Add a GitHub token to search.\"\n            return\n        }\n        let escaped = text.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\").replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n        let query = \"query { found: search(query: \\\"is:pr archived:false involves:@me \\(escaped)\\\", type: ISSUE, first: 30) { nodes { ...pr } } } \\(Self.fragment)\"\n        do {\n            let response = try await Stackbar.http(HTTPRequest(url: \"https://api.github.com/graphql\", method: \"POST\", headers: [\n                \"Authorization\": \"Bearer \\(token)\", \"Content-Type\": \"application/json\", \"Accept\": \"application/json\",\n            ], body: try JSONEncoder().encode([\"query\": query])))\n            guard (200..<300).contains(response.status) else { throw HostError(\"GitHub answered \\(response.status)\") }\n            let decoder = JSONDecoder()\n            decoder.dateDecodingStrategy = .iso8601\n            let reply = try decoder.decode(SearchReply.self, from: response.body)\n            guard let data = reply.data else { throw HostError(reply.errors?.map(\\.message).joined(separator: \"; \") ?? \"GitHub sent no data\") }\n            searchResults = data.found.nodes\n            searchError = nil\n        } catch {\n            searchResults = []\n            searchError = \"\\(error)\"\n        }\n    }\n\n    @ViewBuilder func searchView(_ text: String) -> ViewContent {\n        if text.trimmingCharacters(in: .whitespaces).count <= 2 {\n            // The host filters these as you type.\n            List(searchable: true) {\n                Section(\"Waiting on your review\") {\n                    ForEach(shownReview) { pr in row(pr, showAuthor: true) }\n                }\n                Section(\"Yours\") {\n                    ForEach(shownMine) { pr in row(pr, showAuthor: false) }\n                }\n            }\n        } else if let searchError {\n            ContentUnavailableView(\"Search failed\", symbol: \"exclamationmark.triangle\", message: searchError)\n        } else if searchResults.isEmpty {\n            ContentUnavailableView(\"No pull requests\", symbol: \"magnifyingglass\", message: \"Nothing that involves you matches \\\"\\(text)\\\".\")\n        } else {\n            List {\n                ForEach(searchResults) { pr in row(pr, showAuthor: true) }\n            }\n        }\n    }\n\n    // MARK: Agents\n\n    var tools: [AgentTool] {\n        AgentTool(\"review_queue\", description: \"Pull requests waiting on my review, one per line\") { _ in\n            self.toReview.isEmpty ? \"No pull requests are waiting on your review.\" : self.toReview.map { \"\\($0.id) \\($0.title) by \\($0.author?.login ?? \"?\") (\\($0.url))\" }.joined(separator: \"\\n\")\n        }\n        AgentTool(\"my_pull_requests\", description: \"My open pull requests with their checks and review state, one per line\") { _ in\n            self.mine.isEmpty ? \"You have no open pull requests.\" : self.mine.map { pr in\n                \"\\(pr.id) \\(pr.title): checks \\(pr.checks?.lowercased() ?? \"none\"), review \\(pr.reviewDecision?.lowercased().replacingOccurrences(of: \"_\", with: \" \") ?? \"none\")\\(pr.mergeable == \"CONFLICTING\" ? \", has conflicts\" : \"\") (\\(pr.url))\"\n            }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "a4cf13c7c0ec67f411b0c8b1ec93f391f3d6a9b483e80e6d102a9bc9ce8b9d70"
}