#!/usr/bin/env node /** * Seed ByteLyst tracker items from a JSON payload file. * * Creates feature/bug/task items via the platform-service tracker API * (POST /api/items — the same endpoint tracker-web proxies to). Items are * scoped per `productId`. Use this to file the ENGINEERING_REVIEW_SCORECARD.md * P0-P3 work once the platform stack is running. * * Auth: mints a short-lived HS256 access token signed with $JWT_SECRET * (same secret platform-service verifies with). No external deps — uses * node:crypto only. * * Env: * PLATFORM_API_URL Base URL of platform-service (default http://localhost:4003) * JWT_SECRET Shared JWT secret (required unless --dry-run) * SEED_SUB Token subject / reportedBy (default "eng-review-bot") * SEED_EMAIL Token email claim (default "eng-review-bot@bytelyst.local") * SEED_ROLE Token role claim (default "admin") * ITEMS_FILE Payload file (default ./engineering-review-items.json) * * Flags: * --dry-run Print what would be created; no token, no network calls. * --force Skip the dedupe-by-title check. * * Examples: * node seed-tracker-items.mjs --dry-run * JWT_SECRET=... PLATFORM_API_URL=http://localhost:4003 node seed-tracker-items.mjs */ import { readFileSync } from 'node:fs'; import { createHmac } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const args = new Set(process.argv.slice(2)); const DRY_RUN = args.has('--dry-run'); const FORCE = args.has('--force'); const API = (process.env.PLATFORM_API_URL || 'http://localhost:4003').replace(/\/$/, ''); const SECRET = process.env.JWT_SECRET || ''; const SUB = process.env.SEED_SUB || 'eng-review-bot'; const EMAIL = process.env.SEED_EMAIL || 'eng-review-bot@bytelyst.local'; const ROLE = process.env.SEED_ROLE || 'admin'; const ITEMS_FILE = resolve(__dirname, process.env.ITEMS_FILE || 'engineering-review-items.json'); function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Mint an HS256 access token compatible with @bytelyst/auth extractAuth(). */ function mintToken() { if (!SECRET) throw new Error('JWT_SECRET must be set (or use --dry-run)'); const now = Math.floor(Date.now() / 1000); const header = { alg: 'HS256', typ: 'JWT' }; const payload = { sub: SUB, email: EMAIL, role: ROLE, type: 'access', iat: now, exp: now + 3600 }; const head = b64url(JSON.stringify(header)); const body = b64url(JSON.stringify(payload)); const sig = b64url(createHmac('sha256', SECRET).update(`${head}.${body}`).digest()); return `${head}.${body}.${sig}`; } async function listTitles(token, productId) { // limit max is 100 (server caps it); 500 would 400 and silently break dedupe. const url = `${API}/api/items?productId=${encodeURIComponent(productId)}&limit=100`; const res = await fetch(url, { headers: { authorization: `Bearer ${token}`, 'x-product-id': productId }, }); if (!res.ok) throw new Error(`list failed (${res.status})`); const data = await res.json(); return new Set((data.items || []).map((i) => i.title)); } /** Derive an uppercase-letters-only license prefix (<=8) from a productId. */ function licensePrefix(productId) { return productId.replace(/[^a-z]/gi, '').toUpperCase().slice(0, 8) || 'PRD'; } /** * Ensure a product exists before creating items under it. POST /items skips * product validation when productId is in the body, but GET /items validates — * so without registration the dedupe list 400s ("Unknown product") and * re-runs create duplicates. Idempotent: treats 409/200/201 as success. */ async function ensureProduct(token, productId) { const res = await fetch(`${API}/api/products`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'x-product-id': productId, }, body: JSON.stringify({ productId, displayName: productId.charAt(0).toUpperCase() + productId.slice(1), licensePrefix: licensePrefix(productId), }), }); if (!res.ok && res.status !== 409) { console.warn(` warn: register ${productId} -> HTTP ${res.status}`); } } async function createItem(token, item) { const res = await fetch(`${API}/api/items`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'x-product-id': item.productId, }, body: JSON.stringify(item), }); const text = await res.text(); if (!res.ok) throw new Error(`${res.status} ${text.slice(0, 300)}`); return JSON.parse(text); } async function main() { const raw = JSON.parse(readFileSync(ITEMS_FILE, 'utf8')); const items = raw.items || raw; console.log(`Loaded ${items.length} item(s) from ${ITEMS_FILE}`); console.log(`Target: ${API}/api/items (dry-run=${DRY_RUN}, force=${FORCE})\n`); if (DRY_RUN) { for (const it of items) { console.log(` [DRY] ${it.productId.padEnd(16)} ${it.type}/${it.priority} ${it.title}`); } console.log(`\nDry run only — nothing sent. ${items.length} item(s) would be created.`); return; } const token = mintToken(); // Register every referenced product first so the dedupe list (GET /items) // doesn't 400 on unknown products — which would silently cause duplicates. const productIds = [...new Set(items.map((it) => it.productId))]; console.log(`Ensuring ${productIds.length} product(s) are registered...`); for (const pid of productIds) await ensureProduct(token, pid); const titleCache = new Map(); let created = 0; let skipped = 0; let failed = 0; for (const it of items) { try { if (!FORCE) { if (!titleCache.has(it.productId)) { titleCache.set( it.productId, await listTitles(token, it.productId).catch((e) => { console.warn(` warn: dedupe list for ${it.productId} failed (${e.message}); may create duplicates`); return new Set(); }) ); } if (titleCache.get(it.productId).has(it.title)) { console.log(` SKIP ${it.productId.padEnd(16)} ${it.title} (already exists)`); skipped++; continue; } } const res = await createItem(token, it); console.log(` OK ${it.productId.padEnd(16)} ${res.id} ${it.title}`); created++; } catch (err) { console.error(` FAIL ${it.productId.padEnd(16)} ${it.title}\n ${err.message}`); failed++; } } console.log(`\nDone. created=${created} skipped=${skipped} failed=${failed}`); if (failed > 0) process.exit(1); } main().catch((err) => { console.error(`Fatal: ${err.message}`); process.exit(1); });