@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
1,157 lines • 57.2 kB
JavaScript
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