UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

396 lines (348 loc) 11.5 kB
import { ConfigManager } from '../../config/config-manager.js'; import { Issue, Comment, Attachment, IssueFilter, IssueStats, IssueTemplate, Milestone } from './types.js'; import { promises as fs } from 'fs'; import path from 'path'; export class IssueTracker { private configManager: ConfigManager; private issuesPath: string; private templatesPath: string; private milestonesPath: string; constructor(configManager: ConfigManager) { this.configManager = configManager; this.issuesPath = ''; this.templatesPath = ''; this.milestonesPath = ''; } async init(): Promise<void> { const storageManager = this.configManager.getStorageManager(); const location = await storageManager.getStorageLocation(); const dataPath = path.join(location.data, 'issue-tracking'); this.issuesPath = path.join(dataPath, 'issues'); this.templatesPath = path.join(dataPath, 'templates'); this.milestonesPath = path.join(dataPath, 'milestones'); await this.ensureDirectories(); await this.createDefaultTemplates(); } private async ensureDirectories(): Promise<void> { await fs.mkdir(this.issuesPath, { recursive: true }); await fs.mkdir(this.templatesPath, { recursive: true }); await fs.mkdir(this.milestonesPath, { recursive: true }); } async createIssue(data: { type: Issue['type']; title: string; description: string; priority: Issue['priority']; createdBy: string; labels?: string[]; affectedModules?: string[]; assignedTo?: string; }): Promise<Issue> { const issue: Issue = { id: this.generateId(), type: data.type, title: data.title, description: data.description, status: 'open', priority: data.priority, createdAt: new Date().toISOString(), createdBy: data.createdBy, updatedAt: new Date().toISOString(), assignedTo: data.assignedTo, labels: data.labels || [], affectedModules: data.affectedModules || [], relatedIssues: [], attachments: [], comments: [] }; await this.saveIssue(issue); return issue; } async updateIssue(issueId: string, updates: Partial<Issue>): Promise<Issue> { const issue = await this.getIssue(issueId); const updatedIssue = { ...issue, ...updates, id: issue.id, createdAt: issue.createdAt, createdBy: issue.createdBy, updatedAt: new Date().toISOString() }; if (updates.status === 'resolved' || updates.status === 'closed') { updatedIssue.closedAt = new Date().toISOString(); } await this.saveIssue(updatedIssue); return updatedIssue; } async addComment(issueId: string, data: { author: string; content: string; type?: Comment['type']; }): Promise<Comment> { const issue = await this.getIssue(issueId); const comment: Comment = { id: this.generateId(), issueId, author: data.author, content: data.content, createdAt: new Date().toISOString(), type: data.type || 'comment' }; issue.comments.push(comment); issue.updatedAt = new Date().toISOString(); await this.saveIssue(issue); return comment; } async searchIssues(filter: IssueFilter): Promise<Issue[]> { const allIssues = await this.getAllIssues(); return allIssues.filter(issue => { if (filter.type && !filter.type.includes(issue.type)) return false; if (filter.status && !filter.status.includes(issue.status)) return false; if (filter.priority && !filter.priority.includes(issue.priority)) return false; if (filter.assignedTo && issue.assignedTo !== filter.assignedTo) return false; if (filter.createdBy && issue.createdBy !== filter.createdBy) return false; if (filter.labels && filter.labels.length > 0) { const hasAllLabels = filter.labels.every(label => issue.labels.includes(label)); if (!hasAllLabels) return false; } if (filter.affectedModules && filter.affectedModules.length > 0) { const hasModule = filter.affectedModules.some(module => issue.affectedModules.includes(module) ); if (!hasModule) return false; } if (filter.dateRange) { const createdDate = new Date(issue.createdAt); const startDate = new Date(filter.dateRange.start); const endDate = new Date(filter.dateRange.end); if (createdDate < startDate || createdDate > endDate) return false; } if (filter.searchQuery) { const query = filter.searchQuery.toLowerCase(); const searchableText = `${issue.title} ${issue.description} ${issue.comments.map(c => c.content).join(' ')}`.toLowerCase(); if (!searchableText.includes(query)) return false; } return true; }); } async getIssueStats(filter?: IssueFilter): Promise<IssueStats> { const issues = filter ? await this.searchIssues(filter) : await this.getAllIssues(); const stats: IssueStats = { total: issues.length, byType: { bug: 0, feature: 0, enhancement: 0, documentation: 0, question: 0 }, byStatus: { open: 0, 'in-progress': 0, resolved: 0, closed: 0, 'wont-fix': 0 }, byPriority: { critical: 0, high: 0, medium: 0, low: 0 }, averageResolutionTime: 0, trends: { period: 'weekly', opened: [], closed: [] } }; let totalResolutionTime = 0; let resolvedCount = 0; let oldestOpen: Issue | undefined; for (const issue of issues) { stats.byType[issue.type]++; stats.byStatus[issue.status]++; stats.byPriority[issue.priority]++; if (issue.closedAt) { const resolutionTime = new Date(issue.closedAt).getTime() - new Date(issue.createdAt).getTime(); totalResolutionTime += resolutionTime; resolvedCount++; } if (issue.status === 'open' && (!oldestOpen || issue.createdAt < oldestOpen.createdAt)) { oldestOpen = issue; } } stats.averageResolutionTime = resolvedCount > 0 ? totalResolutionTime / resolvedCount : 0; stats.oldestOpenIssue = oldestOpen; const moduleCounts: Record<string, number> = {}; for (const issue of issues) { for (const module of issue.affectedModules) { moduleCounts[module] = (moduleCounts[module] || 0) + 1; } } const sortedModules = Object.entries(moduleCounts).sort((a, b) => b[1] - a[1]); if (sortedModules.length > 0) { stats.mostActiveModule = sortedModules[0][0]; } return stats; } async createMilestone(data: { title: string; description: string; dueDate: string; issues?: string[]; }): Promise<Milestone> { const milestone: Milestone = { id: this.generateId(), title: data.title, description: data.description, dueDate: data.dueDate, status: new Date(data.dueDate) > new Date() ? 'active' : 'overdue', issues: data.issues || [], progress: { total: 0, completed: 0, percentage: 0 } }; await this.updateMilestoneProgress(milestone); await this.saveMilestone(milestone); return milestone; } async updateMilestoneProgress(milestone: Milestone): Promise<void> { const issues = await Promise.all( milestone.issues.map(id => this.getIssue(id).catch(() => null)) ); const validIssues = issues.filter(Boolean) as Issue[]; const completedIssues = validIssues.filter(i => i.status === 'resolved' || i.status === 'closed' ); milestone.progress = { total: validIssues.length, completed: completedIssues.length, percentage: validIssues.length > 0 ? Math.round((completedIssues.length / validIssues.length) * 100) : 0 }; } private async createDefaultTemplates(): Promise<void> { const bugTemplate: IssueTemplate = { type: 'bug', name: 'Bug Report', description: 'Report a bug or unexpected behavior', sections: [ { title: 'Description', prompt: 'A clear description of the bug', required: true, type: 'multiline' }, { title: 'Steps to Reproduce', prompt: 'Step-by-step instructions to reproduce the issue', required: true, type: 'multiline' }, { title: 'Expected Behavior', prompt: 'What should happen', required: true, type: 'text' }, { title: 'Actual Behavior', prompt: 'What actually happens', required: true, type: 'text' }, { title: 'Environment', prompt: 'OS, Node version, etc.', required: false, type: 'multiline' } ], defaultLabels: ['bug'], defaultPriority: 'medium' }; const featureTemplate: IssueTemplate = { type: 'feature', name: 'Feature Request', description: 'Suggest a new feature', sections: [ { title: 'Feature Description', prompt: 'Describe the feature you would like', required: true, type: 'multiline' }, { title: 'Use Case', prompt: 'Why is this feature needed?', required: true, type: 'multiline' }, { title: 'Proposed Solution', prompt: 'How might this work?', required: false, type: 'multiline' }, { title: 'Alternatives', prompt: 'Have you considered any alternatives?', required: false, type: 'multiline' } ], defaultLabels: ['enhancement'], defaultPriority: 'medium' }; await this.saveTemplate(bugTemplate); await this.saveTemplate(featureTemplate); } private async getIssue(issueId: string): Promise<Issue> { const issuePath = path.join(this.issuesPath, `${issueId}.json`); const content = await fs.readFile(issuePath, 'utf-8'); return JSON.parse(content); } private async getAllIssues(): Promise<Issue[]> { try { const files = await fs.readdir(this.issuesPath); const issues = await Promise.all( files .filter(f => f.endsWith('.json')) .map(async f => { const content = await fs.readFile(path.join(this.issuesPath, f), 'utf-8'); return JSON.parse(content) as Issue; }) ); return issues; } catch { return []; } } private async saveIssue(issue: Issue): Promise<void> { const issuePath = path.join(this.issuesPath, `${issue.id}.json`); await fs.writeFile(issuePath, JSON.stringify(issue, null, 2)); } private async saveMilestone(milestone: Milestone): Promise<void> { const milestonePath = path.join(this.milestonesPath, `${milestone.id}.json`); await fs.writeFile(milestonePath, JSON.stringify(milestone, null, 2)); } private async saveTemplate(template: IssueTemplate): Promise<void> { const templatePath = path.join(this.templatesPath, `${template.type}.json`); await fs.writeFile(templatePath, JSON.stringify(template, null, 2)); } private generateId(): string { return `issue_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } }