{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "network" : {
          "hosts" : [
            "api.linear.app"
          ]
        }
      },
      {
        "secret" : {
          "name" : "LINEAR_API_KEY"
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Linear",
    "id" : "io.navbytes.stackbar.plugins.linear",
    "minimumStackbar" : "0.11.0",
    "name" : "Linear",
    "summary" : "Your assigned Linear issues, branch names, 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" : "NwE8ylH7WhKn2PuWtWYGK56J54CvXZXCHyZiuHPD3k6uJe4Ky6ybDtdwC3anxvEuHVf2Kx0BZBSCM01bPqZkBA==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your Linear issues in the menu bar and the palette: what's assigned to\n/// you, grouped by state, with the branch name a keystroke away, and new\n/// issues filed without leaving what you're doing.\n///\n/// It talks to Linear's GraphQL API with a personal API key (Settings >\n/// Security & access > Personal API keys in Linear), kept in your Keychain\n/// as `LINEAR_API_KEY`.\n@main\nfinal class Linear: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.linear\",\n        name: \"Linear\",\n        version: \"1.0.0\",\n        summary: \"Your assigned Linear issues, branch names, search and new issues from the palette.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .minutes(5), .wake],\n        capabilities: [.network(\"api.linear.app\"), .secret(name: \"LINEAR_API_KEY\")],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Linear\"\n    )\n\n    struct Issue: Codable, Equatable, Identifiable {\n        struct State: Codable, Equatable { let name: String; let type: String }\n        struct Team: Codable, Equatable { let key: String }\n        let id: String\n        let identifier: String\n        let title: String\n        let url: String\n        let branchName: String\n        let priority: Double\n        let state: State\n        let team: Team\n    }\n\n    struct GraphQLReply<T: Decodable>: Decodable {\n        struct Failure: Decodable { let message: String }\n        let data: T?\n        let errors: [Failure]?\n    }\n\n    struct Team: Codable, Equatable, Identifiable {\n        let id: String\n        let key: String\n        let name: String\n    }\n\n    var assigned: [Issue] = []\n    var found: [Issue] = []\n    @Stored(\"teams\") var teams: [Team] = []\n    @Stored(\"defaultTeam\") var defaultTeam = \"\"\n    @Stored(\"menuBarCount\") var menuBarCount = \"In progress\"\n    var problem: String?\n    var loaded = false\n    var formError: String?\n    var created: String?\n\n    static let fields = \"id identifier title url branchName priority state { name type } team { key }\"\n\n    // MARK: API\n\n    func query<T: Decodable>(_ text: String, variables: [String: Any] = [:], as type: T.Type = T.self) async throws -> T {\n        guard let key = try await Stackbar.secret(\"LINEAR_API_KEY\"), !key.isEmpty else {\n            throw HostError(\"Add your Linear API key (LINEAR_API_KEY) to see your issues\")\n        }\n        let body = try JSONSerialization.data(withJSONObject: [\"query\": text, \"variables\": variables])\n        let response = try await Stackbar.http(HTTPRequest(url: \"https://api.linear.app/graphql\", method: \"POST\", headers: [\n            \"Authorization\": key, \"Content-Type\": \"application/json\",\n        ], body: body))\n        guard response.status != 401 else { throw HostError(\"Linear rejected the API key (401); set a new LINEAR_API_KEY\") }\n        guard (200..<300).contains(response.status) || response.status == 400 else { throw HostError(\"Linear answered \\(response.status)\") }\n        let reply = try JSONDecoder().decode(GraphQLReply<T>.self, from: response.body)\n        if let data = reply.data { return data }\n        throw HostError(reply.errors?.map(\\.message).joined(separator: \"; \") ?? \"Linear sent no data\")\n    }\n\n    func refresh() async throws {\n        struct Data: Decodable {\n            struct Viewer: Decodable { struct Issues: Decodable { let nodes: [Issue] }; let assignedIssues: Issues }\n            struct Teams: Decodable { let nodes: [Team] }\n            let viewer: Viewer\n            let teams: Teams\n        }\n        do {\n            let data = try await query(\"\"\"\n            query { viewer { assignedIssues(first: 100, orderBy: updatedAt, filter: { state: { type: { nin: [\"completed\", \"canceled\"] } } }) { nodes { \\(Self.fields) } } } \\\n            teams(first: 50) { nodes { id key name } } }\n            \"\"\", as: Data.self)\n            assigned = data.viewer.assignedIssues.nodes\n            teams = data.teams.nodes\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        loaded = true\n    }\n\n    func search(_ text: String) async {\n        let term = text.trimmingCharacters(in: .whitespaces)\n        guard term.count >= 2 else { found = []; return }\n        struct Data: Decodable { struct Issues: Decodable { let nodes: [Issue] }; let issues: Issues }\n        do {\n            let data = try await query(\"query($term: String!) { issues(first: 25, orderBy: updatedAt, filter: { title: { containsIgnoreCase: $term } }) { nodes { \\(Self.fields) } } }\", variables: [\"term\": term], as: Data.self)\n            found = data.issues.nodes\n        } catch {\n            found = []\n        }\n    }\n\n    // MARK: Grouping\n\n    /// started, unstarted, backlog, triage: the order Linear's own My Issues uses.\n    static let stateOrder = [\"started\", \"unstarted\", \"triage\", \"backlog\"]\n\n    var groups: [(state: String, issues: [Issue])] {\n        let byState = Dictionary(grouping: assigned) { $0.state.name }\n        return byState.map { (state: $0.key, issues: $0.value.sorted { (Self.rank($0.priority), $0.identifier) < (Self.rank($1.priority), $1.identifier) }) }\n            .sorted { lhs, rhs in\n                let left = Self.stateOrder.firstIndex(of: lhs.issues[0].state.type) ?? 9\n                let right = Self.stateOrder.firstIndex(of: rhs.issues[0].state.type) ?? 9\n                return (left, lhs.state) < (right, rhs.state)\n            }\n    }\n\n    /// Linear's priority 0 is \"none\"; 1 (urgent) sorts first.\n    static func rank(_ priority: Double) -> Double { priority == 0 ? 5 : priority }\n\n    static func priorityName(_ priority: Double) -> String? {\n        switch Int(priority) {\n        case 1: return \"Urgent\"\n        case 2: return \"High\"\n        case 3: return \"Medium\"\n        case 4: return \"Low\"\n        default: return nil\n        }\n    }\n\n    var inProgress: [Issue] { assigned.filter { $0.state.type == \"started\" } }\n\n    func row(_ issue: Issue) -> Row {\n        var row = Row(issue.title, subtitle: \"\\(issue.identifier) · \\(issue.state.name)\", symbol: issue.state.type == \"started\" ? \"circle.lefthalf.filled\" : \"circle\", key: issue.id)\n        if let priority = Self.priorityName(issue.priority) { row = row.badge(priority, role: issue.priority == 1 ? .critical : issue.priority == 2 ? .warning : .secondary) }\n        return row\n            .action(\"Open\") { Stackbar.open(issue.url) }\n            .action(\"Copy Branch Name\", shortcut: \"cmd+shift+b\") { Stackbar.copy(issue.branchName) }\n            .action(\"Copy Link\", shortcut: \"cmd+c\") { Stackbar.copy(issue.url) }\n            .action(\"Copy ID\", shortcut: \"cmd+shift+c\") { Stackbar.copy(issue.identifier) }\n    }\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        let count = menuBarCount == \"All assigned\" ? assigned.count : inProgress.count\n        if problem != nil, loaded {\n            Label(\"\", symbol: \"checklist\").role(.secondary)\n        } else {\n            Label(count == 0 ? \"\" : \"\\(count)\", symbol: \"checklist\").role(count == 0 ? .secondary : .primary)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Can't reach Linear\", symbol: \"exclamationmark.triangle\", message: problem)\n        } else if !loaded {\n            ContentUnavailableView(\"Loading…\", symbol: \"checklist\")\n        } else if assigned.isEmpty {\n            ContentUnavailableView(\"Nothing assigned to you\", symbol: \"checkmark.circle\")\n        } else {\n            ForEach(groups) { group in\n                Section(group.state) {\n                    ForEach(group.issues) { issue in self.row(issue) }\n                }\n            }\n        }\n    }\n\n    var settings: ViewContent {\n        Section(\"Menu bar\") {\n            Picker(\"Count\", options: [\"In progress\", \"All assigned\"], selection: menuBarCount) { self.menuBarCount = $0 }\n        }\n        Section(\"New issues\") {\n            if teams.isEmpty {\n                Text(\"Teams appear once your API key works.\").style(.caption)\n            } else {\n                Picker(\"Default team\", options: teams.map(\\.name), selection: teams.first { $0.id == defaultTeam }?.name ?? teams[0].name) { name in\n                    self.defaultTeam = self.teams.first { $0.name == name }?.id ?? \"\"\n                }\n            }\n        }\n        Section(\"API key\") {\n            Text(\"Create a personal API key in Linear (Settings > Security & access). Stackbar asks for it the first time and keeps it in your Keychain as LINEAR_API_KEY.\").style(.caption)\n            if let problem { Text(problem).role(.warning).style(.caption) }\n        }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"my-issues\", title: \"My Linear Issues\", symbol: \"checklist\", keywords: [\"linear\", \"issue\", \"assigned\", \"ticket\"])\n            .view(search: { _ in try await self.refresh() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Can't reach Linear\", 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 Linear Issues\", symbol: \"magnifyingglass\", keywords: [\"linear\", \"issue\", \"find\"])\n            .view(search: { invocation in await self.search(invocation.text) }) { invocation in\n                if invocation.text.count < 2 {\n                    ContentUnavailableView(\"Type to search issue titles\", symbol: \"magnifyingglass\")\n                } else {\n                    List(self.found) { issue in self.row(issue) }\n                }\n            }\n        Command(\"create\", title: \"Create Linear Issue\", symbol: \"plus.circle\", keywords: [\"linear\", \"issue\", \"new\", \"ticket\"])\n            .stackbarOnly()\n            .view { _ in self.form }\n    }\n\n    @ViewBuilder var form: ViewContent {\n        if teams.isEmpty {\n            ContentUnavailableView(\"No teams yet\", symbol: \"person.3\", message: problem ?? \"Add your Linear API key, then try again.\")\n        } else {\n            Form(submit: \"Create Issue\", key: \"issue\") {\n                FormField.picker(\"Team\", name: \"team\", options: teams.map(\\.name), selection: teams.first { $0.id == defaultTeam }?.name ?? teams[0].name)\n                FormField.text(\"Title\", name: \"title\", error: formError)\n                FormField.multiline(\"Description\", name: \"description\", placeholder: \"Markdown\")\n                FormField.picker(\"Priority\", name: \"priority\", options: [\"No priority\", \"Urgent\", \"High\", \"Medium\", \"Low\"], selection: \"No priority\")\n                FormField.toggle(\"Assign to me\", name: \"mine\", isOn: true)\n            } onSubmit: { values in\n                try await self.create(values)\n            }\n            if let created { Text(created).style(.caption) }\n        }\n    }\n\n    func create(_ 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 team = teams.first(where: { $0.name == values.string(\"team\") }) ?? teams.first else { formError = \"Pick a team\"; return }\n        formError = nil\n        var input: [String: Any] = [\"teamId\": team.id, \"title\": title]\n        if let description = values.string(\"description\"), !description.isEmpty { input[\"description\"] = description }\n        if let index = [\"No priority\", \"Urgent\", \"High\", \"Medium\", \"Low\"].firstIndex(of: values.string(\"priority\") ?? \"\") { input[\"priority\"] = index }\n        struct Viewer: Decodable { struct Me: Decodable { let id: String }; let viewer: Me }\n        if case .bool(true)? = values[\"mine\"] {\n            input[\"assigneeId\"] = try await query(\"query { viewer { id } }\", as: Viewer.self).viewer.id\n        }\n        struct Created: Decodable {\n            struct Payload: Decodable { struct Made: Decodable { let identifier: String; let url: String }; let success: Bool; let issue: Made? }\n            let issueCreate: Payload\n        }\n        do {\n            let reply = try await query(\"mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier url } } }\", variables: [\"input\": input], as: Created.self)\n            guard let issue = reply.issueCreate.issue else { formError = \"Linear didn't create it\"; return }\n            created = \"Created \\(issue.identifier)\"\n            Stackbar.copy(issue.url)\n            try await refresh()\n        } catch {\n            formError = \"\\(error)\"\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"assigned\", description: \"Open Linear issues assigned to the user, with identifier, state, priority, title and branch name\") { _ in\n            if !self.loaded { try await self.refresh() }\n            if let problem = self.problem { return problem }\n            return self.assigned.map { \"\\($0.identifier)\\t\\($0.state.name)\\t\\(Self.priorityName($0.priority) ?? \"-\")\\t\\($0.title)\\t\\($0.branchName)\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "209ed58750767e1fdd7ebd9ebb1ad308cef839138433047bbff5b7efea193eeb"
}