@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.
@@ -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;
@@ -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
- /** Roots a manifest path may live under (mirrors `profiles.json#track`). */
29
- const ALLOWED_ROOTS = ['.claude/', 'scripts/', '.husky/'];
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/**`). A correct
33
- * release never produces these — this guards a mispublished/tampered tarball from
34
- * writing `src/` or escaping the repo root. Separator-agnostic (Windows-safe).
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
+ }