export.ts
1import { writeFile } from 'fs/promises';
2import { join } from 'path';
3import { spawn } from 'child_process';
4import type { ReviewExport, ReviewExportFile, ReviewExportThread } from '$lib/types/index';
5import { readFile } from 'fs/promises';
6import { getRepoDir, getRepoInfo, getStatus, getFileContent } from './vcs.ts';
7import { getReview, getThreads, getContextFiles } from './review-store.ts';
8
9export async function buildExport(): Promise<ReviewExport> {
10 const { branch, repoName } = await getRepoInfo();
11 const review = getReview();
12 const changedFiles = await getStatus();
13
14 const files: ReviewExportFile[] = [];
15
16 for (const file of changedFiles) {
17 const fileThreads = getThreads(file.path);
18 if (fileThreads.length === 0) continue;
19
20 let content = '';
21 try {
22 content = await getFileContent(file.path);
23 } catch {
24 // file might be new
25 }
26 const contentLines = content.split('\n');
27
28 const threads: ReviewExportThread[] = fileThreads.map((t) => {
29 const start = Math.max(0, t.lineNumber - 3);
30 const end = Math.min(contentLines.length, t.lineNumber + 2);
31 const codeContext = contentLines.slice(start, end).join('\n');
32
33 return {
34 lineNumber: t.lineNumber,
35 side: t.side,
36 codeContext,
37 comments: t.comments.map((c) => ({ body: c.body, type: c.type }))
38 };
39 });
40
41 files.push({
42 path: file.path,
43 status: file.status,
44 threads
45 });
46 }
47
48 return {
49 version: '1.0',
50 repository: repoName,
51 branch,
52 exportedAt: new Date().toISOString(),
53 review: {
54 status: review.status,
55 summary: review.summary,
56 files
57 }
58 };
59}
60
61export async function exportJSON(): Promise<string> {
62 const data = await buildExport();
63 const filePath = join(getRepoDir(), '.lcr-review.json');
64 await writeFile(filePath, JSON.stringify(data, null, 2));
65 return filePath;
66}
67
68export async function exportMarkdown(): Promise<string> {
69 const data = await buildExport();
70 const lines: string[] = [
71 `# Code Review: ${data.repository}`,
72 ``,
73 `**Branch:** ${data.branch}`,
74 `**Status:** ${data.review.status}`,
75 `**Date:** ${data.exportedAt}`,
76 ``
77 ];
78
79 if (data.review.summary) {
80 lines.push(`## Summary`, ``, data.review.summary, ``);
81 }
82
83 for (const file of data.review.files) {
84 lines.push(`## ${file.path} (${file.status})`, ``);
85 for (const thread of file.threads) {
86 lines.push(`### Line ${thread.lineNumber} (${thread.side})`, ``);
87 if (thread.codeContext) {
88 lines.push('```', thread.codeContext, '```', ``);
89 }
90 for (const comment of thread.comments) {
91 const prefix = comment.type === 'suggestion' ? '💡' : comment.type === 'question' ? '❓' : '💬';
92 lines.push(`${prefix} **${comment.type}:** ${comment.body}`, ``);
93 }
94 }
95 }
96
97 const filePath = join(getRepoDir(), '.lcr-review.md');
98 await writeFile(filePath, lines.join('\n'));
99 return filePath;
100}
101
102import { statSync } from 'fs';
103import { execSync } from 'child_process';
104
105export function findClaude(): string {
106 const paths = [
107 join(process.env.HOME || '', '.local', 'bin', 'claude'),
108 '/usr/local/bin/claude',
109 '/opt/homebrew/bin/claude'
110 ];
111 for (const p of paths) {
112 try {
113 statSync(p);
114 return p;
115 } catch {}
116 }
117 try {
118 return execSync('which claude', { encoding: 'utf-8' }).trim();
119 } catch {}
120 return 'claude';
121}
122
123export async function exportToClaude(): Promise<{ success: boolean; message: string }> {
124 const data = await buildExport();
125
126 if (data.review.files.length === 0) {
127 return { success: false, message: 'No review comments to send. Add comments first.' };
128 }
129
130 const prompt = await buildClaudePrompt(data);
131 const claudePath = findClaude();
132
133 return new Promise((resolve) => {
134 const child = spawn(claudePath, ['-p'], {
135 cwd: getRepoDir(),
136 stdio: ['pipe', 'pipe', 'pipe'],
137 env: {
138 ...process.env,
139 PATH: `${join(process.env.HOME || '', '.local', 'bin')}:/usr/local/bin:/opt/homebrew/bin:${process.env.PATH || ''}`
140 }
141 });
142
143 child.stdin.write(prompt);
144 child.stdin.end();
145
146 let stdout = '';
147 let stderr = '';
148
149 child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
150 child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
151
152 child.on('close', (code) => {
153 if (code === 0) {
154 resolve({ success: true, message: stdout || 'Review sent to Claude Code successfully.' });
155 } else {
156 resolve({
157 success: false,
158 message: stderr || stdout || `Claude Code exited with code ${code}`
159 });
160 }
161 });
162
163 child.on('error', (err) => {
164 resolve({
165 success: false,
166 message: `Failed to launch Claude Code: ${err.message}. Make sure 'claude' CLI is installed.`
167 });
168 });
169 });
170}
171
172export async function buildClaudePrompt(data: ReviewExport): Promise<string> {
173 const lines: string[] = [
174 'I have completed a code review of the current changes. Please address each review comment below by making the necessary changes to the codebase.',
175 ''
176 ];
177
178 if (data.review.summary) {
179 lines.push(`Overall review summary: ${data.review.summary}`, '');
180 }
181
182 for (const file of data.review.files) {
183 lines.push(`File: ${file.path} (${file.status})`);
184 for (const thread of file.threads) {
185 lines.push(` Line ${thread.lineNumber}:`);
186 if (thread.codeContext) {
187 lines.push(` Context:`, ` \`\`\``, ` ${thread.codeContext}`, ` \`\`\``);
188 }
189 for (const comment of thread.comments) {
190 lines.push(` [${comment.type.toUpperCase()}]: ${comment.body}`);
191 }
192 lines.push('');
193 }
194 }
195
196 // Include context files for additional reference
197 const contextFilePaths = getContextFiles();
198 if (contextFilePaths.length > 0) {
199 lines.push('', '--- Additional Context Files ---', '');
200 lines.push('The following files are provided as additional context for your reference when making changes:', '');
201 const repoDir = getRepoDir();
202 for (const filePath of contextFilePaths) {
203 try {
204 const content = await readFile(join(repoDir, filePath), 'utf-8');
205 lines.push(`File: ${filePath}`, '```', content, '```', '');
206 } catch {
207 lines.push(`File: ${filePath} (could not read)`, '');
208 }
209 }
210 }
211
212 return lines.join('\n');
213}
214
215export function getExportPrompt(): string {
216 // For clipboard copy — same as Claude prompt but returned as string
217 const review = getReview();
218 if (review.threads.length === 0) return 'No review comments.';
219
220 const lines: string[] = [
221 'Code review comments:',
222 ''
223 ];
224
225 const fileGroups = new Map<string, typeof review.threads>();
226 for (const thread of review.threads) {
227 const existing = fileGroups.get(thread.filePath) || [];
228 existing.push(thread);
229 fileGroups.set(thread.filePath, existing);
230 }
231
232 for (const [path, threads] of fileGroups) {
233 lines.push(`File: ${path}`);
234 for (const thread of threads) {
235 lines.push(` Line ${thread.lineNumber} (${thread.side}):`);
236 for (const comment of thread.comments) {
237 lines.push(` [${comment.type}]: ${comment.body}`);
238 }
239 }
240 lines.push('');
241 }
242
243 return lines.join('\n');
244}