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.
@@ -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
+ });