Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions scripts/dev-lead-retry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -358,20 +358,30 @@ post_dispatch_guard() {
# lookup_check_run_details <repo> <head_sha> <check_name>
# 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"
}

Expand Down
127 changes: 126 additions & 1 deletion tests/dev-lead/unit/test_dev_lead_retry.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 []" {
Expand Down
Loading