@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
291 lines (290 loc) • 8.48 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 { logger } from "../../core/monitoring/logger.js";
import { IntegrationError, ErrorCode } from "../../core/errors/index.js";
import crypto from "crypto";
function _getEnv(key, defaultValue) {
const value = process.env[key];
if (value === void 0) {
if (defaultValue !== void 0) return defaultValue;
throw new IntegrationError(
`Environment variable ${key} is required`,
ErrorCode.LINEAR_WEBHOOK_FAILED
);
}
return value;
}
function _getOptionalEnv(key) {
return process.env[key];
}
class LinearWebhookHandler {
syncEngine;
taskStore;
webhookSecret;
constructor(webhookSecret) {
this.webhookSecret = webhookSecret || process.env["LINEAR_WEBHOOK_SECRET"];
}
/**
* Set the sync engine for processing webhooks
*/
setSyncEngine(syncEngine) {
this.syncEngine = syncEngine;
}
/**
* Set the task store for direct updates
*/
setTaskStore(taskStore) {
this.taskStore = taskStore;
}
/**
* Verify webhook signature
*/
verifySignature(body, signature) {
if (!this.webhookSecret) {
logger.warn("No webhook secret configured, skipping verification");
return true;
}
const hmac = crypto.createHmac("sha256", this.webhookSecret);
hmac.update(body);
const expectedSignature = hmac.digest("hex");
return signature === expectedSignature;
}
/**
* Validate webhook payload structure
*/
validateWebhookPayload(payload) {
if (!payload || typeof payload !== "object") return null;
const p = payload;
if (!p.action || typeof p.action !== "string") return null;
if (!p.type || typeof p.type !== "string") return null;
if (!p.data || typeof p.data !== "object") return null;
if (!p.data.id || typeof p.data.id !== "string") return null;
if (p.data.title && typeof p.data.title === "string") {
p.data.title = p.data.title.substring(0, 500);
}
if (p.data.description && typeof p.data.description === "string") {
p.data.description = p.data.description.substring(0, 5e3);
}
return p;
}
/**
* Process incoming webhook
*/
async processWebhook(payload) {
const validatedPayload = this.validateWebhookPayload(payload);
if (!validatedPayload) {
logger.error("Invalid webhook payload received");
throw new IntegrationError(
"Invalid webhook payload",
ErrorCode.LINEAR_WEBHOOK_FAILED
);
}
logger.info("Processing Linear webhook", {
action: validatedPayload.action,
type: validatedPayload.type,
id: validatedPayload.data.id
});
payload = validatedPayload;
if (payload.type !== "Issue") {
logger.info(`Ignoring webhook for type: ${payload.type}`);
return;
}
switch (payload.action) {
case "create":
await this.handleIssueCreated(payload);
break;
case "update":
await this.handleIssueUpdated(payload);
break;
case "remove":
await this.handleIssueRemoved(payload);
break;
default:
logger.warn(`Unknown webhook action: ${payload.action}`);
}
}
/**
* Handle issue created in Linear
*/
async handleIssueCreated(payload) {
logger.info("Linear issue created", {
identifier: payload.data.identifier
});
if (!this.shouldSyncIssue(payload.data)) {
return;
}
logger.info("Would create StackMemory task for Linear issue", {
identifier: payload.data.identifier,
title: payload.data.title
});
if (this.taskStore) {
try {
const taskId = this.taskStore.createTask({
frameId: "linear-import",
// Special frame for Linear imports
title: payload.data.title || "Untitled Linear Issue",
description: payload.data.description || "",
priority: this.mapLinearPriorityToStackMemory(payload.data.priority),
assignee: payload.data.assignee?.email,
tags: payload.data.labels?.map((l) => l.name) || []
});
this.storeMapping(taskId, payload.data.id);
logger.info("Created StackMemory task from Linear issue", {
stackmemoryId: taskId,
linearId: payload.data.id
});
} catch (error) {
logger.error("Failed to create task from Linear issue", {
error: error instanceof Error ? error.message : String(error)
});
}
}
}
/**
* Handle issue updated in Linear
*/
async handleIssueUpdated(payload) {
logger.info("Linear issue updated", {
identifier: payload.data.identifier
});
if (!this.syncEngine) {
logger.warn("No sync engine configured, cannot process update");
return;
}
const mapping = this.findMappingByLinearId(payload.data.id);
if (!mapping) {
logger.info("No mapping found for Linear issue", { id: payload.data.id });
return;
}
const task = this.taskStore?.getTask(mapping.stackmemoryId);
if (!task) {
logger.warn("StackMemory task not found", { id: mapping.stackmemoryId });
return;
}
let newStatus;
if (payload.data.state) {
const mappedStatus = this.mapLinearStateToStatus(payload.data.state);
if (mappedStatus !== task.status) {
newStatus = mappedStatus;
}
}
if (payload.data.completedAt) {
newStatus = "completed";
}
if (newStatus) {
this.taskStore?.updateTaskStatus(
mapping.stackmemoryId,
newStatus,
"Linear webhook update"
);
logger.info("Updated StackMemory task status from webhook", {
taskId: mapping.stackmemoryId,
newStatus
});
}
if (payload.data.title && payload.data.title !== task.title) {
logger.info(
"Task title changed in Linear but not updated in StackMemory",
{
taskId: mapping.stackmemoryId,
oldTitle: task.title,
newTitle: payload.data.title
}
);
}
}
/**
* Handle issue removed in Linear
*/
async handleIssueRemoved(payload) {
logger.info("Linear issue removed", {
identifier: payload.data.identifier
});
const mapping = this.findMappingByLinearId(payload.data.id);
if (!mapping) {
logger.info("No mapping found for removed Linear issue");
return;
}
this.taskStore?.updateTaskStatus(
mapping.stackmemoryId,
"cancelled",
"Linear issue deleted"
);
logger.info("Marked StackMemory task as cancelled due to Linear deletion", {
taskId: mapping.stackmemoryId
});
}
/**
* Check if we should sync this issue
*/
shouldSyncIssue(issue) {
if (!issue.title) {
return false;
}
if (issue.state?.type === "canceled" || issue.state?.type === "archived") {
return false;
}
return true;
}
/**
* Find mapping by Linear ID
*/
findMappingByLinearId(linearId) {
const mapping = this.taskMappings.get(linearId);
if (mapping) {
return { stackmemoryId: mapping, linearId };
}
return null;
}
// In-memory task mappings (Linear ID -> StackMemory ID)
taskMappings = /* @__PURE__ */ new Map();
/**
* Store mapping between Linear and StackMemory IDs
*/
storeMapping(stackmemoryId, linearId) {
this.taskMappings.set(linearId, stackmemoryId);
}
/**
* Map Linear priority to StackMemory priority
*/
mapLinearPriorityToStackMemory(priority) {
if (!priority) return "medium";
if (priority <= 1) return "urgent";
if (priority === 2) return "high";
if (priority === 3) return "medium";
return "low";
}
/**
* Map Linear state to StackMemory status
*/
mapLinearStateToStatus(state) {
const stateType = state.type?.toLowerCase() || state.name?.toLowerCase();
switch (stateType) {
case "backlog":
case "unstarted":
return "pending";
case "started":
case "in progress":
return "in_progress";
case "completed":
case "done":
return "completed";
case "canceled":
case "cancelled":
return "cancelled";
default:
return "pending";
}
}
/**
* Map Linear priority to StackMemory priority
*/
mapLinearPriorityToPriority(priority) {
return 5 - priority;
}
}
export {
LinearWebhookHandler
};