Implement Backend API, MCP Server, and Gitea integration
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:
Hector Ros
2026-01-20 00:43:46 +01:00
parent ebf5d74933
commit 5672127593
12 changed files with 1631 additions and 3 deletions

303
src/api/routes/agents.ts Normal file
View 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
View 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
View 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
View 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 })
}
}

View File

@@ -25,7 +25,22 @@ export async function runMigrations() {
console.log('✅ Migrations completed successfully')
await connection.end()
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)
await connection.end()
throw error

View File

@@ -5,6 +5,7 @@
import { runMigrations } from './db/migrate'
import { testConnection } from './db/client'
import { handleProjectRoutes, handleTaskRoutes, handleAgentRoutes } from './api/routes'
console.log('🚀 Starting AiWorker Backend...')
console.log(`Bun version: ${Bun.version}`)
@@ -41,11 +42,43 @@ const server = Bun.serve({
}
// 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/')) {
return Response.json({
message: 'AiWorker API',
path: url.pathname,
method: req.method,
version: '1.0.0',
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',
],
})
}

View 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
View 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()
}

View 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}`)
}
}

View 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}`)
}
}

View 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}`)
}
}

View 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}`)
}
}