#!/usr/bin/env node /** * Linear-TODO Sync Script * * Synchronizes TODO.md with Linear issues for the mozdIT website project * Run this script to: * - Create Linear tickets for TODO tasks without tickets * - Update Linear ticket statuses based on TODO statuses * - Update TODO statuses based on Linear ticket statuses */ const https = require('https'); const fs = require('fs'); const path = require('path'); // Load environment variables from .env file function loadDotEnv() { try { const envPath = path.join(__dirname, '.env'); if (fs.existsSync(envPath)) { const envContent = fs.readFileSync(envPath, 'utf8'); const lines = envContent.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); if (key && valueParts.length > 0) { const value = valueParts.join('=').trim(); process.env[key] = value; } } } console.log('✅ .env fájl betöltve'); } else { console.log('⚠️ .env fájl hiányzik - szükség lesz rá a LINEAR_API_KEY-hez'); } } catch (error) { console.warn('⚠️ .env fájl betöltési hiba:', error.message); } } // Command line argument parsing function parseArgs() { const args = process.argv.slice(2); const options = { dryRun: false, verbose: false, projects: false }; for (const arg of args) { switch (arg) { case '--dry-run': options.dryRun = true; break; case '--verbose': options.verbose = true; break; case '--projects': options.projects = true; break; case '--help': console.log(` 🔄 Linear-TODO Sync Tool Használat: node linear-sync.js [opciók] Opciók: --dry-run Teszt üzemmód - nem hajt végre valódi változtatásokat --verbose Részletes kimenet --projects Linear projektek lekérdezése --help Ez a súgó Példa: node linear-sync.js --dry-run --verbose node linear-sync.js --projects `); process.exit(0); break; } } return options; } // Rate limiting helper function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Load .env on startup loadDotEnv(); // Configuration const LINEAR_API_ENDPOINT = 'https://api.linear.app/graphql'; const TEAM_ID = 'cf285407-a26b-434c-bb99-19676385ef67'; // Zeener team const PROJECT_ID = '54559e3b-9005-4dfa-b7a9-1415cd4bc453'; // Website Development project const TODO_FILE = process.env.TODO_FILE || 'TODO.md'; // Global options let OPTIONS = parseArgs(); /** * Linear GraphQL client */ class LinearClient { constructor(apiKey) { this.apiKey = apiKey; this.lastRequestTime = 0; this.minDelay = 1000; // 1 second minimum delay between requests } async request(query, variables = {}) { // Rate limiting const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.minDelay) { const delayTime = this.minDelay - timeSinceLastRequest; if (OPTIONS.verbose) { console.log(`⏳ Rate limiting: waiting ${delayTime}ms`); } await delay(delayTime); } this.lastRequestTime = Date.now(); if (OPTIONS.dryRun) { console.log(`🔍 DRY RUN: Would execute GraphQL query:`, query.substring(0, 100) + '...'); return {}; // Mock response } return new Promise((resolve, reject) => { const data = JSON.stringify({ query, variables }); const options = { hostname: 'api.linear.app', path: '/graphql', method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': this.apiKey, 'Content-Length': Buffer.byteLength(data) } }; if (OPTIONS.verbose) { console.log(`🔗 API hívás: ${options.method} ${options.hostname}${options.path}`); console.log(`📝 Query: ${query.substring(0, 80)}...`); } const req = https.request(options, (res) => { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { try { const response = JSON.parse(body); if (response.errors) { console.error('🔍 GraphQL errors:', response.errors); reject(new Error(`GraphQL Error: ${response.errors.map(e => e.message).join(', ')}`)); } else { if (OPTIONS.verbose) { console.log('✅ API válasz kapott'); } resolve(response.data); } } catch (error) { // If it's HTML error page if (body.includes(']*>/g, ''); // Remove HTML tags console.log('🔍 HTML válasz előzetes:', preview + '...'); // Mask API key in error message const maskedKey = this.apiKey ? this.apiKey.replace(/(.{8}).*(.{4})/, '$1****$2') : '[NO_KEY]'; reject(new Error(`Linear API HTML válasz visszaadott JSON helyett. Ellenőrizd az API kulcsot (${maskedKey}) és endpoint-ot.`)); } else { reject(error); } } }); }); req.on('error', (error) => { console.error('🔍 Hálózati hiba:', error.message); reject(error); }); req.write(data); req.end(); }); } async getProjects() { const query = ` query GetProjects { projects { nodes { id name description state targetDate slugId url teams { nodes { id name } } } } } `; const result = await this.request(query); return result.projects.nodes; } async getIssues() { const query = ` query GetIssues($teamId: ID!) { issues(filter: { team: { id: { eq: $teamId } } }) { nodes { id identifier title description state { id name } project { id name } } } } `; const result = await this.request(query, { teamId: TEAM_ID }); return result.issues.nodes; } async createIssue(title, description) { const query = ` mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier title url } } } `; const input = { title, description, teamId: TEAM_ID, projectId: PROJECT_ID }; const result = await this.request(query, { input }); return result.issueCreate.issue; } async getWorkflowStates(teamId) { const query = ` query GetWorkflowStates($teamId: ID!) { workflowStates(filter: { team: { id: { eq: $teamId } } }) { nodes { id name type } } } `; const result = await this.request(query, { teamId }); return result.workflowStates.nodes; } async updateIssueStatus(issueId, stateId) { const query = ` mutation UpdateIssueStatus($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { issue { id state { id name } } } } `; const result = await this.request(query, { id: issueId, stateId }); return result.issueUpdate.issue; } } /** * Parse TODO.md content */ function parseTodoFile(content) { const lines = content.split('\n'); const todos = { completed: [], inProgress: [], planned: [] }; let currentSection = null; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('## ✅ Befejezett')) { currentSection = 'completed'; } else if (line.startsWith('## 🔄 Folyamatban')) { currentSection = 'inProgress'; } else if (line.startsWith('## 📋 Tervezett')) { currentSection = 'planned'; } else if (currentSection && line.includes('|')) { // Parse table rows const parts = line.split('|').map(p => p.trim()).filter(p => p); if (parts.length >= 3 && parts[0] !== 'Linear Ticket') { const ticket = parts[0] === '-' || parts[0] === 'TBD' ? null : parts[0]; const task = parts[1]; const status = parts[2]; todos[currentSection].push({ ticket, task, status, isMissingTicket: !ticket || ticket.startsWith('TBC-') }); } } } return todos; } /** * Update TODO.md with new ticket numbers */ function updateTodoFile(content, ticketMappings) { let updatedContent = content; ticketMappings.forEach(mapping => { const placeholder = mapping.placeholder; const newTicket = mapping.ticket; updatedContent = updatedContent.replace(placeholder, newTicket); }); return updatedContent; } /** * Query and display Linear projects */ async function queryProjects() { console.log('🔍 Querying Linear projects...'); // Check for API key const apiKey = process.env.LINEAR_API_KEY; if (!apiKey || apiKey === 'your_linear_api_key_here') { console.error('❌ ERROR: LINEAR_API_KEY is required in .env file'); console.log(' Add your Linear API key to .env file:'); console.log(' LINEAR_API_KEY=lin_api_your_actual_key_here'); console.log(' Get your API key from: https://linear.app/settings/api'); return; } const client = new LinearClient(apiKey); try { console.log('✅ API connection validated'); const projects = await client.getProjects(); console.log(`\n📋 Found ${projects.length} Linear projects:\n`); projects.forEach((project, index) => { console.log(`${index + 1}. ${project.name}`); console.log(` ID: ${project.id}`); console.log(` Slug: ${project.slugId}`); console.log(` State: ${project.state}`); console.log(` URL: ${project.url}`); if (project.description) { console.log(` Description: ${project.description}`); } if (project.targetDate) { console.log(` Target Date: ${new Date(project.targetDate).toLocaleDateString()}`); } if (project.teams && project.teams.nodes.length > 0) { const teamNames = project.teams.nodes.map(team => team.name).join(', '); console.log(` Teams: ${teamNames}`); } console.log(''); // Empty line }); if (projects.length === 0) { console.log('⚠️ No projects found. This could mean:'); console.log(' - You don\'t have access to any projects'); console.log(' - Your API key doesn\'t have the right permissions'); console.log(' - There are no projects created yet'); } } catch (error) { console.error('❌ Failed to query projects:', error.message); if (error.message.includes('GraphQL')) { console.error(' Please check your LINEAR_API_KEY in .env file'); console.error(' Visit: https://linear.app/settings/api'); } } } /** * Main sync function */ async function syncTodoLinear() { const modePrefix = OPTIONS.dryRun ? '(DRY RUN) ' : ''; console.log(`${modePrefix}🔄 Synchronizing TODO.md with Linear...`); // Check for API key const apiKey = process.env.LINEAR_API_KEY; if (!apiKey || apiKey === 'your_linear_api_key_here') { console.error('❌ ERROR: LINEAR_API_KEY is required in .env file'); console.log(' Add your Linear API key to .env file:'); console.log(' LINEAR_API_KEY=lin_api_your_actual_key_here'); console.log(' Get your API key from: https://linear.app/settings/api'); console.log(' Optional: Use --dry-run --verbose for safe testing'); return; } const client = new LinearClient(apiKey); // Quick API validation try { if (OPTIONS.verbose) { console.log(`${modePrefix}🔐 Validating API connection...`); } await client.request('query { viewer { id name } }'); console.log(`${modePrefix}✅ API connection validated`); } catch (validationError) { console.error('❌ API validation failed:', validationError.message); console.error(' Please check your LINEAR_API_KEY in .env file'); console.error(' Visit: https://linear.app/settings/api'); return; } try { console.log(`${modePrefix}� Reading TODO.md...`); const content = fs.readFileSync(TODO_FILE, 'utf8'); const todos = parseTodoFile(content); if (OPTIONS.verbose) { console.log(`${modePrefix}📊 Found todos:`, { completed: todos.completed.length, inProgress: todos.inProgress.length, planned: todos.planned.length }); } console.log(`${modePrefix}🚀 Fetching existing Linear issues...`); const existingIssues = await client.getIssues(); const existingTitles = existingIssues.map(i => i.title); console.log(`${modePrefix}🔄 Fetching workflow states...`); const workflowStates = await client.getWorkflowStates(TEAM_ID); const doneState = workflowStates.find(s => s.type === 'completed') || workflowStates.find(s => s.name.toLowerCase().includes('done')); if (OPTIONS.verbose) { console.log(`${modePrefix}📋 Found ${existingIssues.length} Linear issues`); console.log(`${modePrefix}🔄 Found ${workflowStates.length} workflow states`); if (doneState) { console.log(`${modePrefix}✅ Done state found: ${doneState.name} (${doneState.id})`); } } // Find tasks missing Linear tickets const missingTickets = [ ...todos.inProgress.filter(t => t.isMissingTicket), ...todos.planned.filter(t => t.isMissingTicket) ]; console.log(`${modePrefix}📋 Found ${missingTickets.length} tasks without Linear tickets`); const ticketMappings = []; for (const item of missingTickets) { if (!existingTitles.includes(item.task)) { console.log(`${modePrefix}🔧 Creating Linear issue: ${item.task}`); try { const issue = await client.createIssue( item.task, `## ${item.task}\n\n### Acceptance Criteria\n\n- Task to be completed\n\n### Implementation Notes\n\n- Details to be added` ); console.log(`${modePrefix}✅ Created issue: ${issue.identifier} - ${issue.url}`); // Replace TBC-x with actual ticket const placeholder = item.ticket || item.task; ticketMappings.push({ placeholder: placeholder, ticket: issue.identifier, task: item.task }); } catch (error) { console.error(`❌ Failed to create issue for: ${item.task}`, error.message); } } else { if (OPTIONS.verbose) { console.log(`${modePrefix}⏭️ Issue already exists for: ${item.task}`); } } } // Update Linear status for completed tasks console.log(`${modePrefix}📋 Updating Linear statuses for completed tasks...`); let statusUpdateCount = 0; if (doneState) { for (const issue of existingIssues) { const isCompleted = todos.completed.some(t => t.task === issue.title); const isNotDone = issue.state.id !== doneState.id; if (isCompleted && isNotDone) { console.log(`${modePrefix}🔧 Updating status to ${doneState.name}: ${issue.identifier} - ${issue.title}`); try { await client.updateIssueStatus(issue.id, doneState.id); statusUpdateCount++; if (OPTIONS.verbose) { console.log(`${modePrefix}✅ Updated: ${issue.identifier}`); } } catch (error) { console.error(`❌ Failed to update: ${issue.identifier}`, error.message); } } } } else { console.log(`${modePrefix}⚠️ Could not find 'Done' state - skipping status updates`); } console.log(`${modePrefix}📊 Updated ${statusUpdateCount} Linear issues`); // Update TODO.md with new ticket numbers if (ticketMappings.length > 0) { console.log(`${modePrefix}📋 Updating TODO.md with ${ticketMappings.length} new ticket numbers...`); if (OPTIONS.dryRun) { console.log(`${modePrefix}📋 DRY RUN: Would update TODO.md with mappings:`, ticketMappings.map(m => `${m.placeholder} → ${m.ticket}`).join(', ') ); } else { // Create backup const backupPath = `${TODO_FILE}.backup.${Date.now()}`; fs.copyFileSync(TODO_FILE, backupPath); console.log(`${modePrefix}💾 Backup created: ${backupPath}`); const updatedContent = updateTodoFile(content, ticketMappings); fs.writeFileSync(TODO_FILE, updatedContent, 'utf8'); console.log(`${modePrefix}✅ TODO.md updated successfully`); } } const actionSummary = OPTIONS.dryRun ? `(DRY RUN: No changes made)` : `(Created ${ticketMappings.length} issues, updated ${statusUpdateCount} statuses)`; console.log(`${modePrefix}🎉 Synchronization complete! ${actionSummary}`); } catch (error) { console.error('❌ Synchronization failed:', error.message); if (error.response && error.response.data) { console.error('Response:', error.response.data); } if (error.message.includes('GraphQL') && OPTIONS.verbose) { console.error('🔍 Full error details:', error); } process.exit(1); } } // Run the sync if this script is executed directly if (require.main === module) { if (OPTIONS.projects) { queryProjects().catch(console.error); } else { syncTodoLinear().catch(console.error); } } module.exports = { syncTodoLinear, queryProjects };