Files
websitedev/plane-sync.js
T
Do Siki f918e300a6 feat(plane): add Plane-TODO sync script
Add plane-sync.js one-way (Plane -> TODO.md) synchronization for the
MITHOME project. Config reads from .mcp.json with .env override. CLI:
--list, --dry-run, --yes, --project, --verbose, --help. Interactive
discrepancy resolution (status mismatches, orphans, new issues), backup
before write, rate limiting. Pure functions covered by node:test unit
tests. Includes PLANE-SYNC-GUIDE.md and TODO.md changelog entry.
2026-08-17 12:18:55 +02:00

233 lines
7.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Plane-TODO Sync Script
*
* Synchronizes TODO.md with Plane issues (MITHOME project).
* Plane is the authoritative source; TODO.md is a local mirror.
*
* Usage:
* node plane-sync.js --list # list Plane projects
* node plane-sync.js --dry-run # preview changes, write nothing
* node plane-sync.js --yes # auto-accept recommended fixes
* node plane-sync.js # interactive sync
*
* Config precedence: .env > .mcp.json (mcpServers.plane.env)
*/
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { getConfig, loadDotEnv } = require('./scripts/plane/config');
const { PlaneClient } = require('./scripts/plane/client');
const { SECTION_LABEL, parseTodoFile, regenerate } = require('./scripts/plane/todo');
const { buildDiscrepancies, buildFinalSections } = require('./scripts/plane/sync');
const TODO_FILE = path.join(__dirname, 'TODO.md');
function parseArgs() {
const args = process.argv.slice(2);
const options = {
dryRun: false,
verbose: false,
list: false,
yes: false,
help: false,
projectId: null
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--verbose') options.verbose = true;
else if (arg === '--list') options.list = true;
else if (arg === '--yes') options.yes = true;
else if (arg === '--help' || arg === '-h') options.help = true;
else if (arg === '--project') options.projectId = args[++i];
else if (arg.startsWith('--project=')) options.projectId = arg.split('=')[1];
else {
console.error(`❌ Unknown option: ${arg}`);
options.help = true;
}
}
return options;
}
function printHelp() {
console.log(`
🔄 Plane-TODO Sync Tool
Használat:
node plane-sync.js [opciók]
Opciók:
--list Plane projektek listázása
--dry-run Előnézet — nem ír semmit
--yes Javasolt javítások automatikus elfogadása (nem interaktív)
--project=<id> Projekt ID felülírása (alap: MITHOME)
--verbose Részletes kimenet
--help Ez a súgó
Példa:
node plane-sync.js --dry-run --verbose
node plane-sync.js --yes
`);
}
function printReport(buildData) {
const { mismatches, newIssues, orphans, cancelled } = buildData;
if (mismatches.length) {
console.log('\n⚠️ Státusz eltérések (Plane vs TODO.md):');
for (const m of mismatches) {
console.log(` ${m.id} — Plane: ${SECTION_LABEL[m.toSection]}, TODO.md: ${SECTION_LABEL[m.fromSection]} (${m.name})`);
}
}
if (newIssues.length) {
console.log('\n🆕 Új issue-k a Plane-ben (TODO.md-ből hiányoznak):');
for (const n of newIssues) console.log(` ${n.id} — ${n.name}`);
}
if (orphans.length) {
console.log('\n👻 TODO.md-ben szerepel, de Plane-ben NEM létezik:');
for (const o of orphans) console.log(` ${o.id} — ${o.name}`);
}
if (cancelled.length) {
console.log('\n🚫 Cancelled (nem kerülnek a TODO.md-be):');
for (const c of cancelled) console.log(` ${c.id} — ${c.name}`);
}
}
function ask(question, defaultYes) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const suffix = defaultYes ? ' [Y/n]' : ' [y/N]';
return new Promise(resolve => {
rl.question(`${question}${suffix}: `, answer => {
rl.close();
const a = answer.trim().toLowerCase();
if (a === '') return resolve(defaultYes);
resolve(a === 'y' || a === 'yes');
});
});
}
// DECISION: non-TTY (CI, piping) behaves like --yes to avoid hanging prompts
async function resolveDecisions(buildData, options) {
const decisions = { moveToPlane: new Set(), keepOrphan: new Set(), addNew: new Set() };
const nonInteractive = options.yes || !process.stdin.isTTY;
for (const m of buildData.mismatches) {
const accept = nonInteractive || (await ask(`\n${m.id}: Plane=${SECTION_LABEL[m.toSection]}, TODO.md=${SECTION_LABEL[m.fromSection]}. TODO.md frissítése?`, true));
if (accept) decisions.moveToPlane.add(m.id);
}
if (buildData.orphans.length) {
const list = buildData.orphans.map(o => o.id).join(', ');
const keep = nonInteractive || (await ask(`\n${list}: nincs Plane-ben. Megtartás a TODO.md-ben?`, true));
if (keep) buildData.orphans.forEach(o => decisions.keepOrphan.add(o.id));
}
if (buildData.newIssues.length) {
const list = buildData.newIssues.map(n => n.id).join(', ');
const add = nonInteractive || (await ask(`\n${list}: új a Plane-ben. Hozzáadás a TODO.md-hez?`, true));
if (add) buildData.newIssues.forEach(n => decisions.addNew.add(n.id));
}
return decisions;
}
function printSummary(buildData, decisions, modePrefix) {
const moved = buildData.mismatches.filter(m => decisions.moveToPlane.has(m.id)).length;
const orphansKept = buildData.orphans.filter(o => decisions.keepOrphan.has(o.id)).length;
const newAdded = buildData.newIssues.filter(n => decisions.addNew.has(n.id)).length;
console.log(`\n${modePrefix}📊 Összegzés: ${moved} áthelyezve, ${newAdded} hozzáadva, ${orphansKept}/${buildData.orphans.length} orphan megtartva`);
}
async function listProjects(client) {
const projects = await client.getProjects();
console.log(`\n📋 Found ${projects.length} Plane projects:\n`);
for (const project of projects) {
console.log(`${project.identifier} — ${project.name}`);
console.log(` ID: ${project.id}`);
console.log('');
}
}
async function syncMain(options, config) {
if (!config.apiKey || config.apiKey.includes('your_')) {
throw new Error(`PLANE_API_KEY hiányzik. Add meg a .mcp.json-ban vagy .env-ben.`);
}
if (!config.workspace) throw new Error('PLANE_WORKSPACE_SLUG hiányzik');
const client = new PlaneClient({ ...config, verbose: options.verbose });
const modePrefix = options.dryRun ? '(DRY RUN) ' : '';
console.log(`${modePrefix}🔐 API kapcsolat ellenőrzése...`);
await client.getProjects();
console.log(`${modePrefix}✅ API connection validated`);
console.log(`${modePrefix}📥 Plane issue-k lekérése (${config.projectIdentifier})...`);
const issues = await client.getIssues(config.projectId);
console.log(`${modePrefix}📥 ${issues.length} issue található`);
const content = fs.readFileSync(TODO_FILE, 'utf8');
const todo = parseTodoFile(content);
const buildData = buildDiscrepancies(issues, todo.rows, config.projectIdentifier);
printReport(buildData);
const totalIssues = buildData.mismatches.length + buildData.newIssues.length + buildData.orphans.length;
if (totalIssues === 0) {
console.log(`${modePrefix}✅ TODO.md szinkronban van a Plane-nel.`);
return;
}
const decisions = await resolveDecisions(buildData, options);
printSummary(buildData, decisions, modePrefix);
const final = buildFinalSections(buildData, decisions);
const newContent = regenerate(content, final);
if (options.dryRun) {
console.log(`${modePrefix}🔍 DRY RUN: nincs változtatás írva.`);
return;
}
const backupPath = `${TODO_FILE}.backup.${Date.now()}`;
fs.copyFileSync(TODO_FILE, backupPath);
fs.writeFileSync(TODO_FILE, newContent);
console.log(`${modePrefix}💾 Biztonsági mentés: ${path.basename(backupPath)}`);
console.log(`${modePrefix}✅ TODO.md frissítve.`);
}
async function main() {
loadDotEnv();
const options = parseArgs();
if (options.help) {
printHelp();
return;
}
const config = getConfig();
config.projectId = options.projectId || config.projectId;
try {
const client = new PlaneClient({ ...config, verbose: options.verbose });
if (options.list) {
await listProjects(client);
return;
}
await syncMain(options, config);
} catch (error) {
console.error('❌', error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { parseArgs, printHelp, printReport, resolveDecisions, printSummary, syncMain };