#!/bin/bash
# gh-merge-green REPO#PR [--main-fix] [--override "reason"] [--freeze-token TOKEN] [--window-token TOKEN]... [gh pr merge flags...]
# gh-merge-green --revert REPO#PR: open a revert PR for a merged PR and land it (scripts/ci/revert_pr.py).
# Merges only when the PR head's required-in-practice checks are green.
# main has no required status checks, so `gh pr merge --auto` merges at once; agents use this instead.
set -Eeuo pipefail

repair_note='see cmuxterm-hq REPAIR.md#merging'
refusal_reported=0

print_repair() {
  printf 'fix: %s\n%s\n' "$1" "$repair_note" >&2
}

on_error() {
  local status=$?
  if [ "$refusal_reported" -eq 0 ]; then
    print_repair "inspect the failed GitHub query or command, then retry after it is healthy"
  fi
  exit "$status"
}

refuse() {
  refusal_reported=1
  printf '%s\n' "$1" >&2
  print_repair "$2"
  exit 1
}

trap on_error ERR

[ "$#" -gt 0 ] || refuse \
  'not green: missing OWNER/REPO#NUMBER' \
  'pass the pull request reference, for example OWNER/REPO#123'
ref="$1"; shift
repo="${ref%#*}"; num="${ref#*#}"

# Resolve the checked-in script before locating scripts/ci. The installed
# ~/.local/bin symlink must keep --main-fix pointing at the reviewed checkout,
# even when the caller is outside that checkout.
script_path="${BASH_SOURCE[0]}"
while [ -L "$script_path" ]; do
  script_link_dir="$(cd -- "$(dirname -- "$script_path")" && pwd)"
  script_path="$(readlink "$script_path")"
  case "$script_path" in
    /*) ;;
    *) script_path="$script_link_dir/$script_path" ;;
  esac
done
script_dir="$(cd -- "$(dirname -- "$script_path")" && pwd)"

warn_checkout() {
  printf 'warning: %s\n%s\n' "$1" "$repair_note" >&2
}

maybe_update_checkout() {
  [ "${GH_MERGE_GREEN_NO_AUTO_UPDATE:-0}" = 1 ] && return 0
  local checkout_root branch status current origin_main new_script
  if ! checkout_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null); then
    return 0
  fi
  if ! status=$(git -C "$checkout_root" status --porcelain --untracked-files=all 2>/dev/null); then
    warn_checkout "cannot inspect helper checkout $checkout_root; not auto-updating"
    return 0
  fi
  if [ -n "$status" ]; then
    warn_checkout "helper checkout $checkout_root is dirty; not auto-updating (commit or stash its changes)"
    return 0
  fi
  if ! branch=$(git -C "$checkout_root" branch --show-current 2>/dev/null) || [ "$branch" != main ]; then
    warn_checkout "helper checkout $checkout_root is not on main; not auto-updating"
    return 0
  fi
  if ! git -C "$checkout_root" fetch --quiet origin main 2>/dev/null; then
    warn_checkout "could not fetch origin/main for helper checkout $checkout_root; not auto-updating"
    return 0
  fi
  if ! current=$(git -C "$checkout_root" rev-parse HEAD 2>/dev/null) ||
     ! origin_main=$(git -C "$checkout_root" rev-parse refs/remotes/origin/main 2>/dev/null); then
    warn_checkout "helper checkout $checkout_root has no readable origin/main; not auto-updating"
    return 0
  fi
  [ "$current" = "$origin_main" ] && return 0
  if git -C "$checkout_root" merge-base --is-ancestor "$current" "$origin_main" 2>/dev/null; then
    if ! git -C "$checkout_root" merge --ff-only "$origin_main" >/dev/null 2>&1; then
      warn_checkout "helper checkout $checkout_root could not fast-forward to origin/main; not auto-updating"
      return 0
    fi
    new_script="$checkout_root/scripts/gh-merge-green"
    if [ ! -x "$new_script" ]; then
      warn_checkout "updated helper checkout $checkout_root has no executable scripts/gh-merge-green; not re-executing"
      return 0
    fi
    GH_MERGE_GREEN_NO_AUTO_UPDATE=1 exec "$new_script" "$@"
  fi
  if git -C "$checkout_root" merge-base --is-ancestor "$origin_main" "$current" 2>/dev/null; then
    warn_checkout "helper checkout $checkout_root is ahead of origin/main; not auto-updating"
  else
    warn_checkout "helper checkout $checkout_root has diverged from origin/main; not auto-updating"
  fi
}

maybe_update_checkout "$ref" "$@"

# --revert: one-command rollback. The revert PR lands through this same tool.
if [ "$ref" = --revert ]; then
  exec python3 "$script_dir/ci/revert_pr.py" "$@"
fi

# --main-fix delegates to the existing compile/test evidence validator. That
# path posts the required audit comment before merging a red-main fix-forward.
fix=0
override_reason=''
freeze_token=''
window_tokens=()
args=()
while [ "$#" -gt 0 ]; do
  case "$1" in
    --main-fix)
      fix=1
      ;;
    --freeze-token)
      [ "$#" -ge 2 ] || refuse \
        'not green: --freeze-token requires the token from the FREEZE line' \
        'pass --freeze-token followed by the token=... value you hold'
      freeze_token="$2"
      shift
      ;;
    --window-token)
      [ "$#" -ge 2 ] || refuse \
        'not green: --window-token requires the token of the WINDOW area you hold' \
        'pass --window-token followed by the token: value of your [AREA] in the WINDOW'
      window_tokens+=("$2")
      shift
      ;;
    --override)
      [ "$#" -ge 2 ] || refuse \
        'not green: --override requires a reason' \
        'pass --override followed by a concrete reason of at least 8 words'
      override_reason="$2"
      shift
      ;;
    *)
      args+=("$1")
      ;;
  esac
  shift
done
set -- "${args[@]+"${args[@]}"}"
if [ -n "$override_reason" ]; then
  override_words=$(wc -w <<<"$override_reason")
  if [ "$override_words" -lt 8 ]; then
    refuse \
      'not green: --override reason must contain at least 8 words' \
      'explain the concrete red check and why this exact head is safe to merge'
  fi
fi
skip_vercel=0
if [ -n "$override_reason" ] && [[ "$override_reason" =~ [Vv]ercel ]]; then
  # Vercel is an optional preview deployment status in this repository.  An
  # explicit, auditable override may waive that one external status when it is
  # pending or red; all other pending statuses remain merge blockers.
  skip_vercel=1
fi
if [ "$fix" -eq 1 ] && [ -n "$override_reason" ]; then
  refuse \
    'not green: --override cannot be combined with --main-fix' \
    'use --main-fix for its evidence path, or run the normal exact-head merge with --override'
fi
if ! pr_json=$(gh pr view "$num" --repo "$repo" --json headRefOid,baseRefName,labels,isCrossRepository 2>/dev/null); then
  refuse \
    "not green: cannot read $ref" \
    'check the repository, pull request number, GitHub login, and network access'
fi
if ! fields=$(jq -r '[.headRefOid // "", .baseRefName // ""] | @tsv' <<<"$pr_json" 2>/dev/null); then
  refuse \
    "not green: GitHub returned invalid metadata for $ref" \
    'retry after GitHub returns headRefOid and baseRefName'
fi
IFS=$'\t' read -r sha base <<<"$fields"
if [ -z "$sha" ] || [ -z "$base" ]; then
  refuse \
    "not green: GitHub metadata for $ref has no head SHA or base branch" \
    'refresh the pull request metadata and retry'
fi
if jq -e 'any(.labels[]?; .name == "exploration")' <<<"$pr_json" >/dev/null 2>&1; then
  refuse \
    'exploration PR, needs a decision from Leo or the team before merging.' \
    'remove the exploration label only after Leo or the team decides to merge'
fi
[ $# -gt 0 ] || set -- --squash
if [ "$fix" -eq 1 ]; then
  validator="$script_dir/ci/main_fix_evidence.py"
  if [ ! -f "$validator" ]; then
    repo_root="${CMUX_REPO_ROOT:-}"
    if [ -z "$repo_root" ]; then
      repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
    fi
    origin=""
    [ -n "$repo_root" ] && origin=$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)
    case "$origin" in
      https://github.com/manaflow-ai/cmux.git|git@github.com:manaflow-ai/cmux.git) ;;
      *) repo_root="" ;;
    esac
    validator="${repo_root:+$repo_root/scripts/ci/main_fix_evidence.py}"
  fi
  if [ -z "$validator" ] || [ ! -f "$validator" ]; then
    refuse \
      'not green: --main-fix validator is unavailable' \
      'set CMUX_REPO_ROOT to a cmux checkout containing scripts/ci/main_fix_evidence.py'
  fi
  if ! python3 "$validator" "$ref" "$@"; then
    refuse \
      "not green: --main-fix evidence refused $ref" \
      'read the validator evidence and repair the failing or missing exact-head checks'
  fi
  exit 0
fi

# Require aggregate checks only when their workflow exists on the base. Otherwise
# every reported exact-head check and status context must be completed successfully.
workflow_response=''
workflow_on_base() {
  local file="$1" response
  if response=$(gh api --include "repos/$repo/contents/.github/workflows/$file" -X GET -f "ref=$base" 2>/dev/null); then
    workflow_response="$response"
    return 0
  fi
  if grep -qE '^HTTP/[^ ]+ 404([[:space:]]|$)' <<<"$response"; then
    return 1
  fi
  refuse \
    "not green: cannot probe $file on $repo base $base" \
    'restore GitHub contents API access; only HTTP 404 means the workflow is absent'
}

# ci-status is required only when ci.yml or ci-macos.yml defines that job;
# a ci.yml with only other jobs (bonsplit's `tests`) gets the all-checks path. Fails closed: a body that is missing, too large for the
# contents API, or unreadable still requires ci-status.
reports_ci_status() {
  python3 -c '
import base64, json, re, sys
raw = sys.stdin.read()
body = re.split(r"\r?\n\r?\n", raw, maxsplit=1)
try:
    data = json.loads(body[1])
    text = base64.b64decode(data["content"]).decode() if data.get("encoding") == "base64" else ""
except Exception:
    sys.exit(0)
if not text.strip():
    sys.exit(0)
sys.exit(0 if re.search(r"(?m)^\s+[\x22\x27]?ci-status[\x22\x27]?\s*:", text) else 1)
' <<<"$workflow_response"
}

has_ci=0
has_app_compile=0
if workflow_on_base ci.yml && reports_ci_status; then has_ci=1; fi
if workflow_on_base ci-macos.yml; then
  has_app_compile=1
  if reports_ci_status; then has_ci=1; fi
fi
must=()
excuse_runs=()
red_lanes=()
if [ "$has_ci" -eq 1 ]; then must+=("ci-status"); fi

# Use a conservative path fallback until the exact-head CI routing is available.
needs_app_compile=0
if [ "$base" = main ] && [ "$has_app_compile" -eq 1 ]; then
  # The files API paginates, unlike `gh pr diff` (HTTP 406 past 20000 lines); fail closed if it errors.
  if ! files=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].filename' 2>/dev/null); then
    refuse \
      "not green: cannot list changed files for $ref" \
      'restore GitHub API access, then retry the exact-head check'
  fi
  if grep -qE '\.(swift|m|mm|h|c|cpp|xcconfig|plist|entitlements|xcstrings)$|\.xcodeproj/|^(Sources|Packages|cmuxTests|cmuxUITests|ghostty|vendor)/|^Package\.(swift|resolved)$' <<<"$files"; then
    needs_app_compile=1
  fi
fi
if ! check_runs_json=$(gh api "repos/$repo/commits/$sha/check-runs?per_page=100" --paginate --slurp 2>/dev/null); then
  refuse \
    "not green: cannot read checks for $sha" \
    'restore GitHub API access, then retry the exact-head check'
fi
if ! runs=$(jq -r '
  (map(.check_runs) | add)
  | sort_by(.name, (.id // 0))
  | group_by(.name)[]
  | last
  | [.name,.status,(.conclusion // "")]
  | @tsv
  ' <<<"$check_runs_json" 2>/dev/null); then
  refuse \
    "not green: cannot parse checks for $sha" \
    'retry after GitHub returns valid check-run metadata'
fi

# CI's package graph can exclude iOS-only Swift changes that the path fallback
# includes. Accept an explicit skip only from GitHub Actions in the same suite
# as the latest successful ci-status. Missing or unrelated routing stays closed.
# A reported compile-admission check is still required below, even with a skip.
if [ "$needs_app_compile" -eq 1 ] && ! jq -e '
  (map(.check_runs) | add | sort_by(.name, (.id // 0))
   | group_by(.name) | map(last)) as $latest
  | ($latest[] | select(.name == "ci-status")) as $ci
  | $ci.status == "completed" and $ci.conclusion == "success"
    and $ci.app.slug == "github-actions" and $ci.check_suite.id != null
    and any($latest[];
      .name == "macos" and .status == "completed" and .conclusion == "skipped"
      and .app.slug == "github-actions" and .check_suite.id == $ci.check_suite.id)
' <<<"$check_runs_json" >/dev/null 2>&1; then
  must+=("macos / macOS compile admission")
fi

# God-file, localization and concurrency lint fail only on the change itself, so
# they are never waived: --override is for base-red or flaky lanes. Any such
# check reported on the exact head must complete green, with or without it.
while IFS=$'\t' read -r name st co; do
  [ -n "$name" ] || continue
  lowered=$(tr '[:upper:]' '[:lower:]' <<<"$name")
  [[ "$lowered" =~ god[\ -]?files?|l10n|locali[sz]ation|concurrency ]] || continue
  if [ "$st" != completed ]; then
    refuse \
      "not green: '$name' is $st/$co on $sha; --override cannot bypass god-file, l10n or concurrency lint" \
      "wait for '$name' to complete on the exact head, then retry"
  fi
  case "$co" in
    success|neutral|skipped) ;;
    *)
      refuse \
        "not green: '$name' is $co on $sha; --override cannot bypass god-file, l10n or concurrency lint" \
        "fix what '$name' reports on the exact head, then retry"
      ;;
  esac
done <<<"$runs"

# Pull requests into feat-cmux-next also run the branch's dedicated cmux-next
# workflow. Its Release and native app checks are separate check runs, so the
# legacy ci-status job on that branch cannot stand in for them. Require each
# one that reported on this exact head; a skipped lane remains a passing
# conclusion, while a pending or red lane blocks the merge.
if [ "$base" = feat-cmux-next ]; then
  # A check run that has not been created yet (native jobs still queued behind
  # the Linux lanes) or a rerun after a runner loss leaves the old verdict in
  # place, so also wait for every cmux-next.yml run on this exact head to finish.
  # --override cannot bypass this: a pending run has no verdict to waive.
  if ! next_runs_json=$(gh api "repos/$repo/actions/workflows/cmux-next.yml/runs?head_sha=$sha&per_page=20" 2>/dev/null) \
    || ! next_pending=$(jq -r '
      (if type == "object" then (.workflow_runs // []) else [] end)[]
      | select(.head_sha == null or .head_sha == $sha)
      | select(.status != "completed")
      | "\(.id) attempt \(.run_attempt // 1) is \(.status)"
      ' --arg sha "$sha" <<<"$next_runs_json" 2>/dev/null); then
    refuse \
      "not green: cannot read cmux-next workflow runs for $sha" \
      'restore GitHub API access, then retry the exact-head check'
  fi
  if [ -n "$next_pending" ]; then
    refuse \
      "not green: cmux-next run still pending on $sha ($(tr '\n' ';' <<<"$next_pending" | sed 's/;$//')); --override cannot bypass a run that has not finished" \
      "wait for the cmux-next workflow run to complete on the exact head, then retry"
  fi
  # A cancelled run is completed too: heads merged between cancelling stale runs
  # and rerunning them (#17653, #18079, #18080, #18083) with no native job run.
  # Routing skips happen inside a run that concludes success, so the newest run
  # per event must succeed. A failed run is a red lane --override may waive;
  # one cancelled, skipped or timed out has no verdict to waive.
  if ! next_verdicts=$(jq -r '
      (if type == "object" then (.workflow_runs // []) else [] end)
      | map(select(.head_sha == null or .head_sha == $sha))
      | group_by(.event // "")[]
      | max_by(.id)
      | "\(.id) attempt \(.run_attempt // 1) (\(.event // "unknown")) is \(.conclusion // "unknown")"
      ' --arg sha "$sha" <<<"$next_runs_json" 2>/dev/null); then
    refuse \
      "not green: cannot parse cmux-next workflow runs for $sha" \
      'retry after GitHub returns valid workflow run metadata'
  fi
  # No run is fine only for a pull request outside the workflow's pull_request
  # paths filter; ci-status on the exact head still gates it. A change the filter
  # covers gets a run, so none yet means GitHub has not created it (#18100 merged
  # seconds after a push). Read the filter from this head and fail closed.
  if [ -z "$next_verdicts" ]; then
    if ! next_workflow=$(gh api "repos/$repo/contents/.github/workflows/cmux-next.yml?ref=$sha" \
        -H 'Accept: application/vnd.github.raw' 2>/dev/null) || [ -z "$next_workflow" ] \
      || ! next_files=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].filename' 2>/dev/null); then
      refuse \
        "not green: no cmux-next run on $sha, and its paths filter or changed files cannot be read" \
        'restore GitHub API access, then retry the exact-head check'
    fi
    if ! next_covered=$(python3 -c '
import re, sys
def pull_request_paths(text):
    # The on.pull_request.paths list, by indentation; the workflow is ours.
    lines = text.splitlines()
    for i, line in enumerate(lines):
        if re.match(r"^\s+pull_request:\s*$", line):
            base = len(line) - len(line.lstrip())
            paths, in_paths = [], False
            for line in lines[i + 1:]:
                if not line.strip() or line.lstrip().startswith("#"):
                    continue
                indent = len(line) - len(line.lstrip())
                if indent <= base:
                    break
                if re.match(r"^\s+paths:\s*$", line):
                    in_paths = True
                    continue
                item = re.match(r"^\s+-\s+(.+?)\s*$", line)
                if in_paths and item:
                    paths.append(item.group(1).strip("\"\x27"))
                elif in_paths and not item:
                    in_paths = False
            return paths
    return None
def glob(pattern):
    out, i = "", 0
    while i < len(pattern):
        if pattern.startswith("**", i):
            out, i = out + ".*", i + 2
        elif pattern[i] == "*":
            out, i = out + "[^/]*", i + 1
        elif pattern[i] == "?":
            out, i = out + "[^/]", i + 1
        else:
            out, i = out + re.escape(pattern[i]), i + 1
    return re.compile(out + r"\Z")
paths = pull_request_paths(open(sys.argv[1]).read())
if not paths:
    sys.exit(2)
rules = [(p.startswith("!"), glob(p.lstrip("!"))) for p in paths]
for name in open(sys.argv[2]).read().split():
    included = False
    for negated, rule in rules:
        if rule.match(name):
            included = not negated
    if included:
        print(name)
' <(printf '%s' "$next_workflow") <(printf '%s\n' "$next_files") 2>/dev/null); then
      refuse \
        "not green: no cmux-next run on $sha, and its pull_request paths filter cannot be parsed" \
        'check .github/workflows/cmux-next.yml on the exact head, then retry'
    fi
    if [ -n "$next_covered" ]; then
      refuse \
        "not green: no cmux-next run on $sha yet, but it changes paths the workflow covers ($(head -n 3 <<<"$next_covered" | tr '\n' ' ' | sed 's/ $//')); --override cannot bypass a run that does not exist" \
        "wait for GitHub to create and finish the cmux-next run on the exact head, then retry"
    fi
  fi
  while IFS= read -r verdict; do
    [ -n "$verdict" ] || continue
    case "$verdict" in
      *" is success") ;;
      *" is failure")
        # Judged below: a failure the base branch also has is excused.
        if [ -z "$override_reason" ]; then
          excuse_runs+=(--run "${verdict%% *}")
          red_lanes+=("cmux-next run $verdict")
        fi
        ;;
      *)
        refuse \
          "not green: cmux-next run $verdict on $sha; its native jobs did not all run, and --override cannot bypass that" \
          "rerun the cmux-next workflow on the exact head and wait for it to succeed, then retry"
        ;;
    esac
  done <<<"$next_verdicts"
  for next_check in \
    "cmux-next Release compile (Xcode 26)" \
    "cmux app scheme compile (Debug)" \
    "cmux-next swift test"; do
    if awk -F '\t' -v expected="$next_check" '$1 == expected { found = 1 } END { exit !found }' <<<"$runs"; then
      must+=("$next_check")
    fi
  done
fi

if [ "$base" = main ] && [ "$has_app_compile" -eq 1 ] && grep -q '^macos / macOS compile admission'$'\t' <<<"$runs"; then
  has_admission=0
  for required in "${must[@]}"; do
    [ "$required" = 'macos / macOS compile admission' ] && has_admission=1
  done
  [ "$has_admission" -eq 0 ] && must+=("macos / macOS compile admission")
fi
if [ "$has_ci" -eq 0 ]; then
  # Include legacy commit status contexts (for example review services), not
  # only Actions check runs. Both endpoints are pinned to the evaluated head.
  if ! statuses_json=$(gh api "repos/$repo/commits/$sha/statuses?per_page=100" --paginate --slurp 2>/dev/null); then
    refuse "not green: cannot read status contexts for $sha" 'restore GitHub status API access and retry'
  fi
  if ! statuses=$(jq -r '
    (add // []) | sort_by(.context, .id) | group_by(.context)[] | last
    | [.context, (if .state == "pending" then "in_progress" else "completed" end), .state] | @tsv
  ' <<<"$statuses_json" 2>/dev/null); then
    refuse "not green: cannot parse status contexts for $sha" 'retry after GitHub returns valid status metadata'
  fi
  all_verdicts="$runs"$'\n'"$statuses"
  reported=0
  while IFS=$'\t' read -r name st co; do
    [ -n "$name" ] || continue
    reported=$((reported + 1))
    if [ "$skip_vercel" -eq 1 ] && [ "$name" = Vercel ]; then
      echo "note: --override waived Vercel $st/$co status" >&2
      continue
    fi
    if [ "$st" != completed ]; then
      refuse \
        "not green: '$name' is $st/$co on $sha (no ci-status workflow on base $base)" \
        "wait for or repair '$name'; every reported exact-head check must complete without the aggregate workflow"
    fi
    case "$co" in
      success|neutral|skipped) ;;
      *)
        refuse \
          "not green: '$name' is $st/$co on $sha (no ci-status workflow on base $base)" \
          "wait for or repair '$name'; success, neutral, and skipped pass without the aggregate workflow"
        ;;
    esac
  done <<<"$all_verdicts"
  [ "$reported" -gt 0 ] || refuse \
    "not green: no checks have reported on $sha" \
    'run the repository pull request checks before merging'
fi

bad_required=()
for name in "${must[@]+"${must[@]}"}"; do
  line=$(printf '%s\n' "$runs" | awk -F'\t' -v n="$name" '$1==n')
  if [ -z "$line" ]; then
    fix="run the pull request checks for $sha"
    [ "$name" = ci-status ] && fix='run CI for the pull request head, then retry the exact-head check'
    refuse "not green: '$name' has not run on $sha" "$fix"
  fi
  st=$(cut -f2 <<<"$line"); co=$(cut -f3 <<<"$line")
  if [ "$st" != completed ]; then
    fix="wait for '$name' to complete on the exact head, then retry"
    [ "$name" = ci-status ] && fix='wait for CI to complete on the pull request head, then retry the exact-head check'
    refuse "not green: '$name' is $st/$co on $sha" "$fix"
  fi
  case "$co" in
    success|neutral|skipped) ;;
    failure|timed_out|cancelled|action_required|startup_failure|stale)
      bad_required+=("$name")
      ;;
    *)
      refuse \
        "not green: '$name' has non-verdict conclusion $co on $sha" \
        'rerun CI until the required check reports success, neutral, skipped, or a completed red verdict'
      ;;
  esac
done
# A red lane that fails the same way on the base branch's latest completed run
# (same failed steps, test names and error lines) is excused and named; any
# failure the base does not have, and any runner refusal, still blocks.
if [ -z "$override_reason" ] && { [ "${#bad_required[@]}" -gt 0 ] || [ "${#excuse_runs[@]}" -gt 0 ]; }; then
  excuse_args=("${excuse_runs[@]+"${excuse_runs[@]}"}")
  for name in "${bad_required[@]+"${bad_required[@]}"}"; do
    excuse_args+=(--check "$name")
    red_lanes+=("'$name'")
  done
  excuse_errors=$(mktemp)
  if ! excused=$(python3 "$script_dir/ci/base_red_excuse.py" --repo "$repo" --base "$base" --sha "$sha" \
      "${excuse_args[@]}" 2>"$excuse_errors"); then
    why=$(cat "$excuse_errors")
    rm -f "$excuse_errors"
    refuse \
      "not green: $(printf '%s; ' "${red_lanes[@]}" | sed 's/; $//') on $sha, not excused by $base (${why:-the base comparison failed})" \
      "fix the failure on the exact head; a runner refusal needs a re-run, not a waiver"
  fi
  rm -f "$excuse_errors"
  printf '%s\n' "$excused" | sed 's/^/note: /' >&2
fi
failing=$(printf '%s\n' "$runs" | awk -F'\t' '$3=="failure"||$3=="timed_out"||$3=="cancelled"{print $1}' | sort -u)
if [ -n "$failing" ]; then echo "note: other failing checks on $sha (${must[*]} decides):" >&2; printf '%s\n' "$failing" | sed 's/^/  /' >&2; fi
if ! patches=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].patch // empty' 2>/dev/null); then
  refuse \
    "not green: cannot read the diff for $ref" \
    'restore GitHub API access, then retry the exact-head check'
fi
if grep -qE '^\+(<<<<<<<|>>>>>>>|=======$)' <<<"$patches"; then
  refuse \
    "not green: conflict markers are present in $ref" \
    'resolve the conflict markers, push the repaired head, and rerun the checks'
fi
# A feat-cmux-next merge stops at a frozen path. The coordinator's WINDOW
# (cmux-lawrence:~/agent-mailbox/WINDOW) names each freeze as
# `FREEZE: <path prefix>... token=<token>`; only the token holder
# (--freeze-token) merges into it, and --override never lifts it. A WINDOW
# that cannot be read refuses: the coordinator merges while freezes are unknown.
window=''
if [ "$base" = feat-cmux-next ] || [ -n "$freeze_token" ] || [ "${#window_tokens[@]}" -gt 0 ]; then
  if [ -n "${GH_MERGE_GREEN_WINDOW_FILE:-}" ]; then
    window=$(cat -- "$GH_MERGE_GREEN_WINDOW_FILE" 2>/dev/null) || window=''
  elif [ -n "${GH_MERGE_GREEN_TEST:-}" ]; then
    window=''
  else
    window=$(timeout 30 ssh -o BatchMode=yes -o ConnectTimeout=10 cmux-lawrence 'cat ~/agent-mailbox/WINDOW' 2>/dev/null) || window=''
  fi
fi
if [ "$base" = feat-cmux-next ]; then
  if [ -z "$window" ]; then
    refuse \
      "not merged: cannot read the coordinator's WINDOW, so active freezes on $ref are unknown" \
      'check with the coordinator; they merge this PR while the WINDOW is unreadable'
  fi
  if ! changed=$(gh api --paginate "repos/$repo/pulls/$num/files?per_page=100" -q '.[].filename' 2>/dev/null); then
    refuse \
      "not merged: cannot list changed files for $ref to check freezes" \
      'restore GitHub API access, then retry'
  fi
  if ! frozen=$(WINDOW="$window" FREEZE_TOKEN="$freeze_token" python3 -c '
import os, sys
token = os.environ["FREEZE_TOKEN"]
freezes = []
for line in os.environ["WINDOW"].splitlines():
    if not line.startswith("FREEZE:"):
        continue
    words = line[len("FREEZE:"):].split()
    held = {w.split("=", 1)[1] for w in words if w.startswith("token=")}
    if token and token in held:
        continue
    freezes += [w for w in words if "=" not in w]
for path in sys.stdin.read().split():
    for prefix in freezes:
        if path == prefix or path.startswith(prefix if prefix.endswith("/") else prefix + "/"):
            print(f"{path} (FREEZE: {prefix})")
' <<<"$changed"); then
    refuse \
      "not merged: cannot check $ref against the WINDOW freezes" \
      'check with the coordinator before merging'
  fi
  if [ -n "$frozen" ]; then
    refuse \
      "not merged: $ref touches frozen paths: $(paste -sd ';' <<<"$frozen" | sed 's/;/; /g')" \
      'wait for the freeze to lift, or send the PR to the coordinator; only the token holder merges with --freeze-token'
  fi
fi
# A merge under a WINDOW token releases it: each token must be current (an
# [AREA] section's `token:` or a hold line's `token=`), and right after the
# merge a RELEASE <AREA> mail with the landed SHA goes to
# inbox/lawrence-coordinator (Lawrence's coordinator missed a manual one).
releases=''
# The real mailbox is reachable only from a real run: under test
# (GH_MERGE_GREEN_TEST) or with a local WINDOW file, the RELEASE mail needs
# GH_MERGE_GREEN_MAILBOX_DIR, so a test can never mail a fake RELEASE that
# frees a real token.
mailbox_is_local() {
  [ -n "${GH_MERGE_GREEN_MAILBOX_DIR:-}" ] && return 0
  [ -z "${GH_MERGE_GREEN_TEST:-}" ] && [ -z "${GH_MERGE_GREEN_WINDOW_FILE:-}" ] && return 0
  return 1
}
if [ -n "$freeze_token" ] || [ "${#window_tokens[@]}" -gt 0 ]; then
  if ! mailbox_is_local; then
    refuse \
      "not merged: a test or local-WINDOW run must set GH_MERGE_GREEN_MAILBOX_DIR before a token merge of $ref" \
      'set GH_MERGE_GREEN_MAILBOX_DIR to a temp mailbox; only a real run mails the coordinator'
  fi
  if [ -z "$window" ]; then
    refuse \
      "not merged: cannot read the coordinator's WINDOW to check the tokens for $ref" \
      'check with the coordinator; they merge this PR while the WINDOW is unreadable'
  fi
  if ! releases=$(WINDOW="$window" python3 -c '
import re, os, sys
areas, holds, frozen = {}, {}, set()
area = None
for line in os.environ["WINDOW"].splitlines():
    header = re.match(r"\[([A-Za-z0-9_-]+)\]", line)
    if header:
        area = header.group(1)
        continue
    held = re.match(r"token:\s*(\S+)", line)
    if area and held and held.group(1) != "none":
        areas[held.group(1)] = area
    if not line.strip():
        area = None
    for token in re.findall(r"token=(\S+)", line):
        if line.startswith("FREEZE:"):
            frozen.add(token)
        else:
            holds.setdefault(token, line.split()[0])
missing = []
for token in sys.argv[1:]:
    name = areas.get(token) or holds.get(token) or ("FREEZE" if token in frozen else None)
    if name is None:
        missing.append(token)
    else:
        print(f"{name}\t{token}")
if missing:
    print(" ".join(missing), file=sys.stderr)
    sys.exit(3)
' ${freeze_token:+"$freeze_token"} ${window_tokens[@]+"${window_tokens[@]}"} 2>"${TMPDIR:-/tmp}/gh-merge-green-tokens.$$"); then
    stale=$(cat "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$")
    rm -f "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$"
    refuse \
      "not merged: ${stale:-a token} is not a current WINDOW token" \
      'merge only while you hold the token; ask the coordinator for the current one'
  fi
  rm -f "${TMPDIR:-/tmp}/gh-merge-green-tokens.$$"
fi
# The merged head passes the checks a red lint job can hide (#18326 grew a god
# type while an unrelated lint red masked the god-file step): the Swift god-file
# check when CmuxNext changes, vp check when webviews changes. They run here on
# base + head merged locally, with the base branch's copy of the god-file script
# and baseline, so a pull request cannot loosen its own check.
# The files a vp check report names, sorted (ANSI colors stripped).
vp_flagged_files() {
  sed 's/\x1b\[[0-9;]*m//g' | grep -oE '[A-Za-z0-9_./-]+\.(tsx|ts|jsx|js|mjs|cjs|css|md|json)' | LC_ALL=C sort -u || true
}
merged_head_checks() {
  local repo_dir tmp merged base_sha head_sha status=0 output bun
  local swift_changed=0 web_changed=0
  grep -q '^Packages/macOS/CmuxNext/' <<<"$changed" && swift_changed=1
  grep -q '^webviews/' <<<"$changed" && web_changed=1
  [ "$swift_changed" -eq 1 ] || [ "$web_changed" -eq 1 ] || return 0
  if jq -e '.isCrossRepository == true' <<<"$pr_json" >/dev/null 2>&1; then
    refuse \
      "not merged: $ref is from a fork, and its merged-head checks would run its code here" \
      'send the PR to the coordinator, who merges it after reviewing the god-file and vp check results'
  fi
  repo_dir="${GH_MERGE_GREEN_REPO_DIR:-$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)}"
  if [ -z "$repo_dir" ] || ! git -C "$repo_dir" fetch --quiet origin \
      "+refs/heads/$base:refs/gh-merge-green/base" "+refs/pull/$num/head:refs/gh-merge-green/head" 2>/dev/null; then
    refuse \
      "not merged: cannot fetch $base and $ref to check the merged head" \
      'run gh-merge-green from its cmux checkout with network access, or set GH_MERGE_GREEN_REPO_DIR to a cmux clone'
  fi
  base_sha=$(git -C "$repo_dir" rev-parse refs/gh-merge-green/base)
  head_sha=$(git -C "$repo_dir" rev-parse refs/gh-merge-green/head)
  if [ "$head_sha" != "$sha" ]; then
    refuse \
      "not merged: $ref moved to $head_sha while checking $sha" \
      'retry on the current head'
  fi
  tmp=$(mktemp -d)
  merged="$tmp/merged"
  git -C "$repo_dir" worktree add --quiet --detach "$merged" "$base_sha" >/dev/null 2>&1
  if ! git -C "$merged" -c user.name=gh-merge-green -c user.email=gh-merge-green@invalid \
      merge --quiet --no-ff --no-edit "$head_sha" >/dev/null 2>&1; then
    git -C "$repo_dir" worktree remove --force "$merged" >/dev/null 2>&1 || true
    rm -rf "$tmp"
    refuse \
      "not merged: $ref does not merge cleanly into $base ($base_sha)" \
      'merge the base into the PR (scripts/merge-main.sh), push, and retry'
  fi
  if [ "$swift_changed" -eq 1 ]; then
    git -C "$repo_dir" show "$base_sha:scripts/cmux-next/check-no-godfiles.sh" > "$merged/scripts/cmux-next/check-no-godfiles.sh"
    git -C "$repo_dir" show "$base_sha:scripts/cmux-next/godfile-baseline.tsv" > "$merged/scripts/cmux-next/godfile-baseline.tsv"
    if ! output=$(bash "$merged/scripts/cmux-next/check-no-godfiles.sh" --only swift --base "$base_sha" \
        "$merged/Packages/macOS/CmuxNext" 2>&1); then
      status=1
      printf '%s\n' "$output" | grep -E 'FAIL|god|split' | sed 's/^/  /' >&2 || true
      printf 'not merged: the merged head of %s fails the Swift god-file check\n' "$ref" >&2
    fi
  fi
  if [ "$web_changed" -eq 1 ] && [ "$status" -eq 0 ]; then
    if ! bun=$(command -v bun); then
      status=2
      printf 'not merged: bun is not on PATH, so vp check cannot run on the merged head of %s\n' "$ref" >&2
    elif ! output=$(cd "$merged/webviews" && "$bun" install --frozen-lockfile --ignore-scripts 2>&1 && "$bun" x vp check 2>&1); then
      # A red the base already has is not this PR's: check the base tree with
      # the same install, and refuse only for files the base does not flag.
      local base_output new_files
      git -C "$merged" checkout --quiet --detach "$base_sha" 2>/dev/null
      if base_output=$(cd "$merged/webviews" && "$bun" x vp check 2>&1); then
        new_files=$(vp_flagged_files <<<"$output")
      else
        new_files=$(LC_ALL=C comm -23 <(vp_flagged_files <<<"$output") <(vp_flagged_files <<<"$base_output"))
      fi
      if [ -z "$base_output" ] || [ -n "$new_files" ] || [ -z "$(vp_flagged_files <<<"$output")" ]; then
        status=1
        printf '%s\n' "$output" | grep -vE '^\s*$' | tail -n 15 | sed 's/^/  /' >&2
        printf 'not merged: the merged head of %s fails vp check in webviews/%s\n' "$ref" \
          "${new_files:+ (not on the base: $(paste -sd ' ' <<<"$new_files"))}" >&2
      else
        printf 'note: vp check is red on the base (%s) too (%s), not by %s\n' "$base" \
          "$(vp_flagged_files <<<"$base_output" | paste -sd ' ')" "$ref" >&2
      fi
    fi
  fi
  git -C "$repo_dir" worktree remove --force "$merged" >/dev/null 2>&1 || true
  rm -rf "$tmp"
  if [ "$status" -ne 0 ]; then
    refusal_reported=1
    print_repair 'fix the merged head (split the god type, or run vp check --fix in webviews/), push, and retry'
    exit 1
  fi
}
if [ "$base" = feat-cmux-next ]; then
  merged_head_checks
fi
if [ -n "$override_reason" ]; then
  if ! gh pr comment "$num" --repo "$repo" --body "merge-override: $override_reason" >/dev/null; then
    refuse \
      "not green: could not post the --override reason on $ref" \
      'restore GitHub comment permission or network access, then retry without changing the head'
  fi
  if ! posted=$(gh pr view "$num" --repo "$repo" --json headRefOid,state 2>/dev/null); then
    refuse \
      "not green: could not verify $ref after posting --override" \
      'check the pull request is still open at the same head, then retry'
  fi
  posted_sha=$(jq -r '.headRefOid // ""' <<<"$posted")
  posted_state=$(jq -r '.state // ""' <<<"$posted" | tr '[:upper:]' '[:lower:]')
  if [ "$posted_sha" != "$sha" ] || [ "$posted_state" != open ]; then
    refuse \
      "not green: pull request head moved while posting --override on $ref" \
      'retry the exact current head with a fresh reason'
  fi
fi
if ! gh pr merge "$num" --repo "$repo" --match-head-commit "$sha" "$@"; then
  refuse \
    "not green: GitHub refused to merge $ref" \
    'read the GitHub refusal, repair the named issue, and retry the exact head'
fi
[ -n "$releases" ] || exit 0
landed=''
for _ in 1 2 3 4 5 6 7 8 9 10; do
  landed=$(gh pr view "$num" --repo "$repo" --json state,mergeCommit 2>/dev/null \
    | jq -r 'select(.state == "MERGED") | .mergeCommit.oid // empty' 2>/dev/null || true)
  [ -n "$landed" ] && break
  sleep 3
done
sender="${GH_MERGE_GREEN_SENDER:-}"
[ -n "$sender" ] || { [ -n "${TMUX_PANE:-}" ] && sender=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null); }
sender="${sender:-$(hostname -s)}"
mail_failed=0
while IFS=$'\t' read -r area token; do
  subject="RELEASE $area: $ref landed ${landed:-<merge commit not reported yet>}"
  subject="${subject/$repo#/#}"
  body=$(printf 'from: %s/gh-merge-green\nto: lawrence-coordinator\nre: none\npriority: normal\nsubject: %s\n\nRELEASE %s. #%s landed as %s under token %s (sent by gh-merge-green right after the merge).\n' \
    "$sender" "$subject" "$area" "$num" "${landed:-an unreported merge commit}" "$token")
  name="$(date -u +%Y%m%dT%H%M%SZ)-$sender-release-$(tr '[:upper:]' '[:lower:]' <<<"$area")-$num.md"
  if [ -n "${GH_MERGE_GREEN_MAILBOX_DIR:-}" ]; then
    dir="$GH_MERGE_GREEN_MAILBOX_DIR/inbox/lawrence-coordinator"
    printf '%s\n' "$body" > "$dir/$name.tmp" && mv "$dir/$name.tmp" "$dir/$name" || mail_failed=1
  elif ! mailbox_is_local; then
    mail_failed=1
  else
    printf '%s\n' "$body" | timeout 30 ssh -o BatchMode=yes -o ConnectTimeout=10 cmux-lawrence \
      "f=~/agent-mailbox/inbox/lawrence-coordinator/$name; cat > \"\$f.tmp\" && mv \"\$f.tmp\" \"\$f\"" || mail_failed=1
  fi
  if [ "$mail_failed" -eq 1 ]; then
    refusal_reported=1
    printf 'merged %s, but the RELEASE %s mail failed. Send it by hand to inbox/lawrence-coordinator:\n%s\n' "$ref" "$area" "$body" >&2
    exit 1
  fi
  printf 'mailed RELEASE %s for %s (%s) to inbox/lawrence-coordinator\n' "$area" "$ref" "${landed:-merge commit pending}" >&2
done <<<"$releases"
