UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

1,157 lines 57.2 kB
import { StorageManager } from '../../storage/storage-manager.js'; import { MCPError } from '../../utils/error-handler.js'; import { promises as fs } from 'fs'; import path from 'path'; export class AgileManager { configManager; storageManager; agileDataPath; sprints; stories; epics; standups; retrospectives; constructor(configManager) { this.configManager = configManager; this.storageManager = new StorageManager(); this.agileDataPath = ''; this.sprints = new Map(); this.stories = new Map(); this.epics = new Map(); this.standups = []; this.retrospectives = []; } async initialize() { const location = await this.storageManager.getStorageLocation(); this.agileDataPath = path.join(location.data, 'agile'); await this.loadExistingData(); // Create sample data only in development mode and if explicitly enabled const isDev = process.env.NODE_ENV === 'development' || process.env.ATLAS_DEV === 'true'; const shouldCreateSampleData = process.env.ATLAS_CREATE_SAMPLE_DATA === 'true'; if ((isDev || shouldCreateSampleData) && this.sprints.size === 0 && this.stories.size === 0 && this.epics.size === 0) { console.log('🔧 Development mode detected - creating sample agile data for dashboard testing'); await this.createSampleData(); } } async loadExistingData() { try { // Load sprints const sprintsPath = path.join(this.agileDataPath, 'sprints.json'); if (await this.fileExists(sprintsPath)) { const sprintsData = JSON.parse(await fs.readFile(sprintsPath, 'utf-8')); sprintsData.forEach((sprint) => { sprint.startDate = new Date(sprint.startDate); sprint.endDate = new Date(sprint.endDate); this.sprints.set(sprint.id, sprint); }); } // Load stories const storiesPath = path.join(this.agileDataPath, 'stories.json'); if (await this.fileExists(storiesPath)) { const storiesData = JSON.parse(await fs.readFile(storiesPath, 'utf-8')); storiesData.forEach((story) => { story.createdAt = new Date(story.createdAt); story.updatedAt = new Date(story.updatedAt); // Initialize required arrays if undefined story.acceptanceCriteria = story.acceptanceCriteria || []; story.tags = story.tags || []; story.dependencies = story.dependencies || []; story.watchers = story.watchers || []; // Initialize required objects story.timeTracking = story.timeTracking || { estimated: 0, actual: 0, remaining: 0, logs: [] }; story.attachments = story.attachments || []; story.comments = story.comments || []; story.customFields = story.customFields || []; story.subtasks = story.subtasks || []; // Convert optional dates if (story.dueDate) story.dueDate = new Date(story.dueDate); if (story.startDate) story.startDate = new Date(story.startDate); if (story.completedAt) story.completedAt = new Date(story.completedAt); this.stories.set(story.id, story); }); } // Load epics const epicsPath = path.join(this.agileDataPath, 'epics.json'); if (await this.fileExists(epicsPath)) { const epicsData = JSON.parse(await fs.readFile(epicsPath, 'utf-8')); epicsData.forEach((epic) => { epic.createdAt = new Date(epic.createdAt); epic.updatedAt = new Date(epic.updatedAt); this.epics.set(epic.id, epic); }); } // Load standups const standupsPath = path.join(this.agileDataPath, 'standups.json'); if (await this.fileExists(standupsPath)) { const standupsData = JSON.parse(await fs.readFile(standupsPath, 'utf-8')); this.standups = standupsData.map((standup) => ({ ...standup, date: new Date(standup.date) })); } // Load retrospectives const retrospectivesPath = path.join(this.agileDataPath, 'retrospectives.json'); if (await this.fileExists(retrospectivesPath)) { const retrospectivesData = JSON.parse(await fs.readFile(retrospectivesPath, 'utf-8')); this.retrospectives = retrospectivesData.map((retro) => ({ ...retro, date: new Date(retro.date) })); } } catch (error) { console.error('Error loading agile data:', error); } // Validate and repair data integrity after loading const validationResult = await this.validateAndRepairDataIntegrity(); if (!validationResult.valid && validationResult.repairs.length > 0) { console.log('🔧 Agile data integrity repairs performed:'); validationResult.repairs.forEach(repair => console.log(` - ${repair}`)); } } async validateAndRepairDataIntegrity() { const repairs = []; let dataModified = false; // Check data version to determine repair strategy const dataVersion = await this.getDataVersion(); const needsEpicSync = this.compareVersions(dataVersion, '1.0.19') < 0; if (needsEpicSync) { repairs.push(`Detected data version ${dataVersion} < 1.0.19 - performing epic-story relationship migration`); } // Step 1: Build a map of valid epic IDs const validEpicIds = new Set(this.epics.keys()); // Step 2: Validate and repair story-epic relationships if (!needsEpicSync) { // For v1.0.19+ data: Only remove truly invalid epic references for (const [storyId, story] of this.stories.entries()) { if (story.epic && !validEpicIds.has(story.epic)) { repairs.push(`Story ${storyId} had invalid epic reference ${story.epic} - removed`); story.epic = undefined; story.updatedAt = new Date(); dataModified = true; } } } // For pre-v1.0.19 data: Keep all epic references that point to valid epics // Step 3: Rebuild epic.storyIds arrays from story references if (needsEpicSync) { // For pre-v1.0.19 data: Always rebuild epic.storyIds from story references repairs.push('Rebuilding all epic.storyIds arrays from story references'); // Clear all epic.storyIds arrays first for (const [epicId, epic] of this.epics.entries()) { epic.storyIds = []; epic.updatedAt = new Date(); } // Rebuild from story references for (const [storyId, story] of this.stories.entries()) { if (story.epic && this.epics.has(story.epic)) { const epic = this.epics.get(story.epic); epic.storyIds.push(storyId); repairs.push(` - Added story ${storyId} to epic ${story.epic}`); } } dataModified = true; } else { // For v1.0.19+ data: Only fix discrepancies const epicStoryMap = new Map(); // Initialize empty arrays for all epics for (const epicId of validEpicIds) { epicStoryMap.set(epicId, []); } // Collect all valid story-epic relationships for (const [storyId, story] of this.stories.entries()) { if (story.epic && validEpicIds.has(story.epic)) { epicStoryMap.get(story.epic).push(storyId); } } // Update epic.storyIds arrays only if they differ for (const [epicId, epic] of this.epics.entries()) { const correctStoryIds = epicStoryMap.get(epicId) || []; const currentStoryIds = epic.storyIds || []; // Check if arrays differ if (JSON.stringify(correctStoryIds.sort()) !== JSON.stringify(currentStoryIds.sort())) { repairs.push(`Epic ${epicId} storyIds updated: ${currentStoryIds.length} -> ${correctStoryIds.length} stories`); epic.storyIds = correctStoryIds; epic.updatedAt = new Date(); dataModified = true; } } } // Step 4: Validate sprint-story relationships const validSprintIds = new Set(this.sprints.keys()); for (const [storyId, story] of this.stories.entries()) { if (story.sprintId && !validSprintIds.has(story.sprintId)) { repairs.push(`Story ${storyId} had invalid sprint reference ${story.sprintId} - removed`); story.sprintId = undefined; story.updatedAt = new Date(); dataModified = true; } } // Save changes if any repairs were made if (dataModified) { await Promise.all([ this.saveStories(), this.saveEpics(), this.saveSprints() ]); // Update version if we performed a migration if (needsEpicSync) { await this.updateDataVersion('1.0.19'); repairs.push('Updated data version to 1.0.19'); } } return { valid: repairs.length === 0, repairs }; } async fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } async createSprint(options) { const startDate = options.startDate || new Date(); const sprint = { id: `sprint-${this.generateId()}`, name: options.name, goal: options.goal, status: 'active', startDate: startDate, endDate: new Date(startDate.getTime() + (options.duration * 24 * 60 * 60 * 1000)), duration: options.duration, team: options.team || [], storyIds: [], epicIds: [], capacity: 0, velocity: 0, burndownData: [], }; this.sprints.set(sprint.id, sprint); // Add initial stories if provided if (options.initialStoryIds && options.initialStoryIds.length > 0) { for (const storyId of options.initialStoryIds) { try { await this.addStoryToSprint(storyId, sprint.id); } catch (error) { console.error(`Failed to add story ${storyId} to sprint: ${error}`); } } } await this.saveSprints(); return sprint; } async addStory(options) { // Handle epicId alias const epicId = options.epicId || options.epic; // Convert legacy string acceptanceCriteria to structured format let acceptanceCriteria = []; if (options.acceptanceCriteria) { if (typeof options.acceptanceCriteria[0] === 'string') { // Legacy format - convert strings to structured criteria acceptanceCriteria = options.acceptanceCriteria.map((desc, idx) => ({ id: `ac-${Date.now()}-${idx}`, description: desc, status: 'pending', })); } else { // New structured format acceptanceCriteria = options.acceptanceCriteria; } } const story = { id: `story-${this.generateId()}`, title: options.title, description: options.description, status: options.sprintId ? 'todo' : 'backlog', priority: options.priority || 'medium', storyPoints: options.storyPoints || 0, acceptanceCriteria: acceptanceCriteria, acceptanceCriteriaLegacy: typeof options.acceptanceCriteria?.[0] === 'string' ? options.acceptanceCriteria : undefined, assignee: options.assignee, epic: epicId, sprintId: options.sprintId, createdAt: new Date(), updatedAt: new Date(), hoursSpent: 0, tags: options.tags || [], // Initialize new enhanced properties dependencies: options.dependencies || [], timeTracking: options.timeEstimate ? { estimated: options.timeEstimate, actual: 0, remaining: options.timeEstimate, logs: [] } : { estimated: 0, actual: 0, remaining: 0, logs: [] }, attachments: [], comments: [], customFields: [], subtasks: [], watchers: options.watchers || [], groomedWithUserFeedback: options.groomedWithUserFeedback ?? false, }; this.stories.set(story.id, story); // Add to sprint if specified if (options.sprintId && this.sprints.has(options.sprintId)) { const sprint = this.sprints.get(options.sprintId); sprint.storyIds.push(story.id); this.sprints.set(sprint.id, sprint); await this.saveSprints(); } // Add to epic if specified if (epicId && this.epics.has(epicId)) { const epic = this.epics.get(epicId); if (!epic.storyIds.includes(story.id)) { epic.storyIds.push(story.id); epic.updatedAt = new Date(); this.epics.set(epic.id, epic); await this.saveEpics(); } } await this.saveStories(); // Return with epicId for API compatibility return { ...story, epicId: story.epic }; } async createEpic(options) { const epic = { id: `epic-${this.generateId()}`, title: options.title, description: options.description, status: 'planned', priority: options.priority || 'medium', goals: options.goals || [], owner: options.owner, createdAt: new Date(), updatedAt: new Date(), storyIds: [], stories: [], progress: 0, }; this.epics.set(epic.id, epic); // Handle stories if provided const storiesData = options.stories; if (storiesData && Array.isArray(storiesData)) { const createdStories = []; let totalPoints = 0; for (const storyData of storiesData) { const story = await this.addStory({ title: storyData.title, description: storyData.description, storyPoints: storyData.storyPoints || 0, priority: storyData.priority || 'medium', acceptanceCriteria: storyData.acceptanceCriteria || [], epic: epic.id, }); epic.storyIds.push(story.id); createdStories.push(story); totalPoints += story.storyPoints; } // Return epic with stories array populated const epicWithStories = { ...epic, stories: createdStories, totalStoryPoints: totalPoints }; await this.saveEpics(); return epicWithStories; } await this.saveEpics(); return epic; } async saveEpic(epic) { this.epics.set(epic.id, epic); await this.saveEpics(); } async updateEpic(epicId, updates) { if (!this.epics.has(epicId)) { throw new Error(`Epic ${epicId} not found`); } const epic = this.epics.get(epicId); // Update basic properties if (updates.title !== undefined) epic.title = updates.title; if (updates.description !== undefined) epic.description = updates.description; if (updates.status !== undefined) epic.status = updates.status; if (updates.priority !== undefined) epic.priority = updates.priority; if (updates.goals !== undefined) epic.goals = updates.goals; if (updates.owner !== undefined) epic.owner = updates.owner; // Update metadata if provided if (updates.metadata !== undefined) { epic.metadata = { ...epic.metadata, ...updates.metadata }; } // Update timestamp epic.updatedAt = new Date(); // Recalculate progress based on story statuses if (epic.storyIds.length > 0) { const stories = epic.storyIds .map(id => this.stories.get(id)) .filter(story => story !== undefined); const totalStories = stories.length; const completedStories = stories.filter(s => s.status === 'done').length; epic.progress = totalStories > 0 ? Math.round((completedStories / totalStories) * 100) : 0; } this.epics.set(epic.id, epic); await this.saveEpics(); return epic; } async updateSprint(sprintId, updates) { if (!this.sprints.has(sprintId)) { throw new Error(`Sprint ${sprintId} not found`); } const sprint = this.sprints.get(sprintId); // Update basic properties if (updates.name !== undefined) sprint.name = updates.name; if (updates.goal !== undefined) sprint.goal = updates.goal; if (updates.status !== undefined) sprint.status = updates.status; if (updates.endDate !== undefined) sprint.endDate = updates.endDate; if (updates.team !== undefined) sprint.team = updates.team; if (updates.capacity !== undefined) sprint.capacity = updates.capacity; // Recalculate duration if end date changed if (updates.endDate !== undefined) { sprint.duration = Math.ceil((sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)); } // Recalculate velocity if sprint is completed if (sprint.status === 'completed') { const sprintStories = sprint.storyIds .map(id => this.stories.get(id)) .filter(story => story !== undefined); sprint.velocity = sprintStories .filter(story => story.status === 'done') .reduce((sum, story) => sum + story.storyPoints, 0); } this.sprints.set(sprint.id, sprint); await this.saveSprints(); return sprint; } async addStoryToSprint(storyId, sprintId) { const story = this.stories.get(storyId); const sprint = this.sprints.get(sprintId); if (!story) { throw new Error(`Story ${storyId} not found`); } if (!sprint) { throw new Error(`Sprint ${sprintId} not found`); } // Update story story.sprintId = sprintId; story.updatedAt = new Date(); this.stories.set(story.id, story); // Update sprint if (!sprint.storyIds.includes(storyId)) { sprint.storyIds.push(storyId); this.sprints.set(sprint.id, sprint); } // Save both await Promise.all([this.saveStories(), this.saveSprints()]); } async conductSprintPlanning(options) { if (!this.sprints.has(options.sprintId)) { throw new Error(`Sprint ${options.sprintId} not found`); } const sprint = this.sprints.get(options.sprintId); const planning = { id: `planning-${Date.now()}`, sprintId: options.sprintId, date: new Date(), attendees: options.attendees, capacity: options.capacity || 0, commitments: options.commitments, notes: '', outcome: 'ready', }; // Update sprint with planning results sprint.capacity = planning.capacity; sprint.storyIds = [...new Set([...sprint.storyIds, ...planning.commitments])]; sprint.status = 'active'; this.sprints.set(sprint.id, sprint); await this.saveSprints(); return planning; } async recordStandup(options) { const standup = { id: `standup-${Date.now()}`, sprintId: options.sprintId, attendee: options.attendee, date: new Date(), yesterday: options.yesterday, today: options.today, blockers: options.blockers, }; this.standups.push(standup); await this.saveStandups(); return standup; } async conductRetrospective(options) { if (!this.sprints.has(options.sprintId)) { throw new Error(`Sprint ${options.sprintId} not found`); } const retrospective = { id: `retro-${Date.now()}`, sprintId: options.sprintId, date: new Date(), attendees: options.attendees, whatWentWell: options.whatWentWell, whatCanImprove: options.whatCanImprove, actionItems: options.actionItems, sprintRating: 7, // Default rating }; this.retrospectives.push(retrospective); await this.saveRetrospectives(); // Mark sprint as completed const sprint = this.sprints.get(options.sprintId); sprint.status = 'completed'; this.sprints.set(sprint.id, sprint); await this.saveSprints(); return retrospective; } async updateStoryStatus(storyId, updates) { if (!this.stories.has(storyId)) { throw new MCPError({ message: `Story ${storyId} not found`, code: 'STORY_NOT_FOUND', category: 'validation', details: { storyId }, recoverable: false, suggestedActions: [ 'Check that the story ID is correct', 'Use list_agile_backlog to see available stories', 'Ensure the story was not deleted' ] }); } const story = this.stories.get(storyId); // Validate status transition to in_progress if (updates.status === 'in_progress' && !story.groomedWithUserFeedback) { throw new MCPError({ message: 'Story must be groomed with user feedback before moving to in_progress', code: 'STORY_NOT_GROOMED', category: 'validation', details: { storyId, currentStatus: story.status, targetStatus: updates.status, groomedWithUserFeedback: story.groomedWithUserFeedback }, recoverable: true, suggestedActions: [ 'Mark the story as groomed with user feedback first', 'Run a grooming session with stakeholders', 'Update the story with groomedWithUserFeedback: true' ] }); } // Validate documentation requirements when moving from todo/backlog if (story.status === 'todo' || story.status === 'backlog') { if (updates.status && updates.status !== 'todo' && updates.status !== 'backlog') { // Story is moving out of todo/backlog, check documentation if (story.documentationStatus !== 'approved') { throw new MCPError({ message: 'Story documentation must be approved before moving out of To Do', code: 'DOCUMENTATION_NOT_APPROVED', category: 'validation', details: { storyId, currentStatus: story.status, targetStatus: updates.status, documentationStatus: story.documentationStatus || 'pending', designDocumentUrl: story.designDocumentUrl, implementationDocumentUrl: story.implementationDocumentUrl }, recoverable: true, suggestedActions: [ 'Add design document URL to the story', 'Add implementation document URL to the story', 'Get documentation reviewed and approved', 'Update documentationStatus to "approved"' ] }); } } } // Update basic properties if (updates.status !== undefined) story.status = updates.status; if (updates.title !== undefined) story.title = updates.title; if (updates.description !== undefined) story.description = updates.description; if (updates.priority !== undefined) story.priority = updates.priority; if (updates.storyPoints !== undefined) story.storyPoints = updates.storyPoints; if (updates.assignee !== undefined) story.assignee = updates.assignee; if (updates.tags !== undefined) story.tags = updates.tags; if (updates.groomedWithUserFeedback !== undefined) story.groomedWithUserFeedback = updates.groomedWithUserFeedback; if (updates.designDocumentUrl !== undefined) story.designDocumentUrl = updates.designDocumentUrl; if (updates.implementationDocumentUrl !== undefined) story.implementationDocumentUrl = updates.implementationDocumentUrl; if (updates.documentationStatus !== undefined) story.documentationStatus = updates.documentationStatus; if (updates.documentationLastReviewed !== undefined) story.documentationLastReviewed = updates.documentationLastReviewed; if (updates.documentationReviewers !== undefined) story.documentationReviewers = updates.documentationReviewers; // Handle epic changes if (updates.epic !== undefined) { const oldEpicId = story.epic; const newEpicId = updates.epic; // Remove from old epic if it exists if (oldEpicId && this.epics.has(oldEpicId)) { const oldEpic = this.epics.get(oldEpicId); oldEpic.storyIds = oldEpic.storyIds.filter(id => id !== storyId); oldEpic.updatedAt = new Date(); this.epics.set(oldEpicId, oldEpic); } // Add to new epic if it exists if (newEpicId && this.epics.has(newEpicId)) { const newEpic = this.epics.get(newEpicId); if (!newEpic.storyIds.includes(storyId)) { newEpic.storyIds.push(storyId); newEpic.updatedAt = new Date(); this.epics.set(newEpicId, newEpic); } story.epic = newEpicId; } else if (newEpicId) { throw new MCPError({ message: `Epic ${newEpicId} not found`, code: 'EPIC_NOT_FOUND', category: 'validation', details: { epicId: newEpicId, storyId }, recoverable: false, suggestedActions: [ 'Check that the epic ID is correct', 'Use list_epics to see available epics', 'Create the epic first before assigning stories' ] }); } else { story.epic = undefined; } await this.saveEpics(); } // Handle acceptance criteria updates if (updates.acceptanceCriteria !== undefined) { if (typeof updates.acceptanceCriteria[0] === 'string') { // Legacy format - convert strings to structured criteria story.acceptanceCriteria = updates.acceptanceCriteria.map((desc, idx) => ({ id: `ac-${Date.now()}-${idx}`, description: desc, status: 'pending', })); story.acceptanceCriteriaLegacy = updates.acceptanceCriteria; } else { // New structured format story.acceptanceCriteria = updates.acceptanceCriteria; } } // Update enhanced properties if (updates.dependencies !== undefined) story.dependencies = updates.dependencies; if (updates.timeTracking !== undefined) { story.timeTracking = { ...story.timeTracking, ...updates.timeTracking }; } // Handle time tracking if (updates.hoursSpent !== undefined) { story.hoursSpent = updates.hoursSpent; if (!story.timeTracking) { story.timeTracking = { estimated: 0, actual: updates.hoursSpent, remaining: 0, logs: [] }; } else { story.timeTracking.actual = updates.hoursSpent; } } story.updatedAt = new Date(); this.stories.set(storyId, story); await this.saveStories(); return story; } async generateBurndownChart(sprintId) { if (!this.sprints.has(sprintId)) { throw new Error(`Sprint ${sprintId} not found`); } const sprint = this.sprints.get(sprintId); const sprintStories = sprint.storyIds .map(id => this.stories.get(id)) .filter(story => story !== undefined); const totalStoryPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0); const completedPoints = sprintStories .filter(story => story.status === 'done') .reduce((sum, story) => sum + story.storyPoints, 0); const sprintStart = sprint.startDate; const sprintEnd = sprint.endDate; const totalDays = Math.ceil((sprintEnd.getTime() - sprintStart.getTime()) / (1000 * 60 * 60 * 24)); const currentDate = new Date(); const daysElapsed = Math.min(Math.ceil((currentDate.getTime() - sprintStart.getTime()) / (1000 * 60 * 60 * 24)), totalDays); const idealBurnRate = totalStoryPoints / totalDays; const dailyData = []; for (let day = 0; day <= Math.min(daysElapsed, totalDays); day++) { const idealRemaining = Math.max(0, totalStoryPoints - (idealBurnRate * day)); const actualRemaining = day === daysElapsed ? totalStoryPoints - completedPoints : idealRemaining; dailyData.push({ day, idealPoints: idealRemaining, actualPoints: actualRemaining, remainingPoints: actualRemaining, completedPoints: totalStoryPoints - actualRemaining, }); } const onTrack = completedPoints >= (totalStoryPoints * (daysElapsed / totalDays)) * 0.8; return { sprintId, totalStoryPoints, completedPoints, sprintDays: totalDays, dailyData, onTrack, daysRemaining: Math.max(0, totalDays - daysElapsed), trend: completedPoints > 0 ? 'improving' : 'stable', }; } async generateVelocityReport(options) { const completedSprints = Array.from(this.sprints.values()) .filter(sprint => sprint.status === 'completed') .sort((a, b) => b.endDate.getTime() - a.endDate.getTime()) .slice(0, options.lastNSprints); if (completedSprints.length === 0) { throw new Error('No completed sprints found for velocity calculation'); } const sprintData = completedSprints.map(sprint => { const sprintStories = sprint.storyIds .map(id => this.stories.get(id)) .filter(story => story !== undefined); const velocity = sprintStories .filter(story => story.status === 'done') .reduce((sum, story) => sum + story.storyPoints, 0); const totalPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0); const completionRate = totalPoints > 0 ? Math.round((velocity / totalPoints) * 100) : 0; return { sprintName: sprint.name, velocity, completionRate, startDate: sprint.startDate, endDate: sprint.endDate, }; }); const velocities = sprintData.map(sprint => sprint.velocity); const averageVelocity = Math.round(velocities.reduce((sum, v) => sum + v, 0) / velocities.length); const highestVelocity = Math.max(...velocities); const lowestVelocity = Math.min(...velocities); const trend = velocities.length > 1 ? velocities[0] > velocities[velocities.length - 1] ? 'improving' : 'declining' : 'stable'; const insights = [ `Team completed ${completedSprints.length} sprints`, `Average velocity: ${averageVelocity} story points`, `Velocity range: ${lowestVelocity} - ${highestVelocity} points`, ]; const recommendations = [ averageVelocity < 20 ? 'Consider breaking down stories into smaller tasks' : '', trend === 'declining' ? 'Review team capacity and remove blockers' : '', 'Track velocity consistently for better sprint planning', ].filter(rec => rec !== ''); return { teamName: options.teamName || 'Default Team', sprintsAnalyzed: completedSprints.length, periodStart: completedSprints[completedSprints.length - 1].startDate, periodEnd: completedSprints[0].endDate, averageVelocity, highestVelocity, lowestVelocity, trend, sprintData, insights, recommendations, }; } async getSprintStatus(sprintId) { let sprint; if (sprintId) { if (!this.sprints.has(sprintId)) { throw new Error(`Sprint ${sprintId} not found`); } sprint = this.sprints.get(sprintId); } else { // Get current active sprint const activeSprints = Array.from(this.sprints.values()) .filter(s => s.status === 'active') .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); if (activeSprints.length === 0) { throw new Error('No active sprint found'); } sprint = activeSprints[0]; } // Get full story details const sprintStories = sprint.storyIds .map(id => this.stories.get(id)) .filter(story => story !== undefined); // Get unique epic IDs from stories const epicIds = new Set(); sprintStories.forEach(story => { if (story.epic) { epicIds.add(story.epic); } }); // Get full epic details const epics = Array.from(epicIds) .map(epicId => this.epics.get(epicId)) .filter(epic => epic !== undefined); const totalStories = sprintStories.length; const completedStories = sprintStories.filter(story => story.status === 'done').length; const inProgressStories = sprintStories.filter(story => story.status === 'in_progress').length; const remainingStories = totalStories - completedStories - inProgressStories; const totalStoryPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0); const completedStoryPoints = sprintStories .filter(story => story.status === 'done') .reduce((sum, story) => sum + story.storyPoints, 0); const blockers = this.standups .filter(standup => standup.sprintId === sprint.id) .flatMap(standup => standup.blockers) .filter((blocker, index, arr) => arr.indexOf(blocker) === index); const daysElapsed = Math.ceil((new Date().getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)); const sprintDuration = Math.ceil((sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)); const daysRemaining = Math.max(0, sprintDuration - daysElapsed); const currentVelocity = daysElapsed > 0 ? Math.round(completedStoryPoints / daysElapsed) : 0; // Calculate burndown data const burndownData = this.calculateBurndownData(sprint, sprintStories); const completionPercentage = totalStories > 0 ? (completedStories / totalStories) * 100 : 0; const sprintHealth = completionPercentage > 75 ? 'Excellent' : completionPercentage > 50 ? 'Good' : completionPercentage > 25 ? 'At Risk' : 'Critical'; // Team metrics const teamMembers = sprint.team || []; const teamCapacity = sprint.capacity || 0; const utilizationPercentage = teamCapacity > 0 ? (totalStoryPoints / teamCapacity) * 100 : 0; return { sprint, stories: sprintStories, // Full story objects epics, // Full epic objects totalStories, completedStories, inProgressStories, remainingStories, totalStoryPoints, completedStoryPoints, remainingStoryPoints: totalStoryPoints - completedStoryPoints, sprintHealth, blockers, currentVelocity, metrics: { velocity: sprint.velocity || currentVelocity, capacity: teamCapacity, utilization: utilizationPercentage, burndownData, health: sprintHealth.toLowerCase().replace(' ', '-'), daysElapsed, daysRemaining, sprintDuration }, team: { members: teamMembers, availability: teamMembers.length, blockers } }; } calculateBurndownData(sprint, stories) { const sprintDuration = Math.ceil((sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)); const totalStoryPoints = stories.reduce((sum, story) => sum + story.storyPoints, 0); const dailyData = []; const now = new Date(); for (let day = 0; day <= sprintDuration; day++) { const dayDate = new Date(sprint.startDate.getTime() + day * 24 * 60 * 60 * 1000); if (dayDate > now) { // Future date - project ideal burndown const idealRemaining = totalStoryPoints * (1 - (day / sprintDuration)); dailyData.push({ day, date: dayDate, remainingPoints: Math.round(idealRemaining), completedPoints: totalStoryPoints - Math.round(idealRemaining), idealRemaining: Math.round(idealRemaining) }); } else { // Past or current date - calculate actual const completedByDay = stories.filter(story => { return story.status === 'done' && story.completedAt && story.completedAt <= dayDate; }).reduce((sum, story) => sum + story.storyPoints, 0); dailyData.push({ day, date: dayDate, remainingPoints: totalStoryPoints - completedByDay, completedPoints: completedByDay, idealRemaining: Math.round(totalStoryPoints * (1 - (day / sprintDuration))) }); } } const currentDay = Math.min(Math.floor((now.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)), sprintDuration); const completedPoints = stories .filter(story => story.status === 'done') .reduce((sum, story) => sum + story.storyPoints, 0); const onTrack = completedPoints >= (totalStoryPoints * (currentDay / sprintDuration) * 0.9); return { sprintId: sprint.id, totalStoryPoints, completedPoints, sprintDays: sprintDuration, daysRemaining: Math.max(0, sprintDuration - currentDay), dailyData, onTrack, trend: onTrack ? 'on-track' : 'behind' }; } async getBacklog(filter = {}) { let backlogStories = Array.from(this.stories.values()) .filter(story => !story.sprintId || story.sprintId === ''); // Apply filters if (filter.epic) { backlogStories = backlogStories.filter(story => story.epic === filter.epic); } if (filter.priority) { backlogStories = backlogStories.filter(story => story.priority === filter.priority); } if (filter.assignee) { backlogStories = backlogStories.filter(story => story.assignee === filter.assignee); } if (filter.status) { backlogStories = backlogStories.filter(story => story.status === filter.status); } if (filter.maxStoryPoints) { backlogStories = backlogStories.filter(story => story.storyPoints !== undefined && story.storyPoints <= filter.maxStoryPoints); } // Sort by priority and creation date const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 }; return backlogStories.sort((a, b) => { const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0); if (priorityDiff !== 0) return priorityDiff; return b.createdAt.getTime() - a.createdAt.getTime(); }); } async getSprints(filter) { let sprints = Array.from(this.sprints.values()); // Apply filters if (filter?.status) { sprints = sprints.filter(sprint => sprint.status === filter.status); } if (filter?.includeCompleted === false) { sprints = sprints.filter(sprint => sprint.status !== 'completed'); } // Sort by start date (most recent first) return sprints.sort((a, b) => b.startDate.getTime() - a.startDate.getTime()); } async getSprint(sprintId) { const sprint = this.sprints.get(sprintId); return sprint || null; } async getActiveSprint() { const sprints = Array.from(this.sprints.values()); const activeSprint = sprints.find(sprint => sprint.status === 'active'); return activeSprint || null; } async getEpics(filter) { let epics = Array.from(this.epics.values()); // Apply filters if (filter?.status) { epics = epics.filter(epic => epic.status === filter.status); } if (filter?.owner) { epics = epics.filter(epic => epic.owner === filter.owner); } // Sort by priority and creation date const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 }; return epics.sort((a, b) => { const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0); if (priorityDiff !== 0) return priorityDiff; return b.createdAt.getTime() - a.createdAt.getTime(); }); } async getEpic(epicId) { const epic = this.epics.get(epicId); return epic || null; } async getStory(storyId) { const story = this.stories.get(storyId); return story || null; } async getAllStories() { return Array.from(this.stories.values()); } async getStoriesForSprint(sprintId) { return Array.from(this.stories.values()) .filter(story => story.sprintId === sprintId); } async getEpicsForSprint(sprintId) { const sprint = this.sprints.get(sprintId); if (!sprint) { return []; } const epicIds = new Set(); for (const storyId of sprint.storyIds) { const story = this.stories.get(storyId); if (story && story.epic) { epicIds.add(story.epic); } } const epics = []; for (const epicId of epicIds) { const epic = this.epics.get(epicId); if (epic) { epics.push(epic); } } return epics; } async getStoriesForEpicInSprint(epicId, sprintId) { const sprint = this.sprints.get(sprintId); if (!sprint) { return []; } const stories = []; for (const storyId of sprint.storyIds) { const story = this.stories.get(storyId); if (story && story.epic === epicId) { stories.push(story); } } return stories; } async getSprintProgress(sprintId) { const sprint = this.sprints.get(sprintId); if (!sprint) { return { epicProgress: new Map() }; } const epicProgress = new Map(); for (const storyId of sprint.storyIds) { const story = this.stories.get(storyId); if (story && story.epic) { const current = epicProgress.get(story.epic) || { total: 0, completed: 0 }; current.total += story.storyPoints; if (story.status === 'done') { current.completed += story.storyPoints; } epicProgress.set(story.epic, current); } } return { epicProgress }; } async addEpicToSprint(epicId, sprintId) { const sprint = this.sprints.get(sprintId); const epic = this.epics.get(epicId); if (!sprint) { throw new Error(`Sprint ${sprintId} not found`); } if (!epic) { throw new Error(`Epic ${epicId} not found`); } if (!sprint.epicIds) { sprint.epicIds = []; } if (!sprint.epicIds.includes(epicId)) { sprint.epicIds.push(epicId); this.sprints.set(sprintId, sprint); await this.saveSprints(); } } async removeEpicFromSprint(epicId, sprintId) { const sprint = this.sprints.get(sprintId); if (!sprint) { throw new Error(`Sprint ${sprintId} not found`); } if (sprint.epicIds) { sprint.epicIds = sprint.epicIds.filter(id => id !== epicId); this.sprints.set(sprintId, sprint); await this.saveSprints(); } } async getEpicsForSprintDirect(sprintId) { const sprint = this.sprints.get(sprintId); if (!sprint || !sprint.epicIds) { return []; } const epics = []; for (const epicId of sprint.epicIds) { const epic = this.epics.get(epicId); if (epic) { epics.push(epic); } } return epics; } async saveSprints() { const sprintsPath = path.join(this.agileDataPath, 'sprints.json'); await fs.mkdir(path.dirname(sprintsPath), { recursive: true }); await fs.writeFile(sprintsPath, JSON.stringify(Array.from(this.sprints.values()), null, 2)); } async saveStories() { const storiesPath = path.join(this.agileDataPath, 'stories.json'); await fs.mkdir(path.dirname(storiesPath), { recursive: true }); await fs.writeFile(storiesPath, JSON.stringify(Array.from(this.stories.values()), null, 2)); } async saveEpics() { const epicsPath = path.join(this.agileDataPath, 'epics.json'); await fs.mkdir(path.dirname(epicsPath), { recursive: true }); await fs.writeFile(epicsPath, JSON.stringify(Array.from(this.epics.values()), null, 2)); } async saveStandups() { const standupsPath = path.join(this.agileDataPath, 'standups.json'); await fs.mkdir(path.dirname(standupsPath), { recursive: true }); await fs.writeFile(standupsPath, JSON.stringify(this.standups, null, 2)); } async saveRetrospectives() { const retrospectivesPath = path.join(this.agileDataPath, 'retrospectives.json'); await fs.mkdir(path.dirname(retrospectivesPath), { recursive: true }); await fs.writeFile(retrospectivesPath, JSON.stringify(this.retrospectives, null, 2)); } generateId() { return `${Date.now().toString(16)}-${Math.random().toString(16).substring(2, 10)}`; } async createSam