feat(scripts): add pr-await CI gate to collapse PR polling into one call (#2873)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -16,15 +16,21 @@ Do not read source code, edit files, push, post or resolve GitHub comments,
|
|||||||
trigger workflows, fetch full CI logs, or spawn subagents.
|
trigger workflows, fetch full CI logs, or spawn subagents.
|
||||||
|
|
||||||
Use `scripts/pr-state --summary <PR>` and `scripts/pr-resolve list <PR>` as the
|
Use `scripts/pr-state --summary <PR>` and `scripts/pr-resolve list <PR>` as the
|
||||||
primary sources. Poll at a 30-second cadence for at most 20 minutes. In the
|
primary sources. In the default mode, poll at a 60-second cadence for at most
|
||||||
default mode, return early for a failed check, merge conflict, actionable
|
20 minutes and return early for a failed check, merge conflict, actionable
|
||||||
review feedback, or a terminal clean state. If the caller says "wait N
|
review feedback, or a terminal clean state. Keep polling directly in this mode:
|
||||||
minutes" or "then fix up", use strict-deadline mode: calculate and include the
|
`scripts/pr-await` waits for every check to finish, which cannot return early on
|
||||||
absolute deadline in the polling prompt, accumulate findings, and do not return
|
a failure or conflict while other checks are still pending. If the caller says "wait N
|
||||||
early for findings, pending checks, or a clean snapshot. Stop early only if the
|
minutes" or "then fix up", use strict-deadline mode instead: the 20-minute cap
|
||||||
PR is merged/closed or access is revoked. At the deadline, return the latest
|
does not apply there — honor the caller's own N-minute deadline. Those
|
||||||
named pending checks and named actionable review findings, not only aggregate
|
semantics are exactly what `scripts/pr-await <PR> --deadline-min N` implements,
|
||||||
CI and review states.
|
so prefer it there and report its single output. Without that script, still poll
|
||||||
|
at a 60-second cadence but for the caller's full N minutes: calculate and
|
||||||
|
include the absolute deadline in the polling prompt, accumulate findings, and
|
||||||
|
do not return early for findings, pending checks, or a clean snapshot. Stop
|
||||||
|
early only if the PR is merged/closed or access is revoked. At the deadline,
|
||||||
|
return the latest named pending checks and named actionable review findings,
|
||||||
|
not only aggregate CI and review states.
|
||||||
|
|
||||||
Before the first GitHub request, obtain any runtime network approval required by
|
Before the first GitHub request, obtain any runtime network approval required by
|
||||||
the platform. A denied, cancelled, or interrupted approval is terminal: do not
|
the platform. A denied, cancelled, or interrupted approval is terminal: do not
|
||||||
|
|||||||
@@ -50,19 +50,45 @@ If the fresh mergeability query reports `mergeable=CONFLICTING` or
|
|||||||
result, push it, and restart this section with a new PR-state snapshot. Do not
|
result, push it, and restart this section with a new PR-state snapshot. Do not
|
||||||
triage comments or checks against a conflicted head.
|
triage comments or checks against a conflicted head.
|
||||||
|
|
||||||
For pending CI, do not run a rapid polling loop. Wait at a reasonable interval,
|
For pending CI, wait with `scripts/pr-await <PR>`. It blocks below the
|
||||||
then run the same summary again. Stop after about 20 minutes and report the
|
conversation and prints one report when every check is terminal, so a wait of
|
||||||
exact pending checks as "CI in progress." Use two monitoring modes: default
|
any length costs a single round-trip. Do not re-run `scripts/pr-state
|
||||||
monitoring may return early for a failed check, merge conflict, actionable
|
--summary` on a timer instead: each snapshot is a separate model turn that
|
||||||
finding, or clean terminal state; strict-deadline monitoring applies when the
|
re-reads the whole context, and waiting is only about 6% of what such a turn
|
||||||
user says "wait N minutes" or "then fix up." In strict mode, calculate and
|
actually does.
|
||||||
pass an absolute deadline, accumulate failures/comments, and do not return
|
|
||||||
early for findings, pending checks, or a clean snapshot; stop early only when
|
Default `--mode all-terminal` is the cost-correct choice and returns only when
|
||||||
the PR is merged/closed or access is revoked. Do not use interactive `gh pr
|
no check is pending, so every failure arrives in one report and is fixed in one
|
||||||
checks --watch` in the primary conversation: its TTY redraws make captured
|
pass. It is also the correct choice for a reason unrelated to cost: a failed job
|
||||||
output unusable. Use saved `scripts/pr-state --summary` snapshots about 60–90
|
can be visible while its parent workflow is still running, so an early return
|
||||||
seconds apart, or the read-only `pr-poller` only when the user explicitly asked
|
reports a partial failure set. Use `--mode first-failure` only when the user
|
||||||
to wait or monitor.
|
wants the first failure interactively; it costs an extra full CI cycle per
|
||||||
|
failure. Pass `--deadline-min` for a user-stated limit; on exit 2 report the
|
||||||
|
named pending checks as "CI in progress."
|
||||||
|
|
||||||
|
Read its exit code rather than re-deriving state: 0 clean, 1 terminal with
|
||||||
|
findings, 2 deadline with checks still pending, 3 blocked. Exit 3 covers every
|
||||||
|
case where a clean answer cannot be trusted: the PR merged or closed, a workflow
|
||||||
|
needs approval, access was lost, the merge-state query failed, the snapshot
|
||||||
|
reported `errors` or a null unresolved-thread count, or the host toolchain
|
||||||
|
cannot run `pr-state` correctly. Never downgrade an exit 3 to "probably clean."
|
||||||
|
|
||||||
|
Exit 1 counts blocking reviews, not only threads: a current-head
|
||||||
|
`CHANGES_REQUESTED` review can exist with no unresolved thread attached.
|
||||||
|
|
||||||
|
Every report records the jq and bash versions it ran under. That is provenance,
|
||||||
|
not a gate: `pr-state` used to fail silently on jq 1.6 and on the bash 3.2 that
|
||||||
|
macOS ships as `/bin/bash`, both of which made a dirty PR look clean, and both
|
||||||
|
are fixed at the source. What guards against a degraded `pr-state` now is the
|
||||||
|
snapshot itself, which is stronger than any version check: a summary reporting
|
||||||
|
`errors`, one whose unresolved-thread count is null, and one that does not parse
|
||||||
|
are all treated as unknown rather than clean. A blocked report on an old
|
||||||
|
toolchain adds a note naming it as a possible cause. A push during the wait
|
||||||
|
restarts the gate against the new head and is reported.
|
||||||
|
|
||||||
|
Do not use interactive `gh pr checks --watch` in the primary conversation: its
|
||||||
|
TTY redraws make captured output unusable. Use the read-only `pr-poller` only
|
||||||
|
when the user explicitly asked to wait or monitor and `pr-await` is unavailable.
|
||||||
Treat a poller's unresolved/pending snapshot as provisional: it can predate a
|
Treat a poller's unresolved/pending snapshot as provisional: it can predate a
|
||||||
primary-session push or thread resolution. Re-run `scripts/pr-state --summary
|
primary-session push or thread resolution. Re-run `scripts/pr-state --summary
|
||||||
<PR>` at the current head before acting on it or declaring completion.
|
<PR>` at the current head before acting on it or declaring completion.
|
||||||
@@ -82,8 +108,10 @@ then re-run the summary and require jobs to materialize before polling. `gh run
|
|||||||
approve` is not a valid command.
|
approve` is not a valid command.
|
||||||
|
|
||||||
Treat the state as clean only when the current head has no failed or pending
|
Treat the state as clean only when the current head has no failed or pending
|
||||||
checks, no merge conflict, no actionable review thread or issue comment, and
|
checks, no merge conflict, no blocking review (an active `CHANGES_REQUESTED`
|
||||||
qualifying exact-head semantic evidence where PR delivery requires it.
|
or a review blocked at the exact current head), no actionable review thread or
|
||||||
|
issue comment, and qualifying exact-head semantic evidence where PR delivery
|
||||||
|
requires it.
|
||||||
|
|
||||||
## 2. Fix CI Failures
|
## 2. Fix CI Failures
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ For multiline Markdown issue or PR bodies, write the body to a file and pass it
|
|||||||
with the relevant `gh ... --body-file <path>` option. Do not send escaped
|
with the relevant `gh ... --body-file <path>` option. Do not send escaped
|
||||||
newlines through `--body`; GitHub will render them literally.
|
newlines through `--body`; GitHub will render them literally.
|
||||||
|
|
||||||
For PR review/fixup workflows, prefer the repo helpers before manually querying GitHub/GraphQL: `scripts/pr-state --summary <PR>` for checks and unresolved-thread state, `scripts/pr-state --comment <comment_id>` for a full review-comment body, `scripts/pr-resolve list <PR>` for actionable unresolved review threads, and `scripts/pr-resolve reply <PR> <comment_id> <thread_id> "<body>"` to reply, resolve, and react in one call.
|
For PR review/fixup workflows, prefer the repo helpers before manually querying GitHub/GraphQL: `scripts/pr-await <PR>` to block until CI is terminal and get one report (do not manually poll `pr-state` on a timer in the primary conversation; preserve the documented `pr-poller` fallback when `pr-await` is unavailable), `scripts/pr-state --summary <PR>` for checks and unresolved-thread state, `scripts/pr-state --comment <comment_id>` for a full review-comment body, `scripts/pr-resolve list <PR>` for actionable unresolved review threads, and `scripts/pr-resolve reply <PR> <comment_id> <thread_id> "<body>"` to reply, resolve, and react in one call.
|
||||||
|
|
||||||
When a Kandev system message references an MCP tool that is not visible in the active tool list, use the runtime's tool discovery mechanism, such as `tool_search` when available, before falling back to a less specific workflow. Some task messaging and platform helpers are exposed on demand.
|
When a Kandev system message references an MCP tool that is not visible in the active tool list, use the runtime's tool discovery mechanism, such as `tool_search` when available, before falling back to a less specific workflow. Some task messaging and platform helpers are exposed on demand.
|
||||||
|
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ test-scripts:
|
|||||||
@printf "$(CYAN)Running script tests...$(RESET)\n"
|
@printf "$(CYAN)Running script tests...$(RESET)\n"
|
||||||
@python3 .github/scripts/lint-action-pinning_test.py
|
@python3 .github/scripts/lint-action-pinning_test.py
|
||||||
@bash scripts/pr-state.test.sh
|
@bash scripts/pr-state.test.sh
|
||||||
|
@bash scripts/pr-await.test.sh
|
||||||
@bash scripts/run-quiet.test.sh
|
@bash scripts/run-quiet.test.sh
|
||||||
@bash scripts/dev-prod-db-path.test.sh
|
@bash scripts/dev-prod-db-path.test.sh
|
||||||
@bash scripts/opencode-code-review.test.sh
|
@bash scripts/opencode-code-review.test.sh
|
||||||
|
|||||||
Executable
+789
@@ -0,0 +1,789 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Block until a pull request's CI reaches a terminal state, then emit one snapshot.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/pr-await <PR> [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --mode all-terminal | first-failure
|
||||||
|
# all-terminal (default) waits until no check is pending,
|
||||||
|
# so every failure lands in a single report and a single
|
||||||
|
# fix pass. first-failure returns as soon as one check
|
||||||
|
# fails; it exists for the interactive case and costs an
|
||||||
|
# extra full CI cycle per failure.
|
||||||
|
# --deadline-min N Hard stop, in minutes (default 45).
|
||||||
|
# --interval-sec N Seconds between polls (default 60).
|
||||||
|
# --format text | json Report format (default text).
|
||||||
|
# --quiet Suppress per-poll progress on stderr.
|
||||||
|
#
|
||||||
|
# Why this exists: `pr-state --summary` is a one-shot snapshot, so waiting for CI
|
||||||
|
# costs one model round-trip per snapshot. Measured over 6,658 such round-trips,
|
||||||
|
# each re-read ~177k of cached context at ~$0.26. This script moves the waiting
|
||||||
|
# below the model: one invocation, one report, regardless of how long CI runs.
|
||||||
|
#
|
||||||
|
# stdout carries ONLY the final report, so a caller can capture it verbatim.
|
||||||
|
# Progress goes to stderr.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 terminal and clean - no failed checks, no unresolved threads
|
||||||
|
# 1 terminal with findings - failures and/or review threads to act on
|
||||||
|
# 2 deadline reached - checks still pending; names reported
|
||||||
|
# 3 blocked - merged/closed, approval required, or access lost
|
||||||
|
#
|
||||||
|
# Requires: gh (authenticated), jq, and scripts/pr-state alongside this script.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PR_STATE="${PR_AWAIT_PR_STATE:-$SCRIPT_DIR/pr-state}"
|
||||||
|
GH="${PR_AWAIT_GH:-gh}"
|
||||||
|
SLEEP="${PR_AWAIT_SLEEP:-sleep}"
|
||||||
|
ACTIVE_READ_PID=''
|
||||||
|
ACTIVE_READ_GROUP=''
|
||||||
|
ACTIVE_WATCHDOG_PID=''
|
||||||
|
|
||||||
|
# Blocked results leave the poll loop before a snapshot exists, but a caller
|
||||||
|
# that asked for JSON still needs to parse the answer.
|
||||||
|
emit_blocked() {
|
||||||
|
local outcome=$1 message=$2
|
||||||
|
if [[ "${FORMAT:-text}" == 'json' ]]; then
|
||||||
|
local waited_sec="${WAITED:-0}"
|
||||||
|
local polls="${POLLS:-0}"
|
||||||
|
local head_changes="${HEAD_CHANGES:-0}"
|
||||||
|
local deadline_sec="${DEADLINE_SEC:-0}"
|
||||||
|
local interval_sec="${INTERVAL_SEC:-0}"
|
||||||
|
local mergeable_value='UNKNOWN'
|
||||||
|
local merge_status_value='UNKNOWN'
|
||||||
|
if jq -e . >/dev/null 2>&1 <<<"${MERGE_JSON:-}"; then
|
||||||
|
mergeable_value="$(jq -r '.mergeable // "UNKNOWN"' <<<"$MERGE_JSON")"
|
||||||
|
merge_status_value="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$MERGE_JSON")"
|
||||||
|
fi
|
||||||
|
jq -n \
|
||||||
|
--argjson pr "$PR" \
|
||||||
|
--arg outcome "$outcome" \
|
||||||
|
--arg mode "${MODE:-all-terminal}" \
|
||||||
|
--argjson waited_sec "$waited_sec" \
|
||||||
|
--argjson polls "$polls" \
|
||||||
|
--argjson head_changes "$head_changes" \
|
||||||
|
--argjson exit_code 3 \
|
||||||
|
--arg message "$message" \
|
||||||
|
--arg toolchain "${TOOLCHAIN:-unverified}" \
|
||||||
|
--argjson deadline_sec "$deadline_sec" \
|
||||||
|
--argjson interval_sec "$interval_sec" \
|
||||||
|
--arg mergeable "$mergeable_value" \
|
||||||
|
--arg merge_state_status "$merge_status_value" \
|
||||||
|
'{pr: $pr, outcome: $outcome, mode: $mode, waited_sec: $waited_sec,
|
||||||
|
polls: $polls, head_changes: $head_changes, exit_code: $exit_code,
|
||||||
|
message: $message, toolchain: $toolchain, evidence_complete: false,
|
||||||
|
deadline_sec: $deadline_sec, interval_sec: $interval_sec,
|
||||||
|
mergeable: $mergeable, merge_state_status: $merge_state_status,
|
||||||
|
summary: null}'
|
||||||
|
else
|
||||||
|
printf 'PR %s\n%s\n' "$PR" "$message"
|
||||||
|
toolchain_hint
|
||||||
|
fi
|
||||||
|
exit 3
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,/^# Requires:/p' "$0" | sed 's|^# \{0,1\}||'
|
||||||
|
exit "${1:-1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
PR=''
|
||||||
|
MODE='all-terminal'
|
||||||
|
DEADLINE_MIN=45
|
||||||
|
INTERVAL_SEC=60
|
||||||
|
FORMAT='text'
|
||||||
|
QUIET=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
shift
|
||||||
|
[[ $# -gt 0 ]] || usage 1
|
||||||
|
case "$1" in
|
||||||
|
all-terminal | first-failure) MODE=$1 ;;
|
||||||
|
*) usage 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--deadline-min)
|
||||||
|
shift
|
||||||
|
[[ $# -gt 0 && "$1" =~ ^[0-9]+$ ]] || usage 1
|
||||||
|
# 10# forces base 10. Without it "09" aborts arithmetic under set -e and
|
||||||
|
# "010" silently means 8, because Bash reads a leading zero as octal.
|
||||||
|
DEADLINE_MIN=$((10#$1))
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--interval-sec)
|
||||||
|
shift
|
||||||
|
[[ $# -gt 0 && "$1" =~ ^[0-9]+$ && $((10#$1)) -gt 0 ]] || usage 1
|
||||||
|
INTERVAL_SEC=$((10#$1))
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--format)
|
||||||
|
shift
|
||||||
|
[[ $# -gt 0 ]] || usage 1
|
||||||
|
case "$1" in
|
||||||
|
text | json) FORMAT=$1 ;;
|
||||||
|
*) usage 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--quiet)
|
||||||
|
QUIET=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h | --help) usage 0 ;;
|
||||||
|
-*) usage 1 ;;
|
||||||
|
*)
|
||||||
|
[[ -z "$PR" ]] || usage 1
|
||||||
|
PR=$1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$PR" && "$PR" =~ ^[0-9]+$ ]] || usage 1
|
||||||
|
# Normalize so a value like 0012 is 12 everywhere, including `jq --argjson`.
|
||||||
|
PR=$((10#$PR))
|
||||||
|
# "0" and "00" both pass the pattern above and normalize to 0, which is not a
|
||||||
|
# PR number GitHub ever issues; reject it here instead of chasing it through
|
||||||
|
# three failed pr-state reads as a confusing blocked-access outcome.
|
||||||
|
[[ "$PR" -gt 0 ]] || usage 1
|
||||||
|
|
||||||
|
progress() {
|
||||||
|
[[ "$QUIET" -eq 1 ]] || printf '%s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mergeability is deliberately absent from `pr-state --summary`, so it is
|
||||||
|
# queried separately below rather than letting a caller assume a clean merge
|
||||||
|
# state. There is no `|| {}` fallback: a failed query is unknown state, and the
|
||||||
|
# caller must treat it that way instead of reading an empty object as
|
||||||
|
# "not conflicting".
|
||||||
|
|
||||||
|
TIMEOUT_BIN=''
|
||||||
|
TIMEOUT_RESOLVED=0
|
||||||
|
resolve_timeout_bin() {
|
||||||
|
[[ "$TIMEOUT_RESOLVED" -eq 0 ]] || return 0
|
||||||
|
TIMEOUT_RESOLVED=1
|
||||||
|
# Set PR_AWAIT_NO_TIMEOUT=1 to exercise the shell fallback on a host that does
|
||||||
|
# have timeout(1). The fallback is the path stock macOS takes, so it needs
|
||||||
|
# coverage somewhere other than stock macOS.
|
||||||
|
[[ -z "${PR_AWAIT_NO_TIMEOUT:-}" ]] || return 0
|
||||||
|
local candidate
|
||||||
|
for candidate in timeout gtimeout; do
|
||||||
|
if command -v "$candidate" >/dev/null 2>&1; then
|
||||||
|
TIMEOUT_BIN="$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_active_read() {
|
||||||
|
local pid="${ACTIVE_READ_PID:-}"
|
||||||
|
local group="${ACTIVE_READ_GROUP:-}"
|
||||||
|
local watchdog="${ACTIVE_WATCHDOG_PID:-}"
|
||||||
|
|
||||||
|
# The bounded command is a job-control process-group leader on the fallback
|
||||||
|
# path. Stop the group first so a pr-state shell cannot leave its gh child
|
||||||
|
# running. The KILL fallback makes cancellation deterministic if a child
|
||||||
|
# ignores TERM. The direct kill also covers timeout(1), whose child group is
|
||||||
|
# not portable across implementations.
|
||||||
|
if [[ -n "$group" ]]; then
|
||||||
|
kill -TERM -- "-$group" 2>/dev/null || true
|
||||||
|
kill -KILL -- "-$group" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
[[ -z "$pid" ]] || kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
[[ -z "$pid" ]] || kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
[[ -z "$watchdog" ]] || kill -KILL "$watchdog" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run a command with a wall-clock bound, keeping its stdout. Used for the GitHub
|
||||||
|
# reads so a stalled `pr-state` or `gh pr view` cannot outlive the caller's
|
||||||
|
# deadline: the poll loop can only check the clock between reads, never during.
|
||||||
|
run_bounded_capture() {
|
||||||
|
local secs=$1 outfile=$2
|
||||||
|
shift 2
|
||||||
|
[[ -z "$outfile" ]] || : >"$outfile"
|
||||||
|
|
||||||
|
resolve_timeout_bin
|
||||||
|
if [[ -n "$TIMEOUT_BIN" ]]; then
|
||||||
|
local had_monitor=0
|
||||||
|
case "$-" in
|
||||||
|
*m*) had_monitor=1 ;;
|
||||||
|
esac
|
||||||
|
set -m
|
||||||
|
if [[ -n "$outfile" ]]; then
|
||||||
|
"$TIMEOUT_BIN" "$secs" "$@" >"$outfile" 2>/dev/null &
|
||||||
|
else
|
||||||
|
"$TIMEOUT_BIN" "$secs" "$@" >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
local pid=$!
|
||||||
|
[[ "$had_monitor" -eq 1 ]] || set +m
|
||||||
|
ACTIVE_READ_PID="$pid"
|
||||||
|
ACTIVE_READ_GROUP="$pid"
|
||||||
|
local rc=0
|
||||||
|
wait "$pid" 2>/dev/null || rc=$?
|
||||||
|
ACTIVE_READ_PID=''
|
||||||
|
ACTIVE_READ_GROUP=''
|
||||||
|
return "$rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback for hosts with no timeout(1), stock macOS among them.
|
||||||
|
#
|
||||||
|
# Job control makes the background job a process-group leader so the watchdog
|
||||||
|
# can kill the whole tree; killing the pr-state shell alone leaves the `gh` it
|
||||||
|
# is blocked on running. The watchdog polls in one-second steps and exits by
|
||||||
|
# itself once the command finishes, because a shell blocked in `sleep N` does
|
||||||
|
# not act on a signal until that sleep returns. Its fds are closed because
|
||||||
|
# callers capture this with $(...), and any background process holding that
|
||||||
|
# pipe keeps the substitution blocked afterwards.
|
||||||
|
local had_monitor=0
|
||||||
|
case "$-" in
|
||||||
|
*m*) had_monitor=1 ;;
|
||||||
|
esac
|
||||||
|
set -m
|
||||||
|
if [[ -n "$outfile" ]]; then
|
||||||
|
"$@" >"$outfile" 2>/dev/null &
|
||||||
|
else
|
||||||
|
"$@" >/dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
local pid=$!
|
||||||
|
[[ "$had_monitor" -eq 1 ]] || set +m
|
||||||
|
ACTIVE_READ_PID="$pid"
|
||||||
|
ACTIVE_READ_GROUP="$pid"
|
||||||
|
|
||||||
|
(
|
||||||
|
waited=0
|
||||||
|
while [[ "$waited" -lt "$secs" ]]; do
|
||||||
|
sleep 1
|
||||||
|
kill -0 "$pid" 2>/dev/null || exit 0
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
kill -9 -"$pid" 2>/dev/null || kill -9 "$pid" 2>/dev/null
|
||||||
|
) >/dev/null 2>&1 &
|
||||||
|
ACTIVE_WATCHDOG_PID=$!
|
||||||
|
|
||||||
|
local rc=0
|
||||||
|
wait "$pid" 2>/dev/null || rc=$?
|
||||||
|
kill "$ACTIVE_WATCHDOG_PID" 2>/dev/null || true
|
||||||
|
wait "$ACTIVE_WATCHDOG_PID" 2>/dev/null || true
|
||||||
|
ACTIVE_WATCHDOG_PID=''
|
||||||
|
ACTIVE_READ_PID=''
|
||||||
|
ACTIVE_READ_GROUP=''
|
||||||
|
# 137 is SIGKILL, which here can only have come from the watchdog.
|
||||||
|
[[ "$rc" -ne 137 ]] || return 124
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Seconds left before the caller's deadline, floored at zero.
|
||||||
|
remaining_budget() {
|
||||||
|
local left=$((DEADLINE_SEC - (SECONDS - START_SEC)))
|
||||||
|
[[ "$left" -gt 0 ]] || left=0
|
||||||
|
printf '%s' "$left"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cap on a single GitHub read. A healthy `pr-state --summary` returns in about
|
||||||
|
# ten seconds; three minutes means it is wedged, and waiting out a 45 minute
|
||||||
|
# default deadline on one wedged read helps nobody.
|
||||||
|
READ_CAP=180
|
||||||
|
|
||||||
|
# A zero or already-spent deadline still gets exactly one snapshot, so the
|
||||||
|
# report can name the pending checks rather than saying nothing at all. That
|
||||||
|
# read is bounded tightly: a healthy read returns in about ten seconds, and a
|
||||||
|
# caller who asked for no waiting should not get a long one.
|
||||||
|
READ_FLOOR="${PR_AWAIT_READ_FLOOR:-15}"
|
||||||
|
|
||||||
|
# Recorded in every report as provenance, not as a gate. `pr-state` used to fail
|
||||||
|
# silently on jq 1.6 and on the bash 3.2 that macOS ships as /bin/bash, and this
|
||||||
|
# script refused to run there. Both are fixed at the source now, and blocking on
|
||||||
|
# a version would only turn away working installations.
|
||||||
|
#
|
||||||
|
# What replaced the gate is stronger than it was: the poll loop rejects a
|
||||||
|
# snapshot that reports `errors`, one whose unresolved-thread count is null, and
|
||||||
|
# one that does not parse. Those test the data rather than a proxy for it, so
|
||||||
|
# they catch a degraded pr-state whatever the cause, including a checkout that
|
||||||
|
# predates the fix.
|
||||||
|
record_toolchain() {
|
||||||
|
local jq_bin="${PR_AWAIT_JQ:-jq}"
|
||||||
|
local bash_bin="${PR_AWAIT_PROBE_BASH:-bash}"
|
||||||
|
JQ_VERSION="$("$jq_bin" --version 2>/dev/null || printf 'unknown')"
|
||||||
|
BASH_VERSION_SEEN="$("$bash_bin" -c 'printf "%s" "${BASH_VERSION%%(*}"' 2>/dev/null || printf 'unknown')"
|
||||||
|
TOOLCHAIN="$JQ_VERSION, bash $BASH_VERSION_SEEN"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Appended to a blocked report only. A reader whose pr-state produced nothing
|
||||||
|
# usable is the one person who needs to know their toolchain is a known cause.
|
||||||
|
toolchain_hint() {
|
||||||
|
local hint=''
|
||||||
|
case "$JQ_VERSION" in
|
||||||
|
jq-1.[0-6] | jq-1.[0-6].* | jq-1.[0-6]-*)
|
||||||
|
hint="$JQ_VERSION loops on an empty-matching gsub; a pr-state predating that fix hangs on it." ;;
|
||||||
|
esac
|
||||||
|
case "$BASH_VERSION_SEEN" in
|
||||||
|
[123].*)
|
||||||
|
hint="${hint:+$hint }bash $BASH_VERSION_SEEN errors on an empty array under set -u; a pr-state predating that fix loses review threads there." ;;
|
||||||
|
esac
|
||||||
|
[[ -z "$hint" ]] || printf '\nToolchain note: %s\n' "$hint"
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOLCHAIN='unverified'
|
||||||
|
JQ_VERSION='unknown'
|
||||||
|
BASH_VERSION_SEEN='unknown'
|
||||||
|
record_toolchain
|
||||||
|
|
||||||
|
READ_TMP="$(mktemp "${TMPDIR:-/tmp}/pr-await-read-XXXXXX")"
|
||||||
|
cleanup_read_tmp() {
|
||||||
|
[[ -z "${READ_TMP:-}" ]] || rm -f "$READ_TMP"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_signal() {
|
||||||
|
local code=$1
|
||||||
|
stop_active_read
|
||||||
|
exit "$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_read_tmp EXIT
|
||||||
|
trap 'handle_signal 130' INT
|
||||||
|
trap 'handle_signal 143' TERM
|
||||||
|
|
||||||
|
DEADLINE_SEC=$((DEADLINE_MIN * 60))
|
||||||
|
|
||||||
|
# Sleep until the next poll unless the deadline arrives first, capping the last
|
||||||
|
# sleep to whatever time is left. Returns 1 when the caller must stop, so a
|
||||||
|
# retry path cannot outlive a limit the caller asked for.
|
||||||
|
sleep_or_stop() {
|
||||||
|
local remaining=$((DEADLINE_SEC - (SECONDS - START_SEC)))
|
||||||
|
[[ "$remaining" -gt 0 ]] || return 1
|
||||||
|
if [[ "$remaining" -lt "$INTERVAL_SEC" ]]; then
|
||||||
|
"$SLEEP" "$remaining"
|
||||||
|
else
|
||||||
|
"$SLEEP" "$INTERVAL_SEC"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
START_SEC=$SECONDS
|
||||||
|
POLLS=0
|
||||||
|
CONSECUTIVE_ERRORS=0
|
||||||
|
MERGE_ERRORS=0
|
||||||
|
MERGE_TIMED_OUT=0
|
||||||
|
EVIDENCE_ERRORS=0
|
||||||
|
MERGE_UNKNOWN=0
|
||||||
|
HEAD_CHANGES=0
|
||||||
|
FIRST_HEAD=''
|
||||||
|
SUMMARY_JSON=''
|
||||||
|
MERGE_JSON=''
|
||||||
|
OUTCOME=''
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
POLLS=$((POLLS + 1))
|
||||||
|
ELAPSED=$((SECONDS - START_SEC))
|
||||||
|
|
||||||
|
# A spent deadline still buys one snapshot, but one for the whole iteration:
|
||||||
|
# granting the floor again to the second query would let --deadline-min 0 wait
|
||||||
|
# out two floor-length reads.
|
||||||
|
floor_start=''
|
||||||
|
budget="$(remaining_budget)"
|
||||||
|
if [[ "$budget" -le 0 ]]; then
|
||||||
|
if [[ -n "$SUMMARY_JSON" ]]; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
floor_start=$SECONDS
|
||||||
|
budget="$READ_FLOOR"
|
||||||
|
fi
|
||||||
|
[[ "$budget" -le "$READ_CAP" ]] || budget="$READ_CAP"
|
||||||
|
|
||||||
|
read_rc=0
|
||||||
|
run_bounded_capture "$budget" "$READ_TMP" "$PR_STATE" --summary "$PR" || read_rc=$?
|
||||||
|
if [[ "$read_rc" -eq 124 ]]; then
|
||||||
|
progress "poll ${POLLS}: pr-state exceeded its ${budget}s bound"
|
||||||
|
OUTCOME='deadline'
|
||||||
|
[[ -n "$SUMMARY_JSON" ]] || OUTCOME='blocked-access'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
summary_body="$(cat "$READ_TMP")"
|
||||||
|
# A non-zero read is a failed read, whatever it printed. Checking only the
|
||||||
|
# parse would let a pr-state that exited 1 through on an empty snapshot.
|
||||||
|
# Staged in a local first: assigning straight into SUMMARY_JSON would
|
||||||
|
# clobber the last good snapshot with this failed read's garbage, so a
|
||||||
|
# deadline reached right after would lose evidence a working poll had
|
||||||
|
# already produced.
|
||||||
|
if [[ "$read_rc" -ne 0 ]] || ! jq -e . >/dev/null 2>&1 <<<"$summary_body"; then
|
||||||
|
CONSECUTIVE_ERRORS=$((CONSECUTIVE_ERRORS + 1))
|
||||||
|
progress "poll ${POLLS}: pr-state failed (${CONSECUTIVE_ERRORS} in a row)"
|
||||||
|
# Three consecutive failures is a lost connection, not a transient blip.
|
||||||
|
# Reporting "blocked" beats looping silently against revoked access.
|
||||||
|
if [[ "$CONSECUTIVE_ERRORS" -ge 3 ]]; then
|
||||||
|
OUTCOME='blocked-access'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='blocked-access'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
SUMMARY_JSON="$summary_body"
|
||||||
|
CONSECUTIVE_ERRORS=0
|
||||||
|
|
||||||
|
budget="$(remaining_budget)"
|
||||||
|
if [[ "$budget" -le 0 ]]; then
|
||||||
|
if [[ -n "$MERGE_JSON" ]]; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ -n "$floor_start" ]]; then
|
||||||
|
# Whatever the first read left of this iteration's single floor.
|
||||||
|
budget=$((READ_FLOOR - (SECONDS - floor_start)))
|
||||||
|
[[ "$budget" -gt 0 ]] || budget=1
|
||||||
|
else
|
||||||
|
floor_start=$SECONDS
|
||||||
|
budget="$READ_FLOOR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ "$budget" -le "$READ_CAP" ]] || budget="$READ_CAP"
|
||||||
|
|
||||||
|
read_rc=0
|
||||||
|
run_bounded_capture "$budget" "$READ_TMP" \
|
||||||
|
"$GH" pr view "$PR" --json state,mergeable,mergeStateStatus,headRefOid || read_rc=$?
|
||||||
|
if [[ "$read_rc" -eq 124 ]]; then
|
||||||
|
progress "poll ${POLLS}: gh pr view exceeded its ${budget}s bound"
|
||||||
|
OUTCOME='blocked-merge-state'
|
||||||
|
MERGE_TIMED_OUT=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
merge_body="$(cat "$READ_TMP")"
|
||||||
|
# Staged in a local first, same reason as the pr-state read above: a failed
|
||||||
|
# read must not overwrite the last good MERGE_JSON with unusable text.
|
||||||
|
if [[ "$read_rc" -ne 0 ]] || ! jq -e . >/dev/null 2>&1 <<<"$merge_body"; then
|
||||||
|
MERGE_ERRORS=$((MERGE_ERRORS + 1))
|
||||||
|
progress "poll ${POLLS}: gh pr view failed (${MERGE_ERRORS} in a row)"
|
||||||
|
if [[ "$MERGE_ERRORS" -ge 3 ]]; then
|
||||||
|
OUTCOME='blocked-merge-state'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='blocked-merge-state'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
MERGE_JSON="$merge_body"
|
||||||
|
MERGE_ERRORS=0
|
||||||
|
|
||||||
|
pr_state="$(jq -r '.state // ""' <<<"$MERGE_JSON")"
|
||||||
|
head_oid="$(jq -r '.headRefOid // ""' <<<"$MERGE_JSON")"
|
||||||
|
[[ -n "$head_oid" ]] || head_oid="$(jq -r '.pr.head_ref_oid // ""' <<<"$SUMMARY_JSON")"
|
||||||
|
[[ -n "$FIRST_HEAD" ]] || FIRST_HEAD="$head_oid"
|
||||||
|
|
||||||
|
# A push during the wait restarts CI, so every check counted so far belongs to
|
||||||
|
# a head that no longer exists. Discard that evidence, but not the caller's
|
||||||
|
# clock: --deadline-min is a hard stop, and a PR receiving periodic commits
|
||||||
|
# would otherwise defer it forever.
|
||||||
|
if [[ -n "$head_oid" && "$head_oid" != "$FIRST_HEAD" ]]; then
|
||||||
|
HEAD_CHANGES=$((HEAD_CHANGES + 1))
|
||||||
|
progress "poll ${POLLS}: head moved ${FIRST_HEAD:0:9} -> ${head_oid:0:9}; discarding stale evidence"
|
||||||
|
FIRST_HEAD="$head_oid"
|
||||||
|
EVIDENCE_ERRORS=0
|
||||||
|
MERGE_UNKNOWN=0
|
||||||
|
# The snapshot in hand describes the previous head. Keeping it would let a
|
||||||
|
# deadline reached before the next read print those checks under the new
|
||||||
|
# SHA; with no snapshot the run reports blocked, which is the truth.
|
||||||
|
SUMMARY_JSON=''
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$pr_state" == 'MERGED' || "$pr_state" == 'CLOSED' ]]; then
|
||||||
|
OUTCOME='blocked-pr-state'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed="$(jq -r '.failed_checks | length' <<<"$SUMMARY_JSON")"
|
||||||
|
pending="$(jq -r '.pending_checks | length' <<<"$SUMMARY_JSON")"
|
||||||
|
check_count="$(jq -r '.check_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
passed="$(jq -r '.passed_check_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
complete="$(jq -r '.checks_snapshot_complete' <<<"$SUMMARY_JSON")"
|
||||||
|
checks_head="$(jq -r '.checks_head_sha // ""' <<<"$SUMMARY_JSON")"
|
||||||
|
|
||||||
|
progress "poll ${POLLS} (+$((SECONDS - START_SEC))s): ${passed} passed, ${failed} failed, ${pending} pending"
|
||||||
|
|
||||||
|
# Everything below decides on check evidence, so a snapshot describing a head
|
||||||
|
# that has already been replaced is discarded outright rather than guarded
|
||||||
|
# against case by case. Every snapshot retained past this point is therefore
|
||||||
|
# current-head by construction, and a deadline reached with none in hand
|
||||||
|
# reports blocked instead of printing the previous head's checks under the
|
||||||
|
# new SHA.
|
||||||
|
if [[ -z "$checks_head" || "$checks_head" != "$head_oid" ]]; then
|
||||||
|
progress "poll ${POLLS}: snapshot is for ${checks_head:0:9}, head is ${head_oid:0:9}; discarding"
|
||||||
|
SUMMARY_JSON=''
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(jq -r '.approval_required_runs | length' <<<"$SUMMARY_JSON")" -gt 0 ]]; then
|
||||||
|
# action_required reads as green-or-skipped in a naive check count. It is
|
||||||
|
# blocked verification: waiting for it to finish would never terminate.
|
||||||
|
OUTCOME='blocked-approval'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub can expose a new head before Actions has registered any check. An
|
||||||
|
# empty rollup satisfies "nothing pending" without any evidence behind it;
|
||||||
|
# in this repository the required jobs always run, so zero means not yet.
|
||||||
|
if [[ "$check_count" -eq 0 ]]; then
|
||||||
|
progress "poll ${POLLS}: no checks registered at this head yet; waiting"
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A failed job can be visible while its parent workflow is still running, so
|
||||||
|
# in all-terminal mode a failure is never on its own a reason to stop.
|
||||||
|
if [[ "$MODE" == 'first-failure' && "$failed" -gt 0 ]]; then
|
||||||
|
snapshot_errors="$(jq -r '.errors // [] | length' <<<"$SUMMARY_JSON")"
|
||||||
|
threads_known="$(jq -r 'if .unresolved_review_thread_count == null then "no" else "yes" end' <<<"$SUMMARY_JSON")"
|
||||||
|
if [[ "$complete" != 'true' || "$snapshot_errors" -gt 0 || "$threads_known" == 'no' ]]; then
|
||||||
|
EVIDENCE_ERRORS=$((EVIDENCE_ERRORS + 1))
|
||||||
|
progress "poll ${POLLS}: first failure visible but evidence incomplete (${EVIDENCE_ERRORS} in a row)"
|
||||||
|
if [[ "$EVIDENCE_ERRORS" -ge 3 ]]; then
|
||||||
|
OUTCOME='blocked-evidence'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='blocked-evidence'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
EVIDENCE_ERRORS=0
|
||||||
|
OUTCOME='terminal'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$pending" -eq 0 && "$complete" == 'true' ]]; then
|
||||||
|
# Terminal by check state, but only trustworthy if the snapshot is whole.
|
||||||
|
# A partial fetch reports `errors`, and a failed thread query leaves the
|
||||||
|
# unresolved count null - which `// 0` would otherwise launder into "clean".
|
||||||
|
snapshot_errors="$(jq -r '.errors // [] | length' <<<"$SUMMARY_JSON")"
|
||||||
|
threads_known="$(jq -r 'if .unresolved_review_thread_count == null then "no" else "yes" end' <<<"$SUMMARY_JSON")"
|
||||||
|
if [[ "$snapshot_errors" -gt 0 || "$threads_known" == 'no' ]]; then
|
||||||
|
EVIDENCE_ERRORS=$((EVIDENCE_ERRORS + 1))
|
||||||
|
progress "poll ${POLLS}: checks terminal but evidence incomplete (${EVIDENCE_ERRORS} in a row)"
|
||||||
|
if [[ "$EVIDENCE_ERRORS" -ge 3 ]]; then
|
||||||
|
OUTCOME='blocked-evidence'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='blocked-evidence'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
EVIDENCE_ERRORS=0
|
||||||
|
|
||||||
|
# GitHub reports UNKNOWN while it is still computing mergeability. Deciding
|
||||||
|
# on it would call a PR clean before the answer exists.
|
||||||
|
if [[ "$(jq -r '.mergeable // "UNKNOWN"' <<<"$MERGE_JSON")" == 'UNKNOWN' ]]; then
|
||||||
|
MERGE_UNKNOWN=$((MERGE_UNKNOWN + 1))
|
||||||
|
progress "poll ${POLLS}: mergeability still UNKNOWN (${MERGE_UNKNOWN} in a row)"
|
||||||
|
if [[ "$MERGE_UNKNOWN" -ge 3 ]]; then
|
||||||
|
OUTCOME='blocked-merge-unknown'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='blocked-merge-unknown'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
MERGE_UNKNOWN=0
|
||||||
|
|
||||||
|
OUTCOME='terminal'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sleep_or_stop; then
|
||||||
|
OUTCOME='deadline'
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
WAITED=$((SECONDS - START_SEC))
|
||||||
|
|
||||||
|
# A failed read can leave a partial, unparseable body behind, and that value
|
||||||
|
# survives the `continue` that follows it. Normalize before the report rather
|
||||||
|
# than letting `jq --argjson` abort under `set -e` with no output at all.
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$MERGE_JSON" || MERGE_JSON='{}'
|
||||||
|
|
||||||
|
if [[ -z "$SUMMARY_JSON" ]] || ! jq -e . >/dev/null 2>&1 <<<"$SUMMARY_JSON"; then
|
||||||
|
emit_blocked 'blocked-access' \
|
||||||
|
"BLOCKED: no usable pr-state snapshot was obtained; treat CI state as unknown."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$OUTCOME" == 'blocked-access' ]]; then
|
||||||
|
emit_blocked 'blocked-access' \
|
||||||
|
"BLOCKED: pr-state failed $CONSECUTIVE_ERRORS times in a row; treat CI state as unknown."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$OUTCOME" == 'blocked-merge-state' ]]; then
|
||||||
|
if [[ "$MERGE_TIMED_OUT" -eq 1 ]]; then
|
||||||
|
emit_blocked 'blocked-merge-state' \
|
||||||
|
"BLOCKED: gh pr view exceeded its read bound, so merge state is unknown.
|
||||||
|
A conflicted or closed PR is indistinguishable from a clean one here."
|
||||||
|
fi
|
||||||
|
emit_blocked 'blocked-merge-state' \
|
||||||
|
"BLOCKED: gh pr view failed $MERGE_ERRORS times in a row, so merge state is unknown.
|
||||||
|
A conflicted or closed PR is indistinguishable from a clean one here."
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed_n="$(jq -r '.failed_checks | length' <<<"$SUMMARY_JSON")"
|
||||||
|
pending_n="$(jq -r '.pending_checks | length' <<<"$SUMMARY_JSON")"
|
||||||
|
passed_n="$(jq -r '.passed_check_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
# `unresolved_review_thread_count` is every unresolved thread (pr-state:922);
|
||||||
|
# `hidden_unresolved_threads` is the subset filtered out of `unresolved_threads`,
|
||||||
|
# not an addition to it. Summing the two counts the hidden ones twice.
|
||||||
|
threads_total_n="$(jq -r '.unresolved_review_thread_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
visible_n="$(jq -r '.unresolved_threads | length' <<<"$SUMMARY_JSON")"
|
||||||
|
hidden_n="$(jq -r '.hidden_unresolved_threads | length' <<<"$SUMMARY_JSON")"
|
||||||
|
comments_n="$(jq -r '.actionable_issue_comment_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
changes_req_n="$(jq -r '.review_evidence.active_changes_requested_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
blocked_rev_n="$(jq -r '.review_evidence.blocked_exact_current_head_review_count // 0' <<<"$SUMMARY_JSON")"
|
||||||
|
evidence_errors_n="$(jq -r '.errors // [] | length' <<<"$SUMMARY_JSON")"
|
||||||
|
error_sources="$(jq -r '[.errors // [] | .[].source] | join(", ")' <<<"$SUMMARY_JSON")"
|
||||||
|
threads_known_final="$(jq -r 'if .unresolved_review_thread_count == null then "no" else "yes" end' <<<"$SUMMARY_JSON")"
|
||||||
|
base_advanced="$(jq -r 'if .pr.base_advanced_since_head == true then 1 else 0 end' <<<"$SUMMARY_JSON")"
|
||||||
|
mergeable="$(jq -r '.mergeable // "UNKNOWN"' <<<"$MERGE_JSON")"
|
||||||
|
merge_status="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$MERGE_JSON")"
|
||||||
|
|
||||||
|
conflicted=0
|
||||||
|
[[ "$mergeable" == 'CONFLICTING' || "$merge_status" == 'DIRTY' ]] && conflicted=1
|
||||||
|
|
||||||
|
# A CHANGES_REQUESTED review, or one whose body carries an explicit blocker,
|
||||||
|
# can exist with no unresolved thread and no issue comment attached. Counting
|
||||||
|
# only threads would report such a PR clean.
|
||||||
|
findings=$((failed_n + threads_total_n + comments_n + conflicted + changes_req_n + blocked_rev_n + base_advanced))
|
||||||
|
|
||||||
|
case "$OUTCOME" in
|
||||||
|
blocked-pr-state | blocked-approval | blocked-evidence | blocked-merge-unknown) code=3 ;;
|
||||||
|
deadline) code=2 ;;
|
||||||
|
*) [[ "$findings" -gt 0 ]] && code=1 || code=0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$FORMAT" == 'json' ]]; then
|
||||||
|
jq -n \
|
||||||
|
--argjson pr "$PR" \
|
||||||
|
--argjson summary "$SUMMARY_JSON" \
|
||||||
|
--argjson merge "$MERGE_JSON" \
|
||||||
|
--arg outcome "$OUTCOME" \
|
||||||
|
--arg mode "$MODE" \
|
||||||
|
--argjson waited_sec "$WAITED" \
|
||||||
|
--argjson polls "$POLLS" \
|
||||||
|
--argjson head_changes "$HEAD_CHANGES" \
|
||||||
|
--argjson exit_code "$code" \
|
||||||
|
--arg toolchain "$TOOLCHAIN" \
|
||||||
|
--argjson deadline_sec "$DEADLINE_SEC" \
|
||||||
|
--argjson interval_sec "$INTERVAL_SEC" \
|
||||||
|
--argjson evidence_complete "$([[ "$evidence_errors_n" -eq 0 && "$threads_known_final" == 'yes' ]] && echo true || echo false)" \
|
||||||
|
'{pr: $pr, outcome: $outcome, mode: $mode, waited_sec: $waited_sec, polls: $polls,
|
||||||
|
head_changes: $head_changes, exit_code: $exit_code,
|
||||||
|
message: null, toolchain: $toolchain, evidence_complete: $evidence_complete,
|
||||||
|
deadline_sec: $deadline_sec, interval_sec: $interval_sec,
|
||||||
|
mergeable: ($merge.mergeable // "UNKNOWN"),
|
||||||
|
merge_state_status: ($merge.mergeStateStatus // "UNKNOWN"),
|
||||||
|
summary: $summary}'
|
||||||
|
exit "$code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
branch="$(jq -r '.pr.head_ref_name // "?"' <<<"$SUMMARY_JSON")"
|
||||||
|
base="$(jq -r '.pr.base_ref_name // "?"' <<<"$SUMMARY_JSON")"
|
||||||
|
printf 'PR %s @ %s (%s -> %s)\n' "$PR" "${FIRST_HEAD:0:9}" "$branch" "$base"
|
||||||
|
printf 'gate: %s | outcome: %s | waited %dm%02ds over %d poll(s)\n' \
|
||||||
|
"$MODE" "$OUTCOME" "$((WAITED / 60))" "$((WAITED % 60))" "$POLLS"
|
||||||
|
[[ "$HEAD_CHANGES" -gt 0 ]] && printf 'note: head moved %d time(s) during the wait\n' "$HEAD_CHANGES"
|
||||||
|
printf 'checks: %s passed, %s failed, %s pending\n' "$passed_n" "$failed_n" "$pending_n"
|
||||||
|
printf 'merge: %s / %s\n' "$mergeable" "$merge_status"
|
||||||
|
printf 'toolchain: %s\n' "$TOOLCHAIN"
|
||||||
|
|
||||||
|
if [[ "$failed_n" -gt 0 ]]; then
|
||||||
|
printf '\nFAILED CHECKS:\n'
|
||||||
|
jq -r '.failed_checks[] | " \(.name) run=\(.run_id // "?") job=\(.job_id // "?") \(.conclusion // "?")"' <<<"$SUMMARY_JSON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$pending_n" -gt 0 ]]; then
|
||||||
|
printf '\nSTILL PENDING:\n'
|
||||||
|
jq -r '.pending_checks[] | " \(.name) run=\(.run_id // "?")"' <<<"$SUMMARY_JSON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$threads_total_n" -gt 0 ]]; then
|
||||||
|
printf '\nUNRESOLVED THREADS (%s total: %s visible, %s hidden):\n' \
|
||||||
|
"$threads_total_n" "$visible_n" "$hidden_n"
|
||||||
|
jq -r '(.unresolved_threads + .hidden_unresolved_threads)[]
|
||||||
|
| " \(.thread_id) c=\(.comment_id) \(.author) \(.path // "-")"' <<<"$SUMMARY_JSON"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$comments_n" -gt 0 ]] && printf '\nACTIONABLE ISSUE COMMENTS: %s\n' "$comments_n"
|
||||||
|
|
||||||
|
if [[ "$base_advanced" -eq 1 ]]; then
|
||||||
|
# code-review/SKILL.md: mergeable=MERGEABLE proves conflict compatibility, not
|
||||||
|
# that the merged result was tested. Green checks here ran against an older base.
|
||||||
|
printf '\nBASE ADVANCED since this head: checks did not run against the current base.\n'
|
||||||
|
printf ' Validate the merge result before treating this PR as ready.\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$changes_req_n" -gt 0 || "$blocked_rev_n" -gt 0 ]]; then
|
||||||
|
printf '\nBLOCKING REVIEWS: %s active changes requested, %s blocked at this head\n' \
|
||||||
|
"$changes_req_n" "$blocked_rev_n"
|
||||||
|
jq -r '.review_evidence.blocked_exact_current_head_reviews // [] | .[]
|
||||||
|
| " \(.author) \(.verdict // .state // "?")"' <<<"$SUMMARY_JSON" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$evidence_errors_n" -gt 0 || "$threads_known_final" == 'no' ]]; then
|
||||||
|
printf '\nINCOMPLETE EVIDENCE: %s\n' "${error_sources:-unresolved review thread count is null}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nNEXT: '
|
||||||
|
case "$OUTCOME" in
|
||||||
|
blocked-pr-state) printf 'PR is %s. Do not push a stale head.\n' "$pr_state" ;;
|
||||||
|
blocked-approval) printf 'workflow approval required; CI cannot complete unattended.\n' ;;
|
||||||
|
blocked-evidence)
|
||||||
|
printf 'checks finished but review evidence is incomplete (%s). Treat as unknown, not clean.\n' \
|
||||||
|
"${error_sources:-null unresolved thread count}" ;;
|
||||||
|
blocked-merge-unknown)
|
||||||
|
printf 'GitHub still reports mergeability as UNKNOWN. Re-run before treating this head as clean.\n' ;;
|
||||||
|
deadline)
|
||||||
|
if [[ "$(jq -r '.check_count // 0' <<<"$SUMMARY_JSON")" -eq 0 ]]; then
|
||||||
|
printf 'deadline hit with no checks registered yet at this head. Re-run or raise --deadline-min.\n'
|
||||||
|
else
|
||||||
|
printf 'deadline hit with %s check(s) pending. Re-run or raise --deadline-min.\n' "$pending_n"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ "$conflicted" -eq 1 ]]; then
|
||||||
|
printf 'resolve the merge conflict before triaging checks or threads.\n'
|
||||||
|
elif [[ "$findings" -eq 0 ]]; then
|
||||||
|
printf 'clean at this head.\n'
|
||||||
|
elif [[ "$failed_n" -eq 0 && "$threads_total_n" -eq 0 ]]; then
|
||||||
|
# The finding is entirely from a category the "N failed / M thread"
|
||||||
|
# phrasing below cannot name: an issue comment, a blocking review, or an
|
||||||
|
# advanced base. Saying "fix 0 failed check(s) and 0 thread(s)" here
|
||||||
|
# would tell the caller there is nothing to do while findings > 0.
|
||||||
|
printf 'review the findings reported above (comments, blocking reviews, or an advanced base), then push once.\n'
|
||||||
|
else
|
||||||
|
printf 'fix %s failed check(s) and %s thread(s) in one pass, then push once.\n' \
|
||||||
|
"$failed_n" "$threads_total_n"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit "$code"
|
||||||
Executable
+859
@@ -0,0 +1,859 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SCRIPT="$ROOT_DIR/scripts/pr-await"
|
||||||
|
|
||||||
|
pass() { printf 'ok - %s\n' "$1"; }
|
||||||
|
fail() {
|
||||||
|
printf 'not ok - %s\n' "$1" >&2
|
||||||
|
[[ $# -lt 2 ]] || printf '%s\n' "$2" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP_DIRS=()
|
||||||
|
cleanup() { [[ "${#TMP_DIRS[@]}" -eq 0 ]] || rm -rf "${TMP_DIRS[@]}"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
make_tmp_dir() {
|
||||||
|
local d
|
||||||
|
d="$(mktemp -d)"
|
||||||
|
TMP_DIRS+=("$d")
|
||||||
|
printf '%s' "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A snapshot generator. Each call to the fake pr-state pops the next file from
|
||||||
|
# a numbered sequence, so a test can describe CI as it evolves over time.
|
||||||
|
# The last snapshot repeats once the sequence is exhausted.
|
||||||
|
setup_fake() {
|
||||||
|
local dir=$1
|
||||||
|
mkdir -p "$dir/seq"
|
||||||
|
cat >"$dir/pr-state" <<'FAKE'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
n=$(cat "$FAKE_DIR/counter" 2>/dev/null || echo 0)
|
||||||
|
n=$((n + 1))
|
||||||
|
printf '%s' "$n" > "$FAKE_DIR/counter"
|
||||||
|
last=$(ls "$FAKE_DIR"/seq/*.json 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
[[ $n -le $last ]] || n=$last
|
||||||
|
if [[ -f "$FAKE_DIR/seq/$n.fail" ]]; then exit 1; fi
|
||||||
|
cat "$FAKE_DIR/seq/$n.json"
|
||||||
|
FAKE
|
||||||
|
cat >"$dir/gh" <<'FAKEGH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cat "$FAKE_DIR/gh.json" 2>/dev/null || printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"aaaaaaaaaaaa"}'
|
||||||
|
FAKEGH
|
||||||
|
cat >"$dir/jq-ok" <<'JQOK'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
[ "$1" = "--version" ] && { echo "jq-1.7.1-fake"; exit 0; }
|
||||||
|
exit 0
|
||||||
|
JQOK
|
||||||
|
cat >"$dir/jq-bad" <<'JQBAD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
[ "$1" = "--version" ] && { echo "jq-1.6-fake"; exit 0; }
|
||||||
|
exec sleep 30
|
||||||
|
JQBAD
|
||||||
|
cat >"$dir/bash-ok" <<'BASHOK'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "$2" in
|
||||||
|
*BASH_VERSION*) printf '5.9.9-fake' ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
BASHOK
|
||||||
|
cat >"$dir/bash-bad" <<'BASHBAD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "$2" in
|
||||||
|
*BASH_VERSION*) printf '3.2.57-fake' ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
BASHBAD
|
||||||
|
chmod +x "$dir/jq-ok" "$dir/jq-bad" "$dir/bash-ok" "$dir/bash-bad"
|
||||||
|
cat >"$dir/sleep" <<'FAKESLEEP'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'slept %s\n' "$1" >> "$FAKE_DIR/sleeps"
|
||||||
|
FAKESLEEP
|
||||||
|
chmod +x "$dir/pr-state" "$dir/gh" "$dir/sleep"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
# snapshot <dir> <index> <passed> <failed> <pending> [complete] [head] [extra-json]
|
||||||
|
local dir=$1 idx=$2 passed=$3 failed=$4 pending=$5
|
||||||
|
local complete=${6:-true} head=${7:-aaaaaaaaaaaa} extra=${8:-'{}'}
|
||||||
|
local failed_arr='[]' pending_arr='[]'
|
||||||
|
[[ "$failed" -eq 0 ]] || failed_arr="$(jq -nc --argjson n "$failed" '[range($n) | {name: "Failing \(.)", workflow: "w", status: "completed", conclusion: "failure", run_id: 100, job_id: 200}]')"
|
||||||
|
[[ "$pending" -eq 0 ]] || pending_arr="$(jq -nc --argjson n "$pending" '[range($n) | {name: "Pending \(.)", workflow: "w", status: "in_progress", run_id: 101, job_id: 201}]')"
|
||||||
|
jq -n --argjson f "$failed_arr" --argjson p "$pending_arr" --argjson passed "$passed" \
|
||||||
|
--argjson failed "$failed" --argjson pending "$pending" \
|
||||||
|
--argjson complete "$complete" --arg head "$head" --argjson extra "$extra" \
|
||||||
|
'{pr: {number: 1, head_ref_name: "feat", base_ref_name: "main", head_ref_oid: $head},
|
||||||
|
failed_checks: $f, pending_checks: $p, passed_check_count: $passed,
|
||||||
|
check_count: ($passed + $failed + $pending),
|
||||||
|
checks_head_sha: $head, checks_snapshot_complete: $complete,
|
||||||
|
approval_required_runs: [], unresolved_review_thread_count: 0,
|
||||||
|
unresolved_threads: [], hidden_unresolved_threads: [],
|
||||||
|
actionable_issue_comment_count: 0, errors: [],
|
||||||
|
review_evidence: {active_changes_requested_count: 0,
|
||||||
|
blocked_exact_current_head_review_count: 0}} * $extra' > "$dir/seq/$idx.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_await() {
|
||||||
|
local dir=$1; shift
|
||||||
|
FAKE_DIR="$dir" PR_AWAIT_PR_STATE="$dir/pr-state" PR_AWAIT_GH="$dir/gh" \
|
||||||
|
PR_AWAIT_SLEEP="$dir/sleep" \
|
||||||
|
PR_AWAIT_JQ="${PROBE_JQ:-$dir/jq-ok}" PR_AWAIT_PROBE_BASH="${PROBE_BASH:-$dir/bash-ok}" \
|
||||||
|
"$SCRIPT" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_json_envelope() {
|
||||||
|
local name=$1 json=$2
|
||||||
|
jq -e '
|
||||||
|
(keys | sort) == [
|
||||||
|
"deadline_sec", "evidence_complete", "exit_code", "head_changes",
|
||||||
|
"interval_sec", "merge_state_status", "mergeable", "message", "mode",
|
||||||
|
"outcome", "polls", "pr", "summary", "toolchain", "waited_sec"
|
||||||
|
]
|
||||||
|
and (.pr | type) == "number"
|
||||||
|
and (.message == null or (.message | type) == "string")
|
||||||
|
and (.summary == null or (.summary | type) == "object")
|
||||||
|
' <<<"$json" >/dev/null || fail "$name" "$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- argument validation ---------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
run_await "$d" >/dev/null 2>&1 && fail "missing PR should exit non-zero" || pass "missing PR argument is rejected"
|
||||||
|
run_await "$d" 12 --mode bogus >/dev/null 2>&1 && fail "bad --mode accepted" || pass "invalid --mode is rejected"
|
||||||
|
run_await "$d" notanumber >/dev/null 2>&1 && fail "non-numeric PR accepted" || pass "non-numeric PR is rejected"
|
||||||
|
run_await "$d" 0 >/dev/null 2>&1 && fail "PR 0 accepted" || pass "PR #0 is rejected as a usage error"
|
||||||
|
run_await "$d" 00 >/dev/null 2>&1 && fail "PR 00 accepted" || pass "PR #00 is rejected as a usage error"
|
||||||
|
run_await "$d" 12 --interval-sec 0 >/dev/null 2>&1 && fail "zero interval accepted" || pass "zero --interval-sec is rejected"
|
||||||
|
"$SCRIPT" --help >/dev/null 2>&1 && pass "--help exits 0" || fail "--help should exit 0"
|
||||||
|
|
||||||
|
# --- the documented race: a failure while the workflow is still running -----
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 1 5 # a job has failed, but 5 checks still pending
|
||||||
|
snapshot "$d" 2 12 1 3
|
||||||
|
snapshot "$d" 3 14 2 0 # terminal: a SECOND failure only appeared at the end
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "all-terminal with findings should exit 1, got $rc" "$out"
|
||||||
|
grep -q '2 failed' <<<"$out" || fail "should report BOTH failures, not just the first" "$out"
|
||||||
|
grep -q '0 pending' <<<"$out" || fail "should only return when nothing is pending" "$out"
|
||||||
|
pass "all-terminal waits past an early failure and reports every failure at once"
|
||||||
|
|
||||||
|
# --- first-failure returns early (and therefore reports less) --------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 1 5
|
||||||
|
snapshot "$d" 2 14 2 0
|
||||||
|
out="$(run_await "$d" 12 --mode first-failure --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "first-failure with a failure should exit 1, got $rc" "$out"
|
||||||
|
grep -q '1 failed' <<<"$out" || fail "first-failure should return on the first failure" "$out"
|
||||||
|
grep -q '5 pending' <<<"$out" || fail "first-failure returns with checks still pending" "$out"
|
||||||
|
pass "first-failure returns on the first failing check"
|
||||||
|
|
||||||
|
# --- first-failure requires complete evidence before returning --------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
snapshot "$d" "$i" 10 1 2 false aaaaaaaaaaaa \
|
||||||
|
'{"errors":[{"source":"review_threads","message":"graphql failed"}]}'
|
||||||
|
done
|
||||||
|
out="$(run_await "$d" 12 --mode first-failure --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "incomplete first-failure evidence should block, got $rc" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-evidence' ]] \
|
||||||
|
|| fail "incomplete first-failure evidence should report blocked-evidence" "$out"
|
||||||
|
assert_json_envelope "blocked-evidence must use the JSON envelope" "$out"
|
||||||
|
pass "first-failure waits for complete evidence before returning a failure"
|
||||||
|
|
||||||
|
# --- clean terminal state ---------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "clean state should exit 0, got $rc" "$out"
|
||||||
|
grep -q 'clean at this head' <<<"$out" || fail "clean state should say so" "$out"
|
||||||
|
pass "clean terminal state exits 0"
|
||||||
|
|
||||||
|
# --- an incomplete snapshot is not terminal --------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 false
|
||||||
|
snapshot "$d" 2 20 0 0 true
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 2>"$d/err")" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "expected 0 after snapshot completed, got $rc" "$out"
|
||||||
|
grep -q '2 poll' <<<"$out" || fail "should have polled twice, not stopped on the incomplete snapshot" "$out"
|
||||||
|
pass "checks_snapshot_complete=false keeps waiting"
|
||||||
|
|
||||||
|
# --- deadline ---------------------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 4
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 2 ]] || fail "deadline should exit 2, got $rc" "$out"
|
||||||
|
grep -q 'STILL PENDING' <<<"$out" || fail "deadline should name pending checks" "$out"
|
||||||
|
grep -q 'Pending 0' <<<"$out" || fail "deadline should list check names" "$out"
|
||||||
|
pass "deadline exits 2 and names the pending checks"
|
||||||
|
|
||||||
|
# --- approval-required is blocked, not green --------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 0 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"approval_required_runs":[{"run_id":9,"name":"E2E","conclusion":"action_required"}]}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "approval_required should exit 3, got $rc" "$out"
|
||||||
|
grep -q 'approval required' <<<"$out" || fail "should explain the block" "$out"
|
||||||
|
pass "approval_required_runs exits 3 instead of reading as clean"
|
||||||
|
|
||||||
|
# --- merged/closed PR -------------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 2
|
||||||
|
printf '{"state":"MERGED","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "merged PR should exit 3, got $rc" "$out"
|
||||||
|
grep -q 'MERGED' <<<"$out" || fail "should report the merged state" "$out"
|
||||||
|
pass "a merged PR exits 3 rather than waiting forever"
|
||||||
|
|
||||||
|
# --- merge conflict ---------------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
printf '{"state":"OPEN","mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "conflict should exit 1, got $rc" "$out"
|
||||||
|
grep -q 'resolve the merge conflict' <<<"$out" || fail "conflict should lead the NEXT line" "$out"
|
||||||
|
pass "a merge conflict is surfaced even when every check passed"
|
||||||
|
|
||||||
|
# --- transient pr-state failures recover; sustained ones block --------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
: > "$d/seq/1.fail"; snapshot "$d" 1 0 0 0
|
||||||
|
snapshot "$d" 2 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "one transient failure should be retried, got $rc" "$out"
|
||||||
|
pass "a transient pr-state failure is retried"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "three consecutive failures should exit 3, got $rc" "$out"
|
||||||
|
grep -q 'unknown' <<<"$out" || fail "should say CI state is unknown" "$out"
|
||||||
|
pass "three consecutive pr-state failures exit 3 as unknown, never as clean"
|
||||||
|
|
||||||
|
# --- a push during the wait restarts the gate ------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 3 true aaaaaaaaaaaa
|
||||||
|
snapshot "$d" 2 2 0 8 true bbbbbbbbbbbb
|
||||||
|
snapshot "$d" 3 20 0 0 true bbbbbbbbbbbb
|
||||||
|
cat > "$d/gh" <<'GH2'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
n=$(cat "$FAKE_DIR/counter" 2>/dev/null || echo 1)
|
||||||
|
if [[ $n -le 1 ]]; then oid=aaaaaaaaaaaa; else oid=bbbbbbbbbbbb; fi
|
||||||
|
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"%s"}' "$oid"
|
||||||
|
GH2
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "expected clean after the new head settled, got $rc" "$out"
|
||||||
|
grep -q 'head moved 1 time' <<<"$out" || fail "should report the head change" "$out"
|
||||||
|
grep -q 'bbbbbbbbb' <<<"$out" || fail "should report the NEW head, not the stale one" "$out"
|
||||||
|
pass "a push during the wait restarts the gate and reports the new head"
|
||||||
|
|
||||||
|
# --- stdout carries only the report ----------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 2
|
||||||
|
snapshot "$d" 2 12 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 2>/dev/null)"
|
||||||
|
grep -q '^poll ' <<<"$out" && fail "progress leaked into stdout" "$out"
|
||||||
|
err="$(run_await "$d" 12 --interval-sec 1 2>&1 >/dev/null)"
|
||||||
|
grep -q '^poll ' <<<"$err" || fail "progress should go to stderr" "$err"
|
||||||
|
pass "progress goes to stderr; stdout carries only the final report"
|
||||||
|
|
||||||
|
# --- --quiet silences progress ---------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 0
|
||||||
|
err="$(run_await "$d" 12 --interval-sec 1 --quiet 2>&1 >/dev/null)"
|
||||||
|
[[ -z "$err" ]] || fail "--quiet should emit nothing on stderr" "$err"
|
||||||
|
pass "--quiet silences per-poll progress"
|
||||||
|
|
||||||
|
# --- json format -----------------------------------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 14 2 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "json mode should keep the exit code, got $rc" "$out"
|
||||||
|
jq -e . >/dev/null <<<"$out" || fail "json mode must emit valid JSON" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'terminal' ]] || fail "outcome missing" "$out"
|
||||||
|
[[ "$(jq -r '.summary.failed_checks | length' <<<"$out")" == '2' ]] || fail "summary should be embedded whole" "$out"
|
||||||
|
assert_json_envelope "terminal JSON must use the shared envelope" "$out"
|
||||||
|
[[ "$(jq -r '.pr' <<<"$out")" == '12' ]] || fail "terminal JSON must include the PR number" "$out"
|
||||||
|
[[ "$(jq -r '.message' <<<"$out")" == 'null' ]] || fail "terminal JSON message must be null" "$out"
|
||||||
|
pass "--format json embeds the full pr-state summary and keeps the exit code"
|
||||||
|
|
||||||
|
# --- the cost claim: one invocation, not one per poll ----------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4 5 6 7; do snapshot "$d" "$i" "$i" 0 $((8 - i)); done
|
||||||
|
snapshot "$d" 8 20 0 0
|
||||||
|
run_await "$d" 12 --interval-sec 1 --quiet >/dev/null 2>&1 || true
|
||||||
|
[[ "$(wc -l < "$d/sleeps" | tr -d ' ')" -eq 7 ]] || fail "expected 7 internal sleeps" "$(cat "$d/sleeps")"
|
||||||
|
pass "eight polls happen inside a single invocation (7 internal sleeps, 0 caller round-trips)"
|
||||||
|
|
||||||
|
# --- P1: a snapshot carrying errors is never clean ---------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4; do
|
||||||
|
snapshot "$d" "$i" 20 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"errors":[{"source":"review_threads","message":"graphql failed"}]}'
|
||||||
|
done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "errors in snapshot must not exit 0, got $rc" "$out"
|
||||||
|
grep -q 'review_threads' <<<"$out" || fail "should name the failing source" "$out"
|
||||||
|
pass "a pr-state snapshot with non-empty errors exits 3, never clean"
|
||||||
|
|
||||||
|
# --- P1: a transient error that clears is fine ------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa '{"errors":[{"source":"x","message":"blip"}]}'
|
||||||
|
snapshot "$d" 2 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "a cleared transient error should exit 0, got $rc" "$out"
|
||||||
|
pass "an error that clears on a later poll does not block"
|
||||||
|
|
||||||
|
# --- P1: an unknown thread count is unknown, not zero -----------------------
|
||||||
|
# This is the observed bash-3.2 pr-state failure: pagination dies non-fatally
|
||||||
|
# and the count comes back null while GitHub has unresolved threads.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4; do
|
||||||
|
snapshot "$d" "$i" 20 0 0 true aaaaaaaaaaaa '{"unresolved_review_thread_count":null}'
|
||||||
|
done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "null thread count must not read as 0/clean, got $rc" "$out"
|
||||||
|
pass "a null unresolved_review_thread_count blocks instead of counting as zero"
|
||||||
|
|
||||||
|
# --- P1: blocking reviews count as findings ---------------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"review_evidence":{"active_changes_requested_count":1,"blocked_exact_current_head_review_count":0}}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "CHANGES_REQUESTED with no threads should exit 1, got $rc" "$out"
|
||||||
|
grep -qi 'changes requested' <<<"$out" || fail "should report the blocking review" "$out"
|
||||||
|
pass "an active CHANGES_REQUESTED review is a finding even with zero threads"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"review_evidence":{"active_changes_requested_count":0,"blocked_exact_current_head_review_count":2}}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "blocked exact-head reviews should exit 1, got $rc" "$out"
|
||||||
|
pass "blocked exact-current-head reviews count as findings"
|
||||||
|
|
||||||
|
# --- P1: a failed merge-state query is unknown, not clean -------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/gh" <<'GHFAIL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 1
|
||||||
|
GHFAIL
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "failed gh pr view must not exit 0, got $rc" "$out"
|
||||||
|
grep -qi 'merge state' <<<"$out" || fail "should say merge state is unknown" "$out"
|
||||||
|
pass "a failed merge-state query exits 3 instead of reporting clean"
|
||||||
|
|
||||||
|
# --- P2: first-failure must not act on a stale head -------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
# snapshot 1 describes the OLD head; gh already reports the NEW one.
|
||||||
|
snapshot "$d" 1 10 1 5 true aaaaaaaaaaaa
|
||||||
|
snapshot "$d" 2 20 0 0 true bbbbbbbbbbbb
|
||||||
|
cat > "$d/gh" <<'GH3'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"bbbbbbbbbbbb"}'
|
||||||
|
GH3
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --mode first-failure --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "stale first-failure snapshot must be discarded, got $rc" "$out"
|
||||||
|
grep -q '0 failed' <<<"$out" || fail "should report the NEW head, not the stale failure" "$out"
|
||||||
|
pass "first-failure discards a snapshot whose checks belong to a superseded head"
|
||||||
|
|
||||||
|
# --- P2: leading-zero arguments are decimal, not octal ---------------------
|
||||||
|
# Assert the parsed values rather than timing the wait: "010" as octal is 8,
|
||||||
|
# which a duration test would only catch by waiting out the difference.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 09 --quiet --format json 2>"$d/err")" || true
|
||||||
|
grep -q 'value too great for base' "$d/err" && fail "09 must not be read as octal" "$(cat "$d/err")"
|
||||||
|
[[ "$(jq -r '.deadline_sec' <<<"$out")" == '540' ]] \
|
||||||
|
|| fail "--deadline-min 09 should be 540s, got $(jq -r '.deadline_sec' <<<"$out")" "$out"
|
||||||
|
pass "--deadline-min 09 parses as 9 minutes, not an octal error"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 010 --deadline-min 010 --quiet --format json 2>"$d/err")" || true
|
||||||
|
[[ "$(jq -r '.interval_sec' <<<"$out")" == '10' ]] \
|
||||||
|
|| fail "--interval-sec 010 should be 10, got $(jq -r '.interval_sec' <<<"$out")" "$out"
|
||||||
|
[[ "$(jq -r '.deadline_sec' <<<"$out")" == '600' ]] \
|
||||||
|
|| fail "--deadline-min 010 should be 600s (octal would be 480), got $(jq -r '.deadline_sec' <<<"$out")" "$out"
|
||||||
|
pass "--interval-sec 010 and --deadline-min 010 parse as decimal 10, not octal 8"
|
||||||
|
|
||||||
|
|
||||||
|
# --- toolchain preflight: pr-state degrades silently, so refuse to run ------
|
||||||
|
# An old toolchain is recorded, not refused. pr-state works on jq 1.6 and bash
|
||||||
|
# 3.2 now, so a version gate would only turn away working installations; the
|
||||||
|
# snapshot-quality guards below cover a degraded pr-state whatever its cause.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(PROBE_JQ="$d/jq-bad" run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "an old jq must not block a healthy run, got $rc" "$out"
|
||||||
|
grep -q 'jq-1.6-fake' <<<"$out" || fail "the old jq should still be recorded" "$out"
|
||||||
|
pass "an old jq is recorded in the report rather than refused"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(PROBE_BASH="$d/bash-bad" run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "an old bash must not block a healthy run, got $rc" "$out"
|
||||||
|
grep -q '3.2.57-fake' <<<"$out" || fail "the old bash should still be recorded" "$out"
|
||||||
|
pass "an old bash is recorded in the report rather than refused"
|
||||||
|
|
||||||
|
# But a reader whose pr-state produced nothing usable needs to know the
|
||||||
|
# toolchain is a known cause of exactly that.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(PROBE_JQ="$d/jq-bad" PROBE_BASH="$d/bash-bad" run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "expected blocked, got $rc" "$out"
|
||||||
|
grep -qi 'Toolchain note' <<<"$out" || fail "a blocked report should name the toolchain risk" "$out"
|
||||||
|
grep -qi 'empty-matching gsub' <<<"$out" || fail "should explain the jq risk" "$out"
|
||||||
|
grep -qi 'empty array under set -u' <<<"$out" || fail "should explain the bash risk" "$out"
|
||||||
|
pass "a blocked report explains a known-bad toolchain as a possible cause"
|
||||||
|
|
||||||
|
# A current toolchain gets no note, so the hint stays signal.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "expected blocked, got $rc" "$out"
|
||||||
|
grep -qi 'Toolchain note' <<<"$out" && fail "a current toolchain must not emit a note" "$out"
|
||||||
|
pass "a current toolchain produces no toolchain note"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "a good toolchain should not block, got $rc" "$out"
|
||||||
|
grep -q 'toolchain: jq-1.7.1-fake, bash 5.9.9-fake' <<<"$out" \
|
||||||
|
|| fail "the report must record the verified toolchain" "$out"
|
||||||
|
pass "a verified toolchain is recorded in every report"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet --format json 2>/dev/null)"
|
||||||
|
[[ "$(jq -r '.toolchain' <<<"$out")" == 'jq-1.7.1-fake, bash 5.9.9-fake' ]] \
|
||||||
|
|| fail "json report must carry the toolchain" "$out"
|
||||||
|
[[ "$(jq -r '.evidence_complete' <<<"$out")" == 'true' ]] || fail "evidence_complete missing" "$out"
|
||||||
|
pass "json report carries toolchain and evidence_complete"
|
||||||
|
|
||||||
|
|
||||||
|
# --- mergeable UNKNOWN is inconclusive, not clean ---------------------------
|
||||||
|
# GitHub returns UNKNOWN while it computes mergeability; merge-conflicts.md
|
||||||
|
# says query again rather than deciding.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4; do snapshot "$d" "$i" 20 0 0; done
|
||||||
|
printf '{"state":"OPEN","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "persistent mergeable=UNKNOWN must not exit 0, got $rc" "$out"
|
||||||
|
grep -qi 'mergeability' <<<"$out" || fail "should explain the unknown mergeability" "$out"
|
||||||
|
pass "a persistently UNKNOWN mergeability exits 3 instead of clean"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0; snapshot "$d" 2 20 0 0
|
||||||
|
cat > "$d/gh" <<'GHU'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
n=$(cat "$FAKE_DIR/counter" 2>/dev/null || echo 1)
|
||||||
|
if [[ $n -le 1 ]]; then m=UNKNOWN; else m=MERGEABLE; fi
|
||||||
|
printf '{"state":"OPEN","mergeable":"%s","mergeStateStatus":"CLEAN","headRefOid":"aaaaaaaaaaaa"}' "$m"
|
||||||
|
GHU
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "UNKNOWN that resolves should exit 0, got $rc" "$out"
|
||||||
|
pass "an UNKNOWN mergeability that resolves does not block"
|
||||||
|
|
||||||
|
# --- the deadline is honored while retrying, not only while polling ---------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4 5; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 30 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
: > "$d/sleeps.seen"; [[ -f "$d/sleeps" ]] || : > "$d/sleeps"
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "expected blocked, got $rc" "$out"
|
||||||
|
sleeps=$(wc -l < "$d/sleeps" 2>/dev/null | tr -d ' ' || echo 0)
|
||||||
|
[[ "${sleeps:-0}" -le 1 ]] || fail "a zero deadline must not burn 3 retry intervals, slept $sleeps times"
|
||||||
|
pass "a retry path stops at the deadline instead of sleeping out its full strike count"
|
||||||
|
|
||||||
|
# --- the jq probe works without timeout(1) on PATH --------------------------
|
||||||
|
# Stock macOS ships no /usr/bin/timeout, so a probe that depends on it would
|
||||||
|
# silently skip on the exact hosts that carry the broken jq.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/pr-state" <<'HANGNT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec sleep 120
|
||||||
|
HANGNT
|
||||||
|
chmod +x "$d/pr-state"
|
||||||
|
start=$SECONDS
|
||||||
|
out="$(PR_AWAIT_NO_TIMEOUT=1 PR_AWAIT_READ_FLOOR=2 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "a stalled read must be bounded without timeout(1), got $rc" "$out"
|
||||||
|
[[ $((SECONDS - start)) -lt 12 ]] || fail "the shell fallback did not bound the read"
|
||||||
|
pass "a stalled read is bounded without timeout(1) available"
|
||||||
|
|
||||||
|
# The fallback must also complete a normal run, not merely catch a hang.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(PR_AWAIT_NO_TIMEOUT=1 run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "the shell fallback must handle a healthy read, got $rc" "$out"
|
||||||
|
pass "the shell fallback completes a normal run without timeout(1)"
|
||||||
|
|
||||||
|
# --- the suite is wired into make test-scripts ------------------------------
|
||||||
|
grep -q 'bash scripts/pr-await.test.sh' "$ROOT_DIR/Makefile" \
|
||||||
|
|| fail "pr-await.test.sh must be registered in make test-scripts"
|
||||||
|
pass "the suite runs under make test-scripts"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the deadline is absolute, not restarted by every push -----------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4 5 6; do snapshot "$d" "$i" 2 0 5 true "head$i"; done
|
||||||
|
cat > "$d/gh" <<'GHMOVE'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
n=$(cat "$FAKE_DIR/counter" 2>/dev/null || echo 1)
|
||||||
|
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"head%s"}' "$n"
|
||||||
|
GHMOVE
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 2 ]] || fail "a repeatedly-moving head must still hit the deadline, got $rc" "$out"
|
||||||
|
pass "a repeatedly-moving head still reaches the deadline"
|
||||||
|
|
||||||
|
# The behavioural test above cannot separate "clock reset" from "clock kept"
|
||||||
|
# without waiting out a real deadline, so assert the invariant directly: the
|
||||||
|
# deadline is absolute for the invocation, and a push discards stale evidence,
|
||||||
|
# never the caller's time limit.
|
||||||
|
[[ "$(grep -c 'START_SEC=\$SECONDS' "$ROOT_DIR/scripts/pr-await")" -eq 1 ]] \
|
||||||
|
|| fail "the deadline clock must be assigned once, not reset on head change"
|
||||||
|
pass "the deadline clock is assigned exactly once (a push cannot defer the hard stop)"
|
||||||
|
|
||||||
|
# --- blocked outcomes still honor --format json ----------------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "expected 3, got $rc" "$out"
|
||||||
|
jq -e . >/dev/null <<<"$out" || fail "blocked-access must still emit JSON" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-access' ]] || fail "outcome missing" "$out"
|
||||||
|
[[ "$(jq -r '.exit_code' <<<"$out")" == '3' ]] || fail "exit_code missing" "$out"
|
||||||
|
assert_json_envelope "blocked-access JSON must use the shared envelope" "$out"
|
||||||
|
pass "a blocked-access result is still valid JSON under --format json"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
out="$(PROBE_JQ="$d/jq-bad" run_await "$d" 12 --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "an old jq must not block the JSON path, got $rc" "$out"
|
||||||
|
jq -e . >/dev/null <<<"$out" || fail "must emit valid JSON" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'terminal' ]] || fail "outcome should be terminal" "$out"
|
||||||
|
grep -q 'jq-1.6-fake' <<<"$(jq -r '.toolchain' <<<"$out")" || fail "toolchain should record the jq" "$out"
|
||||||
|
pass "an old jq is recorded in the JSON toolchain field, not treated as blocked"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/gh" <<'GHF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 1
|
||||||
|
GHF
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
jq -e . >/dev/null <<<"$out" || fail "merge-state failure must emit JSON" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-merge-state' ]] || fail "outcome missing" "$out"
|
||||||
|
assert_json_envelope "blocked-merge-state JSON must use the shared envelope" "$out"
|
||||||
|
pass "a blocked merge-state result is still valid JSON under --format json"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"approval_required_runs":[{"run_id":9,"name":"E2E","conclusion":"action_required"}]}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "blocked-approval JSON should exit 3, got $rc" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-approval' ]] || fail "blocked-approval outcome missing" "$out"
|
||||||
|
assert_json_envelope "blocked-approval JSON must use the shared envelope" "$out"
|
||||||
|
pass "blocked-approval produces the shared JSON envelope"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
printf '{"state":"MERGED","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "blocked-pr-state JSON should exit 3, got $rc" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-pr-state' ]] || fail "blocked-pr-state outcome missing" "$out"
|
||||||
|
assert_json_envelope "blocked-pr-state JSON must use the shared envelope" "$out"
|
||||||
|
pass "blocked-pr-state produces the shared JSON envelope"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do snapshot "$d" "$i" 20 0 0; done
|
||||||
|
printf '{"state":"OPEN","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","headRefOid":"aaaaaaaaaaaa"}' > "$d/gh.json"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "blocked-merge-unknown JSON should exit 3, got $rc" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'blocked-merge-unknown' ]] || fail "blocked-merge-unknown outcome missing" "$out"
|
||||||
|
assert_json_envelope "blocked-merge-unknown JSON must use the shared envelope" "$out"
|
||||||
|
pass "blocked-merge-unknown produces the shared JSON envelope"
|
||||||
|
|
||||||
|
|
||||||
|
# --- hidden threads are a subset of the total, not an addition -------------
|
||||||
|
# pr-state:922 counts every unresolved thread; hidden_unresolved_threads is the
|
||||||
|
# subset filtered out of unresolved_threads. Summing both double-counts.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa \
|
||||||
|
'{"unresolved_review_thread_count":4,"unresolved_threads":[],
|
||||||
|
"hidden_unresolved_threads":[{"thread_id":"t1","comment_id":1,"author":"bot","path":"a.go"},
|
||||||
|
{"thread_id":"t2","comment_id":2,"author":"bot","path":"b.go"},
|
||||||
|
{"thread_id":"t3","comment_id":3,"author":"bot","path":"c.go"},
|
||||||
|
{"thread_id":"t4","comment_id":4,"author":"bot","path":"d.go"}]}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "expected findings, got $rc" "$out"
|
||||||
|
grep -q 'fix 0 failed check(s) and 4 thread(s)' <<<"$out" \
|
||||||
|
|| fail "4 unresolved threads must count as 4, not 8" "$out"
|
||||||
|
grep -q '4 total: 0 visible, 4 hidden' <<<"$out" \
|
||||||
|
|| fail "the header must not label the total as visible" "$out"
|
||||||
|
pass "hidden threads are counted once, and the total is not labelled visible"
|
||||||
|
|
||||||
|
# --- an empty check rollup is not terminal ---------------------------------
|
||||||
|
# Right after a push GitHub can expose the new head before Actions registers
|
||||||
|
# any checks. Zero of everything is "not yet", not "clean".
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do snapshot "$d" "$i" 0 0 0; done
|
||||||
|
snapshot "$d" 4 20 0 0
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "expected clean once checks appeared, got $rc" "$out"
|
||||||
|
grep -q '4 poll' <<<"$out" || fail "must wait for checks to appear, not stop at the empty rollup" "$out"
|
||||||
|
pass "an empty check rollup keeps waiting instead of reporting clean"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3 4 5; do snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "a permanently empty rollup must never exit 0" "$out"
|
||||||
|
pass "a rollup that never populates does not become clean at the deadline"
|
||||||
|
grep -qi 'no checks registered yet' <<<"$out" \
|
||||||
|
|| fail "a zero-check deadline must say no checks registered, not '0 check(s) pending'" "$out"
|
||||||
|
grep -q '0 check(s) pending' <<<"$out" \
|
||||||
|
&& fail "a zero-check deadline must not be phrased as pending checks" "$out"
|
||||||
|
pass "a zero-check deadline is distinguished from a deadline with pending checks"
|
||||||
|
|
||||||
|
# Skipped and neutral checks are terminal evidence even though pr-state does
|
||||||
|
# not include them in passed_check_count. The separate check_count prevents the
|
||||||
|
# await loop from treating such a rollup as if Actions had not registered it.
|
||||||
|
for conclusion in skipped neutral; do
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 0 0 0 true aaaaaaaaaaaa "{\"check_count\":1,\"terminal_conclusions\":[\"$conclusion\"]}"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "$conclusion-only check rollup should be terminal, got $rc" "$out"
|
||||||
|
[[ "$(jq -r '.outcome' <<<"$out")" == 'terminal' ]] || fail "$conclusion-only rollup should emit terminal" "$out"
|
||||||
|
done
|
||||||
|
pass "skipped and neutral terminal checks do not look like an empty rollup"
|
||||||
|
|
||||||
|
# --- a base that advanced leaves the merge result untested -----------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa '{"pr":{"base_advanced_since_head":true}}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "base_advanced_since_head must prevent a clean verdict, got $rc" "$out"
|
||||||
|
grep -qi 'base advanced' <<<"$out" || fail "should report the advanced base" "$out"
|
||||||
|
pass "an advanced base is reported and prevents a clean verdict"
|
||||||
|
|
||||||
|
# --- approval-required must belong to the current head ---------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 10 0 2 true aaaaaaaaaaaa \
|
||||||
|
'{"approval_required_runs":[{"run_id":9,"name":"E2E","conclusion":"action_required"}]}'
|
||||||
|
snapshot "$d" 2 20 0 0 true bbbbbbbbbbbb
|
||||||
|
cat > "$d/gh" <<'GHAP'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"bbbbbbbbbbbb"}'
|
||||||
|
GHAP
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "approval for a superseded head must be discarded, got $rc" "$out"
|
||||||
|
pass "an approval-required run from a superseded head is not actionable"
|
||||||
|
|
||||||
|
# --- a leading-zero PR number survives the JSON path -----------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
for i in 1 2 3; do : > "$d/seq/$i.fail"; snapshot "$d" "$i" 0 0 0; done
|
||||||
|
out="$(run_await "$d" 0012 --interval-sec 1 --quiet --format json 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
jq -e . >/dev/null <<<"$out" || fail "leading-zero PR must not break the JSON envelope" "$out"
|
||||||
|
[[ "$(jq -r '.pr' <<<"$out")" == '12' ]] || fail "PR should normalize to 12" "$out"
|
||||||
|
pass "a leading-zero PR number normalizes and keeps the JSON envelope valid"
|
||||||
|
|
||||||
|
|
||||||
|
# The bounded reads are always captured with $(...). A watchdog that keeps that
|
||||||
|
# pipe open blocks the substitution long after the command itself has finished,
|
||||||
|
# so a fast run must stay fast when captured, not only when redirected.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
start=$SECONDS
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 30 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
elapsed=$((SECONDS - start))
|
||||||
|
[[ "$rc" -eq 0 ]] || fail "expected clean, got $rc" "$out"
|
||||||
|
[[ "$elapsed" -lt 10 ]] || fail "a captured fast run must not wait on the watchdog, took ${elapsed}s"
|
||||||
|
pass "a bounded read does not hold the command substitution open after it finishes"
|
||||||
|
|
||||||
|
# --- a stalled GitHub read cannot outlive the deadline ---------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/pr-state" <<'HANG'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec sleep 120
|
||||||
|
HANG
|
||||||
|
chmod +x "$d/pr-state"
|
||||||
|
start=$SECONDS
|
||||||
|
out="$(PR_AWAIT_READ_FLOOR=2 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
elapsed=$((SECONDS - start))
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "a stalled pr-state must never exit 0, got $rc" "$out"
|
||||||
|
[[ "$elapsed" -lt 12 ]] || fail "a zero deadline must not wait out a 120s read, took ${elapsed}s"
|
||||||
|
pass "a stalled pr-state read cannot exceed the deadline"
|
||||||
|
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/gh" <<'HANGGH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec sleep 120
|
||||||
|
HANGGH
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
start=$SECONDS
|
||||||
|
out="$(PR_AWAIT_READ_FLOOR=2 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
elapsed=$((SECONDS - start))
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "a stalled gh pr view must never exit 0, got $rc" "$out"
|
||||||
|
[[ "$elapsed" -lt 12 ]] || fail "a zero deadline must not wait out a 120s gh read, took ${elapsed}s"
|
||||||
|
pass "a stalled gh pr view read cannot exceed the deadline"
|
||||||
|
grep -qi 'exceeded its read bound' <<<"$out" \
|
||||||
|
|| fail "a timed-out gh pr view must say so, not report a bare failure count" "$out"
|
||||||
|
grep -q 'failed 0 times' <<<"$out" \
|
||||||
|
&& fail "a pure gh pr view timeout must not be reported as 'failed 0 times in a row'" "$out"
|
||||||
|
pass "a timed-out gh pr view is reported as a timeout, not a miscounted failure"
|
||||||
|
|
||||||
|
# Structural guard: both external reads must go through the bounded helper, or
|
||||||
|
# the deadline is only enforced between reads and not during them.
|
||||||
|
grep -q 'run_bounded_capture "\$budget" "\$READ_TMP" "\$PR_STATE"' "$ROOT_DIR/scripts/pr-await" \
|
||||||
|
|| fail "the pr-state read must run through run_bounded_capture"
|
||||||
|
grep -A2 'run_bounded_capture "\$budget" "\$READ_TMP"' "$ROOT_DIR/scripts/pr-await" \
|
||||||
|
| grep -q '"\$GH" pr view' \
|
||||||
|
|| fail "the gh pr view read must run through run_bounded_capture"
|
||||||
|
pass "every external GitHub read is bounded by the remaining deadline"
|
||||||
|
|
||||||
|
# Structural guard: a failed read must be validated in a local before it can
|
||||||
|
# replace SUMMARY_JSON/MERGE_JSON. Otherwise a transient failure right before
|
||||||
|
# the deadline destroys evidence a working poll already produced, and the
|
||||||
|
# deadline-clock check ("if -n SUMMARY_JSON: OUTCOME=deadline") wrongly trusts
|
||||||
|
# garbage instead of falling back to the last good snapshot.
|
||||||
|
grep -q 'summary_body="\$(cat "\$READ_TMP")"' "$ROOT_DIR/scripts/pr-await" \
|
||||||
|
|| fail "the pr-state read must stage into a local before committing to SUMMARY_JSON"
|
||||||
|
grep -q 'merge_body="\$(cat "\$READ_TMP")"' "$ROOT_DIR/scripts/pr-await" \
|
||||||
|
|| fail "the gh pr view read must stage into a local before committing to MERGE_JSON"
|
||||||
|
pass "a failed GitHub read is staged before it can clobber the last good snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- a head change voids the snapshot in hand ------------------------------
|
||||||
|
# Otherwise a deadline reached before the next read renders the old head's
|
||||||
|
# checks under the new SHA.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 3 2 7 true aaaaaaaaaaaa
|
||||||
|
snapshot "$d" 2 3 2 7 true aaaaaaaaaaaa
|
||||||
|
cat > "$d/gh" <<'GHMOVED'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '{"state":"OPEN","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefOid":"bbbbbbbbbbbb"}'
|
||||||
|
GHMOVED
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 3 ]] || fail "a deadline with only stale evidence must block, got $rc" "$out"
|
||||||
|
grep -q 'Failing' <<<"$out" && fail "must not report the old head's checks under the new SHA" "$out"
|
||||||
|
pass "a head change voids the snapshot, so a deadline cannot report stale checks"
|
||||||
|
|
||||||
|
# --- a timed-out read takes its children with it ---------------------------
|
||||||
|
# On timeout-less hosts, killing only pr-state's shell leaves its gh child
|
||||||
|
# running and still issuing requests.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/pr-state" <<'SPAWN'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
sleep 300 &
|
||||||
|
printf '%s' "$!" > "$FAKE_DIR/childpid"
|
||||||
|
wait
|
||||||
|
SPAWN
|
||||||
|
chmod +x "$d/pr-state"
|
||||||
|
PR_AWAIT_NO_TIMEOUT=1 PR_AWAIT_READ_FLOOR=2 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet >/dev/null 2>&1 || true
|
||||||
|
child="$(cat "$d/childpid" 2>/dev/null || echo '')"
|
||||||
|
[[ -n "$child" ]] || fail "the fake did not record its child pid"
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$child" 2>/dev/null; then
|
||||||
|
kill -9 "$child" 2>/dev/null || true
|
||||||
|
fail "a timed-out read left its child process running (pid $child)"
|
||||||
|
fi
|
||||||
|
pass "a timed-out read terminates the whole process group, not just the shell"
|
||||||
|
|
||||||
|
# --- cancellation stops an active bounded read -----------------------------
|
||||||
|
# A caller that cancels the waiter must not leave a child GitHub read alive.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/pr-state" <<'CANCEL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
sleep 120 &
|
||||||
|
printf '%s' "$!" > "$FAKE_DIR/childpid"
|
||||||
|
wait "$!"
|
||||||
|
CANCEL
|
||||||
|
chmod +x "$d/pr-state"
|
||||||
|
FAKE_DIR="$d" PR_AWAIT_PR_STATE="$d/pr-state" PR_AWAIT_GH="$d/gh" \
|
||||||
|
PR_AWAIT_SLEEP="$d/sleep" PR_AWAIT_JQ="$d/jq-ok" PR_AWAIT_PROBE_BASH="$d/bash-ok" \
|
||||||
|
PR_AWAIT_NO_TIMEOUT=1 PR_AWAIT_READ_FLOOR=30 "$SCRIPT" 12 --interval-sec 1 --deadline-min 30 --quiet >"$d/out" 2>"$d/err" &
|
||||||
|
await_pid=$!
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
[[ -s "$d/childpid" ]] && break
|
||||||
|
/bin/sleep 1
|
||||||
|
done
|
||||||
|
[[ -s "$d/childpid" ]] || { kill -KILL "$await_pid" 2>/dev/null || true; fail "the fake did not start a bounded child read"; }
|
||||||
|
child="$(<"$d/childpid")"
|
||||||
|
kill -TERM "$await_pid"
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
kill -0 "$await_pid" 2>/dev/null || break
|
||||||
|
/bin/sleep 1
|
||||||
|
done
|
||||||
|
if kill -0 "$await_pid" 2>/dev/null; then
|
||||||
|
kill -KILL "$await_pid" 2>/dev/null || true
|
||||||
|
kill -KILL "$child" 2>/dev/null || true
|
||||||
|
wait "$await_pid" 2>/dev/null || true
|
||||||
|
fail "SIGTERM must stop an active bounded read"
|
||||||
|
fi
|
||||||
|
wait "$await_pid" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 143 ]] || fail "SIGTERM should return 143, got $rc" "$(<"$d/err")"
|
||||||
|
if kill -0 "$child" 2>/dev/null; then
|
||||||
|
kill -KILL "$child" 2>/dev/null || true
|
||||||
|
fail "SIGTERM left the bounded child read running (pid $child)"
|
||||||
|
fi
|
||||||
|
pass "SIGTERM stops the waiter and its active bounded read"
|
||||||
|
|
||||||
|
|
||||||
|
# --- a spent deadline grants one read floor, not one per query -------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
# pr-state SUCCEEDS but eats most of the floor; gh then stalls. A shared budget
|
||||||
|
# leaves gh the remainder; a fresh grant hands it a whole second floor.
|
||||||
|
cat > "$d/pr-state" <<'SLOWPS'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
sleep 3
|
||||||
|
cat "$FAKE_DIR/seq/1.json"
|
||||||
|
SLOWPS
|
||||||
|
cat > "$d/gh" <<'SLOWGH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec sleep 120
|
||||||
|
SLOWGH
|
||||||
|
chmod +x "$d/pr-state" "$d/gh"
|
||||||
|
start=$SECONDS
|
||||||
|
PR_AWAIT_READ_FLOOR=4 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet >/dev/null 2>&1 || true
|
||||||
|
elapsed=$((SECONDS - start))
|
||||||
|
[[ "$elapsed" -lt 6 ]] || fail "a zero deadline granted a second read floor, took ${elapsed}s (budget was 4)"
|
||||||
|
pass "a spent deadline grants a single read floor shared across both queries"
|
||||||
|
|
||||||
|
# --- every --format json exit path emits parseable JSON --------------------
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0
|
||||||
|
cat > "$d/gh" <<'GARBAGE'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'not json at all'
|
||||||
|
GARBAGE
|
||||||
|
chmod +x "$d/gh"
|
||||||
|
out="$(PR_AWAIT_READ_FLOOR=2 run_await "$d" 12 --interval-sec 1 --deadline-min 0 --quiet --format json 2>/dev/null)" || true
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" || fail "a garbage merge-state read must still yield parseable JSON" "$out"
|
||||||
|
pass "a garbage merge-state read still yields parseable JSON"
|
||||||
|
|
||||||
|
# --- a comment-only finding gets an accurate NEXT message -------------------
|
||||||
|
# Zero failed checks and zero unresolved threads must not be reported as
|
||||||
|
# "fix 0 failed check(s) and 0 thread(s)" when an actionable issue comment
|
||||||
|
# (or a blocking review, conflict, or advanced base) is the actual finding.
|
||||||
|
d="$(make_tmp_dir)"; setup_fake "$d"
|
||||||
|
snapshot "$d" 1 20 0 0 true aaaaaaaaaaaa '{"actionable_issue_comment_count":1}'
|
||||||
|
out="$(run_await "$d" 12 --interval-sec 1 --quiet 2>/dev/null)" && rc=0 || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "an actionable issue comment with clean checks should exit 1, got $rc" "$out"
|
||||||
|
grep -q 'fix 0 failed check(s) and 0 thread(s)' <<<"$out" \
|
||||||
|
&& fail "must not claim nothing to fix when a comment is the only finding" "$out"
|
||||||
|
grep -qi 'review the findings reported above' <<<"$out" \
|
||||||
|
|| fail "should point the caller at the reported findings instead" "$out"
|
||||||
|
pass "a comment-only finding is not reported as '0 failed check(s) and 0 thread(s)'"
|
||||||
|
|
||||||
|
printf '\nall tests passed\n'
|
||||||
@@ -583,6 +583,7 @@ summarize_state_json() {
|
|||||||
| select(successful_check)
|
| select(successful_check)
|
||||||
| {name, workflow, status, conclusion, run_id, job_id, details_url, target_url}
|
| {name, workflow, status, conclusion, run_id, job_id, details_url, target_url}
|
||||||
],
|
],
|
||||||
|
check_count: ([effective_checks[]] | length),
|
||||||
passed_check_count: ([effective_checks[] | select(successful_check)] | length),
|
passed_check_count: ([effective_checks[] | select(successful_check)] | length),
|
||||||
unresolved_review_thread_count,
|
unresolved_review_thread_count,
|
||||||
filtered_review_thread_count,
|
filtered_review_thread_count,
|
||||||
|
|||||||
@@ -1248,6 +1248,7 @@ test_summary_mode_returns_compact_fixup_state() {
|
|||||||
assert_jq "summary pending check count" '.pending_checks | length == 2' "$json"
|
assert_jq "summary pending check count" '.pending_checks | length == 2' "$json"
|
||||||
assert_jq "summary pending check" '.pending_checks[0] | .name == "claude-review" and .status == "in_progress" and .run_id == "27340000003"' "$json"
|
assert_jq "summary pending check" '.pending_checks[0] | .name == "claude-review" and .status == "in_progress" and .run_id == "27340000003"' "$json"
|
||||||
assert_jq "summary pending status context keeps target url" '.pending_checks[] | select(.name == "external pending") | .status == "pending" and .details_url == null and .target_url == "https://ci.example.test/build/1"' "$json"
|
assert_jq "summary pending status context keeps target url" '.pending_checks[] | select(.name == "external pending") | .status == "pending" and .details_url == null and .target_url == "https://ci.example.test/build/1"' "$json"
|
||||||
|
assert_jq "summary counts every effective check" '.check_count == 7' "$json"
|
||||||
assert_jq "summary exposes normalized passing checks" '(.passed_check_count == (.successful_checks | length)) and any(.successful_checks[]; .name == "web lint" and .conclusion == "success") and any(.successful_checks[]; .name == "opencode-review-same-repo" and .conclusion == "success")' "$json"
|
assert_jq "summary exposes normalized passing checks" '(.passed_check_count == (.successful_checks | length)) and any(.successful_checks[]; .name == "web lint" and .conclusion == "success") and any(.successful_checks[]; .name == "opencode-review-same-repo" and .conclusion == "success")' "$json"
|
||||||
assert_jq "summary unresolved count" '.unresolved_review_thread_count == 2' "$json"
|
assert_jq "summary unresolved count" '.unresolved_review_thread_count == 2' "$json"
|
||||||
assert_jq "summary filtered thread count" '.filtered_review_thread_count == 1' "$json"
|
assert_jq "summary filtered thread count" '.filtered_review_thread_count == 1' "$json"
|
||||||
|
|||||||
Reference in New Issue
Block a user