#!/bin/bash
# ci-release <verb> <candidate-tag> [extra]   -- JSON payload on stdin for `brief`
# CANONICAL SOURCE (SID-607). Exec'd unchanged by `agentforge ci release`
# (src/agentforge/ci/release.py) — the ROOT-owned copy at
# /usr/local/bin/ci-release is a stable shim over that entry point, so every
# fix here reaches the box on the next `agentforge update apply` instead of
# waiting for someone to re-run install-ci-task-creation.sh. Kept as bash
# rather than rewritten in Python for the same reason as ci-autorelease: the
# argv validation and its exact error text are load-bearing (they are what
# release-gate-workflow tests assert against) and moving them unchanged is
# far lower risk than re-deriving them.
#
# Root-owned on purpose: the CI runner account may execute this as
# `agentforge` but must not be able to change what it does. Same shape, same
# reasoning and the same single sudoers rule pattern as ci-create-agent-task,
# which this joins rather than replaces (the fix-task path is unchanged).
#
# Why a wrapper at all: the release verbs need the `agentforge` user's Linear
# identity (vault + agents/*/config.json, both 0600), and the CI runner account
# must not be able to reach into that tree or to choose what runs under it. The
# runner may therefore say "post the brief for THIS candidate" and nothing else
# — argv is validated here, never interpolated into a shell.
set -euo pipefail

ROOT=/home/agentforge/AgentForge

verb="${1:?usage: ci-release <brief|status|close|finding> <candidate-tag> [extra]}"
candidate="${2:?usage: ci-release <brief|status|close|finding> <candidate-tag> [extra]}"
extra="${3:-}"

case "$verb" in
  brief|status|close|finding) ;;
  *) echo "invalid verb: $verb (expected brief|status|close|finding)" >&2; exit 2 ;;
esac

# `<product>-v<major>.<minor>.<patch>` optionally followed by `-rc.<n>`. Nothing
# else parses, so a tag cannot carry an option, a path or a shell metacharacter
# into the command line below.
if [[ ! "$candidate" =~ ^([a-z0-9][a-z0-9-]*)-(v[0-9]+\.[0-9]+\.[0-9]+)(-rc\.[0-9]+)?$ ]]; then
  echo "invalid candidate tag: $candidate" >&2
  exit 2
fi
product="${BASH_REMATCH[1]}"
version="${BASH_REMATCH[2]}"

# `unclassified` is in the list on purpose: pre-release-qa's resolver emits it
# for a candidate whose tag carries no Release-Class trailer (SID-231), and
# refusing it here would turn "nobody classified this" into "the brief never
# posted" — swapping a visible warning for a silent gap, which is the whole
# failure this file's neighbours exist to prevent.
case "$extra" in
  ""|routine|sensible|unclassified|green|red|unknown|blocking|note) ;;
  *) echo "invalid 3rd argument: $extra" >&2; exit 2 ;;
esac

PY="$ROOT/runtime/.venv/bin/python"

case "$verb" in
  brief)
    # `--class` only accepts the two release classes; a gate value here would
    # be meaningless but harmless, so the shared allowlist above is enough.
    exec "$PY" -m agentforge.linear_cli release brief \
      --root "$ROOT" --product "$product" --version "$version" \
      --candidate "$candidate" --class "${extra}" --payload -
    ;;
  status)
    # Fail-closed: no `--require-go` means "report", with it means "guard".
    # The deployment path always passes the gate colour explicitly, because
    # `unknown` is not green and must not read as green.
    #
    # Not a read-only verb: it reconciles the release graph first (re-laying
    # the `blocks` arcs a create-then-relate lost) and then judges what is
    # left. That is deliberate — this runs last before a deployment, which is
    # both the moment to notice and the moment to fix.
    exec "$PY" -m agentforge.linear_cli release status \
      --root "$ROOT" --product "$product" --version "$version" \
      --gate "${extra:-unknown}" --require-go --json
    ;;
  close)
    exec "$PY" -m agentforge.linear_cli release close \
      --root "$ROOT" --product "$product" --version "$version"
    ;;
  finding)
    # Added for the fail-closed merge back (SID-386): when
    # `release-publish.yml`'s `merge-back` cannot merge `release/<date>`
    # back into `main`, it files a BLOCKING issue against the release rather
    # than closing it, and this is the only path it has to Linear.
    #
    # The TITLE is built here, not passed in. Free text on a command line is
    # exactly what the argv allowlist above exists to keep out, and the caller
    # has nothing to say that the candidate tag does not already say. The body
    # — which does carry a PR URL, a run URL and newlines — arrives on stdin,
    # the same way `brief` takes its payload.
    kind="${extra:-blocking}"
    case "$kind" in
      blocking|note) ;;
      *) echo "invalid finding kind: $kind (expected blocking or note)" >&2; exit 2 ;;
    esac
    # `kind` (blocking|note) is this wrapper's own stable argv contract; it is
    # translated here to `linear_cli`'s severity vocabulary
    # (release-finding-schema.md: blocker|should-fix|nit) rather than exposed
    # as-is, so a future caller of THIS wrapper never has to know that vocabulary
    # changed underneath it.
    severity="blocker"
    [ "$kind" = "note" ] && severity="should-fix"
    body="$(cat)"
    [ -n "$body" ] || { echo "ci-release finding: empty body on stdin" >&2; exit 2; }
    printf '%s' "$body" | "$PY" -m agentforge.linear_cli release finding \
      --root "$ROOT" --product "$product" --version "$version" \
      --severity "$severity" \
      --title "Fusion retour bloquée — $candidate" --body -
    exit $?
    ;;
esac
