@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.
232 lines (231 loc) • 7.2 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 { LinearClient } from "./client.js";
import { ContextService } from "../../services/context-service.js";
import { ConfigService } from "../../services/config-service.js";
import { logger } from "../../core/monitoring/logger.js";
import { IntegrationError, ErrorCode } from "../../core/errors/index.js";
class LinearSyncService {
linearClient;
contextService;
configService;
// Using singleton logger from monitoring
constructor() {
this.configService = new ConfigService();
this.contextService = new ContextService();
const apiKey = process.env["LINEAR_API_KEY"];
if (!apiKey) {
throw new IntegrationError(
"LINEAR_API_KEY environment variable not set",
ErrorCode.LINEAR_AUTH_FAILED
);
}
this.linearClient = new LinearClient({ apiKey });
}
async syncAllIssues() {
const result = {
created: 0,
updated: 0,
deleted: 0,
conflicts: 0,
errors: []
};
try {
const config = await this.configService.getConfig();
const teamId = config.integrations?.linear?.teamId;
if (!teamId) {
throw new IntegrationError(
"Linear team ID not configured",
ErrorCode.LINEAR_SYNC_FAILED
);
}
const issues = await this.linearClient.getIssues({ teamId });
for (const issue of issues) {
try {
const synced = await this.syncIssueToLocal(issue);
if (synced === "created") result.created++;
else if (synced === "updated") result.updated++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.errors.push(`Failed to sync ${issue.identifier}: ${message}`);
}
}
logger.info(
`Sync complete: ${result.created} created, ${result.updated} updated`
);
} catch (error) {
logger.error("Sync failed:", error);
const message = error instanceof Error ? error.message : String(error);
result.errors.push(message);
}
return result;
}
async syncIssueToLocal(issue) {
try {
const task = this.convertIssueToTask(issue);
const existingTask = await this.contextService.getTaskByExternalId(
issue.id
);
if (existingTask) {
if (this.hasChanges(existingTask, task)) {
await this.contextService.updateTask(existingTask.id, task);
logger.debug(`Updated task: ${issue.identifier}`);
return "updated";
}
return "skipped";
} else {
await this.contextService.createTask(task);
logger.debug(`Created task: ${issue.identifier}`);
return "created";
}
} catch (error) {
logger.error(`Failed to sync issue ${issue.identifier}:`, error);
throw error;
}
}
async syncLocalToLinear(taskId) {
try {
const task = await this.contextService.getTask(taskId);
if (!task) {
throw new IntegrationError(
`Task ${taskId} not found`,
ErrorCode.LINEAR_SYNC_FAILED,
{ taskId }
);
}
if (task.externalId) {
const updateData = this.convertTaskToUpdateData(task);
const updated = await this.linearClient.updateIssue(
task.externalId,
updateData
);
logger.debug(`Updated Linear issue: ${updated.identifier}`);
return updated;
} else {
const config = await this.configService.getConfig();
const teamId = config.integrations?.linear?.teamId;
if (!teamId) {
throw new IntegrationError(
"Linear team ID not configured",
ErrorCode.LINEAR_SYNC_FAILED
);
}
const createData = {
title: task.title,
description: task.description,
teamId,
priority: this.mapTaskPriorityToLinearPriority(task.priority)
};
const created = await this.linearClient.createIssue(createData);
await this.contextService.updateTask(taskId, {
externalId: created.id
});
logger.debug(`Created Linear issue: ${created.identifier}`);
return created;
}
} catch (error) {
logger.error(`Failed to sync task ${taskId} to Linear:`, error);
throw error;
}
}
async removeLocalIssue(identifier) {
try {
const tasks = await this.contextService.getAllTasks();
const task = tasks.find((t) => t.externalIdentifier === identifier);
if (task) {
await this.contextService.deleteTask(task.id);
logger.debug(`Removed local task: ${identifier}`);
}
} catch (error) {
logger.error(`Failed to remove task ${identifier}:`, error);
throw error;
}
}
convertIssueToTask(issue) {
return {
title: issue.title,
description: issue.description || "",
status: this.mapLinearStateToTaskStatus(issue.state.type),
priority: this.mapLinearPriorityToTaskPriority(issue.priority),
externalId: issue.id,
externalIdentifier: issue.identifier,
externalUrl: issue.url,
tags: issue.labels?.map((l) => l.name) || [],
metadata: {
linear: {
stateId: issue.state.id,
stateName: issue.state.name,
assigneeId: issue.assignee?.id,
assigneeName: issue.assignee?.name
}
},
updatedAt: new Date(issue.updatedAt)
};
}
convertTaskToUpdateData(task) {
return {
title: task.title,
description: task.description,
priority: this.mapTaskPriorityToLinearPriority(task.priority),
stateId: task.metadata?.linear?.stateId
};
}
mapLinearStateToTaskStatus(state) {
switch (state.toLowerCase()) {
case "backlog":
case "triage":
return "todo";
case "unstarted":
case "todo":
return "todo";
case "started":
case "in_progress":
return "in_progress";
case "completed":
case "done":
return "done";
case "canceled":
case "cancelled":
return "cancelled";
default:
return "todo";
}
}
mapTaskPriorityToLinearPriority(priority) {
switch (priority) {
case "urgent":
return 1;
case "high":
return 2;
case "medium":
return 3;
case "low":
return 4;
default:
return 0;
}
}
mapLinearPriorityToTaskPriority(priority) {
switch (priority) {
case 1:
return "urgent";
case 2:
return "high";
case 3:
return "medium";
case 4:
return "low";
default:
return void 0;
}
}
hasChanges(existing, updated) {
return existing.title !== updated.title || existing.description !== updated.description || existing.status !== updated.status || existing.priority !== updated.priority || JSON.stringify(existing.tags) !== JSON.stringify(updated.tags);
}
}
export {
LinearSyncService
};
//# sourceMappingURL=sync-service.js.map