bugger-mcp
Version:
MCP Server for managing bugs, feature requests, and improvements
680 lines (602 loc) • 24.2 kB
text/typescript
// Improvement management operations
import { formatImprovements, formatImprovementsWithContext, formatBulkUpdateResults, formatTokenUsage } from './format.js';
import { TokenUsageTracker } from './token-usage-tracker.js';
import sqlite3 from 'sqlite3';
import * as fs from 'fs';
import * as path from 'path';
// Improvement interface
export interface Improvement {
id: string;
status: 'Proposed' | 'In Discussion' | 'Approved' | 'In Development' | 'Completed (Awaiting Human Verification)' | 'Completed' | 'Rejected';
priority: 'Low' | 'Medium' | 'High';
dateRequested: string;
dateCompleted?: string;
category: string;
requestedBy?: string;
title: string;
description: string;
currentState: string;
desiredState: string;
acceptanceCriteria: string[];
implementationDetails?: string;
potentialImplementation?: string;
filesLikelyInvolved?: string[];
dependencies?: string[];
effortEstimate?: 'Small' | 'Medium' | 'Large';
benefits?: string[];
}
export class ImprovementManager {
private tokenTracker: TokenUsageTracker;
constructor() {
this.tokenTracker = TokenUsageTracker.getInstance();
}
/**
* Map simple TODO statuses to improvement statuses
*/
private mapTodoStatus(status: string): Improvement['status'] | null {
const s = String(status || '').toLowerCase();
switch (s) {
case 'todo':
return 'Proposed';
case 'doing':
return 'In Development';
case 'blocked':
return 'In Discussion';
case 'done':
return 'Completed';
default:
return null;
}
}
/**
* Quick-create a TODO (lightweight improvement)
*/
async createTodo(db: sqlite3.Database, args: any): Promise<string> {
this.tokenTracker.startOperation('create_todo');
const title: string = args.title as string;
const description: string = args.description || '';
if (!title) {
throw new Error('title is required for TODOs');
}
const improvement: Improvement = {
id: args.improvementId || await this.generateNextIdWithRetry(db, 'improvement'),
status: (this.mapTodoStatus(args.status) || 'Proposed') as Improvement['status'],
priority: args.priority || 'Medium',
dateRequested: new Date().toISOString().split('T')[0],
category: args.category || 'TODO',
requestedBy: args.requestedBy,
title,
description,
// Derive lightweight fields to satisfy schema without burdening user
currentState: description || 'n/a',
desiredState: title || 'n/a',
acceptanceCriteria: [],
filesLikelyInvolved: args.filesLikelyInvolved || [],
dependencies: [],
benefits: []
};
return new Promise((resolve, reject) => {
const insertQuery = `
INSERT INTO improvements (
id, status, priority, dateRequested, category, requestedBy, title, description,
currentState, desiredState, acceptanceCriteria, implementationDetails,
potentialImplementation, filesLikelyInvolved, dependencies, effortEstimate, benefits
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(insertQuery, [
improvement.id,
improvement.status,
improvement.priority,
improvement.dateRequested,
improvement.category,
improvement.requestedBy,
improvement.title,
improvement.description,
improvement.currentState,
improvement.desiredState,
JSON.stringify(improvement.acceptanceCriteria),
improvement.implementationDetails,
improvement.potentialImplementation,
JSON.stringify(improvement.filesLikelyInvolved),
JSON.stringify(improvement.dependencies),
improvement.effortEstimate,
JSON.stringify(improvement.benefits)
], async (err) => {
if (err) {
reject(new Error(`Failed to create TODO: ${err.message}`));
} else {
const inputText = JSON.stringify(args);
const outputText = `Improvement ${improvement.id} created successfully.`;
const tokenUsage = this.tokenTracker.recordUsage(inputText, outputText, 'create_todo');
resolve(`${outputText}${formatTokenUsage(tokenUsage)}`);
}
});
});
}
/**
* Create a new improvement
*/
async createImprovement(db: sqlite3.Database, args: any): Promise<string> {
this.tokenTracker.startOperation('create_improvement');
// Validate required fields and support common aliases
const title: string | undefined = args.title;
const description: string | undefined = args.description;
// Support alias: currentBehavior (sometimes used inconsistently) → currentState
const currentStateArg: string | undefined = args.currentState ?? args.currentBehavior;
// Support alias: expectedBehavior → desiredState
const desiredStateArg: string | undefined = args.desiredState ?? args.expectedBehavior;
if (!title) {
throw new Error('title is required for improvements');
}
if (!description) {
throw new Error('description is required for improvements');
}
if (!currentStateArg) {
throw new Error('currentState is required for improvements (alias: currentBehavior)');
}
if (!desiredStateArg) {
throw new Error('desiredState is required for improvements (alias: expectedBehavior)');
}
const improvement: Improvement = {
id: args.improvementId || await this.generateNextIdWithRetry(db, 'improvement'),
status: 'Proposed',
priority: args.priority || 'Medium',
dateRequested: new Date().toISOString().split('T')[0],
category: args.category || 'General',
requestedBy: args.requestedBy,
title,
description,
currentState: currentStateArg,
desiredState: desiredStateArg,
acceptanceCriteria: args.acceptanceCriteria || [],
implementationDetails: args.implementationDetails,
potentialImplementation: args.potentialImplementation,
filesLikelyInvolved: args.filesLikelyInvolved || [],
dependencies: args.dependencies || [],
effortEstimate: args.effortEstimate,
benefits: args.benefits || []
};
return new Promise((resolve, reject) => {
const insertQuery = `
INSERT INTO improvements (
id, status, priority, dateRequested, category, requestedBy, title, description,
currentState, desiredState, acceptanceCriteria, implementationDetails,
potentialImplementation, filesLikelyInvolved, dependencies, effortEstimate, benefits
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(insertQuery, [
improvement.id,
improvement.status,
improvement.priority,
improvement.dateRequested,
improvement.category,
improvement.requestedBy,
improvement.title,
improvement.description,
improvement.currentState,
improvement.desiredState,
JSON.stringify(improvement.acceptanceCriteria),
improvement.implementationDetails,
improvement.potentialImplementation,
JSON.stringify(improvement.filesLikelyInvolved),
JSON.stringify(improvement.dependencies),
improvement.effortEstimate,
JSON.stringify(improvement.benefits)
], async (err) => {
if (err) {
reject(new Error(`Failed to create improvement: ${err.message}`));
} else {
// Record token usage
const inputText = JSON.stringify(args);
const outputText = `Improvement ${improvement.id} created successfully.`;
const tokenUsage = this.tokenTracker.recordUsage(inputText, outputText, 'create_improvement');
resolve(`${outputText}${formatTokenUsage(tokenUsage)}`);
}
});
});
}
/**
* List improvements with filtering options
*/
async listImprovements(db: sqlite3.Database, args: any): Promise<string> {
this.tokenTracker.startOperation('list_improvements');
let query = 'SELECT * FROM improvements';
const params: any[] = [];
const conditions: string[] = [];
// Support TODO-centric views
const view: string | undefined = args.view;
if (view) {
const todayStr = new Date().toISOString().split('T')[0];
switch (String(view)) {
case 'todos': {
// Open-like states (mapped from TODO semantics)
const openStatuses = ['Proposed', 'In Development', 'In Discussion'];
conditions.push(`status IN (${openStatuses.map(() => '?').join(', ')})`);
params.push(...openStatuses);
break;
}
case 'blocked': {
conditions.push('status = ?');
params.push('In Discussion');
break;
}
case 'done': {
conditions.push('status = ?');
params.push('Completed');
break;
}
case 'today': {
conditions.push('dateRequested = ?');
params.push(todayStr);
break;
}
}
}
// Allow status filter as string or array; map TODO labels if provided
if (args.status) {
const toImprovementStatus = (s: string) => this.mapTodoStatus(s) || s;
if (Array.isArray(args.status)) {
const statuses = args.status.map((s: string) => toImprovementStatus(s));
conditions.push(`status IN (${statuses.map(() => '?').join(', ')})`);
params.push(...statuses);
} else {
const status = toImprovementStatus(args.status);
conditions.push('status = ?');
params.push(status);
}
}
if (args.priority) {
conditions.push('priority = ?');
params.push(args.priority);
}
if (args.category) {
conditions.push('category = ?');
params.push(args.category);
}
if (args.requestedBy) {
conditions.push('requestedBy = ?');
params.push(args.requestedBy);
}
if (args.effortEstimate) {
conditions.push('effortEstimate = ?');
params.push(args.effortEstimate);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY dateRequested DESC';
return new Promise((resolve, reject) => {
db.all(query, params, async (err, rows: any[]) => {
if (err) {
reject(new Error(`Failed to list improvements: ${err.message}`));
} else {
const improvements = rows.map(row => ({
...row,
acceptanceCriteria: JSON.parse(row.acceptanceCriteria || '[]'),
filesLikelyInvolved: JSON.parse(row.filesLikelyInvolved || '[]'),
dependencies: JSON.parse(row.dependencies || '[]'),
benefits: JSON.parse(row.benefits || '[]')
}));
let output: string;
if (args.includeCodeContext) {
// Get code context for each improvement
const improvementsWithContext = await Promise.all(
improvements.map(async (improvement) => {
const codeContext = await this.getCodeContextForImprovement(improvement);
return { ...improvement, codeContext };
})
);
output = formatImprovementsWithContext(improvementsWithContext);
} else {
output = formatImprovements(improvements);
}
// Record token usage
const inputText = JSON.stringify(args);
const tokenUsage = this.tokenTracker.recordUsage(inputText, output, 'list_improvements');
resolve(`${output}\n\nToken usage: ${tokenUsage.total} tokens (${tokenUsage.input} input, ${tokenUsage.output} output)`);
}
});
});
}
/**
* Update improvement status
*/
async updateImprovementStatus(db: sqlite3.Database, args: any): Promise<string> {
this.tokenTracker.startOperation('update_improvement_status');
const { itemId } = args;
let { status, dateCompleted } = args;
const validStatuses = ['Proposed', 'In Discussion', 'Approved', 'In Development', 'Completed (Awaiting Human Verification)', 'Completed', 'Rejected'];
// Allow lightweight TODO statuses by mapping them first
const mapped = this.mapTodoStatus(status);
if (mapped) status = mapped;
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
}
return new Promise((resolve, reject) => {
let updateQuery = 'UPDATE improvements SET status = ?';
const params = [status];
if (dateCompleted) {
updateQuery += ', dateCompleted = ?';
params.push(dateCompleted);
}
updateQuery += ' WHERE id = ?';
params.push(itemId);
db.run(updateQuery, params, function (this: any, err: any) {
if (err) {
reject(new Error(`Failed to update improvement status: ${err.message}`));
} else if (this && this.changes === 0) {
reject(new Error(`Improvement ${itemId} not found`));
} else {
// Record token usage
const inputText = JSON.stringify(args);
const outputText = `Updated improvement ${itemId} to ${status}`;
const tokenUsage = TokenUsageTracker.getInstance().recordUsage(inputText, outputText, 'update_improvement_status');
resolve(`Improvement ${itemId} updated to ${status}.\n\nToken usage: ${tokenUsage.total} tokens (${tokenUsage.input} input, ${tokenUsage.output} output)`);
}
});
});
}
/**
* Search improvements
*/
async searchImprovements(db: sqlite3.Database, query: string, args: any): Promise<any[]> {
this.tokenTracker.startOperation('search_improvements');
// Normalize and whitelist search fields, supporting common aliases
const aliasMap: Record<string, string> = {
currentBehavior: 'currentState',
expectedBehavior: 'desiredState',
};
const allowedFields = new Set(['title', 'description', 'category', 'currentState', 'desiredState']);
const requestedFields: string[] = Array.isArray(args.searchFields)
? args.searchFields
: ['title', 'description', 'category'];
const searchFields = requestedFields
.map((f) => aliasMap[f] || f)
.filter((f) => allowedFields.has(f));
if (searchFields.length === 0) {
searchFields.push('title', 'description', 'category');
}
const limit = args.limit || 50;
const offset = args.offset || 0;
let sql = 'SELECT * FROM improvements WHERE ';
const conditions: string[] = [];
const params: any[] = [];
// Add search conditions
if (query) {
const searchConditions = searchFields.map((field: string) => `${field} LIKE ?`);
conditions.push(`(${searchConditions.join(' OR ')})`);
searchFields.forEach(() => params.push(`%${query}%`));
}
// Add filters
if (args.status) {
if (Array.isArray(args.status)) {
conditions.push(`status IN (${args.status.map(() => '?').join(', ')})`);
params.push(...args.status);
} else {
conditions.push('status = ?');
params.push(args.status);
}
}
if (args.priority) {
if (Array.isArray(args.priority)) {
conditions.push(`priority IN (${args.priority.map(() => '?').join(', ')})`);
params.push(...args.priority);
} else {
conditions.push('priority = ?');
params.push(args.priority);
}
}
if (args.category) {
conditions.push('category LIKE ?');
params.push(`%${args.category}%`);
}
if (args.effortEstimate) {
if (Array.isArray(args.effortEstimate)) {
conditions.push(`effortEstimate IN (${args.effortEstimate.map(() => '?').join(', ')})`);
params.push(...args.effortEstimate);
} else {
conditions.push('effortEstimate = ?');
params.push(args.effortEstimate);
}
}
// Date range filter
if (args.dateFrom) {
conditions.push('dateRequested >= ?');
params.push(args.dateFrom);
}
if (args.dateTo) {
conditions.push('dateRequested <= ?');
params.push(args.dateTo);
}
if (conditions.length === 0) {
sql = 'SELECT * FROM improvements';
} else {
sql += conditions.join(' AND ');
}
// Add sorting with whitelist mapping
const sortKey = args.sortBy || 'date';
const sortMap: Record<string, string> = {
date: 'dateRequested',
priority: 'priority',
title: 'title',
status: 'status',
category: 'category',
requestedBy: 'requestedBy',
};
const orderByColumn = sortMap[sortKey] || 'dateRequested';
const sortOrder = (String(args.sortOrder).toLowerCase() === 'asc') ? 'ASC' : 'DESC';
sql += ` ORDER BY ${orderByColumn} ${sortOrder}`;
// Add pagination
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows: any[]) => {
if (err) {
reject(new Error(`Failed to search improvements: ${err.message}`));
} else {
const improvements = rows.map(row => ({
...row,
type: 'improvement',
acceptanceCriteria: JSON.parse(row.acceptanceCriteria || '[]'),
filesLikelyInvolved: JSON.parse(row.filesLikelyInvolved || '[]'),
dependencies: JSON.parse(row.dependencies || '[]'),
benefits: JSON.parse(row.benefits || '[]')
}));
resolve(improvements);
}
});
});
}
/**
* Bulk update improvement status
*/
async bulkUpdateImprovementStatus(db: sqlite3.Database, args: any): Promise<string> {
this.tokenTracker.startOperation('bulk_update_improvement_status');
const { updates } = args;
const results: any[] = [];
for (const update of updates) {
try {
const result = await this.updateImprovementStatus(db, update);
results.push({
status: 'success',
improvementId: update.itemId,
message: `Updated to ${update.status}`,
dateCompleted: update.dateCompleted
});
} catch (error) {
results.push({
status: 'error',
improvementId: update.itemId,
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
// Record token usage
const inputText = JSON.stringify(args);
const outputText = formatBulkUpdateResults(results, 'improvements');
const tokenUsage = this.tokenTracker.recordUsage(inputText, outputText, 'bulk_update_improvement_status');
return `${outputText}\n\nToken usage: ${tokenUsage.total} tokens (${tokenUsage.input} input, ${tokenUsage.output} output)`;
}
/**
* Get code context for improvement
*/
async getCodeContextForImprovement(improvement: any): Promise<any[]> {
const codeContext: any[] = [];
const root = path.resolve(process.env.CONTEXT_ROOT || process.cwd());
if (improvement.filesLikelyInvolved && improvement.filesLikelyInvolved.length > 0) {
for (const file of improvement.filesLikelyInvolved) {
try {
const resolved = path.resolve(file);
if (!(resolved === root || resolved.startsWith(root + path.sep))) {
codeContext.push({ file, error: 'Access denied outside allowed root', relevanceScore: 0 });
continue;
}
if (fs.existsSync(resolved)) {
const stat = fs.statSync(resolved);
if (stat.size > 1_000_000) { // 1MB limit
codeContext.push({ file: resolved, error: 'File too large to read', relevanceScore: 0 });
continue;
}
const content = fs.readFileSync(resolved, 'utf8');
const relevantSection = this.extractRelevantSections(content, improvement);
codeContext.push({
file: resolved,
content: relevantSection || content.substring(0, 2000) + (content.length > 2000 ? '...' : ''),
relevanceScore: relevantSection ? 0.8 : 0.5
});
} else {
codeContext.push({
file: resolved,
error: 'File not found',
relevanceScore: 0
});
}
} catch (error) {
codeContext.push({
file: file,
error: `Error reading file: ${error instanceof Error ? error.message : 'Unknown error'}`,
relevanceScore: 0
});
}
}
}
return codeContext;
}
/**
* Extract relevant sections from file content
*/
private extractRelevantSections(content: string, improvement: any): string | null {
const keywords = this.extractKeywords(improvement.currentState + ' ' + improvement.desiredState + ' ' + improvement.description);
const lines = content.split('\n');
const relevantLines: { line: number; content: string; score: number }[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let score = 0;
for (const keyword of keywords) {
if (line.toLowerCase().includes(keyword.toLowerCase())) {
score += 1;
}
}
if (score > 0) {
relevantLines.push({ line: i, content: line, score });
}
}
if (relevantLines.length === 0) {
return null;
}
// Sort by score and get top relevant lines
relevantLines.sort((a, b) => b.score - a.score);
const topLines = relevantLines.slice(0, 10);
// Get context around each relevant line
const contextLines: string[] = [];
const contextSize = 3;
for (const relevantLine of topLines) {
const start = Math.max(0, relevantLine.line - contextSize);
const end = Math.min(lines.length, relevantLine.line + contextSize + 1);
const section = lines.slice(start, end).join('\n');
if (!contextLines.includes(section)) {
contextLines.push(section);
}
}
return contextLines.join('\n\n---\n\n');
}
/**
* Extract keywords from text
*/
private extractKeywords(text: string): string[] {
const words = text.toLowerCase().match(/\b[a-z]{3,}\b/g) || [];
const stopWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'this', 'that', 'which', 'who', 'what', 'where', 'when', 'why', 'how', 'can', 'could', 'should', 'would', 'will', 'have', 'has', 'had', 'do', 'does', 'did', 'is', 'are', 'was', 'were', 'be', 'been', 'being'];
return words.filter(word => !stopWords.includes(word)).slice(0, 20);
}
/**
* Generate next ID for improvement with retry mechanism for race conditions
*/
private async generateNextIdWithRetry(db: sqlite3.Database, type: 'improvement', retryCount = 0): Promise<string> {
const maxRetries = 3;
try {
return await this.generateNextId(db, type);
} catch (error) {
if (retryCount < maxRetries && error instanceof Error && error.message.includes('UNIQUE constraint')) {
// Wait a bit and retry
await new Promise(resolve => setTimeout(resolve, Math.random() * 100 + 50));
return this.generateNextIdWithRetry(db, type, retryCount + 1);
}
throw error;
}
}
/**
* Generate next ID for improvement
*/
private async generateNextId(db: sqlite3.Database, type: 'improvement'): Promise<string> {
return new Promise((resolve, reject) => {
db.get('SELECT MAX(CAST(SUBSTR(id, 5) AS INTEGER)) as maxId FROM improvements', [], (err, row: any) => {
if (err) {
reject(new Error(`Failed to generate ID: ${err.message}`));
} else {
const nextNum = (row?.maxId || 0) + 1;
resolve(`IMP-${nextNum.toString().padStart(3, '0')}`);
}
});
});
}
}