Add bd exec bridge and mutation API routes with tests
This commit is contained in:
parent
0e3815ac3c
commit
2c80265258
15 changed files with 904 additions and 5 deletions
51
src/app/api/beads/_shared.ts
Normal file
51
src/app/api/beads/_shared.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { executeMutation, MutationValidationError, validateMutationPayload, type MutationOperation } from '../../../lib/mutations';
|
||||
|
||||
function badRequest(message: string, operation: MutationOperation) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
operation,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMutationRequest(request: Request, operation: MutationOperation): Promise<Response> {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return badRequest('Invalid JSON body.', operation);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = validateMutationPayload(operation, body);
|
||||
const result = await executeMutation(operation, payload);
|
||||
|
||||
const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400;
|
||||
return NextResponse.json(result, { status });
|
||||
} catch (error) {
|
||||
if (error instanceof MutationValidationError) {
|
||||
return badRequest(error.message, operation);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
operation,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Unknown mutation error.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/beads/close/route.ts
Normal file
5
src/app/api/beads/close/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'close');
|
||||
}
|
||||
5
src/app/api/beads/comment/route.ts
Normal file
5
src/app/api/beads/comment/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'comment');
|
||||
}
|
||||
5
src/app/api/beads/create/route.ts
Normal file
5
src/app/api/beads/create/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'create');
|
||||
}
|
||||
5
src/app/api/beads/reopen/route.ts
Normal file
5
src/app/api/beads/reopen/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'reopen');
|
||||
}
|
||||
5
src/app/api/beads/update/route.ts
Normal file
5
src/app/api/beads/update/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'update');
|
||||
}
|
||||
78
src/lib/bd-path.ts
Normal file
78
src/lib/bd-path.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ResolveBdExecutableOptions {
|
||||
explicitPath?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface BdExecutableResolution {
|
||||
executable: string;
|
||||
source: 'config' | 'path';
|
||||
}
|
||||
|
||||
export class BdExecutableNotFoundError extends Error {
|
||||
readonly code = 'BD_NOT_FOUND';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BdExecutableNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const value = env.Path ?? env.PATH ?? '';
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function executableCandidates(directory: string): string[] {
|
||||
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
|
||||
}
|
||||
|
||||
function buildNotFoundMessage(explicitPath?: string | null): string {
|
||||
const lines = [
|
||||
'bd.exe was not found.',
|
||||
'Install it with: npm install -g @beads/bd',
|
||||
'Or configure an explicit executable path in request payload/config.',
|
||||
];
|
||||
|
||||
if (explicitPath) {
|
||||
lines.push(`Configured path was not found: ${explicitPath}`);
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
|
||||
if (options.explicitPath && options.explicitPath.trim()) {
|
||||
const explicit = path.resolve(options.explicitPath);
|
||||
if (await fileExists(explicit)) {
|
||||
return { executable: explicit, source: 'config' };
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
|
||||
}
|
||||
|
||||
for (const dir of splitEnvPath(options.env)) {
|
||||
for (const candidate of executableCandidates(dir)) {
|
||||
if (await fileExists(candidate)) {
|
||||
return { executable: candidate, source: 'path' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage());
|
||||
}
|
||||
163
src/lib/bridge.ts
Normal file
163
src/lib/bridge.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execFileAsync = promisify(nodeExecFile);
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
export interface RunBdCommandOptions {
|
||||
projectRoot: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
explicitBdPath?: string | null;
|
||||
}
|
||||
|
||||
export interface RunBdCommandResult {
|
||||
success: boolean;
|
||||
classification: BdFailureClassification | null;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ExecFileOptions = {
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ExecFileLike = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
execFile: ExecFileLike;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
if (error.code === 'ENOENT') {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
|
||||
return 'bad_args';
|
||||
}
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const args = [...options.args];
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
execFile: injectedDeps?.execFile ?? execFileAsync,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd.exe';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
explicitPath: options.explicitBdPath,
|
||||
env: deps.env,
|
||||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const { stdout, stderr } = await deps.execFile(command, args, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
env: deps.env,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(stdout),
|
||||
stderr: normalizeOutput(stderr),
|
||||
code: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
if (rawError instanceof BdExecutableNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
classification: 'not_found',
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: rawError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification: classifyFailure(error),
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
295
src/lib/mutations.ts
Normal file
295
src/lib/mutations.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
||||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: MutationStatus;
|
||||
priority?: number;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface CloseMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ReopenMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommentMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MutationPayload =
|
||||
| CreateMutationPayload
|
||||
| UpdateMutationPayload
|
||||
| CloseMutationPayload
|
||||
| ReopenMutationPayload
|
||||
| CommentMutationPayload;
|
||||
|
||||
export interface MutationErrorShape {
|
||||
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MutationResponse {
|
||||
ok: boolean;
|
||||
operation: MutationOperation;
|
||||
command: RunBdCommandResult;
|
||||
error?: MutationErrorShape;
|
||||
}
|
||||
|
||||
export class MutationValidationError extends Error {
|
||||
readonly code = 'MUTATION_VALIDATION_ERROR';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MutationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new MutationValidationError('Expected a string value.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asOptionalPriority(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
||||
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalLabels(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new MutationValidationError('"labels" must be an array of strings.');
|
||||
}
|
||||
const labels = value.map((label) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
||||
}
|
||||
return label.trim();
|
||||
});
|
||||
|
||||
return labels.length ? labels : undefined;
|
||||
}
|
||||
|
||||
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const status = asNonEmptyString(value, 'status');
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
||||
throw new MutationValidationError('"status" is invalid.');
|
||||
}
|
||||
return status as MutationStatus;
|
||||
}
|
||||
|
||||
function parseBasePayload(raw: unknown): MutationBasePayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new MutationValidationError('Payload must be a JSON object.');
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
||||
const base = parseBasePayload(payload);
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
if (operation === 'create') {
|
||||
return {
|
||||
...base,
|
||||
title: asNonEmptyString(data.title, 'title'),
|
||||
description: asOptionalString(data.description),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const mapped: UpdateMutationPayload = {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
title: asOptionalString(data.title),
|
||||
description: asOptionalString(data.description),
|
||||
status: asOptionalStatus(data.status),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
|
||||
if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) {
|
||||
throw new MutationValidationError('At least one update field is required.');
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
text: asNonEmptyString(data.text, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
||||
if (value) {
|
||||
args.push(flag, value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('-l', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
||||
if (operation === 'create') {
|
||||
const data = payload as CreateMutationPayload;
|
||||
const args = ['create', data.title];
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const data = payload as UpdateMutationPayload;
|
||||
const args = ['update', data.id];
|
||||
pushOptionalArg(args, '--title', data.title);
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
pushOptionalArg(args, '-s', data.status);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
const data = payload as CloseMutationPayload;
|
||||
const args = ['close', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
const data = payload as ReopenMutationPayload;
|
||||
const args = ['reopen', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
const data = payload as CommentMutationPayload;
|
||||
return ['comments', 'add', data.id, data.text, '--json'];
|
||||
}
|
||||
|
||||
interface ExecuteMutationDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
export async function executeMutation(
|
||||
operation: MutationOperation,
|
||||
payload: MutationPayload,
|
||||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
explicitBdPath: payload.bdPath,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return {
|
||||
ok: false,
|
||||
operation,
|
||||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.error ?? (command.stderr || 'Mutation command failed.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
command,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue