Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions .github/scripts/gemini-pr-review.js
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/');

Comment on lines +45 to +48
Copy link

Copilot AI Apr 22, 2026

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/scripts and 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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postReview always creates a new PR review via the Reviews API. On synchronize (and on repeated /gemini-review runs) this will generate multiple advisory reviews and can clutter the PR timeline. Consider de-duping by using the Issues Comments API as the primary mechanism (you already have update-or-create logic in postFallbackComment) or by checking for an existing bot review/comment and updating/replacing it instead of posting a new one each run.

Suggested change
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 uses AI. Check for mistakes.
}
}

async function postFallbackComment(repo, prNumber, token, body) {
const listResp = await fetchWithTimeout(`https://api.github.com/repos/${repo}/issues/${prNumber}/comments`, {
headers: { Authorization: `token ${token}` },
});
if (!listResp.ok) return;

const comments = await listResp.json();
const existing = comments.find(c => c.body.includes(BOT_SIGNATURE));

const url = existing ? `https://api.github.com/repos/${repo}/issues/comments/${existing.id}` : `https://api.github.com/repos/${repo}/issues/${prNumber}/comments`;
const method = existing ? 'PATCH' : 'POST';

await fetchWithTimeout(url, {
method,
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
}

async function run() {
const { GITHUB_TOKEN, GEMINI_API_KEY, GITHUB_REPOSITORY: REPO, GITHUB_EVENT_PATH, GITHUB_EVENT_NAME } = process.env;

if (!GITHUB_TOKEN || !GEMINI_API_KEY || !REPO || !GITHUB_EVENT_PATH) {
console.error('Required environment variables are missing.');
process.exit(1);
}

Comment on lines +155 to +159
Copy link

Copilot AI Apr 22, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
const event = JSON.parse(fs.readFileSync(GITHUB_EVENT_PATH, 'utf8'));
let prNumber;
let triggerSource = '';

if (GITHUB_EVENT_NAME === 'pull_request') {
prNumber = event.pull_request.number;
const labels = event.pull_request.labels || [];
triggerSource = labels.some(l => l.name === 'ai-review') ? 'Label: ai-review' : 'Default: pull_request event';
} else if (GITHUB_EVENT_NAME === 'issue_comment') {
if (!event.issue.pull_request) return;
prNumber = event.issue.number;
const commentBody = event.comment.body || '';
if (commentBody.includes('/gemini-review')) {
triggerSource = 'Comment: /gemini-review';
} else {
return;
}
Comment on lines +168 to +176
Copy link

Copilot AI Apr 22, 2026

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.

Copilot uses AI. Check for mistakes.
}

console.log(`🚀 Starting lightweight Gemini Review (Advisory) for PR #${prNumber}`);
console.log(`Trigger: ${triggerSource}`);

try {
const diff = await getPRFilesAndDiff(REPO, prNumber, GITHUB_TOKEN);

// 2. Empty Diff Case -> Post Feedback
if (!diff || !diff.trim()) {
console.log('No relevant code changes found after filtering.');
await postReview(REPO, prNumber, GITHUB_TOKEN, '🔎 No relevant code changes found to review after filtering out documentation and configuration files.');
return;
}

// 3. Improved Prompt
const prompt = `You are a senior software engineer. Review the following code changes.
Structure your response as follows:
1. **Summary**: A concise summary of changes.
2. **Analysis**: Bug reports, security risks, or performance concerns.
3. **Best Practices**: Suggestions for better code quality.

Guidelines:
- Focus on actionable feedback and avoid vague statements.
- Be concise and use bullet points.
- If you find a potential issue, prefix it with "⚠️ Potential issue (not blocking):".
- Do not repeat the diff content.

DIFF CONTENT:
${diff}`;

const reviewText = await callGemini(prompt, GEMINI_API_KEY);
await postReview(REPO, prNumber, GITHUB_TOKEN, reviewText);
console.log('Review process completed.');
} catch (err) {
console.error('Workflow failed gracefully:', err.message);
await postReview(REPO, prNumber, GITHUB_TOKEN, '⚠️ **Gemini review currently unavailable.** Please check the workflow logs for details.');
}
}

run().catch(console.error);
40 changes: 40 additions & 0 deletions .github/workflows/gemini-pr-review.yml
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
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow doesn't set a top-level permissions: {} block. In this repo, other workflows explicitly set empty default permissions and then grant the minimum job-level permissions (e.g., .github/workflows/ci.yml:11, codeql.yml:23). Adding permissions: {} at the workflow level reduces the chance of accidentally inheriting broader defaults if GitHub changes defaults or the workflow is extended later.

Copilot uses AI. Check for mistakes.
# 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
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operational/perf: for issue_comment events, the job runs for every PR comment and only exits later inside the Node script unless the comment contains /gemini-review. You can reduce wasted runner time by adding an additional workflow/job-level condition that checks the comment body (e.g., contains(github.event.comment.body, '/gemini-review')) when github.event_name == 'issue_comment'.

Suggested change
# 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 uses AI. Check for mistakes.

permissions:
contents: read
pull-requests: write
issues: write

steps:
- 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 }}
Comment on lines +28 to +39
Copy link

Copilot AI Apr 22, 2026

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.

Suggested change
- 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 uses AI. Check for mistakes.
run: node .github/scripts/gemini-pr-review.js
Comment on lines +28 to +40
Copy link

Copilot AI Apr 22, 2026

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).

Copilot uses AI. Check for mistakes.
Loading