@dforce2055/dai 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/lib/help.mjs CHANGED
@@ -227,6 +227,74 @@ Ejemplo:
227
227
  dai pr --base develop --description-file notas.md
228
228
  `,
229
229
 
230
+ release: `dai release — el ciclo de versión: qué entra, cortarla, cerrarla, contarla
231
+
232
+ Uso:
233
+ dai release plan [--from <ref>] [--to <rama>] [--json] [--no-network]
234
+ dai release cut <X.Y.Z> [--no-branch] [--yes] [--dry-run]
235
+ dai release done <X.Y.Z> [--app <n>] [--no-release] [--no-notify] [--yes] [--dry-run]
236
+ dai release stamp <X.Y.Z> --env <ambiente> [--app <n>] [--url <u>] [--yes] [--dry-run]
237
+ dai release status [--no-network]
238
+ dai release notify --test
239
+
240
+ El ciclo, con la firma humana en el medio:
241
+
242
+ plan ──▶ cut ──▶ dai pr ──▶ [ merge + publicar: lo firma una persona ]
243
+
244
+
245
+ done ──▶ stamp (opcional)
246
+
247
+ plan — el MANIFIESTO: qué hay entre el último tag y la rama de integración, qué User
248
+ Stories entran y en qué estado, qué branches entraron sin US, y qué bump PROPONE.
249
+ Propone, no decide: dai lee los tipos de commit y la regla del repo mira el
250
+ comportamiento. Algo que mueve un default es minor aunque todo sea \`fix:\`.
251
+
252
+ cut — prepara la versión: crea \`release/X.Y.Z\`, sube el número en los archivos que este
253
+ repo espeja (VERSION, package.json — si no hay ninguno, la versión es el tag), escribe
254
+ la entrada del CHANGELOG con el material para repartir, y commitea. No habla hacia
255
+ afuera: ni push, ni tag, ni PR. La prosa del CHANGELOG la escribís vos: dai sabe qué
256
+ entró, no por qué importa.
257
+ --no-branch taguea desde la rama de integración, sin branch de release
258
+
259
+ done — cierra la versión DESPUÉS del merge, igual que \`dai done\` cierra el trabajo de una
260
+ branch: tag anotado, release note en el forge,
261
+ back-merge a la rama de integración y aviso al canal. Es la mitad que se olvida cuando
262
+ la ceremonia se hace a mano. Cada paso reporta por separado: si falla la release note,
263
+ el tag YA existe y hay que saberlo.
264
+ --app <nombre> con qué nombre aparece la app (default: el del repo)
265
+ --no-release no publica la nota en el forge
266
+ --no-notify no avisa al canal (DAI_NOTIFY)
267
+ --keep-branch conserva la rama release/X.Y.Z (por default la borra: ya está mergeada,
268
+ tagueada y con el back-merge hecho, y una que sobrevive a su release
269
+ es un fork). git se niega a borrar una sin mergear.
270
+
271
+ stamp — le avisa a CADA User Story del release en qué versión y ambiente salió. Es el
272
+ comando que más cuidado necesita: escribe N veces hacia afuera, en tickets de gente
273
+ distinta, y no se deshace. Por eso muestra el alcance REAL antes —cuántos comentarios,
274
+ en qué tickets, cuáles se saltean por estar ya estampados— y pide confirmación.
275
+ Es OPCIONAL: decir que no sale con 0 y no rompe nada, porque la versión ya está hecha.
276
+ Idempotente por (app, versión, ambiente): redesplegar no llena el ticket de repetidos.
277
+ --env <ambiente> obligatorio. El que uses (prod, pre, test, uat-2): dai no tiene catálogo
278
+ --url <u> link al release, para que el comentario lleve a algún lado
279
+
280
+ status — dónde estás en el ciclo: versión declarada vs último tag, cuánto hay sin promover,
281
+ si falta el back-merge, branches de release abiertas y el canal configurado.
282
+ Lo que NO contesta es qué versión hay en cada ambiente: eso es un evento, no un archivo
283
+ del repo, y su registro son los stamps de las US.
284
+
285
+ notify --test — postea un mensaje de prueba al canal. Un webhook no se puede validar sin
286
+ postear, y fingir que sí sería justo lo que dai no hace: avisa antes y pide confirmación.
287
+
288
+ Config del repo (.env.dai):
289
+ DAI_BRANCH_DEV / DAI_BRANCH_PROD las dos ramas de vida larga
290
+ DAI_NOTIFY=discord|slack|webex|telegram|webhook|none · DAI_NOTIFY_WEBHOOK=<endpoint>
291
+
292
+ Ejemplo:
293
+ dai release plan
294
+ dai release cut 1.2.0
295
+ dai release done 1.2.0
296
+ `,
297
+
230
298
  done: `dai done — cierra la US: vuelve a la base, actualiza y borra la branch local
231
299
 
232
300
  Uso:
@@ -411,6 +479,14 @@ export function globalUsage() {
411
479
  " stamp [<ID>…] [--all] estampa la cobertura en el tracker (ADR-0005)\n" +
412
480
  " sin ID: la US de esta branch; si hay varias, pregunta\n" +
413
481
  " done [--base b] [--force] cierra la US: vuelve a la base, actualiza y borra la branch local\n" +
482
+ " release plan [--from r] [--to b] [--json] el manifiesto de la próxima versión: qué US entran,\n" +
483
+ " qué entró sin US, y qué bump propone (propone: firmás vos)\n" +
484
+ " release cut <X.Y.Z> [--no-branch] prepara la versión: branch + bump + entrada del CHANGELOG + commit\n" +
485
+ " release done <X.Y.Z> tras el merge: tag + release note + back-merge + aviso al canal\n" +
486
+ " [--app n] [--no-release] [--no-notify]\n" +
487
+ " release stamp <X.Y.Z> --env <amb> avisa a cada US en qué versión y ambiente salió\n" +
488
+ " (muestra el alcance y confirma · opcional: decir que no no rompe nada)\n" +
489
+ " release status · release notify --test dónde estás en el ciclo · probar el canal\n" +
414
490
  " archive [<change>] [--skip-specs] funde los delta specs del change en las specs canónicas y lo archiva (lo corre el aprobador en la PR)\n" +
415
491
  " pr (alias mr) [--assignee u] [--base b] [--draft] [--yes] crea o ACTUALIZA TU PR/MR precargada (muestra + confirma)\n" +
416
492
  " [--us <ID>] [--title t] la US la resuelve la branch; si hay varias, pregunta (sin TTY, falla)\n" +
@@ -0,0 +1,205 @@
1
+ // dai · aviso de release a un canal de equipo (opcional, opt-in, default apagado).
2
+ //
3
+ // Tercer adaptador del CLI, con la misma anatomía que los dos que ya existen: una variable
4
+ // que elige el backend (DAI_NOTIFY, como DAI_PM) y las variables propias de ese backend.
5
+ //
6
+ // El mensaje es UNO SOLO para todos los canales: con ESTRUCTURA (qué salió, quién, cuándo,
7
+ // qué trae, dónde mirar) pero SIN FORMATO. Esa distinción es la que hace que esto sea una
8
+ // tabla y no un módulo de render:
9
+ //
10
+ // · la estructura son saltos de línea y viñetas, y se ve igual en los cinco canales,
11
+ // · el formato son cuatro dialectos incompatibles (Discord **negrita** sin links con
12
+ // nombre, Slack *negrita* con <url|texto>, Webex markdown completo, Telegram con su
13
+ // parse_mode y el escapeo de MarkdownV2 que devuelve 400 por un punto suelto),
14
+ // · una URL pelada se vuelve clickeable sola en los cuatro,
15
+ // · y lo único que queda distinto entre proveedores es EN QUÉ CAMPO del JSON va el string.
16
+ //
17
+ // Las viñetas se cortan en MAX_BULLETS, así que el mensaje no se acerca al límite de
18
+ // ningún canal (el más chico es Discord, 2000) y no hay nada que recortar a mano.
19
+ //
20
+ // El detalle del release vive en el ticket (dai release stamp) y en el release note. El
21
+ // canal avisa y linkea: un chat no es un registro, a los dos días nadie lo encuentra.
22
+ //
23
+ // Lo que esto NO es: un framework de notificaciones. dai avisa eventos de release con su
24
+ // manifiesto. Mandar mensajes arbitrarios a un canal no es dai.
25
+
26
+ import { daiFetch } from "./http.mjs";
27
+
28
+ // El envelope de cada canal. Esto es el adaptador entero.
29
+ const CANALES = {
30
+ discord: (msg) => ({ content: msg }),
31
+ slack: (msg) => ({ text: msg }),
32
+ webex: (msg) => ({ markdown: msg }),
33
+ telegram: (msg, cfg) => ({ chat_id: cfg.chatId, text: msg }),
34
+ // Genérico: los campos estructurados MÁS el texto ya armado. Una integración propia usa
35
+ // los campos; cualquier endpoint estilo Slack (Mattermost, Rocket.Chat, un webhook de
36
+ // Teams) levanta el `text` sin configurar nada. No cuesta una línea más soportar los dos.
37
+ webhook: (msg, cfg, ev) => ({ ...eventFields(ev), text: msg }),
38
+ };
39
+
40
+ export const CANALES_VALIDOS = Object.keys(CANALES);
41
+
42
+ // ── Config ───────────────────────────────────────────────────────────────────
43
+ // Devuelve null cuando el repo no declaró canal: no avisar es el default, y un default
44
+ // que habla hacia afuera sería una sorpresa desagradable.
45
+ export function notifyConfig(env = {}) {
46
+ const channel = String(env.DAI_NOTIFY ?? "").trim().toLowerCase();
47
+ if (channel === "" || channel === "none") return null;
48
+ if (!CANALES[channel]) {
49
+ throw new Error(
50
+ `DAI_NOTIFY='${channel}' no es un canal que dai conozca (${CANALES_VALIDOS.join(" | ")} | none).\n` +
51
+ ` Para un destino propio —Teams, Mattermost, un sistema interno— usá 'webhook':\n` +
52
+ ` manda los campos del release en JSON y tu endpoint hace lo que quiera con ellos.`,
53
+ );
54
+ }
55
+ const endpoint = String(env.DAI_NOTIFY_WEBHOOK ?? "").trim();
56
+ if (!endpoint) {
57
+ throw new Error(
58
+ `DAI_NOTIFY=${channel} pero falta DAI_NOTIFY_WEBHOOK en el .env.dai (el endpoint del canal).\n` +
59
+ ` Ese endpoint ES la credencial: quien lo tiene, postea. Nunca lo commitees.`,
60
+ );
61
+ }
62
+ const chatId = String(env.DAI_NOTIFY_CHAT_ID ?? "").trim();
63
+ // Telegram es el raro de los cuatro: no tiene webhooks de entrada. Es token de bot +
64
+ // chat_id, y el token vale para el bot entero, no para un canal — por eso hace falta
65
+ // decir a qué chat. Si falta, el POST sale igual y la API contesta un 400 que no lo explica.
66
+ if (channel === "telegram" && !chatId) {
67
+ throw new Error(
68
+ "DAI_NOTIFY=telegram necesita además DAI_NOTIFY_CHAT_ID (a qué chat postear).\n" +
69
+ " Telegram no usa webhooks de entrada: el endpoint es el bot y el chat_id el destino.",
70
+ );
71
+ }
72
+ return { channel, endpoint, chatId: chatId || null };
73
+ }
74
+
75
+ // Qué se le muestra al usuario como destino. NUNCA la URL entera: el endpoint es la
76
+ // credencial, y un webhook filtrado en una captura de pantalla es un canal comprometido.
77
+ export function describeTarget(cfg) {
78
+ if (!cfg) return "sin canal (DAI_NOTIFY no declarado)";
79
+ let host = "(endpoint ilegible)";
80
+ try { host = new URL(cfg.endpoint).host; } catch { /* URL rara: no la mostramos igual */ }
81
+ return `${cfg.channel} · ${host}${cfg.chatId ? ` · chat ${cfg.chatId}` : ""}`;
82
+ }
83
+
84
+ // ── El mensaje ───────────────────────────────────────────────────────────────
85
+ // Un evento de release: qué app, qué versión, a dónde fue, y qué US lleva adentro.
86
+ // event: "released" → se publicó la versión (dai release done)
87
+ // event: "deployed" → esa versión llegó a un ambiente (dai release stamp --env X)
88
+ // event: "test" → prueba de canal (dai release notify --test)
89
+ function eventFields(ev = {}) {
90
+ return {
91
+ event: `release.${ev.event || "released"}`,
92
+ app: ev.app ?? null,
93
+ version: ev.version ?? null,
94
+ environment: ev.environment ?? null,
95
+ url: ev.url ?? null,
96
+ stories: (Array.isArray(ev.stories) ? ev.stories : []).map((s) => (typeof s === "string" ? { id: s, title: null } : { id: s.id, title: s.title ?? null })),
97
+ };
98
+ }
99
+
100
+ // El texto, uno solo para todos los canales. Con ESTRUCTURA (qué salió, quién, cuándo,
101
+ // qué trae, dónde mirar) pero SIN FORMATO: ni negritas ni links con nombre.
102
+ //
103
+ // La diferencia no es estética, es de costo. La estructura son saltos de línea y viñetas,
104
+ // que se ven igual en los cinco canales. El formato son cuatro dialectos incompatibles:
105
+ // Discord usa **negrita** y no soporta links con nombre; Slack usa *negrita* de un
106
+ // asterisco y <url|texto>; Webex quiere markdown completo; Telegram exige parse_mode y
107
+ // escapar media docena de caracteres o devuelve 400. Una URL pelada, en cambio, se vuelve
108
+ // clickeable sola en los cuatro.
109
+ //
110
+ // 🎉 Nuevo release · backend v1.2.0
111
+ // Autor: Ada Lovelace · Fecha: 09/09/2026 08:12 · Ambiente: PRODUCCIÓN
112
+ //
113
+ // Cambios principales:
114
+ // • ACME-482 Checkout sin duplicado
115
+ // • ACME-491 Alta de póliza sin duplicar cliente
116
+ //
117
+ // Ver release: https://…/releases/v1.2.0
118
+ //
119
+ // Las viñetas salen de las US del manifiesto, no de los subjects de los commits: lo que
120
+ // el equipo quiere leer es qué valor salió, no qué archivos se tocaron. Es la misma
121
+ // diferencia entre el QUÉ y el CÓMO que sostiene todo el método.
122
+ const MAX_BULLETS = 8;
123
+
124
+ const dosDigitos = (n) => String(n).padStart(2, "0");
125
+ export function formatFecha(d = new Date()) {
126
+ return `${dosDigitos(d.getDate())}/${dosDigitos(d.getMonth() + 1)}/${d.getFullYear()} ` +
127
+ `${dosDigitos(d.getHours())}:${dosDigitos(d.getMinutes())}`;
128
+ }
129
+
130
+ export function renderNotice(ev = {}) {
131
+ if (ev.event === "test") {
132
+ return "✅ Prueba de canal · dai\nSi ves esto, el canal está bien configurado.";
133
+ }
134
+ const app = ev.app ? `${ev.app} ` : "";
135
+ const version = ev.version ? `v${String(ev.version).replace(/^v/, "")}` : "(sin versión)";
136
+ const L = [];
137
+ L.push(ev.environment
138
+ ? `🚀 Release desplegada · ${app}${version} → ${String(ev.environment).toUpperCase()}`
139
+ : `🎉 Nuevo release · ${app}${version}`);
140
+
141
+ // Segunda línea: quién y cuándo. Sale de git y del reloj — nada que configurar.
142
+ const meta = [];
143
+ if (ev.author) meta.push(`Autor: ${ev.author}`);
144
+ meta.push(`Fecha: ${ev.date || formatFecha()}`);
145
+ L.push(meta.join(" · "));
146
+
147
+ const stories = Array.isArray(ev.stories) ? ev.stories : [];
148
+ if (stories.length) {
149
+ L.push("");
150
+ L.push("Cambios principales:");
151
+ for (const s of stories.slice(0, MAX_BULLETS)) {
152
+ const id = typeof s === "string" ? s : s.id;
153
+ const title = typeof s === "string" ? null : s.title;
154
+ L.push(` • ${id}${title ? ` ${title}` : ""}`);
155
+ }
156
+ if (stories.length > MAX_BULLETS) L.push(` • …y ${stories.length - MAX_BULLETS} más`);
157
+ }
158
+ if (ev.url) { L.push(""); L.push(`Ver release: ${ev.url}`); }
159
+ return L.join("\n");
160
+ }
161
+
162
+ // El cuerpo que se le manda al canal.
163
+ export function payloadFor(cfg, ev) {
164
+ const envelope = CANALES[cfg.channel];
165
+ if (!envelope) throw new Error(`canal desconocido: ${cfg.channel}`);
166
+ return envelope(renderNotice(ev), cfg, ev);
167
+ }
168
+
169
+ // ── Efecto de red ────────────────────────────────────────────────────────────
170
+ // Un aviso que no sale NO puede voltear una release: para cuando esto corre, el tag ya
171
+ // existe y las US ya están estampadas. Por eso devuelve el error en vez de tirarlo —
172
+ // quien llama lo reporta como advertencia y sigue.
173
+ export async function sendNotice(cfg, ev) {
174
+ try {
175
+ const res = await daiFetch(cfg.endpoint, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify(payloadFor(cfg, ev)),
179
+ });
180
+ if (!res.ok) {
181
+ const body = (await res.text().catch(() => "")).slice(0, 300);
182
+ return { ok: false, status: res.status, error: explainNotifyError(cfg, res.status, body) };
183
+ }
184
+ return { ok: true, status: res.status, error: null };
185
+ } catch (e) {
186
+ // El mensaje de daiFetch ya explica el caso de TLS corporativo; acá solo se le pone
187
+ // alrededor qué se estaba haciendo, sin filtrar el endpoint.
188
+ return { ok: false, status: null, error: `no pude avisar a ${describeTarget(cfg)}: ${String(e.message).split("\n")[0]}` };
189
+ }
190
+ }
191
+
192
+ export function explainNotifyError(cfg, status, body = "") {
193
+ const donde = describeTarget(cfg);
194
+ if (status === 401 || status === 403) {
195
+ return `${donde} rechazó el aviso (${status}): el endpoint existe pero no autoriza.\n` +
196
+ " Revisá DAI_NOTIFY_WEBHOOK en el .env.dai — puede estar revocado o ser de otro espacio.";
197
+ }
198
+ if (status === 404) {
199
+ return `${donde} no existe (404). El webhook fue borrado, o la URL está mal copiada.`;
200
+ }
201
+ if (status === 429) {
202
+ return `${donde} te frenó por rate limit (429). El aviso no salió; el release sí está hecho.`;
203
+ }
204
+ return `${donde} respondió ${status}.${body ? `\n ${body}` : ""}`;
205
+ }
@@ -9,6 +9,8 @@
9
9
  // Interfaz (fetchUS/stamp pueden ser sync o async — el CLI siempre await-ea):
10
10
  // fetchUS(id) → { id, title, spec_version, ac_hash, url, raw } | null
11
11
  // stamp(id, record) → destino donde quedó la cobertura
12
+ // comment(id, markdown)→ comentario libre en la US (lo usa `dai release stamp`)
13
+ // listComments(id) → [texto] para reconocer los comentarios que dai ya puso
12
14
  // createUS({...}) → { id, url } (opcional: `dai publish`)
13
15
  // updateUS(id, {...}) → { id, url } (opcional: `dai update-us`)
14
16
  // kind → nombre del backend
@@ -57,6 +59,20 @@ function mdAdapter(env) {
57
59
  const raw = readFileSync(p, "utf8");
58
60
  return { id, ...parseUS(raw), raw };
59
61
  },
62
+ // Sin tracker, un "comentario" es una línea más en el registro de despliegues de esa US.
63
+ // Se APÉNDEA (no se pisa): el archivo es la bitácora, y una bitácora que se sobrescribe
64
+ // no es una bitácora.
65
+ comment(id, markdown) {
66
+ const p = join(dir, `${id}.deploys.md`);
67
+ mkdirSync(dirname(p), { recursive: true });
68
+ const previo = existsSync(p) ? readFileSync(p, "utf8") : `# Despliegues de ${id}\n`;
69
+ writeFileSync(p, `${previo.replace(/\s*$/, "")}\n\n${markdown}\n`);
70
+ return p;
71
+ },
72
+ listComments(id) {
73
+ const p = join(dir, `${id}.deploys.md`);
74
+ return existsSync(p) ? [readFileSync(p, "utf8")] : [];
75
+ },
60
76
  stamp(id, record) {
61
77
  const p = join(dir, `${id}.coverage.md`);
62
78
  mkdirSync(dirname(p), { recursive: true });
@@ -35,6 +35,23 @@ export function clickupAdapter(env) {
35
35
  // `url` es la canónica (/t/<team_id>/<id>): la sabe ClickUp, no la deducimos.
36
36
  return { id, ...parseUS(raw), url: j.url || null, raw };
37
37
  },
38
+ // Comentario libre (lo usa `dai release stamp`). ClickUp toma texto directamente.
39
+ async comment(id, markdown) {
40
+ const res = await fetch(clickupCommentUrl(id), {
41
+ method: "POST", headers: clickupAuthHeaders(env),
42
+ body: JSON.stringify({ comment_text: markdown }),
43
+ });
44
+ if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
45
+ return `task ${id} (comentario)`;
46
+ },
47
+ // Los comentarios como texto, para reconocer los que dai ya puso por su marca.
48
+ async listComments(id) {
49
+ const res = await fetch(clickupCommentUrl(id), { headers: clickupAuthHeaders(env) });
50
+ if (res.status === 404) return [];
51
+ if (!res.ok) throw new Error(`clickup ${res.status}: ${await res.text()}`);
52
+ const j = await res.json();
53
+ return (j.comments || []).map((c) => c.comment_text || c.text_content || "");
54
+ },
38
55
  async stamp(id, record) {
39
56
  const res = await fetch(clickupCommentUrl(id), {
40
57
  method: "POST", headers: clickupAuthHeaders(env),
@@ -147,6 +147,26 @@ export function jiraAdapter(env) {
147
147
  const raw = jiraIssueToText(await res.json());
148
148
  return { id, ...parseUS(raw), url: `${trim(base)}/browse/${id}`, raw };
149
149
  },
150
+ // Comentario libre en markdown (lo usa `dai release stamp`). Jira Cloud pide ADF, así
151
+ // que se convierte con el mismo parser que ya usa la descripción de la US.
152
+ async comment(id, markdown) {
153
+ const res = await daiFetch(jiraCommentUrl(base, id), {
154
+ method: "POST", headers: jiraAuthHeaders(env),
155
+ body: JSON.stringify({ body: markdownToAdf(markdown) }),
156
+ });
157
+ if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
158
+ return `${trim(base)}/browse/${id}`;
159
+ },
160
+ // Los comentarios como texto plano, para que dai reconozca los suyos por la marca y no
161
+ // vuelva a estampar lo mismo. Los más nuevos primero: la marca que buscamos, si está,
162
+ // es reciente. 100 alcanza de sobra y evita paginar un ticket con años de historia.
163
+ async listComments(id) {
164
+ const res = await daiFetch(`${jiraCommentUrl(base, id)}?maxResults=100&orderBy=-created`, { headers: jiraAuthHeaders(env) });
165
+ if (res.status === 404) return [];
166
+ if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
167
+ const j = await res.json();
168
+ return (j.comments || []).map((c) => (typeof c.body === "string" ? c.body : adfToMarkdown(c.body)));
169
+ },
150
170
  async stamp(id, record) {
151
171
  const res = await daiFetch(jiraCommentUrl(base, id), {
152
172
  method: "POST", headers: jiraAuthHeaders(env),
@@ -53,6 +53,20 @@ export function updatePrCmd(tool, { number, title, body, bodyFile }) {
53
53
  return ["mr", "update", String(number), "--title", title, "--description", body, "--yes"];
54
54
  }
55
55
 
56
+ // Plan B para actualizar, cuando el comando de alto nivel falla por algo que no tiene que
57
+ // ver con la edición. Pasa de verdad: `gh pr edit` consulta GraphQL y arrastra campos
58
+ // deprecados del servidor —hoy, `projectCards` de Projects (classic)— así que devuelve un
59
+ // error sobre proyectos cuando lo único que querías era cambiar el body. La API REST no
60
+ // pasa por ahí, y `gh api` usa la misma autenticación: no hace falta un token nuevo.
61
+ //
62
+ // Devuelve null si no sabemos hacer el plan B para esa herramienta (glab actualiza por REST
63
+ // de entrada, así que no lo necesita).
64
+ export function updatePrApiCmd(tool, { number, title, bodyFile, projectPath }) {
65
+ if (tool !== "gh" || !projectPath) return null;
66
+ return ["api", "--method", "PATCH", `repos/${projectPath}/pulls/${number}`,
67
+ "-F", `body=@${bodyFile}`, "-f", `title=${title}`];
68
+ }
69
+
56
70
  // ── "ya existe una PR para esta branch" ──────────────────────────────────────
57
71
  // El forge lo dice de formas distintas y en inglés. Se reconoce para poder pasar al camino
58
72
  // de actualizar en vez de morir con el comando crudo en pantalla.
@@ -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 cuerpo = String(entry ?? "").replace(/<!--[\s\S]*?-->/g, "");
67
+ if (String(entry ?? "").includes(CHANGELOG_MARK)) gaps.push("el manifiesto de dai sigue sin repartir");
68
+ const conTexto = cuerpo.split("\n").some((l) => /^\s*[-*]\s+\S/.test(l));
69
+ if (!conTexto) 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 resto = s.slice(ini);
103
+ const j = resto.search(/^## \[/m);
104
+ return (j === -1 ? resto : resto.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
+ }