UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

590 lines (582 loc) 17.5 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { LinearClient } from "./client.js"; import { LinearDuplicateDetector } from "./sync.js"; import { logger } from "../../core/monitoring/logger.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { join, dirname } from "path"; import { EventEmitter } from "events"; const DEFAULT_UNIFIED_CONFIG = { enabled: true, direction: "bidirectional", duplicateDetection: true, duplicateSimilarityThreshold: 0.85, mergeStrategy: "merge_content", conflictResolution: "newest_wins", taskPlanningEnabled: true, taskPlanFile: ".stackmemory/task-plan.md", autoCreateTaskPlan: true, maxBatchSize: 50, rateLimitDelay: 100, maxRetries: 3, autoSync: false, autoSyncInterval: 15 }; class UnifiedLinearSync extends EventEmitter { config; linearClient; taskStore; authManager; duplicateDetector; projectRoot; mappings = /* @__PURE__ */ new Map(); // task.id -> linear.id lastSyncStats; syncInProgress = false; constructor(taskStore, authManager, projectRoot, config) { super(); this.taskStore = taskStore; this.authManager = authManager; this.projectRoot = projectRoot; this.config = { ...DEFAULT_UNIFIED_CONFIG, ...config }; this.linearClient = null; this.duplicateDetector = null; this.loadMappings(); } /** * Initialize the sync system */ async initialize() { try { const token = await this.authManager.getValidToken(); if (!token) { throw new IntegrationError( 'Linear authentication required. Run "stackmemory linear auth" first.', ErrorCode.LINEAR_AUTH_FAILED ); } const isOAuth = this.authManager.isOAuth(); this.linearClient = new LinearClient({ apiKey: token, useBearer: isOAuth, teamId: this.config.defaultTeamId, onUnauthorized: isOAuth ? async () => { const refreshed = await this.authManager.refreshAccessToken(); return refreshed.accessToken; } : void 0 }); if (this.config.duplicateDetection) { this.duplicateDetector = new LinearDuplicateDetector(this.linearClient); } if (this.config.taskPlanningEnabled) { await this.initializeTaskPlanning(); } logger.info("Unified Linear sync initialized", { direction: this.config.direction, duplicateDetection: this.config.duplicateDetection, taskPlanning: this.config.taskPlanningEnabled }); } catch (error) { logger.error("Failed to initialize Linear sync:", error); throw error; } } /** * Main sync method - orchestrates bidirectional sync */ async sync() { if (this.syncInProgress) { throw new IntegrationError( "Sync already in progress", ErrorCode.LINEAR_SYNC_FAILED ); } this.syncInProgress = true; const startTime = Date.now(); const stats = { toLinear: { created: 0, updated: 0, skipped: 0, duplicatesMerged: 0 }, fromLinear: { created: 0, updated: 0, skipped: 0 }, conflicts: [], errors: [], duration: 0, timestamp: Date.now() }; try { this.emit("sync:started", { config: this.config }); switch (this.config.direction) { case "bidirectional": await this.syncFromLinear(stats); await this.syncToLinear(stats); break; case "from_linear": await this.syncFromLinear(stats); break; case "to_linear": await this.syncToLinear(stats); break; } if (this.config.taskPlanningEnabled) { await this.updateTaskPlan(stats); } this.saveMappings(); stats.duration = Date.now() - startTime; this.lastSyncStats = stats; this.emit("sync:completed", { stats }); logger.info("Unified sync completed", { duration: `${stats.duration}ms`, toLinear: stats.toLinear, fromLinear: stats.fromLinear, conflicts: stats.conflicts.length }); return stats; } catch (error) { stats.errors.push(error.message); stats.duration = Date.now() - startTime; this.emit("sync:failed", { stats, error }); logger.error("Unified sync failed:", error); throw error; } finally { this.syncInProgress = false; } } /** * Sync from Linear to local tasks */ async syncFromLinear(stats) { try { logger.debug("Syncing from Linear..."); const teamId = this.config.defaultTeamId || await this.getDefaultTeamId(); const issues = await this.linearClient.getIssues({ teamId, limit: this.config.maxBatchSize }); for (const issue of issues) { try { await this.delay(this.config.rateLimitDelay); const localTaskId = this.findLocalTaskByLinearId(issue.id); if (localTaskId) { const localTask = await this.taskStore.getTask(localTaskId); if (localTask && this.hasChanges(localTask, issue)) { await this.updateLocalTask(localTask, issue); stats.fromLinear.updated++; } else { stats.fromLinear.skipped++; } } else { await this.createLocalTask(issue); stats.fromLinear.created++; } } catch (error) { stats.errors.push( `Failed to sync issue ${issue.identifier}: ${error.message}` ); } } } catch (error) { logger.error("Failed to sync from Linear:", error); throw error; } } /** * Sync local tasks to Linear */ async syncToLinear(stats) { try { logger.debug("Syncing to Linear..."); const tasks = await this.taskStore.getAllTasks(); const teamId = this.config.defaultTeamId || await this.getDefaultTeamId(); for (const task of tasks) { try { await this.delay(this.config.rateLimitDelay); const linearId = this.mappings.get(task.id); if (linearId) { const linearIssue = await this.linearClient.getIssue(linearId); if (linearIssue && this.taskNeedsUpdate(task, linearIssue)) { await this.updateLinearIssue(linearIssue, task); stats.toLinear.updated++; } else { stats.toLinear.skipped++; } } else { if (this.config.duplicateDetection) { const duplicateCheck = await this.duplicateDetector.checkForDuplicate( task.title, teamId ); if (duplicateCheck.isDuplicate && duplicateCheck.existingIssue) { if (this.config.mergeStrategy === "merge_content") { await this.mergeTaskIntoLinear( task, duplicateCheck.existingIssue ); this.mappings.set(task.id, duplicateCheck.existingIssue.id); stats.toLinear.duplicatesMerged++; } else if (this.config.mergeStrategy === "skip") { stats.toLinear.skipped++; continue; } } else { await this.createLinearIssue(task, teamId); stats.toLinear.created++; } } else { await this.createLinearIssue(task, teamId); stats.toLinear.created++; } } } catch (error) { stats.errors.push( `Failed to sync task ${task.id}: ${error.message}` ); } } } catch (error) { logger.error("Failed to sync to Linear:", error); throw error; } } /** * Initialize task planning system */ async initializeTaskPlanning() { const planFile = join(this.projectRoot, this.config.taskPlanFile); const planDir = dirname(planFile); if (!existsSync(planDir)) { mkdirSync(planDir, { recursive: true }); } if (!existsSync(planFile) && this.config.autoCreateTaskPlan) { const defaultPlan = { version: "1.0.0", lastUpdated: /* @__PURE__ */ new Date(), phases: [ { name: "Backlog", description: "Tasks to be prioritized", tasks: [] }, { name: "Current Sprint", description: "Active work items", tasks: [] }, { name: "Completed", description: "Finished tasks", tasks: [] } ] }; this.saveTaskPlan(defaultPlan); logger.info("Created default task plan", { path: planFile }); } } /** * Update task plan with sync results */ async updateTaskPlan(stats) { if (!this.config.taskPlanningEnabled) return; try { const plan = this.loadTaskPlan(); const tasks = await this.taskStore.getAllTasks(); plan.phases = [ { name: "Backlog", description: "Tasks to be prioritized", tasks: tasks.filter((t) => t.status === "todo").map((t) => ({ id: t.id, title: t.title, priority: t.priority || "medium", status: t.status, linearId: this.mappings.get(t.id) })) }, { name: "In Progress", description: "Active work items", tasks: tasks.filter((t) => t.status === "in_progress").map((t) => ({ id: t.id, title: t.title, priority: t.priority || "medium", status: t.status, linearId: this.mappings.get(t.id) })) }, { name: "Completed", description: "Finished tasks", tasks: tasks.filter((t) => t.status === "done").slice(-20).map((t) => ({ id: t.id, title: t.title, priority: t.priority || "medium", status: t.status, linearId: this.mappings.get(t.id) })) } ]; plan.lastUpdated = /* @__PURE__ */ new Date(); this.saveTaskPlan(plan); this.generateTaskReport(plan, stats); } catch (error) { logger.error("Failed to update task plan:", error); } } /** * Generate markdown task report */ generateTaskReport(plan, stats) { const reportFile = join(this.projectRoot, ".stackmemory", "task-report.md"); let content = `# Task Sync Report `; content += `**Last Updated:** ${plan.lastUpdated.toLocaleString()} `; content += `**Sync Duration:** ${stats.duration}ms `; content += `## Sync Statistics `; content += `### To Linear `; content += `- Created: ${stats.toLinear.created} `; content += `- Updated: ${stats.toLinear.updated} `; content += `- Duplicates Merged: ${stats.toLinear.duplicatesMerged} `; content += `- Skipped: ${stats.toLinear.skipped} `; content += `### From Linear `; content += `- Created: ${stats.fromLinear.created} `; content += `- Updated: ${stats.fromLinear.updated} `; content += `- Skipped: ${stats.fromLinear.skipped} `; if (stats.conflicts.length > 0) { content += `### Conflicts `; stats.conflicts.forEach((c) => { content += `- **${c.taskId}**: ${c.reason} (${c.resolution}) `; }); content += "\n"; } content += `## Task Overview `; plan.phases.forEach((phase) => { content += `### ${phase.name} (${phase.tasks.length}) `; content += `> ${phase.description} `; if (phase.tasks.length > 0) { phase.tasks.slice(0, 10).forEach((task) => { const linearLink = task.linearId ? ` [Linear]` : ""; content += `- **${task.title}**${linearLink} `; }); if (phase.tasks.length > 10) { content += `- _...and ${phase.tasks.length - 10} more_ `; } } content += "\n"; }); writeFileSync(reportFile, content); logger.debug("Task report generated", { path: reportFile }); } /** * Helper methods */ async getDefaultTeamId() { const teams = await this.linearClient.getTeams(); if (teams.length === 0) { throw new IntegrationError( "No Linear teams found", ErrorCode.LINEAR_API_ERROR ); } return teams[0].id; } findLocalTaskByLinearId(linearId) { for (const [taskId, linId] of this.mappings) { if (linId === linearId) return taskId; } return void 0; } hasChanges(localTask, linearIssue) { return localTask.title !== linearIssue.title || localTask.description !== (linearIssue.description || "") || this.mapLinearStateToStatus(linearIssue.state.type) !== localTask.status; } taskNeedsUpdate(task, linearIssue) { return task.title !== linearIssue.title || task.description !== (linearIssue.description || "") || task.status !== this.mapLinearStateToStatus(linearIssue.state.type); } async createLocalTask(issue) { const task = await this.taskStore.createTask({ title: issue.title, description: issue.description || "", status: this.mapLinearStateToStatus(issue.state.type), priority: this.mapLinearPriorityToPriority(issue.priority), metadata: { linear: { id: issue.id, identifier: issue.identifier, url: issue.url } } }); this.mappings.set(task.id, issue.id); } async updateLocalTask(task, issue) { await this.taskStore.updateTask(task.id, { title: issue.title, description: issue.description || "", status: this.mapLinearStateToStatus(issue.state.type), priority: this.mapLinearPriorityToPriority(issue.priority) }); } async createLinearIssue(task, teamId) { const input = { title: task.title, description: task.description || "", teamId, priority: this.mapPriorityToLinearPriority(task.priority) }; const issue = await this.linearClient.createIssue(input); this.mappings.set(task.id, issue.id); await this.taskStore.updateTask(task.id, { metadata: { ...task.metadata, linear: { id: issue.id, identifier: issue.identifier, url: issue.url } } }); } async updateLinearIssue(issue, task) { await this.linearClient.updateIssue(issue.id, { title: task.title, description: task.description, priority: this.mapPriorityToLinearPriority(task.priority) }); } async mergeTaskIntoLinear(task, existingIssue) { await this.duplicateDetector.mergeIntoExisting( existingIssue, task.title, task.description, `StackMemory Task: ${task.id} Merged: ${(/* @__PURE__ */ new Date()).toISOString()}` ); } mapLinearStateToStatus(state) { switch (state.toLowerCase()) { case "backlog": case "unstarted": return "todo"; case "started": return "in_progress"; case "completed": return "done"; case "cancelled": return "cancelled"; default: return "todo"; } } mapLinearPriorityToPriority(priority) { switch (priority) { case 1: return "urgent"; case 2: return "high"; case 3: return "medium"; case 4: return "low"; default: return void 0; } } mapPriorityToLinearPriority(priority) { switch (priority) { case "urgent": return 1; case "high": return 2; case "medium": return 3; case "low": return 4; default: return 0; } } loadMappings() { const mappingFile = join( this.projectRoot, ".stackmemory", "linear-mappings.json" ); if (existsSync(mappingFile)) { try { const data = JSON.parse(readFileSync(mappingFile, "utf8")); this.mappings = new Map(Object.entries(data)); } catch (error) { logger.error("Failed to load mappings:", error); } } } saveMappings() { const mappingFile = join( this.projectRoot, ".stackmemory", "linear-mappings.json" ); const data = Object.fromEntries(this.mappings); writeFileSync(mappingFile, JSON.stringify(data, null, 2)); } loadTaskPlan() { const planFile = join(this.projectRoot, this.config.taskPlanFile); if (existsSync(planFile)) { try { return JSON.parse(readFileSync(planFile, "utf8")); } catch (error) { logger.error("Failed to load task plan:", error); } } return { version: "1.0.0", lastUpdated: /* @__PURE__ */ new Date(), phases: [] }; } saveTaskPlan(plan) { const planFile = join(this.projectRoot, this.config.taskPlanFile); writeFileSync(planFile, JSON.stringify(plan, null, 2)); } delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get last sync statistics */ getLastSyncStats() { return this.lastSyncStats; } /** * Clear duplicate detector cache */ clearCache() { if (this.duplicateDetector) { this.duplicateDetector.clearCache(); } } } export { DEFAULT_UNIFIED_CONFIG, UnifiedLinearSync }; //# sourceMappingURL=unified-sync.js.map