feat(bridge): M2 Bridge MVP with HTTP endpoints
This commit is contained in:
parent
0042fb3df0
commit
dbf290e49a
5 changed files with 2422 additions and 39 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@kanban/bridge",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -19,15 +19,22 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rm -rf dist"
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kanban/contracts": "workspace:*",
|
||||
"express": "^4.21.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "^5.5.0",
|
||||
"tsx": "^4.16.0"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
281
packages/bridge/src/__tests__/routes.test.ts
Normal file
281
packages/bridge/src/__tests__/routes.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// ─────────────────────────────────────────────────────────────
|
||||
// @kanban/bridge — Route integration tests (vitest)
|
||||
// Tests the Express app with mock Hermes CLI responses
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp, type BridgeConfig, VERSION } from '../index.js';
|
||||
import type { Board, TaskSummary, TaskDetail } from '@kanban/contracts';
|
||||
|
||||
// ─── Mock the HermesKanbanClient ─────────────────────────────
|
||||
|
||||
vi.mock('../hermes-client.js', () => {
|
||||
const mockBoards: Board[] = [
|
||||
{ slug: 'default', name: 'Default Board', current: true },
|
||||
{ slug: 'backend', name: 'Backend Board' },
|
||||
];
|
||||
|
||||
const mockTasks: TaskSummary[] = [
|
||||
{
|
||||
id: 'T-001',
|
||||
title: 'Implement auth',
|
||||
status: 'todo',
|
||||
assignee: null,
|
||||
priority: 1,
|
||||
createdAt: '2026-07-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'T-002',
|
||||
title: 'Write tests',
|
||||
status: 'running',
|
||||
assignee: 'hermes',
|
||||
priority: 2,
|
||||
createdAt: '2026-07-10T01:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTaskDetail: TaskDetail = {
|
||||
id: 'T-001',
|
||||
title: 'Implement auth',
|
||||
status: 'todo',
|
||||
body: 'Implement Telegram initData validation',
|
||||
assignee: null,
|
||||
priority: 1,
|
||||
createdAt: '2026-07-10T00:00:00Z',
|
||||
comments: [
|
||||
{
|
||||
id: 'C-001',
|
||||
author: 'hermes',
|
||||
body: 'Starting implementation',
|
||||
createdAt: '2026-07-10T00:30:00Z',
|
||||
},
|
||||
],
|
||||
runs: [],
|
||||
events: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
const mockCreatedTask: TaskDetail = {
|
||||
...mockTaskDetail,
|
||||
id: 'T-003',
|
||||
title: 'New task from API',
|
||||
status: 'triage',
|
||||
};
|
||||
|
||||
return {
|
||||
HermesKanbanClient: vi.fn().mockImplementation(() => ({
|
||||
listBoards: vi.fn().mockResolvedValue(mockBoards),
|
||||
listTasks: vi.fn().mockResolvedValue(mockTasks),
|
||||
getTask: vi.fn().mockImplementation((_board: string, taskId: string) => {
|
||||
if (taskId === 'T-001') return Promise.resolve(mockTaskDetail);
|
||||
return Promise.reject(new Error(`No such task: ${taskId}`));
|
||||
}),
|
||||
createTask: vi.fn().mockResolvedValue(mockCreatedTask),
|
||||
addComment: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Test config ─────────────────────────────────────────────
|
||||
|
||||
const devConfig: BridgeConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 0, // random port for testing
|
||||
bridgeToken: 'test-token',
|
||||
hermesHome: '/tmp/hermes-test',
|
||||
hermesBin: 'echo', // won't actually be called due to mock
|
||||
commandTimeoutSeconds: 5,
|
||||
allowedBoards: ['default', 'backend'],
|
||||
logTailMaxLines: 100,
|
||||
authMode: 'dev',
|
||||
};
|
||||
|
||||
const tokenConfig: BridgeConfig = {
|
||||
...devConfig,
|
||||
authMode: 'token',
|
||||
};
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('Bridge MVP Routes', () => {
|
||||
// ── Health ─────────────────────────────────────────────────
|
||||
describe('GET /bridge/v1/health', () => {
|
||||
it('returns health status', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app).get('/bridge/v1/health').expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data.status).toBe('ok');
|
||||
expect(res.body.data.version).toBe(VERSION);
|
||||
expect(res.body.data.bridge.configured).toBe(true);
|
||||
expect(res.body.data.bridge.reachable).toBe(true);
|
||||
expect(res.body.requestId).toBeDefined();
|
||||
});
|
||||
|
||||
it('is accessible without auth', async () => {
|
||||
const app = createApp(tokenConfig);
|
||||
// No token header — should still succeed
|
||||
const res = await request(app).get('/bridge/v1/health').expect(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boards ─────────────────────────────────────────────────
|
||||
describe('GET /bridge/v1/boards', () => {
|
||||
it('returns board list in dev mode', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app).get('/bridge/v1/boards').expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
expect(res.body.data[0].slug).toBe('default');
|
||||
expect(res.body.data[1].slug).toBe('backend');
|
||||
});
|
||||
|
||||
it('requires auth in token mode', async () => {
|
||||
const app = createApp(tokenConfig);
|
||||
await request(app).get('/bridge/v1/boards').expect(401);
|
||||
});
|
||||
|
||||
it('accepts valid token', async () => {
|
||||
const app = createApp(tokenConfig);
|
||||
const res = await request(app)
|
||||
.get('/bridge/v1/boards')
|
||||
.set('X-Bridge-Token', 'test-token')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tasks ──────────────────────────────────────────────────
|
||||
describe('GET /bridge/v1/boards/:board/tasks', () => {
|
||||
it('returns task list', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.get('/bridge/v1/boards/default/tasks')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
expect(res.body.data[0].id).toBe('T-001');
|
||||
expect(res.body.data[1].status).toBe('running');
|
||||
});
|
||||
|
||||
it('forwards query params', async () => {
|
||||
const app = createApp(devConfig);
|
||||
await request(app)
|
||||
.get('/bridge/v1/boards/default/tasks?status=todo&limit=10')
|
||||
.expect(200);
|
||||
|
||||
// The mock was called; verify the shape is ok
|
||||
});
|
||||
|
||||
it('rejects disallowed boards', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.get('/bridge/v1/boards/secret/tasks')
|
||||
.expect(403);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error.code).toBe('FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /bridge/v1/boards/:board/tasks/:taskId', () => {
|
||||
it('returns task detail', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.get('/bridge/v1/boards/default/tasks/T-001')
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data.id).toBe('T-001');
|
||||
expect(res.body.data.body).toBeDefined();
|
||||
expect(res.body.data.comments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 404 for missing task', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.get('/bridge/v1/boards/default/tasks/T-999')
|
||||
.expect(404);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create task ────────────────────────────────────────────
|
||||
describe('POST /bridge/v1/boards/:board/tasks', () => {
|
||||
it('creates a task', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks')
|
||||
.send({ title: 'New task from API' })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data.id).toBe('T-003');
|
||||
});
|
||||
|
||||
it('rejects missing title', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks')
|
||||
.send({ body: 'no title' })
|
||||
.expect(422);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('rejects empty title', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks')
|
||||
.send({ title: ' ' })
|
||||
.expect(422);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add comment ────────────────────────────────────────────
|
||||
describe('POST /bridge/v1/boards/:board/tasks/:taskId/comments', () => {
|
||||
it('adds a comment', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||
.send({ body: 'Looks good!' })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.data.added).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing body', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||
.send({})
|
||||
.expect(422);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('rejects empty body', async () => {
|
||||
const app = createApp(devConfig);
|
||||
const res = await request(app)
|
||||
.post('/bridge/v1/boards/default/tasks/T-001/comments')
|
||||
.send({ body: '' })
|
||||
.expect(422);
|
||||
|
||||
expect(res.body.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,24 @@
|
|||
// ─────────────────────────────────────────────────────────────
|
||||
// @kanban/bridge — entry point
|
||||
// @kanban/bridge — Express HTTP server (M2 Bridge MVP)
|
||||
// Self-hosted Hermes Kanban Bridge (Node.js)
|
||||
// ADR §5: "runs on trusted host near Hermes"
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
import type { HealthData } from '@kanban/contracts';
|
||||
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
HealthData,
|
||||
Board,
|
||||
TaskSummary,
|
||||
TaskDetail,
|
||||
ApiResponse,
|
||||
ErrorCode,
|
||||
} from '@kanban/contracts';
|
||||
import {
|
||||
HermesKanbanClient,
|
||||
type HermesClientConfig,
|
||||
} from './hermes-client.js';
|
||||
import { validateInitData, createDevUser, type DevUser } from './auth.js';
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -17,30 +31,145 @@ export interface BridgeConfig {
|
|||
commandTimeoutSeconds: number;
|
||||
allowedBoards: string[];
|
||||
logTailMaxLines: number;
|
||||
/** 'token' = validate X-Bridge-Token; 'dev' = bypass auth */
|
||||
authMode: 'token' | 'dev';
|
||||
/** Telegram bot token for initData validation (optional) */
|
||||
botToken?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(): BridgeConfig {
|
||||
return {
|
||||
host: process.env.BRIDGE_HOST ?? '127.0.0.1',
|
||||
port: parseInt(process.env.BRIDGE_PORT ?? '8787', 10),
|
||||
bridgeToken: requireEnv('BRIDGE_TOKEN'),
|
||||
bridgeToken: process.env.BRIDGE_TOKEN ?? '',
|
||||
hermesHome: process.env.HERMES_HOME ?? '',
|
||||
hermesBin: process.env.HERMES_BIN ?? 'hermes',
|
||||
commandTimeoutSeconds: parseInt(process.env.COMMAND_TIMEOUT_SECONDS ?? '30', 10),
|
||||
commandTimeoutSeconds: parseInt(
|
||||
process.env.COMMAND_TIMEOUT_SECONDS ?? '30',
|
||||
10
|
||||
),
|
||||
allowedBoards: (process.env.BRIDGE_ALLOWED_BOARDS ?? 'default')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
logTailMaxLines: parseInt(process.env.LOG_TAIL_MAX_LINES ?? '500', 10),
|
||||
authMode: (process.env.AUTH_MODE as 'token' | 'dev') ?? 'token',
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function ok<T>(data: T, requestId: string): ApiResponse<T> {
|
||||
return { ok: true, data, requestId };
|
||||
}
|
||||
return value;
|
||||
|
||||
function err(
|
||||
code: ErrorCode,
|
||||
message: string,
|
||||
requestId: string,
|
||||
details?: unknown
|
||||
): ApiResponse<never> {
|
||||
return { ok: false, error: { code, message, details }, requestId } as ApiResponse<never>;
|
||||
}
|
||||
|
||||
// ─── Extend Express Request ──────────────────────────────────
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
actor?: DevUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Request ID middleware ────────────────────────────────────
|
||||
|
||||
function requestIdMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||
req.requestId =
|
||||
(req.headers['x-request-id'] as string) ?? randomUUID();
|
||||
next();
|
||||
}
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────
|
||||
|
||||
function createAuthMiddleware(config: BridgeConfig) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// Health endpoint is always public
|
||||
if (req.path === '/bridge/v1/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (config.authMode === 'dev') {
|
||||
// Dev mode: inject a fake user
|
||||
req.actor = createDevUser();
|
||||
return next();
|
||||
}
|
||||
|
||||
// Token mode: validate X-Bridge-Token
|
||||
const token = req.headers['x-bridge-token'] as string | undefined;
|
||||
if (!token || token !== config.bridgeToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json(err('UNAUTHORIZED', 'Missing or invalid bridge token', req.requestId));
|
||||
}
|
||||
|
||||
// Optionally validate Telegram initData from X-Telegram-Init-Data header
|
||||
const initData = req.headers['x-telegram-init-data'] as string | undefined;
|
||||
if (initData && config.botToken) {
|
||||
const result = validateInitData(initData, { botToken: config.botToken });
|
||||
if (!result.valid) {
|
||||
return res
|
||||
.status(401)
|
||||
.json(err('UNAUTHORIZED', `Invalid initData: ${result.error}`, req.requestId));
|
||||
}
|
||||
req.actor = {
|
||||
telegramId: result.user?.id ?? 0,
|
||||
firstName: result.user?.first_name ?? 'Unknown',
|
||||
role: 'operator',
|
||||
};
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Board allowlist middleware ───────────────────────────────
|
||||
|
||||
function boardAllowlistMiddleware(config: BridgeConfig) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const board = req.params.board;
|
||||
if (board && config.allowedBoards.length > 0 && !config.allowedBoards.includes('*')) {
|
||||
if (!config.allowedBoards.includes(board)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json(
|
||||
err(
|
||||
'FORBIDDEN',
|
||||
`Board '${board}' is not in the allowed list`,
|
||||
req.requestId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Error handler ───────────────────────────────────────────
|
||||
|
||||
function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[bridge] Unhandled error on ${req.method} ${req.path}:`, message);
|
||||
res
|
||||
.status(500)
|
||||
.json(
|
||||
(function () {
|
||||
const code: ErrorCode = 'INTERNAL_ERROR';
|
||||
return { ok: false as const, error: { code, message }, requestId: req.requestId };
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Health check ────────────────────────────────────────────
|
||||
|
|
@ -56,39 +185,180 @@ export function getHealthData(version: string): HealthData {
|
|||
};
|
||||
}
|
||||
|
||||
// ─── Server skeleton ─────────────────────────────────────────
|
||||
// ─── Build Express app ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Placeholder for the actual HTTP server (Express / Fastify / raw http).
|
||||
* M2 will implement real endpoints.
|
||||
*
|
||||
* Route map (ADR §10):
|
||||
* GET /bridge/v1/health
|
||||
* GET /bridge/v1/boards
|
||||
* GET /bridge/v1/boards/:board/tasks
|
||||
* GET /bridge/v1/boards/:board/tasks/:taskId
|
||||
* GET /bridge/v1/boards/:board/tasks/:taskId/log
|
||||
* POST /bridge/v1/boards/:board/tasks
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/comment
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/assign
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/promote
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/block
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/unblock
|
||||
* POST /bridge/v1/boards/:board/tasks/:taskId/archive
|
||||
* POST /bridge/v1/boards/:board/dispatch
|
||||
*/
|
||||
export async function startBridge(config: BridgeConfig): Promise<void> {
|
||||
// M2: implement HTTP server with auth middleware
|
||||
console.log(
|
||||
`[bridge] Skeleton loaded. Would listen on ${config.host}:${config.port}`
|
||||
export function createApp(config: BridgeConfig): express.Express {
|
||||
const app = express();
|
||||
|
||||
// Parse JSON bodies
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Request ID
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// Auth
|
||||
app.use(createAuthMiddleware(config));
|
||||
|
||||
// ── Health ───────────────────────────────────────────────
|
||||
app.get('/bridge/v1/health', (_req: Request, res: Response) => {
|
||||
res.json(ok(getHealthData(VERSION), randomUUID()));
|
||||
});
|
||||
|
||||
// ── Board routes ─────────────────────────────────────────
|
||||
const boardRouter = express.Router({ mergeParams: true });
|
||||
|
||||
// Board allowlist check on all /boards/:board routes
|
||||
boardRouter.use(boardAllowlistMiddleware(config));
|
||||
|
||||
// GET /bridge/v1/boards
|
||||
app.get('/bridge/v1/boards', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const client = getClient(config);
|
||||
const boards = await client.listBoards();
|
||||
res.json(ok<Board[]>(boards, req.requestId));
|
||||
} catch (e) {
|
||||
handleCliError(e, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /bridge/v1/boards/:board/tasks
|
||||
boardRouter.get('/tasks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const client = getClient(config);
|
||||
const { status, assignee, limit } = req.query;
|
||||
const tasks = await client.listTasks(req.params.board, {
|
||||
status: status as string | undefined,
|
||||
assignee: assignee as string | undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
});
|
||||
res.json(ok<TaskSummary[]>(tasks, req.requestId));
|
||||
} catch (e) {
|
||||
handleCliError(e, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /bridge/v1/boards/:board/tasks/:taskId
|
||||
boardRouter.get('/tasks/:taskId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const client = getClient(config);
|
||||
const task = await client.getTask(req.params.board, req.params.taskId);
|
||||
res.json(ok<TaskDetail>(task, req.requestId));
|
||||
} catch (e) {
|
||||
handleCliError(e, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /bridge/v1/boards/:board/tasks
|
||||
boardRouter.post('/tasks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, body: taskBody, assignee, priority } = req.body ?? {};
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
return res
|
||||
.status(422)
|
||||
.json(err('VALIDATION_ERROR', 'title is required', req.requestId));
|
||||
}
|
||||
const client = getClient(config);
|
||||
const task = await client.createTask(req.params.board, {
|
||||
title: title.trim(),
|
||||
body: taskBody,
|
||||
assignee,
|
||||
priority,
|
||||
});
|
||||
res.status(201).json(ok<TaskDetail>(task, req.requestId));
|
||||
} catch (e) {
|
||||
handleCliError(e, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /bridge/v1/boards/:board/tasks/:taskId/comments
|
||||
boardRouter.post('/tasks/:taskId/comments', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { body: commentBody } = req.body ?? {};
|
||||
if (
|
||||
!commentBody ||
|
||||
typeof commentBody !== 'string' ||
|
||||
commentBody.trim().length === 0
|
||||
) {
|
||||
return res
|
||||
.status(422)
|
||||
.json(err('VALIDATION_ERROR', 'body is required', req.requestId));
|
||||
}
|
||||
const client = getClient(config);
|
||||
await client.addComment(
|
||||
req.params.board,
|
||||
req.params.taskId,
|
||||
commentBody.trim()
|
||||
);
|
||||
res.json(ok({ added: true }, req.requestId));
|
||||
} catch (e) {
|
||||
handleCliError(e, req, res);
|
||||
}
|
||||
});
|
||||
|
||||
// Mount board router
|
||||
app.use('/bridge/v1/boards/:board', boardRouter);
|
||||
|
||||
// Error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── CLI error mapping ───────────────────────────────────────
|
||||
|
||||
function handleCliError(e: unknown, req: Request, res: Response) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown bridge error';
|
||||
if (message.includes('not found') || message.includes('No such task')) {
|
||||
res.status(404).json(err('NOT_FOUND', message, req.requestId));
|
||||
} else if (message.includes('timeout') || message.includes('TIMEOUT')) {
|
||||
res.status(504).json(err('TIMEOUT', message, req.requestId));
|
||||
} else {
|
||||
res.status(502).json(err('BRIDGE_ERROR', message, req.requestId));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton client ────────────────────────────────────────
|
||||
|
||||
let _client: HermesKanbanClient | null = null;
|
||||
|
||||
function getClient(config: BridgeConfig): HermesKanbanClient {
|
||||
if (!_client) {
|
||||
const clientConfig: HermesClientConfig = {
|
||||
bin: config.hermesBin,
|
||||
home: config.hermesHome,
|
||||
defaultBoard: config.allowedBoards[0] ?? 'default',
|
||||
timeoutMs: config.commandTimeoutSeconds * 1000,
|
||||
};
|
||||
_client = new HermesKanbanClient(clientConfig);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
// ─── Server lifecycle ────────────────────────────────────────
|
||||
|
||||
export async function startBridge(config: BridgeConfig): Promise<ReturnType<typeof express.application.listen>> {
|
||||
const app = createApp(config);
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(config.port, config.host, () => {
|
||||
console.log(
|
||||
`[bridge] v${VERSION} listening on http://${config.host}:${config.port}`
|
||||
);
|
||||
console.log(`[bridge] Auth mode: ${config.authMode}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────
|
||||
|
||||
export const VERSION = '0.1.0';
|
||||
export const VERSION = '0.2.0';
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// Only start server when run directly
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
process.argv[1] &&
|
||||
import.meta.url === `file://${process.argv[1]}`
|
||||
) {
|
||||
const config = loadConfig();
|
||||
console.log(`[bridge] v${VERSION} starting...`);
|
||||
await startBridge(config);
|
||||
|
|
|
|||
9
packages/bridge/vitest.config.ts
Normal file
9
packages/bridge/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
1816
pnpm-lock.yaml
generated
1816
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue