diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index 0a924817f..cb4aa3011 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -358,20 +358,30 @@ post_dispatch_guard() { # lookup_check_run_details # Returns JSON {id, details_url} for the most recent failed check matching # check_name on head_sha, so the retry dispatch has full failure context. +# Without one it prints {"id":"","details_url":""} and a ::warning:: on stderr +# naming the repo and check: a retry without them cannot fetch the run's logs. lookup_check_run_details() { - local repo="$1" head_sha="$2" check_name="$3" out - # On an API error gh prints the error body to stdout, so a `|| echo` fallback - # would be appended to it; replace the output instead. - if ! out=$(gh api "repos/${repo}/commits/${head_sha}/check-runs?per_page=100" \ - --jq --arg name "$check_name" \ - '.check_runs - | map(select(.name == $name and .conclusion == "failure")) - | sort_by(.completed_at) - | last - | {id: (.id // ""), details_url: (.details_url // "")}' \ - 2>/dev/null); then + local repo="$1" head_sha="$2" check_name="$3" raw out safe_name + # `gh api --jq` takes no `--arg`, so filter the raw pages with jq, which also + # keeps the check name out of the filter text. A commit can carry more than + # 100 check runs, so every page is read. On an API error gh prints the error + # body to stdout, so a `|| echo` fallback would be appended to it; replace the + # output instead (an error body has no .check_runs, so jq fails). + if ! raw=$(gh api --paginate "repos/${repo}/commits/${head_sha}/check-runs?per_page=100" 2>/dev/null) \ + || ! out=$(jq -sc --arg name "$check_name" \ + '[.[].check_runs[]] + | map(select(.name == $name and .conclusion == "failure")) + | sort_by(.completed_at) + | last + | {id: (.id // ""), details_url: (.details_url // "")}' \ + <<< "$raw" 2>/dev/null); then out='{"id":"","details_url":""}' fi + if [[ "$out" == '{"id":"","details_url":""}' ]]; then + # One line, bounded: the name lands inside a workflow command. + safe_name=$(printf '%s' "$check_name" | tr -c '[:alnum:] ._()/-' ' ' | cut -c1-120) + echo "::warning::dev-lead-retry: no failed check run '${safe_name}' found on ${repo}@${head_sha:0:8} (or check runs unreadable); the CI retry is sent without its id and details_url" >&2 + fi printf '%s\n' "$out" } diff --git a/tests/dev-lead/unit/test_dev_lead_retry.bats b/tests/dev-lead/unit/test_dev_lead_retry.bats index dbff1ea2e..bb4e92e1a 100644 --- a/tests/dev-lead/unit/test_dev_lead_retry.bats +++ b/tests/dev-lead/unit/test_dev_lead_retry.bats @@ -255,10 +255,135 @@ GHEOF _real_jq_gh_stub export GH_FAIL_PATHS='repos/combsmith/app/commits/*' - run lookup_check_run_details "combsmith/app" abc123 "CI" + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 "CI" [ "$status" -eq 0 ] [ "$output" = '{"id":"","details_url":""}' ] + [[ "$stderr" == *"::warning::"*"'CI'"*"combsmith/app@abc123"* ]] +} + +# _strict_gh_stub: parses its arguments the way the real gh does — value flags +# take the next word, and `gh api` accepts exactly one positional (the endpoint), +# else "accepts 1 arg(s), received N" and exit 1. The check-runs endpoint answers +# $CHECK_RUNS_RAW (one JSON page per line; only the first without --paginate, +# all back to back with it, as gh does) through --jq; dispatches are captured to $PAYLOAD_FILE. +_strict_gh_stub() { + cat > "$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +shift # "api" +filter="." pages=1 pos=() +while [ $# -gt 0 ]; do + case "$1" in + --jq|-q) filter="$2"; shift 2 ;; + --method|-X|-f|-F|-H|--input) shift 2 ;; + --paginate) pages=0; shift ;; + --silent|-i) shift ;; + *) pos+=("$1"); shift ;; + esac +done +if [ "${#pos[@]}" -ne 1 ]; then + echo "accepts 1 arg(s), received ${#pos[@]}" >&2 + exit 1 +fi +case "${pos[0]}" in + */dispatches) cat > "${PAYLOAD_FILE:-/dev/null}" ;; + */check-runs*) printf '%s\n' "${CHECK_RUNS_RAW:-{\}}" \ + | if [ "$pages" -eq 1 ]; then head -n 1; else cat; fi | jq -c "$filter" ;; + *) echo "[]" ;; +esac +GHEOF + chmod +x "$MOCK_BIN/gh" +} + +# Two failed runs of "CI" (the later one wins), a success and another check. +_check_runs_raw() { + jq -nc --arg name "${1:-CI}" '{check_runs: [ + {id: 11, name: $name, conclusion: "failure", completed_at: "2026-06-18T01:00:00Z", + details_url: "https://github.com/combsmith/app/actions/runs/1/job/11"}, + {id: 12, name: $name, conclusion: "failure", completed_at: "2026-06-18T02:00:00Z", + details_url: "https://github.com/combsmith/app/actions/runs/2/job/12"}, + {id: 13, name: $name, conclusion: "success", completed_at: "2026-06-18T03:00:00Z", + details_url: "https://github.com/combsmith/app/actions/runs/3/job/13"}, + {id: 14, name: "lint", conclusion: "failure", completed_at: "2026-06-18T04:00:00Z", + details_url: "https://github.com/combsmith/app/actions/runs/4/job/14"}]}' +} + +@test "lookup_check_run_details: returns the id and URL of the latest failed run of the check" { + _strict_gh_stub + CHECK_RUNS_RAW="$(_check_runs_raw)" + export CHECK_RUNS_RAW + + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 "CI" + + [ "$status" -eq 0 ] + [ "$(jq -r '.id' <<<"$output")" = "12" ] + [ "$(jq -r '.details_url' <<<"$output")" = "https://github.com/combsmith/app/actions/runs/2/job/12" ] + [ -z "$stderr" ] +} + +@test "lookup_check_run_details: a check name with quotes, backslashes and \\(...) matches literally" { + _strict_gh_stub + local name='build "x" \\ \(env.OS) (ubuntu)' + CHECK_RUNS_RAW="$(_check_runs_raw "$name")" + export CHECK_RUNS_RAW + + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 "$name" + + [ "$status" -eq 0 ] + [ "$(jq -r '.id' <<<"$output")" = "12" ] +} + +@test "lookup_check_run_details: the warning keeps a hostile check name to one bounded line" { + _strict_gh_stub + CHECK_RUNS_RAW="$(_check_runs_raw)" + export CHECK_RUNS_RAW + + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 $'x\n::error::y %0A'"$(printf 'z%.0s' {1..300})" + + [ "$status" -eq 0 ] + [ "$output" = '{"id":"","details_url":""}' ] + [ "$(wc -l <<<"$stderr")" -eq 1 ] + [[ "$stderr" != *"::error::"* ]] + [[ "$stderr" != *"%0A"* ]] +} + +@test "lookup_check_run_details: a failure on a later page is found (all pages are read)" { + _strict_gh_stub + CHECK_RUNS_RAW="$(jq -nc '{check_runs: [{id: 1, name: "lint", conclusion: "success"}]}')"$'\n'"$(_check_runs_raw)" + export CHECK_RUNS_RAW + + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 "CI" + + [ "$status" -eq 0 ] + [ "$(jq -r '.id' <<<"$output")" = "12" ] + [ "$(jq -r '.details_url' <<<"$output")" = "https://github.com/combsmith/app/actions/runs/2/job/12" ] +} + +@test "lookup_check_run_details: no failed run of the check prints empty fields, not null" { + _strict_gh_stub + CHECK_RUNS_RAW="$(_check_runs_raw)" + export CHECK_RUNS_RAW + + run --separate-stderr lookup_check_run_details "combsmith/app" abc123 "deploy (prod)" + + [ "$status" -eq 0 ] + [ "$output" = '{"id":"","details_url":""}' ] + [[ "$stderr" == *"::warning::"*"'deploy (prod)'"*"combsmith/app@abc123"* ]] + [ "$(grep -c '::warning::' <<<"$stderr")" -eq 1 ] +} + +@test "dispatch_ci_retry: the payload carries the check run id and URL" { + _strict_gh_stub + CHECK_RUNS_RAW="$(_check_runs_raw)" + export CHECK_RUNS_RAW DRY_RUN="false" + rm -f "$PAYLOAD_FILE" + + run dispatch_ci_retry "combsmith/app" 42 abc123 "CI" + + [ "$status" -eq 0 ] + [ "$(jq -r '.event_type' "$PAYLOAD_FILE")" = "dev-lead-ci-failure" ] + [ "$(jq -r '.client_payload.checks[0].id' "$PAYLOAD_FILE")" = "12" ] + [ "$(jq -r '.client_payload.checks[0].details_url' "$PAYLOAD_FILE")" = "https://github.com/combsmith/app/actions/runs/2/job/12" ] } @test "gh_api_list: pages are joined into one array; a non-array page becomes []" {