@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
323 lines • 12.1 kB
JavaScript
import { promises as fs } from 'fs';
import path from 'path';
export class IssueTracker {
configManager;
issuesPath;
templatesPath;
milestonesPath;
constructor(configManager) {
this.configManager = configManager;
this.issuesPath = '';
this.templatesPath = '';
this.milestonesPath = '';
}
async init() {
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();
}
async ensureDirectories() {
await fs.mkdir(this.issuesPath, { recursive: true });
await fs.mkdir(this.templatesPath, { recursive: true });
await fs.mkdir(this.milestonesPath, { recursive: true });
}
async createIssue(data) {
const 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, updates) {
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, data) {
const issue = await this.getIssue(issueId);
const 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) {
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) {
const issues = filter ? await this.searchIssues(filter) : await this.getAllIssues();
const stats = {
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;
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 = {};
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) {
const 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) {
const issues = await Promise.all(milestone.issues.map(id => this.getIssue(id).catch(() => null)));
const validIssues = issues.filter(Boolean);
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
};
}
async createDefaultTemplates() {
const bugTemplate = {
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 = {
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);
}
async getIssue(issueId) {
const issuePath = path.join(this.issuesPath, `${issueId}.json`);
const content = await fs.readFile(issuePath, 'utf-8');
return JSON.parse(content);
}
async getAllIssues() {
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);
}));
return issues;
}
catch {
return [];
}
}
async saveIssue(issue) {
const issuePath = path.join(this.issuesPath, `${issue.id}.json`);
await fs.writeFile(issuePath, JSON.stringify(issue, null, 2));
}
async saveMilestone(milestone) {
const milestonePath = path.join(this.milestonesPath, `${milestone.id}.json`);
await fs.writeFile(milestonePath, JSON.stringify(milestone, null, 2));
}
async saveTemplate(template) {
const templatePath = path.join(this.templatesPath, `${template.type}.json`);
await fs.writeFile(templatePath, JSON.stringify(template, null, 2));
}
generateId() {
return `issue_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
//# sourceMappingURL=issue-tracker.js.map