diff --git a/scripts/dev-lead-fix-ci.sh b/scripts/dev-lead-fix-ci.sh index 090338620..2331c7e5d 100755 --- a/scripts/dev-lead-fix-ci.sh +++ b/scripts/dev-lead-fix-ci.sh @@ -9,6 +9,7 @@ set -euo pipefail source "$(dirname "$0")/engine.sh" +INTENT_TYPE="fix-ci" PR_NUMBER="${PR_NUMBER:-}" HEAD_SHA="${HEAD_SHA:-}" CHECKS_JSON="${CHECKS_JSON:-[]}" @@ -202,7 +203,7 @@ main() { echo " [fix-ci] cycle $cycle/$MAX_CI_CYCLES" local engine_rc=0 - run_writer_with_fallback "$prompt_file" || engine_rc=$? + run_writer_with_fallback "$prompt_file" "$(model_for_intent "$INTENT_TYPE")" || engine_rc=$? if [ "$engine_rc" -ne 0 ]; then if [ "$engine_rc" -eq 2 ]; then diff --git a/scripts/dev-lead-fix-issue.sh b/scripts/dev-lead-fix-issue.sh index f3e89572d..09dc15e12 100755 --- a/scripts/dev-lead-fix-issue.sh +++ b/scripts/dev-lead-fix-issue.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(dirname "$0")/engine.sh" +INTENT_TYPE="fix-issue" ISSUE_NUMBER="${ISSUE_NUMBER:-}" REPO="${REPO:-${GITHUB_REPOSITORY:-}}" DEV_LEAD_DRY_RUN="${DEV_LEAD_DRY_RUN:-false}" @@ -54,7 +55,7 @@ main() { local pre_engine_sha pre_engine_sha=$(git rev-parse HEAD) - if ! run_writer_with_fallback "$prompt_file"; then + if ! run_writer_with_fallback "$prompt_file" "$(model_for_intent "$INTENT_TYPE")"; then echo "::error::Engine failed to implement issue #${ISSUE_NUMBER}" rm -f "$prompt_file" exit 1 diff --git a/scripts/dev-lead-fix-reviews.sh b/scripts/dev-lead-fix-reviews.sh index d09465451..b92d80964 100755 --- a/scripts/dev-lead-fix-reviews.sh +++ b/scripts/dev-lead-fix-reviews.sh @@ -33,6 +33,7 @@ fi build_and_run() { local template_name="$1" + local model="${2:-$ENGINE_ACTION_MODEL}" local prompt_file="/tmp/dev-lead-${template_name}-prompt-$$.md" # Export required vars then envsubst envsubst < "${PROMPTS_DIR}/${template_name}.md" > "$prompt_file" @@ -44,7 +45,7 @@ build_and_run() { fi local rc=0 - run_writer_with_fallback "$prompt_file" || rc=$? + run_writer_with_fallback "$prompt_file" "$model" || rc=$? rm -f "$prompt_file" return "$rc" } @@ -257,7 +258,7 @@ case "$INTENT_TYPE" in --jq '.data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved == false))' 2>/dev/null || echo "[]") export OPEN_THREADS_JSON rc=0 - build_and_run "fix-reviews" || rc=$? + build_and_run "fix-reviews" "$(model_for_intent "fix-reviews")" || rc=$? [ "$rc" -eq 2 ] && handle_rate_limit "fix-reviews" if [ "$rc" -eq 0 ]; then if commit_and_push "fix-reviews"; then @@ -272,7 +273,7 @@ case "$INTENT_TYPE" in export PR_NUMBER PR_URL="https://github.com/${REPO}/pull/${PR_NUMBER}" export REPO ACTOR="${ACTOR:-}" COMMENT_BODY="${COMMENT_BODY:-}" HEAD_SHA rc=0 - build_and_run "fix-bot-comment" || rc=$? + build_and_run "fix-bot-comment" "$(model_for_intent "fix-bot-comment")" || rc=$? [ "$rc" -eq 2 ] && handle_rate_limit "fix-bot-comment" if [ "$rc" -eq 0 ]; then if commit_and_push "fix-bot-comment"; then @@ -288,7 +289,7 @@ case "$INTENT_TYPE" in export PR_URL="https://github.com/${REPO}/pull/${PR_NUMBER}" export REPO ACTOR="${ACTOR:-}" USER_INSTRUCTION="${USER_INSTRUCTION:-}" PR_DESCRIPTION="${PR_DESCRIPTION:-}" rc=0 - build_and_run "human" || rc=$? + build_and_run "human" "$(model_for_intent "human")" || rc=$? [ "$rc" -eq 2 ] && handle_rate_limit "human" if [ "$rc" -eq 0 ]; then if commit_and_push "human"; then @@ -317,7 +318,7 @@ case "$INTENT_TYPE" in --jq '.data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved == false))' 2>/dev/null || echo "[]") export OPEN_THREADS_JSON BASE_REF="${BASE_REF:-main}" rc=0 - build_and_run "human-pr" || rc=$? + build_and_run "human-pr" "$(model_for_intent "human-pr")" || rc=$? [ "$rc" -eq 2 ] && handle_rate_limit "human-pr" if [ "$rc" -eq 0 ]; then if commit_and_push "human-pr"; then @@ -344,7 +345,7 @@ case "$INTENT_TYPE" in CONFLICTING_FILES=$(git merge-tree "$(git merge-base HEAD "origin/${BASE_REF}")" HEAD "origin/${BASE_REF}" 2>/dev/null | grep "^changed in both" | awk '{print $NF}' || true) export CONFLICTING_FILES rc=0 - build_and_run "rebase" || rc=$? + build_and_run "rebase" "$(model_for_intent "rebase")" || rc=$? [ "$rc" -eq 2 ] && handle_rate_limit "rebase" if [ "$rc" -eq 0 ]; then if commit_and_push "rebase"; then diff --git a/scripts/engine.sh b/scripts/engine.sh index df0155eb2..8a2c60e3d 100644 --- a/scripts/engine.sh +++ b/scripts/engine.sh @@ -4,11 +4,16 @@ set -euo pipefail # Supports: claude, gemini, copilot # # Sourced by review-one-pr.sh — provides: -# run_triage — no-tool tier (stdout capture) -# run_agentic — full-tool tier (stdout) -# run_duck — cross-engine adversarial (stdout) +# run_triage — no-tool tier (review-one-pr.sh only) +# run_agentic — full-tool tier (review-one-pr.sh only) +# run_duck — adversarial cross-engine (review-one-pr.sh only) # ENGINE_* env vars for model names and labels # DUCK_ENGINE / DUCK_MODEL for rubber-duck cross-engine review +# +# Sourced by dev-lead scripts — provides: +# model_for_intent — select model tier by intent complexity +# run_writer [model] — write-capable agent run +# run_writer_with_fallback [model] — run with engine fallback REVIEW_ENGINE="${REVIEW_ENGINE:-claude}" export REVIEW_ENGINE @@ -29,8 +34,13 @@ DUCK_TIMEOUT_SEC="${DUCK_TIMEOUT_SEC:-300}" RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-2}" # total attempts including first RETRY_BASE_DELAY_SEC="${RETRY_BASE_DELAY_SEC:-5}" -set_engine_config() { - case "$REVIEW_ENGINE" in +# _setup_engine_vars +# Sets all ENGINE_* model vars for the given engine name. +# Called once at source time and again by run_writer_with_fallback when +# switching engines mid-fallback, so each invocation gets compatible model IDs. +_setup_engine_vars() { + local eng="${1:-$REVIEW_ENGINE}" + case "$eng" in claude) ENGINE_TRIAGE_MODEL="claude-haiku-4-5-20251001" ENGINE_DEEP_MODEL="claude-sonnet-4-6" @@ -44,13 +54,13 @@ set_engine_config() { DUCK_MODEL="o4-mini" ;; gemini) - ENGINE_TRIAGE_MODEL="auto" - ENGINE_DEEP_MODEL="auto" - ENGINE_AUDIT_MODEL="auto" - ENGINE_ACTION_MODEL="auto" - ENGINE_SINGLE_MODEL="auto" - ENGINE_LABEL="auto (Gemini 2.5/3 balanced)" - ENGINE_SINGLE_LABEL="single-reviewer mode: auto" + ENGINE_TRIAGE_MODEL="gemini-2.0-flash" + ENGINE_DEEP_MODEL="gemini-1.5-pro" + ENGINE_AUDIT_MODEL="gemini-1.5-pro" + ENGINE_ACTION_MODEL="gemini-1.5-pro" + ENGINE_SINGLE_MODEL="gemini-1.5-pro" + ENGINE_LABEL="triage: gemini-2.0-flash → deep: gemini-1.5-pro + duck: sonnet 4.6 → audit: gemini-1.5-pro" + ENGINE_SINGLE_LABEL="single-reviewer mode: gemini-1.5-pro" # Cross-engine rubber duck: use Claude for diversity DUCK_ENGINE="claude" DUCK_MODEL="claude-sonnet-4-6" @@ -75,19 +85,17 @@ set_engine_config() { DUCK_MODEL="claude-sonnet-4-6" ;; *) - echo "::error::Unknown REVIEW_ENGINE='$REVIEW_ENGINE' (expected: claude, gemini, or copilot)" - exit 1 + echo "::error::Unknown REVIEW_ENGINE='$eng' (expected: claude, gemini, or copilot)" + return 1 ;; esac - export ENGINE_TRIAGE_MODEL ENGINE_DEEP_MODEL ENGINE_AUDIT_MODEL export ENGINE_ACTION_MODEL ENGINE_SINGLE_MODEL export ENGINE_LABEL ENGINE_SINGLE_LABEL export DUCK_ENGINE DUCK_MODEL } -# Initial config -set_engine_config +_setup_engine_vars "$REVIEW_ENGINE" || exit 1 echo " engine: $REVIEW_ENGINE ($ENGINE_LABEL)" # is_rate_limited @@ -449,25 +457,52 @@ parse_reset_time() { printf '%s' "${iso:-}" > /tmp/dev-lead-rate-limit-reset } -# run_writer [model] +# model_for_intent +# Returns an engine-agnostic tier key: "triage", "action", or "deep". +# run_writer resolves the key to the correct model ID for the active engine +# via ENGINE_TRIAGE_MODEL / ENGINE_ACTION_MODEL / ENGINE_DEEP_MODEL, which are +# re-initialized per engine by run_writer_with_fallback so fallback engines +# always get a compatible model ID. +# triage — human-pr, fix-bot-comment: lightweight checks, no agentic writes needed +# action — fix-reviews, fix-ci, rebase: write operations on known-scope diffs +# deep — fix-issue, human: full agentic feature work +model_for_intent() { + case "${1:-}" in + human-pr|fix-bot-comment) echo "triage" ;; + fix-reviews|fix-ci|rebase) echo "action" ;; + fix-issue|human) echo "deep" ;; + *) echo "action" ;; + esac +} + +# run_writer [tier_or_model] # Full write-access mode for applying code fixes. -# When DEV_LEAD_DRY_RUN=true: builds prompt but does NOT call engine; exits 0. -# Exit codes: 0=success, 1=non-retriable failure, 2=rate-limited +# tier_or_model: a tier key ("triage", "action", "deep") or a literal model ID. +# Tier keys are resolved to the current engine's model via ENGINE_*_MODEL vars, +# which run_writer_with_fallback keeps in sync when switching engines. +# When DEV_LEAD_DRY_RUN=true: logs the prompt size but does NOT call engine; exits 0. +# Exit codes: 0=success, 1=non-retriable failure, 2=rate-limited or unavailable # On exit 2, writes parsed reset timestamp to /tmp/dev-lead-rate-limit-reset. run_writer() { local prompt_file="$1" - local model="${2:-$ENGINE_ACTION_MODEL}" + local tier_or_model="${2:-action}" + + # Resolve tier key → current engine's model ID. + local model + case "$tier_or_model" in + triage) model="$ENGINE_TRIAGE_MODEL" ;; + deep) model="$ENGINE_DEEP_MODEL" ;; + action) model="$ENGINE_ACTION_MODEL" ;; + *) model="$tier_or_model" ;; # pass-through for literal model strings + esac if [ "${DEV_LEAD_DRY_RUN:-false}" = "true" ]; then - echo " [dry-run] run_writer: would invoke $REVIEW_ENGINE with prompt $(wc -l < "$prompt_file") lines" + echo " [dry-run] run_writer: would invoke $REVIEW_ENGINE ($model) with prompt $(wc -l < "$prompt_file") lines" return 0 fi # Capture stdout to a temp file so is_rate_limited can inspect it, while - # still streaming output to the caller. The old approach read from - # /tmp/dev-lead-writer-stderr which was never written (claude --print outputs - # to stdout, not stderr), so is_rate_limited never fired and fallback engines - # were never tried. + # still streaming output to the caller. local _tmp rc=0 _tmp=$(mktemp) @@ -487,15 +522,13 @@ run_writer() { < "$prompt_file" | tee "$_tmp" || rc=${PIPESTATUS[0]} ;; copilot) - # Copilot (gh copilot suggest) is text-only — falls back to Claude for write ops - echo "::warning::Copilot engine is text-only; falling back to Claude for write operations" >&2 - local saved="$REVIEW_ENGINE" - REVIEW_ENGINE="claude" timeout "$ACTION_TIMEOUT_SEC" claude --print \ - --model "$model" \ - --permission-mode acceptEdits \ - --allowed-tools "Bash,Read,Write,Edit,Grep,Glob" \ - < "$prompt_file" | tee "$_tmp" || rc=${PIPESTATUS[0]} - REVIEW_ENGINE="$saved" + # GitHub Models REST API is text-only — no Write/Edit tool access. + # Returning exit 2 signals "write engine unavailable" so run_writer_with_fallback + # exhausts the chain and the retry cron re-triggers when a write-capable + # engine (claude, gemini) is available again. + rm -f "$_tmp" + echo "::notice::copilot engine is text-only; write operations unavailable — triggering retry" >&2 + return 2 ;; esac @@ -509,36 +542,43 @@ run_writer() { return "$rc" } -# run_writer_with_fallback [model] -# Tries primary engine, falls back through claude → gemini → copilot on rate-limit. -# Only rate-limit (exit 2) triggers fallback; other failures propagate immediately. +# run_writer_with_fallback [tier_or_model] +# Tries primary engine, falls back through claude → gemini → copilot on rate-limit (exit 2). +# Re-initializes ENGINE_* vars for each engine so model IDs are always compatible. +# Exit 2 (rate-limited) and exit 127 (engine binary not installed) both trigger fallback. +# Other non-zero exits propagate immediately as real failures. run_writer_with_fallback() { local prompt_file="$1" + local tier_or_model="${2:-action}" local engines=("$REVIEW_ENGINE") for e in claude gemini copilot; do [ "$e" != "$REVIEW_ENGINE" ] && engines+=("$e") done + local orig_engine="$REVIEW_ENGINE" for engine in "${engines[@]}"; do - local saved="$REVIEW_ENGINE" + # Re-initialize ENGINE_* vars for this engine so model tier resolution + # in run_writer returns a compatible model ID for the active provider. export REVIEW_ENGINE="$engine" - # Re-evaluate model names for the new engine - set_engine_config + _setup_engine_vars "$engine" local rc=0 - # Don't pass 'model' argument; run_writer will use the updated $ENGINE_ACTION_MODEL - run_writer "$prompt_file" || rc=$? - export REVIEW_ENGINE="$saved" - # Restore original config for subsequent PRs in the same session - set_engine_config - [ "$rc" -eq 0 ] && return 0 - if [ "$rc" -eq 2 ]; then - echo "::warning::$engine rate-limited, trying next engine" >&2 + run_writer "$prompt_file" "$tier_or_model" || rc=$? + if [ "$rc" -eq 0 ]; then + export REVIEW_ENGINE="$orig_engine"; _setup_engine_vars "$orig_engine" + return 0 + fi + if [ "$rc" -eq 2 ] || [ "$rc" -eq 127 ]; then + # exit 2: rate-limited or text-only engine unavailable for writes + # exit 127: engine binary not installed in this environment + echo "::warning::$engine unavailable (exit $rc), trying next engine" >&2 continue fi + export REVIEW_ENGINE="$orig_engine"; _setup_engine_vars "$orig_engine" return "$rc" done + export REVIEW_ENGINE="$orig_engine"; _setup_engine_vars "$orig_engine" echo "::error::All engines rate-limited or unavailable" >&2 return 2 } diff --git a/tests/dev-lead/unit/test_engine_fallback.bats b/tests/dev-lead/unit/test_engine_fallback.bats index 440d950b7..b25221edd 100644 --- a/tests/dev-lead/unit/test_engine_fallback.bats +++ b/tests/dev-lead/unit/test_engine_fallback.bats @@ -92,16 +92,10 @@ STUBEOF } @test "fallback: all rate-limited → returns 2" { - # All engines exit 2 + # claude exits 2 (rate-limited), gemini exits 2 (rate-limited), + # copilot returns 2 directly in run_writer (text-only, write ops unsupported). _make_stub "claude" 2 _make_stub "gemini" 2 - # copilot falls back to claude internally, so we only need claude and gemini - # Create a copilot stub that also fails with 2 - cat > "$STUB_BIN_DIR/copilot" <<'STUBEOF' -#!/usr/bin/env bash -exit 2 -STUBEOF - chmod +x "$STUB_BIN_DIR/copilot" _source_engine "claude" run run_writer_with_fallback "$TEST_PROMPT" @@ -122,6 +116,39 @@ STUBEOF [ "$status" -eq 1 ] } +@test "fallback: engine not installed (exit 127) → tries next engine" { + # Simulates gemini not installed: exit 127 should trigger fallback, not abort + _make_stub "claude" 2 + _make_stub "gemini" 127 + _source_engine "claude" + + run run_writer_with_fallback "$TEST_PROMPT" + + # claude rate-limited (2), gemini not installed (127), copilot text-only (2) → all exhausted + [ "$status" -eq 2 ] +} + +@test "fallback: engine not installed (127) → next engine succeeds" { + # claude rate-limited, gemini not installed, but the next engine after gemini succeeds + # In the actual fallback order: claude → gemini → copilot. Since copilot returns 2 + # (text-only) in run_writer, we verify with a scenario where gemini is "not found" + # and a recording stub shows that claude was tried after gemini failed. + _make_stub "gemini" 2 + local record_file + record_file="$(mktemp)" + _make_recording_stub "claude" 127 "$record_file" + _source_engine "gemini" + + run run_writer_with_fallback "$TEST_PROMPT" + + # gemini (primary) rate-limited, claude not installed (127 → treated as unavailable), + # copilot text-only (2) → all engines exhausted + [ "$status" -eq 2 ] + # claude was tried (even though it exited 127, it was reached) + [ -s "$record_file" ] + rm -f "$record_file" +} + @test "fallback: fallback engine order is claude → gemini → copilot" { # Primary = gemini (exits 2), then fallback order should try claude first _make_stub "gemini" 2 diff --git a/tests/dev-lead/unit/test_engine_writer.bats b/tests/dev-lead/unit/test_engine_writer.bats index b749b9833..bf7ec3fe0 100644 --- a/tests/dev-lead/unit/test_engine_writer.bats +++ b/tests/dev-lead/unit/test_engine_writer.bats @@ -90,16 +90,18 @@ _source_engine() { [ "$status" -eq 0 ] } -@test "writer: copilot falls back to claude in run_writer (internal)" { - # When REVIEW_ENGINE=copilot, run_writer internally falls back to claude +@test "writer: copilot returns exit 2 for write operations (text-only engine)" { + # Copilot is text-only and cannot write files. run_writer returns exit 2 so + # run_writer_with_fallback exhausts the chain and the retry cron re-triggers. + # claude must NOT be called — the old fallback-to-claude bug must stay fixed. + export COPILOT_API_MODEL="openai/o4-mini" _source_engine "copilot" - export STUB_ENGINE_EXIT=0 export DEV_LEAD_DRY_RUN=false + rm -f "$STUB_BIN_DIR/claude" run run_writer "$TEST_PROMPT" - # Should succeed because the internal claude stub is present - [ "$status" -eq 0 ] + [ "$status" -eq 2 ] } @test "writer: run_writer dry-run logs prompt line count" { @@ -178,7 +180,7 @@ STUB @test "writer: run_writer_with_fallback retries all engines and returns 2 when all rate-limited" { _source_engine "claude" export DEV_LEAD_DRY_RUN=false - # All engines output rate-limit text and exit 1 → run_writer returns 2 for each + # claude and gemini stubs output rate-limit text and exit 1 → run_writer returns 2 for engine in claude gemini; do cat > "$STUB_BIN_DIR/$engine" << 'STUB' #!/usr/bin/env bash @@ -187,8 +189,7 @@ exit 1 STUB chmod +x "$STUB_BIN_DIR/$engine" done - # Add copilot stub (falls back to claude internally) - cp "$STUB_BIN_DIR/claude" "$STUB_BIN_DIR/copilot" + # copilot returns exit 2 directly in run_writer (text-only, no stub needed) run run_writer_with_fallback "$TEST_PROMPT" @@ -240,3 +241,64 @@ STUB result=$(cat /tmp/dev-lead-rate-limit-reset) [ -z "$result" ] } + +# ── model_for_intent tests ───────────────────────────────────────────────────── + +@test "model_for_intent: human-pr → triage tier" { + _source_engine "claude" + [ "$(model_for_intent "human-pr")" = "triage" ] +} + +@test "model_for_intent: fix-bot-comment → triage tier" { + _source_engine "claude" + [ "$(model_for_intent "fix-bot-comment")" = "triage" ] +} + +@test "model_for_intent: fix-reviews → action tier" { + _source_engine "claude" + [ "$(model_for_intent "fix-reviews")" = "action" ] +} + +@test "model_for_intent: fix-ci → action tier" { + _source_engine "claude" + [ "$(model_for_intent "fix-ci")" = "action" ] +} + +@test "model_for_intent: rebase → action tier" { + _source_engine "claude" + [ "$(model_for_intent "rebase")" = "action" ] +} + +@test "model_for_intent: fix-issue → deep tier" { + _source_engine "claude" + [ "$(model_for_intent "fix-issue")" = "deep" ] +} + +@test "model_for_intent: human → deep tier" { + _source_engine "claude" + [ "$(model_for_intent "human")" = "deep" ] +} + +@test "model_for_intent: unknown intent → action tier (default)" { + _source_engine "claude" + [ "$(model_for_intent "unknown-intent")" = "action" ] +} + +@test "model_for_intent: empty intent → action tier (default)" { + _source_engine "claude" + [ "$(model_for_intent "")" = "action" ] +} + +@test "model_for_intent: tier resolves to correct model for claude engine" { + _source_engine "claude" + [ "$(model_for_intent "human-pr")" = "triage" ] + # run_writer resolves "triage" → ENGINE_TRIAGE_MODEL for the current engine + [ "$ENGINE_TRIAGE_MODEL" = "claude-haiku-4-5-20251001" ] +} + +@test "model_for_intent: tier key is engine-agnostic (same key for gemini)" { + _source_engine "gemini" + [ "$(model_for_intent "human-pr")" = "triage" ] + # ENGINE_TRIAGE_MODEL is now gemini-2.0-flash for this engine + [ "$ENGINE_TRIAGE_MODEL" = "gemini-2.0-flash" ] +} diff --git a/tests/dev-lead/unit/test_fix_ci.bats b/tests/dev-lead/unit/test_fix_ci.bats index 97295bcf6..2d605a077 100644 --- a/tests/dev-lead/unit/test_fix_ci.bats +++ b/tests/dev-lead/unit/test_fix_ci.bats @@ -19,6 +19,23 @@ setup() { export PATH="$STUB_BIN_DIR:$PATH" export STUB_BIN_DIR + # Stub curl so copilot_chat returns a 429 rate-limit response for GitHub Models API. + # This makes the copilot fallback behave as rate-limited without a real API call. + cat > "$STUB_BIN_DIR/curl" << 'CURLEOF' +#!/usr/bin/env bash +# Detect GitHub Models API calls and return a synthetic 429 response. +for arg in "$@"; do + if [[ "$arg" == *"models.github.ai"* ]]; then + echo '{"error":{"message":"rate limit exceeded","type":"rate_limit"}}' + echo "429" + exit 0 + fi +done +# Non-API curl calls: pass through to system curl +exec /usr/bin/curl "$@" +CURLEOF + chmod +x "$STUB_BIN_DIR/curl" + # Default environment for fix-ci export PR_NUMBER="42" export HEAD_SHA="abc123def456" @@ -27,6 +44,8 @@ setup() { export REVIEW_ENGINE="claude" export DEV_LEAD_DRY_RUN="true" export GH_STUB_COMMENT_BODY="" + export COPILOT_GITHUB_TOKEN="stub-token" + export COPILOT_API_MODEL="openai/o4-mini" # Point to project root for prompts cd "$SCRIPT_DIR" } diff --git a/tests/dev-lead/unit/test_fix_reviews.bats b/tests/dev-lead/unit/test_fix_reviews.bats index acecf127c..7d7be49d7 100644 --- a/tests/dev-lead/unit/test_fix_reviews.bats +++ b/tests/dev-lead/unit/test_fix_reviews.bats @@ -18,6 +18,21 @@ setup() { export PATH="$STUB_BIN_DIR:$PATH" export STUB_BIN_DIR + # Stub curl so copilot_chat returns a 429 rate-limit response for GitHub Models API. + # This makes the copilot fallback behave as rate-limited without a real API call. + cat > "$STUB_BIN_DIR/curl" << 'CURLEOF' +#!/usr/bin/env bash +for arg in "$@"; do + if [[ "$arg" == *"models.github.ai"* ]]; then + echo '{"error":{"message":"rate limit exceeded","type":"rate_limit"}}' + echo "429" + exit 0 + fi +done +exec /usr/bin/curl "$@" +CURLEOF + chmod +x "$STUB_BIN_DIR/curl" + # Default env export PR_NUMBER="54" export HEAD_SHA="ddd444eee555" @@ -27,6 +42,8 @@ setup() { export GITHUB_REPOSITORY="petry-projects/.github-private" export BASE_REF="main" export ACTOR="donpetry" + export COPILOT_GITHUB_TOKEN="stub-token" + export COPILOT_API_MODEL="openai/o4-mini" # Install a graphql-aware gh stub cat > "$STUB_BIN_DIR/gh" <<'GHEOF'