@timekast/factory 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.js +6 -1
- package/dist/commands/backlog.js +706 -0
- package/dist/commands/env.js +9 -0
- package/dist/commands/provision.js +689 -42
- package/dist/commands/update.js +45 -1
- package/dist/index.js +43 -2
- package/dist/lib/atomic-swap.js +13 -6
- package/dist/lib/backlog-api.js +302 -0
- package/dist/lib/backlog-mapping.js +175 -0
- package/dist/lib/backlog-parse.js +208 -0
- package/dist/lib/cloudflare-api.js +112 -2
- package/dist/lib/constants.js +23 -0
- package/dist/lib/env-file.js +51 -6
- package/dist/lib/env-wizard.js +30 -8
- package/dist/lib/package-json.js +59 -1
- package/dist/lib/provision-preflight.js +8 -0
- package/dist/lib/provision-state.js +10 -0
- package/dist/lib/vercel-env.js +42 -8
- package/package.json +1 -1
package/dist/commands/update.js
CHANGED
|
@@ -45,7 +45,7 @@ import { CLAUDE_MD_FILE, GITATTRIBUTES_FILE, LOCKFILE_FILE, PRETTIERIGNORE_FILE,
|
|
|
45
45
|
import { decideCommit } from '../lib/commit-decision.js';
|
|
46
46
|
import { extractManagedBlock, syncManagedBlock } from '../lib/gitattributes.js';
|
|
47
47
|
import { diffLockfiles, hasLockfile, normalizeThenHash, planAutoRegister, readLockfile, writeLockfile, } from '../lib/lockfile.js';
|
|
48
|
-
import { detectProfile, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
48
|
+
import { detectProfile, emptyShippedE2EPort, insertFactoryUpdateScript, setAgentKitVersion, } from '../lib/package-json.js';
|
|
49
49
|
import { runPreflight } from '../lib/preflight.js';
|
|
50
50
|
import { printProvisionPointer } from '../lib/provision-pointer.js';
|
|
51
51
|
import { hasProvisionState } from '../lib/provision-state.js';
|
|
@@ -58,6 +58,7 @@ export function parseUpdateFlags(argv) {
|
|
|
58
58
|
mineAll: argv.includes('--mine-all'),
|
|
59
59
|
resume: argv.includes('--resume'),
|
|
60
60
|
verify: argv.includes('--verify'),
|
|
61
|
+
dryRun: argv.includes('--dry-run'),
|
|
61
62
|
core: argv.includes('--core'),
|
|
62
63
|
full: argv.includes('--full'),
|
|
63
64
|
commit: argv.includes('--commit'),
|
|
@@ -332,6 +333,10 @@ function maintainDerivedPkg(rootDir, agentKitVersion) {
|
|
|
332
333
|
try {
|
|
333
334
|
warnOnScriptConflict(insertFactoryUpdateScript(pkgPath).action, pkgPath);
|
|
334
335
|
setAgentKitVersion(pkgPath, agentKitVersion);
|
|
336
|
+
// Retire the E2E port the kit used to ship (`ports.e2e: 3006`) so the runner that just
|
|
337
|
+
// arrived via `scripts/**` can derive one per checkout. Conditional and silent: it only
|
|
338
|
+
// ever touches that exact value, so a dev-chosen port survives every update untouched.
|
|
339
|
+
emptyShippedE2EPort(pkgPath);
|
|
335
340
|
}
|
|
336
341
|
catch {
|
|
337
342
|
console.warn('Aviso: no se pudo actualizar package.json (¿JSON inválido?); el cerebro se instaló igual. ' +
|
|
@@ -395,6 +400,26 @@ function syncDerivedPrettierignore(rootDir, stagedDir) {
|
|
|
395
400
|
console.warn('Aviso: no se pudo sincronizar `.prettierignore`; el cerebro se instaló igual.');
|
|
396
401
|
}
|
|
397
402
|
}
|
|
403
|
+
/** Exit code of `--dry-run` when the preview found conflicts (0 = none). Lets a
|
|
404
|
+
* non-interactive caller (the GUI) branch on the result instead of clobbering. */
|
|
405
|
+
const DRY_RUN_CONFLICTS_EXIT = 2;
|
|
406
|
+
/**
|
|
407
|
+
* `--dry-run`: preview the plan (buckets + the explicit conflict list) and apply
|
|
408
|
+
* NOTHING. Prints the conflicting paths so a human or agent sees exactly what
|
|
409
|
+
* collides before deciding. The GUI runs this first: no conflicts → it is safe to
|
|
410
|
+
* apply `--theirs-all` (only adds/overwrites of clean files); conflicts → hand off
|
|
411
|
+
* to the CLI for per-file resolution instead of silently taking theirs.
|
|
412
|
+
*/
|
|
413
|
+
function reportDryRun(diff, manifest) {
|
|
414
|
+
console.log(`\n🔍 Vista previa de update a v${manifest.version} — no se modificó nada:`);
|
|
415
|
+
reportSummary(diff, manifest);
|
|
416
|
+
if (diff.conflicts.length > 0) {
|
|
417
|
+
console.log('\nArchivos en conflicto (los modificaste tú y el Factory también los cambió):');
|
|
418
|
+
for (const c of diff.conflicts)
|
|
419
|
+
console.log(` • ${c.path}`);
|
|
420
|
+
console.log('\nPara resolverlos, corre `factory update` (sin `--dry-run`): pregunta archivo por archivo.');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
398
423
|
/** Print the deletes + kept-retired (design §7.6 — visible) + a one-line summary. */
|
|
399
424
|
function reportSummary(diff, manifest) {
|
|
400
425
|
if (diff.deleteSilent.length > 0) {
|
|
@@ -469,6 +494,9 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
469
494
|
if (flags.beta && flags.stable) {
|
|
470
495
|
throw new CLIError('No puedes combinar `--beta` con `--stable`.');
|
|
471
496
|
}
|
|
497
|
+
if (flags.dryRun && (flags.verify || flags.resume)) {
|
|
498
|
+
throw new CLIError('No puedes combinar `--dry-run` con `--verify` ni `--resume`.');
|
|
499
|
+
}
|
|
472
500
|
// --verify and --resume short-circuit (no download).
|
|
473
501
|
if (flags.verify) {
|
|
474
502
|
runVerify(rootDir);
|
|
@@ -529,6 +557,14 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
529
557
|
'El release está mal publicado; no se modificó nada.');
|
|
530
558
|
}
|
|
531
559
|
if (legacy) {
|
|
560
|
+
// --dry-run on a no-lockfile repo: nothing to conflict with (first sync has no
|
|
561
|
+
// prior version), so report clean and STOP — never auto-register under dry-run.
|
|
562
|
+
if (flags.dryRun) {
|
|
563
|
+
console.log('\n🔍 Vista previa: este repo aún no tiene lockfile. El primer `factory update` ' +
|
|
564
|
+
'registra el cerebro; en el primer sync no hay conflictos posibles.');
|
|
565
|
+
process.exitCode = 0;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
532
568
|
await applyLegacy(rootDir, stagedDir, manifest);
|
|
533
569
|
// Legacy auto-register succeeded → end-pointer (PVSKILL-002).
|
|
534
570
|
printProvisionPointer({ assumeYes: flags.yes });
|
|
@@ -542,6 +578,14 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
542
578
|
]);
|
|
543
579
|
const disk = hashDiskPaths(rootDir, trackedPaths);
|
|
544
580
|
const diff = diffLockfiles(oldLock, manifest, disk);
|
|
581
|
+
// --dry-run: preview + STOP. No prompts, no apply, no lockfile, no commit.
|
|
582
|
+
// Exit 2 when conflicts exist so the GUI branches on it (0 otherwise). `return`
|
|
583
|
+
// runs the finally → tmpRoot is cleaned (no update-state was written).
|
|
584
|
+
if (flags.dryRun) {
|
|
585
|
+
reportDryRun(diff, manifest);
|
|
586
|
+
process.exitCode = diff.conflicts.length > 0 ? DRY_RUN_CONFLICTS_EXIT : 0;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
545
589
|
// Resolve conflicts.
|
|
546
590
|
const theirsConflicts = [];
|
|
547
591
|
for (const conflict of diff.conflicts) {
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname, join } from 'node:path';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { CLIError } from './lib/cli-error.js';
|
|
13
13
|
import { parseAddFlags, runAdd } from './commands/add.js';
|
|
14
|
+
import { parseBacklogPushFlags, parseBacklogSyncFlags, runBacklogPush, runBacklogSync, } from './commands/backlog.js';
|
|
14
15
|
import { parseEnvPushFlags, parseEnvWizardFlags, runEnvPush, runEnvWizardApply, runEnvWizardGenVapid, runEnvWizardPlan, runEnvWizardReview, } from './commands/env.js';
|
|
15
16
|
import { parseInviteAdminFlags, runInviteAdmin } from './commands/invite-admin.js';
|
|
16
17
|
import { envWizardFlow } from './tui/env-wizard-flow.js';
|
|
@@ -40,7 +41,8 @@ Comandos:
|
|
|
40
41
|
(--launcher: verifica el entorno — Node ≥ 22, gh, gh auth, membresía org)
|
|
41
42
|
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
42
43
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
43
|
-
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS) — flag-driven, --json
|
|
44
|
+
provision Aprovisiona la infra de un derivado (Neon/Vercel/GitHub/DNS/backlog) — flag-driven, --json
|
|
45
|
+
backlog <sub> push: refleja el backlog local en backlog.timekast.mx · sync: status + self-healing
|
|
44
46
|
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
45
47
|
env <sub> push: sube product vars a Vercel (day-2) · wizard: configura .env.local
|
|
46
48
|
invite-admin Crea una invitación de super_admin para un derivado (sin password en texto plano)
|
|
@@ -69,6 +71,7 @@ Sobre \`update\`:
|
|
|
69
71
|
--mine-all Conserva tu versión en todos los conflictos
|
|
70
72
|
--resume Retoma un update interrumpido sin re-descargar
|
|
71
73
|
--verify Reporta diferencias disco-vs-lockfile sin modificar nada
|
|
74
|
+
--dry-run Previsualiza el plan (conflictos incluidos) SIN aplicar; sale 2 si hay conflictos
|
|
72
75
|
--beta Entra al canal beta (instala el último pre-release \`-beta.N\`)
|
|
73
76
|
--stable Sale del canal beta y baja al estable (limpia \`beta\` del lockfile)
|
|
74
77
|
--yes / -y Auto-confirma el auto-registro de un repo legacy (sin lockfile) —
|
|
@@ -81,7 +84,10 @@ Sobre \`provision\`:
|
|
|
81
84
|
headless-safe; la interacción la pone la skill \`tk-provision\`. Preflight
|
|
82
85
|
fail-closed (gh + tokens del gate + \`VERCEL_TOKEN\` con acceso al team);
|
|
83
86
|
\`neonctl\` es opcional (ausente → warning). Flags:
|
|
84
|
-
--services=a,b Servicios a crear (neon,vercel,github,dns)
|
|
87
|
+
--services=a,b Servicios a crear (neon,vercel,github,dns,backlog). \`backlog\`
|
|
88
|
+
registra el proyecto en backlog.timekast.mx y escribe
|
|
89
|
+
BACKLOG_API_KEY + BACKLOG_PROJECT_ID a .env.local (requiere
|
|
90
|
+
BACKLOG_ADMIN_TOKEN en el rail; ausente en un bundle → se omite)
|
|
85
91
|
--slug=<slug> Identificador del stack (nombre del proyecto + dominios interinos)
|
|
86
92
|
--team=<id> Team de Vercel (override; default = \`VERCEL_TEAM_ID\` del rail ~/.claude/.env)
|
|
87
93
|
--org-id=<id> Org de GitHub (override; default = org del Factory)
|
|
@@ -117,6 +123,24 @@ Sobre \`env\`:
|
|
|
117
123
|
--plan [--json] Emite el plan de preguntas (lo consume el agente / la skill tk-provision)
|
|
118
124
|
--apply [--push] Lee \`{ answers, push? }\` JSON por stdin y escribe \`.env.local\`
|
|
119
125
|
|
|
126
|
+
Sobre \`backlog\`:
|
|
127
|
+
\`backlog push\` refleja el backlog local (\`project/backlog/*\`) en
|
|
128
|
+
backlog.timekast.mx en cascada (épicas → features de los Refs \`FT-XX\` → issues
|
|
129
|
+
story/task según el campo \`Board:\`). Read-only por defecto: sin \`BACKLOG_API_KEY\`
|
|
130
|
+
en \`.env.local\` termina con exit 0 y una nota (nunca bloquea \`/backlog\`). Flags:
|
|
131
|
+
--reconcile PATCH del status cuando el elemento ya existe (en vez de saltarlo)
|
|
132
|
+
--stamp Incluye issues/épicas sin UUID y estampa su \`Backlog UUID\` de vuelta
|
|
133
|
+
--backfill Campaña legacy: incluye + estampa un backlog pre-BSYNC-007 completo
|
|
134
|
+
--source-lang <l> Idioma del texto pusheado (default \`es\`)
|
|
135
|
+
|
|
136
|
+
\`backlog sync\` actualiza el status de un elemento puntual y corre un heartbeat de
|
|
137
|
+
self-healing (repara drift vs backlog.timekast.mx). Fire-and-forget: sin
|
|
138
|
+
\`BACKLOG_API_KEY\` termina con exit 0; acotado a un deadline de 5s (nunca bloquea el
|
|
139
|
+
workflow). Flags:
|
|
140
|
+
--issue <ID> --status <s> PATCH del status de un issue (s: enum remoto o bucket local)
|
|
141
|
+
--epic <ID> --status <s> Equivalente para una épica
|
|
142
|
+
--heal-only Corre solo el self-healing (sin el sync puntual)
|
|
143
|
+
|
|
120
144
|
Sobre \`invite-admin\`:
|
|
121
145
|
Materializa el primer super_admin de un derivado por invitación (reemplaza el
|
|
122
146
|
viejo seed con password en texto plano). Resuelve la connection string del
|
|
@@ -220,6 +244,23 @@ async function main(argv) {
|
|
|
220
244
|
await runProvision(parseProvisionFlags(rest));
|
|
221
245
|
return;
|
|
222
246
|
}
|
|
247
|
+
case 'backlog': {
|
|
248
|
+
const sub = rest[0];
|
|
249
|
+
// `factory backlog push` — mirror the local backlog to backlog.timekast.mx.
|
|
250
|
+
// Read-only by default (no key → exit 0).
|
|
251
|
+
if (sub === 'push') {
|
|
252
|
+
await runBacklogPush(parseBacklogPushFlags(rest.slice(1)));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// `factory backlog sync` — point status-sync + self-healing heartbeat
|
|
256
|
+
// (BSYNC-006). Fire-and-forget: no key → exit 0; bounded to a 5s deadline.
|
|
257
|
+
if (sub === 'sync') {
|
|
258
|
+
await runBacklogSync(parseBacklogSyncFlags(rest.slice(1)));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
throw new CLIError(`Subcomando de backlog desconocido: \`${sub ?? '(ninguno)'}\`. ` +
|
|
262
|
+
'Uso: `factory backlog push` | `factory backlog sync`.');
|
|
263
|
+
}
|
|
223
264
|
case 'publish': {
|
|
224
265
|
await runPublish(parsePublishArgs(rest));
|
|
225
266
|
return;
|
package/dist/lib/atomic-swap.js
CHANGED
|
@@ -25,13 +25,20 @@ import path from 'node:path';
|
|
|
25
25
|
import { CLIError } from './cli-error.js';
|
|
26
26
|
import { CLAUDE_MD_FILE, TIMEKAST_DIR } from './constants.js';
|
|
27
27
|
import { parseLockfile } from './lockfile.js';
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Roots a manifest path may live under (mirrors `profiles.json#track`).
|
|
30
|
+
*
|
|
31
|
+
* `.github/workflows/` is deliberately NARROWER than `.github/` — the tracked set is
|
|
32
|
+
* only the CI workflows, and widening the guard to the whole directory would let a
|
|
33
|
+
* tampered tarball rewrite issue templates, CODEOWNERS or funding config.
|
|
34
|
+
*/
|
|
35
|
+
const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/', '.github/workflows/'];
|
|
30
36
|
/**
|
|
31
37
|
* Reject a manifest path that is unsafe to write: absolute, escaping via `..`, or
|
|
32
|
-
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky
|
|
33
|
-
* release never produces these — this guards a
|
|
34
|
-
* writing `src/` or escaping the repo root.
|
|
38
|
+
* outside the tracked scope (`.claude/**`, `scripts/**`, `CLAUDE.md`, `.husky/**`,
|
|
39
|
+
* `.github/workflows/**`). A correct release never produces these — this guards a
|
|
40
|
+
* mispublished/tampered tarball from writing `src/` or escaping the repo root.
|
|
41
|
+
* Separator-agnostic (Windows-safe).
|
|
35
42
|
*/
|
|
36
43
|
export function assertSafeTrackedPath(rel) {
|
|
37
44
|
const segments = rel.split(/[\\/]/);
|
|
@@ -40,7 +47,7 @@ export function assertSafeTrackedPath(rel) {
|
|
|
40
47
|
}
|
|
41
48
|
const tracked = rel === CLAUDE_MD_FILE || ALLOWED_ROOTS.some((root) => rel.startsWith(root));
|
|
42
49
|
if (!tracked) {
|
|
43
|
-
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`, \`.husky/\`). ` +
|
|
50
|
+
throw new CLIError(`El manifest declara \`${rel}\`, fuera del scope rastreado (\`.claude/\`, \`scripts/\`, \`CLAUDE.md\`, \`.husky/\`, \`.github/workflows/\`). ` +
|
|
44
51
|
'El release está mal construido; no se tocó nada.');
|
|
45
52
|
}
|
|
46
53
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backlog-central REST v1 client for the Factory ↔ backlog.timekast.mx integration.
|
|
3
|
+
*
|
|
4
|
+
* A THIN HTTP client that MIRRORS the proven fetch shape of `neon-api.ts`
|
|
5
|
+
* (`Authorization: Bearer ${key}` + a constant base URL + a typed error carrying
|
|
6
|
+
* the HTTP status + an injectable `fetchImpl` seam) — we reuse that shape rather
|
|
7
|
+
* than reinvent an HTTP client per command (issue §6). The provision/push/sync
|
|
8
|
+
* commands (BSYNC-003/005/006) consume this single surface instead of each
|
|
9
|
+
* hand-rolling headers/errors/backoff.
|
|
10
|
+
*
|
|
11
|
+
* TWO auth scopes ride the SAME `Authorization: Bearer` header; the CALLER picks
|
|
12
|
+
* which key to pass (this client does not distinguish them):
|
|
13
|
+
* - the org-level `admin` token (rail `BACKLOG_ADMIN_TOKEN`) — ONLY the three
|
|
14
|
+
* org endpoints: `POST /projects`, `POST /projects/{pid}/keys`,
|
|
15
|
+
* `DELETE /projects/{pid}`. It 403s (`PROJECT_NOT_IN_KEY`) on anything scoped
|
|
16
|
+
* to a project's elements.
|
|
17
|
+
* - the per-project `read_write` key (derivative `.env.local` `BACKLOG_API_KEY`)
|
|
18
|
+
* — every element/release/sync-state/summary call.
|
|
19
|
+
* (Live-verified separation — `project/abraham/smoke-findings.md` Hallazgo 1.)
|
|
20
|
+
*
|
|
21
|
+
* Every call surfaces a typed {@link BacklogApiError} on a non-2xx response that
|
|
22
|
+
* carries the HTTP status + best-effort body, so the caller can map edge cases to
|
|
23
|
+
* actionable es-MX messages. The `429` (rate ~120 req/min) backoff lives HERE, in
|
|
24
|
+
* the shared request helper — NOT in the parser/mapping of BSYNC-004 (issue §6).
|
|
25
|
+
*
|
|
26
|
+
* The `fetch` impl is an INJECTABLE seam (`BacklogApiDeps.fetchImpl`) so the unit
|
|
27
|
+
* tests drive method + URL + payload with a mock and NEVER hit the live network;
|
|
28
|
+
* the real end-to-end exercise is BSYNC-001 (spike) + BSYNC-014 (E2E).
|
|
29
|
+
*/
|
|
30
|
+
/** Backlog-central REST v1 base (`servers[0].url` of the live `/openapi.json`). */
|
|
31
|
+
export const BACKLOG_API_BASE = 'https://backlog.timekast.mx/api/v1';
|
|
32
|
+
/** Timeout (ms) per call — a dead network must not hang the command using it. */
|
|
33
|
+
const BACKLOG_API_TIMEOUT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* A `429 Too Many Requests` (rate ~120 req/min) is retried with linear backoff —
|
|
36
|
+
* same shape `neon-api.ts` uses for its `423` lock retry. Tests inject a no-op
|
|
37
|
+
* `sleepImpl` so no real time passes.
|
|
38
|
+
*/
|
|
39
|
+
const BACKLOG_RATE_MAX_RETRIES = 4;
|
|
40
|
+
const BACKLOG_RATE_BACKOFF_MS = 1_000;
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Error + injectable seams (calqued 1:1 from neon-api.ts)
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
/** A non-2xx response from the backlog API. Carries the HTTP status for mapping. */
|
|
45
|
+
export class BacklogApiError extends Error {
|
|
46
|
+
status;
|
|
47
|
+
/** The raw response body (best-effort, truncated) — context, never a secret. */
|
|
48
|
+
body;
|
|
49
|
+
constructor(status, body, context) {
|
|
50
|
+
super(`Backlog API ${status} en ${context}: ${body || '(sin cuerpo)'}`);
|
|
51
|
+
this.name = 'BacklogApiError';
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.body = body;
|
|
54
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Path segment (plural) per element type — the API routes elements by plural. */
|
|
58
|
+
const ELEMENT_PATH_SEGMENT = {
|
|
59
|
+
epic: 'epics',
|
|
60
|
+
feature: 'features',
|
|
61
|
+
story: 'stories',
|
|
62
|
+
task: 'tasks',
|
|
63
|
+
bug: 'bugs',
|
|
64
|
+
};
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Shared request helper — headers, timeout, 429 backoff, error, `{data}` unwrap
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
/** Shared headers for every backlog call (Bearer auth + JSON). */
|
|
69
|
+
function backlogHeaders(apiKey) {
|
|
70
|
+
return {
|
|
71
|
+
Authorization: `Bearer ${apiKey}`,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Read a response body as text (best-effort) for error context — never throws. */
|
|
77
|
+
async function safeBody(response) {
|
|
78
|
+
try {
|
|
79
|
+
return (await response.text()).slice(0, 500);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Parse a JSON body defensively — a missing/invalid body yields `{}`, never throws. */
|
|
86
|
+
async function safeJson(response) {
|
|
87
|
+
try {
|
|
88
|
+
return await response.json();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Perform one backlog call: Bearer headers + JSON body + per-call abort timeout,
|
|
96
|
+
* retrying `429` with linear backoff (the ONE place backoff lives — issue §6).
|
|
97
|
+
* On any other non-2xx it throws a typed {@link BacklogApiError}. On success it
|
|
98
|
+
* unwraps the universal `{ data }` envelope and returns `data` typed as `T`.
|
|
99
|
+
*
|
|
100
|
+
* @throws {BacklogApiError} on any non-2xx (after exhausting the 429 retries).
|
|
101
|
+
*/
|
|
102
|
+
async function backlogRequest(apiKey, path, init, context, deps = {}) {
|
|
103
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
104
|
+
const sleep = deps.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
105
|
+
const doFetch = () => fetchImpl(`${BACKLOG_API_BASE}${path}`, {
|
|
106
|
+
method: init.method,
|
|
107
|
+
headers: backlogHeaders(apiKey),
|
|
108
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
109
|
+
signal: AbortSignal.timeout(BACKLOG_API_TIMEOUT_MS),
|
|
110
|
+
});
|
|
111
|
+
// Retry on 429 (rate limit) with linear backoff — every endpoint can hit it.
|
|
112
|
+
let response = await doFetch();
|
|
113
|
+
for (let attempt = 0; response.status === 429 && attempt < BACKLOG_RATE_MAX_RETRIES; attempt++) {
|
|
114
|
+
await sleep(BACKLOG_RATE_BACKOFF_MS * (attempt + 1));
|
|
115
|
+
response = await doFetch();
|
|
116
|
+
}
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw new BacklogApiError(response.status, await safeBody(response), context);
|
|
119
|
+
}
|
|
120
|
+
const json = (await safeJson(response));
|
|
121
|
+
return json.data;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create the backlog project — `POST /projects` with the org-level `admin` token.
|
|
125
|
+
* `internalKey` is our slug (server validates `^[a-z0-9-]{3,40}$`; a collision
|
|
126
|
+
* 409s `INTERNAL_KEY_TAKEN` — the caller adopts the existing project). Returns the
|
|
127
|
+
* project id (`{pid}`) + its public board UUID (smoke Hallazgo 1).
|
|
128
|
+
*
|
|
129
|
+
* @throws {BacklogApiError} on any non-2xx (409 = internalKey taken → adopt).
|
|
130
|
+
*/
|
|
131
|
+
export async function createBacklogProject(adminToken, input, deps = {}) {
|
|
132
|
+
const data = await backlogRequest(adminToken, '/projects', {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
body: {
|
|
135
|
+
name: input.name,
|
|
136
|
+
internalKey: input.internalKey,
|
|
137
|
+
defaultLanguage: input.defaultLanguage ?? 'es',
|
|
138
|
+
enabledLanguages: input.enabledLanguages ?? ['es', 'en'],
|
|
139
|
+
},
|
|
140
|
+
}, 'POST /projects', deps);
|
|
141
|
+
if (!data?.id || !data?.publicUuid) {
|
|
142
|
+
throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects (respuesta sin id/publicUuid)');
|
|
143
|
+
}
|
|
144
|
+
return { id: data.id, publicUuid: data.publicUuid };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Mint a per-project `read_write` key — `POST /projects/{pid}/keys` with the
|
|
148
|
+
* `admin` token. The returned `plaintextKey` (`tk_live_*`, scope fixed to this
|
|
149
|
+
* project) is SHOW-ONCE: it is the only time this value is readable, so the caller
|
|
150
|
+
* writes it straight to `.env.local` (`BACKLOG_API_KEY`) and never logs it.
|
|
151
|
+
*
|
|
152
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
153
|
+
*/
|
|
154
|
+
export async function mintBacklogKey(adminToken, projectId, keyName, deps = {}) {
|
|
155
|
+
const data = await backlogRequest(adminToken, `/projects/${encodeURIComponent(projectId)}/keys`, { method: 'POST', body: { name: keyName } }, 'POST /projects/{pid}/keys', deps);
|
|
156
|
+
if (!data?.plaintextKey) {
|
|
157
|
+
throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects/{pid}/keys (respuesta sin plaintextKey)');
|
|
158
|
+
}
|
|
159
|
+
return data.plaintextKey;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Soft-delete the backlog project — `DELETE /projects/{pid}` with the `admin`
|
|
163
|
+
* token. It DEACTIVATES (does not hard-delete, does not cascade) and is
|
|
164
|
+
* IDEMPOTENT: a repeat returns 200 with the same `{ deactivated: true }` (smoke
|
|
165
|
+
* Hallazgo 7). Unblocks `--destroy` (BSYNC-003).
|
|
166
|
+
*
|
|
167
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
168
|
+
*/
|
|
169
|
+
export async function deleteBacklogProject(adminToken, projectId, deps = {}) {
|
|
170
|
+
await backlogRequest(adminToken, `/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' }, 'DELETE /projects/{pid}', deps);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Shared POST for the four element creates — `POST /projects/{pid}/{plural}`.
|
|
174
|
+
* The body is the caller's already-mapped payload (BSYNC-004 builds it); this
|
|
175
|
+
* client only performs the HTTP call. Returns the created id + minted humanId.
|
|
176
|
+
*
|
|
177
|
+
* NOTE on idempotency: a re-POST of an existing `id` currently returns `500`
|
|
178
|
+
* (NOT the documented `409 ELEMENT_ID_EXISTS`) — smoke Hallazgo 3. So the push
|
|
179
|
+
* (BSYNC-005) MUST pre-check existence via {@link getSyncState}, not treat a
|
|
180
|
+
* status here as an "already exists" signal.
|
|
181
|
+
*
|
|
182
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
183
|
+
*/
|
|
184
|
+
async function createElement(apiKey, projectId, segment, body, context, deps) {
|
|
185
|
+
const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/${segment}`, { method: 'POST', body }, context, deps);
|
|
186
|
+
const humanId = data?.humanId;
|
|
187
|
+
// The response may echo the id; fall back to the client-supplied one we sent.
|
|
188
|
+
const id = data?.id ?? body.id;
|
|
189
|
+
if (!id || !humanId) {
|
|
190
|
+
throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), `${context} (respuesta sin id/humanId)`);
|
|
191
|
+
}
|
|
192
|
+
return { id, humanId };
|
|
193
|
+
}
|
|
194
|
+
/** Create an epic — `POST /projects/{pid}/epics`. @throws {BacklogApiError} on non-2xx. */
|
|
195
|
+
export async function createEpic(apiKey, projectId, body, deps = {}) {
|
|
196
|
+
return createElement(apiKey, projectId, 'epics', body, 'POST /projects/{pid}/epics', deps);
|
|
197
|
+
}
|
|
198
|
+
/** Create a feature — `POST /projects/{pid}/features`. @throws {BacklogApiError} on non-2xx. */
|
|
199
|
+
export async function createFeature(apiKey, projectId, body, deps = {}) {
|
|
200
|
+
return createElement(apiKey, projectId, 'features', body, 'POST /projects/{pid}/features', deps);
|
|
201
|
+
}
|
|
202
|
+
/** Create a story — `POST /projects/{pid}/stories`. @throws {BacklogApiError} on non-2xx. */
|
|
203
|
+
export async function createStory(apiKey, projectId, body, deps = {}) {
|
|
204
|
+
return createElement(apiKey, projectId, 'stories', body, 'POST /projects/{pid}/stories', deps);
|
|
205
|
+
}
|
|
206
|
+
/** Create a task — `POST /projects/{pid}/tasks`. @throws {BacklogApiError} on non-2xx. */
|
|
207
|
+
export async function createTask(apiKey, projectId, body, deps = {}) {
|
|
208
|
+
return createElement(apiKey, projectId, 'tasks', body, 'POST /projects/{pid}/tasks', deps);
|
|
209
|
+
}
|
|
210
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
// Element status PATCH — per-project `read_write` key (BSYNC-005/006)
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
/**
|
|
214
|
+
* Update one element's status — `PATCH /projects/{pid}/{plural}/{id}` with
|
|
215
|
+
* `{ status }` (smoke Hallazgo 3). `elementType` selects the plural path segment.
|
|
216
|
+
* The response `{ updated: true }` is not echoed back to the caller.
|
|
217
|
+
*
|
|
218
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
219
|
+
*/
|
|
220
|
+
export async function patchElementStatus(apiKey, projectId, elementType, elementId, status, deps = {}) {
|
|
221
|
+
const segment = ELEMENT_PATH_SEGMENT[elementType];
|
|
222
|
+
await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/${segment}/${encodeURIComponent(elementId)}`, { method: 'PATCH', body: { status } }, `PATCH /projects/{pid}/${segment}/{id}`, deps);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Read the project's flat element list — `GET /projects/{pid}/sync-state` (smoke
|
|
226
|
+
* Hallazgo 4). It lists ALL elements (does NOT filter by visibility), so it is the
|
|
227
|
+
* remote `{ id → (status, points, updatedAt) }` truth the self-healing diffs
|
|
228
|
+
* against the local SSOT — and the existence pre-check the push uses in place of
|
|
229
|
+
* the (broken) 409-on-collision signal. Paginated via `page`/`pageSize`; the
|
|
230
|
+
* caller reads `total` to decide whether more pages are needed.
|
|
231
|
+
*
|
|
232
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
233
|
+
*/
|
|
234
|
+
export async function getSyncState(apiKey, projectId, opts = {}, deps = {}) {
|
|
235
|
+
const query = new URLSearchParams();
|
|
236
|
+
if (opts.page !== undefined)
|
|
237
|
+
query.set('page', String(opts.page));
|
|
238
|
+
if (opts.pageSize !== undefined)
|
|
239
|
+
query.set('pageSize', String(opts.pageSize));
|
|
240
|
+
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
241
|
+
const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/sync-state${suffix}`, { method: 'GET' }, 'GET /projects/{pid}/sync-state', deps);
|
|
242
|
+
return {
|
|
243
|
+
items: data?.items ?? [],
|
|
244
|
+
total: data?.total ?? 0,
|
|
245
|
+
page: data?.page ?? 1,
|
|
246
|
+
pageSize: data?.pageSize ?? data?.items?.length ?? 0,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* List the project's releases — `GET /projects/{pid}/releases`. Feeds the
|
|
251
|
+
* adopt-by-name flow (releases take NO client-supplied id, so the push matches an
|
|
252
|
+
* existing release by name before creating one — D12). Tolerates either a bare
|
|
253
|
+
* `data: [...]` or a `data: { items: [...] }` envelope (same defensive parse
|
|
254
|
+
* `neon-api.ts` uses for its key list).
|
|
255
|
+
*
|
|
256
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
257
|
+
*/
|
|
258
|
+
export async function listBacklogReleases(apiKey, projectId, deps = {}) {
|
|
259
|
+
const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/releases`, { method: 'GET' }, 'GET /projects/{pid}/releases', deps);
|
|
260
|
+
const list = Array.isArray(data) ? data : (data?.items ?? []);
|
|
261
|
+
return list
|
|
262
|
+
.filter((r) => typeof r?.id === 'string')
|
|
263
|
+
.map((r) => ({ id: r.id, name: (r.name ?? {}) }));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Create a release — `POST /projects/{pid}/releases` (smoke Hallazgo 5). It takes
|
|
267
|
+
* no client-supplied `id`; the server mints one and returns it. Returns the new
|
|
268
|
+
* release id.
|
|
269
|
+
*
|
|
270
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
271
|
+
*/
|
|
272
|
+
export async function createRelease(apiKey, projectId, body, deps = {}) {
|
|
273
|
+
const data = await backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/releases`, { method: 'POST', body }, 'POST /projects/{pid}/releases', deps);
|
|
274
|
+
if (!data?.id) {
|
|
275
|
+
throw new BacklogApiError(201, JSON.stringify(data).slice(0, 500), 'POST /projects/{pid}/releases (respuesta sin id)');
|
|
276
|
+
}
|
|
277
|
+
return { id: data.id };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Assign an element to a release — `POST /releases/{rid}/assignments` (smoke
|
|
281
|
+
* Hallazgo 5). NOTE this route is RELEASE-scoped, NOT under `/projects/{pid}`.
|
|
282
|
+
* The mapping rule: assign ONLY epics — features/stories inherit the release, and
|
|
283
|
+
* assigning a child whose ancestor epic is already assigned 422s `PARENT_MISMATCH`
|
|
284
|
+
* (tasks/bugs are not releasable at all). Enforcing that rule is the caller's job;
|
|
285
|
+
* the API accepts `epic`/`feature`/`story` here.
|
|
286
|
+
*
|
|
287
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
288
|
+
*/
|
|
289
|
+
export async function assignToRelease(apiKey, releaseId, elementType, elementId, deps = {}) {
|
|
290
|
+
await backlogRequest(apiKey, `/releases/${encodeURIComponent(releaseId)}/assignments`, { method: 'POST', body: { elementType, elementId } }, 'POST /releases/{rid}/assignments', deps);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Read the project's rollup — `GET /projects/{pid}/summary` (smoke Hallazgo 5).
|
|
294
|
+
* The rollup propagates epic → release and drops `wont_do` from point
|
|
295
|
+
* denominators. Prefer {@link getSyncState} for heartbeat/reconcile; use this for
|
|
296
|
+
* a point/progress rollup view.
|
|
297
|
+
*
|
|
298
|
+
* @throws {BacklogApiError} on any non-2xx.
|
|
299
|
+
*/
|
|
300
|
+
export async function getSummary(apiKey, projectId, deps = {}) {
|
|
301
|
+
return backlogRequest(apiKey, `/projects/${encodeURIComponent(projectId)}/summary`, { method: 'GET' }, 'GET /projects/{pid}/summary', deps);
|
|
302
|
+
}
|