diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index 66d77baa5..5ad949f10 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -22,10 +22,15 @@ # Failure modes handled gracefully: # - without `workflows` permission (403): posts an idempotent comment asking # the author to rebase manually (sentinel: ) -# - merge conflict (422): posts an idempotent comment asking the author to -# resolve conflicts (sentinel: ) +# - merge conflict (422): posts a SHA-keyed sentinel comment and fires a +# repository_dispatch event (type: claude-rebase). The sentinel is keyed to +# the base-branch HEAD SHA, so each new merge to the base branch resets the +# gate and allows Claude another attempt on the next auto-rebase run. +# Note: repository_dispatch is one of only two event types that GITHUB_TOKEN +# is permitted to trigger workflow runs for (the other being workflow_dispatch). # # No secrets required — uses github.token only. No auto-merge logic. +# Conflict-handling logic lives in scripts/auto-rebase.sh for testability. name: Auto-rebase non-Dependabot PRs (Reusable) on: @@ -35,14 +40,23 @@ jobs: auto-rebase: runs-on: ubuntu-latest permissions: - contents: write # needed for update-branch (may touch .github/workflows/) + contents: write # needed for update-branch and repository_dispatch pull-requests: write # needed to post comments on PRs steps: + - name: Checkout petry-projects/.github (for scripts) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: petry-projects/.github + fetch-depth: 1 - name: Update behind non-Dependabot PRs env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | + # Always sourced from petry-projects/.github regardless of caller repo. + # shellcheck source=scripts/auto-rebase.sh + source scripts/auto-rebase.sh + # Find open non-Dependabot PRs from the same repo (exclude forks) PRS=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \ --jq '.[] | select(.user.login != "dependabot[bot]") | select(.head.repo != null) | select(.head.repo.full_name == .base.repo.full_name) | "\(.number) \(.head.ref)"') @@ -76,50 +90,9 @@ jobs: echo " Update failed: $UPDATE_OUTPUT" if echo "$UPDATE_OUTPUT" | grep -qF "without \`workflows\` permission"; then - # GitHub blocks update-branch when merging the base would add - # .github/workflows/ changes and the token lacks the 'workflows' - # permission. Ask the author to rebase manually. - # - # Idempotent: skip if sentinel comment already exists. - SENTINEL="" - ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ - --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") - if [[ "$ALREADY_POSTED" -gt 0 ]]; then - echo " Skipping — blocked comment already posted" - else - echo " Posting manual-rebase request (workflows permission missing)" - BLOCKED_BODY="" - BLOCKED_BODY+=$'\n'"**Auto-rebase blocked** — the base branch contains \`.github/workflows/\` changes" - BLOCKED_BODY+=" that require the \`workflows\` permission to merge into this branch," - BLOCKED_BODY+=" but the auto-rebase workflow's token does not have that permission." - BLOCKED_BODY+=$'\n\n'"Please rebase this branch manually:" - BLOCKED_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" - BLOCKED_BODY+=$'\n'"git rebase origin/$BASE_BRANCH" - BLOCKED_BODY+=$'\n'"git push --force-with-lease"$'\n'"\`\`\`" - gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BLOCKED_BODY" - fi + handle_permissions_block "$PR_NUMBER" "$BASE_BRANCH" elif echo "$UPDATE_OUTPUT" | grep -qi "merge conflict"; then - # Merge conflict — ask the author to resolve it. - # gh api surfaces the GitHub API error JSON: {"message":"merge conflict",...} - # - # Idempotent: skip if sentinel comment already exists. - SENTINEL="" - ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ - --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") - if [[ "$ALREADY_POSTED" -gt 0 ]]; then - echo " Skipping — conflict comment already posted" - else - echo " Posting conflict resolution request" - CONFLICT_BODY="" - CONFLICT_BODY+=$'\n'"**Auto-rebase failed — merge conflict** — this branch has conflicts" - CONFLICT_BODY+=" with \`$BASE_BRANCH\` that must be resolved manually." - CONFLICT_BODY+=$'\n\n'"Please resolve the conflicts and push:" - CONFLICT_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" - CONFLICT_BODY+=$'\n'"git merge origin/$BASE_BRANCH" - CONFLICT_BODY+=$'\n'"# resolve conflicts, then:" - CONFLICT_BODY+=$'\n'"git add ."$'\n'"git commit"$'\n'"git push"$'\n'"\`\`\`" - gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$CONFLICT_BODY" - fi + handle_conflict "$PR_NUMBER" "$HEAD_REF" "$BASE_BRANCH" else echo " Warning: failed to update PR #$PR_NUMBER — $UPDATE_OUTPUT" fi diff --git a/.github/workflows/auto-rebase-tests.yml b/.github/workflows/auto-rebase-tests.yml new file mode 100644 index 000000000..2f4f0c2e9 --- /dev/null +++ b/.github/workflows/auto-rebase-tests.yml @@ -0,0 +1,55 @@ +# Quality gates for the auto-rebase workflow and its supporting scripts. +# +# Triggered on any PR that touches: +# - .github/workflows/auto-rebase*.yml +# - scripts/auto-rebase.sh +# - test/workflows/auto-rebase/** +# +# Gates (all must pass before merge): +# 1. shellcheck — static analysis of scripts/auto-rebase.sh +# 2. bats — unit test suite for conflict-handling logic + +name: Auto-Rebase Tests + +on: + pull_request: + paths: + - '.github/workflows/auto-rebase*.yml' + - 'scripts/auto-rebase.sh' + - 'test/workflows/auto-rebase/**' + push: + branches: + - main + paths: + - '.github/workflows/auto-rebase*.yml' + - 'scripts/auto-rebase.sh' + - 'test/workflows/auto-rebase/**' + +permissions: + contents: read + +concurrency: + group: auto-rebase-tests-${{ github.ref }}-${{ github.sha }} + cancel-in-progress: true + +jobs: + test: + name: ShellCheck and bats + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install bats and shellcheck + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats shellcheck + + - name: ShellCheck + run: shellcheck --severity=warning scripts/auto-rebase.sh + + - name: Run bats suite + run: bats --print-output-on-failure test/workflows/auto-rebase/ diff --git a/.github/workflows/claude-code-reusable.yml b/.github/workflows/claude-code-reusable.yml index fb9b25625..93bf675e2 100644 --- a/.github/workflows/claude-code-reusable.yml +++ b/.github/workflows/claude-code-reusable.yml @@ -346,3 +346,100 @@ jobs: - If the comment requires human judgment, leave a reply explaining what you need. 4. Check CI status. If CI fails, read the logs, fix the issues, and push again. Repeat until CI passes. 5. When CI is green, all actionable review comments are resolved, and the PR is ready, read the CODEOWNERS file and leave a comment tagging the relevant code owners to review and merge. + + # Automation mode: agentic rebase — triggered by a repository_dispatch event + # (type: claude-rebase) fired by auto-rebase-reusable.yml when a conflict + # cannot be resolved via the merge strategy. + # Checks out the branch, rebases onto the base branch, resolves conflicts using + # best-effort judgment, and pushes. Posts a summary comment on completion. + # + # Note: repository_dispatch is one of only two event types that GITHUB_TOKEN + # is permitted to trigger new workflow runs for. The caller (auto-rebase.yml + # in each repo) must include `repository_dispatch: types: [claude-rebase]` + # in its `on:` block for this job to receive the event. + claude-rebase: + if: >- + github.event_name == 'repository_dispatch' && + github.event.action == 'claude-rebase' + concurrency: + group: claude-rebase-${{ github.event.client_payload.pr_number }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + id-token: write + pull-requests: write + issues: write + actions: read + checks: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} + - name: Run Claude Code + uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} + # yamllint disable rule:line-length + claude_args: | + --allowedTools "Bash(git*:*),Bash(gh pr checkout:*),Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh api:*),Edit,Write" + # yamllint enable rule:line-length + # yamllint disable rule:line-length + prompt: | + PR #${{ github.event.client_payload.pr_number }} has a merge conflict that the auto-rebase workflow could not resolve using the fast-forward merge strategy. You've been assigned to resolve it via a rebase. + + ## Your task + + ### 1. Configure git identity and check out the PR branch + ``` + git config user.name "claude[bot]" + git config user.email "claude[bot]@users.noreply.github.com" + gh pr checkout ${{ github.event.client_payload.pr_number }} + ``` + + ### 2. Rebase onto the base branch + ``` + git fetch origin + BASE=$(gh pr view ${{ github.event.client_payload.pr_number }} --json baseRefName -q .baseRefName) + git rebase origin/$BASE + ``` + + ### 3. Resolve conflicts intelligently + During a rebase, conflicts are resolved commit-by-commit. For each stopped commit: + + a. For every conflicted file in this commit, resolve it using judgment: + - **Workflow YAML / action pins**: if main already pins the same action to a newer SHA, accept main's version (`git checkout --ours ` — during a rebase, `--ours` is the target branch i.e. main). If the PR's pin is newer or correct, keep it (`git checkout --theirs `). + - **`.gitignore`, config files**: merge both sides — preserve all unique entries from both. + - **Application code**: carefully merge both sides. If too complex to resolve safely, abort (see below). + + b. After **all** conflicted files for this commit are resolved, stage them all at once, then continue: + ``` + git add ... # stage every resolved file for this commit + git rebase --continue # move to the next commit (run once per commit, not per file) + ``` + + c. Repeat steps a–b for each subsequent commit that has conflicts. + + ### 4. Push on success + ``` + git push --force-with-lease + ``` + + ### 5. Post a summary comment on PR #${{ github.event.client_payload.pr_number }} + Summarize: + - Which files had conflicts and how each was resolved + - The rebase outcome (success or failure) + - If any commits were dropped because they were fully superseded by main (0 unique commits remaining), note that the PR may be a candidate for closure + + ## If rebase cannot be completed safely + If conflicts involve complex application code that requires human judgment: + 1. `git rebase --abort` + 2. Post a comment on PR #${{ github.event.client_payload.pr_number }} explaining: + - Which specific files conflict + - Why the conflict needs human resolution + - The exact conflict markers so the author can find them quickly + # yamllint enable rule:line-length diff --git a/scripts/auto-rebase.sh b/scripts/auto-rebase.sh new file mode 100644 index 000000000..eb3872ac4 --- /dev/null +++ b/scripts/auto-rebase.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# auto-rebase.sh — conflict-handling helpers for the auto-rebase workflow. +# Called from .github/workflows/auto-rebase-reusable.yml. +# +# All functions read REPO from the environment and accept explicit arguments +# for the values that vary per PR, making them unit-testable. +# +# Required env: GH_TOKEN, REPO +set -euo pipefail + +# Post the "blocked by workflows permission" comment (idempotent). +# Returns 0 whether it posts or skips. +handle_permissions_block() { + local pr_number="$1" + local base_branch="$2" + + local sentinel="" + local already_posted + already_posted=$(gh pr view "$pr_number" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$sentinel\"))] | length") + if [[ "$already_posted" -gt 0 ]]; then + echo " Skipping — blocked comment already posted" + return 0 + fi + + echo " Posting manual-rebase request (workflows permission missing)" + local body="$sentinel" + body+=$'\n'"**Auto-rebase blocked** — the base branch contains \`.github/workflows/\` changes" + body+=" that require the \`workflows\` permission to merge into this branch," + body+=" but the auto-rebase workflow's token does not have that permission." + body+=$'\n\n'"Please rebase this branch manually:" + body+=$'\n'"\`\`\`"$'\n'"git fetch origin" + body+=$'\n'"git rebase origin/$base_branch" + body+=$'\n'"git push --force-with-lease"$'\n'"\`\`\`" + gh pr comment "$pr_number" --repo "$REPO" --body "$body" +} + +# Post a SHA-keyed conflict sentinel comment and fire a repository_dispatch +# event to trigger the claude-rebase job. Idempotent: skips if a comment with +# this exact sentinel (tied to the current base-branch HEAD SHA) already exists, +# so a new merge to the base branch resets the gate and allows Claude another attempt. +handle_conflict() { + local pr_number="$1" + local head_ref="$2" + local base_branch="$3" + + # First 8 chars of the base branch HEAD SHA — changes with every merge. + local base_sha + base_sha=$(gh api "repos/$REPO/branches/$base_branch" --jq '.commit.sha' | cut -c1-8) + + # Sentinel is SHA-keyed so a new main commit resets idempotency for that PR. + local sentinel="" + local already_posted + already_posted=$(gh pr view "$pr_number" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$sentinel\"))] | length") + if [[ "$already_posted" -gt 0 ]]; then + echo " Skipping — conflict for $base_branch@$base_sha already dispatched" + return 0 + fi + + echo " Dispatching claude-rebase for $base_branch@$base_sha and posting conflict comment" + + # Dispatch FIRST: if this fails the sentinel comment is never posted, + # so the next auto-rebase run for this base SHA can retry. + # repository_dispatch is one of two event types GITHUB_TOKEN IS allowed to + # trigger new workflow runs for (the other being workflow_dispatch). + gh api "repos/$REPO/dispatches" \ + -X POST \ + -f event_type=claude-rebase \ + -F "client_payload[pr_number]=$pr_number" \ + -F "client_payload[head_ref]=$head_ref" \ + -F "client_payload[base_branch]=$base_branch" + + # Post sentinel only after successful dispatch so the idempotency guard + # never permanently blocks retries when a prior dispatch failed. + local body="$sentinel" + body+=$'\n'"**Auto-rebase failed — merge conflict** — this branch conflicts" + body+=" with \`$base_branch\` and cannot be updated via the merge strategy." + body+=$'\n\n'"Claude has been dispatched to attempt an agentic rebase with conflict resolution." + body+=" If Claude's rebase also fails, resolve manually:" + body+=$'\n'"\`\`\`"$'\n'"git fetch origin" + body+=$'\n'"git rebase origin/$base_branch" + body+=$'\n'"# resolve conflicts per file, then for each commit:" + body+=$'\n'"git add "$'\n'"git rebase --continue"$'\n'"git push --force-with-lease"$'\n'"\`\`\`" + gh pr comment "$pr_number" --repo "$REPO" --body "$body" +} diff --git a/test/workflows/auto-rebase/auto-rebase.bats b/test/workflows/auto-rebase/auto-rebase.bats new file mode 100644 index 000000000..01f1ac422 --- /dev/null +++ b/test/workflows/auto-rebase/auto-rebase.bats @@ -0,0 +1,172 @@ +#!/usr/bin/env bats +# Unit tests for scripts/auto-rebase.sh +# +# Tests the two public functions: +# handle_permissions_block — posts a "blocked" comment idempotently +# handle_conflict — posts a SHA-keyed sentinel comment and fires +# repository_dispatch idempotently + +load 'helpers/setup' + +setup() { + ar_make_tmpdir + export REPO="petry-projects/test-repo" + export GH_TOKEN="fake-token" +} + +teardown() { + ar_cleanup_tmpdir +} + +# ─── handle_permissions_block ─────────────────────────────────────────────── + +@test "handle_permissions_block: posts blocked comment when no sentinel exists" { + ar_install_gh_stub "0" # gh pr view → count=0 (sentinel absent) + source "$AR_SCRIPT" + + run handle_permissions_block "42" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_called "pr comment" + ar_assert_gh_called "auto-rebase-blocked" +} + +@test "handle_permissions_block: skips comment when sentinel already present" { + ar_install_gh_stub "1" # gh pr view → count=1 (sentinel present) + source "$AR_SCRIPT" + + run handle_permissions_block "42" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_not_called "pr comment" + [[ "$output" == *"already posted"* ]] +} + +@test "handle_permissions_block: comment body contains git rebase instructions" { + ar_install_gh_stub "0" + source "$AR_SCRIPT" + + handle_permissions_block "7" "main" + + ar_assert_gh_called "git rebase origin" +} + +# ─── handle_conflict ──────────────────────────────────────────────────────── + +# Helper: set up multi-response stub for handle_conflict. +# The stub returns pre-jq-processed values (as real gh would after applying --jq). +# Calls in order: +# 1. gh api repos/.../branches/BASE --jq .commit.sha → raw SHA string +# 2. gh pr view ... --jq "[...] | length" → sentinel count +# 3. gh pr comment (if not skipped) → (empty) +# 4. gh api .../dispatches → (empty) +_setup_conflict_stub() { + local sha="$1" # raw SHA string (as gh --jq '.commit.sha' would return) + local sentinel_count="$2" # "0" or "1" + AR_GH_RESPONSES=("$sha" "$sentinel_count" "" "") + ar_install_multi_gh_stub +} + +@test "handle_conflict: posts comment and dispatches when sentinel absent" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + run handle_conflict "99" "feat/my-branch" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_called "pr comment" + ar_assert_gh_called "dispatches" + ar_assert_gh_called "claude-rebase" +} + +@test "handle_conflict: sentinel contains first-8 chars of base SHA" { + _setup_conflict_stub "deadbeef12345678" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + # The sentinel must appear in the comment call + ar_assert_gh_called "auto-rebase-conflict:deadbeef" +} + +@test "handle_conflict: skips when SHA-keyed sentinel already present" { + _setup_conflict_stub "abc12345def67890" "1" + source "$AR_SCRIPT" + + run handle_conflict "99" "feat/my-branch" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_not_called "pr comment" + ar_assert_gh_not_called "dispatches" + [[ "$output" == *"already dispatched"* ]] +} + +@test "handle_conflict: dispatches with pr_number in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "123" "feat/branch" "main" + + ar_assert_gh_called "pr_number" + ar_assert_gh_called "123" +} + +@test "handle_conflict: dispatches with head_ref in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/my-feature" "main" + + ar_assert_gh_called "head_ref" + ar_assert_gh_called "feat/my-feature" +} + +@test "handle_conflict: dispatches with base_branch in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "develop" + + ar_assert_gh_called "base_branch" + ar_assert_gh_called "develop" +} + +@test "handle_conflict: different SHA → different sentinel, allows new dispatch" { + # Simulate two sequential runs: different base SHA each time → both dispatch + _setup_conflict_stub "aaaaaaa1bbbbbbbb" "0" + source "$AR_SCRIPT" + handle_conflict "7" "feat/x" "main" + local first_count + first_count=$(ar_gh_call_count "dispatches") + + # Second call with a different SHA — reset tmp dir so logs are clean + ar_cleanup_tmpdir + ar_make_tmpdir + _setup_conflict_stub "bbbbbbb1cccccccc" "0" + handle_conflict "7" "feat/x" "main" + local second_count + second_count=$(ar_gh_call_count "dispatches") + + [ "$first_count" -eq 1 ] + [ "$second_count" -eq 1 ] +} + +@test "handle_conflict: comment body contains manual fallback instructions" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + ar_assert_gh_called "git rebase" + ar_assert_gh_called "force-with-lease" +} + +@test "handle_conflict: event_type is claude-rebase" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + ar_assert_gh_called "event_type" + ar_assert_gh_called "claude-rebase" +} diff --git a/test/workflows/auto-rebase/helpers/setup.bash b/test/workflows/auto-rebase/helpers/setup.bash new file mode 100644 index 000000000..fb7cd42b1 --- /dev/null +++ b/test/workflows/auto-rebase/helpers/setup.bash @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Common test helpers for auto-rebase bats suites. + +AR_REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)" +export AR_REPO_ROOT + +AR_SCRIPT="${AR_REPO_ROOT}/scripts/auto-rebase.sh" +export AR_SCRIPT + +ar_make_tmpdir() { + AR_TMP="$(mktemp -d)" + export AR_TMP +} + +ar_cleanup_tmpdir() { + if [ -n "${AR_TMP:-}" ] && [ -d "${AR_TMP}" ]; then + rm -rf "${AR_TMP}" + fi +} + +# Install a recording gh stub on PATH. +# Args: +# $1 — value to echo on stdout for every call (default: empty) +# $2 — exit code for every call (default: 0) +# The stub logs each invocation to $AR_GH_LOG (one line per call, argv tab-separated). +ar_install_gh_stub() { + local stdout="${1:-}" + local exit_code="${2:-0}" + local stub_dir="${AR_TMP}/bin" + mkdir -p "$stub_dir" + AR_GH_LOG="${AR_TMP}/gh.log" + export AR_GH_LOG + # Write the fixed stdout/exit values into files so the stub can read them at + # runtime without relying on env vars that may not survive the function call. + echo -n "$stdout" > "${AR_TMP}/gh-stdout" + echo -n "$exit_code" > "${AR_TMP}/gh-exit" + # Expand AR_TMP now (double-quote around heredoc delimiter) so the stub + # path is baked in at install time. + # shellcheck disable=SC2086 + cat >"$stub_dir/gh" <> "${AR_TMP}/gh.log" +printf '\n' >> "${AR_TMP}/gh.log" +cat "${AR_TMP}/gh-stdout" +exit \$(cat "${AR_TMP}/gh-exit") +STUB + chmod +x "$stub_dir/gh" + PATH="${stub_dir}:${PATH}" + export PATH +} + +# Install a multi-response gh stub driven by AR_GH_RESPONSES (bash array). +# Each element is echoed as the response for successive gh invocations. +ar_install_multi_gh_stub() { + local stub_dir="${AR_TMP}/bin" + mkdir -p "$stub_dir" + AR_GH_LOG="${AR_TMP}/gh.log" + export AR_GH_LOG + local counter_file="${AR_TMP}/.gh-counter" + local responses_file="${AR_TMP}/gh-responses" + echo "0" > "$counter_file" + # One response per line (elements must not contain literal newlines). + printf '%s\n' "${AR_GH_RESPONSES[@]}" > "$responses_file" + # shellcheck disable=SC2086 + cat >"$stub_dir/gh" <> "${AR_TMP}/gh.log" +printf '\n' >> "${AR_TMP}/gh.log" +n=\$(cat "${counter_file}") +sed -n "\$((n+1))p" "${responses_file}" +echo \$((n+1)) > "${counter_file}" +exit 0 +STUB + chmod +x "$stub_dir/gh" + PATH="${stub_dir}:${PATH}" + export PATH +} + +# Count how many times gh was called with a substring match on the full argv line. +ar_gh_call_count() { + local pattern="$1" + local count + # grep -c prints 0 even on no match; exit code 1 means no match (not error). + count=$(grep -c "$pattern" "${AR_GH_LOG:-/dev/null}" 2>/dev/null) || count=0 + echo "$count" +} + +# Assert gh was called at least once with the given pattern. +ar_assert_gh_called() { + local pattern="$1" + local count + count=$(ar_gh_call_count "$pattern") + if [[ "$count" -eq 0 ]]; then + echo "Expected gh to be called with pattern '$pattern' but it was not." >&2 + echo "Actual calls:" >&2 + cat "${AR_GH_LOG:-/dev/null}" >&2 + return 1 + fi +} + +# Assert gh was NOT called with the given pattern. +ar_assert_gh_not_called() { + local pattern="$1" + local count + count=$(ar_gh_call_count "$pattern") + if [[ "$count" -gt 0 ]]; then + echo "Expected gh NOT to be called with pattern '$pattern' but it was ($count times)." >&2 + return 1 + fi +}