@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
439 lines (373 loc) • 15.1 kB
text/typescript
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'
};
}
}