diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index b72267ec8..0a924817f 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -359,15 +359,46 @@ post_dispatch_guard() { # 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. lookup_check_run_details() { - local repo="$1" head_sha="$2" check_name="$3" - gh api "repos/${repo}/commits/${head_sha}/check-runs?per_page=100" \ + 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 || echo '{"id":"","details_url":""}' + 2>/dev/null); then + out='{"id":"","details_url":""}' + fi + printf '%s\n' "$out" +} + +# gh_api_list +# GETs a paginated list endpoint (repos//) and prints ONE JSON array: +# the pages, each mapped through (which must yield an array), joined. +# On a failed call gh prints the API's error body to stdout (e.g. a GHSA +# temporary private fork answers 404 on /issues), so neither +# `|| echo "[]"` nor `jq -s add` is safe on the raw output. A failed call or a +# non-array page instead prints `[]`, emits a ::warning:: naming the repo, the +# endpoint and the API's error message (when the body has one), and returns 1, +# so one bad repo is visible but never aborts the sweep. Callers that may treat +# an unreadable list as empty append `|| true`. +gh_api_list() { + local repo="$1" path="$2" filter="$3" raw out msg + if raw=$(gh api --paginate "repos/${repo}/${path}" --jq "$filter" 2>/dev/null) \ + && out=$(jq -sc 'if all(type == "array") then add // [] else error("non-array page") end' \ + <<< "$raw" 2>/dev/null); then + printf '%s\n' "$out" + return 0 + fi + # One line, bounded: the message lands inside a workflow command. + msg=$(jq -rs '[.[] | objects | .message | strings] | last // empty' <<< "$raw" 2>/dev/null || true) + msg=$(printf '%s' "$msg" | tr -c '[:alnum:] ._-' ' ' | cut -c1-120) + echo "::warning::dev-lead-retry: GET repos/${repo}/${path%%\?*} failed or did not return a list${msg:+ (${msg})}; treating it as empty" >&2 + echo "[]" + return 1 } # dispatch_ci_retry @@ -591,10 +622,15 @@ scan_pr_for_rate_limits() { fi # Fetch our automation's comment bodies, paginating to ensure we don't miss - # markers on busy PRs + # markers on busy PRs. Unreadable comments could hide a dispatch guard or an + # unexpired rate-limit marker, so fail closed. local comments_json - comments_json=$(gh api --paginate "repos/${repo}/issues/${pr_number}/comments?per_page=100" \ - --jq "$(retry_marker_bodies_jq)" 2>/dev/null | jq -s 'add // []' || echo "[]") + if ! comments_json=$(gh_api_list "$repo" "issues/${pr_number}/comments?per_page=100" \ + "$(retry_marker_bodies_jq)"); then + echo " [warn] could not read PR ${pr_number} comments in ${repo} — skipping (fail closed)" >&2 + echo "0" + return 0 + fi # Deduplication guard: if a concurrent resume path already claimed dispatch # for this SHA, skip to prevent duplicate repository_dispatch calls (#1407). @@ -1461,11 +1497,11 @@ scan_repo() { echo "[retry] scanning ${repo}..." local prs_json - prs_json=$(gh api --paginate "repos/${repo}/pulls?state=open&per_page=100" \ - --jq '[.[] | {number: .number, head_sha: .head.sha}]' 2>/dev/null || echo "[]") + prs_json=$(gh_api_list "$repo" "pulls?state=open&per_page=100" \ + '[.[] | {number: .number, head_sha: .head.sha}]') || true local pr_count - pr_count=$(echo "$prs_json" | jq -s 'add // [] | length') + pr_count=$(jq 'length' <<< "$prs_json") if [ "$pr_count" -eq 0 ]; then echo " no open PRs in ${repo}" else @@ -1488,7 +1524,7 @@ scan_repo() { dispatched=$(scan_pr_for_unreplied_bot_threads "$repo" "$pr_number") total_dispatched=$(( total_dispatched + dispatched )) fi - done < <(echo "$prs_json" | jq -sc 'add // [] | .[]') + done < <(jq -c '.[]' <<< "$prs_json") echo " dispatched ${total_dispatched} PR retries from ${repo}" fi @@ -1500,13 +1536,14 @@ scan_repo() { # open_issue_pr_exists # Returns 0 if an open PR for this issue already exists (branch dev-lead/issue-*), # meaning a prior attempt already produced — or is producing — a PR. In that case -# the issue must NOT be re-dispatched (the PR path takes over). +# the issue must NOT be re-dispatched (the PR path takes over). Returns 1 when +# none exists, and 2 when the open PRs cannot be listed: a retry dispatched +# blind could open a duplicate PR, so the caller holds it (fail closed). open_issue_pr_exists() { - local repo="$1" issue_number="$2" count - count=$(gh api --paginate "repos/${repo}/pulls?state=open&per_page=100" \ - --jq "[.[] | select(.head.ref | startswith(\"dev-lead/issue-${issue_number}-\"))]" \ - 2>/dev/null | jq -s 'add | length' || echo "0") - [ "${count:-0}" -gt 0 ] + local repo="$1" issue_number="$2" prs + prs=$(gh_api_list "$repo" "pulls?state=open&per_page=100" \ + "[.[] | select(.head.ref | startswith(\"dev-lead/issue-${issue_number}-\"))]") || return 2 + (( $(jq 'length' <<< "$prs") > 0 )) } # scan_issue_for_retry @@ -1517,15 +1554,15 @@ scan_issue_for_retry() { local repo="$1" issue_number="$2" local comments_json - comments_json=$(gh api --paginate "repos/${repo}/issues/${issue_number}/comments?per_page=100" \ - --jq "$(retry_marker_bodies_jq)" 2>/dev/null || echo "[]") + comments_json=$(gh_api_list "$repo" "issues/${issue_number}/comments?per_page=100" \ + "$(retry_marker_bodies_jq)") || true # Newest dev-lead-issue marker for this issue (comments are chronological, so # the last matching one is the most recent attempt — earlier ones superseded). local prefix="${ISSUE_MARKER_PREFIX}${issue_number} " local marker - marker=$(echo "$comments_json" | jq -s -r --arg p "$prefix" \ - '[ .[] | .[]? | select(. != null and (. | contains($p))) ] | last // ""' 2>/dev/null || echo "") + marker=$(echo "$comments_json" | jq -r --arg p "$prefix" \ + '[ .[] | strings | select(contains($p)) ] | last // ""' 2>/dev/null || echo "") if [ -z "$marker" ]; then # No failure marker → nothing failed (or it succeeded). Nothing to retry. @@ -1568,10 +1605,16 @@ scan_issue_for_retry() { # Skip if a PR already exists for this issue (prior attempt succeeded, or a # retry run is currently producing one). - if open_issue_pr_exists "$repo" "$issue_number"; then + local pr_rc=0 + open_issue_pr_exists "$repo" "$issue_number" || pr_rc=$? + if (( pr_rc == 0 )); then echo " [skip] issue #${issue_number}: an open dev-lead PR already exists" >&2 echo "0"; return 0 fi + if (( pr_rc != 1 )); then + echo " [hold] issue #${issue_number}: the open PR list of ${repo} is unreadable — not re-dispatching (fail closed)" >&2 + echo "0"; return 0 + fi echo " [retry] issue #${issue_number}: status=${status} reason=${reason:-?} attempt=${attempt} < ${MAX_ATTEMPTS} → re-dispatching" >&2 dispatch_issue_retry "$repo" "$issue_number" "$attempt" @@ -1587,15 +1630,14 @@ scan_repo_issues() { # escalation label. The issues endpoint also returns PRs, so filter # .pull_request==null to keep true issues only. local issues_json - issues_json=$(gh api --paginate \ - "repos/${repo}/issues?state=open&labels=${DEV_LEAD_LABEL}&per_page=100" \ - --jq "[.[] | select(.pull_request == null) + issues_json=$(gh_api_list "$repo" \ + "issues?state=open&labels=${DEV_LEAD_LABEL}&per_page=100" \ + "[.[] | select(.pull_request == null) | select([.labels[].name] | index(\"${NEEDS_HUMAN_LABEL}\") | not) - | {number: .number}]" \ - 2>/dev/null || echo "[]") + | {number: .number}]") || true local issue_count - issue_count=$(echo "$issues_json" | jq -s 'add // [] | length') + issue_count=$(jq 'length' <<< "$issues_json") if [ "${issue_count:-0}" -eq 0 ]; then echo " no open dev-lead issues in ${repo}" return 0 @@ -1613,15 +1655,27 @@ scan_repo_issues() { echo " dispatched ${total_dispatched} issue retries from ${repo}" } -# list_repos_for_org : list all non-fork repos in the org. +# list_repos_for_org : list all non-fork repos in the org, leaving out GHSA +# temporary private forks. GitHub creates those for security advisories without +# the fork flag or a parent; they have issues and Actions disabled, so no +# dev-lead work can live in or be dispatched to them. No single field marks +# one, so a repo is left out only when ALL of these hold: it is private, its +# issues are disabled, and its name is another repo in the listing plus +# `-ghsa-` and a GHSA id (e.g. TalkTerm-ghsa-pc5f-cw67-7cmm next to TalkTerm). # Hard-errors (non-zero exit) when the list is empty and not in DRY_RUN, since # an empty result most likely means a token permission issue rather than a # legitimately empty org — silently scanning 0 repos would hide misconfig. list_repos_for_org() { local org="$1" local result - result=$(gh repo list "$org" --limit 1000 --json nameWithOwner,isFork \ - --jq '[.[] | select(.isFork == false) | .nameWithOwner]' 2>/dev/null || echo "[]") + result=$(gh repo list "$org" --limit 1000 --json nameWithOwner,isFork,isPrivate,hasIssuesEnabled \ + --jq '"-ghsa(-[23456789cfghjmpqrvwx]{4}){3}$" as $ghsa + | [.[].nameWithOwner] as $names + | [ .[] | select(.isFork == false) + | select((.isPrivate == true and .hasIssuesEnabled == false + and (.nameWithOwner | test($ghsa)) + and ((.nameWithOwner | sub($ghsa; "")) as $p | any($names[]; . == $p))) | not) + | .nameWithOwner ]' 2>/dev/null || echo "[]") if [ "$result" = "[]" ] || [ -z "$result" ]; then echo "::warning::No repos found for org '${org}' — check GH_TOKEN has repo read scope" >&2 if [ "${DRY_RUN:-false}" != "true" ]; then diff --git a/tests/dev-lead/e2e/scenarios/07-rate-limit-retry.sh b/tests/dev-lead/e2e/scenarios/07-rate-limit-retry.sh index 60349f00e..9e3d4622a 100755 --- a/tests/dev-lead/e2e/scenarios/07-rate-limit-retry.sh +++ b/tests/dev-lead/e2e/scenarios/07-rate-limit-retry.sh @@ -517,8 +517,9 @@ case "$*" in *"repo list"*) echo '[{"nameWithOwner":"combsmith/test-repo","isFork":false}]' ;; *startswith*) - # open_issue_pr_exists probe → no existing PR for the issue - echo "0" ;; + # open_issue_pr_exists probe → no existing PR for the issue (post-jq shape: + # an empty array; anything that is not an array now holds the retry) + echo '[]' ;; *"issues/478/comments"*) # post-jq shape (gh applies --jq '[.[].body]'): array of comment-body strings echo '[""]' ;; diff --git a/tests/dev-lead/unit/test_dev_lead_retry.bats b/tests/dev-lead/unit/test_dev_lead_retry.bats index 3415ac3e7..dbff1ea2e 100644 --- a/tests/dev-lead/unit/test_dev_lead_retry.bats +++ b/tests/dev-lead/unit/test_dev_lead_retry.bats @@ -8,6 +8,9 @@ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)" RETRY_SCRIPT="$SCRIPT_DIR/scripts/dev-lead-retry.sh" +# run --separate-stderr (gh_api_list warnings go to stderr) +bats_require_minimum_version 1.5.0 + setup() { MOCK_BIN="$(mktemp -d)" export PATH="$MOCK_BIN:$PATH" @@ -21,7 +24,7 @@ setup() { # gh stub: emulates the *post-jq* output the script expects (the real gh # applies --jq server/client-side; the stub returns the finished value). # …/issues//comments -> $COMMENTS_JSON (array of comment bodies) - # …/pulls?state=open -> $OPEN_PR_COUNT (integer) + # …/pulls?state=open -> $OPEN_PR_COUNT matching PRs (as an array) # …/dispatches -> capture payload to $PAYLOAD_FILE (POST path) export PAYLOAD_FILE="$MOCK_BIN/payload.json" cat > "$MOCK_BIN/gh" <<'GHEOF' @@ -30,7 +33,7 @@ args="$*" case "$args" in *dispatches*) cat > "${PAYLOAD_FILE:-/dev/null}"; exit 0 ;; *comments*) printf '%s' "${COMMENTS_JSON}" ;; - *pulls*) printf '%s' "${OPEN_PR_COUNT}" ;; + *pulls*) jq -nc --argjson n "${OPEN_PR_COUNT}" '[range($n) | {}]' ;; *) echo "[]" ;; esac GHEOF @@ -160,6 +163,248 @@ _marker() { [ ! -f "$PAYLOAD_FILE" ] } +# ── a repo whose list endpoint answers with an error body ───────────────────── +# On an HTTP error gh prints the API's error JSON to stdout (no --jq applied) and +# exits 1. GHSA temporary private forks answer 404 on /issues, and the old +# `gh api ... || echo "[]"` + `jq -s 'add'` turned that into object + array, a +# jq crash that set -e made fatal to the whole org sweep. + +# _real_jq_gh_stub: a gh stub that, like the real gh, applies --jq to a raw +# response, and answers with an error body for the endpoints in $GH_FAIL_PATHS +# (space-separated globs). `gh repo list` returns $REPOS_RAW through --jq. +_real_jq_gh_stub() { + cat > "$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +filter="." path="" prev="" +for a in "$@"; do + [ "$prev" = "--jq" ] && filter="$a" + case "$a" in repos/*) [ -n "$path" ] || path="$a" ;; esac + prev="$a" +done +if [ "$1 $2" = "repo list" ]; then + printf '%s' "${REPOS_RAW:-[]}" | jq -c "$filter"; exit +fi +for f in ${GH_FAIL_PATHS:-}; do + # shellcheck disable=SC2053 # $f is a glob on purpose + if [[ "$path" == $f ]]; then + printf '{"message":"Not Found","documentation_url":"https://docs.github.com/rest","status":"404"}' + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + fi +done +case "$path" in + repos/*/issues\?*) raw="${ISSUES_RAW:-[]}" ;; + repos/*/pulls/[0-9]*) raw="${PR_RAW:-{\}}" ;; + *) raw="[]" ;; +esac +printf '%s' "$raw" | jq -c "$filter" +GHEOF + chmod +x "$MOCK_BIN/gh" +} + +@test "gh_api_list: an error body becomes [] with a warning naming repo and endpoint" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/x-ghsa-pc5f-cw67-7cmm/issues*' + + run --separate-stderr gh_api_list "combsmith/x-ghsa-pc5f-cw67-7cmm" "issues?state=open&per_page=100" '[.[] | {number}]' + + [ "$status" -eq 1 ] + [ "$output" = "[]" ] + [[ "$stderr" == *"::warning::"*"repos/combsmith/x-ghsa-pc5f-cw67-7cmm/issues failed"*"(Not Found)"* ]] +} + +@test "gh_api_list: a failure partway through pagination drops the pages already read" { + # Page 1 arrives, then gh prints the error body of page 2 and exits 1. + cat > "$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +echo '[{"number":1}]' +printf '{"message":"You have exceeded a secondary rate limit","status":"403"}' +exit 1 +GHEOF + chmod +x "$MOCK_BIN/gh" + + run --separate-stderr gh_api_list "combsmith/a" "issues?per_page=100" '.' + + [ "$status" -eq 1 ] + [ "$output" = "[]" ] + [[ "$stderr" == *"repos/combsmith/a/issues failed"*"(You have exceeded a secondary rate limit)"* ]] +} + +@test "retry: markers on two pages — the newest still wins" { + cat > "$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +case "$*" in + *comments*) printf '%s\n%s\n' "$PAGE1" "$PAGE2" ;; + *pulls*) echo '[]' ;; + *) echo '[]' ;; +esac +GHEOF + chmod +x "$MOCK_BIN/gh" + PAGE1="$(jq -nc --arg m "$(_marker failed 1)" '[$m, "chatter"]')" + PAGE2="$(jq -nc --arg m "$(_marker failed 2)" '["more chatter", $m]')" + export PAGE1 PAGE2 + + run scan_issue_for_retry "combsmith/.github" 478 + + [ "$status" -eq 0 ] + [[ "$output" == *"would dispatch dev-lead-issue-retry"* ]] + [[ "$output" == *"attempt=2"* ]] +} + +@test "lookup_check_run_details: a failed call prints exactly the fallback object" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/app/commits/*' + + run lookup_check_run_details "combsmith/app" abc123 "CI" + + [ "$status" -eq 0 ] + [ "$output" = '{"id":"","details_url":""}' ] +} + +@test "gh_api_list: pages are joined into one array; a non-array page becomes []" { + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$PAGES"\n' > "$MOCK_BIN/gh" + chmod +x "$MOCK_BIN/gh" + + PAGES=$'[{"number":1}]\n[{"number":2}]' run gh_api_list "combsmith/a" "pulls" '.' + [ "$status" -eq 0 ] + [ "$output" = '[{"number":1},{"number":2}]' ] + + PAGES=$'[{"number":1}]\n{"message":"Server Error"}' run --separate-stderr gh_api_list "combsmith/a" "pulls" '.' + [ "$status" -eq 1 ] + [ "$output" = "[]" ] + [[ "$stderr" == *"::warning::"*"repos/combsmith/a/pulls"* ]] +} + +@test "scan_repo_issues: an error body on the issues endpoint is no issues, not a crash" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/broken/issues*' + + run bash -c 'set -euo pipefail; source "$1"; scan_repo_issues combsmith/broken; echo "after"' _ "$RETRY_SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::"*"repos/combsmith/broken/issues"* ]] + [[ "$output" == *"no open dev-lead issues in combsmith/broken"* ]] + [[ "$output" == *"after"* ]] +} + +@test "scan_repo: an error body on the pulls endpoint is no PRs, and the issue scan still runs" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/broken/pulls*' + export ISSUES_RAW='[{"number":7,"labels":[{"name":"dev-lead"}]}]' + + run bash -c 'set -euo pipefail; source "$1"; scan_repo combsmith/broken; echo "after"' _ "$RETRY_SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::"*"repos/combsmith/broken/pulls"* ]] + [[ "$output" == *"no open PRs in combsmith/broken"* ]] + [[ "$output" == *"found 1 open dev-lead issue(s)"* ]] + [[ "$output" == *"after"* ]] +} + +@test "retry: open PRs that cannot be listed hold the issue retry (fail closed)" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/.github/pulls*' + export DRY_RUN="false" + rm -f "$PAYLOAD_FILE" + + run open_issue_pr_exists "combsmith/.github" 478 + [ "$status" -eq 2 ] + + # The caller holds a retryable issue, says why, and POSTs nothing. + COMMENTS_JSON="$(jq -nc --arg m "$(_marker failed 1)" '[$m]')" + export COMMENTS_JSON + cat > "$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +case "$*" in + *dispatches*) cat > "${PAYLOAD_FILE:-/dev/null}"; exit 0 ;; + *comments*) printf '%s' "${COMMENTS_JSON}" ;; + *pulls*) printf '{"message":"Not Found","status":"404"}'; exit 1 ;; + *) echo "[]" ;; +esac +GHEOF + chmod +x "$MOCK_BIN/gh" + + run scan_issue_for_retry "combsmith/.github" 478 + + [ "$status" -eq 0 ] + [ "${lines[-1]}" = "0" ] + [[ "$output" == *"[hold] issue #478: the open PR list of combsmith/.github is unreadable"* ]] + [ ! -f "$PAYLOAD_FILE" ] +} + +@test "sweep: repos whose issues or pulls endpoint errors are skipped with warnings and the sweep reaches the next repo" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/no-issues/issues* repos/combsmith/no-pulls/pulls*' + export ISSUES_RAW='[{"number":7,"labels":[{"name":"dev-lead"}]}]' + export REPOS_RAW='[{"nameWithOwner":"combsmith/no-issues","isFork":false}, + {"nameWithOwner":"combsmith/no-pulls","isFork":false}, + {"nameWithOwner":"combsmith/healthy","isFork":false}]' + export DISPATCH_DELAY_SEC=0 TARGET_ORG=combsmith DELEGATION_ORGS="" + unset SWEEP_AGENT_REF + + run bash "$RETRY_SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::"*"repos/combsmith/no-issues/issues"* ]] + [[ "$output" == *"::warning::"*"repos/combsmith/no-pulls/pulls"* ]] + [[ "$output" == *"[retry] scanning combsmith/healthy..."* ]] + [[ "$output" == *"dispatched 0 issue retries from combsmith/healthy"* ]] + [[ "$output" == *"[retry] done at"* ]] + [[ "$output" != *"jq: error"* ]] +} + +# _repo +_repo() { + jq -nc --arg n "combsmith/$1" --argjson f "$2" --argjson p "$3" --argjson i "$4" \ + '{nameWithOwner: $n, isFork: $f, isPrivate: $p, hasIssuesEnabled: $i}' +} + +@test "list_repos_for_org: GHSA temporary private forks and real forks are left out" { + _real_jq_gh_stub + REPOS_RAW=$(jq -sc '.' <<< "$(_repo TalkTerm false false true) +$(_repo TalkTerm-ghsa-pc5f-cw67-7cmm false true false) +$(_repo .github-private false false true) +$(_repo .github-private-ghsa-rw84-v39h-45cp false true false) +$(_repo upstream-copy true false true)") + export REPOS_RAW + + run list_repos_for_org combsmith + + [ "$status" -eq 0 ] + [ "$output" = '["combsmith/TalkTerm","combsmith/.github-private"]' ] +} + +@test "list_repos_for_org: a GHSA-shaped name alone never leaves a repo out" { + _real_jq_gh_stub + # Each kept repo misses one signal: public; issues enabled; no repo of the + # prefix name in the org; not a GHSA id (the id alphabet has no 'a' or 'b'). + REPOS_RAW=$(jq -sc '.' <<< "$(_repo app false true false) +$(_repo app-ghsa-pc5f-cw67-7cmm false false false) +$(_repo app-ghsa-rw84-v39h-45cp false true true) +$(_repo gone-ghsa-fx78-wx9r-525p false true false) +$(_repo app-ghsa-aaaa-bbbb-aaaa false true false) +$(_repo ghsa-notes false true false)") + export REPOS_RAW + + run list_repos_for_org combsmith + + [ "$status" -eq 0 ] + [ "$output" = '["combsmith/app","combsmith/app-ghsa-pc5f-cw67-7cmm","combsmith/app-ghsa-rw84-v39h-45cp","combsmith/gone-ghsa-fx78-wx9r-525p","combsmith/app-ghsa-aaaa-bbbb-aaaa","combsmith/ghsa-notes"]' ] +} + +@test "scan_pr_for_rate_limits: unreadable PR comments skip the PR (fail closed)" { + _real_jq_gh_stub + export GH_FAIL_PATHS='repos/combsmith/app/issues/12/comments*' + export PR_RAW='{"number":12,"state":"open","head":{"sha":"abc123def456"},"labels":[]}' + pr_resume_suppressed() { return 1; } # past the human/budget gate + + run --separate-stderr scan_pr_for_rate_limits "combsmith/app" 12 + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [[ "$stderr" == *"could not read PR 12 comments in combsmith/app"* ]] +} + # ── #2008: sweep trigger for bot comments edited after their disposition ────── # CodeRabbit edits ONE summary comment in place (e.g. appending a Security # Architecture finding). dev-lead only fires on CREATED comments, and the caller