UNPKG

ai-sprint

Version:

AI-powered sprint management and SDLC tracking with automatic IDE integration

394 lines 15.9 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { promises as fs } from 'fs'; import path from 'path'; import { watch } from 'chokidar'; import { simpleGit } from 'simple-git'; import { glob } from 'glob'; // Configuration schema const ConfigSchema = z.object({ projectId: z.string().uuid(), projectPath: z.string(), apiEndpoint: z.string().url(), apiKey: z.string().optional(), watchPatterns: z.array(z.string()).default(['sessions/*.md', 'docs/sprint-*.md']), }); class SDLCTrackerServer { server; config; git = simpleGit(); ideType; autoConfigured; constructor() { this.server = new Server({ name: 'ai-sprint', version: '1.0.0', }, { capabilities: { tools: {}, }, }); // Detect if auto-configured this.ideType = process.env.IDE_TYPE || 'generic'; this.autoConfigured = process.env.AUTO_CONFIGURED === 'true'; this.setupHandlers(); this.autoConfigureIfNeeded(); } async autoConfigureIfNeeded() { if (this.autoConfigured && process.env.PROJECT_ID && process.env.PROJECT_PATH) { // Auto-configure from environment variables const autoConfig = { projectId: process.env.PROJECT_ID, projectPath: process.env.PROJECT_PATH, apiEndpoint: process.env.API_ENDPOINT || 'https://portal.unblockd.com', apiKey: process.env.API_KEY, watchPatterns: process.env.WATCH_PATTERNS?.split(',') || ['sessions/*.md', 'sprints/*.md'] }; try { await this.configure(autoConfig); console.error(`Auto-configured for ${this.ideType} IDE`); } catch (error) { console.error('Auto-configuration failed:', error); } } } setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'configure', description: 'Configure the SDLC tracker with project details', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'UUID of the project' }, projectPath: { type: 'string', description: 'Path to the project directory' }, apiEndpoint: { type: 'string', description: 'BizOps Portal API endpoint' }, apiKey: { type: 'string', description: 'API key for authentication (optional)' }, watchPatterns: { type: 'array', items: { type: 'string' }, description: 'File patterns to watch for changes', }, }, required: ['projectId', 'projectPath', 'apiEndpoint'], }, }, { name: 'sync_sessions', description: 'Sync all session files to the portal', inputSchema: { type: 'object', properties: {}, }, }, { name: 'sync_git_history', description: 'Sync git commit history to the portal', inputSchema: { type: 'object', properties: { branch: { type: 'string', description: 'Git branch to sync (default: current branch)' }, since: { type: 'string', description: 'Date to sync from (YYYY-MM-DD)' }, }, }, }, { name: 'submit_test_results', description: 'Submit test run results to the portal', inputSchema: { type: 'object', properties: { sessionId: { type: 'string', description: 'Session ID (optional)' }, testType: { type: 'string', enum: ['smoke', 'unit', 'integration', 'e2e', 'checkpoint', 'visual', 'performance'], }, passed: { type: 'number' }, failed: { type: 'number' }, skipped: { type: 'number' }, coverage: { type: 'number', description: 'Coverage percentage (0-100)' }, duration: { type: 'number', description: 'Duration in milliseconds' }, }, required: ['testType', 'passed', 'failed'], }, }, { name: 'watch', description: 'Start watching for file changes', inputSchema: { type: 'object', properties: {}, }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case 'configure': return this.configure(request.params.arguments); case 'sync_sessions': return this.syncSessions(); case 'sync_git_history': return this.syncGitHistory(request.params.arguments); case 'submit_test_results': return this.submitTestResults(request.params.arguments); case 'watch': return this.startWatching(); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } }); } async configure(args) { try { this.config = ConfigSchema.parse(args); // Save config to file const configPath = path.join(this.config.projectPath, '.ai-sprint.json'); await fs.writeFile(configPath, JSON.stringify(this.config, null, 2)); return { content: [ { type: 'text', text: `AI Sprint configured for project ${this.config.projectId}`, }, ], }; } catch (error) { throw new McpError(ErrorCode.InvalidParams, `Configuration error: ${error}`); } } async syncSessions() { if (!this.config) { throw new McpError(ErrorCode.InvalidRequest, 'Not configured. Run configure first.'); } try { const sessionFiles = await glob('sessions/*.md', { cwd: this.config.projectPath, }); const sessions = []; for (const file of sessionFiles) { const content = await fs.readFile(path.join(this.config.projectPath, file), 'utf-8'); const session = this.parseSessionFile(file, content); if (session) { sessions.push(session); } } // Send sessions to API for (const session of sessions) { await this.sendToAPI('/api/sdlc/sessions', session); } return { content: [ { type: 'text', text: `Synced ${sessions.length} sessions to the portal`, }, ], }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Session sync error: ${error}`); } } parseSessionFile(filename, content) { // Parse session filename: YYYY-MM-DD-HHMM-[type]-[name].md const match = filename.match(/(\d{4}-\d{2}-\d{2})-(\d{4})-(\w+)-(.+)\.md$/); if (!match) return null; const [, date, time, type, name] = match; const sessionName = name.replace(/-/g, ' '); // Extract session metadata from content const goals = this.extractSection(content, 'Goals') || this.extractSection(content, 'Objectives'); const acceptanceCriteria = this.extractList(content, 'Acceptance Criteria'); const testResults = this.extractTestResults(content); return { project_id: this.config.projectId, session_type: type, session_name: sessionName, start_time: new Date(`${date} ${time.slice(0, 2)}:${time.slice(2)}`).toISOString(), goals, acceptance_criteria: acceptanceCriteria, test_results: testResults, description: this.extractSection(content, 'Description'), }; } extractSection(content, heading) { const regex = new RegExp(`^#+ ${heading}\\s*\\n([\\s\\S]*?)(?=\\n#|$)`, 'mi'); const match = content.match(regex); return match ? match[1].trim() : null; } extractList(content, heading) { const section = this.extractSection(content, heading); if (!section) return []; return section .split('\n') .filter(line => line.match(/^[\-\*\+]\s+/)) .map(line => line.replace(/^[\-\*\+]\s+/, '').trim()); } extractTestResults(content) { const testSection = this.extractSection(content, 'Test Results') || this.extractSection(content, 'Tests'); if (!testSection) return null; const passed = (testSection.match(/passed:\s*(\d+)/i) || [])[1]; const failed = (testSection.match(/failed:\s*(\d+)/i) || [])[1]; const skipped = (testSection.match(/skipped:\s*(\d+)/i) || [])[1]; const coverage = (testSection.match(/coverage:\s*([\d.]+)%/i) || [])[1]; if (!passed && !failed) return null; return { passed: parseInt(passed || '0'), failed: parseInt(failed || '0'), skipped: parseInt(skipped || '0'), coverage: parseFloat(coverage || '0'), }; } async syncGitHistory(args) { if (!this.config) { throw new McpError(ErrorCode.InvalidRequest, 'Not configured. Run configure first.'); } try { await this.git.cwd(this.config.projectPath); const branch = args.branch || (await this.git.branch()).current; const since = args.since || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const log = await this.git.log(['--since', since]); const commits = log.all.map((commit) => ({ project_id: this.config.projectId, commit_hash: commit.hash, branch_name: branch, author_name: commit.author_name, author_email: commit.author_email, timestamp: commit.date, message: commit.message + (commit.body ? '\n\n' + commit.body : ''), // Parse diff stats if available files_changed: 0, // Would need to parse from diff additions: 0, deletions: 0, })); await this.sendToAPI('/api/sdlc/git-sync', { project_id: this.config.projectId, commits, branch, }); return { content: [ { type: 'text', text: `Synced ${commits.length} commits from branch ${branch}`, }, ], }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Git sync error: ${error}`); } } async submitTestResults(args) { if (!this.config) { throw new McpError(ErrorCode.InvalidRequest, 'Not configured. Run configure first.'); } try { const testRun = { project_id: this.config.projectId, session_id: args.sessionId, test_type: args.testType, passed: args.passed, failed: args.failed, skipped: args.skipped || 0, coverage_percent: args.coverage, duration_ms: args.duration, }; await this.sendToAPI('/api/sdlc/test-runs', testRun); return { content: [ { type: 'text', text: `Submitted test results: ${args.passed} passed, ${args.failed} failed`, }, ], }; } catch (error) { throw new McpError(ErrorCode.InternalError, `Test submission error: ${error}`); } } async startWatching() { if (!this.config) { throw new McpError(ErrorCode.InvalidRequest, 'Not configured. Run configure first.'); } const watcher = watch(this.config.watchPatterns, { cwd: this.config.projectPath, persistent: true, }); watcher.on('add', async (path) => { console.log(`New file detected: ${path}`); if (path.startsWith('sessions/')) { await this.syncSessions(); } }); watcher.on('change', async (path) => { console.log(`File changed: ${path}`); if (path.startsWith('sessions/')) { await this.syncSessions(); } }); return { content: [ { type: 'text', text: `Started watching for changes in: ${this.config.watchPatterns.join(', ')}`, }, ], }; } async sendToAPI(endpoint, data) { if (!this.config) { throw new Error('Not configured'); } const url = new URL(endpoint, this.config.apiEndpoint); const headers = { 'Content-Type': 'application/json', }; if (this.config.apiKey) { headers['Authorization'] = `Bearer ${this.config.apiKey}`; } const response = await fetch(url.toString(), { method: 'POST', headers, body: JSON.stringify(data), }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } return response.json(); } async run() { console.error('AI Sprint starting...'); console.error('Environment:', { PROJECT_ID: process.env.PROJECT_ID, PROJECT_PATH: process.env.PROJECT_PATH, API_ENDPOINT: process.env.API_ENDPOINT, NEON_BRANCH_ID: process.env.NEON_BRANCH_ID, }); try { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('AI Sprint Server running on stdio'); } catch (error) { console.error('Failed to start MCP server:', error); throw error; } } } const server = new SDLCTrackerServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map