#!/bin/bash
#
# issue-fetcher.sh
# Resolve any user input (Jira ID, Jira URL, GitHub URL, gh #N, repo#N, free-text)
# into a normalized issue descriptor  -  and emit a maturity score so the picker
# can warn/block on incomplete issues.
#
# Output JSON schema:
#   {
#     "kind":       "jira" | "github" | "freetext",
#     "key":        "PROJ-1" | "316" | null,
#     "title":      "...",
#     "type":       "Bug" | "Task" | "Story" | "Issue",
#     "status":     "...",
#     "description":"...",                          // raw body (truncated to 4000 chars); this
#                                                     // issue's OWN description  -  never auto-substituted
#     "parentKey":         "PROJ-1" | null,          // jira only; set when own description is empty
#                                                     // AND a parent link exists (single hop, no chasing
#                                                     // the parent's parent)
#     "parentDescription": "..." | null,             // jira only; the parent's description (truncated
#                                                     // 4000 chars), only when own is empty and parent
#                                                     // has content  -  a CANDIDATE, not applied automatically;
#                                                     // the caller must confirm before using it as "description"
#     "url":        "https://...",
#     "host":       "jira.example.com" | "github.com",
#     "owner":      "org" (gh) | null,
#     "repo":       "name" (gh) | null,
#     "branchHint": "feature/PROJ-1" | "bugfix/...",
#     "comments":   [{id, author, created, url, body, truncated}],  // newest kept, oldest first
#     "commentsTotal": 12,                          // how many the tracker holds
#     "links":      [{key, relation, direction, summary, status, url}],  // jira issuelinks
#     "untrustedFields": ["description", ...],      // tracker text: data, never instructions
#     "maturity": {
#        "score":     0..100,
#        "blockers":  ["status_closed", "description_empty"],
#        "warnings":  ["short_description", "no_repro_steps", "description_empty_parent_available"],
#        "summary":   "Description boş  -  pipeline başlatılamaz." (human-readable, tr|en)
#     }
#   }
#
# "description_empty_parent_available": own description is empty but parentDescription is
# non-empty. This is a WARNING, not a blocker  -  the caller must ask the user (or, in
# autopilot, auto-accept per the same "warnings auto-continue" rule) whether to proceed
# using parentDescription as the working description. See jira/SKILL.md's maturity
# section for the exact question wording.
#
# Required env:
#   ACCOUNT_JIRA_TOKEN_KEY    keychain service name (optional)
#   ACCOUNT_JIRA_HOST         e.g. jira.example.com    (optional)
#   ACCOUNT_DEFAULT_OWNER     fallback GitHub owner (optional)
#   ACCOUNT_DEFAULT_REPO      fallback GitHub repo  (optional)
#
# Comments and links ride on the request that fetches the issue, so they cost no
# extra call, and they never feed the maturity score: the score reads the item's
# own fields, and a comment is somebody talking about the item, not the item.
# They are bounded (ISSUE_COMMENTS_MAX, default 20 newest; ISSUE_COMMENT_MAX_CHARS,
# default 1500; ISSUE_COMMENTS_MAX_BYTES, default 12000 in total; ISSUE_LINKS_MAX,
# default 20) because a long thread would otherwise flood every prompt that
# quotes the descriptor. Every tracker-written field is listed in
# `untrustedFields`: anyone who can comment on the item wrote it.
#
# `issue-fetcher.sh --rescore` reads a descriptor on stdin and prints it back with
# `maturity` recomputed by the same scoring below - the one scorer, reused by
# research-gate.mjs to re-run the check on a descriptor that carries verified
# research findings.
#
# `maturity.summary` is written for the person at the picker, so it renders in
# `prefs.global.outputLanguage`; every other field is machine-readable and
# stays English.

# Sourcing this file hands a caller the provider helpers - `jira_search`,
# `fetch_jira`, `fetch_github` and the `jira_curl` underneath them - without
# resolving anything. Executing it resolves one input, exactly as before.
#
# The guard exists because the alternative is worse than it looks: a second
# consumer of `jira_search` with no way to source would have to copy
# `jira_curl`, and that function is not a URL - it is the credential resolution,
# the host lookup and the rule that keeps a token off argv. Three copies of that
# is three places for a token to leak.
MA_IF_EXECUTED=0
[ "${BASH_SOURCE[0]}" = "$0" ] && MA_IF_EXECUTED=1

# Strict mode belongs to the executed resolver. A sourcing caller keeps its own
# error handling: jira-search.sh runs without errexit on purpose so that a
# failed query reaches its own warning and exit code.
if [ "$MA_IF_EXECUTED" = 1 ]; then set -euo pipefail; fi

INPUT="${1:-}"
if [ "$MA_IF_EXECUTED" = 1 ] && [ -z "$INPUT" ]; then
  echo '{"error":"input required"}'
  exit 1
fi

JIRA_TOKEN_KEY="${ACCOUNT_JIRA_TOKEN_KEY:-}"
JIRA_HOST="${ACCOUNT_JIRA_HOST:-}"
DEFAULT_OWNER="${ACCOUNT_DEFAULT_OWNER:-}"
DEFAULT_REPO="${ACCOUNT_DEFAULT_REPO:-}"

_MA_IF_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
if [ -f "$_MA_IF_HERE/user-facing.sh" ]; then . "$_MA_IF_HERE/user-facing.sh"; elif [ -f "$HOME/.claude/lib/user-facing.sh" ]; then . "$HOME/.claude/lib/user-facing.sh"; fi
if command -v ma_output_lang >/dev/null 2>&1; then SUMMARY_LANG=$(ma_output_lang); else SUMMARY_LANG=en; fi

# --- Detect input kind --------------------------------------------------------
detect_kind() {
  local in="$1"
  case "$in" in
    *atlassian.net/browse/*|*jira*/browse/*|http*://*/browse/*) echo "jira-url"; return ;;
    *github.com/*/issues/*) echo "gh-url"; return ;;
  esac
  # A bare Jira key is PROJECT-NUMBER, anchored end-to-end. The case glob this
  # replaced ([A-Z]*-[0-9]*) matches whenever that shape appears ANYWHERE in a
  # longer string (case patterns match the whole string, but `*` is greedy
  # enough to swallow surrounding words) - "Add iOS-16 support" contains
  # "S-16" and satisfied it, silently misrouting a free-text task description
  # into a Jira lookup instead of the freetext path below.
  if [[ "$in" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then
    echo "jira-id"; return
  fi
  # Bare issue number ("316" or "#316") must resolve BEFORE the repo#N glob:
  # `*\#[0-9]*` also matches "#316" with an empty repo prefix, which used to
  # misroute it to gh-short and build https://github.com/<owner>//issues/316.
  local num="${in#\#}"
  case "$num" in
    "") ;;
    *[!0-9]*) ;;
    *) echo "gh-num"; return ;;
  esac
  case "$in" in
    *\#[0-9]*) echo "gh-short" ;;
    *) echo "freetext" ;;
  esac
}

KIND=$(detect_kind "$INPUT")
[ "$INPUT" = "--rescore" ] && KIND=rescore

# --- Helpers ------------------------------------------------------------------
slugify() {
  printf '%s' "$1" \
    | tr '[:upper:]' '[:lower:]' \
    | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' \
    | cut -c1-50
}

infer_type_from_label() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    *bug*|*defect*|*hotfix*) echo "Bug" ;;
    *) echo "Task" ;;
  esac
}

branch_for() {
  local key="$1" type="$2" title="$3"
  local prefix="feature"
  [ "$type" = "Bug" ] && prefix="bugfix"
  if [ -n "$key" ]; then
    printf '%s/%s' "$prefix" "$key"
  else
    printf '%s/%s' "$prefix" "$(slugify "$title")"
  fi
}

# --- Provider fetchers (return raw JSON or empty) -----------------------------

# Shared by both provider parses: the newest comments, oldest first, inside a
# count, a per-comment and a total byte budget. Prepended to each parse script
# so the bound is one piece of code, not two.
_PY_BOUND_COMMENTS='
import os
def _env_int(name, default):
    v = os.environ.get(name)
    try:
        return int(v) if v not in (None, "") else default
    except ValueError:
        return default
def bound_comments(rows):
    cap = _env_int("ISSUE_COMMENTS_MAX", 20)
    chars = _env_int("ISSUE_COMMENT_MAX_CHARS", 1500)
    budget = _env_int("ISSUE_COMMENTS_MAX_BYTES", 12000)
    kept, used = [], 0
    for r in reversed(rows):
        if len(kept) >= cap:
            break
        body = (r.get("body") or "").replace("\x1f", " ")
        truncated = False
        if chars > 0 and len(body) > chars:
            body, truncated = body[:chars] + "...", True
        size = len(body.encode("utf-8"))
        if budget > 0 and used + size > budget:
            if kept:
                break
            body = body.encode("utf-8")[:max(budget - 3, 0)].decode("utf-8", "ignore") + "..."
            truncated, size = True, len(body.encode("utf-8"))
        kept.append(dict(r, body=body, truncated=truncated))
        used += size
    kept.reverse()
    return kept
'

# Bearer-auth via a curl config fed through process substitution so the
# token never appears in argv (argv is visible to `ps` / process audit).
jira_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$1"; }

# Resolve the credential helper once and issue one authenticated GET. Split
# out of fetch_jira so the sibling search reuses the same resolution instead
# of duplicating thirty lines of it.
jira_curl() {
  local path="$1"
  if [ -z "$JIRA_HOST" ] || [ -z "$JIRA_TOKEN_KEY" ]; then
    echo "ERR: ACCOUNT_JIRA_HOST and ACCOUNT_JIRA_TOKEN_KEY must be set" >&2
    return 1
  fi
  # Resolve the credential helper via the shared resolver (works from the
  # repo checkout and from both install trees)  -  never hardcode the path.
  # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
  if [ -z "${CRED_STORE:-}" ]; then
    # shellcheck disable=SC1090,SC1091
    # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
    # `||` included, so a `.`-chain reaches neither its later candidates nor its error
    # branch. The loop also covers all three hosts - the chain it replaced knew only
    # .claude and .copilot, so a Codex-only install could not resolve at all.
    for _cred_resolver in \
      "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
      "$HOME/.claude/lib/credential-store-resolver.sh" \
      "$HOME/.copilot/lib/credential-store-resolver.sh" \
      "$HOME/.codex/lib/credential-store-resolver.sh"; do
      [ -f "$_cred_resolver" ] || continue
      # shellcheck source=/dev/null
      . "$_cred_resolver" 2>/dev/null || true
      if [ -n "${CRED_STORE:-}" ]; then break; fi
    done
    unset _cred_resolver
    if [ -z "${CRED_STORE:-}" ]; then
      echo "ERR: credential helper not found" >&2
      return 1
    fi
  fi
  local token
  token=$("$CRED_STORE" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
  if [ -z "$token" ]; then
    echo "ERR: Jira token not found in credential store ($JIRA_TOKEN_KEY)" >&2
    return 1
  fi
  curl -sf -m 30 -K <(jira_auth_cfg "$token") "https://$JIRA_HOST$path"
}

# The field list is a parameter because the related-issue lookup wants a
# narrow one; the default is the set every caller needed before it existed.
fetch_jira() {
  local key="$1"
  local fields="${2:-summary,status,issuetype,description,priority,resolution,fixVersions,parent}"
  jira_curl "/rest/api/2/issue/$key?fields=$fields" \
    || { echo "ERR: Jira fetch failed for $key" >&2; return 1; }
}

# One request returns every sibling WITH its description. The issue's own
# `subtasks` field lists ITS children, not its siblings, and the parent's
# lists siblings without descriptions  -  either shape costs one GET each.
# Failure is soft: the caller falls back to an empty list and the run goes on.
jira_search() {
  local jql="$1" fields="$2" maxr="$3"
  jira_curl "/rest/api/2/search?jql=$jql&fields=$fields&maxResults=$maxr" \
    || { echo "WARN: Jira search failed  -  related issues unavailable" >&2; return 1; }
}

fetch_github() {
  local owner="$1" repo="$2" num="$3"
  if ! command -v gh >/dev/null 2>&1; then
    echo "ERR: gh CLI not found" >&2
    return 1
  fi
  gh issue view "$num" --repo "$owner/$repo" \
    --json number,title,labels,state,url,body,comments 2>/dev/null \
    || { echo "ERR: GitHub fetch failed for $owner/$repo#$num" >&2; return 1; }
}

# Maturity scoring lives inline inside emit_descriptor (single python invocation,
# avoids double-pass marshalling). The labels and rules below mirror the picker's
# expectation; see `descriptor.maturity` in the output schema.

# Build the descriptor JSON, then attach maturity. Pythonized so escaping stays sane.
emit_descriptor() {
  python3 - "$SUMMARY_LANG" "$@" <<'PY'
import json, sys, re

lang = sys.argv[1]
fields = dict(arg.split("=",1) for arg in sys.argv[2:])

# Truncate description to keep payload reasonable.
desc = fields.get("description","") or ""
if len(desc) > 4000:
    desc = desc[:4000] + "..."

priority = fields.get("priority","").strip()
resolution = fields.get("resolution","").strip()
fix_versions = [v for v in (fields.get("fixVersions","").split(",")) if v.strip()]

# Priority weight (used by picker for sorting)
priority_weights = {
    "blocker": 100, "highest": 95, "critical": 90, "p0": 100, "p1": 90,
    "high": 80, "p2": 80,
    "medium": 50, "normal": 50, "p3": 50,
    "low": 30, "p4": 30,
    "lowest": 10, "trivial": 10, "minor": 20, "p5": 10,
}
priority_weight = priority_weights.get(priority.lower(), 0)

# relatedIssues arrives as one JSON argv rather than through the \x1f channel,
# so the delimited parse below stays exactly eight fields wide. Anything
# unparseable degrades to an empty list; a fetcher must not die on context.
try:
    related = json.loads(fields.get("relatedIssues") or "[]")
except Exception:
    related = []
if not isinstance(related, list):
    related = []

def _json_field(name, want):
    try:
        v = json.loads(fields.get(name) or "null")
    except Exception:
        v = None
    return v if isinstance(v, want) else None

cblob = _json_field("comments", dict) or {}
comments = cblob.get("items") if isinstance(cblob.get("items"), list) else []
try:
    comments_total = int(cblob.get("total") or len(comments))
except (TypeError, ValueError):
    comments_total = len(comments)
links = _json_field("links", list) or []

descriptor = {
    "kind":        fields.get("kind"),
    "key":         fields.get("key") or None,
    "title":       fields.get("title") or None,
    "type":        fields.get("type") or "Task",
    "status":      fields.get("status") or "Unknown",
    "priority":    priority or None,
    "priorityWeight": priority_weight,
    "resolution":  resolution or None,
    "fixVersions": fix_versions,
    "description": desc,
    "parentKey":   fields.get("parentKey") or None,
    "parentDescription": fields.get("parentDescription") or None,
    "host":        fields.get("host") or None,
    "url":         fields.get("url") or None,
    "owner":       fields.get("owner") or None,
    "repo":        fields.get("repo") or None,
    "branchHint":  fields.get("branchHint") or None,
    "relatedIssues": related,
    "comments":    comments,
    "commentsTotal": comments_total,
    "links":       links,
    "untrustedFields": ["title", "description", "parentDescription", "relatedIssues", "comments", "links"],
}
extra = {}
if fields.get("needsRepoPicker"):
    extra["needsRepoPicker"] = True
descriptor.update(extra)

# Compute maturity inline (avoid second python invocation).
title = (descriptor.get("title") or "").strip()
status = (descriptor.get("status") or "").lower()
itype = (descriptor.get("type") or "").lower()

parent_desc = (descriptor.get("parentDescription") or "").strip()
parent_key = descriptor.get("parentKey") or ""
sibling_key = next((r.get("key") or "" for r in related
                    if isinstance(r, dict) and (r.get("description") or "").strip()), "")

blockers, warnings = [], []
closed = {"done","closed","cancelled","canceled","resolved"}
if status in closed:
    blockers.append("status_closed")
# Jira "resolution" field: if set (Fixed, Won't Do, Done...), issue was already resolved.
if resolution:
    blockers.append("already_resolved")
if not desc.strip():
    # A parent with actual content is a candidate, not an auto-fix  -  surface it
    # as a warning so the caller asks the user before substituting it in (or,
    # under autopilot, auto-accepts per the same "warnings auto-continue" rule).
    # No parent / an equally-empty parent keeps the hard blocker as before.
    if parent_desc:
        warnings.append("description_empty_parent_available")
    elif sibling_key:
        # Same shape one level out: the analysis sibling is where the content
        # actually is on some boards. Only fires when the parent has none, so
        # it never costs a point on an issue that reads fine.
        warnings.append("description_empty_sibling_available")
    else:
        blockers.append("description_empty")
elif len(desc.strip()) < 80:
    warnings.append("short_description")
if len(title) < 10:
    warnings.append("short_title")
if "bug" in itype or "defect" in itype:
    if desc and not re.search(r"(steps to reproduce|reproduce|repro|adımlar|adimlar|nasıl|how to reproduce)", desc, re.I):
        warnings.append("no_repro_steps")
if any(w in itype for w in ("story","task","feature","sub-task")):
    if desc and len(desc) >= 80 and not re.search(r"(acceptance criteria|kabul kriter|definition of done|\bAC[: ]|\bDoD\b)", desc, re.I):
        warnings.append("no_acceptance_criteria")

score = max(0, min(100, 100 - 40*len(blockers) - 10*len(warnings)))

labels_tr = {
    "status_closed":         "Issue kapalı veya iptal edilmiş",
    "already_resolved":      "Issue zaten resolved/merged ({})".format(resolution or " - "),
    "description_empty":     "Açıklama boş",
    "description_empty_parent_available":"Açıklama boş ama parent'ta ({}) içerik var  -  oradan devam edilsin mi?".format(parent_key or " - "),
    "description_empty_sibling_available":"Açıklama boş, parent da boş, ama kardeş maddede ({}) içerik var  -  oradan devam edilsin mi?".format(sibling_key or " - "),
    "short_description":     "Açıklama çok kısa (<80 karakter)",
    "short_title":           "Başlık çok kısa (<10 karakter)",
    "no_repro_steps":        "Hatanın nasıl tekrarlanacağı yazmamış: ne yapıldı, ne bekleniyordu, ne oldu",
    "no_acceptance_criteria":"Kabul kriteri yok: işin bittiğini neye bakarak anlayacağımız yazmamış",
}
labels_en = {
    "status_closed":         "Issue is closed/cancelled",
    "already_resolved":      "Issue already resolved/merged ({})".format(resolution or " - "),
    "description_empty":     "Description is empty",
    "description_empty_parent_available":"Description is empty but the parent ({}) has content  -  continue from there?".format(parent_key or " - "),
    "description_empty_sibling_available":"Description is empty and so is the parent, but a sibling issue ({}) has content  -  continue from there?".format(sibling_key or " - "),
    "short_description":     "Description too short (<80 chars)",
    "short_title":           "Title too short (<10 chars)",
    "no_repro_steps":        "No reproduction steps: what was done, what was expected, what happened",
    "no_acceptance_criteria":"No acceptance criteria: nothing says what \"done\" looks like",
}
labels = labels_tr if lang == "tr" else labels_en
lines = []
for c in blockers:
    lines.append(("⛔ " if lang == "tr" else "BLOCK ") + labels.get(c, c))
for c in warnings:
    lines.append(("⚠ " if lang == "tr" else "WARN  ") + labels.get(c, c))
summary = "\n".join(lines) if lines else (
    "Issue olgun, devam edilebilir." if lang == "tr" else "Issue mature; ready to proceed."
)

descriptor["maturity"] = {
    "score":    score,
    "blockers": blockers,
    "warnings": warnings,
    "summary":  summary,
}

# For freetext, no description fetched  -  score the title instead.
if descriptor.get("kind") == "freetext":
    # No real issue, no maturity to assess. Mark as N/A.
    descriptor["maturity"] = {
        "score":    None,
        "blockers": [],
        "warnings": [],
        "summary":  "Free-text input  -  maturity check skipped." if lang == "en"
                    else "Free-text girdi  -  maturity kontrolü atlandı.",
    }

print(json.dumps(descriptor, ensure_ascii=False))
PY
}

# --- Dispatch -----------------------------------------------------------------
if [ "$MA_IF_EXECUTED" = 1 ]; then
case "$KIND" in
  jira-id)
    KEY="$INPUT"
    raw=$(fetch_jira "$KEY" "summary,status,issuetype,description,priority,resolution,fixVersions,parent,comment,issuelinks") || raw=""
    commentsjson='{"items":[],"total":0}'; linksjson="[]"
    if [ -n "$raw" ]; then
      # Single python3 pass  -  emit all fields joined by U+001F (Unit Separator)
      # so we avoid spawning 7 interpreter startups per Jira fetch
      # (~200-300ms each). The separator is non-whitespace, so empty fields
      # (e.g. unresolved issues) are preserved by `read` instead of collapsed.
      # `read -d ''` lets descriptions with embedded newlines pass through.
      parsed=$(printf '%s' "$raw" | SELF_KEY="$KEY" JIRA_HOST="$JIRA_HOST" python3 -c "$_PY_BOUND_COMMENTS"'
import json, re, sys
d = json.load(sys.stdin)
f = d.get("fields", {}) or {}
host = os.environ.get("JIRA_HOST", "")
self_key = os.environ.get("SELF_KEY", "")
key_re = re.compile(r"^[A-Z][A-Z0-9]*-[0-9]+$")
def _name(x):
    return (x or {}).get("name", "") or ""
title       = f.get("summary", "") or ""
itype       = _name(f.get("issuetype"))
status      = _name(f.get("status"))
description = (f.get("description") or "")
priority    = _name(f.get("priority"))
resolution  = _name(f.get("resolution"))
fixversions = ",".join((v or {}).get("name", "") for v in (f.get("fixVersions") or []))
parentkey   = (f.get("parent") or {}).get("key", "") or ""
cblock = f.get("comment") or {}
crows = []
for c in cblock.get("comments") or []:
    if not isinstance(c, dict):
        continue
    cid = str(c.get("id") or "")
    crows.append({
        "id": cid,
        "author": ((c.get("author") or {}).get("displayName") or ""),
        "created": c.get("created") or "",
        "url": "https://%s/browse/%s?focusedCommentId=%s" % (host, self_key, cid) if host and cid else "",
        "body": c.get("body") or "",
    })
comments = {"items": bound_comments(crows), "total": int(cblock.get("total") or len(crows))}
links = []
for l in f.get("issuelinks") or []:
    if not isinstance(l, dict):
        continue
    t = l.get("type") or {}
    other, direction = (l.get("outwardIssue"), "outward") if l.get("outwardIssue") else (l.get("inwardIssue"), "inward")
    other = other or {}
    lkey = other.get("key") or ""
    if not key_re.match(lkey):
        continue
    of = other.get("fields") or {}
    links.append({
        "key": lkey,
        "relation": (t.get(direction) or t.get("name") or ""),
        "direction": direction,
        "summary": of.get("summary") or "",
        "status": _name(of.get("status")),
        "url": "https://%s/browse/%s" % (host, lkey) if host else "",
    })
links = links[:max(_env_int("ISSUE_LINKS_MAX", 20), 0)]
parts = [title, itype, status, description, priority, resolution, fixversions, parentkey]
parts = [v.replace("\x1f", " ") for v in parts]
parts += [json.dumps(comments, ensure_ascii=False), json.dumps(links, ensure_ascii=False)]
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions parentkey commentsjson linksjson <<< "$parsed" || true
      linksjson="${linksjson%$'\n'}"
      # `<<<` appends a trailing newline to its input; `read -d ''` has no
      # delimiter to stop on, so that newline folds into whichever field
      # comes last  -  now parentkey. Strip it, or a genuinely-empty parent
      # (no fallback needed) or a real key gets a stray \n appended, which
      # breaks the parent lookup's URL and misreports parentKey in output.
      parentkey="${parentkey%$'\n'}"
    else
      title="(unfetched)"; itype="Task"; status="Unknown"; description=""
      priority=""; resolution=""; fixversions=""; parentkey=""
    fi
    # Development sub-tasks are frequently filed with an empty description while
    # the real requirements live on the parent (story/epic). Single hop only  -
    # if the parent is also empty/unreachable, description stays empty and
    # description_empty blocks below, same as before this existed. This fetcher
    # only surfaces the parent's description as a CANDIDATE (parentDescription)
    # - it never substitutes it into "description" itself. Whether to actually
    # use it is a decision for the caller to put in front of the user (or
    # auto-accept under autopilot), same as any other maturity warning.
    parentdesc=""
    if [ -z "$(printf '%s' "$description" | tr -d '[:space:]')" ] && [ -n "${parentkey:-}" ]; then
      praw=$(fetch_jira "$parentkey") || praw=""
      if [ -n "$praw" ]; then
        pdesc=$(printf '%s' "$praw" | python3 -c '
import json, sys
d = json.load(sys.stdin)
f = d.get("fields", {}) or {}
sys.stdout.write((f.get("description") or "").replace("\x1f", " "))
')
        if [ -n "$(printf '%s' "$pdesc" | tr -d '[:space:]')" ]; then
          parentdesc="$pdesc"
        fi
      fi
    fi
    # A development sub-task's siblings under the same parent carry the rest of
    # the picture: the analysis sub-task is often the one with content while the
    # dev task has none. One search returns all of them WITH their descriptions,
    # so this costs exactly one extra request, and none at all on an issue with
    # no parent. Gated on parentkey alone, not on an empty own description  -
    # the analysis sibling is worth reading even when the dev task is filled in.
    # Read prefs only once parentkey is known non-empty: an issue with no parent
    # is the common case and should not pay for an interpreter start. Env wins,
    # which is what lets the offline gate drive every branch without a prefs file.
    prefs_jiraContext_enabled="${JIRA_CONTEXT_ENABLED:-}"
    prefs_jiraContext_maxItems="${JIRA_CONTEXT_MAX_ITEMS:-}"
    prefs_jiraContext_maxCharsPerItem="${JIRA_CONTEXT_MAX_CHARS:-}"
    if [ -n "${parentkey:-}" ] && { [ -z "$prefs_jiraContext_enabled" ] || \
         [ -z "$prefs_jiraContext_maxItems" ] || [ -z "$prefs_jiraContext_maxCharsPerItem" ]; }; then
      _jc=$(python3 - <<'PY' 2>/dev/null || true
import json, os
p = os.path.expanduser("~/.claude/multi-agent-preferences.json")
c = {}
try:
    c = ((json.load(open(p, encoding="utf-8")).get("global") or {}).get("jiraContext") or {})
except Exception:
    c = {}
print("%s %s %s" % (
    "false" if c.get("enabled") is False else "true",
    int(c.get("maxItems", 6)),
    int(c.get("maxCharsPerItem", 1200)),
))
PY
)
      # Parameter expansion, not `set --`: the positional parameters belong to
      # the script and the jira-url branch re-execs with them.
      _jc="${_jc:-true 6 1200}"
      _jc_rest="${_jc#* }"
      [ -n "$prefs_jiraContext_enabled" ] || prefs_jiraContext_enabled="${_jc%% *}"
      [ -n "$prefs_jiraContext_maxItems" ] || prefs_jiraContext_maxItems="${_jc_rest%% *}"
      [ -n "$prefs_jiraContext_maxCharsPerItem" ] || prefs_jiraContext_maxCharsPerItem="${_jc_rest##* }"
      unset _jc _jc_rest
    fi
    : "${prefs_jiraContext_enabled:=true}"
    : "${prefs_jiraContext_maxItems:=6}"
    : "${prefs_jiraContext_maxCharsPerItem:=1200}"
    relatedjson="[]"
    if [ -n "${parentkey:-}" ] && [ "$prefs_jiraContext_enabled" = "true" ] && [ "$prefs_jiraContext_maxItems" -gt 0 ]; then
      # parentkey is API-supplied but goes straight into a URL query, so it is
      # re-validated against the same anchored pattern detect_kind uses. That
      # makes the encoding trivial and closes the JQL-injection path.
      if printf '%s' "$parentkey" | grep -qE '^[A-Z][A-Z0-9]*-[0-9]+$'; then
        sraw=$(jira_search "parent%3D%22$parentkey%22" \
                 "summary,issuetype,status,description" 20) || sraw=""
        if [ -n "$sraw" ]; then
          relatedjson=$(printf '%s' "$sraw" | SELF_KEY="$KEY" \
            RELATED_MAX="$prefs_jiraContext_maxItems" RELATED_CHARS="$prefs_jiraContext_maxCharsPerItem" python3 -c '
import json, os, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.stdout.write("[]"); raise SystemExit(0)
self_key = os.environ.get("SELF_KEY", "")
cap = int(os.environ.get("RELATED_MAX") or 0)
chars = int(os.environ.get("RELATED_CHARS") or 0)
out = []
for i in d.get("issues") or []:
    key = i.get("key") or ""
    if not key or key == self_key:
        continue
    f = i.get("fields") or {}
    desc = (f.get("description") or "").replace("\x1f", " ")
    truncated = False
    if chars and len(desc) > chars:
        desc = desc[:chars] + "..."
        truncated = True
    out.append({
        "key": key,
        "relation": "sibling",
        "type": ((f.get("issuetype") or {}).get("name") or ""),
        "status": ((f.get("status") or {}).get("name") or ""),
        "summary": (f.get("summary") or ""),
        "description": desc,
        "truncated": truncated,
    })
# Siblings that carry content come first, so the cap keeps the useful ones.
out.sort(key=lambda r: 0 if r["description"].strip() else 1)
sys.stdout.write(json.dumps(out[:cap], ensure_ascii=False))
') || relatedjson="[]"
        fi
      fi
    fi
    [ -n "$relatedjson" ] || relatedjson="[]"
    branch=$(branch_for "$KEY" "$itype" "$title")
    emit_descriptor \
      "kind=jira" "key=$KEY" "title=$title" "type=$itype" "status=$status" \
      "description=$description" "host=$JIRA_HOST" \
      "url=https://$JIRA_HOST/browse/$KEY" "branchHint=$branch" \
      "priority=$priority" "resolution=$resolution" "fixVersions=$fixversions" \
      "parentKey=$parentkey" "parentDescription=$parentdesc" \
      "relatedIssues=$relatedjson" "comments=$commentsjson" "links=$linksjson"
    ;;

  jira-url)
    HOST=$(printf '%s' "$INPUT" | sed -E 's#https?://##; s#/.*##')
    KEY=$(printf '%s' "$INPUT" | sed -E 's#.*/browse/##; s#[/?#].*##')
    # Re-dispatch only a real key. Anything else would fall through
    # detect_kind to the freetext branch and start a run titled with a URL.
    if ! [[ "$KEY" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then
      echo '{"error":"no Jira issue key in URL"}'
      exit 2
    fi
    # The link's host receives the Jira token on the re-dispatch, and a link is
    # ticket or request text. HOST is the exact string curl will see, so it must
    # be a bare host[:port] and name the configured Jira host; without one, only
    # Atlassian Cloud, where the host is the tenant.
    if ! [[ "$HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
      echo '{"error":"host-malformed"}'
      exit 2
    fi
    _allowed="${JIRA_HOST:-}"
    if [ -z "$_allowed" ]; then
      _prefs="${MA_PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
      _allowed=$(python3 - "$_prefs" <<'PY' 2>/dev/null || true
import json, sys
try:
    print((json.load(open(sys.argv[1])).get("global", {}).get("hosts", {}) or {}).get("jira") or "")
except Exception:
    print("")
PY
)
    fi
    _allowed=$(printf '%s' "$_allowed" | tr '[:upper:]' '[:lower:]' | sed -E 's#^https?://##; s#/.*##; s#:.*##; s#\.$##')
    _host=$(printf '%s' "$HOST" | tr '[:upper:]' '[:lower:]' | sed -E 's#:.*##; s#\.$##')
    if [ -n "$_allowed" ]; then
      [ "$_host" = "$_allowed" ] || { echo '{"error":"host-not-allowed"}'; exit 2; }
    else
      case "$_host" in
        *.atlassian.net) ;;
        *) echo '{"error":"host-not-configured"}'; exit 2 ;;
      esac
    fi
    unset _allowed _prefs _host
    ACCOUNT_JIRA_HOST="$HOST" JIRA_HOST="$HOST" exec "$0" "$KEY"
    ;;

  gh-url)
    OWNER=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/##; s#/.*##')
    REPO=$(printf '%s' "$INPUT" | sed -E 's#https?://github.com/[^/]+/##; s#/.*##')
    NUM=$(printf '%s' "$INPUT" | sed -E 's#.*/issues/##; s#[/?#].*##')
    raw=$(fetch_github "$OWNER" "$REPO" "$NUM") || raw=""
    commentsjson='{"items":[],"total":0}'
    if [ -n "$raw" ]; then
      # Single python3 pass  -  see jira-id branch for rationale on the
      # U+001F separator + `read -d ''` combo.
      parsed=$(printf '%s' "$raw" | python3 -c "$_PY_BOUND_COMMENTS"'
import json, sys
d = json.load(sys.stdin)
title       = d.get("title", "") or ""
labels      = ",".join((l or {}).get("name", "") for l in (d.get("labels") or []))
status      = d.get("state", "open") or "open"
description = d.get("body", "") or ""
crows = []
for c in d.get("comments") or []:
    if not isinstance(c, dict):
        continue
    crows.append({
        "id": str(c.get("id") or ""),
        "author": ((c.get("author") or {}).get("login") or ""),
        "created": c.get("createdAt") or "",
        "url": c.get("url") or "",
        "body": c.get("body") or "",
    })
comments = {"items": bound_comments(crows), "total": len(crows)}
parts = [title, labels, status, description]
parts = [v.replace("\x1f", " ") for v in parts]
parts.append(json.dumps(comments, ensure_ascii=False))
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title labels status description commentsjson <<< "$parsed" || true
      # Same `<<<` trailing newline as the jira-id branch; here the comments
      # JSON is the last field and would carry it.
      commentsjson="${commentsjson%$'\n'}"
    else
      title="(unfetched)"; labels=""; status="open"; description=""
    fi
    itype=$(infer_type_from_label "$labels")
    branch=$(branch_for "${REPO}-${NUM}" "$itype" "$title")
    emit_descriptor \
      "kind=github" "key=$NUM" "title=$title" "type=$itype" "status=$status" \
      "description=$description" "host=github.com" \
      "url=https://github.com/$OWNER/$REPO/issues/$NUM" \
      "owner=$OWNER" "repo=$REPO" "branchHint=$branch" "comments=$commentsjson"
    ;;

  gh-short)
    REPO=$(printf '%s' "$INPUT" | sed -E 's/#.*//')
    NUM=$(printf '%s' "$INPUT" | sed -E 's/.*#//')
    OWNER="${DEFAULT_OWNER:-}"
    if [ -z "$OWNER" ]; then
      echo '{"error":"ACCOUNT_DEFAULT_OWNER not set; cannot resolve repo#N"}'
      exit 2
    fi
    exec "$0" "https://github.com/$OWNER/$REPO/issues/$NUM"
    ;;

  gh-num)
    NUM="${INPUT#\#}"
    OWNER="${DEFAULT_OWNER:-}"
    REPO="${DEFAULT_REPO:-}"
    if [ -z "$OWNER" ] || [ -z "$REPO" ]; then
      emit_descriptor \
        "kind=github" "key=$NUM" "title=" "type=Task" "status=unresolved" \
        "description=" "host=github.com" \
        "needsRepoPicker=1"
      exit 0
    fi
    exec "$0" "https://github.com/$OWNER/$REPO/issues/$NUM"
    ;;

  rescore)
    # The descriptor's own fields, handed back to the one scorer. Read in full
    # first: the argument list is built by a second process.
    desc_in=$(cat)
    rescore_args=()
    while IFS= read -r -d '' a; do rescore_args+=("$a"); done < <(printf '%s' "$desc_in" | python3 -c '
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    d = None
if not isinstance(d, dict):
    sys.exit(0)
def s(v):
    return "" if v is None else str(v)
comments = d.get("comments") if isinstance(d.get("comments"), list) else []
out = {
    "kind": s(d.get("kind")), "key": s(d.get("key")), "title": s(d.get("title")),
    "type": s(d.get("type")), "status": s(d.get("status")), "description": s(d.get("description")),
    "priority": s(d.get("priority")), "resolution": s(d.get("resolution")),
    "fixVersions": ",".join(s(v) for v in (d.get("fixVersions") or [])),
    "parentKey": s(d.get("parentKey")), "parentDescription": s(d.get("parentDescription")),
    "host": s(d.get("host")), "url": s(d.get("url")), "owner": s(d.get("owner")),
    "repo": s(d.get("repo")), "branchHint": s(d.get("branchHint")),
    "relatedIssues": json.dumps(d.get("relatedIssues") or [], ensure_ascii=False),
    "comments": json.dumps({"items": comments, "total": d.get("commentsTotal") or len(comments)}, ensure_ascii=False),
    "links": json.dumps(d.get("links") or [], ensure_ascii=False),
}
if d.get("needsRepoPicker"):
    out["needsRepoPicker"] = "1"
for k, v in out.items():
    sys.stdout.write("%s=%s\0" % (k, v.replace("\0", "")))
')
    if [ "${#rescore_args[@]}" -eq 0 ]; then
      echo '{"error":"--rescore needs a descriptor JSON object on stdin"}'
      exit 2
    fi
    emit_descriptor "${rescore_args[@]}"
    ;;

  freetext)
    title="$INPUT"
    branch=$(branch_for "" "Task" "$title")
    emit_descriptor \
      "kind=freetext" "key=" "title=$title" "type=Task" "status=new" \
      "description=" "branchHint=$branch"
    ;;
esac
fi
