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,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client - Unified wrapper for MCP server connections.
|
|
3
|
+
*
|
|
4
|
+
* Uses subprocess/stdio transport with JSON-RPC 2.0 protocol.
|
|
5
|
+
* Designed for ephemeral connections in CI/CD environments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, type Subprocess, type FileSink } from "bun";
|
|
9
|
+
import type { Result } from "../types/result";
|
|
10
|
+
import { ok, err } from "../types/result";
|
|
11
|
+
import type {
|
|
12
|
+
MCPServerConfig,
|
|
13
|
+
MCPError,
|
|
14
|
+
MCPRequest,
|
|
15
|
+
MCPResponse,
|
|
16
|
+
MCPTool,
|
|
17
|
+
MCPToolsListResult,
|
|
18
|
+
MCPToolCallResult,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Constants
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT = 30_000; // 30 seconds
|
|
26
|
+
const INIT_TIMEOUT = 10_000; // 10 seconds for initialization
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// MCP Client Interface
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export interface MCPClient {
|
|
33
|
+
/** Server name from config */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** Call a tool on the MCP server */
|
|
36
|
+
callTool(name: string, args: Record<string, unknown>): Promise<Result<MCPToolCallResult, MCPError>>;
|
|
37
|
+
/** List available tools */
|
|
38
|
+
listTools(): Promise<Result<MCPTool[], MCPError>>;
|
|
39
|
+
/** Close the connection and kill subprocess */
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Internal State
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
interface MCPClientState {
|
|
48
|
+
config: MCPServerConfig;
|
|
49
|
+
proc: Subprocess;
|
|
50
|
+
requestId: number;
|
|
51
|
+
pendingRequests: Map<number, {
|
|
52
|
+
resolve: (response: MCPResponse) => void;
|
|
53
|
+
reject: (error: Error) => void;
|
|
54
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
55
|
+
}>;
|
|
56
|
+
buffer: string;
|
|
57
|
+
initialized: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// MCP Client Factory
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create and initialize an MCP client.
|
|
66
|
+
*
|
|
67
|
+
* Spawns the MCP server subprocess and performs the initialization handshake.
|
|
68
|
+
*/
|
|
69
|
+
export async function createMCPClient(
|
|
70
|
+
config: MCPServerConfig
|
|
71
|
+
): Promise<Result<MCPClient, MCPError>> {
|
|
72
|
+
// Spawn the subprocess
|
|
73
|
+
let proc: Subprocess;
|
|
74
|
+
try {
|
|
75
|
+
proc = spawn([config.command, ...config.args], {
|
|
76
|
+
env: { ...process.env, ...config.env },
|
|
77
|
+
stdin: "pipe",
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return err({
|
|
83
|
+
code: "SPAWN_ERROR",
|
|
84
|
+
message: `Failed to spawn MCP server: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create client state
|
|
89
|
+
const state: MCPClientState = {
|
|
90
|
+
config,
|
|
91
|
+
proc,
|
|
92
|
+
requestId: 0,
|
|
93
|
+
pendingRequests: new Map(),
|
|
94
|
+
buffer: "",
|
|
95
|
+
initialized: false,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Start reading stdout
|
|
99
|
+
startReadingStdout(state);
|
|
100
|
+
|
|
101
|
+
// Start reading stderr (for debugging)
|
|
102
|
+
startReadingStderr(state);
|
|
103
|
+
|
|
104
|
+
// Perform MCP initialization handshake
|
|
105
|
+
const initResult = await initializeMCP(state);
|
|
106
|
+
if (!initResult.ok) {
|
|
107
|
+
await killProcess(state);
|
|
108
|
+
return initResult;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
state.initialized = true;
|
|
112
|
+
|
|
113
|
+
// Create the client interface
|
|
114
|
+
const client: MCPClient = {
|
|
115
|
+
name: config.name,
|
|
116
|
+
|
|
117
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<Result<MCPToolCallResult, MCPError>> {
|
|
118
|
+
if (!state.initialized) {
|
|
119
|
+
return err({ code: "CONNECTION_FAILED", message: "MCP client not initialized" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const response = await sendRequest(state, "tools/call", {
|
|
123
|
+
name,
|
|
124
|
+
arguments: args,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (response.value.error) {
|
|
132
|
+
return err({
|
|
133
|
+
code: "TOOL_ERROR",
|
|
134
|
+
message: response.value.error.message,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return ok(response.value.result as MCPToolCallResult);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async listTools(): Promise<Result<MCPTool[], MCPError>> {
|
|
142
|
+
if (!state.initialized) {
|
|
143
|
+
return err({ code: "CONNECTION_FAILED", message: "MCP client not initialized" });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const response = await sendRequest(state, "tools/list", {});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
return response;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (response.value.error) {
|
|
153
|
+
return err({
|
|
154
|
+
code: "SERVER_ERROR",
|
|
155
|
+
message: response.value.error.message,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = response.value.result as MCPToolsListResult;
|
|
160
|
+
return ok(result.tools);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async close(): Promise<void> {
|
|
164
|
+
await killProcess(state);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return ok(client);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// Internal Functions
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Perform MCP initialization handshake.
|
|
177
|
+
*/
|
|
178
|
+
async function initializeMCP(state: MCPClientState): Promise<Result<void, MCPError>> {
|
|
179
|
+
// Send initialize request
|
|
180
|
+
const initResponse = await sendRequest(state, "initialize", {
|
|
181
|
+
protocolVersion: "2024-11-05",
|
|
182
|
+
capabilities: {},
|
|
183
|
+
clientInfo: {
|
|
184
|
+
name: "specvector",
|
|
185
|
+
version: "1.0.0",
|
|
186
|
+
},
|
|
187
|
+
}, INIT_TIMEOUT);
|
|
188
|
+
|
|
189
|
+
if (!initResponse.ok) {
|
|
190
|
+
return err(initResponse.error);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (initResponse.value.error) {
|
|
194
|
+
return err({
|
|
195
|
+
code: "PROTOCOL_ERROR",
|
|
196
|
+
message: `MCP initialization failed: ${initResponse.value.error.message}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Send initialized notification (no response expected)
|
|
201
|
+
const notification: MCPRequest = {
|
|
202
|
+
jsonrpc: "2.0",
|
|
203
|
+
id: 0, // Notifications don't have IDs, but we include for consistency
|
|
204
|
+
method: "notifications/initialized",
|
|
205
|
+
params: {},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const writer = state.proc.stdin as FileSink | null;
|
|
210
|
+
if (writer && typeof writer.write === "function") {
|
|
211
|
+
writer.write(JSON.stringify(notification) + "\n");
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// Notification failures are non-fatal
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return ok(undefined);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Send a JSON-RPC request and await response.
|
|
222
|
+
*/
|
|
223
|
+
async function sendRequest(
|
|
224
|
+
state: MCPClientState,
|
|
225
|
+
method: string,
|
|
226
|
+
params: Record<string, unknown>,
|
|
227
|
+
timeout?: number
|
|
228
|
+
): Promise<Result<MCPResponse, MCPError>> {
|
|
229
|
+
const id = ++state.requestId;
|
|
230
|
+
const request: MCPRequest = {
|
|
231
|
+
jsonrpc: "2.0",
|
|
232
|
+
id,
|
|
233
|
+
method,
|
|
234
|
+
params,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const requestTimeout = timeout ?? state.config.timeout ?? DEFAULT_TIMEOUT;
|
|
238
|
+
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
// Set up timeout
|
|
241
|
+
const timeoutHandle = setTimeout(() => {
|
|
242
|
+
state.pendingRequests.delete(id);
|
|
243
|
+
resolve(err({
|
|
244
|
+
code: "TIMEOUT",
|
|
245
|
+
message: `Request timed out after ${requestTimeout}ms`,
|
|
246
|
+
}));
|
|
247
|
+
}, requestTimeout);
|
|
248
|
+
|
|
249
|
+
// Store pending request
|
|
250
|
+
state.pendingRequests.set(id, {
|
|
251
|
+
resolve: (response: MCPResponse) => {
|
|
252
|
+
clearTimeout(timeoutHandle);
|
|
253
|
+
state.pendingRequests.delete(id);
|
|
254
|
+
resolve(ok(response));
|
|
255
|
+
},
|
|
256
|
+
reject: (error: Error) => {
|
|
257
|
+
clearTimeout(timeoutHandle);
|
|
258
|
+
state.pendingRequests.delete(id);
|
|
259
|
+
resolve(err({
|
|
260
|
+
code: "PROTOCOL_ERROR",
|
|
261
|
+
message: error.message,
|
|
262
|
+
}));
|
|
263
|
+
},
|
|
264
|
+
timeout: timeoutHandle,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Send the request
|
|
268
|
+
try {
|
|
269
|
+
const writer = state.proc.stdin as FileSink | null;
|
|
270
|
+
if (writer && typeof writer.write === "function") {
|
|
271
|
+
writer.write(JSON.stringify(request) + "\n");
|
|
272
|
+
} else {
|
|
273
|
+
state.pendingRequests.get(id)?.reject(new Error("stdin not available"));
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
state.pendingRequests.get(id)?.reject(
|
|
277
|
+
error instanceof Error ? error : new Error("Failed to write to stdin")
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Start reading stdout and dispatch responses.
|
|
285
|
+
*/
|
|
286
|
+
function startReadingStdout(state: MCPClientState): void {
|
|
287
|
+
const reader = state.proc.stdout;
|
|
288
|
+
if (!reader || typeof reader === "number") return;
|
|
289
|
+
|
|
290
|
+
const stream = reader as ReadableStream<Uint8Array>;
|
|
291
|
+
const textReader = stream.getReader();
|
|
292
|
+
|
|
293
|
+
(async () => {
|
|
294
|
+
const decoder = new TextDecoder();
|
|
295
|
+
try {
|
|
296
|
+
while (true) {
|
|
297
|
+
const { done, value } = await textReader.read();
|
|
298
|
+
if (done) break;
|
|
299
|
+
state.buffer += decoder.decode(value, { stream: true });
|
|
300
|
+
processBuffer(state);
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
// Stream ended or error - reject all pending requests
|
|
304
|
+
for (const [, pending] of state.pendingRequests) {
|
|
305
|
+
pending.reject(new Error("Connection closed"));
|
|
306
|
+
}
|
|
307
|
+
state.pendingRequests.clear();
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Process buffered data, extracting complete JSON lines.
|
|
314
|
+
*/
|
|
315
|
+
function processBuffer(state: MCPClientState): void {
|
|
316
|
+
let newlineIndex: number;
|
|
317
|
+
while ((newlineIndex = state.buffer.indexOf("\n")) !== -1) {
|
|
318
|
+
const line = state.buffer.slice(0, newlineIndex).trim();
|
|
319
|
+
state.buffer = state.buffer.slice(newlineIndex + 1);
|
|
320
|
+
|
|
321
|
+
if (!line) continue;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const response = JSON.parse(line) as MCPResponse;
|
|
325
|
+
const pending = state.pendingRequests.get(response.id);
|
|
326
|
+
if (pending) {
|
|
327
|
+
pending.resolve(response);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Invalid JSON - ignore
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Start reading stderr for debugging.
|
|
337
|
+
*/
|
|
338
|
+
function startReadingStderr(state: MCPClientState): void {
|
|
339
|
+
const reader = state.proc.stderr;
|
|
340
|
+
if (!reader || typeof reader === "number") return;
|
|
341
|
+
|
|
342
|
+
const stream = reader as ReadableStream<Uint8Array>;
|
|
343
|
+
const textReader = stream.getReader();
|
|
344
|
+
|
|
345
|
+
(async () => {
|
|
346
|
+
const decoder = new TextDecoder();
|
|
347
|
+
try {
|
|
348
|
+
while (true) {
|
|
349
|
+
const { done, value } = await textReader.read();
|
|
350
|
+
if (done) break;
|
|
351
|
+
const text = decoder.decode(value, { stream: true });
|
|
352
|
+
// Log stderr for debugging (could be made configurable)
|
|
353
|
+
if (text.trim()) {
|
|
354
|
+
console.error(`[MCP:${state.config.name}] ${text.trim()}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
// Ignore stderr errors
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Kill the subprocess and clean up.
|
|
365
|
+
*/
|
|
366
|
+
async function killProcess(state: MCPClientState): Promise<void> {
|
|
367
|
+
// Clear all pending requests
|
|
368
|
+
for (const [, pending] of state.pendingRequests) {
|
|
369
|
+
clearTimeout(pending.timeout);
|
|
370
|
+
pending.reject(new Error("Client closed"));
|
|
371
|
+
}
|
|
372
|
+
state.pendingRequests.clear();
|
|
373
|
+
|
|
374
|
+
// Kill the process
|
|
375
|
+
try {
|
|
376
|
+
state.proc.kill();
|
|
377
|
+
} catch {
|
|
378
|
+
// Ignore kill errors
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
state.initialized = false;
|
|
382
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Types for the JSON-RPC 2.0 protocol used by MCP servers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Error Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export type MCPErrorCode =
|
|
12
|
+
| "CONNECTION_FAILED"
|
|
13
|
+
| "SPAWN_ERROR"
|
|
14
|
+
| "TIMEOUT"
|
|
15
|
+
| "PROTOCOL_ERROR"
|
|
16
|
+
| "TOOL_ERROR"
|
|
17
|
+
| "SERVER_ERROR";
|
|
18
|
+
|
|
19
|
+
export interface MCPError {
|
|
20
|
+
code: MCPErrorCode;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Server Configuration
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
export interface MCPServerConfig {
|
|
29
|
+
/** Server identifier (e.g., 'linear', 'filesystem') */
|
|
30
|
+
name: string;
|
|
31
|
+
/** Command to spawn (e.g., 'bunx', 'npx', or path to binary) */
|
|
32
|
+
command: string;
|
|
33
|
+
/** Arguments for the command (e.g., ['-y', '@anthropic-ai/mcp-server-linear']) */
|
|
34
|
+
args: string[];
|
|
35
|
+
/** Environment variables to pass (merged with process.env) */
|
|
36
|
+
env?: Record<string, string>;
|
|
37
|
+
/** Timeout in ms for requests (default: 30000) */
|
|
38
|
+
timeout?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// MCP Protocol Types (JSON-RPC 2.0)
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
export interface MCPRequest {
|
|
46
|
+
jsonrpc: "2.0";
|
|
47
|
+
id: number;
|
|
48
|
+
method: string;
|
|
49
|
+
params?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MCPResponse {
|
|
53
|
+
jsonrpc: "2.0";
|
|
54
|
+
id: number;
|
|
55
|
+
result?: unknown;
|
|
56
|
+
error?: MCPRPCError;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MCPRPCError {
|
|
60
|
+
code: number;
|
|
61
|
+
message: string;
|
|
62
|
+
data?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// MCP Tool Types
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
export interface MCPTool {
|
|
70
|
+
name: string;
|
|
71
|
+
description: string;
|
|
72
|
+
inputSchema: MCPInputSchema;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MCPInputSchema {
|
|
76
|
+
type: "object";
|
|
77
|
+
properties?: Record<string, MCPSchemaProperty>;
|
|
78
|
+
required?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MCPSchemaProperty {
|
|
82
|
+
type: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// MCP Result Types (for tools/list and tools/call)
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
export interface MCPToolsListResult {
|
|
91
|
+
tools: MCPTool[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MCPToolCallResult {
|
|
95
|
+
content: MCPContent[];
|
|
96
|
+
isError?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MCPContent {
|
|
100
|
+
type: "text" | "image" | "resource";
|
|
101
|
+
text?: string;
|
|
102
|
+
data?: string;
|
|
103
|
+
mimeType?: string;
|
|
104
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff parser for unified diff format.
|
|
3
|
+
* Converts raw `gh pr diff` output into structured types.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
DiffChange,
|
|
8
|
+
DiffFile,
|
|
9
|
+
DiffHunk,
|
|
10
|
+
ParsedDiff,
|
|
11
|
+
} from "../types/diff";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a unified diff string into structured format.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDiff(diffText: string): ParsedDiff {
|
|
17
|
+
const files: DiffFile[] = [];
|
|
18
|
+
let totalAdditions = 0;
|
|
19
|
+
let totalDeletions = 0;
|
|
20
|
+
|
|
21
|
+
// Split by file boundaries (diff --git)
|
|
22
|
+
const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
for (const section of fileSections) {
|
|
25
|
+
const file = parseFileSection(section);
|
|
26
|
+
if (file) {
|
|
27
|
+
files.push(file);
|
|
28
|
+
totalAdditions += file.additions;
|
|
29
|
+
totalDeletions += file.deletions;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
files,
|
|
35
|
+
totalAdditions,
|
|
36
|
+
totalDeletions,
|
|
37
|
+
filesChanged: files.length,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a single file section from the diff.
|
|
43
|
+
*/
|
|
44
|
+
function parseFileSection(section: string): DiffFile | null {
|
|
45
|
+
const lines = section.split("\n");
|
|
46
|
+
if (lines.length === 0) return null;
|
|
47
|
+
|
|
48
|
+
// First line is the file paths: a/path b/path
|
|
49
|
+
const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+?)$/);
|
|
50
|
+
if (!headerMatch) return null;
|
|
51
|
+
|
|
52
|
+
const oldPath = headerMatch[1] ?? null;
|
|
53
|
+
const newPath = headerMatch[2] ?? null;
|
|
54
|
+
|
|
55
|
+
// Check for binary file
|
|
56
|
+
const isBinary = lines.some((line) => line.startsWith("Binary files"));
|
|
57
|
+
if (isBinary) {
|
|
58
|
+
return {
|
|
59
|
+
oldPath,
|
|
60
|
+
newPath,
|
|
61
|
+
status: determineStatus(oldPath, newPath, lines),
|
|
62
|
+
binary: true,
|
|
63
|
+
hunks: [],
|
|
64
|
+
additions: 0,
|
|
65
|
+
deletions: 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Parse hunks
|
|
70
|
+
const hunks: DiffHunk[] = [];
|
|
71
|
+
let currentHunk: DiffHunk | null = null;
|
|
72
|
+
let additions = 0;
|
|
73
|
+
let deletions = 0;
|
|
74
|
+
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
// Hunk header: @@ -1,3 +1,4 @@
|
|
77
|
+
const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
|
78
|
+
if (hunkMatch) {
|
|
79
|
+
if (currentHunk) {
|
|
80
|
+
hunks.push(currentHunk);
|
|
81
|
+
}
|
|
82
|
+
currentHunk = {
|
|
83
|
+
oldStart: parseInt(hunkMatch[1] ?? "0", 10),
|
|
84
|
+
oldLines: parseInt(hunkMatch[2] ?? "1", 10) || 1,
|
|
85
|
+
newStart: parseInt(hunkMatch[3] ?? "0", 10),
|
|
86
|
+
newLines: parseInt(hunkMatch[4] ?? "1", 10) || 1,
|
|
87
|
+
content: line,
|
|
88
|
+
changes: [],
|
|
89
|
+
};
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Content lines
|
|
94
|
+
if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
|
|
95
|
+
const change: DiffChange = {
|
|
96
|
+
type: line.startsWith("+") ? "add" : line.startsWith("-") ? "delete" : "normal",
|
|
97
|
+
content: line.slice(1),
|
|
98
|
+
};
|
|
99
|
+
currentHunk.changes.push(change);
|
|
100
|
+
currentHunk.content += "\n" + line;
|
|
101
|
+
|
|
102
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
103
|
+
additions++;
|
|
104
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
105
|
+
deletions++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Push last hunk
|
|
111
|
+
if (currentHunk) {
|
|
112
|
+
hunks.push(currentHunk);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
oldPath,
|
|
117
|
+
newPath,
|
|
118
|
+
status: determineStatus(oldPath, newPath, lines),
|
|
119
|
+
binary: false,
|
|
120
|
+
hunks,
|
|
121
|
+
additions,
|
|
122
|
+
deletions,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Determine the status of a file change.
|
|
128
|
+
*/
|
|
129
|
+
function determineStatus(
|
|
130
|
+
oldPath: string | null,
|
|
131
|
+
newPath: string | null,
|
|
132
|
+
lines: string[]
|
|
133
|
+
): DiffFile["status"] {
|
|
134
|
+
// Check for new file
|
|
135
|
+
if (lines.some((l) => l.startsWith("new file mode"))) {
|
|
136
|
+
return "added";
|
|
137
|
+
}
|
|
138
|
+
// Check for deleted file
|
|
139
|
+
if (lines.some((l) => l.startsWith("deleted file mode"))) {
|
|
140
|
+
return "deleted";
|
|
141
|
+
}
|
|
142
|
+
// Check for rename
|
|
143
|
+
if (lines.some((l) => l.startsWith("rename from")) || oldPath !== newPath) {
|
|
144
|
+
return "renamed";
|
|
145
|
+
}
|
|
146
|
+
return "modified";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get a summary of the diff for logging.
|
|
151
|
+
*/
|
|
152
|
+
export function getDiffSummary(diff: ParsedDiff): string {
|
|
153
|
+
const lines = [
|
|
154
|
+
`Files changed: ${diff.filesChanged}`,
|
|
155
|
+
`Additions: +${diff.totalAdditions}`,
|
|
156
|
+
`Deletions: -${diff.totalDeletions}`,
|
|
157
|
+
"",
|
|
158
|
+
"Files:",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const file of diff.files) {
|
|
162
|
+
const path = file.newPath ?? file.oldPath ?? "unknown";
|
|
163
|
+
const stats = file.binary ? "(binary)" : `+${file.additions}/-${file.deletions}`;
|
|
164
|
+
lines.push(` ${file.status}: ${path} ${stats}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|