md-linear-sync
Version:
Sync Linear tickets to local markdown files with status-based folder organization
353 lines • 14.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.startListenCommand = startListenCommand;
exports.stopListenCommand = stopListenCommand;
const ngrok_1 = __importDefault(require("ngrok"));
const express_1 = __importDefault(require("express"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
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 WebhookListener {
constructor() {
this.ngrokUrl = '';
this.webhookId = '';
this.restartTimer = null;
this.slackService = SlackNotificationService_1.SlackNotificationServiceImpl.getInstance();
}
async start() {
console.log('🚀 Starting webhook listener...');
// Start initial session
await this.startSession();
// Auto-restart every 55 minutes
this.scheduleRestart();
// Save PID for stop command
this.savePID();
}
async startSession() {
try {
// 1. Start ngrok
this.ngrokUrl = await ngrok_1.default.connect(3001);
console.log(`🌐 Tunnel: ${this.ngrokUrl}`);
// 2. Update Linear webhook
await this.updateWebhook();
console.log('✅ Webhook updated');
// 3. Start express server
this.startServer();
}
catch (error) {
console.error('❌ Failed to start session:', error);
throw error;
}
}
scheduleRestart() {
this.restartTimer = setTimeout(async () => {
console.log('⏰ Restarting session...');
try {
await ngrok_1.default.disconnect();
await this.startSession();
this.scheduleRestart();
console.log('✅ Session restarted');
}
catch (error) {
console.error('❌ Restart failed:', error);
// Retry in 5 minutes if restart fails
setTimeout(() => this.scheduleRestart(), 5 * 60 * 1000);
}
}, 55 * 60 * 1000); // 55 minutes
}
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)();
app.use(express_1.default.json());
// 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 {
console.log('📥 Webhook received:', JSON.stringify(req.body, null, 2));
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') {
console.log('⏱️ Waiting 2s for comment indexing...');
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, () => {
console.log('🎯 Webhook server listening on port 3001');
});
}
async syncTicket(ticketId, webhookPayload) {
await RetryManager_1.RetryManager.withRetry(async () => {
console.log(`📥 Syncing ${ticketId} from Linear...`);
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);
console.log(`✅ Synced ${ticketId}`);
// 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 () => {
console.log(`🗑️ Handling deletion of ${ticketId}...`);
const { data } = webhookPayload;
// Remove local file
await this.removeLocalTicketFile(ticketId);
// Send Slack notification
await this.sendDeletionNotification(ticketId, data);
console.log(`✅ Handled deletion of ${ticketId}`);
}, {}, `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);
console.log(`📁 Removed local file: ${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 stop() {
console.log('🛑 Stopping webhook listener...');
// Clear timer
if (this.restartTimer) {
clearTimeout(this.restartTimer);
}
// Stop server
if (this.server) {
this.server.close();
}
// Disconnect ngrok
try {
await ngrok_1.default.disconnect();
}
catch (error) {
console.log('⚠️ ngrok disconnect error:', error);
}
// 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 startListenCommand() {
const listener = new WebhookListener();
// Handle shutdown signals
process.on('SIGINT', async () => {
await listener.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await listener.stop();
process.exit(0);
});
await listener.start();
console.log('🎯 Listening for webhooks. Press Ctrl+C to stop.');
// Keep process alive
process.stdin.resume();
}
async function stopListenCommand() {
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=listen.js.map