#!/usr/bin/env bash
#
# watch-gate - read the canonical pipeline's JOB LIST for the current HEAD and say
#              whether the gate is green, by D-023(5)'s definition and no other.
#
# WHY THIS EXISTS, and it is a process defect rather than a tooling gap.
#
# On 2026-08-25 the coordinator pushed three commits in a row - T-801, T-802, T-803 -
# without reading any of their pipelines, and all three were RED. Earlier the same day
# it had done the same thing once already, signed two rows and tagged the theme's 1.0.0
# on top of a red gate, and wrote down that "pushing is not finishing; finishing is
# having read the pipeline's job list". Then it did it again within hours.
#
# A resolution that has already failed twice is not a fix. This is the fix: one command,
# short enough to be run every time, that turns "I pushed" into "I know".
#
# WHAT IT DOES NOT DO. It does not decide anything. D-023(5) defines the gate as a
# statement about the JOB LIST read from the API - never the pipeline's status field,
# which has reported `success` over a failed job in this very project (I-043). So this
# prints the list, applies that rule, and exits non-zero when the rule is not met.
#
# IT RE-READS HEAD ON EVERY POLL, and that was a DEFECT FIX rather than a nicety.
# Until 2026-08-26 --wait resolved HEAD once and watched that sha to the end. Push again
# while it waits - which is the normal rhythm of a working session - and two things follow,
# one merely annoying and one dangerous:
#
# `canceling` IS IN THE NON-TERMINAL LIST, and it is there because the first version of this
# fix missed it. GitLab's cancellation is a two-step state - `canceling` then `canceled` - and
# a run that reached the API mid-transition printed six jobs `canceled`, two `canceling` and a
# NOT GREEN verdict. Treating `canceling` as terminal is the same defect this whole block is
# about, one state earlier. Observed on pipeline 936407, not reasoned about.
#
#   FALSE RED    GitLab auto-cancels the superseded pipeline. Observed: pipeline 936400 came
#                back with phpunit `canceled`, and this script reported NOT GREEN over a
#                commit nothing was wrong with. A red that has to be explained away teaches
#                people to explain reds away.
#   FALSE GREEN  the serious one. It reports GREEN for a commit that is no longer HEAD, while
#                the code actually on the branch has been tested by nothing. That is this
#                project's whole catalogue of false greens, arriving through the very tool
#                written to end them.
#
# So HEAD is re-read every iteration and a move is announced and re-targeted, never absorbed
# silently. A terminal `canceled` is also checked against the ref for a NEWER pipeline before
# it is called a failure: superseded and broken look identical in the status field and are
# not the same event.
#
# Usage:  bash tests/bin/watch-gate [--wait] [--repo <project-path>] [--mirror-page <n>]
#           --wait         poll until the pipeline reaches a terminal state (default: report now)
#           --repo         defaults to the remote of this checkout
#           --mirror-page  how many GitHub runs to examine for the streak (default 30)

set -u

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && { pwd -W 2>/dev/null || pwd; })"
cd "$REPO" || { echo "FATAL: cannot cd to repository root"; exit 2; }

API="https://git.drupalcode.org/api/v4/projects"
PROJECT=""
WAIT=0
MIRROR_PAGE=30
while [ $# -gt 0 ]; do
  case "$1" in
    --wait) WAIT=1 ;;
    --repo) shift; PROJECT="${1:-}" ;;
    --mirror-page) shift; MIRROR_PAGE="${1:-}" ;;
    *) echo "unknown argument: $1" >&2; exit 2 ;;
  esac
  shift
done

case "$MIRROR_PAGE" in
  ''|*[!0-9]*) echo "FATAL: --mirror-page wants a positive integer, got '$MIRROR_PAGE'" >&2; exit 2 ;;
esac
[ "$MIRROR_PAGE" -gt 0 ] || { echo "FATAL: --mirror-page 0 would examine nothing" >&2; exit 2; }

# The project is inferred from this checkout's canonical remote even when --repo
# names one explicitly, because the MIRROR section below reads the GitHub remote
# of THIS tree. If the two disagree, the mirror this tree can reach is not the
# mirror of the project being watched, and saying so is the only honest answer -
# reporting the wrong repository's conclusion under the right project's name is
# precisely the shape of false green this whole script exists to refuse.
URL="$(git config --get remote.drupalcode.url 2>/dev/null || true)"
INFERRED=""
case "$URL" in
  *agora_theme*)        INFERRED="project%2Fagora_theme" ;;
  *agora_transparency*) INFERRED="project%2Fagora_transparency" ;;
esac

if [ -z "$PROJECT" ]; then
  [ -n "$INFERRED" ] || { echo "FATAL: cannot infer the project from remote.drupalcode.url ('$URL'). Pass --repo." >&2; exit 2; }
  PROJECT="$INFERRED"
fi

SHA="$(git rev-parse HEAD)"
SHORT="${SHA:0:7}"
echo "HEAD $SHORT in $(basename "$REPO")"

# Re-read HEAD and re-target if it moved. Announced, never absorbed: the whole value of this
# script is that its verdict names the commit the verdict is about.
retarget_if_head_moved() {
  local now
  now="$(git rev-parse HEAD)"
  [ "$now" = "$SHA" ] && return 1
  echo "  HEAD MOVED: $SHORT -> ${now:0:7} - re-targeting; the earlier sha's pipeline is superseded"
  SHA="$now"
  SHORT="${SHA:0:7}"
  return 0
}

# The floor. D-023(5) says `jobs >= 7`; the observed lists are larger and the operative
# floor lives beside the observed table in CLAUDE.md. Deliberately NOT hard-coded to the
# current count: a number that must be raised every time the list grows is a number
# somebody eventually lowers instead.
FLOOR=7

# THE FLOOR WAS NEVER ENOUGH, AND AN AUDIT SAID SO ON 2026-08-26.
#
# Until today this script checked the job COUNT and never the job NAMES. A vanished
# `nightwatch` leaves eight jobs, clears FLOOR=7, and the script printed GREEN - over a
# pipeline that had stopped running the accessibility gate. That is precisely the mistake
# CLAUDE.md's paired job tables were shaped to prevent, and I-061 states it in as many
# words: "a floor catches a pipeline that materialised almost nothing; only an observed job
# LIST catches a single missing job." The tool that reads the gate did not implement the
# idiom the same author had written down hours earlier.
#
# So the names are declared, per project, and compared in BOTH directions:
#   MISSING  a declared job absent from the pipeline  -> NOT GREEN, named
#   NEW      a job present and not declared           -> NOT GREEN, named
#
# The second direction matters as much as the first. A new job is a change to the gate, and
# a gate that grows silently is one nobody decided to grow. The remedy in either direction
# is to OBSERVE the new list, update CLAUDE.md's table in the commit that changes it, and
# update these lines with it - never to delete a name to get back to green.
#
# The two sets are DIFFERENT and that is the point (I-061): both projects run nine blocking
# jobs and three of the nine differ. A shared floor of nine would be satisfied by two
# different lists.
EXPECTED_agora_transparency='Drupal CMS
agora-invariants
composer
composer-lint
cspell
eslint
phpcs
phpstan
phpunit
phpunit-pgsql'

# `phpunit` joined the THEME's list on 2026-09-02 with its first PHP test class.
# It is declared here rather than left to appear on its own, because that is what
# declaring a list is for: this script printed UNDECLARED JOB on the very
# pipeline that introduced the job. A gate that grows silently is one nobody
# decided to grow.
EXPECTED_agora_theme='agora-invariants
composer
composer-lint
cspell
eslint
nightwatch
phpcs
phpstan
phpunit
stylelint'

case "$PROJECT" in
  *agora_theme*)        EXPECTED="$EXPECTED_agora_theme" ;;
  *agora_transparency*) EXPECTED="$EXPECTED_agora_transparency" ;;
  *) EXPECTED='' ;;
esac

# ---------------------------------------------------------------- THE MIRROR --
#
# WHY A SCRIPT WHOSE SUBJECT IS THE GATE ALSO READS SOMETHING THAT IS NOT THE GATE.
#
# On 2026-09-19 the GitHub mirror's only workflow was found to have failed on NINE
# consecutive runs since 2026-08-27 - three weeks, an email to the maintainer on
# every push, and nobody looked. D-020 keeps that workflow as an informative
# second opinion and says, in its own words, that it "may fail without blocking,
# but it may never lie". The half about lying had quietly stopped holding, and the
# mechanism that let it stop was not a missing check. It was that NOTHING IN THIS
# REPOSITORY EVER READ IT: the mirror was watched by a human noticing an email,
# which is the same mechanism that had just missed nine of them in a row.
#
# THIS IS WHERE IT BELONGS, and the alternative was considered by name and
# rejected. An INVARIANT under tests/bin/ that went red on a stale mirror would
# need the network and a GitHub token inside `agora-invariants`, a job that runs
# in a drupalcode container which has neither. A gate that goes red because a
# token is absent is a gate somebody makes permissive within the week - the exact
# shape D-023(5) exists to refuse, arriving through the door marked "more rigour".
# watch-gate already needs the network, already knows which commit the verdict is
# about, and is already the thing a human runs after a push. So the mirror is READ
# here and ACCOUNTED FOR offline in tests/bin/claims-match-sources, which names it
# in the NOT CHECKED list rather than leaving GitHub absent from both.
#
# IT DOES NOT TOUCH THE EXIT STATUS, and that is not timidity. D-020 makes
# drupalcode the gate and the mirror informative; promoting a red mirror to a
# failed gate here would be a signed decision taken by a tool instead of by a
# person. The verdict line is computed from the drupalcode job list and from
# nothing else, and this section says so on every run so that a later reader does
# not read the omission as an oversight and "fix" it.
#
# THE THIRD STATE IS THE WHOLE POINT. No gh, no authentication, no github remote,
# no run for this commit, a run still going, a page that answered nothing - each
# prints its own sentence naming itself. None of them may read as "the mirror is
# fine", because an absence of information reading as good news for three weeks is
# the entire defect being closed here (I-045, I-054).
#
# THE STREAK IS THE QUANTITY THAT WAS MISSING, and one conclusion for one commit
# would not have caught this. Every one of those nine runs was a fresh red on a
# fresh commit and each was individually unremarkable; what nobody had was the
# NUMBER NINE. So this counts consecutive non-success completed runs on the
# branch, names the conclusions they carried, names the last success and its age,
# and prints how many runs it examined. When the page it read contains no success
# at all it reports the streak as a FLOOR (">= N"), never as a count it cannot
# justify - the same reason `jobs: 0` is a failure and not an empty result.
#
# WHY THE WORKFLOW LIST IS DECLARED. The mirror is scheduled to grow: D-009(d)
# and T-804 put visual regression on it. A second workflow whose conclusion
# nobody reads would reproduce this defect exactly, one file over, so the names
# are declared here and an undeclared one is named on sight. The disk side of the
# same comparison is offline and lives in tests/bin/claims-match-sources.
MIRROR_WORKFLOWS='phpunit.yml'
MIRROR_REPORTED=0

MIRROR_SKIP=''
if [ -z "$INFERRED" ]; then
  MIRROR_SKIP="this checkout has no recognised drupalcode remote, so which GitHub mirror belongs to $PROJECT cannot be established from here"
elif [ "$PROJECT" != "$INFERRED" ]; then
  MIRROR_SKIP="--repo names $PROJECT while this checkout is $INFERRED, so the github remote here is not that project's mirror"
fi

# Reported from an EXIT trap rather than from the verdict path, and that is a
# requirement rather than a convenience. This script has six terminating paths -
# no pipeline yet, still running, superseded, cancelled, green, not green - and
# the commonest way to run it is straight after a push, which lands on "no
# pipeline yet". A mirror section printed only beside a finished verdict would be
# absent from exactly the invocation a human makes most often, which is how it
# would come to be unread again. The trap captures $? first and returns it, so
# this section can never change the verdict it is printed beside.
report_mirror() {
  _rc=$?
  [ "$MIRROR_REPORTED" -eq 1 ] && return "$_rc"
  MIRROR_REPORTED=1

  printf '\nmirror (GitHub Actions) - INFORMATIVE under D-020. It does not affect the exit\n'
  printf 'status: the verdict above is computed from the drupalcode job list and nothing else.\n'

  if [ -n "$MIRROR_SKIP" ]; then
    printf '  NOT READ - %s\n' "$MIRROR_SKIP"
    return "$_rc"
  fi

  _mirror_url="$(git config --get remote.github.url 2>/dev/null || true)"
  if [ -z "$_mirror_url" ]; then
    printf '  NOT READ - this checkout has no remote named `github`, which is the name D-016\n'
    printf '             gives the mirror. That is not "the mirror is fine"; it is "nothing\n'
    printf '             here can see the mirror".\n'
    return "$_rc"
  fi

  _slug="$_mirror_url"
  _slug="${_slug#https://github.com/}"
  _slug="${_slug#http://github.com/}"
  _slug="${_slug#git@github.com:}"
  _slug="${_slug#ssh://git@github.com/}"
  _slug="${_slug%.git}"
  case "$_slug" in
    */*) : ;;
    *) printf '  NOT READ - remote.github.url (%s) does not parse to owner/repo\n' "$_mirror_url"
       return "$_rc" ;;
  esac

  if ! command -v gh >/dev/null 2>&1; then
    printf '  NOT READ - gh is not installed, so the mirror cannot be reached from this shell.\n'
    printf '             Read it by hand at https://github.com/%s/actions - an unread\n' "$_slug"
    printf '             mirror is exactly how nine consecutive reds went unnoticed for\n'
    printf '             three weeks. THIS IS NOT A PASS.\n'
    return "$_rc"
  fi

  _branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
  _query="repos/$_slug/actions/runs?per_page=$MIRROR_PAGE"
  if [ "$_branch" = "HEAD" ]; then
    _scope='all branches - this checkout is in detached HEAD, so no branch filter applies'
  else
    _query="$_query&branch=$_branch"
    _scope="branch $_branch"
  fi

  _err_file="$(mktemp -t watch-gate-mirror.XXXXXX 2>/dev/null)" || _err_file=''
  if [ -z "$_err_file" ]; then
    printf '  NOT READ - cannot create a temp file, so the gh error stream cannot be read\n'
    printf '             and a failure could not be told apart from an empty answer.\n'
    return "$_rc"
  fi
  _json="$(gh api "$_query" 2>"$_err_file")"
  _gh_status=$?
  _msg="$(tr '\n' ' ' < "$_err_file" | cut -c1-300)"
  rm -f "$_err_file"

  if [ "$_gh_status" -ne 0 ]; then
    case "$_msg" in
      *authentication*|*"not logged"*|*"Bad credentials"*|*"HTTP 401"*|*"gh auth login"*|*GH_TOKEN*)
        printf '  NOT READ - gh is installed and NOT AUTHENTICATED for %s.\n' "$_slug"
        printf '             Run `gh auth login`, then re-run. A third state, not a pass.\n' ;;
      *"HTTP 404"*|*"Not Found"*)
        printf '  NOT READ - GitHub answered 404 for %s: either that repository does not\n' "$_slug"
        printf '             exist or this token cannot see it. Either way it is unwatched.\n' ;;
      *)
        printf '  NOT READ - the GitHub API call failed (gh exit %s).\n' "$_gh_status" ;;
    esac
    printf '             gh said: %s\n' "${_msg:-(nothing on stderr)}"
    return "$_rc"
  fi

  # The env vars go on the python3 side of the pipe. Setting them on gh would
  # export them into the process that does not read them - the same mistake the
  # job-list reader above records having made once already.
  printf '%s' "$_json" | \
    MIRROR_HEAD="$SHA" MIRROR_SCOPE="$_scope" MIRROR_SLUG="$_slug" \
    MIRROR_DECLARED="$MIRROR_WORKFLOWS" MIRROR_PAGE="$MIRROR_PAGE" python3 -c '
import datetime, json, os, sys

def out(line):
    sys.stdout.write("  " + line + "\n")

try:
    data = json.load(sys.stdin)
except Exception as exc:
    out("NOT READ - the GitHub API answered something that is not JSON: %s" % exc)
    raise SystemExit(0)

runs = data.get("workflow_runs")
if runs is None:
    out("NOT READ - the answer carries no workflow_runs key, so nothing was examined.")
    raise SystemExit(0)

page = os.environ["MIRROR_PAGE"]
out("runs examined: %d of at most %s  (%s, newest first)"
    % (len(runs), page, os.environ["MIRROR_SCOPE"]))
if not runs:
    out("NO RUNS AT ALL in that scope. That is an unread mirror, not a green one.")
    raise SystemExit(0)

UTC = datetime.timezone.utc
NOW = datetime.datetime.now(UTC)

def age_days(stamp):
    try:
        when = datetime.datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ")
    except Exception:
        return None
    return (NOW - when.replace(tzinfo=UTC)).days

def aged(stamp):
    days = age_days(stamp or "")
    return "%s (%s days ago)" % (stamp, days) if days is not None else str(stamp)

head = os.environ["MIRROR_HEAD"]
short = head[:7]
noisy = False

# ------------------------------------------ the run for the commit under verdict
mine = [r for r in runs if r.get("head_sha") == head]
if not mine:
    noisy = True
    out("HEAD %s: NO RUN - GitHub has started none for this commit in the page" % short)
    out("         examined. Pushed to `github` yet? The mirror is a separate push.")
    out("         NO RUN IS A THIRD STATE: neither a pass nor a failure.")
else:
    run = mine[0]
    status = run.get("status") or "?"
    verdict = run.get("conclusion")
    if status != "completed":
        noisy = True
        out("HEAD %s: %s - it has concluded NOTHING yet (run %s)"
            % (short, status, run.get("id")))
    elif verdict == "success":
        out("HEAD %s: success  run %s  %s" % (short, run.get("id"), run.get("created_at")))
    else:
        noisy = True
        out("HEAD %s: %s  run %s  %s   <-- NOT a success"
            % (short, verdict or "(no conclusion recorded)", run.get("id"),
               run.get("created_at")))

# ----------------------------------------------------------------- the streak
completed = [r for r in runs if r.get("status") == "completed"]
out("completed runs in that page: %d" % len(completed))
if not completed:
    noisy = True
    out("streak: NOT COMPUTED - none of the runs examined has completed.")
else:
    streak = 0
    for run in completed:
        if run.get("conclusion") == "success":
            break
        streak += 1
    success = None
    for run in completed:
        if run.get("conclusion") == "success":
            success = run
            break

    if streak == 0:
        out("consecutive non-success completed runs: 0 - the newest completed run passed.")
    else:
        noisy = True
        kinds = {}
        for run in completed[:streak]:
            key = run.get("conclusion") or "(none)"
            kinds[key] = kinds.get(key, 0) + 1
        spread = ", ".join("%s x%d" % (k, v) for k, v in sorted(kinds.items()))
        oldest = completed[streak - 1].get("created_at")
        if success is None:
            out("RED STREAK: >= %d consecutive non-success completed runs - the page" % streak)
            out("            examined holds NO success, so that is a FLOOR and not a count.")
            out("            Re-run with --mirror-page N to bound it.")
        else:
            out("RED STREAK: %d consecutive non-success completed runs." % streak)
        out("            conclusions in the streak: %s" % spread)
        out("            oldest in the streak: %s" % aged(oldest))

    if success is None:
        out("last success: NONE in the %d runs examined." % len(runs))
    else:
        out("last success: %s  run %s  %s"
            % ((success.get("head_sha") or "?")[:7], success.get("id"),
               aged(success.get("created_at"))))

# -------------------------------------------------------- workflow accounting
declared = sorted(set(os.environ.get("MIRROR_DECLARED", "").split()))
seen = sorted(set((r.get("path") or "").split("/")[-1] for r in runs if r.get("path")))
out("workflows declared here: %d  %s" % (len(declared), " ".join(declared) or "(none)"))
out("workflows seen in page:  %d  %s" % (len(seen), " ".join(seen) or "(none)"))
for name in sorted(set(seen) - set(declared)):
    noisy = True
    out("UNDECLARED WORKFLOW: %s runs on the mirror and is not declared here, so" % name)
    out("                     its conclusion is being read by nobody.")
for name in sorted(set(declared) - set(seen)):
    out("declared and not seen in this page: %s (it may simply not have run lately)" % name)

if noisy:
    out("look at it: https://github.com/%s/actions" % os.environ["MIRROR_SLUG"])
'
  _reader_status=$?
  if [ "$_reader_status" -ne 0 ]; then
    printf '  NOT READ - the mirror reader itself failed (exit %s). A reader that did not\n' "$_reader_status"
    printf '             run is not a mirror that is fine (I-027).\n'
  fi
  return "$_rc"
}

# ------------------------------------------------------------------ THE TAGS --
#
# WHY A SCRIPT ABOUT HEAD ALSO READS PIPELINES THAT ARE NOT ON A BRANCH.
#
# Every place this file derived a ref took the BRANCH: the mirror section above and
# the supersession check below both call `git rev-parse --abbrev-ref HEAD`, and
# tests/bin/mirror-streak is branch-scoped too. So a pipeline whose ref is a TAG was
# read by NOTHING in either repository, and that is a property of the tooling rather
# than a property of tags.
#
# WHAT IT COST, measured from the API and not imagined. agora_theme pipeline 969368,
# ref 1.2.0, sha 4f82307: four of its ten jobs - cspell, nightwatch, phpcs, stylelint
# - died on `error: 429` and `exit code 128` within four seconds, having never
# cloned, so nothing was measured. It stayed RED FOR 23 HOURS while the IDENTICAL
# COMMIT was green on the branch: pipeline 969322, ref 1.x, same sha 4f82307. Same
# bytes, two refs, two answers. What ended it was a person going to look before
# writing release notes, not anything reporting it.
#
# WHY A TAG RED IS WORSE THAN A BRANCH RED. A tag pipeline is what sits beside a
# PUBLISHED RELEASE on drupal.org, which is the one page a marketplace reviewer
# opens. A branch red is seen by the people who push; a tag red is seen by strangers.
#
# THE TAG ANALOGUE OF THE STREAK IS THE AGE, and it is a different question from the
# one T-0623 answered. The mirror needed a streak because nine reds on nine commits
# were each individually unremarkable and nobody ever held the number nine. A tag has
# exactly ONE pipeline, so there is no streak to count. What nobody held during those
# 23 hours was HOW LONG IT HAD BEEN RED, and whether it was the NEWEST tag. Both are
# printed, and only for a tag that is not green, because a duration beside a pass is
# noise.
#
# IT DOES NOT TOUCH THE EXIT STATUS, for the mirror reason with a sharper edge: the
# remedy for a red tag pipeline is a RETRY, which is a human action on a ref the
# current commit is not even on. Failing the gate on it would be an unfixable red,
# exactly what D-023(5) refuses. Falsified in both directions rather than asserted -
# a red tag still exits 0 over a green branch pipeline, and a green tag still exits 1
# over a branch pipeline that measured nothing.
#
# THE DECLARED JOB LIST IS DELIBERATELY NOT APPLIED TO TAGS, and that is a
# measurement rather than a preference. Of the 11 tags agora_theme carries, EIGHT ran
# NINE jobs and three ran ten: `phpunit` joined that project on 2026-09-02 and tags
# 1.0.0 through 1.0.7 predate it. Comparing a historical tag against the list
# declared TODAY would report MISSING JOB: phpunit on eight of eleven tags - every
# one correct history, not one of them fixable by anybody. A section that prints
# eight findings nobody can act on is a section people learn to skip, which is the
# defect this whole file exists to close. So a tag pipeline gets the REST of
# D-023(5) - read from the API, jobs above zero, at or above the floor, every job
# success, every job blocking - and the job COUNT is printed so a reader can see that
# history rather than be alarmed by it.
#
# THE BOUND IS A CONSTANT AND NOT AN OPTION, unlike --mirror-page above, and the
# asymmetry is the reason. Widening the mirror page only ever shows MORE runs, so it
# is safe on a command line; a tag bound works the other way, and a `--tag-limit 1`
# would be a one-word way to make an older red tag vanish from the output of the tool
# written to find it. Lowering it has to be an edit to a versioned file that a
# reviewer can see. FIVE is defensible rather than arbitrary: it is more than one, so
# a red tag is not hidden behind a newer green one, and it bounds the work at 1 + 2N
# requests however many releases a project accumulates. Every tag it does NOT open is
# named, because a bound that is silent about what it skipped reads as coverage.
TAG_LIMIT=5
TAGS_REPORTED=0

report_tags() {
  [ "$TAGS_REPORTED" -eq 1 ] && return 0
  TAGS_REPORTED=1

  printf '\ntags (drupalcode) - INFORMATIVE. It does not affect the exit status: the verdict\n'
  printf 'above is computed from the job list for HEAD and nothing else. The remedy for a\n'
  printf 'red tag pipeline is a retry, which no commit can perform.\n'

  if ! command -v python3 >/dev/null 2>&1; then
    printf '  NOT READ - python3 is not on PATH, so the tag reader cannot run. That is not\n'
    printf '             "the tags are fine"; it is "nothing here looked".\n'
    return 0
  fi

  # Anonymous on purpose. The job LIST needs no credential - only /jobs/<id>/trace
  # answers 401 - so nothing here wires a token into anything.
  TAG_API="$API" TAG_PROJECT="$PROJECT" TAG_LIMIT="$TAG_LIMIT" \
  TAG_FLOOR="$FLOOR" TAG_EXPECTED="$EXPECTED" python3 -c '
import datetime, json, os, sys, urllib.error, urllib.parse, urllib.request
# cspell:ignore fromisoformat isinstance urlopen

PAD = " " * 19    # detail under a tag row, aligned past the sha column
SUB = " " * 14    # continuation under NOT EXAMINED:
NOTE = " " * 6    # continuation under note:

def out(line):
    sys.stdout.write("  " + line + "\n")

API = os.environ["TAG_API"]
PROJECT = os.environ["TAG_PROJECT"]
LIMIT = int(os.environ["TAG_LIMIT"])
FLOOR = int(os.environ["TAG_FLOOR"])
DECLARED = len([n for n in os.environ.get("TAG_EXPECTED", "").split(chr(10)) if n])

# Same list the verdict body uses. `canceling` is in it because GitLab cancellation
# is a two-step state and treating the first step as terminal is a known defect.
NOT_TERMINAL = ("running", "pending", "created", "waiting_for_resource",
               "preparing", "canceling")

def get(path):
    url = API + "/" + PROJECT + path
    try:
        req = urllib.request.Request(url, headers={"Accept": "application/json"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            if resp.status != 200:
                return None, "HTTP %s" % resp.status
            return json.load(resp), None
    except urllib.error.HTTPError as exc:
        return None, "HTTP %s" % exc.code
    except Exception as exc:
        return None, "%s: %s" % (type(exc).__name__, exc)

NOW = datetime.datetime.now(datetime.timezone.utc)

def aged(stamp):
    text = (stamp or "").strip()
    if not text:
        return "(no timestamp recorded)"
    try:
        when = datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
    except Exception:
        return text
    hours = (NOW - when).total_seconds() / 3600.0
    if hours < 48:
        return "%s (%.0f hours ago)" % (text, hours)
    return "%s (%.0f days ago)" % (text, hours / 24.0)

tags, why = get("/repository/tags?per_page=100&order_by=updated&sort=desc")
if tags is None:
    out("NOT READ - the tags endpoint could not be read (%s). An unread tag list" % why)
    out("           is a third state: neither a pass nor a failure.")
    raise SystemExit(0)
if not isinstance(tags, list):
    out("NOT READ - the tags endpoint answered something that is not a list, so")
    out("           nothing was examined.")
    raise SystemExit(0)

total = len(tags)
more = " (at least - the page came back full)" if total >= 100 else ""
if total == 0:
    out("tags in this project: 0 - none has ever been cut here. That is a FACT about")
    out("the repository and not a failure: there is no tag pipeline going unread.")
    raise SystemExit(0)

examined = tags[:LIMIT]
skipped = tags[LIMIT:]
out("tags in this project: %d%s   examined: %d, newest first, bounded by TAG_LIMIT=%d"
    % (total, more, len(examined), LIMIT))
out("newest is by COMMIT DATE (order_by=updated): GitLab exposes no tag creation")
out("date, so a tag cut today onto an old commit sorts as old. Named, not hidden.")

read = 0
green = 0
counts = []
noisy = False

for index, tag in enumerate(examined):
    name = tag.get("name") or "?"
    sha = ((tag.get("commit") or {}).get("id") or "")[:7]
    label = "%-10s %-7s" % (name[:10], sha)
    newest = (index == 0)

    pipes, why = get("/pipelines?ref=" + urllib.parse.quote(name, safe="") + "&per_page=1")
    if pipes is None:
        noisy = True
        out("%s NOT READ - the pipelines endpoint failed (%s)" % (label, why))
        continue
    if not isinstance(pipes, list) or not pipes:
        out("%s NO PIPELINE - drupalcode started none for this tag. A third state:" % label)
        out(PAD + "neither a pass nor a failure.")
        continue

    pipe = pipes[0]
    pid = pipe.get("id")
    status = pipe.get("status") or "?"
    head = "%s pipeline %-7s" % (label, pid)

    if status in NOT_TERMINAL:
        noisy = True
        out("%s %s - it has concluded NOTHING yet" % (head, status))
        continue

    jobs, why = get("/pipelines/%s/jobs?per_page=100" % pid)
    if jobs is None:
        noisy = True
        out("%s NOT READ - the jobs endpoint failed (%s)" % (head, why))
        continue
    read += 1
    if not isinstance(jobs, list) or not jobs:
        noisy = True
        out("%s jobs: 0 - that is a FAILURE, not an empty result (D-023(5))" % head)
        continue

    counts.append(len(jobs))
    failed = sorted(j.get("name") or "?" for j in jobs if j.get("status") != "success")
    loose = sorted(j.get("name") or "?" for j in jobs if j.get("allow_failure"))
    below = len(jobs) < FLOOR

    if not failed and not loose and not below:
        green += 1
        out("%s jobs %-3d GREEN" % (head, len(jobs)))
        continue

    noisy = True
    out("%s jobs %-3d NOT GREEN" % (head, len(jobs)))
    if failed:
        out(PAD + "not success: " + ", ".join(failed))
    if loose:
        out(PAD + "allow_failure=true, needs a dated exception: " + ", ".join(loose))
    if below:
        out(PAD + "BELOW THE FLOOR: %d jobs, floor is %d" % (len(jobs), FLOOR))
    if status == "success":
        out(PAD + "the pipeline status field says success. It is not the evidence")
        out(PAD + "either way - this project has seen that exact lie (I-043).")
    out(PAD + "non-green since " + aged(pipe.get("updated_at")))
    out(PAD + "that duration is the quantity nobody held the last time this")
    out(PAD + "happened, and it is why the age is printed rather than the state.")
    if newest:
        out(PAD + "THIS IS THE NEWEST TAG: it is what sits beside the current")
        out(PAD + "release on drupal.org, where a reviewer looks.")
    out(PAD + "look at it: https://git.drupalcode.org/"
        + urllib.parse.unquote(PROJECT) + "/-/pipelines/" + str(pid))

if skipped:
    names = [t.get("name") or "?" for t in skipped]
    shown = ", ".join(names[:8])
    if len(names) > 8:
        shown += " and %d more" % (len(names) - 8)
    out("NOT EXAMINED: %d older tag(s) - %s" % (len(names), shown))
    out(SUB + "TAG_LIMIT bounds the requests, not the risk. This is not a clean")
    out(SUB + "bill of health for the tags it did not open.")

if DECLARED and counts and min(counts) < DECLARED:
    fewer = len([c for c in counts if c < DECLARED])
    out("note: %d of the %d pipelines read ran FEWER jobs than the %d declared for"
        % (fewer, read, DECLARED))
    out(NOTE + "this project. That is HISTORY, not drift: a tag pipeline measures")
    out(NOTE + "the gate as it WAS. The declared-name comparison is deliberately")
    out(NOTE + "not applied to tags - see the block above this section.")

out("examined: %d - pipelines read: %d - green by the job-list test: %d"
    % (len(examined), read, green))
if read == 0:
    out("PIPELINES READ: 0 across %d tag(s) examined. NOTHING WAS MEASURED here,"
        % len(examined))
    out("which is not the same as nothing being wrong (I-028).")
elif noisy:
    out("a tag above is not green. The remedy is a RETRY on that pipeline, which is")
    out("a human action: this section reports it and deliberately does not gate on it.")
'
  _tag_status=$?
  if [ "$_tag_status" -ne 0 ]; then
    printf '  NOT READ - the tag reader itself failed (exit %s). A reader that did not\n' "$_tag_status"
    printf '             run is not a set of tags that is fine (I-027).\n'
  fi
  return 0
}

# ONE EXIT TRAP, TWO SECTIONS, and the wrapper is a requirement rather than tidiness.
# bash installs a SINGLE EXIT trap, so a bare `trap report_tags EXIT` would have
# REPLACED the mirror trap and silently deleted the section T-1907 added - a section
# removed by an unrelated change being exactly how the mirror came to be unread the
# first time.
#
# The subshell on the first line restores $? for report_mirror, whose own header
# promises it captures $? first and returns it. Called from inside this wrapper it
# would otherwise capture the status of the assignment above it - zero, always - and
# that promise would quietly become false while still reading true. Measured, not
# reasoned: with the restore in place report_mirror sees 3 for a body that exits 3 and
# 0 for a body that exits 0.
#
# The script exit status is preserved by bash itself - an EXIT trap that does not call
# `exit` cannot change it, which was falsified both ways before this was trusted - and
# the explicit return says so to a reader who should not have to know that.
report_exit() {
  _exit_rc=$?
  ( exit "$_exit_rc" )
  report_mirror
  report_tags
  return "$_exit_rc"
}
trap report_exit EXIT

attempt=0
while : ; do
  attempt=$((attempt + 1))
  PIPE="$(curl -sS "$API/$PROJECT/pipelines?sha=$SHA&per_page=1" 2>/dev/null)"
  PID="$(printf '%s' "$PIPE" | python3 -c "
import json,sys
try:
    d = json.load(sys.stdin)
    print(d[0]['id'] if d else '')
except Exception:
    print('')
" 2>/dev/null)"

  if [ -z "$PID" ]; then
    echo "  no pipeline for $SHORT yet"
    [ "$WAIT" -eq 1 ] || { echo "watch-gate: NOT MEASURED - nothing to read"; exit 1; }
    sleep 30; retarget_if_head_moved; continue
  fi

  STATUS="$(printf '%s' "$PIPE" | python3 -c "
import json,sys
print(json.load(sys.stdin)[0]['status'])" 2>/dev/null)"

  case "$STATUS" in
    running|pending|created|waiting_for_resource|preparing|canceling)
      echo "  pipeline $PID: $STATUS (attempt $attempt)"
      [ "$WAIT" -eq 1 ] || { echo "watch-gate: NOT FINISHED - re-run with --wait"; exit 1; }
      sleep 30; retarget_if_head_moved; continue ;;
    canceled)
      # Superseded and broken look identical here. Ask the ref whether something newer exists
      # before calling it either one - and never call a superseded pipeline green.
      NEWER="$(curl -sS "$API/$PROJECT/pipelines?ref=$(git rev-parse --abbrev-ref HEAD)&per_page=1" 2>/dev/null | python3 -c "
import json,sys
try:
    d=json.load(sys.stdin); print(d[0]['id'] if d else '')
except Exception:
    print('')" 2>/dev/null)"
      if [ -n "$NEWER" ] && [ "$NEWER" != "$PID" ]; then
        echo "  pipeline $PID: canceled, and pipeline $NEWER is NEWER on this ref"
        echo "  -> SUPERSEDED, not failed. This is not a verdict about the code."
        if retarget_if_head_moved || [ "$WAIT" -eq 1 ]; then
          sleep 5; continue
        fi
        echo "watch-gate: NOT MEASURED - the pipeline for $SHORT was superseded before it finished"
        exit 1
      fi
      echo "  pipeline $PID: canceled, and nothing newer exists on this ref - somebody cancelled it"
      echo "watch-gate: NOT GREEN - a cancelled pipeline measured nothing"
      exit 1 ;;
  esac

  # The env var goes on the python3 side of the pipe, NOT the curl side. Setting it on curl
  # was the first attempt and it exported the list into the process that does not read it -
  # so python3 saw an empty EXPECTED_LIST and printed "NO DECLARED JOB LIST", which is the
  # honest degenerate message doing its job: the check announced that it was not checking.
  curl -sS "$API/$PROJECT/pipelines/$PID/jobs?per_page=100" 2>/dev/null | EXPECTED_LIST="$EXPECTED" python3 -c "
import json, os, sys
jobs = json.load(sys.stdin)
floor = $FLOOR
expected = set(n for n in os.environ.get('EXPECTED_LIST','').split(chr(10)) if n)
if not jobs:
    # 'jobs: 0' is a failure, not 'nothing to report' - D-023(5) says so in as many words.
    print('  jobs: 0 - that is a FAILURE, not an empty result')
    sys.exit(1)
bad = 0
for j in sorted(jobs, key=lambda x: x['name']):
    flag = '' if j['status'] == 'success' and not j['allow_failure'] else '   <-- '
    if flag:
        bad += 1
        flag += 'FAILED' if j['status'] != 'success' else 'PERMISSIVE (needs a dated exception)'
    print('  %-18s %-9s %-9s allow_failure=%s%s'
          % (j['name'], j['stage'], j['status'], j['allow_failure'], flag))
print('  jobs: %d' % len(jobs))
if len(jobs) < floor:
    print('  BELOW THE FLOOR: %d jobs, floor is %d' % (len(jobs), floor))
    bad += 1
if expected:
    got = set(j['name'] for j in jobs)
    for name in sorted(expected - got):
        print('  MISSING JOB: %s is declared and did not run - the count alone would not notice' % name)
        bad += 1
    for name in sorted(got - expected):
        print('  UNDECLARED JOB: %s ran and is not declared - a gate that grows silently is one nobody decided to grow' % name)
        bad += 1
    print('  declared: %d - matched: %d' % (len(expected), len(expected & got)))
else:
    print('  NO DECLARED JOB LIST for this project - the count is the only check, which I-061 says is not enough')
sys.exit(1 if bad else 0)
"
  rc=$?
  echo "  pipeline $PID: $STATUS"

  # THE LAST RE-READ, and it closes the window the earlier fix left open. HEAD was re-read on
  # every poll, but not once more between the API answering and the verdict printing - so a
  # push landing inside that window produced a verdict about a sha that had stopped being HEAD.
  # One poll's latency rather than 30 seconds, but not zero, and this script's own header says
  # its whole value is that the verdict names the commit it is about. So: re-read, and NAME THE
  # SHA in the verdict line, which until today it did not do.
  VERDICT_SHA="$SHORT"
  if retarget_if_head_moved; then
    echo "watch-gate: NOT MEASURED - HEAD moved to $SHORT while the verdict for $VERDICT_SHA was being read"
    exit 1
  fi
  if [ "$rc" -eq 0 ]; then
    echo "watch-gate: GREEN for $VERDICT_SHA - every declared job ran, every job success, every job blocking"
  else
    echo "watch-gate: NOT GREEN for $VERDICT_SHA - and the pipeline's own status field is not the evidence either way"
  fi
  exit "$rc"
done
