UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

439 lines (373 loc) 15.1 kB
import { promises as fs } from 'fs'; import { join } from 'path'; import { ProductRequirement, CreateRequirementOptions, UpdateRequirementOptions, RequirementFilter, RequirementSearchOptions, RequirementStoryLink, RequirementsReport, ImplementationStatusUpdate, RequirementChange } from './types.js'; export class ProductRequirementsStore { private requirementsPath: string; private linksPath: string; private changesPath: string; constructor(dataDir: string = '.atlas') { this.requirementsPath = join(dataDir, 'requirements.json'); this.linksPath = join(dataDir, 'requirement-links.json'); this.changesPath = join(dataDir, 'requirement-changes.json'); } private async ensureDataDir(): Promise<void> { try { await fs.mkdir('.atlas', { recursive: true }); } catch (error) { // Directory might already exist } } private async loadRequirements(): Promise<ProductRequirement[]> { try { const data = await fs.readFile(this.requirementsPath, 'utf-8'); return JSON.parse(data); } catch (error) { return []; } } private async saveRequirements(requirements: ProductRequirement[]): Promise<void> { await this.ensureDataDir(); await fs.writeFile(this.requirementsPath, JSON.stringify(requirements, null, 2)); } private async loadLinks(): Promise<RequirementStoryLink[]> { try { const data = await fs.readFile(this.linksPath, 'utf-8'); return JSON.parse(data); } catch (error) { return []; } } private async saveLinks(links: RequirementStoryLink[]): Promise<void> { await this.ensureDataDir(); await fs.writeFile(this.linksPath, JSON.stringify(links, null, 2)); } private async loadChanges(): Promise<RequirementChange[]> { try { const data = await fs.readFile(this.changesPath, 'utf-8'); return JSON.parse(data); } catch (error) { return []; } } private async saveChanges(changes: RequirementChange[]): Promise<void> { await this.ensureDataDir(); await fs.writeFile(this.changesPath, JSON.stringify(changes, null, 2)); } private async recordChange(requirementId: string, field: string, oldValue: any, newValue: any): Promise<void> { const changes = await this.loadChanges(); changes.push({ requirement_id: requirementId, field, old_value: oldValue, new_value: newValue, changed_by: 'system', changed_at: new Date().toISOString() }); await this.saveChanges(changes); } async createRequirement(options: CreateRequirementOptions): Promise<ProductRequirement> { const requirements = await this.loadRequirements(); const requirement: ProductRequirement = { id: `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, name: options.name, type: options.type, description: options.description, acceptance_criteria: options.acceptance_criteria || [], priority: options.priority || 'medium', status: options.status || 'draft', repositories: options.repositories || [], related_stories: options.related_stories || [], tags: options.tags || [], created_at: new Date().toISOString(), updated_at: new Date().toISOString(), version: 1, business_value: options.business_value, technical_notes: options.technical_notes, test_criteria: options.test_criteria || [], compliance_requirements: options.compliance_requirements || [], estimated_effort: options.estimated_effort, target_release: options.target_release, parent_requirement_id: options.parent_requirement_id, child_requirement_ids: [] }; // Update parent's child list if this is a child requirement if (options.parent_requirement_id) { const parentIndex = requirements.findIndex(r => r.id === options.parent_requirement_id); if (parentIndex !== -1) { requirements[parentIndex].child_requirement_ids = requirements[parentIndex].child_requirement_ids || []; requirements[parentIndex].child_requirement_ids.push(requirement.id); } } requirements.push(requirement); await this.saveRequirements(requirements); return requirement; } async getRequirement(id: string): Promise<ProductRequirement | null> { const requirements = await this.loadRequirements(); return requirements.find(r => r.id === id) || null; } async updateRequirement(options: UpdateRequirementOptions): Promise<ProductRequirement | null> { const requirements = await this.loadRequirements(); const index = requirements.findIndex(r => r.id === options.id); if (index === -1) { return null; } const oldRequirement = { ...requirements[index] }; const updatedRequirement = { ...requirements[index] }; // Track changes for each modified field const fieldsToUpdate = Object.keys(options).filter(key => key !== 'id'); for (const field of fieldsToUpdate) { const oldValue = (oldRequirement as any)[field]; const newValue = (options as any)[field]; if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { (updatedRequirement as any)[field] = newValue; await this.recordChange(options.id, field, oldValue, newValue); } } updatedRequirement.updated_at = new Date().toISOString(); updatedRequirement.version = (updatedRequirement.version || 1) + 1; requirements[index] = updatedRequirement; await this.saveRequirements(requirements); return updatedRequirement; } async deleteRequirement(id: string): Promise<boolean> { const requirements = await this.loadRequirements(); const index = requirements.findIndex(r => r.id === id); if (index === -1) { return false; } const requirement = requirements[index]; // Remove from parent's child list if this is a child requirement if (requirement.parent_requirement_id) { const parentIndex = requirements.findIndex(r => r.id === requirement.parent_requirement_id); if (parentIndex !== -1 && requirements[parentIndex].child_requirement_ids) { requirements[parentIndex].child_requirement_ids = requirements[parentIndex].child_requirement_ids!.filter(childId => childId !== id); } } // Remove all links for this requirement const links = await this.loadLinks(); const updatedLinks = links.filter(l => l.requirement_id !== id); await this.saveLinks(updatedLinks); requirements.splice(index, 1); await this.saveRequirements(requirements); return true; } async listRequirements(filter?: RequirementFilter): Promise<ProductRequirement[]> { const requirements = await this.loadRequirements(); if (!filter) { return requirements; } return requirements.filter(req => { if (filter.type && req.type !== filter.type) return false; if (filter.status && req.status !== filter.status) return false; if (filter.priority && req.priority !== filter.priority) return false; if (filter.repository) { const hasRepo = req.repositories?.some(r => r.name === filter.repository); if (!hasRepo) return false; } if (filter.tag) { const hasTag = req.tags?.includes(filter.tag); if (!hasTag) return false; } if (filter.parent_id && req.parent_requirement_id !== filter.parent_id) return false; if (filter.has_stories !== undefined) { const hasStories = req.related_stories && req.related_stories.length > 0; if (filter.has_stories !== hasStories) return false; } if (filter.implementation_status) { const hasStatus = req.repositories?.some(r => r.implementation_status === filter.implementation_status); if (!hasStatus) return false; } return true; }); } async searchRequirements(options: RequirementSearchOptions): Promise<ProductRequirement[]> { const requirements = await this.loadRequirements(); const query = options.query.toLowerCase(); const searchFields = options.search_in || ['name', 'description']; let results = requirements.filter(req => { for (const field of searchFields) { switch (field) { case 'name': if (req.name.toLowerCase().includes(query)) return true; break; case 'description': if (req.description.toLowerCase().includes(query)) return true; break; case 'acceptance_criteria': if (req.acceptance_criteria?.some(ac => ac.toLowerCase().includes(query))) return true; break; case 'tags': if (req.tags?.some(tag => tag.toLowerCase().includes(query))) return true; break; } } return false; }); // Apply additional filters if provided if (options.filter) { results = results.filter(req => { const filter = options.filter!; if (filter.type && req.type !== filter.type) return false; if (filter.status && req.status !== filter.status) return false; if (filter.priority && req.priority !== filter.priority) return false; return true; }); } // Apply pagination const offset = options.offset || 0; const limit = options.limit || results.length; return results.slice(offset, offset + limit); } async linkRequirementToStory(requirementId: string, storyId: string, linkType?: string, notes?: string): Promise<RequirementStoryLink> { const requirements = await this.loadRequirements(); const requirement = requirements.find(r => r.id === requirementId); if (!requirement) { throw new Error(`Requirement ${requirementId} not found`); } // Update requirement's related stories if (!requirement.related_stories) { requirement.related_stories = []; } if (!requirement.related_stories.includes(storyId)) { requirement.related_stories.push(storyId); await this.saveRequirements(requirements); } // Create link record const links = await this.loadLinks(); const link: RequirementStoryLink = { requirement_id: requirementId, story_id: storyId, link_type: linkType as any || 'implements', notes, created_at: new Date().toISOString() }; links.push(link); await this.saveLinks(links); return link; } async getRequirementsByStory(storyId: string): Promise<ProductRequirement[]> { const links = await this.loadLinks(); const requirementIds = links .filter(l => l.story_id === storyId) .map(l => l.requirement_id); const requirements = await this.loadRequirements(); return requirements.filter(r => requirementIds.includes(r.id)); } async getStoriesByRequirement(requirementId: string): Promise<string[]> { const requirement = await this.getRequirement(requirementId); return requirement?.related_stories || []; } async updateImplementationStatus(update: ImplementationStatusUpdate): Promise<ProductRequirement | null> { const requirements = await this.loadRequirements(); const requirement = requirements.find(r => r.id === update.requirement_id); if (!requirement) { return null; } if (!requirement.repositories) { requirement.repositories = []; } const repoIndex = requirement.repositories.findIndex(r => r.name === update.repository_name); if (repoIndex === -1) { // Add new repository requirement.repositories.push({ name: update.repository_name, implementation_status: update.status, branch: update.branch, pr_url: update.pr_url, notes: update.notes }); } else { // Update existing repository requirement.repositories[repoIndex] = { ...requirement.repositories[repoIndex], implementation_status: update.status, branch: update.branch || requirement.repositories[repoIndex].branch, pr_url: update.pr_url || requirement.repositories[repoIndex].pr_url, notes: update.notes || requirement.repositories[repoIndex].notes }; } requirement.updated_at = new Date().toISOString(); await this.saveRequirements(requirements); return requirement; } async getRequirementsByStatus(status: ProductRequirement['status']): Promise<ProductRequirement[]> { const requirements = await this.loadRequirements(); return requirements.filter(r => r.status === status); } async generateRequirementsReport(options?: { format?: 'json' | 'markdown' | 'html'; include_implementation_status?: boolean; filter?: RequirementFilter; group_by?: 'type' | 'status' | 'priority'; }): Promise<RequirementsReport> { const requirements = options?.filter ? await this.listRequirements(options.filter) : await this.loadRequirements(); const summary = { total_requirements: requirements.length, by_type: {} as Record<string, number>, by_status: {} as Record<string, number>, by_priority: {} as Record<string, number>, implementation_progress: { not_started: 0, in_progress: 0, completed: 0, blocked: 0 } }; // Calculate summary statistics for (const req of requirements) { // By type summary.by_type[req.type] = (summary.by_type[req.type] || 0) + 1; // By status summary.by_status[req.status!] = (summary.by_status[req.status!] || 0) + 1; // By priority summary.by_priority[req.priority!] = (summary.by_priority[req.priority!] || 0) + 1; // Implementation progress if (req.repositories && req.repositories.length > 0) { // Check if all repos are completed const allCompleted = req.repositories.every(r => r.implementation_status === 'completed'); const anyInProgress = req.repositories.some(r => r.implementation_status === 'in_progress'); const anyBlocked = req.repositories.some(r => r.implementation_status === 'blocked'); if (allCompleted) { summary.implementation_progress.completed++; } else if (anyBlocked) { summary.implementation_progress.blocked++; } else if (anyInProgress) { summary.implementation_progress.in_progress++; } else { summary.implementation_progress.not_started++; } } else { summary.implementation_progress.not_started++; } } // Sort requirements based on group_by option let sortedRequirements = [...requirements]; if (options?.group_by) { sortedRequirements.sort((a, b) => { const aValue = a[options.group_by as keyof ProductRequirement] as string; const bValue = b[options.group_by as keyof ProductRequirement] as string; return aValue.localeCompare(bValue); }); } return { summary, requirements: sortedRequirements, generated_at: new Date().toISOString(), format: options?.format || 'json' }; } }