605 lines
18 KiB
JavaScript
Executable File
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 }; |