{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "network" : {
          "hosts" : [
            "api.github.com"
          ]
        }
      },
      {
        "secret" : {
          "name" : "GITHUB_TOKEN"
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/GitHub",
    "id" : "io.navbytes.stackbar.plugins.github",
    "minimumStackbar" : "0.11.0",
    "name" : "GitHub",
    "summary" : "Your repositories and issues from the palette, and new issues without a browser.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "pvDeHtWUSKc9tKknSGO/dGZci5f+iQky6lt6JB+rzlmBDfcEpUt8RmiOZKytyLcyXcEz4KbOTSZsNbCin/vrCQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// GitHub from the palette: jump to any of your repositories (or its issues,\n/// pull requests and Actions), see the issues assigned to you, and file a\n/// new issue without opening a browser first.\n///\n/// It uses the same `GITHUB_TOKEN` as PR Cockpit, so one token serves both.\n/// A fine-grained token with read access to metadata and issues covers\n/// everything except creating issues, which needs write access to issues.\n@main\nfinal class GitHub: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.github\",\n        name: \"GitHub\",\n        version: \"1.0.0\",\n        summary: \"Your repositories and issues from the palette, and new issues without a browser.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch, .minutes(30), .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/GitHub\"\n    )\n\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    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\n    @Stored(\"repos\") var repos: [Repo] = []\n    @Stored(\"recent\") var recent: [String] = []\n    @Stored(\"includeArchived\") var includeArchived = false\n    @Stored(\"cloneWith\") var cloneWith = \"SSH\"\n    var issues: [Issue] = []\n    var searchResults: [Repo] = []\n    var problem: String?\n    var formError: String?\n    var created: String?\n\n    // MARK: API\n\n    func token() async throws -> String {\n        guard let token = try await Stackbar.secret(\"GITHUB_TOKEN\"), !token.isEmpty else {\n            throw HostError(\"Add a GitHub token (GITHUB_TOKEN) to use GitHub\")\n        }\n        return token\n    }\n\n    static let decoder: JSONDecoder = {\n        let decoder = JSONDecoder()\n        decoder.dateDecodingStrategy = .iso8601\n        return decoder\n    }()\n\n    func api(_ path: String, method: String = \"GET\", body: Data? = nil) async throws -> Data {\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://api.github.com\" + path, method: method, headers: [\n            \"Authorization\": \"Bearer \\(try await 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    func refresh() async throws {\n        do {\n            try await loadRepos()\n            try await loadIssues()\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n    }\n\n    /// Your repositories and those of your organizations, most recently\n    /// pushed first; three pages of 100 at most.\n    func loadRepos() async throws {\n        var all: [Repo] = []\n        for page in 1...3 {\n            let data = try await api(\"/user/repos?sort=pushed&per_page=100&page=\\(page)&affiliation=owner,collaborator,organization_member\")\n            let batch = try Self.decoder.decode([Repo].self, from: data)\n            all += batch\n            if batch.count < 100 { break }\n        }\n        repos = all\n    }\n\n    func loadIssues() async throws {\n        let data = try await api(\"/issues?filter=assigned&state=open&per_page=50&sort=updated\")\n        issues = try Self.decoder.decode([Issue].self, from: data).filter { $0.pull_request == nil }\n    }\n\n    func search(_ text: String) async {\n        let query = text.trimmingCharacters(in: .whitespaces)\n        guard query.count >= 3, matching(query).count < 5 else {\n            searchResults = []\n            return\n        }\n        struct Reply: Decodable { let items: [Repo] }\n        let encoded = query.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? query\n        do {\n            let data = try await api(\"/search/repositories?q=\\(encoded)&per_page=10\")\n            searchResults = try Self.decoder.decode(Reply.self, from: data).items\n        } catch {\n            searchResults = []\n        }\n    }\n\n    func matching(_ 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] {\n        repos.filter { includeArchived || !$0.archived }\n    }\n\n    /// Recently opened first, then most recently pushed.\n    var ordered: [Repo] {\n        let rank = Dictionary(recent.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        recent = Array(([repo.full_name] + recent.filter { $0 != repo.full_name }).prefix(10))\n    }\n\n    // MARK: Rows\n\n    func row(_ repo: Repo) -> Row {\n        var row = Row(repo.full_name, subtitle: repo.description, symbol: repo.private ? \"lock\" : \"book.closed\", key: \"repo-\\(repo.id)\")\n        if repo.archived { row = row.badge(\"archived\") }\n        if repo.fork { row = row.badge(\"fork\") }\n        return row\n            .action(\"Open\") { self.opened(repo); Stackbar.open(repo.html_url) }\n            .action(\"Issues\", shortcut: \"cmd+i\") { self.opened(repo); Stackbar.open(repo.html_url + \"/issues\") }\n            .action(\"Pull Requests\", shortcut: \"cmd+p\") { self.opened(repo); Stackbar.open(repo.html_url + \"/pulls\") }\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 row(_ issue: Issue) -> Row {\n        var row = Row(issue.title, subtitle: \"\\(issue.repository?.full_name ?? \"\")#\\(issue.number)\", symbol: \"smallcircle.filled.circle\", key: \"issue-\\(issue.id)\")\n        for label in issue.labels.prefix(2) { row = row.badge(label.name) }\n        return row\n            .action(\"Open\") { 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.repository?.full_name ?? \"\")#\\(issue.number)\") }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"repos\", title: \"Search GitHub Repositories\", symbol: \"book.closed\", keywords: [\"github\", \"repo\", \"repository\", \"gh\"])\n            .view(search: { invocation in\n                if self.repos.isEmpty { try await self.refresh() }\n                await self.search(invocation.text)\n            }) { _ in\n                if let problem = self.problem, self.repos.isEmpty {\n                    ContentUnavailableView(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    List(searchable: true) {\n                        ForEach(self.ordered) { repo in self.row(repo) }\n                        ForEach(self.searchResults.filter { result in !self.repos.contains { $0.id == result.id } }) { repo in self.row(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\n                do { try await self.loadIssues(); self.problem = nil } catch { self.problem = \"\\(error)\" }\n            }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else if self.issues.isEmpty {\n                    ContentUnavailableView(\"Nothing assigned to you\", symbol: \"checkmark.circle\")\n                } else {\n                    List(self.issues, searchable: true) { issue in self.row(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 { invocation in self.issueForm(repo: invocation[\"repo\"]) }\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: problem ?? \"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 } ?? recent.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 { Text(created).style(.caption) }\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 api(\"/repos/\\(repo)/issues\", method: \"POST\", body: body)\n            struct Created: Decodable { let html_url: String; let number: Int }\n            let issue = try Self.decoder.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: Panel and settings\n\n    var panel: ViewContent {\n        if let problem, repos.isEmpty {\n            ContentUnavailableView(\"Can't reach GitHub\", symbol: \"exclamationmark.triangle\", message: problem)\n        } else {\n            if !issues.isEmpty {\n                Section(\"Assigned to you\") {\n                    ForEach(issues.prefix(5)) { issue in self.row(issue) }\n                }\n            }\n            Section(\"Repositories\") {\n                ForEach(ordered.prefix(8)) { repo in self.row(repo) }\n            }\n            Text(\"Search all \\(shownRepos.count) with Search GitHub Repositories in the palette.\").style(.caption)\n        }\n    }\n\n    var settings: ViewContent {\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\") { try await self.refresh() }\n        }\n        Section(\"Token\") {\n            Text(\"Uses GITHUB_TOKEN, shared with PR Cockpit. A fine-grained token with read access to metadata and issues works; creating issues needs write access to issues.\").style(.caption)\n            if let problem { Text(problem).role(.warning).style(.caption) }\n        }\n    }\n}\n",
  "sourceDigest" : "61a8839ef08bd2d9b8615b90c4ae1d8a73c2ac3aa24416ffa98034f4af7d335f"
}