#!/bin/bash # pr-ci-wait.sh - Wait for PR CI status to reach terminal state (GitHub/Gitea) # Usage: pr-ci-wait.sh -n [-r owner/repo] [-t timeout_sec] [-i interval_sec] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" PR_NUMBER="" TIMEOUT_SEC=1800 INTERVAL_SEC=15 REPO_OVERRIDE="" HOST_OVERRIDE="" usage() { cat < [-t timeout_sec] [-i interval_sec] Options: -n, --number NUMBER PR number (required) -r, --repo OWNER/REPO Repository slug (default: infer from git origin) --host HOST Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL) -t, --timeout SECONDS Max wait time in seconds (default: 1800) -i, --interval SECONDS Poll interval in seconds (default: 15) -h, --help Show this help Examples: $(basename "$0") -n 643 $(basename "$0") -n 643 --repo ddk/ai-bma $(basename "$0") -n 643 -t 900 -i 10 EOF } # get_remote_host and get_gitea_token are provided by detect-platform.sh extract_state_from_status_json() { # Capture piped JSON BEFORE invoking `python3 - <&2 usage >&2 exit 1 ;; esac done if [[ -z "$PR_NUMBER" ]]; then echo "Error: PR number is required (-n)." >&2 usage >&2 exit 1 fi if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; then echo "Error: timeout and interval must be integer seconds." >&2 exit 1 fi if [[ -n "$REPO_OVERRIDE" ]]; then REPO_INFO="$REPO_OVERRIDE" PLATFORM=$(detect_platform 2>/dev/null || echo gitea) else detect_platform > /dev/null REPO_INFO=$(get_repo_info) fi if [[ -z "$REPO_INFO" || "$REPO_INFO" == error:* || "$REPO_INFO" != */* ]]; then echo "Error: Could not determine repository from git origin. Run from a repo or pass --repo owner/repo." >&2 exit 1 fi OWNER=${REPO_INFO%%/*} REPO=${REPO_INFO##*/} START_TS=$(date +%s) DEADLINE_TS=$((START_TS + TIMEOUT_SEC)) if [[ "$PLATFORM" == "github" ]]; then if ! command -v gh >/dev/null 2>&1; then echo "Error: gh CLI is required for GitHub CI status polling." >&2 exit 1 fi HEAD_SHA=$(github_get_pr_head_sha) if [[ -z "$HEAD_SHA" ]]; then echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2 exit 1 fi echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}" elif [[ "$PLATFORM" == "gitea" ]]; then if [[ -n "$HOST_OVERRIDE" ]]; then HOST="$HOST_OVERRIDE" elif [[ -n "$REPO_OVERRIDE" ]]; then HOST=$(get_gitea_api_host_for_repo_override) || { echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2 exit 1 } else HOST=$(get_remote_host) || { echo "Error: Could not determine Gitea host from git origin." >&2 exit 1 } fi TOKEN=$(get_gitea_token "$HOST") || { echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2 exit 1 } HEAD_SHA=$(gitea_get_pr_head_sha "$HOST" "$OWNER/$REPO" "$TOKEN") if [[ -z "$HEAD_SHA" ]]; then echo "Error: Could not resolve head SHA for PR #$PR_NUMBER." >&2 exit 1 fi echo "[pr-ci-wait] Platform=gitea host=${HOST} repo=${OWNER}/${REPO} PR=#${PR_NUMBER} head_sha=${HEAD_SHA}" else echo "Error: Unsupported platform '${PLATFORM}'." >&2 exit 1 fi # No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak). # # PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT # BRANCH's commit status. A repo whose default branch carries CI statuses # demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply # has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that # case we must NEVER fast-green; we keep polling until the pipeline registers or the # timeout fires (both safe). This closes the webhook-lag false-green: a slow-to- # register pipeline feeding a merge gate can no longer be mistaken for "no CI". # # SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos # (default branch also has no CI history, e.g. device-imaging class), where burning # the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0. # # Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we # would rather wait-then-timeout than risk a false-green, per the merge-gate priority. REPO_HAS_CI=1 detect_repo_ci() { local def_branch def_status # Every early exit returns 0: a probe miss must leave the conservative # REPO_HAS_CI=1 default in place, never abort the caller under `set -e`. if [[ "$PLATFORM" == "github" ]]; then def_branch=$(github_get_default_branch 2>/dev/null) || { echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; } [[ -n "$def_branch" ]] || return 0 def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0 else def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || { echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; } [[ -n "$def_branch" ]] || return 0 def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0 fi if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then REPO_HAS_CI=0 echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)." else REPO_HAS_CI=1 echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green." fi } detect_repo_ci || true NO_CI_STREAK=0 NO_CI_MAX=3 while true; do NOW_TS=$(date +%s) if (( NOW_TS > DEADLINE_TS )); then echo "Error: Timed out waiting for CI status on PR #$PR_NUMBER after ${TIMEOUT_SEC}s." >&2 exit 124 fi if [[ "$PLATFORM" == "github" ]]; then STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA") else STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$HEAD_SHA") fi STATE=$(printf '%s' "$STATUS_JSON" | extract_state_from_status_json) echo "[pr-ci-wait] state=${STATE} pr=#${PR_NUMBER} sha=${HEAD_SHA}" case "$STATE" in success) printf '%s' "$STATUS_JSON" | print_status_summary echo "[pr-ci-wait] CI is green for PR #$PR_NUMBER." exit 0 ;; failure|error) printf '%s' "$STATUS_JSON" | print_status_summary echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2 exit 1 ;; no-status) if [[ "$REPO_HAS_CI" == "1" ]]; then # PRIMARY tier: repo demonstrably runs CI but this commit's pipeline # has not registered yet (webhook/queue lag). Do NOT fast-green — keep # polling until it registers or the timeout fires. Reset the streak so # a later genuine CI-less misread can't accumulate across this state. NO_CI_STREAK=0 echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening." else # SECONDARY tier: genuinely CI-less repo (default branch has no CI # history either). Empty polls => fast-exit green after NO_CI_MAX. NO_CI_STREAK=$((NO_CI_STREAK + 1)) if (( NO_CI_STREAK >= NO_CI_MAX )); then echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green." exit 0 fi fi sleep "$INTERVAL_SEC" ;; pending|unknown) # A pipeline exists but hasn't reached a terminal state (or is # transiently ambiguous) — keep waiting, and reset the no-CI streak # since this commit is not in the "no CI at all" condition. NO_CI_STREAK=0 sleep "$INTERVAL_SEC" ;; *) echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..." NO_CI_STREAK=0 sleep "$INTERVAL_SEC" ;; esac done