@dforce2055/dai 0.14.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/.env.dai.example +16 -0
- package/CHANGELOG.md +127 -0
- package/CONTRIBUTING.md +22 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +571 -17
- package/cli/lib/bootstrap.mjs +4 -4
- package/cli/lib/branch-flow.mjs +6 -6
- package/cli/lib/branch-scope.mjs +24 -9
- package/cli/lib/help.mjs +76 -0
- package/cli/lib/notify.mjs +205 -0
- package/cli/lib/pm-adapter.mjs +16 -0
- package/cli/lib/pm-clickup.mjs +17 -0
- package/cli/lib/pm-jira.mjs +92 -8
- package/cli/lib/pr-remote.mjs +14 -0
- package/cli/lib/release-files.mjs +119 -0
- package/cli/lib/release-plan.mjs +221 -0
- package/cli/lib/release-stamp.mjs +87 -0
- 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/docs/adr/0019-ciclo-de-version-y-aviso-de-release.md +190 -0
- package/docs/adr/README.md +1 -0
- package/docs/guias/index.md +3 -0
- package/docs/guias/releases.md +156 -0
- package/docs/tutoriales/ciclo-de-release.md +266 -0
- package/docs/tutoriales/index.md +6 -0
- package/package.json +1 -1
- package/skills/dai-release/SKILL.md +167 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// dai · los archivos que toca cortar una versión: dónde vive el número y qué se escribe
|
|
2
|
+
// en el CHANGELOG. Parte pura; los efectos (escribir, commitear, taguear) viven en dai.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Premisa que vale para todo el módulo: **el tag es la fuente de verdad de la versión; los
|
|
5
|
+
// archivos son espejos opcionales.** Un repo Node tiene package.json, dai tiene además un
|
|
6
|
+
// VERSION, y un repo .NET o un frontend corporativo puede no tener ninguno de los dos y
|
|
7
|
+
// versionar igual. Por eso dai actualiza los espejos que RECONOCE, dice cuáles tocó, y no
|
|
8
|
+
// se planta si no encuentra ninguno.
|
|
9
|
+
|
|
10
|
+
// ── El número, en los archivos que lo espejan ────────────────────────────────
|
|
11
|
+
|
|
12
|
+
// package.json: se cambia SOLO la línea de la versión.
|
|
13
|
+
//
|
|
14
|
+
// Reserializar el JSON (`JSON.stringify(pkg, null, 2)`) parece más limpio y es peor: te
|
|
15
|
+
// reformatea los objetos compactos (`"repository": { ... }` en una línea) a multi-línea y
|
|
16
|
+
// ensucia el diff de la release con ruido que nadie pidió. El diff de un `chore(release)`
|
|
17
|
+
// tiene que ser tres líneas.
|
|
18
|
+
export function bumpPackageJson(text, version) {
|
|
19
|
+
const re = /("version"\s*:\s*")([^"]*)(")/;
|
|
20
|
+
const m = String(text ?? "").match(re);
|
|
21
|
+
if (!m) return { text, changed: false, from: null };
|
|
22
|
+
if (m[2] === version) return { text, changed: false, from: m[2] };
|
|
23
|
+
return { text: text.replace(re, `$1${version}$3`), changed: true, from: m[2] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// VERSION: el archivo entero es el número. Sin salto final, como lo escribe dai.
|
|
27
|
+
export function bumpVersionFile(text, version) {
|
|
28
|
+
const from = String(text ?? "").trim().split("\n")[0] || null;
|
|
29
|
+
if (from === version) return { text, changed: false, from };
|
|
30
|
+
return { text: version, changed: true, from };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── CHANGELOG ────────────────────────────────────────────────────────────────
|
|
34
|
+
// Keep a Changelog. dai escribe el ANDAMIO y el material; la prosa la escribe una persona
|
|
35
|
+
// (o la skill), y esa división no es pereza: dai sabe QUÉ entró, no POR QUÉ importa. Un
|
|
36
|
+
// changelog autogenerado desde los commits es una lista que nadie lee — el de este repo se
|
|
37
|
+
// lee justamente porque cada entrada cuenta qué estaba mal.
|
|
38
|
+
//
|
|
39
|
+
// El material va en un comentario HTML: se ve al editar y desaparece al renderizar, así
|
|
40
|
+
// que si alguien no lo reparte, el archivo publicado no queda con andamio a la vista.
|
|
41
|
+
export const CHANGELOG_MARK = "<!-- dai:manifiesto";
|
|
42
|
+
|
|
43
|
+
export function changelogEntry({ version, date, manifest = {}, secciones = ["Agregado", "Cambiado", "Corregido", "Interno"] }) {
|
|
44
|
+
const L = [`## [${version}] — ${date}`, ""];
|
|
45
|
+
L.push(`${CHANGELOG_MARK} · el material de esta versión. Repartilo abajo y contá el porqué:`);
|
|
46
|
+
L.push(` dai sabe qué entró; por qué importa lo sabés vos.`);
|
|
47
|
+
for (const s of manifest.stories || []) {
|
|
48
|
+
L.push(` ${s.id}${s.title ? ` ${s.title}` : ""}${s.status === "atrasado" ? " ⚠️ ATRASADA" : ""}`);
|
|
49
|
+
}
|
|
50
|
+
for (const b of manifest.chores || []) L.push(` (sin US) ${b}`);
|
|
51
|
+
for (const b of manifest.orphans || []) L.push(` (sin US, sin prefijo exento) ${b}`);
|
|
52
|
+
if (!(manifest.stories || []).length && !(manifest.chores || []).length && !(manifest.orphans || []).length) {
|
|
53
|
+
L.push(` (el manifiesto no encontró US ni branches en el rango)`);
|
|
54
|
+
}
|
|
55
|
+
L.push(`-->`);
|
|
56
|
+
L.push("");
|
|
57
|
+
for (const s of secciones) { L.push(`### ${s}`); L.push(""); }
|
|
58
|
+
return L.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ¿La entrada quedó con el andamio sin repartir? Mismo espíritu que `bodyGaps` en `dai pr`:
|
|
62
|
+
// publicar el molde es peor que no publicar nada, porque parece que alguien lo escribió.
|
|
63
|
+
// Acá es un AVISO, no un bloqueo: cortar la versión no debe frenarse por la redacción.
|
|
64
|
+
export function changelogGaps(entry) {
|
|
65
|
+
const gaps = [];
|
|
66
|
+
const body = String(entry ?? "").replace(/<!--[\s\S]*?-->/g, "");
|
|
67
|
+
if (String(entry ?? "").includes(CHANGELOG_MARK)) gaps.push("el manifiesto de dai sigue sin repartir");
|
|
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
|
+
return gaps;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Inserta la entrada arriba de la primera que ya exista, y agrega el link al pie.
|
|
74
|
+
// Idempotente en lo que importa: si la versión YA está, no la duplica.
|
|
75
|
+
export function insertChangelogEntry(text, entry, { version, repoUrl } = {}) {
|
|
76
|
+
const s = String(text ?? "");
|
|
77
|
+
if (version && new RegExp(`^## \\[${version.replace(/\./g, "\\.")}\\]`, "m").test(s)) {
|
|
78
|
+
return { text: s, changed: false, reason: `el CHANGELOG ya tiene una entrada para ${version}` };
|
|
79
|
+
}
|
|
80
|
+
const i = s.search(/^## \[/m);
|
|
81
|
+
let out = i === -1
|
|
82
|
+
? `${s.replace(/\s*$/, "")}\n\n${entry}\n`
|
|
83
|
+
: `${s.slice(0, i)}${entry}\n${s.slice(i)}`;
|
|
84
|
+
if (version && repoUrl) {
|
|
85
|
+
const link = `[${version}]: ${repoUrl.replace(/\/+$/, "")}/releases/tag/v${version}`;
|
|
86
|
+
if (!out.includes(link)) {
|
|
87
|
+
// Junto a los otros links del pie si los hay; si no, al final.
|
|
88
|
+
const j = out.search(/^\[\d+\.\d+\.\d+\]: /m);
|
|
89
|
+
out = j === -1 ? `${out.replace(/\s*$/, "")}\n\n${link}\n` : `${out.slice(0, j)}${link}\n${out.slice(j)}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { text: out, changed: true, reason: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// El cuerpo de una versión, para reusarlo como release note del forge.
|
|
96
|
+
export function changelogSection(text, version) {
|
|
97
|
+
const s = String(text ?? "");
|
|
98
|
+
const re = new RegExp(`^## \\[${String(version).replace(/\./g, "\\.")}\\][^\\n]*\\n`, "m");
|
|
99
|
+
const m = s.match(re);
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
const ini = m.index + m[0].length;
|
|
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
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Nombres ──────────────────────────────────────────────────────────────────
|
|
108
|
+
export const releaseBranch = (version) => `release/${version}`;
|
|
109
|
+
export const tagName = (version) => `v${String(version).replace(/^v/, "")}`;
|
|
110
|
+
|
|
111
|
+
// Normaliza y valida lo que tipeó quien corta la versión. Un tag mal escrito se arrastra a
|
|
112
|
+
// npm, al CHANGELOG y a todos los avisos, y renombrarlo después no existe.
|
|
113
|
+
export function normalizeVersion(input) {
|
|
114
|
+
const v = String(input ?? "").trim().replace(/^v/i, "");
|
|
115
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-+]+)?$/.test(v)) {
|
|
116
|
+
throw new Error(`'${input}' no es una versión semver (X.Y.Z, opcionalmente -rc.1).`);
|
|
117
|
+
}
|
|
118
|
+
return v;
|
|
119
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// dai · el manifiesto de una release: qué entra entre el último tag y la integración.
|
|
2
|
+
//
|
|
3
|
+
// Es la pieza de la que dependen los otros comandos de `dai release`, y la que contesta la
|
|
4
|
+
// pregunta que a un equipo sin versiones no le contesta nadie: **¿qué User Stories tiene
|
|
5
|
+
// esta versión?** El resto —el tag, el CHANGELOG, el comentario en cada ticket, el aviso al
|
|
6
|
+
// canal— son formas distintas de publicar ESTE dato.
|
|
7
|
+
//
|
|
8
|
+
// Todo acá es puro: entran las salidas de git ya crudas y sale la decisión. Los efectos
|
|
9
|
+
// (correr git, consultar el tracker) viven en dai.mjs.
|
|
10
|
+
|
|
11
|
+
import { parseVersion } from "./semver.mjs";
|
|
12
|
+
import { branchType, trackerKeysIn } from "./branch-scope.mjs";
|
|
13
|
+
|
|
14
|
+
// ── Commits ──────────────────────────────────────────────────────────────────
|
|
15
|
+
// Formato pedido a git: `%H%x1f%s` por línea (sha, US, subject) — el separador es \x1f
|
|
16
|
+
// (unit separator) y no un pipe o un tab porque un subject puede contener cualquiera de
|
|
17
|
+
// esos, y partir mal el log es empezar el manifiesto con datos corridos.
|
|
18
|
+
export function parseCommitLog(raw) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of String(raw ?? "").split("\n")) {
|
|
21
|
+
if (!line.trim()) continue;
|
|
22
|
+
const i = line.indexOf("\x1f");
|
|
23
|
+
if (i === -1) continue;
|
|
24
|
+
const sha = line.slice(0, i).trim();
|
|
25
|
+
const subject = line.slice(i + 1);
|
|
26
|
+
out.push({ sha, subject, ...classifySubject(subject) });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Conventional Commits, con la tolerancia justa: el repo puede tener commits que no lo
|
|
32
|
+
// sigan, y un manifiesto que los ignora miente por omisión. Los que no matchean quedan
|
|
33
|
+
// con type null y se cuentan igual.
|
|
34
|
+
const CC = /^(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<bang>!)?:\s*(?<rest>.+)$/i;
|
|
35
|
+
|
|
36
|
+
export function classifySubject(subject) {
|
|
37
|
+
const s = String(subject ?? "");
|
|
38
|
+
const merge = /^Merge (pull request|branch|remote-tracking)/i.test(s);
|
|
39
|
+
const m = s.match(CC);
|
|
40
|
+
if (!m) return { type: null, scope: null, breaking: false, merge };
|
|
41
|
+
return {
|
|
42
|
+
type: m.groups.type.toLowerCase(),
|
|
43
|
+
scope: m.groups.scope || null,
|
|
44
|
+
breaking: Boolean(m.groups.bang),
|
|
45
|
+
merge,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// La branch que trajo un merge commit, cuando el forge la nombra en el subject:
|
|
50
|
+
// "Merge pull request #47 from dforce2055/fix/pr-base" → "fix/pr-base"
|
|
51
|
+
// "Merge branch 'feature/ABC-1-x' into develop" → "feature/ABC-1-x"
|
|
52
|
+
// Sirve para detectar trabajo que ENTRÓ sin declarar US. Si el equipo hace squash o
|
|
53
|
+
// rebase no hay merge commits y esta señal no existe: por eso es un aviso, no un gate.
|
|
54
|
+
export function mergedBranch(subject) {
|
|
55
|
+
const s = String(subject ?? "");
|
|
56
|
+
const pr = s.match(/^Merge pull request #\d+ from [^/\s]+\/(.+?)\s*$/i);
|
|
57
|
+
if (pr) return pr[1];
|
|
58
|
+
const br = s.match(/^Merge (?:remote-tracking )?branch '([^']+)'/i);
|
|
59
|
+
if (br) return br[1].replace(/^origin\//, "");
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Bump propuesto ───────────────────────────────────────────────────────────
|
|
64
|
+
// PROPUESTO, no decidido. dai mira los tipos de commit, que es lo único que puede leer
|
|
65
|
+
// sin criterio; la regla del repo mira el COMPORTAMIENTO, y esa diferencia no es teórica:
|
|
66
|
+
// la 0.14.0 salió con cuatro commits `fix:` y era minor, porque cambió un default
|
|
67
|
+
// observable. Cualquier derivación automática habría cortado un patch equivocado.
|
|
68
|
+
// Por eso esto devuelve un PISO y su justificación, y quien firma es una persona.
|
|
69
|
+
export function proposeBump(commits = []) {
|
|
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
|
+
}
|
|
75
|
+
if (realCommits.some((c) => c.type === "feat")) {
|
|
76
|
+
const n = realCommits.filter((c) => c.type === "feat").length;
|
|
77
|
+
return { bump: "minor", floor: true, reason: `${n} commit(s) feat: agregan funcionalidad` };
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
bump: "patch",
|
|
81
|
+
floor: true,
|
|
82
|
+
reason: realCommits.length
|
|
83
|
+
? `solo hay ${[...new Set(realCommits.map((c) => c.type || "sin-tipo"))].sort().join(", ")}: ningún feat ni breaking`
|
|
84
|
+
: "no hay commits nuevos",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// El aviso que acompaña SIEMPRE a la propuesta. No es decoración: es la diferencia entre
|
|
89
|
+
// una sugerencia y una automatización que se equivoca en silencio.
|
|
90
|
+
export const BUMP_CAVEAT =
|
|
91
|
+
"propuesta a partir de los TIPOS de commit — la regla del repo mira el COMPORTAMIENTO.\n" +
|
|
92
|
+
" Si algo mueve un default, agrega un flag o cambia lo que ve quien no configura nada,\n" +
|
|
93
|
+
" es minor aunque todo sea `fix:`. Decidilo vos: dai propone, no versiona por su cuenta.";
|
|
94
|
+
|
|
95
|
+
export function nextVersion(current, bump) {
|
|
96
|
+
const v = parseVersion(current);
|
|
97
|
+
if (!v) return null;
|
|
98
|
+
if (bump === "major") return `${v.major + 1}.0.0`;
|
|
99
|
+
if (bump === "minor") return `${v.major}.${v.minor + 1}.0`;
|
|
100
|
+
return `${v.major}.${v.minor}.${v.patch + 1}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Manifiesto ───────────────────────────────────────────────────────────────
|
|
104
|
+
// `linked` son las filas de implements.yaml que aparecieron en el rango (las arma dai.mjs
|
|
105
|
+
// leyendo el árbol de cada commit: el link viaja CON el código, así que no depende de que
|
|
106
|
+
// la branch siga existiendo ni de que el change no se haya archivado).
|
|
107
|
+
// `live` es lo que contestó el tracker por id: { [id]: { title, ac_hash, spec_version } }.
|
|
108
|
+
export function buildManifest({ commits = [], linked = [], live = {}, unreachable = false, repoUsesStories = true } = {}) {
|
|
109
|
+
// Una US puede aparecer en varios commits (se creó el link, después se resincronizó) y
|
|
110
|
+
// en dos paths (el change y su copia archivada). El manifiesto la nombra UNA vez.
|
|
111
|
+
const porId = new Map();
|
|
112
|
+
for (const r of linked) {
|
|
113
|
+
if (!r?.id) continue;
|
|
114
|
+
const prev = porId.get(r.id);
|
|
115
|
+
// Gana la última aparición: es el estado con el que la US entró al release.
|
|
116
|
+
if (!prev || (r.order ?? 0) >= (prev.order ?? 0)) porId.set(r.id, r);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stories = [...porId.values()].map((r) => {
|
|
120
|
+
const l = live[r.id];
|
|
121
|
+
const storyStatus = unreachable || !l ? (unreachable ? "sin-respuesta" : "sin-us")
|
|
122
|
+
: l.ac_hash === r.ac_hash ? "al-dia" : "atrasado";
|
|
123
|
+
return {
|
|
124
|
+
id: r.id,
|
|
125
|
+
title: l?.title ?? null,
|
|
126
|
+
version: r.version ?? null,
|
|
127
|
+
ac_hash: r.ac_hash ?? null,
|
|
128
|
+
spec_version: l?.spec_version ?? null,
|
|
129
|
+
change: r.change ?? null,
|
|
130
|
+
status: storyStatus,
|
|
131
|
+
};
|
|
132
|
+
}).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
133
|
+
|
|
134
|
+
// Las branches que se mergearon en el rango, separadas en las que declaran US y las que
|
|
135
|
+
// están exentas por tipo. Las que NO son ninguna de las dos son el hallazgo: entró
|
|
136
|
+
// trabajo de producto sin link, y esta es la última oportunidad de verlo.
|
|
137
|
+
const ids = new Set(stories.map((s) => String(s.id).toLowerCase()));
|
|
138
|
+
const branches = commits.map((c) => mergedBranch(c.subject)).filter(Boolean);
|
|
139
|
+
const EXEMPT_TYPES = new Set(["chore", "docs", "ci", "build", "test", "refactor", "style", "release", "hotfix", "revert"]);
|
|
140
|
+
const chores = [], orphans = [];
|
|
141
|
+
for (const b of branches) {
|
|
142
|
+
if (namesAnyStory(b, ids)) continue; // ya está contada como US
|
|
143
|
+
if (EXEMPT_TYPES.has(branchType(b))) { chores.push(b); continue; }
|
|
144
|
+
// "Entró trabajo sin link" solo es un hallazgo si el repo trabaja con User Stories. En
|
|
145
|
+
// un repo de tooling —el de dai, sin ir más lejos— NINGUNA branch va a declarar una, y
|
|
146
|
+
// marcarlas todas convierte el aviso en ruido que se aprende a ignorar. La excepción es
|
|
147
|
+
// una branch que NOMBRA un ticket: ahí alguien quiso linkear una US y no lo hizo, y eso
|
|
148
|
+
// vale como hallazgo aunque el repo no declare ninguna.
|
|
149
|
+
if (repoUsesStories || trackerKeysIn(b).length > 0) orphans.push(b);
|
|
150
|
+
else chores.push(b);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
stories,
|
|
155
|
+
chores,
|
|
156
|
+
orphans,
|
|
157
|
+
counts: {
|
|
158
|
+
commits: commits.filter((c) => !c.merge).length,
|
|
159
|
+
merges: commits.filter((c) => c.merge).length,
|
|
160
|
+
stories: stories.length,
|
|
161
|
+
stale: stories.filter((s) => s.status === "atrasado").length,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ¿El nombre de la branch nombra alguna de las US del manifiesto? Comparación en
|
|
167
|
+
// minúsculas: el slug de la branch va en minúscula y el key del tracker en mayúscula.
|
|
168
|
+
function namesAnyStory(branch, ids) {
|
|
169
|
+
const b = String(branch).toLowerCase();
|
|
170
|
+
for (const id of ids) if (b.includes(id)) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
175
|
+
const ICON = { "al-dia": "✅", atrasado: "⚠️ ", "sin-us": "❓", "sin-respuesta": "⚠️ " };
|
|
176
|
+
const LABEL = {
|
|
177
|
+
"al-dia": "al día", atrasado: "ATRASADA", "sin-us": "no está en el tracker",
|
|
178
|
+
"sin-respuesta": "no verificada",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function renderManifest(m, ctx = {}) {
|
|
182
|
+
const { from, to, current, proposed, bump, reason } = ctx;
|
|
183
|
+
const L = [];
|
|
184
|
+
L.push(` ── Release a preparar ────────────────────────────────`);
|
|
185
|
+
L.push(` desde: ${from || "(el principio del repo)"}`);
|
|
186
|
+
L.push(` hasta: ${to}`);
|
|
187
|
+
L.push(` cambios: ${m.counts.commits} commit(s) · ${m.counts.merges} merge(s)`);
|
|
188
|
+
L.push(` versión: ${current} → ${proposed || "(sin propuesta)"} (${bump})`);
|
|
189
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
190
|
+
|
|
191
|
+
if (m.stories.length === 0) {
|
|
192
|
+
L.push(` Ninguna US declarada en este rango.`);
|
|
193
|
+
} else {
|
|
194
|
+
L.push(` User Stories que entran (${m.stories.length}):`);
|
|
195
|
+
const w = Math.max(...m.stories.map((s) => String(s.id).length), 4);
|
|
196
|
+
for (const s of m.stories) {
|
|
197
|
+
const t = s.title ? ` ${s.title}` : "";
|
|
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
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (m.counts.stale > 0) {
|
|
203
|
+
L.push(``);
|
|
204
|
+
L.push(` ⚠ ${m.counts.stale} US ATRASADA(S): el QUÉ cambió después de implementarlo.`);
|
|
205
|
+
L.push(` Esta release las llevaría sin cubrir el criterio nuevo. Revisalas antes de cortar.`);
|
|
206
|
+
}
|
|
207
|
+
if (m.chores.length) {
|
|
208
|
+
L.push(``);
|
|
209
|
+
L.push(` Sin US (${m.chores.length}): ${m.chores.slice(0, 6).join(", ")}${m.chores.length > 6 ? "…" : ""}`);
|
|
210
|
+
}
|
|
211
|
+
if (m.orphans.length) {
|
|
212
|
+
L.push(``);
|
|
213
|
+
L.push(` ⚠ ${m.orphans.length} branch(es) sin US y sin prefijo exento:`);
|
|
214
|
+
for (const b of m.orphans) L.push(` ${b}`);
|
|
215
|
+
L.push(` Entró trabajo que nadie va a poder rastrear a una historia. Es la última`);
|
|
216
|
+
L.push(` oportunidad de verlo antes de que quede adentro de una versión.`);
|
|
217
|
+
}
|
|
218
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
219
|
+
if (reason) L.push(` ${bump}: ${reason}`);
|
|
220
|
+
return L.join("\n");
|
|
221
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// dai · el comentario que le avisa a cada User Story en qué versión y en qué ambiente salió.
|
|
2
|
+
//
|
|
3
|
+
// Es la vuelta completa de la trazabilidad: el `implements.yaml` ata la US al código, el
|
|
4
|
+
// `dai stamp` de cobertura ata la US al commit, y esto ata la US a la VERSIÓN DESPLEGADA.
|
|
5
|
+
// Con eso, el funcional abre el ticket y ve "esto está en producción desde la v1.2.0" sin
|
|
6
|
+
// preguntarle a nadie — que es exactamente lo que un equipo sin versiones no puede contestar.
|
|
7
|
+
//
|
|
8
|
+
// Dos cuidados que mandan sobre el diseño de este módulo:
|
|
9
|
+
//
|
|
10
|
+
// 1. Escribe N veces hacia afuera, en tickets de gente distinta, y no se deshace. Por eso
|
|
11
|
+
// el comando muestra el alcance ANTES (cuántos comentarios, en qué tickets) y pide
|
|
12
|
+
// confirmación. Un susto genérico no sirve: el número tiene que ser el real.
|
|
13
|
+
// 2. Es OPCIONAL. Si alguien no quiere hacer ruido en veinte tickets, la release ya está
|
|
14
|
+
// hecha: el tag existe, el release note existe. Decir que no no puede romper nada.
|
|
15
|
+
|
|
16
|
+
// La marca que hace el comentario reconocible para dai mismo. Sin esto no hay forma de
|
|
17
|
+
// saber si ya estampamos: redesplegar la misma versión llenaría el ticket de comentarios
|
|
18
|
+
// idénticos, y un ticket con cuarenta avisos de deploy no lo lee nadie.
|
|
19
|
+
export function releaseMarker({ app, version, environment }) {
|
|
20
|
+
const v = String(version ?? "").replace(/^v/, "");
|
|
21
|
+
return `[dai:release app=${app || "?"} version=${v} env=${String(environment ?? "").toLowerCase() || "?"}]`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ¿Este comentario ya está puesto? Se compara la marca completa, así que la misma versión
|
|
25
|
+
// en OTRO ambiente (o de otra app) no se confunde con una repetición.
|
|
26
|
+
export function alreadyStamped(comments = [], marker) {
|
|
27
|
+
return comments.some((c) => String(c ?? "").includes(marker));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// El comentario, en markdown. Los backends lo convierten a lo suyo (Jira lo pasa a ADF,
|
|
31
|
+
// ClickUp lo manda como texto). Corto a propósito: el detalle vive en el release note,
|
|
32
|
+
// y el ticket solo necesita saber qué salió, dónde y cuándo.
|
|
33
|
+
export function renderReleaseStamp(ev = {}) {
|
|
34
|
+
const v = `v${String(ev.version ?? "").replace(/^v/, "")}`;
|
|
35
|
+
const envLabel = String(ev.environment ?? "").toUpperCase();
|
|
36
|
+
const L = [`**${ev.app || "app"} ${v}** desplegada en **${envLabel}** — ${ev.date || ""}`.trim(), ""];
|
|
37
|
+
if (ev.commit) L.push(`- commit: \`${String(ev.commit).slice(0, 8)}\``);
|
|
38
|
+
if (ev.url) L.push(`- release: ${ev.url}`);
|
|
39
|
+
L.push("");
|
|
40
|
+
L.push(releaseMarker(ev));
|
|
41
|
+
return L.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// El plan del estampado: a quién le toca, a quién no, y por qué. Se calcula ANTES de
|
|
45
|
+
// escribir nada para poder mostrarlo — mostrar "12 US" cuando en realidad se van a escribir
|
|
46
|
+
// 9 comentarios es la clase de aviso que la gente aprende a ignorar.
|
|
47
|
+
//
|
|
48
|
+
// stories → las US del manifiesto
|
|
49
|
+
// stamped → Set de ids que YA tienen la marca de este (app, versión, ambiente)
|
|
50
|
+
export function stampPlan({ stories = [], stamped = new Set(), unknown = false } = {}) {
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
// El aviso de alcance. Es la pantalla que pidió existir: cuántos comentarios, en qué
|
|
57
|
+
// tickets, y que no se deshace.
|
|
58
|
+
export function renderStampPlan(plan, { app, version, environment, tracker } = {}) {
|
|
59
|
+
const v = `v${String(version ?? "").replace(/^v/, "")}`;
|
|
60
|
+
const L = [];
|
|
61
|
+
L.push(` ── Estampar despliegue ───────────────────────────────`);
|
|
62
|
+
L.push(` versión: ${v} app: ${app || "?"} ambiente: ${String(environment ?? "").toUpperCase()}`);
|
|
63
|
+
L.push(` tracker: ${tracker || "?"} · ${plan.total} User Storie(s) en el release`);
|
|
64
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
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
|
+
if (plan.total === 0) L.push(` (ninguna: el manifiesto de esta versión no declara US)`);
|
|
68
|
+
L.push(` ─────────────────────────────────────────────────────`);
|
|
69
|
+
if (plan.unknown) {
|
|
70
|
+
L.push(` ⚠ No pude leer los comentarios del tracker, así que no sé cuáles ya estampé.`);
|
|
71
|
+
L.push(` Si esta versión ya se estampó en este ambiente, van a salir repetidos.`);
|
|
72
|
+
}
|
|
73
|
+
return L.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// La frase que decide. Dice el número REAL de escrituras y que no hay vuelta atrás.
|
|
77
|
+
export function stampWarning(plan) {
|
|
78
|
+
const n = plan.pending.length;
|
|
79
|
+
if (n === 0) return "no hay nada para estampar: todas las US ya tienen esta versión en este ambiente.";
|
|
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
|
+
}
|
|
83
|
+
|
|
84
|
+
// Lo que se pierde al decir que no. Se dice UNA vez, sin insistir: elegir menos ruido es
|
|
85
|
+
// una decisión legítima, no un error a corregir.
|
|
86
|
+
export const NOT_STAMPED_NOTE =
|
|
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.
|