{
  "format" : 1,
  "manifest" : {
    "accent" : "violet",
    "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.17.0",
    "name" : "PR Cockpit",
    "summary" : "Your pull requests, reviews and issues with CI and review state, and your repositories a keystroke away.",
    "surfaces" : [
      "menuBar",
      "panel",
      "window",
      "widget",
      "settings"
    ],
    "symbol" : "arrow.triangle.pull",
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.6.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "f9wh0aekkm8UDR4E4Qm3yG7F37+t4pvznwWeS9relBADw0WUCnsJqFUPvTOsVV6BBPOaHYDWDk35SeaRF8zRAA==",
  "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; the issues assigned to you; and your repositories\n/// and new issues from the palette. (The separate GitHub plugin is now part\n/// of this one.)\n///\n/// One GraphQL request a refresh fetches both lists with their checks, review\n/// decision and merge conflicts, alongside (not after) the issues and inbox\n/// requests, so a refresh is as slow as the slowest of them. Clicks wait for a\n/// running refresh, so keeping it short keeps switching tabs quick. A setting\n/// limits the lists to what changed in the last few days. It tells you when\n/// someone asks for your 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,\n/// issues and metadata covers everything but creating issues, which needs\n/// write access to issues), and every request goes through the host to the\n/// one declared 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.6.0\",\n        summary: \"Your pull requests, reviews and issues with CI and review state, and your repositories a keystroke away.\",\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.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/PRCockpit\",\n        accent: .violet,\n        symbol: \"arrow.triangle.pull\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<PRCockpit>] = [\n        PreviewScene(\"hero\", \"Every pull request waiting on you\") { plugin in sample(plugin) },\n        PreviewScene(\"mine\", \"Your own PRs: checks, reviews and conflicts\") { plugin in\n            sample(plugin)\n            plugin.tab = Tab.mine.rawValue\n        },\n        PreviewScene(\"widget\", \"Your review queue on the desktop\", surface: .widget) { plugin in sample(plugin) },\n    ]\n\n    static func sample(_ plugin: PRCockpit) {\n        let now = Date()\n        func pr(_ repo: String, _ number: Int, _ title: String, by author: String, minutesAgo: Double, review: String?, checks: String?,\n                mergeable: String = \"MERGEABLE\", draft: Bool = false, plus: Int, minus: Int, comments: Int) -> PullRequest {\n            PullRequest(number: number, title: title, url: \"https://github.com/\\(repo)/pull/\\(number)\", isDraft: draft,\n                        updatedAt: now.addingTimeInterval(-minutesAgo * 60), repository: .init(nameWithOwner: repo), author: .init(login: author),\n                        reviewDecision: review, mergeable: mergeable, headRefName: \"feature/\\(number)\", additions: plus, deletions: minus,\n                        commits: checks.map { .init(nodes: [.init(commit: .init(statusCheckRollup: .init(state: $0)))]) },\n                        comments: .init(totalCount: comments), state: \"OPEN\")\n        }\n        plugin.toReview = [\n            pr(\"acme/web\", 1482, \"Add Apple Pay to checkout\", by: \"priya-dev\", minutesAgo: 8, review: \"REVIEW_REQUIRED\", checks: \"SUCCESS\", plus: 412, minus: 38, comments: 3),\n            pr(\"acme/api\", 977, \"Rate-limit the export endpoint\", by: \"marco-k\", minutesAgo: 35, review: \"REVIEW_REQUIRED\", checks: \"PENDING\", plus: 96, minus: 12, comments: 1),\n            pr(\"acme/web\", 1479, \"Fix layout shift on product pages\", by: \"lena-w\", minutesAgo: 120, review: \"CHANGES_REQUESTED\", checks: \"SUCCESS\", plus: 28, minus: 41, comments: 6),\n            pr(\"acme/ios\", 311, \"Push notification when an order ships\", by: \"tom-b\", minutesAgo: 260, review: \"REVIEW_REQUIRED\", checks: \"FAILURE\", plus: 233, minus: 17, comments: 0),\n        ]\n        plugin.mine = [\n            pr(\"acme/api\", 981, \"Move cron jobs to the queue\", by: \"you\", minutesAgo: 15, review: \"APPROVED\", checks: \"SUCCESS\", plus: 188, minus: 140, comments: 4),\n            pr(\"acme/web\", 1485, \"Dark mode for the order screen\", by: \"you\", minutesAgo: 50, review: \"REVIEW_REQUIRED\", checks: \"PENDING\", plus: 77, minus: 9, comments: 0),\n            pr(\"acme/api\", 969, \"Audit log for admin actions\", by: \"you\", minutesAgo: 1440, review: \"REVIEW_REQUIRED\", checks: \"SUCCESS\", mergeable: \"CONFLICTING\", plus: 520, minus: 61, comments: 2),\n        ]\n        plugin.history = [3, 4, 2, 5, 6, 4, 3, 5, 7, 4]\n        plugin.updatedAt = now\n        plugin.loaded = true\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 issues = \"Issues\"\n        case inbox = \"Inbox\"\n\n        /// Short, so four tabs and their counts fit the panel's width.\n        var title: String { self == .review ? \"Review\" : rawValue }\n    }\n\n    /// One of your repositories (REST).\n    struct Repo: Codable, Equatable, Identifiable {\n        let id: Int\n        let full_name: String\n        let html_url: String\n        let ssh_url: String\n        let clone_url: String\n        let description: String?\n        let `private`: Bool\n        let fork: Bool\n        let archived: Bool\n        let has_issues: Bool\n        let open_issues_count: Int\n        let stargazers_count: Int\n        let pushed_at: Date?\n    }\n\n    /// An issue assigned to you (REST).\n    struct Issue: Decodable, Equatable, Identifiable {\n        struct Label: Decodable, Equatable { let name: String }\n        struct Repository: Decodable, Equatable { let full_name: String }\n        let id: Int\n        let number: Int\n        let title: String\n        let html_url: String\n        let updated_at: Date\n        let labels: [Label]\n        let repository: Repository?\n        let pull_request: [String: String?]?\n\n        var reference: String { \"\\(repository?.full_name ?? \"\")#\\(number)\" }\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    /// Every review request listed since launch, so one that drops out of the\n    /// age window and comes back with new activity isn't announced as new.\n    var everRequested: Set<String> = []\n    var mine: [PullRequest] = []\n    var lastError: String?\n    var updatedAt: Date?\n    var loaded = false\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    /// The last state each notification announced, by `<id> <topic>`, so a\n    /// state GitHub briefly loses and finds again (mergeable going UNKNOWN\n    /// after the base branch moves, checks going PENDING, search lagging\n    /// behind a merge) is not announced twice.\n    @Stored(\"announced\") var announced: [String: String] = [:]\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    /// Only pull requests and issues updated in this many days; 0 for any age.\n    @Stored(\"recentDays\") var recentDays = 0\n    var inbox: [InboxItem] = []\n    var inboxError: String?\n    var recentlyClosed: [PullRequest] = []\n    var issues: [Issue] = []\n    var issuesError: String?\n    var issuesLoaded = false\n    @Stored(\"repos\") var repos: [Repo] = []\n    @Stored(\"reposLoaded\") var reposLoaded: Date? = nil\n    @Stored(\"recentRepos\") var recentRepos: [String] = []\n    @Stored(\"includeArchived\") var includeArchived = false\n    @Stored(\"cloneWith\") var cloneWith = \"SSH\"\n    var repoResults: [Repo] = []\n    var reposError: String?\n    var formError: String?\n    var created: String?\n\n    func refresh() async throws {\n        defer { loaded = true }\n        guard let token = try await Stackbar.secret(\"GITHUB_TOKEN\"), !token.isEmpty else {\n            lastError = \"Add a GitHub token to see your pull requests.\"\n            return\n        }\n        // The three requests run side by side: a refresh takes as long as the\n        // slowest one, not all three, and a tab click waits for less.\n        async let pullRequests: Void = loadPullRequests(token: token)\n        async let notifications: Void = showInbox ? refreshInbox(token: token) : ()\n        async let assigned: Void = loadIssues()\n        _ = await (pullRequests, notifications, assigned)\n    }\n\n    /// Both lists and what closed lately, in one GraphQL request.\n    func loadPullRequests(token: String) async {\n        let requested = includeTeams ? \"review-requested:@me\" : \"user-review-requested:@me\"\n        let draft = includeDrafts ? \"\" : \" draft:false\"\n        let recent = recentCutoff.map { \" updated:>=\\(Self.day($0))\" } ?? \"\"\n        let since = Self.day(Date().addingTimeInterval(-2 * 86400))\n        let query = \"\"\"\n        query { \\\n        review: search(query: \"is:pr is:open archived:false \\(requested)\\(draft)\\(recent) sort:updated-desc\", type: ISSUE, first: 40) { nodes { ...pr } } \\\n        mine: search(query: \"is:pr is:open archived:false author:@me\\(recent) sort:updated-desc\", 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    }\n\n    /// A date as GitHub's search wants it: 2026-11-03.\n    static func day(_ date: Date) -> String {\n        ISO8601DateFormatter.string(from: date, timeZone: .gmt, formatOptions: [.withFullDate])\n    }\n\n    /// The choices for how far back to look, and the days each one means.\n    static let ages: [(title: String, days: Int)] = [(\"Any age\", 0), (\"1 week\", 7), (\"2 weeks\", 14), (\"1 month\", 30), (\"3 months\", 90)]\n\n    /// The oldest update still shown, or nil to show any age.\n    var recentCutoff: Date? {\n        recentDays > 0 ? Date().addingTimeInterval(-Double(recentDays) * 86400) : nil\n    }\n\n    func isRecent(_ date: Date) -> Bool { recentCutoff.map { date >= $0 } ?? true }\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        let response = 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        if let response, (200..<300).contains(response.status) { inbox.removeAll { $0.id == item.id } }\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 fetchedMine: [PullRequest], closed: [PullRequest] = []) {\n        // Search can list a PR that just closed under both queries for a refresh or two.\n        let closedIDs = Set(closed.map(\\.id))\n        let newMine = fetchedMine.filter { !closedIDs.contains($0.id) }\n        let notifying = notifyChanges && updatedAt != nil\n        if notifying {\n            let wasOpen = Set(mine.map(\\.id))\n            for pr in closed where wasOpen.contains(pr.id) && announce(pr, \"closed\", as: pr.state ?? \"CLOSED\") {\n                Stackbar.notify(pr.state == \"MERGED\" ? \"Merged: \\(pr.title)\" : \"Closed: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n            }\n            let added = review.filter { !everRequested.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        }\n        let before = Set(mine.map(\\.id))\n        for pr in newMine {\n            // A PR seen for the first time records its state without a notification.\n            let tell = notifying && before.contains(pr.id)\n            if let decision = pr.reviewDecision, announce(pr, \"review\", as: decision), tell {\n                if decision == \"APPROVED\" {\n                    Stackbar.notify(\"Approved: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                } else if decision == \"CHANGES_REQUESTED\" {\n                    Stackbar.notify(\"Changes requested: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                }\n            }\n            // An unknown merge state passes (GitHub recomputes it whenever the\n            // base moves); running checks reset, so a new push that goes green is news.\n            let outcome: String? = switch pr.checks {\n            case \"PENDING\", \"EXPECTED\": \"running\"\n            case _ where pr.checksFailed: \"failed\"\n            case _ where pr.readyToMerge: \"ready\"\n            case \"SUCCESS\" where pr.mergeable == \"UNKNOWN\": nil\n            default: \"blocked\"\n            }\n            if let outcome, announce(pr, \"checks\", as: outcome), tell {\n                if outcome == \"failed\" {\n                    Stackbar.notify(\"Checks failed: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"See checks\", url: pr.url + \"/checks\")])\n                } else if outcome == \"ready\" {\n                    Stackbar.notify(\"Ready to merge: \\(pr.title)\", body: pr.id, actions: [NotificationAction(\"Open\", url: pr.url)])\n                }\n            }\n        }\n        let tracked = Set((newMine + closed).map(\\.id))\n        announced = announced.filter { tracked.contains(String($0.key.split(separator: \" \").first ?? \"\")) }\n        everRequested.formUnion(review.map(\\.id))\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        // Also checked here, so a new setting applies before the next fetch\n        // and a PR ages out between fetches.\n        if !isRecent(pr.updatedAt) { return false }\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    /// Snoozed or in a hidden repository; not the ones too old to show.\n    var hiddenCount: Int {\n        (toReview + mine).filter { isRecent($0.updatedAt) }.count - shownReview.count - shownMine.count\n    }\n    var shownIssues: [Issue] { issues.filter { isRecent($0.updated_at) } }\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    /// Records `state` as the last one announced about `pr`'s `topic`, and\n    /// says whether it is a change from what was announced before.\n    func announce(_ pr: PullRequest, _ topic: String, as state: String) -> Bool {\n        let key = \"\\(pr.id) \\(topic)\"\n        guard announced[key] != state else { return false }\n        announced[key] = state\n        return true\n    }\n\n    func open(_ pr: PullRequest, path: String = \"\") {\n        markSeen(pr)\n        Stackbar.open(pr.url + path)\n    }\n\n    // MARK: Issues and repositories (REST)\n\n    static let restDecoder: JSONDecoder = {\n        let decoder = JSONDecoder()\n        decoder.dateDecodingStrategy = .iso8601\n        return decoder\n    }()\n\n    func rest(_ path: String, method: String = \"GET\", body: Data? = nil) async throws -> Data {\n        guard let token = try await Stackbar.secret(\"GITHUB_TOKEN\"), !token.isEmpty else {\n            throw HostError(\"Add a GitHub token (GITHUB_TOKEN) first\")\n        }\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://api.github.com\" + path, method: method, headers: [\n            \"Authorization\": \"Bearer \\(token)\",\n            \"Accept\": \"application/vnd.github+json\",\n            \"X-GitHub-Api-Version\": \"2022-11-28\",\n            \"Content-Type\": \"application/json\",\n        ], body: body))\n        switch response.status {\n        case 200..<300: return response.body\n        case 401: throw HostError(\"GitHub rejected the token (401); set a new GITHUB_TOKEN\")\n        case 403, 404:\n            struct Message: Decodable { let message: String }\n            let message = (try? JSONDecoder().decode(Message.self, from: response.body))?.message ?? \"not allowed\"\n            throw HostError(\"GitHub said \\(response.status): \\(message). The token may need more access.\")\n        case 422: throw HostError(\"GitHub couldn't do that (422): \\(String(decoding: response.body.prefix(200), as: UTF8.self))\")\n        default: throw HostError(\"GitHub answered \\(response.status)\")\n        }\n    }\n\n    /// Open issues assigned to you, telling you about new ones.\n    func loadIssues() async {\n        do {\n            // Every assigned issue, not only recent ones: an old one that\n            // changes must not look newly assigned. `shownIssues` filters by age.\n            let data = try await rest(\"/issues?filter=assigned&state=open&per_page=50&sort=updated\")\n            let fresh = try Self.restDecoder.decode([Issue].self, from: data).filter { $0.pull_request == nil }\n            if notifyChanges, issuesLoaded {\n                let known = Set(issues.map(\\.id))\n                let added = fresh.filter { !known.contains($0.id) }\n                if added.count == 1, let issue = added.first {\n                    Stackbar.notify(\"Assigned: \\(issue.title)\", body: issue.reference, actions: [NotificationAction(\"Open\", url: issue.html_url)])\n                } else if added.count > 1 {\n                    Stackbar.notify(\"\\(added.count) issues assigned to you\", body: added.prefix(3).map(\\.title).joined(separator: \"\\n\"))\n                }\n            }\n            issues = fresh\n            issuesError = nil\n            issuesLoaded = true\n        } catch {\n            issuesError = \"\\(error)\"\n        }\n    }\n\n    /// Your repositories and your organizations', most recently pushed\n    /// first; three pages of 100 at most. Kept for six hours.\n    func loadRepos(force: Bool = false) async {\n        if !force, !repos.isEmpty, let at = reposLoaded, Date().timeIntervalSince(at) < 6 * 3600 { return }\n        do {\n            var all: [Repo] = []\n            for page in 1...3 {\n                let data = try await rest(\"/user/repos?sort=pushed&per_page=100&page=\\(page)&affiliation=owner,collaborator,organization_member\")\n                let batch = try Self.restDecoder.decode([Repo].self, from: data)\n                all += batch\n                if batch.count < 100 { break }\n            }\n            repos = all\n            reposLoaded = Date()\n            reposError = nil\n        } catch {\n            reposError = \"\\(error)\"\n        }\n    }\n\n    /// GitHub search for repositories you don't have, once your own run short.\n    func searchRepos(_ text: String) async {\n        let query = text.trimmingCharacters(in: .whitespaces)\n        guard query.count >= 3, matchingRepos(query).count < 5 else {\n            repoResults = []\n            return\n        }\n        struct Reply: Decodable { let items: [Repo] }\n        let encoded = query.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? query\n        repoResults = (try? Self.restDecoder.decode(Reply.self, from: try await rest(\"/search/repositories?q=\\(encoded)&per_page=10\")).items) ?? []\n    }\n\n    func matchingRepos(_ text: String) -> [Repo] {\n        let words = text.lowercased().split(separator: \" \")\n        return shownRepos.filter { repo in words.allSatisfy { repo.full_name.lowercased().contains($0) } }\n    }\n\n    var shownRepos: [Repo] { repos.filter { includeArchived || !$0.archived } }\n\n    /// Recently opened first, then most recently pushed.\n    var orderedRepos: [Repo] {\n        let rank = Dictionary(recentRepos.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first })\n        return shownRepos.sorted { (rank[$0.full_name] ?? Int.max, $0.pushed_at.map { -$0.timeIntervalSince1970 } ?? 0) < (rank[$1.full_name] ?? Int.max, $1.pushed_at.map { -$0.timeIntervalSince1970 } ?? 0) }\n    }\n\n    func opened(_ repo: Repo) {\n        recentRepos = Array(([repo.full_name] + recentRepos.filter { $0 != repo.full_name }).prefix(10))\n    }\n\n    func repoRow(_ repo: Repo) -> Row {\n        let fallback = \"★ \\(repo.stargazers_count) · \\(repo.open_issues_count) open issue\\(repo.open_issues_count == 1 ? \"\" : \"s\")\"\n        var row = Row(repo.full_name, subtitle: repo.description.flatMap { $0.isEmpty ? nil : $0 } ?? fallback, symbol: repo.private ? \"lock\" : \"book.closed\", key: \"repo-\\(repo.id)\")\n        if repo.archived { row = row.badge(\"archived\") } else if repo.fork { row = row.badge(\"fork\") }\n        return row\n            .action(\"Open\") { self.opened(repo); Stackbar.open(repo.html_url) }\n            .action(\"Pull Requests\", shortcut: \"cmd+p\") { self.opened(repo); Stackbar.open(repo.html_url + \"/pulls\") }\n            .action(\"Issues\", shortcut: \"cmd+i\") { self.opened(repo); Stackbar.open(repo.html_url + \"/issues\") }\n            .action(\"Actions\", shortcut: \"cmd+shift+a\") { self.opened(repo); Stackbar.open(repo.html_url + \"/actions\") }\n            .action(\"Copy Clone URL\", shortcut: \"cmd+shift+c\") { Stackbar.copy(self.cloneWith == \"HTTPS\" ? repo.clone_url : repo.ssh_url) }\n            .action(\"Copy URL\", shortcut: \"cmd+c\") { Stackbar.copy(repo.html_url) }\n            .navigate(\"New Issue\", to: \"create-issue\", arguments: [\"repo\": repo.full_name], shortcut: \"cmd+n\")\n    }\n\n    func issueRow(_ issue: Issue) -> Row {\n        var row = Row(issue.title, subtitle: \"\\(issue.reference) · \\(Self.ago(issue.updated_at))\", symbol: \"smallcircle.filled.circle\", key: \"issue-\\(issue.id)\")\n        if let label = issue.labels.first { row = row.badge(label.name) }\n        return row\n            .action(\"Open\", shortcut: \"cmd+return\") { Stackbar.open(issue.html_url) }\n            .action(\"Copy Link\", shortcut: \"cmd+c\") { Stackbar.copy(issue.html_url) }\n            .action(\"Copy Reference\", shortcut: \"cmd+shift+c\") { Stackbar.copy(issue.reference) }\n    }\n\n    @ViewBuilder func issueForm(repo: String?) -> ViewContent {\n        let names = shownRepos.filter(\\.has_issues).map(\\.full_name)\n        if names.isEmpty {\n            ContentUnavailableView(\"No repositories yet\", symbol: \"book.closed\", message: reposError ?? \"Add a GitHub token, then try again.\")\n        } else {\n            Form(submit: \"Create Issue\", key: \"issue\") {\n                FormField.picker(\"Repository\", name: \"repo\", options: names, selection: repo.flatMap { names.contains($0) ? $0 : nil } ?? recentRepos.first { names.contains($0) } ?? names.first)\n                FormField.text(\"Title\", name: \"title\", error: formError)\n                FormField.multiline(\"Description\", name: \"body\", placeholder: \"Markdown\")\n            } onSubmit: { values in\n                try await self.createIssue(values)\n            }\n            if let created { Banner(created, symbol: \"checkmark.circle.fill\", theme: .success) }\n        }\n    }\n\n    func createIssue(_ values: [String: EventValue]) async throws {\n        let title = (values.string(\"title\") ?? \"\").trimmingCharacters(in: .whitespaces)\n        guard !title.isEmpty else { formError = \"Give it a title\"; return }\n        guard let repo = values.string(\"repo\"), repo.split(separator: \"/\").count == 2 else { formError = \"Pick a repository\"; return }\n        formError = nil\n        let body = try JSONSerialization.data(withJSONObject: [\"title\": title, \"body\": values.string(\"body\") ?? \"\"])\n        do {\n            let data = try await rest(\"/repos/\\(repo)/issues\", method: \"POST\", body: body)\n            struct Created: Decodable { let html_url: String; let number: Int }\n            let issue = try Self.restDecoder.decode(Created.self, from: data)\n            created = \"Created \\(repo)#\\(issue.number)\"\n            Stackbar.open(issue.html_url)\n        } catch {\n            formError = \"\\(error)\"\n        }\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) >= 5 ? \"\\(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        for badge in badges(pr, mine: !showAuthor).prefix(2) { row = row.badge(badge.text, role: badge.role) }\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    /// What a pull request needs you to know, most urgent first. Green is\n    /// the normal state, so \"checks pass\" is never said on its own.\n    func badges(_ pr: PullRequest, mine: Bool) -> [(text: String, role: Role)] {\n        var found: [(String, Role)] = []\n        if pr.checksFailed { found.append((\"Checks fail\", .critical)) }\n        if pr.reviewDecision == \"CHANGES_REQUESTED\" { found.append((\"Changes requested\", .critical)) }\n        if pr.mergeable == \"CONFLICTING\" { found.append((\"Conflicts\", .warning)) }\n        if mine, pr.readyToMerge { found.append((\"Ready to merge\", .success)) } else if pr.reviewDecision == \"APPROVED\" { found.append((\"Approved\", .success)) }\n        if pr.checks == \"PENDING\" || pr.checks == \"EXPECTED\" { found.append((\"Checks running\", .warning)) }\n        if pr.isDraft { found.append((\"Draft\", .secondary)) }\n        return found\n    }\n\n    var failingMine: Int { shownMine.filter(\\.checksFailed).count }\n\n    let theme = Theme.violet\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        if lastError != nil && toReview.isEmpty && mine.isEmpty {\n            Label(\"\", symbol: \"arrow.triangle.pull\").role(.warning)\n        } else if failingMine > 0 {\n            Label(\"\\(shownReview.count) · \\(failingMine)✕\", symbol: \"arrow.triangle.pull\").role(.critical)\n        } else if shownReview.isEmpty {\n            Label(\"\", symbol: \"arrow.triangle.pull\").role(.secondary)\n        } else {\n            Label(\"\\(shownReview.count)\", symbol: \"arrow.triangle.pull\")\n        }\n    }\n\n    var headline: String {\n        if failingMine > 0 { return \"\\(failingMine) PR\\(failingMine == 1 ? \"\" : \"s\") failing checks\" }\n        if !shownReview.isEmpty { return \"\\(shownReview.count) waiting on you\" }\n        return \"All caught up\"\n    }\n\n    var panel: ViewContent {\n        VStack(spacing: Space.m) {\n            if !loaded {\n                PanelHeader(\"Pull requests\", subtitle: \"Checking…\", symbol: \"arrow.triangle.pull\", theme: theme)\n            } else if let lastError, toReview.isEmpty, mine.isEmpty, updatedAt == nil {\n                if lastError.contains(\"Add a GitHub token\") {\n                    EmptyState(\"Paste a GitHub token above\", symbol: \"key.fill\", message: \"A fine-grained token with read access to pull requests, issues and metadata. Add write access to issues to create them from the palette.\", theme: theme)\n                } else {\n                    EmptyState(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle.fill\", message: lastError, theme: .critical) {\n                        ActionButton(\"Try Again\", symbol: \"arrow.clockwise\", theme: theme) { try await self.refresh() }\n                    }\n                }\n            } else {\n                PanelHeader(headline, subtitle: \"\\(shownMine.count) of yours open\" + (updatedAt.map { \" · updated \\($0.formatted(date: .omitted, time: .shortened))\" } ?? \"\"), symbol: \"arrow.triangle.pull\", theme: theme) {\n                    if (shownReview + shownMine).contains(where: isNew) {\n                        IconButton(\"checkmark.circle\", label: \"Mark all as seen\", theme: theme, prominence: .tinted) { (self.toReview + self.mine).forEach(self.markSeen) }\n                    }\n                }\n                HeroCard(theme: theme, look: .glow) {\n                    HStack(spacing: Space.l) {\n                        HeroStat(\"To review\", value: \"\\(shownReview.count)\", theme: theme, live: !shownReview.isEmpty)\n                        HeroStat(\"Mine\", value: \"\\(shownMine.count)\", theme: .neutral, symbol: \"person.fill\")\n                        if failingMine > 0 {\n                            HeroStat(\"Failing\", value: \"\\(failingMine)\", theme: .critical, symbol: \"xmark.octagon.fill\")\n                        } else {\n                            HeroStat(\"Ready\", value: \"\\(shownMine.filter(\\.readyToMerge).count)\", theme: .success, symbol: \"checkmark.seal.fill\")\n                        }\n                        Spacer()\n                    }\n                }\n                Picker(\"Show\", options: tabs.map(label), selection: label(currentTab), style: .segmented) { chosen in\n                    self.tab = (self.tabs.first { self.label($0) == chosen } ?? .review).rawValue\n                }\n                if let lastError { Banner(lastError) }\n                if currentTab == .inbox {\n                    if let inboxError { Banner(inboxError) }\n                    if inbox.isEmpty {\n                        if inboxError == nil { EmptyState(\"Inbox zero\", symbol: \"tray\", message: \"No GitHub notifications.\", theme: .success) }\n                    } else {\n                        List(inbox, searchable: true) { item in inboxRow(item) }\n                    }\n                } else if currentTab == .issues {\n                    if let issuesError { Banner(issuesError) }\n                    if shownIssues.isEmpty {\n                        if issuesError == nil { EmptyState(\"No issues assigned\", symbol: \"checkmark\", message: \"Open issues assigned to you show up here.\", theme: .success) }\n                    } else {\n                        List(shownIssues, searchable: true) { issue in issueRow(issue) }\n                    }\n                } else if currentTab == .mine {\n                    list(shownMine, showAuthor: false, empty: (\"Nothing of yours open\", \"Pull requests you open show up here.\"))\n                } else {\n                    list(shownReview, showAuthor: true, empty: (\"No reviews waiting\", \"Pull requests that ask for your review show up here.\"))\n                }\n                if hiddenCount > 0 || !hiddenRepos.isEmpty {\n                    ActionButton(\"Show \\(hiddenCount) hidden\", symbol: \"eye\", theme: theme, key: \"unhide\") {\n                        self.snoozed = [:]\n                        self.hiddenRepos = []\n                    }\n                }\n            }\n        }\n    }\n\n    /// Fetches again after a setting changed what is fetched. Pull requests\n    /// the new search brings in were there all along, so they are not\n    /// announced as new review requests.\n    func refetch() async throws {\n        everRequested = []\n        updatedAt = nil\n        try await refresh()\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.refetch()\n            }\n            Toggle(\"Include drafts to review\", isOn: includeDrafts) { on in\n                self.includeDrafts = on\n                try await self.refetch()\n            }\n            Picker(\"Updated within\", options: Self.ages.map(\\.title), selection: (Self.ages.first { $0.days == recentDays } ?? Self.ages[0]).title) { chosen in\n                self.recentDays = Self.ages.first { $0.title == chosen }?.days ?? 0\n                try await self.refetch()\n            }\n            Text(\"Pull requests and issues nobody has touched for longer stay out of the lists, and fewer to fetch makes refreshing quicker.\").style(.caption).role(.secondary)\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(\"Repositories\") {\n            LabeledContent(\"Loaded\", value: \"\\(repos.count)\")\n            Toggle(\"Include archived\", isOn: includeArchived) { self.includeArchived = $0 }\n            Picker(\"Copy clone URLs as\", options: [\"SSH\", \"HTTPS\"], selection: cloneWith) { self.cloneWith = $0 }\n            Button(\"Reload\", symbol: \"arrow.clockwise\") { await self.loadRepos(force: true) }\n            Text(\"Search GitHub Repositories in the palette opens any of them, or its pull requests, issues or Actions. Creating issues needs a token with write access to issues.\").style(.caption).role(.secondary)\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        VStack(spacing: Space.s) {\n            HStack(spacing: Space.s) {\n                StatTile(\"To review\", value: \"\\(shownReview.count)\", theme: theme, emphasized: !shownReview.isEmpty)\n                StatTile(\"Mine\", value: \"\\(shownMine.count)\")\n                if failingMine > 0 {\n                    StatTile(\"Failing\", value: \"\\(failingMine)\", theme: .critical, emphasized: true)\n                } else {\n                    StatTile(\"Ready\", value: \"\\(shownMine.filter(\\.readyToMerge).count)\", theme: .success, emphasized: shownMine.contains(where: \\.readyToMerge))\n                }\n            }\n            if let pr = shownReview.first {\n                VStack(spacing: 0) {\n                    Text(pr.title).typography(.headline)\n                    Text(\"\\(pr.repo.split(separator: \"/\").last.map(String.init) ?? pr.repo)\\(pr.author.map { \" · by \\($0.login)\" } ?? \"\") · \\(Self.ago(pr.updatedAt))\").role(.secondary).typography(.caption)\n                }\n            }\n        }\n        .frame(fillWidth: true, fillHeight: true)\n    }\n\n    var tabs: [Tab] { showInbox ? Tab.allCases : [.review, .mine, .issues] }\n\n    var currentTab: Tab {\n        let chosen = Tab(rawValue: tab) ?? .review\n        return tabs.contains(chosen) ? chosen : .review\n    }\n\n    /// The tab's title, with its count when there is something in it.\n    func label(_ tab: Tab) -> String {\n        let count: Int\n        switch tab {\n        case .review: count = shownReview.count\n        case .mine: count = shownMine.count\n        case .issues: count = shownIssues.count\n        case .inbox: count = inbox.filter(\\.unread).count\n        }\n        return count > 0 ? \"\\(tab.title) \\(count)\" : tab.title\n    }\n\n    @ViewBuilder func list(_ prs: [PullRequest], showAuthor: Bool, empty: (String, String)) -> ViewContent {\n        if prs.isEmpty {\n            if lastError == nil { EmptyState(empty.0, symbol: \"checkmark\", message: empty.1, theme: .success) }\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(\"repos\", title: \"Search GitHub Repositories\", symbol: \"book.closed\", keywords: [\"github\", \"repo\", \"repository\", \"gh\"])\n            .view(search: { invocation in\n                await self.loadRepos()\n                await self.searchRepos(invocation.text)\n            }) { _ in\n                if let problem = self.reposError, self.repos.isEmpty {\n                    ContentUnavailableView(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    List(searchable: true) {\n                        ForEach(self.orderedRepos) { repo in self.repoRow(repo) }\n                        ForEach(self.repoResults.filter { result in !self.repos.contains { $0.id == result.id } }) { repo in self.repoRow(repo) }\n                    }\n                }\n            }\n        Command(\"issues\", title: \"My GitHub Issues\", subtitle: \"Open issues assigned to you\", symbol: \"smallcircle.filled.circle\", keywords: [\"github\", \"issue\", \"assigned\", \"gh\"])\n            .view(search: { _ in await self.loadIssues() }) { _ in\n                if let problem = self.issuesError {\n                    ContentUnavailableView(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else if self.shownIssues.isEmpty {\n                    ContentUnavailableView(\"Nothing assigned to you\", symbol: \"checkmark.circle\")\n                } else {\n                    List(self.shownIssues, searchable: true) { issue in self.issueRow(issue) }\n                }\n            }\n        Command(\"create-issue\", title: \"Create GitHub Issue\", symbol: \"plus.circle\", keywords: [\"github\", \"issue\", \"new\", \"bug\", \"gh\"])\n            .argument(\"repo\", placeholder: \"Repository\", required: false)\n            .stackbarOnly()\n            .view(search: { _ in await self.loadRepos() }) { invocation in self.issueForm(repo: invocation[\"repo\"]) }\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_issues\", description: \"Open GitHub issues assigned to me, one per line\") { _ in\n            self.issues.isEmpty ? \"No open issues are assigned to you.\" : self.issues.map { \"\\($0.reference) \\($0.title) (\\($0.html_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" : "c8ef604639a0f87ab1dc6054d8009dbb4ff81b104463926eb20f1290bb093227"
}