ai-sprint
Version:
AI-powered sprint management and SDLC tracking with automatic IDE integration
394 lines • 15.9 kB
JavaScript
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