feat: integrate stillriver-ai-workflows action #42
Workflow file for this run
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: AI PR Review | |
| # Centralized timeout configuration | |
| env: | |
| AI_EXECUTION_TIMEOUT_MINUTES: ${{ vars.AI_EXECUTION_TIMEOUT_MINUTES || '10' }} | |
| # Triggers: | |
| # - When a PR is first opened or moves from draft to ready for review | |
| # - When ai-review-needed label is added to a PR | |
| # - When someone comments "/review" on a PR | |
| # Note: Removed 'synchronize' trigger to avoid running on every commit | |
| # Note: Bot comments are explicitly filtered to prevent infinite loops | |
| on: | |
| pull_request: | |
| types: [opened, labeled] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| checks: read | |
| actions: read | |
| # NOTE: This workflow uses GITHUB_TOKEN for GitHub API operations | |
| # Uses declared permissions above for proper access control | |
| concurrency: | |
| group: ai-workflows-${{ github.event.pull_request.head.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| review: | |
| # Only run on PRs when opened, when ai-review-needed label is added, and not a draft, skip AI-generated branches | |
| if: > | |
| github.event_name == 'pull_request' && | |
| (github.event.action == 'opened' || | |
| (github.event.action == 'labeled' && github.event.label.name == 'ai-review-needed')) && | |
| github.event.pull_request.draft == false && | |
| !startsWith(github.head_ref, 'fix/ai-') && | |
| !startsWith(github.head_ref, 'feature/ai-task-') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Get PR data when triggered by comment | |
| if: github.event_name == 'issue_comment' | |
| id: get_pr | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| const pr = await github.rest.pulls.get({ | |
| ...context.repo, | |
| pull_number: context.issue.number | |
| }); | |
| // Set outputs for later steps | |
| core.setOutput('base_ref', pr.data.base.ref); | |
| core.setOutput('head_ref', pr.data.head.ref); | |
| core.setOutput('head_sha', pr.data.head.sha); | |
| core.setOutput('body', pr.data.body || ''); | |
| core.setOutput('draft', pr.data.draft); | |
| // Store PR data for later use | |
| const prData = { | |
| base: { ref: pr.data.base.ref }, | |
| head: { ref: pr.data.head.ref, sha: pr.data.head.sha }, | |
| body: pr.data.body || '', | |
| draft: pr.data.draft | |
| }; | |
| // Export as environment variable for other steps | |
| core.exportVariable('PR_DATA', JSON.stringify(prData)); | |
| - name: Check for recent AI review | |
| id: check_recent_review | |
| uses: actions/github-script@v7 | |
| env: | |
| RATE_LIMIT_MINUTES: ${{ vars.AI_REVIEW_RATE_LIMIT_MINUTES || '1' }} | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| // Get recent comments | |
| const comments = await github.rest.issues.listComments({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| per_page: 100 | |
| }); | |
| // Check if there's a recent AI review (within rate limit window) | |
| const rateLimitMinutes = parseInt(process.env.RATE_LIMIT_MINUTES) || 1; | |
| const rateLimitMs = rateLimitMinutes * 60 * 1000; | |
| const rateLimitAgo = new Date(Date.now() - rateLimitMs); | |
| const recentAIReview = comments.data.find(comment => | |
| comment.user.login === 'github-actions[bot]' && | |
| comment.body.includes('🤖 AI Review') && | |
| new Date(comment.created_at) > rateLimitAgo | |
| ); | |
| if (recentAIReview) { | |
| console.log(`Recent AI review found within ${rateLimitMinutes} minute(s), skipping to prevent spam`); | |
| core.setOutput('skip', 'true'); | |
| } else { | |
| core.setOutput('skip', 'false'); | |
| } | |
| - name: Check test status | |
| id: check_tests | |
| if: github.event_name == 'pull_request' && steps.check_recent_review.outputs.skip != 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| // Get the latest commit SHA | |
| const sha = context.payload.pull_request.head.sha; | |
| // Get all check runs for this commit | |
| const { data: checkRuns } = await github.rest.checks.listForRef({ | |
| ...context.repo, | |
| ref: sha | |
| }); | |
| // Also get workflow runs for this commit | |
| const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ | |
| ...context.repo, | |
| head_sha: sha | |
| }); | |
| // Look for test-related workflows or checks | |
| const testChecks = checkRuns.check_runs.filter(check => | |
| check.name.toLowerCase().includes('test') || | |
| check.name.toLowerCase().includes('ci') || | |
| check.name.toLowerCase().includes('build') | |
| ); | |
| const testWorkflows = workflowRuns.workflow_runs.filter(run => | |
| run.name.toLowerCase().includes('test') || | |
| run.name.toLowerCase().includes('ci') || | |
| run.name.toLowerCase().includes('build') | |
| ); | |
| // Check if any tests are failing | |
| const failedChecks = testChecks.filter(check => check.conclusion === 'failure'); | |
| const failedWorkflows = testWorkflows.filter(run => run.conclusion === 'failure'); | |
| const hasFailingTests = failedChecks.length > 0 || failedWorkflows.length > 0; | |
| if (hasFailingTests) { | |
| console.log('Tests are failing. AI review will be skipped.'); | |
| console.log('Failed checks:', failedChecks.map(c => c.name)); | |
| console.log('Failed workflows:', failedWorkflows.map(w => w.name)); | |
| // Create a comment explaining why review is skipped | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| body: `## 🚨 AI Review Skipped - Tests Failing\n\n` + | |
| `The AI review has been skipped because one or more tests are currently failing.\n\n` + | |
| `**Failed tests:**\n` + | |
| `${failedChecks.map(c => `- ${c.name}: ${c.conclusion}`).join('\n')}\n` + | |
| `${failedWorkflows.map(w => `- ${w.name}: ${w.conclusion}`).join('\n')}\n\n` + | |
| `Please fix the failing tests first, then the AI review will run automatically.` | |
| }); | |
| core.setOutput('result', 'false'); | |
| return false; | |
| } | |
| core.setOutput('result', 'true'); | |
| return true; | |
| - name: Checkout code | |
| if: (steps.check_tests.outputs.result == 'true' || github.event_name != 'pull_request') && steps.check_recent_review.outputs.skip != 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Run AI Review using stillriver-ai-workflows | |
| if: (steps.check_tests.outputs.result == 'true' || github.event_name != 'pull_request') && steps.check_recent_review.outputs.skip != 'true' | |
| id: ai_review | |
| uses: stillrivercode/stillriver-ai-workflows@v1 | |
| env: | |
| AI_ENABLE_INLINE_COMMENTS: ${{ vars.AI_ENABLE_INLINE_COMMENTS || 'true' }} | |
| with: | |
| github_token: ${{ github.token }} | |
| openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }} | |
| model: ${{ vars.AI_MODEL || 'anthropic/claude-sonnet-4' }} | |
| review_type: 'full' | |
| max_tokens: 4096 | |
| temperature: 0.7 | |
| request_timeout_seconds: 600 # 10 minutes default, can be overridden by the action | |
| retries: 3 | |
| post_comment: 'true' # Let the action handle resolvable comments | |
| - name: Handle AI review failure | |
| if: steps.ai_review.outputs.review_status == 'failure' || steps.ai_review.outputs.review_status == 'error' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| // Add a label to indicate AI review failed (but not ai-review-needed to avoid loops) | |
| await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| labels: ['ai-review-failed'] | |
| }); | |
| // Post a comment explaining the failure | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| body: `## ⚠️ AI Review Failed\n\nThe AI review could not be completed. Status: ${{ steps.ai_review.outputs.review_status }}\n\nThis could be due to:\n- API rate limiting\n- Large diff size\n- Temporary service issues\n\nPlease retry the review later or request manual review.` | |
| }); | |
| // Fail the workflow step to indicate the review failure | |
| core.setFailed('AI review failed - manual review needed'); | |
| - name: Add labels based on review | |
| if: > | |
| github.event_name == 'pull_request' && | |
| steps.check_tests.outputs.result == 'true' && | |
| steps.check_recent_review.outputs.skip != 'true' && | |
| steps.ai_review.outputs.review_status == 'success' | |
| uses: actions/github-script@v7 | |
| env: | |
| REVIEW_COMMENT: ${{ steps.ai_review.outputs.review_comment }} | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| const review = process.env.REVIEW_COMMENT.toLowerCase(); | |
| const labels = []; | |
| // Helper function to check if a phrase indicates actual concern vs no concern | |
| const hasActualConcern = (text, keywords) => { | |
| for (const keyword of keywords) { | |
| const regex = new RegExp(`(no|zero|minimal|none|without|negligible)\\s+${keyword}`, 'i'); | |
| if (regex.test(text)) { | |
| // Found negation before the keyword, so no actual concern | |
| return false; | |
| } | |
| if (text.includes(keyword)) { | |
| // Found keyword without negation | |
| return true; | |
| } | |
| } | |
| return false; | |
| }; | |
| // Add labels based on review content (avoid triggering AI fix workflows) | |
| if (hasActualConcern(review, ['security concern', 'security issue', 'security vulnerability', 'security risk'])) { | |
| labels.push('security-review-needed'); | |
| } | |
| if (hasActualConcern(review, ['performance impact', 'performance implication', 'performance degradation', 'performance issue'])) { | |
| labels.push('performance-impact'); | |
| } | |
| if (hasActualConcern(review, ['breaking change', 'backward incompatible', 'backwards incompatible'])) { | |
| labels.push('breaking-change'); | |
| } | |
| if (review.includes('needs documentation') || review.includes('documentation needed') || review.includes('missing documentation')) { | |
| labels.push('needs-docs'); | |
| } | |
| labels.push('ai-reviewed'); | |
| // Filter out any labels that could trigger AI fix workflows to prevent loops | |
| const filteredLabels = labels.filter(label => | |
| !label.startsWith('ai-fix-') && | |
| label !== 'ai-fix-lint' && | |
| label !== 'ai-fix-security' && | |
| label !== 'ai-fix-tests' | |
| ); | |
| if (filteredLabels.length > 0) { | |
| await github.rest.issues.addLabels({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| labels: filteredLabels | |
| }); | |
| } | |
| - name: Remove ai-review-needed label | |
| if: > | |
| github.event.action == 'labeled' && | |
| github.event.label.name == 'ai-review-needed' && | |
| steps.check_tests.outputs.result == 'true' && | |
| steps.check_recent_review.outputs.skip != 'true' && | |
| steps.ai_review.outputs.review_status == 'success' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| await github.rest.issues.removeLabel({ | |
| ...context.repo, | |
| issue_number: context.issue.number, | |
| name: 'ai-review-needed' | |
| }); |