Add bd exec bridge and mutation API routes with tests

This commit is contained in:
zenchantlive 2026-02-11 19:46:02 -08:00
parent 0e3815ac3c
commit 2c80265258
15 changed files with 904 additions and 5 deletions

View 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 },
);
}
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'close');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'comment');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'create');
}

View file

@ -0,0 +1,5 @@
import { handleMutationRequest } from '../_shared';
export async function POST(request: Request): Promise<Response> {
return handleMutationRequest(request, 'reopen');
}

View 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
View 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
View 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
View 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,
};
}