398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { adminDb } from '@/lib/firebase/admin';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
interface TimelineDay {
|
|
date: string; // YYYY-MM-DD format
|
|
dayOfWeek: string;
|
|
gitCommits: Array<{
|
|
hash: string;
|
|
time: string;
|
|
author: string;
|
|
message: string;
|
|
filesChanged: number;
|
|
insertions: number;
|
|
deletions: number;
|
|
}>;
|
|
extensionSessions: Array<{
|
|
startTime: string;
|
|
endTime: string;
|
|
duration: number; // minutes
|
|
filesModified: string[];
|
|
conversationSummary?: string;
|
|
}>;
|
|
cursorMessages: Array<{
|
|
time: string;
|
|
type: 'user' | 'assistant';
|
|
conversationName: string;
|
|
preview: string; // First 100 chars
|
|
}>;
|
|
summary: {
|
|
totalGitCommits: number;
|
|
totalExtensionSessions: number;
|
|
totalCursorMessages: number;
|
|
linesAdded: number;
|
|
linesRemoved: number;
|
|
uniqueFilesModified: number;
|
|
};
|
|
}
|
|
|
|
interface UnifiedTimeline {
|
|
projectId: string;
|
|
dateRange: {
|
|
earliest: string;
|
|
latest: string;
|
|
totalDays: number;
|
|
};
|
|
days: TimelineDay[];
|
|
dataSources: {
|
|
git: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
|
extension: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
|
cursor: { available: boolean; firstDate: string | null; lastDate: string | null; totalRecords: number };
|
|
};
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ projectId: string }> }
|
|
) {
|
|
try {
|
|
const { projectId } = await params;
|
|
|
|
// 1. Load Git commits
|
|
const repoPath = '/Users/markhenderson/ai-proxy';
|
|
let gitCommits: any[] = [];
|
|
let gitFirstDate: string | null = null;
|
|
let gitLastDate: string | null = null;
|
|
|
|
try {
|
|
const { stdout: commitsOutput } = await execAsync(
|
|
`cd "${repoPath}" && git log --all --pretty=format:"%H|%ai|%an|%s" --numstat`,
|
|
{ maxBuffer: 10 * 1024 * 1024 }
|
|
);
|
|
|
|
if (commitsOutput.trim()) {
|
|
const lines = commitsOutput.split('\n');
|
|
let currentCommit: any = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.includes('|')) {
|
|
if (currentCommit) {
|
|
gitCommits.push(currentCommit);
|
|
}
|
|
const [hash, date, author, message] = line.split('|');
|
|
currentCommit = {
|
|
hash: hash.substring(0, 8),
|
|
date,
|
|
author,
|
|
message,
|
|
filesChanged: 0,
|
|
insertions: 0,
|
|
deletions: 0
|
|
};
|
|
} else if (line.trim() && currentCommit) {
|
|
const parts = line.trim().split('\t');
|
|
if (parts.length === 3) {
|
|
const [insertStr, delStr] = parts;
|
|
const insertions = insertStr === '-' ? 0 : parseInt(insertStr, 10) || 0;
|
|
const deletions = delStr === '-' ? 0 : parseInt(delStr, 10) || 0;
|
|
currentCommit.filesChanged++;
|
|
currentCommit.insertions += insertions;
|
|
currentCommit.deletions += deletions;
|
|
}
|
|
}
|
|
}
|
|
if (currentCommit) {
|
|
gitCommits.push(currentCommit);
|
|
}
|
|
|
|
gitCommits.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
if (gitCommits.length > 0) {
|
|
gitFirstDate = gitCommits[0].date;
|
|
gitLastDate = gitCommits[gitCommits.length - 1].date;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('⚠️ Could not load Git commits:', error);
|
|
}
|
|
|
|
// 2. Load Extension sessions
|
|
let extensionSessions: any[] = [];
|
|
let extensionFirstDate: string | null = null;
|
|
let extensionLastDate: string | null = null;
|
|
|
|
try {
|
|
// Try to find sessions by projectId first
|
|
let sessionsSnapshot = await adminDb
|
|
.collection('sessions')
|
|
.where('projectId', '==', projectId)
|
|
.get();
|
|
|
|
// If no sessions found by projectId, try by workspacePath
|
|
if (sessionsSnapshot.empty) {
|
|
const workspacePath = '/Users/markhenderson/ai-proxy';
|
|
sessionsSnapshot = await adminDb
|
|
.collection('sessions')
|
|
.where('workspacePath', '==', workspacePath)
|
|
.get();
|
|
}
|
|
|
|
extensionSessions = sessionsSnapshot.docs.map(doc => {
|
|
const data = doc.data();
|
|
const startTime = data.startTime?.toDate?.() || new Date(data.startTime);
|
|
const endTime = data.endTime?.toDate?.() || new Date(data.endTime);
|
|
|
|
return {
|
|
startTime,
|
|
endTime,
|
|
filesModified: data.filesModified || [],
|
|
conversationSummary: data.conversationSummary || '',
|
|
conversation: data.conversation || []
|
|
};
|
|
});
|
|
|
|
extensionSessions.sort((a, b) =>
|
|
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
|
);
|
|
|
|
if (extensionSessions.length > 0) {
|
|
extensionFirstDate = extensionSessions[0].startTime.toISOString();
|
|
extensionLastDate = extensionSessions[extensionSessions.length - 1].endTime.toISOString();
|
|
}
|
|
} catch (error) {
|
|
console.log('⚠️ Could not load extension sessions:', error);
|
|
}
|
|
|
|
// 3. Load Cursor messages (from both cursorConversations and extension sessions)
|
|
let cursorMessages: any[] = [];
|
|
let cursorFirstDate: string | null = null;
|
|
let cursorLastDate: string | null = null;
|
|
|
|
try {
|
|
// Load from cursorConversations (backfilled historical data)
|
|
const conversationsSnapshot = await adminDb
|
|
.collection('projects')
|
|
.doc(projectId)
|
|
.collection('cursorConversations')
|
|
.get();
|
|
|
|
for (const convDoc of conversationsSnapshot.docs) {
|
|
const conv = convDoc.data();
|
|
const messagesSnapshot = await adminDb
|
|
.collection('projects')
|
|
.doc(projectId)
|
|
.collection('cursorConversations')
|
|
.doc(convDoc.id)
|
|
.collection('messages')
|
|
.orderBy('createdAt', 'asc')
|
|
.get();
|
|
|
|
const messages = messagesSnapshot.docs.map(msgDoc => {
|
|
const msg = msgDoc.data();
|
|
return {
|
|
createdAt: msg.createdAt,
|
|
type: msg.type === 1 ? 'user' : 'assistant',
|
|
text: msg.text || '',
|
|
conversationName: conv.name || 'Untitled'
|
|
};
|
|
});
|
|
|
|
cursorMessages = cursorMessages.concat(messages);
|
|
}
|
|
|
|
// Also load from extension sessions conversation data
|
|
for (const session of extensionSessions) {
|
|
if (session.conversation && Array.isArray(session.conversation)) {
|
|
for (const msg of session.conversation) {
|
|
cursorMessages.push({
|
|
createdAt: msg.timestamp || session.startTime.toISOString(),
|
|
type: msg.role === 'user' ? 'user' : 'assistant',
|
|
text: msg.message || '',
|
|
conversationName: 'Extension Session'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
cursorMessages.sort((a, b) =>
|
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
);
|
|
|
|
if (cursorMessages.length > 0) {
|
|
cursorFirstDate = cursorMessages[0].createdAt;
|
|
cursorLastDate = cursorMessages[cursorMessages.length - 1].createdAt;
|
|
}
|
|
} catch (error) {
|
|
console.log('⚠️ Could not load Cursor messages:', error);
|
|
}
|
|
|
|
// 4. Find overall date range
|
|
const allFirstDates = [
|
|
gitFirstDate ? new Date(gitFirstDate) : null,
|
|
extensionFirstDate ? new Date(extensionFirstDate) : null,
|
|
cursorFirstDate ? new Date(cursorFirstDate) : null
|
|
].filter(d => d !== null) as Date[];
|
|
|
|
const allLastDates = [
|
|
gitLastDate ? new Date(gitLastDate) : null,
|
|
extensionLastDate ? new Date(extensionLastDate) : null,
|
|
cursorLastDate ? new Date(cursorLastDate) : null
|
|
].filter(d => d !== null) as Date[];
|
|
|
|
if (allFirstDates.length === 0 && allLastDates.length === 0) {
|
|
return NextResponse.json({
|
|
error: 'No timeline data available',
|
|
projectId,
|
|
dateRange: { earliest: null, latest: null, totalDays: 0 },
|
|
days: [],
|
|
dataSources: {
|
|
git: { available: false, firstDate: null, lastDate: null, totalRecords: 0 },
|
|
extension: { available: false, firstDate: null, lastDate: null, totalRecords: 0 },
|
|
cursor: { available: false, firstDate: null, lastDate: null, totalRecords: 0 }
|
|
}
|
|
});
|
|
}
|
|
|
|
const earliestDate = new Date(Math.min(...allFirstDates.map(d => d.getTime())));
|
|
const latestDate = new Date(Math.max(...allLastDates.map(d => d.getTime())));
|
|
const totalDays = Math.ceil((latestDate.getTime() - earliestDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
|
|
// 5. Group data by day
|
|
const dayMap = new Map<string, TimelineDay>();
|
|
|
|
// Initialize all days
|
|
for (let i = 0; i < totalDays; i++) {
|
|
const date = new Date(earliestDate);
|
|
date.setDate(date.getDate() + i);
|
|
const dateKey = date.toISOString().split('T')[0];
|
|
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'long' });
|
|
|
|
dayMap.set(dateKey, {
|
|
date: dateKey,
|
|
dayOfWeek,
|
|
gitCommits: [],
|
|
extensionSessions: [],
|
|
cursorMessages: [],
|
|
summary: {
|
|
totalGitCommits: 0,
|
|
totalExtensionSessions: 0,
|
|
totalCursorMessages: 0,
|
|
linesAdded: 0,
|
|
linesRemoved: 0,
|
|
uniqueFilesModified: 0
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add Git commits to days
|
|
for (const commit of gitCommits) {
|
|
const dateKey = commit.date.split(' ')[0];
|
|
const day = dayMap.get(dateKey);
|
|
if (day) {
|
|
day.gitCommits.push({
|
|
hash: commit.hash,
|
|
time: commit.date,
|
|
author: commit.author,
|
|
message: commit.message,
|
|
filesChanged: commit.filesChanged,
|
|
insertions: commit.insertions,
|
|
deletions: commit.deletions
|
|
});
|
|
day.summary.totalGitCommits++;
|
|
day.summary.linesAdded += commit.insertions;
|
|
day.summary.linesRemoved += commit.deletions;
|
|
}
|
|
}
|
|
|
|
// Add Extension sessions to days
|
|
for (const session of extensionSessions) {
|
|
const dateKey = new Date(session.startTime).toISOString().split('T')[0];
|
|
const day = dayMap.get(dateKey);
|
|
if (day) {
|
|
const startTime = new Date(session.startTime);
|
|
const endTime = new Date(session.endTime);
|
|
const duration = Math.round((endTime.getTime() - startTime.getTime()) / (1000 * 60));
|
|
|
|
day.extensionSessions.push({
|
|
startTime: session.startTime.toISOString(),
|
|
endTime: session.endTime.toISOString(),
|
|
duration,
|
|
filesModified: session.filesModified,
|
|
conversationSummary: session.conversationSummary
|
|
});
|
|
day.summary.totalExtensionSessions++;
|
|
|
|
// Track unique files
|
|
const uniqueFiles = new Set([...session.filesModified]);
|
|
day.summary.uniqueFilesModified += uniqueFiles.size;
|
|
}
|
|
}
|
|
|
|
// Add Cursor messages to days
|
|
for (const message of cursorMessages) {
|
|
const dateKey = new Date(message.createdAt).toISOString().split('T')[0];
|
|
const day = dayMap.get(dateKey);
|
|
if (day) {
|
|
day.cursorMessages.push({
|
|
time: message.createdAt,
|
|
type: message.type,
|
|
conversationName: message.conversationName,
|
|
preview: message.text.substring(0, 100)
|
|
});
|
|
day.summary.totalCursorMessages++;
|
|
}
|
|
}
|
|
|
|
// Convert to array and sort by date
|
|
const days = Array.from(dayMap.values()).sort((a, b) =>
|
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
|
|
const timeline: UnifiedTimeline = {
|
|
projectId,
|
|
dateRange: {
|
|
earliest: earliestDate.toISOString(),
|
|
latest: latestDate.toISOString(),
|
|
totalDays
|
|
},
|
|
days,
|
|
dataSources: {
|
|
git: {
|
|
available: gitCommits.length > 0,
|
|
firstDate: gitFirstDate,
|
|
lastDate: gitLastDate,
|
|
totalRecords: gitCommits.length
|
|
},
|
|
extension: {
|
|
available: extensionSessions.length > 0,
|
|
firstDate: extensionFirstDate,
|
|
lastDate: extensionLastDate,
|
|
totalRecords: extensionSessions.length
|
|
},
|
|
cursor: {
|
|
available: cursorMessages.length > 0,
|
|
firstDate: cursorFirstDate,
|
|
lastDate: cursorLastDate,
|
|
totalRecords: cursorMessages.length
|
|
}
|
|
}
|
|
};
|
|
|
|
return NextResponse.json(timeline);
|
|
|
|
} catch (error) {
|
|
console.error('Error generating unified timeline:', error);
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Failed to generate unified timeline',
|
|
details: error instanceof Error ? error.message : String(error)
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|