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