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}