-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add lightweight Gemini advisory PR review workflow #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,217 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fs = require('fs'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_DIFF_LENGTH = 20000; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Soft cap to prevent excessive API usage and runtime overhead for extremely large PRs | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MAX_FILES_TO_PROCESS = 300; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const BOT_SIGNATURE = '## 🤖 Gemini PR Review (Advisory Only)'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const DISCLAIMER = '\n\n---\n*This is an AI-generated review. It does not block merging and should be validated by maintainers.*'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const FETCH_TIMEOUT = 15000; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function fetchWithTimeout(url, options = {}) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const signal = AbortSignal.timeout(FETCH_TIMEOUT); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await fetch(url, { ...options, signal }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function getPRFilesAndDiff(repo, prNumber, token) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let allFiles = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let page = 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const perPage = 100; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 1. Pagination Handling | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Fetching files for PR #${prNumber}...`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (true) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetchWithTimeout(`https://api.github.com/repos/${repo}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { Authorization: `token ${token}` }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) throw new Error(`Failed to fetch PR files (page ${page}): ${response.statusText}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const files = await response.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!files.length) break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| allFiles = allFiles.concat(files); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (files.length < perPage || allFiles.length >= MAX_FILES_TO_PROCESS) break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| page++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Total files found in PR: ${allFiles.length}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| let diffText = ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let processedCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let filteredCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let patchMissingCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const file of allFiles) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const filename = file.filename; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isIgnored = filename.endsWith('.md') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| filename.endsWith('.txt') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| filename.startsWith('.github/'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isIgnored) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| filteredCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (file.patch) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| diffText += `File: ${filename}\n${file.patch}\n\n`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| processedCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| patchMissingCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 4. Improved Logging | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`File Processing Results: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Processed: ${processedCount} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Filtered (docs/config): ${filteredCount} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Skipped (binary / no patch files): ${patchMissingCount} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Total Fetched: ${allFiles.length}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (diffText.length > MAX_DIFF_LENGTH) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Diff too large (${diffText.length} chars). Truncating to ${MAX_DIFF_LENGTH}.`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| diffText = diffText.substring(0, MAX_DIFF_LENGTH) + '\n\n... (diff truncated for size limits)'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| return diffText; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function callGemini(prompt, apiKey, retryCount = 2) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i <= retryCount; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetchWithTimeout(geminiUrl, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| contents: [{ parts: [{ text: prompt }] }], | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| generationConfig: { temperature: 0.2, topP: 0.95 } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) throw new Error(`Gemini API Error: ${response.status}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const data = await response.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const text = data.candidates?.[0]?.content?.parts?.[0]?.text; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!text) throw new Error('Empty Gemini response'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return text; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn(`Gemini attempt ${i + 1} failed: ${err.message}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (i === retryCount) throw err; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| await new Promise(res => setTimeout(res, 2000 * (i + 1))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function postReview(repo, prNumber, token, body) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const commentBody = `${BOT_SIGNATURE}\n\n${body}${DISCLAIMER}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetchWithTimeout(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| Authorization: `token ${token}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: commentBody, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| event: 'COMMENT' | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn('Pull Request Review API failed, falling back to Issue Comment API.'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| await postFallbackComment(repo, prNumber, token, commentBody); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log('Advisory review posted successfully.'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error('Failed to post review:', err.message); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| await postFallbackComment(repo, prNumber, token, commentBody); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+109
to
+129
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetchWithTimeout(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, { | |
| method: 'POST', | |
| headers: { | |
| Authorization: `token ${token}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| body: commentBody, | |
| event: 'COMMENT' | |
| }), | |
| }); | |
| if (!response.ok) { | |
| console.warn('Pull Request Review API failed, falling back to Issue Comment API.'); | |
| await postFallbackComment(repo, prNumber, token, commentBody); | |
| } else { | |
| console.log('Advisory review posted successfully.'); | |
| } | |
| } catch (err) { | |
| console.error('Failed to post review:', err.message); | |
| await postFallbackComment(repo, prNumber, token, commentBody); | |
| await postFallbackComment(repo, prNumber, token, commentBody); | |
| console.log('Advisory review comment upserted successfully.'); | |
| } catch (err) { | |
| console.error('Failed to post advisory review comment:', err.message); |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For forked PRs (and other contexts), GEMINI_API_KEY won't be available; currently the script exits with code 1 when env vars are missing, which will mark the workflow run as failed and add noise even though the review is intended to be advisory. Consider treating missing GEMINI_API_KEY as a graceful skip (log and exit 0), while still failing only on genuinely unexpected errors when the secret is configured.
| if (!GITHUB_TOKEN || !GEMINI_API_KEY || !REPO || !GITHUB_EVENT_PATH) { | |
| console.error('Required environment variables are missing.'); | |
| process.exit(1); | |
| } | |
| if (!GITHUB_TOKEN || !REPO || !GITHUB_EVENT_PATH) { | |
| console.error('Required GitHub environment variables are missing.'); | |
| process.exit(1); | |
| } | |
| if (!GEMINI_API_KEY) { | |
| console.log('Skipping Gemini PR review: GEMINI_API_KEY is not available in this context.'); | |
| process.exit(0); | |
| } |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security/abuse risk: the issue_comment trigger runs with repository secrets, and currently anyone who can comment on a PR can trigger /gemini-review (including external contributors on fork PRs). Add an authorization check (e.g., event.comment.author_association in {MEMBER, OWNER, COLLABORATOR} or explicitly allow-listed users) before calling Gemini to prevent secret-backed API usage being triggered by untrusted actors.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: Gemini Advisory PR Review | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| on: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pull_request: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| types: [opened, synchronize, labeled] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| issue_comment: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| types: [created] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+8
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Avoid overlapping runs on the same PR | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| concurrency: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| group: gemini-review-${{ github.event.pull_request.number || github.event.issue.number }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cancel-in-progress: true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| jobs: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| review: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runs-on: ubuntu-latest | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Only run for pull requests or comments on pull requests | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if: | | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| github.event_name == 'pull_request' || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (github.event_name == 'issue_comment' && github.event.issue.pull_request) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+20
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Only run for pull requests or comments on pull requests | |
| if: | | |
| github.event_name == 'pull_request' || | |
| (github.event_name == 'issue_comment' && github.event.issue.pull_request) | |
| # Only run for pull requests or comments on pull requests that request Gemini review | |
| if: | | |
| github.event_name == 'pull_request' || | |
| ( | |
| github.event_name == 'issue_comment' && | |
| github.event.issue.pull_request && | |
| contains(github.event.comment.body, '/gemini-review') | |
| ) |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codebase convention/supply-chain hardening: other workflows in this repo pin actions to immutable SHAs and include a step-security/harden-runner step (e.g., .github/workflows/ci.yml, codeql.yml). Here actions/checkout@v4 and actions/setup-node@v4 are unpinned and there's no runner hardening, which weakens provenance/egress auditing. Consider adding the harden-runner step and pinning action versions to SHAs for consistency and security.
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Run Gemini PR Review | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| - name: Harden runner | |
| uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf | |
| with: | |
| egress-policy: audit | |
| - name: Checkout repository | |
| uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 | |
| with: | |
| node-version: '20' | |
| - name: Run Gemini PR Review | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GEMINI_API_KEY: ${{ secrets.GITHUB_TOKEN }} |
Copilot
AI
Apr 22, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security: this workflow checks out the PR's code (actions/checkout default ref) and then executes a script from the checkout while GEMINI_API_KEY is provided as a secret. A PR author could modify .github/scripts/gemini-pr-review.js to exfiltrate the secret during the run. Use a trusted ref for checkout (e.g., the base branch/pull_request.base.sha), or avoid checkout entirely and run a trusted script (or move to pull_request_target with a safe checkout strategy).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The diff filtering currently ignores any file under
.github/(filename.startsWith('.github/')). That will exclude code changes in.github/scriptsand other non-config logic stored under.github/, which conflicts with the stated goal of ignoring only docs/config. Consider narrowing the ignore list to specific paths (e.g.,.github/workflows/,.github/dependabot.yml) or excluding only known non-code extensions, so actionable code in.github/can still be reviewed.