# =jira-cloud — Atlassian Cloud (official Remote MCP, mcp.atlassian.com/v1/mcp).
#
# Written against the REAL wire shapes returned by the official Atlassian
# Remote MCP (verified live 2026-06-03), which differ from generic Jira MCPs:
#
#   searchJiraIssuesUsingJql / getJiraIssue both return:
#     { "issues": { "totalCount": N, "nodes": [ <issue>, ... ], "pageInfo": {...} } }
#   i.e. the issue array is at `.issues.nodes` (NOT `.issues`). Each node's
#   `.self` is an api.atlassian.com proxy URL and is NOT browsable. A `.webUrl`
#   IS sometimes present but is NOT reliable on the Remote MCP (production
#   2026-06-05 returned `null`/missing), so we ALWAYS derive the browse URL
#   from getAccessibleAtlassianResources()[0].url + "/browse/" + key. The
#   resources call is also what the dispatcher uses for cloudId resolution
#   (cached per user), so this is essentially free.
#
#   getVisibleJiraProjects returns { "values": [ {key,name,...}, ... ] }.
#
# cloudId: required on every Jira call, but NOT set here — the bolt-api
#   dispatcher auto-resolves it once per user (getAccessibleAtlassianResources)
#   and injects it, because the MCP id below (`mcp-atlassian-com`) is
#   recognized by is_atlassian_cloud_mcp (src/routes/agent.rs).
#
# Unbounded JQL is rejected by this server ("add a search restriction"), so
# every scope's JQL carries a restriction (assignee / text / created).
schema_version: 1
id: jira-cloud
chip: =jira-cloud
title: Jira Work (Cloud)
icon: jira
description: Search Jira issues, browse sprints, and look up tickets by key (Atlassian Cloud).
placeholder_examples:
  - "ENG-123"
  - "printer"
  - "navigation light"
  - "anything in a summary or description"
emits: Issue
# Token lives under MCP id `mcp-atlassian-com` (where OAuth was completed), so
# the OAuth gate must check that provider — see jira_cloud_user_obo in
# auth-profiles.yaml. Using the generic jira_user_obo (provider atlassian-jira)
# yields a false needs_oauth even when the official MCP is connected.
auth_profile_ref: jira_cloud_user_obo

requires:
  - mcp: mcp-atlassian-com
    version: ">=1"
    tools:
      - searchJiraIssuesUsingJql
      - getJiraIssue
      - getVisibleJiraProjects
      - editJiraIssue
      - atlassianUserInfo
    scopes:
      - read:jira-work
      - write:jira-work

# Launcher trigger semantics. confirm = the right default for an MCP/network
# backend (don't hammer Atlassian on every keystroke). The empty default scope
# (your open issues) always auto-loads; an exact issue key auto-fires; free-text
# search runs on Enter. Switch mode to `auto` if you want live keystroke search.
dispatch:
  mode: confirm
  confirm_label: 'Search Jira for "{{query}}"'
  confirm_hint: Press Enter to search all of Jira
  auto_when:
    # Case-insensitive so `sl-1` auto-fires just like `SL-1` (frontend tests
    # this with no /i flag, so the insensitivity is baked into the class).
    - regex: '^[A-Za-z][A-Za-z0-9]+-\d+$'
      reason: Exact issue-key lookup is unambiguous and cheap.

# Bare detection: when the user types a Jira-key-shaped token in the launcher
# WITHOUT the =jira-cloud chip (e.g. `SL-1` / `sl-1`), the launcher recognizes
# it and proposes a row that hands off into =jira-cloud with the key pre-filled
# (Enter → direct ticket lookup). `network` kind = only surfaced when the
# Atlassian MCP is connected. Served on GET /v1/agent/utilities; the launcher's
# runtime.utility responder claims the token.
bare:
  kind: network
  chip_key: jira-cloud
  patterns:
    - regex: '^[A-Za-z][A-Za-z0-9]{1,9}-\d+$'
      flags: ''
      priority: 20

scopes:
  # DEFAULT: your open issues, most-recently-updated first, top ~{{limit}} (~20).
  - id: my
    label: My open issues
    default: true
    flow:
      # Resolve the user's site URL ONCE per flow. getAccessibleAtlassianResources
      # returns [{ id, url, scopes, avatarUrl }, ...] where `url` is the actual
      # browsable site (e.g. https://your-site.atlassian.net). The bolt-api
      # dispatcher already calls this tool (cached) for cloudId injection, so
      # the second call is served from the MCP server's cache too; cost ~free.
      # We thread the result through `out: site` and read it from the shared
      # transform via $site.
      - call: &resources_call
          kind: mcp
          mcp: mcp-atlassian-com
          tool: getAccessibleAtlassianResources
        out: site
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: searchJiraIssuesUsingJql
          args:
            jql: 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'
            maxResults: '{{limit}}'
            # Anchored field list (project + issuetype power the detail card),
            # aliased on every other issue call below.
            fields: &issue_fields
              - summary
              - status
              - priority
              - issuetype
              - assignee
              - reporter
              - creator
              - created
              - updated
              - duedate
              - resolution
              - labels
              - components
              - fixVersions
              - votes
              - watches
              - parent
              - project
              - description
              - subtasks
              # `comment` returns { comments: [...], total: N }. The latest
              # comment lights up the entity_preview "Latest activity" line so
              # the user gets context without leaving the launcher. Zero
              # extra MCP calls — same getJiraIssue/searchJiraIssuesUsingJql
              # round trip just carries the field.
              - comment
        out: r
      # The issue array lives at `.issues.nodes`. We tolerate the legacy flat
      # `.issues` array too. The browse URL is built from the
      # getAccessibleAtlassianResources site URL + "/browse/" + key — `.webUrl`
      # on the node is unreliable on the Remote MCP.
      # Normalize whatever envelope the dispatcher/MCP produces
      # ({issues:{nodes}}, flat {issues:[...]}, or bare array — `?` suppresses
      # index-on-array errors) into a flat, render-ready row. Flat top-level
      # keys (statusName, assigneeName, …) are what the entity_preview card
      # binds to ({{row.x}}); the nested `fields` object backs the list
      # presentation paths (fields.summary, …). Optional values default to "—".
      - transform: &issues_to_rows |
          # Flatten Atlassian Document Format → plain text. Comment bodies
          # come back as ADF JSON ({type:doc, content:[{type:paragraph,
          # content:[{type:text, text:"..."}]}, ...]}); we depth-first
          # extract every text node and join with spaces. Safe on strings
          # (pass through), null/missing (→ ""), and arbitrarily-nested
          # marks (the recursion catches them).
          def adfText:
            if type == "string" then .
            elif type == "object" then
              (if .type == "text" then (.text // "")
               elif (.content | type) == "array" then (.content | map(adfText) | join(" "))
               else "" end)
            elif type == "array" then (map(adfText) | join(" "))
            else "" end;
          ((.r.structured_content?.data? // .r.structured_content? // .r) // {}) as $p
          | ((.site.structured_content?.data? // .site.structured_content? // .site) // {}) as $sp
          | ( ($sp | if type == "array" then . else empty end)
              // ($sp.resources?)
              // ($sp.values?)
              // [] ) as $res
          | (($res[0] // {}) | (.url // "")) as $siteUrl
          | ( ($p.issues?.nodes?)
              // (($p.issues?) | if type == "array" then . else empty end)
              // ($p.nodes?)
              // ($p | if type == "array" then . else empty end)
              // ($p | if (type == "object" and (has("key") or has("fields"))) then [.] else empty end)
              // [] ) as $nodes
          | ($nodes | map(
                . as $iss
                | ($iss.fields // {}) as $f
                | ($f.assignee // {}) as $a
                | ($f.reporter // {}) as $rp
                | ($f.creator // {}) as $cr
                | ($f.status // {}) as $st
                | ($st.statusCategory // {}) as $sc
                | ($st.name // "—") as $stName
                | ($sc.name // "—") as $scName
                | ($f.priority // {}) as $pr
                | ($f.issuetype // {}) as $it
                | ($f.project // {}) as $pj
                | ($f.parent // {}) as $par
                | ($f.resolution // {}) as $res
                # Latest comment extraction — temporarily reverted 2026-06-06.
                # 1104c7d5 added jq logic to extract latest comment, but some
                # response variant from Atlassian Remote MCP makes a downstream
                # map/last/iterate see null and crash with "cannot use null as
                # iterable", breaking the entire =jira-cloud flow. Setting
                # placeholder values until we can re-add with bulletproof
                # guards (likely wrap the whole block in `try`/`catch` so a
                # single ticket's malformed comment field can't take the
                # whole row down).
                | "" as $lcmtText
                | "" as $lcmtAuthor
                | "" as $lcmtAt
                | 0 as $cmtCount
                | ($a.avatarUrls // {}) as $av
                | ($f.votes // {}) as $vt
                | ($f.watches // {}) as $wt
                | (($f.labels // []) | join(", ")) as $labels
                | (($f.components // []) | map(.name) | join(", ")) as $comps
                | (($f.fixVersions // []) | map(.name) | join(", ")) as $fixv
                | ([ $iss.key, ($it.name // empty), ($st.name // empty),
                     ($pr.name // empty), ($a.displayName // "Unassigned") ]
                    | map(select(. != null and . != "")) | join("  ·  ")) as $subline
                | ($iss.key // "") as $key
                | (if ($siteUrl | length) > 0 and ($key | length) > 0
                     then ($siteUrl + "/browse/" + $key)
                     else ($iss.webUrl // "") end) as $browseUrl
                | {
                    key: $key,
                    _url: $browseUrl,
                    _siteUrl: $siteUrl,
                    summary: ($f.summary // "(no summary)"),
                    subline: $subline,
                    type: ($it.name // "—"),
                    typeIcon: ($it.iconUrl // ""),
                    statusName: $stName,
                    statusCategory: $scName,
                    # Card status string: the name alone when it equals its
                    # category (avoids "In Progress · In Progress"); name ·
                    # category when they differ (e.g. "Open · To Do").
                    statusDisplay: (if $stName == $scName then $stName else ($stName + " · " + $scName) end),
                    statusColor: ($sc.colorName // ""),
                    priorityName: ($pr.name // "—"),
                    priorityIcon: ($pr.iconUrl // ""),
                    assigneeName: ($a.displayName // "Unassigned"),
                    assigneeAvatar: ($a.avatarUrl // $av["48x48"] // ""),
                    assigneeEmail: ($a.emailAddress // "—"),
                    reporterName: ($rp.displayName // "—"),
                    creatorName: ($cr.displayName // "—"),
                    created: ($f.created // "—"),
                    updated: ($f.updated // "—"),
                    due: ($f.duedate // "—"),
                    resolutionName: ($res.name // "—"),
                    labels: (if $labels == "" then "—" else $labels end),
                    components: (if $comps == "" then "—" else $comps end),
                    fixVersions: (if $fixv == "" then "—" else $fixv end),
                    votes: ($vt.votes // 0),
                    watchers: ($wt.watchCount // 0),
                    projectName: ($pj.name // "—"),
                    projectKey: ($pj.key // ""),
                    parentKey: ($par.key // ""),
                    parentSummary: (($par.fields // {}).summary // ""),
                    description: ((if ($f.description | type) == "string" then $f.description else "" end)
                                  | if length > 280 then (.[0:277] + "…") else . end
                                  | if . == "" then "No description" else . end),
                    # Full, untruncated description for the right_widget Markdown
                    # body (renders as rich text, scrollable). ADF-aware: a string
                    # passes through (preserving any newlines/markdown the MCP
                    # sent); an ADF doc is flattened to text via adfText. Empty
                    # when the issue has no description → the body section is
                    # simply not rendered (no "No description" placeholder).
                    descriptionFull: (($f.description // "") | adfText),
                    # Latest-comment slot for the right_widget Activity row.
                    # Empty string when no comments — UI shows "— No comments yet"
                    # via the bind default. Body capped at 200 chars to keep
                    # the preview compact.
                    latestCommentBody: (if ($lcmtText | length) == 0 then ""
                                        elif ($lcmtText | length) > 200 then ($lcmtText[0:197] + "…")
                                        else $lcmtText end),
                    latestCommentAuthor: (if $lcmtAuthor == "" then "—" else $lcmtAuthor end),
                    latestCommentAt: $lcmtAt,
                    commentCount: $cmtCount,
                    fields: {
                      summary: ($f.summary // ""),
                      status: { name: ($st.name // "") },
                      priority: { name: ($pr.name // "") },
                      assignee: {
                        displayName: ($a.displayName // "Unassigned"),
                        avatarUrl: ($a.avatarUrl // $av["48x48"] // "")
                      },
                      reporter: { displayName: ($rp.displayName // "") },
                      updated: ($f.updated // "")
                    }
                  }
              ))
        out: enriched
      - emit: enriched

  # Every issue assigned to you, any status.
  - id: all
    label: All my issues
    flow:
      - call: *resources_call
        out: site
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: searchJiraIssuesUsingJql
          args:
            jql: 'assignee = currentUser() ORDER BY updated DESC'
            maxResults: '{{limit}}'
            fields: *issue_fields
        out: r
      - transform: *issues_to_rows
        out: enriched
      - emit: enriched

  # Your active-sprint issues.
  - id: sprint
    label: Active sprint
    flow:
      - call: *resources_call
        out: site
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: searchJiraIssuesUsingJql
          args:
            jql: 'sprint in openSprints() AND assignee = currentUser() ORDER BY updated DESC'
            maxResults: '{{limit}}'
            fields: *issue_fields
        out: r
      - transform: *issues_to_rows
        out: enriched
      - emit: enriched

  # Typing after the chip searches all of Jira.
  - id: search
    label: Search
    match:
      # `PROJ-123` / `proj-123` → jump straight to that ticket. Case-insensitive
      # (Jira keys are uppercase, so we upper() before the lookup). We resolve
      # via searchJiraIssuesUsingJql(`key = …`) rather than getJiraIssue: both
      # return the same issue, but the search tool yields the {issues:{nodes}}
      # envelope the shared transform already renders, whereas getJiraIssue's
      # single-issue shape did not surface a row.
      - regex: '^[A-Za-z][A-Za-z0-9]+-\d+$'
        bind:
          key: '{{match}}'
        flow:
          - call: *resources_call
            out: site
          - call:
              kind: mcp
              mcp: mcp-atlassian-com
              tool: searchJiraIssuesUsingJql
              args:
                jql: 'key = "{{key | upper}}"'
                maxResults: '{{limit}}'
                fields: *issue_fields
            out: r
          - transform: *issues_to_rows
            out: enriched
          - emit: enriched
      # Any other text → compile the query into JQL, then search.
      #
      # Query grammar (all declarative; any util can adapt this jq for its own
      # backend's filter syntax):
      #   • bare words            → search everywhere: text index (summary +
      #     description + comments) OR labels, plus the exact key when the word
      #     is key-shaped (so `SL-1`/`sl-1` resolves via search too). Enum
      #     fields (status/priority/type) + assignee can't be partial-matched in
      #     JQL without erroring, so those stay as explicit tokens below.
      #   • status:open           → status = "open"
      #   • assignee:me|none|name → assignee = currentUser() | is EMPTY | = "name"
      #   • project:SL label:x priority:High type:Bug
      #   • updated:-7d created:-30d (relative or YYYY-MM-DD; unquoted)
      #   • sort:updated|created|priority|due  → ORDER BY <field> DESC
      #   • a full JQL expression (contains =, ~, "in", "order by", currentUser())
      #     is passed through verbatim — power-user escape hatch.
      # The transform reads {{query}} (the flow exposes the context root as the
      # jq input), so its output `q.jql` flows into the call args below.
      - text: '{{query}}'
        flow:
          - call: *resources_call
            out: site
          - transform: |
              ((.query // "") | gsub("^\\s+";"") | gsub("\\s+$";"")) as $q
              | if ($q | test("(?i)( = | != | ~ | in |order by|currentuser\\()")) then { jql: $q }
                else
                  ([ $q | scan("([A-Za-z]+):(\"[^\"]+\"|[^ ]+)") ]) as $pairs
                  | (reduce $pairs[] as $p ({clauses:[], order:null};
                      ($p[0]|ascii_downcase) as $k | ($p[1]|sub("^\"";"")|sub("\"$";"")) as $v |
                      if   $k=="status"   then .clauses += ["status = \"\($v)\""]
                      elif $k=="assignee" then (if ($v|ascii_downcase)=="me" then .clauses += ["assignee = currentUser()"]
                                                elif ($v|ascii_downcase)=="none" then .clauses += ["assignee is EMPTY"]
                                                else .clauses += ["assignee = \"\($v)\""] end)
                      elif $k=="project"  then .clauses += ["project = \"\($v)\""]
                      elif $k=="label"    then .clauses += ["labels = \"\($v)\""]
                      elif $k=="priority" then .clauses += ["priority = \"\($v)\""]
                      elif $k=="type"     then .clauses += ["issuetype = \"\($v)\""]
                      elif ($k=="updated" or $k=="created")
                        then .clauses += ["\($k) >= " + (if ($v|test("^-?[0-9]")) then $v else "\"\($v)\"" end)]
                      elif $k=="sort"     then .order = "ORDER BY \($v|ascii_downcase) DESC"
                      else . end)) as $acc
                  | ($q | gsub("([A-Za-z]+):(\"[^\"]+\"|[^ ]+)";"") | gsub(" +";" ") | sub("^ +";"") | sub(" +$";"")) as $text
                  | (if ($text|length)>0 then
                       [ ( [ "text ~ \"\($text)*\"", "labels = \"\($text)\"" ]
                           + (if ($text | test("^[A-Za-z][A-Za-z0-9]+-[0-9]+$"))
                                then ["key = \"\($text|ascii_upcase)\""] else [] end)
                         | "(" + join(" OR ") + ")" ) ]
                     else [] end) as $tc
                  | (($tc + $acc.clauses) | join(" AND ")) as $where
                  # Always carry a restriction — this server rejects unbounded JQL.
                  | (if ($where|length)>0 then $where else "created >= \"1970-01-01\"" end) as $base
                  | { jql: ($base + (if $acc.order then " " + $acc.order else " ORDER BY updated DESC" end)) }
                end
            out: q
          - call:
              kind: mcp
              mcp: mcp-atlassian-com
              tool: searchJiraIssuesUsingJql
              args:
                jql: '{{q.jql}}'
                maxResults: '{{limit}}'
                fields: *issue_fields
            out: r
          - transform: *issues_to_rows
            out: enriched
          - emit: enriched

filters:
  - id: project
    kind: enum
    label: Project
    apply: pre
    maps_to: jql.project
    populate:
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: getVisibleJiraProjects
          args:
            action: view
        out: r
      - transform: |
          (.r.structured_content?.data? // .r.structured_content? // .r) // {}
        out: projects
      - emit: projects
        path: /values

  - id: status
    kind: enum
    label: Status
    apply: pre
    maps_to: jql.status
    static:
      - Open
      - In Progress
      - Done

actions:
  - id: open
    kind: url
    target: row
    label: Open in Jira
    icon: external_link
    # Each row carries the full browse URL (siteUrl + /browse/ + key), derived
    # in the shared transform from getAccessibleAtlassianResources.
    url_template: '{{row._url}}'

  - id: assign_me
    kind: tool
    target: row
    label: Assign to me
    confirm: 'Assign {{row.key}} to me?'
    # Jira Cloud needs the assignee's *Atlassian accountId* — NOT the Bolt
    # user id. Resolve the current user via atlassianUserInfo, then assign.
    flow:
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: atlassianUserInfo
        out: me
      - transform: |
          ((.me.structured_content?.data? // .me.structured_content? // .me) // {})
          | { accountId: (.account_id // .accountId // "") }
        out: u
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: editJiraIssue
          args:
            issueIdOrKey: '{{row.key}}'
            fields:
              assignee:
                accountId: '{{u.accountId}}'
        out: r
      - emit: r

presentation:
  widget: list
  # Flat keys (emitted by the transform) so list projection + card binds both
  # resolve. subline packs key · type · status · priority · assignee.
  title_field: summary
  subtitle_field: subline
  list_fields:
    - key
    - summary
    - statusName
    - priorityName
    - assigneeName
  # Client-side refine/highlight across every shown field — so once results are
  # in, typing narrows + highlights matches in key, summary, status, priority,
  # type, assignee, reporter, project, labels, and description.
  search_fields:
    - key
    - summary
    - statusName
    - statusCategory
    - priorityName
    - type
    - assigneeName
    - reporterName
    - projectName
    - labels
    - description
  sort_field: updated
  sort_order: desc
  row_key_field: key
  searchable: true
  filterable: true
  # Detail pane bound to the highlighted row. `kind: entity_preview` is the
  # wide pane (media + facts + sticky max-3+overflow footer); engines that
  # don't yet implement it fall back to the standard card. All bind values use
  # FLAT row keys ({{row.x}}) so interpolation resolves without dotted-path
  # support. Empty values already arrive as "—" from the transform.
  right_widget:
    kind: entity_preview
    mode: selected_row
    bind:
      title: '{{row.summary}}'
      image_url: '{{row.assigneeAvatar}}'
      fields:
        # Ticket key as the leading "ID" field. It no longer rides in a subtitle
        # above the divider (that key · status line was removed so the header is
        # just the summary + assignee avatar) — the key lives only here, below
        # the line.
        - { label: ID,            value: '{{row.key}}' }
        - { label: Status,        value: '{{row.statusDisplay}}' }
        - { label: Type,          value: '{{row.type}}' }
        - { label: Priority,      value: '{{row.priorityName}}' }
        - { label: Assignee,      value: '{{row.assigneeName}}' }
        - { label: Reporter,      value: '{{row.reporterName}}' }
        - { label: Project,       value: '{{row.projectName}}' }
        - { label: Components,    value: '{{row.components}}' }
        - { label: Labels,        value: '{{row.labels}}' }
        - { label: Due,           value: '{{row.due}}' }
        - { label: Created,       value: '{{row.created}}' }
        - { label: Updated,       value: '{{row.updated}}' }
        - { label: Watchers,      value: '{{row.watchers}}' }
        # Latest-comment field temporarily removed 2026-06-06 — paired with
        # the transform revert (see manifest body). Will be re-added with
        # bulletproof guards (try/catch around the jq path so a single
        # ticket's malformed comment field can't take the whole flow down).
      # Full description rendered as Markdown under the fact list (scrollable;
      # sanitized; links open externally). Replaces the old truncated
      # "Description" fact line — the card now shows the real description
      # instead of "No description"/a 280-char snippet.
      body: '{{row.descriptionFull}}'
      actions:
        - open
        - assign_me

enabled: true
provisioning_mode: public
departments: []
roles: []
groups: []
