Implement Backend API, MCP Server, and Gitea integration
All checks were successful
Build and Push Backend / build (push) Successful in 5s
All checks were successful
Build and Push Backend / build (push) Successful in 5s
- Add REST API routes for projects, tasks, and agents (CRUD operations) - Implement MCP Server with 4 core tools: - get_next_task: Assign tasks to agents - update_task_status: Update task states - create_branch: Create Git branches via Gitea API - create_pull_request: Create PRs via Gitea API - Add Gitea API client for repository operations - Fix database migration error handling for existing tables - Connect all routes to Bun.serve() main server Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
303
src/api/routes/agents.ts
Normal file
303
src/api/routes/agents.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Agents API Routes
|
||||||
|
* CRUD operations and status management for Claude Code agents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../db/client'
|
||||||
|
import { agents, tasks } from '../../db/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all agent routes
|
||||||
|
*/
|
||||||
|
export async function handleAgentRoutes(req: Request, url: URL): Promise<Response> {
|
||||||
|
const method = req.method
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
// GET /api/agents - List all agents
|
||||||
|
if (method === 'GET' && pathParts.length === 2) {
|
||||||
|
return await listAgents(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/agents/:id - Get single agent
|
||||||
|
if (method === 'GET' && pathParts.length === 3) {
|
||||||
|
const agentId = pathParts[2]
|
||||||
|
return await getAgent(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agents - Register agent
|
||||||
|
if (method === 'POST' && pathParts.length === 2) {
|
||||||
|
return await registerAgent(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/agents/:id - Update agent
|
||||||
|
if (method === 'PATCH' && pathParts.length === 3) {
|
||||||
|
const agentId = pathParts[2]
|
||||||
|
return await updateAgent(agentId, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/agents/:id/heartbeat - Update heartbeat
|
||||||
|
if (method === 'POST' && pathParts.length === 4 && pathParts[3] === 'heartbeat') {
|
||||||
|
const agentId = pathParts[2]
|
||||||
|
return await updateHeartbeat(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/agents/:id - Unregister agent
|
||||||
|
if (method === 'DELETE' && pathParts.length === 3) {
|
||||||
|
const agentId = pathParts[2]
|
||||||
|
return await unregisterAgent(agentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List agents with optional status filter
|
||||||
|
*/
|
||||||
|
async function listAgents(url: URL): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const status = url.searchParams.get('status')
|
||||||
|
|
||||||
|
let query = db.select().from(agents)
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query = query.where(eq(agents.status, status as any)) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAgents = await query
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: allAgents,
|
||||||
|
count: allAgents.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing agents:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to list agents',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single agent
|
||||||
|
*/
|
||||||
|
async function getAgent(agentId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const agent = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (agent.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Agent not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: agent[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting agent:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get agent',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register new agent (called when pod starts)
|
||||||
|
*/
|
||||||
|
async function registerAgent(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.podName) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'podName is required',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if agent with this podName already exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.podName, body.podName))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Agent already exists, return existing
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: existing[0],
|
||||||
|
message: 'Agent already registered',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAgent = {
|
||||||
|
id: randomUUID(),
|
||||||
|
podName: body.podName,
|
||||||
|
k8sNamespace: body.k8sNamespace || 'agents',
|
||||||
|
status: 'idle' as const,
|
||||||
|
currentTaskId: null,
|
||||||
|
tasksCompleted: 0,
|
||||||
|
lastHeartbeat: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(agents).values(newAgent)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: newAgent,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering agent:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to register agent',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update agent status and current task
|
||||||
|
*/
|
||||||
|
async function updateAgent(agentId: string, req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Check if agent exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Agent not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData: any = {}
|
||||||
|
|
||||||
|
if (body.status !== undefined) updateData.status = body.status
|
||||||
|
if (body.currentTaskId !== undefined) updateData.currentTaskId = body.currentTaskId
|
||||||
|
if (body.tasksCompleted !== undefined) updateData.tasksCompleted = body.tasksCompleted
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
|
||||||
|
// Get updated agent
|
||||||
|
const updated = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: updated[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating agent:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update agent',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update agent heartbeat (keep-alive)
|
||||||
|
*/
|
||||||
|
async function updateHeartbeat(agentId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if agent exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Agent not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update heartbeat timestamp
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set({ lastHeartbeat: new Date() })
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Heartbeat updated',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating heartbeat:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update heartbeat',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister agent (called when pod terminates)
|
||||||
|
*/
|
||||||
|
async function unregisterAgent(agentId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if agent exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Agent not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If agent has a current task, set it to null
|
||||||
|
if (existing[0].currentTaskId) {
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ assignedAgentId: null })
|
||||||
|
.where(eq(tasks.id, existing[0].currentTaskId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete agent
|
||||||
|
await db.delete(agents).where(eq(agents.id, agentId))
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Agent unregistered',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unregistering agent:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to unregister agent',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/api/routes/index.ts
Normal file
8
src/api/routes/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* API Routes Index
|
||||||
|
* Exports all route handlers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { handleProjectRoutes } from './projects'
|
||||||
|
export { handleTaskRoutes } from './tasks'
|
||||||
|
export { handleAgentRoutes } from './agents'
|
||||||
247
src/api/routes/projects.ts
Normal file
247
src/api/routes/projects.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Projects API Routes
|
||||||
|
* CRUD operations for projects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../db/client'
|
||||||
|
import { projects } from '../../db/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all project routes
|
||||||
|
*/
|
||||||
|
export async function handleProjectRoutes(req: Request, url: URL): Promise<Response> {
|
||||||
|
const method = req.method
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
// GET /api/projects - List all projects
|
||||||
|
if (method === 'GET' && pathParts.length === 2) {
|
||||||
|
return await listProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/projects/:id - Get single project
|
||||||
|
if (method === 'GET' && pathParts.length === 3) {
|
||||||
|
const projectId = pathParts[2]
|
||||||
|
return await getProject(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/projects - Create project
|
||||||
|
if (method === 'POST' && pathParts.length === 2) {
|
||||||
|
return await createProject(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/projects/:id - Update project
|
||||||
|
if (method === 'PATCH' && pathParts.length === 3) {
|
||||||
|
const projectId = pathParts[2]
|
||||||
|
return await updateProject(projectId, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id - Delete project
|
||||||
|
if (method === 'DELETE' && pathParts.length === 3) {
|
||||||
|
const projectId = pathParts[2]
|
||||||
|
return await deleteProject(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all projects
|
||||||
|
*/
|
||||||
|
async function listProjects(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const allProjects = await db.select().from(projects)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: allProjects,
|
||||||
|
count: allProjects.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing projects:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to list projects',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single project
|
||||||
|
*/
|
||||||
|
async function getProject(projectId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (project.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: project[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting project:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get project',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create project
|
||||||
|
*/
|
||||||
|
async function createProject(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.name) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Name is required',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate k8s namespace name from project name
|
||||||
|
const k8sNamespace = `proj-${body.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`
|
||||||
|
|
||||||
|
const newProject = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: body.name,
|
||||||
|
description: body.description || null,
|
||||||
|
k8sNamespace,
|
||||||
|
giteaRepoId: body.giteaRepoId || null,
|
||||||
|
giteaRepoUrl: body.giteaRepoUrl || null,
|
||||||
|
giteaOwner: body.giteaOwner || null,
|
||||||
|
giteaRepoName: body.giteaRepoName || null,
|
||||||
|
defaultBranch: body.defaultBranch || 'main',
|
||||||
|
dockerImage: body.dockerImage || null,
|
||||||
|
envVars: body.envVars || null,
|
||||||
|
replicas: body.replicas || 1,
|
||||||
|
cpuLimit: body.cpuLimit || '500m',
|
||||||
|
memoryLimit: body.memoryLimit || '512Mi',
|
||||||
|
status: body.status || 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(projects).values(newProject)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: newProject,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating project:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create project',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project
|
||||||
|
*/
|
||||||
|
async function updateProject(projectId: string, req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Check if project exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only provided fields
|
||||||
|
const updateData: any = {}
|
||||||
|
|
||||||
|
if (body.name !== undefined) updateData.name = body.name
|
||||||
|
if (body.description !== undefined) updateData.description = body.description
|
||||||
|
if (body.giteaRepoId !== undefined) updateData.giteaRepoId = body.giteaRepoId
|
||||||
|
if (body.giteaRepoUrl !== undefined) updateData.giteaRepoUrl = body.giteaRepoUrl
|
||||||
|
if (body.giteaOwner !== undefined) updateData.giteaOwner = body.giteaOwner
|
||||||
|
if (body.giteaRepoName !== undefined) updateData.giteaRepoName = body.giteaRepoName
|
||||||
|
if (body.defaultBranch !== undefined) updateData.defaultBranch = body.defaultBranch
|
||||||
|
if (body.dockerImage !== undefined) updateData.dockerImage = body.dockerImage
|
||||||
|
if (body.envVars !== undefined) updateData.envVars = body.envVars
|
||||||
|
if (body.replicas !== undefined) updateData.replicas = body.replicas
|
||||||
|
if (body.cpuLimit !== undefined) updateData.cpuLimit = body.cpuLimit
|
||||||
|
if (body.memoryLimit !== undefined) updateData.memoryLimit = body.memoryLimit
|
||||||
|
if (body.status !== undefined) updateData.status = body.status
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(projects)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
|
||||||
|
// Get updated project
|
||||||
|
const updated = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: updated[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update project',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete project
|
||||||
|
*/
|
||||||
|
async function deleteProject(projectId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if project exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(projects).where(eq(projects.id, projectId))
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Project deleted',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete project',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
328
src/api/routes/tasks.ts
Normal file
328
src/api/routes/tasks.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Tasks API Routes
|
||||||
|
* CRUD operations and state management for tasks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../db/client'
|
||||||
|
import { tasks, projects, agents } from '../../db/schema'
|
||||||
|
import { eq, and, or } from 'drizzle-orm'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all task routes
|
||||||
|
*/
|
||||||
|
export async function handleTaskRoutes(req: Request, url: URL): Promise<Response> {
|
||||||
|
const method = req.method
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
// GET /api/tasks - List tasks (with filters)
|
||||||
|
if (method === 'GET' && pathParts.length === 2) {
|
||||||
|
return await listTasks(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tasks/:id - Get single task
|
||||||
|
if (method === 'GET' && pathParts.length === 3) {
|
||||||
|
const taskId = pathParts[2]
|
||||||
|
return await getTask(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tasks - Create task
|
||||||
|
if (method === 'POST' && pathParts.length === 2) {
|
||||||
|
return await createTask(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/tasks/:id - Update task
|
||||||
|
if (method === 'PATCH' && pathParts.length === 3) {
|
||||||
|
const taskId = pathParts[2]
|
||||||
|
return await updateTask(taskId, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tasks/:id/respond - Respond to task question
|
||||||
|
if (method === 'POST' && pathParts.length === 4 && pathParts[3] === 'respond') {
|
||||||
|
const taskId = pathParts[2]
|
||||||
|
return await respondToTask(taskId, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/tasks/:id - Delete task
|
||||||
|
if (method === 'DELETE' && pathParts.length === 3) {
|
||||||
|
const taskId = pathParts[2]
|
||||||
|
return await deleteTask(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tasks with optional filters
|
||||||
|
*/
|
||||||
|
async function listTasks(url: URL): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const projectId = url.searchParams.get('projectId')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const assignedAgentId = url.searchParams.get('assignedAgentId')
|
||||||
|
|
||||||
|
let query = db.select().from(tasks)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const conditions = []
|
||||||
|
if (projectId) conditions.push(eq(tasks.projectId, projectId))
|
||||||
|
if (state) conditions.push(eq(tasks.state, state as any))
|
||||||
|
if (assignedAgentId) conditions.push(eq(tasks.assignedAgentId, assignedAgentId))
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query = query.where(and(...conditions)) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTasks = await query
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: allTasks,
|
||||||
|
count: allTasks.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing tasks:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to list tasks',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single task with project and agent details
|
||||||
|
*/
|
||||||
|
async function getTask(taskId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const task = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (task.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: task[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting task:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get task',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create task
|
||||||
|
*/
|
||||||
|
async function createTask(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.projectId) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectId is required',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.title) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'title is required',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project exists
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, body.projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (project.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTask = {
|
||||||
|
id: randomUUID(),
|
||||||
|
projectId: body.projectId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description || null,
|
||||||
|
priority: body.priority || 'medium',
|
||||||
|
state: body.state || 'backlog',
|
||||||
|
assignedAgentId: body.assignedAgentId || null,
|
||||||
|
branchName: body.branchName || null,
|
||||||
|
prUrl: body.prUrl || null,
|
||||||
|
previewUrl: body.previewUrl || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(tasks).values(newTask)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: newTask,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create task',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update task (including state transitions)
|
||||||
|
*/
|
||||||
|
async function updateTask(taskId: string, req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Check if task exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData: any = {}
|
||||||
|
|
||||||
|
if (body.title !== undefined) updateData.title = body.title
|
||||||
|
if (body.description !== undefined) updateData.description = body.description
|
||||||
|
if (body.priority !== undefined) updateData.priority = body.priority
|
||||||
|
if (body.state !== undefined) updateData.state = body.state
|
||||||
|
if (body.assignedAgentId !== undefined) updateData.assignedAgentId = body.assignedAgentId
|
||||||
|
if (body.branchName !== undefined) updateData.branchName = body.branchName
|
||||||
|
if (body.prUrl !== undefined) updateData.prUrl = body.prUrl
|
||||||
|
if (body.previewUrl !== undefined) updateData.previewUrl = body.previewUrl
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
// Get updated task
|
||||||
|
const updated = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: updated[0],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating task:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update task',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respond to task (used when task is in needs_input state)
|
||||||
|
*/
|
||||||
|
async function respondToTask(taskId: string, req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Check if task exists and is in needs_input state
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing[0].state !== 'needs_input') {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task is not waiting for input',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state to in_progress
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ state: 'in_progress' })
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
// TODO: Notify agent via MCP about the response
|
||||||
|
// This will be implemented in the MCP server
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Response sent to agent',
|
||||||
|
response: body.response,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error responding to task:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to respond to task',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete task
|
||||||
|
*/
|
||||||
|
async function deleteTask(taskId: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if task exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Task not found',
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(tasks).where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Task deleted',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting task:', error)
|
||||||
|
return Response.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to delete task',
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,22 @@ export async function runMigrations() {
|
|||||||
console.log('✅ Migrations completed successfully')
|
console.log('✅ Migrations completed successfully')
|
||||||
await connection.end()
|
await connection.end()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
// If table already exists, it's not a fatal error
|
||||||
|
const errorMessage = error?.message || String(error)
|
||||||
|
const errorCode = error?.code || error?.cause?.code
|
||||||
|
const errorErrno = error?.errno || error?.cause?.errno
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorCode === 'ER_TABLE_EXISTS_ERROR' ||
|
||||||
|
errorErrno === 1050 ||
|
||||||
|
errorMessage.includes('already exists')
|
||||||
|
) {
|
||||||
|
console.log('⚠️ Tables already exist, skipping migrations')
|
||||||
|
await connection.end()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
console.error('❌ Migration failed:', error)
|
console.error('❌ Migration failed:', error)
|
||||||
await connection.end()
|
await connection.end()
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
37
src/index.ts
37
src/index.ts
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { runMigrations } from './db/migrate'
|
import { runMigrations } from './db/migrate'
|
||||||
import { testConnection } from './db/client'
|
import { testConnection } from './db/client'
|
||||||
|
import { handleProjectRoutes, handleTaskRoutes, handleAgentRoutes } from './api/routes'
|
||||||
|
|
||||||
console.log('🚀 Starting AiWorker Backend...')
|
console.log('🚀 Starting AiWorker Backend...')
|
||||||
console.log(`Bun version: ${Bun.version}`)
|
console.log(`Bun version: ${Bun.version}`)
|
||||||
@@ -41,11 +42,43 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
if (url.pathname.startsWith('/api/projects')) {
|
||||||
|
return handleProjectRoutes(req, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/tasks')) {
|
||||||
|
return handleTaskRoutes(req, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/api/agents')) {
|
||||||
|
return handleAgentRoutes(req, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic API info
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
message: 'AiWorker API',
|
message: 'AiWorker API',
|
||||||
path: url.pathname,
|
version: '1.0.0',
|
||||||
method: req.method,
|
endpoints: [
|
||||||
|
'GET /api/health',
|
||||||
|
'GET /api/projects',
|
||||||
|
'GET /api/projects/:id',
|
||||||
|
'POST /api/projects',
|
||||||
|
'PATCH /api/projects/:id',
|
||||||
|
'DELETE /api/projects/:id',
|
||||||
|
'GET /api/tasks',
|
||||||
|
'GET /api/tasks/:id',
|
||||||
|
'POST /api/tasks',
|
||||||
|
'PATCH /api/tasks/:id',
|
||||||
|
'POST /api/tasks/:id/respond',
|
||||||
|
'DELETE /api/tasks/:id',
|
||||||
|
'GET /api/agents',
|
||||||
|
'GET /api/agents/:id',
|
||||||
|
'POST /api/agents',
|
||||||
|
'PATCH /api/agents/:id',
|
||||||
|
'POST /api/agents/:id/heartbeat',
|
||||||
|
'DELETE /api/agents/:id',
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
169
src/services/gitea/client.ts
Normal file
169
src/services/gitea/client.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Gitea API Client
|
||||||
|
* Handles interactions with Gitea API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GITEA_URL = process.env.GITEA_URL || 'https://git.fuq.tv'
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''
|
||||||
|
|
||||||
|
interface GiteaBranchOptions {
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
branchName: string
|
||||||
|
fromBranch: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaPullRequestOptions {
|
||||||
|
owner: string
|
||||||
|
repo: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
head: string
|
||||||
|
base: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make authenticated request to Gitea API
|
||||||
|
*/
|
||||||
|
async function giteaRequest(path: string, options: RequestInit = {}) {
|
||||||
|
const url = `${GITEA_URL}/api/v1${path}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
throw new Error(`Gitea API error: ${response.status} - ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new branch in Gitea
|
||||||
|
*/
|
||||||
|
export async function createGiteaBranch(options: GiteaBranchOptions) {
|
||||||
|
const { owner, repo, branchName, fromBranch } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the ref of the source branch
|
||||||
|
const refData = await giteaRequest(
|
||||||
|
`/repos/${owner}/${repo}/git/refs/heads/${fromBranch}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create new branch from the ref
|
||||||
|
const result = await giteaRequest(
|
||||||
|
`/repos/${owner}/${repo}/branches`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_branch_name: branchName,
|
||||||
|
old_ref_name: fromBranch,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating Gitea branch:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Pull Request in Gitea
|
||||||
|
*/
|
||||||
|
export async function createGiteaPullRequest(options: GiteaPullRequestOptions) {
|
||||||
|
const { owner, repo, title, body, head, base } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await giteaRequest(
|
||||||
|
`/repos/${owner}/${repo}/pulls`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating Gitea PR:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository information
|
||||||
|
*/
|
||||||
|
export async function getGiteaRepo(owner: string, repo: string) {
|
||||||
|
try {
|
||||||
|
return await giteaRequest(`/repos/${owner}/${repo}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error getting Gitea repo:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new repository
|
||||||
|
*/
|
||||||
|
export async function createGiteaRepo(name: string, description: string, isPrivate = false) {
|
||||||
|
try {
|
||||||
|
const result = await giteaRequest(
|
||||||
|
`/user/repos`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
private: isPrivate,
|
||||||
|
auto_init: true,
|
||||||
|
default_branch: 'main',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating Gitea repo:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a Pull Request
|
||||||
|
*/
|
||||||
|
export async function mergeGiteaPullRequest(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number,
|
||||||
|
method: 'merge' | 'rebase' | 'squash' = 'merge'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await giteaRequest(
|
||||||
|
`/repos/${owner}/${repo}/pulls/${prNumber}/merge`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
Do: method,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error merging Gitea PR:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/services/mcp/server.ts
Normal file
178
src/services/mcp/server.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* MCP Server - Model Context Protocol
|
||||||
|
* Handles communication with Claude Code agents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
|
import { handleGetNextTask } from './tools/get-next-task'
|
||||||
|
import { handleUpdateTaskStatus } from './tools/update-task-status'
|
||||||
|
import { handleCreateBranch } from './tools/create-branch'
|
||||||
|
import { handleCreatePullRequest } from './tools/create-pull-request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server for agent communication
|
||||||
|
*/
|
||||||
|
export class MCPServer {
|
||||||
|
private server: Server
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.server = new Server(
|
||||||
|
{
|
||||||
|
name: 'aiworker-mcp',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.setupHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers() {
|
||||||
|
// List available tools
|
||||||
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'get_next_task',
|
||||||
|
description: 'Get the next available task from the queue and assign it to the agent',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
agentId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID of the agent requesting a task',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'List of agent capabilities (optional)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['agentId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update_task_status',
|
||||||
|
description: 'Update the status of a task',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
taskId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID of the task',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['in_progress', 'needs_input', 'ready_to_test', 'approved', 'staging', 'production'],
|
||||||
|
description: 'New status for the task',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Additional metadata about the update (optional)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['taskId', 'status'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create_branch',
|
||||||
|
description: 'Create a new branch in Gitea for the task',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
taskId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID of the task',
|
||||||
|
},
|
||||||
|
branchName: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Name for the branch (optional, auto-generated if not provided)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['taskId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create_pull_request',
|
||||||
|
description: 'Create a Pull Request in Gitea with the task changes',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
taskId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID of the task',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Title for the PR',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Description/body of the PR (supports markdown)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['taskId', 'title', 'description'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Handle tool calls
|
||||||
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case 'get_next_task':
|
||||||
|
return await handleGetNextTask(args)
|
||||||
|
|
||||||
|
case 'update_task_status':
|
||||||
|
return await handleUpdateTaskStatus(args)
|
||||||
|
|
||||||
|
case 'create_branch':
|
||||||
|
return await handleCreateBranch(args)
|
||||||
|
|
||||||
|
case 'create_pull_request':
|
||||||
|
return await handleCreatePullRequest(args)
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown tool: ${name}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Error: ${error.message}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the MCP server with stdio transport
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
const transport = new StdioServerTransport()
|
||||||
|
await this.server.connect(transport)
|
||||||
|
console.log('🤖 MCP Server started (stdio transport)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if executed directly
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = new MCPServer()
|
||||||
|
await server.start()
|
||||||
|
}
|
||||||
87
src/services/mcp/tools/create-branch.ts
Normal file
87
src/services/mcp/tools/create-branch.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* MCP Tool: create_branch
|
||||||
|
* Creates a new branch in Gitea for the task
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../db/client'
|
||||||
|
import { tasks, projects } from '../../../db/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { createGiteaBranch } from '../../gitea/client'
|
||||||
|
|
||||||
|
interface CreateBranchArgs {
|
||||||
|
taskId: string
|
||||||
|
branchName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate branch name from task
|
||||||
|
*/
|
||||||
|
function generateBranchName(task: any): string {
|
||||||
|
const shortId = task.id.substring(0, 8)
|
||||||
|
const slugified = task.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 40)
|
||||||
|
|
||||||
|
return `task-${shortId}-${slugified}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateBranch(args: any) {
|
||||||
|
const { taskId, branchName } = args as CreateBranchArgs
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get task and project
|
||||||
|
const task = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (task.length === 0) {
|
||||||
|
throw new Error('Task not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, task[0].projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (project.length === 0) {
|
||||||
|
throw new Error('Project not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or use provided branch name
|
||||||
|
const finalBranchName = branchName || generateBranchName(task[0])
|
||||||
|
|
||||||
|
// Create branch in Gitea
|
||||||
|
const result = await createGiteaBranch({
|
||||||
|
owner: project[0].giteaOwner || 'admin',
|
||||||
|
repo: project[0].giteaRepoName || '',
|
||||||
|
branchName: finalBranchName,
|
||||||
|
fromBranch: project[0].defaultBranch || 'main',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update task with branch name
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ branchName: finalBranchName })
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
branchName: finalBranchName,
|
||||||
|
repoUrl: project[0].giteaRepoUrl,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to create branch: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/services/mcp/tools/create-pull-request.ts
Normal file
80
src/services/mcp/tools/create-pull-request.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* MCP Tool: create_pull_request
|
||||||
|
* Creates a Pull Request in Gitea with the task changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../db/client'
|
||||||
|
import { tasks, projects } from '../../../db/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { createGiteaPullRequest } from '../../gitea/client'
|
||||||
|
|
||||||
|
interface CreatePullRequestArgs {
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreatePullRequest(args: any) {
|
||||||
|
const { taskId, title, description } = args as CreatePullRequestArgs
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get task and project
|
||||||
|
const task = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (task.length === 0) {
|
||||||
|
throw new Error('Task not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task[0].branchName) {
|
||||||
|
throw new Error('Task has no branch. Create a branch first with create_branch')
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, task[0].projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (project.length === 0) {
|
||||||
|
throw new Error('Project not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PR in Gitea
|
||||||
|
const result = await createGiteaPullRequest({
|
||||||
|
owner: project[0].giteaOwner || 'admin',
|
||||||
|
repo: project[0].giteaRepoName || '',
|
||||||
|
title,
|
||||||
|
body: description,
|
||||||
|
head: task[0].branchName,
|
||||||
|
base: project[0].defaultBranch || 'main',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update task with PR info
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({
|
||||||
|
prUrl: result.html_url,
|
||||||
|
state: 'ready_to_test',
|
||||||
|
})
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
prUrl: result.html_url,
|
||||||
|
prNumber: result.number,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to create pull request: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/services/mcp/tools/get-next-task.ts
Normal file
113
src/services/mcp/tools/get-next-task.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* MCP Tool: get_next_task
|
||||||
|
* Gets the next available task from the queue and assigns it to the agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../db/client'
|
||||||
|
import { tasks, projects, agents } from '../../../db/schema'
|
||||||
|
import { eq, and } from 'drizzle-orm'
|
||||||
|
|
||||||
|
interface GetNextTaskArgs {
|
||||||
|
agentId: string
|
||||||
|
capabilities?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetNextTask(args: any) {
|
||||||
|
const { agentId, capabilities } = args as GetNextTaskArgs
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify agent exists
|
||||||
|
const agent = await db
|
||||||
|
.select()
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (agent.length === 0) {
|
||||||
|
throw new Error('Agent not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next available task (state = 'backlog', priority descending)
|
||||||
|
const availableTasks = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.state, 'backlog'))
|
||||||
|
.orderBy(tasks.priority)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (availableTasks.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
task: null,
|
||||||
|
message: 'No tasks available',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = availableTasks[0]
|
||||||
|
|
||||||
|
// Get project details
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, task.projectId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (project.length === 0) {
|
||||||
|
throw new Error('Project not found for task')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign task to agent
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({
|
||||||
|
state: 'in_progress',
|
||||||
|
assignedAgentId: agentId,
|
||||||
|
})
|
||||||
|
.where(eq(tasks.id, task.id))
|
||||||
|
|
||||||
|
// Update agent status
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set({
|
||||||
|
status: 'busy',
|
||||||
|
currentTaskId: task.id,
|
||||||
|
})
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
|
||||||
|
// Return task with project info
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
task: {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
priority: task.priority,
|
||||||
|
state: 'in_progress',
|
||||||
|
project: {
|
||||||
|
id: project[0].id,
|
||||||
|
name: project[0].name,
|
||||||
|
giteaRepoUrl: project[0].giteaRepoUrl,
|
||||||
|
giteaOwner: project[0].giteaOwner,
|
||||||
|
giteaRepoName: project[0].giteaRepoName,
|
||||||
|
defaultBranch: project[0].defaultBranch,
|
||||||
|
dockerImage: project[0].dockerImage,
|
||||||
|
k8sNamespace: project[0].k8sNamespace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to get next task: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/services/mcp/tools/update-task-status.ts
Normal file
67
src/services/mcp/tools/update-task-status.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* MCP Tool: update_task_status
|
||||||
|
* Updates the status of a task
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../db/client'
|
||||||
|
import { tasks, agents } from '../../../db/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
interface UpdateTaskStatusArgs {
|
||||||
|
taskId: string
|
||||||
|
status: 'in_progress' | 'needs_input' | 'ready_to_test' | 'approved' | 'staging' | 'production'
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateTaskStatus(args: any) {
|
||||||
|
const { taskId, status, metadata } = args as UpdateTaskStatusArgs
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify task exists
|
||||||
|
const task = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (task.length === 0) {
|
||||||
|
throw new Error('Task not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task status
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ state: status })
|
||||||
|
.where(eq(tasks.id, taskId))
|
||||||
|
|
||||||
|
// If task is completed or ready_to_test, update agent status to idle
|
||||||
|
if (status === 'ready_to_test' || status === 'approved') {
|
||||||
|
if (task[0].assignedAgentId) {
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set({
|
||||||
|
status: 'idle',
|
||||||
|
currentTaskId: null,
|
||||||
|
tasksCompleted: db.raw('tasks_completed + 1') as any,
|
||||||
|
})
|
||||||
|
.where(eq(agents.id, task[0].assignedAgentId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
taskId,
|
||||||
|
newStatus: status,
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to update task status: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user