@dforce2055/dai 0.15.0 → 0.15.1
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 +56 -0
- package/CONTRIBUTING.md +22 -0
- package/VERSION +1 -1
- package/cli/dai.mjs +105 -94
- package/cli/lib/bootstrap.mjs +4 -4
- package/cli/lib/branch-flow.mjs +6 -6
- package/cli/lib/notify.mjs +13 -13
- package/cli/lib/pm-adapter.mjs +2 -2
- package/cli/lib/pm-jira.mjs +72 -8
- package/cli/lib/release-files.mjs +6 -6
- package/cli/lib/release-plan.mjs +21 -21
- package/cli/lib/release-stamp.mjs +11 -11
- package/cli/lib/review-findings.mjs +2 -2
- package/cli/lib/us-format.mjs +2 -2
- package/cli/lib/us.mjs +6 -6
- package/docs/adr/0008-estrategia-de-i18n.md +58 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,61 @@
|
|
|
3
3
|
Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
|
|
4
4
|
(ver `VERSION`).
|
|
5
5
|
|
|
6
|
+
## [0.15.1] — 2026-09-10
|
|
7
|
+
|
|
8
|
+
**Una US puede estar impecable y dai igual no leerla. El molde de trazabilidad pide que la
|
|
9
|
+
metadata viva en una tabla, y cuando la US se escribe a mano en Jira esa tabla es una tabla
|
|
10
|
+
de verdad — que dai no sabía leer. El `spec_version` declarado se perdía en el camino y el
|
|
11
|
+
link quedaba estampado en `pendiente`, con un mensaje que mandaba a agregar una fila que ya
|
|
12
|
+
estaba ahí. Esta versión le enseña a dai a leer y escribir tablas.**
|
|
13
|
+
|
|
14
|
+
### Corregido
|
|
15
|
+
- **El `spec_version` que la US declaraba y dai no veía.** `adfToMarkdown` no tenía caso
|
|
16
|
+
para las tablas de Jira Cloud, así que caían al `default` y cada celda terminaba en su
|
|
17
|
+
propia línea: `spec_version` en una, `v1` en la siguiente. Como el regex que lee la
|
|
18
|
+
versión no cruza saltos de línea —a propósito, para no capturar un `v2` de veinte líneas
|
|
19
|
+
más abajo— no encontraba nada, y el link se estampaba en `version: pendiente`. El aviso
|
|
20
|
+
era el peor de todos: uno que **acusa al usuario** de no haber hecho algo que sí hizo.
|
|
21
|
+
Ahora una fila de tabla llega como una fila de markdown. El `ac_hash` **no se mueve** —la
|
|
22
|
+
metadata vive arriba del bloque de criterios—, así que ningún `implements.yaml` ya
|
|
23
|
+
estampado queda atrasado por este cambio: lo único que cambia es que `pendiente` pasa a
|
|
24
|
+
ser la versión que la US declara. Es el hermano del #46: aquel era el campo escrito
|
|
25
|
+
`specversion`, este es el campo escrito en la tabla que el propio molde pide.
|
|
26
|
+
- **La otra mitad del mismo bug: dai tampoco escribía tablas.** `markdownToAdf` mandaba la
|
|
27
|
+
metadata del molde —y el comentario de cobertura de `dai release stamp`, que también es
|
|
28
|
+
una tabla— como párrafos con pipes adentro. Ilegibles, y encima invitaban a rehacerlos
|
|
29
|
+
como tabla de Jira a mano, que es justo lo que del otro lado no se sabía leer. Ahora
|
|
30
|
+
viajan como nodo `table` y el ida y vuelta de `dai edit-us` no destruye la metadata.
|
|
31
|
+
- **`dai release done` avisaba de un problema inexistente.** Miraba `origin/release/X.Y.Z`,
|
|
32
|
+
una ref local que sobrevive desactualizada hasta el próximo prune, en vez de preguntarle
|
|
33
|
+
al remoto. Cuando GitHub ya había borrado la rama al mergear —su comportamiento por
|
|
34
|
+
default— intentaba borrar algo que no estaba y reportaba un ⚠ sobre un estado que era el
|
|
35
|
+
correcto. Ahora pregunta con `git ls-remote --heads`, que es autoritativo, limpia la ref
|
|
36
|
+
fantasma y explica en una línea por qué no hubo nada que borrar. Un aviso falso es caro:
|
|
37
|
+
enseña a ignorar los avisos.
|
|
38
|
+
|
|
39
|
+
### Cambiado
|
|
40
|
+
- **`dai release plan --json` expone `counts.stale`, antes `counts.atrasadas`.** Era una
|
|
41
|
+
clave en español en una salida que lee un script — la única que quedaba — y sigue el
|
|
42
|
+
glosario que ADR-0008 ya había fijado (*atrasado* → *stale*). Si escribiste algo contra
|
|
43
|
+
el `--json` de la 0.15.0 (publicada un día antes), es el único nombre que tenés que
|
|
44
|
+
cambiar.
|
|
45
|
+
|
|
46
|
+
### Interno
|
|
47
|
+
- **Convención de idioma, ahora escrita.** El repo siempre fue código en inglés y
|
|
48
|
+
comentarios en español, pero no estaba en ningún lado: al escribir el ciclo de release se
|
|
49
|
+
rompió sistemáticamente (25% de identificadores en español en lo nuevo, contra ~2%
|
|
50
|
+
preexistente). Se renombraron 209 identificadores al inglés y la regla quedó como sexta
|
|
51
|
+
regla de oro en `CONTRIBUTING.md`, con una excepción deliberada: **`autor` se queda**. No
|
|
52
|
+
es código, es un campo del `implements.yaml` y parte del contrato del método (ADR-0004);
|
|
53
|
+
renombrarlo sería romper un contrato para ganar consistencia interna.
|
|
54
|
+
- **ADR-0008** documenta el relevamiento que falta para poder ejecutar el i18n.
|
|
55
|
+
- **484 tests** (+9 desde la 0.15.0): las tablas ADF en las dos direcciones —incluido el
|
|
56
|
+
ida y vuelta markdown → ADF → markdown, que es el que protege la metadata de `dai
|
|
57
|
+
edit-us`— y la primera cobertura de integración de `dai release done`, que era casi todo
|
|
58
|
+
efecto y no tenía ninguna: el ciclo completo contra un repo con remoto de verdad, el caso
|
|
59
|
+
del forge que se adelantó, y las dos negativas a taguear.
|
|
60
|
+
|
|
6
61
|
## [0.15.0] — 2026-09-09
|
|
7
62
|
|
|
8
63
|
**Un equipo puede tener el link QUÉ↔CÓMO perfecto y seguir sin poder contestar la pregunta
|
|
@@ -988,6 +1043,7 @@ ClickUp y Jira Cloud.
|
|
|
988
1043
|
- Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
|
|
989
1044
|
`files` de npm sin tests ni secretos.
|
|
990
1045
|
|
|
1046
|
+
[0.15.1]: https://github.com/dforce2055/dai/releases/tag/v0.15.1
|
|
991
1047
|
[0.15.0]: https://github.com/dforce2055/dai/releases/tag/v0.15.0
|
|
992
1048
|
[0.14.0]: https://github.com/dforce2055/dai/releases/tag/v0.14.0
|
|
993
1049
|
[0.13.3]: https://github.com/dforce2055/dai/releases/tag/v0.13.3
|
package/CONTRIBUTING.md
CHANGED
|
@@ -41,6 +41,28 @@ git config core.hooksPath .githooks # valida convención de commits + autoría
|
|
|
41
41
|
(Cursor, Copilot, Claude, …). Un agente puede ayudarte, pero el cambio lo firmas
|
|
42
42
|
tú: con tu identidad y sin el trailer del agente. Un check de CI lo bloquea en cada
|
|
43
43
|
PR (ver [`governance/human-authorship.md`](governance/human-authorship.md)).
|
|
44
|
+
6. **Código en inglés, comentarios en español.** Los identificadores —variables,
|
|
45
|
+
funciones, constantes, claves de objeto— van en **inglés**; los comentarios y la
|
|
46
|
+
documentación, en **español**. Y todo lo que sale por una interfaz de máquina
|
|
47
|
+
(`--json`, el payload de un webhook, los códigos de estado internos) va en inglés
|
|
48
|
+
también: es una API, la lee un script, y traducirla después rompe a quien la consume.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// El endpoint ES la credencial: quien lo tiene, postea. ← comentario en español
|
|
52
|
+
export function describeTarget(cfg) { ← código en inglés
|
|
53
|
+
const target = new URL(cfg.endpoint).host;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**La excepción, y es una sola:** los nombres de campo del `implements.yaml` son
|
|
57
|
+
parte del contrato del método, no del código. `autor:` se llama así en el schema
|
|
58
|
+
(ADR-0004) y en todos los repos que ya lo usan; renombrarlo sería romper el
|
|
59
|
+
contrato para ganar consistencia interna, que es el peor cambio posible.
|
|
60
|
+
|
|
61
|
+
Lo que **todavía no** cumple esta regla son los **mensajes al usuario**, que hoy son
|
|
62
|
+
literales en español repartidos por todo el CLI. Eso no es un descuido: es el
|
|
63
|
+
refactor que [ADR-0008](docs/adr/0008-estrategia-de-i18n.md) tiene planificado como
|
|
64
|
+
fase 3 (`DAI_LANG` + un catálogo `t(key)` sin dependencias). Mientras tanto, escribí
|
|
65
|
+
los mensajes en español y **no inventes un mecanismo de traducción propio**.
|
|
44
66
|
|
|
45
67
|
## Flujo (la propia metodología)
|
|
46
68
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.15.
|
|
1
|
+
0.15.1
|
package/cli/dai.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { listPrCmd, parsePrList, updatePrCmd, updatePrApiCmd, isAlreadyExistsErr
|
|
|
33
33
|
import { parseCommitLog, proposeBump, nextVersion, buildManifest, renderManifest, BUMP_CAVEAT } from "./lib/release-plan.mjs";
|
|
34
34
|
import { bumpPackageJson, bumpVersionFile, changelogEntry, insertChangelogEntry, changelogSection, changelogGaps, releaseBranch, tagName, normalizeVersion } from "./lib/release-files.mjs";
|
|
35
35
|
import { notifyConfig, describeTarget, renderNotice, sendNotice, formatFecha } from "./lib/notify.mjs";
|
|
36
|
-
import { releaseMarker, alreadyStamped, renderReleaseStamp, stampPlan, renderStampPlan, stampWarning,
|
|
36
|
+
import { releaseMarker, alreadyStamped, renderReleaseStamp, stampPlan, renderStampPlan, stampWarning, NOT_STAMPED_NOTE } from "./lib/release-stamp.mjs";
|
|
37
37
|
import { parseImplements } from "./lib/implements.mjs";
|
|
38
38
|
import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
|
|
39
39
|
import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
|
|
@@ -353,9 +353,9 @@ function ciBranch() {
|
|
|
353
353
|
// lo hace pasar por verificado (issue #46). No cambia el exit code — el hash manda.
|
|
354
354
|
function versionDriftHint(im, live) {
|
|
355
355
|
const declared = String(im?.version ?? "").trim();
|
|
356
|
-
const
|
|
357
|
-
if (!
|
|
358
|
-
warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${
|
|
356
|
+
const liveVersion = String(live?.spec_version ?? "").trim();
|
|
357
|
+
if (!liveVersion || declared === liveVersion) return;
|
|
358
|
+
warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${liveVersion}'.`);
|
|
359
359
|
process.stdout.write(` Ese número se publica en la PR y se estampa en el tracker. Corregilo: dai link-us ${im.id} --resync\n`);
|
|
360
360
|
}
|
|
361
361
|
|
|
@@ -366,7 +366,7 @@ async function cmdCheck() {
|
|
|
366
366
|
const adapter = getAdapter(process.env);
|
|
367
367
|
const found = discoverImplements(process.cwd(), { includeArchived: false });
|
|
368
368
|
let worst = 0, n = 0;
|
|
369
|
-
const
|
|
369
|
+
const stale = [];
|
|
370
370
|
for (const f of found) for (const im of f.implements || []) {
|
|
371
371
|
if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
|
|
372
372
|
n++;
|
|
@@ -378,7 +378,7 @@ async function cmdCheck() {
|
|
|
378
378
|
}
|
|
379
379
|
else if (status === "atrasado") {
|
|
380
380
|
process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
|
|
381
|
-
|
|
381
|
+
stale.push(im.id);
|
|
382
382
|
worst = Math.max(worst, 1);
|
|
383
383
|
} else if (status === "sin-respuesta") {
|
|
384
384
|
// No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
|
|
@@ -391,9 +391,9 @@ async function cmdCheck() {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
if (n === 0) process.stdout.write("No hay implements.yaml para chequear.\n");
|
|
394
|
-
if (
|
|
394
|
+
if (stale.length) {
|
|
395
395
|
process.stdout.write("\n El QUÉ cambió desde que lo implementaste. Para resincronizar:\n");
|
|
396
|
-
for (const id of
|
|
396
|
+
for (const id of stale) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
|
|
397
397
|
process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
|
|
398
398
|
}
|
|
399
399
|
process.exitCode = worst;
|
|
@@ -1091,8 +1091,8 @@ async function cmdPr(opts) {
|
|
|
1091
1091
|
const existing = findExistingPr(tool, branch);
|
|
1092
1092
|
|
|
1093
1093
|
// 4. Mostrar y pedir confirmación (acción hacia afuera).
|
|
1094
|
-
const
|
|
1095
|
-
process.stdout.write(`\n ── Pull Request ${
|
|
1094
|
+
const action = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
|
|
1095
|
+
process.stdout.write(`\n ── Pull Request ${action} ──────────────────────────────\n`);
|
|
1096
1096
|
process.stdout.write(` título: ${title}\n de: ${branch}\n`);
|
|
1097
1097
|
process.stdout.write(` a: ${base} ${C.dim(`(${baseSource}${baseWhy ? ` — ${baseWhy}` : ""})`)}${toProd ? ` ${C.b("⚠️ DESPLIEGA A PRODUCCIÓN")}` : ""}\n`);
|
|
1098
1098
|
process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
|
|
@@ -1284,16 +1284,16 @@ function linkedInRange(range) {
|
|
|
1284
1284
|
raw = git(["log", "--format=\x1e%H", "--name-only", "--diff-filter=AMR", range, "--", "*implements.yaml"]);
|
|
1285
1285
|
} catch { return rows; }
|
|
1286
1286
|
let order = 0;
|
|
1287
|
-
for (const
|
|
1288
|
-
const
|
|
1289
|
-
if (
|
|
1290
|
-
const sha =
|
|
1291
|
-
for (const path of
|
|
1287
|
+
for (const chunk of raw.split("\x1e")) {
|
|
1288
|
+
const outLines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1289
|
+
if (outLines.length === 0) continue;
|
|
1290
|
+
const sha = outLines[0];
|
|
1291
|
+
for (const path of outLines.slice(1)) {
|
|
1292
1292
|
if (!path.endsWith("implements.yaml")) continue;
|
|
1293
|
-
let
|
|
1294
|
-
try {
|
|
1293
|
+
let text;
|
|
1294
|
+
try { text = git(["show", `${sha}:${path}`]); } catch { continue; } // borrado después
|
|
1295
1295
|
let parsed;
|
|
1296
|
-
try { parsed = parseImplements(
|
|
1296
|
+
try { parsed = parseImplements(text); } catch { continue; }
|
|
1297
1297
|
for (const im of parsed.implements || []) {
|
|
1298
1298
|
if (isPlaceholderId(im.id)) continue;
|
|
1299
1299
|
rows.push({ ...im, change: parsed.change, repo: parsed.repo, path, sha, order: order++ });
|
|
@@ -1307,11 +1307,11 @@ function linkedInRange(range) {
|
|
|
1307
1307
|
// contestar "qué entra en esta versión" — si cada comando lo calculara a su manera,
|
|
1308
1308
|
// tarde o temprano dirían cosas distintas y le creerías al que tenés más a mano.
|
|
1309
1309
|
async function releaseManifest(opts = {}, { from, to } = {}) {
|
|
1310
|
-
const
|
|
1311
|
-
const
|
|
1312
|
-
const toRev = resolveRev(
|
|
1313
|
-
if (!toRev) fail(`no encuentro '${
|
|
1314
|
-
const range =
|
|
1310
|
+
const fromRef = from ?? (textOpt(opts, "from") || lastTag());
|
|
1311
|
+
const toRef = to ?? (textOpt(opts, "to") || branchFlow(process.env).dev || gitBranch());
|
|
1312
|
+
const toRev = resolveRev(toRef) || resolveRev(`origin/${toRef}`);
|
|
1313
|
+
if (!toRev) fail(`no encuentro '${toRef}' (ni local ni en origin/${toRef}).\n Traelo primero: git fetch origin ${toRef}`, 1);
|
|
1314
|
+
const range = fromRef ? `${fromRef}..${toRev}` : toRev;
|
|
1315
1315
|
|
|
1316
1316
|
let commits = [];
|
|
1317
1317
|
try { commits = parseCommitLog(git(["log", "--format=%H\x1f%s", range])); }
|
|
@@ -1335,7 +1335,7 @@ async function releaseManifest(opts = {}, { from, to } = {}) {
|
|
|
1335
1335
|
// declararla nunca. Se mira el repo entero, no el rango.
|
|
1336
1336
|
const repoUsesStories = flattenImplements(discoverImplements(process.cwd())).length > 0;
|
|
1337
1337
|
const m = buildManifest({ commits, linked, live, unreachable: unreachable || Boolean(opts.noNetwork), repoUsesStories });
|
|
1338
|
-
return { manifest: m, commits, from:
|
|
1338
|
+
return { manifest: m, commits, from: fromRef, to: toRef, range };
|
|
1339
1339
|
}
|
|
1340
1340
|
|
|
1341
1341
|
// La versión que este repo declara hoy. `null` si no espeja el número en ningún archivo
|
|
@@ -1386,31 +1386,31 @@ async function cmdReleaseCut(versionArg, opts = {}) {
|
|
|
1386
1386
|
const { manifest: m, commits, from, to } = await releaseManifest(opts);
|
|
1387
1387
|
const { version: current, file: versionFile } = currentVersion();
|
|
1388
1388
|
const { bump, reason } = proposeBump(commits);
|
|
1389
|
-
const
|
|
1389
|
+
const suggested = current ? nextVersion(current, bump) : null;
|
|
1390
1390
|
|
|
1391
1391
|
// Qué archivos espejan el número en ESTE repo. Puede no haber ninguno (un repo .NET, un
|
|
1392
1392
|
// frontend corporativo): el tag sigue siendo la versión.
|
|
1393
|
-
const
|
|
1393
|
+
const mirrors = [];
|
|
1394
1394
|
for (const [file, fn] of [["VERSION", bumpVersionFile], ["package.json", bumpPackageJson]]) {
|
|
1395
1395
|
const p = join(process.cwd(), file);
|
|
1396
1396
|
if (!existsSync(p)) continue;
|
|
1397
1397
|
const r = fn(readFileSync(p, "utf8"), version);
|
|
1398
|
-
|
|
1398
|
+
mirrors.push({ file, path: p, ...r });
|
|
1399
1399
|
}
|
|
1400
1400
|
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1401
|
+
const makeBranch = opts.branch !== false && !opts.noBranch;
|
|
1402
|
+
const releaseBranchName = releaseBranch(version);
|
|
1403
1403
|
|
|
1404
1404
|
process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo)", proposed: version, bump: `pediste ${version}`, reason }) + "\n");
|
|
1405
1405
|
process.stdout.write(`\n ── Se va a preparar ─────────────────────────────────\n`);
|
|
1406
|
-
process.stdout.write(` versión: ${version}${
|
|
1407
|
-
process.stdout.write(` branch: ${
|
|
1408
|
-
for (const e of
|
|
1409
|
-
if (
|
|
1406
|
+
process.stdout.write(` versión: ${version}${suggested && suggested !== version ? C.dim(` (dai habría propuesto ${suggested} — ${bump})`) : ""}\n`);
|
|
1407
|
+
process.stdout.write(` branch: ${makeBranch ? `${releaseBranchName} (desde ${branch})` : C.dim("ninguna — se taguea desde la rama de integración")}\n`);
|
|
1408
|
+
for (const e of mirrors) process.stdout.write(` ${e.file.padEnd(9)} ${e.changed ? `${e.from} → ${version}` : C.dim(`ya está en ${version}`)}\n`);
|
|
1409
|
+
if (mirrors.length === 0) process.stdout.write(` ${C.dim("este repo no espeja el número en ningún archivo que dai reconozca: la versión es el tag")}\n`);
|
|
1410
1410
|
process.stdout.write(` CHANGELOG entrada nueva para ${version} (con el manifiesto para repartir)\n`);
|
|
1411
1411
|
process.stdout.write(` commit: chore(release): v${version}\n`);
|
|
1412
1412
|
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
1413
|
-
if (m.counts.
|
|
1413
|
+
if (m.counts.stale > 0) warn(`vas a cortar una versión con ${m.counts.stale} US ATRASADA(S). Revisalas o resincronizá antes.`);
|
|
1414
1414
|
if (m.orphans.length) warn(`hay ${m.orphans.length} branch(es) sin US ni prefijo exento en el rango.`);
|
|
1415
1415
|
|
|
1416
1416
|
if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
|
|
@@ -1420,35 +1420,35 @@ async function cmdReleaseCut(versionArg, opts = {}) {
|
|
|
1420
1420
|
if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
|
|
1421
1421
|
}
|
|
1422
1422
|
|
|
1423
|
-
if (
|
|
1424
|
-
try { git(["checkout", "-b",
|
|
1425
|
-
catch (e) { fail(`no pude crear '${
|
|
1423
|
+
if (makeBranch) {
|
|
1424
|
+
try { git(["checkout", "-b", releaseBranchName]); ok(`branch ${releaseBranchName}`); }
|
|
1425
|
+
catch (e) { fail(`no pude crear '${releaseBranchName}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
1426
1426
|
}
|
|
1427
|
-
const
|
|
1428
|
-
for (const e of
|
|
1427
|
+
const touched = [];
|
|
1428
|
+
for (const e of mirrors) {
|
|
1429
1429
|
if (!e.changed) continue;
|
|
1430
1430
|
writeFileSync(e.path, e.text);
|
|
1431
|
-
|
|
1431
|
+
touched.push(e.file);
|
|
1432
1432
|
ok(`${e.file}: ${e.from} → ${version}`);
|
|
1433
1433
|
}
|
|
1434
1434
|
|
|
1435
1435
|
// CHANGELOG: el andamio y el material. La prosa la escribe una persona (o la skill).
|
|
1436
1436
|
const chPath = join(process.cwd(), "CHANGELOG.md");
|
|
1437
1437
|
const entry = changelogEntry({ version, date: new Date().toISOString().slice(0, 10), manifest: m });
|
|
1438
|
-
const
|
|
1438
|
+
const previousChangelog = existsSync(chPath) ? readFileSync(chPath, "utf8") : "# Changelog\n";
|
|
1439
1439
|
const repoUrl = (() => { const p = parseRemote(gitRemote()); return p ? `https://${p.host}/${p.path}` : null; })();
|
|
1440
|
-
const res = insertChangelogEntry(
|
|
1441
|
-
if (res.changed) { writeFileSync(chPath, res.text);
|
|
1440
|
+
const res = insertChangelogEntry(previousChangelog, entry, { version, repoUrl });
|
|
1441
|
+
if (res.changed) { writeFileSync(chPath, res.text); touched.push("CHANGELOG.md"); ok(`CHANGELOG.md: entrada para ${version}`); }
|
|
1442
1442
|
else warn(`CHANGELOG.md sin tocar — ${res.reason}.`);
|
|
1443
1443
|
|
|
1444
|
-
if (
|
|
1445
|
-
git(["add", ...
|
|
1444
|
+
if (touched.length === 0) { warn("no hubo nada que cambiar: no hago un commit vacío."); return; }
|
|
1445
|
+
git(["add", ...touched]);
|
|
1446
1446
|
git(["commit", "-m", `chore(release): v${version}`, "-m", `Manifiesto: ${m.counts.stories} US · ${m.counts.commits} commits desde ${from || "el principio"}.`]);
|
|
1447
1447
|
ok(`commit chore(release): v${version}`);
|
|
1448
1448
|
|
|
1449
1449
|
process.stdout.write("\n");
|
|
1450
1450
|
info("Falta lo que dai no puede escribir por vos: repartí el manifiesto del CHANGELOG y contá el porqué.");
|
|
1451
|
-
info(`Después: dai pr${
|
|
1451
|
+
info(`Después: dai pr${makeBranch ? "" : ""} → se mergea → dai release done ${version}`);
|
|
1452
1452
|
}
|
|
1453
1453
|
|
|
1454
1454
|
// ── release done: cerrar la versión ────────────────────────────────────────
|
|
@@ -1481,20 +1481,24 @@ async function cmdReleaseDone(versionArg, opts = {}) {
|
|
|
1481
1481
|
const notes = existsSync(join(process.cwd(), "CHANGELOG.md"))
|
|
1482
1482
|
? changelogSection(readFileSync(join(process.cwd(), "CHANGELOG.md"), "utf8"), version) : null;
|
|
1483
1483
|
const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
|
|
1484
|
-
const
|
|
1484
|
+
const willNotify = cfg && !opts.noNotify;
|
|
1485
1485
|
|
|
1486
1486
|
process.stdout.write(`\n ── Cerrar la versión ${version} ──────────────────────────\n`);
|
|
1487
1487
|
process.stdout.write(` tag: ${tag} → ${prod} @ ${String(head).slice(0, 8)}\n`);
|
|
1488
1488
|
process.stdout.write(` release: ${opts.noRelease ? C.dim("no (--no-release)") : `nota en el forge${notes ? "" : C.dim(" (sin sección del CHANGELOG: sale con las notas del forge)")}`}\n`);
|
|
1489
1489
|
process.stdout.write(` back-merge: ${dev ? `${prod} → ${dev}` : C.dim("no (DAI_BRANCH_DEV no declarada)")}\n`);
|
|
1490
1490
|
const relBranch = releaseBranch(version);
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1491
|
+
const hasLocal = Boolean(safeGit(["rev-parse", "--verify", "--quiet", relBranch]));
|
|
1492
|
+
// Se pregunta al REMOTO, no a `origin/<branch>`: esa ref local queda desactualizada
|
|
1493
|
+
// cuando el forge borra la rama al mergear (auto-delete on merge, que es lo normal), y
|
|
1494
|
+
// dai terminaba intentando borrar algo que ya no estaba y reportando un ⚠ por un estado
|
|
1495
|
+
// que en realidad era el correcto. Avisar de un problema inexistente enseña a ignorar los avisos.
|
|
1496
|
+
const hasRemote = Boolean(safeGit(["ls-remote", "--heads", "origin", relBranch])?.trim());
|
|
1497
|
+
const willCleanup = (hasLocal || hasRemote) && !opts.keepBranch;
|
|
1498
|
+
process.stdout.write(` limpieza: ${willCleanup
|
|
1499
|
+
? `borrar ${relBranch}${hasLocal ? " (local)" : ""}${hasLocal && hasRemote ? " +" : ""}${hasRemote ? " (remota)" : ""}`
|
|
1500
|
+
: C.dim(hasLocal || hasRemote ? "no (--keep-branch)" : "nada que borrar")}\n`);
|
|
1501
|
+
process.stdout.write(` aviso: ${willNotify ? describeTarget(cfg) : C.dim(cfg ? "no (--no-notify)" : "no (DAI_NOTIFY no declarado)")}\n`);
|
|
1498
1502
|
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
1499
1503
|
if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
|
|
1500
1504
|
if (!opts.yes) {
|
|
@@ -1552,33 +1556,40 @@ async function cmdReleaseDone(versionArg, opts = {}) {
|
|
|
1552
1556
|
// no pasa acá, se acumulan — y una rama de release que sobrevive a su release es un fork.
|
|
1553
1557
|
// Misma disciplina que `dai done`: `git branch -d` (minúscula) se niega si no está
|
|
1554
1558
|
// mergeada, así que la red de seguridad la pone git, no una suposición nuestra.
|
|
1555
|
-
if (
|
|
1556
|
-
if (
|
|
1559
|
+
if (willCleanup) {
|
|
1560
|
+
if (hasLocal) {
|
|
1557
1561
|
try { git(["branch", "-d", relBranch]); ok(`borrada la branch local ${relBranch}`); }
|
|
1558
1562
|
catch (e) {
|
|
1559
1563
|
warn(`no borré '${relBranch}' local: ${String(e.message).split("\n")[0]}`);
|
|
1560
1564
|
process.stdout.write(` git se niega a borrar una branch sin mergear. Revisala: quizá tiene commits que no llegaron a '${prod}'.\n`);
|
|
1561
1565
|
}
|
|
1562
1566
|
}
|
|
1563
|
-
if (
|
|
1567
|
+
if (hasRemote) {
|
|
1564
1568
|
try {
|
|
1565
1569
|
git(["push", "origin", "--delete", relBranch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
|
|
1566
1570
|
ok(`borrada la branch remota origin/${relBranch}`);
|
|
1567
1571
|
} catch (e) {
|
|
1568
1572
|
warn(`no borré 'origin/${relBranch}': ${String(e.message).split("\n")[0]}`);
|
|
1569
|
-
process.stdout.write(`
|
|
1573
|
+
process.stdout.write(` Borrala a mano: git push origin --delete ${relBranch}\n`);
|
|
1574
|
+
}
|
|
1575
|
+
} else if (hasLocal) {
|
|
1576
|
+
// La ref local puede seguir apuntando a una rama que el forge ya borró. Se limpia
|
|
1577
|
+
// para que el próximo `git branch -r` no muestre un fantasma.
|
|
1578
|
+
if (safeGit(["rev-parse", "--verify", "--quiet", `origin/${relBranch}`])) {
|
|
1579
|
+
safeGit(["branch", "-dr", `origin/${relBranch}`]);
|
|
1580
|
+
info(`la branch remota ya no estaba (la borró el forge al mergear); limpié la referencia local`);
|
|
1570
1581
|
}
|
|
1571
1582
|
}
|
|
1572
1583
|
}
|
|
1573
1584
|
|
|
1574
1585
|
// 5. El aviso. Lo último a propósito: anunciar algo que después falla es peor que no anunciar.
|
|
1575
|
-
if (
|
|
1586
|
+
if (willNotify) {
|
|
1576
1587
|
const ev = {
|
|
1577
1588
|
event: "released", app: releaseApp(opts), version, environment: null,
|
|
1578
1589
|
author: gitUser(), date: formatFecha(), url: releaseUrl,
|
|
1579
1590
|
stories: m.stories.map((s) => ({ id: s.id, title: s.title })),
|
|
1580
1591
|
};
|
|
1581
|
-
await
|
|
1592
|
+
await confirmAndNotify(cfg, ev, opts);
|
|
1582
1593
|
}
|
|
1583
1594
|
process.stdout.write("\n");
|
|
1584
1595
|
ok(`versión ${version} cerrada.`);
|
|
@@ -1609,11 +1620,11 @@ async function cmdReleaseStamp(versionArg, opts = {}) {
|
|
|
1609
1620
|
|
|
1610
1621
|
// El manifiesto de ESA versión: lo que entró entre el tag anterior y este. Se pide por
|
|
1611
1622
|
// tag, no por rama, porque estampar es contar qué se desplegó — y lo desplegado es el tag.
|
|
1612
|
-
const
|
|
1623
|
+
const previousTag = safeGit(["describe", "--tags", "--abbrev=0", `${tag}^`])?.trim() || null;
|
|
1613
1624
|
if (!safeGit(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`])) {
|
|
1614
1625
|
fail(`no existe el tag ${tag} en este repo.\n Cerrá la versión primero: dai release done ${version}\n (o traé los tags: git fetch --tags)`, 1);
|
|
1615
1626
|
}
|
|
1616
|
-
const { manifest: m } = await releaseManifest(opts, { from:
|
|
1627
|
+
const { manifest: m } = await releaseManifest(opts, { from: previousTag, to: tag });
|
|
1617
1628
|
|
|
1618
1629
|
// ¿Cuáles ya están estampadas? Sin poder leer los comentarios dai NO afirma que no las
|
|
1619
1630
|
// estampó: lo dice, y quien decide es la persona. Afirmar de más acá se paga en duplicados.
|
|
@@ -1631,42 +1642,42 @@ async function cmdReleaseStamp(versionArg, opts = {}) {
|
|
|
1631
1642
|
const plan = stampPlan({ stories: m.stories, stamped, unknown });
|
|
1632
1643
|
process.stdout.write("\n" + renderStampPlan(plan, { app, version, environment, tracker: adapter.kind }) + "\n");
|
|
1633
1644
|
warn(stampWarning(plan));
|
|
1634
|
-
if (plan.
|
|
1645
|
+
if (plan.pending.length === 0) { info("Nada que hacer."); return; }
|
|
1635
1646
|
|
|
1636
1647
|
if (opts.dryRun) { info("[dry-run] no se escribió nada."); return; }
|
|
1637
1648
|
if (!opts.yes) {
|
|
1638
1649
|
if (!process.stdin.isTTY) {
|
|
1639
|
-
info(`Sin TTY y sin --yes: no estampo. Re-ejecutá con --yes si querés los ${plan.
|
|
1650
|
+
info(`Sin TTY y sin --yes: no estampo. Re-ejecutá con --yes si querés los ${plan.pending.length} comentarios.`);
|
|
1640
1651
|
return;
|
|
1641
1652
|
}
|
|
1642
|
-
const a = (await askOrCancel(` ¿Estampo ${plan.
|
|
1653
|
+
const a = (await askOrCancel(` ¿Estampo ${plan.pending.length} comentario(s)? (s/N) `) || "").toLowerCase();
|
|
1643
1654
|
if (!["s", "si", "sí", "y", "yes"].includes(a)) {
|
|
1644
1655
|
info("No se estampó nada — la release está hecha igual.");
|
|
1645
|
-
info(
|
|
1656
|
+
info(NOT_STAMPED_NOTE);
|
|
1646
1657
|
return; // salida 0: decir que no es una respuesta válida, no un error
|
|
1647
1658
|
}
|
|
1648
1659
|
}
|
|
1649
1660
|
|
|
1650
|
-
const
|
|
1661
|
+
const body = renderReleaseStamp({
|
|
1651
1662
|
app, version, environment, date: formatFecha(), commit: safeGit(["rev-list", "-n", "1", tag])?.trim(),
|
|
1652
1663
|
url: textOpt(opts, "url") || null,
|
|
1653
1664
|
});
|
|
1654
|
-
let
|
|
1655
|
-
const
|
|
1656
|
-
for (const s of plan.
|
|
1657
|
-
try { await adapter.comment(s.id,
|
|
1658
|
-
catch (e) {
|
|
1659
|
-
}
|
|
1660
|
-
if (
|
|
1661
|
-
warn(`quedaron ${
|
|
1665
|
+
let stampedCount = 0;
|
|
1666
|
+
const failed = [];
|
|
1667
|
+
for (const s of plan.pending) {
|
|
1668
|
+
try { await adapter.comment(s.id, body); stampedCount++; ok(`${s.id} estampada`); }
|
|
1669
|
+
catch (e) { failed.push(s.id); warn(`${s.id}: ${String(e.message).split("\n")[0]}`); }
|
|
1670
|
+
}
|
|
1671
|
+
if (failed.length) {
|
|
1672
|
+
warn(`quedaron ${failed.length} sin estampar: ${failed.join(", ")}. Volvé a correr el comando — las ya hechas se saltean.`);
|
|
1662
1673
|
process.exitCode = 1;
|
|
1663
|
-
} else ok(`${
|
|
1674
|
+
} else ok(`${stampedCount} US estampada(s) con ${app} ${tag} → ${environment.toUpperCase()}.`);
|
|
1664
1675
|
|
|
1665
1676
|
// El aviso al canal es del EVENTO de despliegue, no del estampado: sale aunque no se
|
|
1666
1677
|
// haya estampado nada (para eso está `--only-notify`), y no sale si el repo no lo declaró.
|
|
1667
1678
|
const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
|
|
1668
1679
|
if (cfg && !opts.noNotify) {
|
|
1669
|
-
await
|
|
1680
|
+
await confirmAndNotify(cfg, {
|
|
1670
1681
|
event: "deployed", app, version, environment, author: gitUser(), date: formatFecha(),
|
|
1671
1682
|
url: textOpt(opts, "url") || null, stories: m.stories.map((x) => ({ id: x.id, title: x.title })),
|
|
1672
1683
|
}, opts);
|
|
@@ -1703,21 +1714,21 @@ async function cmdReleaseStatus(opts = {}) {
|
|
|
1703
1714
|
|
|
1704
1715
|
// Back-merge pendiente: producción adelante de integración es el olvido clásico.
|
|
1705
1716
|
if (prod && flow.dev) {
|
|
1706
|
-
const
|
|
1707
|
-
if (
|
|
1708
|
-
warn(`'${prod}' está ${
|
|
1717
|
+
const behind = safeGit(["rev-list", "--count", `${flow.dev}..${prod}`])?.trim();
|
|
1718
|
+
if (behind && Number(behind) > 0) {
|
|
1719
|
+
warn(`'${prod}' está ${behind} commit(s) adelante de '${flow.dev}': falta el back-merge.`);
|
|
1709
1720
|
process.stdout.write(` git checkout ${flow.dev} && git merge ${prod} && git push origin ${flow.dev}\n`);
|
|
1710
1721
|
}
|
|
1711
1722
|
}
|
|
1712
1723
|
|
|
1713
1724
|
// Branches de release abiertas (model B): una que sobrevive a su release es un fork.
|
|
1714
|
-
const
|
|
1725
|
+
const openBranches = (safeGit(["branch", "--list", "release/*", "--format=%(refname:short)"]) || "")
|
|
1715
1726
|
.split("\n").map((x) => x.trim()).filter(Boolean);
|
|
1716
|
-
if (
|
|
1717
|
-
warn(`${
|
|
1727
|
+
if (openBranches.length) {
|
|
1728
|
+
warn(`${openBranches.length} branch(es) de release sin borrar: ${openBranches.join(", ")}`);
|
|
1718
1729
|
process.stdout.write(` Una rama de release que sobrevive a su release es un fork. Desde 0.15.0 las borra\n`);
|
|
1719
1730
|
process.stdout.write(` \`dai release done\`; las de antes se limpian a mano (git se niega si alguna no está mergeada):\n`);
|
|
1720
|
-
process.stdout.write(` git branch -d ${
|
|
1731
|
+
process.stdout.write(` git branch -d ${openBranches.join(" ")}\n`);
|
|
1721
1732
|
}
|
|
1722
1733
|
|
|
1723
1734
|
const cfg = (() => { try { return notifyConfig(process.env); } catch { return null; } })();
|
|
@@ -1730,8 +1741,8 @@ async function cmdReleaseStatus(opts = {}) {
|
|
|
1730
1741
|
try {
|
|
1731
1742
|
const out = execFileSync(tool, ["release", "list", ...(tool === "gh" ? ["--limit", "3"] : ["--per-page", "3"])],
|
|
1732
1743
|
{ encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
1733
|
-
const
|
|
1734
|
-
if (
|
|
1744
|
+
const outLines = String(out).split("\n").filter(Boolean).slice(0, 3);
|
|
1745
|
+
if (outLines.length) { info("últimos releases publicados:"); for (const l of outLines) process.stdout.write(` ${l}\n`); }
|
|
1735
1746
|
} catch { /* sin binario, sin auth o sin releases: no es un problema que resolver acá */ }
|
|
1736
1747
|
}
|
|
1737
1748
|
}
|
|
@@ -1745,7 +1756,7 @@ async function cmdReleaseNotify(opts = {}) {
|
|
|
1745
1756
|
try { cfg = notifyConfig(process.env); } catch (e) { fail(String(e.message), 1); }
|
|
1746
1757
|
if (!cfg) fail("no hay canal declarado: poné DAI_NOTIFY (y DAI_NOTIFY_WEBHOOK) en el .env.dai.", 1);
|
|
1747
1758
|
if (!opts.test) fail("por ahora `dai release notify` solo sabe probar el canal: dai release notify --test", 2);
|
|
1748
|
-
await
|
|
1759
|
+
await confirmAndNotify(cfg, { event: "test" }, opts);
|
|
1749
1760
|
}
|
|
1750
1761
|
|
|
1751
1762
|
// El nombre de la app en los avisos y en el stamp. Sale del repo, no de una variable
|
|
@@ -1756,7 +1767,7 @@ function releaseApp(opts) {
|
|
|
1756
1767
|
|
|
1757
1768
|
// Mostrar el mensaje EXACTO que va a salir y pedir confirmación. Un mensaje a un canal de
|
|
1758
1769
|
// equipo no se desmanda: se muestra antes, siempre, aunque el aviso sea de una sola línea.
|
|
1759
|
-
async function
|
|
1770
|
+
async function confirmAndNotify(cfg, ev, opts = {}) {
|
|
1760
1771
|
const msg = renderNotice(ev);
|
|
1761
1772
|
process.stdout.write(`\n ── Aviso a ${describeTarget(cfg)} ──\n`);
|
|
1762
1773
|
process.stdout.write(msg.split("\n").map((l) => ` │ ${l}`).join("\n") + "\n");
|
|
@@ -2169,14 +2180,14 @@ function cmdDocs(dest) {
|
|
|
2169
2180
|
// repo de nadie: las capturas de los tutoriales ni siquiera viajan en el paquete npm
|
|
2170
2181
|
// (issue #37). Se saltea, y los links que las nombran se absolutizan contra el sitio.
|
|
2171
2182
|
cpSync(join(ROOT, "docs"), dest, { recursive: true, filter: (src) => !/[/\\]public([/\\]|$)/.test(src) });
|
|
2172
|
-
let
|
|
2183
|
+
let rewritten = 0;
|
|
2173
2184
|
for (const f of walkMd(dest)) {
|
|
2174
2185
|
const md = readFileSync(f, "utf8");
|
|
2175
2186
|
const out = absolutizeSiteLinks(md);
|
|
2176
|
-
if (out !== md) { writeFileSync(f, out);
|
|
2187
|
+
if (out !== md) { writeFileSync(f, out); rewritten++; }
|
|
2177
2188
|
}
|
|
2178
2189
|
ok(`documentación copiada a ${dest}`);
|
|
2179
|
-
if (
|
|
2190
|
+
if (rewritten) info(`${rewritten} documento(s) con capturas: los links apuntan al sitio publicado.`);
|
|
2180
2191
|
}
|
|
2181
2192
|
|
|
2182
2193
|
// Los .md de un árbol, para la reescritura de links de cmdDocs.
|
|
@@ -2437,13 +2448,13 @@ function cmdDoctor() {
|
|
|
2437
2448
|
if (localActive.length && existsSync(join(cwd, "openspec"))) {
|
|
2438
2449
|
info("OpenSpec — los comandos van en el chat del asistente, no en la terminal:");
|
|
2439
2450
|
for (const a of localActive) {
|
|
2440
|
-
const
|
|
2441
|
-
if (
|
|
2451
|
+
const missing = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
|
|
2452
|
+
if (missing.length === OPSX_IDS.length) {
|
|
2442
2453
|
warn(`${a.label}: no hay comandos opsx. Generalos: openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force`);
|
|
2443
2454
|
} else {
|
|
2444
2455
|
const hay = OPSX_IDS.filter((id) => existsSync(opsxPath(a.kind, id)));
|
|
2445
2456
|
ok(`${a.label}: ${hay.map((id) => opsxCommand(a.kind, id)).join(" · ")}`);
|
|
2446
|
-
if (
|
|
2457
|
+
if (missing.length) warn(`${a.label}: faltan ${missing.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
|
|
2447
2458
|
}
|
|
2448
2459
|
}
|
|
2449
2460
|
process.stdout.write(" (el nombre sale del archivo que genera OpenSpec: solo Claude usa los dos puntos)\n");
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -169,7 +169,7 @@ export function envFor(pm) {
|
|
|
169
169
|
// Flujo de branches: sin esto, `dai pr` tiene que adivinar la base, y en un repo con
|
|
170
170
|
// ramas de ambiente adivinar significa proponer un merge a producción (issue #46).
|
|
171
171
|
// Van vacías a propósito: vacío = "no declarada", y dai cae a la default del remoto.
|
|
172
|
-
const
|
|
172
|
+
const flowBlock =
|
|
173
173
|
"\n# ── Flujo de branches (dai pr · dai done) ─────────────────────────────────\n" +
|
|
174
174
|
"# Las DOS ramas de vida larga del repo. La base de una PR sale del TIPO de branch:\n" +
|
|
175
175
|
"# feature/ · fix/ → PR contra DAI_BRANCH_DEV\n" +
|
|
@@ -178,7 +178,7 @@ export function envFor(pm) {
|
|
|
178
178
|
"DAI_BRANCH_DEV=\n" +
|
|
179
179
|
"DAI_BRANCH_PROD=\n";
|
|
180
180
|
if (pm === "clickup") {
|
|
181
|
-
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n" +
|
|
181
|
+
return head + "DAI_PM=clickup\nDAI_CLICKUP_TOKEN=\nDAI_CLICKUP_LIST_ID=\n" + flowBlock;
|
|
182
182
|
}
|
|
183
183
|
if (pm === "jira") {
|
|
184
184
|
return head +
|
|
@@ -192,9 +192,9 @@ export function envFor(pm) {
|
|
|
192
192
|
"# Solo si tu Jira exige campos propios AL CREAR una US (lo usa grill-user-story,\n" +
|
|
193
193
|
"# no hace falta para leerlas). El default ya es .dai/jira-fields.json; descomentá\n" +
|
|
194
194
|
"# solo para apuntar a otra ruta. Si el archivo no existe, se ignora.\n" +
|
|
195
|
-
"# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" +
|
|
195
|
+
"# DAI_JIRA_FIELDS_FILE=.dai/jira-fields.json\n" + flowBlock;
|
|
196
196
|
}
|
|
197
|
-
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n" +
|
|
197
|
+
return head + "DAI_PM=md\nDAI_MD_US_DIR=.dai/us\n" + flowBlock;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// ── Helpers aditivos para `dai init` — no destruir la config de un repo vivo ──
|
package/cli/lib/branch-flow.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function branchFlow(env = {}) {
|
|
|
29
29
|
|
|
30
30
|
// Los tipos de branch que van contra producción: una release que se corta y un hotfix
|
|
31
31
|
// que sale del tag que está en PRO. El resto integra.
|
|
32
|
-
const
|
|
32
|
+
const PROD_BOUND_TYPES = new Set(["release", "hotfix"]);
|
|
33
33
|
|
|
34
34
|
// Resuelve la base de una PR y —tan importante como el valor— POR QUÉ es esa.
|
|
35
35
|
// --base > el mapa de ramas según el tipo de branch > rama default del remoto > main
|
|
@@ -38,9 +38,9 @@ export function resolveBase({ flag, branch = null, env = {}, originHead = null }
|
|
|
38
38
|
if (explicit) return { base: explicit, source: "--base", reason: null };
|
|
39
39
|
|
|
40
40
|
const flow = branchFlow(env);
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${
|
|
41
|
+
const branchKind = branchType(branch);
|
|
42
|
+
if (PROD_BOUND_TYPES.has(branchKind) && flow.prod) {
|
|
43
|
+
return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${branchKind}/` };
|
|
44
44
|
}
|
|
45
45
|
if (flow.dev) {
|
|
46
46
|
return { base: flow.dev, source: "DAI_BRANCH_DEV (.env.dai)", reason: null };
|
|
@@ -61,12 +61,12 @@ export function isProdBranch(base, env = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Las fuentes que YA son una decisión de alguien: no hay nada que avisar.
|
|
64
|
-
const
|
|
64
|
+
const DECIDED_SOURCES = new Set(["--base", "DAI_BRANCH_DEV (.env.dai)", "DAI_BRANCH_PROD (.env.dai)", "lo respondiste vos"]);
|
|
65
65
|
|
|
66
66
|
// El aviso que va debajo del preview cuando la base salió de un default. Desaparece en
|
|
67
67
|
// cuanto el repo declara su mapa de ramas, que es justo lo que se le pide.
|
|
68
68
|
export function baseHint(source, base) {
|
|
69
|
-
if (
|
|
69
|
+
if (DECIDED_SOURCES.has(source)) return null;
|
|
70
70
|
return `la base '${base}' salió de ${source} — dai no sabe cuáles son las ramas de vida larga de este repo. Declaralas una vez en el .env.dai:\n` +
|
|
71
71
|
` DAI_BRANCH_DEV=<rama-que-integra> · DAI_BRANCH_PROD=<rama-que-despliega-a-PRO>\n` +
|
|
72
72
|
` Con eso: feature/ y fix/ van contra DEV; release/ y hotfix/ contra PROD (con confirmación).`;
|
package/cli/lib/notify.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { daiFetch } from "./http.mjs";
|
|
27
27
|
|
|
28
28
|
// El envelope de cada canal. Esto es el adaptador entero.
|
|
29
|
-
const
|
|
29
|
+
const CHANNELS = {
|
|
30
30
|
discord: (msg) => ({ content: msg }),
|
|
31
31
|
slack: (msg) => ({ text: msg }),
|
|
32
32
|
webex: (msg) => ({ markdown: msg }),
|
|
@@ -37,7 +37,7 @@ const CANALES = {
|
|
|
37
37
|
webhook: (msg, cfg, ev) => ({ ...eventFields(ev), text: msg }),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
export const
|
|
40
|
+
export const VALID_CHANNELS = Object.keys(CHANNELS);
|
|
41
41
|
|
|
42
42
|
// ── Config ───────────────────────────────────────────────────────────────────
|
|
43
43
|
// Devuelve null cuando el repo no declaró canal: no avisar es el default, y un default
|
|
@@ -45,9 +45,9 @@ export const CANALES_VALIDOS = Object.keys(CANALES);
|
|
|
45
45
|
export function notifyConfig(env = {}) {
|
|
46
46
|
const channel = String(env.DAI_NOTIFY ?? "").trim().toLowerCase();
|
|
47
47
|
if (channel === "" || channel === "none") return null;
|
|
48
|
-
if (!
|
|
48
|
+
if (!CHANNELS[channel]) {
|
|
49
49
|
throw new Error(
|
|
50
|
-
`DAI_NOTIFY='${channel}' no es un canal que dai conozca (${
|
|
50
|
+
`DAI_NOTIFY='${channel}' no es un canal que dai conozca (${VALID_CHANNELS.join(" | ")} | none).\n` +
|
|
51
51
|
` Para un destino propio —Teams, Mattermost, un sistema interno— usá 'webhook':\n` +
|
|
52
52
|
` manda los campos del release en JSON y tu endpoint hace lo que quiera con ellos.`,
|
|
53
53
|
);
|
|
@@ -121,10 +121,10 @@ function eventFields(ev = {}) {
|
|
|
121
121
|
// diferencia entre el QUÉ y el CÓMO que sostiene todo el método.
|
|
122
122
|
const MAX_BULLETS = 8;
|
|
123
123
|
|
|
124
|
-
const
|
|
124
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
125
125
|
export function formatFecha(d = new Date()) {
|
|
126
|
-
return `${
|
|
127
|
-
`${
|
|
126
|
+
return `${pad2(d.getDate())}/${pad2(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
|
127
|
+
`${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
export function renderNotice(ev = {}) {
|
|
@@ -161,7 +161,7 @@ export function renderNotice(ev = {}) {
|
|
|
161
161
|
|
|
162
162
|
// El cuerpo que se le manda al canal.
|
|
163
163
|
export function payloadFor(cfg, ev) {
|
|
164
|
-
const envelope =
|
|
164
|
+
const envelope = CHANNELS[cfg.channel];
|
|
165
165
|
if (!envelope) throw new Error(`canal desconocido: ${cfg.channel}`);
|
|
166
166
|
return envelope(renderNotice(ev), cfg, ev);
|
|
167
167
|
}
|
|
@@ -190,16 +190,16 @@ export async function sendNotice(cfg, ev) {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
export function explainNotifyError(cfg, status, body = "") {
|
|
193
|
-
const
|
|
193
|
+
const target = describeTarget(cfg);
|
|
194
194
|
if (status === 401 || status === 403) {
|
|
195
|
-
return `${
|
|
195
|
+
return `${target} rechazó el aviso (${status}): el endpoint existe pero no autoriza.\n` +
|
|
196
196
|
" Revisá DAI_NOTIFY_WEBHOOK en el .env.dai — puede estar revocado o ser de otro espacio.";
|
|
197
197
|
}
|
|
198
198
|
if (status === 404) {
|
|
199
|
-
return `${
|
|
199
|
+
return `${target} no existe (404). El webhook fue borrado, o la URL está mal copiada.`;
|
|
200
200
|
}
|
|
201
201
|
if (status === 429) {
|
|
202
|
-
return `${
|
|
202
|
+
return `${target} te frenó por rate limit (429). El aviso no salió; el release sí está hecho.`;
|
|
203
203
|
}
|
|
204
|
-
return `${
|
|
204
|
+
return `${target} respondió ${status}.${body ? `\n ${body}` : ""}`;
|
|
205
205
|
}
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -65,8 +65,8 @@ function mdAdapter(env) {
|
|
|
65
65
|
comment(id, markdown) {
|
|
66
66
|
const p = join(dir, `${id}.deploys.md`);
|
|
67
67
|
mkdirSync(dirname(p), { recursive: true });
|
|
68
|
-
const
|
|
69
|
-
writeFileSync(p, `${
|
|
68
|
+
const previousTag = existsSync(p) ? readFileSync(p, "utf8") : `# Despliegues de ${id}\n`;
|
|
69
|
+
writeFileSync(p, `${previousTag.replace(/\s*$/, "")}\n\n${markdown}\n`);
|
|
70
70
|
return p;
|
|
71
71
|
},
|
|
72
72
|
listComments(id) {
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -43,6 +43,12 @@ export function jiraAuthHeaders(env) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// ── ADF → markdown (para leer la descripción) ────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// Una celda puede tener varios párrafos, y una fila de markdown no sobrevive un salto de
|
|
48
|
+
// línea en el medio: se aplana a UNA línea y se escapan los `|` que traiga el texto.
|
|
49
|
+
const inlineCell = (cell) => adfToMarkdown(cell).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
50
|
+
const mdRow = (cells) => `| ${cells.join(" | ")} |`;
|
|
51
|
+
|
|
46
52
|
export function adfToMarkdown(node) {
|
|
47
53
|
if (node == null) return "";
|
|
48
54
|
if (Array.isArray(node)) return node.map(adfToMarkdown).join("");
|
|
@@ -53,6 +59,25 @@ export function adfToMarkdown(node) {
|
|
|
53
59
|
case "bulletList":
|
|
54
60
|
case "orderedList": return (node.content || []).map(adfToMarkdown).join("");
|
|
55
61
|
case "listItem": return "- " + (node.content || []).map(adfToMarkdown).join("").trim() + "\n";
|
|
62
|
+
// Jira Cloud tiene tablas de verdad, y el molde de US pone la metadata —`spec_version`
|
|
63
|
+
// incluido— en una. Sin estos casos caían al `default`, que aplana cada celda a su
|
|
64
|
+
// propia línea: `spec_version` quedaba en una y `v1` en la siguiente, y SPEC_VERSION_RE
|
|
65
|
+
// —que no cruza saltos de línea a propósito— no encontraba nada. La US declaraba `v1`
|
|
66
|
+
// en el tracker y dai estampaba `version: pendiente` sin que nada explicara por qué.
|
|
67
|
+
case "table": {
|
|
68
|
+
const rows = (node.content || []).filter((r) => r?.type === "tableRow");
|
|
69
|
+
if (rows.length === 0) return "";
|
|
70
|
+
const out = rows.map((r) => mdRow((r.content || []).map(inlineCell)));
|
|
71
|
+
// El separador va solo si la primera fila es de encabezados: es lo que hace que esto
|
|
72
|
+
// se RENDERICE como tabla donde el markdown importa (el cuerpo de una PR, el .md que
|
|
73
|
+
// baja `dai edit-us`). Para el parseo no cambia nada.
|
|
74
|
+
const head = rows[0].content || [];
|
|
75
|
+
if (head.length && head.every((c) => c?.type === "tableHeader")) {
|
|
76
|
+
out.splice(1, 0, mdRow(head.map(() => "---")));
|
|
77
|
+
}
|
|
78
|
+
return out.join("\n") + "\n";
|
|
79
|
+
}
|
|
80
|
+
case "tableRow": return mdRow((node.content || []).map(inlineCell)) + "\n";
|
|
56
81
|
case "text": return node.text || "";
|
|
57
82
|
case "hardBreak": return "\n";
|
|
58
83
|
default: return (node.content || []).map(adfToMarkdown).join("");
|
|
@@ -60,23 +85,62 @@ export function adfToMarkdown(node) {
|
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
// ── markdown → ADF (para CREAR el issue: la descripción va en ADF) ────────────
|
|
63
|
-
// Parser de bloques: headings, párrafos y
|
|
88
|
+
// Parser de bloques: headings, párrafos, bullets y tablas. Suficiente para el formato de US.
|
|
89
|
+
|
|
90
|
+
// Una fila de markdown: `| a | b |`. El separador (`|---|---|`) marca que la fila de
|
|
91
|
+
// arriba era el encabezado; no es una fila de datos.
|
|
92
|
+
const MD_ROW_RE = /^\s*\|.*\|\s*$/;
|
|
93
|
+
const MD_SEP_RE = /^\s*\|[\s:|-]+\|\s*$/;
|
|
94
|
+
const splitCells = (line) =>
|
|
95
|
+
line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|"));
|
|
96
|
+
|
|
64
97
|
export function markdownToAdf(md) {
|
|
65
|
-
|
|
98
|
+
// El `_` se saca solo cuando hace de énfasis (_así_), no cuando vive DENTRO de una
|
|
99
|
+
// palabra: sacarlo siempre publicaba la fila del molde como `specversion`, un nombre de
|
|
100
|
+
// campo que nadie escribió y que del otro lado hubo que aprender a leer (issue #46).
|
|
101
|
+
const clean = (s) => s.replace(/[*`]+/g, "").replace(/(?<![A-Za-z0-9])_+|_+(?![A-Za-z0-9])/g, "").trim();
|
|
66
102
|
const content = [];
|
|
67
|
-
let para = [], bullets = null;
|
|
103
|
+
let para = [], bullets = null, rows = null;
|
|
68
104
|
const flushPara = () => { if (para.length) { const t = clean(para.join(" ")); if (t) content.push({ type: "paragraph", content: [{ type: "text", text: t }] }); para = []; } };
|
|
69
105
|
const flushBullets = () => { if (bullets) { if (bullets.length) content.push({ type: "bulletList", content: bullets }); bullets = null; } };
|
|
106
|
+
// La metadata de trazabilidad del molde de US es una TABLA, y el comentario de cobertura
|
|
107
|
+
// también. Sin este caso viajaban a Jira como párrafos con pipes adentro: ilegibles, y
|
|
108
|
+
// encima invitaban a rehacerlos como tabla de Jira a mano — que es justo lo que del otro
|
|
109
|
+
// lado dai no sabía leer.
|
|
110
|
+
const flushTable = () => {
|
|
111
|
+
if (!rows) return;
|
|
112
|
+
const data = rows.filter((r) => !r.sep);
|
|
113
|
+
if (data.length) {
|
|
114
|
+
const headed = rows.length > 1 && rows[1].sep;
|
|
115
|
+
const cell = (raw, header) => {
|
|
116
|
+
const text = clean(raw);
|
|
117
|
+
return {
|
|
118
|
+
type: header ? "tableHeader" : "tableCell",
|
|
119
|
+
attrs: {},
|
|
120
|
+
content: [{ type: "paragraph", content: text ? [{ type: "text", text }] : [] }],
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
content.push({
|
|
124
|
+
type: "table",
|
|
125
|
+
attrs: { isNumberColumnEnabled: false, layout: "default" },
|
|
126
|
+
content: data.map((r, i) => ({ type: "tableRow", content: r.cells.map((c) => cell(c, headed && i === 0)) })),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
rows = null;
|
|
130
|
+
};
|
|
70
131
|
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
71
132
|
const line = raw.replace(/\s+$/, "");
|
|
72
133
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
73
134
|
const b = line.match(/^\s*[-*+]\s+(?:\[[ xX]\]\s+)?(.*)$/);
|
|
74
|
-
if (h) { flushPara(); flushBullets(); const t = clean(h[2]); if (t) content.push({ type: "heading", attrs: { level: h[1].length }, content: [{ type: "text", text: t }] }); }
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
else { flushBullets();
|
|
135
|
+
if (h) { flushPara(); flushBullets(); flushTable(); const t = clean(h[2]); if (t) content.push({ type: "heading", attrs: { level: h[1].length }, content: [{ type: "text", text: t }] }); }
|
|
136
|
+
// Una fila se reconoce por abrir Y cerrar con `|`; el separador entra acá también,
|
|
137
|
+
// marcado, porque es lo único que distingue un encabezado de una fila más.
|
|
138
|
+
else if (MD_ROW_RE.test(line)) { flushPara(); flushBullets(); if (!rows) rows = []; rows.push({ sep: MD_SEP_RE.test(line), cells: splitCells(line) }); }
|
|
139
|
+
else if (b) { flushPara(); flushTable(); if (!bullets) bullets = []; const t = clean(b[1]); if (t) bullets.push({ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }] }); }
|
|
140
|
+
else if (line.trim() === "") { flushPara(); flushBullets(); flushTable(); }
|
|
141
|
+
else { flushBullets(); flushTable(); para.push(line.trim()); }
|
|
78
142
|
}
|
|
79
|
-
flushPara(); flushBullets();
|
|
143
|
+
flushPara(); flushBullets(); flushTable();
|
|
80
144
|
if (content.length === 0) content.push({ type: "paragraph", content: [{ type: "text", text: " " }] });
|
|
81
145
|
return { type: "doc", version: 1, content };
|
|
82
146
|
}
|
|
@@ -63,10 +63,10 @@ export function changelogEntry({ version, date, manifest = {}, secciones = ["Agr
|
|
|
63
63
|
// Acá es un AVISO, no un bloqueo: cortar la versión no debe frenarse por la redacción.
|
|
64
64
|
export function changelogGaps(entry) {
|
|
65
65
|
const gaps = [];
|
|
66
|
-
const
|
|
66
|
+
const body = String(entry ?? "").replace(/<!--[\s\S]*?-->/g, "");
|
|
67
67
|
if (String(entry ?? "").includes(CHANGELOG_MARK)) gaps.push("el manifiesto de dai sigue sin repartir");
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
68
|
+
const hasItems = body.split("\n").some((l) => /^\s*[-*]\s+\S/.test(l));
|
|
69
|
+
if (!hasItems) gaps.push("no hay ni un ítem en las secciones");
|
|
70
70
|
return gaps;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -99,9 +99,9 @@ export function changelogSection(text, version) {
|
|
|
99
99
|
const m = s.match(re);
|
|
100
100
|
if (!m) return null;
|
|
101
101
|
const ini = m.index + m[0].length;
|
|
102
|
-
const
|
|
103
|
-
const j =
|
|
104
|
-
return (j === -1 ?
|
|
102
|
+
const rest = s.slice(ini);
|
|
103
|
+
const j = rest.search(/^## \[/m);
|
|
104
|
+
return (j === -1 ? rest : rest.slice(0, j)).replace(/<!--[\s\S]*?-->/g, "").trim() || null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// ── Nombres ──────────────────────────────────────────────────────────────────
|
package/cli/lib/release-plan.mjs
CHANGED
|
@@ -67,20 +67,20 @@ export function mergedBranch(subject) {
|
|
|
67
67
|
// observable. Cualquier derivación automática habría cortado un patch equivocado.
|
|
68
68
|
// Por eso esto devuelve un PISO y su justificación, y quien firma es una persona.
|
|
69
69
|
export function proposeBump(commits = []) {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
const
|
|
73
|
-
return { bump: "major", floor: true, reason: `hay un commit marcado como breaking (${
|
|
70
|
+
const realCommits = commits.filter((c) => !c.merge);
|
|
71
|
+
if (realCommits.some((c) => c.breaking)) {
|
|
72
|
+
const offender = realCommits.find((c) => c.breaking);
|
|
73
|
+
return { bump: "major", floor: true, reason: `hay un commit marcado como breaking (${offender.subject})` };
|
|
74
74
|
}
|
|
75
|
-
if (
|
|
76
|
-
const n =
|
|
75
|
+
if (realCommits.some((c) => c.type === "feat")) {
|
|
76
|
+
const n = realCommits.filter((c) => c.type === "feat").length;
|
|
77
77
|
return { bump: "minor", floor: true, reason: `${n} commit(s) feat: agregan funcionalidad` };
|
|
78
78
|
}
|
|
79
79
|
return {
|
|
80
80
|
bump: "patch",
|
|
81
81
|
floor: true,
|
|
82
|
-
reason:
|
|
83
|
-
? `solo hay ${[...new Set(
|
|
82
|
+
reason: realCommits.length
|
|
83
|
+
? `solo hay ${[...new Set(realCommits.map((c) => c.type || "sin-tipo"))].sort().join(", ")}: ningún feat ni breaking`
|
|
84
84
|
: "no hay commits nuevos",
|
|
85
85
|
};
|
|
86
86
|
}
|
|
@@ -118,7 +118,7 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
|
|
|
118
118
|
|
|
119
119
|
const stories = [...porId.values()].map((r) => {
|
|
120
120
|
const l = live[r.id];
|
|
121
|
-
const
|
|
121
|
+
const storyStatus = unreachable || !l ? (unreachable ? "sin-respuesta" : "sin-us")
|
|
122
122
|
: l.ac_hash === r.ac_hash ? "al-dia" : "atrasado";
|
|
123
123
|
return {
|
|
124
124
|
id: r.id,
|
|
@@ -127,7 +127,7 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
|
|
|
127
127
|
ac_hash: r.ac_hash ?? null,
|
|
128
128
|
spec_version: l?.spec_version ?? null,
|
|
129
129
|
change: r.change ?? null,
|
|
130
|
-
status:
|
|
130
|
+
status: storyStatus,
|
|
131
131
|
};
|
|
132
132
|
}).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
133
133
|
|
|
@@ -136,11 +136,11 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
|
|
|
136
136
|
// trabajo de producto sin link, y esta es la última oportunidad de verlo.
|
|
137
137
|
const ids = new Set(stories.map((s) => String(s.id).toLowerCase()));
|
|
138
138
|
const branches = commits.map((c) => mergedBranch(c.subject)).filter(Boolean);
|
|
139
|
-
const
|
|
139
|
+
const EXEMPT_TYPES = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
|
|
140
140
|
const chores = [], orphans = [];
|
|
141
141
|
for (const b of branches) {
|
|
142
|
-
if (
|
|
143
|
-
if (
|
|
142
|
+
if (namesAnyStory(b, ids)) continue; // ya está contada como US
|
|
143
|
+
if (EXEMPT_TYPES.has(branchType(b))) { chores.push(b); continue; }
|
|
144
144
|
// "Entró trabajo sin link" solo es un hallazgo si el repo trabaja con User Stories. En
|
|
145
145
|
// un repo de tooling —el de dai, sin ir más lejos— NINGUNA branch va a declarar una, y
|
|
146
146
|
// marcarlas todas convierte el aviso en ruido que se aprende a ignorar. La excepción es
|
|
@@ -158,22 +158,22 @@ export function buildManifest({ commits = [], linked = [], live = {}, unreachabl
|
|
|
158
158
|
commits: commits.filter((c) => !c.merge).length,
|
|
159
159
|
merges: commits.filter((c) => c.merge).length,
|
|
160
160
|
stories: stories.length,
|
|
161
|
-
|
|
161
|
+
stale: stories.filter((s) => s.status === "atrasado").length,
|
|
162
162
|
},
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// ¿El nombre de la branch nombra alguna de las US del manifiesto? Comparación en
|
|
167
167
|
// minúsculas: el slug de la branch va en minúscula y el key del tracker en mayúscula.
|
|
168
|
-
function
|
|
168
|
+
function namesAnyStory(branch, ids) {
|
|
169
169
|
const b = String(branch).toLowerCase();
|
|
170
170
|
for (const id of ids) if (b.includes(id)) return true;
|
|
171
171
|
return false;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
175
|
-
const
|
|
176
|
-
const
|
|
175
|
+
const ICON = { "al-dia": "✅", atrasado: "⚠️ ", "sin-us": "❓", "sin-respuesta": "⚠️ " };
|
|
176
|
+
const LABEL = {
|
|
177
177
|
"al-dia": "al día", atrasado: "ATRASADA", "sin-us": "no está en el tracker",
|
|
178
178
|
"sin-respuesta": "no verificada",
|
|
179
179
|
};
|
|
@@ -195,13 +195,13 @@ export function renderManifest(m, ctx = {}) {
|
|
|
195
195
|
const w = Math.max(...m.stories.map((s) => String(s.id).length), 4);
|
|
196
196
|
for (const s of m.stories) {
|
|
197
197
|
const t = s.title ? ` ${s.title}` : "";
|
|
198
|
-
L.push(` ${
|
|
199
|
-
if (s.status !== "al-dia") L.push(` ${" ".repeat(w)} ${
|
|
198
|
+
L.push(` ${ICON[s.status] || " "} ${String(s.id).padEnd(w)} ${s.version || "?"}${t}`);
|
|
199
|
+
if (s.status !== "al-dia") L.push(` ${" ".repeat(w)} ${LABEL[s.status]}`);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
-
if (m.counts.
|
|
202
|
+
if (m.counts.stale > 0) {
|
|
203
203
|
L.push(``);
|
|
204
|
-
L.push(` ⚠ ${m.counts.
|
|
204
|
+
L.push(` ⚠ ${m.counts.stale} US ATRASADA(S): el QUÉ cambió después de implementarlo.`);
|
|
205
205
|
L.push(` Esta release las llevaría sin cubrir el criterio nuevo. Revisalas antes de cortar.`);
|
|
206
206
|
}
|
|
207
207
|
if (m.chores.length) {
|
|
@@ -32,8 +32,8 @@ export function alreadyStamped(comments = [], marker) {
|
|
|
32
32
|
// y el ticket solo necesita saber qué salió, dónde y cuándo.
|
|
33
33
|
export function renderReleaseStamp(ev = {}) {
|
|
34
34
|
const v = `v${String(ev.version ?? "").replace(/^v/, "")}`;
|
|
35
|
-
const
|
|
36
|
-
const L = [`**${ev.app || "app"} ${v}** desplegada en **${
|
|
35
|
+
const envLabel = String(ev.environment ?? "").toUpperCase();
|
|
36
|
+
const L = [`**${ev.app || "app"} ${v}** desplegada en **${envLabel}** — ${ev.date || ""}`.trim(), ""];
|
|
37
37
|
if (ev.commit) L.push(`- commit: \`${String(ev.commit).slice(0, 8)}\``);
|
|
38
38
|
if (ev.url) L.push(`- release: ${ev.url}`);
|
|
39
39
|
L.push("");
|
|
@@ -48,9 +48,9 @@ export function renderReleaseStamp(ev = {}) {
|
|
|
48
48
|
// stories → las US del manifiesto
|
|
49
49
|
// stamped → Set de ids que YA tienen la marca de este (app, versión, ambiente)
|
|
50
50
|
export function stampPlan({ stories = [], stamped = new Set(), unknown = false } = {}) {
|
|
51
|
-
const
|
|
52
|
-
for (const s of stories) (stamped.has(s.id) ?
|
|
53
|
-
return {
|
|
51
|
+
const pending = [], alreadyDone = [];
|
|
52
|
+
for (const s of stories) (stamped.has(s.id) ? alreadyDone : pending).push(s);
|
|
53
|
+
return { pending, alreadyDone, unknown, total: stories.length };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// El aviso de alcance. Es la pantalla que pidió existir: cuántos comentarios, en qué
|
|
@@ -62,8 +62,8 @@ export function renderStampPlan(plan, { app, version, environment, tracker } = {
|
|
|
62
62
|
L.push(` versión: ${v} app: ${app || "?"} ambiente: ${String(environment ?? "").toUpperCase()}`);
|
|
63
63
|
L.push(` tracker: ${tracker || "?"} · ${plan.total} User Storie(s) en el release`);
|
|
64
64
|
L.push(` ─────────────────────────────────────────────────────`);
|
|
65
|
-
for (const s of plan.
|
|
66
|
-
for (const s of plan.
|
|
65
|
+
for (const s of plan.pending) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""}`);
|
|
66
|
+
for (const s of plan.alreadyDone) L.push(` ${s.id}${s.title ? ` ${s.title}` : ""} ← ya estampada, se saltea`);
|
|
67
67
|
if (plan.total === 0) L.push(` (ninguna: el manifiesto de esta versión no declara US)`);
|
|
68
68
|
L.push(` ─────────────────────────────────────────────────────`);
|
|
69
69
|
if (plan.unknown) {
|
|
@@ -75,13 +75,13 @@ export function renderStampPlan(plan, { app, version, environment, tracker } = {
|
|
|
75
75
|
|
|
76
76
|
// La frase que decide. Dice el número REAL de escrituras y que no hay vuelta atrás.
|
|
77
77
|
export function stampWarning(plan) {
|
|
78
|
-
const n = plan.
|
|
78
|
+
const n = plan.pending.length;
|
|
79
79
|
if (n === 0) return "no hay nada para estampar: todas las US ya tienen esta versión en este ambiente.";
|
|
80
|
-
const
|
|
81
|
-
return `esto escribe ${n} comentario(s) en el tracker de todo el equipo${
|
|
80
|
+
const skipped = plan.alreadyDone.length ? ` (${plan.alreadyDone.length} ya estampada(s), se saltean)` : "";
|
|
81
|
+
return `esto escribe ${n} comentario(s) en el tracker de todo el equipo${skipped}. No se deshace.`;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Lo que se pierde al decir que no. Se dice UNA vez, sin insistir: elegir menos ruido es
|
|
85
85
|
// una decisión legítima, no un error a corregir.
|
|
86
|
-
export const
|
|
86
|
+
export const NOT_STAMPED_NOTE =
|
|
87
87
|
"sin estampar, el ticket no va a decir en qué versión salió: el registro queda solo en el release note.";
|
|
@@ -168,7 +168,7 @@ export function renderReviewSummary(r, { kept = [], suppressed = [], rejected =
|
|
|
168
168
|
.filter(({ n }) => n > 0)
|
|
169
169
|
.map(({ s, n }) => `${n} ${SEV[s].emoji} ${SEV[s].label}`);
|
|
170
170
|
|
|
171
|
-
const
|
|
171
|
+
const findings = kept.length
|
|
172
172
|
? `Dejé **${kept.length}** ${kept.length === 1 ? "comentario" : "comentarios"} en línea: ${counts.join(" · ")}.`
|
|
173
173
|
: "Sin comentarios en línea: no encontré nada concreto que marcar.";
|
|
174
174
|
|
|
@@ -181,7 +181,7 @@ export function renderReviewSummary(r, { kept = [], suppressed = [], rejected =
|
|
|
181
181
|
r.summary || null,
|
|
182
182
|
r.summary ? "" : null,
|
|
183
183
|
"### Hallazgos",
|
|
184
|
-
|
|
184
|
+
findings,
|
|
185
185
|
"",
|
|
186
186
|
"### ✅ Lo que está bien",
|
|
187
187
|
bullets(r.good),
|
package/cli/lib/us-format.mjs
CHANGED
|
@@ -93,8 +93,8 @@ export function validateUS(md) {
|
|
|
93
93
|
|
|
94
94
|
for (const c of criteria) {
|
|
95
95
|
if (!isGherkin(c)) {
|
|
96
|
-
const
|
|
97
|
-
warnings.push(`${c.label}: no es Gherkin completo — falta ${
|
|
96
|
+
const missingParts = [!c.dado && "Dado", !c.cuando && "Cuando", !c.entonces && "Entonces"].filter(Boolean).join(" / ");
|
|
97
|
+
warnings.push(`${c.label}: no es Gherkin completo — falta ${missingParts}. Un criterio sin las tres partes es difícil de volver un test.`);
|
|
98
98
|
}
|
|
99
99
|
if (TECNICAS.test(c.text)) {
|
|
100
100
|
warnings.push(`${c.label}: menciona implementación (${c.text.match(TECNICAS)[0]}). El QUÉ describe comportamiento observable; el CÓMO lo decide el dev.`);
|
package/cli/lib/us.mjs
CHANGED
|
@@ -55,17 +55,17 @@ export function renderCoverage(id, r) {
|
|
|
55
55
|
// llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
|
|
56
56
|
export function explainFetchError(err, { kind, endpoint, id } = {}) {
|
|
57
57
|
const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
|
|
58
|
-
const
|
|
59
|
-
const
|
|
58
|
+
const where = [kind, endpoint].filter(Boolean).join(" · ");
|
|
59
|
+
const head = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${where ? ` en ${where}` : ""}: ${raw}`;
|
|
60
60
|
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
|
|
61
|
-
return `${
|
|
61
|
+
return `${head}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
|
|
62
62
|
` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
|
|
63
63
|
` Diagnóstico: dai doctor`;
|
|
64
64
|
}
|
|
65
65
|
if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
|
|
66
|
-
return `${
|
|
66
|
+
return `${head}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
|
|
67
67
|
` Diagnóstico: dai doctor`;
|
|
68
68
|
}
|
|
69
|
-
if (/\b5\d\d\b/.test(raw)) return `${
|
|
70
|
-
return
|
|
69
|
+
if (/\b5\d\d\b/.test(raw)) return `${head}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
|
|
70
|
+
return head;
|
|
71
71
|
}
|
|
@@ -46,6 +46,64 @@ Adoptamos un modelo de **fuente única + traducciones derivadas**, por superfici
|
|
|
46
46
|
core (MANIFIESTO, METODOLOGIA, glosario, EJEMPLO, guías) · (3) CLI i18n + skills · (4) el
|
|
47
47
|
resto (detalle/, ADRs, templates). Se prioriza por alcance, no por completitud.
|
|
48
48
|
|
|
49
|
+
## Estado medido — 2026-09-10
|
|
50
|
+
|
|
51
|
+
El ADR sigue en **propuesto**; esto es el relevamiento que hace falta para poder ejecutarlo,
|
|
52
|
+
tomado al auditar la convención de naming después de la 0.15.0.
|
|
53
|
+
|
|
54
|
+
### Cuánto es el trabajo de la fase 3 (CLI)
|
|
55
|
+
|
|
56
|
+
| Superficie | Volumen |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `ok()` / `info()` / `warn()` / `fail()` en `cli/dai.mjs` | 348 llamadas |
|
|
59
|
+
| `process.stdout.write` con texto en `cli/dai.mjs` | 99 |
|
|
60
|
+
| `cli/lib/help.mjs` (es **todo** texto al usuario) | 524 líneas |
|
|
61
|
+
| `throw new Error(...)` / `fail(...)` en `cli/lib/` | 67 |
|
|
62
|
+
|
|
63
|
+
Del orden de **mil literales**. Es mecánico, pero confirma lo que el ADR ya anticipaba: se
|
|
64
|
+
hace de una sola vez, no a pedazos. El ciclo de release (0.15.0) le sumó volumen: los
|
|
65
|
+
comandos nuevos, su ayuda y el manifiesto son texto al usuario de punta a punta.
|
|
66
|
+
|
|
67
|
+
### Lo que ya está resuelto y no hay que rehacer
|
|
68
|
+
|
|
69
|
+
- **Los identificadores del código están en inglés** (auditado y corregido en la 0.15.x). El
|
|
70
|
+
refactor de i18n toca strings, no nombres.
|
|
71
|
+
- **Las salidas de máquina ya usan claves en inglés**: el manifiesto de `dai release plan
|
|
72
|
+
--json` y el payload del webhook genérico.
|
|
73
|
+
- **El glosario ES→EN de §5 ya existe** y se usó: `atrasado → stale` fue el término que se
|
|
74
|
+
aplicó al renombrar `counts.atrasadas`.
|
|
75
|
+
|
|
76
|
+
### La decisión de contrato que falta tomar, y su costo real
|
|
77
|
+
|
|
78
|
+
`coverageStatus()` devuelve valores **en español** —`"al-dia"`, `"atrasado"`, `"sin-us"`,
|
|
79
|
+
`"sin-respuesta"`— y esos valores **salen por `--json`**: son parte de la API que puede estar
|
|
80
|
+
parseando el CI de alguien.
|
|
81
|
+
|
|
82
|
+
Lo interesante es que el costo interno de cambiarlos es **casi cero**: ya están separados de
|
|
83
|
+
lo que se muestra (`statusLabel()` los traduce a `✅ al día` y compañía), así que el i18n de
|
|
84
|
+
la *presentación* no necesita tocarlos. La única razón para renombrarlos es que una API en
|
|
85
|
+
inglés se lea en inglés.
|
|
86
|
+
|
|
87
|
+
Entonces la decisión es puramente de contrato, y hay tres caminos:
|
|
88
|
+
|
|
89
|
+
1. **Dejarlos.** Son un enum opaco; el consumidor los compara, no los lee. Costo cero,
|
|
90
|
+
inconsistencia visible en cada `--json`.
|
|
91
|
+
2. **Renombrarlos en una major** (`up-to-date`, `stale`, `no-story`, `unreachable`). Limpio,
|
|
92
|
+
pero obliga a una major solo por esto.
|
|
93
|
+
3. **Emitir los dos** por un tiempo (`status` en inglés + `status_es` deprecado), y sacar el
|
|
94
|
+
viejo en la próxima major. Es el camino habitual para no romper, y el que menos duele si
|
|
95
|
+
ya hay alguien parseando.
|
|
96
|
+
|
|
97
|
+
No se decide acá: se decide **junto con** la fase 3, porque hacer dos cambios de la salida
|
|
98
|
+
`--json` en versiones distintas es peor que hacer uno solo.
|
|
99
|
+
|
|
100
|
+
### Recomendación de orden
|
|
101
|
+
|
|
102
|
+
Mantener el orden del ADR y **no empezar por el CLI**. Un CLI traducido con un README en
|
|
103
|
+
español no lo encuentra nadie: lo que abre la herramienta a usuarios anglófonos es la fase 1
|
|
104
|
+
(README + landing), porque es lo que se ve en npm y en GitHub. La fase 3 recién rinde cuando
|
|
105
|
+
ya hay alguien de habla inglesa llegando.
|
|
106
|
+
|
|
49
107
|
## Consecuencias
|
|
50
108
|
|
|
51
109
|
- ✅ Alcance internacional con el README/landing en inglés (fase 1) sin reescribir todo.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforce2055/dai",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
|
|
5
5
|
"repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
|
|
6
6
|
"homepage": "https://dforce2055.github.io/dai/",
|