UNPKG

md-linear-sync

Version:

Sync Linear tickets to local markdown files with status-based folder organization

309 lines • 12.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.pushCommand = pushCommand; exports.pullCommand = pullCommand; exports.pushSingleTicket = pushSingleTicket; exports.pullSingleTicket = pullSingleTicket; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const client_1 = require("../client"); const config_1 = require("../config"); const parsers_1 = require("../parsers"); async function pushCommand(ticketId) { console.log(`šŸš€ Pushing ${ticketId ? `ticket ${ticketId}` : 'all changes'} to Linear...\n`); try { // Load configuration const config = await config_1.ConfigManager.loadConfig(); const envConfig = config_1.ConfigManager.loadEnvironmentConfig(); // Initialize Linear client const client = new client_1.LinearSyncClient(envConfig.linear.apiKey); if (ticketId) { // Push specific ticket await pushSingleTicket(ticketId, client, config); } else { // Push all modified tickets await pushAllTickets(client, config); } } catch (error) { console.error('\nāŒ Push failed:', error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } } async function pullCommand(ticketId) { console.log(`šŸ“„ Pulling ${ticketId ? `ticket ${ticketId}` : 'all changes'} from Linear...\n`); try { // Load configuration const config = await config_1.ConfigManager.loadConfig(); const envConfig = config_1.ConfigManager.loadEnvironmentConfig(); // Initialize Linear client const client = new client_1.LinearSyncClient(envConfig.linear.apiKey); if (ticketId) { // Pull specific ticket await pullSingleTicket(ticketId, client, config); } else { // Pull all tickets await pullAllTickets(client, config); } } catch (error) { console.error('\nāŒ Pull failed:', error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } } async function pushSingleTicket(ticketId, client, config) { // Find the file for this ticket const filePath = findTicketFile(ticketId); if (!filePath) { throw new Error(`Ticket file for ${ticketId} not found`); } console.log(`šŸ“„ Processing ${path_1.default.basename(filePath)}...`); // Parse the file const content = fs_1.default.readFileSync(filePath, 'utf-8'); const ticket = parsers_1.TicketFileParser.parseFile(content); // Validate file const validation = parsers_1.TicketFileParser.validateFile(content); if (!validation.valid) { throw new Error(`Invalid ticket file: ${validation.errors.join(', ')}`); } // Get current status folder const currentFolder = path_1.default.basename(path_1.default.dirname(filePath)); const newStateId = findStateIdForFolder(currentFolder, config); if (!newStateId) { throw new Error(`Cannot find Linear state ID for folder "${currentFolder}"`); } // Get Linear issue to compare const linearIssue = await client.getIssue(ticket.frontmatter.linear_id); // Build updates object const updates = {}; // Check if status changed (file moved to different folder) if (linearIssue.state.id !== newStateId) { updates.stateId = newStateId; console.log(` šŸ“ Status: ${linearIssue.state.name} → ${currentFolder}`); } // Check if title changed (prefer frontmatter title, fallback to filename) const titleFromFrontmatter = ticket.frontmatter.title; const filename = path_1.default.basename(filePath, '.md'); const titleFromFilename = extractTitleFromFilename(filename, ticket.frontmatter.linear_id); const newTitle = titleFromFrontmatter || titleFromFilename; if (newTitle && linearIssue.title !== newTitle) { updates.title = newTitle; console.log(` šŸ“ Title: "${linearIssue.title}" → "${newTitle}"`); } // Check if description changed if (ticket.content && linearIssue.description !== ticket.content) { updates.description = ticket.content; console.log(` šŸ“„ Description updated`); } // Apply updates if any if (Object.keys(updates).length > 0) { await client.updateIssue(linearIssue.id, updates); console.log(`āœ… Updated ${ticket.frontmatter.linear_id} in Linear`); // Update the local file's updated_at timestamp ticket.frontmatter.updated_at = parsers_1.TimezoneUtils.now(); const updatedContent = parsers_1.TicketFileParser.generateFile(ticket); fs_1.default.writeFileSync(filePath, updatedContent, 'utf-8'); } else { console.log(`ā„¹ļø No changes detected for ${ticket.frontmatter.linear_id}`); } } async function pushAllTickets(client, config) { const tickets = getAllTicketFiles(); if (tickets.length === 0) { console.log('ā„¹ļø No ticket files found to push'); return; } console.log(`šŸ“‹ Found ${tickets.length} ticket files to check for changes`); let pushed = 0; let skipped = 0; let errors = 0; for (const filePath of tickets) { try { const content = fs_1.default.readFileSync(filePath, 'utf-8'); const ticket = parsers_1.TicketFileParser.parseFile(content); // Check if file was modified since last sync const stats = fs_1.default.statSync(filePath); const fileModifiedTime = new Date(stats.mtime); const lastSyncTime = new Date(ticket.frontmatter.updated_at); if (fileModifiedTime > lastSyncTime) { await pushSingleTicket(ticket.frontmatter.linear_id, client, config); pushed++; } else { skipped++; } } catch (error) { errors++; const filename = path_1.default.basename(filePath); console.error(`āŒ ${filename}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } console.log(`\nšŸ“Š Push complete: ${pushed} updated, ${skipped} unchanged, ${errors} errors`); } async function pullSingleTicket(ticketId, client, config) { // Get issue from Linear const issue = await client.getIssue(ticketId); if (!issue) { throw new Error(`Ticket ${ticketId} not found in Linear`); } console.log(`šŸ“„ Pulling ${issue.identifier}: ${issue.title}`); // Find current local file if it exists const existingFilePath = findTicketFile(issue.identifier); // Get status folder for current Linear state const statusFolder = findStatusFolder(issue.state.name, config); if (!statusFolder) { throw new Error(`Unknown Linear state "${issue.state.name}" not in config`); } // Generate new filename and path const filename = parsers_1.TicketFileParser.generateFilename(issue.identifier, issue.title, issue.parent?.identifier); const newFilePath = path_1.default.join(process.cwd(), 'linear-tickets', statusFolder, filename); // Get comments from issue response (already included in getIssue query) const comments = issue.comments?.nodes || []; // Convert to ticket format const ticket = convertLinearIssueToTicket(issue, comments, config); // Generate content const content = parsers_1.TicketFileParser.generateFile(ticket); // If file moved to different folder, remove old file if (existingFilePath && existingFilePath !== newFilePath) { fs_1.default.unlinkSync(existingFilePath); console.log(` šŸ“ Moved from ${path_1.default.relative(process.cwd(), existingFilePath)} to ${path_1.default.relative(process.cwd(), newFilePath)}`); } // Ensure directory exists const dir = path_1.default.dirname(newFilePath); if (!fs_1.default.existsSync(dir)) { fs_1.default.mkdirSync(dir, { recursive: true }); } // Write file fs_1.default.writeFileSync(newFilePath, content, 'utf-8'); console.log(`āœ… Updated ${issue.identifier}`); } async function pullAllTickets(client, config) { // Get all issues from Linear const result = await client.getIssues(config.teamId, config.projectId); const issues = result.issues; console.log(`šŸ“‹ Found ${issues.length} issues in Linear to sync`); let updated = 0; let errors = 0; for (const issue of issues) { try { await pullSingleTicket(issue.identifier, client, config); updated++; } catch (error) { errors++; console.error(`āŒ ${issue.identifier}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } console.log(`\nšŸ“Š Pull complete: ${updated} updated, ${errors} errors`); } // Helper functions function findTicketFile(ticketId) { const linearDir = path_1.default.join(process.cwd(), 'linear-tickets'); if (!fs_1.default.existsSync(linearDir)) { return null; } // Search all status folders const statusFolders = fs_1.default.readdirSync(linearDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const folder of statusFolders) { const folderPath = path_1.default.join(linearDir, folder); const files = fs_1.default.readdirSync(folderPath) .filter(file => file.endsWith('.md')); for (const file of files) { const extractedId = parsers_1.TicketFileParser.extractLinearIdFromFilename(file); if (extractedId && extractedId.toLowerCase() === ticketId.toLowerCase()) { return path_1.default.join(folderPath, file); } } } return null; } function getAllTicketFiles() { const linearDir = path_1.default.join(process.cwd(), 'linear-tickets'); const files = []; if (!fs_1.default.existsSync(linearDir)) { return files; } // Get all markdown files from all status folders const statusFolders = fs_1.default.readdirSync(linearDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const folder of statusFolders) { const folderPath = path_1.default.join(linearDir, folder); const folderFiles = fs_1.default.readdirSync(folderPath) .filter(file => file.endsWith('.md') && file !== 'README.md') .map(file => path_1.default.join(folderPath, file)); files.push(...folderFiles); } return files; } function findStateIdForFolder(folder, config) { for (const [stateName, mapping] of Object.entries(config.statusMapping)) { if (mapping.folder === folder) { return mapping.id; } } return null; } function findStatusFolder(stateName, config) { for (const [configStateName, mapping] of Object.entries(config.statusMapping)) { if (configStateName === stateName) { return mapping.folder; } } return null; } function extractTitleFromFilename(filename, linearId) { // Remove Linear ID prefix and file extension const prefix = `${linearId}-`; if (!filename.startsWith(prefix)) { return null; } const title = filename .substring(prefix.length) .replace(/-/g, ' ') .replace(/\b\w/g, char => char.toUpperCase()); // Title case return title || null; } function convertLinearIssueToTicket(issue, linearComments, config) { // Convert timestamps to SGT const createdAt = parsers_1.TimezoneUtils.utcToSGT(issue.createdAt); const updatedAt = parsers_1.TimezoneUtils.utcToSGT(issue.updatedAt); const dueDate = issue.dueDate ? parsers_1.TimezoneUtils.utcToSGT(issue.dueDate) : undefined; // Build frontmatter metadata const frontmatter = { linear_id: issue.identifier, title: issue.title, status: issue.state.name, assignee: issue.assignee?.email, labels: issue.labels?.nodes?.map((label) => label.name) || [], priority: issue.priority || 0, due_date: dueDate, url: issue.url, created_at: createdAt, updated_at: updatedAt }; // Convert description const content = issue.description || `# ${issue.title}\n\n*No description provided*`; // Convert comments const comments = linearComments.map(comment => ({ id: comment.id, author: comment.user?.email || comment.user?.name || 'Unknown', content: comment.body || '', created_at: parsers_1.TimezoneUtils.utcToSGT(comment.createdAt) })); return { frontmatter, content, comments }; } //# sourceMappingURL=sync.js.map