diff --git a/src/app/api/gnap/route.ts b/src/app/api/gnap/route.ts new file mode 100644 index 0000000..481df67 --- /dev/null +++ b/src/app/api/gnap/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireRole } from '@/lib/auth' +import { config } from '@/lib/config' +import { logger } from '@/lib/logger' +import { + initGnapRepo, + syncGnap, + getGnapStatus, +} from '@/lib/gnap-sync' + +/** + * GET /api/gnap — GNAP sync status + */ +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const gnapConfig = config.gnap + if (!gnapConfig.enabled) { + return NextResponse.json({ enabled: false }) + } + + try { + const status = getGnapStatus(gnapConfig.repoPath) + return NextResponse.json({ + enabled: true, + repoPath: gnapConfig.repoPath, + autoSync: gnapConfig.autoSync, + ...status, + }) + } catch (err) { + logger.error({ err }, 'GET /api/gnap error') + return NextResponse.json({ error: 'Failed to get GNAP status' }, { status: 500 }) + } +} + +/** + * POST /api/gnap?action=init|sync — GNAP management + */ +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 gnapConfig = config.gnap + if (!gnapConfig.enabled) { + return NextResponse.json({ error: 'GNAP is not enabled' }, { status: 400 }) + } + + const { searchParams } = new URL(request.url) + const action = searchParams.get('action') + + try { + switch (action) { + case 'init': { + initGnapRepo(gnapConfig.repoPath) + const status = getGnapStatus(gnapConfig.repoPath) + return NextResponse.json({ success: true, ...status }) + } + case 'sync': { + const result = syncGnap(gnapConfig.repoPath) + return NextResponse.json({ success: true, ...result }) + } + default: + return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 }) + } + } catch (err) { + logger.error({ err, action }, 'POST /api/gnap error') + return NextResponse.json({ error: 'GNAP operation failed' }, { status: 500 }) + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index c6afb6a..846fe18 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -8,6 +8,8 @@ import { validateBody, updateTaskSchema } from '@/lib/validation'; import { resolveMentionRecipients } from '@/lib/mentions'; import { normalizeTaskUpdateStatus } from '@/lib/task-status'; import { pushTaskToGitHub } from '@/lib/github-sync-engine'; +import { pushTaskToGnap, removeTaskFromGnap } from '@/lib/gnap-sync'; +import { config } from '@/lib/config'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -402,6 +404,12 @@ export async function PUT( } } + // Fire-and-forget GNAP sync for task updates + if (config.gnap.enabled && config.gnap.autoSync && changes.length > 0) { + try { pushTaskToGnap(updatedTask as any, config.gnap.repoPath) } + catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task update') } + } + // Broadcast to SSE clients eventBus.broadcast('task.updated', parsedTask); @@ -463,6 +471,12 @@ export async function DELETE( workspaceId ); + // Remove from GNAP repo + if (config.gnap.enabled && config.gnap.autoSync) { + try { removeTaskFromGnap(taskId, config.gnap.repoPath) } + catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for task deletion') } + } + // Broadcast to SSE clients eventBus.broadcast('task.deleted', { id: taskId, title: task.title }); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index b540a51..3d55912 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -8,6 +8,8 @@ import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/li import { resolveMentionRecipients } from '@/lib/mentions'; import { normalizeTaskCreateStatus } from '@/lib/task-status'; import { pushTaskToGitHub } from '@/lib/github-sync-engine'; +import { pushTaskToGnap } from '@/lib/gnap-sync'; +import { config } from '@/lib/config'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -314,6 +316,12 @@ export async function POST(request: NextRequest) { } } + // Fire-and-forget GNAP sync for new tasks + if (config.gnap.enabled && config.gnap.autoSync) { + try { pushTaskToGnap(parsedTask as any, config.gnap.repoPath) } + catch (err) { logger.warn({ err, taskId }, 'GNAP sync failed for new task') } + } + // Broadcast to SSE clients eventBus.broadcast('task.created', parsedTask); diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 12b983d..c9ab387 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -323,6 +323,8 @@ export function TaskBoardPanel() { timeoutSeconds: 300 }) const [isSpawning, setIsSpawning] = useState(false) + const [gnapStatus, setGnapStatus] = useState<{ enabled: boolean; taskCount?: number; lastSync?: string } | null>(null) + const [gnapSyncing, setGnapSyncing] = useState(false) const isLocal = dashboardMode === 'local' const dragCounter = useRef(0) const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10) @@ -412,6 +414,26 @@ export function TaskBoardPanel() { fetchData() }, [fetchData]) + // Fetch GNAP status + useEffect(() => { + fetch('/api/gnap') + .then(r => r.ok ? r.json() : null) + .then(data => { if (data) setGnapStatus(data) }) + .catch(() => {}) + }, []) + + const handleGnapSync = useCallback(async () => { + setGnapSyncing(true) + try { + const res = await fetch('/api/gnap?action=sync', { method: 'POST' }) + if (res.ok) { + const data = await res.json() + setGnapStatus(prev => prev ? { ...prev, taskCount: data.pushed, lastSync: data.lastSync } : prev) + } + } catch { /* ignore */ } + finally { setGnapSyncing(false) } + }, []) + // Sync global activeProject into local projectFilter useEffect(() => { const newFilter = activeProject ? String(activeProject.id) : 'all' @@ -663,6 +685,24 @@ export function TaskBoardPanel() {