/packs / jira

jira

Jira Cloud issues, heard by webhook, answered and moved along their workflow.

  • integration
  • sources
  • Jira

As of yardr-packs at commit a2b86e6, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member, merges · lands.

01 / apply it

yardr pack apply ./jira

From a clone of yardr-packs: the pack's directory. yardr pack check ./jira shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/jira/

From this site, as the files below.

Lives in jira/ at a2b86e6 · SETUP.md

02 / the files

Every file, as it ships.

# Jira Cloud as a pack of kind source: the integration, imported into the
# yard and into no depot. What Jira is here: an admin webhook heard as
# issue and comment envelopes, the yard's replies, issues and status moves
# in its issues, and two reads an agent may ask for through `yardr call`.
# Every agent granted a call, and every crew member given the account's
# credential, acts as that one Atlassian account.
#
#   yardr pack apply /Users/mckean/projects/repos/yardr-packs/jira
#
# The source is named jira: a pack is named after the service it talks to.
# Jira is per site, so the declaration ships a placeholder address that
# SETUP.md has the operator replace in the yard's copy.
name: jira
kind: source
setup: SETUP.md

sources:
  jira:
    declaration: jira.yaml

# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and a
# terminal pack apply ignores them.
presets:
  read:
    about: Read an issue and its comments and search issues by JQL; change nothing
    grants:
      jira: [read]
#!/bin/sh
# mint: the credential Jira takes, from the account's email and an API
# token. stdin {"inputs": {"JIRA_EMAIL": …, "JIRA_API_TOKEN": …}, "scope": …},
# stdout {"token": …, "expires_at": …}, as a source's mint_command is run.
#
# Jira's Basic scheme is the base64 of "email:token", and a declared call's
# header takes one value (Authorization: Basic {credential}), as a command
# is handed one variable. So the pair is made into that value here, once,
# for the calls and the thread commands alike. The value does not expire;
# the hour is only how long the yard keeps it before it asks again, and a
# new token in the env file is a new key, minted at once.
#
# Nothing but a fixed line reaches stderr: jq's own errors can quote the
# value they failed on, and the value is the token. Needs jq.
jq -c '
  .inputs as $i
  | if ($i.JIRA_EMAIL | type) != "string" or ($i.JIRA_API_TOKEN | type) != "string"
       or $i.JIRA_EMAIL == "" or $i.JIRA_API_TOKEN == ""
    then error("incomplete") else . end
  | { token: ("\($i.JIRA_EMAIL):\($i.JIRA_API_TOKEN)" | @base64),
      expires_at: (now + 3600 | floor | todate) }
' 2>/dev/null || { echo "jira/mint: want JIRA_EMAIL and JIRA_API_TOKEN among the inputs" >&2; exit 1; }
#!/bin/sh
# A verified Jira webhook to the yard's envelope, as Linear's is made:
# stdin {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
#
# The body is an admin webhook's: {timestamp, webhookEvent, user, issue,
# comment, changelog}. A body that is not an object is refused: a non-zero
# exit is answered 400, and the delivery reaches no subscription. Nothing
# of the body is ever printed to stderr.
#
# Vocabulary is Linear's: type issue or comment, action create, update or
# remove (Jira's created, updated, deleted). Any other event keeps its own
# name as the type and has no action.
#
# A text field is a string (wiki markup, as the webhook's REST v2 shape
# has it) or an ADF document (Atlassian Document Format, as v3 has it): both
# are read, since the docs name the comment's shape "as the REST API
# returns it" without saying which. A person is mentioned by account id,
# `[~accountid:<id>]` in a string and a mention node in ADF; the id is what
# the envelope's mentions and its sender carry, and what hear matches.
#
# The actor is who did it: the delivery's user where it names one, else the
# comment's author (its editor, for an update). A deleted comment names
# nobody, so a delete without a user has no actor and no sender.
#
# The thread is the issue, named by its key. Its url is the site's browse
# page, from the host the payload's own self names (the site itself sends
# it); where that is Atlassian's gateway, the host of YARDR_SOURCE_BASE
# when that is a site address, else none.
exec jq -c '
def str($v): if ($v | type) == "string" then $v elif $v == null then "" else ($v | tostring) end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if any(.[]; . == $x) then . else . + [$x] end);

# ADF to text: a block puts its children on lines of their own, an inline
# node runs on.
def adf_text:
  if type == "string" then .
  elif type != "object" then ""
  elif .type == "text" then (.text // "")
  elif .type == "hardBreak" then "\n"
  elif .type == "mention" then (.attrs.text // "")
  elif .type == "emoji" then (.attrs.text // .attrs.shortName // "")
  elif .type == "inlineCard" or .type == "blockCard" then (.attrs.url // "")
  elif (.type | IN("paragraph", "heading", "codeBlock")) then ([.content[]? | adf_text] | join(""))
  else ([.content[]? | adf_text] | join("\n")) end;
def text_of: if type == "object" then adf_text else str(.) end;

def mentions_in($v):
  ( [ $v | select(type == "object") | .. | objects | select(.type == "mention")
      | .attrs.id | select(type == "string" and . != "") ]
  + [ $v | select(type == "string") | scan("\\[~accountid:([^\\]\\s]+)\\]") | .[0] ]
  ) | ordered;

def origin($u): if ($u | type) == "string" then ($u | capture("^(?<o>https?://[^/]+)") // {o: ""} | .o) else "" end;
# The site that sent the delivery, for browse links.
def site($i):
  origin($i.self) as $o
  | if $o != "" and ($o | test("^https?://api\\.atlassian\\.com$") | not) then $o
    else (($ENV.YARDR_SOURCE_BASE // "") | capture("^(?<o>https://[A-Za-z0-9-]+\\.atlassian\\.net)(/|$)") // {o: ""} | .o) end;
def browse($i): site($i) as $s | if $s != "" and str($i.key) != "" then "\($s)/browse/\($i.key)" else "" end;

def person($u):
  if ($u | type) == "object"
  then {id: str($u.accountId), name: (str($u.displayName) | if . == "" then str($u.name) else . end)}
  else {id: "", name: ""} end;

def kind($e):
  ($e | sub("^jira:"; "")) as $n
  | ($n | capture("^(?<t>.+)_(?<a>created|updated|deleted)$") // null) as $m
  | if $m == null then {type: ($n | ascii_downcase), action: ""}
    else {type: ($m.t | ascii_downcase), action: ({"created": "create", "updated": "update", "deleted": "remove"}[$m.a])} end;

def fields($i; $sender):
  ( { project:   [$i.fields.project.key],
      issuetype: [$i.fields.issuetype.name],
      status:    [$i.fields.status.name],
      label:     ($i.fields.labels // []) }
    | map_values(map(select(. != null and . != ""))) | with_entries(select(.value | length > 0)) )
  + (if $sender != "" then {sender: [$sender]} else {} end);
def with_fields($i):
  (.actor.id // "") as $sender | fields($i; $sender) as $f | if ($f | length) > 0 then . + {fields: $f} else . end;

def changed($p):
  ( [ $p.changelog.items[]? | .field | select(. != null and . != "") ] | ordered | join(", ") ) as $fs
  | if $fs != "" then ("changed " + $fs | trunc)
    elif $p.comment != null then "commented"
    else "" end;

def main:
(.body | if type == "object" then . else error("jira body is not an object") end) as $p
| ($p.issue // {}) as $i
| ($i.fields // {}) as $f
| ($p.comment // null) as $c
| kind(str($p.webhookEvent)) as $k
| if $k.type == "" then error("jira delivery names no webhookEvent") else . end
| { source: "jira", type: $k.type, action: $k.action, url: browse($i) }
| if .type == "comment" then . +
    { url: (if browse($i) != "" and str($c.id) != "" then "\(browse($i))?focusedCommentId=\(str($c.id))" else browse($i) end),
      actor: person($p.user // (if $k.action == "update" then ($c.updateAuthor // $c.author)
                                 elif $k.action == "remove" then null
                                 else ($c.author // $c.updateAuthor) end)),
      thread: {id: str($i.key), title: str($f.summary), url: browse($i)},
      text: ($c.body | text_of | trunc), message: str($c.id), parent: "", entity: str($c.id),
      mentions: mentions_in($c.body) } | with_fields($i)
  elif .type == "issue" then . +
    { actor: person($p.user),
      thread: {id: str($i.key), title: str($f.summary), url: browse($i)},
      assignee: str($f.assignee.accountId),
      text: (if .action == "update" then changed($p) else ($f.description | text_of | trunc) end),
      entity: str($i.id), mentions: mentions_in($f.description) } | with_fields($i)
  else . +
    { actor: person($p.user),
      thread: {id: str($i.key), title: str($f.summary), url: browse($i)},
      mentions: [] } | with_fields($i)
  end;
# jq quotes the value it failed on, and the body is what a stranger wrote:
# only the messages above, which start "jira ", get out.
try main catch ((if type == "string" and startswith("jira ") then . else "jira body is not in a shape normalize reads" end) | error)
'
#!/bin/bash
# thread: what the yard does in a Jira issue: reply, posted, read, open,
# opened, state. One JSON object on stdin, one on stdout, as the thread key
# of the declaration says. JIRA_AUTH (the Basic credential jira/mint made
# from the account's email and API token) and YARDR_SOURCE_BASE, the
# declaration's base, are in the environment. Needs curl and jq.
#
# A thread is an issue, named by its key (SHED-7). Jira takes no id of
# ours for a comment or an issue, so the key a reply or an open is given
# goes in as an entity property, `yardr.key`, in the same request that
# makes the comment or the issue: posted and opened read it back, which
# is how a retry finds what a lost answer left, and the property shows
# on no page.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

# The shipped base is no site, and nothing is sent to it: the operator
# writes their own into the yard's copy of the declaration, and a pack
# applied again puts this one back. (${VAR:?} is not used for what follows:
# bash 3.2 exits 0 after it prints.)
case ${YARDR_SOURCE_BASE:-} in
https://example.atlassian.net | https://example.atlassian.net/)
  echo "the jira source's base is still the pack's placeholder, https://example.atlassian.net: put your site's address in the yard's copy of jira.yaml (SETUP.md, step 2)" >&2
  exit 1 ;;
esac
[ -n "${YARDR_SOURCE_BASE:-}" ] || { echo "YARDR_SOURCE_BASE is not set" >&2; exit 1; }
[ -n "${JIRA_AUTH:-}" ] || { echo "JIRA_AUTH is not set: the account's JIRA_EMAIL and JIRA_API_TOKEN go in the crew member's env file (SETUP.md)" >&2; exit 1; }
base=${YARDR_SOURCE_BASE%/}

# jira_error: what Jira said, in a line, from the answer in $tmp/out. An
# answer that is not JSON is not Jira's: a wrong base answers so.
# Errors can echo user-authored text; remove terminal controls and flatten it
# before it reaches stderr.
jira_error() {
  jq -r '[(.errorMessages // [])[], ((.errors // {}) | to_entries[] | "\(.key): \(.value)")] | join("; ") | gsub("\u001b\\[[0-?]*[ -/]*[@-~]"; "") | gsub("[[:cntrl:]]"; " ") | gsub("[[:space:]]+"; " ") | if . == "" then "no message" else . end' "$tmp/out" 2>/dev/null \
    || echo "an answer that is not Jira's (is the base the site's address?)"
}

# api <method> <path> [json body]: the answer's body, or the failure on
# stderr. The credential goes to curl as a config line on its stdin,
# written by the shell itself: an argument can be read by every process on
# the machine while curl runs. -q first, so no .curlrc of the operator's
# can trace the header into an error.
api() {
  local m=$1 u=$base$2 code msg
  if [ $# -ge 3 ]; then
    code=$(printf 'header = "Authorization: Basic %s"\n' "$JIRA_AUTH" \
      | curl -q -sS -o "$tmp/out" -w '%{http_code}' -K - -X "$m" "$u" \
        -H 'Accept: application/json' -H 'Content-Type: application/json' -d "$3") || {
      echo "could not reach $base (curl exit $?)" >&2
      return 1
    }
  else
    code=$(printf 'header = "Authorization: Basic %s"\n' "$JIRA_AUTH" \
      | curl -q -sS -o "$tmp/out" -w '%{http_code}' -K - -X "$m" "$u" \
        -H 'Accept: application/json') || {
      echo "could not reach $base (curl exit $?)" >&2
      return 1
    }
  fi
  if [ "${code:0:1}" != 2 ]; then
    msg=$(jira_error)
    echo "$m $2: HTTP $code: ${msg//"$JIRA_AUTH"/[credential redacted]}" >&2
    return 1
  fi
  cat "$tmp/out"
}

# site: the site's own address, which browse links are on. Under a scoped
# token the base is Atlassian's gateway, and the site names itself.
site() {
  local s
  # bash clears -e inside a command substitution, so the failure is passed on
  # here, and api's message is the only one.
  s=$(api GET /rest/api/3/serverInfo | jq -r '(.displayUrl // .baseUrl // "") | sub("/+$"; "")') || return 1
  [ -n "$s" ] || { echo "Jira's serverInfo names no site address" >&2; return 1; }
  printf '%s' "$s"
}

# An issue as a thread is its key, and nothing that would reach another
# endpoint.
valid_thread() {
  [[ $1 =~ ^[A-Za-z][A-Za-z0-9_]*-[0-9]+$ ]] || { echo "thread $1: want an issue key (SHED-7)" >&2; return 1; }
}

# Text to ADF, Atlassian Document Format, which v3 takes in place of a
# string: a paragraph at each blank line, a line break at each other. And
# ADF back to text, a timestamp to RFC 3339, and the newest messages that
# fit a command's 64 KB answer.
lib='
def adf:
  gsub("\r\n?"; "\n") as $t
  | [ $t | splits("\n{2,}") | gsub("^\n+|\n+$"; "") | select(test("\\S")) ] as $paras
  | { type: "doc", version: 1,
      content: ( if ($paras | length) == 0 then [{type: "paragraph"}]
                 else [ $paras[] | split("\n") as $lines
                        | { type: "paragraph",
                            content: [ range(0; $lines | length) as $n
                                       | (if $n > 0 then {type: "hardBreak"} else empty end),
                                         (if $lines[$n] == "" then empty else {type: "text", text: $lines[$n]} end) ] } ] end ) };
def adf_text:
  if type == "string" then .
  elif type != "object" then ""
  elif .type == "text" then (.text // "")
  elif .type == "hardBreak" then "\n"
  elif .type == "mention" then (.attrs.text // "")
  elif .type == "emoji" then (.attrs.text // .attrs.shortName // "")
  elif .type == "inlineCard" or .type == "blockCard" then (.attrs.url // "")
  elif (.type | IN("paragraph", "heading", "codeBlock")) then ([.content[]? | adf_text] | join(""))
  else ([.content[]? | adf_text] | join("\n")) end;
def rfc3339: sub("(?<h>[+-][0-9]{2})(?<m>[0-9]{2})$"; "\(.h):\(.m)");
def fit($max):
  . as $m
  | [$m[] | (tojson | utf8bytelength) + 1] as $sz
  | ( reduce range(($m | length) - 1; -1; -1) as $i ({sum: 0, from: ($m | length), full: false};
        if .full then . elif .sum + $sz[$i] > $max then .full = true else .sum += $sz[$i] | .from = $i end) ) as $r
  | $m[$r.from:];
'

case "$1" in
reply) # comments are not threaded in Jira, so the parent is left out
  thread=$(arg thread)
  key=$(arg key)
  valid_thread "$thread"
  s=$(site)
  api POST "/rest/api/3/issue/$thread/comment" \
    "$(jq -c --arg k "$key" "$lib"'{body: (.body | adf)} + (if $k != "" then {properties: [{key: "yardr.key", value: $k}]} else {} end)' <<<"$in")" |
    jq -c --arg s "$s" --arg t "$thread" '{id: (.id | tostring), url: "\($s)/browse/\($t)?focusedCommentId=\(.id)"}' ;;

posted) # {} for none: a retry asks before it posts, so nothing is posted twice.
  # Newest comments first, a page at a time, each page's properties read
  # in one request: ours is among the latest.
  thread=$(arg thread)
  key=$(arg key)
  valid_thread "$thread"
  s=$(site)
  start=0
  found=
  while [ -n "$key" ]; do
    page=$(api GET "/rest/api/3/issue/$thread/comment?orderBy=-created&startAt=$start&maxResults=100")
    n=$(jq '.comments | length' <<<"$page")
    [ "$n" -gt 0 ] || break
    found=$(api POST "/rest/api/3/comment/list?expand=properties" \
      "$(jq -c '{ids: [.comments[].id | tonumber]}' <<<"$page")" |
      jq -r --arg k "$key" '[.values[]? | select(any(.properties[]?; .key == "yardr.key" and .value == $k)) | .id | tostring][0] // empty')
    [ -z "$found" ] || break
    start=$((start + n))
    [ "$start" -lt "$(jq '.total // 0' <<<"$page")" ] || break
  done
  if [ -n "$found" ]; then
    jq -nc --arg id "$found" --arg u "$s/browse/$thread?focusedCommentId=$found" '{id: $id, url: $u}'
  else
    printf '%s' '{}'
  fi ;;

read) # every page, oldest first
  thread=$(arg thread)
  valid_thread "$thread"
  s=$(site)
  start=0
  : >"$tmp/comments"
  while :; do
    page=$(api GET "/rest/api/3/issue/$thread/comment?orderBy=created&startAt=$start&maxResults=100")
    jq -c '.comments[]?' <<<"$page" >>"$tmp/comments"
    n=$(jq '.comments | length' <<<"$page")
    start=$((start + n))
    { [ "$n" -gt 0 ] && [ "$start" -lt "$(jq '.total // 0' <<<"$page")" ]; } || break
  done
  jq -cs --arg s "$s" --arg t "$thread" "$lib"'
    {messages: ([.[] | {id: (.id | tostring), parent: "",
        author: {id: (.author.accountId // ""), name: (.author.displayName // "")},
        at: ((.created // "0001-01-01T00:00:00Z") | rfc3339), body: (.body | adf_text),
        url: "\($s)/browse/\($t)?focusedCommentId=\(.id)"}] | fit(60000))}' "$tmp/comments" ;;

open) # the project is given by its key. An issue type is needed: Task where
  # the project has one, else its first ordinary type. An assignee is an
  # account id.
  into=$(arg in | tr '[:lower:]' '[:upper:]')
  key=$(arg key)
  assignee=$(arg assignee)
  [[ $into =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "--in $(arg in): want a project key (SHED)" >&2; exit 1; }
  s=$(site)
  itype=$(api GET "/rest/api/3/issue/createmeta/$into/issuetypes?maxResults=200" | jq -r '
    [(.issueTypes // .createMetaIssueType // [])[] | select(.subtask != true)] as $t
    | ( [$t[] | select(.name | ascii_downcase == "task")][0]
        // [$t[] | select((.hierarchyLevel // 0) == 0)][0] // $t[0] ) | .id // empty')
  [ -n "$itype" ] || { echo "project $into has no issue type to open an issue as" >&2; exit 1; }
  title=$(jq -r '.title | gsub("[\r\n]+"; " ") | .[0:255]' <<<"$in")
  api POST /rest/api/3/issue \
    "$(jq -c --arg p "$into" --arg t "$itype" --arg k "$key" --arg a "$assignee" --arg title "$title" "$lib"'
      { fields: ( {project: {key: $p}, issuetype: {id: $t}, summary: $title}
                  + (if (.body // "") | test("\\S") then {description: (.body | adf)} else {} end)
                  + (if $a != "" then {assignee: {accountId: $a}} else {} end) ) }
      + (if $k != "" then {properties: [{key: "yardr.key", value: $k}]} else {} end)' <<<"$in")" |
    jq -c --arg s "$s" --arg title "$title" '{id: .key, title: $title, url: "\($s)/browse/\(.key)"}' ;;

opened) # posted's sibling for an open whose answer was lost: one search for
  # what this account made in the project since the attempt was put on
  # record (a minute earlier, for clocks that disagree), asking for the
  # key's property. The search lags the write by a few seconds: an attempt
  # run again within them may not find what is there.
  into=$(arg in | tr '[:lower:]' '[:upper:]')
  key=$(arg key)
  since=$(arg since)
  [[ $into =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "--in $(arg in): want a project key (SHED)" >&2; exit 1; }
  if [ -z "$key" ]; then
    printf '%s' '{}'
    exit 0
  fi
  s=$(site)
  mins=$(jq -nr --arg s "$since" '
    ( try ((now - ($s | sub("\\.[0-9]+"; "") | sub("[+-]00:00$"; "Z") | fromdateiso8601)) / 60 | ceil + 2) catch 1440 )
    | if . < 2 then 2 else . end')
  api POST /rest/api/3/search/jql \
    "$(jq -nc --arg q "project = \"$into\" AND creator = currentUser() AND created >= -${mins}m ORDER BY created DESC" \
      '{jql: $q, maxResults: 50, fields: ["summary"], properties: ["yardr.key"]}')" |
    jq -c --arg k "$key" --arg s "$s" '
      [.issues[]? | select(((.properties // {}) | if type == "array" then (map(select(.key == "yardr.key")) | .[0].value) else ."yardr.key" end) == $k)][0]
      | if . == null then {} else {id: .key, title: (.fields.summary // ""), url: "\($s)/browse/\(.key)"} end' ;;

state) # started, review or done: Jira moves an issue by a transition, not by
  # naming a status, and the ones it may take depend on where it stands. A
  # status is read by its category (new, indeterminate, done), which every
  # workflow has. review is the started status whose name says review,
  # else any started one; done is a done status that is not a way of not
  # doing it (won't do, cancelled, duplicate).
  thread=$(arg thread)
  want=$(arg state)
  forward=$(arg forward)
  valid_thread "$thread"
  case $want in
  started | review | done) ;;
  *)
    echo "jira has no status for \"$want\"" >&2
    exit 1 ;;
  esac
  cur=$(api GET "/rest/api/3/issue/$thread?fields=status")
  trans=$(api GET "/rest/api/3/issue/$thread/transitions")
  plan=$(jq -nc --argjson cur "$cur" --argjson tr "$trans" --arg w "$want" --arg fwd "$forward" '
    $cur.fields.status as $c
    | ($c.statusCategory.key // "") as $ck
    # a category of no known kind is past every status, as a canceled one is
    | (({"new": 1, "indeterminate": 2, "done": 3})[$ck] // 4) as $cr
    | [ $tr.transitions[]? | select(.to != null and .isAvailable != false) ] as $all
    | [ $all[] | select(.to.statusCategory.key == "indeterminate") ] as $started
    | [ $started[] | select(.to.name | test("\\breview"; "i")) ] as $reviewing
    | [ $all[] | select(.to.statusCategory.key == "done"
                        and (.to.name | test("won.?t|cancel|reject|declin|duplicate|invalid|obsolete|abandon"; "i") | not)) ] as $finishing
    | ( if $w == "done" then $finishing[0] elif $w == "review" then ($reviewing[0] // $started[0]) else $started[0] end ) as $t
    | ( if $w == "done" then $ck == "done"
        elif $w == "review" then ($ck == "indeterminate" and (($c.name | test("\\breview"; "i")) or ($reviewing | length) == 0))
        else $ck == "indeterminate" end ) as $there
    | (if $w == "done" then 3 else 2 end) as $wr
    | if $there or ($fwd == "true" and $cr > $wr) then {move: false, status: $c.name}
      elif $t == null then {error: (if ($all | length) == 0 then "no transition is open to the account from \($c.name)" else "no transition from \($c.name) to a \($w) status; it may take: \([$all[].to.name] | join(", "))" end)}
      else {move: true, id: $t.id, status: $t.to.name, was: $c.name} end')
  if [ -n "$(jq -r '.error // empty' <<<"$plan")" ]; then
    err=$(jq -r '.error | gsub("\u001b\\[[0-?]*[ -/]*[@-~]"; "") | gsub("[[:cntrl:]]"; " ") | gsub("[[:space:]]+"; " ")' <<<"$plan")
    echo "$thread: $err" >&2
    exit 1
  fi
  if [ "$(jq -r .move <<<"$plan")" = true ]; then
    api POST "/rest/api/3/issue/$thread/transitions" "$(jq -nc --arg id "$(jq -r .id <<<"$plan")" '{transition: {id: $id}}')" >/dev/null
    jq -c '{status, moved: true, was}' <<<"$plan"
  else
    jq -c '{status, moved: false}' <<<"$plan"
  fi ;;

*)
  echo "unknown: $1" >&2
  exit 2 ;;
esac
# Jira Cloud as a declared source: how the core verifies a delivery from a
# Jira admin webhook and the command that turns it into the envelope; what
# the yard does in a Jira issue, by one command per thing; and three reads
# an agent may ask for. Linear's declaration (packs/linear in the yardr
# repository) is the pattern, and Jira differs where noted.
#
# Checked against Atlassian's documentation and OpenAPI description on
# 2 November 2026, and against the real yardr in a scratch yard with a
# stand-in for Jira. Not checked against a site: a person has to make one
# (README.md says what that leaves open).
#
# The name is jira: a pack is named after the service it talks to.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: jira
about: Jira Cloud issues and comments. Hears an admin webhook and speaks in its issues.

# The names of the variables, never the values. Atlassian takes an API
# token as HTTP Basic, the base64 of "email:token". A command is handed one
# variable and a declared call's header takes one {credential}, so the
# email and the token are two mint inputs and jira/mint makes the one value
# both take, JIRA_AUTH. The email is no secret, so it is asked in the
# clear. They live in the source's env file, and in each crew member's own
# for the thread commands. hook.normalize is handed no credential at all.
credential:
  env: JIRA_AUTH
  header: "Authorization: Basic {credential}"
  mint:
    - name: JIRA_EMAIL
      secret: false
    - JIRA_API_TOKEN
  mint_command: [jira/mint]

# ---- what it sends us ----------------------------------------------------

hook:
  # Adapter.Verify stays data, checked by the core: the secret of the
  # webhook is handed to no command, and an unverified body reaches none.
  # Jira signs a secured admin webhook X-Hub-Signature: sha256=<hex>, the
  # HMAC-SHA256 of the body (Atlassian's webhooks page gives a test
  # vector, which the pack's test holds this to). Every delivery names its
  # id in X-Atlassian-Webhook-Identifier, the same on each retry.
  #
  # No timestamp window. Jira retries a failed delivery up to five times,
  # five to fifteen minutes apart, so a window narrower than an hour and a
  # quarter would refuse a genuine retry, and a wider one stops no replay
  # that the delivery id and the body do not.
  verify:
    scheme: hmac-sha256
    signed: body
    signature: { header: X-Hub-Signature, encoding: hex, prefix: "sha256=" }
    delivery: { header: X-Atlassian-Webhook-Identifier }
  # Every body names the event's time to the millisecond and its own
  # entity, so two events never share one. Two webhooks of the site that
  # both match, aimed at one hook, would send the same event twice: the
  # second is dropped here.
  bodies: unique
  # Jira's deliveries follow issue activity, not a rhythm, so silence is never quiet.
  untimed: true

  # Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
  # envelope. The core sets source and delivery itself.
  normalize: [jira/normalize]

  # The keys its events' fields may carry: a subscription's --where may
  # name them beside type, action, team, project, repo and label. project
  # is the project key, status and issuetype are names, and sender is the
  # Atlassian account id of who did it, the field the hear key below
  # matches.
  keys: [project, issuetype, status, label, sender]

# ---- who is heard ---------------------------------------------------------
# A delivery is kept with its speaker named where the sender is a user of
# the yard, matched by identity: fields.sender holds the account id, which
# normalize also sets as actor.id. The core adds fields.speaker for a kept
# delivery (the yard user's name, or outside where the hook's --unknown
# word allows an unmapped sender). speaker is the derived filter key for
# this hear, and stands in no hook.keys above, which names only what
# normalize may set.

hear:
  field: sender
  by: identity

# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under JIRA_AUTH, with YARDR_SOURCE_BASE from base below.
# jira/thread is one program. A thread is an issue, named by its key.
# Jira takes no id of ours for a comment or an issue, so reply and open
# carry the key as an entity property, in the request that makes the
# thing, and posted and opened read it back: reply posts the comment (a
# comment has no parent in Jira), posted finds what a key left ({} for
# none), read lists the issue's comments oldest first, open makes an issue
# in the project in, opened finds what an open left ({} for none), and
# state takes the transition that leads to the status the word means,
# exactly, or never backwards with forward (a stage map's move). The shapes
# are in docs/integrations.md in the yardr repository.

thread:
  reply:  [jira/thread, reply]      # in  {thread, parent, body, key}      out {id, url}
  posted: [jira/thread, posted]     # in  {thread, key}                    out {id, url} or {}
  read:   [jira/thread, read]       # in  {thread}                         out {messages: [...]}
  place:  project                   # what open's in names: a project key
  open:   [jira/thread, open]       # in  {in, title, body, key, assignee?} out {id, title, url}
  assignee: id                      # the assignee open takes: an account id
  opened: [jira/thread, opened]     # in  {in, key, since?}                out {id, title, url} or {}
  state:  [jira/thread, state]      # in  {thread, state, forward?}        out {status, moved, was?}
  states: [started, review, done]   # the words it has a status for

# ---- its issues as a bead's link ------------------------------------------
# None. The address of a Jira issue starts with the site's own host, which a
# file shared by every yard cannot name. link.example.yaml is the key for
# the yard's copy of this file (SETUP.md, step 9), and the thread commands
# give the browse address of what they touch in their answers.

# ---- what an agent may ask of it, through `yardr call` --------------------
# Declared HTTPS requests, granted one by one. A command is never something
# an agent calls.
#
# base is Jira's address, and Jira is per site, so this is a placeholder
# that SETUP.md has the operator replace in the yard's copy of this file:
# the credential goes to the hosts a file names and to no others. A token
# with scopes is called at https://api.atlassian.com/ex/jira/<cloud id>,
# and one without at https://<site>.atlassian.net. A pack applied again
# replaces the yard's copy and puts this one back (SETUP.md, step 10), and
# the commands refuse to send anything to it.
base: https://example.atlassian.net
check: viewer
calls:
  viewer:
    kind: read
    about: Who the credential acts as.
    request: { method: GET, path: /rest/api/3/myself }
    answer: { select: [accountId, displayName, emailAddress, accountType, active] }
  issue:
    kind: read
    about: Read one issue by its key, with its comments.
    params:
      key: { type: string, pattern: "^[A-Za-z][A-Za-z0-9_]*-[0-9]+$", max: 40, log: true }
    request:
      method: GET
      path: "/rest/api/3/issue/{key}"
      # The fields an agent reads an issue by. comment is one: its total is
      # the count, and its comments come with it, as ADF documents. The
      # answer is read whole, up to the 1 MiB a call may raise its bound to.
      query: { fields: "summary,status,issuetype,project,priority,assignee,reporter,labels,created,updated,resolution,description,comment" }
    answer: { select: [id, key, fields], max: 1048576 }
  search:
    kind: read
    about: Search issues by JQL, a page at a time.
    params:
      jql: { type: string, max: 2000 }
      limit: { type: int, default: 20 }
      page: { type: string, max: 512, optional: true }   # the nextPageToken of the page before
    # A search is a read, though Jira takes it as a POST. The old
    # /rest/api/3/search is being removed; this is its replacement. It wants
    # a bounded query (a clause that limits the issues, not just an order)
    # and answers ids alone unless fields says more.
    request:
      method: POST
      path: /rest/api/3/search/jql
      body: { jql: "{jql}", maxResults: "{limit}", nextPageToken: "{page}", fields: [summary, status, issuetype, project, priority, assignee, labels, updated] }
    answer: { select: [issues, nextPageToken, isLast], max: 262144 }
# The link key for the yard's copy of jira.yaml, for an operator who wants
# a bead to track a Jira issue. SETUP.md step 9 appends it after replacing
# <site host> with the site's own host (acme.atlassian.net), because the
# pack cannot name a host that is different for every yard.
#
# It tells yardr which addresses are Jira issues. The key stays as typed
# (SHED-7): the issue key a webhook carries is upper case, and a link that
# folded its case would not match the thread it belongs to.
link:
  host: <site host>
  issue: '^/browse/([A-Za-z][A-Za-z0-9_]*-[0-9]+)'
  fixed: "https://<site host>/browse/{1}"
  thread: "{1}"
# Jira

Jira Cloud as a pack of kind source, for a company whose tracker is Jira. An
admin webhook delivers issue and comment events to the yard as envelopes it
subscribes to, and the yard answers in the issue, opens issues and moves them
along their workflow, as it does in Linear. Two reads let an agent look at an
issue or search by JQL. Nothing here is compiled into yardr; apply it with:

```sh
yardr pack apply /path/to/yardr-packs/jira
```

SETUP.md is the operator's steps. `packs/linear` in the yardr repository is the
pattern, and this file says where Jira differs.

## How it hangs together

**Hearing.** A Jira admin webhook with a secret signs each delivery
`X-Hub-Signature: sha256=<hex>`, the HMAC-SHA256 of the body, and names it in
`X-Atlassian-Webhook-Identifier`. The core checks both (`hook.verify` in
`jira.yaml`). It uses the identifier to drop a retry, and the body to drop a
second webhook's copy of the same event. `jira/normalize` turns a verified body
into the envelope, with Linear's vocabulary:

| Jira event | type | action | text |
|---|---|---|---|
| `jira:issue_created` | `issue` | `create` | the description |
| `jira:issue_updated` | `issue` | `update` | `changed summary, status`, or `commented` |
| `jira:issue_deleted` | `issue` | `remove` | none |
| `comment_created`, `comment_updated`, `comment_deleted` | `comment` | `create`, `update`, `remove` | the comment |
| any other event | its own name | none | none |

The thread is the issue, named by its key (`SHED-7`) and addressed by its browse
page. The actor and `fields.sender` are the Atlassian account id of who did it,
which `hear` matches against the identities users and crew members hold in
`jira`. `mentions` holds the account ids a text mentions. A deleted comment's
delivery may name no user, and then the envelope has no actor and no sender,
rather than crediting the delete to the comment's author. `fields` carries
`project`, `issuetype`, `status`, `label` and `sender`.

A text is read as a wiki string (`[~accountid:<id>]` for a mention) or as an ADF
document (a `mention` node), because Atlassian's page names the comment's shape
"as the REST API returns it" without saying which version. Both have goldens.

**Speaking.** `jira/thread` runs reply, posted, read, open, opened and state:

| command | requests |
|---|---|
| reply | `GET serverInfo`, `POST issue/{key}/comment` with the key as a comment property |
| posted | the issue's comments newest first, then `POST comment/list?expand=properties` for each page's properties |
| read | every page of `GET issue/{key}/comment`, oldest first |
| open | `GET issue/createmeta/{project}/issuetypes`, then `POST issue` with the key as an issue property |
| opened | one `POST search/jql` for what this account made in the project since the attempt, asking for the key's property |
| state | `GET issue/{key}?fields=status`, `GET issue/{key}/transitions`, and `POST transitions` when it moves |

A thread id is an issue key, checked before it goes in a path. A text becomes ADF:
a paragraph at each blank line and a line break at each other. `read` keeps the
newest messages that fit the 64 KB a command may answer. Browse addresses are
built from `serverInfo`, because under a scoped token the base is Atlassian's
gateway and the site names itself.

Jira takes no id of ours for a comment or an issue, so `yardr.key` rides along as
an entity property and `posted` and `opened` read it back. A comment is found
exactly. An issue is found through a JQL search, which lags the write by a few
seconds, so an `open` retried within them can make a second issue.

`state` reads a status by its category (new, indeterminate, done), which every
workflow has, and takes the transition that leads to one. Its rules are in
SETUP.md step 8 and pinned by the `state-*` cases.

**Reading.** `viewer` (the check call), `issue` and `search` are declared calls at
the base, all kind read. `issue` asks for the fields an agent reads an issue by,
the comments among them. `search` is the new `search/jql` endpoint, a POST that
is still a read.

**The credential.** Atlassian takes an API token as Basic, `email:token`. A
command is handed one variable and a call's header takes one, so the two are
mint inputs and `jira/mint` makes the base64 both use (`JIRA_AUTH`). A token with
scopes is called at `https://api.atlassian.com/ex/jira/<cloud id>` and one
without at the site. Both take Basic, so only the base differs.

**The base.** Jira is per site, so the declaration ships the placeholder
`https://example.atlassian.net` and SETUP.md has the operator write their own
into the yard's copy. A pack applied again puts the placeholder back; the test
pins that, and SETUP.md step 10 says what to do. `jira/thread` refuses to send
anything to the placeholder. If a real user trips on this, a core `base_url`
setting becomes its own bead.

**No link key.** An issue's address starts with the site's host, which the shared
file cannot name. Without the key a bead cannot track a Jira issue and a stage
map has nothing to move, but the thread commands work, since they take the issue
key. `link.example.yaml` is the key for the yard's copy, and the test holds it to
the real yardr.

## Files

- `pack.yaml`, `jira.yaml`: the pack, with the `read` preset, and the declaration.
- `jira/normalize`: POSIX sh and jq, a verified body to the envelope.
- `jira/thread`: bash and curl and jq. The credential goes to curl on its stdin
  as a config line, never in an argument, and `-q` keeps a `.curlrc` from
  tracing it into an error. Written for bash 3.2, which macOS ships.
- `jira/mint`: sh and jq, the Basic credential from the email and the token. It
  prints a fixed line on failure, since jq's own errors quote the value.
- `link.example.yaml`: the optional link key.
- `testdata/`: webhook bodies and goldens, thread cases and goldens, a stand-in
  `curl`, a stand-in for Jira's API, and the declaration golden.

## Runtimes

`jira/normalize` and `jira/mint` need `sh` and `jq` 1.6 or later. `jira/thread`
also needs `bash` and `curl`. The test needs those too, and for its last part
`yardr`, `python3` and `openssl`; without `yardr` or `python3` it says so and goes
on.

## Testing

```sh
./test
```

in this directory, about five seconds, no network and no secret. The gate
(`.yardr/test`) runs `yardr pack check` first.

- `normalize` against each body in `testdata/hook`, byte for byte, and three
  bodies it must refuse without quoting them.
- `mint`: the value, the expiry, and what it says when an input is missing.
- `thread`: each `testdata/thread/*.case` is one run against a stand-in `curl`.
  The case's golden holds the requests the program made (method, address,
  headers, body, and whether the credential was the right one), the exit status,
  stdout and stderr. A changed request is a changed golden. The run fails if the
  token or the credential is in an argument, an output or the log.
- the declaration as the yard reads it, calls included, against
  `testdata/declaration.golden`; the signature the declaration describes against
  Atlassian's published example; the check call made through the real yardr and
  minted by `jira/mint`, against a stand-in on a loopback port; and a pack
  applied again over an edited address.

`UPDATE_GOLDEN=1 ./test` writes the goldens again. Read the diff before you
commit it.

## What was checked, and what was not

Checked on 2 November 2026 against Atlassian's pages: the webhook signature, its
identifier and retry rules, the `search/jql` endpoint and its parameters, the
comment, transition, issue and property endpoints in the OpenAPI description,
which tokens go where and with what scheme, and the scopes each call needs.
Seen on public Atlassian sites without a login: the shapes of comments and
issues, `tenant_info`, and `serverInfo` answering through the gateway with the
site's address.

Not checked against a site, because a person has to make one (a free Atlassian
Cloud site serves this pack and the Confluence one). The live proof is a later
bead. It should settle:

- the shape of a webhook's comment body, a string or ADF, and whether a comment
  delivery has a `user`;
- that a token with scopes is accepted as Basic at the gateway, as Atlassian's
  support pages say. A community thread says only Bearer works there. If a site
  answers 401, the fix is in `jira/mint` and the header in `jira.yaml`;
- that a `properties` entry in a create request is kept for an issue and a
  comment, and whether a search returns the property as an object or a list (both
  are read);
- how long the search lags a write, which bounds `opened`;
- the transitions a real workflow offers, and which ones want a screen filled in;
- the names Jira's admin screen uses for the webhook's fields.

## Left out

Jira Service Management and its queues, custom-field editing beyond what a
transition needs, polling (the webhook covers the events), per-person identity
(yardr's B11 is not built), and the Confluence pack, which shares the login
story only (yardr-packs-19te).
# Setting up Jira

The pack wires Jira Cloud into the yard as the declared source `jira`. A Jira
webhook delivers issue and comment events to the yard. The yard answers in the
issue, opens issues and moves them along their workflow, and an agent may read
an issue or search by JQL. What a stage means in Jira is a flow pack's to say,
not this file's.

**The yard acts as one Atlassian account.** Every crew member given the
account's token, and every agent granted a call of this source, is that account
to Jira. It sees what the account sees, in every project the account belongs
to, and nobody is asked before it posts a comment, opens an issue or moves one.
The grant is the permission. Make the account for the yard alone, and give it
the projects the yard should touch and no others.

You need a Jira Cloud site, an administrator of it for step 4, and a public
HTTPS address for the yard's hooks. The pack's programs need `bash`, `curl` and
`jq` on the yard's machine. Commands below call the pack's directory (the `jira`
directory of the yardr-packs checkout) `<pack dir>`.

1. **The account and its token, in Atlassian.** Make an Atlassian account that
   only the yard uses. A service account fits best. Make it in Atlassian
   Administration, under Directory, Service accounts; an organization may have
   five for free. An ordinary account with an email of its own works too. Add it
   to the projects the yard should touch, with a role that may browse them,
   comment, create issues and transition them. The newer Jira screens call
   projects spaces.

   Then make its API token, with scopes. Atlassian recommends that, and gives a
   service account no other kind.
   - A service account: Atlassian Administration, Directory, Service accounts,
     the account, Create credential, API token.
   - An ordinary account: sign in as it, open
     https://id.atlassian.com/manage-profile/security/api-tokens and choose
     Create API token with scopes.

   Choose Jira and the scopes `read:jira-work`, `write:jira-work` and
   `read:jira-user`. Atlassian shows the token once. A token lasts 1 to 365 days,
   a year unless you choose otherwise, so note the day it ends. Step 10 says what
   to do then. A token made without scopes (Create API token) also works, and
   can do everything the account can. Step 2 says where it goes.

2. **The pack and the site's address.** Apply the pack as the operator:
   ```sh
   yardr pack apply <pack dir>
   ```
   The declaration it installs gives the source a placeholder address,
   `https://example.atlassian.net`. A source's credential goes only to the hosts
   its file names, so the yard's copy of the file must name yours. A token with
   scopes is called through Atlassian's gateway,
   `https://api.atlassian.com/ex/jira/<cloud id>`. Your cloud id is the string
   after `/s/` in the address of admin.atlassian.com, and the site gives it
   without a login:
   ```sh
   curl -s https://<site>.atlassian.net/_edge/tenant_info
   ```
   A token without scopes is called at the site, `https://<site>.atlassian.net`.
   Write the address you need into a copy of the declaration, beside the
   original so that the programs it names are found, and add the copy to the
   yard. The copy is ignored by git.
   ```sh
   sed 's|^base: .*|base: https://api.atlassian.com/ex/jira/<cloud id>|' <pack dir>/jira.yaml > <pack dir>/jira.local.yaml
   ```
   ```sh
   yardr source add <pack dir>/jira.local.yaml
   ```
   ```sh
   yardr source show jira
   ```
   The first line of `source show` is the address in use. Until it is yours,
   `yardr source check jira` calls the placeholder and fails, and the thread
   commands refuse to run and say why.

3. **The account's credentials, in the yard.** The credential is two names:
   `JIRA_EMAIL`, the account's email, and `JIRA_API_TOKEN`. The pack's `jira/mint`
   makes the Basic credential Atlassian takes from them. They live in env files,
   mode 0600, and never in the store.

   For the source's own calls, `yardr source key` asks for both and shows
   nothing as they are typed. It writes the file the source reads
   (`~/.yardr/sources/jira.env`). Then check the source:
   ```sh
   yardr source key jira
   ```
   ```sh
   yardr source check jira
   ```
   The check calls `GET /rest/api/3/myself` and prints the account: its
   `accountId`, name and email. Keep the `accountId` for step 6.

   A crew member speaks in Jira with the thread commands, and those read the
   crew member's own env file. Have the crew member ask for it, and answer on
   its card, in footplate or in `yardr chat` (`ctrl+o`), where the fields show
   dots:
   ```sh
   yardr bead ask <bead> "The email and token of the Jira account it speaks as" --secret jira --for-crew <name> --context "The yard's Jira account: its email, and an API token with the scopes of SETUP.md step 1."
   ```
   The yard's server writes both into the crew member's env file, or with none
   set into `~/.yardr/crew/<name>.env`, which is then set as `yardr crew set`
   sets it. By hand it is yours instead. A new file takes its mode from the
   shell's umask, so narrow it first. The token is never a command's word,
   because that line would stay in the shell's history. Each command below is one
   thing to type, in a block of its own. Run the third, paste the token blind and
   press Enter:
   ```sh
   umask 077
   ```
   ```sh
   printf 'JIRA_EMAIL=%s\n' '<the account email>' > ~/.yardr/crew/<name>.env
   ```
   ```sh
   IFS= read -rs v && printf 'JIRA_API_TOKEN=%s\n' "$v" >> ~/.yardr/crew/<name>.env; unset v
   ```
   ```sh
   chmod 600 ~/.yardr/crew/<name>.env
   ```
   ```sh
   yardr crew set <name> env_file=~/.yardr/crew/<name>.env
   ```
   The running session does not see a new value: restart the crew member after
   giving it.

4. **The webhook, in Jira.** Sign in as a Jira administrator, not as the yard's
   account: a webhook takes the Administer Jira permission. Choose Settings,
   System, and under Advanced, WebHooks, then create one. Fill it in:
   - The URL is `https://<public host>/hooks/<hook name>`. The path ends in the
     hook's name, the one step 5 gives. Jira delivers to HTTPS addresses whose
     certificate a trusted authority signed, on port 443.
   - The secret signs each delivery. Let Jira generate one and copy it before you
     save, because Jira never shows it again.
   - The events are Issue created and updated, and Comment created, updated and
     deleted. The yard reads those and nothing else.
   - A JQL filter limits the webhook to the projects the yard should hear, such
     as `project in (SHED, OPS)`. Without one Jira sends every issue of the site.
   - Exclude body stays off.

   If the yard's address sits behind a firewall, allow Atlassian's outgoing
   addresses, which https://ip-ranges.atlassian.com/ lists.

5. **The hook and its secret, in the yard.** The secret goes in a file, mode
   0600, which the hook names. A new file takes its mode from the shell's umask,
   so narrow it first. Each command below is one thing to type, in a block of its
   own. Run the second, paste the secret blind and press Enter:
   ```sh
   umask 077
   ```
   ```sh
   IFS= read -rs v && printf '%s' "$v" > ~/.yardr/jira.secret; unset v
   ```
   ```sh
   chmod 600 ~/.yardr/jira.secret
   ```
   ```sh
   yardr hook add jira --source jira --secret-file ~/.yardr/jira.secret --unknown allow
   ```
   One hook serves every crew member. `--unknown allow` hears every site member's
   comment, and keeps a delivery from a sender no yard user maps with the speaker
   `outside`. Give each person their identity (step 6) and their deliveries
   arrive named as them, which a subscription may filter with
   `--where speaker=<user>`.

   A crew member sets the hook up without handling the secret, by two asks on
   one bead, the secret first. The operator pastes the secret where Jira showed
   it, typed unseen, and the yard's server writes it to
   `<home>/hooks/jira.secret`:
   ```sh
   yardr bead ask <id> "The signing secret of the Jira webhook" --secret-hook jira --source jira --context "Jira shows it once, when the webhook is made."
   ```
   ```sh
   yardr bead ask <id> "Add the Jira hook" --run "hook add jira --source jira --unknown allow" --context "The webhook of SETUP.md step 4 posts to it."
   ```

6. **Its identity.** The account id from step 3 is how a delivery is matched to
   the crew member. It is mentioned when a comment or a description mentions the
   account, assigned when an issue's assignee is the account, and deaf to what it
   did itself. That last one matters, because the yard's own comments come back
   as webhooks.
   ```sh
   yardr crew identity <name> --source jira --id <account id>
   ```
   A person's account id ends the address of their Jira profile,
   `https://<site>.atlassian.net/jira/people/<account id>`. Their identity is what
   makes a comment of theirs arrive named as them, and what `yardr thread open
   --assign <user>` assigns by. An id names one user.
   ```sh
   yardr user identity <user> --source jira --id <their account id>
   ```

7. **The grants and who hears.** Opening issues and moving them are the
   operator's to grant:
   ```sh
   yardr crew set <name> open_threads=jira set_state=jira
   ```
   The reads an agent may ask for through `yardr call` are granted by name.
   `read` covers all three: `viewer` (who the account is), `issue` (one issue with
   its comments) and `search` (a JQL query, a page at a time). `yardr pack show
   jira` prints the pack's suggestion. A group of agents takes `--group <group>`
   in place of `--crew <name>`.
   ```sh
   yardr source grant jira read --crew <name>
   ```
   A crew member hears the source through a subscription. `mentions` hears what
   mentions it. `involved`, the default for a hook source, adds what is assigned
   to it and the threads it has opened or answered in. `all` hears every delivery
   the hook keeps.
   ```sh
   yardr subscribe <name> jira --scope involved
   ```

8. **Test it.** As the crew member, open an issue, mention the account from
   Jira, then move the issue:
   ```sh
   yardr thread open jira --in <project key> "Hello from the yard" -b "testing the wiring"
   ```
   In Jira, comment on that issue and type `@` and the account's name. The
   comment lands in the crew member's inbox as mentioned. `yardr thread list
   <name>` shows the thread, whose id is the issue key.
   ```sh
   yardr thread state <thread id> started
   ```
   Jira moves an issue by a transition, so the word picks the transition that
   leads to a status of the right kind. `started` takes a status of the In
   Progress kind. `review` takes one of that kind whose name says review, and
   otherwise does what `started` does. `done` takes a status of the Done kind
   that is not a way of not doing the work: Won't Do, Cancelled, Duplicate and
   the like are skipped. Where the workflow offers no such transition from the
   issue's status, the command says so and moves nothing. A transition that asks
   for a field on a screen, a resolution for instance, fails with Jira's own
   message and the issue stays where it was.

   To open an issue assigned to a person, give them their identity (step 6) and
   name them:
   ```sh
   yardr thread open jira --in <project key> --assign <user> "For you"
   ```

9. **Beads that track issues (optional).** The shipped declaration has no `link`
   key, because the address of a Jira issue starts with your site's own host and
   no pack can name that. Without the key, `yardr link add <bead> <Jira address>`
   is refused (yardr knows no issues there), so a bead cannot track a Jira issue
   and a stage map has nothing to move. The thread commands do not need it. They
   work on the thread's id, which is the issue key.

   To have it, add the key to your copy from `link.example.yaml`, with your site's
   own host. Start from a fresh copy so the key is added once: run step 2's `sed`
   again, then
   ```sh
   sed 's|<site host>|<site>.atlassian.net|g' <pack dir>/link.example.yaml >> <pack dir>/jira.local.yaml
   ```
   ```sh
   yardr source add <pack dir>/jira.local.yaml
   ```
   After that `yardr link add <bead> https://<site>.atlassian.net/browse/SHED-7`
   makes the bead track the issue. The key stays as typed, and anything after the
   issue in the address, such as `?focusedCommentId=`, is dropped.

   A stage map moves the tracked issue when its bead enters a stage, as the crew
   member, and never backwards. `hook add` replaces the whole definition, so give
   the secret file and `--unknown allow` again:
   ```sh
   yardr hook add jira --source jira --secret-file ~/.yardr/jira.secret --unknown allow --as <name> --map task:new=started --map task:review=review --map task:merged=done
   ```

10. **Updating the pack, and when the token ends.** A pack applied again replaces
    the source's declaration whole, and the placeholder address comes back with
    it. Your env file, the grants and the hook stay. After pulling the checkout,
    read what would change:
    ```sh
    yardr pack check <pack dir>
    ```
    If you apply the pack, the plan lists the address as `base: <your address> ->
    https://example.atlassian.net`. That line is how you notice, and it is the
    edit being undone. Until you add your copy again, `yardr source check jira`
    calls the placeholder, a host no yard owns, and the thread commands refuse to
    run. The shorter way skips the apply. Make the copy again from the new
    `jira.yaml` with step 2's `sed`, add it with `yardr source add`, and repeat
    step 9 if you use the key. The source then never points at the placeholder.

    When the token ends, make a new one as in step 1 and give it again: `yardr
    source key jira` for the source, and each crew member's card or file from
    step 3, then restart them. A token that expired or was revoked shows as a 401
    from `yardr source check jira`.

11. **Ending.** Disable or delete the webhook in Jira (Settings, System,
    WebHooks), and remove the hook. Events the hook accepted are kept. Revoke the
    token at id.atlassian.com, or delete the service account's credential in
    Atlassian Administration.
    ```sh
    yardr hook rm jira
    ```
#!/bin/sh
# The jira pack's tests: what the gate runs beside yardr pack check.
# Needs no network and no secret, done in seconds.
#
#   hook     each webhook body in testdata/hook is turned into the yard's
#            envelope by jira/normalize; its output must match the golden
#            beside it byte for byte. The documented signature of a known
#            payload is checked against what the declaration says of the
#            signature's header, prefix and encoding.
#   mint     jira/mint makes the Basic credential from the email and the
#            token, and says nothing of either when it cannot.
#   thread   each testdata/thread/*.case is one run of jira/thread against a
#            stand-in curl (testdata/bin/curl) that records each request and
#            answers from the case. The case's golden holds the requests (a
#            changed request is a changed golden), the exit status, stdout
#            and stderr. No secret reaches an argument, an output or the
#            log: the stand-in checks the arguments, and the run checks
#            the rest.
#   core     the declaration as the yard reads it, its calls pinned to a
#            golden; the check call made through the real yardr against a
#            loopback stand-in, minted by jira/mint; and what a pack applied
#            again does to a host the operator edited. These run in a
#            scratch yard of their own and want yardr and python3; where one
#            is missing the test says so and goes on.
#
# UPDATE_GOLDEN=1 ./test writes the goldens again: read the diff before
# committing it.
set -u
cd "$(dirname "$0")"
here=$(pwd)
fail=0
tmp=$(mktemp -d)
cleanup() {
  [ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
  if [ -n "${core:-}" ]; then
    # The scratch yard's server starts on its first command and would
    # outlive the directory it owns: stop it first.
    env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
    rm -rf "$core"
  fi
  rm -rf "$tmp"
}
trap cleanup EXIT HUP INT TERM

# check <name> <actual file> <golden file>
check() {
  if [ "${UPDATE_GOLDEN:-}" = 1 ]; then
    if ! cmp -s "$2" "$3"; then cp "$2" "$3"; echo "test: $1 golden written"; fi
    return
  fi
  if [ ! -e "$3" ]; then
    echo "test: $1 has no golden $3 (UPDATE_GOLDEN=1 ./test writes it)"
    fail=1
  elif cmp -s "$2" "$3"; then
    echo "test: $1 ok"
  else
    echo "test: $1 differs from $3:"
    diff "$3" "$2" | sed 's/^/    /' | head -30
    fail=1
  fi
}
# ok <name> <condition's exit status> [detail]
ok() {
  if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}

# The account the tests use is a fixture, not a secret.
email=ada@example.com
token=tok-FIXTURE-0123456789abcdef
auth=$(printf '{"inputs":{"JIRA_EMAIL":"%s","JIRA_API_TOKEN":"%s"},"scope":""}' "$email" "$token" | ./jira/mint | jq -r .token)

# ---- hook ------------------------------------------------------------------
for f in testdata/hook/*.json; do
  n=$(basename "$f" .json)
  env -i PATH="$PATH" YARDR_SOURCE_BASE=https://acme.atlassian.net ./jira/normalize <"$f" >"$tmp/$n.out" 2>"$tmp/$n.err"
  check "normalize $n" "$tmp/$n.out" "testdata/hook/$n.golden"
  [ ! -s "$tmp/$n.err" ]
  ok "normalize $n prints nothing to stderr" $? "$(head -c 200 "$tmp/$n.err")"
done

# Every key an envelope's fields carry is one the declaration lists in
# hook.keys (or one the core knows already), and every key it lists is
# set by some envelope here.
keys=$(sed -n 's/^  keys: \[\(.*\)\]$/\1/p' jira.yaml | tr -d ' ' | tr ',' ' ')
seen=$(for g in testdata/hook/*.golden; do jq -r '.fields // {} | keys[]' "$g"; done | sort -u | tr '\n' ' ')
bad=
for k in $seen; do
  case " $keys team project repo label " in *" $k "*) ;; *) bad="$bad $k is set and in no hook.keys;" ;; esac
done
for k in $keys; do
  case " $seen " in *" $k "*) ;; *) bad="$bad hook.keys lists $k and no envelope sets it;" ;; esac
done
[ -z "$bad" ]
ok "fields and hook.keys agree" $? "$bad"

# refuse <name> <body> <stderr wanted>: a body normalize will not read is
# refused with a fixed line that quotes none of it.
refuse() {
  printf '%s' "$2" | env -i PATH="$PATH" ./jira/normalize >"$tmp/r.out" 2>"$tmp/r.err"
  rc=$?
  line=$(sed 's/^jq: error (at [^)]*): //' "$tmp/r.err")
  [ "$rc" != 0 ] && [ ! -s "$tmp/r.out" ] && [ "$line" = "$3" ]
  ok "normalize refuses $1" $? "exit $rc, stdout $(wc -c <"$tmp/r.out") bytes, stderr: $line"
}
refuse "a body that is not an object" '{"headers":{},"body":"text"}' "jira body is not an object"
refuse "a delivery with no event" '{"headers":{},"body":{"issue":{"key":"X-1"}}}' "jira delivery names no webhookEvent"
refuse "a shape it cannot read, without quoting it" '{"headers":{},"body":{"webhookEvent":"jira:issue_created","issue":{"key":"X-1","fields":"QUOTED-BODY-TEXT"}}}' "jira body is not in a shape normalize reads"

# ---- mint ------------------------------------------------------------------
printf '{"inputs":{"JIRA_EMAIL":"%s","JIRA_API_TOKEN":"%s"},"scope":"SHED-7"}' "$email" "$token" | ./jira/mint >"$tmp/mint.out"
want=$(printf '%s:%s' "$email" "$token" | base64 | tr -d '\n')
[ "$(jq -r .token "$tmp/mint.out")" = "$want" ]
ok "mint makes base64 of email:token" $?
jq -e '(.expires_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and (.expires_at | fromdateiso8601) > now and ((keys | sort) == ["expires_at", "token"])' "$tmp/mint.out" >/dev/null
ok "mint answers a future RFC 3339 expiry and nothing else" $?
i=0
for bad in '{"inputs":{"JIRA_EMAIL":"'"$email"'"}}' '{"inputs":{"JIRA_API_TOKEN":"'"$token"'"}}' '{"inputs":{"JIRA_EMAIL":"","JIRA_API_TOKEN":"'"$token"'"}}' '{"inputs":"'"$token"'"}' 'not json '"$token"; do
  i=$((i + 1))
  printf '%s' "$bad" | ./jira/mint >"$tmp/m.out" 2>"$tmp/m.err"
  rc=$?
  [ "$rc" = 1 ] && [ ! -s "$tmp/m.out" ] && [ "$(cat "$tmp/m.err")" = "jira/mint: want JIRA_EMAIL and JIRA_API_TOKEN among the inputs" ]
  ok "mint refuses input $i with a fixed line that quotes none of it" $?
done

# ---- thread ----------------------------------------------------------------
ago=$(jq -nr 'now - 300 | todate')
# A case file is sections, each opened by a line "=== name": cmd (the
# command), base (YARDR_SOURCE_BASE, the shipped placeholder's host
# otherwise), in (the object on stdin) and answer_n (the nth request's
# answer: its status, then its body).
for c in testdata/thread/*.case; do
  n=$(basename "$c" .case)
  d="$tmp/case_$n"
  mkdir "$d"
  # @AUTH@ is the credential the run is given and @AGO@ a time five minutes
  # back, where a fixture needs the run's own (an answer that echoes the
  # credential, an open whose attempt was put on record a moment ago).
  sed -e "s|@AUTH@|$auth|g" -e "s|@AGO@|$ago|g" "$c" | awk -v dir="$d" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } { print >> f }'
  base=https://acme.atlassian.net
  [ ! -e "$d/base" ] || base=$(cat "$d/base")
  : >"$tmp/requests"
  rm -f "$tmp/requests.n"
  env -i PATH="$here/testdata/bin:$PATH" HOME="$tmp" JIRA_AUTH="$auth" YARDR_SOURCE_BASE="$base" \
    FAKE_CASE="$d" FAKE_LOG="$tmp/requests" FAKE_AUTH="Basic $auth" FAKE_SECRETS="$token $auth" \
    ./jira/thread "$(cat "$d/cmd")" <"$d/in" >"$tmp/stdout" 2>"$tmp/stderr"
  status=$?
  {
    echo "--- requests"
    cat "$tmp/requests"
    echo "--- status"
    echo "$status"
    echo "--- stdout"
    cat "$tmp/stdout"
    [ ! -s "$tmp/stdout" ] || [ "$(tail -c 1 "$tmp/stdout" | wc -l)" -ne 0 ] || echo
    echo "--- stderr"
    cat "$tmp/stderr"
  } >"$tmp/golden"
  if grep -q '@AGO@' "$c"; then
    # The minutes a search looks back are the clock's: the golden has a mark
    # for them, and the range is asserted here.
    sed 's/created >= -[0-9][0-9]*m/created >= -<N>m/' "$tmp/golden" >"$tmp/golden.masked"
    mins=$(sed -n 's/.*created >= -\([0-9][0-9]*\)m.*/\1/p' "$tmp/requests" | head -1)
    [ -n "$mins" ] && [ "$mins" -ge 7 ] && [ "$mins" -le 12 ]
    ok "thread $n looks back the minutes since the attempt was put on record" $? "${mins:-no created clause} minutes for 5 (7 to 12 allowed)"
  else
    cp "$tmp/golden" "$tmp/golden.masked"
  fi
  check "thread $n" "$tmp/golden.masked" "testdata/thread/$n.golden"
  # No secret anywhere a program can put one.
  ! grep -qF -e "$token" -e "$auth" "$tmp/golden"
  ok "thread $n leaks no secret" $?
  ! grep -q "LEAK" "$tmp/requests"
  ok "thread $n passes no secret in an argument" $?
done

# Without its credential the program says what is missing and sends nothing.
: >"$tmp/requests"
rm -f "$tmp/requests.n"
printf '{"thread":"SHED-7"}' | env -i PATH="$here/testdata/bin:$PATH" HOME="$tmp" YARDR_SOURCE_BASE=https://acme.atlassian.net \
  FAKE_CASE="$tmp" FAKE_LOG="$tmp/requests" ./jira/thread read >"$tmp/stdout" 2>"$tmp/stderr"
rc=$?
[ "$rc" != 0 ] && [ ! -s "$tmp/requests" ] && grep -q 'JIRA_AUTH is not set' "$tmp/stderr"
ok "thread without JIRA_AUTH says so and sends nothing" $? "exit $rc"

# ---- core ------------------------------------------------------------------
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
if [ ! -x "$YARDR" ]; then
  echo "test: core skipped: no yardr ($YARDR)"
elif ! command -v python3 >/dev/null 2>&1; then
  echo "test: core skipped: python3 is not on PATH"
else
  . ./testdata/core.sh
fi

if [ "$fail" = 0 ]; then echo "test: jira: all ok"; else echo "test: jira: FAILED"; fi
exit $fail
#!/bin/sh
# A stand-in curl for the thread tests, put first on the program's PATH. It
# answers the options jira/thread uses and no others, records each request
# in $FAKE_LOG, and answers from the case's fixtures: the nth request gets
# $FAKE_CASE/answer_n, whose first line is the status and the rest the body
# (a case with none for it answers 599, and a file named curl_exit_n makes
# the nth request fail as a refused connection does).
#
# The credential comes on the stdin config line, as the real program sends
# it. The log says only whether it was the one expected ($FAKE_AUTH) and
# whether any argument held a secret ($FAKE_SECRETS): the test fails if a
# secret reaches an argument, and the log never holds one.
args="$*"
config=$(cat)
out= fmt= method= url= body= headers=
while [ $# -gt 0 ]; do
  case $1 in
  -q | -s | -S | -sS) ;;
  -K) shift ;;
  -o) shift; out=$1 ;;
  -w) shift; fmt=$1 ;;
  -X) shift; method=$1 ;;
  -H) shift; headers="$headers  header: $1
" ;;
  -d) shift; body=$1 ;;
  -*) echo "stand-in curl: no option $1" >&2; exit 2 ;;
  *) url=$1 ;;
  esac
  shift
done
n=$(cat "$FAKE_LOG.n" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" >"$FAKE_LOG.n"
got=$(printf '%s\n' "$config" | sed -n 's/^header = "Authorization: \(.*\)"$/\1/p')
if [ "$got" = "$FAKE_AUTH" ]; then auth=ok; elif [ -z "$got" ]; then auth=missing; else auth=WRONG; fi
leak=
for s in $FAKE_SECRETS; do
  case $args in *"$s"*) leak="  LEAK: an argument holds a secret
" ;; esac
done
{
  printf '%s %s\n' "$method" "$url"
  printf '  auth: %s\n' "$auth"
  printf '%s' "$headers"
  [ -z "$body" ] || printf '  body: %s\n' "$(printf '%s' "$body" | jq -S -c . 2>/dev/null || echo "NOT JSON")"
  printf '%s' "$leak"
} >>"$FAKE_LOG"
if [ -e "$FAKE_CASE/curl_exit_$n" ]; then
  echo "curl: (7) Failed to connect to stand-in" >&2
  exit 7
fi
if [ -r "$FAKE_CASE/answer_$n" ]; then
  status=$(sed -n 1p "$FAKE_CASE/answer_$n")
  sed 1d "$FAKE_CASE/answer_$n" >"$out"
else
  status=599
  printf '%s' '{"errorMessages":["the stand-in has no answer for this request"]}' >"$out"
fi
case $fmt in *'%{http_code}'*) printf '%s' "$status" ;; esac
GET /rest/api/3/myself
  auth: ok
  accept: application/json
# The core part of the jira test, sourced by ./test: the declaration as the
# yard reads it, the check call made through the real yardr, and a pack
# applied again over an edited host. Everything runs in a scratch yard
# under a short path (a unix socket's path is held to 100 bytes) with its
# own home and an environment of nothing but PATH: none of this session's
# YARDR_* variables lead back to a real yard. No daemon is started, so no
# agent is, and the only listener is the stand-in's, on 127.0.0.1 and a
# port the system chooses.
core=$(mktemp -d /tmp/jirat.XXXXXX)
mkdir -p "$core/h"
yrun() { env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester "$@"; }

if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
  echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
  fail=1
else
  # -- the declaration as the yard reads it
  if yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
    yrun source show jira --json | jq -S .declaration >"$core/declaration.json"
    check "declaration as the yard reads it (base, credential, hook, hear, thread, calls)" "$core/declaration.json" testdata/declaration.golden

    # Atlassian's own test vector for X-Hub-Signature: the declaration's
    # header, prefix and encoding, applied to their secret and payload, make
    # their signature.
    hv=$(jq -c .hook.verify "$core/declaration.json")
    sig=$(printf 'Hello World!' | openssl dgst -sha256 -hmac "It's a Secret to Everybody" | awk '{print $NF}')
    [ "$(jq -r '.scheme + " " + .signed + " " + .signature.header + " " + .signature.encoding + " " + .delivery.header' <<<"$hv")" = "hmac-sha256 body X-Hub-Signature hex X-Atlassian-Webhook-Identifier" ] \
      && [ "$(jq -r .signature.prefix <<<"$hv")$sig" = "sha256=a4771c39fbe90f317c7824e83ddef3caae9cb3d976c214ace1f2937e133263c9" ]
    ok "hook.verify reads Atlassian's documented signature (header, prefix, hex over the body) and delivery id" $?
  else
    echo "test: core FAILED: the pack did not apply: $(tail -c 300 "$core/apply.out")"
    fail=1
  fi

  # -- the check call, through the real yardr, minted by jira/mint
  # The operator's copy: the pack's declaration with its base replaced, and
  # the programs beside it. Here the site is the stand-in.
  mkdir -p "$core/mine"
  cp "$here/jira.yaml" "$core/mine/"
  cp -R "$here/jira" "$core/mine/"
  : >"$core/requests"
  python3 -I testdata/standin.py "$core/port" "$core/requests" testdata/myself.json "$auth" 2>"$core/standin.err" &
  standin_pid=$!
  i=0
  while [ ! -s "$core/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
  if [ ! -s "$core/port" ]; then
    echo "test: core FAILED: the stand-in did not listen: $(cat "$core/standin.err")"
    fail=1
  else
    site=http://127.0.0.1:$(cat "$core/port")
    sed "s|^base: .*|base: $site|" "$here/jira.yaml" >"$core/mine/jira.yaml"
    umask 077
    printf 'JIRA_EMAIL=%s\nJIRA_API_TOKEN=%s\n' "$email" "$token" >"$core/jira.env"
    umask 022
    yrun source add "$core/mine/jira.yaml" >"$core/add.out" 2>&1 && yrun source set jira env_file="$core/jira.env" >>"$core/add.out" 2>&1
    ok "the operator's copy is added and its env file set" $? "$(tail -c 300 "$core/add.out")"
    yrun source check jira >"$core/check.out" 2>&1
    rc=$?
    grep -q 'Yard Bot' "$core/check.out" && grep -q '5b10ac8d82e05b22cc7d4ef5' "$core/check.out" && [ "$rc" = 0 ]
    ok "source check reaches the site with a minted credential and shows the account" $? "exit $rc: $(tail -c 300 "$core/check.out")"
    ! grep -q -e 'avatarUrls' -e 'Europe/Warsaw' -e 'applicationRoles' "$core/check.out"
    ok "source check keeps to the fields the call selects" $?
    ! grep -qF -e "$token" -e "$auth" "$core/check.out" "$core/add.out"
    ok "the token is in no output of the yard's commands" $?
    check "the check call's request" "$core/requests" testdata/check.requests.golden

    # -- a pack applied again over an edited host
    yrun source show jira >"$core/show.before-grant" 2>&1
    yrun source grant jira read --crew yardmaster >/dev/null 2>&1
    umask 077
    printf 'a secret for the hook' >"$core/jira.secret"
    umask 022
    yrun hook add jira --source jira --secret-file "$core/jira.secret" --unknown allow >/dev/null 2>&1
    yrun source show jira >"$core/show.edited" 2>&1
    yrun pack check "$here" >"$core/plan.out" 2>&1
    yrun pack apply "$here" --yes >"$core/reapply.out" 2>&1
    yrun source show jira >"$core/show.reapplied" 2>&1
    grep -qF "$site" "$core/show.edited" && ! grep -qF "https://example.atlassian.net" "$core/show.edited"
    ok "the operator's host is what the source calls after the edit is added" $?
    grep -qF "$site" "$core/plan.out"
    ok "the plan of an update names the host it would take away (how the operator notices)" $?
    grep -qF "https://example.atlassian.net" "$core/show.reapplied" && ! grep -qF "$site" "$core/show.reapplied"
    ok "a pack applied again puts the shipped host back (the edit does not survive)" $?
    grep -qF "$core/jira.env" "$core/show.reapplied"
    ok "the env file stays set across the re-apply" $?
    ! grep -q yardmaster "$core/show.before-grant" && grep -q yardmaster "$core/show.edited" && grep -q yardmaster "$core/show.reapplied"
    ok "a grant of the source stands across the re-apply" $?
    yrun hook list 2>&1 | grep -q '^jira '
    ok "the hook stands across the re-apply" $?
    yrun source add "$core/mine/jira.yaml" >/dev/null 2>&1
    yrun source show jira >"$core/show.readded" 2>&1
    grep -qF "$site" "$core/show.readded" && grep -qF "$core/jira.env" "$core/show.readded"
    ok "adding the edited copy again restores the host, and the env file still stands" $?

    # -- links: none in the pack, and the optional key SETUP.md offers
    bead=$(yrun bead create "a bead to link" --json 2>/dev/null | jq -r .id)
    issue_url=https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10300
    yrun link add "$bead" "$issue_url" >"$core/link.none" 2>&1
    [ $? != 0 ] && ! yrun link list "$bead" 2>&1 | grep -q 'SHED-7'
    ok "without a link key a Jira address links no bead (the per-site cost)" $?
    cp "$core/mine/jira.yaml" "$core/mine/jira.local.yaml"
    sed 's|<site host>|acme.atlassian.net|g' "$here/link.example.yaml" >>"$core/mine/jira.local.yaml"
    yrun source add "$core/mine/jira.local.yaml" >/dev/null 2>&1
    yrun link add "$bead" "$issue_url" >"$core/link.some" 2>&1
    yrun link list "$bead" >"$core/link.list" 2>&1
    grep -qF 'https://acme.atlassian.net/browse/SHED-7' "$core/link.list" && ! grep -q 'focusedCommentId' "$core/link.list"
    ok "with link.example.yaml a Jira address links a bead, kept as the issue's one form" $?
  fi
fi
[ -z "${standin_pid:-}" ] || { kill "$standin_pid" 2>/dev/null; standin_pid=; }
{
  "about": "Jira Cloud issues and comments. Hears an admin webhook and speaks in its issues.",
  "base": "https://example.atlassian.net",
  "calls": [
    {
      "about": "Who the credential acts as.",
      "answer": {
        "max": 65536,
        "select": [
          "accountId",
          "displayName",
          "emailAddress",
          "accountType",
          "active"
        ]
      },
      "kind": "read",
      "name": "viewer",
      "request": {
        "method": "GET",
        "path": "/rest/api/3/myself"
      }
    },
    {
      "about": "Read one issue by its key, with its comments.",
      "answer": {
        "max": 1048576,
        "select": [
          "id",
          "key",
          "fields"
        ]
      },
      "kind": "read",
      "name": "issue",
      "params": [
        {
          "log": true,
          "max": 40,
          "name": "key",
          "pattern": "^[A-Za-z][A-Za-z0-9_]*-[0-9]+$",
          "type": "string"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/rest/api/3/issue/{key}",
        "query": [
          {
            "key": "fields",
            "value": "summary,status,issuetype,project,priority,assignee,reporter,labels,created,updated,resolution,description,comment"
          }
        ]
      }
    },
    {
      "about": "Search issues by JQL, a page at a time.",
      "answer": {
        "max": 262144,
        "select": [
          "issues",
          "nextPageToken",
          "isLast"
        ]
      },
      "kind": "read",
      "name": "search",
      "params": [
        {
          "max": 2000,
          "name": "jql",
          "type": "string"
        },
        {
          "default": 20,
          "name": "limit",
          "optional": true,
          "type": "int"
        },
        {
          "max": 512,
          "name": "page",
          "optional": true,
          "type": "string"
        }
      ],
      "request": {
        "body": {
          "fields": [
            "summary",
            "status",
            "issuetype",
            "project",
            "priority",
            "assignee",
            "labels",
            "updated"
          ],
          "jql": "{jql}",
          "maxResults": "{limit}",
          "nextPageToken": "{page}"
        },
        "method": "POST",
        "path": "/rest/api/3/search/jql"
      }
    }
  ],
  "check": "viewer",
  "credential": {
    "env": "JIRA_AUTH",
    "header": "Authorization: Basic {credential}",
    "mint": [
      "JIRA_EMAIL",
      "JIRA_API_TOKEN"
    ],
    "mint_command": [
      "jira/mint"
    ],
    "not_secret": [
      "JIRA_EMAIL"
    ]
  },
  "hear": {
    "address": {
      "env": ""
    },
    "by": "identity",
    "field": "sender"
  },
  "hook": {
    "bodies": "unique",
    "keys": [
      "project",
      "issuetype",
      "status",
      "label",
      "sender"
    ],
    "normalize": [
      "jira/normalize"
    ],
    "untimed": true,
    "verify": {
      "delivery": {
        "header": "X-Atlassian-Webhook-Identifier"
      },
      "scheme": "hmac-sha256",
      "signature": {
        "encoding": "hex",
        "header": "X-Hub-Signature",
        "prefix": "sha256="
      },
      "signed": "body"
    }
  },
  "name": "jira",
  "thread": {
    "assignee": "id",
    "open": [
      "jira/thread",
      "open"
    ],
    "opened": [
      "jira/thread",
      "opened"
    ],
    "place": "project",
    "posted": [
      "jira/thread",
      "posted"
    ],
    "read": [
      "jira/thread",
      "read"
    ],
    "reply": [
      "jira/thread",
      "reply"
    ],
    "state": [
      "jira/thread",
      "state"
    ],
    "states": [
      "started",
      "review",
      "done"
    ]
  }
}
{"source":"jira","type":"comment","action":"create","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10301","actor":{"id":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","name":"Grace Hopper"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"text":"@Ada Lovelace the loop is off by one fix is in https://example.com/pr/12 retry twice watch the log","message":"10301","parent":"","entity":"10301","mentions":["5b10ac8d82e05b22cc7d4ef5"],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"],"sender":["557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000005"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762078260002,
  "webhookEvent": "comment_created",
  "comment": {
   "self": "https://acme.atlassian.net/rest/api/3/issue/10042/comment/10301", "id": "10301",
   "author": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper", "accountType": "atlassian"},
   "body": {"type": "doc", "version": 1, "content": [
     {"type": "paragraph", "content": [{"type": "mention", "attrs": {"id": "5b10ac8d82e05b22cc7d4ef5", "text": "@Ada Lovelace", "accessLevel": ""}}, {"type": "text", "text": " the loop is off by one"}, {"type": "hardBreak"}, {"type": "text", "text": "fix is in "}, {"type": "inlineCard", "attrs": {"url": "https://example.com/pr/12"}}]},
     {"type": "bulletList", "content": [
       {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "retry twice"}]}]},
       {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "watch the log"}]}]}]}]},
   "updateAuthor": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper"},
   "created": "2026-11-02T10:11:00.002+0000", "updated": "2026-11-02T10:11:00.002+0000"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {"summary": "Retries drop the last attempt of a run", "project": {"key": "SHED"}, "issuetype": {"name": "Bug"}, "status": {"name": "In Progress"}}}}}
{"source":"jira","type":"comment","action":"create","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10300","actor":{"id":"5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"text":"[~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] and [~accountid:712020:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d], the *retry loop* stops one short. See {{attempts}} and again [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077].","message":"10300","parent":"","entity":"10300","mentions":["557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","712020:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"],"label":["retries","nightly"],"sender":["5b10ac8d82e05b22cc7d4ef5"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000004"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762078201001,
  "webhookEvent": "comment_created",
  "comment": {
   "self": "https://acme.atlassian.net/rest/api/2/issue/10042/comment/10300", "id": "10300",
   "author": {"self": "https://acme.atlassian.net/rest/api/2/user?accountId=5b10ac8d82e05b22cc7d4ef5", "accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace", "accountType": "atlassian"},
   "body": "[~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] and [~accountid:712020:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d], the *retry loop*\nstops one short. See {{attempts}} and again [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077].",
   "updateAuthor": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace"},
   "created": "2026-11-02T10:10:00.789+0000", "updated": "2026-11-02T10:10:00.789+0000", "jsdPublic": true},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {"summary": "Retries drop the last attempt of a run", "project": {"id": "10001", "key": "SHED", "name": "Shed"}, "issuetype": {"name": "Bug"}, "status": {"name": "In Progress"}, "labels": ["retries", "nightly"]}}}}
{"source":"jira","type":"comment","action":"remove","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10300","actor":{"id":"","name":""},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"text":"Moving this along, now with the fix attached.","message":"10300","parent":"","entity":"10300","mentions":[],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000007"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762078800004,
  "webhookEvent": "comment_deleted",
  "comment": {
   "self": "https://acme.atlassian.net/rest/api/2/issue/10042/comment/10300", "id": "10300",
   "author": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace"},
   "body": "Moving this along, now with the fix attached.",
   "created": "2026-11-02T10:10:00.789+0000", "updated": "2026-11-02T10:15:00.003+0000"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {"summary": "Retries drop the last attempt of a run", "project": {"key": "SHED"}, "issuetype": {"name": "Bug"}, "status": {"name": "In Progress"}}}}}
{"source":"jira","type":"comment","action":"update","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10300","actor":{"id":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","name":"Grace Hopper"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"text":"Moving this along, now with the fix attached.","message":"10300","parent":"","entity":"10300","mentions":[],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"],"sender":["557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000006"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762078500003,
  "webhookEvent": "comment_updated",
  "comment": {
   "self": "https://acme.atlassian.net/rest/api/2/issue/10042/comment/10300", "id": "10300",
   "author": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace"},
   "body": "Moving this along, now with the fix attached.",
   "updateAuthor": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper"},
   "created": "2026-11-02T10:10:00.789+0000", "updated": "2026-11-02T10:15:00.003+0000"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {"summary": "Retries drop the last attempt of a run", "project": {"key": "SHED"}, "issuetype": {"name": "Bug"}, "status": {"name": "In Progress"}}}}}
{"source":"jira","type":"issue","action":"update","url":"https://acme.atlassian.net/browse/SHED-7","actor":{"id":"5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"assignee":"","text":"commented","entity":"10042","mentions":[],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"],"sender":["5b10ac8d82e05b22cc7d4ef5"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000003"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762078200789,
  "webhookEvent": "jira:issue_updated",
  "issue_event_type_name": "issue_commented",
  "user": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace", "accountType": "atlassian"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {"summary": "Retries drop the last attempt of a run", "project": {"key": "SHED"}, "issuetype": {"name": "Bug"}, "status": {"name": "In Progress"}, "labels": []}},
  "comment": {"self": "https://acme.atlassian.net/rest/api/2/issue/10042/comment/10300", "id": "10300",
   "author": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace"},
   "body": "Moving this along.", "created": "2026-11-02T10:10:00.789+0000", "updated": "2026-11-02T10:10:00.789+0000"}}}
{"source":"jira","type":"issue","action":"create","url":"https://acme.atlassian.net/browse/SHED-8","actor":{"id":"5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace"},"thread":{"id":"SHED-8","title":"No description","url":"https://acme.atlassian.net/browse/SHED-8"},"assignee":"","text":"","entity":"10043","mentions":[],"fields":{"project":["SHED"],"issuetype":["Task"],"status":["To Do"],"sender":["5b10ac8d82e05b22cc7d4ef5"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000008"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762077600999,
  "webhookEvent": "jira:issue_created",
  "user": {"accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace"},
  "issue": {
   "id": "10043", "self": "https://api.atlassian.com/ex/jira/0a1b2c3d-1111-4222-8333-444455556666/rest/api/2/issue/10043", "key": "SHED-8",
   "fields": {"summary": "No description", "project": {"key": "SHED"}, "issuetype": {"name": "Task"}, "status": {"name": "To Do"}, "assignee": null}}}}
{"source":"jira","type":"issue","action":"create","url":"https://acme.atlassian.net/browse/SHED-7","actor":{"id":"5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt","url":"https://acme.atlassian.net/browse/SHED-7"},"assignee":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","text":"Seen in the nightly run. [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] can you look? Ask [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] again if it recurs. Steps: # retry twice # watch the log","entity":"10042","mentions":["557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["To Do"],"label":["retries","nightly"],"sender":["5b10ac8d82e05b22cc7d4ef5"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000001"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762077600123,
  "webhookEvent": "jira:issue_created",
  "issue_event_type_name": "issue_created",
  "user": {"self": "https://acme.atlassian.net/rest/api/2/user?accountId=5b10ac8d82e05b22cc7d4ef5", "accountId": "5b10ac8d82e05b22cc7d4ef5", "displayName": "Ada Lovelace", "active": true, "accountType": "atlassian"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {
    "summary": "Retries drop the last attempt",
    "description": "Seen in the nightly run. [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] can you look? Ask [~accountid:557058:f58131cb-b67d-43c7-b30d-6b58d40bd077] again if it recurs.\n\nSteps:\n# retry twice\n# watch the log",
    "project": {"id": "10001", "key": "SHED", "name": "Shed"},
    "issuetype": {"id": "10002", "name": "Bug"},
    "status": {"id": "10000", "name": "To Do", "statusCategory": {"id": 2, "key": "new", "name": "To Do"}},
    "labels": ["retries", "nightly"],
    "assignee": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper"},
    "created": "2026-11-02T10:00:00.123+0000"}}}}
{"source":"jira","type":"issue","action":"update","url":"https://acme.atlassian.net/browse/SHED-7","actor":{"id":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","name":"Grace Hopper"},"thread":{"id":"SHED-7","title":"Retries drop the last attempt of a run","url":"https://acme.atlassian.net/browse/SHED-7"},"assignee":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","text":"changed summary, status","entity":"10042","mentions":["5b10ac8d82e05b22cc7d4ef5"],"fields":{"project":["SHED"],"issuetype":["Bug"],"status":["In Progress"],"label":["retries"],"sender":["557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"]}}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000002"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762077900456,
  "webhookEvent": "jira:issue_updated",
  "issue_event_type_name": "issue_generic",
  "user": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper", "accountType": "atlassian"},
  "issue": {
   "id": "10042", "self": "https://acme.atlassian.net/rest/api/2/issue/10042", "key": "SHED-7",
   "fields": {
    "summary": "Retries drop the last attempt of a run",
    "description": {"type": "doc", "version": 1, "content": [
      {"type": "paragraph", "content": [{"type": "text", "text": "Seen in the nightly run. "}, {"type": "mention", "attrs": {"id": "5b10ac8d82e05b22cc7d4ef5", "text": "@Ada Lovelace"}}, {"type": "text", "text": " reproduced it."}]}]},
    "project": {"id": "10001", "key": "SHED", "name": "Shed"},
    "issuetype": {"id": "10002", "name": "Bug"},
    "status": {"id": "3", "name": "In Progress", "statusCategory": {"id": 4, "key": "indeterminate", "name": "In Progress"}},
    "labels": ["retries"],
    "assignee": {"accountId": "557058:f58131cb-b67d-43c7-b30d-6b58d40bd077", "displayName": "Grace Hopper"}}},
  "changelog": {"id": "10124", "items": [
    {"field": "summary", "fieldtype": "jira", "from": null, "fromString": "Retries drop the last attempt", "to": null, "toString": "Retries drop the last attempt of a run"},
    {"field": "status", "fieldtype": "jira", "from": "10000", "fromString": "To Do", "to": "3", "toString": "In Progress"},
    {"field": "summary", "fieldtype": "jira", "from": null, "fromString": "x", "to": null, "toString": "y"}]}}}
{"source":"jira","type":"version_released","action":"","url":"","actor":{"id":"","name":""},"thread":{"id":"","title":"","url":""},"mentions":[]}
{"headers": {"Content-Type": ["application/json"], "X-Atlassian-Webhook-Identifier": ["6f1c2d3e-0000-4000-8000-000000000009"], "X-Hub-Signature": ["sha256=0000000000000000000000000000000000000000000000000000000000000000"]},
 "body": {
  "timestamp": 1762081200005,
  "webhookEvent": "jira:version_released",
  "version": {"id": "10010", "name": "1.4", "released": true, "projectId": 10001}}}
{"self":"https://acme.atlassian.net/rest/api/3/user?accountId=5b10ac8d82e05b22cc7d4ef5","accountId":"5b10ac8d82e05b22cc7d4ef5","accountType":"atlassian","emailAddress":"yard-bot@example.com","avatarUrls":{"48x48":"https://example.com/avatar.png"},"displayName":"Yard Bot","active":true,"timeZone":"Europe/Warsaw","locale":"en_US","groups":{"size":1,"items":[]},"applicationRoles":{"size":1,"items":[]},"expand":"groups,applicationRoles"}
# A stand-in for Jira's REST API, for the one call the test makes through
# the real yardr (the source's check call). It serves GET /rest/api/3/myself
# from the file it is given and 404 for anything else, records each request
# (method, path, whether Authorization was the Basic value expected, Accept)
# in the log it is given, and writes the port it bound, on 127.0.0.1, to the
# file it is given once it listens.
#
#   python3 -I standin.py <port file> <request log> <myself answer> <expected Basic value>
import http.server
import sys

port_file, log_file, myself_file, expected = sys.argv[1:5]
with open(myself_file, "rb") as f:
    myself = f.read()


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        auth = "ok" if self.headers.get("Authorization") == "Basic " + expected else "WRONG"
        with open(log_file, "a") as f:
            f.write("%s %s\n  auth: %s\n  accept: %s\n" % (self.command, self.path, auth, self.headers.get("Accept")))
        if self.path == "/rest/api/3/myself":
            status, body = 200, myself
        else:
            status, body = 404, b'{"errorMessages":["the stand-in serves /rest/api/3/myself alone"]}'
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w") as f:
    f.write(str(server.server_address[1]))
server.serve_forever()
=== cmd
reply
=== in
{"thread":"../../rest/api/3/myself","parent":"","body":"hello","key":"k"}
--- requests
--- status
1
--- stdout
--- stderr
thread ../../rest/api/3/myself: want an issue key (SHED-7)
=== cmd
read
=== in
{"thread":"SHED-7"}
=== answer_1
401
{"errorMessages":["\u001b[31mBasic @AUTH@ is not accepted\u001b[0m\nforged log line"],"errors":{}}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
GET /rest/api/3/serverInfo: HTTP 401: Basic [credential redacted] is not accepted forged log line
=== cmd
read
=== in
{"thread":"SHED-7"}
=== answer_1
404
<!DOCTYPE html><html><head><title>Atlassian Cloud Notifications - Page Unavailable</title></head></html>
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
GET /rest/api/3/serverInfo: HTTP 404: an answer that is not Jira's (is the base the site's address?)
=== cmd
read
=== in
{"thread":"SHED-7"}
=== curl_exit_1
x
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
curl: (7) Failed to connect to stand-in
could not reach https://acme.atlassian.net (curl exit 7)
=== cmd
open
=== in
{"in":"not a key!","title":"x","body":"","key":"k-1"}
--- requests
--- status
1
--- stdout
--- stderr
--in not a key!: want a project key (SHED)
=== cmd
open
=== in
{"in":"SHED","title":"First line\nsecond line xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","body":"","key":"","assignee":""}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":200,"total":3,"issueTypes":[{"id":"10005","name":"Epic","subtask":false,"hierarchyLevel":1},{"id":"10004","name":"Sub-task","subtask":true,"hierarchyLevel":-1},{"id":"10001","name":"Story","subtask":false,"hierarchyLevel":0}]}
=== answer_3
201
{"id":"10051","key":"SHED-13","self":"https://acme.atlassian.net/rest/api/3/issue/10051"}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/createmeta/SHED/issuetypes?maxResults=200
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":{"issuetype":{"id":"10001"},"project":{"key":"SHED"},"summary":"First line second line xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}}
--- status
0
--- stdout
{"id":"SHED-13","title":"First line second line xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","url":"https://acme.atlassian.net/browse/SHED-13"}
--- stderr
=== cmd
open
=== in
{"in":"SHED","title":"Nothing to open it as","body":"","key":"k-1"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":200,"total":1,"issueTypes":[{"id":"10004","name":"Sub-task","subtask":true,"hierarchyLevel":-1}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/createmeta/SHED/issuetypes?maxResults=200
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
project SHED has no issue type to open an issue as
=== cmd
open
=== in
{"in":"SHED","title":"Assigned to nobody who can be","body":"","key":"k-2","assignee":"000000:not-assignable"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":200,"total":5,"issueTypes":[{"id":"10005","name":"Epic","subtask":false,"hierarchyLevel":1},{"id":"10004","name":"Sub-task","subtask":true,"hierarchyLevel":-1},{"id":"10003","name":"Bug","subtask":false,"hierarchyLevel":0},{"id":"10002","name":"Task","subtask":false,"hierarchyLevel":0},{"id":"10001","name":"Story","subtask":false,"hierarchyLevel":0}]}
=== answer_3
400
{"errorMessages":[],"errors":{"assignee":"User '000000:not-assignable' cannot be assigned issues."}}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/createmeta/SHED/issuetypes?maxResults=200
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":{"assignee":{"accountId":"000000:not-assignable"},"issuetype":{"id":"10002"},"project":{"key":"SHED"},"summary":"Assigned to nobody who can be"},"properties":[{"key":"yardr.key","value":"k-2"}]}
--- status
1
--- stdout
--- stderr
POST /rest/api/3/issue: HTTP 400: assignee: User '000000:not-assignable' cannot be assigned issues.
=== cmd
open
=== in
{"in":"shed","title":"Retries drop the last attempt","body":"Seen in the nightly run.\n\nSteps: retry twice.","key":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e","assignee":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":200,"total":5,"issueTypes":[{"id":"10005","name":"Epic","subtask":false,"hierarchyLevel":1},{"id":"10004","name":"Sub-task","subtask":true,"hierarchyLevel":-1},{"id":"10003","name":"Bug","subtask":false,"hierarchyLevel":0},{"id":"10002","name":"Task","subtask":false,"hierarchyLevel":0},{"id":"10001","name":"Story","subtask":false,"hierarchyLevel":0}]}
=== answer_3
201
{"id":"10050","key":"SHED-12","self":"https://acme.atlassian.net/rest/api/3/issue/10050"}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/createmeta/SHED/issuetypes?maxResults=200
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":{"assignee":{"accountId":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077"},"description":{"content":[{"content":[{"text":"Seen in the nightly run.","type":"text"}],"type":"paragraph"},{"content":[{"text":"Steps: retry twice.","type":"text"}],"type":"paragraph"}],"type":"doc","version":1},"issuetype":{"id":"10002"},"project":{"key":"SHED"},"summary":"Retries drop the last attempt"},"properties":[{"key":"yardr.key","value":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e"}]}
--- status
0
--- stdout
{"id":"SHED-12","title":"Retries drop the last attempt","url":"https://acme.atlassian.net/browse/SHED-12"}
--- stderr
=== cmd
opened
=== in
{"in":"shed","key":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e","since":"@AGO@"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"isLast":true,"issues":[{"id":"10050","key":"SHED-12","fields":{"summary":"Retries drop the last attempt"},"properties":[{"key":"yardr.key","value":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e"}]}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/search/jql
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":["summary"],"jql":"project = \"SHED\" AND creator = currentUser() AND created >= -<N>m ORDER BY created DESC","maxResults":50,"properties":["yardr.key"]}
--- status
0
--- stdout
{"id":"SHED-12","title":"Retries drop the last attempt","url":"https://acme.atlassian.net/browse/SHED-12"}
--- stderr
=== cmd
opened
=== in
{"in":"SHED","key":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"isLast":true,"issues":[{"id":"10052","key":"SHED-14","fields":{"summary":"Another issue of ours"},"properties":{"yardr.key":"aaaaaaaa-0000-4000-8000-000000000000"}},{"id":"10050","key":"SHED-12","fields":{"summary":"Retries drop the last attempt"},"properties":{"yardr.key":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e"}}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/search/jql
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":["summary"],"jql":"project = \"SHED\" AND creator = currentUser() AND created >= -1440m ORDER BY created DESC","maxResults":50,"properties":["yardr.key"]}
--- status
0
--- stdout
{"id":"SHED-12","title":"Retries drop the last attempt","url":"https://acme.atlassian.net/browse/SHED-12"}
--- stderr
=== cmd
opened
=== in
{"in":"SHED","key":"7c1d9e52-3b6f-4e0a-9d44-8f2a1b3c5d6e","since":"not a time"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"isLast":true,"issues":[{"id":"10052","key":"SHED-14","fields":{"summary":"Made by a person"},"properties":{}},{"id":"10053","key":"SHED-15","fields":{"summary":"Made by us, for another key"},"properties":{"yardr.key":"bbbbbbbb-0000-4000-8000-000000000000"}}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/search/jql
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"fields":["summary"],"jql":"project = \"SHED\" AND creator = currentUser() AND created >= -1440m ORDER BY created DESC","maxResults":50,"properties":["yardr.key"]}
--- status
0
--- stdout
{}
--- stderr
=== cmd
reply
=== base
https://example.atlassian.net
=== in
{"thread":"SHED-7","parent":"","body":"hello","key":"k"}
--- requests
--- status
1
--- stdout
--- stderr
the jira source's base is still the pack's placeholder, https://example.atlassian.net: put your site's address in the yard's copy of jira.yaml (SETUP.md, step 2)
=== cmd
posted
=== in
{"thread":"SHED-7","key":"3f0f8b2e-6a1d-4f0e-9a52-0d4f6a1b7c11"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":100,"total":3,"comments":[{"id":"10402","body":{"type":"doc","version":1,"content":[]}},{"id":"10400"},{"id":"10300"}]}
=== answer_3
200
{"isLast":true,"maxResults":1048576,"startAt":0,"total":3,"values":[{"id":"10402","properties":[]},{"id":"10400","properties":[{"key":"yardr.key","value":"3f0f8b2e-6a1d-4f0e-9a52-0d4f6a1b7c11"}]},{"id":"10300","properties":[]}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment?orderBy=-created&startAt=0&maxResults=100
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/comment/list?expand=properties
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"ids":[10402,10400,10300]}
--- status
0
--- stdout
{"id":"10400","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10400"}
--- stderr
=== cmd
posted
=== in
{"thread":"SHED-7","key":"00000000-0000-4000-8000-00000000dead"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":2,"total":3,"comments":[{"id":"10402"},{"id":"10400"}]}
=== answer_3
200
{"isLast":true,"values":[{"id":"10402","properties":[{"key":"sd.public.comment","value":{"internal":true}}]},{"id":"10400","properties":[{"key":"yardr.key","value":"3f0f8b2e-6a1d-4f0e-9a52-0d4f6a1b7c11"}]}]}
=== answer_4
200
{"startAt":2,"maxResults":2,"total":3,"comments":[{"id":"10300"}]}
=== answer_5
200
{"isLast":true,"values":[{"id":"10300"}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment?orderBy=-created&startAt=0&maxResults=100
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/comment/list?expand=properties
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"ids":[10402,10400]}
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment?orderBy=-created&startAt=2&maxResults=100
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/comment/list?expand=properties
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"ids":[10300]}
--- status
0
--- stdout
{}
--- stderr
=== cmd
posted
=== in
{"thread":"SHED-9","key":"11111111-2222-4333-8444-555555555555"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":100,"total":0,"comments":[]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-9/comment?orderBy=-created&startAt=0&maxResults=100
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{}
--- stderr
=== cmd
read
=== in
{"thread":"SHED-7"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
200
{"startAt":0,"maxResults":2,"total":3,"comments":[{"id":"10300","author":{"accountId":"5b10ac8d82e05b22cc7d4ef5","displayName":"Ada Lovelace"},"body":{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","text":"@Grace Hopper"}},{"type":"text","text":" the loop is off by one"},{"type":"hardBreak"},{"type":"text","text":"see below"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"retry twice"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"watch the log"}]}]}]}]},"created":"2026-11-02T10:10:00.789-0500","updated":"2026-11-02T10:10:00.789-0500"},{"id":"10301","author":{"accountId":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","displayName":"Grace Hopper"},"body":{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Fixed in the branch."}]}]},"created":"2026-11-02T10:12:00.000+0000"}]}
=== answer_3
200
{"startAt":2,"maxResults":2,"total":3,"comments":[{"id":"10302","author":{"accountId":"712020:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","displayName":"Ren"},"body":{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Thanks, merged."}]}]},"created":"2026-11-02T10:30:00.000Z"}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment?orderBy=created&startAt=0&maxResults=100
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment?orderBy=created&startAt=2&maxResults=100
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"messages":[{"id":"10300","parent":"","author":{"id":"5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace"},"at":"2026-11-02T10:10:00.789-05:00","body":"@Grace Hopper the loop is off by one\nsee below\nretry twice\nwatch the log","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10300"},{"id":"10301","parent":"","author":{"id":"557058:f58131cb-b67d-43c7-b30d-6b58d40bd077","name":"Grace Hopper"},"at":"2026-11-02T10:12:00.000+00:00","body":"Fixed in the branch.","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10301"},{"id":"10302","parent":"","author":{"id":"712020:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d","name":"Ren"},"at":"2026-11-02T10:30:00.000Z","body":"Thanks, merged.","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10302"}]}
--- stderr
=== cmd
reply
=== base
https://api.atlassian.com/ex/jira/0a1b2c3d-1111-4222-8333-444455556666
=== in
{"thread":"SHED-7","parent":"10300","body":"On it.","key":"9d2e7a44-0c1b-4d8e-8f55-1e2f3a4b5c6d"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
201
{"self":"https://api.atlassian.com/ex/jira/0a1b2c3d-1111-4222-8333-444455556666/rest/api/3/issue/10042/comment/10401","id":"10401","created":"2026-11-02T10:21:00.000+0000"}
--- requests
GET https://api.atlassian.com/ex/jira/0a1b2c3d-1111-4222-8333-444455556666/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
POST https://api.atlassian.com/ex/jira/0a1b2c3d-1111-4222-8333-444455556666/rest/api/3/issue/SHED-7/comment
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"body":{"content":[{"content":[{"text":"On it.","type":"text"}],"type":"paragraph"}],"type":"doc","version":1},"properties":[{"key":"yardr.key","value":"9d2e7a44-0c1b-4d8e-8f55-1e2f3a4b5c6d"}]}
--- status
0
--- stdout
{"id":"10401","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10401"}
--- stderr
=== cmd
reply
=== in
{"thread":"SHED-7","parent":"","body":"Looked at the retry loop.\n\nIt stops one short:\nattempts is compared with <, not <=.\r\n\r\n\r\nFix is in the branch.","key":"3f0f8b2e-6a1d-4f0e-9a52-0d4f6a1b7c11"}
=== answer_1
200
{"baseUrl":"https://acme.atlassian.net","displayUrl":"https://acme.atlassian.net","deploymentType":"Cloud","serverTitle":"Acme"}
=== answer_2
201
{"self":"https://acme.atlassian.net/rest/api/3/issue/10042/comment/10400","id":"10400","author":{"accountId":"5b10ac8d82e05b22cc7d4ef5","displayName":"Yard Bot"},"created":"2026-11-02T10:20:00.000+0000","updated":"2026-11-02T10:20:00.000+0000"}
--- requests
GET https://acme.atlassian.net/rest/api/3/serverInfo
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/comment
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"body":{"content":[{"content":[{"text":"Looked at the retry loop.","type":"text"}],"type":"paragraph"},{"content":[{"text":"It stops one short:","type":"text"},{"type":"hardBreak"},{"text":"attempts is compared with <, not <=.","type":"text"}],"type":"paragraph"},{"content":[{"text":"Fix is in the branch.","type":"text"}],"type":"paragraph"}],"type":"doc","version":1},"properties":[{"key":"yardr.key","value":"3f0f8b2e-6a1d-4f0e-9a52-0d4f6a1b7c11"}]}
--- status
0
--- stdout
{"id":"10400","url":"https://acme.atlassian.net/browse/SHED-7?focusedCommentId=10400"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"blocked"}
--- requests
--- status
1
--- stdout
--- stderr
jira has no status for "blocked"
=== cmd
state
=== in
{"thread":"SHED-7","state":"done","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"61","name":"Reopen","to":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"Done","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"done","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"51","name":"Won't do","to":{"id":"10002","name":"Won't Do","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
SHED-7: no transition from In Progress to a done status; it may take: Won't Do
=== cmd
state
=== in
{"thread":"SHED-7","state":"done","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"51","name":"Won't do","to":{"id":"10002","name":"Won't Do","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true},{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true},{"id":"21","name":"Back to To Do","to":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"31"}}
--- status
0
--- stdout
{"status":"Done","moved":true,"was":"In Progress"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":true}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"41","name":"Send to review","to":{"id":"10100","name":"In Review","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"41"}}
--- status
0
--- stdout
{"status":"In Review","moved":true,"was":"In Progress"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":true}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"71","name":"Reopen","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"Done","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":true}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10003","name":"Parked","statusCategory":{"id":1,"key":"undefined","name":"No Category"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"11","name":"Start progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"Parked","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"done","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10000","name":"To Do\u001b[2J\nforged log line","statusCategory":{"id":2,"key":"new","name":"To Do"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
1
--- stdout
--- stderr
SHED-7: no transition is open to the account from To Do forged log line
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"11","name":"Start progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
400
{"errorMessages":[],"errors":{"resolution":"Resolution is required."}}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"11"}}
--- status
1
--- stdout
--- stderr
POST /rest/api/3/issue/SHED-7/transitions: HTTP 400: resolution: Resolution is required.
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"11","name":"Start progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true},{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"11"}}
--- status
0
--- stdout
{"status":"In Progress","moved":true,"was":"To Do"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"In Progress","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"15","name":"Show a preview","to":{"id":"10300","name":"Preview","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"In Progress","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10100","name":"In Review","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"12","name":"Back to progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true},{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"In Review","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"review","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"21","name":"Back to To Do","to":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}},"hasScreen":false,"isAvailable":true},{"id":"41","name":"Send to review","to":{"id":"10100","name":"In Review","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true},{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"41"}}
--- status
0
--- stdout
{"status":"In Review","moved":true,"was":"In Progress"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10100","name":"In Review","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"12","name":"Back to progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
--- status
0
--- stdout
{"status":"In Review","moved":false}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"11","name":"Start progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true},{"id":"31","name":"Done","to":{"id":"10001","name":"Done","statusCategory":{"id":3,"key":"done","name":"Done"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"11"}}
--- status
0
--- stdout
{"status":"In Progress","moved":true,"was":"To Do"}
--- stderr
=== cmd
state
=== in
{"thread":"SHED-7","state":"started","forward":false}
=== answer_1
200
{"id":"10042","key":"SHED-7","fields":{"status":{"id":"10000","name":"To Do","statusCategory":{"id":2,"key":"new","name":"To Do"}}}}
=== answer_2
200
{"expand":"transitions","transitions":[{"id":"11","name":"Start progress","to":{"id":"3","name":"In Progress","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":false},{"id":"12","name":"Begin","to":{"id":"10200","name":"Selected","statusCategory":{"id":4,"key":"indeterminate","name":"In Progress"}},"hasScreen":false,"isAvailable":true}]}
=== answer_3
204

--- requests
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7?fields=status
  auth: ok
  header: Accept: application/json
GET https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
POST https://acme.atlassian.net/rest/api/3/issue/SHED-7/transitions
  auth: ok
  header: Accept: application/json
  header: Content-Type: application/json
  body: {"transition":{"id":"12"}}
--- status
0
--- stdout
{"status":"Selected","moved":true,"was":"To Do"}
--- stderr
=== cmd
frobnicate
=== in
{}
--- requests
--- status
2
--- stdout
--- stderr
unknown: frobnicate