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.
233 lines
7.9 KiB
JavaScript
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 };
|