From 60197ab21f05232cfa39e657743c4058664cb91e Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Mon, 2 Mar 2026 12:45:39 +0700 Subject: [PATCH] feat: add GitHub Issues sync (Phase 1, Issue #58) Import GitHub issues as Mission Control tasks with duplicate detection, priority mapping from labels, and bidirectional actions (comment/close). - Migration 017: github_syncs table for sync history tracking - GitHub API client (src/lib/github.ts) with fetch, comment, close ops - POST/GET /api/github route with sync, comment, close, status actions - GitHubSyncPanel UI: import form, issue preview, sync history, linked tasks - Nav rail + page router wiring - 6 E2E tests (all passing) - Validation schema + github.synced event type --- src/app/api/github/route.ts | 315 ++++++++++++ src/app/page.tsx | 3 + src/components/layout/nav-rail.tsx | 9 + src/components/panels/github-sync-panel.tsx | 534 ++++++++++++++++++++ src/lib/event-bus.ts | 1 + src/lib/github.ts | 156 ++++++ src/lib/migrations.ts | 19 + src/lib/validation.ts | 11 + tests/github-sync.spec.ts | 62 +++ 9 files changed, 1110 insertions(+) create mode 100644 src/app/api/github/route.ts create mode 100644 src/components/panels/github-sync-panel.tsx create mode 100644 src/lib/github.ts create mode 100644 tests/github-sync.spec.ts diff --git a/src/app/api/github/route.ts b/src/app/api/github/route.ts new file mode 100644 index 0000000..2a0a1e7 --- /dev/null +++ b/src/app/api/github/route.ts @@ -0,0 +1,315 @@ +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, + fetchIssues, + fetchIssue, + createIssueComment, + updateIssueState, + type GitHubIssue, +} from '@/lib/github' + +/** + * 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 !== 'issues') { + return NextResponse.json({ error: 'Unknown action. Use ?action=issues' }, { 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 = 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) + case 'comment': + return await handleComment(body, auth.user.username) + case 'close': + return await handleClose(body, auth.user.username) + case 'status': + return handleStatus() + 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 +) { + const repo = body.repo || process.env.GITHUB_DEFAULT_REPO + if (!repo) { + return NextResponse.json({ error: 'repo is required' }, { status: 400 }) + } + + const token = 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') = ? + `).get(repo, issue.number) 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const dbResult = stmt.run( + issue.title, + issue.body || '', + status, + priority, + body.assignAgent || null, + actor, + now, + now, + JSON.stringify(tags), + JSON.stringify(metadata) + ) + + 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 } + ) + + const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) 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 + 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 +) { + 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 } + ) + + return NextResponse.json({ ok: true }) +} + +// ── Close: close a GitHub issue ───────────────────────────────── + +async function handleClose( + body: { repo?: string; issueNumber?: number; comment?: string }, + actor: string +) { + 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') = ? + `).run(now, body.repo, body.issueNumber) + + db_helpers.logActivity( + 'github_close', + 'task', + 0, + actor, + `Closed GitHub issue ${body.repo}#${body.issueNumber}`, + { github_repo: body.repo, github_issue: body.issueNumber } + ) + + return NextResponse.json({ ok: true }) +} + +// ── Status: return recent sync history ────────────────────────── + +function handleStatus() { + const db = getDatabase() + const syncs = db.prepare(` + SELECT * FROM github_syncs + ORDER BY created_at DESC + LIMIT 20 + `).all() + + return NextResponse.json({ syncs }) +} + +// ── 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' +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c751683..42ebffd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -29,6 +29,7 @@ import { IntegrationsPanel } from '@/components/panels/integrations-panel' import { AlertRulesPanel } from '@/components/panels/alert-rules-panel' import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel' import { SuperAdminPanel } from '@/components/panels/super-admin-panel' +import { GitHubSyncPanel } from '@/components/panels/github-sync-panel' import { ChatPanel } from '@/components/chat/chat-panel' import { ErrorBoundary } from '@/components/ErrorBoundary' import { useWebSocket } from '@/lib/websocket' @@ -184,6 +185,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'settings': return + case 'github': + return case 'super-admin': return default: diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 8166f56..ed8e343 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -45,6 +45,7 @@ const navGroups: NavGroup[] = [ { id: 'spawn', label: 'Spawn', icon: , priority: false }, { id: 'webhooks', label: 'Webhooks', icon: , priority: false }, { id: 'alerts', label: 'Alerts', icon: , priority: false }, + { id: 'github', label: 'GitHub', icon: , priority: false }, ], }, { @@ -590,6 +591,14 @@ function AgentCostsIcon() { ) } +function GitHubIcon() { + return ( + + + + ) +} + function SettingsIcon() { return ( diff --git a/src/components/panels/github-sync-panel.tsx b/src/components/panels/github-sync-panel.tsx new file mode 100644 index 0000000..48efd09 --- /dev/null +++ b/src/components/panels/github-sync-panel.tsx @@ -0,0 +1,534 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +interface GitHubLabel { + name: string + color?: string +} + +interface GitHubIssue { + number: number + title: string + body: string | null + state: 'open' | 'closed' + labels: GitHubLabel[] + assignee: { login: string } | null + html_url: string + created_at: string + updated_at: string +} + +interface SyncRecord { + id: number + repo: string + last_synced_at: number + issue_count: number + sync_direction: string + status: string + error: string | null + created_at: number +} + +interface LinkedTask { + id: number + title: string + status: string + priority: string + metadata: { + github_repo?: string + github_issue_number?: number + github_issue_url?: string + github_synced_at?: string + github_state?: string + } +} + +export function GitHubSyncPanel() { + // Connection status + const [tokenStatus, setTokenStatus] = useState<{ connected: boolean; user?: string } | null>(null) + + // Import form + const [repo, setRepo] = useState('') + const [labelFilter, setLabelFilter] = useState('') + const [stateFilter, setStateFilter] = useState<'open' | 'closed' | 'all'>('open') + const [assignAgent, setAssignAgent] = useState('') + const [agents, setAgents] = useState<{ name: string }[]>([]) + + // Preview + const [previewIssues, setPreviewIssues] = useState([]) + const [previewing, setPreviewing] = useState(false) + + // Sync + const [syncing, setSyncing] = useState(false) + const [syncResult, setSyncResult] = useState<{ imported: number; skipped: number; errors: number } | null>(null) + + // Sync history + const [syncHistory, setSyncHistory] = useState([]) + + // Linked tasks + const [linkedTasks, setLinkedTasks] = useState([]) + + // Feedback + const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) + const [loading, setLoading] = useState(true) + + const showFeedback = (ok: boolean, text: string) => { + setFeedback({ ok, text }) + setTimeout(() => setFeedback(null), 4000) + } + + // Check GitHub token status + const checkToken = useCallback(async () => { + try { + const res = await fetch('/api/integrations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'test', integrationId: 'github' }), + }) + const data = await res.json() + setTokenStatus({ + connected: data.ok === true, + user: data.detail?.replace('User: ', ''), + }) + } catch { + setTokenStatus({ connected: false }) + } + }, []) + + // Fetch sync history + const fetchSyncHistory = useCallback(async () => { + try { + const res = await fetch('/api/github', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'status' }), + }) + if (res.ok) { + const data = await res.json() + setSyncHistory(data.syncs || []) + } + } catch { /* ignore */ } + }, []) + + // Fetch linked tasks + const fetchLinkedTasks = useCallback(async () => { + try { + const res = await fetch('/api/tasks?limit=200') + if (res.ok) { + const data = await res.json() + const linked = (data.tasks || []).filter( + (t: LinkedTask) => t.metadata?.github_repo + ) + setLinkedTasks(linked) + } + } catch { /* ignore */ } + }, []) + + // Fetch agents for assign dropdown + const fetchAgents = useCallback(async () => { + try { + const res = await fetch('/api/agents') + if (res.ok) { + const data = await res.json() + setAgents((data.agents || []).map((a: any) => ({ name: a.name }))) + } + } catch { /* ignore */ } + }, []) + + useEffect(() => { + Promise.all([checkToken(), fetchSyncHistory(), fetchLinkedTasks(), fetchAgents()]) + .finally(() => setLoading(false)) + }, [checkToken, fetchSyncHistory, fetchLinkedTasks, fetchAgents]) + + // Preview issues from GitHub + const handlePreview = async () => { + if (!repo) { + showFeedback(false, 'Enter a repository (owner/repo)') + return + } + setPreviewing(true) + setPreviewIssues([]) + setSyncResult(null) + try { + const params = new URLSearchParams({ action: 'issues', repo, state: stateFilter }) + if (labelFilter) params.set('labels', labelFilter) + const res = await fetch(`/api/github?${params}`) + const data = await res.json() + if (res.ok) { + setPreviewIssues(data.issues || []) + if (data.issues?.length === 0) showFeedback(true, 'No issues found matching filters') + } else { + showFeedback(false, data.error || 'Failed to fetch issues') + } + } catch { + showFeedback(false, 'Network error') + } finally { + setPreviewing(false) + } + } + + // Import issues as tasks + const handleImport = async () => { + if (!repo) return + setSyncing(true) + setSyncResult(null) + try { + const res = await fetch('/api/github', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'sync', + repo, + labels: labelFilter || undefined, + state: stateFilter, + assignAgent: assignAgent || undefined, + }), + }) + const data = await res.json() + if (res.ok) { + setSyncResult({ imported: data.imported, skipped: data.skipped, errors: data.errors }) + showFeedback(true, `Imported ${data.imported} issue${data.imported === 1 ? '' : 's'}, skipped ${data.skipped}`) + setPreviewIssues([]) + fetchSyncHistory() + fetchLinkedTasks() + } else { + showFeedback(false, data.error || 'Sync failed') + } + } catch { + showFeedback(false, 'Network error') + } finally { + setSyncing(false) + } + } + + if (loading) { + return ( +
+
+ Loading GitHub sync... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

GitHub Issues Sync

+

+ Import GitHub issues as Mission Control tasks +

+
+ {/* Connection status badge */} +
+ + + {tokenStatus?.connected + ? `GitHub: ${tokenStatus.user || 'connected'}` + : 'GitHub: not configured'} + +
+
+ + {/* Feedback */} + {feedback && ( +
+ {feedback.text} +
+ )} + + {/* Sync result banner */} + {syncResult && ( +
+ Imported: {syncResult.imported} + Skipped: {syncResult.skipped} + {syncResult.errors > 0 && Errors: {syncResult.errors}} +
+ )} + + {/* Import Issues Form */} +
+
+

Import Issues

+
+
+
+ {/* Repo input */} +
+ + setRepo(e.target.value)} + placeholder="owner/repo" + className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ + {/* Label filter */} +
+ + setLabelFilter(e.target.value)} + placeholder="bug,enhancement" + className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ + {/* State filter */} +
+ + +
+ + {/* Assign to agent */} +
+ + +
+
+ + {/* Actions */} +
+ + +
+
+
+ + {/* Issue Preview Table */} + {previewIssues.length > 0 && ( +
+
+

+ Preview ({previewIssues.length} issues) +

+
+
+ + + + + + + + + + + + {previewIssues.map(issue => ( + + + + + + + + ))} + +
#TitleLabelsStateCreated
{issue.number} + + {issue.title} + + +
+ {issue.labels.map(l => ( + + {l.name} + + ))} +
+
+ + {issue.state} + + + {new Date(issue.created_at).toLocaleDateString()} +
+
+
+ )} + + {/* Sync History */} + {syncHistory.length > 0 && ( +
+
+

Sync History

+
+
+ + + + + + + + + + + {syncHistory.map(sync => ( + + + + + + + ))} + +
RepoIssuesStatusSynced At
{sync.repo}{sync.issue_count} + + {sync.status} + + + {new Date(sync.created_at * 1000).toLocaleString()} +
+
+
+ )} + + {/* Linked Tasks */} + {linkedTasks.length > 0 && ( +
+
+

+ Linked Tasks ({linkedTasks.length}) +

+
+
+ + + + + + + + + + + + {linkedTasks.map(task => ( + + + + + + + + ))} + +
TaskStatusPriorityGitHubSynced
{task.title} + + {task.status} + + + + {task.priority} + + + {task.metadata.github_issue_url ? ( + + {task.metadata.github_repo}#{task.metadata.github_issue_number} + + ) : ( + + )} + + {task.metadata.github_synced_at + ? new Date(task.metadata.github_synced_at).toLocaleDateString() + : '—'} +
+
+
+ )} +
+ ) +} diff --git a/src/lib/event-bus.ts b/src/lib/event-bus.ts index f08d017..bce1c14 100644 --- a/src/lib/event-bus.ts +++ b/src/lib/event-bus.ts @@ -30,6 +30,7 @@ export type EventType = | 'audit.security' | 'connection.created' | 'connection.disconnected' + | 'github.synced' class ServerEventBus extends EventEmitter { private static instance: ServerEventBus | null = null diff --git a/src/lib/github.ts b/src/lib/github.ts new file mode 100644 index 0000000..0528063 --- /dev/null +++ b/src/lib/github.ts @@ -0,0 +1,156 @@ +/** + * GitHub API client for Mission Control issue sync. + * Uses GITHUB_TOKEN from env (integration key, not core config). + */ + +export interface GitHubLabel { + name: string + color?: string +} + +export interface GitHubUser { + login: string + avatar_url?: string +} + +export interface GitHubIssue { + number: number + title: string + body: string | null + state: 'open' | 'closed' + labels: GitHubLabel[] + assignee: GitHubUser | null + html_url: string + created_at: string + updated_at: string +} + +export function getGitHubToken(): string | null { + return process.env.GITHUB_TOKEN || null +} + +/** + * Authenticated fetch wrapper for GitHub API. + */ +export async function githubFetch( + path: string, + options: RequestInit = {} +): Promise { + const token = getGitHubToken() + if (!token) { + throw new Error('GITHUB_TOKEN not configured') + } + + const url = path.startsWith('https://') + ? path + : `https://api.github.com${path.startsWith('/') ? '' : '/'}${path}` + + const headers: Record = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'MissionControl/1.0', + ...(options.headers as Record || {}), + } + + if (options.body) { + headers['Content-Type'] = 'application/json' + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15000) + + try { + const res = await fetch(url, { + ...options, + headers, + signal: controller.signal, + }) + return res + } finally { + clearTimeout(timeout) + } +} + +/** + * Fetch issues from a GitHub repo. + */ +export async function fetchIssues( + repo: string, + params?: { + state?: 'open' | 'closed' | 'all' + labels?: string + since?: string + per_page?: number + page?: number + } +): Promise { + const searchParams = new URLSearchParams() + if (params?.state) searchParams.set('state', params.state) + if (params?.labels) searchParams.set('labels', params.labels) + if (params?.since) searchParams.set('since', params.since) + searchParams.set('per_page', String(params?.per_page ?? 30)) + searchParams.set('page', String(params?.page ?? 1)) + + const qs = searchParams.toString() + const res = await githubFetch(`/repos/${repo}/issues?${qs}`) + + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub API error ${res.status}: ${text}`) + } + + const data = await res.json() + // Filter out pull requests (GitHub API returns PRs in issues endpoint) + return (data as any[]).filter((item: any) => !item.pull_request) +} + +/** + * Fetch a single issue. + */ +export async function fetchIssue( + repo: string, + issueNumber: number +): Promise { + const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}`) + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub API error ${res.status}: ${text}`) + } + return res.json() +} + +/** + * Post a comment on a GitHub issue. + */ +export async function createIssueComment( + repo: string, + issueNumber: number, + body: string +): Promise { + const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub API error ${res.status}: ${text}`) + } +} + +/** + * Update an issue's state (open/closed). + */ +export async function updateIssueState( + repo: string, + issueNumber: number, + state: 'open' | 'closed' +): Promise { + const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}`, { + method: 'PATCH', + body: JSON.stringify({ state }), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`GitHub API error ${res.status}: ${text}`) + } +} diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 1d06d1b..420f4c9 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -458,6 +458,25 @@ const migrations: Migration[] = [ CREATE INDEX IF NOT EXISTS idx_direct_connections_status ON direct_connections(status); `) } + }, + { + id: '017_github_sync', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS github_syncs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT NOT NULL, + last_synced_at INTEGER NOT NULL DEFAULT (unixepoch()), + issue_count INTEGER NOT NULL DEFAULT 0, + sync_direction TEXT NOT NULL DEFAULT 'inbound', + status TEXT NOT NULL DEFAULT 'success', + error TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_github_syncs_repo ON github_syncs(repo); + CREATE INDEX IF NOT EXISTS idx_github_syncs_created_at ON github_syncs(created_at); + `) + } } ] diff --git a/src/lib/validation.ts b/src/lib/validation.ts index d14a9f0..96c33c8 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -161,3 +161,14 @@ export const connectSchema = z.object({ agent_role: z.string().max(100).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }) + +export const githubSyncSchema = z.object({ + action: z.enum(['sync', 'comment', 'close', 'status']), + repo: z.string().regex(/^[^/]+\/[^/]+$/, 'Repo must be owner/repo format').optional(), + labels: z.string().optional(), + state: z.enum(['open', 'closed', 'all']).optional(), + assignAgent: z.string().optional(), + issueNumber: z.number().optional(), + body: z.string().optional(), + comment: z.string().optional(), +}) diff --git a/tests/github-sync.spec.ts b/tests/github-sync.spec.ts new file mode 100644 index 0000000..2161cb0 --- /dev/null +++ b/tests/github-sync.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER } from './helpers' + +test.describe('GitHub Sync API', () => { + // ── GET /api/github ──────────────────────────── + + test('GET /api/github?action=issues requires auth', async ({ request }) => { + const res = await request.get('/api/github?action=issues&repo=owner/repo') + expect(res.status()).toBe(401) + }) + + test('GET /api/github?action=issues returns error without GITHUB_TOKEN', async ({ request }) => { + const res = await request.get('/api/github?action=issues&repo=owner/repo', { + headers: API_KEY_HEADER, + }) + // Either 400 (token not configured) or 500 (API error) are acceptable + expect([400, 500]).toContain(res.status()) + const body = await res.json() + expect(body.error).toBeDefined() + }) + + test('GET /api/github rejects invalid action', async ({ request }) => { + const res = await request.get('/api/github?action=invalid', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(400) + const body = await res.json() + expect(body.error).toContain('Unknown action') + }) + + // ── POST /api/github ─────────────────────────── + + test('POST /api/github with action=status returns sync history', async ({ request }) => { + const res = await request.post('/api/github', { + headers: API_KEY_HEADER, + data: { action: 'status' }, + }) + expect(res.status()).toBe(200) + const body = await res.json() + expect(body.syncs).toBeDefined() + expect(Array.isArray(body.syncs)).toBe(true) + }) + + test('POST /api/github with action=sync requires repo param', async ({ request }) => { + const res = await request.post('/api/github', { + headers: API_KEY_HEADER, + data: { action: 'sync' }, + }) + // Should fail because no repo and no GITHUB_DEFAULT_REPO + expect([400, 500]).toContain(res.status()) + }) + + test('POST /api/github rejects invalid repo format', async ({ request }) => { + const res = await request.post('/api/github', { + headers: API_KEY_HEADER, + data: { action: 'sync', repo: 'invalid-no-slash' }, + }) + expect(res.status()).toBe(400) + const body = await res.json() + expect(body.error || body.details).toBeDefined() + }) +})