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
This commit is contained in:
parent
7eff5e8efe
commit
60197ab21f
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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 <IntegrationsPanel />
|
||||
case 'settings':
|
||||
return <SettingsPanel />
|
||||
case 'github':
|
||||
return <GitHubSyncPanel />
|
||||
case 'super-admin':
|
||||
return <SuperAdminPanel />
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const navGroups: NavGroup[] = [
|
|||
{ id: 'spawn', label: 'Spawn', icon: <SpawnIcon />, priority: false },
|
||||
{ id: 'webhooks', label: 'Webhooks', icon: <WebhookIcon />, priority: false },
|
||||
{ id: 'alerts', label: 'Alerts', icon: <AlertIcon />, priority: false },
|
||||
{ id: 'github', label: 'GitHub', icon: <GitHubIcon />, priority: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -590,6 +591,14 @@ function AgentCostsIcon() {
|
|||
)
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 12.5c-3 1-3-1.5-4-2m8 4v-2.2a2.1 2.1 0 00-.6-1.6c2-.2 4.1-1 4.1-4.5a3.5 3.5 0 00-1-2.4 3.2 3.2 0 00-.1-2.4s-.8-.2-2.5 1a8.7 8.7 0 00-4.6 0C3.7 3.4 2.9 3.6 2.9 3.6a3.2 3.2 0 00-.1 2.4 3.5 3.5 0 00-1 2.4c0 3.5 2.1 4.3 4.1 4.5a2.1 2.1 0 00-.6 1.6v2.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
|
|||
|
|
@ -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<GitHubIssue[]>([])
|
||||
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<SyncRecord[]>([])
|
||||
|
||||
// Linked tasks
|
||||
const [linkedTasks, setLinkedTasks] = useState<LinkedTask[]>([])
|
||||
|
||||
// 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 (
|
||||
<div className="p-6 flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Loading GitHub sync...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">GitHub Issues Sync</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Import GitHub issues as Mission Control tasks
|
||||
</p>
|
||||
</div>
|
||||
{/* Connection status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-2xs px-2 py-1 rounded flex items-center gap-1.5 ${
|
||||
tokenStatus?.connected
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
tokenStatus?.connected ? 'bg-green-500' : 'bg-destructive'
|
||||
}`} />
|
||||
{tokenStatus?.connected
|
||||
? `GitHub: ${tokenStatus.user || 'connected'}`
|
||||
: 'GitHub: not configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{feedback && (
|
||||
<div className={`rounded-lg p-3 text-xs font-medium ${
|
||||
feedback.ok ? 'bg-green-500/10 text-green-400' : 'bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
{feedback.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync result banner */}
|
||||
{syncResult && (
|
||||
<div className="rounded-lg p-3 text-xs bg-blue-500/10 text-blue-400 flex items-center gap-4">
|
||||
<span>Imported: {syncResult.imported}</span>
|
||||
<span>Skipped: {syncResult.skipped}</span>
|
||||
{syncResult.errors > 0 && <span className="text-destructive">Errors: {syncResult.errors}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Issues Form */}
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-foreground">Import Issues</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{/* Repo input */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Repository</label>
|
||||
<input
|
||||
type="text"
|
||||
value={repo}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label filter */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Labels (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={labelFilter}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* State filter */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">State</label>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value as any)}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Assign to agent */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Assign to Agent (optional)</label>
|
||||
<select
|
||||
value={assignAgent}
|
||||
onChange={e => setAssignAgent(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map(a => (
|
||||
<option key={a.name} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || !repo}
|
||||
className="px-4 py-1.5 text-xs rounded-md border border-border text-foreground hover:bg-secondary transition-colors flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{previewing ? (
|
||||
<div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="M11 11l3 3" />
|
||||
</svg>
|
||||
)}
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={syncing || !repo}
|
||||
className={`px-4 py-1.5 text-xs rounded-md font-medium transition-colors flex items-center gap-1.5 ${
|
||||
repo
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{syncing ? (
|
||||
<div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M5 7l3 3 3-3" />
|
||||
<path d="M3 12v2h10v-2" />
|
||||
</svg>
|
||||
)}
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Preview Table */}
|
||||
{previewIssues.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Preview ({previewIssues.length} issues)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
<th className="text-left px-4 py-2 font-medium">#</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Title</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Labels</th>
|
||||
<th className="text-left px-4 py-2 font-medium">State</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewIssues.map(issue => (
|
||||
<tr key={issue.number} className="border-b border-border/50 hover:bg-secondary/50">
|
||||
<td className="px-4 py-2 text-muted-foreground">{issue.number}</td>
|
||||
<td className="px-4 py-2 text-foreground max-w-[300px] truncate">
|
||||
<a
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
{issue.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.labels.map(l => (
|
||||
<span
|
||||
key={l.name}
|
||||
className="px-1.5 py-0.5 rounded text-2xs bg-secondary text-muted-foreground"
|
||||
>
|
||||
{l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-2xs ${
|
||||
issue.state === 'open'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-purple-500/10 text-purple-400'
|
||||
}`}>
|
||||
{issue.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{new Date(issue.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync History */}
|
||||
{syncHistory.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-foreground">Sync History</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
<th className="text-left px-4 py-2 font-medium">Repo</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Issues</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Synced At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{syncHistory.map(sync => (
|
||||
<tr key={sync.id} className="border-b border-border/50 hover:bg-secondary/50">
|
||||
<td className="px-4 py-2 font-mono text-foreground">{sync.repo}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{sync.issue_count}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-2xs ${
|
||||
sync.status === 'success'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: sync.status === 'partial'
|
||||
? 'bg-yellow-500/10 text-yellow-400'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
{sync.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{new Date(sync.created_at * 1000).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Tasks */}
|
||||
{linkedTasks.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Linked Tasks ({linkedTasks.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
<th className="text-left px-4 py-2 font-medium">Task</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Priority</th>
|
||||
<th className="text-left px-4 py-2 font-medium">GitHub</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{linkedTasks.map(task => (
|
||||
<tr key={task.id} className="border-b border-border/50 hover:bg-secondary/50">
|
||||
<td className="px-4 py-2 text-foreground max-w-[250px] truncate">{task.title}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="px-1.5 py-0.5 rounded text-2xs bg-secondary text-muted-foreground">
|
||||
{task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-2xs ${
|
||||
task.priority === 'critical' ? 'bg-red-500/10 text-red-400' :
|
||||
task.priority === 'high' ? 'bg-orange-500/10 text-orange-400' :
|
||||
task.priority === 'low' ? 'bg-blue-500/10 text-blue-400' :
|
||||
'bg-secondary text-muted-foreground'
|
||||
}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{task.metadata.github_issue_url ? (
|
||||
<a
|
||||
href={task.metadata.github_issue_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline font-mono"
|
||||
>
|
||||
{task.metadata.github_repo}#{task.metadata.github_issue_number}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{task.metadata.github_synced_at
|
||||
? new Date(task.metadata.github_synced_at).toLocaleDateString()
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
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<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'MissionControl/1.0',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
}
|
||||
|
||||
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<GitHubIssue[]> {
|
||||
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<GitHubIssue> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
`)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue