Files
websitedev/linear-sync.js
T

605 lines
18 KiB
JavaScript
Executable File

#!/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('<!doctype')) {
const preview = body.slice(0, 200).replace(/<[^>]*>/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 };