316 lines
9.4 KiB
TypeScript
316 lines
9.4 KiB
TypeScript
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'
|
|
}
|