{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "network" : {
          "hosts" : [
            "*.atlassian.net"
          ]
        }
      },
      {
        "secret" : {
          "name" : "JIRA_API_TOKEN"
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Jira",
    "id" : "io.navbytes.stackbar.plugins.jira",
    "minimumStackbar" : "0.11.0",
    "name" : "Jira",
    "summary" : "Your assigned Jira Cloud issues, search and new issues from the palette.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

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

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "eyNDKysfnokpHrTJRqfjd/hq3zNa6XK4AVi2DQkfYjr1kNbiHwH4Z3KYwy1ETez6CPqNzoFWfOwxnzLJIZBjAw==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your Jira Cloud issues in the menu bar and the palette: what's assigned\n/// to you, search by text or key, and new issues without opening Jira.\n///\n/// It signs in to your site (`yourteam.atlassian.net`) with your email and\n/// an API token from id.atlassian.com, kept in your Keychain as\n/// `JIRA_API_TOKEN`. Requests go only to `*.atlassian.net`.\n@main\nfinal class Jira: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.jira\",\n        name: \"Jira\",\n        version: \"1.0.0\",\n        summary: \"Your assigned Jira Cloud issues, search and new issues from the palette.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .minutes(5), .wake],\n        capabilities: [.network(\"*.atlassian.net\"), .secret(name: \"JIRA_API_TOKEN\")],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Jira\"\n    )\n\n    struct Issue: Decodable, Equatable, Identifiable {\n        struct Fields: Decodable, Equatable {\n            struct Named: Decodable, Equatable { let name: String }\n            struct Status: Decodable, Equatable {\n                struct Category: Decodable, Equatable { let key: String }\n                let name: String\n                let statusCategory: Category?\n            }\n            struct Project: Decodable, Equatable { let key: String; let name: String }\n            let summary: String\n            let status: Status?\n            let priority: Named?\n            let issuetype: Named?\n            let project: Project?\n        }\n        let id: String\n        let key: String\n        let fields: Fields\n        /// new, indeterminate (in progress) or done.\n        var category: String { fields.status?.statusCategory?.key ?? \"new\" }\n    }\n\n    struct Project: Codable, Equatable, Identifiable {\n        let id: String\n        let key: String\n        let name: String\n    }\n\n    @Stored(\"site\") var site = \"\"\n    @Stored(\"email\") var email = \"\"\n    @Stored(\"projects\") var projects: [Project] = []\n    @Stored(\"defaultProject\") var defaultProject = \"\"\n    @Stored(\"jql\") var jql = Jira.defaultJQL\n    var assigned: [Issue] = []\n    var found: [Issue] = []\n    var problem: String?\n    var loaded = false\n    var formError: String?\n    var created: String?\n\n    static let defaultJQL = \"assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC\"\n    static let fields = [\"summary\", \"status\", \"priority\", \"issuetype\", \"project\"]\n\n    /// `yourteam`, `yourteam.atlassian.net` or a pasted URL, as the host.\n    static func host(of site: String) -> String? {\n        var text = site.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n        if let range = text.range(of: \"://\") { text = String(text[range.upperBound...]) }\n        text = String(text.prefix { $0 != \"/\" })\n        guard !text.isEmpty, text.allSatisfy({ $0.isLetter || $0.isNumber || $0 == \"-\" || $0 == \".\" }) else { return nil }\n        return text.hasSuffix(\".atlassian.net\") ? text : text + \".atlassian.net\"\n    }\n\n    func url(for issue: Issue) -> String {\n        \"https://\\(Self.host(of: site) ?? \"\")/browse/\\(issue.key)\"\n    }\n\n    // MARK: API\n\n    func api(_ path: String, method: String = \"GET\", body: Any? = nil) async throws -> Data {\n        guard let host = Self.host(of: site), !email.isEmpty else {\n            throw HostError(\"Set your Jira site and email in Settings\")\n        }\n        guard let token = try await Stackbar.secret(\"JIRA_API_TOKEN\"), !token.isEmpty else {\n            throw HostError(\"Add your Jira API token (JIRA_API_TOKEN) from id.atlassian.com\")\n        }\n        let basic = Data(\"\\(email):\\(token)\".utf8).base64EncodedString()\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://\\(host)\\(path)\", method: method, headers: [\n            \"Authorization\": \"Basic \\(basic)\", \"Accept\": \"application/json\", \"Content-Type\": \"application/json\",\n        ], body: try body.map { try JSONSerialization.data(withJSONObject: $0) }))\n        switch response.status {\n        case 200..<300: return response.body\n        case 401: throw HostError(\"Jira rejected the email or API token (401)\")\n        case 403: throw HostError(\"Jira says this account can't do that (403)\")\n        case 404: throw HostError(\"Jira didn't find that on \\(host) (404)\")\n        default:\n            struct Failure: Decodable { let errorMessages: [String]?; let errors: [String: String]? }\n            let failure = try? JSONDecoder().decode(Failure.self, from: response.body)\n            let messages = (failure?.errorMessages ?? []) + (failure?.errors.map { $0.map { \"\\($0.key): \\($0.value)\" } } ?? [])\n            throw HostError(\"Jira answered \\(response.status)\" + (messages.isEmpty ? \"\" : \": \" + messages.joined(separator: \"; \")))\n        }\n    }\n\n    func search(jql: String, limit: Int = 50) async throws -> [Issue] {\n        struct Reply: Decodable { let issues: [Issue] }\n        let data = try await api(\"/rest/api/3/search/jql\", method: \"POST\", body: [\"jql\": jql, \"fields\": Self.fields, \"maxResults\": limit] as [String: Any])\n        return try JSONDecoder().decode(Reply.self, from: data).issues\n    }\n\n    func refresh() async throws {\n        guard Self.host(of: site) != nil, !email.isEmpty else {\n            problem = \"Set your Jira site and email in Settings\"\n            loaded = true\n            return\n        }\n        do {\n            assigned = try await search(jql: jql)\n            if projects.isEmpty { try await loadProjects() }\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        loaded = true\n    }\n\n    func loadProjects() async throws {\n        struct Reply: Decodable { let values: [Project] }\n        let data = try await api(\"/rest/api/3/project/search?maxResults=50&orderBy=-lastIssueUpdatedTime\")\n        projects = try JSONDecoder().decode(Reply.self, from: data).values\n    }\n\n    /// A key like `ABC-123` is looked up directly; anything else is a text search.\n    func find(_ text: String) async {\n        let term = text.trimmingCharacters(in: .whitespaces)\n        guard term.count >= 2 else { found = []; return }\n        let escaped = term.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\").replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n        let isKey = term.range(of: #\"^[A-Za-z][A-Za-z0-9_]+-\\d+$\"#, options: .regularExpression) != nil\n        let query = isKey ? \"key = \\\"\\(escaped.uppercased())\\\"\" : \"text ~ \\\"\\(escaped)\\\" ORDER BY updated DESC\"\n        found = (try? await search(jql: query, limit: 25)) ?? []\n    }\n\n    // MARK: Rows\n\n    func row(_ issue: Issue) -> Row {\n        var row = Row(issue.fields.summary, subtitle: \"\\(issue.key) · \\(issue.fields.status?.name ?? \"\")\", symbol: issue.category == \"indeterminate\" ? \"circle.lefthalf.filled\" : issue.category == \"done\" ? \"checkmark.circle\" : \"circle\", key: issue.id)\n        if let type = issue.fields.issuetype?.name { row = row.badge(type) }\n        if let priority = issue.fields.priority?.name, [\"Highest\", \"High\", \"Blocker\", \"Critical\"].contains(priority) {\n            row = row.badge(priority, role: .warning)\n        }\n        let link = url(for: issue)\n        return row\n            .action(\"Open\") { Stackbar.open(link) }\n            .action(\"Copy Link\", shortcut: \"cmd+c\") { Stackbar.copy(link) }\n            .action(\"Copy Key\", shortcut: \"cmd+shift+c\") { Stackbar.copy(issue.key) }\n            .action(\"Copy Branch Name\", shortcut: \"cmd+shift+b\") { Stackbar.copy(Self.branchName(issue)) }\n    }\n\n    /// `ABC-123-fix-login-redirect`, which Jira's development panel links.\n    static func branchName(_ issue: Issue) -> String {\n        let words = issue.fields.summary.lowercased().split { !($0.isLetter || $0.isNumber) }.prefix(6)\n        return ([issue.key] + words.map(String.init)).joined(separator: \"-\")\n    }\n\n    // MARK: Surfaces\n\n    var inProgress: [Issue] { assigned.filter { $0.category == \"indeterminate\" } }\n\n    var menuBar: ViewContent {\n        if problem != nil, loaded {\n            Label(\"\", symbol: \"square.stack.3d.up\").role(.secondary)\n        } else {\n            Label(inProgress.isEmpty ? \"\" : \"\\(inProgress.count)\", symbol: \"square.stack.3d.up\").role(inProgress.isEmpty ? .secondary : .primary)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't reach Jira\", symbol: \"exclamationmark.triangle\", message: problem)\n        } else if !loaded {\n            ContentUnavailableView(\"Loading…\", symbol: \"square.stack.3d.up\")\n        } else if assigned.isEmpty {\n            ContentUnavailableView(\"Nothing assigned to you\", symbol: \"checkmark.circle\")\n        } else {\n            let inProgress = assigned.filter { $0.category == \"indeterminate\" }\n            let toDo = assigned.filter { $0.category != \"indeterminate\" }\n            if !inProgress.isEmpty {\n                Section(\"In progress\") { ForEach(inProgress) { issue in self.row(issue) } }\n            }\n            if !toDo.isEmpty {\n                Section(\"To do\") { ForEach(toDo) { issue in self.row(issue) } }\n            }\n        }\n    }\n\n    var settings: ViewContent {\n        Section(\"Account\") {\n            TextField(\"yourteam.atlassian.net\", text: site, key: \"site\") { value in\n                self.site = Self.host(of: value) ?? value\n                self.projects = []\n                try await self.refresh()\n            }\n            TextField(\"you@example.com\", text: email, key: \"email\") { value in\n                self.email = value.trimmingCharacters(in: .whitespaces)\n                try await self.refresh()\n            }\n            Text(\"Create an API token at id.atlassian.com > Security > API tokens. Stackbar asks for it the first time and keeps it in your Keychain as JIRA_API_TOKEN.\").style(.caption)\n            if let problem { Text(problem).role(.warning).style(.caption) }\n        }\n        Section(\"Issues\") {\n            TextField(Self.defaultJQL, text: jql, key: \"jql\") { value in\n                self.jql = value.trimmingCharacters(in: .whitespaces).isEmpty ? Self.defaultJQL : value\n                try await self.refresh()\n            }\n            Text(\"The JQL for the menu bar and panel. Leave it empty for open issues assigned to you.\").style(.caption)\n            if !projects.isEmpty {\n                Picker(\"Default project for new issues\", options: projects.map(\\.name), selection: projects.first { $0.key == defaultProject }?.name ?? projects[0].name) { name in\n                    self.defaultProject = self.projects.first { $0.name == name }?.key ?? \"\"\n                }\n            }\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"my-issues\", title: \"My Jira Issues\", symbol: \"square.stack.3d.up\", keywords: [\"jira\", \"issue\", \"ticket\", \"assigned\"])\n            .view(search: { _ in try await self.refresh() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't reach Jira\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    List(self.assigned, searchable: true) { issue in self.row(issue) }\n                }\n            }\n        Command(\"search\", title: \"Search Jira\", subtitle: \"Text, or a key like ABC-123\", symbol: \"magnifyingglass\", keywords: [\"jira\", \"issue\", \"find\", \"ticket\"])\n            .view(search: { invocation in await self.find(invocation.text) }) { invocation in\n                if invocation.text.count < 2 {\n                    ContentUnavailableView(\"Type to search, or paste a key\", symbol: \"magnifyingglass\")\n                } else {\n                    List(self.found) { issue in self.row(issue) }\n                }\n            }\n        Command(\"create\", title: \"Create Jira Issue\", symbol: \"plus.circle\", keywords: [\"jira\", \"issue\", \"new\", \"ticket\", \"bug\"])\n            .stackbarOnly()\n            .view { _ in self.form }\n    }\n\n    @ViewBuilder var form: ViewContent {\n        if projects.isEmpty {\n            ContentUnavailableView(\"No projects yet\", symbol: \"square.stack.3d.up\", message: problem ?? \"Set up your Jira account in Settings, then try again.\")\n        } else {\n            Form(submit: \"Create Issue\", key: \"issue\") {\n                FormField.picker(\"Project\", name: \"project\", options: projects.map(\\.name), selection: projects.first { $0.key == defaultProject }?.name ?? projects[0].name)\n                FormField.picker(\"Type\", name: \"type\", options: [\"Task\", \"Bug\", \"Story\"], selection: \"Task\")\n                FormField.text(\"Summary\", name: \"summary\", error: formError)\n                FormField.multiline(\"Description\", name: \"description\")\n            } onSubmit: { values in\n                try await self.create(values)\n            }\n            if let created { Text(created).style(.caption) }\n        }\n    }\n\n    /// Plain paragraphs in Atlassian Document Format.\n    static func document(_ text: String) -> [String: Any] {\n        let paragraphs = text.components(separatedBy: \"\\n\\n\").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }\n        return [\"type\": \"doc\", \"version\": 1, \"content\": paragraphs.map { [\"type\": \"paragraph\", \"content\": [[\"type\": \"text\", \"text\": $0]]] }]\n    }\n\n    func create(_ values: [String: EventValue]) async throws {\n        let summary = (values.string(\"summary\") ?? \"\").trimmingCharacters(in: .whitespaces)\n        guard !summary.isEmpty else { formError = \"Give it a summary\"; return }\n        guard let project = projects.first(where: { $0.name == values.string(\"project\") }) ?? projects.first else { formError = \"Pick a project\"; return }\n        formError = nil\n        var fields: [String: Any] = [\"project\": [\"key\": project.key], \"summary\": summary, \"issuetype\": [\"name\": values.string(\"type\") ?? \"Task\"]]\n        if let description = values.string(\"description\"), !description.isEmpty { fields[\"description\"] = Self.document(description) }\n        do {\n            struct Created: Decodable { let key: String }\n            let created = try JSONDecoder().decode(Created.self, from: try await api(\"/rest/api/3/issue\", method: \"POST\", body: [\"fields\": fields]))\n            self.created = \"Created \\(created.key)\"\n            Stackbar.open(\"https://\\(Self.host(of: site) ?? \"\")/browse/\\(created.key)\")\n            try await refresh()\n        } catch {\n            formError = \"\\(error)\"\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"assigned\", description: \"Open Jira issues assigned to the user, with key, status, type and summary\") { _ in\n            if !self.loaded { try await self.refresh() }\n            if let problem = self.problem { return problem }\n            return self.assigned.map { \"\\($0.key)\\t\\($0.fields.status?.name ?? \"\")\\t\\($0.fields.issuetype?.name ?? \"\")\\t\\($0.fields.summary)\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "33128092f2b98a8cc92d927ab49f2388abfd46c43fa146de56a1bc210b1912a3"
}