@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.
@@ -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 flujo =
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" + flujo;
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" + flujo;
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" + flujo;
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 ──
@@ -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 HACIA_PROD = new Set(["release", "hotfix"]);
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 tipo = branchType(branch);
42
- if (HACIA_PROD.has(tipo) && flow.prod) {
43
- return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${tipo}/` };
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 DECIDIDAS = new Set(["--base", "DAI_BRANCH_DEV (.env.dai)", "DAI_BRANCH_PROD (.env.dai)", "lo respondiste vos"]);
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 (DECIDIDAS.has(source)) return null;
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).`;
@@ -194,16 +194,31 @@ export function prScope({ branch, rows, allRows = rows, ids = [] }) {
194
194
  if (req.kind === "exempt") {
195
195
  return { mode: "exempt", target: null, candidates: rows, reason: `${req.reason} y su nombre no nombra ninguna US` };
196
196
  }
197
- // El repo no tiene NINGUNA US viva. Exigirle un link a una branch que el propio
198
- // branch-naming declara exenta es pedir algo que no existe: `fix/lo-que-sea` (sin ID en
199
- // el nombre) terminaba con "corré dai link-us primero" y el consejo de renombrarla a
200
- // `chore/`, que para un fix es directamente el consejo equivocado. Le pasa a cualquier
201
- // repo de tooling — al de dai, sin ir más lejos, que no se trackea a sí mismo con US.
202
- if (rows.length === 0) {
203
- return req.required
204
- ? { mode: "none", target: null, candidates: [], reason: "no hay implements.yaml vivo en el repo" }
205
- : { mode: "exempt", target: null, candidates: [], reason: `${req.reason}, y el repo no declara ninguna US` };
197
+ // El repo no tiene NINGUNA US viva. Exigir un link acá es pedir algo que no existe: le
198
+ // pasa a cualquier repo de tooling —al de dai, sin ir más lejos, que no se trackea a
199
+ // mismo con US— y el mensaje terminaba mandando a renombrar la branch a `chore/`, que
200
+ // para un fix o una feature es el consejo equivocado.
201
+ //
202
+ // Lo que distingue un olvido de un repo sin US es si la branch NOMBRA UN TICKET: con
203
+ // `feature/ABC-482-checkout` alguien quiso implementar una US y no corrió `link-us`, y
204
+ // ahí el link falta de verdad. Sin key en el nombre no hay nada que reclamar.
205
+ //
206
+ // Esto no afloja ningún gate: el gate es `dai check --ci`, que sigue mirando requiresLink.
207
+ // Acá solo se decide con qué titular una PR.
208
+ // Se mira `allRows` (archivados incluidos), no `rows`: un repo con US archivadas SÍ
209
+ // trabaja con User Stories, así que una `feature/` sin link ahí es un olvido, no un repo
210
+ // de tooling. La exención es para el repo que nunca declaró una.
211
+ if (allRows.length === 0 && trackerKeysIn(branch).length === 0) {
212
+ return {
213
+ mode: "exempt", target: null, candidates: [],
214
+ // Si branch-naming ya tiene un motivo (chore/ exenta por tipo, fix/ sin ID), se usa
215
+ // ese: es más específico y es el que el equipo puede ir a leer.
216
+ reason: req.required
217
+ ? "la branch no nombra ninguna US y el repo no declara ninguna"
218
+ : `${req.reason}, y el repo no declara ninguna US`,
219
+ };
206
220
  }
221
+ if (rows.length === 0) return { mode: "none", target: null, candidates: [], reason: "no hay implements.yaml vivo en el repo" };
207
222
  if (rows.length === 1) return { mode: "only", target: rows[0], candidates: rows, reason: "es la única US viva del repo" };
208
223
  return { mode: "ambiguous", target: null, candidates: rows, reason: `hay ${rows.length} US vivas y la branch '${branch}' no dice cuál` };
209
224
  }
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 CHANNELS = {
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 VALID_CHANNELS = Object.keys(CHANNELS);
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 (!CHANNELS[channel]) {
49
+ throw new Error(
50
+ `DAI_NOTIFY='${channel}' no es un canal que dai conozca (${VALID_CHANNELS.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 pad2 = (n) => String(n).padStart(2, "0");
125
+ export function formatFecha(d = new Date()) {
126
+ return `${pad2(d.getDate())}/${pad2(d.getMonth() + 1)}/${d.getFullYear()} ` +
127
+ `${pad2(d.getHours())}:${pad2(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 = CHANNELS[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 target = describeTarget(cfg);
194
+ if (status === 401 || status === 403) {
195
+ return `${target} 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 `${target} no existe (404). El webhook fue borrado, o la URL está mal copiada.`;
200
+ }
201
+ if (status === 429) {
202
+ return `${target} te frenó por rate limit (429). El aviso no salió; el release sí está hecho.`;
203
+ }
204
+ return `${target} 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 previousTag = existsSync(p) ? readFileSync(p, "utf8") : `# Despliegues de ${id}\n`;
69
+ writeFileSync(p, `${previousTag.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),
@@ -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 bullets. Suficiente para el formato de US.
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
- const clean = (s) => s.replace(/[*_`]+/g, "").trim();
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
- else if (b) { flushPara(); if (!bullets) bullets = []; const t = clean(b[1]); if (t) bullets.push({ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }] }); }
76
- else if (line.trim() === "") { flushPara(); flushBullets(); }
77
- else { flushBullets(); para.push(line.trim()); }
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
  }
@@ -147,6 +211,26 @@ export function jiraAdapter(env) {
147
211
  const raw = jiraIssueToText(await res.json());
148
212
  return { id, ...parseUS(raw), url: `${trim(base)}/browse/${id}`, raw };
149
213
  },
214
+ // Comentario libre en markdown (lo usa `dai release stamp`). Jira Cloud pide ADF, así
215
+ // que se convierte con el mismo parser que ya usa la descripción de la US.
216
+ async comment(id, markdown) {
217
+ const res = await daiFetch(jiraCommentUrl(base, id), {
218
+ method: "POST", headers: jiraAuthHeaders(env),
219
+ body: JSON.stringify({ body: markdownToAdf(markdown) }),
220
+ });
221
+ if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
222
+ return `${trim(base)}/browse/${id}`;
223
+ },
224
+ // Los comentarios como texto plano, para que dai reconozca los suyos por la marca y no
225
+ // vuelva a estampar lo mismo. Los más nuevos primero: la marca que buscamos, si está,
226
+ // es reciente. 100 alcanza de sobra y evita paginar un ticket con años de historia.
227
+ async listComments(id) {
228
+ const res = await daiFetch(`${jiraCommentUrl(base, id)}?maxResults=100&orderBy=-created`, { headers: jiraAuthHeaders(env) });
229
+ if (res.status === 404) return [];
230
+ if (!res.ok) throw new Error(`jira ${res.status}: ${await res.text()}`);
231
+ const j = await res.json();
232
+ return (j.comments || []).map((c) => (typeof c.body === "string" ? c.body : adfToMarkdown(c.body)));
233
+ },
150
234
  async stamp(id, record) {
151
235
  const res = await daiFetch(jiraCommentUrl(base, id), {
152
236
  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.