@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.
302 lines (301 loc) • 9.32 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 { 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
};
//# sourceMappingURL=webhook-handler.js.map