diff --git a/.github/workflows/auto-fix-ci.yml b/.github/workflows/auto-fix-ci.yml new file mode 100644 index 0000000..cd547eb --- /dev/null +++ b/.github/workflows/auto-fix-ci.yml @@ -0,0 +1,252 @@ +name: Auto Fix CI Failures + +# NOTE: This workflow only works for PRs from branches in the same repo. +# Fork PRs cannot be auto-fixed because GITHUB_TOKEN lacks push access to forks. + +on: + workflow_run: + workflows: ["Validate"] + types: + - completed + +permissions: + contents: write + pull-requests: write + actions: read + issues: write + +jobs: + auto-fix: + # Only run when: + # 1. The Validate workflow failed + # 2. There is an associated PR + # 3. The branch was created by Claude (starts with 'claude/') + if: | + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.pull_requests[0] && + startsWith(github.event.workflow_run.head_branch, 'claude/') + runs-on: ubuntu-latest + steps: + - name: Check if PR is still open + id: pr_check + uses: actions/github-script@v7 + with: + script: | + const pullRequests = ${{ toJSON(github.event.workflow_run.pull_requests) }}; + if (!pullRequests || pullRequests.length === 0) { + console.log('No pull requests found'); + return { isOpen: false, prNumber: null }; + } + const prNumber = pullRequests[0].number; + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + return { isOpen: pr.data.state === 'open', prNumber: prNumber }; + + - name: Checkout code + if: fromJSON(steps.pr_check.outputs.result).isOpen + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + fetch-depth: 10 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for recent auto-fix commits (prevent loops) + if: fromJSON(steps.pr_check.outputs.result).isOpen + id: check_loop + run: | + # Check last 3 commits for auto-fix tag to prevent infinite loops. + # We look back 3 commits as a lightweight safeguard: this is enough to catch + # any recent auto-fix pushes triggered by this workflow without scanning the full history. + RECENT_COMMITS=$(git log -3 --pretty=%s) + if echo "$RECENT_COMMITS" | grep -q "\[auto-fix\]"; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "Recent commit was an auto-fix, skipping to prevent loop" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Get CI failure details + if: | + fromJSON(steps.pr_check.outputs.result).isOpen && + steps.check_loop.outputs.skip != 'true' + id: failure_details + uses: actions/github-script@v7 + with: + script: | + const run = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }} + }); + + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }} + }); + + const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure'); + + if (failedJobs.length === 0) { + return { + runUrl: run.data.html_url, + failedJobs: [], + errorLogs: [], + hasFailedJobs: false + }; + } + + let errorLogs = []; + for (const job of failedJobs) { + try { + const logs = await github.rest.actions.downloadJobLogsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + job_id: job.id + }); + // Truncate logs to 50000 characters to stay within Claude's context window + // and GitHub API response size limits + errorLogs.push({ + jobName: job.name, + logs: logs.data.substring(0, 50000) + }); + } catch (e) { + errorLogs.push({ + jobName: job.name, + logs: `Failed to retrieve logs: ${e.message}` + }); + } + } + + return { + runUrl: run.data.html_url, + failedJobs: failedJobs.map(j => j.name), + errorLogs: errorLogs, + hasFailedJobs: true + }; + + - name: Fix CI failures with Claude + if: | + fromJSON(steps.pr_check.outputs.result).isOpen && + steps.check_loop.outputs.skip != 'true' && + fromJSON(steps.failure_details.outputs.result).hasFailedJobs + id: claude + uses: anthropics/claude-code-action@v1 + with: + prompt: | + The CI workflow "Validate" has failed on a PR that you (Claude) contributed to. + Please analyze the failure and fix the issues. + + ## Failure Information + - Failed CI Run: ${{ fromJSON(steps.failure_details.outputs.result).runUrl }} + - Failed Jobs: ${{ join(fromJSON(steps.failure_details.outputs.result).failedJobs, ', ') }} + - PR Number: ${{ fromJSON(steps.pr_check.outputs.result).prNumber }} + - Branch: ${{ github.event.workflow_run.head_branch }} + - Repository: ${{ github.repository }} + + ## CI Workflow Steps + The Validate workflow runs these checks: + 1. `uv run ruff format --check src/ tests/` - Code formatting + 2. `uv run ruff check src/ tests/` - Linting + 3. `uv run pytest tests/ -v` - Tests + + ## Your Task + 1. Analyze the error logs below to understand what failed + 2. Fix the issues in the code: + - For formatting errors: Run `uv run ruff format src/ tests/` + - For linting errors: Fix the code issues reported by ruff + - For test failures: Fix the failing tests or the code they're testing + 3. Commit your fixes with a message that includes "[auto-fix]" tag + 4. Push the changes to the branch: ${{ github.event.workflow_run.head_branch }} + + IMPORTANT: Your commit message MUST include "[auto-fix]" to prevent infinite loops. + Example: "Fix linting errors in parser module [auto-fix]" + + ## Error Logs + ``` + ${{ toJSON(fromJSON(steps.failure_details.outputs.result).errorLogs) }} + ``` + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--allowedTools Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash(git commit:*),Bash(git push:*),Bash(git status:*),Bash(git diff:*),Bash(uv:*),Bash(python:*),Bash(pytest:*),Bash(ruff:*)" + + - name: Comment on PR with fix status + if: | + always() && + fromJSON(steps.pr_check.outputs.result).isOpen && + steps.check_loop.outputs.skip != 'true' && + steps.failure_details.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + // Safely access pull request number with null check + const pullRequests = ${{ toJSON(github.event.workflow_run.pull_requests) }}; + if (!pullRequests || pullRequests.length === 0) { + console.log('No pull request found, skipping comment'); + return; + } + const prNumber = pullRequests[0].number; + + // Safely access failure details with null check and JSON validation + const failureDetails = '${{ steps.failure_details.outputs.result }}'; + if (!failureDetails || failureDetails.trim() === '' || failureDetails === 'undefined' || failureDetails === 'null') { + console.log('No failure details available, skipping comment'); + return; + } + let failureData; + try { + failureData = JSON.parse(failureDetails); + } catch (e) { + console.log('Failed to parse failure details:', e); + return; + } + + const claudeOutcome = '${{ steps.claude.outcome }}'; + const hasFailedJobs = failureData.hasFailedJobs; + const runUrl = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'; + const failureUrl = failureData.runUrl; + + let body; + if (!hasFailedJobs) { + body = [ + '## CI Auto-Fix Skipped', + '', + 'The CI workflow failed but no individual jobs failed (possibly a setup/infrastructure issue).', + '', + `- **Fix workflow run**: ${runUrl}`, + `- **Original failure**: ${failureUrl}`, + '', + 'Manual investigation may be required.' + ].join('\n'); + } else if (claudeOutcome === 'success') { + body = [ + '## CI Auto-Fix Attempted', + '', + 'Claude has analyzed the CI failure and attempted to fix the issues.', + '', + `- **Fix workflow run**: ${runUrl}`, + `- **Original failure**: ${failureUrl}`, + '', + 'Please review the changes pushed to this branch.' + ].join('\n'); + } else { + body = [ + '## CI Auto-Fix Failed', + '', + 'Claude attempted to fix the CI failure but encountered issues.', + '', + `- **Fix workflow run**: ${runUrl}`, + `- **Original failure**: ${failureUrl}`, + '', + 'Manual intervention may be required.' + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + });