Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/local-actions/branch-manager/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/local-actions/labels-sync/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/local-actions/lock-closed/main.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm bazel test -- //...

pr_review:
needs: [lint, test]
if: always() && github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'reopened')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./github-actions/review-bot
with:
angular-review-bot-key: ${{ secrets.ANGULAR_REVIEW_BOT_PRIVATE_KEY }}
google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }}

# macos testing is disabled as we do not have any targets we currently test.
#test-macos:
# timeout-minutes: 30
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ github-actions/post-approval-changes/main.js
github-actions/previews/pack-and-upload-artifact/inject-artifact-metadata.js
github-actions/previews/upload-artifacts-to-firebase/extract-artifact-metadata.js
github-actions/previews/upload-artifacts-to-firebase/fetch-workflow-artifact.js
github-actions/review-bot/main.js
github-actions/saucelabs/set-saucelabs-env.js
github-actions/labeling/issue/main.js
github-actions/slash-commands/main.js
Expand Down
2 changes: 1 addition & 1 deletion github-actions/branch-manager/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion github-actions/feature-request/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion github-actions/labeling/issue/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion github-actions/labeling/pull-request/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion github-actions/org-file-sync/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion github-actions/post-approval-changes/main.js

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions github-actions/review-bot/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
load("//tools:defaults.bzl", "esbuild_checked_in", "ts_project")

package(default_visibility = ["//github-actions/review-bot:__subpackages__"])

npm_link_all_packages()

ts_project(
name = "lib",
srcs = glob(["lib/**/*.ts"]),
tsconfig = "//github-actions:tsconfig",
deps = [
":node_modules/@actions/core",
":node_modules/@actions/github",
":node_modules/@google/genai",
":node_modules/@octokit/rest",
":node_modules/@octokit/types",
":node_modules/@types/node",
"//github-actions:utils",
],
)

esbuild_checked_in(
name = "main",
srcs = [
":lib",
],
entry_point = "lib/main.ts",
format = "esm",
platform = "node",
target = "node22",
)
14 changes: 14 additions & 0 deletions github-actions/review-bot/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: 'Gemini Code Review'
description: 'Uses Gemini to perform an automated code review on a pull request and instruct on fixing failed checks.'
author: 'Angular'
inputs:
angular-review-bot-key:
description: 'The private key for the Angular Review Bot'
required: true
gemini-api-key:
description: 'The API key for the Gemini model'
required: true

runs:
using: 'node20'
main: 'main.js'
214 changes: 214 additions & 0 deletions github-actions/review-bot/lib/gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {GoogleGenAI, Type} from '@google/genai';
import {Octokit} from '@octokit/rest';
import * as core from '@actions/core';
import {CodeReview} from './github';

const LLM_MODEL = 'gemini-2.5-pro';

export async function performCodeReview(
apiKey: string,
diff: string,
filesContext: {filename: string; content: string; status: string}[],
failedChecks: {name: string; output: string | null}[],
octokit: Octokit,
owner: string,
repo: string,
ref: string,
): Promise<CodeReview | null> {
const ai = new GoogleGenAI({apiKey});

// Create a function declaration for our GitHub search tool
const searchCodebaseTool = {
name: 'searchCodebase',
description: 'Search the repository codebase to find usage examples or architectural patterns.',
parameters: {
type: Type.OBJECT,
properties: {
query: {
type: Type.STRING,
description: 'The search query string (e.g. "functionName" or "class Name")',
},
},
required: ['query'],
},
};

const getFileContextTool = {
name: 'getFileContext',
description:
'Fetch the full content of a specific file in the repository to understand its surrounding code.',
parameters: {
type: Type.OBJECT,
properties: {
path: {
type: Type.STRING,
description: 'The exact path to the file in the repository.',
},
},
required: ['path'],
},
};

const tools = [
{
functionDeclarations: [searchCodebaseTool, getFileContextTool],
},
];

const systemInstruction = `You are an expert Angular and TypeScript code reviewer.
Your goal is to provide a constructive, actionable code review for a pull request.
You must analyze the provided file contexts, the exact diff, and any failed CI checks (like linting or test failures).
Provide concrete instructions on how to fix failures or improve code layout.

You have access to tools that allow you to search the wider codebase or fetch additional files if you need more context (e.g. to see how a function is used elsewhere or to find existing patterns). Use them if the diff alone is not enough to make a recommendation.

You must return your feedback strictly matching the requested JSON schema.
For inline comments, ensure the \`path\` matches a modified file exactly, and the \`line\` number falls within the added/modified span of the RIGHT side of the diff. Do not comment on unmodified lines.`;

const filesPrompt = filesContext
.map(
(f) =>
`--- File: ${f.filename} (Status: ${f.status}) ---\n${f.content}\n-----------------------`,
)
.join('\n\n');

const failedChecksPrompt =
failedChecks.length > 0
? failedChecks
.map((c) => `Check: ${c.name}\nOutput:\n${c.output || 'No output.'}`)
.join('\n\n')
: 'No failed checks.';

const prompt = `Please review the following PR.

## Modified Files Context
${filesPrompt}

## Pull Request Diff
\`\`\`diff
${diff}
\`\`\`

## Failed Checks (Lint/Tests)
${failedChecksPrompt}

Analyze the changes and the failed checks. If you need more context on how specific functions, classes, or patterns are used across the repository, use your tools to search the codebase or fetch file context before providing your final markdown review.`;

const responseSchema = {
type: Type.OBJECT,
properties: {
generalComment: {
type: Type.STRING,
description:
'A brief overview of the changes and general observations about the PR. Can include overall feedback on code quality and structure.',
},
inlineComments: {
type: Type.ARRAY,
description:
'Specific comments tied to exact files and line numbers in the PR diff. Useful for localized actionable feedback.',
items: {
type: Type.OBJECT,
properties: {
path: {
type: Type.STRING,
description:
"The relative path of the module/file being commented on (e.g. 'src/app/app.component.ts').",
},
line: {
type: Type.INTEGER,
description:
'The specific line number in the new code (RIGHT side of the diff) the comment refers to.',
},
body: {
type: Type.STRING,
description: 'The actionable feedback or Markdown comment for this specific line.',
},
},
required: ['path', 'line', 'body'],
},
},
},
required: ['generalComment', 'inlineComments'],
};

try {
const chat = ai.chats.create({
model: LLM_MODEL,
config: {
systemInstruction,
tools,
responseMimeType: 'application/json',
responseSchema,
},
});

let response = await chat.sendMessage({
message: prompt,
});

// Handle tool calls iteratively
while (response.functionCalls && response.functionCalls.length > 0) {
const call = response.functionCalls[0];
const args: Record<string, any> = call.args || {};
let toolResult: any;

try {
if (call.name === 'searchCodebase') {
core.info(`Gemini called tool: searchCodebase("${args.query}")`);
const searchRes = await octokit.rest.search.code({
q: `${args.query} repo:${owner}/${repo}`,
per_page: 5,
});
toolResult = searchRes.data.items.map((i) => ({
name: i.name,
path: i.path,
repository: i.repository.full_name,
url: i.html_url,
}));
} else if (call.name === 'getFileContext') {
core.info(`Gemini called tool: getFileContext("${args.path}")`);
const contentRes = await octokit.rest.repos.getContent({
owner,
repo,
path: String(args.path),
ref,
});
if (
'type' in contentRes.data &&
contentRes.data.type === 'file' &&
'content' in contentRes.data
) {
toolResult = {
path: args.path,
content: Buffer.from(contentRes.data.content, 'base64').toString('utf8'),
};
} else {
toolResult = {error: 'Not a file or file too large.'};
}
}
} catch (err: any) {
core.error(`Tool execution failed: ${err.message}`);
toolResult = {error: err.message};
}

response = await chat.sendMessage({
message: [
{
functionResponse: {
name: call.name || '',
response: {result: toolResult},
},
},
],
});
}

if (!response.text) return null;
return JSON.parse(response.text);
} catch (error) {
core.error(
`Failed to execute Gemini review: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
Loading