UNPKG

@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

301 lines (300 loc) 9.28 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { createHmac } from "crypto"; import { LinearSyncEngine } from "./sync.js"; import { LinearAuthManager } from "./auth.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; import { logger } from "../../core/monitoring/logger.js"; import { ClaudeCodeSubagentClient } from "../claude-code/subagent-client.js"; const AUTOMATION_LABELS = ["automated", "claude-code", "stackmemory"]; 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 { taskStore; syncEngine = null; webhookSecret; constructor(taskStore, webhookSecret) { this.taskStore = taskStore; this.webhookSecret = webhookSecret; if (process.env["LINEAR_API_KEY"]) { const authManager = new LinearAuthManager(); this.syncEngine = new LinearSyncEngine(taskStore, authManager, { enabled: true, direction: "from_linear", autoSync: false, conflictResolution: "linear_wins" }); } } /** * Verify webhook signature */ verifySignature(payload, signature) { const hmac = createHmac("sha256", this.webhookSecret); hmac.update(payload); const expectedSignature = hmac.digest("hex"); return signature === expectedSignature; } /** * Handle incoming webhook from Linear */ async handleWebhook(req, res) { try { const rawBody = JSON.stringify(req.body); const signature = req.headers["linear-signature"]; if (!this.verifySignature(rawBody, signature)) { logger.error("Invalid webhook signature"); res.status(401).json({ error: "Invalid signature" }); return; } const payload = req.body; if (payload.type !== "Issue") { res.status(200).json({ message: "Ignored non-issue webhook" }); return; } switch (payload.action) { case "create": await this.handleIssueCreate(payload); break; case "update": await this.handleIssueUpdate(payload); break; case "remove": await this.handleIssueRemove(payload); break; default: logger.warn(`Unknown webhook action: ${payload.action}`); } res.status(200).json({ message: "Webhook processed successfully" }); } catch (error) { logger.error("Failed to process webhook:", error); res.status(500).json({ error: "Failed to process webhook" }); } } /** * Handle issue creation */ async handleIssueCreate(payload) { const issue = payload.data; const existingTasks = this.taskStore.getActiveTasks(); const exists = existingTasks.some( (t) => t.title.includes(issue.identifier) || t.external_refs?.linear === issue.id ); if (exists) { logger.info(`Task ${issue.identifier} already exists locally`); return; } const taskId = this.taskStore.createTask({ title: `[${issue.identifier}] ${issue.title}`, description: issue.description || "", priority: this.mapLinearPriorityToLocal(issue.priority), frameId: "linear-webhook", tags: issue.labels?.map((l) => l.name) || ["linear"], estimatedEffort: issue.estimate ? issue.estimate * 60 : void 0, assignee: issue.assignee?.name }); const status = this.mapLinearStateToLocalStatus(issue.state.type); if (status !== "pending") { this.taskStore.updateTaskStatus( taskId, status, `Synced from Linear (${issue.state.name})` ); } await this.storeLinearMapping(taskId, issue.id, issue.identifier); logger.info(`Created task ${taskId} from Linear issue ${issue.identifier}`); if (this.shouldSpawnSubagent(issue)) { await this.spawnSubagentForIssue(issue); } } /** * Check if issue should trigger subagent spawn */ shouldSpawnSubagent(issue) { if (!issue.labels) return false; const labelNames = issue.labels.map((l) => l.name.toLowerCase()); return AUTOMATION_LABELS.some((label) => labelNames.includes(label)); } /** * Spawn a Claude Code subagent for the issue */ async spawnSubagentForIssue(issue) { logger.info(`Spawning subagent for ${issue.identifier}`); try { const client = new ClaudeCodeSubagentClient(); const agentType = this.determineAgentType(issue.labels || []); const task = this.buildTaskPrompt(issue); const sourceUrl = this.extractSourceUrl(issue.description); const result = await client.executeSubagent({ type: agentType, task, context: { linearIssueId: issue.id, linearIdentifier: issue.identifier, linearUrl: issue.url, sourceUrl: sourceUrl || issue.url }, timeout: 5 * 60 * 1e3 // 5 min }); logger.info(`Subagent completed for ${issue.identifier}`, { sessionId: result.sessionId, status: result.status }); } catch (error) { logger.error(`Failed to spawn subagent for ${issue.identifier}`, { error: error instanceof Error ? error.message : "Unknown error" }); } } /** * Determine agent type from issue labels */ determineAgentType(labels) { const lowerLabels = labels.map((l) => l.name.toLowerCase()); if (lowerLabels.some((l) => l.includes("review") || l.includes("pr"))) { return "review"; } if (lowerLabels.some((l) => l.includes("explore") || l.includes("research"))) { return "context"; } return "code"; } /** * Build task prompt from Linear issue */ buildTaskPrompt(issue) { const parts = [`Linear Issue: ${issue.identifier} - ${issue.title}`]; if (issue.description) { const quoteMatch = issue.description.match(/^>\s*(.+?)(?:\n\n|$)/s); if (quoteMatch) { parts.push("", "Context:", quoteMatch[1].replace(/^>\s*/gm, "")); } else { parts.push("", "Description:", issue.description); } } parts.push("", `URL: ${issue.url}`); return parts.join("\n"); } /** * Extract source URL from description */ extractSourceUrl(description) { if (!description) return void 0; const urlMatch = description.match(/\*\*Source:\*\*\s*\[.+?\]\((.+?)\)/); return urlMatch?.[1]; } /** * Handle issue update */ async handleIssueUpdate(payload) { const issue = payload.data; const tasks = this.taskStore.getActiveTasks(); const localTask = tasks.find( (t) => t.title.includes(issue.identifier) || t.external_refs?.linear === issue.id ); if (!localTask) { await this.handleIssueCreate(payload); return; } const newStatus = this.mapLinearStateToLocalStatus(issue.state.type); if (newStatus !== localTask.status) { this.taskStore.updateTaskStatus( localTask.id, newStatus, `Updated from Linear (${issue.state.name})` ); } const newPriority = this.mapLinearPriorityToLocal(issue.priority); if (newPriority !== localTask.priority) { logger.info( `Priority changed for ${issue.identifier}: ${localTask.priority} -> ${newPriority}` ); } logger.info( `Updated task ${localTask.id} from Linear issue ${issue.identifier}` ); } /** * Handle issue removal */ async handleIssueRemove(payload) { const issue = payload.data; const tasks = this.taskStore.getActiveTasks(); const localTask = tasks.find( (t) => t.title.includes(issue.identifier) || t.external_refs?.linear === issue.id ); if (localTask) { this.taskStore.updateTaskStatus( localTask.id, "cancelled", `Removed in Linear` ); logger.info( `Cancelled task ${localTask.id} (Linear issue ${issue.identifier} was removed)` ); } } /** * Store Linear mapping for a task */ async storeLinearMapping(taskId, linearId, linearIdentifier) { logger.info( `Mapped task ${taskId} to Linear ${linearIdentifier} (${linearId})` ); } /** * Map Linear priority to local priority */ mapLinearPriorityToLocal(priority) { if (!priority) return "medium"; switch (priority) { case 0: return "urgent"; case 1: return "high"; case 2: return "medium"; case 3: case 4: return "low"; default: return "medium"; } } /** * Map Linear state to local status */ mapLinearStateToLocalStatus(state) { switch (state) { case "backlog": case "unstarted": return "pending"; case "started": return "in_progress"; case "completed": return "completed"; case "cancelled": return "cancelled"; default: return "pending"; } } } export { LinearWebhookHandler };