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
+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 };