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 };
|
||||
Reference in New Issue
Block a user