Skip to content

test(dev-lead): E2E test suite for all trigger scenarios - #189

Merged
don-petry merged 3 commits into
mainfrom
test/dev-lead-e2e
May 15, 2026
Merged

don-petry merged 3 commits into
mainfrom
test/dev-lead-e2e

Conversation

@don-petry

Copy link
Copy Markdown
Collaborator

Summary

  • Adds tests/dev-lead/e2e/ with a complete E2E test framework for the dev-lead agent
  • Provides run-all.sh orchestrator with --dry-run and --scenario <name> flags
  • Implements 6 scenarios covering every intent path (skip, human, fix-ci, issue, anti-loop, exhaustion)
  • 3 fixture-based scenarios run fully locally without network access; verified passing locally

Scenarios

# Name Type What it validates
01 skip-bot-pr fixture dependabot PR → intent=skip(bot-pr)
02 human-at-mention live @dev-lead comment → human intent, workflow runs
03 ci-failure-relay live failing check → ci-relay → repository_dispatch → fix-ci
04 issue-labeled live issue labeled dev-lead → issue intent, workflow runs
05 skip-anti-loop fixture BOT_USER sync → skip(dev-lead-own-commit); human sync → human-pr
06 exhaustion-guard script+stub 2 failures → PR-level exhaustion block posted; pre-block → exit 0

Test plan

  • bash -n on all 8 shell files — all pass
  • Scenario 01 runs locally: [PASS]
  • Scenario 05 runs locally: [PASS] (both parts A and B)
  • Scenario 06 runs locally: [PASS] (both parts A and B)
  • run-all.sh --dry-run lists all 6 scenarios and exits 0
  • run-all.sh --scenario 05-skip-anti-loop runs and passes
  • Scenarios 02–04 require GH_TOKEN and a live GitHub environment

🤖 Generated with Claude Code

don-petry and others added 3 commits May 15, 2026 15:01
…d on large PRs

When the engine times out or fails on the same PR MAX_FAIL_ATTEMPTS times
(default 2), post a PR-level exhaustion marker that blocks ALL subsequent
SHAs on that PR — not just the current SHA. This prevents the agent from
retrying every new commit on a PR where Claude consistently times out.

- check_idempotency() now checks for exhaustion marker first (PR-wide block)
  then falls back to SHA-specific check
- count_recent_failures() counts status=failed markers across all SHAs on the PR
- After hitting threshold, post_exhaustion() posts a human-clearable block comment
- Timeout (exit 124) is surfaced as a distinct reason in the failure comment
- 3 new unit tests cover exhaustion posting, blocking, and below-threshold behavior
- Use standalone jq pipe (not --jq flag) so test stubs work correctly
Adds tests/dev-lead/e2e/ with a master run-all.sh orchestrator, shared
helpers library, and six scenario scripts covering the full dispatch matrix:
fixture-based (01 bot-pr skip, 05 anti-loop guard, 06 exhaustion guard) and
live GitHub API (02 human @mention, 03 ci-relay → fix-ci, 04 issue labeled).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 15, 2026 20:11
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@don-petry has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 51 minutes and 40 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2832cf21-1552-4fc5-9d1c-100c10011a79

📥 Commits

Reviewing files that changed from the base of the PR and between 02103f7 and f0c087e.

📒 Files selected for processing (12)
  • scripts/dev-lead-fix-ci.sh
  • tests/dev-lead/e2e/README.md
  • tests/dev-lead/e2e/lib/helpers.sh
  • tests/dev-lead/e2e/results/.gitignore
  • tests/dev-lead/e2e/run-all.sh
  • tests/dev-lead/e2e/scenarios/01-skip-bot-pr.sh
  • tests/dev-lead/e2e/scenarios/02-human-at-mention.sh
  • tests/dev-lead/e2e/scenarios/03-ci-failure-relay.sh
  • tests/dev-lead/e2e/scenarios/04-issue-labeled.sh
  • tests/dev-lead/e2e/scenarios/05-skip-anti-loop.sh
  • tests/dev-lead/e2e/scenarios/06-exhaustion-guard.sh
  • tests/dev-lead/unit/test_fix_ci.bats
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/dev-lead-e2e

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

@don-petry
don-petry merged commit 1adf9a2 into main May 15, 2026
24 checks passed
@don-petry
don-petry deleted the test/dev-lead-e2e branch May 15, 2026 20:12

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an exhaustion guard mechanism to the fix-ci script to prevent excessive token consumption after repeated engine failures, along with a comprehensive E2E test suite. Feedback focused on improving the robustness of the scripts, specifically addressing dangerous JSON truncation in E2E helpers, potential race conditions when counting PR failures, and fragile parsing of GitHub CLI output. Additionally, recommendations were made to handle API pagination and avoid hardcoding the default branch name.

local run_data
run_data=$(echo "$runs_json" | jq -r \
".workflow_runs[] | select(.name == \"${workflow_name}\")" \
2>/dev/null | head -c 4096 || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using 'head -c 4096' to truncate the JSON response is dangerous. GitHub API responses for workflow runs can easily exceed 4KB, and truncating the stream will result in invalid JSON, causing the subsequent 'jq' calls to fail. Bash variables can handle much larger strings, so this truncation should be removed. If this large payload is subsequently passed to 'curl', ensure it is handled via a temporary file to avoid ARG_MAX limits.

Suggested change
2>/dev/null | head -c 4096 || true)
2>/dev/null || true)
References
  1. When passing large JSON payloads (e.g., PR diffs) to curl in shell scripts, use a temporary file and the --data-binary @file flag to avoid exceeding the ARG_MAX command-line argument limit.

if [ -n "$created_after" ]; then
run_data=$(echo "$runs_json" | jq -r \
".workflow_runs[] | select(.name == \"${workflow_name}\" and .created_at > \"${created_after}\")" \
2>/dev/null | head -c 4096 || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

As noted above, truncating the JSON response to 4KB will likely lead to parsing errors in 'jq'. Please remove the 'head -c 4096' limit. For large payloads that might be passed to other tools like 'curl', remember to use temporary files to stay within ARG_MAX limits.

Suggested change
2>/dev/null | head -c 4096 || true)
2>/dev/null || true)
References
  1. When passing large JSON payloads (e.g., PR diffs) to curl in shell scripts, use a temporary file and the --data-binary @file flag to avoid exceeding the ARG_MAX command-line argument limit.

Comment on lines +28 to +29
comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
| jq -r '.[].body' 2>/dev/null || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'gh api' call for comments defaults to 30 items per page. If a PR has many comments, the exhaustion or idempotency markers might be missed. Adding '--per-page 100' or using '--paginate' would make this more robust. Keeping the error redirection inline ensures the intent to silence errors is clear for this specific tier.

Suggested change
comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
| jq -r '.[].body' 2>/dev/null || true)
comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --per-page 100 2>/dev/null \
| jq -r '.[].body' 2>/dev/null || true)
References
  1. Keep error routing logic inline when tiers have distinct requirements to avoid obscuring intent or introducing false positives.

# count_recent_failures: count status=failed markers on this PR (any SHA)
count_recent_failures() {
local pattern="${MARKER_PREFIX}[a-f0-9A-F]* status=failed"
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the idempotency check, this API call should handle pagination or at least increase the items per page to ensure all failure markers are counted correctly. Maintaining the inline error routing is preferred here to keep the logic explicit.

Suggested change
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --per-page 100 2>/dev/null \
References
  1. Keep error routing logic inline when tiers have distinct requirements to avoid obscuring intent or introducing false positives.

Comment on lines +151 to +160
post_summary "failed" "$reason"

# Check if we've hit the consecutive-failure threshold for this PR
local fail_count
fail_count=$(count_recent_failures)
echo " [fix-ci] consecutive failures on this PR: $fail_count (threshold: $MAX_FAIL_ATTEMPTS)"
if [ "$fail_count" -ge "$MAX_FAIL_ATTEMPTS" ]; then
echo "::warning::Exhaustion threshold reached — posting PR-level block to prevent further token spend"
post_exhaustion "$reason"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a potential race condition here. 'post_summary' calls the GitHub API to post a comment, and 'count_recent_failures' immediately calls the API to list comments. Due to eventual consistency in GitHub's APIs, the comment just posted might not be returned in the list immediately. It is safer to count the existing failures before posting the new one. Additionally, for LLM-based tasks, ensure the script detects rate limits by checking exit codes and scanning output streams, exiting with code 2 if a limit is hit to allow for engine fallback.

Suggested change
post_summary "failed" "$reason"
# Check if we've hit the consecutive-failure threshold for this PR
local fail_count
fail_count=$(count_recent_failures)
echo " [fix-ci] consecutive failures on this PR: $fail_count (threshold: $MAX_FAIL_ATTEMPTS)"
if [ "$fail_count" -ge "$MAX_FAIL_ATTEMPTS" ]; then
echo "::warning::Exhaustion threshold reached — posting PR-level block to prevent further token spend"
post_exhaustion "$reason"
fi
# Count existing failures before posting the new one to avoid API race conditions
local prev_fail_count
prev_fail_count=$(count_recent_failures)
local total_fail_count=$((prev_fail_count + 1))
post_summary "failed" "$reason"
echo " [fix-ci] consecutive failures on this PR: $total_fail_count (threshold: $MAX_FAIL_ATTEMPTS)"
if [ "$total_fail_count" -ge "$MAX_FAIL_ATTEMPTS" ]; then
echo "::warning::Exhaustion threshold reached — posting PR-level block to prevent further token spend"
post_exhaustion "$reason"
fi
References
  1. In shell scripts implementing retry loops for LLM-based tasks, detect rate limits immediately by checking exit codes and scanning both stdout and stderr for rate-limit indicators before attempting a retry. If a rate limit is detected, exit with a specific code (e.g., 2) to enable engine fallback mechanisms.

Comment on lines +38 to +39
main_sha=$(gh api "repos/${E2E_TARGET_REPO}/git/ref/heads/main" \
--jq '.object.sha' 2>/dev/null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding the branch name to 'main' can cause failures in repositories that use a different default branch (e.g., 'master'). It's better to query the repository's default branch dynamically.

Suggested change
main_sha=$(gh api "repos/${E2E_TARGET_REPO}/git/ref/heads/main" \
--jq '.object.sha' 2>/dev/null)
local default_branch
default_branch=$(gh repo view "${E2E_TARGET_REPO}" --json defaultBranchRef --jq .defaultBranchRef.name)
main_sha=$(gh api "repos/${E2E_TARGET_REPO}/git/ref/heads/${default_branch}" \
--jq '.object.sha' 2>/dev/null)

Comment on lines +68 to +71
--base "main" \
--title "${title}" \
--body "${body}" \
2>&1 | grep -oE '[0-9]+$' | head -1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Parsing the PR number from stdout using 'grep' is fragile. The 'gh pr create' command supports '--json' and '--jq' flags, which provide a much more reliable way to extract the PR number. Also, avoid hardcoding the base branch as 'main'.

Suggested change
--base "main" \
--title "${title}" \
--body "${body}" \
2>&1 | grep -oE '[0-9]+$' | head -1)
local default_branch
default_branch=$(gh repo view "${E2E_TARGET_REPO}" --json defaultBranchRef --jq .defaultBranchRef.name)
pr_number=$(gh pr create \
--repo "${E2E_TARGET_REPO}" \
--head "${branch}" \
--base "${default_branch}" \
--title "${title}" \
--body "${body}" \
--json number --jq .number)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new end-to-end test framework for the dev-lead agent under tests/dev-lead/e2e/, with an orchestrator and six scenarios covering bot-PR skip, human at-mention, CI-failure relay, issue-labeling, anti-loop guard, and exhaustion guard. The production change in scripts/dev-lead-fix-ci.sh introduces a PR-level exhaustion marker that blocks repeated engine-failure retries once MAX_FAIL_ATTEMPTS is hit, plus matching unit tests in test_fix_ci.bats.

Changes:

  • Adds the tests/dev-lead/e2e/ suite: run-all.sh orchestrator, lib/helpers.sh, six scenario scripts, README, and a gitignored results dir.
  • Adds an exhaustion guard to scripts/dev-lead-fix-ci.sh (MAX_FAIL_ATTEMPTS, EXHAUSTION_MARKER, count_recent_failures, post_exhaustion) and refactors check_idempotency to use jq on raw comment bodies.
  • Extends test_fix_ci.bats with three exhaustion-related tests and updates the existing idempotency stub.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
scripts/dev-lead-fix-ci.sh Adds PR-level exhaustion marker, failure counter, and reworks idempotency check
tests/dev-lead/unit/test_fix_ci.bats Updates idempotency stub and adds three exhaustion-threshold tests
tests/dev-lead/e2e/run-all.sh New orchestrator with --dry-run / --scenario flags and summary reporting
tests/dev-lead/e2e/lib/helpers.sh Shared helpers: branch/PR/issue creation, workflow polling, assertions, cleanup
tests/dev-lead/e2e/scenarios/01-skip-bot-pr.sh Fixture-based scenario asserting dependabot PR → skip(bot-pr)
tests/dev-lead/e2e/scenarios/02-human-at-mention.sh Live scenario: @dev-lead comment triggers workflow
tests/dev-lead/e2e/scenarios/03-ci-failure-relay.sh Live scenario: failing check → ci-relay → repository_dispatch → fix-ci
tests/dev-lead/e2e/scenarios/04-issue-labeled.sh Live scenario: labeling issue dev-lead triggers the issue intent
tests/dev-lead/e2e/scenarios/05-skip-anti-loop.sh Fixture-based anti-loop guard (BOT_USER → skip, human → human-pr)
tests/dev-lead/e2e/scenarios/06-exhaustion-guard.sh Stubs gh and claude to validate the new exhaustion behaviour end-to-end
tests/dev-lead/e2e/README.md Documents usage, scenarios, env vars, and CI integration
tests/dev-lead/e2e/results/.gitignore Gitignores generated result files
Comments suppressed due to low confidence (9)

tests/dev-lead/e2e/run-all.sh:128

  • --scenario matching uses substring (*"${SINGLE_SCENARIO}"*) against the basename, so a short value like --scenario 0 would silently match every scenario, and --scenario 06 would only work because it happens to be unique. Consider matching by exact basename or by leading-digit prefix to make the CLI predictable, and erroring (or listing) when multiple scenarios match.
if [ -n "${SINGLE_SCENARIO}" ]; then
  FILTERED=()
  for s in "${ALL_SCENARIOS[@]}"; do
    if [[ "$(basename "$s" .sh)" == *"${SINGLE_SCENARIO}"* ]]; then
      FILTERED+=("$s")
    fi
  done
  if [ "${#FILTERED[@]}" -eq 0 ]; then
    log "ERROR: No scenario found matching '${SINGLE_SCENARIO}'"
    log "Available scenarios:"
    for s in "${ALL_SCENARIOS[@]}"; do
      log "  $(basename "$s" .sh)"
    done
    exit 1
  fi
  ALL_SCENARIOS=("${FILTERED[@]}")
fi

tests/dev-lead/e2e/run-all.sh:171

  • The capability summary only reports GH_TOKEN/GH_PAT and tags live scenarios as "will be skipped" when neither is set, but the orchestrator does not actually skip those scenarios — it still runs them, and each scenario decides on its own whether to SKIP. More importantly, when neither token is set, each scenario will exit 0 (SKIP) but run-all.sh counts that as PASS (line 164–166), since it has no way to distinguish PASS from internally-recorded SKIP. Consider reading results.txt to count true SKIP outcomes, or have scenarios use a distinct exit code for SKIP.
  # ── Execute scenario ────────────────────────────────────────────────────────
  set +e
  bash "${scenario_script}"
  scenario_exit=$?
  set -e

  if [ "${scenario_exit}" -eq 0 ]; then
    log "[PASS] ${scenario_name}"
    PASS_COUNT=$(( PASS_COUNT + 1 ))
  else
    log "[FAIL] ${scenario_name} (exit code: ${scenario_exit})"
    FAIL_COUNT=$(( FAIL_COUNT + 1 ))
  fi
done

scripts/dev-lead-fix-ci.sh:161

  • The exhaustion threshold logic counts status=failed markers via count_recent_failures after post_summary "failed" has just been called — but that summary call posts the new failure marker through the real GitHub API. So in production, after the first failure on a fresh PR, the count immediately becomes 1; after the second, it becomes 2. With the default MAX_FAIL_ATTEMPTS=2, this works, but the comment around line 156 ("consecutive failures") is misleading: there is no recency window, so old status=failed markers from prior SHAs (even after a successful intervening run) still count. Consider scoping the count to the current SHA or to comments newer than the most recent status=applied, or update the variable/comment naming to reflect that it is a lifetime PR-level count.
    if [ "$engine_rc" -ne 0 ]; then
      # Classify the failure: timeout (124) is a hard exhaustion candidate
      local reason="Engine invocation failed (exit ${engine_rc})"
      [ "$engine_rc" -eq 124 ] && reason="Engine timed out — PR may be too large for automated fixing"
      post_summary "failed" "$reason"

      # Check if we've hit the consecutive-failure threshold for this PR
      local fail_count
      fail_count=$(count_recent_failures)
      echo "  [fix-ci] consecutive failures on this PR: $fail_count (threshold: $MAX_FAIL_ATTEMPTS)"
      if [ "$fail_count" -ge "$MAX_FAIL_ATTEMPTS" ]; then
        echo "::warning::Exhaustion threshold reached — posting PR-level block to prevent further token spend"
        post_exhaustion "$reason"
      fi
      exit 1

scripts/dev-lead-fix-ci.sh:51

  • The regex ${MARKER_PREFIX}[a-f0-9A-F]* status=failed only matches markers whose SHA segment is purely hexadecimal. Real commit SHAs always are, but MARKER_PREFIX ends with <!-- dev-lead-fix-ci sha= and embeds a literal <!--. While <, !, - are not jq/Oniguruma metacharacters, future modifications to MARKER_PREFIX could introduce regex specials and silently break the count. Consider building the regex from a regex-escaped marker prefix, or using a fixed-string contains("...") check combined with a separate endswith check on status=failed -->.
count_recent_failures() {
  local pattern="${MARKER_PREFIX}[a-f0-9A-F]* status=failed"
  gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
    | jq "[.[] | select(.body | test(\"${pattern}\"))] | length" 2>/dev/null \
    || echo "0"
}

scripts/dev-lead-fix-ci.sh:115

  • check_idempotency now treats any exhaustion marker as a hard block until somebody manually deletes the comment. The user-facing instructions ("delete this comment or push a new commit with a substantially different change") in post_exhaustion claim a new commit will re-enable, but the guard explicitly says "blocks regardless of SHA" and the code only looks at the marker, not at the SHA. New pushes will still be blocked. Either reconcile the message with the behavior, or make the unblock work as advertised (e.g. invalidate the marker after N new SHAs, or only honour exhaustion markers whose sha= matches).
post_exhaustion() {
  local reason="$1"
  local body="${EXHAUSTION_MARKER}
## Dev-Lead Fix CI — exhausted

This PR has had **${MAX_FAIL_ATTEMPTS}** consecutive engine failures (timeouts or errors). Automated CI fixing has been paused to avoid consuming further tokens.

**Reason for last failure:** ${reason}

To re-enable, delete this comment or push a new commit with a substantially different change."
  if [ "${DEV_LEAD_DRY_RUN:-false}" = "true" ]; then
    echo "[dry-run] would post exhaustion comment"
  else
    gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$body"
  fi
}

tests/dev-lead/e2e/lib/helpers.sh:183

  • wait_for_workflow_by_event echoes the conclusion to stdout (line 169) but also calls info/err (which write to stdout/stderr) from within the same function. info prefixes go to stdout (line 22), so the caller's command substitution conclusion=$(wait_for_workflow_by_event ...) will capture both the [hh:mm:ss] INFO … lines and the final conclusion concatenated together. The same issue affects wait_for_workflow. Callers like scenarios 03/04 then compare the multi-line result against "success"/"neutral", which will fail spuriously. Either route log/info to stderr (>&2) inside these helpers, or write the result to a file/named variable rather than stdout.
wait_for_workflow() {
  local repo="${1:?repo required}"
  local workflow_name="${2:?workflow_name required}"
  local head_sha="${3:?head_sha required}"
  local timeout_sec="${4:-300}"

  local deadline=$(( $(date +%s) + timeout_sec ))
  local poll_interval=15

  info "Waiting for workflow '${workflow_name}' on SHA ${head_sha:0:8}... (timeout: ${timeout_sec}s)"

  while [ "$(date +%s)" -lt "$deadline" ]; do
    local run_data
    run_data=$(gh api \
      "repos/${repo}/actions/runs?head_sha=${head_sha}&per_page=20" \
      --jq ".workflow_runs[] | select(.name == \"${workflow_name}\")" \
      2>/dev/null || true)

    if [ -n "$run_data" ]; then
      local status conclusion
      status=$(echo "$run_data" | jq -r '.status' 2>/dev/null | head -1)
      conclusion=$(echo "$run_data" | jq -r '.conclusion // ""' 2>/dev/null | head -1)

      if [ "$status" = "completed" ] && [ -n "$conclusion" ]; then
        info "Workflow '${workflow_name}' completed: conclusion=${conclusion}"
        echo "${conclusion}"
        return 0
      fi
      info "  status=${status} conclusion=${conclusion} — still running..."
    else
      info "  no runs yet for '${workflow_name}' on ${head_sha:0:8}..."
    fi

    sleep "$poll_interval"
  done

  err "Timed out after ${timeout_sec}s waiting for workflow '${workflow_name}'"
  echo "timeout"
  return 1
}

# ── wait_for_workflow_by_event <repo> <workflow_name> <event> <timeout_sec> ───
# Polls until the most recent workflow run triggered by <event> completes.
# Used for events like repository_dispatch or issues where we don't have a SHA.
wait_for_workflow_by_event() {
  local repo="${1:?repo required}"
  local workflow_name="${2:?workflow_name required}"
  local event="${3:?event required}"
  local timeout_sec="${4:-300}"
  local created_after="${5:-}"   # ISO8601 string; filters to runs created after this time

  local deadline=$(( $(date +%s) + timeout_sec ))
  local poll_interval=15

  info "Waiting for workflow '${workflow_name}' triggered by '${event}'... (timeout: ${timeout_sec}s)"

  while [ "$(date +%s)" -lt "$deadline" ]; do
    local runs_json
    runs_json=$(gh api \
      "repos/${repo}/actions/runs?event=${event}&per_page=10" \
      2>/dev/null || echo '{"workflow_runs":[]}')

    local run_data
    run_data=$(echo "$runs_json" | jq -r \
      ".workflow_runs[] | select(.name == \"${workflow_name}\")" \
      2>/dev/null | head -c 4096 || true)

    if [ -n "$run_data" ]; then
      # If created_after filter, select only runs newer than it
      if [ -n "$created_after" ]; then
        run_data=$(echo "$runs_json" | jq -r \
          ".workflow_runs[] | select(.name == \"${workflow_name}\" and .created_at > \"${created_after}\")" \
          2>/dev/null | head -c 4096 || true)
      fi
    fi

    if [ -n "$run_data" ]; then
      local status conclusion
      status=$(echo "$run_data" | jq -rs '.[0].status // ""')
      conclusion=$(echo "$run_data" | jq -rs '.[0].conclusion // ""')

      if [ "$status" = "completed" ] && [ -n "$conclusion" ] && [ "$conclusion" != "null" ]; then
        info "Workflow '${workflow_name}' completed: conclusion=${conclusion}"
        echo "${conclusion}"
        return 0
      fi
      info "  status=${status} conclusion=${conclusion} — still running..."
    else
      info "  no matching runs yet for '${workflow_name}'..."
    fi

    sleep "$poll_interval"
  done

  err "Timed out after ${timeout_sec}s waiting for workflow '${workflow_name}'"
  echo "timeout"
  return 1
}

tests/dev-lead/e2e/lib/helpers.sh:17

  • Scenarios 02–04 source helpers.sh, which on line 14 emits a WARNING to stderr if neither GH_TOKEN nor GH_PAT is set, before the scenario's own GH_TOKEN precheck has a chance to gracefully SKIP. The warning is harmless but will be confusing in logs ("API calls will fail" then the test immediately reports SKIP). Consider moving the token presence check earlier in scenarios, or only emitting the warning at the point of first use.
GH_TOKEN="${GH_TOKEN:-${GH_PAT:-}}"
if [ -z "$GH_TOKEN" ]; then
  echo "[helpers] WARNING: neither GH_TOKEN nor GH_PAT is set — API calls will fail" >&2
fi
export GH_TOKEN

tests/dev-lead/e2e/lib/helpers.sh:76

  • create_test_pr extracts the PR number with grep -oE '[0-9]+$' | head -1 from the entire stdout+stderr output of gh pr create. gh pr create prints the PR URL (e.g. https://github.com/owner/repo/pull/123), so the last numeric run captures the PR number — but on warning/error output, this can pick up an unrelated number (an HTTP status, a rate-limit count) and return the wrong value silently. Prefer gh pr create … 2>/dev/null | tail -1 and parse the URL explicitly, or use gh pr view --json number --jq .number after creation.

  local pr_number
  pr_number=$(gh pr create \
    --repo "${E2E_TARGET_REPO}" \
    --head "${branch}" \
    --base "main" \
    --title "${title}" \
    --body "${body}" \
    2>&1 | grep -oE '[0-9]+$' | head -1)

  if [ -z "$pr_number" ]; then
    err "Failed to create PR for branch ${branch}"
    return 1
  fi

tests/dev-lead/e2e/lib/helpers.sh:371

  • record_result is called with status="SKIP" in scenarios 02–04 when GH_TOKEN is unset, but it is also called with "PASS"/"FAIL" elsewhere. Because the recorded line uses the literal status, the summary correctly distinguishes them — but the orchestrator counters in run-all.sh count any exit-0 scenario as PASS (including SKIPs from scenarios 02–04), masking missing-token runs in the final tally. Either propagate SKIP via an exit code (e.g. 77, the autotools convention) and have run-all.sh interpret it, or grep results.txt for the SKIP entries when computing the summary.
record_result() {
  local scenario="${1:?scenario required}"
  local status="${2:?status required}"   # PASS or FAIL
  local details="${3:-}"

  mkdir -p "${E2E_RESULTS_DIR}"
  local result_file="${E2E_RESULTS_DIR}/results.txt"
  local ts
  ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  echo "${ts} [${status}] ${scenario}: ${details}" >> "${result_file}"
}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +62 to +73
local intent_output
intent_output=$(
GITHUB_ENV="${GITHUB_ENV_FILE}" \
GITHUB_OUTPUT="${GITHUB_OUTPUT_FILE}" \
GITHUB_EVENT_NAME="pull_request" \
GITHUB_EVENT_PATH="${fixture}" \
GITHUB_REPOSITORY="${E2E_TARGET_REPO}" \
BOT_USER="donpetry-bot" \
bash "${INTENT_SCRIPT}" 2>&1
)

local exit_code=$?
Comment on lines +70 to +141
local intent_output_a
intent_output_a=$(
GITHUB_ENV="${GITHUB_ENV_FILE}" \
GITHUB_OUTPUT="${GITHUB_OUTPUT_FILE}" \
GITHUB_EVENT_NAME="pull_request" \
GITHUB_EVENT_PATH="${fixture}" \
GITHUB_REPOSITORY="${E2E_TARGET_REPO}" \
BOT_USER="donpetry-bot" \
bash "${INTENT_SCRIPT}" 2>&1
)

local exit_code_a=$?
log "Script exit code: ${exit_code_a}"
echo "${intent_output_a}" | sed 's/^/ /'

local intent_type_a intent_reason_a
intent_type_a=$(_get_env_var "INTENT_TYPE")
intent_reason_a=$(_get_env_var "INTENT_REASON")
log "INTENT_TYPE=${intent_type_a} INTENT_REASON=${intent_reason_a}"

# Reset for part B
> "$GITHUB_ENV_FILE"
> "$GITHUB_OUTPUT_FILE"

# ── Part B: human synchronize → must NOT emit anti-loop skip ──────────────
log ""
log "Part B: synchronize from human user (not BOT_USER) — using pr_opened_human.json"

local human_fixture="${FIXTURE_DIR}/pr_opened_human.json"
# pr_opened_human.json has action=opened, but we just need a sender that's not the bot
# We also verify the human synchronize path doesn't accidentally hit anti-loop.
# For a pure synchronize test from a human, we'd need a separate fixture.
# The existing pr_opened_human.json has action=opened which goes through different routing.
# We create an inline fixture for a human synchronize:
local human_sync_fixture
human_sync_fixture=$(mktemp --suffix=.json)
jq -n '{
action: "synchronize",
number: 99,
pull_request: {
number: 99,
title: "Human sync test",
body: "",
state: "open",
author_association: "OWNER",
head: {
sha: "human999sha",
ref: "feat/human-branch",
repo: { full_name: "petry-projects/.github-private" }
},
base: {
ref: "main",
repo: { full_name: "petry-projects/.github-private" }
}
},
repository: { full_name: "petry-projects/.github-private" },
sender: { login: "donpetry", type: "User" }
}' > "${human_sync_fixture}"

local intent_output_b
intent_output_b=$(
GITHUB_ENV="${GITHUB_ENV_FILE}" \
GITHUB_OUTPUT="${GITHUB_OUTPUT_FILE}" \
GITHUB_EVENT_NAME="pull_request" \
GITHUB_EVENT_PATH="${human_sync_fixture}" \
GITHUB_REPOSITORY="${E2E_TARGET_REPO}" \
BOT_USER="donpetry-bot" \
bash "${INTENT_SCRIPT}" 2>&1
)

local exit_code_b=$?
log "Script exit code (human sync): ${exit_code_b}"

[ "$status" -eq 1 ]
# Should post sha-level marker but NOT exhaustion comment
[[ "$output" != *"exhaustion threshold reached"* ]] || true
# The existing pr_opened_human.json has action=opened which goes through different routing.
# We create an inline fixture for a human synchronize:
local human_sync_fixture
human_sync_fixture=$(mktemp --suffix=.json)
LOG_MAX_LINES="${LOG_MAX_LINES:-200}"
MAX_FAIL_ATTEMPTS="${MAX_FAIL_ATTEMPTS:-2}"
MARKER_PREFIX="<!-- dev-lead-fix-ci sha="
EXHAUSTION_MARKER="<!-- dev-lead-fix-ci pr=${PR_NUMBER} status=exhausted -->"
Comment on lines +65 to +66
log() { echo "[$(date -u +%H:%M:%S)] $*"; }

#!/usr/bin/env bash
# tests/dev-lead/e2e/lib/helpers.sh — Shared helpers for dev-lead E2E tests.
# Sourced by each scenario script.
set -euo pipefail
Comment on lines +56 to +64
ISSUE_NUMBER=$(gh issue create \
--repo "${E2E_TARGET_REPO}" \
--title "[E2E] Scenario 04: dev-lead label routing test" \
--body "Automated E2E test issue for scenario 04. Safe to close.

This issue tests that the dev-lead agent correctly handles the \`issues labeled\` event with the \`dev-lead\` label.

Created: $(date -u)" \
2>&1 | grep -oE '[0-9]+$' | head -1)
Comment on lines +66 to +73
local head_sha
head_sha=$(push_file_to_branch \
"${BRANCH}" \
"tests/dev-lead/e2e/.tmp-bad-script-03.sh" \
"${content_b64}" \
"test(e2e): scenario 03 — intentional CI failure file")

log "HEAD SHA: ${head_sha}"
Comment on lines +93 to +100
if ! assert_conclusion "${conclusion}" "success" "${SCENARIO_NAME}: workflow completes successfully"; then
# Also accept "neutral" (if DEV_LEAD_DRY_RUN blocks real actions) for now
if [ "${conclusion}" = "neutral" ]; then
echo "[PASS] ${SCENARIO_NAME}: workflow conclusion=neutral (dry-run mode)"
else
all_pass=false
fi
fi

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0c087ef99

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

local ts
ts=$(date -u +%Y%m%d-%H%M%S)
local branch_name="${base_name}-${ts}"
info "Creating branch: ${branch_name} from main on ${E2E_TARGET_REPO}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redirect helper logs away from returned values

Live scenarios 02 and 03 capture this helper with BRANCH=$(create_test_branch ...), but info writes to stdout before the function echoes the branch name. That makes BRANCH contain both the log line and the branch, so the next push_file_to_branch "${BRANCH}" ... call uses an invalid ref and the live E2E scenarios fail as soon as a GitHub token is present. Send these logs to stderr or otherwise keep stdout reserved for the returned value.

Useful? React with 👍 / 👎.

Comment on lines +47 to +50
local pattern="${MARKER_PREFIX}[a-f0-9A-F]* status=failed"
gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
| jq "[.[] | select(.body | test(\"${pattern}\"))] | length" 2>/dev/null \
|| echo "0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count only consecutive failures before exhausting

This counts every historical status=failed marker on the PR, even if a later status=applied, no-changes, or dry-run marker proves the failure streak was broken. In a PR with one old engine failure, then a successful auto-fix, then one new engine failure, MAX_FAIL_ATTEMPTS=2 will incorrectly post the PR-level exhaustion marker and block all future SHAs. The exhaustion check needs to consider only the latest consecutive failed attempts rather than all failed comments.

Useful? React with 👍 / 👎.

Comment on lines +79 to +83
conclusion=$(wait_for_workflow \
"${E2E_TARGET_REPO}" \
"Dev-Lead Agent" \
"${head_sha}" \
300) || conclusion="timeout"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for issue_comment runs by event

For the issue_comment trigger used in this scenario, the Dev-Lead Agent workflow run is associated with the workflow/default-branch commit rather than the PR branch commit created above, so filtering by the PR head_sha never finds the run even when the @mention correctly triggered it. This makes scenario 02 time out in live runs; use the event-based waiter with the recorded start time, as scenario 04 does for issues, instead of waiting on the PR SHA.

Useful? React with 👍 / 👎.

Comment on lines +52 to +61
# The failing file: a shell script with a syntax error (missing 'fi')
# shellcheck will catch this, or bash -n will reject it.
local bad_script
bad_script='#!/usr/bin/env bash
# E2E test: intentional syntax error for CI failure scenario
# This file is part of the automated E2E test suite — safe to delete.
if [ "x" = "y" ]; then
echo "syntax error below — missing fi"
# deliberately omitting fi to trigger shellcheck/bash -n failure
'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the CI-relay scenario actually fail a check

Adding a syntactically invalid .sh file here does not fail any checked workflow in this repo: I checked .github/workflows/test.yml lines 18-22 and .github/workflows/test-dev-lead.yml lines 27-60, and they run two specific scripts, fixture validation, Bats unit tests, and prompt coverage—not shellcheck or bash -n over this new file. With no failing check_run, the relay path under test is never triggered and scenario 03 times out or validates the wrong thing; introduce a change that one of the configured workflows actually fails.

Useful? React with 👍 / 👎.


**Reason for last failure:** ${reason}

To re-enable, delete this comment or push a new commit with a substantially different change."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not tell users a new commit clears exhaustion

The exhaustion marker is PR-level (check_idempotency skips whenever this comment exists, regardless of HEAD_SHA), so pushing a new commit does not re-enable automated fixing despite this message saying it does. In the exhaustion path users will follow the suggested remediation, push another commit, and still be blocked until the comment is deleted, so the comment should only advertise actions that the script actually honors.

Useful? React with 👍 / 👎.

Comment on lines +153 to +155
if [[ "${scenario_name}" == *"${needs_claude_scenario}"* ]] && [ "${HAS_CLAUDE_TOKEN}" = "false" ]; then
log " NOTE: ${scenario_name} will skip Claude response assertions (CLAUDE_CODE_OAUTH_TOKEN not set)"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip live scenarios when Claude token is absent

When GH_TOKEN is set but CLAUDE_CODE_OAUTH_TOKEN is not, this only logs a note and still executes scenarios 02-04. Those scenarios trigger the real Dev-Lead workflow, whose preflight requires CLAUDE_CODE_OAUTH_TOKEN, so the workflow concludes failure rather than merely skipping response-content assertions; the runner should skip these scenarios or provide a real dry-run path instead of running tests that are guaranteed to fail in that advertised configuration.

Useful? React with 👍 / 👎.


[ "$status" -eq 1 ]
# Should post sha-level marker but NOT exhaustion comment
[[ "$output" != *"exhaustion threshold reached"* ]] || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the unconditional pass from threshold assertion

This assertion can never fail because of the trailing || true, so the below-threshold test will pass even if the script logs exhaustion threshold reached and posts the PR-level block after only one prior failure. That leaves the new exhaustion guard behavior unprotected against exactly the regression this test claims to catch.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants