diff --git a/.github/workflows/pr-review-agent.yml b/.github/workflows/pr-review-agent.yml new file mode 100644 index 0000000..67d195d --- /dev/null +++ b/.github/workflows/pr-review-agent.yml @@ -0,0 +1,140 @@ +name: PR Review Agent + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + summarize-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Summarize PR and post comment + uses: actions/github-script@v7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr_number = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + // Fetch PR metadata + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: pr_number, + }); + + // Fetch changed files (up to 100) + const { data: files } = await github.rest.pulls.listFiles({ + owner, repo, pull_number: pr_number, per_page: 100, + }); + + // Build a compact file-change list + const fileLines = files.map( + f => `- \`${f.filename}\` (${f.status}): +${f.additions} −${f.deletions}` + ); + + // Collect diff patches for the first 15 text files + const patches = files + .filter(f => f.patch) + .slice(0, 15) + .map(f => `### \`${f.filename}\`\n\`\`\`diff\n${f.patch.slice(0, 2000)}\n\`\`\``) + .join('\n\n'); + + const userPrompt = [ + 'You are a senior engineer reviewing a pull request for AccessHub, a TypeScript', + 'internal developer platform (Express + Prisma + SQLite API, React + Vite frontend).', + '', + 'PR title: ' + pr.title, + 'PR description: ' + (pr.body || '(none)'), + '', + 'Changed files (' + files.length + ' total):', + fileLines.join('\n'), + '', + 'Diff excerpts:', + patches || '(no text patches available)', + '', + 'Write a concise PR summary using these sections:', + '## Summary', + 'One or two sentences describing what this PR does.', + '', + '## Changes', + 'Bullet-point list of the most important changes.', + '', + '## Potential concerns', + 'Flag any security, architecture, or testing issues relevant to this codebase.', + 'If there are none, say "None identified."', + ].join('\n'); + + let body; + + try { + const token = process.env.GITHUB_TOKEN; + const resp = await fetch('https://models.inference.ai.azure.com/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: userPrompt }], + max_tokens: 1024, + }), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error('Models API ' + resp.status + ': ' + text); + } + + const json = await resp.json(); + const aiSummary = json.choices[0].message.content.trim(); + body = '## \uD83E\uDD16 PR Review Agent\n\n' + aiSummary + + '\n\n---\n*Generated automatically by the PR Review Agent.*'; + } catch (err) { + // Fallback: structured summary without AI + core.warning('AI summarization unavailable (' + err.message + '); using structured fallback.'); + body = [ + '## \uD83E\uDD16 PR Review Agent', + '', + '## Summary', + 'This PR — **' + pr.title + '** — modifies ' + files.length + ' file(s).', + '', + '## Changes', + fileLines.join('\n'), + '', + '## Potential concerns', + '_AI summarization was unavailable; please review manually._', + '', + '---', + '*Generated automatically by the PR Review Agent.*', + ].join('\n'); + } + + // Remove any previous bot summary comment to keep the thread clean + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number: pr_number, + }); + for (const c of comments) { + if ( + c.user?.type === 'Bot' && + c.body?.includes('PR Review Agent') + ) { + await github.rest.issues.deleteComment({ + owner, repo, comment_id: c.id, + }); + } + } + + // Post the new summary + await github.rest.issues.createComment({ + owner, repo, issue_number: pr_number, body, + });