CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/coding-policy

General-purpose coding policy for Baruch's AI agents

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

test_poll_pr_reviews.shskills/release/tests/

#!/usr/bin/env bash
# Outcome-based tests for poll-pr-reviews.sh, focused on the new
# `merge_state` output path: `fetch_merge_state` must return the
# `{status, mergeable}` envelope keyed off GitHub's `mergeStateStatus`
# and `mergeable` fields, and `main` must surface that envelope as a
# top-level field in the snapshot JSON.
#
# Approach: source the script (its main() guard prevents auto-run when
# sourced) and override `gh` with a shell function that returns
# fixture JSON for the two surfaces poll-pr-reviews.sh calls —
# `gh pr checks` and `gh pr view --json mergeStateStatus,mergeable` —
# plus the two `gh api` surfaces for reviews/comments. jq runs locally
# so the script's filter logic is exercised, not duplicated in the test.
#
# Run: bash skills/release/tests/test_poll_pr_reviews.sh
# Exit 0 on all-pass; non-zero with a per-test diagnostic on failure.

set -uo pipefail

SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/poll-pr-reviews.sh"
[[ -x "$SCRIPT" ]] || { echo "fatal: poll-pr-reviews.sh not executable at $SCRIPT" >&2; exit 2; }

# shellcheck disable=SC1090
source "$SCRIPT" || true
set +e

FAIL_COUNT=0
PASS_COUNT=0

# The head SHA the mock's `gh pr view` returns. A review fixture whose
# commit_id equals this is "fresh" (bound to head); anything else is "stale"
# and its verdict resolves to state "none" (#186).
HEAD_SHA="head000000000000000000000000000000000000"
OLD_SHA="0ld00000000000000000000000000000000000000"

assert_eq() {
  local label="$1" expected="$2" actual="$3"
  if [[ "$expected" == "$actual" ]]; then
    return 0
  fi
  echo "    FAIL: ${label}: expected '${expected}', got '${actual}'" >&2
  return 1
}

run() {
  local name="$1"; shift
  if "$@"; then
    PASS_COUNT=$((PASS_COUNT + 1))
    echo "  pass: $name"
  else
    FAIL_COUNT=$((FAIL_COUNT + 1))
    echo "  FAIL: $name" >&2
  fi
}

# Mock `gh` — supports the four invocations poll-pr-reviews.sh issues.
# MOCK_MERGE_STATE selects the fixture for `gh pr view`; other surfaces
# return minimal fixtures so main() can complete.
gh() {
  case "$1" in
    pr)
      local subcmd="$2"
      shift 2
      case "$subcmd" in
        view)
          # Contract: `gh pr view <N> --repo <o/r> --json mergeStateStatus,mergeable`.
          # Validate the --json args explicitly so a regression that drops --json
          # or asks for the wrong fields surfaces as a loud mock failure rather
          # than passing silently against a permissive stub.
          local saw_json=0 json_args=""
          while [[ $# -gt 0 ]]; do
            case "$1" in
              --json) saw_json=1; json_args="${2:-}"; shift 2 ;;
              *)      shift ;;
            esac
          done
          [[ $saw_json -eq 1 ]] || { echo "mock gh pr view: missing --json flag (contract: --json mergeStateStatus,mergeable,headRefOid)" >&2; return 99; }
          [[ "$json_args" == "mergeStateStatus,mergeable,headRefOid" ]] || { echo "mock gh pr view: wrong --json args: '${json_args}' (expected 'mergeStateStatus,mergeable,headRefOid')" >&2; return 99; }
          # headRefOid is the head SHA review verdicts are bound to. Tests that
          # exercise verdict resolution set review fixtures' commit_id to
          # HEAD_SHA (fresh) or something else (stale) — see HEAD_SHA below.
          case "${MOCK_MERGE_STATE:-}" in
            clean)        echo '{"mergeStateStatus":"CLEAN","mergeable":"MERGEABLE","headRefOid":"'"$HEAD_SHA"'"}' ;;
            dirty)        echo '{"mergeStateStatus":"DIRTY","mergeable":"CONFLICTING","headRefOid":"'"$HEAD_SHA"'"}' ;;
            unknown)      echo '{"mergeStateStatus":"UNKNOWN","mergeable":"UNKNOWN","headRefOid":"'"$HEAD_SHA"'"}' ;;
            no-head)      echo '{"mergeStateStatus":"CLEAN","mergeable":"MERGEABLE","headRefOid":""}' ;;
            *) echo "mock gh: unknown MOCK_MERGE_STATE='${MOCK_MERGE_STATE:-}'" >&2; return 2 ;;
          esac
          ;;
        checks)
          # gh pr checks <N> --repo <o/r> --json name,bucket
          echo "${MOCK_CHECKS_BODY:-[]}"
          ;;
        *) echo "mock gh pr: unsupported subcommand: $subcmd" >&2; return 2 ;;
      esac
      ;;
    api)
      # gh api --paginate repos/<o>/<r>/pulls/<N>/reviews?per_page=100
      # gh api --paginate repos/<o>/<r>/pulls/<N>/comments?per_page=100
      # The script pipes the raw paginated output through `jq -s` itself,
      # so this mock no longer forwards `--jq`. It echoes a fixture body
      # keyed off the path; tests can simulate multiple pages by setting
      # MOCK_REVIEWS_BODY / MOCK_COMMENTS_BODY to several concatenated
      # JSON arrays (what `gh api --paginate` actually emits across pages).
      shift  # consume "api"
      local path="" saw_paginate=0
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --paginate) saw_paginate=1; shift ;;
          --jq)       echo "mock gh api: --jq is incompatible with --paginate here; the script should jq -s externally" >&2; return 99 ;;
          *)          [[ -z "$path" ]] && path="$1"; shift ;;
        esac
      done
      [[ $saw_paginate -eq 1 ]] || { echo "mock gh api: missing --paginate (required so the script never silently misses page 2+)" >&2; return 99; }
      case "$path" in
        *reviews*)  echo "${MOCK_REVIEWS_BODY:-[]}" ;;
        *comments*) echo "${MOCK_COMMENTS_BODY:-[]}" ;;
        *) echo "mock gh api: unsupported path: $path" >&2; return 2 ;;
      esac
      ;;
    *) echo "mock gh: unsupported invocation: $*" >&2; return 2 ;;
  esac
}

# --- test bodies ---

t_fetch_merge_state_clean_returns_mergeable_envelope() {
  MOCK_MERGE_STATE=clean
  local out status mergeable
  out=$(fetch_merge_state "owner" "repo" "1")
  status=$(echo "$out" | jq -r '.status')
  mergeable=$(echo "$out" | jq -r '.mergeable')
  assert_eq "status"    "CLEAN"     "$status"    || return 1
  assert_eq "mergeable" "MERGEABLE" "$mergeable"
}

t_fetch_merge_state_dirty_returns_conflicting_envelope() {
  MOCK_MERGE_STATE=dirty
  local out status mergeable
  out=$(fetch_merge_state "owner" "repo" "1")
  status=$(echo "$out" | jq -r '.status')
  mergeable=$(echo "$out" | jq -r '.mergeable')
  assert_eq "status"    "DIRTY"       "$status"    || return 1
  assert_eq "mergeable" "CONFLICTING" "$mergeable"
}

t_fetch_merge_state_unknown_returns_unknown_envelope() {
  MOCK_MERGE_STATE=unknown
  local out status mergeable
  out=$(fetch_merge_state "owner" "repo" "1")
  status=$(echo "$out" | jq -r '.status')
  mergeable=$(echo "$out" | jq -r '.mergeable')
  assert_eq "status"    "UNKNOWN" "$status"    || return 1
  assert_eq "mergeable" "UNKNOWN" "$mergeable"
}

t_main_surfaces_merge_state_as_top_level_field() {
  MOCK_MERGE_STATE=clean
  local out keys
  out=$(main "owner" "repo" "1")
  keys=$(echo "$out" | jq -r '.merge_state | "\(.status)|\(.mergeable)"')
  assert_eq "merge_state in main output" "CLEAN|MERGEABLE" "$keys"
}

t_main_propagates_dirty_state() {
  MOCK_MERGE_STATE=dirty
  local out keys
  out=$(main "owner" "repo" "1")
  keys=$(echo "$out" | jq -r '.merge_state | "\(.status)|\(.mergeable)"')
  assert_eq "merge_state in main output" "DIRTY|CONFLICTING" "$keys"
}

# Issue #83: on PRs with > 1 page of reviews, gh api without --paginate
# returns only page 1. The pre-fix `| last` filter then picked the last
# entry on page 1 — not the actual newest review on the last page — and
# the gate could approve a merge against stale data.
#
# Build a fixture that mimics what `gh api --paginate` actually emits:
# two concatenated JSON arrays. Page 1's last entry is a COMMENTED review
# at 17:00; page 2's last entry is a CHANGES_REQUESTED review at 18:04.
# A correct implementation must report CHANGES_REQUESTED@18:04.
t_latest_review_by_picks_from_last_page() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"APPROVED","submitted_at":"2026-05-18T16:00:00Z"},{"user":{"login":"github-actions[bot]"},"state":"COMMENTED","submitted_at":"2026-05-18T17:00:00Z"}][{"user":{"login":"github-actions[bot]"},"state":"CHANGES_REQUESTED","submitted_at":"2026-05-18T18:04:00Z"}]'
  local out state submitted_at
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  submitted_at=$(echo "$out" | jq -r '.submitted_at')
  assert_eq "state from last page"        "CHANGES_REQUESTED"     "$state"        || return 1
  assert_eq "submitted_at from last page" "2026-05-18T18:04:00Z"  "$submitted_at"
}

t_latest_review_by_returns_none_when_no_reviews() {
  MOCK_REVIEWS_BODY='[]'
  local out state submitted_at body
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  submitted_at=$(echo "$out" | jq -r '.submitted_at')
  body=$(echo "$out" | jq -r '.body')
  assert_eq "state for empty"        "none" "$state"        || return 1
  assert_eq "submitted_at for empty" "null" "$submitted_at" || return 1
  assert_eq "body for empty"         "null" "$body"
}

# A review's state classifies merge-gating, not whether its body must be read.
# A COMMENTED review with zero inline comments still carries a body the gate
# must surface so the agent reads it (rules/reviewer-feedback-reading.md).
t_latest_review_by_surfaces_body_text() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"COMMENTED","submitted_at":"2026-05-18T16:00:00Z","body":"Non-blocking, but the rename in foo.py:42 drops the retry guard."}]'
  local out state body
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  body=$(echo "$out" | jq -r '.body')
  assert_eq "state surfaced"  "COMMENTED" "$state" || return 1
  assert_eq "body surfaced verbatim" "Non-blocking, but the rename in foo.py:42 drops the retry guard." "$body"
}

t_latest_review_by_filters_other_logins_across_pages() {
  # Page 1: two human reviews + one bot review. Page 2: one human review
  # that's newer than the bot review. The bot's latest is still the page-1
  # bot review, even though the page-2 human is newer.
  MOCK_REVIEWS_BODY='[{"user":{"login":"alice"},"state":"COMMENTED","submitted_at":"2026-05-18T15:00:00Z"},{"user":{"login":"github-actions[bot]"},"state":"APPROVED","submitted_at":"2026-05-18T16:00:00Z"},{"user":{"login":"bob"},"state":"COMMENTED","submitted_at":"2026-05-18T16:30:00Z"}][{"user":{"login":"alice"},"state":"COMMENTED","submitted_at":"2026-05-18T17:00:00Z"}]'
  local out state submitted_at
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  submitted_at=$(echo "$out" | jq -r '.submitted_at')
  assert_eq "bot state"        "APPROVED"             "$state"        || return 1
  assert_eq "bot submitted_at" "2026-05-18T16:00:00Z" "$submitted_at"
}

# Same shape for comments: counts must sum across pages, not pick page 1
# alone. Mix in a non-target login and an in_reply_to_id to confirm the
# filter still discards both.
t_toplevel_comments_by_sums_across_pages() {
  MOCK_COMMENTS_BODY='[{"user":{"login":"github-actions[bot]"},"in_reply_to_id":null},{"user":{"login":"github-actions[bot]"},"in_reply_to_id":null},{"user":{"login":"alice"},"in_reply_to_id":null}][{"user":{"login":"github-actions[bot]"},"in_reply_to_id":null},{"user":{"login":"github-actions[bot]"},"in_reply_to_id":12345}]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "github-actions[bot]")
  assert_eq "top-level bot comments across both pages" "3" "$count"
}

t_toplevel_comments_by_returns_zero_for_no_comments() {
  MOCK_COMMENTS_BODY='[]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "github-actions[bot]")
  assert_eq "comments count for empty" "0" "$count"
}

# Copilot authors its REVIEW as `copilot-pull-request-reviewer[bot]` but its
# INLINE COMMENTS as `Copilot`. Counting comments against the review login
# matched nothing, so `inline_comments.copilot` read 0 on every PR — vacuously
# satisfying the release skill's Step 7 "every inline comment has a reply" gate
# and letting a real Copilot finding merge unanswered.
t_toplevel_comments_by_counts_copilot_login() {
  MOCK_COMMENTS_BODY='[{"user":{"login":"Copilot"},"in_reply_to_id":null,"path":"a/b.md","body":"Real finding."}]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "${COPILOT_COMMENT_LOGINS[@]}")
  assert_eq "Copilot-authored comment is counted" "1" "$count"
}

# The review login must stay in the comment set: a regression that swapped one
# login for the other instead of matching both would pass the test above and
# still lose comments on any PR where Copilot posts under the review login.
t_toplevel_comments_by_matches_either_copilot_login() {
  MOCK_COMMENTS_BODY='[{"user":{"login":"Copilot"},"in_reply_to_id":null},{"user":{"login":"copilot-pull-request-reviewer[bot]"},"in_reply_to_id":null}]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "${COPILOT_COMMENT_LOGINS[@]}")
  assert_eq "both Copilot logins counted" "2" "$count"
}

t_toplevel_comments_by_excludes_replies_and_other_logins() {
  MOCK_COMMENTS_BODY='[{"user":{"login":"Copilot"},"in_reply_to_id":null},{"user":{"login":"Copilot"},"in_reply_to_id":991},{"user":{"login":"some-human"},"in_reply_to_id":null}]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "${COPILOT_COMMENT_LOGINS[@]}")
  assert_eq "replies and foreign logins excluded" "1" "$count"
}

# End-to-end through main() — the path the Step 7 merge gate actually reads.
# Codex and Copilot counts must not bleed into each other.
t_main_counts_copilot_comments_in_snapshot() {
  MOCK_MERGE_STATE=clean
  MOCK_COMMENTS_BODY='[{"user":{"login":"Copilot"},"in_reply_to_id":null},{"user":{"login":"github-actions[bot]"},"in_reply_to_id":null},{"user":{"login":"github-actions[bot]"},"in_reply_to_id":null}]'
  local out counts
  out=$(main "owner" "repo" "1")
  counts=$(echo "$out" | jq -r '.inline_comments | "\(.codex)|\(.copilot)"')
  assert_eq "inline_comments {codex|copilot}" "2|1" "$counts"
}

# coding-policy#202: consumer PRs are reviewed by the central fleet App
# `coding-policy-fleet-reviewer[bot]`, not `github-actions[bot]`. A watcher that
# only knew the latter sat blind at `none` on every consumer PR. The policy
# reviewer must resolve across BOTH logins.
t_latest_review_by_resolves_fleet_app_login() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"coding-policy-fleet-reviewer[bot]"},"state":"APPROVED","submitted_at":"2026-07-21T05:15:00Z"}]'
  local out state
  out=$(latest_review_by "owner" "repo" "1" "${CODEX_REVIEW_LOGINS[@]}")
  state=$(echo "$out" | jq -r '.state')
  assert_eq "fleet-App review resolves to policy state" "APPROVED" "$state"
}

# Only one policy reviewer posts per PR, but resolving across both logins must
# still return the newest verdict when (hypothetically) both appear.
t_latest_review_by_policy_reviewer_picks_newest_across_logins() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"COMMENTED","submitted_at":"2026-07-21T05:00:00Z"},{"user":{"login":"coding-policy-fleet-reviewer[bot]"},"state":"CHANGES_REQUESTED","submitted_at":"2026-07-21T05:10:00Z"}]'
  local out state
  out=$(latest_review_by "owner" "repo" "1" "${CODEX_REVIEW_LOGINS[@]}")
  state=$(echo "$out" | jq -r '.state')
  assert_eq "newest policy verdict across both logins" "CHANGES_REQUESTED" "$state"
}

# Fail-safe: an active CHANGES_REQUESTED from one policy-reviewer identity must
# NOT be masked by a LATER clean review from the other identity — a gating
# watcher that surfaced the newest-overall would think the reviewer was clean
# while GitHub still blocked the merge.
t_latest_review_by_changes_requested_not_masked_by_later_other_login() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"CHANGES_REQUESTED","submitted_at":"2026-07-21T05:00:00Z"},{"user":{"login":"coding-policy-fleet-reviewer[bot]"},"state":"COMMENTED","submitted_at":"2026-07-21T05:10:00Z"}]'
  local out state
  out=$(latest_review_by "owner" "repo" "1" "${CODEX_REVIEW_LOGINS[@]}")
  state=$(echo "$out" | jq -r '.state')
  assert_eq "active block not masked by later clean review" "CHANGES_REQUESTED" "$state"
}

t_main_surfaces_fleet_app_review_as_codex() {
  MOCK_MERGE_STATE=clean
  MOCK_REVIEWS_BODY='[{"user":{"login":"coding-policy-fleet-reviewer[bot]"},"state":"APPROVED","submitted_at":"2026-07-21T05:15:00Z","commit_id":"'"$HEAD_SHA"'"}]'
  local out state
  out=$(main "owner" "repo" "1")
  state=$(echo "$out" | jq -r '.reviews.codex.state')
  assert_eq "fleet-App review surfaced as .reviews.codex.state" "APPROVED" "$state"
}

t_toplevel_comments_by_counts_fleet_app_login() {
  MOCK_COMMENTS_BODY='[{"user":{"login":"coding-policy-fleet-reviewer[bot]"},"in_reply_to_id":null}]'
  local count
  count=$(toplevel_comments_by "owner" "repo" "1" "${CODEX_COMMENT_LOGINS[@]}")
  assert_eq "fleet-App inline comment counted for policy reviewer" "1" "$count"
}

# A login's own latest review must be selected by submitted_at, NOT by the
# API's array position: jq group_by preserves input order within a group, so
# if the reviews API returns a login's reviews out of chronological order, a
# `last`-based pick would gate on a superseded verdict. Here the newest review
# (CHANGES_REQUESTED @18:00) is NOT last in the input — a correct max_by pick
# surfaces it; a `last` pick would wrongly return the APPROVED @16:00.
t_latest_review_by_picks_max_by_time_not_array_position() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"CHANGES_REQUESTED","submitted_at":"2026-07-25T18:00:00Z"},{"user":{"login":"github-actions[bot]"},"state":"COMMENTED","submitted_at":"2026-07-25T17:00:00Z"},{"user":{"login":"github-actions[bot]"},"state":"APPROVED","submitted_at":"2026-07-25T16:00:00Z"}]'
  local out state submitted_at
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  submitted_at=$(echo "$out" | jq -r '.submitted_at')
  assert_eq "latest by time, not array position" "CHANGES_REQUESTED"     "$state"        || return 1
  assert_eq "latest submitted_at"                "2026-07-25T18:00:00Z"  "$submitted_at"
}

# GitHub review states outside the snapshot schema (DISMISSED, PENDING) are
# not live verdicts, but watch-pr-reviews.sh treats any non-"none" state as
# "a bot posted" and would let one satisfy the ready gate. They must normalize
# to "none".
t_latest_review_by_normalizes_dismissed_to_none() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"DISMISSED","submitted_at":"2026-07-25T16:00:00Z","body":"was dismissed"}]'
  local out state
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  assert_eq "DISMISSED normalizes to none" "none" "$state"
}

t_latest_review_by_normalizes_pending_to_none() {
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"PENDING","submitted_at":"2026-07-25T16:00:00Z"}]'
  local out state
  out=$(latest_review_by "owner" "repo" "1" "github-actions[bot]")
  state=$(echo "$out" | jq -r '.state')
  assert_eq "PENDING normalizes to none" "none" "$state"
}

# --- #186: review verdicts bound to the PR head SHA ---

# A verdict on the current head passes through unchanged, flagged not-stale.
t_resolve_against_head_fresh_passes_through() {
  local review out state stale
  review='{"state":"APPROVED","submitted_at":"2026-07-25T10:00:00Z","body":"ok","commit_id":"'"$HEAD_SHA"'"}'
  out=$(resolve_review_against_head "$review" "$HEAD_SHA")
  state=$(echo "$out" | jq -r '.state')
  stale=$(echo "$out" | jq -r '.stale')
  assert_eq "fresh review keeps its state" "APPROVED" "$state" || return 1
  assert_eq "fresh review not stale"       "false"    "$stale"
}

# A verdict on a superseded SHA collapses to "none" (absent, not clean) with
# stale=true, but keeps its body/commit_id visible for diagnosis. This is the
# #186 fix: a stale APPROVED/COMMENTED must not read as a live clean verdict.
t_resolve_against_head_stale_collapses_to_none() {
  local review out state stale body commit
  review='{"state":"APPROVED","submitted_at":"2026-07-25T09:00:00Z","body":"reviewed old code","commit_id":"'"$OLD_SHA"'"}'
  out=$(resolve_review_against_head "$review" "$HEAD_SHA")
  state=$(echo "$out" | jq -r '.state')
  stale=$(echo "$out" | jq -r '.stale')
  body=$(echo "$out" | jq -r '.body')
  commit=$(echo "$out" | jq -r '.commit_id')
  assert_eq "stale review resolves to none" "none"                "$state"  || return 1
  assert_eq "stale review flagged stale"    "true"                "$stale"  || return 1
  assert_eq "stale verdict body kept"       "reviewed old code"   "$body"   || return 1
  assert_eq "stale verdict commit kept"     "$OLD_SHA"            "$commit"
}

# A genuinely-absent review (never posted) stays none, stale=false — an agent
# can tell "never reviewed" (stale false) from "reviewed older commit" (true).
t_resolve_against_head_none_stays_none_not_stale() {
  local review out state stale
  review='{"state":"none","submitted_at":null,"body":null,"commit_id":null}'
  out=$(resolve_review_against_head "$review" "$HEAD_SHA")
  state=$(echo "$out" | jq -r '.state')
  stale=$(echo "$out" | jq -r '.stale')
  assert_eq "absent review stays none"        "none"  "$state" || return 1
  assert_eq "absent review is not stale" "false" "$stale"
}

# End-to-end #186 symptom 2 (false ready): a stale COMMENTED from an earlier
# SHA must NOT surface as a live verdict — it collapses to none so the watcher
# keeps waiting instead of merging unreviewed code.
t_main_stale_review_collapses_to_none() {
  MOCK_MERGE_STATE=clean
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"COMMENTED","submitted_at":"2026-07-25T09:00:00Z","body":"looks fine","commit_id":"'"$OLD_SHA"'"}]'
  local out state stale
  out=$(main "owner" "repo" "1")
  state=$(echo "$out" | jq -r '.reviews.codex.state')
  stale=$(echo "$out" | jq -r '.reviews.codex.stale')
  assert_eq "stale codex verdict reads none in snapshot" "none" "$state" || return 1
  assert_eq "stale codex verdict flagged stale"          "true" "$stale"
}

# A fresh verdict (commit_id == head) surfaces normally.
t_main_fresh_review_surfaces_state() {
  MOCK_MERGE_STATE=clean
  MOCK_REVIEWS_BODY='[{"user":{"login":"github-actions[bot]"},"state":"APPROVED","submitted_at":"2026-07-25T10:00:00Z","body":"ok","commit_id":"'"$HEAD_SHA"'"}]'
  local out state stale
  out=$(main "owner" "repo" "1")
  state=$(echo "$out" | jq -r '.reviews.codex.state')
  stale=$(echo "$out" | jq -r '.reviews.codex.stale')
  assert_eq "fresh codex verdict surfaces" "APPROVED" "$state" || return 1
  assert_eq "fresh codex verdict not stale" "false"   "$stale"
}

t_main_surfaces_head_sha_top_level() {
  MOCK_MERGE_STATE=clean
  MOCK_REVIEWS_BODY='[]'
  local out head
  out=$(main "owner" "repo" "1")
  head=$(echo "$out" | jq -r '.head_sha')
  assert_eq "head_sha surfaced top-level" "$HEAD_SHA" "$head"
}

# Guard: an empty headRefOid must fail loudly, never silently void the review
# gate by marking every verdict stale.
t_main_no_head_sha_fails() {
  MOCK_MERGE_STATE=no-head
  MOCK_REVIEWS_BODY='[]'
  # Subshell: main() calls `exit` on the guard, which would otherwise
  # terminate this sourced test script rather than just the call.
  local rc=0
  ( main "owner" "repo" "1" ) >/dev/null 2>&1 || rc=$?
  [[ "$rc" -ne 0 ]] || { echo "    FAIL: main should exit non-zero on empty headRefOid, got rc=0" >&2; return 1; }
}

# --- #182: cancelled superseded runs are no-signal, not failure ---

# A cancelled bucket alongside a success is still success (the cancelled twin
# was superseded; its replacement concluded). Folding cancel into pending would
# have wedged this.
t_ci_status_cancel_with_success_is_success() {
  MOCK_MERGE_STATE=clean
  MOCK_CHECKS_BODY='[{"name":"CI","bucket":"success"},{"name":"CI","bucket":"cancel"}]'
  local out ci
  out=$(main "owner" "repo" "1")
  ci=$(echo "$out" | jq -r '.ci.status')
  assert_eq "success alongside a cancelled twin is success" "success" "$ci"
}

# Only cancels present (replacement not yet registered) → pending, so the
# watcher waits rather than concluding.
t_ci_status_only_cancels_is_pending() {
  MOCK_MERGE_STATE=clean
  MOCK_CHECKS_BODY='[{"name":"CI","bucket":"cancel"},{"name":"review","bucket":"cancel"}]'
  local out ci
  out=$(main "owner" "repo" "1")
  ci=$(echo "$out" | jq -r '.ci.status')
  assert_eq "all-cancelled reads pending" "pending" "$ci"
}

# A real failure on head still fails, even next to a cancelled run.
t_ci_status_fail_with_cancel_is_failure() {
  MOCK_MERGE_STATE=clean
  MOCK_CHECKS_BODY='[{"name":"CI","bucket":"fail"},{"name":"old","bucket":"cancel"}]'
  local out ci
  out=$(main "owner" "repo" "1")
  ci=$(echo "$out" | jq -r '.ci.status')
  assert_eq "a real fail still fails next to a cancel" "failure" "$ci"
}

# --- driver ---

echo "== poll-pr-reviews.sh tests =="
run "fetch_merge_state returns {CLEAN, MERGEABLE} for a clean PR"     t_fetch_merge_state_clean_returns_mergeable_envelope
run "fetch_merge_state returns {DIRTY, CONFLICTING} on conflict"      t_fetch_merge_state_dirty_returns_conflicting_envelope
run "fetch_merge_state propagates UNKNOWN/UNKNOWN while computing"    t_fetch_merge_state_unknown_returns_unknown_envelope
run "main surfaces merge_state as a top-level field"                  t_main_surfaces_merge_state_as_top_level_field
run "main propagates DIRTY merge_state end-to-end"                    t_main_propagates_dirty_state
run "latest_review_by picks newest review on page 2 (issue #83)"      t_latest_review_by_picks_from_last_page
run "latest_review_by returns 'none' for empty reviews"               t_latest_review_by_returns_none_when_no_reviews
run "latest_review_by surfaces the review body text"                  t_latest_review_by_surfaces_body_text
run "latest_review_by ignores other logins across pages"              t_latest_review_by_filters_other_logins_across_pages
run "toplevel_comments_by sums counts across pages (issue #83)"       t_toplevel_comments_by_sums_across_pages
run "toplevel_comments_by returns 0 for empty comments"               t_toplevel_comments_by_returns_zero_for_no_comments
run "toplevel_comments_by counts the 'Copilot' comment login"         t_toplevel_comments_by_counts_copilot_login
run "toplevel_comments_by matches either Copilot login"               t_toplevel_comments_by_matches_either_copilot_login
run "toplevel_comments_by excludes replies and foreign logins"        t_toplevel_comments_by_excludes_replies_and_other_logins
run "main counts Copilot comments in the snapshot"                    t_main_counts_copilot_comments_in_snapshot
run "latest_review_by resolves the fleet App login (#202)"            t_latest_review_by_resolves_fleet_app_login
run "latest_review_by picks newest policy verdict across logins"      t_latest_review_by_policy_reviewer_picks_newest_across_logins
run "latest_review_by never masks an active block with a later clean" t_latest_review_by_changes_requested_not_masked_by_later_other_login
run "latest_review_by picks latest by time, not array position"       t_latest_review_by_picks_max_by_time_not_array_position
run "latest_review_by normalizes DISMISSED to none"                   t_latest_review_by_normalizes_dismissed_to_none
run "latest_review_by normalizes PENDING to none"                     t_latest_review_by_normalizes_pending_to_none
run "main surfaces the fleet App review as .reviews.codex"            t_main_surfaces_fleet_app_review_as_codex
run "toplevel_comments_by counts the fleet App comment login"         t_toplevel_comments_by_counts_fleet_app_login
run "resolve_review_against_head: fresh verdict passes through"       t_resolve_against_head_fresh_passes_through
run "resolve_review_against_head: stale verdict collapses to none"    t_resolve_against_head_stale_collapses_to_none
run "resolve_review_against_head: absent stays none, not stale"       t_resolve_against_head_none_stays_none_not_stale
run "main collapses a stale review to none (#186 false ready)"        t_main_stale_review_collapses_to_none
run "main surfaces a fresh (head-bound) review's state"               t_main_fresh_review_surfaces_state
run "main surfaces head_sha as a top-level field"                     t_main_surfaces_head_sha_top_level
run "main fails loudly on an empty headRefOid"                        t_main_no_head_sha_fails
run "ci.status: success next to a cancelled twin is success (#182)"   t_ci_status_cancel_with_success_is_success
run "ci.status: only-cancels reads pending (#182)"                    t_ci_status_only_cancels_is_pending
run "ci.status: a real fail next to a cancel still fails (#182)"      t_ci_status_fail_with_cancel_is_failure

echo "== summary: ${PASS_COUNT} passed, ${FAIL_COUNT} failed =="
[[ "$FAIL_COUNT" -eq 0 ]]

README.md

tile.json