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.
This commit is contained in:
Do Siki
2026-08-17 12:18:55 +02:00
parent 3c79a64903
commit f918e300a6
11 changed files with 845 additions and 9 deletions
+2 -2
View File
@@ -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
+3
View File
@@ -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.*
+1 -1
View File
@@ -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
+130
View File
@@ -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=<id> # 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.<timestamp>
```
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
+5 -5
View File
@@ -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
+232
View File
@@ -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=<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 };
+79
View File
@@ -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 };
+53
View File
@@ -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 };
+164
View File
@@ -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 | ✅ |');
});
+74
View File
@@ -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 };
+101
View File
@@ -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 };