#!/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/...",
#     "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)
#   PROMPT_LANG               tr|en  -  controls maturity.summary language (default: tr)

set -euo pipefail

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

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

# --- 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")

# --- 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) -----------------------------

# 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 -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 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 - "$PROMPT_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 = []

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,
}
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 -----------------------------------------------------------------
case "$KIND" in
  jira-id)
    KEY="$INPUT"
    raw=$(fetch_jira "$KEY") || raw=""
    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" | python3 -c '
import json, sys
d = json.load(sys.stdin)
f = d.get("fields", {}) or {}
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 ""
parts = [title, itype, status, description, priority, resolution, fixversions, parentkey]
parts = [v.replace("\x1f", " ") for v in parts]
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title itype status description priority resolution fixversions parentkey <<< "$parsed" || true
      # `<<<` 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"
    ;;

  jira-url)
    HOST=$(printf '%s' "$INPUT" | sed -E 's#https?://##; s#/.*##')
    KEY=$(printf '%s' "$INPUT" | sed -E 's#.*/browse/##; s#[/?].*##')
    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=""
    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 '
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 ""
parts = [title, labels, status, description]
parts = [v.replace("\x1f", " ") for v in parts]
sys.stdout.write("\x1f".join(parts))
')
      IFS=$'\x1f' read -r -d '' title labels status description <<< "$parsed" || true
    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"
    ;;

  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"
    ;;

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