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.

362 lines (360 loc) 10.9 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { LinearRestClient } from "./rest-client.js"; import { logger } from "../../core/monitoring/logger.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; import chalk from "chalk"; class LinearMigrator { sourceClient; targetClient; config; constructor(config) { this.config = config; this.sourceClient = new LinearRestClient(config.sourceApiKey); this.targetClient = new LinearRestClient(config.targetApiKey); } /** * Test connections to both workspaces */ async testConnections() { const result = { source: { success: false }, target: { success: false } }; try { const sourceViewer = await this.sourceClient.getViewer(); const sourceTeam = await this.sourceClient.getTeam(); result.source = { success: true, info: { user: sourceViewer, team: sourceTeam } }; } catch (error) { result.source = { success: false, error: error.message }; } try { const targetViewer = await this.targetClient.getViewer(); const targetTeam = await this.targetClient.getTeam(); result.target = { success: true, info: { user: targetViewer, team: targetTeam } }; } catch (error) { result.target = { success: false, error: error.message }; } return result; } /** * Migrate all tasks from source to target workspace */ async migrate() { const result = { totalTasks: 0, exported: 0, imported: 0, failed: 0, deleted: 0, deleteFailed: 0, errors: [], taskMappings: [] }; try { console.log(chalk.yellow("Starting Linear workspace migration...")); const sourceTasks = await this.sourceClient.getAllTasks(true); result.totalTasks = sourceTasks.length; console.log( chalk.cyan(`Found ${sourceTasks.length} tasks in source workspace`) ); let tasksToMigrate = sourceTasks; if (this.config.taskPrefix) { tasksToMigrate = sourceTasks.filter( (task) => task.identifier.startsWith(this.config.taskPrefix) ); console.log( chalk.cyan( `Filtered to ${tasksToMigrate.length} tasks with prefix "${this.config.taskPrefix}"` ) ); } if (this.config.includeStates?.length) { tasksToMigrate = tasksToMigrate.filter( (task) => this.config.includeStates.includes(task.state.type) ); const stateStr = this.config.includeStates.join(", "); console.log( chalk.cyan( `Further filtered to ${tasksToMigrate.length} tasks matching states: ${stateStr}` ) ); } result.exported = tasksToMigrate.length; if (this.config.dryRun) { console.log(chalk.yellow("DRY RUN - No tasks will be created")); tasksToMigrate.forEach((task) => { result.taskMappings.push({ sourceId: task.id, sourceIdentifier: task.identifier, targetId: "DRY_RUN", targetIdentifier: "DRY_RUN" }); }); result.imported = tasksToMigrate.length; return result; } const targetTeam = await this.targetClient.getTeam(); console.log( chalk.cyan(`Target team: ${targetTeam.name} (${targetTeam.key})`) ); const batchSize = this.config.batchSize || 5; const delayMs = this.config.delayMs || 2e3; for (let i = 0; i < tasksToMigrate.length; i += batchSize) { const batch = tasksToMigrate.slice(i, i + batchSize); console.log( chalk.yellow( `Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(tasksToMigrate.length / batchSize)}` ) ); for (const task of batch) { try { const newTask = await this.migrateTask(task, targetTeam.id); const mapping = { sourceId: task.id, sourceIdentifier: task.identifier, targetId: newTask.id, targetIdentifier: newTask.identifier, deleted: false }; result.imported++; console.log( chalk.green( `${task.identifier} \u2192 ${newTask.identifier}: ${task.title}` ) ); if (this.config.deleteFromSource) { try { await this.deleteTask(task.id); mapping.deleted = true; result.deleted++; console.log( chalk.gray(`Deleted ${task.identifier} from source`) ); } catch (deleteError) { result.deleteFailed++; result.errors.push( `Delete failed for ${task.identifier}: ${deleteError.message}` ); console.log( chalk.yellow( `Failed to delete ${task.identifier} from source: ${deleteError.message}` ) ); } } result.taskMappings.push(mapping); } catch (error) { const errorMsg = error.message; result.errors.push(`${task.identifier}: ${errorMsg}`); result.taskMappings.push({ sourceId: task.id, sourceIdentifier: task.identifier, error: errorMsg }); result.failed++; console.log(chalk.red(`${task.identifier}: ${errorMsg}`)); } } if (i + batchSize < tasksToMigrate.length) { console.log(chalk.gray(`Waiting ${delayMs}ms before next batch...`)); await this.delay(delayMs); } } } catch (error) { result.errors.push(`Migration failed: ${error.message}`); logger.error("Migration failed:", error); } return result; } /** * Migrate a single task */ async migrateTask(sourceTask, targetTeamId) { const stateMapping = { backlog: "backlog", unstarted: "unstarted", started: "started", completed: "completed", canceled: "canceled" }; const createTaskQuery = ` mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title description state { id name type } priority createdAt updatedAt url } } } `; const taskInput = { title: `[MIGRATED] ${sourceTask.title}`, description: this.formatMigratedDescription(sourceTask), teamId: targetTeamId, priority: this.mapPriority(sourceTask.priority) }; const response = await this.targetClient.makeRequest(createTaskQuery, { input: taskInput }); if (!response.data?.issueCreate?.success) { throw new IntegrationError( "Failed to create task in target workspace", ErrorCode.LINEAR_SYNC_FAILED, { sourceTaskId: sourceTask.id, sourceIdentifier: sourceTask.identifier } ); } return response.data.issueCreate.issue; } /** * Format description with migration context */ formatMigratedDescription(sourceTask) { let description = sourceTask.description || ""; description += ` --- **Migration Info:** `; description += `- Original ID: ${sourceTask.identifier} `; description += `- Migrated: ${(/* @__PURE__ */ new Date()).toISOString()} `; description += `- Original State: ${sourceTask.state.name} `; if (sourceTask.assignee) { description += `- Original Assignee: ${sourceTask.assignee.name} `; } if (sourceTask.estimate) { description += `- Original Estimate: ${sourceTask.estimate} points `; } return description; } /** * Map priority values */ mapPriority(priority) { return priority || 0; } /** * Delete a task from the source workspace */ async deleteTask(taskId) { const deleteQuery = ` mutation DeleteIssue($id: String!) { issueDelete(id: $id) { success } } `; const response = await this.sourceClient.makeRequest(deleteQuery, { id: taskId }); if (!response.data?.issueDelete?.success) { throw new IntegrationError( "Failed to delete task from source workspace", ErrorCode.LINEAR_SYNC_FAILED, { taskId } ); } } /** * Delay helper */ delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } } async function runMigration(config) { const migrator = new LinearMigrator(config); console.log(chalk.blue("Testing connections...")); const connectionTest = await migrator.testConnections(); if (!connectionTest.source.success) { console.error( chalk.red(`Source connection failed: ${connectionTest.source.error}`) ); return; } if (!connectionTest.target.success) { console.error( chalk.red(`Target connection failed: ${connectionTest.target.error}`) ); return; } console.log(chalk.green("Both connections successful")); console.log( chalk.cyan( `Source: ${connectionTest.source.info.user.name} @ ${connectionTest.source.info.team.name}` ) ); console.log( chalk.cyan( `Target: ${connectionTest.target.info.user.name} @ ${connectionTest.target.info.team.name}` ) ); const result = await migrator.migrate(); console.log(chalk.blue("\nMigration Summary:")); console.log(` Total tasks: ${result.totalTasks}`); console.log(` Exported: ${result.exported}`); console.log(chalk.green(` Imported: ${result.imported}`)); console.log(chalk.red(` Failed: ${result.failed}`)); if (config.deleteFromSource) { console.log(chalk.gray(` Deleted: ${result.deleted}`)); if (result.deleteFailed > 0) { console.log(chalk.yellow(` Delete failed: ${result.deleteFailed}`)); } } if (result.errors.length > 0) { console.log(chalk.red("\nErrors:")); result.errors.forEach((error) => console.log(chalk.red(` - ${error}`))); } if (result.imported > 0) { console.log( chalk.green( ` Migration completed! ${result.imported} tasks migrated successfully.` ) ); if (config.deleteFromSource && result.deleted > 0) { console.log( chalk.gray(` ${result.deleted} tasks deleted from source workspace.`) ); } } } export { LinearMigrator, runMigration }; //# sourceMappingURL=migration.js.map