@dforce2055/dai 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.dai.example +16 -0
- package/CHANGELOG.md +127 -0
- package/CONTRIBUTING.md +22 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +571 -17
- package/cli/lib/bootstrap.mjs +4 -4
- package/cli/lib/branch-flow.mjs +6 -6
- package/cli/lib/branch-scope.mjs +24 -9
- package/cli/lib/help.mjs +76 -0
- package/cli/lib/notify.mjs +205 -0
- package/cli/lib/pm-adapter.mjs +16 -0
- package/cli/lib/pm-clickup.mjs +17 -0
- package/cli/lib/pm-jira.mjs +92 -8
- package/cli/lib/pr-remote.mjs +14 -0
- package/cli/lib/release-files.mjs +119 -0
- package/cli/lib/release-plan.mjs +221 -0
- package/cli/lib/release-stamp.mjs +87 -0
- package/cli/lib/review-findings.mjs +2 -2
- package/cli/lib/us-format.mjs +2 -2
- package/cli/lib/us.mjs +6 -6
- package/docs/adr/0008-estrategia-de-i18n.md +58 -0
- package/docs/adr/0019-ciclo-de-version-y-aviso-de-release.md +190 -0
- package/docs/adr/README.md +1 -0
- package/docs/guias/index.md +3 -0
- package/docs/guias/releases.md +156 -0
- package/docs/tutoriales/ciclo-de-release.md +266 -0
- package/docs/tutoriales/index.md +6 -0
- package/package.json +1 -1
- package/skills/dai-release/SKILL.md +167 -0
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -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
|
|
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" +
|
|
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" +
|
|
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" +
|
|
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 ──
|
package/cli/lib/branch-flow.mjs
CHANGED
|
@@ -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
|
|
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
|
|
42
|
-
if (
|
|
43
|
-
return { base: flow.prod, source: "DAI_BRANCH_PROD (.env.dai)", reason: `la branch es ${
|
|
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
|
|
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 (
|
|
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).`;
|
package/cli/lib/branch-scope.mjs
CHANGED
|
@@ -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.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 sí
|
|
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 SÍ 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
|
+
}
|
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 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 });
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
else { flushBullets();
|
|
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),
|
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.
|