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.

247 lines (213 loc) • 6.57 kB
#!/usr/bin/env node /** * Script to cancel duplicate Linear tasks * Uses the actual Linear API to find and cancel duplicates */ import { readFileSync } from 'fs'; import { join } from 'path'; interface TaskGroup { pattern: string; keepFirst: boolean; } interface LinearIssue { id: string; identifier: string; title: string; createdAt: string; team?: { states?: { nodes?: Array<{ id: string; type: string }>; }; }; } interface WorkflowState { id: string; type: string; } // Define patterns to identify duplicate tasks const duplicatePatterns: TaskGroup[] = [ { pattern: 'Linear API Integration', keepFirst: true }, { pattern: 'Performance Optimization', keepFirst: true }, { pattern: 'Security Audit', keepFirst: true }, { pattern: '[HIGH] Implement Proper Error Handling', keepFirst: true }, { pattern: '[HIGH] Implement Comprehensive Testing Suite', keepFirst: true }, ]; async function cancelDuplicateTasks(dryRun = true) { const mode = dryRun ? 'šŸ” DRY RUN MODE' : '⚔ LIVE MODE'; console.log(`\n${mode} - Cancel duplicate Linear tasks\n`); console.log('='.repeat(60)); // Load Linear tokens const tokensPath = join(process.cwd(), '.stackmemory', 'linear-tokens.json'); let accessToken: string; try { const tokensData = readFileSync(tokensPath, 'utf8'); const tokens = JSON.parse(tokensData); accessToken = tokens.accessToken; console.log('āœ… Loaded Linear authentication tokens\n'); } catch { console.error( 'āŒ Failed to load Linear tokens. Please run: stackmemory linear setup' ); process.exit(1); } // GraphQL helper const linearApiUrl = 'https://api.linear.app/graphql'; async function graphqlRequest( query: string, variables: Record<string, unknown> = {} ) { const response = await fetch(linearApiUrl, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), }); if (!response.ok) { throw new Error( `Linear API error: ${response.status} ${response.statusText}` ); } const result = (await response.json()) as { errors?: unknown[]; data: unknown; }; if (result.errors) { throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`); } return result.data; } // First, get all issues console.log('Fetching all issues...\n'); const issuesQuery = ` query { issues(first: 250, filter: { state: { type: { nin: ["completed", "canceled"] } } }) { nodes { id identifier title description createdAt state { id name type } team { id key states { nodes { id name type } } } } } } `; const issuesData = (await graphqlRequest(issuesQuery)) as { issues: { nodes: LinearIssue[] }; }; const allIssues = issuesData.issues.nodes; console.log(`Found ${allIssues.length} active issues\n`); // Get canceled state from the first issue's team const canceledState = allIssues[0]?.team?.states?.nodes?.find( (s: WorkflowState) => s.type === 'canceled' ); if (!canceledState) { console.error('āŒ No canceled state found in workflow'); process.exit(1); } // Group issues by pattern const groupedIssues = new Map<string, LinearIssue[]>(); for (const pattern of duplicatePatterns) { const matches = allIssues.filter((issue: LinearIssue) => issue.title.includes(pattern.pattern) ); if (matches.length > 1) { // Sort by creation date to keep the oldest matches.sort( (a: LinearIssue, b: LinearIssue) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); groupedIssues.set(pattern.pattern, matches); } } // Process each group let totalCanceled = 0; let totalKept = 0; for (const [pattern, issues] of groupedIssues.entries()) { console.log(`\nšŸ“‹ Pattern: "${pattern}"`); console.log(` Found ${issues.length} matching issues:`); const [primary, ...duplicates] = issues; console.log(` āœ… Keep: ${primary.identifier} - ${primary.title}`); totalKept++; for (const duplicate of duplicates) { console.log( ` ${dryRun ? 'šŸ”' : 'āŒ'} Cancel: ${duplicate.identifier} - ${duplicate.title}` ); if (!dryRun) { try { const cancelMutation = ` mutation CancelIssue($id: String!, $stateId: String!) { issueUpdate( id: $id, input: { stateId: $stateId, description: "Duplicate task - kept ${primary.identifier}" } ) { success issue { identifier state { name } } } } `; await graphqlRequest(cancelMutation, { id: duplicate.id, stateId: canceledState.id, }); console.log(` āœ… Successfully canceled ${duplicate.identifier}`); totalCanceled++; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); console.log( ` āŒ Failed to cancel ${duplicate.identifier}: ${errorMessage}` ); } } else { totalCanceled++; } } } // Summary console.log('\n' + '='.repeat(60)); console.log(`\n✨ ${dryRun ? 'DRY RUN' : 'CLEANUP'} COMPLETE!\n`); console.log('šŸ“Š Summary:'); console.log(` Duplicate groups found: ${groupedIssues.size}`); console.log(` Tasks to keep: ${totalKept}`); console.log( ` Tasks ${dryRun ? 'to cancel' : 'canceled'}: ${totalCanceled}` ); console.log(` Total active tasks: ${allIssues.length}`); console.log(` Tasks after cleanup: ${allIssues.length - totalCanceled}`); if (dryRun) { console.log('\nšŸ’” To execute these changes, run with --execute flag'); } } // Parse command line arguments const isDryRun = !process.argv.includes('--execute'); // Run the cleanup cancelDuplicateTasks(isDryRun).catch((error) => { console.error('āŒ Fatal error:', error); process.exit(1); });