UNPKG

md-linear-sync

Version:

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

457 lines 19.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.startSyncCommand = startSyncCommand; exports.stopSyncCommand = stopSyncCommand; const express_1 = __importDefault(require("express")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const chokidar = __importStar(require("chokidar")); const client_1 = require("../client"); const config_1 = require("../config"); const sync_1 = require("./sync"); const RetryManager_1 = require("../utils/RetryManager"); const SlackNotificationService_1 = require("../services/SlackNotificationService"); class SyncDaemon { constructor() { this.ngrokUrl = ''; this.webhookId = ''; this.fileWatcher = null; this.moveDebounceMap = new Map(); this.slackService = SlackNotificationService_1.SlackNotificationServiceImpl.getInstance(); } async start() { console.log('🚀 Starting bidirectional sync daemon...'); // Get ngrok URL from environment variable this.ngrokUrl = process.env.NGROK_URL || ''; if (!this.ngrokUrl) { throw new Error('NGROK_URL environment variable is required. Please start the tunnel first.'); } console.log(`🌐 Using tunnel: ${this.ngrokUrl}`); // Update Linear webhook and start server await this.updateWebhook(); console.log('✅ Webhook updated'); this.startServer(); // Save PID for stop command this.savePID(); } async updateWebhook() { const client = new client_1.LinearSyncClient(process.env.LINEAR_API_KEY); const config = await config_1.ConfigManager.loadConfig(); const webhookUrl = this.ngrokUrl + '/webhook'; console.log(`📡 Setting webhook URL: ${webhookUrl}`); this.webhookId = await client.upsertWebhook({ url: webhookUrl, teamId: config.teamId }); } startServer() { const app = (0, express_1.default)(); // Payload size logging middleware - BEFORE body parsing app.use((req, res, next) => { const contentLength = req.get('Content-Length'); const endpoint = `${req.method} ${req.path}`; if (contentLength) { console.log(`📏 Incoming request to ${endpoint}: Content-Length = ${contentLength} bytes`); // Warn for large payloads const sizeInMB = parseInt(contentLength) / (1024 * 1024); if (sizeInMB > 5) { console.log(`⚠️ Large payload detected: ${sizeInMB.toFixed(2)} MB`); } } else { console.log(`📏 Incoming request to ${endpoint}: No Content-Length header`); } next(); }); // Body parser with increased limit to handle large Linear payloads app.use(express_1.default.json({ limit: '10mb' })); // Global error handler for payload size errors app.use((error, req, res, next) => { if (error.type === 'entity.too.large') { const endpoint = `${req.method} ${req.path}`; const contentLength = req.get('Content-Length'); const rawSize = req.rawBodySize; console.error('🚨 PayloadTooLargeError Details:'); console.error(` Endpoint: ${endpoint}`); console.error(` Content-Length header: ${contentLength || 'Not provided'} bytes`); console.error(` Actual body size: ${rawSize || 'Not measured'} bytes`); console.error(` Error message: ${error.message}`); console.error(` Error limit: ${error.limit || 'Unknown'}`); return res.status(413).json({ error: 'Payload too large', details: { contentLength: contentLength || null, actualSize: rawSize || null, limit: error.limit || null, endpoint } }); } if (error.type === 'entity.parse.failed') { console.error('🚨 JSON Parse Error:'); console.error(` Endpoint: ${req.method} ${req.path}`); console.error(` Body size: ${req.rawBodySize || 'Unknown'} bytes`); console.error(` Error: ${error.message}`); } next(error); }); // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy', tunnel: this.ngrokUrl }); }); // Webhook endpoint app.post('/webhook', async (req, res) => { try { // Log successful payload processing const payloadSize = JSON.stringify(req.body).length; console.log(`✅ Successfully parsed webhook payload: ${payloadSize} bytes`); const { action, data, type } = req.body; // Handle issue updates, creates, and removes const ticketId = data?.identifier || data?.issue?.identifier; if ((action === 'update' || action === 'create') && ticketId) { console.log(`🔄 ${action}: ${ticketId} (${type || 'Issue'})`); // Add small delay for comment creation to allow Linear to index if (action === 'create' && type === 'Comment') { await new Promise(resolve => setTimeout(resolve, 2000)); } await this.syncTicket(ticketId, req.body); } else if (action === 'remove' && ticketId) { console.log(`🗑️ ${action}: ${ticketId} (${type || 'Issue'})`); await this.handleTicketDeletion(ticketId, req.body); } res.status(200).send('OK'); } catch (error) { console.error('❌ Webhook error:', error); res.status(500).send('Error'); } }); this.server = app.listen(3001, async () => { console.log('🎯 Webhook server listening on port 3001'); // Start file watcher for bidirectional sync await this.startFileWatcher(); }); } async syncTicket(ticketId, webhookPayload) { await RetryManager_1.RetryManager.withRetry(async () => { const client = new client_1.LinearSyncClient(process.env.LINEAR_API_KEY); const config = await config_1.ConfigManager.loadConfig(); // This is the genius part - reuse existing sync logic! await (0, sync_1.pullSingleTicket)(ticketId, client, config); // Send Slack notification after successful sync await this.sendSlackNotification(ticketId, webhookPayload, client); }, {}, `sync ticket ${ticketId}`); } async sendSlackNotification(ticketId, webhookPayload, client) { try { const { action, data, type, updatedFrom } = webhookPayload; // Get current ticket info for notification const ticket = await client.getIssue(ticketId); if (!ticket) return; const baseEvent = { ticketId, ticketTitle: ticket.title, ticketUrl: ticket.url, timestamp: new Date().toISOString() }; if (type === 'Comment' && action === 'create') { // Comment notification const comment = data; await this.slackService.sendWebhookNotification({ ...baseEvent, type: 'comment_added', comment: { id: comment.id, author: comment.user?.email || comment.user?.name || 'Unknown', content: comment.body || 'No content' } }); } else if (type === 'Issue' && action === 'create') { // New issue created notification await this.slackService.sendWebhookNotification({ ...baseEvent, type: 'issue_created' }); } else if (type === 'Issue' && action === 'update' && updatedFrom) { // Issue update notification with changes const changes = this.extractChanges(updatedFrom, data); if (Object.keys(changes).length > 0) { await this.slackService.sendWebhookNotification({ ...baseEvent, type: 'issue_updated', changes }); } } } catch (error) { console.log('⚠️ Slack notification failed:', error instanceof Error ? error.message : 'Unknown error'); } } extractChanges(updatedFrom, current) { const changes = {}; // Status change - Linear includes stateId when state changes if ('stateId' in updatedFrom && current.state?.name) { changes.status = { from: null, // We don't have old state name from webhook to: current.state.name }; } // Title change - same logic if ('title' in updatedFrom && updatedFrom.title !== current.title) { changes.title = { from: updatedFrom.title || 'Untitled', to: current.title || 'Untitled' }; } // Description change - this we can diff properly if ('description' in updatedFrom && updatedFrom.description !== current.description) { changes.description = { from: updatedFrom.description || '', to: current.description || '' }; } // Assignee change if ('assigneeId' in updatedFrom || updatedFrom.assignee) { const oldEmail = updatedFrom.assignee?.email; const newEmail = current.assignee?.email; if (oldEmail !== newEmail) { changes.assignee = { from: oldEmail, to: newEmail }; } } // Priority change if ('priority' in updatedFrom && updatedFrom.priority !== current.priority) { changes.priority = { from: updatedFrom.priority || 0, to: current.priority || 0 }; } // Labels change - Linear provides full label arrays if ('labels' in updatedFrom) { const oldLabels = (updatedFrom.labels || []).sort(); const newLabels = (current.labels || []).sort(); if (JSON.stringify(oldLabels) !== JSON.stringify(newLabels)) { const added = newLabels.filter((l) => !oldLabels.includes(l)); const removed = oldLabels.filter((l) => !newLabels.includes(l)); if (added.length > 0 || removed.length > 0) { changes.labels = { added, removed }; } } } return changes; } async handleTicketDeletion(ticketId, webhookPayload) { await RetryManager_1.RetryManager.withRetry(async () => { const { data } = webhookPayload; // Remove local file await this.removeLocalTicketFile(ticketId); // Send Slack notification await this.sendDeletionNotification(ticketId, data); }, {}, `handle deletion of ${ticketId}`); } async removeLocalTicketFile(ticketId) { try { const config = await config_1.ConfigManager.loadConfig(); // Search for the ticket file across all status folders for (const [statusName, statusConfig] of Object.entries(config.statusMapping)) { const folderPath = path_1.default.join(process.cwd(), 'linear-tickets', statusConfig.folder); if (fs_1.default.existsSync(folderPath)) { const files = fs_1.default.readdirSync(folderPath); // Look for files that match this ticket ID (including parent-child format) const matchingFile = files.find(file => { const fileTicketId = this.extractTicketIdFromFilename(file); return fileTicketId === ticketId; }); if (matchingFile) { const filePath = path_1.default.join(folderPath, matchingFile); fs_1.default.unlinkSync(filePath); return; } } } console.log(`⚠️ Local file for ${ticketId} not found`); } catch (error) { console.error(`❌ Failed to remove local file for ${ticketId}:`, error); } } extractTicketIdFromFilename(filename) { // Handle both regular (PAP-123-title.md) and parent-child (PAP-123.456-title.md) formats const match = filename.match(/^([A-Z]+-\d+(?:\.\d+)?)-/); return match ? match[1] : ''; } async sendDeletionNotification(ticketId, ticketData) { try { await this.slackService.sendWebhookNotification({ ticketId, ticketTitle: ticketData.title || 'Untitled', ticketUrl: ticketData.url || '', timestamp: new Date().toISOString(), type: 'issue_deleted' }); } catch (error) { console.log('⚠️ Slack deletion notification failed:', error instanceof Error ? error.message : 'Unknown error'); } } savePID() { fs_1.default.writeFileSync('.webhook-listener.pid', process.pid.toString()); } async startFileWatcher() { const linearTicketsDir = path_1.default.join(process.cwd(), 'linear-tickets'); if (!fs_1.default.existsSync(linearTicketsDir)) { console.log('📁 No linear-tickets directory found, skipping file watcher'); return; } console.log('👀 Starting file watcher for local changes...'); this.fileWatcher = chokidar.watch(linearTicketsDir, { ignored: /(^|[\/\\])\../, // ignore dotfiles persistent: true, ignoreInitial: true }); this.fileWatcher.on('add', (filePath) => this.handleFileMove(filePath, 'added')); this.fileWatcher.on('unlink', (filePath) => this.handleFileMove(filePath, 'removed')); console.log('✅ File watcher started - local file moves will sync to Linear'); } async handleFileMove(filePath, action) { // Only process .md files, ignore README.md if (!filePath.endsWith('.md') || filePath.endsWith('README.md')) { return; } // Extract ticket ID from filename const filename = path_1.default.basename(filePath); const ticketIdMatch = filename.match(/^([A-Z]+-\d+)/); if (!ticketIdMatch) { return; } const ticketId = ticketIdMatch[1]; // Debounce moves (wait 2 seconds for bulk operations) const debounceKey = ticketId; if (this.moveDebounceMap.has(debounceKey)) { clearTimeout(this.moveDebounceMap.get(debounceKey)); } const timeoutId = setTimeout(async () => { this.moveDebounceMap.delete(debounceKey); if (action === 'added') { try { const config = await config_1.ConfigManager.loadConfig(); const envConfig = config_1.ConfigManager.loadEnvironmentConfig(); const client = new client_1.LinearSyncClient(envConfig.linear.apiKey); await (0, sync_1.pushSingleTicket)(ticketId, client, config); } catch (error) { console.error(`❌ Failed to sync ${ticketId}:`, error instanceof Error ? error.message : 'Unknown error'); } } }, 2000); this.moveDebounceMap.set(debounceKey, timeoutId); } async stop() { console.log('🛑 Stopping sync daemon...'); // Stop file watcher if (this.fileWatcher) { await this.fileWatcher.close(); console.log('🛑 File watcher stopped'); } // Clear debounce timers for (const timeout of this.moveDebounceMap.values()) { clearTimeout(timeout); } this.moveDebounceMap.clear(); // Stop server if (this.server) { this.server.close(); } // Remove webhook from Linear if (this.webhookId) { try { const client = new client_1.LinearSyncClient(process.env.LINEAR_API_KEY); await client.deleteWebhook(this.webhookId); console.log('🗑️ Webhook removed from Linear'); } catch (error) { console.log('⚠️ Failed to remove webhook:', error); } } // Remove PID file try { fs_1.default.unlinkSync('.webhook-listener.pid'); } catch { } console.log('✅ Stopped'); } } // Commands async function startSyncCommand() { const daemon = new SyncDaemon(); // Handle shutdown signals process.on('SIGINT', async () => { await daemon.stop(); process.exit(0); }); process.on('SIGTERM', async () => { await daemon.stop(); process.exit(0); }); // Save PID for stop command daemon.savePID(); await daemon.start(); console.log('🎯 Bidirectional sync active. Press Ctrl+C to stop.'); // Keep process alive process.stdin.resume(); } async function stopSyncCommand() { try { const pidContent = fs_1.default.readFileSync('.webhook-listener.pid', 'utf8'); const pid = parseInt(pidContent.trim()); process.kill(pid, 'SIGINT'); console.log('✅ Stop signal sent to webhook listener'); } catch (error) { console.log('❌ No listener running or failed to stop'); } } //# sourceMappingURL=sync-daemon.js.map