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.

292 lines (291 loc) 8.52 kB
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 }; //# sourceMappingURL=webhook.js.map