import { NextRequest, NextResponse } from 'next/server' import { getDatabase, Task, db_helpers } from '@/lib/db' import { eventBus } from '@/lib/event-bus' import { requireRole } from '@/lib/auth' import { mutationLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' import { validateBody, githubSyncSchema } from '@/lib/validation' import { getGitHubToken, githubFetch, fetchIssues, fetchIssue, createIssueComment, updateIssueState, type GitHubIssue, } from '@/lib/github' import { initializeLabels, pullFromGitHub } from '@/lib/github-sync-engine' /** * GET /api/github?action=issues&repo=owner/repo&state=open&labels=bug * Fetch issues from GitHub for preview before import. */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) try { const { searchParams } = new URL(request.url) const action = searchParams.get('action') if (action === 'stats') { return await handleGitHubStats() } if (action !== 'issues') { return NextResponse.json({ error: 'Unknown action. Use ?action=issues or ?action=stats' }, { status: 400 }) } const repo = searchParams.get('repo') || process.env.GITHUB_DEFAULT_REPO if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) { return NextResponse.json({ error: 'repo query parameter required (owner/repo format)' }, { status: 400 }) } const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } const state = (searchParams.get('state') as 'open' | 'closed' | 'all') || 'open' const labels = searchParams.get('labels') || undefined const issues = await fetchIssues(repo, { state, labels, per_page: 50 }) return NextResponse.json({ issues, total: issues.length, repo }) } catch (error: any) { logger.error({ err: error }, 'GET /api/github error') return NextResponse.json({ error: error.message || 'Failed to fetch issues' }, { status: 500 }) } } /** * POST /api/github — Action dispatcher for sync, comment, close, status. */ export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = mutationLimiter(request) if (rateCheck) return rateCheck const validated = await validateBody(request, githubSyncSchema) if ('error' in validated) return validated.error const body = validated.data const { action } = body try { switch (action) { case 'sync': return await handleSync(body, auth.user.username, auth.user.workspace_id ?? 1) case 'comment': return await handleComment(body, auth.user.username, auth.user.workspace_id ?? 1) case 'close': return await handleClose(body, auth.user.username, auth.user.workspace_id ?? 1) case 'status': return handleStatus(auth.user.workspace_id ?? 1) case 'init-labels': return await handleInitLabels(body, auth.user.workspace_id ?? 1) case 'sync-project': return await handleSyncProject(body, auth.user.username, auth.user.workspace_id ?? 1) default: return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) } } catch (error: any) { logger.error({ err: error }, `POST /api/github action=${action} error`) return NextResponse.json({ error: error.message || 'GitHub action failed' }, { status: 500 }) } } // ── Sync: import GitHub issues as MC tasks ────────────────────── async function handleSync( body: { repo?: string; labels?: string; state?: 'open' | 'closed' | 'all'; assignAgent?: string }, actor: string, workspaceId: number ) { const repo = body.repo || process.env.GITHUB_DEFAULT_REPO if (!repo) { return NextResponse.json({ error: 'repo is required' }, { status: 400 }) } const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } const issues = await fetchIssues(repo, { state: body.state || 'open', labels: body.labels, per_page: 100, }) const db = getDatabase() const now = Math.floor(Date.now() / 1000) let imported = 0 let skipped = 0 let errors = 0 const createdTasks: any[] = [] for (const issue of issues) { try { // Check for duplicate: existing task with same github_repo + github_issue_number const existing = db.prepare(` SELECT id FROM tasks WHERE json_extract(metadata, '$.github_repo') = ? AND json_extract(metadata, '$.github_issue_number') = ? AND workspace_id = ? `).get(repo, issue.number, workspaceId) as { id: number } | undefined if (existing) { skipped++ continue } // Map priority from labels const priority = mapPriority(issue.labels.map(l => l.name)) const tags = issue.labels.map(l => l.name) const status = issue.state === 'closed' ? 'done' : 'inbox' const metadata = { github_repo: repo, github_issue_number: issue.number, github_issue_url: issue.html_url, github_synced_at: new Date().toISOString(), github_state: issue.state, } const stmt = db.prepare(` INSERT INTO tasks ( title, description, status, priority, assigned_to, created_by, created_at, updated_at, tags, metadata, workspace_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const dbResult = stmt.run( issue.title, issue.body || '', status, priority, body.assignAgent || null, actor, now, now, JSON.stringify(tags), JSON.stringify(metadata), workspaceId ) const taskId = dbResult.lastInsertRowid as number db_helpers.logActivity( 'task_created', 'task', taskId, actor, `Imported from GitHub: ${repo}#${issue.number}`, { github_issue: issue.number, github_repo: repo }, workspaceId ) const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task const parsedTask = { ...createdTask, tags: JSON.parse(createdTask.tags || '[]'), metadata: JSON.parse(createdTask.metadata || '{}'), } eventBus.broadcast('task.created', parsedTask) createdTasks.push(parsedTask) imported++ } catch (err: any) { logger.error({ err, issue: issue.number }, 'Failed to import GitHub issue') errors++ } } // Log sync to github_syncs table const syncTableHasWorkspace = db .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'") .get() as { ok?: number } | undefined if (syncTableHasWorkspace?.ok) { db.prepare(` INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error, workspace_id) VALUES (?, ?, ?, 'inbound', ?, ?, ?) `).run( repo, now, imported, errors > 0 ? 'partial' : 'success', errors > 0 ? `${errors} issues failed to import` : null, workspaceId ) } else { db.prepare(` INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error) VALUES (?, ?, ?, 'inbound', ?, ?) `).run( repo, now, imported, errors > 0 ? 'partial' : 'success', errors > 0 ? `${errors} issues failed to import` : null ) } eventBus.broadcast('github.synced', { repo, imported, skipped, errors, timestamp: now, }) return NextResponse.json({ imported, skipped, errors, tasks: createdTasks, }) } // ── Comment: post a comment on a GitHub issue ─────────────────── async function handleComment( body: { repo?: string; issueNumber?: number; body?: string }, actor: string, workspaceId: number ) { if (!body.repo || !body.issueNumber || !body.body) { return NextResponse.json( { error: 'repo, issueNumber, and body are required' }, { status: 400 } ) } await createIssueComment(body.repo, body.issueNumber, body.body) db_helpers.logActivity( 'github_comment', 'task', 0, actor, `Commented on ${body.repo}#${body.issueNumber}`, { github_repo: body.repo, github_issue: body.issueNumber }, workspaceId ) return NextResponse.json({ ok: true }) } // ── Close: close a GitHub issue ───────────────────────────────── async function handleClose( body: { repo?: string; issueNumber?: number; comment?: string }, actor: string, workspaceId: number ) { if (!body.repo || !body.issueNumber) { return NextResponse.json( { error: 'repo and issueNumber are required' }, { status: 400 } ) } // Optionally post a closing comment first if (body.comment) { await createIssueComment(body.repo, body.issueNumber, body.comment) } await updateIssueState(body.repo, body.issueNumber, 'closed') // Update local task metadata if we have a linked task const db = getDatabase() const now = Math.floor(Date.now() / 1000) db.prepare(` UPDATE tasks SET metadata = json_set(metadata, '$.github_state', 'closed'), updated_at = ? WHERE json_extract(metadata, '$.github_repo') = ? AND json_extract(metadata, '$.github_issue_number') = ? AND workspace_id = ? `).run(now, body.repo, body.issueNumber, workspaceId) db_helpers.logActivity( 'github_close', 'task', 0, actor, `Closed GitHub issue ${body.repo}#${body.issueNumber}`, { github_repo: body.repo, github_issue: body.issueNumber }, workspaceId ) return NextResponse.json({ ok: true }) } // ── Status: return recent sync history ────────────────────────── function handleStatus(workspaceId: number) { const db = getDatabase() const tableHasWorkspace = db .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'") .get() as { ok?: number } | undefined const syncs = db.prepare(` SELECT * FROM github_syncs ${tableHasWorkspace?.ok ? 'WHERE workspace_id = ?' : ''} ORDER BY created_at DESC LIMIT 20 `).all(...(tableHasWorkspace?.ok ? [workspaceId] : [])) return NextResponse.json({ syncs }) } // ── Stats: GitHub user profile + repo overview ────────────────── async function handleGitHubStats() { const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } // Fetch user profile const userRes = await githubFetch('/user') if (!userRes.ok) { return NextResponse.json({ error: 'Failed to fetch GitHub user' }, { status: 500 }) } const user = await userRes.json() as Record // Fetch repos (up to 100, sorted by recent push) const reposRes = await githubFetch('/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator') if (!reposRes.ok) { return NextResponse.json({ error: 'Failed to fetch repos' }, { status: 500 }) } const allRepos = await reposRes.json() as Array> // Filter: exclude repos that are forks AND where user has never pushed // A fork the user actively commits to will have pushed_at > created_at (by more than a few seconds) const activeRepos = allRepos.filter(r => { if (!r.fork) return true // For forks, include only if pushed_at is meaningfully after created_at // (GitHub sets pushed_at = parent's pushed_at on fork creation) const created = new Date(r.created_at).getTime() const pushed = new Date(r.pushed_at).getTime() return (pushed - created) > 60_000 // pushed > 1min after fork creation }) // Aggregate languages const langCounts: Record = {} for (const r of activeRepos) { if (r.language) { langCounts[r.language] = (langCounts[r.language] || 0) + 1 } } const topLanguages = Object.entries(langCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 6) .map(([name, count]) => ({ name, count })) // Recent repos (last 10 with actual pushes) const recentRepos = activeRepos.slice(0, 10).map(r => ({ name: r.full_name, description: r.description, language: r.language, stars: r.stargazers_count, forks: r.forks_count, open_issues: r.open_issues_count, pushed_at: r.pushed_at, is_fork: r.fork, is_private: r.private, html_url: r.html_url, })) return NextResponse.json({ user: { login: user.login, name: user.name, avatar_url: user.avatar_url, public_repos: user.public_repos, followers: user.followers, following: user.following, }, repos: { total: activeRepos.length, public: activeRepos.filter(r => !r.private).length, private: activeRepos.filter(r => r.private).length, total_stars: activeRepos.reduce((sum: number, r) => sum + (r.stargazers_count || 0), 0), total_forks: activeRepos.reduce((sum: number, r) => sum + (r.forks_count || 0), 0), total_open_issues: activeRepos.reduce((sum: number, r) => sum + (r.open_issues_count || 0), 0), }, topLanguages, recentRepos, }) } // ── Init Labels: create MC labels on repo ──────────────────────── async function handleInitLabels( body: { repo?: string }, workspaceId: number ) { const repo = body.repo || process.env.GITHUB_DEFAULT_REPO if (!repo) { return NextResponse.json({ error: 'repo is required' }, { status: 400 }) } await initializeLabels(repo) // Mark project labels as initialized const db = getDatabase() db.prepare(` UPDATE projects SET github_labels_initialized = 1, updated_at = unixepoch() WHERE github_repo = ? AND workspace_id = ? `).run(repo, workspaceId) return NextResponse.json({ ok: true, repo }) } // ── Sync Project: pull from GitHub for a project ───────────────── async function handleSyncProject( body: { project_id?: number }, actor: string, workspaceId: number ) { if (typeof body.project_id !== 'number') { return NextResponse.json({ error: 'project_id is required' }, { status: 400 }) } const db = getDatabase() const project = db.prepare(` SELECT id, github_repo, github_sync_enabled, github_default_branch FROM projects WHERE id = ? AND workspace_id = ? AND status = 'active' `).get(body.project_id, workspaceId) as any | undefined if (!project) { return NextResponse.json({ error: 'Project not found' }, { status: 404 }) } if (!project.github_repo || !project.github_sync_enabled) { return NextResponse.json({ error: 'GitHub sync not enabled for this project' }, { status: 400 }) } const result = await pullFromGitHub(project, workspaceId) db_helpers.logActivity( 'github_sync', 'project', project.id, actor, `Manual sync: pulled ${result.pulled}, pushed ${result.pushed}`, { repo: project.github_repo, ...result }, workspaceId ) return NextResponse.json({ ok: true, ...result }) } // ── Priority mapping helper ───────────────────────────────────── function mapPriority(labels: string[]): 'critical' | 'high' | 'medium' | 'low' { for (const label of labels) { const lower = label.toLowerCase() if (lower === 'priority:critical' || lower === 'critical') return 'critical' if (lower === 'priority:high' || lower === 'high') return 'high' if (lower === 'priority:low' || lower === 'low') return 'low' if (lower === 'priority:medium') return 'medium' } return 'medium' }