feat: add allowed Linear API operations to MCP config
This commit is contained in:
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/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
|
||||
};
|
||||
|
||||
for (const arg of args) {
|
||||
switch (arg) {
|
||||
case '--dry-run':
|
||||
options.dryRun = true;
|
||||
break;
|
||||
case '--verbose':
|
||||
options.verbose = 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
|
||||
--help Ez a súgó
|
||||
|
||||
Példa:
|
||||
node linear-sync.js --dry-run --verbose
|
||||
`);
|
||||
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://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: '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 getIssues() {
|
||||
const query = `
|
||||
query GetIssues($teamId: ID!) {
|
||||
issues(filter: { team: { id: { eq: $teamId } } }) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
status
|
||||
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 updateIssueStatus(issueId, status) {
|
||||
const query = `
|
||||
mutation UpdateIssueStatus($input: IssueUpdateInput!) {
|
||||
issueUpdate(input: $input) {
|
||||
issue {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const input = {
|
||||
id: issueId,
|
||||
status
|
||||
};
|
||||
const result = await this.request(query, { input });
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}📋 Found ${existingIssues.length} Linear issues`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
for (const issue of existingIssues) {
|
||||
if (todos.completed.some(t => t.task === issue.title) && issue.status !== 'Done') {
|
||||
console.log(`${modePrefix}🔧 Updating status to Done: ${issue.identifier} - ${issue.title}`);
|
||||
try {
|
||||
await client.updateIssueStatus(issue.id, 'Done');
|
||||
statusUpdateCount++;
|
||||
if (OPTIONS.verbose) {
|
||||
console.log(`${modePrefix}✅ Updated: ${issue.identifier}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update: ${issue.identifier}`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
syncTodoLinear().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { syncTodoLinear };
|
||||
Reference in New Issue
Block a user