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.
80 lines
2.4 KiB
JavaScript
80 lines
2.4 KiB
JavaScript
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 };
|