@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
text/typescript
/**
* 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);
});