@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
361 lines (359 loc) • 10.9 kB
JavaScript
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
};