Claude Issue Implementation #2911
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Claude Issue Implementation | |
| # Serialized, durable runner for issue implementation. | |
| # | |
| # Requests are enqueued by claude-issue-enqueue.yml, which adds the | |
| # `impl:queued` label (a durable queue entry). This runner drains that queue | |
| # ONE issue at a time: | |
| # | |
| # * concurrency group `claude-impl` (cancel-in-progress: false) guarantees | |
| # no two implementation runs — across this runner, claude-batch-fix, and | |
| # claude-stale-check — ever execute at the same time, preventing | |
| # conflicting branches/PRs against main. | |
| # * The queue lives in labels, not in GitHub's pending-run slot, so nothing | |
| # is ever silently dropped when many requests arrive at once (the failure | |
| # that stranded issue #1039). | |
| # | |
| # Draining is driven by three triggers: the `labeled` event (fast path), | |
| # a periodic `schedule` (backstop so the queue can never stall), and | |
| # `workflow_dispatch` (self re-dispatch after each item + manual kick). | |
| on: | |
| issues: | |
| types: [labeled] | |
| schedule: | |
| # Backstop: drain anything left in the queue even if a labeled/dispatch | |
| # event was lost or evicted. Cheap no-op when the queue is empty. | |
| - cron: "*/10 * * * *" | |
| workflow_dispatch: | |
| jobs: | |
| claude: | |
| # Only react to the queue label (or schedule / manual / self re-dispatch). | |
| # Authorization is enforced at enqueue time; by the time `impl:queued` | |
| # exists the request is already vetted. | |
| if: | | |
| github.event_name != 'issues' || github.event.label.name == 'impl:queued' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| # The single global implementation lock. Shared with claude-batch-fix and | |
| # claude-stale-check so main-mutating automation is strictly serialized. | |
| concurrency: | |
| group: claude-impl | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| id-token: write | |
| env: | |
| MIX_ENV: test | |
| # The "Pick next queued issue" step runs before checkout, so gh can't | |
| # infer the repo from a local .git — GH_REPO targets it explicitly. | |
| GH_REPO: ${{ github.repository }} | |
| steps: | |
| - name: Check PAT availability | |
| run: | | |
| if [ -z "${{ secrets.PAT_WORKFLOW_TRIGGER }}" ]; then | |
| echo "::warning::PAT_WORKFLOW_TRIGGER secret not set - PRs won't trigger CI automatically" | |
| fi | |
| # Claim the oldest queued issue. Flips impl:queued -> impl:running so a | |
| # concurrent enqueue can't double-process it. Exits cleanly (has_work=false) | |
| # when the queue is empty, which makes schedule/spurious triggers no-ops. | |
| - name: Pick next queued issue | |
| id: pick | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| run: | | |
| set -euo pipefail | |
| # FIFO-ish: oldest by issue number among queued, not-yet-running issues. | |
| NEXT=$(gh issue list \ | |
| --repo "${{ github.repository }}" \ | |
| --label "impl:queued" \ | |
| --state open \ | |
| --json number,labels \ | |
| --jq '[ .[] | select([.labels[].name] | index("impl:running") | not) ] | |
| | sort_by(.number) | .[0].number // empty') | |
| if [ -z "${NEXT:-}" ]; then | |
| echo "Queue empty — nothing to implement." | |
| echo "has_work=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Claiming issue #$NEXT" | |
| gh issue edit "$NEXT" --add-label "impl:running" --remove-label "impl:queued" | |
| echo "has_work=true" >> "$GITHUB_OUTPUT" | |
| echo "issue=$NEXT" >> "$GITHUB_OUTPUT" | |
| # Expose to all later steps as $ISSUE_NUMBER / env.ISSUE_NUMBER. | |
| echo "ISSUE_NUMBER=$NEXT" >> "$GITHUB_ENV" | |
| - name: Record implementation claim | |
| id: claim | |
| if: steps.pick.outputs.has_work == 'true' | |
| continue-on-error: true | |
| timeout-minutes: 3 | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| run: | | |
| set -euo pipefail | |
| CLAIMED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" | |
| CURRENT_BODY=$(gh issue view "$ISSUE_NUMBER" --json body --jq '.body // ""') | |
| CLEAN_BODY=$(printf '%s\n' "$CURRENT_BODY" | sed '/^## Automation Claim/,$d') | |
| cat > /tmp/issue-body.md << EOF | |
| $CLEAN_BODY | |
| ## Automation Claim | |
| <!-- automation-claim: {"status":"RUNNING","issue":${ISSUE_NUMBER},"run_id":${GITHUB_RUN_ID},"run_attempt":${GITHUB_RUN_ATTEMPT},"run_url":"${RUN_URL}","claimed_at":"${CLAIMED_AT}"} --> | |
| | Field | Value | | |
| |-------|-------| | |
| | Status | \`RUNNING\` | | |
| | Run | [${GITHUB_RUN_ID}](${RUN_URL}) | | |
| | Attempt | ${GITHUB_RUN_ATTEMPT} | | |
| | Claimed At | ${CLAIMED_AT} | | |
| EOF | |
| gh issue edit "$ISSUE_NUMBER" --body-file /tmp/issue-body.md | |
| - name: Check issue still needs implementation | |
| id: issue-state | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' | |
| continue-on-error: true | |
| timeout-minutes: 3 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| ISSUE_STATE=$(gh issue view "$ISSUE_NUMBER" --json state --jq '.state') | |
| if [ "$ISSUE_STATE" != "OPEN" ]; then | |
| echo "Issue #$ISSUE_NUMBER is $ISSUE_STATE; skipping implementation." | |
| echo "should_run=false" >> "$GITHUB_OUTPUT" | |
| echo "skip_reason=issue-${ISSUE_STATE}" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| MERGED_PRS=$(gh pr list \ | |
| --repo "${{ github.repository }}" \ | |
| --state merged \ | |
| --limit 100 \ | |
| --json number,body \ | |
| | jq --arg issue "$ISSUE_NUMBER" '[.[] | select((.body // "") | test("(?i)(closes|fixes|resolves)[[:space:]]+#" + $issue + "([^0-9]|$)"))] | length') | |
| if [ "${MERGED_PRS:-0}" -gt 0 ]; then | |
| echo "Issue #$ISSUE_NUMBER already has a merged closing PR; skipping implementation." | |
| echo "should_run=false" >> "$GITHUB_OUTPUT" | |
| echo "skip_reason=merged-pr-exists" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "should_run=true" >> "$GITHUB_OUTPUT" | |
| - name: Checkout repository | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| - name: Install git pre-commit hook | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| timeout-minutes: 3 | |
| run: | | |
| cp scripts/pre-commit .git/hooks/pre-commit | |
| chmod +x .git/hooks/pre-commit | |
| - name: Setup Elixir environment | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| uses: ./.github/actions/setup-elixir | |
| - name: Setup Claude Code | |
| id: setup-claude | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| uses: ./.github/actions/setup-claude-code | |
| - name: Run tests and capture failures | |
| id: test-feedback | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| continue-on-error: true | |
| timeout-minutes: 15 | |
| run: | | |
| echo "Running tests to capture any failures..." | |
| FAILURES="" | |
| # Compile | |
| echo "=== Compiling ===" | tee /tmp/test_output.txt | |
| if ! mix compile --warnings-as-errors 2>&1 | tee -a /tmp/test_output.txt; then | |
| FAILURES="${FAILURES}COMPILE_FAILED " | |
| fi | |
| # Tests (limit failures for manageable output) | |
| echo "" >> /tmp/test_output.txt | |
| echo "=== Running tests ===" | tee -a /tmp/test_output.txt | |
| if ! mix test --max-failures 5 --warnings-as-errors 2>&1 | tee -a /tmp/test_output.txt; then | |
| FAILURES="${FAILURES}TESTS_FAILED " | |
| fi | |
| # Format check | |
| echo "" >> /tmp/test_output.txt | |
| echo "=== Checking format ===" | tee -a /tmp/test_output.txt | |
| if ! mix format --check-formatted 2>&1 | tee -a /tmp/test_output.txt; then | |
| FAILURES="${FAILURES}FORMAT_FAILED " | |
| fi | |
| # Credo | |
| echo "" >> /tmp/test_output.txt | |
| echo "=== Running Credo ===" | tee -a /tmp/test_output.txt | |
| if ! mix credo --strict 2>&1 | tee -a /tmp/test_output.txt; then | |
| FAILURES="${FAILURES}CREDO_FAILED " | |
| fi | |
| # Capture output for Claude | |
| if [ -n "$FAILURES" ]; then | |
| echo "has_failures=true" >> "$GITHUB_OUTPUT" | |
| echo "failures=$FAILURES" >> "$GITHUB_OUTPUT" | |
| # Truncate output if too long (keep last 200 lines) | |
| tail -200 /tmp/test_output.txt > /tmp/test_output_truncated.txt | |
| # Store output in multiline format | |
| { | |
| echo 'output<<EOF' | |
| cat /tmp/test_output_truncated.txt | |
| echo 'EOF' | |
| } >> "$GITHUB_OUTPUT" | |
| echo "::warning::Test failures detected: $FAILURES" | |
| else | |
| echo "has_failures=false" >> "$GITHUB_OUTPUT" | |
| echo "All checks passed" | |
| fi | |
| - name: Run Claude Code | |
| id: claude | |
| if: steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| timeout-minutes: 30 | |
| uses: anthropics/claude-code-action@v1 | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| # Used by E2E tests that call LLM APIs | |
| OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| with: | |
| allowed_bots: "claude" | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| # Use PAT for git operations to avoid GitHub App workflow permission issues | |
| github_token: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| path_to_claude_code_executable: ${{ steps.setup-claude.outputs.executable-path }} | |
| prompt: | | |
| Implement issue #${{ steps.pick.outputs.issue }}. | |
| ## Step 1: Read Issue and Check Dependencies | |
| ```bash | |
| gh issue view ${{ steps.pick.outputs.issue }} --comments | |
| ``` | |
| **Check for "Blocked by:" in the issue body.** If blocking issues exist: | |
| 1. Check if each blocking issue is closed: `gh issue view BLOCKER_NUMBER --json state` | |
| 2. If ANY blocker is still open: | |
| - Post status comment (see Step 5) with result: BLOCKED | |
| - Remove label: `gh issue edit ${{ steps.pick.outputs.issue }} --remove-label "ready-for-implementation"` | |
| - Stop here - do not implement | |
| ## Step 2: Get Epic Context (if part of an epic) | |
| Check issue labels for `epic:*` label. If present: | |
| ```bash | |
| # Find the active epic | |
| gh issue list --label "type:epic" --label "status:active" --state open --json number,title,body --limit 1 | |
| ``` | |
| Read the epic to understand the broader context and what's been completed. | |
| ## Step 3: Scope Check (IMPORTANT) | |
| Before implementing, assess whether the issue scope is appropriate: | |
| **Signs of scope creep (STOP and report):** | |
| - Changes spanning multiple unrelated modules or subsystems | |
| - Discovering substantial prerequisite work not mentioned in the issue | |
| - The implementation feels like 2-3 separate issues bundled together | |
| - Non-mechanical changes touching areas unrelated to the issue's focus | |
| **NOT scope creep (proceed normally):** | |
| - Mechanical changes across many files (renames, import updates, type fixes) | |
| - Related changes that naturally flow from the core implementation | |
| - Test files matching the implementation scope | |
| When stopping for scope issues: | |
| 1. Post status (see Step 6) with result: INCOMPLETE | |
| 2. Add `needs-breakdown` label and explain how to split | |
| 3. If discovered blocker: | |
| - Create issue with `discovered-blocker` AND `needs-review` labels | |
| - Update this issue's "Blocked by:" section | |
| - Remove `ready-for-implementation` from this issue | |
| ## Step 4: Check for Existing PR (Duplicate Detection) | |
| Before creating a new PR, check if one already exists: | |
| ```bash | |
| EXISTING_PR=$(gh pr list --head "claude/${{ steps.pick.outputs.issue }}-" --state open --json number,url --jq '.[0]') | |
| ``` | |
| If a PR exists: | |
| - Update the existing PR instead of creating a new one | |
| - Push to the existing branch | |
| - Post a comment noting continued work | |
| ## Step 5: Implement | |
| ${{ steps.test-feedback.outputs.has_failures == 'true' && format('### Current Test Failures | |
| The following checks are currently failing: {0} | |
| ``` | |
| {1} | |
| ``` | |
| **Be aware of these existing failures. If related to your changes, fix them.** | |
| ', steps.test-feedback.outputs.failures, steps.test-feedback.outputs.output) || '' }} | |
| **Guidelines:** | |
| - Keep it simple - only implement what's requested | |
| - Run `mix precommit` before committing - NEVER use `--no-verify` | |
| - Create PR with "Closes #${{ steps.pick.outputs.issue }}" in body | |
| - Use branch naming: `claude/${{ steps.pick.outputs.issue }}-short-description` | |
| **Protected files - changes require justification:** | |
| - `docs/specs/*.md` - Specifications (source of truth) | |
| - `docs/guidelines/*.md` - Process documentation | |
| - `.github/workflows/*.yml` - Automation workflows | |
| - `.credo.exs`, `.formatter.exs` - Linter configs | |
| If you MUST change these, explain why in the PR description. | |
| ## TODO and Skip Tag Protocol (IMPORTANT) | |
| When you need to add TODOs, skip tests, or disable linting: | |
| **Step 1: Search for existing issues first** | |
| ```bash | |
| gh issue list --search "keyword describing the problem" --state open --limit 5 | |
| ``` | |
| **Step 2: Create issue if none exists** | |
| ```bash | |
| gh issue create --title "[Tech Debt] Brief description" \ | |
| --label "tech-debt" --label "from-pr-review" \ | |
| --body "Source: \`path/to/file.ex:LINE\` | |
| Description of what needs to be fixed/cleaned up. | |
| Found during implementation of #${{ steps.pick.outputs.issue }}." | |
| ``` | |
| **Step 3: Reference issue in code (REQUIRED)** | |
| ```elixir | |
| # TODO(#123): Explanation of what needs to be done | |
| # FIXME(#123): Explanation of the bug | |
| @tag :skip # Skipped: #123 - reason why test is skipped | |
| # credo:disable-for-next-line Credo.Check.Name - #123 | |
| ``` | |
| **NEVER add TODO/FIXME/@tag :skip without an issue reference.** | |
| ## Step 6: MANDATORY Status Update (ALWAYS DO THIS) | |
| You MUST update the issue body with automation state. This is required | |
| even if you encounter errors or cannot complete the implementation. | |
| First, get the current issue body, then append/update the automation state section: | |
| ```bash | |
| # Get current body and update automation state | |
| CURRENT_BODY=$(gh issue view ${{ steps.pick.outputs.issue }} --json body --jq '.body') | |
| # Remove old automation state if present | |
| CLEAN_BODY=$(echo "$CURRENT_BODY" | sed '/^## Automation State/,/^## /{ /^## Automation State/d; /^## /!d; }' | sed '/<!-- automation-state:/d') | |
| # Create new body with automation state | |
| cat > /tmp/issue-body.md << 'ISSUE_EOF' | |
| $CLEAN_BODY | |
| ## Automation State | |
| <!-- automation-state: {"status":"[SUCCESS|INCOMPLETE|BLOCKED]","pr":null,"branch":"claude/${{ steps.pick.outputs.issue }}-xxx","attempts":1} --> | |
| | Field | Value | | |
| |-------|-------| | |
| | Status | `[SUCCESS\|INCOMPLETE\|BLOCKED]` | | |
| | PR | #NNN or None | | |
| | Branch | `claude/${{ steps.pick.outputs.issue }}-xxx` | | |
| | Attempts | 1 | | |
| **Details:** [What was done, what remains, blockers discovered] | |
| **Next Steps:** [If not SUCCESS: what needs to happen] | |
| ISSUE_EOF | |
| gh issue edit ${{ steps.pick.outputs.issue }} --body-file /tmp/issue-body.md | |
| ``` | |
| **Status meanings:** | |
| - SUCCESS: PR created, all work complete | |
| - INCOMPLETE: Stopped due to scope/complexity/errors (explain why) | |
| - BLOCKED: Open dependencies prevent implementation | |
| NEVER include "@claude" in comments. | |
| claude_args: '--model claude-opus-4-8 --max-turns 300 --allowed-tools "Read,Write,Edit,MultiEdit,Glob,Grep,LS,Bash,WebSearch,WebFetch,Task,TodoWrite,TodoRead"' | |
| show_full_output: true | |
| # Verify status was updated in issue body | |
| - name: Verify implementation status | |
| if: always() && steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| id: verify-status | |
| timeout-minutes: 3 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Check if automation state exists in issue body | |
| ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --json body --jq '.body') | |
| if echo "$ISSUE_BODY" | grep -q "<!-- automation-state:"; then | |
| echo "status_posted=true" >> "$GITHUB_OUTPUT" | |
| # Extract status from JSON | |
| STATUS_JSON=$(echo "$ISSUE_BODY" | grep -o '<!-- automation-state: {[^}]*}' | sed 's/<!-- automation-state: //') | |
| RESULT=$(echo "$STATUS_JSON" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) | |
| echo "result=$RESULT" >> "$GITHUB_OUTPUT" | |
| echo "Implementation result: $RESULT" | |
| else | |
| echo "status_posted=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::No automation state found in issue body" | |
| fi | |
| # Post fallback status if Claude didn't update issue body | |
| - name: Post fallback status | |
| if: always() && steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' && steps.verify-status.outputs.status_posted == 'false' | |
| timeout-minutes: 5 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Check for branch and PR | |
| BRANCH_NAME="claude/${ISSUE_NUMBER}-" | |
| BRANCH=$(git branch -r --list "origin/${BRANCH_NAME}*" | head -1 | tr -d ' ') | |
| BRANCH_SHORT=$(echo "$BRANCH" | sed 's|origin/||' | tr -d ' ') | |
| PR_NUMBER="" | |
| if [ -n "$BRANCH_SHORT" ]; then | |
| PR_NUMBER=$(gh pr list --head "$BRANCH_SHORT" --json number --jq '.[0].number' 2>/dev/null || echo "") | |
| fi | |
| # Get current issue body | |
| CURRENT_BODY=$(gh issue view "$ISSUE_NUMBER" --json body --jq '.body') | |
| # Remove any partial automation state | |
| CLEAN_BODY=$(echo "$CURRENT_BODY" | sed '/^## Automation State/,$d') | |
| # Create updated body with fallback status | |
| cat > /tmp/issue-body.md << EOF | |
| $CLEAN_BODY | |
| ## Automation State | |
| <!-- automation-state: {"status":"INCOMPLETE","pr":${PR_NUMBER:-null},"branch":"${BRANCH_SHORT:-null}","attempts":1,"interrupted":true} --> | |
| | Field | Value | | |
| |-------|-------| | |
| | Status | \`INCOMPLETE\` | | |
| | PR | ${PR_NUMBER:-None} | | |
| | Branch | \`${BRANCH_SHORT:-None}\` | | |
| | Attempts | 1 | | |
| **Details:** Workflow interrupted before completion (timeout or error). Check workflow logs. | |
| **Next Steps:** Re-trigger with \`@claude Please continue implementing this issue.\` | |
| EOF | |
| gh issue edit "$ISSUE_NUMBER" --body-file /tmp/issue-body.md | |
| # Add needs-attention label | |
| gh issue edit "$ISSUE_NUMBER" --add-label "needs-attention" || true | |
| # Safety net: push any unpushed commits and remove workflow files | |
| - name: Push unpushed commits | |
| if: always() && steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| timeout-minutes: 5 | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| run: | | |
| ISSUE_STATE=$(gh issue view "$ISSUE_NUMBER" --json state --jq '.state') | |
| if [ "$ISSUE_STATE" != "OPEN" ]; then | |
| echo "Issue #$ISSUE_NUMBER is $ISSUE_STATE; skipping fallback push." | |
| exit 0 | |
| fi | |
| CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) | |
| [ "$CURRENT_BRANCH" = "main" ] && exit 0 | |
| # Re-configure git auth (Claude may have changed it) | |
| git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" | |
| # Remove workflow files from any unpushed commits (GitHub App tokens can't push these) | |
| REWROTE_COMMIT=false | |
| if git log origin/main..HEAD --name-only --pretty=format: 2>/dev/null | grep -q "^\.github/workflows/"; then | |
| echo "Removing workflow files from commit..." | |
| COMMIT_MSG=$(git log -1 --format=%s) | |
| git reset HEAD~1 --soft | |
| git checkout origin/main -- .github/workflows/ 2>/dev/null || true | |
| git add -A | |
| git reset HEAD -- .github/workflows/ 2>/dev/null || true | |
| if git commit -m "$COMMIT_MSG" 2>/dev/null; then | |
| REWROTE_COMMIT=true | |
| else | |
| echo "No changes after removing workflow files" | |
| fi | |
| fi | |
| # Push if there are unpushed commits | |
| if git log origin/main..HEAD --oneline 2>/dev/null | grep -q .; then | |
| echo "Pushing unpushed commits..." | |
| if [ "$REWROTE_COMMIT" = "true" ]; then | |
| echo "Using force-with-lease (commit was rewritten to remove workflow files)" | |
| git push --force-with-lease -u origin HEAD | |
| else | |
| git push -u origin HEAD | |
| fi | |
| echo "branch_pushed=true" >> "$GITHUB_OUTPUT" | |
| echo "branch_name=$CURRENT_BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Pushed commits" | |
| fi | |
| - name: Create PR if branch was pushed but no PR exists | |
| if: always() && steps.pick.outputs.has_work == 'true' && steps.claim.outcome == 'success' && steps.issue-state.outcome == 'success' && steps.issue-state.outputs.should_run == 'true' | |
| timeout-minutes: 5 | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| run: | | |
| ISSUE_STATE=$(gh issue view "$ISSUE_NUMBER" --json state --jq '.state') | |
| if [ "$ISSUE_STATE" != "OPEN" ]; then | |
| echo "Issue #$ISSUE_NUMBER is $ISSUE_STATE; skipping fallback PR creation." | |
| exit 0 | |
| fi | |
| CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) | |
| [ "$CURRENT_BRANCH" = "main" ] && exit 0 | |
| # Check if PR already exists for this branch | |
| EXISTING_PR=$(gh pr list --head "$CURRENT_BRANCH" --state all --json number --jq '.[0].number') | |
| MERGED_CLOSING_PR=$(gh pr list \ | |
| --repo "${{ github.repository }}" \ | |
| --state merged \ | |
| --limit 100 \ | |
| --json number,body \ | |
| | jq -r --arg issue "$ISSUE_NUMBER" '[.[] | select((.body // "") | test("(?i)(closes|fixes|resolves)[[:space:]]+#" + $issue + "([^0-9]|$)"))][0].number // empty') | |
| if [ -n "$MERGED_CLOSING_PR" ]; then | |
| echo "Merged closing PR #$MERGED_CLOSING_PR already exists; skipping fallback PR creation." | |
| exit 0 | |
| fi | |
| if [ -z "$EXISTING_PR" ]; then | |
| echo "No PR exists for branch $CURRENT_BRANCH, creating one..." | |
| # Get issue title for PR title | |
| ISSUE_TITLE=$(gh issue view ${{ env.ISSUE_NUMBER }} --json title --jq '.title') | |
| gh pr create \ | |
| --head "$CURRENT_BRANCH" \ | |
| --base main \ | |
| --title "$ISSUE_TITLE" \ | |
| --body "$(cat <<EOF | |
| Closes #${{ env.ISSUE_NUMBER }} | |
| This PR was created by the fallback mechanism after Claude's push succeeded | |
| but PR creation failed (likely due to GitHub App permission issues). | |
| 🤖 Generated with [Claude Code](https://claude.com/claude-code) | |
| EOF | |
| )" || echo "::warning::Could not create PR" | |
| else | |
| echo "PR #$EXISTING_PR already exists for branch $CURRENT_BRANCH" | |
| fi | |
| # Release the claim and keep the queue draining. Runs even on failure so a | |
| # crashed item never stays stuck as impl:running. Re-dispatch is a fast | |
| # path; the */10 schedule is the guaranteed backstop. | |
| - name: Release claim and drain next | |
| if: always() && steps.pick.outputs.has_work == 'true' | |
| timeout-minutes: 5 | |
| env: | |
| GH_TOKEN: ${{ secrets.PAT_WORKFLOW_TRIGGER || github.token }} | |
| run: | | |
| set -euo pipefail | |
| ISSUE_STATE=$(gh issue view "$ISSUE_NUMBER" --json state --jq '.state') | |
| if [ "${{ steps.claim.outcome }}" != "success" ] || [ "${{ steps.issue-state.outcome }}" != "success" ]; then | |
| if [ "$ISSUE_STATE" = "OPEN" ]; then | |
| echo "Pre-implementation setup failed; requeueing issue #$ISSUE_NUMBER." | |
| gh issue edit "$ISSUE_NUMBER" \ | |
| --remove-label "impl:running" \ | |
| --add-label "impl:queued" 2>/dev/null || true | |
| else | |
| echo "Pre-implementation setup failed, but issue #$ISSUE_NUMBER is $ISSUE_STATE; removing running claim." | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "impl:running" 2>/dev/null || true | |
| fi | |
| else | |
| gh issue edit "$ISSUE_NUMBER" --remove-label "impl:running" 2>/dev/null || true | |
| fi | |
| REMAINING=$(gh issue list \ | |
| --repo "${{ github.repository }}" \ | |
| --label "impl:queued" --state open \ | |
| --json number --jq 'length') | |
| echo "Released issue #$ISSUE_NUMBER. Queue depth now: ${REMAINING:-0}." | |
| if [ "${REMAINING:-0}" -gt 0 ]; then | |
| echo "Re-dispatching runner to drain next queued issue..." | |
| gh workflow run claude-issue.yml --repo "${{ github.repository }}" \ | |
| || echo "::warning::Self re-dispatch failed; the */10 schedule will drain the queue." | |
| fi |