AI PR Summary #1135
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
| # ────────────────────────────────────────────────────────────────────────────── | |
| # AI PR Summary — Consolidates all AI agent feedback into one clean verdict | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Triggered after each AI agent workflow completes. Fetches all agent comments | |
| # on the PR, parses verdicts, and posts/updates a single summary table. | |
| # Uses an HTML marker (<!-- ai-pr-summary -->) for idempotent upserts. | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| name: AI PR Summary | |
| on: | |
| workflow_run: | |
| workflows: | |
| - "AI Code Review" | |
| - "AI Security Scan" | |
| - "AI Breaking Change Detector" | |
| - "AI Docs Sync Check" | |
| - "AI Test Generator" | |
| types: [completed] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| summarize: | |
| name: Post unified summary | |
| runs-on: ubuntu-latest | |
| # Only run when the triggering workflow was for a PR | |
| if: >- | |
| github.event.workflow_run.event == 'pull_request' || | |
| github.event.workflow_run.event == 'pull_request_target' | |
| steps: | |
| - name: Get PR number from triggering workflow | |
| id: pr | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| // The workflow_run event contains the head SHA; find the PR for it | |
| const runId = context.payload.workflow_run.id; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const headSha = context.payload.workflow_run.head_sha; | |
| const headBranch = context.payload.workflow_run.head_branch; | |
| // Try to find PR by head branch | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner, | |
| repo, | |
| state: 'open', | |
| head: `${owner}:${headBranch}`, | |
| per_page: 5, | |
| }); | |
| let prNumber = null; | |
| if (prs.length > 0) { | |
| prNumber = prs[0].number; | |
| } else { | |
| // Fallback: search by commit SHA | |
| const { data: searchPrs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, | |
| repo, | |
| commit_sha: headSha, | |
| }); | |
| const openPr = searchPrs.find(p => p.state === 'open'); | |
| if (openPr) prNumber = openPr.number; | |
| } | |
| if (!prNumber) { | |
| core.info('No open PR found for this workflow run — skipping summary.'); | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| core.info(`Found PR #${prNumber}`); | |
| core.setOutput('found', 'true'); | |
| core.setOutput('number', String(prNumber)); | |
| - name: Collect agent comments and post summary | |
| if: steps.pr.outputs.found == 'true' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); | |
| const SUMMARY_MARKER = '<!-- ai-pr-summary -->'; | |
| // ── Agent registry ────────────────────────────────────────── | |
| // Maps agent-type marker to display info | |
| const AGENTS = [ | |
| { marker: '<!-- ai-agent:code-reviewer -->', icon: '🔍', label: 'Code Review' }, | |
| { marker: '<!-- ai-agent:security-scanner -->', icon: '🛡️', label: 'Security Scan' }, | |
| { marker: '<!-- ai-agent:breaking-change -->', icon: '🔄', label: 'Breaking Changes' }, | |
| { marker: '<!-- ai-agent:docs-sync -->', icon: '📝', label: 'Docs Sync' }, | |
| { marker: '<!-- ai-agent:test-generator -->', icon: '🧪', label: 'Test Coverage' }, | |
| ]; | |
| // ── Fetch all comments on the PR ──────────────────────────── | |
| let allComments = []; | |
| let page = 1; | |
| while (true) { | |
| const { data: batch } = await github.rest.issues.listComments({ | |
| owner, repo, issue_number: prNumber, per_page: 100, page, | |
| }); | |
| if (!batch.length) break; | |
| allComments = allComments.concat(batch); | |
| if (batch.length < 100) break; | |
| page++; | |
| } | |
| // ── Parse each agent's verdict ────────────────────────────── | |
| function parseVerdict(body) { | |
| if (!body) return { status: '⏳', statusLabel: 'Pending', detail: 'Awaiting results' }; | |
| const lower = body.toLowerCase(); | |
| // Check for critical / error first | |
| if (/critical|error|vulnerabilit(y|ies)\s+found|high\s+severity/i.test(body)) { | |
| // Try to extract a useful detail line | |
| const detailMatch = body.match(/^###\s+(.+)$/m) || body.match(/^.*(?:critical|error|vulnerab).*$/mi); | |
| return { status: '❌', statusLabel: 'Failed', detail: detailMatch ? detailMatch[1] || detailMatch[0] : 'Issues detected' }; | |
| } | |
| // Warnings | |
| if (/warning|⚠️|potential|breaking\s+change|minor/i.test(body)) { | |
| const warnMatch = body.match(/(\d+)\s*warning/i); | |
| const detailMatch = body.match(/^###\s+(.+)$/m) || body.match(/^.*(?:warning|⚠️|potential|breaking).*$/mi); | |
| const count = warnMatch ? warnMatch[1] : ''; | |
| const label = count ? `${count} Warning${count === '1' ? '' : 's'}` : 'Warning'; | |
| return { status: '⚠️', statusLabel: label, detail: detailMatch ? (detailMatch[1] || detailMatch[0]).replace(/^#+\s*/, '').trim() : 'See details' }; | |
| } | |
| // Suggestions / info | |
| if (/suggest|consider|recommend|ℹ️|info/i.test(body)) { | |
| const detailMatch = body.match(/^###\s+(.+)$/m) || body.match(/^.*(?:suggest|consider|recommend).*$/mi); | |
| return { status: 'ℹ️', statusLabel: 'Suggestion', detail: detailMatch ? (detailMatch[1] || detailMatch[0]).replace(/^#+\s*/, '').trim() : 'See suggestions' }; | |
| } | |
| // Passed / clean | |
| if (/no issues|pass(ed)?|clean|no vulnerabilit|up.to.date|no breaking|all good|looks good|lgtm/i.test(body)) { | |
| const detailMatch = body.match(/^.*(?:no issues|pass|clean|no vulnerab|up.to.date|no breaking|all good|looks good).*$/mi); | |
| return { status: '✅', statusLabel: 'Passed', detail: detailMatch ? detailMatch[0].replace(/^#+\s*/, '').trim().slice(0, 80) : 'No issues found' }; | |
| } | |
| // Default: completed but unclear verdict | |
| return { status: '✅', statusLabel: 'Completed', detail: 'Analysis complete' }; | |
| } | |
| // Build rows | |
| const rows = AGENTS.map(agent => { | |
| const comment = allComments.find(c => c.body && c.body.includes(agent.marker)); | |
| const verdict = comment ? parseVerdict(comment.body) : { status: '⏳', statusLabel: 'Pending', detail: 'Awaiting results' }; | |
| return `| ${agent.icon} ${agent.label} | ${verdict.status} ${verdict.statusLabel} | ${verdict.detail} |`; | |
| }); | |
| // ── Determine overall verdict ─────────────────────────────── | |
| const allVerdicts = rows.join('\n'); | |
| let overallVerdict; | |
| if (allVerdicts.includes('❌')) { | |
| overallVerdict = '**Verdict: ❌ Changes needed — see failures above**'; | |
| } else if (allVerdicts.includes('⚠️')) { | |
| overallVerdict = '**Verdict: ⚠️ Ready for human review — see warnings above**'; | |
| } else if (allVerdicts.includes('⏳')) { | |
| overallVerdict = '**Verdict: ⏳ Still running — some checks have not completed yet**'; | |
| } else { | |
| overallVerdict = '**Verdict: ✅ Ready for human review**'; | |
| } | |
| // ── Build summary body ────────────────────────────────────── | |
| const summaryBody = [ | |
| SUMMARY_MARKER, | |
| '## ✅ PR Review Summary', | |
| '', | |
| '| Check | Status | Details |', | |
| '|-------|--------|---------|', | |
| ...rows, | |
| '', | |
| overallVerdict, | |
| '', | |
| '> 💡 Individual agent reports are collapsed below for reference.', | |
| ].join('\n'); | |
| // ── Upsert summary comment ────────────────────────────────── | |
| const existing = allComments.find(c => c.body && c.body.includes(SUMMARY_MARKER)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, repo, comment_id: existing.id, body: summaryBody, | |
| }); | |
| core.info(`Updated existing summary comment ${existing.id}`); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: prNumber, body: summaryBody, | |
| }); | |
| core.info('Created new summary comment'); | |
| } |