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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 | ✅ |');
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user