md-linear-sync
Version:
Sync Linear tickets to local markdown files with status-based folder organization
457 lines • 19.9 kB
JavaScript
;
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