@theihtisham/review-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/github.ts ADDED
@@ -0,0 +1,180 @@
1
+ import * as core from "@actions/core";
2
+ import * as github from "@actions/github";
3
+ import {
4
+ GitHubPRContext,
5
+ FileDiff,
6
+ ReviewResult,
7
+ ReviewType,
8
+ } from "./types";
9
+ import { formatCommentBody, buildSummaryComment } from "./utils/security";
10
+ import { RateLimiter, RetryHandler } from "./utils/rate-limiter";
11
+
12
+ const apiLimiter = new RateLimiter(5, 300);
13
+
14
+ export class GitHubClient {
15
+ private octokit: ReturnType<typeof github.getOctokit>;
16
+ private context: GitHubPRContext;
17
+
18
+ constructor(token: string) {
19
+ this.octokit = github.getOctokit(token);
20
+ const ctx = github.context;
21
+
22
+ if (!ctx.payload.pull_request) {
23
+ throw new Error(
24
+ "This action can only be run on pull_request events. No pull_request in payload."
25
+ );
26
+ }
27
+
28
+ this.context = {
29
+ owner: ctx.repo.owner,
30
+ repo: ctx.repo.repo,
31
+ pullNumber: ctx.payload.pull_request.number,
32
+ commitId:
33
+ ctx.payload.pull_request.head?.sha ||
34
+ ctx.payload.after ||
35
+ "",
36
+ };
37
+
38
+ core.info(
39
+ `GitHub context: ${this.context.owner}/${this.context.repo}#${this.context.pullNumber} @ ${this.context.commitId.substring(0, 7)}`
40
+ );
41
+ }
42
+
43
+ getContext(): GitHubPRContext {
44
+ return { ...this.context };
45
+ }
46
+
47
+ async getDiff(): Promise<FileDiff[]> {
48
+ await apiLimiter.acquire();
49
+ try {
50
+ return await RetryHandler.withRetry(async () => {
51
+ const response = await this.octokit.rest.pulls.listFiles({
52
+ owner: this.context.owner,
53
+ repo: this.context.repo,
54
+ pull_number: this.context.pullNumber,
55
+ });
56
+
57
+ return response.data
58
+ .filter((file) => file.patch)
59
+ .map((file) => ({
60
+ filename: file.filename,
61
+ patch: file.patch || "",
62
+ additions: file.additions,
63
+ deletions: file.deletions,
64
+ changeType: file.status,
65
+ }));
66
+ }, 2, 1000);
67
+ } finally {
68
+ apiLimiter.release();
69
+ }
70
+ }
71
+
72
+ async getFileContent(path: string, ref?: string): Promise<string | null> {
73
+ await apiLimiter.acquire();
74
+ try {
75
+ return await RetryHandler.withRetry(async () => {
76
+ try {
77
+ const response = await this.octokit.rest.repos.getContent({
78
+ owner: this.context.owner,
79
+ repo: this.context.repo,
80
+ path,
81
+ ref: ref || this.context.commitId,
82
+ });
83
+
84
+ if ("content" in response.data && response.data.content) {
85
+ return Buffer.from(response.data.content, "base64").toString(
86
+ "utf-8"
87
+ );
88
+ }
89
+ return null;
90
+ } catch (err: unknown) {
91
+ if (
92
+ err instanceof Error &&
93
+ "status" in err &&
94
+ (err as { status: number }).status === 404
95
+ ) {
96
+ return null;
97
+ }
98
+ throw err;
99
+ }
100
+ }, 1, 500);
101
+ } finally {
102
+ apiLimiter.release();
103
+ }
104
+ }
105
+
106
+ async postReview(
107
+ result: ReviewResult,
108
+ reviewType: ReviewType,
109
+ filesReviewed: number
110
+ ): Promise<{ reviewId: number; commentsPosted: number }> {
111
+ const event = reviewType === "request-changes" ? "REQUEST_CHANGES" :
112
+ reviewType === "approve" ? "APPROVE" : "COMMENT";
113
+
114
+ const body = buildSummaryComment(
115
+ result.score,
116
+ result.breakdown,
117
+ result.summary,
118
+ filesReviewed,
119
+ result.comments.length
120
+ );
121
+
122
+ const reviewComments = result.comments.map((c) => ({
123
+ path: c.path,
124
+ line: c.line,
125
+ side: c.side as "LEFT" | "RIGHT",
126
+ body: formatCommentBody(c.body, c.severity, c.category),
127
+ ...(c.startLine ? { start_line: c.startLine, start_side: c.side as "LEFT" | "RIGHT" } : {}),
128
+ }));
129
+
130
+ await apiLimiter.acquire();
131
+ try {
132
+ core.info(`Posting review with ${reviewComments.length} comments (event: ${event})`);
133
+
134
+ const response = await RetryHandler.withRetry(
135
+ () =>
136
+ this.octokit.rest.pulls.createReview({
137
+ owner: this.context.owner,
138
+ repo: this.context.repo,
139
+ pull_number: this.context.pullNumber,
140
+ commit_id: this.context.commitId,
141
+ body,
142
+ event,
143
+ comments: reviewComments,
144
+ }),
145
+ 2,
146
+ 2000
147
+ );
148
+
149
+ const reviewId = response.data.id;
150
+
151
+ core.info(`Review posted: ID=${reviewId}, comments=${reviewComments.length}`);
152
+ return { reviewId, commentsPosted: reviewComments.length };
153
+ } finally {
154
+ apiLimiter.release();
155
+ }
156
+ }
157
+
158
+ async postFallbackComment(result: ReviewResult, filesReviewed: number): Promise<void> {
159
+ const body = buildSummaryComment(
160
+ result.score,
161
+ result.breakdown,
162
+ result.summary,
163
+ filesReviewed,
164
+ result.comments.length
165
+ );
166
+
167
+ await apiLimiter.acquire();
168
+ try {
169
+ await this.octokit.rest.issues.createComment({
170
+ owner: this.context.owner,
171
+ repo: this.context.repo,
172
+ issue_number: this.context.pullNumber,
173
+ body,
174
+ });
175
+ core.info("Fallback summary comment posted");
176
+ } finally {
177
+ apiLimiter.release();
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,210 @@
1
+ import OpenAI from "openai";
2
+ import * as core from "@actions/core";
3
+ import {
4
+ LLMProvider,
5
+ ReviewAgentConfig,
6
+ RepoConvention,
7
+ FileDiff,
8
+ LLMReviewResponse,
9
+ } from "./types";
10
+ import { formatDiffForReview, getFileLanguage } from "./utils/diff-parser";
11
+ import { RateLimiter, RetryHandler } from "./utils/rate-limiter";
12
+
13
+ const rateLimiter = new RateLimiter(3, 1000);
14
+
15
+ export class LLMClient {
16
+ private client: OpenAI;
17
+ private model: string;
18
+ private provider: LLMProvider;
19
+
20
+ constructor(config: ReviewAgentConfig) {
21
+ this.provider = config.llm.provider;
22
+ this.model = config.llm.model;
23
+
24
+ const options: ConstructorParameters<typeof OpenAI>[0] = {
25
+ apiKey: config.llm.apiKey || "ollama-placeholder",
26
+ baseURL: config.llm.baseUrl,
27
+ };
28
+
29
+ if (this.provider === "anthropic") {
30
+ options.defaultHeaders = {
31
+ "anthropic-version": "2023-06-01",
32
+ "anthropic-dangerous-direct-browser-access": "true",
33
+ };
34
+ }
35
+
36
+ this.client = new OpenAI(options);
37
+ }
38
+
39
+ async reviewFile(
40
+ diff: FileDiff,
41
+ conventions: RepoConvention[],
42
+ config: ReviewAgentConfig
43
+ ): Promise<LLMReviewResponse> {
44
+ await rateLimiter.acquire();
45
+ try {
46
+ return await RetryHandler.withRetry(
47
+ () => this.doReviewFile(diff, conventions, config),
48
+ 2,
49
+ 1500
50
+ );
51
+ } finally {
52
+ rateLimiter.release();
53
+ }
54
+ }
55
+
56
+ private async doReviewFile(
57
+ diff: FileDiff,
58
+ conventions: RepoConvention[],
59
+ config: ReviewAgentConfig
60
+ ): Promise<LLMReviewResponse> {
61
+ const language = getFileLanguage(diff.filename);
62
+ const diffText = formatDiffForReview(diff);
63
+
64
+ const systemPrompt = this.buildSystemPrompt(language, conventions, config);
65
+ const userPrompt = this.buildUserPrompt(diffText, config);
66
+
67
+ core.info(`Reviewing ${diff.filename} (${language}) with ${this.provider}/${this.model}`);
68
+
69
+ const completion = await this.client.chat.completions.create({
70
+ model: this.model,
71
+ messages: [
72
+ { role: "system", content: systemPrompt },
73
+ { role: "user", content: userPrompt },
74
+ ],
75
+ temperature: 0.1,
76
+ max_tokens: 4096,
77
+ response_format: { type: "json_object" },
78
+ });
79
+
80
+ const content = completion.choices[0]?.message?.content;
81
+ if (!content) {
82
+ throw new Error("LLM returned empty response");
83
+ }
84
+
85
+ return this.parseResponse(content);
86
+ }
87
+
88
+ private buildSystemPrompt(
89
+ language: string,
90
+ conventions: RepoConvention[],
91
+ config: ReviewAgentConfig
92
+ ): string {
93
+ const conventionText =
94
+ conventions.length > 0
95
+ ? conventions
96
+ .map(
97
+ (c) =>
98
+ `- ${c.language}: naming=${c.namingStyle}, patterns=${c.patterns.join(", ")}${c.examples.length > 0 ? `, examples=${c.examples.join("; ")}` : ""}`
99
+ )
100
+ .join("\n")
101
+ : "No conventions learned.";
102
+
103
+ const customRulesText =
104
+ config.rules.length > 0
105
+ ? config.rules
106
+ .map(
107
+ (r) =>
108
+ `- "${r.name}": pattern=/${r.pattern}/, message="${r.message}", severity=${r.severity}, category=${r.category}`
109
+ )
110
+ .join("\n")
111
+ : "No custom rules.";
112
+
113
+ return `You are an expert code reviewer. Analyze the provided code diff and find issues.
114
+
115
+ ## Review Categories
116
+ - **bug**: Logic errors, null/undefined access, off-by-one errors, race conditions, unhandled edge cases
117
+ - **security**: OWASP Top 10 (injection, XSS, CSRF, broken auth, sensitive data exposure, security misconfiguration, etc.), hardcoded secrets, unsafe deserialization
118
+ - **performance**: N+1 queries, unnecessary re-renders, memory leaks, missing indexes, inefficient algorithms
119
+ - **style**: Naming, formatting, code organization, readability
120
+ - **convention**: Violations of project-specific patterns and conventions
121
+
122
+ ## Severity Levels
123
+ - **critical**: Must fix before merge — security vulnerabilities, bugs that will cause failures
124
+ - **warning**: Should fix — potential bugs, performance issues, bad practices
125
+ - **info**: Nice to have — style improvements, minor optimizations
126
+
127
+ ## Repository Conventions
128
+ ${conventionText}
129
+
130
+ ## Custom Rules
131
+ ${customRulesText}
132
+
133
+ ## Language
134
+ The code is primarily ${language}. Apply language-specific best practices.
135
+
136
+ ## Constraints
137
+ - Only flag lines that appear in the diff (lines starting with +)
138
+ - Minimum severity threshold: ${config.review.severity}
139
+ - Be specific: reference the exact line and explain WHY it is an issue and HOW to fix it
140
+ - Do not flag false positives
141
+ - If the code looks good, return an empty comments array
142
+
143
+ ## Response Format
144
+ Respond with a JSON object:
145
+ {
146
+ "comments": [
147
+ {
148
+ "line": <line number in the new file>,
149
+ "endLine": <optional end line for multi-line issues>,
150
+ "severity": "critical" | "warning" | "info",
151
+ "category": "bug" | "security" | "performance" | "style" | "convention",
152
+ "message": "Clear description of the issue and suggested fix"
153
+ }
154
+ ],
155
+ "score": <0-100 overall quality score for this file>,
156
+ "summary": "Brief summary of findings"
157
+ }`;
158
+ }
159
+
160
+ private buildUserPrompt(
161
+ diffText: string,
162
+ _config: ReviewAgentConfig
163
+ ): string {
164
+ return `Please review this code diff and provide feedback:
165
+
166
+ ${diffText}
167
+
168
+ Return your review as a JSON object with "comments", "score", and "summary" fields.`;
169
+ }
170
+
171
+ private parseResponse(content: string): LLMReviewResponse {
172
+ try {
173
+ const parsed = JSON.parse(content);
174
+
175
+ const comments: LLMReviewResponse["comments"] = (parsed.comments || [])
176
+ .filter(
177
+ (c: Record<string, unknown>) =>
178
+ c.line &&
179
+ typeof c.line === "number" &&
180
+ c.severity &&
181
+ c.category &&
182
+ c.message
183
+ )
184
+ .map((c: Record<string, unknown>) => ({
185
+ line: c.line as number,
186
+ endLine: c.endLine as number | undefined,
187
+ severity: c.severity as LLMReviewResponse["comments"][0]["severity"],
188
+ category: c.category as LLMReviewResponse["comments"][0]["category"],
189
+ message: c.message as string,
190
+ }));
191
+
192
+ return {
193
+ comments,
194
+ score: typeof parsed.score === "number"
195
+ ? Math.max(0, Math.min(100, parsed.score))
196
+ : 75,
197
+ summary: typeof parsed.summary === "string" ? parsed.summary : "Review completed.",
198
+ };
199
+ } catch (err) {
200
+ core.warning(
201
+ `Failed to parse LLM response as JSON: ${err instanceof Error ? err.message : String(err)}`
202
+ );
203
+ return {
204
+ comments: [],
205
+ score: 75,
206
+ summary: "Review completed but response parsing failed.",
207
+ };
208
+ }
209
+ }
210
+ }
package/src/main.ts ADDED
@@ -0,0 +1,106 @@
1
+ import * as core from "@actions/core";
2
+ import { parseActionInputs, buildConfig } from "./config";
3
+ import { GitHubClient } from "./github";
4
+ import { Reviewer } from "./reviewer";
5
+ import { learnRepoConventions } from "./conventions";
6
+ import { validateApiKey, sanitizeForLog } from "./utils/security";
7
+
8
+ async function run(): Promise<void> {
9
+ try {
10
+ core.info("ReviewAgent starting...");
11
+
12
+ // 1. Parse and validate inputs
13
+ const inputs = parseActionInputs();
14
+ core.setSecret(inputs.githubToken);
15
+ if (inputs.llmApiKey) {
16
+ core.setSecret(inputs.llmApiKey);
17
+ }
18
+
19
+ validateApiKey(inputs.llmApiKey, inputs.llmProvider);
20
+
21
+ core.info(`Provider: ${inputs.llmProvider}, Model: ${inputs.llmModel}`);
22
+ core.info(`Severity threshold: ${inputs.severity}, Max comments: ${inputs.maxComments}`);
23
+
24
+ // 2. Build config (merge with .reviewagent.yml if present)
25
+ const workspaceDir =
26
+ process.env.GITHUB_WORKSPACE || process.cwd();
27
+ const config = buildConfig(inputs, workspaceDir);
28
+
29
+ // 3. Initialize GitHub client and get PR diff
30
+ const githubClient = new GitHubClient(inputs.githubToken);
31
+ const diffs = await githubClient.getDiff();
32
+
33
+ if (diffs.length === 0) {
34
+ core.info("No files changed in this PR. Nothing to review.");
35
+ setOutputs(0, "0", 100, "No files to review.");
36
+ return;
37
+ }
38
+
39
+ core.info(`Found ${diffs.length} changed files`);
40
+
41
+ // 4. Learn repo conventions from existing code
42
+ const conventions = await learnRepoConventions(
43
+ workspaceDir,
44
+ diffs,
45
+ config
46
+ );
47
+ if (conventions.length > 0) {
48
+ core.info(
49
+ `Learned conventions for: ${conventions.map((c) => c.language).join(", ")}`
50
+ );
51
+ }
52
+
53
+ // 5. Run the review
54
+ const reviewer = new Reviewer(config);
55
+ const result = await reviewer.review(diffs, conventions);
56
+
57
+ // 6. Post the review
58
+ const filesReviewed = diffs.filter(
59
+ (d) => !require("./utils/diff-parser").shouldIgnoreFile(d.filename, config)
60
+ ).length;
61
+
62
+ try {
63
+ const { reviewId, commentsPosted } = await githubClient.postReview(
64
+ result,
65
+ config.review.reviewType,
66
+ filesReviewed
67
+ );
68
+
69
+ setOutputs(
70
+ reviewId,
71
+ commentsPosted.toString(),
72
+ result.score,
73
+ result.summary
74
+ );
75
+ } catch (postErr) {
76
+ // If inline review fails (e.g., outdated diff), post as issue comment
77
+ core.warning(
78
+ `Inline review failed, posting as comment: ${postErr instanceof Error ? postErr.message : String(postErr)}`
79
+ );
80
+ await githubClient.postFallbackComment(result, filesReviewed);
81
+ setOutputs(0, "0", result.score, result.summary);
82
+ }
83
+
84
+ core.info(
85
+ `ReviewAgent complete: score=${result.score}, comments=${result.comments.length}`
86
+ );
87
+ } catch (err) {
88
+ const message =
89
+ err instanceof Error ? err.message : String(err);
90
+ core.setFailed(`ReviewAgent failed: ${sanitizeForLog(message)}`);
91
+ }
92
+ }
93
+
94
+ function setOutputs(
95
+ reviewId: number,
96
+ commentsPosted: string,
97
+ score: number,
98
+ summary: string
99
+ ): void {
100
+ core.setOutput("review-id", reviewId.toString());
101
+ core.setOutput("comments-posted", commentsPosted);
102
+ core.setOutput("score", score.toString());
103
+ core.setOutput("summary", summary);
104
+ }
105
+
106
+ run();
@@ -0,0 +1,161 @@
1
+ import * as core from "@actions/core";
2
+ import {
3
+ FileDiff,
4
+ ReviewAgentConfig,
5
+ ReviewComment,
6
+ ReviewResult,
7
+ RepoConvention,
8
+ ReviewCategory,
9
+ } from "./types";
10
+ import { LLMClient } from "./llm-client";
11
+ import { scanForSecurityIssues } from "./reviewers/security";
12
+ import { shouldIgnoreFile } from "./utils/diff-parser";
13
+ import { severityMeetsThreshold } from "./config";
14
+
15
+ export class Reviewer {
16
+ private llm: LLMClient;
17
+ private config: ReviewAgentConfig;
18
+
19
+ constructor(config: ReviewAgentConfig) {
20
+ this.llm = new LLMClient(config);
21
+ this.config = config;
22
+ }
23
+
24
+ async review(
25
+ diffs: FileDiff[],
26
+ conventions: RepoConvention[]
27
+ ): Promise<ReviewResult> {
28
+ const allComments: ReviewComment[] = [];
29
+ let totalScore = 0;
30
+ let scoredFiles = 0;
31
+ const breakdown: Record<ReviewCategory, number> = {
32
+ bug: 0,
33
+ security: 0,
34
+ performance: 0,
35
+ style: 0,
36
+ convention: 0,
37
+ };
38
+
39
+ const filteredDiffs = diffs.filter(
40
+ (d) => !shouldIgnoreFile(d.filename, this.config)
41
+ );
42
+
43
+ core.info(
44
+ `Reviewing ${filteredDiffs.length} files (${diffs.length - filteredDiffs.length} ignored)`
45
+ );
46
+
47
+ for (const diff of filteredDiffs) {
48
+ core.info(`Reviewing: ${diff.filename}`);
49
+
50
+ // 1. Static security scan (fast, local)
51
+ const securityComments = scanForSecurityIssues(diff, this.config);
52
+ for (const comment of securityComments) {
53
+ allComments.push(comment);
54
+ breakdown[comment.category]++;
55
+ }
56
+
57
+ // 2. LLM-powered deep review
58
+ try {
59
+ const llmResult = await this.llm.reviewFile(
60
+ diff,
61
+ conventions,
62
+ this.config
63
+ );
64
+
65
+ totalScore += llmResult.score;
66
+ scoredFiles++;
67
+
68
+ for (const llmComment of llmResult.comments) {
69
+ // Deduplicate: skip if static scanner already flagged this line for security
70
+ const isDuplicate =
71
+ llmComment.category === "security" &&
72
+ securityComments.some((sc) => sc.line === llmComment.line);
73
+
74
+ if (!isDuplicate && severityMeetsThreshold(llmComment.severity, this.config.review.severity)) {
75
+ allComments.push({
76
+ path: diff.filename,
77
+ line: llmComment.line,
78
+ side: "RIGHT",
79
+ body: llmComment.message,
80
+ severity: llmComment.severity,
81
+ category: llmComment.category,
82
+ startLine: llmComment.endLine,
83
+ });
84
+ breakdown[llmComment.category]++;
85
+ }
86
+ }
87
+ } catch (err) {
88
+ core.warning(
89
+ `LLM review failed for ${diff.filename}: ${err instanceof Error ? err.message : String(err)}`
90
+ );
91
+ // Continue reviewing other files
92
+ }
93
+ }
94
+
95
+ // Sort by severity (critical first), then by file and line
96
+ const sortedComments = allComments
97
+ .sort((a, b) => {
98
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
99
+ const sevDiff = severityOrder[a.severity] - severityOrder[b.severity];
100
+ if (sevDiff !== 0) return sevDiff;
101
+ const pathDiff = a.path.localeCompare(b.path);
102
+ if (pathDiff !== 0) return pathDiff;
103
+ return a.line - b.line;
104
+ })
105
+ .slice(0, this.config.review.maxComments);
106
+
107
+ const avgScore =
108
+ scoredFiles > 0 ? Math.round(totalScore / scoredFiles) : 75;
109
+
110
+ // Deduct for issues found
111
+ const criticalCount = allComments.filter(
112
+ (c) => c.severity === "critical"
113
+ ).length;
114
+ const warningCount = allComments.filter(
115
+ (c) => c.severity === "warning"
116
+ ).length;
117
+ const finalScore = Math.max(
118
+ 0,
119
+ Math.min(100, avgScore - criticalCount * 10 - warningCount * 3)
120
+ );
121
+
122
+ const summary = buildReviewSummary(sortedComments, filteredDiffs.length);
123
+
124
+ core.info(
125
+ `Review complete: score=${finalScore}, comments=${sortedComments.length}/${allComments.length}`
126
+ );
127
+
128
+ return {
129
+ comments: sortedComments,
130
+ score: finalScore,
131
+ summary,
132
+ breakdown,
133
+ };
134
+ }
135
+ }
136
+
137
+ function buildReviewSummary(
138
+ comments: ReviewComment[],
139
+ filesReviewed: number
140
+ ): string {
141
+ if (comments.length === 0) {
142
+ return `Reviewed ${filesReviewed} files. No issues found. Clean code!`;
143
+ }
144
+
145
+ const criticalCount = comments.filter((c) => c.severity === "critical").length;
146
+ const warningCount = comments.filter((c) => c.severity === "warning").length;
147
+ const infoCount = comments.filter((c) => c.severity === "info").length;
148
+
149
+ const parts: string[] = [];
150
+ if (criticalCount > 0) {
151
+ parts.push(`${criticalCount} critical issue${criticalCount > 1 ? "s" : ""}`);
152
+ }
153
+ if (warningCount > 0) {
154
+ parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
155
+ }
156
+ if (infoCount > 0) {
157
+ parts.push(`${infoCount} suggestion${infoCount > 1 ? "s" : ""}`);
158
+ }
159
+
160
+ return `Reviewed ${filesReviewed} files. Found ${parts.join(", ")}.`;
161
+ }