# ----------------------------------------------------------------------------
# Example workflow distributed with the jira-sdlc plugin
# (https://github.com/kantorv/jira-sdlc-tools). It ships as an example to
# copy into your own repo's .github/workflows/ and adapt — it is not
# installed with the plugin.
#
#   Guide:         https://kantorv.github.io/jira-sdlc-tools/docs/applications/ci-issue-to-task-demo
#   All examples:  https://kantorv.github.io/jira-sdlc-tools/docs/applications
#   Source:        https://github.com/kantorv/jira-sdlc-tools/blob/main/docs/github-automations/example-workflows/demo-claude-issue-to-task.yml
# ----------------------------------------------------------------------------

# Demo — turn a GitHub issue into a Jira task via a `/make-task` comment,
# using jira-task-assigner
#
# What it demos: on a `/make-task` issue comment, invoke the jira-task-assigner
# skill headlessly with the issue's title + body as its free-form task
# description, so a commented GitHub issue becomes a Jira Task plus a
# feature/<KEY> branch and a worktree — the same thing a developer gets by
# typing `/jira-sdlc:jira-task-assigner <text>` interactively, but triggered
# by the comment instead of the issue-opened event. It is a Claude-powered
# sibling of demo-claude-hotfix-flow.yml (assigner half) and
# demo-claude-reviewer.yml, and the `/make-task` counterpart of
# demo-claude-feature-flow.yml's `/make-feature`: same trigger, same
# prose-parsing step, assigner half only (no executor/reviewer jobs).
# demo-claude-issue-to-bug.yml is the byte-identical `/make-bug` twin.
#
# Security boundary — there is NO human approval gate on this job (see
# "No production gate" below): the job-level `if` requires the
# comment body to be exactly `/make-task` (or `/make-task` followed by a space
# or newline and free-form prose) AND `author_association` to be OWNER
# `issue_comment` fires for any commenter on a public repo, and what
# follows runs an LLM with write access on a runner, so the OWNER guard
# is the *only* gate — do not loosen it to CONTRIBUTOR or drop it in favour of
# "an approval will catch it" (an approval prompt is a poor place to be reading
# attacker-supplied text for the first time). The guard also requires
# `pull_request == null`, excluding PR comments so the assigner never receives
# a PR description as a feature request. Prose typed after a non-bare command
# is parsed and appended to the prompt as a labelled
# `-- EXTRA DIRECTION FOR THIS RUN --` section — it supplements the issue text
# and the fixed CI instruction, never replaces either; a bare command leaves
# the section out entirely.
#
# ── No production gate ───────────────────────────────────────────────────────
#
# Earlier revisions declared `environment: production` on this job so GitHub
# paused for a human approval before the assigner ran. No job declares an
# environment any more: the OWNER comment guard above is the sole security
# boundary, and every secret below is a repository secret.
#
# ── The worktree-persistence caveat (read this before copying the file) ─────
#
#  This is a demo of the *invocation shape*, not a durable end-to-end
#  issue→task→implement→review pipeline. The assigner skill pushes the new
#  branch to origin (that survives — it's on the remote) AND creates a linked
#  worktree for it under WORKTREES_DIR (here $RUNNER_TEMP/worktrees). On a
#  GitHub-hosted runner each job gets a fresh VM, so WORKTREES_DIR and every
#  worktree under it are destroyed when the job ends — the branch is still on
#  origin, but the worktree a later executor/reviewer run would expect to
#  *already exist* is not. End-to-end chaining on hosted runners therefore
#  needs the demo-claude-hotfix-flow.yml trick (a later job rebuilds the
#  worktree from origin) or a persistent/self-hosted runner whose disk
#  survives across runs the way a developer's machine does. This file
#  deliberately stops after the assigner: it shows the assigner half of the
#  chain, and flags that the worktree half does not survive a hosted runner's
#  teardown rather than silently pretending worktrees persist.
#
# ── Caveats worth knowing before you copy this into another repo ──────────
#
#  1. **Do not export GH_TOKEN / GITHUB_TOKEN into the step that runs the
#     skill.** Every skill starts with statuscheck, which reads
#     GITHUB_PAT_TOKEN out of the env FILE (not the environment) and runs
#     `gh auth logout && gh auth login --with-token`; gh refuses that login
#     while either token variable is set — "first clear the value from the
#     environment", exit 1 — which FAILs the gh_auth row and aborts the run
#     before any work happens. CLAUDE_CODE_OAUTH_TOKEN is the ONLY secret
#     exported into the skill-running step; the rest are written to
#     .jst/jira-sdlc-tools.local.env and read from there. `git push` is
#     unaffected: it uses the credentials actions/checkout persisted in
#     `.git/config`, so the built-in GITHUB_TOKEN (which can push given the
#     job `permissions:` below) is enough — GITHUB_PAT_TOKEN is not a secret
#     to create.
#  2. **The skills come from the marketplace, not from this checkout.** The
#     run installs the plugin with `claude plugin marketplace add` +
#     `claude plugin install`, which clones the plugin repo's *default*
#     branch — so the skill prompts loaded are that branch's, and a skill
#     change is exercised here only once it has landed there. The checkout
#     still has to stand on the base branch, for an unrelated reason: the
#     assigner refuses to run from an issue branch (its step-2 healthcheck
#     hard-stops), so that is required, not decorative.
#  3. **No shared disk across runs** (the worktree caveat above): the only
#     durable artifact of this run is the branch the assigner pushed to
#     origin and the uploaded log; the local worktree it created is gone
#     when the VM tears down.
#
# ── Secrets (repo-level) ─────────────────────────────────────────────────────
#
#  These live as repository secrets — the job declares no environment (see
#  "No production gate" above). Each is named exactly as the
#  `jira-sdlc-tools.local.env` key it becomes — which is what lets the bootstrap
#  step below be one loop over a KEYS list rather than a hand-mapped list of
#  printfs (secret -> file via env, no sed — the demo-kimi-openrouter-reviewer.yml
#  mapping pattern, applied to .jst/jira-sdlc-tools.local.env the way
#  demo-claude-hotfix-flow.yml does):
#
#    CLAUDE_CODE_OAUTH_TOKEN  every run (read from the environment by the
#                             CLI — the only one never written to the file)
#    JIRA_ACCOUNT_URL         every run
#    JIRA_ASSIGNER_EMAIL / JIRA_ASSIGNER_TOKEN   the assigner's Jira identity
#    JIRA_EXECUTOR_EMAIL      not a credential — the assigner assigns every
#                             issue it creates to the executor, and
#                             get_assignee_email.sh reads that address (and
#                             only the address — no executor token) from the
#                             env file
#    GITHUB_PAT_TOKEN         not a secret to create — always populated from
#                             the built-in GITHUB_TOKEN (which can push the
#                             new branch given the job permissions below).
#                             One limitation of the built-in token carries
#                             over unchanged: PRs opened with it don't
#                             trigger other workflows
#
#  None are assumed to exist: the bootstrap step checks its whole set up front
#  and fails loud with `::error::` rather than letting the claude CLI die
#  halfway through on a missing key (CLAUDE_CODE_OAUTH_TOKEN in particular
#  does not exist in this repo yet).
#
#  `WORKTREES_DIR` and `GITHUB_PAT_TOKEN` are the two keys that are *not*
#  secrets: `WORKTREES_DIR` is a path on *this* runner, computed per job
#  under RUNNER_TEMP (a value carried over from a developer's machine would
#  point somewhere that doesn't exist here), and `GITHUB_PAT_TOKEN` is
#  populated from the runner's built-in token, per above.

name: Demo — Claude issue-to-task (jira-task-assigner on /make-task comment)



env:
  # Pinned deliberately, same as validator.yml: a float would let an upstream
  # CLI change break this demo on an unrelated day. Workflow-level env can't
  # cross files, so validator.yml hardcodes the same pin — bump both together.
  CLAUDE_CLI_VERSION: 2.1.263
  DEFAULT_ASSIGNER_MODEL: sonnet


on:
  issue_comment:
    types: [created]

jobs:
  assign:
    name: Assigner — turn the `/make-task`-commented issue into a Jira task + branch
    # The whole security boundary of this workflow — see the header. All three
    # conditions are required; the comment clause accepts the bare command or
    # the command plus a separator (space or newline, the latter spelled via
    # fromJSON('"\n"')/fromJSON('"\r"') because a GitHub expression has no
    # string escape sequences and comment bodies come back CRLF).
    if: |
      github.event.issue.pull_request == null &&
      (github.event.comment.body == '/make-task' ||
       startsWith(github.event.comment.body, '/make-task ') ||
       startsWith(github.event.comment.body, format('/make-task{0}', fromJSON('"\n"'))) ||
       startsWith(github.event.comment.body, format('/make-task{0}', fromJSON('"\r"')))) &&
      github.event.comment.author_association == 'OWNER'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: write # the assigner pushes the new feature branch to origin
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          # Full history + every remote ref: the assigner cuts the new issue
          # branch from a fetched origin/<base>, which a shallow single-branch
          # clone doesn't have. fetch-depth: 0 also lets `git switch` to the
          # base branch below work regardless of which ref actions/checkout
          # defaulted to (the repo default branch is `development`, so checkout
          # already lands there — the switch is a robust no-op confirmation).
          fetch-depth: 0

      - name: Write .jst/jira-sdlc-tools.local.env (assigner role only)
        shell: bash
        env:
          # Repo-level secrets, each named exactly as the env-file key it
          # becomes — which is what lets the step below be one loop instead of
          # a hand-mapped list. KEYS is the whole contract. (The job declares no
          # environment, so these resolve from the repo; see the header's
          # "No production gate" note.)
          KEYS: JIRA_ACCOUNT_URL JIRA_ASSIGNER_EMAIL JIRA_ASSIGNER_TOKEN JIRA_EXECUTOR_EMAIL GITHUB_PAT_TOKEN
          JIRA_ACCOUNT_URL: ${{ secrets.JIRA_ACCOUNT_URL }}
          JIRA_ASSIGNER_EMAIL: ${{ secrets.JIRA_ASSIGNER_EMAIL }}
          JIRA_ASSIGNER_TOKEN: ${{ secrets.JIRA_ASSIGNER_TOKEN }}
          # Not a credential: the assigner assigns every issue it creates to
          # the executor, and get_assignee_email.sh reads that address (and
          # only the address — no executor token) from the env file.
          JIRA_EXECUTOR_EMAIL: ${{ secrets.JIRA_EXECUTOR_EMAIL }}
          GITHUB_PAT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Checked here so one step catches every missing secret, but never
          # written to the file — the claude CLI reads it from the environment.
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
        run: |
          set -euo pipefail
          # No filesystem carries over from a previous run on a hosted runner,
          # so this file is rebuilt from this job's own secrets every time.
          # jira.sh and statuscheck read credentials ONLY from here (never the
          # environment), resolved from the git top-level.
          missing=()
          for k in CLAUDE_CODE_OAUTH_TOKEN $KEYS; do
            [ -n "${!k:-}" ] || missing+=("$k")
          done
          if [ ${#missing[@]} -gt 0 ]; then
            echo "::error::missing or empty repository secret(s): ${missing[*]} — this demo cannot run without them (Settings → Secrets and variables → Actions)"
            exit 1
          fi

          # Create empty and lock it down BEFORE the secrets go in.
          : > .jst/jira-sdlc-tools.local.env
          chmod 600 .jst/jira-sdlc-tools.local.env
          # printf with the value as an argument: secret values are data, never
          # re-expanded by the shell and never part of the script text.
          for k in $KEYS; do
            printf '%s=%s\n' "$k" "${!k}" >> .jst/jira-sdlc-tools.local.env
          done
          # WORKTREES_DIR is deliberately NOT a secret: it's a path on *this*
          # runner, and a value carried over from a developer's machine would
          # point somewhere that doesn't exist here. The assigner never mkdirs
          # it, so create it now (see the worktree caveat in the header).
          mkdir -p "$RUNNER_TEMP/worktrees"
          printf 'WORKTREES_DIR=%s\n' "$RUNNER_TEMP/worktrees" >> .jst/jira-sdlc-tools.local.env
          echo "Wrote $(grep -c . .jst/jira-sdlc-tools.local.env) config lines (values not echoed)."

      - name: Resolve project config
        id: config
        shell: bash
        run: |
          set -euo pipefail
          # Branch names are <TOKEN>s from the team-shared env file, never
          # literals in this workflow (AGENTS.md). Only the base branch is
          # needed here; the assigner derives the project key itself.
          cfg() {
            grep -E "^[[:space:]]*$1[[:space:]]*=" .jst/jira-sdlc-tools.env \
              | tail -1 | sed -e 's/^[^=]*=[[:space:]]*//' -e 's/[[:space:]]*$//'
          }
          for k in DEFAULT_BASE_BRANCH; do
            v=$(cfg "$k")
            if [ -z "$v" ]; then
              echo "::error::$k is unset in .jst/jira-sdlc-tools.env"
              exit 1
            fi
            echo "$(echo "$k" | tr '[:upper:]' '[:lower:]')=$v" >> "$GITHUB_OUTPUT"
            echo "$k=$v"
          done

      - name: Stand on the base branch
        shell: bash
        env:
          BASE: ${{ steps.config.outputs.default_base_branch }}
        run: |
          set -euo pipefail
          # The assigner skill requires running from a *main checkout* (`.git`
          # is a directory) on the base branch, NEVER a linked worktree or an
          # issue branch — its step-2 healthcheck hard-stops otherwise (it
          # *creates* worktrees, it doesn't run inside one). A fresh
          # actions/checkout on the default branch satisfies the main-checkout
          # half; this switch lands us on the base branch the skill expects.
          # fetch-depth: 0 above made origin/<base> available for the DWIM
          # tracking-branch creation if checkout didn't already land here.
          git switch "$BASE"
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Install claude CLI (native, no npm)
        # Same install as validator.yml and demo-claude-hotfix-flow.yml: the
        # installer downloads a prebuilt binary, SHA256-verifies it against
        # Anthropic's manifest, and runs its own `claude install`. Pinned
        # deliberately — a float would let an upstream CLI change break this
        # demo on an unrelated day. The other demos pin this same version (via
        # their workflow-level CLAUDE_CLI_VERSION env var) — bump them
        # together with this file's inline pin. validator.yml hardcodes the
        # same pin inline and is bumped in lockstep with this set too.
        shell: bash
        run: |
          set -euo pipefail
          curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CLI_VERSION"
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"

      - name: Install the jira-sdlc plugin from its marketplace
        shell: bash
        run: |
          set -euo pipefail
          # The skills come from the plugin's marketplace, not from this
          # checkout — which is what makes this workflow copy-pasteable into a
          # repo that does not contain the plugin. External consumers: swap the
          # URL below for your own fork or clone.
          #
          # `marketplace add` clones the source repo's DEFAULT branch, so what
          # runs here is whatever has landed there — not the code on the branch
          # under test. Land a skill change on the default branch before
          # expecting a demo run to exercise it.
          #
          # Spelled as an https:// URL rather than the `owner/repo` shorthand:
          # the shorthand tries SSH first and only falls back to HTTPS once that
          # fails, and a runner has no SSH key for github.com. Same marketplace
          # name (`jira-sdlc-tools`, from its manifest) either way.
          claude plugin marketplace add https://github.com/kantorv/jira-sdlc-tools.git
          claude plugin install jira-sdlc@jira-sdlc-tools

      - name: Parse the prose after the trigger command
        # Free-form direction may follow the command in the triggering comment
        # (`/make-task focus on the retry path`). Strip the leading token, trim,
        # and expose the remainder as a step output; an empty remainder is the
        # normal case and leaves the run behaving exactly as a bare command
        # always did.
        #
        # This step parses, it never gates: the security boundary is the
        # job-level `if:` above and stays there, so this step only ever sees a
        # comment that already passed it. The body is attacker-supplied text —
        # it enters as an env var and leaves through `printf`, never as part of
        # the script.
        #
        # Identical in the comment-triggered demos apart from COMMAND — keep it
        # that way, so a diff between two of them shows only what genuinely
        # differs.
        id: prose
        shell: bash
        env:
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMAND: /make-task
        run: |
          set -euo pipefail
          shopt -s extglob
          PROSE=${COMMENT_BODY:-}
          PROSE=${PROSE//$'\r'/}          # comment bodies arrive CRLF
          PROSE=${PROSE#"$COMMAND"}       # drop the leading command token
          PROSE=${PROSE##+([[:space:]])}  # trim both ends, newlines included
          PROSE=${PROSE%%+([[:space:]])}
          # Multi-line step output needs a delimiter, and a fixed one would let
          # prose containing that marker close the block early and forge further
          # outputs — so randomize it.
          DELIM="PROSE_EOF_$(openssl rand -hex 8)"
          {
            printf 'text<<%s\n' "$DELIM"
            printf '%s\n' "$PROSE"
            printf '%s\n' "$DELIM"
          } >> "$GITHUB_OUTPUT"
          if [ -n "$PROSE" ]; then
            printf 'Extra direction from the triggering comment: %s characters.\n' "${#PROSE}"
          else
            printf 'Bare command — no extra direction.\n'
          fi

      - name: Run jira-task-assigner
        timeout-minutes: 25
        shell: bash
        env:
          # The only secret exported into the skill-running step — see
          # caveat 1 in the header: exporting GH_TOKEN/GITHUB_TOKEN here would
          # block statuscheck's gh login and abort before any work happens.
          CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          # Same rule as the issue text below: comment prose is user content,
          # so it arrives as an env var and is printf'd, never interpolated
          # here.
          COMMENT_PROSE: ${{ steps.prose.outputs.text }}
          # Pull the issue's title and body into env vars FIRST, then build the
          # prompt from them — never interpolate github.event.issue.* directly
          # into the shell command (issue bodies are user content, not script
          # text). Built into a temp file with printf below so the values stay
          # data, never re-expanded by the shell.
          ISSUE_TITLE: ${{ github.event.issue.title }}
          ISSUE_BODY: ${{ github.event.issue.body }}
        run: |
          set -euo pipefail
          # Labeled sections so the model can tell "here is the issue text"
          # apart from "here is what CI wants you to do with it" — plain
          # concatenation left that boundary implicit. The middle paragraph is a
          # fixed instruction (never issue content) telling the assigner it's
          # running headless from a CI issue_comment event: turn the issue into
          # a Jira Task on the planned-work (feature) flow, decide single-step
          # vs. multistep scope itself, and rule out the hotfix path explicitly
          # (the skill's own hotfix trigger just spots words like
          # "hotfix"/"urgent fix" in the free-form task text, and an issue
          # reporting a production bug could easily contain one). It forbids
          # clarifying questions outright: there's no human on the other end of
          # this -p call to answer one, so the skill's normal "ask if ambiguous"
          # step must instead become "stop and fail" rather than guess — this
          # demo stops after the assigner, so a run that stops to ask ends with
          # no branch created. printf from env vars so the issue text stays
          # data, never interpolated into the script.
          {
            printf -- '-- ISSUE TITLE --\n%s\n\n' "$ISSUE_TITLE"
            printf -- 'You are running headless, triggered by a GitHub Actions issue_comment event — there is no one here to answer a clarifying question. Turn the issue below into a Jira Task, on the planned-work (feature) flow — not the hotfix flow, regardless of anything in its title or body that sounds urgent. Decide single-step vs. multistep scope yourself and proceed. If anything material is genuinely ambiguous, do not guess and do not ask — stop and report the specific blocker as a failure instead.\n\n'
            printf -- '-- ISSUE BODY --\n%s' "${ISSUE_BODY:-}"
            # Whatever was typed after the trigger command, as its own labelled
            # section: extra steering for THIS run, while the issue above stays
            # the task description and the paragraph above stays the fixed CI
            # instruction. A bare command skips the section entirely, leaving
            # the prompt byte-identical to a bare-command run.
            if [ -n "${COMMENT_PROSE:-}" ]; then
              printf -- '\n\n-- EXTRA DIRECTION FOR THIS RUN (from the triggering comment) --\n%s' "$COMMENT_PROSE"
            fi
          } > "$RUNNER_TEMP/assigner-prompt.txt"

          # --dangerously-skip-permissions: headless runs have no interactive
          # turn to approve tool calls. Safe-ish here and nowhere else: a
          # throwaway runner VM, non-root (the flag refuses to run as root).
          # The prompt text is passed via `$(cat …)` inside the quoted -p
          # argument, so it stays a single argument (no word splitting) and its
          # newlines survive into the model.
          claude \
            --dangerously-skip-permissions \
            --model "$DEFAULT_ASSIGNER_MODEL" \
            -p "/jira-sdlc:jira-task-assigner $(cat "$RUNNER_TEMP/assigner-prompt.txt")" \
            2>&1 | tee "$RUNNER_TEMP/assigner.log"

      - name: Upload assigner log
        # The assigner's full transcript — its report of the Jira issue(s),
        # branch(es), and worktree(s) it created is echoed here, so a human
        # inspecting the run sees what happened. The assigner skill itself owns
        # its Jira comment content; this log is the reliable, path-independent
        # artifact of the run. (See the worktree caveat: the local worktree is
        # gone when the VM tears down, but the log and the pushed branch remain.)
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: assigner-log
          path: ${{ runner.temp }}/assigner.log
          if-no-files-found: warn
          retention-days: 7
