@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/LICENSE +21 -0
- package/README.md +352 -0
- package/__tests__/config.test.ts +99 -0
- package/__tests__/diff-parser.test.ts +137 -0
- package/__tests__/fixtures/mock-data.ts +120 -0
- package/__tests__/llm-client.test.ts +166 -0
- package/__tests__/rate-limiter.test.ts +95 -0
- package/__tests__/security-utils.test.ts +152 -0
- package/__tests__/security.test.ts +138 -0
- package/action.yml +65 -0
- package/dist/index.js +55824 -0
- package/dist/sourcemap-register.js +1 -0
- package/package.json +46 -0
- package/src/config.ts +201 -0
- package/src/conventions.ts +288 -0
- package/src/github.ts +180 -0
- package/src/llm-client.ts +210 -0
- package/src/main.ts +106 -0
- package/src/reviewer.ts +161 -0
- package/src/reviewers/security.ts +205 -0
- package/src/types.ts +114 -0
- package/src/utils/diff-parser.ts +177 -0
- package/src/utils/rate-limiter.ts +72 -0
- package/src/utils/security.ts +125 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +16 -0
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();
|
package/src/reviewer.ts
ADDED
|
@@ -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
|
+
}
|