@mate_tsaava/pr-review 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.md +21 -0
- package/README.md +109 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +133 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +44 -0
- package/dist/config.d.ts +84 -0
- package/dist/config.js +77 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/llm/adapter.d.ts +13 -0
- package/dist/llm/adapter.js +1 -0
- package/dist/llm/azure-openai.d.ts +10 -0
- package/dist/llm/azure-openai.js +73 -0
- package/dist/llm/claude.d.ts +11 -0
- package/dist/llm/claude.js +187 -0
- package/dist/llm/index.d.ts +5 -0
- package/dist/llm/index.js +15 -0
- package/dist/llm/openai.d.ts +10 -0
- package/dist/llm/openai.js +73 -0
- package/dist/llm/types.d.ts +31 -0
- package/dist/llm/types.js +1 -0
- package/dist/reviewer.d.ts +29 -0
- package/dist/reviewer.js +142 -0
- package/dist/rules/clean-code-dotnet.md +3252 -0
- package/dist/rules/pr-review.md +232 -0
- package/dist/utils/batcher.d.ts +13 -0
- package/dist/utils/batcher.js +39 -0
- package/dist/utils/tokens.d.ts +8 -0
- package/dist/utils/tokens.js +11 -0
- package/package.json +54 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
const SYSTEM_PROMPT = `You are a Senior Principal Engineer conducting a code review. You are direct, blunt, and pragmatic.
|
|
3
|
+
|
|
4
|
+
Your task is to review the PR diff and identify issues in the NEW CODE ONLY. For each issue, provide:
|
|
5
|
+
- file: The EXACT file path from the diff header (e.g., "/CredoWebAPI/Controllers/ScoringController.cs")
|
|
6
|
+
- line: The EXACT line number where the problem IS, not where the function starts. Point to the specific problematic line.
|
|
7
|
+
- endLine: (optional) If the issue spans multiple lines, include the ending line number.
|
|
8
|
+
- severity: BLOCK (must fix before merge), HIGH (should fix), or MEDIUM (nice to fix)
|
|
9
|
+
- category: Type of issue (Security, Architecture, Naming, Performance, Clean Code, etc.)
|
|
10
|
+
- codeSnippet: Quote the EXACT problematic code (1-3 lines max) so there's no ambiguity
|
|
11
|
+
- message: A witty, memorable comment about the issue
|
|
12
|
+
- fix: The specific action to fix the issue, referencing the exact lines
|
|
13
|
+
|
|
14
|
+
CRITICAL RULES:
|
|
15
|
+
1. ONLY review lines starting with "+" (added or modified code). These are the NEW lines being introduced.
|
|
16
|
+
2. NEVER comment on lines starting with "-" (deleted/old code). These are being REMOVED from the codebase.
|
|
17
|
+
3. When a line is CHANGED, it appears as "-old" then "+new" - only review the "+new" version.
|
|
18
|
+
4. The "file" field MUST be the exact path from the diff (starts with /)
|
|
19
|
+
5. The "line" field MUST be a positive integer from the NEW code's line numbers (the + lines)
|
|
20
|
+
6. If old problematic code is being deleted and replaced with better code, that is GOOD - do not flag the old code as an issue.
|
|
21
|
+
|
|
22
|
+
Return your response as valid JSON in this exact format:
|
|
23
|
+
{
|
|
24
|
+
"issues": [
|
|
25
|
+
{
|
|
26
|
+
"file": "/path/to/file.cs",
|
|
27
|
+
"line": 42,
|
|
28
|
+
"endLine": 43,
|
|
29
|
+
"severity": "HIGH",
|
|
30
|
+
"category": "Naming",
|
|
31
|
+
"codeSnippet": "var isLimit = await productTask;",
|
|
32
|
+
"message": "Your creative roast here",
|
|
33
|
+
"fix": "The actual fix they need, e.g., 'On lines 42-43, change X to Y'"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"summary": "Review complete. BLOCK: X | HIGH: X | MEDIUM: X"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
If there are no issues, return:
|
|
40
|
+
{
|
|
41
|
+
"issues": [],
|
|
42
|
+
"summary": "Ship it! Clean code detected."
|
|
43
|
+
}`;
|
|
44
|
+
export class ClaudeAdapter {
|
|
45
|
+
client;
|
|
46
|
+
model;
|
|
47
|
+
verbose;
|
|
48
|
+
constructor(config) {
|
|
49
|
+
this.client = new Anthropic({ apiKey: config.apiKey });
|
|
50
|
+
this.model = config.model;
|
|
51
|
+
this.verbose = config.verbose || false;
|
|
52
|
+
}
|
|
53
|
+
async review(prompt) {
|
|
54
|
+
const userPrompt = this.buildUserPrompt(prompt);
|
|
55
|
+
const systemPrompt = SYSTEM_PROMPT + "\n\n" + prompt.rules;
|
|
56
|
+
const maxRetries = 3;
|
|
57
|
+
let lastError = null;
|
|
58
|
+
if (this.verbose) {
|
|
59
|
+
console.log("\n" + "=".repeat(80));
|
|
60
|
+
console.log("📤 API CALL: Claude messages.create");
|
|
61
|
+
console.log("=".repeat(80));
|
|
62
|
+
console.log(`Model: ${this.model}`);
|
|
63
|
+
console.log(`Max tokens: 4096`);
|
|
64
|
+
console.log(`System prompt length: ${systemPrompt.length} chars`);
|
|
65
|
+
console.log(`User prompt length: ${userPrompt.length} chars`);
|
|
66
|
+
console.log("\n--- SYSTEM PROMPT ---");
|
|
67
|
+
console.log(systemPrompt.slice(0, 2000) + (systemPrompt.length > 2000 ? "\n... (truncated)" : ""));
|
|
68
|
+
console.log("\n--- USER PROMPT (first 3000 chars) ---");
|
|
69
|
+
console.log(userPrompt.slice(0, 3000) + (userPrompt.length > 3000 ? "\n... (truncated)" : ""));
|
|
70
|
+
console.log("=".repeat(80) + "\n");
|
|
71
|
+
}
|
|
72
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.client.messages.create({
|
|
75
|
+
model: this.model,
|
|
76
|
+
max_tokens: 4096,
|
|
77
|
+
system: systemPrompt,
|
|
78
|
+
messages: [
|
|
79
|
+
{
|
|
80
|
+
role: "user",
|
|
81
|
+
content: userPrompt,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
if (this.verbose) {
|
|
86
|
+
console.log("\n" + "=".repeat(80));
|
|
87
|
+
console.log("📥 API RESPONSE");
|
|
88
|
+
console.log("=".repeat(80));
|
|
89
|
+
console.log(`Input tokens: ${response.usage?.input_tokens}`);
|
|
90
|
+
console.log(`Output tokens: ${response.usage?.output_tokens}`);
|
|
91
|
+
console.log(`Stop reason: ${response.stop_reason}`);
|
|
92
|
+
}
|
|
93
|
+
const content = response.content[0];
|
|
94
|
+
if (content.type !== "text") {
|
|
95
|
+
throw new Error("Unexpected response type from Claude");
|
|
96
|
+
}
|
|
97
|
+
if (this.verbose) {
|
|
98
|
+
console.log("\n--- RAW RESPONSE ---");
|
|
99
|
+
console.log(content.text);
|
|
100
|
+
console.log("=".repeat(80) + "\n");
|
|
101
|
+
}
|
|
102
|
+
return this.parseResponse(content.text);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
106
|
+
const isRateLimit = lastError.message.includes("429") || lastError.message.includes("rate_limit");
|
|
107
|
+
if (this.verbose) {
|
|
108
|
+
console.log(`\n❌ API Error (attempt ${attempt}/${maxRetries}): ${lastError.message}`);
|
|
109
|
+
}
|
|
110
|
+
if (isRateLimit && attempt < maxRetries) {
|
|
111
|
+
const waitTime = Math.pow(2, attempt) * 30; // 60s, 120s, 240s
|
|
112
|
+
console.log(`⏳ Rate limited. Waiting ${waitTime}s before retry ${attempt + 1}/${maxRetries}...`);
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
throw lastError;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastError;
|
|
121
|
+
}
|
|
122
|
+
buildUserPrompt(prompt) {
|
|
123
|
+
let userPrompt = `## PR Metadata
|
|
124
|
+
- ID: ${prompt.prMetadata.id}
|
|
125
|
+
- Title: ${prompt.prMetadata.title}
|
|
126
|
+
- Author: ${prompt.prMetadata.author}
|
|
127
|
+
- Source Branch: ${prompt.prMetadata.sourceBranch}
|
|
128
|
+
- Target Branch: ${prompt.prMetadata.targetBranch}
|
|
129
|
+
- Description: ${prompt.prMetadata.description || "No description"}
|
|
130
|
+
|
|
131
|
+
## File Diffs
|
|
132
|
+
`;
|
|
133
|
+
for (const diff of prompt.diffs) {
|
|
134
|
+
userPrompt += `\n### ${diff.path}\n\`\`\`diff\n${diff.diff}\n\`\`\`\n`;
|
|
135
|
+
}
|
|
136
|
+
if (prompt.cleanCodeGuide) {
|
|
137
|
+
userPrompt += `\n## Clean Code Guidelines Reference\n${prompt.cleanCodeGuide.slice(0, 10000)}\n`;
|
|
138
|
+
}
|
|
139
|
+
return userPrompt;
|
|
140
|
+
}
|
|
141
|
+
parseResponse(text) {
|
|
142
|
+
// Extract JSON - find the first { and match to its closing }
|
|
143
|
+
const startIdx = text.indexOf("{");
|
|
144
|
+
if (startIdx === -1) {
|
|
145
|
+
throw new Error("Could not find JSON in Claude response");
|
|
146
|
+
}
|
|
147
|
+
// Find matching closing brace
|
|
148
|
+
let depth = 0;
|
|
149
|
+
let endIdx = -1;
|
|
150
|
+
for (let i = startIdx; i < text.length; i++) {
|
|
151
|
+
if (text[i] === "{")
|
|
152
|
+
depth++;
|
|
153
|
+
if (text[i] === "}")
|
|
154
|
+
depth--;
|
|
155
|
+
if (depth === 0) {
|
|
156
|
+
endIdx = i;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (endIdx === -1) {
|
|
161
|
+
throw new Error("Could not find closing brace in Claude response");
|
|
162
|
+
}
|
|
163
|
+
const jsonStr = text.slice(startIdx, endIdx + 1);
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(jsonStr);
|
|
166
|
+
const issues = (parsed.issues || []).filter((issue) => {
|
|
167
|
+
// Validate issue has valid file path and line number
|
|
168
|
+
if (!issue.file || !issue.file.startsWith("/")) {
|
|
169
|
+
console.warn(`⚠ Skipping issue with invalid file path: ${issue.file}`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (!issue.line || issue.line <= 0) {
|
|
173
|
+
console.warn(`⚠ Skipping issue with invalid line number: ${issue.line}`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
issues,
|
|
180
|
+
summary: parsed.summary || "Review complete.",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
throw new Error(`Failed to parse Claude response: ${error}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LLMAdapter, LLMAdapterConfig } from "./adapter.js";
|
|
2
|
+
export type LLMProvider = "claude" | "openai" | "azure-openai";
|
|
3
|
+
export declare function createLLMAdapter(provider: LLMProvider, config: LLMAdapterConfig): LLMAdapter;
|
|
4
|
+
export type { LLMAdapter, LLMAdapterConfig } from "./adapter.js";
|
|
5
|
+
export type { ReviewPrompt, ReviewResult, ReviewIssue, Severity } from "./types.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ClaudeAdapter } from "./claude.js";
|
|
2
|
+
import { OpenAIAdapter } from "./openai.js";
|
|
3
|
+
import { AzureOpenAIAdapter } from "./azure-openai.js";
|
|
4
|
+
export function createLLMAdapter(provider, config) {
|
|
5
|
+
switch (provider) {
|
|
6
|
+
case "claude":
|
|
7
|
+
return new ClaudeAdapter(config);
|
|
8
|
+
case "openai":
|
|
9
|
+
return new OpenAIAdapter(config);
|
|
10
|
+
case "azure-openai":
|
|
11
|
+
return new AzureOpenAIAdapter(config);
|
|
12
|
+
default:
|
|
13
|
+
throw new Error(`Unknown LLM provider: ${provider}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LLMAdapter, LLMAdapterConfig } from "./adapter.js";
|
|
2
|
+
import type { ReviewPrompt, ReviewResult } from "./types.js";
|
|
3
|
+
export declare class OpenAIAdapter implements LLMAdapter {
|
|
4
|
+
private client;
|
|
5
|
+
private model;
|
|
6
|
+
constructor(config: LLMAdapterConfig);
|
|
7
|
+
review(prompt: ReviewPrompt): Promise<ReviewResult>;
|
|
8
|
+
private buildUserPrompt;
|
|
9
|
+
private parseResponse;
|
|
10
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
const SYSTEM_PROMPT = `You are a Senior Principal Engineer conducting a code review. You are direct, blunt, and pragmatic.
|
|
3
|
+
|
|
4
|
+
Your task is to review the PR diff and identify issues. For each issue, provide:
|
|
5
|
+
- file: The file path
|
|
6
|
+
- line: The line number where the issue occurs
|
|
7
|
+
- severity: BLOCK (must fix before merge), HIGH (should fix), or MEDIUM (nice to fix)
|
|
8
|
+
- category: Type of issue (Security, Architecture, Naming, Performance, Clean Code, etc.)
|
|
9
|
+
- message: A witty, memorable comment about the issue
|
|
10
|
+
- fix: The specific action to fix the issue
|
|
11
|
+
|
|
12
|
+
IMPORTANT: Return your response as valid JSON in this exact format:
|
|
13
|
+
{
|
|
14
|
+
"issues": [...],
|
|
15
|
+
"summary": "Review complete. BLOCK: X | HIGH: X | MEDIUM: X"
|
|
16
|
+
}`;
|
|
17
|
+
export class OpenAIAdapter {
|
|
18
|
+
client;
|
|
19
|
+
model;
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.client = new OpenAI({ apiKey: config.apiKey });
|
|
22
|
+
this.model = config.model;
|
|
23
|
+
}
|
|
24
|
+
async review(prompt) {
|
|
25
|
+
const userPrompt = this.buildUserPrompt(prompt);
|
|
26
|
+
const response = await this.client.chat.completions.create({
|
|
27
|
+
model: this.model,
|
|
28
|
+
messages: [
|
|
29
|
+
{
|
|
30
|
+
role: "system",
|
|
31
|
+
content: SYSTEM_PROMPT + "\n\n" + prompt.rules,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
role: "user",
|
|
35
|
+
content: userPrompt,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
response_format: { type: "json_object" },
|
|
39
|
+
});
|
|
40
|
+
const content = response.choices[0]?.message?.content;
|
|
41
|
+
if (!content) {
|
|
42
|
+
throw new Error("Empty response from OpenAI");
|
|
43
|
+
}
|
|
44
|
+
return this.parseResponse(content);
|
|
45
|
+
}
|
|
46
|
+
buildUserPrompt(prompt) {
|
|
47
|
+
let userPrompt = `## PR Metadata
|
|
48
|
+
- ID: ${prompt.prMetadata.id}
|
|
49
|
+
- Title: ${prompt.prMetadata.title}
|
|
50
|
+
- Author: ${prompt.prMetadata.author}
|
|
51
|
+
- Source Branch: ${prompt.prMetadata.sourceBranch}
|
|
52
|
+
- Target Branch: ${prompt.prMetadata.targetBranch}
|
|
53
|
+
|
|
54
|
+
## File Diffs
|
|
55
|
+
`;
|
|
56
|
+
for (const diff of prompt.diffs) {
|
|
57
|
+
userPrompt += `\n### ${diff.path}\n\`\`\`diff\n${diff.diff}\n\`\`\`\n`;
|
|
58
|
+
}
|
|
59
|
+
return userPrompt;
|
|
60
|
+
}
|
|
61
|
+
parseResponse(text) {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(text);
|
|
64
|
+
return {
|
|
65
|
+
issues: parsed.issues || [],
|
|
66
|
+
summary: parsed.summary || "Review complete.",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`Failed to parse OpenAI response: ${error}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Severity = "BLOCK" | "HIGH" | "MEDIUM";
|
|
2
|
+
export interface ReviewIssue {
|
|
3
|
+
file: string;
|
|
4
|
+
line: number;
|
|
5
|
+
endLine?: number;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
category: string;
|
|
8
|
+
codeSnippet?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fix: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ReviewResult {
|
|
13
|
+
issues: ReviewIssue[];
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ReviewPrompt {
|
|
17
|
+
prMetadata: {
|
|
18
|
+
id: number;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
sourceBranch: string;
|
|
22
|
+
targetBranch: string;
|
|
23
|
+
author: string;
|
|
24
|
+
};
|
|
25
|
+
diffs: Array<{
|
|
26
|
+
path: string;
|
|
27
|
+
diff: string;
|
|
28
|
+
}>;
|
|
29
|
+
rules: string;
|
|
30
|
+
cleanCodeGuide?: string;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReviewResult } from "./llm/types.js";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
export interface ReviewOptions {
|
|
4
|
+
organization: string;
|
|
5
|
+
project: string;
|
|
6
|
+
repositoryId: string;
|
|
7
|
+
pullRequestId: number;
|
|
8
|
+
dryRun: boolean;
|
|
9
|
+
maxTokensPerBatch?: number;
|
|
10
|
+
maxFilesPerBatch?: number;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ReviewOutput {
|
|
14
|
+
result: ReviewResult;
|
|
15
|
+
postedComments: number;
|
|
16
|
+
summaryPosted: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare class PRReviewer {
|
|
19
|
+
private config;
|
|
20
|
+
private client;
|
|
21
|
+
private fetcher;
|
|
22
|
+
private poster;
|
|
23
|
+
constructor(config: Config, organization: string, project: string);
|
|
24
|
+
review(options: ReviewOptions): Promise<ReviewOutput>;
|
|
25
|
+
private loadRules;
|
|
26
|
+
private loadCleanCodeGuide;
|
|
27
|
+
private formatComment;
|
|
28
|
+
private generateSummary;
|
|
29
|
+
}
|
package/dist/reviewer.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { AzureDevOpsClient, PullRequestFetcher, CommentPoster, createPatAuthenticator } from "@mate_tsaava/azure-devops-core";
|
|
5
|
+
import { createLLMAdapter } from "./llm/index.js";
|
|
6
|
+
import { createBatches } from "./utils/batcher.js";
|
|
7
|
+
import { estimateDiffTokens } from "./utils/tokens.js";
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
export class PRReviewer {
|
|
10
|
+
config;
|
|
11
|
+
client;
|
|
12
|
+
fetcher;
|
|
13
|
+
poster;
|
|
14
|
+
constructor(config, organization, project) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.client = new AzureDevOpsClient({
|
|
17
|
+
organization,
|
|
18
|
+
getToken: createPatAuthenticator(),
|
|
19
|
+
});
|
|
20
|
+
this.fetcher = new PullRequestFetcher(this.client, project);
|
|
21
|
+
this.poster = new CommentPoster(this.client, project);
|
|
22
|
+
}
|
|
23
|
+
async review(options) {
|
|
24
|
+
// 1. Fetch PR diff
|
|
25
|
+
console.log(`Fetching PR #${options.pullRequestId}...`);
|
|
26
|
+
const prDiff = await this.fetcher.fetchFullDiff(options.repositoryId, options.pullRequestId);
|
|
27
|
+
console.log(`Found ${prDiff.files.length} changed files`);
|
|
28
|
+
// 2. Load review rules
|
|
29
|
+
const rules = this.loadRules();
|
|
30
|
+
// 3. Create batches
|
|
31
|
+
const maxTokens = options.maxTokensPerBatch || 150000;
|
|
32
|
+
const maxFiles = options.maxFilesPerBatch || 10;
|
|
33
|
+
const diffs = prDiff.diffs.map((d) => ({ path: d.path, diff: d.diff }));
|
|
34
|
+
const totalTokens = estimateDiffTokens(diffs);
|
|
35
|
+
const batches = createBatches(diffs, maxTokens, maxFiles);
|
|
36
|
+
console.log(`Estimated ${totalTokens} tokens across ${diffs.length} files`);
|
|
37
|
+
console.log(`📦 Processing ${batches.length} batch(es)...`);
|
|
38
|
+
// 4. Send to LLM
|
|
39
|
+
console.log(`Analyzing with ${this.config.llm.provider}...`);
|
|
40
|
+
const llm = createLLMAdapter(this.config.llm.provider, {
|
|
41
|
+
apiKey: this.config.llm.apiKey,
|
|
42
|
+
model: this.config.llm.model || "claude-sonnet-4-20250514",
|
|
43
|
+
endpoint: this.config.llm.endpoint,
|
|
44
|
+
verbose: options.verbose,
|
|
45
|
+
});
|
|
46
|
+
const allIssues = [];
|
|
47
|
+
try {
|
|
48
|
+
for (let i = 0; i < batches.length; i++) {
|
|
49
|
+
const batch = batches[i];
|
|
50
|
+
console.log(`🔍 Reviewing batch ${i + 1}/${batches.length} (${batch.files.length} files, ~${batch.totalTokens} tokens)...`);
|
|
51
|
+
const batchResult = await llm.review({
|
|
52
|
+
prMetadata: {
|
|
53
|
+
id: prDiff.pr.id,
|
|
54
|
+
title: prDiff.pr.title,
|
|
55
|
+
description: prDiff.pr.description,
|
|
56
|
+
sourceBranch: prDiff.pr.sourceBranch,
|
|
57
|
+
targetBranch: prDiff.pr.targetBranch,
|
|
58
|
+
author: prDiff.pr.author.displayName,
|
|
59
|
+
},
|
|
60
|
+
diffs: batch.files.map((f) => ({ path: f.path, diff: f.diff })),
|
|
61
|
+
rules,
|
|
62
|
+
cleanCodeGuide: this.loadCleanCodeGuide(),
|
|
63
|
+
});
|
|
64
|
+
allIssues.push(...batchResult.issues);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`Failed to analyze PR with ${this.config.llm.provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
}
|
|
70
|
+
// 5. Generate combined result
|
|
71
|
+
const result = {
|
|
72
|
+
issues: allIssues,
|
|
73
|
+
summary: this.generateSummary(allIssues),
|
|
74
|
+
};
|
|
75
|
+
// 4. Post comments (unless dry run)
|
|
76
|
+
let postedComments = 0;
|
|
77
|
+
let summaryPosted = false;
|
|
78
|
+
if (!options.dryRun) {
|
|
79
|
+
console.log("\nPosting comments...");
|
|
80
|
+
for (const issue of result.issues) {
|
|
81
|
+
try {
|
|
82
|
+
await this.poster.postInlineComment(options.repositoryId, options.pullRequestId, {
|
|
83
|
+
filePath: issue.file,
|
|
84
|
+
line: issue.line,
|
|
85
|
+
content: this.formatComment(issue),
|
|
86
|
+
});
|
|
87
|
+
postedComments++;
|
|
88
|
+
console.log(` • ${issue.severity}: ${issue.category} (${issue.file}:${issue.line})`);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.error(` ✗ Failed to post comment at ${issue.file}:${issue.line}: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await this.poster.postSummaryComment(options.repositoryId, options.pullRequestId, { content: result.summary });
|
|
96
|
+
summaryPosted = true;
|
|
97
|
+
console.log("📝 Summary posted.");
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
console.error(`✗ Failed to post summary comment: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.log("\nDry run - would post these comments:");
|
|
105
|
+
for (const issue of result.issues) {
|
|
106
|
+
console.log(` [${issue.severity}] ${issue.category} (${issue.file}:${issue.line})`);
|
|
107
|
+
console.log(` ${issue.message}`);
|
|
108
|
+
console.log(` Fix: ${issue.fix}\n`);
|
|
109
|
+
}
|
|
110
|
+
console.log(`Summary: ${result.summary}`);
|
|
111
|
+
}
|
|
112
|
+
return { result, postedComments, summaryPosted };
|
|
113
|
+
}
|
|
114
|
+
loadRules() {
|
|
115
|
+
const rulesPath = this.config.rules?.path || join(__dirname, "rules", "pr-review.md");
|
|
116
|
+
if (existsSync(rulesPath)) {
|
|
117
|
+
return readFileSync(rulesPath, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
console.warn(`⚠ Rules file not found at ${rulesPath}, using default rules`);
|
|
120
|
+
return "Review for code quality, security issues, and best practices.";
|
|
121
|
+
}
|
|
122
|
+
loadCleanCodeGuide() {
|
|
123
|
+
// Skip for large PRs to stay within token limits
|
|
124
|
+
// TODO: Add --no-guide flag to control this
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
formatComment(issue) {
|
|
128
|
+
const lineRef = issue.endLine ? `Lines ${issue.line}-${issue.endLine}` : `Line ${issue.line}`;
|
|
129
|
+
const snippet = issue.codeSnippet ? `\n\n\`\`\`\n${issue.codeSnippet}\n\`\`\`` : "";
|
|
130
|
+
return `**[${issue.severity}] ${issue.category}** (${lineRef})${snippet}
|
|
131
|
+
|
|
132
|
+
> ${issue.message}
|
|
133
|
+
|
|
134
|
+
**Fix:** ${issue.fix}`;
|
|
135
|
+
}
|
|
136
|
+
generateSummary(issues) {
|
|
137
|
+
const block = issues.filter((i) => i.severity === "BLOCK").length;
|
|
138
|
+
const high = issues.filter((i) => i.severity === "HIGH").length;
|
|
139
|
+
const medium = issues.filter((i) => i.severity === "MEDIUM").length;
|
|
140
|
+
return `Review complete. BLOCK: ${block} | HIGH: ${high} | MEDIUM: ${medium}`;
|
|
141
|
+
}
|
|
142
|
+
}
|