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.

303 lines (262 loc) โ€ข 9.55 kB
#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load environment variables from .env file dotenv.config({ path: path.join(__dirname, '..', '.env'), debug: false, override: true, silent: true }); // Debug: Check if key is loaded console.log(`API Key loaded: ${(process.env.STACKMEMORY_LINEAR_API_KEY || process.env.LINEAR_API_KEY) ? 'Yes' : 'No'} (length: ${(process.env.STACKMEMORY_LINEAR_API_KEY || process.env.LINEAR_API_KEY)?.length || 0})`); async function queryLinear(query, variables = {}) { const response = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': process.env.STACKMEMORY_LINEAR_API_KEY || process.env.LINEAR_API_KEY }, body: JSON.stringify({ query, variables }) }); const data = await response.json(); if (data.errors) { throw new Error(data.errors[0].message); } return data.data; } async function syncLinearTasks() { const apiKey = process.env.STACKMEMORY_LINEAR_API_KEY || process.env.LINEAR_API_KEY; if (!apiKey) { console.error('โŒ LINEAR_API_KEY not found in environment'); process.exit(1); } // Check for --mirror flag to do a complete replacement const isMirrorMode = process.argv.includes('--mirror'); if (isMirrorMode) { console.log('๐Ÿ”„ Running in MIRROR mode - will replace all local tasks with Linear tasks...'); } else { console.log('๐Ÿ”„ Connecting to Linear API...'); } try { // Test connection const viewer = await queryLinear('{ viewer { id email name } }'); console.log(`โœ… Connected as: ${viewer.viewer.name || viewer.viewer.email}`); // Get teams const teamsData = await queryLinear(` query { teams { nodes { id key name } } } `); console.log(`\n๐Ÿ“‹ Found ${teamsData.teams.nodes.length} teams:`); for (const team of teamsData.teams.nodes) { console.log(` - ${team.key}: ${team.name}`); } // Get ALL issues from all teams using pagination let allIssues = []; let hasNextPage = true; let endCursor = null; while (hasNextPage) { const issuesData = await queryLinear(` query($after: String) { issues(first: 100, orderBy: updatedAt, after: $after) { nodes { id identifier title description state { name type } priority priorityLabel team { key name } assignee { name email } createdAt updatedAt url } pageInfo { hasNextPage endCursor } } } `, { after: endCursor }); allIssues = allIssues.concat(issuesData.issues.nodes); hasNextPage = issuesData.issues.pageInfo.hasNextPage; endCursor = issuesData.issues.pageInfo.endCursor; if (hasNextPage) { console.log(` Fetched ${allIssues.length} issues so far...`); } } const issuesData = { issues: { nodes: allIssues } }; console.log(`\n๐Ÿ“ฅ Found ${issuesData.issues.nodes.length} issues total`); // Group by team const issuesByTeam = {}; for (const issue of issuesData.issues.nodes) { const teamKey = issue.team.key; if (!issuesByTeam[teamKey]) { issuesByTeam[teamKey] = []; } issuesByTeam[teamKey].push(issue); } for (const [teamKey, issues] of Object.entries(issuesByTeam)) { console.log(` ${teamKey}: ${issues.length} issues`); } // Load local tasks const tasksFile = path.join(__dirname, '..', '.stackmemory', 'tasks.jsonl'); let localTasks = []; const localLinearIds = new Set(); if (isMirrorMode) { // In mirror mode, we'll replace everything console.log('\n๐Ÿงน Mirror mode: Clearing existing tasks...'); if (fs.existsSync(tasksFile)) { fs.writeFileSync(tasksFile, ''); // Clear the file } } else if (fs.existsSync(tasksFile)) { const lines = fs.readFileSync(tasksFile, 'utf8').split('\n').filter(l => l.trim()); for (const line of lines) { try { const task = JSON.parse(line); if (task.type === 'task_create' || task.type === 'task_update') { localTasks.push(task); const match = task.title?.match(/\[(STA-\d+|ENG-\d+)\]/); if (match) { localLinearIds.add(match[1]); } } } catch (e) { // Skip invalid lines } } } console.log(`\n๐Ÿ“‚ Local tasks: ${localTasks.length}`); console.log(`๐ŸŒ Linear issues: ${issuesData.issues.nodes.length}`); // In mirror mode, sync ALL issues; otherwise just missing ones const issuesToSync = isMirrorMode ? issuesData.issues.nodes : []; if (!isMirrorMode) { // Find issues not in local tasks for (const issue of issuesData.issues.nodes) { if (!localLinearIds.has(issue.identifier)) { issuesToSync.push(issue); } } } if (issuesToSync.length > 0) { const modeText = isMirrorMode ? 'Mirroring ALL' : 'Adding new'; console.log(`\n๐Ÿ†• ${modeText} Linear issues (${issuesToSync.length}):`); const newTasks = []; const displayLimit = Math.min(issuesToSync.length, 20); for (let i = 0; i < issuesToSync.length; i++) { const issue = issuesToSync[i]; if (i < displayLimit) { console.log(` - ${issue.identifier}: ${issue.title}`); } const taskId = `tsk-${Math.random().toString(36).substr(2, 8)}`; const task = { id: taskId, type: 'task_create', timestamp: Date.now(), frame_id: 'linear-sync', title: `[${issue.identifier}] ${issue.title}`, description: issue.description || '', status: mapLinearState(issue.state.type), priority: mapLinearPriority(issue.priority), created_at: Date.now(), depends_on: [], blocks: [], tags: ['linear', 'synced', issue.team.key.toLowerCase()], context_score: 0.5, external_refs: { linear_id: issue.id, linear_identifier: issue.identifier, linear_url: issue.url, team: issue.team.key, assignee: issue.assignee?.email || null, state_name: issue.state.name } }; newTasks.push(JSON.stringify(task)); } if (issuesToSync.length > displayLimit) { console.log(` ... and ${issuesToSync.length - displayLimit} more`); } if (newTasks.length > 0) { console.log(`\n๐Ÿ’พ Writing ${newTasks.length} tasks to local storage...`); fs.appendFileSync(tasksFile, newTasks.join('\n') + '\n'); console.log('โœ… Tasks synced successfully!'); } } else if (!isMirrorMode) { console.log('\nโœ… All Linear issues already exist locally'); } // Find local tasks not in Linear const linearIds = new Set(issuesData.issues.nodes.map(i => i.identifier)); const missingInLinear = localTasks.filter(task => { const match = task.title?.match(/\[(STA-\d+|ENG-\d+)\]/); return match && !linearIds.has(match[1]); }); if (missingInLinear.length > 0) { console.log(`\n๐Ÿ“ค Local tasks not in Linear (${missingInLinear.length}):`); for (const task of missingInLinear.slice(0, 10)) { const match = task.title?.match(/\[(STA-\d+|ENG-\d+)\]/); console.log(` - ${match?.[1] || 'Unknown'}: ${task.title}`); } if (missingInLinear.length > 10) { console.log(` ... and ${missingInLinear.length - 10} more`); } console.log('\n๐Ÿ’ก These may be deleted Linear issues or local-only tasks'); } // Summary console.log('\n๐Ÿ“Š Sync Summary:'); console.log(` Total Linear issues: ${issuesData.issues.nodes.length}`); console.log(` Total local tasks: ${localTasks.length}`); if (!isMirrorMode) { console.log(` Added to local: ${issuesToSync.length}`); console.log(` Local-only tasks: ${missingInLinear.length}`); } else { console.log(` Mirrored: ${issuesToSync.length} tasks`); } } catch (error) { console.error('โŒ Error syncing with Linear:', error.message); if (error.message.includes('authentication') || error.message.includes('401')) { console.log('\n๐Ÿ”‘ Authentication failed. Please check your LINEAR_API_KEY'); } } } function mapLinearState(state) { switch (state) { case 'completed': return 'completed'; case 'started': return 'in_progress'; case 'canceled': return 'cancelled'; case 'cancelled': return 'cancelled'; default: return 'pending'; } } function mapLinearPriority(priority) { switch (priority) { case 0: return 'none'; case 1: return 'urgent'; case 2: return 'high'; case 3: return 'medium'; case 4: return 'low'; default: return 'medium'; } } syncLinearTasks();