Skip to content
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ jobs:
AI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
AI_MODEL: "gpt-4o-mini"

# Optional configurations (currently not used)
# REVIEW_MAX_COMMENTS: 10
# EXCLUDE_PATTERNS: "**/*.md,**/*.json"
# APPROVE_REVIEWS: false
# REVIEW_PROJECT_CONTEXT: "This is a Node.js TypeScript project"
# Optional configurations
APPROVE_REVIEWS: true
MAX_COMMENTS: 10 # 0 to disable
PROJECT_CONTEXT: "This is a Node.js TypeScript project"
CONTEXT_FILES: "package.json,README.md"
EXCLUDE_PATTERNS: "**/*.md,**/*.json"
```

## Configuration
Expand All @@ -65,10 +66,11 @@ jobs:
| `AI_PROVIDER` | AI provider to use (`openai`, `anthropic`, `google`) | `openai` |
| `AI_API_KEY` | API key for chosen provider | Required |
| `AI_MODEL` | Model to use (see supported models below) | Provider's default |
| `REVIEW_MAX_COMMENTS` | Maximum number of review comments | `10` |
| `APPROVE_REVIEWS` | Whether to approve PRs automatically | `true` |
| `MAX_COMMENTS` | Maximum number of review comments | `0` |
| `PROJECT_CONTEXT` | Project context for better reviews | `""` |
| `CONTEXT_FILES` | Files to include in review (comma-separated) | `"package.json,README.md"` |
| `EXCLUDE_PATTERNS` | Files to exclude (glob patterns, comma-separated) | `""` |
| `APPROVE_REVIEWS` | Whether to approve PRs automatically | `false` |
| `REVIEW_PROJECT_CONTEXT` | Project context for better reviews | `""` |

### Supported Models

Expand Down
14 changes: 11 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,21 @@ inputs:
AI_API_KEY:
description: "API key for the chosen AI provider"
required: true
REVIEW_BEHAVIOR:
description: "Review behavior (suggest_approval, comment_only, full_review)"
APPROVE_REVIEWS:
description: "Whether to approve/reject PRs automatically"
required: false
default: "full_review"
default: "true"
MAX_COMMENTS:
description: "Maximum number of review comments"
required: false
default: "0"
PROJECT_CONTEXT:
description: "Additional context about the project"
required: false
CONTEXT_FILES:
description: "Files to include in review (comma-separated)"
required: false
default: "package.json,README.md"
EXCLUDE_PATTERNS:
description: "Files to exclude (glob patterns)"
required: false
Expand Down
174 changes: 141 additions & 33 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ main().catch(error => {
"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.baseCodeReviewPrompt = exports.outputFormat = void 0;
exports.updateReviewPrompt = exports.baseCodeReviewPrompt = exports.outputFormat = void 0;
exports.outputFormat = `
{
"summary": "",
Expand Down Expand Up @@ -162,7 +162,15 @@ For the "comments" field, provide a list of comments. Each comment should have t
- path: The path to the file that the comment is about
- line: The line number in the file that the comment is about
- comment: The comment text
Comments should ONLY be added to lines or blocks of code that have issues.
Other rules for "comments" field:
- Comments should ONLY be added to lines or blocks of code that have issues.
- ONLY use line numbers that appear in the "diff" property of each file
- Each diff line starts with a prefix:
* "normal" for unchanged lines
* "del" for removed lines
* "add" for added lines
- Extract the line number that appears after the prefix
- DO NOT use line number 0 or line numbers not present in the diff

For the "suggestedAction" field, provide a single word that indicates the action to be taken. Options are:
- "approve"
Expand All @@ -171,6 +179,15 @@ For the "suggestedAction" field, provide a single word that indicates the action

For the "confidence" field, provide a number between 0 and 100 that indicates the confidence in the verdict.
`;
exports.updateReviewPrompt = `
When reviewing updates to a PR:
1. Focus on the modified sections but consider their context
2. Reference previous comments if they're still relevant
3. Acknowledge fixed issues from previous reviews
4. Only comment on new issues or unresolved previous issues
5. Consider the cumulative impact of changes
6. IMPORTANT: Only use line numbers that appear in the current "diff" field
`;
exports["default"] = exports.baseCodeReviewPrompt;


Expand Down Expand Up @@ -256,16 +273,15 @@ class AnthropicProvider {
}
async review(request) {
var _a;
const prompt = this.buildPrompt(request);
core.info(`Sending request to Anthropic with prompt structure: ${JSON.stringify(request, null, 2)}`);
core.debug(`Sending request to Anthropic with prompt structure: ${JSON.stringify(request, null, 2)}`);
const response = await this.client.messages.create({
model: this.config.model,
max_tokens: 4000,
system: prompts_1.baseCodeReviewPrompt,
system: this.buildSystemPrompt(request),
messages: [
{
role: 'user',
content: prompt,
content: this.buildPullRequestPrompt(request),
},
{
role: 'user',
Expand All @@ -274,19 +290,35 @@ class AnthropicProvider {
],
temperature: (_a = this.config.temperature) !== null && _a !== void 0 ? _a : 0.3,
});
core.info(`Raw Anthropic response: ${JSON.stringify(response.content[0].text, null, 2)}`);
core.debug(`Raw Anthropic response: ${JSON.stringify(response.content[0].text, null, 2)}`);
const parsedResponse = this.parseResponse(response);
core.info(`Parsed response: ${JSON.stringify(parsedResponse, null, 2)}`);
return parsedResponse;
}
buildPrompt(request) {
buildPullRequestPrompt(request) {
var _a;
return JSON.stringify({
type: 'code_review',
files: request.files,
pr: request.pullRequest,
context: request.context,
previousReviews: (_a = request.previousReviews) === null || _a === void 0 ? void 0 : _a.map(review => ({
summary: review.summary,
lineComments: review.lineComments.map(comment => ({
path: comment.path,
line: comment.line,
comment: comment.comment
}))
}))
});
}
buildSystemPrompt(request) {
const isUpdate = request.context.isUpdate;
return `
${prompts_1.baseCodeReviewPrompt}
${isUpdate ? prompts_1.updateReviewPrompt : ''}
`;
}
parseResponse(response) {
try {
const content = JSON.parse(response.content[0].text);
Expand Down Expand Up @@ -368,33 +400,50 @@ class GeminiProvider {
});
}
async review(request) {
const prompt = this.buildPrompt(request);
core.info(`Sending request to Gemini with prompt structure: ${JSON.stringify(request, null, 2)}`);
core.debug(`Sending request to Gemini with prompt structure: ${JSON.stringify(request, null, 2)}`);
const result = await this.model.generateContent({
systemInstruction: prompts_1.baseCodeReviewPrompt,
systemInstruction: this.buildSystemPrompt(request),
contents: [
{
role: 'user',
parts: [
{
text: prompt,
text: this.buildPullRequestPrompt(request),
}
]
}
]
});
const response = result.response;
core.info(`Raw Gemini response: ${JSON.stringify(response.text(), null, 2)}`);
return this.parseResponse(response);
core.debug(`Raw Gemini response: ${JSON.stringify(response.text(), null, 2)}`);
const parsedResponse = this.parseResponse(response);
core.info(`Parsed response: ${JSON.stringify(parsedResponse, null, 2)}`);
return parsedResponse;
}
buildPrompt(request) {
buildPullRequestPrompt(request) {
var _a;
return JSON.stringify({
type: 'code_review',
files: request.files,
pr: request.pullRequest,
context: request.context,
previousReviews: (_a = request.previousReviews) === null || _a === void 0 ? void 0 : _a.map(review => ({
summary: review.summary,
lineComments: review.lineComments.map(comment => ({
path: comment.path,
line: comment.line,
comment: comment.comment
}))
}))
});
}
buildSystemPrompt(request) {
const isUpdate = request.context.isUpdate;
return `
${prompts_1.baseCodeReviewPrompt}
${isUpdate ? prompts_1.updateReviewPrompt : ''}
`;
}
parseResponse(response) {
try {
const content = JSON.parse(response.text());
Expand Down Expand Up @@ -474,36 +523,51 @@ class OpenAIProvider {
}
async review(request) {
var _a;
const prompt = this.buildPrompt(request);
core.info(`Sending request to OpenAI with prompt structure: ${JSON.stringify(request, null, 2)}`);
const response = await this.client.chat.completions.create({
model: this.config.model,
messages: [
{
role: 'system',
content: prompts_1.baseCodeReviewPrompt,
content: this.buildSystemPrompt(request),
},
{
role: 'user',
content: prompt,
content: this.buildPullRequestPrompt(request),
},
],
temperature: (_a = this.config.temperature) !== null && _a !== void 0 ? _a : 0.3,
response_format: { type: 'json_object' },
});
core.info(`Raw OpenAI response: ${JSON.stringify(response.choices[0].message.content, null, 2)}`);
core.debug(`Raw OpenAI response: ${JSON.stringify(response.choices[0].message.content, null, 2)}`);
const parsedResponse = this.parseResponse(response);
core.info(`Parsed response: ${JSON.stringify(parsedResponse, null, 2)}`);
return parsedResponse;
}
buildPrompt(request) {
buildPullRequestPrompt(request) {
var _a;
return JSON.stringify({
type: 'code_review',
files: request.files,
pr: request.pullRequest,
context: request.context,
previousReviews: (_a = request.previousReviews) === null || _a === void 0 ? void 0 : _a.map(review => ({
summary: review.summary,
lineComments: review.lineComments.map(comment => ({
path: comment.path,
line: comment.line,
comment: comment.comment
}))
}))
});
}
buildSystemPrompt(request) {
const isUpdate = request.context.isUpdate;
return `
${prompts_1.baseCodeReviewPrompt}
${isUpdate ? prompts_1.updateReviewPrompt : ''}
`;
}
parseResponse(response) {
var _a;
// Implement response parsing
Expand Down Expand Up @@ -575,23 +639,26 @@ class DiffService {
.map(p => p.trim());
}
async getRelevantFiles(prDetails, lastReviewedCommit) {
const baseUrl = `https://api.github.com/repos/${prDetails.owner}/${prDetails.repo}/pulls/${prDetails.number}`;
const baseUrl = `https://api.github.com/repos/${prDetails.owner}/${prDetails.repo}`;
const diffUrl = lastReviewedCommit ?
`${baseUrl}/compare/${lastReviewedCommit}...${prDetails.head}.diff` :
`${baseUrl}.diff`;
`${baseUrl}/compare/${lastReviewedCommit}...${prDetails.head}` :
`${baseUrl}/pulls/${prDetails.number}`;
const response = await fetch(diffUrl, {
headers: {
'Authorization': `Bearer ${this.githubToken}`,
'Accept': 'application/vnd.github.v3.diff'
'Accept': 'application/vnd.github.v3.diff',
'X-GitHub-Api-Version': '2022-11-28'
}
});
if (!response.ok) {
core.error(`Failed to fetch diff: ${await response.text()}`);
const errorText = await response.text();
core.error(`Failed to fetch diff from ${diffUrl}: ${errorText}`);
throw new Error(`Failed to fetch diff: ${response.statusText}`);
}
const diffText = await response.text();
core.info(`Diff text length: ${diffText.length}`);
core.debug(`Full diff text length: ${diffText.length}`);
const files = (0, parse_diff_1.default)(diffText);
core.info(`Found ${files.length} files in diff`);
return this.filterRelevantFiles(files);
}
filterRelevantFiles(files) {
Expand Down Expand Up @@ -805,6 +872,34 @@ class GitHubService {
});
return (lastCommit === null || lastCommit === void 0 ? void 0 : lastCommit.sha) || null;
}
async getPreviousReviews(prNumber) {
const { data: reviews } = await this.octokit.pulls.listReviews({
owner: this.owner,
repo: this.repo,
pull_number: prNumber,
});
// Filter to bot reviews and fetch their comments
const botReviews = reviews.filter(review => { var _a; return ((_a = review.user) === null || _a === void 0 ? void 0 : _a.login) === 'github-actions[bot]'; });
core.debug(`Found ${botReviews.length} bot reviews`);
const botReviewsWithComments = await Promise.all(botReviews.map(async (review) => {
const { data: comments } = await this.octokit.pulls.listReviewComments({
owner: this.owner,
repo: this.repo,
pull_number: prNumber,
review_id: review.id
});
return {
commit: review.commit_id,
summary: review.body || '',
lineComments: comments.map(comment => ({
path: comment.path,
line: comment.line || 0,
comment: comment.body
}))
};
}));
return botReviewsWithComments;
}
}
exports.GitHubService = GitHubService;

Expand Down Expand Up @@ -865,21 +960,33 @@ class ReviewService {
const prDetails = await this.githubService.getPRDetails(prNumber);
core.info(`PR title: ${prDetails.title}`);
// Get modified files from diff
const modifiedFiles = await this.diffService.getRelevantFiles(prDetails);
const lastReviewedCommit = await this.githubService.getLastReviewedCommit(prNumber);
const isUpdate = !!lastReviewedCommit;
// If this is an update, get previous reviews
let previousReviews;
if (isUpdate) {
previousReviews = await this.githubService.getPreviousReviews(prNumber);
core.debug(`Found ${previousReviews.length} previous reviews`);
}
const modifiedFiles = await this.diffService.getRelevantFiles(prDetails, lastReviewedCommit);
core.info(`Modified files length: ${modifiedFiles.length}`);
// Get full content for each modified file
const filesWithContent = await Promise.all(modifiedFiles.map(async (file) => ({
path: file.path,
content: await this.githubService.getFileContent(file.path, prDetails.head),
originalContent: await this.githubService.getFileContent(file.path, prDetails.base),
diff: file.diff,
})));
const filesWithContent = await Promise.all(modifiedFiles.map(async (file) => {
const fullContent = await this.githubService.getFileContent(file.path, prDetails.head);
return {
path: file.path,
content: fullContent,
originalContent: await this.githubService.getFileContent(file.path, prDetails.base),
diff: file.diff,
};
}));
// Get repository context (package.json, readme, etc)
const contextFiles = await this.getRepositoryContext();
// Perform AI review
const review = await this.aiProvider.review({
files: filesWithContent,
contextFiles,
previousReviews,
pullRequest: {
title: prDetails.title,
description: prDetails.description,
Expand All @@ -890,6 +997,7 @@ class ReviewService {
repository: (_a = process.env.GITHUB_REPOSITORY) !== null && _a !== void 0 ? _a : '',
owner: (_b = process.env.GITHUB_REPOSITORY_OWNER) !== null && _b !== void 0 ? _b : '',
projectContext: process.env.INPUT_PROJECT_CONTEXT,
isUpdate,
},
});
// Add model name to summary
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

Loading
Loading