@dforce2055/dai 0.10.0 → 0.12.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/CHANGELOG.md +168 -0
- package/README.md +12 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +492 -36
- package/cli/lib/bootstrap.mjs +5 -2
- package/cli/lib/branch-scope.mjs +200 -0
- package/cli/lib/forge-api.mjs +57 -0
- package/cli/lib/pm-adapter.mjs +16 -2
- package/cli/lib/pm-clickup.mjs +16 -1
- package/cli/lib/pm-jira.mjs +21 -1
- package/cli/lib/pr.mjs +21 -1
- package/cli/lib/us-format.mjs +147 -0
- package/docs/EJEMPLO-END-TO-END.md +26 -3
- package/docs/METODOLOGIA.md +5 -0
- package/docs/PROBAR.md +12 -1
- package/docs/SCRUM-CON-IA.md +2 -2
- package/docs/adr/0018-alcance-de-stamp-y-gate-de-ci.md +183 -0
- package/docs/adr/README.md +1 -0
- package/docs/detalle/01-refinamiento.md +14 -0
- package/docs/detalle/07-merge-trazabilidad.md +13 -2
- package/docs/glosario.md +1 -1
- package/docs/guias/dev.md +21 -0
- package/docs/guias/po.md +40 -1
- package/docs/public/tutoriales/funcional-1-skills-usuario.png +0 -0
- package/docs/public/tutoriales/funcional-2-copilot-signin.png +0 -0
- package/docs/public/tutoriales/funcional-3-carpeta-configurada.png +0 -0
- package/docs/public/tutoriales/funcional-4-doctor.png +0 -0
- package/docs/public/tutoriales/funcional-5-publish-parent.png +0 -0
- package/docs/tutoriales/index.md +9 -0
- package/docs/tutoriales/setup-dev.md +516 -0
- package/docs/tutoriales/setup-funcional.md +480 -0
- package/governance/ci-rules.md +47 -8
- package/package.json +1 -1
- package/skills/dai-review/SKILL.md +1 -1
- package/skills/grill-epic/SKILL.md +2 -2
- package/skills/grill-intent/SKILL.md +1 -1
- package/skills/grill-user-story/SKILL.md +58 -10
- package/templates/ci-dai-gate.yml +50 -0
package/cli/dai.mjs
CHANGED
|
@@ -35,6 +35,9 @@ import { parseSource } from "./lib/skills-source.mjs";
|
|
|
35
35
|
import { skillToCursor, validateSkill, constitution, constitutionCursorRule, envFor, mergeEnv, upsertBlock, reconcileGitignore, stalePromptFiles } from "./lib/bootstrap.mjs";
|
|
36
36
|
import { parseFieldsFile, parseFieldOverrides, resolveJiraFields } from "./lib/jira-fields.mjs";
|
|
37
37
|
import { assertProjectKey } from "./lib/pm-jira.mjs";
|
|
38
|
+
import { flattenImplements, stampScope, prScope, matchBranchToImplements, requiresLink, trackerKeysIn } from "./lib/branch-scope.mjs";
|
|
39
|
+
import { describeForgeError, parseForgeError } from "./lib/forge-api.mjs";
|
|
40
|
+
import { validateUS, renderValidation, parseSpecVersion, bumpSpecVersion, setSpecVersion } from "./lib/us-format.mjs";
|
|
38
41
|
|
|
39
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
43
|
|
|
@@ -50,6 +53,9 @@ const _color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
|
50
53
|
const paint = (code, m) => (_color ? `\x1b[${code}m${m}\x1b[0m` : m);
|
|
51
54
|
const C = { y: (m) => paint("33", m), r: (m) => paint("31", m), cy: (m) => paint("36", m), b: (m) => paint("1", m), dim: (m) => paint("2", m) };
|
|
52
55
|
const ROOT = join(HERE, ".."); // raíz del paquete dai (cli/ está adentro)
|
|
56
|
+
// Rutas relativas al cwd en la salida: una ruta absoluta de 120 caracteres no se lee ni
|
|
57
|
+
// se copia. Si el archivo está fuera del cwd (`../otro`), se muestra tal cual.
|
|
58
|
+
const rel = (p) => { const r = relative(process.cwd(), p); return !r || r.startsWith("..") ? p : r; };
|
|
53
59
|
|
|
54
60
|
// Banner de bienvenida de `dai init`: el Sol de Mayo en bloques (cuerpo y rayos rectos en
|
|
55
61
|
// oro; rayos ondulados en celeste) al lado del título, más el preview de lo que se configura.
|
|
@@ -203,6 +209,81 @@ function gitRemote() { try { return git(["remote", "get-url", "origin"]); } catc
|
|
|
203
209
|
function gitBranch() { try { return git(["rev-parse", "--abbrev-ref", "HEAD"]); } catch { return null; } }
|
|
204
210
|
function gitCommit() { try { return git(["rev-parse", "HEAD"]); } catch { return null; } }
|
|
205
211
|
|
|
212
|
+
// ── check --ci: el gate de governance/ci-rules.md, ejecutable ────────────────
|
|
213
|
+
//
|
|
214
|
+
// La brecha del issue #26: ci-rules.md prometía "sin implements.yaml el CI bloquea",
|
|
215
|
+
// pero no existía el comando que lo hiciera. Era una regla escrita que nadie aplicaba.
|
|
216
|
+
//
|
|
217
|
+
// Lo que NO hace: exigirle US a todo. Una `chore/` o una `fix/` sin ticket son trabajo
|
|
218
|
+
// legítimo (branch-naming.md), y un gate que las bloquea se desactiva a la semana.
|
|
219
|
+
// Quién decide es requiresLink(), leyendo el nombre de la branch.
|
|
220
|
+
//
|
|
221
|
+
// Salidas: 0 = pasa · 1 = falta el link · 2 = hay link pero el QUÉ cambió (atrasado)
|
|
222
|
+
async function cmdCheckCi(opts = {}) {
|
|
223
|
+
const branch = opts.branch || process.env.DAI_CI_BRANCH || ciBranch() || gitBranch();
|
|
224
|
+
const { required, reason } = requiresLink(branch);
|
|
225
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
226
|
+
|
|
227
|
+
info(`branch '${branch || "(desconocida)"}' — ${reason}`);
|
|
228
|
+
if (!required) {
|
|
229
|
+
if (rows.length) info(`igual declara ${rows.length} US (${rows.map((r) => r.id).join(", ")}) — se chequea su cobertura.`);
|
|
230
|
+
else { ok("gate OK — esta branch no requiere US."); process.exit(0); }
|
|
231
|
+
}
|
|
232
|
+
if (required && rows.length === 0) {
|
|
233
|
+
const ids = trackerKeysIn(branch);
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
"✗ gate: falta el link QUÉ↔CÓMO — esta branch no tiene implements.yaml.\n" +
|
|
236
|
+
` Crealo: dai link-us ${ids[0] || "<ID-DE-LA-US>"}\n` +
|
|
237
|
+
" Si NO implementa una US (tooling, deps, docs), renombrá la branch con un\n" +
|
|
238
|
+
" prefijo exento — chore/, docs/, ci/ — según governance/branch-naming.md.\n");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Hay link: que además esté al día contra la US viva. Sin token/backend eso no se
|
|
243
|
+
// puede saber, y un gate que bloquea por falta de red es un gate que se apaga:
|
|
244
|
+
// con --no-network (o sin adaptador utilizable) valida el link y no más.
|
|
245
|
+
if (opts.noNetwork) {
|
|
246
|
+
ok(`gate OK — ${rows.length} US linkeada(s): ${rows.map((r) => r.id).join(", ")} (--no-network: no se comparó contra la US viva).`);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
loadDaiEnv();
|
|
250
|
+
let adapter;
|
|
251
|
+
try { adapter = getAdapter(process.env); }
|
|
252
|
+
catch (e) {
|
|
253
|
+
warn(`no puedo comparar contra la US viva: ${e.message}`);
|
|
254
|
+
ok(`gate OK igual — el link existe (${rows.map((r) => r.id).join(", ")}). Configurá el backend para chequear también el atraso.`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
let worst = 0;
|
|
258
|
+
for (const r of rows) {
|
|
259
|
+
let live = null, netErr = null;
|
|
260
|
+
try { live = await adapter.fetchUS(r.id); } catch (e) { netErr = String(e.message).split("\n")[0]; }
|
|
261
|
+
if (netErr) { warn(`${r.id}: no pude leer la US (${netErr}) — no bloqueo por un problema de red/credencial.`); continue; }
|
|
262
|
+
const status = coverageStatus(r.ac_hash, live?.ac_hash);
|
|
263
|
+
if (status === "al-dia") ok(`${r.id} al día (${r.version})`);
|
|
264
|
+
else if (status === "atrasado") {
|
|
265
|
+
process.stderr.write(`✗ gate: ${r.id} ATRASADO — implementaste ${r.ac_hash}, la US viva es ${live.ac_hash}.\n` +
|
|
266
|
+
` El QUÉ cambió. Resincronizá y revisá que lo cubras: dai link-us ${r.id} --resync\n`);
|
|
267
|
+
worst = Math.max(worst, 2);
|
|
268
|
+
} else {
|
|
269
|
+
warn(`${r.id}: no encontré la US en ${adapter.kind} — el link apunta a un ID que el tracker no tiene.`);
|
|
270
|
+
worst = Math.max(worst, 2);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (worst === 0) ok(`gate OK — ${rows.length} US linkeada(s) y al día.`);
|
|
274
|
+
process.exit(worst);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// La branch real en CI: en una PR, HEAD es un merge commit detached, así que
|
|
278
|
+
// `git rev-parse --abbrev-ref HEAD` devuelve "HEAD" y no el nombre. Cada forge la
|
|
279
|
+
// expone en su propia variable.
|
|
280
|
+
function ciBranch() {
|
|
281
|
+
const e = process.env;
|
|
282
|
+
return e.GITHUB_HEAD_REF || e.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || e.CI_COMMIT_REF_NAME ||
|
|
283
|
+
e.BITBUCKET_BRANCH || e.BUILD_SOURCEBRANCHNAME ||
|
|
284
|
+
(e.GITHUB_REF_NAME && !/^\d+\/merge$/.test(e.GITHUB_REF_NAME) ? e.GITHUB_REF_NAME : null) || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
206
287
|
// ── check ──────────────────────────────────────────────────────────────────
|
|
207
288
|
async function cmdCheck() {
|
|
208
289
|
loadDaiEnv();
|
|
@@ -235,38 +316,106 @@ async function cmdCheck() {
|
|
|
235
316
|
}
|
|
236
317
|
|
|
237
318
|
// ── stamp ──────────────────────────────────────────────────────────────────
|
|
238
|
-
|
|
319
|
+
// Estampa la cobertura de UNA US: la de esta branch. Antes recorría todo el repo
|
|
320
|
+
// —archivados incluidos— así que cerrar una US le dejaba un comentario a las cuatro
|
|
321
|
+
// del sprint (issue #22). Ahora decide con branch-scope.mjs y, si no puede saber cuál,
|
|
322
|
+
// PREGUNTA en vez de estampar de más: un comentario en el tracker no se deshace.
|
|
323
|
+
async function cmdStamp(ids = [], opts = {}) {
|
|
239
324
|
loadDaiEnv();
|
|
240
325
|
const adapter = getAdapter(process.env);
|
|
241
326
|
const remote = gitRemote(), branch = gitBranch(), commit = gitCommit();
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
327
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
328
|
+
const allRows = flattenImplements(discoverImplements(process.cwd()));
|
|
329
|
+
const scope = stampScope({ branch, rows, allRows, ids, all: !!opts.all });
|
|
330
|
+
|
|
331
|
+
if (scope.mode === "none") { process.stdout.write("No hay implements.yaml para estampar.\n"); return; }
|
|
332
|
+
if (scope.mode === "explicit" && scope.missing?.length) {
|
|
333
|
+
fail(`no encontré implements.yaml para: ${scope.missing.join(", ")}.\n` +
|
|
334
|
+
` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let targets = scope.targets;
|
|
338
|
+
if (scope.mode === "ambiguous") {
|
|
339
|
+
warn(`${scope.reason}.`);
|
|
340
|
+
const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
|
|
341
|
+
process.stdout.write(` US vivas en el repo:\n${listed}\n`);
|
|
342
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
343
|
+
fail("no sé cuál estampar. Decilo explícitamente: dai stamp <ID> (o `dai stamp --all` para todas).", 1);
|
|
344
|
+
}
|
|
345
|
+
const ans = await askOrCancel(" ¿Cuál estampo? (número, varios con coma, 'a'=todas, Enter=cancelar) ");
|
|
346
|
+
if (!ans) { info("Cancelado — no se estampó nada."); return; }
|
|
347
|
+
if (/^a(ll|)$/i.test(ans)) targets = scope.candidates;
|
|
348
|
+
else {
|
|
349
|
+
const picked = ans.split(/[,\s]+/).filter(Boolean).map((t) => Number(t));
|
|
350
|
+
if (picked.some((n) => !Number.isInteger(n) || n < 1 || n > scope.candidates.length)) {
|
|
351
|
+
fail(`respuesta inválida: '${ans}'. Se esperaba número(s) entre 1 y ${scope.candidates.length}, o 'a'.`, 1);
|
|
352
|
+
}
|
|
353
|
+
targets = picked.map((n) => scope.candidates[n - 1]);
|
|
354
|
+
}
|
|
355
|
+
} else if (scope.mode !== "all") {
|
|
356
|
+
info(`${scope.reason} → estampo ${targets.map((t) => t.id).join(", ")}.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const r of targets) {
|
|
360
|
+
const live = await adapter.fetchUS(r.id);
|
|
361
|
+
const status = coverageStatus(r.ac_hash, live?.ac_hash);
|
|
249
362
|
const record = {
|
|
250
|
-
repo:
|
|
363
|
+
repo: r.repo, change: r.change, version: r.version, ac_hash: r.ac_hash, status,
|
|
251
364
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
252
365
|
};
|
|
253
|
-
const where = await adapter.stamp(
|
|
254
|
-
process.stdout.write(`✓ ${
|
|
366
|
+
const where = await adapter.stamp(r.id, record);
|
|
367
|
+
process.stdout.write(`✓ ${r.id} → ${where} (${statusLabel(status)})\n`);
|
|
255
368
|
}
|
|
256
|
-
if (
|
|
369
|
+
if (targets.length === 0) process.stdout.write("No se estampó nada.\n");
|
|
257
370
|
}
|
|
258
371
|
|
|
259
372
|
// ── forge (review) ───────────────────────────────────────────────────────────
|
|
373
|
+
// El review.json lo escribe la SKILL, no el CLI — así que `dai init`/`dai sync` son los
|
|
374
|
+
// únicos que ponían `.dai/reviews/` en el .gitignore, y un repo inicializado con una dai
|
|
375
|
+
// vieja se comía borradores a medio editar en un commit (issue #25). Acá lo arreglamos
|
|
376
|
+
// donde duele: al consumir el archivo. Aditivo, y solo si el borrador está bajo
|
|
377
|
+
// `.dai/reviews/` — si el equipo decidió versionar sus reviews en otro lado, no opinamos.
|
|
378
|
+
function ensureReviewsIgnored(fromPath) {
|
|
379
|
+
const rel = relative(process.cwd(), fromPath).replace(/\\/g, "/");
|
|
380
|
+
if (!rel.startsWith(".dai/reviews/")) return;
|
|
381
|
+
const giPath = join(process.cwd(), ".gitignore");
|
|
382
|
+
if (!existsSync(giPath)) return; // sin .gitignore no inventamos uno
|
|
383
|
+
const cur = readFileSync(giPath, "utf8");
|
|
384
|
+
const gi = reconcileGitignore(cur, {}); // {} → solo el `ensure` base (.env.dai, .dai/reviews/)
|
|
385
|
+
if (!gi.changed) return;
|
|
386
|
+
writeFileSync(giPath, gi.text.endsWith("\n") ? gi.text : gi.text + "\n");
|
|
387
|
+
info(".gitignore: agregué `.dai/reviews/` — los borradores de review no viajan en un commit.");
|
|
388
|
+
info(" Si tu equipo los quiere versionar, sacá esa línea a mano.");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Una pregunta puntual por TTY. Ctrl+D (EOF) devuelve "" — se trata como "cancelar", no
|
|
392
|
+
// como un crash: en un prompt que precede a una acción irreversible, abortar es la
|
|
393
|
+
// respuesta más segura, y "Aborted with Ctrl+D" no le dice eso a nadie.
|
|
394
|
+
async function askOrCancel(q) {
|
|
395
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
396
|
+
try { return (await rl.question(q)).trim(); }
|
|
397
|
+
catch { process.stdout.write("\n"); return ""; }
|
|
398
|
+
finally { rl.close(); }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Corre una llamada al forge y, si falla, la traduce a una causa concreta (issue #24)
|
|
402
|
+
// en vez del genérico "¿token? ¿ref correcta?".
|
|
403
|
+
async function forgeCall(pr, fn) {
|
|
404
|
+
try { return await fn(); }
|
|
405
|
+
catch (e) { fail(describeForgeError(pr, { ...parseForgeError(e), env: process.env }), 1); }
|
|
406
|
+
}
|
|
407
|
+
|
|
260
408
|
async function cmdForge(sub, ref, opts) {
|
|
261
409
|
loadDaiEnv();
|
|
262
410
|
const pr = parsePrRef(ref, gitRemote());
|
|
263
411
|
if (!pr) fail("no pude resolver la PR/MR. Pasa la URL completa o el número (con remoto git).", 1);
|
|
264
412
|
if (sub === "pr") {
|
|
265
|
-
|
|
413
|
+
const j = await forgeCall(pr, () => getPR(pr, process.env));
|
|
414
|
+
process.stdout.write(JSON.stringify(j, null, 2) + "\n");
|
|
266
415
|
} else if (sub === "comment") {
|
|
267
416
|
const body = opts.bodyFile ? readFileSync(opts.bodyFile, "utf8") : opts.body;
|
|
268
417
|
if (!body) fail("falta --body-file <archivo> o --body <texto>.", 1);
|
|
269
|
-
const res = await postComment(pr, body, process.env);
|
|
418
|
+
const res = await forgeCall(pr, () => postComment(pr, body, process.env));
|
|
270
419
|
process.stdout.write(`✓ comentario posteado${res.url ? `: ${res.url}` : ""}\n`);
|
|
271
420
|
} else if (sub === "review") {
|
|
272
421
|
await cmdForgeReview(pr, opts);
|
|
@@ -284,11 +433,11 @@ async function cmdForge(sub, ref, opts) {
|
|
|
284
433
|
async function cmdForgeReview(pr, opts) {
|
|
285
434
|
if (!opts.from) fail("falta --from <review.json>. Lo escribe la skill dai-review; revisalo antes de postear.", 1);
|
|
286
435
|
const review = parseFindings(readFileSync(opts.from, "utf8"));
|
|
436
|
+
ensureReviewsIgnored(opts.from);
|
|
287
437
|
|
|
288
438
|
// El diff sale de git (local, por SSH), no de la API: es la fuente de verdad de qué
|
|
289
439
|
// línea es comentable, y no gasta rate limit.
|
|
290
|
-
const remote = await
|
|
291
|
-
if (!remote) fail("no pude leer la PR/MR del forge (¿token? ¿ref correcta?).", 1);
|
|
440
|
+
const remote = await forgeCall(pr, () => getPR(pr, process.env));
|
|
292
441
|
const base = opts.base || remote.baseRef;
|
|
293
442
|
if (!base) fail("no pude saber la branch base de la PR. Pasala con --base <branch>.", 1);
|
|
294
443
|
let diff = "";
|
|
@@ -335,7 +484,7 @@ async function cmdForgeReview(pr, opts) {
|
|
|
335
484
|
}
|
|
336
485
|
if (!kept.length && !review.summary) fail("no hay nada que postear (0 comentarios y resumen vacío).", 1);
|
|
337
486
|
|
|
338
|
-
const res = await postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env);
|
|
487
|
+
const res = await forgeCall(pr, () => postReview(pr, { body, comments, headSha: remote.headSha, diffRefs: remote.diffRefs }, process.env));
|
|
339
488
|
ok(`review posteado${res.url ? `: ${res.url}` : ""} — ${res.posted} comentario(s) en línea.`);
|
|
340
489
|
if (res.failed.length) {
|
|
341
490
|
warn(`${res.failed.length} comentario(s) NO entraron (gitlab no es atómico: el resumen y el resto SÍ están posteados):`);
|
|
@@ -387,6 +536,254 @@ async function cmdPublish(file, opts = {}) {
|
|
|
387
536
|
info(`Próximo paso (el dev abre el CÓMO): dai link-us ${r.id}`);
|
|
388
537
|
}
|
|
389
538
|
|
|
539
|
+
// ── edit-us / update-us: el QUÉ cambia y el tracker se entera ────────────────
|
|
540
|
+
//
|
|
541
|
+
// Dos puertas de entrada a UN camino. La diferencia es de dónde sale el markdown:
|
|
542
|
+
//
|
|
543
|
+
// dai edit-us <ID> trae la US del tracker → la abrís en tu editor → valida → empuja
|
|
544
|
+
// dai update-us <ID> ya tenés el .md escrito (lo refinaste implementando) → empuja
|
|
545
|
+
//
|
|
546
|
+
// El tramo compartido —validar, mostrar el diff, proponer el bump de spec_version,
|
|
547
|
+
// confirmar, escribir, re-estampar el ac_hash local— es `pushUS`. No es código duplicado
|
|
548
|
+
// con dos nombres: es un solo camino con dos entradas, y por eso las dos puertas dan
|
|
549
|
+
// exactamente el mismo preview y la misma confirmación.
|
|
550
|
+
|
|
551
|
+
// Los campos propios de Jira, si el proyecto los exige (compartido por las dos puertas).
|
|
552
|
+
function fieldsFor(adapter, opts) {
|
|
553
|
+
if (opts.field === undefined) return undefined;
|
|
554
|
+
if (adapter.kind !== "jira") fail(`--field es solo para jira (DAI_PM=${adapter.kind}).`, 2);
|
|
555
|
+
return resolveJiraFields({
|
|
556
|
+
spec: loadJiraFieldsSpec(),
|
|
557
|
+
issuetype: opts.issuetype || process.env.DAI_JIRA_ISSUETYPE || "Story",
|
|
558
|
+
overrides: parseFieldOverrides(asList(opts.field)),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// El backend md devuelve SINCRÓNICO (el contrato del adaptador lo permite), así que
|
|
563
|
+
// `adapter.fetchUS(id).catch(...)` explotaba con DAI_PM=md. Se normaliza a promesa.
|
|
564
|
+
const fetchLive = (adapter, id) => Promise.resolve().then(() => adapter.fetchUS(id)).catch(() => null);
|
|
565
|
+
|
|
566
|
+
// Valida el formato e imprime el veredicto. Devuelve el resultado; corta si hay errores.
|
|
567
|
+
function validateOrFail(md, source, { strict = false } = {}) {
|
|
568
|
+
const v = validateUS(md);
|
|
569
|
+
const lines = renderValidation(v);
|
|
570
|
+
process.stdout.write(`\n ── formato de la US (${source}) ────────────\n`);
|
|
571
|
+
for (const l of lines) process.stdout.write(l + "\n");
|
|
572
|
+
if (!v.ok) {
|
|
573
|
+
process.stdout.write("\n");
|
|
574
|
+
fail("la US no tiene el formato mínimo para viajar al tracker (ver arriba).\n" +
|
|
575
|
+
" El molde canónico está en .dai/templates/formato-us.md, y /grill-user-story te interroga hasta llegar a él.", 2);
|
|
576
|
+
}
|
|
577
|
+
if (strict && v.warnings.length) {
|
|
578
|
+
process.stdout.write("\n");
|
|
579
|
+
fail(`--strict: ${v.warnings.length} advertencia(s) y ninguna se puede ignorar en este modo.`, 2);
|
|
580
|
+
}
|
|
581
|
+
return v;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// El tramo compartido: preview → spec_version → confirmación → tracker → ac_hash local.
|
|
585
|
+
// `md` puede reescribirse acá (el bump de spec_version), por eso devuelve el markdown final.
|
|
586
|
+
async function pushUS(id, md, { adapter, file, opts, live }) {
|
|
587
|
+
const validation = validateOrFail(md, rel(file), { strict: !!opts.strict });
|
|
588
|
+
const title = opts.title || validation.title;
|
|
589
|
+
const newHash = acHash(md);
|
|
590
|
+
|
|
591
|
+
// ── spec_version: el número COMUNICA, el hash DETECTA (METODOLOGIA §4) ──────
|
|
592
|
+
// Si cambiaron los criterios se PROPONE subirlo, no se impone: dai mirando el hash no
|
|
593
|
+
// distingue un criterio nuevo de un typo corregido, y quien sabe la diferencia es el PO.
|
|
594
|
+
const curVer = parseSpecVersion(md);
|
|
595
|
+
const hashChanged = live?.ac_hash !== newHash;
|
|
596
|
+
let newVer = curVer;
|
|
597
|
+
if (hashChanged && !opts.noBump) {
|
|
598
|
+
const proposed = bumpSpecVersion(curVer);
|
|
599
|
+
if (opts.bump === true || opts.yes) newVer = proposed;
|
|
600
|
+
else if (typeof opts.bump === "string") newVer = opts.bump;
|
|
601
|
+
else if (process.stdin.isTTY) {
|
|
602
|
+
process.stdout.write(`\n Cambiaron los criterios (ac_hash ${C.dim(live?.ac_hash ?? "ninguno")} → ${C.b(newHash)}).\n`);
|
|
603
|
+
process.stdout.write(` s = cambio ${C.b("material")}: subo spec_version a ${C.b(proposed)} y los repos con ${curVer || "la versión vieja"} se marcan ATRASADOS\n`);
|
|
604
|
+
process.stdout.write(` n = cambio ${C.b("editorial")} (typo, redacción): se queda en ${curVer || "(sin versión)"}\n`);
|
|
605
|
+
const ans = await askOrCancel(` ¿Subo spec_version a ${proposed}? (S/n) `);
|
|
606
|
+
if (!/^n/i.test(ans)) newVer = proposed;
|
|
607
|
+
} else {
|
|
608
|
+
// Sin TTY y sin --bump/--no-bump no hay quién decida, y decidir por nuestra cuenta
|
|
609
|
+
// sería inventar la respuesta a la única pregunta que este comando NO puede
|
|
610
|
+
// responder solo. Se deja como está, pero se DICE — un no-op silencioso acá
|
|
611
|
+
// termina en un spec_version que dejó de comunicar nada.
|
|
612
|
+
info(`cambiaron los criterios y spec_version se queda en ${curVer || "(sin versión)"}: no hay TTY para preguntarlo.`);
|
|
613
|
+
info(` Si el cambio es material: --bump (${curVer || "v1"} → ${proposed}, marca atrasados a los repos)`);
|
|
614
|
+
info(" Si es editorial (typo): --no-bump");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (newVer && newVer !== curVer) md = setSpecVersion(md, newVer);
|
|
618
|
+
|
|
619
|
+
// ── Preview: qué cambia ALLÁ ARRIBA. Se muestra siempre, antes de escribir nada ──
|
|
620
|
+
// "(ninguno)" también del lado del "sin cambios": imprimir el `null` crudo hace dudar
|
|
621
|
+
// de si el comando se rompió, justo en el preview que la persona lee antes de aprobar.
|
|
622
|
+
const show = (v) => v ?? "(ninguno)";
|
|
623
|
+
const chg = (from, to) => (from === to ? `${show(to)}${C.dim(" (sin cambios)")}` : `${C.dim(show(from))} → ${C.b(show(to))}`);
|
|
624
|
+
process.stdout.write(`\n ── ${id} · qué cambia en ${adapter.kind} ────────────\n`);
|
|
625
|
+
process.stdout.write(` título: ${chg(live?.title, title)}\n`);
|
|
626
|
+
process.stdout.write(` criterios: ${validation.criteria.length}\n`);
|
|
627
|
+
process.stdout.write(` version: ${chg(curVer, newVer)}\n`);
|
|
628
|
+
process.stdout.write(` ac_hash: ${chg(live?.ac_hash, newHash)}\n`);
|
|
629
|
+
process.stdout.write(` fuente: ${rel(file)}\n ──────────────────────────────────────\n`);
|
|
630
|
+
|
|
631
|
+
if (opts.dryRun) { info("[dry-run] no se tocó el tracker."); return md; }
|
|
632
|
+
if (!opts.yes && process.stdin.isTTY) {
|
|
633
|
+
const ans = await askOrCancel(` Esto PISA la US ${id} en ${adapter.kind}. ¿Guardo? (s/N) `);
|
|
634
|
+
if (!/^s|^y/i.test(ans)) { info("Cancelado — el tracker quedó como estaba."); return null; }
|
|
635
|
+
}
|
|
636
|
+
if (md !== readFileSync(file, "utf8")) writeFileSync(file, md); // el bump también queda local
|
|
637
|
+
|
|
638
|
+
const r = await adapter.updateUS(id, { title, descriptionMarkdown: md, fields: fieldsFor(adapter, opts) });
|
|
639
|
+
ok(`${id} actualizada en ${adapter.kind}${r.url ? ` → ${r.url}` : ""}`);
|
|
640
|
+
|
|
641
|
+
// Re-estampar el ac_hash local: si no, `dai check` marca atrasado por tu propia edición.
|
|
642
|
+
if (opts.noResync) { info("--no-resync: el implements.yaml quedó con el ac_hash viejo (dai check te lo va a marcar)."); return md; }
|
|
643
|
+
const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
|
|
644
|
+
if (!target) { info(`sin implements.yaml para ${id} en este repo — no hay ac_hash local que resincronizar.`); return md; }
|
|
645
|
+
const prev = (target.implements.find((im) => im.id === id) || {}).ac_hash;
|
|
646
|
+
if (prev === newHash && !newVer) { ok(`ac_hash local ya estaba al día (${newHash}).`); return md; }
|
|
647
|
+
let txt = readFileSync(target.path, "utf8").replace(/^(\s*ac_hash:\s*).*$/m, `$1${newHash}`);
|
|
648
|
+
if (newVer) txt = txt.replace(/^(\s*version:\s*).*$/m, `$1${newVer}`);
|
|
649
|
+
writeFileSync(target.path, txt);
|
|
650
|
+
ok(`ac_hash re-estampado: ${prev} → ${newHash}${newVer && newVer !== curVer ? ` (${newVer})` : ""} en ${rel(target.path)}`);
|
|
651
|
+
if (prev !== newHash) warn("cambiaron los criterios: revisá que tu implementación los cubra, y corré tus tests.");
|
|
652
|
+
return md;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// El .md de trabajo de una US: explícito con --us, o el `us.md` del change que la implementa.
|
|
656
|
+
function usFileFor(id, opts, { quiet = false } = {}) {
|
|
657
|
+
if (typeof opts.us === "string") return opts.us;
|
|
658
|
+
const target = discoverImplements(process.cwd()).find((f) => (f.implements || []).some((im) => im.id === id));
|
|
659
|
+
const guess = target ? join(dirname(target.path), "us.md") : null;
|
|
660
|
+
if (guess && existsSync(guess)) {
|
|
661
|
+
if (!quiet) info(`sin --us: uso ${rel(guess)} (el change que implementa ${id}).`);
|
|
662
|
+
return guess;
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── edit-us: traer la US del tracker, editarla, validarla y guardarla ─────────
|
|
668
|
+
//
|
|
669
|
+
// Para el PO: la US vive en el tracker, no en un .md que alguien tiene que acordarse de
|
|
670
|
+
// sincronizar. Este comando la BAJA, te la abre en tu editor, valida el formato cuando
|
|
671
|
+
// guardás, te muestra qué cambia y recién ahí la sube. Si el formato no da, te deja
|
|
672
|
+
// volver al editor en vez de tirarte el trabajo.
|
|
673
|
+
async function cmdEditUs(id, opts = {}) {
|
|
674
|
+
if (!id) fail("uso: dai edit-us <ID> [--us <archivo.md>] [--strict] [--dry-run] [--yes]", 1);
|
|
675
|
+
if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
|
|
676
|
+
loadDaiEnv();
|
|
677
|
+
const adapter = getAdapter(process.env);
|
|
678
|
+
if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
|
|
679
|
+
|
|
680
|
+
const live = await fetchLive(adapter, id);
|
|
681
|
+
if (!live) {
|
|
682
|
+
fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto?\n` +
|
|
683
|
+
` Para CREARLA: dai publish <us.md> (edit-us edita una que ya existe)`, 2);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Dónde se edita: el us.md del change si existe (así el dev y el PO tocan el MISMO
|
|
687
|
+
// archivo), si no `.dai/us/<ID>.md`, que es la convención del backend md.
|
|
688
|
+
const file = usFileFor(id, opts, { quiet: true }) ||
|
|
689
|
+
join(process.env.DAI_MD_US_DIR || join(".dai", "us"), `${id}.md`);
|
|
690
|
+
|
|
691
|
+
// El cuerpo que baja del tracker. Si ya hay un .md local con el MISMO ac_hash, se
|
|
692
|
+
// respeta el local: puede tener secciones del molde (contexto, fuera de scope) que el
|
|
693
|
+
// tracker no devuelve, y pisarlas con la versión de arriba sería perder trabajo.
|
|
694
|
+
let md = live.raw || null;
|
|
695
|
+
const localExists = existsSync(file);
|
|
696
|
+
const local = localExists ? readFileSync(file, "utf8") : null;
|
|
697
|
+
if (local && acHash(local) === live.ac_hash) {
|
|
698
|
+
md = local;
|
|
699
|
+
info(`${rel(file)} ya está al día con ${adapter.kind} (ac_hash ${live.ac_hash}) — edito el local, que tiene el molde completo.`);
|
|
700
|
+
} else if (md == null) {
|
|
701
|
+
fail(`el backend '${adapter.kind}' no devuelve el cuerpo de la US, así que no puedo traerla para editar.\n` +
|
|
702
|
+
" Editá el .md a mano y empujalo con: dai update-us " + id + " --us <archivo.md>", 1);
|
|
703
|
+
} else if (localExists && typeof opts.us === "string") {
|
|
704
|
+
// Pediste ESE archivo: es la fuente, punto. Bajarle la versión del tracker encima
|
|
705
|
+
// borraría justo lo que viniste a subir — es el camino que usa /grill-user-story,
|
|
706
|
+
// que escribe la US refinada al .md y después la empuja.
|
|
707
|
+
md = local;
|
|
708
|
+
info(`${rel(file)} es tu fuente (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}) — no lo piso con el tracker.`);
|
|
709
|
+
} else if (localExists) {
|
|
710
|
+
warn(`${rel(file)} existe pero DIFIERE del tracker (local ${acHash(local) || "sin criterios"} vs vivo ${live.ac_hash}).`);
|
|
711
|
+
// Ante la duda gana lo LOCAL: es trabajo que alguien escribió y que el tracker no
|
|
712
|
+
// tiene. Pisarlo es la única de las dos opciones que destruye algo.
|
|
713
|
+
if (!opts.yes && process.stdin.isTTY) {
|
|
714
|
+
const ans = await askOrCancel(" ¿Lo piso con la versión del tracker? (s/N — 'n' edita el local tal cual) ");
|
|
715
|
+
if (!/^s|^y/i.test(ans)) { md = local; info("Edito el local, sin pisarlo."); }
|
|
716
|
+
} else {
|
|
717
|
+
md = local;
|
|
718
|
+
info(`edito el local sin pisarlo (${opts.yes ? "--yes" : "no interactivo"}). Para partir del tracker, borrá ${rel(file)} o pasá otro --us.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
723
|
+
if (md !== local) { writeFileSync(file, md); ok(`traje ${id} de ${adapter.kind} → ${rel(file)}`); }
|
|
724
|
+
|
|
725
|
+
// ── El ciclo editar → validar ──────────────────────────────────────────────
|
|
726
|
+
// Un formato inválido NO tira el trabajo: te devuelve al editor con los errores a la
|
|
727
|
+
// vista. Se sale con Ctrl+C, no perdiendo lo escrito.
|
|
728
|
+
for (;;) {
|
|
729
|
+
if (!opts.noEditor) await openEditor(file);
|
|
730
|
+
md = readFileSync(file, "utf8");
|
|
731
|
+
const v = validateUS(md);
|
|
732
|
+
if (v.ok || opts.noEditor || !process.stdin.isTTY) break;
|
|
733
|
+
process.stdout.write(`\n ── formato de la US (${rel(file)}) ────────────\n`);
|
|
734
|
+
for (const l of renderValidation(v)) process.stdout.write(l + "\n");
|
|
735
|
+
const ans = await askOrCancel("\n El formato no da. ¿Vuelvo a abrir el editor? (S/n — 'n' aborta sin tocar el tracker) ");
|
|
736
|
+
if (/^n/i.test(ans)) { info("Abortado — el tracker quedó como estaba; tu edición está en " + rel(file) + "."); return; }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await pushUS(id, md, { adapter, file, opts, live });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Abre $VISUAL/$EDITOR sobre el archivo. Sin editor configurado no se impone `vi`: se
|
|
743
|
+
// pide editar el archivo y volver — funciona igual en una terminal, en un IDE, o con el
|
|
744
|
+
// archivo abierto en otra ventana.
|
|
745
|
+
async function openEditor(file) {
|
|
746
|
+
const ed = process.env.VISUAL || process.env.EDITOR;
|
|
747
|
+
if (ed && process.stdin.isTTY) {
|
|
748
|
+
info(`abriendo ${rel(file)} en ${ed}…`);
|
|
749
|
+
try {
|
|
750
|
+
execFileSync(ed, [file], { stdio: "inherit", shell: process.platform === "win32" });
|
|
751
|
+
return;
|
|
752
|
+
} catch (e) {
|
|
753
|
+
warn(`no pude abrir '${ed}': ${String(e.message).split("\n")[0]}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (!process.stdin.isTTY) return;
|
|
757
|
+
if (!ed) info("no hay $EDITOR ni $VISUAL configurados.");
|
|
758
|
+
await askOrCancel(` Editá ${rel(file)} y presioná Enter cuando termines (Ctrl+C para abortar) `);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── update-us: empuja al tracker una US que ya escribiste ─────────────────────
|
|
762
|
+
//
|
|
763
|
+
// El inverso de `dai publish`: la US ya existe con su key, la refinaste implementando (un
|
|
764
|
+
// criterio que apareció escribiendo el test) y el tracker quedó viejo. Sin esto había que
|
|
765
|
+
// copiar y pegar a mano, que es justo lo que el método no quiere (Art. 10).
|
|
766
|
+
async function cmdUpdateUs(id, opts = {}) {
|
|
767
|
+
if (!id) fail("uso: dai update-us <ID> [--us <archivo.md>] [--strict] [--no-resync] [--dry-run] [--yes]", 1);
|
|
768
|
+
if (!isValidKey(id)) fail(`key inválido: '${id}'. Sin espacios ni barras (ej.: ABC-482 o 86cxyz).`, 1);
|
|
769
|
+
loadDaiEnv();
|
|
770
|
+
|
|
771
|
+
const file = usFileFor(id, opts);
|
|
772
|
+
if (!file) {
|
|
773
|
+
fail(`falta --us <archivo.md> con la US (no encontré un us.md junto al implements.yaml de ${id}).\n` +
|
|
774
|
+
` Si querés traerla del tracker y editarla ahí mismo: dai edit-us ${id}`, 1);
|
|
775
|
+
}
|
|
776
|
+
if (!existsSync(file)) fail(`no existe el archivo '${file}'.`, 1);
|
|
777
|
+
|
|
778
|
+
const adapter = getAdapter(process.env);
|
|
779
|
+
if (typeof adapter.updateUS !== "function") fail(`el backend '${adapter.kind}' no soporta actualizar US.`, 1);
|
|
780
|
+
const live = await fetchLive(adapter, id);
|
|
781
|
+
if (!live && adapter.kind !== "md") {
|
|
782
|
+
fail(`no encontré la US ${id} en ${adapter.kind}. ¿Es el key correcto? Para CREARLA: dai publish ${rel(file)}`, 2);
|
|
783
|
+
}
|
|
784
|
+
await pushUS(id, readFileSync(file, "utf8"), { adapter, file, opts, live });
|
|
785
|
+
}
|
|
786
|
+
|
|
390
787
|
// ── done: cierra una US — vuelve a la base, actualiza y borra la branch local ──
|
|
391
788
|
function cmdDone(opts) {
|
|
392
789
|
const base = opts.base || "main";
|
|
@@ -405,8 +802,12 @@ function cmdDone(opts) {
|
|
|
405
802
|
}
|
|
406
803
|
|
|
407
804
|
// La US que se cierra (informativo) — leerla ANTES de cambiar de branch.
|
|
408
|
-
|
|
409
|
-
|
|
805
|
+
// Solo la de ESTA branch: antes listaba todas las del repo (archivadas incluidas), así
|
|
806
|
+
// que cerrar una branch anunciaba el cierre de medio sprint — el mismo defecto que
|
|
807
|
+
// `dai pr` en los issues #31/#32/#33, acá en un mensaje.
|
|
808
|
+
const usIds = matchBranchToImplements(
|
|
809
|
+
branch, flattenImplements(discoverImplements(process.cwd(), { includeArchived: false })),
|
|
810
|
+
).map((r) => r.id);
|
|
410
811
|
|
|
411
812
|
// Ir a la base y actualizar.
|
|
412
813
|
info(`Cambiando a '${base}' y actualizando…`);
|
|
@@ -451,7 +852,10 @@ async function cmdPr(opts) {
|
|
|
451
852
|
const ask = async (q) => {
|
|
452
853
|
if (!process.stdin.isTTY) return null; // no interactivo → sin preguntas
|
|
453
854
|
if (!_rl) _rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
454
|
-
|
|
855
|
+
// Ctrl+D (EOF) → "" = cancelar, no un crash: todas estas preguntas preceden a una
|
|
856
|
+
// acción hacia afuera (push + PR), y ahí abortar es la respuesta segura.
|
|
857
|
+
try { return (await _rl.question(q)).trim(); }
|
|
858
|
+
catch { process.stdout.write("\n"); return ""; }
|
|
455
859
|
};
|
|
456
860
|
const closeRl = () => { if (_rl) { _rl.close(); _rl = null; } };
|
|
457
861
|
|
|
@@ -466,19 +870,53 @@ async function cmdPr(opts) {
|
|
|
466
870
|
fail(`no hay commits en '${branch}' por encima de '${base}'. Una PR necesita cambios: haz commit primero (git commit).`, 1);
|
|
467
871
|
}
|
|
468
872
|
|
|
469
|
-
// 1. Resolver el link (US) de
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
873
|
+
// 1. Resolver el link (US) de ESTA branch (issues #31, #32, #33).
|
|
874
|
+
// Antes se recorrían todos los implements.yaml del repo —archivados incluidos— y
|
|
875
|
+
// ganaba el último: la branch, que es la que sabe la respuesta, no entraba en la
|
|
876
|
+
// decisión. La PR salía titulada con la historia de otro y nadie se enteraba,
|
|
877
|
+
// porque el `dai check ✅` del body era el de esa otra US.
|
|
878
|
+
const rows = flattenImplements(discoverImplements(process.cwd(), { includeArchived: false }));
|
|
879
|
+
const allRows = flattenImplements(discoverImplements(process.cwd()));
|
|
880
|
+
const scope = prScope({ branch, rows, allRows, ids: opts.us ? [String(opts.us)] : [] });
|
|
881
|
+
let entry = scope.target;
|
|
882
|
+
|
|
883
|
+
if (scope.mode === "explicit" && !entry) {
|
|
884
|
+
closeRl();
|
|
885
|
+
fail(`no encontré implements.yaml para '${opts.us}'.\n` +
|
|
886
|
+
` US en este repo: ${allRows.map((r) => r.id).join(", ") || "(ninguna)"}`, 1);
|
|
474
887
|
}
|
|
475
|
-
if (
|
|
476
|
-
|
|
888
|
+
if (scope.mode === "none") {
|
|
889
|
+
closeRl();
|
|
890
|
+
fail("no hay una US linkeada (implements.yaml). Si este PR implementa una US, corré `dai link-us` primero. Si es un chore/tooling (sin US), renombrá la branch a `chore/…` o creá la PR con tu forge: `glab mr create` / `gh pr create`.", 1);
|
|
891
|
+
}
|
|
892
|
+
if (scope.mode === "ambiguous") {
|
|
893
|
+
// Elegir en silencio es EL bug: la PR sale publicada con el link QUÉ↔CÓMO de otra US.
|
|
894
|
+
warn(`${scope.reason}.`);
|
|
895
|
+
const listed = scope.candidates.map((r, i) => ` ${i + 1}) ${r.id} ${C.dim(`(${r.change})`)}`).join("\n");
|
|
896
|
+
process.stdout.write(` US vivas en el repo:\n${listed}\n`);
|
|
897
|
+
if (opts.yes || !process.stdin.isTTY) {
|
|
898
|
+
closeRl();
|
|
899
|
+
fail("no sé con qué US titular la PR. Decilo explícitamente: dai pr --us <ID>", 1);
|
|
900
|
+
}
|
|
901
|
+
const ans = await ask(` ¿Con qué US titulo la PR? (número, Enter=cancelar) `);
|
|
902
|
+
if (!ans) { closeRl(); info("Cancelado — no se creó la PR."); return; }
|
|
903
|
+
const n = Number(ans);
|
|
904
|
+
if (!Number.isInteger(n) || n < 1 || n > scope.candidates.length) {
|
|
905
|
+
closeRl();
|
|
906
|
+
fail(`respuesta inválida: '${ans}'. Se esperaba un número entre 1 y ${scope.candidates.length}.`, 1);
|
|
907
|
+
}
|
|
908
|
+
entry = scope.candidates[n - 1];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// De dónde salió la US: en el preview, el título sin justificación no delata nada
|
|
912
|
+
// cuando está mal (sugerencia del issue #32).
|
|
913
|
+
const usWhy = entry ? (scope.mode === "ambiguous" ? "la elegiste vos" : scope.reason) : scope.reason;
|
|
914
|
+
const { id, version, ac_hash } = entry || {};
|
|
477
915
|
|
|
478
916
|
// 2. Estado de trazabilidad (dai check) contra la US viva.
|
|
479
917
|
const adapter = getAdapter(process.env);
|
|
480
|
-
const live = await Promise.resolve(adapter.fetchUS(id)).catch(() => null);
|
|
481
|
-
const status = coverageStatus(ac_hash, live?.ac_hash);
|
|
918
|
+
const live = id ? await Promise.resolve(adapter.fetchUS(id)).catch(() => null) : null;
|
|
919
|
+
const status = id ? coverageStatus(ac_hash, live?.ac_hash) : null;
|
|
482
920
|
if (status === "atrasado") {
|
|
483
921
|
warn(`la US ${id} está ATRASADA respecto de tu implementación (${ac_hash} ≠ ${live?.ac_hash}).`);
|
|
484
922
|
warn(`resincroniza antes de abrir la PR: dai link-us ${id} --resync`);
|
|
@@ -492,22 +930,25 @@ async function cmdPr(opts) {
|
|
|
492
930
|
let commits = [];
|
|
493
931
|
try { commits = git(["log", `${base}..HEAD`, "--pretty=%s"]).split("\n").filter(Boolean); } catch { /* base local ausente */ }
|
|
494
932
|
// La canónica del tracker (live.url) gana sobre la derivada; el template gana sobre todo.
|
|
495
|
-
const usUrl = usUrlFor(id, live?.url);
|
|
496
|
-
if (!usUrl) {
|
|
933
|
+
const usUrl = id ? usUrlFor(id, live?.url) : null;
|
|
934
|
+
if (id && !usUrl) {
|
|
497
935
|
warn(`no sé la URL de ${id} en el tracker: la PR va a quedar sin link a la US.`);
|
|
498
936
|
warn(`configurá DAI_TRACKER_URL_TEMPLATE en el .env.dai (p. ej. https://tu-tracker/browse/{id}).`);
|
|
499
937
|
}
|
|
500
938
|
const body = composePrBody(readFileSync(tplPath, "utf8"), {
|
|
501
|
-
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits,
|
|
939
|
+
id, version, ac_hash, status, usUrl, usTitle: live?.title, commits, noUsReason: id ? null : scope.reason,
|
|
502
940
|
branch, branchUrl: branchUrl(remote, branch), commit, commitUrl: commitUrl(remote, commit),
|
|
503
941
|
});
|
|
504
|
-
|
|
942
|
+
// Sin US el título sale del último commit: describe lo que hay adentro, en vez de
|
|
943
|
+
// pedirle prestada la historia a otro (issue #31).
|
|
944
|
+
const title = prTitle(opts, id, live?.title, commits[0] || branch);
|
|
505
945
|
const forge = detectForge(parseRemote(remote)?.host);
|
|
506
946
|
const tool = forgeTool(forge);
|
|
507
947
|
|
|
508
948
|
// 4. Mostrar y pedir confirmación (acción hacia afuera).
|
|
509
949
|
process.stdout.write(`\n ── Pull Request a crear ──────────────────────────────\n`);
|
|
510
950
|
process.stdout.write(` título: ${title}\n de: ${branch}\n a: ${base}\n`);
|
|
951
|
+
process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
|
|
511
952
|
process.stdout.write(` forge: ${forge} (${tool})${opts.assignee ? `\n asignar: ${opts.assignee}` : ""}${opts.draft ? "\n draft: sí" : ""}\n`);
|
|
512
953
|
process.stdout.write(` ─────────────────────────────────────────────────────\n\n${body}\n`);
|
|
513
954
|
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
@@ -840,6 +1281,7 @@ async function cmdInit(repo, opts) {
|
|
|
840
1281
|
}
|
|
841
1282
|
writeFileSync(join(dai, "VERSION"), readFileSync(join(ROOT, "VERSION"), "utf8"));
|
|
842
1283
|
ok(".dai/ moldes (templates) + reglas (governance) del método");
|
|
1284
|
+
info(" gate de CI opcional: cp .dai/templates/ci-dai-gate.yml .github/workflows/");
|
|
843
1285
|
|
|
844
1286
|
// Config de dai en SUS PROPIOS archivos, sin tocar el `.env`/`.env.example` del equipo:
|
|
845
1287
|
// muchas orgs versionan el `.env` como política, así que dai lo deja en paz (solo lo lee,
|
|
@@ -1252,8 +1694,10 @@ switch (cmd) {
|
|
|
1252
1694
|
case "ac-hash": cmdAcHash(pos[0]); break;
|
|
1253
1695
|
case "ls": cmdLs(opts); break;
|
|
1254
1696
|
case "link-us": cmdLinkUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1255
|
-
case "check": cmdCheck().catch((e) => fail(String(e.message))); break;
|
|
1256
|
-
case "stamp": cmdStamp().catch((e) => fail(String(e.message))); break;
|
|
1697
|
+
case "check": (opts.ci ? cmdCheckCi(opts) : cmdCheck()).catch((e) => fail(String(e.message))); break;
|
|
1698
|
+
case "stamp": cmdStamp(pos, opts).catch((e) => fail(String(e.message))); break;
|
|
1699
|
+
case "update-us": cmdUpdateUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1700
|
+
case "edit-us": cmdEditUs(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1257
1701
|
case "forge": cmdForge(pos[0], pos[1], opts).catch((e) => fail(String(e.message))); break;
|
|
1258
1702
|
case "publish": cmdPublish(pos[0], opts).catch((e) => fail(String(e.message))); break;
|
|
1259
1703
|
case "pr":
|
|
@@ -1283,11 +1727,23 @@ switch (cmd) {
|
|
|
1283
1727
|
" [--field alias=valor] campos propios que exige tu Jira (.dai/jira-fields.json); repetible\n" +
|
|
1284
1728
|
" link-us <KEY> [--us <md>] crea branch + implements.yaml; sin --us trae la US del tracker (ADR-0004)\n" +
|
|
1285
1729
|
" link-us <KEY> --resync re-estampa el ac_hash contra la US viva (tras un ⚠️ de check)\n" +
|
|
1730
|
+
" edit-us <KEY> trae la US del tracker, la abrís en tu editor, valida el formato,\n" +
|
|
1731
|
+
" muestra qué cambia y la guarda (para el PO)\n" +
|
|
1732
|
+
" [--no-editor] no abre $EDITOR (para skills/scripts que ya escribieron el .md)\n" +
|
|
1733
|
+
" [--bump | --no-bump] decide el spec_version sin preguntar (sin TTY no se toca y avisa)\n" +
|
|
1734
|
+
" update-us <KEY> [--us <md>] empuja al tracker un .md que ya escribiste + re-estampa el ac_hash\n" +
|
|
1735
|
+
" [--dry-run] [--yes] sin --yes muestra el diff y pide confirmación · [--no-resync]\n" +
|
|
1736
|
+
" [--strict] las advertencias de formato también frenan · [--no-bump] no toca spec_version\n" +
|
|
1286
1737
|
" check compara vs la US viva → atrasado (ADR-0003)\n" +
|
|
1287
|
-
"
|
|
1738
|
+
" check --ci gate de CI: exige el link según branch-naming (chore/ y docs/ exentas)\n" +
|
|
1739
|
+
" [--branch b] la branch a evaluar (en CI se detecta sola) · [--no-network]\n" +
|
|
1740
|
+
" salidas: 0 pasa · 1 falta el link · 2 el QUÉ cambió\n" +
|
|
1741
|
+
" stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
|
|
1742
|
+
" sin ID: la US de esta branch; si hay varias, pregunta\n" +
|
|
1288
1743
|
" done [--base main] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local (si está mergeada)\n" +
|
|
1289
1744
|
" archive [<change>] [--skip-specs] funde los delta specs del change en las specs canónicas y lo archiva (lo corre el aprobador en la PR)\n" +
|
|
1290
1745
|
" pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea TU PR/MR precargada (muestra + confirma)\n" +
|
|
1746
|
+
" [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
|
|
1291
1747
|
" forge comment <ref> --body-file <f> · forge pr <ref> comentar/leer una PR ajena (github/gitlab)\n" +
|
|
1292
1748
|
" forge review <ref> --from <review.json> [--dry-run|--yes] review inline: resumen + comentario por línea\n" +
|
|
1293
1749
|
" --min-severity low|medium|high · --min-confidence 0..1 · --max-comments N · --base <branch>\n" +
|