@dforce2055/dai 0.13.3 → 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 +31 -0
- package/CHANGELOG.md +133 -0
- package/README.md +6 -4
- package/VERSION +1 -1
- package/cli/dai.mjs +721 -66
- package/cli/lib/bootstrap.mjs +14 -3
- package/cli/lib/branch-flow.mjs +82 -0
- package/cli/lib/branch-scope.mjs +24 -0
- package/cli/lib/help.mjs +524 -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 +94 -0
- package/cli/lib/release-files.mjs +119 -0
- package/cli/lib/release-plan.mjs +221 -0
- package/cli/lib/release-stamp.mjs +87 -0
- package/cli/lib/us-format.mjs +14 -3
- package/cli/lib/us.mjs +5 -2
- 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/governance/branch-naming.md +15 -1
- package/package.json +1 -1
- package/skills/dai-release/SKILL.md +167 -0
|
@@ -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),
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dai · la PR/MR que YA existe para esta branch: cómo buscarla, cómo actualizarla (issue #46).
|
|
2
|
+
//
|
|
3
|
+
// El bug: con una MR ya abierta, `dai pr` pusheaba la branch (el diff quedaba al día) y
|
|
4
|
+
// después `glab mr create` fallaba porque la MR existía. El body NO se actualizaba y lo
|
|
5
|
+
// único que se veía era el comando crudo del forge, sin un error legible. Resultado: una
|
|
6
|
+
// MR con el diff correcto y una descripción que MIENTE sobre lo que contiene — el peor de
|
|
7
|
+
// los dos mundos, porque parece que salió bien.
|
|
8
|
+
//
|
|
9
|
+
// Acá está la parte pura y testeable: armar los comandos del forge y leer lo que devuelven.
|
|
10
|
+
// Los efectos (execFileSync) viven en dai.mjs.
|
|
11
|
+
|
|
12
|
+
// ── Buscar la PR/MR abierta de una branch ────────────────────────────────────
|
|
13
|
+
export function listPrCmd(tool, branch) {
|
|
14
|
+
if (tool === "gh") {
|
|
15
|
+
return ["pr", "list", "--head", branch, "--state", "open", "--limit", "5",
|
|
16
|
+
"--json", "number,url,title,baseRefName"];
|
|
17
|
+
}
|
|
18
|
+
return ["mr", "list", "--source-branch", branch, "--per-page", "5", "--output", "json"];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Normaliza la respuesta de gh/glab a la MISMA forma: { number, url, title, base }.
|
|
22
|
+
// Devuelve null si no hay ninguna abierta. Tira si el JSON no parsea — quien llama decide
|
|
23
|
+
// si eso es fatal (no lo es: se sigue por el camino de crear, que también sabe fallar bien).
|
|
24
|
+
export function parsePrList(tool, stdout) {
|
|
25
|
+
const raw = String(stdout ?? "").trim();
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
const arr = JSON.parse(raw);
|
|
28
|
+
const list = Array.isArray(arr) ? arr : Array.isArray(arr?.items) ? arr.items : [];
|
|
29
|
+
for (const it of list) {
|
|
30
|
+
// glab devuelve TODAS las MR de la branch si no se filtra por estado: las cerradas y
|
|
31
|
+
// mergeadas no cuentan — reabrir una MR mergeada no es lo que pidió nadie.
|
|
32
|
+
const state = String(it.state ?? "opened").toLowerCase();
|
|
33
|
+
if (!["open", "opened"].includes(state)) continue;
|
|
34
|
+
const number = it.number ?? it.iid ?? null;
|
|
35
|
+
if (number == null) continue;
|
|
36
|
+
return {
|
|
37
|
+
number: Number(number),
|
|
38
|
+
url: it.url ?? it.web_url ?? null,
|
|
39
|
+
title: it.title ?? null,
|
|
40
|
+
base: it.baseRefName ?? it.target_branch ?? null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Actualizar el body/título de una PR/MR existente ─────────────────────────
|
|
47
|
+
// gh lee el body de un archivo; glab lo toma como string (igual que en `mr create`, que
|
|
48
|
+
// ya funciona así). Por eso la firma pide los dos y cada uno usa el que le sirve.
|
|
49
|
+
export function updatePrCmd(tool, { number, title, body, bodyFile }) {
|
|
50
|
+
if (tool === "gh") {
|
|
51
|
+
return ["pr", "edit", String(number), "--title", title, "--body-file", bodyFile];
|
|
52
|
+
}
|
|
53
|
+
return ["mr", "update", String(number), "--title", title, "--description", body, "--yes"];
|
|
54
|
+
}
|
|
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
|
+
|
|
70
|
+
// ── "ya existe una PR para esta branch" ──────────────────────────────────────
|
|
71
|
+
// El forge lo dice de formas distintas y en inglés. Se reconoce para poder pasar al camino
|
|
72
|
+
// de actualizar en vez de morir con el comando crudo en pantalla.
|
|
73
|
+
const ALREADY = [
|
|
74
|
+
/already exists/i,
|
|
75
|
+
/a merge request already exists/i,
|
|
76
|
+
/existing (?:pull request|merge request)/i,
|
|
77
|
+
/pull request for branch .* already exists/i,
|
|
78
|
+
/open merge request already exists/i,
|
|
79
|
+
];
|
|
80
|
+
export function isAlreadyExistsError(msg) {
|
|
81
|
+
const s = String(msg ?? "");
|
|
82
|
+
return ALREADY.some((re) => re.test(s));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Lo que se le muestra al dev cuando dai detecta la PR existente: qué va a pasar con ella.
|
|
86
|
+
export function describeUpdate(pr, { base, tool }) {
|
|
87
|
+
const lines = [`ya hay una PR/MR abierta para esta branch: #${pr.number}${pr.url ? ` — ${pr.url}` : ""}`];
|
|
88
|
+
if (pr.base && base && pr.base !== base) {
|
|
89
|
+
lines.push(`ojo: apunta a '${pr.base}' y vos pediste '${base}'. dai NO cambia la base de una PR abierta: ` +
|
|
90
|
+
`si querés otra base, cerrala y creá una nueva.`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(`dai va a ACTUALIZAR su título y su descripción con ${tool} (el diff ya lo actualiza el push).`);
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|