From f918e300a65640233d9ad541243f34fdd9344def Mon Sep 17 00:00:00 2001 From: Do Siki Date: Mon, 17 Aug 2026 12:18:55 +0200 Subject: [PATCH] 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. --- .agent/AGENTS.md | 4 +- .gitignore | 3 + CLAUDE.md | 2 +- PLANE-SYNC-GUIDE.md | 130 +++++++++++++++++ TODO.md | 12 +- plane-sync.js | 232 +++++++++++++++++++++++++++++++ scripts/plane/client.js | 79 +++++++++++ scripts/plane/config.js | 53 +++++++ scripts/plane/plane-sync.test.js | 164 ++++++++++++++++++++++ scripts/plane/sync.js | 74 ++++++++++ scripts/plane/todo.js | 101 ++++++++++++++ 11 files changed, 845 insertions(+), 9 deletions(-) create mode 100644 PLANE-SYNC-GUIDE.md create mode 100644 plane-sync.js create mode 100644 scripts/plane/client.js create mode 100644 scripts/plane/config.js create mode 100644 scripts/plane/plane-sync.test.js create mode 100644 scripts/plane/sync.js create mode 100644 scripts/plane/todo.js diff --git a/.agent/AGENTS.md b/.agent/AGENTS.md index f4a3fc5..faedf31 100644 --- a/.agent/AGENTS.md +++ b/.agent/AGENTS.md @@ -92,8 +92,8 @@ websitedev/ │ ├── workflows/ # Slash command workflow-ok │ └── references/ # Ellenőrzőlisták ├── scripts/ # Szinkronizáló és reporting scriptek -├── TODO.md # Feladat követés (Linear tükörképe) -└── linear-sync.js # Linear szinkronizáló script +├── TODO.md # Feladat követés (Plane tükörképe) +└── plane-sync.js # Plane szinkronizáló script ``` ### Forbidden zones diff --git a/.gitignore b/.gitignore index 47b195e..a708661 100755 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ next-env.d.ts # MCP konfiguráció (API kulcsokat tartalmaz — soha ne commitolj!) .mcp.json mcp.json + +# Plane sync backup fájlok +TODO.md.backup.* diff --git a/CLAUDE.md b/CLAUDE.md index 67fd90c..25931fc 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ ## Gyors referencia — Alapszabályok 1. **Szöveg sosem kerülhet közvetlenül komponensbe** → `proto/src/content/pages/*.json` -2. **Feladatok**: `TODO.md` (Linear tükörképe), szinkron: `node linear-sync.js` +2. **Feladatok**: `TODO.md` (Plane tükörképe), szinkron: `node plane-sync.js` 3. **Tesztek**: minden feature-höz kötelező; commit előtt `npm test` zöld 4. **Commit**: Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`) 5. **Fájlméret**: soft limit 300 sor, hard limit 400 sor diff --git a/PLANE-SYNC-GUIDE.md b/PLANE-SYNC-GUIDE.md new file mode 100644 index 0000000..19c504c --- /dev/null +++ b/PLANE-SYNC-GUIDE.md @@ -0,0 +1,130 @@ +# Plane Sync Guide + +## 🎯 **Cél** + +A `plane-sync.js` script a **Plane** (MITHOME projekt) és a **TODO.md** közötti egyirányú szinkronizációt biztosítja. + +- **Plane** = authoritative source (elsődleges forrás) +- **TODO.md** = lokális tükörkép (csak referencia) +- **Irány**: Plane → TODO.md (a Plane mindig nyer) + +--- + +## 🚀 **Használat** + +### 1. Alap szinkron (interaktív) + +```bash +node plane-sync.js +``` + +Ha eltérést talál, konzolon kérdez a feloldás módjáról. + +### 2. Előnézet (nem ír semmit) + +```bash +node plane-sync.js --dry-run +``` + +### 3. Automatikus javítás (nem interaktív) + +```bash +node plane-sync.js --yes +``` + +CI környezetben (nem TTY) automatikusan a `--yes` viselkedés aktív. + +### 4. Projektek listázása + +```bash +node plane-sync.js --list +``` + +### 5. Egyéb opciók + +```bash +node plane-sync.js --verbose # részletes kimenet +node plane-sync.js --project= # projekt ID felülírása +node plane-sync.js --help # súgó +``` + +--- + +## ⚙️ **Konfiguráció** + +**Precedencia**: `.env` > `.mcp.json` (`mcpServers.plane.env`) + +| Változó | Forrás | Leírás | +| --- | --- | --- | +| `PLANE_API_KEY` | `.mcp.json` / `.env` | Plane API kulcs | +| `PLANE_API_HOST_URL` | `.mcp.json` / `.env` | Plane URL (alap: `https://pm.llmdev.mozdit.hu/`) | +| `PLANE_WORKSPACE_SLUG` | `.mcp.json` / `.env` | Workspace slug (alap: `developments`) | +| `PLANE_PROJECT_ID` | `.env` | Projekt ID (alap: MITHOME `643f7055-1237-4912-912f-99ec49fd0f0e`) | + +A kulcs elsődlegesen a `.mcp.json`-ban van (gitignore-olt) — a script ezt használja, így nincs duplikáció. + +--- + +## 🔄 **Státusz Mapping** + +| Plane group | TODO.md szekció | Emoji | +| --- | --- | --- | +| `completed` | `## ✅ Befejezett` | ✅ | +| `started` | `## 🔄 In Progress` | 🔄 | +| `unstarted` | `## 🚀 TODO` | ⏳ | +| `backlog` | `## 📋 Backlog` | 📋 | +| `cancelled` | (nincs hely — csak riport) | — | + +--- + +## ⚠️ **Eltérések Kezelése** + +A script háromféle eltérést ismer fel és interaktívan kérdez: + +1. **Státusz eltérés** (Plane ≠ TODO.md) — TODO.md frissítése a Plane szerint? *(ajánlott: igen)* +2. **Orphan** (csak TODO.md-ben szerepel) — megtartás? *(ajánlott: igen)* +3. **Új issue** (csak Plane-ben van) — hozzáadás a TODO.md-hez? *(ajánlott: igen)* + +--- + +## 🔒 **Biztonsági Mentés** + +Írás előtt a script mindig biztonsági másolatot készít: + +``` +TODO.md.backup. +``` + +Ezek a fájlok a `.gitignore`-ban vannak. + +--- + +## 🧪 **Tesztek** + +```bash +node --test scripts/plane/plane-sync.test.js +``` + +Pure függvények (TODO.md parse, discrepancy detektálás, tábla generálás) natív `node:test`-tel teszteltek. + +--- + +## 📂 **Kódstruktúra** + +``` +plane-sync.js # CLI belépési pont (argok, help, sync flow) +scripts/plane/config.js # Config (.mcp.json + .env override) +scripts/plane/client.js # PlaneClient (API, rate limiting) +scripts/plane/todo.js # TODO.md parse + tábla generálás + regenerate +scripts/plane/sync.js # Discrepancy detektálás + final sections build +scripts/plane/plane-sync.test.js # Unit tesztek +``` + +--- + +## 🔗 **Kapcsolódó Dokumentumok** + +- [README](./README.md) +- [GitHub CI/CD Guide](./GITHUB-CICD-GUIDE.md) + +**Utolsó frissítés:** 2026-08-17 diff --git a/TODO.md b/TODO.md index c63cbe7..070b976 100755 --- a/TODO.md +++ b/TODO.md @@ -1,14 +1,13 @@ # mozdIT Weboldal - Fejlesztési TODO Lista ## Projekt Áttekintés -Next.js 15 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva. +Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolva. ## ⚠️ Projekt Management **Elsődleges forrás**: Plane — `MITHOME` projekt (`pm.llmdev.mozdit.hu`, workspace: `developments`) **Lokális másolat**: Ez a fájl csak referencia, a Plane az authoritative source --- - ## ✅ Befejezett | Plane | Feladat | Státusz | @@ -25,8 +24,6 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva. | MITHOME-10 | Unit teszt infrastruktúra (Jest + React Testing Library) | ✅ | | MITHOME-11 | Contact API — rate limiting, spam detection, validáció | ✅ | | MITHOME-12 | Docker stack — Next.js + MongoDB + Loki + Grafana | ✅ | -| MITHOME-15 | Prod app deployment + docker-compose.prod.yml + deploy.sh | ✅ | -| MITHOME-18 | Jogi oldalak (Adatvédelem, ÁSZF) JSON-ből integrálva | ✅ | | MITHOME-27 | Custom CMS (content-editor.js) UI fejlesztés, Basic Auth & Publikálás gomb | ✅ | | MITHOME-28 | Webmail link (mail.mozdit.hu) és Logo finomhangolás | ✅ | @@ -53,7 +50,9 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva. | Plane | Feladat | Státusz | |-------|---------|---------| +| MITHOME-15 | Prod app + domain konfiguráció + HTTPS | 📋 | | MITHOME-16 | Site config migrálás MongoDB-ba | 📋 | +| MITHOME-18 | Adatvédelmi tájékoztató oldal (/adatvedelem) — GDPR | 📋 | | MITHOME-19 | Accessibility (A11y) — WCAG 2.1 AA megfelelőség | 📋 | | MITHOME-20 | Lighthouse score ≥ 90 minden kategóriában | 📋 | | MITHOME-21 | Logging middleware megvalósítása | 📋 | @@ -71,7 +70,7 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Dokploy-on hostolva. - **Database**: MongoDB (Mongoose ODM) - **Logging**: Winston + Loki - **Monitoring**: Grafana -- **Deployment**: Dokploy +- **Deployment**: Docker Compose (deploy.sh) - **Testing**: Jest, React Testing Library - **Project Management**: Plane MITHOME (elsődleges) @@ -104,6 +103,7 @@ docker-compose -f docker-compose.dev.yml down --- ## Frissítési Napló +- **2026-08-17**: Plane sync futtatása (`plane-sync.js`). MITHOME-15, MITHOME-18 visszaminősítve Backlog-ba (Plane szerint), MITHOME-27/28 megtartva. - **2026-04-26**: Átállás Linear → Plane (MITHOME projekt). TODO.md teljes újraírva, 26 issue szinkronizálva. - **2025-01-23**: ZEE-29 (Tailwind + layout) és JSON content management befejezése (Linear) -- **2025-09-05**: Docker fejlesztői környezet implementálva \ No newline at end of file +- **2025-09-05**: Docker fejlesztői környezet implementálva diff --git a/plane-sync.js b/plane-sync.js new file mode 100644 index 0000000..9514521 --- /dev/null +++ b/plane-sync.js @@ -0,0 +1,232 @@ +#!/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= 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 }; diff --git a/scripts/plane/client.js b/scripts/plane/client.js new file mode 100644 index 0000000..c2d59a8 --- /dev/null +++ b/scripts/plane/client.js @@ -0,0 +1,79 @@ +const https = require('https'); + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function maskKey(key) { + if (!key) return '[NO_KEY]'; + return key.length > 12 ? `${key.slice(0, 8)}...${key.slice(-4)}` : '***'; +} + +class PlaneClient { + constructor(config) { + this.apiKey = config.apiKey; + this.host = config.host; + this.workspace = config.workspace; + this.verbose = config.verbose || false; + this.lastRequestTime = 0; + this.minDelay = 500; + } + + async request(apiPath) { + // WHY: keep a small delay between requests to respect Plane's rate limits + const wait = this.minDelay - (Date.now() - this.lastRequestTime); + if (wait > 0) await delay(wait); + this.lastRequestTime = Date.now(); + + if (this.verbose) console.log(`🔗 GET ${apiPath}`); + + return new Promise((resolve, reject) => { + const url = new URL(`${this.host}${apiPath}`); + const req = https.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: 'GET', + headers: { 'x-api-key': this.apiKey, 'Accept': 'application/json' } + }, + res => { + let body = ''; + res.on('data', chunk => (body += chunk)); + res.on('end', () => { + if (res.statusCode >= 400) { + return reject(new Error(`Plane API error ${res.statusCode}: ${body.slice(0, 200)}`)); + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(new Error(`Invalid JSON response (key: ${maskKey(this.apiKey)}): ${body.slice(0, 120)}`)); + } + }); + } + ); + req.on('error', reject); + req.end(); + }); + } + + async getProjects() { + const data = await this.request(`/api/v1/workspaces/${this.workspace}/projects/`); + return data.results || []; + } + + async getIssues(projectId) { + const issues = []; + let cursor = null; + do { + const base = `/api/v1/workspaces/${this.workspace}/projects/${projectId}/issues/`; + const query = `per_page=100&expand=state${cursor ? `&cursor=${cursor}` : ''}`; + const data = await this.request(`${base}?${query}`); + issues.push(...(data.results || [])); + cursor = data.next_page_results ? data.next_cursor : null; + } while (cursor); + return issues; + } +} + +module.exports = { PlaneClient, maskKey }; diff --git a/scripts/plane/config.js b/scripts/plane/config.js new file mode 100644 index 0000000..1392f9d --- /dev/null +++ b/scripts/plane/config.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const path = require('path'); + +// WHY: .mcp.json is gitignored but already holds the Plane credentials for the MCP +// server, so reusing it keeps the API key in a single place instead of duplicating it. +const MCP_PATH = path.join(__dirname, '..', '..', '.mcp.json'); + +const DEFAULT_PROJECT_ID = '643f7055-1237-4912-912f-99ec49fd0f0e'; +const DEFAULT_PROJECT_IDENTIFIER = 'MITHOME'; + +function loadDotEnv() { + try { + const envPath = path.join(__dirname, '..', '..', '.env'); + if (fs.existsSync(envPath)) { + const lines = fs.readFileSync(envPath, 'utf8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx > 0) process.env[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim(); + } + } + } catch (error) { + console.warn('⚠️ .env load error:', error.message); + } +} + +function loadMcpConfig() { + try { + if (fs.existsSync(MCP_PATH)) { + const mcp = JSON.parse(fs.readFileSync(MCP_PATH, 'utf8')); + const plane = mcp.mcpServers && mcp.mcpServers.plane; + return plane && plane.env ? plane.env : {}; + } + } catch (error) { + console.warn('⚠️ .mcp.json parse error:', error.message); + } + return {}; +} + +// DECISION: env vars override .mcp.json so CI/CD can supply its own credentials. +function getConfig() { + const mcp = loadMcpConfig(); + return { + apiKey: process.env.PLANE_API_KEY || mcp.PLANE_API_KEY, + host: (process.env.PLANE_API_HOST_URL || mcp.PLANE_API_HOST_URL || 'https://pm.llmdev.mozdit.hu/').replace(/\/+$/, ''), + workspace: process.env.PLANE_WORKSPACE_SLUG || mcp.PLANE_WORKSPACE_SLUG, + projectId: process.env.PLANE_PROJECT_ID || DEFAULT_PROJECT_ID, + projectIdentifier: process.env.PLANE_PROJECT_IDENTIFIER || DEFAULT_PROJECT_IDENTIFIER + }; +} + +module.exports = { getConfig, loadDotEnv }; diff --git a/scripts/plane/plane-sync.test.js b/scripts/plane/plane-sync.test.js new file mode 100644 index 0000000..ffe8b1f --- /dev/null +++ b/scripts/plane/plane-sync.test.js @@ -0,0 +1,164 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseTodoFile, buildTable, regenerate, seqNum, SECTIONS } = require('./todo'); +const { groupToSection, buildDiscrepancies, buildFinalSections } = require('./sync'); + +const SAMPLE_TODO = `# mozdIT Weboldal + +## Projekt Áttekintés +Next.js 15 alapú weboldal. + +--- +## ✅ Befejezett + +| Plane | Feladat | Státusz | +|-------|---------|---------| +| MITHOME-1 | Kezdőlap | ✅ | +| MITHOME-15 | Prod app + domain konfiguráció + HTTPS | ✅ | + +--- + +## 🚀 TODO (Soron következő — v1.0.1 roadmap) + +| Plane | Feladat | Státusz | Megjegyzés | +|-------|---------|---------|-----------| +| MITHOME-13 | CI pipeline | ⏳ | Elsődleges prioritás | + +--- + +## 🔄 In Progress + +| Plane | Feladat | Státusz | Megjegyzés | +|-------|---------|---------|-----------| +| MITHOME-17 | Winston logger + Loki integráció | 🔄 | részben kész | + +--- + +## 📋 Backlog (v1.0.2+) + +| Plane | Feladat | Státusz | +|-------|---------|---------| +| MITHOME-19 | Accessibility | 📋 | + +--- + +## Technikai Stack +- **Frontend**: Next.js 15 +`; + +test('parseTodoFile parses sections and rows with notes', () => { + const { sections, rows } = parseTodoFile(SAMPLE_TODO); + assert.equal(sections.completed.rows.length, 2); + assert.equal(sections.completed.header, '## ✅ Befejezett'); + assert.equal(sections.todo.rows[0].note, 'Elsődleges prioritás'); + assert.equal(sections.inProgress.rows[0].note, 'részben kész'); + assert.equal(rows.length, 5); + assert.equal(rows[1].section, 'completed'); + assert.equal(rows[1].id, 'MITHOME-15'); +}); + +test('parseTodoFile ignores table header and separator lines', () => { + const { rows } = parseTodoFile(SAMPLE_TODO); + assert.ok(!rows.some(r => r.name === 'Feladat')); +}); + +test('groupToSection maps Plane groups', () => { + assert.equal(groupToSection('completed'), 'completed'); + assert.equal(groupToSection('started'), 'inProgress'); + assert.equal(groupToSection('unstarted'), 'todo'); + assert.equal(groupToSection('backlog'), 'backlog'); + assert.equal(groupToSection('cancelled'), null); +}); + +test('seqNum extracts trailing number from identifier', () => { + assert.equal(seqNum('MITHOME-13'), 13); + assert.equal(seqNum('MITHOME-1'), 1); + assert.equal(seqNum('MITHOME-27'), 27); + assert.equal(seqNum('MITHOME-'), 0); +}); + +test('buildDiscrepancies detects mismatches, new issues, orphans and cancelled', () => { + const planeIssues = [ + { sequence_id: 1, name: 'Kezdőlap', state: { group: 'completed' } }, + { sequence_id: 15, name: 'Prod app + domain konfiguráció + HTTPS', state: { group: 'backlog' } }, + { sequence_id: 18, name: 'Adatvédelmi tájékoztató oldal', state: { group: 'completed' } }, + { sequence_id: 30, name: 'Új feladat', state: { group: 'started' } }, + { sequence_id: 40, name: 'Törölt feladat', state: { group: 'cancelled' } } + ]; + const { rows } = parseTodoFile(SAMPLE_TODO); + const result = buildDiscrepancies(planeIssues, rows, 'MITHOME'); + + assert.equal(result.mismatches.length, 1); + assert.equal(result.mismatches[0].id, 'MITHOME-15'); + assert.equal(result.mismatches[0].fromSection, 'completed'); + assert.equal(result.mismatches[0].toSection, 'backlog'); + + assert.equal(result.newIssues.length, 2); + assert.deepEqual(result.newIssues.map(n => n.id), ['MITHOME-18', 'MITHOME-30']); + + assert.equal(result.orphans.length, 3); + assert.deepEqual(result.orphans.map(o => o.id).sort(), ['MITHOME-13', 'MITHOME-17', 'MITHOME-19']); + + assert.deepEqual(result.cancelled, [{ id: 'MITHOME-40', name: 'Törölt feladat' }]); +}); + +test('buildFinalSections keeps plane rows and applies decisions', () => { + const planeIssues = [ + { sequence_id: 1, name: 'Kezdőlap', state: { group: 'completed' } }, + { sequence_id: 15, name: 'Prod app + domain konfiguráció + HTTPS', state: { group: 'backlog' } }, + { sequence_id: 17, name: 'Winston logger + Loki integráció', state: { group: 'started' } }, + { sequence_id: 30, name: 'Új feladat', state: { group: 'started' } } + ]; + const { rows } = parseTodoFile(SAMPLE_TODO); + const buildData = buildDiscrepancies(planeIssues, rows, 'MITHOME'); + const decisions = { + moveToPlane: new Set(['MITHOME-15']), + keepOrphan: new Set(['MITHOME-13', 'MITHOME-17', 'MITHOME-19']), + addNew: new Set(['MITHOME-30']) + }; + const final = buildFinalSections(buildData, decisions); + + assert.deepEqual(final.completed.map(r => r.id), ['MITHOME-1']); + assert.deepEqual(final.backlog.map(r => r.id), ['MITHOME-15', 'MITHOME-19']); + assert.deepEqual(final.inProgress.map(r => r.id), ['MITHOME-17', 'MITHOME-30']); + assert.equal(final.inProgress.find(r => r.id === 'MITHOME-17').note, 'részben kész'); +}); + +test('regenerate preserves prefix/suffix and rebuilds sections', () => { + const planeIssues = [ + { sequence_id: 1, name: 'Kezdőlap', state: { group: 'completed' } }, + { sequence_id: 15, name: 'Prod app + domain konfiguráció + HTTPS', state: { group: 'backlog' } }, + { sequence_id: 13, name: 'CI pipeline', state: { group: 'unstarted' } }, + { sequence_id: 17, name: 'Winston logger + Loki integráció', state: { group: 'started' } }, + { sequence_id: 19, name: 'Accessibility', state: { group: 'backlog' } } + ]; + const { rows } = parseTodoFile(SAMPLE_TODO); + const buildData = buildDiscrepancies(planeIssues, rows, 'MITHOME'); + const decisions = { + moveToPlane: new Set(['MITHOME-15']), + keepOrphan: new Set(['MITHOME-13', 'MITHOME-17', 'MITHOME-19']), + addNew: new Set() + }; + const final = buildFinalSections(buildData, decisions); + const output = regenerate(SAMPLE_TODO, final); + + assert.ok(output.includes('# mozdIT Weboldal')); + assert.ok(output.includes('Next.js 15 alapú weboldal.')); + assert.ok(output.includes('## Technikai Stack')); + assert.ok(output.includes('- **Frontend**: Next.js 15')); + assert.ok(output.includes('| MITHOME-15 | Prod app + domain konfiguráció + HTTPS | 📋 |')); + assert.ok(!output.includes('| MITHOME-15 | Prod app + domain konfiguráció + HTTPS | ✅ |')); + assert.ok(output.includes('## ✅ Befejezett')); + assert.ok(output.includes('| MITHOME-1 | Kezdőlap | ✅ |')); +}); + +test('buildTable sorts rows by sequence number', () => { + const table = buildTable(SECTIONS[0], [ + { id: 'MITHOME-15', name: 'B' }, + { id: 'MITHOME-1', name: 'A' } + ]); + const lines = table.split('\n'); + assert.equal(lines[2], '| MITHOME-1 | A | ✅ |'); + assert.equal(lines[3], '| MITHOME-15 | B | ✅ |'); +}); diff --git a/scripts/plane/sync.js b/scripts/plane/sync.js new file mode 100644 index 0000000..d7b6908 --- /dev/null +++ b/scripts/plane/sync.js @@ -0,0 +1,74 @@ +// WHY: single source of truth for group -> section, cancelled is reported not placed +function groupToSection(group) { + switch (group) { + case 'completed': + return 'completed'; + case 'started': + return 'inProgress'; + case 'unstarted': + return 'todo'; + case 'backlog': + return 'backlog'; + default: + return null; + } +} + +function buildDiscrepancies(planeIssues, todoRows, identifier) { + const todoById = new Map(todoRows.map(row => [row.id, row])); + const planeById = new Map(); + const cancelled = []; + + for (const issue of planeIssues) { + const id = `${identifier}-${issue.sequence_id}`; + const group = (issue.state && issue.state.group) || 'backlog'; + const section = groupToSection(group); + if (!section) { + cancelled.push({ id, name: issue.name }); + continue; + } + planeById.set(id, { id, name: issue.name, section, group }); + } + + const mismatches = []; + const newIssues = []; + const orphans = []; + + for (const [id, p] of planeById) { + const t = todoById.get(id); + if (t && t.section !== p.section) mismatches.push({ id, fromSection: t.section, toSection: p.section, name: p.name }); + if (!t) newIssues.push({ id, section: p.section, name: p.name }); + } + for (const [id, t] of todoById) { + if (!planeById.has(id)) orphans.push({ id, section: t.section, name: t.name }); + } + + return { mismatches, newIssues, orphans, cancelled, planeById, todoById }; +} + +// WHY: Plane is authoritative, so plane rows always win; decisions only move/add local rows +function buildFinalSections(buildData, decisions) { + const final = { completed: [], todo: [], inProgress: [], backlog: [] }; + const { planeById, todoById, mismatches } = buildData; + + for (const [id, p] of planeById) { + const t = todoById.get(id); + const note = t ? t.note : ''; + const isMismatch = mismatches.some(m => m.id === id); + if (isMismatch && !decisions.moveToPlane.has(id)) { + final[t.section].push({ id, name: p.name, note }); + } else { + final[p.section].push({ id, name: p.name, note }); + } + } + + for (const orphan of buildData.orphans) { + if (decisions.keepOrphan.has(orphan.id)) { + final[orphan.section].push({ id: orphan.id, name: orphan.name, note: '' }); + } + } + + return final; +} + +module.exports = { groupToSection, buildDiscrepancies, buildFinalSections }; diff --git a/scripts/plane/todo.js b/scripts/plane/todo.js new file mode 100644 index 0000000..f132dfd --- /dev/null +++ b/scripts/plane/todo.js @@ -0,0 +1,101 @@ +// WHY: sections are identified by emoji header; order defines generated layout +const SECTIONS = [ + { key: 'completed', header: /^## ✅/, emoji: '✅', hasNote: false }, + { key: 'todo', header: /^## 🚀/, emoji: '⏳', hasNote: true }, + { key: 'inProgress', header: /^## 🔄/, emoji: '🔄', hasNote: true }, + { key: 'backlog', header: /^## 📋/, emoji: '📋', hasNote: false } +]; + +const SECTION_LABEL = { + completed: 'Befejezett', + todo: 'TODO', + inProgress: 'In Progress', + backlog: 'Backlog' +}; + +function parseTodoFile(content) { + const sections = { + completed: { header: null, rows: [] }, + todo: { header: null, rows: [] }, + inProgress: { header: null, rows: [] }, + backlog: { header: null, rows: [] } + }; + const rows = []; + let current = null; + + for (const line of content.split('\n')) { + const sec = SECTIONS.find(s => s.header.test(line)); + if (sec) { + current = sec.key; + if (!sections[current].header) sections[current].header = line; + continue; + } + if (!current || !line.trim().startsWith('|')) continue; + const cols = line.split('|').map(c => c.trim()).filter(c => c !== ''); + if (cols.length < 3) continue; + if (cols[0] === 'Plane' || /^[-]+$/.test(cols[0])) continue; + const row = { id: cols[0], name: cols[1], status: cols[2], note: cols[3] || '' }; + sections[current].rows.push(row); + rows.push({ section: current, ...row }); + } + + return { sections, rows }; +} + +function seqNum(id) { + const match = id.match(/(\d+)$/); + return match ? parseInt(match[1], 10) : 0; +} + +function buildTable(sec, rows) { + const header = sec.hasNote + ? '| Plane | Feladat | Státusz | Megjegyzés |' + : '| Plane | Feladat | Státusz |'; + const separator = sec.hasNote + ? '|-------|---------|---------|-----------|' + : '|-------|---------|---------|'; + const body = rows + .slice() + .sort((a, b) => seqNum(a.id) - seqNum(b.id)) + .map(row => { + if (sec.hasNote) return `| ${row.id} | ${row.name} | ${sec.emoji} | ${row.note || ''} |`; + return `| ${row.id} | ${row.name} | ${sec.emoji} |`; + }); + return [header, separator, ...body].join('\n'); +} + +// WHY: only the section region is regenerated, prefix/suffix (overview, commands) are preserved +function regenerate(content, finalSections) { + const lines = content.split('\n'); + const firstIdx = lines.findIndex(l => SECTIONS[0].header.test(l)); + const backlogIdx = lines.findIndex(l => SECTIONS[3].header.test(l)); + if (firstIdx === -1 || backlogIdx === -1) throw new Error('TODO.md section headers not found'); + + let endIdx = lines.length; + for (let i = backlogIdx + 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + endIdx = i; + break; + } + } + + const prefix = lines.slice(0, firstIdx).join('\n'); + const suffix = lines.slice(endIdx + 1).join('\n'); + + const headerOf = key => { + const sec = SECTIONS.find(s => s.key === key); + const idx = lines.findIndex(l => sec.header.test(l)); + return idx >= 0 ? lines[idx].replace(/^## /, '') : key; + }; + + const blocks = SECTIONS.map(sec => { + const rows = finalSections[sec.key] || []; + return `## ${headerOf(sec.key)}\n\n${buildTable(sec, rows)}`; + }); + const middle = blocks.join('\n\n---\n\n') + '\n\n---\n'; + + const result = `${prefix}${middle}${suffix}`; + return result.endsWith('\n') ? result : `${result}\n`; +} + +module.exports = { SECTIONS, SECTION_LABEL, parseTodoFile, buildTable, regenerate, seqNum };