specvector 0.0.1 â 0.1.2
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/README.md +132 -12
- package/package.json +28 -7
- package/src/agent/.gitkeep +0 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/loop.ts +221 -0
- package/src/agent/tools/find-symbol.ts +224 -0
- package/src/agent/tools/grep.ts +149 -0
- package/src/agent/tools/index.ts +9 -0
- package/src/agent/tools/list-dir.ts +191 -0
- package/src/agent/tools/outline.ts +259 -0
- package/src/agent/tools/read-file.ts +140 -0
- package/src/agent/types.ts +145 -0
- package/src/config/.gitkeep +0 -0
- package/src/config/index.ts +285 -0
- package/src/context/index.ts +11 -0
- package/src/context/linear.ts +201 -0
- package/src/github/.gitkeep +0 -0
- package/src/github/comment.ts +102 -0
- package/src/github/diff.ts +90 -0
- package/src/index.ts +247 -0
- package/src/llm/factory.ts +146 -0
- package/src/llm/index.ts +50 -0
- package/src/llm/ollama.ts +321 -0
- package/src/llm/openrouter.ts +348 -0
- package/src/llm/provider.ts +133 -0
- package/src/mcp/.gitkeep +0 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/mcp-client.ts +382 -0
- package/src/mcp/types.ts +104 -0
- package/src/review/.gitkeep +0 -0
- package/src/review/diff-parser.ts +168 -0
- package/src/review/engine.ts +268 -0
- package/src/review/formatter.ts +168 -0
- package/src/tools/.gitkeep +0 -0
- package/src/types/diff.ts +65 -0
- package/src/types/llm.ts +126 -0
- package/src/types/result.ts +17 -0
- package/src/types/review.ts +111 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Context Provider - Fetches ticket context for reviews.
|
|
3
|
+
*
|
|
4
|
+
* Extracts ticket ID from branch name/PR and fetches details via MCP.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createMCPClient } from "../mcp";
|
|
8
|
+
import type { Result } from "../types/result";
|
|
9
|
+
import { ok, err } from "../types/result";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
export interface LinearTicket {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
state: string;
|
|
20
|
+
priority: number;
|
|
21
|
+
labels: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LinearContextError {
|
|
25
|
+
code: "NO_TOKEN" | "NO_TICKET_ID" | "CONNECTION_FAILED" | "FETCH_FAILED";
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Ticket ID Extraction
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract Linear ticket ID from various sources.
|
|
35
|
+
*
|
|
36
|
+
* Patterns supported:
|
|
37
|
+
* - Branch: D6N-5/add-mcp-support or feat/SPC-123-feature
|
|
38
|
+
* - PR Title: [SPC-123] Feature or SPC-123: Feature
|
|
39
|
+
* - PR Body: "Linear: SPC-123" or "Closes SPC-123"
|
|
40
|
+
*/
|
|
41
|
+
export function extractTicketId(
|
|
42
|
+
branchName?: string,
|
|
43
|
+
prTitle?: string,
|
|
44
|
+
prBody?: string
|
|
45
|
+
): string | null {
|
|
46
|
+
// Linear ticket pattern: 2-4 alphanumeric chars (at least one letter), hyphen, 1+ digits
|
|
47
|
+
// Examples: SPC-123, D6N-5, ABC-42
|
|
48
|
+
const ticketPattern = /\b([A-Z0-9]{2,4}-\d+)\b/;
|
|
49
|
+
|
|
50
|
+
// Try branch name first (most reliable)
|
|
51
|
+
if (branchName) {
|
|
52
|
+
const match = branchName.match(ticketPattern);
|
|
53
|
+
if (match) return match[1] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Try PR title
|
|
57
|
+
if (prTitle) {
|
|
58
|
+
const match = prTitle.match(ticketPattern);
|
|
59
|
+
if (match) return match[1] ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Try PR body
|
|
63
|
+
if (prBody) {
|
|
64
|
+
const match = prBody.match(ticketPattern);
|
|
65
|
+
if (match) return match[1] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Linear Context Fetching
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fetch Linear ticket context for a review.
|
|
77
|
+
*
|
|
78
|
+
* Returns formatted context string to inject into review prompt.
|
|
79
|
+
*/
|
|
80
|
+
export async function fetchLinearContext(
|
|
81
|
+
ticketId: string
|
|
82
|
+
): Promise<Result<string, LinearContextError>> {
|
|
83
|
+
// Check for API token
|
|
84
|
+
const apiToken = process.env.LINEAR_API_TOKEN;
|
|
85
|
+
if (!apiToken) {
|
|
86
|
+
return err({
|
|
87
|
+
code: "NO_TOKEN",
|
|
88
|
+
message: "LINEAR_API_TOKEN not set",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Create MCP client
|
|
93
|
+
const clientResult = await createMCPClient({
|
|
94
|
+
name: "linear",
|
|
95
|
+
command: "npx",
|
|
96
|
+
args: ["linear-mcp-server"],
|
|
97
|
+
env: {
|
|
98
|
+
LINEAR_API_KEY: apiToken,
|
|
99
|
+
},
|
|
100
|
+
timeout: 30000,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!clientResult.ok) {
|
|
104
|
+
return err({
|
|
105
|
+
code: "CONNECTION_FAILED",
|
|
106
|
+
message: clientResult.error.message,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const client = clientResult.value;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Fetch ticket details
|
|
114
|
+
const ticketResult = await client.callTool("get_ticket", { ticket_id: ticketId });
|
|
115
|
+
|
|
116
|
+
if (!ticketResult.ok) {
|
|
117
|
+
return err({
|
|
118
|
+
code: "FETCH_FAILED",
|
|
119
|
+
message: ticketResult.error.message,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Parse the response
|
|
124
|
+
const content = ticketResult.value.content;
|
|
125
|
+
const textContent = content.find(c => c.type === "text");
|
|
126
|
+
|
|
127
|
+
if (!textContent?.text) {
|
|
128
|
+
return err({
|
|
129
|
+
code: "FETCH_FAILED",
|
|
130
|
+
message: "No ticket data returned",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Format the context for the review prompt
|
|
135
|
+
const contextBlock = formatLinearContext(ticketId, textContent.text);
|
|
136
|
+
return ok(contextBlock);
|
|
137
|
+
|
|
138
|
+
} finally {
|
|
139
|
+
await client.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Format Linear ticket data as context for review prompt.
|
|
145
|
+
*/
|
|
146
|
+
function formatLinearContext(ticketId: string, ticketData: string): string {
|
|
147
|
+
return `
|
|
148
|
+
## Linear Ticket Context: ${ticketId}
|
|
149
|
+
|
|
150
|
+
The following is the context from the linked Linear ticket. Use this to understand the business requirements and acceptance criteria for this PR.
|
|
151
|
+
|
|
152
|
+
${ticketData}
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
When reviewing, check that the PR changes align with the ticket requirements above.
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get Linear context if available, with graceful degradation.
|
|
161
|
+
*
|
|
162
|
+
* This is the main entry point for the review engine.
|
|
163
|
+
* Returns context string or null if not available.
|
|
164
|
+
*/
|
|
165
|
+
export async function getLinearContextForReview(
|
|
166
|
+
branchName?: string,
|
|
167
|
+
prTitle?: string,
|
|
168
|
+
prBody?: string
|
|
169
|
+
): Promise<{ context: string | null; ticketId: string | null; warning?: string }> {
|
|
170
|
+
// Extract ticket ID
|
|
171
|
+
const ticketId = extractTicketId(branchName, prTitle, prBody);
|
|
172
|
+
|
|
173
|
+
if (!ticketId) {
|
|
174
|
+
return { context: null, ticketId: null };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for API token first (fast fail)
|
|
178
|
+
if (!process.env.LINEAR_API_TOKEN) {
|
|
179
|
+
return {
|
|
180
|
+
context: null,
|
|
181
|
+
ticketId,
|
|
182
|
+
warning: `Found ticket ${ticketId} but LINEAR_API_TOKEN not set`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Fetch context
|
|
187
|
+
const result = await fetchLinearContext(ticketId);
|
|
188
|
+
|
|
189
|
+
if (!result.ok) {
|
|
190
|
+
return {
|
|
191
|
+
context: null,
|
|
192
|
+
ticketId,
|
|
193
|
+
warning: `Failed to fetch ${ticketId}: ${result.error.message}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
context: result.value,
|
|
199
|
+
ticketId,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub CLI wrapper for posting PR comments.
|
|
3
|
+
* Uses `gh pr comment` to post review feedback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { $ } from "bun";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import type { Result } from "../types/result";
|
|
10
|
+
import { ok, err } from "../types/result";
|
|
11
|
+
|
|
12
|
+
export interface CommentError {
|
|
13
|
+
code: "NOT_FOUND" | "AUTH_ERROR" | "COMMAND_FAILED" | "CONTENT_TOO_LARGE";
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum comment size for GitHub (65536 characters).
|
|
19
|
+
*/
|
|
20
|
+
const MAX_COMMENT_SIZE = 65536;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Post a comment to a pull request.
|
|
24
|
+
*
|
|
25
|
+
* Uses --body-file to avoid shell injection from comment body content.
|
|
26
|
+
*
|
|
27
|
+
* @param prNumber - The PR number to comment on
|
|
28
|
+
* @param body - The markdown content of the comment
|
|
29
|
+
* @returns Result with success boolean or error
|
|
30
|
+
*/
|
|
31
|
+
export async function postPRComment(
|
|
32
|
+
prNumber: number,
|
|
33
|
+
body: string
|
|
34
|
+
): Promise<Result<boolean, CommentError>> {
|
|
35
|
+
// Check comment size
|
|
36
|
+
if (body.length > MAX_COMMENT_SIZE) {
|
|
37
|
+
return err({
|
|
38
|
+
code: "CONTENT_TOO_LARGE",
|
|
39
|
+
message: `Comment too large (${body.length} chars, max ${MAX_COMMENT_SIZE})`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Write body to temp file to prevent shell injection
|
|
44
|
+
const tempFile = join(tmpdir(), `specvector-comment-${Date.now()}.md`);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await Bun.write(tempFile, body);
|
|
48
|
+
|
|
49
|
+
// Use --body-file instead of --body to avoid shell metacharacter issues
|
|
50
|
+
const result = await $`gh pr comment ${prNumber} --body-file ${tempFile}`.quiet();
|
|
51
|
+
|
|
52
|
+
// Clean up temp file
|
|
53
|
+
await Bun.file(tempFile).exists() && await $`rm ${tempFile}`.quiet();
|
|
54
|
+
|
|
55
|
+
if (result.exitCode !== 0) {
|
|
56
|
+
const stderr = result.stderr.toString();
|
|
57
|
+
|
|
58
|
+
// Check for common error patterns
|
|
59
|
+
if (stderr.includes("Could not find pull request")) {
|
|
60
|
+
return err({
|
|
61
|
+
code: "NOT_FOUND",
|
|
62
|
+
message: `PR #${prNumber} not found`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (stderr.includes("authentication") || stderr.includes("gh auth login")) {
|
|
67
|
+
return err({
|
|
68
|
+
code: "AUTH_ERROR",
|
|
69
|
+
message: "GitHub CLI not authenticated. Run: gh auth login",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return err({
|
|
74
|
+
code: "COMMAND_FAILED",
|
|
75
|
+
message: `gh pr comment failed: ${stderr}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ok(true);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Clean up temp file on error
|
|
82
|
+
try {
|
|
83
|
+
await $`rm ${tempFile}`.quiet();
|
|
84
|
+
} catch {
|
|
85
|
+
// Ignore cleanup errors
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
89
|
+
|
|
90
|
+
if (message.includes("not found") || message.includes("ENOENT")) {
|
|
91
|
+
return err({
|
|
92
|
+
code: "COMMAND_FAILED",
|
|
93
|
+
message: "GitHub CLI (gh) not installed. Install from: https://cli.github.com/",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return err({
|
|
98
|
+
code: "COMMAND_FAILED",
|
|
99
|
+
message: `Failed to post comment: ${message}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub CLI wrapper for diff extraction.
|
|
3
|
+
* Uses `gh pr diff` to get PR changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { $ } from "bun";
|
|
7
|
+
import type { Result } from "../types/result";
|
|
8
|
+
import { ok, err } from "../types/result";
|
|
9
|
+
|
|
10
|
+
export interface DiffError {
|
|
11
|
+
code: "NOT_FOUND" | "AUTH_ERROR" | "COMMAND_FAILED";
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the raw diff for a pull request using gh CLI.
|
|
17
|
+
*
|
|
18
|
+
* @param prNumber - The PR number to get diff for
|
|
19
|
+
* @returns Result containing raw diff string or error
|
|
20
|
+
*/
|
|
21
|
+
export async function getPRDiff(prNumber: number): Promise<Result<string, DiffError>> {
|
|
22
|
+
try {
|
|
23
|
+
// Use Bun's shell operator to run gh pr diff
|
|
24
|
+
const result = await $`gh pr diff ${prNumber}`.quiet();
|
|
25
|
+
|
|
26
|
+
if (result.exitCode !== 0) {
|
|
27
|
+
const stderr = result.stderr.toString();
|
|
28
|
+
|
|
29
|
+
// Check for common error patterns
|
|
30
|
+
if (stderr.includes("Could not find pull request")) {
|
|
31
|
+
return err({
|
|
32
|
+
code: "NOT_FOUND",
|
|
33
|
+
message: `PR #${prNumber} not found`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (stderr.includes("authentication") || stderr.includes("gh auth login")) {
|
|
38
|
+
return err({
|
|
39
|
+
code: "AUTH_ERROR",
|
|
40
|
+
message: "GitHub CLI not authenticated. Run: gh auth login",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return err({
|
|
45
|
+
code: "COMMAND_FAILED",
|
|
46
|
+
message: `gh pr diff failed: ${stderr}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return ok(result.stdout.toString());
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Handle case where gh CLI is not installed
|
|
53
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
54
|
+
|
|
55
|
+
if (message.includes("not found") || message.includes("ENOENT")) {
|
|
56
|
+
return err({
|
|
57
|
+
code: "COMMAND_FAILED",
|
|
58
|
+
message: "GitHub CLI (gh) not installed. Install from: https://cli.github.com/",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return err({
|
|
63
|
+
code: "COMMAND_FAILED",
|
|
64
|
+
message: `Failed to get diff: ${message}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if gh CLI is available and authenticated.
|
|
71
|
+
*/
|
|
72
|
+
export async function checkGHCLI(): Promise<Result<boolean, DiffError>> {
|
|
73
|
+
try {
|
|
74
|
+
const result = await $`gh auth status`.quiet();
|
|
75
|
+
|
|
76
|
+
if (result.exitCode !== 0) {
|
|
77
|
+
return err({
|
|
78
|
+
code: "AUTH_ERROR",
|
|
79
|
+
message: "GitHub CLI not authenticated. Run: gh auth login",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return ok(true);
|
|
84
|
+
} catch {
|
|
85
|
+
return err({
|
|
86
|
+
code: "COMMAND_FAILED",
|
|
87
|
+
message: "GitHub CLI (gh) not installed",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SpecVector CLI - Context-aware AI code review
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getPRDiff } from "./github/diff";
|
|
8
|
+
import { postPRComment } from "./github/comment";
|
|
9
|
+
import { parseDiff, getDiffSummary } from "./review/diff-parser";
|
|
10
|
+
import { formatReviewComment, formatReviewSummary } from "./review/formatter";
|
|
11
|
+
import { runReview } from "./review/engine";
|
|
12
|
+
|
|
13
|
+
const VERSION = "0.1.0";
|
|
14
|
+
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
// Handle --help and --version
|
|
19
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
20
|
+
printHelp();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
25
|
+
console.log(`SpecVector v${VERSION}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse command
|
|
30
|
+
const command = args[0];
|
|
31
|
+
|
|
32
|
+
if (!command) {
|
|
33
|
+
printHelp();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (command === "review") {
|
|
38
|
+
await handleReview(args.slice(1));
|
|
39
|
+
} else {
|
|
40
|
+
console.error(`Unknown command: ${command}`);
|
|
41
|
+
console.error("Run with --help for usage information.");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printHelp(): void {
|
|
47
|
+
console.log(`SpecVector CLI v${VERSION}
|
|
48
|
+
Context-aware AI code review
|
|
49
|
+
|
|
50
|
+
USAGE:
|
|
51
|
+
specvector review <pr-number> Review a pull request
|
|
52
|
+
specvector review <pr-number> --dry-run Preview review without posting
|
|
53
|
+
specvector review <pr-number> --mock Use mock review (no LLM)
|
|
54
|
+
specvector --help Show this help
|
|
55
|
+
specvector --version Show version
|
|
56
|
+
|
|
57
|
+
OPTIONS:
|
|
58
|
+
--dry-run Preview the review comment without posting to GitHub
|
|
59
|
+
--mock Use mock review instead of LLM (for testing)
|
|
60
|
+
|
|
61
|
+
ENVIRONMENT:
|
|
62
|
+
OPENROUTER_API_KEY API key for OpenRouter
|
|
63
|
+
SPECVECTOR_PROVIDER LLM provider (openrouter or ollama)
|
|
64
|
+
SPECVECTOR_MODEL Model to use
|
|
65
|
+
|
|
66
|
+
EXAMPLES:
|
|
67
|
+
specvector review 123 Review PR #123 with AI and post comment
|
|
68
|
+
specvector review 123 --dry-run Preview AI review for PR #123
|
|
69
|
+
specvector review 123 --mock Use mock review (no LLM calls)
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleReview(args: string[]): Promise<void> {
|
|
74
|
+
const dryRun = args.includes("--dry-run");
|
|
75
|
+
const useMock = args.includes("--mock");
|
|
76
|
+
const prNumberArg = args.find((arg) => !arg.startsWith("--"));
|
|
77
|
+
|
|
78
|
+
if (!prNumberArg) {
|
|
79
|
+
console.error("Error: PR number required");
|
|
80
|
+
console.error("Usage: specvector review <pr-number>");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const prNumber = parseInt(prNumberArg, 10);
|
|
85
|
+
|
|
86
|
+
if (isNaN(prNumber) || prNumber <= 0) {
|
|
87
|
+
console.error(`Error: Invalid PR number: ${prNumberArg}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`đ Reviewing PR #${prNumber}...`);
|
|
92
|
+
if (dryRun) {
|
|
93
|
+
console.log(" (dry run - will not post comment)");
|
|
94
|
+
}
|
|
95
|
+
if (useMock) {
|
|
96
|
+
console.log(" (mock mode - no LLM calls)");
|
|
97
|
+
}
|
|
98
|
+
console.log("");
|
|
99
|
+
|
|
100
|
+
// Step 1: Get the diff
|
|
101
|
+
console.log("đĨ Fetching diff...");
|
|
102
|
+
const diffResult = await getPRDiff(prNumber);
|
|
103
|
+
|
|
104
|
+
if (!diffResult.ok) {
|
|
105
|
+
console.error(`â ${diffResult.error.message}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rawDiff = diffResult.value;
|
|
110
|
+
|
|
111
|
+
if (!rawDiff.trim()) {
|
|
112
|
+
console.log("âšī¸ No changes found in this PR.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 2: Parse the diff
|
|
117
|
+
console.log("đ Parsing diff...");
|
|
118
|
+
const parsedDiff = parseDiff(rawDiff);
|
|
119
|
+
const diffSummary = getDiffSummary(parsedDiff);
|
|
120
|
+
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log("đ Diff Summary:");
|
|
123
|
+
console.log("â".repeat(40));
|
|
124
|
+
console.log(diffSummary);
|
|
125
|
+
console.log("â".repeat(40));
|
|
126
|
+
console.log("");
|
|
127
|
+
|
|
128
|
+
// Step 3: Generate review (AI or mock)
|
|
129
|
+
if (useMock) {
|
|
130
|
+
console.log("đ¤ Generating mock review...");
|
|
131
|
+
const mockReview = generateMockReview(parsedDiff.filesChanged);
|
|
132
|
+
displayAndPostReview(mockReview, prNumber, dryRun);
|
|
133
|
+
} else {
|
|
134
|
+
console.log("đ¤ Running AI review...");
|
|
135
|
+
console.log(" (this may take 15-30 seconds)");
|
|
136
|
+
console.log("");
|
|
137
|
+
|
|
138
|
+
// Get branch name for Linear context
|
|
139
|
+
// In GitHub Actions PRs, GITHUB_HEAD_REF contains the branch name
|
|
140
|
+
// Fallback to git command for local development
|
|
141
|
+
let branchName: string | undefined = process.env.GITHUB_HEAD_REF;
|
|
142
|
+
if (!branchName) {
|
|
143
|
+
try {
|
|
144
|
+
const { stdout } = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
145
|
+
const branch = new TextDecoder().decode(stdout).trim();
|
|
146
|
+
if (branch && branch !== "HEAD") {
|
|
147
|
+
branchName = branch;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Ignore - branch extraction is optional
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (branchName) {
|
|
154
|
+
console.log(`đ Branch: ${branchName}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const reviewResult = await runReview(rawDiff, diffSummary, {
|
|
158
|
+
workingDir: process.cwd(),
|
|
159
|
+
branchName,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!reviewResult.ok) {
|
|
163
|
+
console.error(`â Review failed: ${reviewResult.error.message}`);
|
|
164
|
+
console.error("");
|
|
165
|
+
console.error("Tip: Run with --mock to test without LLM");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
displayAndPostReview(reviewResult.value, prNumber, dryRun);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function displayAndPostReview(
|
|
174
|
+
reviewResult: import("./types/review").ReviewResult,
|
|
175
|
+
prNumber: number,
|
|
176
|
+
dryRun: boolean
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
// Display review summary
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log("đ Review Result:");
|
|
181
|
+
console.log("â".repeat(40));
|
|
182
|
+
console.log(formatReviewSummary(reviewResult));
|
|
183
|
+
console.log("â".repeat(40));
|
|
184
|
+
console.log("");
|
|
185
|
+
|
|
186
|
+
// Post or preview comment
|
|
187
|
+
if (dryRun) {
|
|
188
|
+
console.log("đ Comment Preview:");
|
|
189
|
+
console.log("â".repeat(50));
|
|
190
|
+
console.log(formatReviewComment(reviewResult));
|
|
191
|
+
console.log("â".repeat(50));
|
|
192
|
+
console.log("");
|
|
193
|
+
console.log("â
Dry run complete (no comment posted)");
|
|
194
|
+
} else {
|
|
195
|
+
console.log("đŦ Posting comment to PR...");
|
|
196
|
+
const commentBody = formatReviewComment(reviewResult);
|
|
197
|
+
const postResult = await postPRComment(prNumber, commentBody);
|
|
198
|
+
|
|
199
|
+
if (!postResult.ok) {
|
|
200
|
+
console.error(`â Failed to post comment: ${postResult.error.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log("â
Review posted successfully!");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate a mock review result (for testing without LLM).
|
|
210
|
+
*/
|
|
211
|
+
function generateMockReview(filesReviewed: number): import("./types/review").ReviewResult {
|
|
212
|
+
const { calculateStats, determineRecommendation } = require("./types/review");
|
|
213
|
+
|
|
214
|
+
const findings: import("./types/review").ReviewFinding[] = [
|
|
215
|
+
{
|
|
216
|
+
severity: "MEDIUM" as const,
|
|
217
|
+
category: "Code Quality",
|
|
218
|
+
title: "Consider adding error handling",
|
|
219
|
+
description: "The function does not handle potential edge cases.",
|
|
220
|
+
suggestion: "Add try/catch block or validate inputs.",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
severity: "LOW" as const,
|
|
224
|
+
category: "Documentation",
|
|
225
|
+
title: "Missing JSDoc comment",
|
|
226
|
+
description: "Public functions should have JSDoc comments.",
|
|
227
|
+
suggestion: "Add JSDoc describing parameters and return value.",
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const stats = calculateStats(findings);
|
|
232
|
+
const recommendation = determineRecommendation(stats);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
findings,
|
|
236
|
+
summary: "This is a mock review for testing. Run without --mock for AI-powered review.",
|
|
237
|
+
recommendation,
|
|
238
|
+
stats,
|
|
239
|
+
filesReviewed,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Run the CLI
|
|
244
|
+
main().catch((error) => {
|
|
245
|
+
console.error("Fatal error:", error);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
});
|