@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/.env.dai.example +16 -0
- package/CHANGELOG.md +71 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +545 -2
- 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 +20 -0
- 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/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
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
|
+
}
|
package/cli/lib/pm-adapter.mjs
CHANGED
|
@@ -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 });
|
package/cli/lib/pm-clickup.mjs
CHANGED
|
@@ -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),
|
package/cli/lib/pm-jira.mjs
CHANGED
|
@@ -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),
|
package/cli/lib/pr-remote.mjs
CHANGED
|
@@ -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
|
+
}
|