@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/dai.mjs
CHANGED
|
@@ -29,7 +29,12 @@ import { trackerUrl } from "./lib/tracker-url.mjs";
|
|
|
29
29
|
import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
|
|
30
30
|
import { composePrBody, prTitle, forgeTool, bodyGaps } from "./lib/pr.mjs";
|
|
31
31
|
import { resolveBase, isProdBranch, branchFlow, baseHint, parseOriginHead } from "./lib/branch-flow.mjs";
|
|
32
|
-
import { listPrCmd, parsePrList, updatePrCmd, isAlreadyExistsError, describeUpdate } from "./lib/pr-remote.mjs";
|
|
32
|
+
import { listPrCmd, parsePrList, updatePrCmd, updatePrApiCmd, isAlreadyExistsError, describeUpdate } from "./lib/pr-remote.mjs";
|
|
33
|
+
import { parseCommitLog, proposeBump, nextVersion, buildManifest, renderManifest, BUMP_CAVEAT } from "./lib/release-plan.mjs";
|
|
34
|
+
import { bumpPackageJson, bumpVersionFile, changelogEntry, insertChangelogEntry, changelogSection, changelogGaps, releaseBranch, tagName, normalizeVersion } from "./lib/release-files.mjs";
|
|
35
|
+
import { notifyConfig, describeTarget, renderNotice, sendNotice, formatFecha } from "./lib/notify.mjs";
|
|
36
|
+
import { releaseMarker, alreadyStamped, renderReleaseStamp, stampPlan, renderStampPlan, stampWarning, NOT_STAMPED_NOTE } from "./lib/release-stamp.mjs";
|
|
37
|
+
import { parseImplements } from "./lib/implements.mjs";
|
|
33
38
|
import { absolutizeSiteLinks } from "./lib/docs-links.mjs";
|
|
34
39
|
import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
|
|
35
40
|
import { dirsEqual } from "./lib/fsutil.mjs";
|
|
@@ -348,9 +353,9 @@ function ciBranch() {
|
|
|
348
353
|
// lo hace pasar por verificado (issue #46). No cambia el exit code — el hash manda.
|
|
349
354
|
function versionDriftHint(im, live) {
|
|
350
355
|
const declared = String(im?.version ?? "").trim();
|
|
351
|
-
const
|
|
352
|
-
if (!
|
|
353
|
-
warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${
|
|
356
|
+
const liveVersion = String(live?.spec_version ?? "").trim();
|
|
357
|
+
if (!liveVersion || declared === liveVersion) return;
|
|
358
|
+
warn(`${im.id}: el link declara version '${declared || "(vacío)"}' y la US viva dice '${liveVersion}'.`);
|
|
354
359
|
process.stdout.write(` Ese número se publica en la PR y se estampa en el tracker. Corregilo: dai link-us ${im.id} --resync\n`);
|
|
355
360
|
}
|
|
356
361
|
|
|
@@ -361,7 +366,7 @@ async function cmdCheck() {
|
|
|
361
366
|
const adapter = getAdapter(process.env);
|
|
362
367
|
const found = discoverImplements(process.cwd(), { includeArchived: false });
|
|
363
368
|
let worst = 0, n = 0;
|
|
364
|
-
const
|
|
369
|
+
const stale = [];
|
|
365
370
|
for (const f of found) for (const im of f.implements || []) {
|
|
366
371
|
if (isPlaceholderId(im.id)) continue; // plantilla sin completar, no es una US real
|
|
367
372
|
n++;
|
|
@@ -373,7 +378,7 @@ async function cmdCheck() {
|
|
|
373
378
|
}
|
|
374
379
|
else if (status === "atrasado") {
|
|
375
380
|
process.stdout.write(`⚠️ ${im.id} ATRASADO: implementaste ${im.ac_hash}, la US viva es ${live.ac_hash}${live.spec_version ? ` (${live.spec_version})` : ""}\n`);
|
|
376
|
-
|
|
381
|
+
stale.push(im.id);
|
|
377
382
|
worst = Math.max(worst, 1);
|
|
378
383
|
} else if (status === "sin-respuesta") {
|
|
379
384
|
// No es "no hay US": es "no pude preguntar". Antes moría acá con `fetch failed` y sin
|
|
@@ -386,9 +391,9 @@ async function cmdCheck() {
|
|
|
386
391
|
}
|
|
387
392
|
}
|
|
388
393
|
if (n === 0) process.stdout.write("No hay implements.yaml para chequear.\n");
|
|
389
|
-
if (
|
|
394
|
+
if (stale.length) {
|
|
390
395
|
process.stdout.write("\n El QUÉ cambió desde que lo implementaste. Para resincronizar:\n");
|
|
391
|
-
for (const id of
|
|
396
|
+
for (const id of stale) process.stdout.write(` dai link-us ${id} --resync # re-estampa el ac_hash contra la US viva\n`);
|
|
392
397
|
process.stdout.write(" Después, revisa si tu implementación cubre el criterio nuevo.\n");
|
|
393
398
|
}
|
|
394
399
|
process.exitCode = worst;
|
|
@@ -1086,8 +1091,8 @@ async function cmdPr(opts) {
|
|
|
1086
1091
|
const existing = findExistingPr(tool, branch);
|
|
1087
1092
|
|
|
1088
1093
|
// 4. Mostrar y pedir confirmación (acción hacia afuera).
|
|
1089
|
-
const
|
|
1090
|
-
process.stdout.write(`\n ── Pull Request ${
|
|
1094
|
+
const action = existing ? `a ACTUALIZAR (#${existing.number})` : "a crear";
|
|
1095
|
+
process.stdout.write(`\n ── Pull Request ${action} ──────────────────────────────\n`);
|
|
1091
1096
|
process.stdout.write(` título: ${title}\n de: ${branch}\n`);
|
|
1092
1097
|
process.stdout.write(` a: ${base} ${C.dim(`(${baseSource}${baseWhy ? ` — ${baseWhy}` : ""})`)}${toProd ? ` ${C.b("⚠️ DESPLIEGA A PRODUCCIÓN")}` : ""}\n`);
|
|
1093
1098
|
process.stdout.write(` US: ${id || C.dim("(sin US)")} ${C.dim(`— ${usWhy}`)}\n`);
|
|
@@ -1188,6 +1193,19 @@ async function cmdPr(opts) {
|
|
|
1188
1193
|
return true;
|
|
1189
1194
|
} catch (e) {
|
|
1190
1195
|
const msg = String(e.stderr || e.message || "");
|
|
1196
|
+
// Plan B por REST: el comando de alto nivel puede fallar por algo ajeno a la edición
|
|
1197
|
+
// (gh consulta GraphQL y arrastra campos deprecados del servidor). Se intenta callado
|
|
1198
|
+
// y solo se reporta si TAMBIÉN falla — avisar de un error que se resolvió solo es ruido.
|
|
1199
|
+
const api = updatePrApiCmd(tool, { number, title, bodyFile, projectPath: parseRemote(remote)?.path });
|
|
1200
|
+
if (api) {
|
|
1201
|
+
try {
|
|
1202
|
+
execFileSync(tool, api, { encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
1203
|
+
ok(`PR/MR #${number} actualizada: título + descripción${existing?.url ? ` — ${existing.url}` : ""}.`);
|
|
1204
|
+
info(`(\`${tool} pr edit\` falló por algo ajeno a la edición; se actualizó por la API REST)`);
|
|
1205
|
+
try { rmSync(bodyFile); } catch { /* noop */ }
|
|
1206
|
+
return true;
|
|
1207
|
+
} catch (e2) { process.stdout.write(` ${tool} api también falló: ${String(e2.stderr || e2.message).split("\n")[0]}\n`); }
|
|
1208
|
+
}
|
|
1191
1209
|
warn(`no pude actualizar la PR/MR #${number} con ${tool}. El body quedó en ${bodyFile}.`);
|
|
1192
1210
|
if (msg.trim()) process.stdout.write(` ${tool} dijo:\n ${msg.trim().split("\n").join("\n ")}\n`);
|
|
1193
1211
|
process.stdout.write(shellHint(tool, ucmd));
|
|
@@ -1240,7 +1258,534 @@ async function cmdPr(opts) {
|
|
|
1240
1258
|
}
|
|
1241
1259
|
}
|
|
1242
1260
|
|
|
1243
|
-
// ──
|
|
1261
|
+
// ── release plan: el manifiesto de la versión ─────────────────────────────────
|
|
1262
|
+
//
|
|
1263
|
+
// Contesta la pregunta que a un equipo sin versiones no le contesta nadie: qué User
|
|
1264
|
+
// Stories tiene esta release. El tag, el CHANGELOG, el comentario en cada ticket y el
|
|
1265
|
+
// aviso al canal son formas distintas de publicar ESTE dato, así que primero el dato.
|
|
1266
|
+
|
|
1267
|
+
// El último tag alcanzable, ordenado por semver (no por fecha: un hotfix tagueado tarde
|
|
1268
|
+
// no es "el último release"). null si el repo todavía no tiene ninguno.
|
|
1269
|
+
function lastTag() {
|
|
1270
|
+
try {
|
|
1271
|
+
const out = git(["tag", "--sort=-v:refname", "--merged", "HEAD"]);
|
|
1272
|
+
return out.split("\n").map((t) => t.trim()).filter(Boolean)[0] || null;
|
|
1273
|
+
} catch { return null; }
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Las US que entraron en el rango, leyendo los implements.yaml TAL COMO ESTABAN en cada
|
|
1277
|
+
// commit que los tocó. El link viaja con el código, así que esto no depende de que la
|
|
1278
|
+
// branch siga existiendo ni de que el change no se haya archivado — que es exactamente
|
|
1279
|
+
// lo que pasa cuando llegás a cortar la release, días después del merge.
|
|
1280
|
+
function linkedInRange(range) {
|
|
1281
|
+
const rows = [];
|
|
1282
|
+
let raw;
|
|
1283
|
+
try {
|
|
1284
|
+
raw = git(["log", "--format=\x1e%H", "--name-only", "--diff-filter=AMR", range, "--", "*implements.yaml"]);
|
|
1285
|
+
} catch { return rows; }
|
|
1286
|
+
let order = 0;
|
|
1287
|
+
for (const chunk of raw.split("\x1e")) {
|
|
1288
|
+
const outLines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1289
|
+
if (outLines.length === 0) continue;
|
|
1290
|
+
const sha = outLines[0];
|
|
1291
|
+
for (const path of outLines.slice(1)) {
|
|
1292
|
+
if (!path.endsWith("implements.yaml")) continue;
|
|
1293
|
+
let text;
|
|
1294
|
+
try { text = git(["show", `${sha}:${path}`]); } catch { continue; } // borrado después
|
|
1295
|
+
let parsed;
|
|
1296
|
+
try { parsed = parseImplements(text); } catch { continue; }
|
|
1297
|
+
for (const im of parsed.implements || []) {
|
|
1298
|
+
if (isPlaceholderId(im.id)) continue;
|
|
1299
|
+
rows.push({ ...im, change: parsed.change, repo: parsed.repo, path, sha, order: order++ });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return rows;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// El manifiesto, sin render: lo comparten plan, cut y done. Un solo lugar que sabe
|
|
1307
|
+
// contestar "qué entra en esta versión" — si cada comando lo calculara a su manera,
|
|
1308
|
+
// tarde o temprano dirían cosas distintas y le creerías al que tenés más a mano.
|
|
1309
|
+
async function releaseManifest(opts = {}, { from, to } = {}) {
|
|
1310
|
+
const fromRef = from ?? (textOpt(opts, "from") || lastTag());
|
|
1311
|
+
const toRef = to ?? (textOpt(opts, "to") || branchFlow(process.env).dev || gitBranch());
|
|
1312
|
+
const toRev = resolveRev(toRef) || resolveRev(`origin/${toRef}`);
|
|
1313
|
+
if (!toRev) fail(`no encuentro '${toRef}' (ni local ni en origin/${toRef}).\n Traelo primero: git fetch origin ${toRef}`, 1);
|
|
1314
|
+
const range = fromRef ? `${fromRef}..${toRev}` : toRev;
|
|
1315
|
+
|
|
1316
|
+
let commits = [];
|
|
1317
|
+
try { commits = parseCommitLog(git(["log", "--format=%H\x1f%s", range])); }
|
|
1318
|
+
catch (e) { fail(`no pude leer el historial de '${range}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
1319
|
+
|
|
1320
|
+
const linked = linkedInRange(range);
|
|
1321
|
+
const live = {};
|
|
1322
|
+
let unreachable = false;
|
|
1323
|
+
if (!opts.noNetwork && linked.length) {
|
|
1324
|
+
try {
|
|
1325
|
+
const adapter = getAdapter(process.env);
|
|
1326
|
+
for (const id of new Set(linked.map((r) => r.id))) {
|
|
1327
|
+
const { us, unreachable: nore } = await fetchLiveUS(adapter, id);
|
|
1328
|
+
if (nore) { unreachable = true; break; }
|
|
1329
|
+
if (us) live[id] = us;
|
|
1330
|
+
}
|
|
1331
|
+
} catch (e) { unreachable = true; warn(`no puedo verificar contra el tracker: ${String(e.message).split("\n")[0]}`); }
|
|
1332
|
+
}
|
|
1333
|
+
// ¿Este repo trabaja con User Stories? Si nunca declaró ninguna (archivadas incluidas),
|
|
1334
|
+
// marcar cada branch como "entró sin link" es ruido: en un repo de tooling ninguna va a
|
|
1335
|
+
// declararla nunca. Se mira el repo entero, no el rango.
|
|
1336
|
+
const repoUsesStories = flattenImplements(discoverImplements(process.cwd())).length > 0;
|
|
1337
|
+
const m = buildManifest({ commits, linked, live, unreachable: unreachable || Boolean(opts.noNetwork), repoUsesStories });
|
|
1338
|
+
return { manifest: m, commits, from: fromRef, to: toRef, range };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// La versión que este repo declara hoy. `null` si no espeja el número en ningún archivo
|
|
1342
|
+
// que dai reconozca — que es legítimo: el tag es la fuente de verdad.
|
|
1343
|
+
function currentVersion() {
|
|
1344
|
+
for (const [file, leer] of [
|
|
1345
|
+
["VERSION", (t) => t.trim().split("\n")[0]],
|
|
1346
|
+
["package.json", (t) => { try { return JSON.parse(t).version || null; } catch { return null; } }],
|
|
1347
|
+
]) {
|
|
1348
|
+
const p = join(process.cwd(), file);
|
|
1349
|
+
if (existsSync(p)) { const v = leer(readFileSync(p, "utf8")); if (v) return { version: v, file }; }
|
|
1350
|
+
}
|
|
1351
|
+
return { version: null, file: null };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function cmdReleasePlan(opts = {}) {
|
|
1355
|
+
loadDaiEnv();
|
|
1356
|
+
if (!gitBranch()) fail("esto no parece un repo git.", 1);
|
|
1357
|
+
const { manifest: m, commits, from, to } = await releaseManifest(opts);
|
|
1358
|
+
const { version: current } = currentVersion();
|
|
1359
|
+
const { bump, reason } = proposeBump(commits);
|
|
1360
|
+
const proposed = current ? nextVersion(current, bump) : null;
|
|
1361
|
+
|
|
1362
|
+
if (opts.json) {
|
|
1363
|
+
process.stdout.write(JSON.stringify({ from, to, current, proposed, bump, reason, caveat: BUMP_CAVEAT, ...m }, null, 2) + "\n");
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo de versión)", proposed, bump, reason }) + "\n");
|
|
1367
|
+
process.stdout.write(` ${C.dim(BUMP_CAVEAT.split("\n").join("\n "))}\n\n`);
|
|
1368
|
+
if (m.counts.commits === 0) { info("no hay nada nuevo para promover: no hace falta cortar una versión."); return; }
|
|
1369
|
+
info(`Cuando lo confirmes: dai release cut ${proposed || "<X.Y.Z>"}`);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ── release cut: preparar la versión ─────────────────────────────────────────
|
|
1373
|
+
// Branch + bump + entrada del CHANGELOG + commit. Todo lo que pasa ANTES de la firma
|
|
1374
|
+
// humana; nada que hable hacia afuera (ni push, ni tag, ni PR).
|
|
1375
|
+
async function cmdReleaseCut(versionArg, opts = {}) {
|
|
1376
|
+
loadDaiEnv();
|
|
1377
|
+
const branch = gitBranch();
|
|
1378
|
+
if (!branch) fail("esto no parece un repo git.", 1);
|
|
1379
|
+
let version;
|
|
1380
|
+
try { version = normalizeVersion(versionArg); }
|
|
1381
|
+
catch (e) { fail(`${e.message}\n Uso: dai release cut 1.2.0`, 1); }
|
|
1382
|
+
|
|
1383
|
+
const dirty = (() => { try { return git(["status", "--porcelain"]).length > 0; } catch { return false; } })();
|
|
1384
|
+
if (dirty && !opts.dryRun) fail("tenés cambios sin commitear. Una versión se corta sobre un árbol limpio.", 1);
|
|
1385
|
+
|
|
1386
|
+
const { manifest: m, commits, from, to } = await releaseManifest(opts);
|
|
1387
|
+
const { version: current, file: versionFile } = currentVersion();
|
|
1388
|
+
const { bump, reason } = proposeBump(commits);
|
|
1389
|
+
const suggested = current ? nextVersion(current, bump) : null;
|
|
1390
|
+
|
|
1391
|
+
// Qué archivos espejan el número en ESTE repo. Puede no haber ninguno (un repo .NET, un
|
|
1392
|
+
// frontend corporativo): el tag sigue siendo la versión.
|
|
1393
|
+
const mirrors = [];
|
|
1394
|
+
for (const [file, fn] of [["VERSION", bumpVersionFile], ["package.json", bumpPackageJson]]) {
|
|
1395
|
+
const p = join(process.cwd(), file);
|
|
1396
|
+
if (!existsSync(p)) continue;
|
|
1397
|
+
const r = fn(readFileSync(p, "utf8"), version);
|
|
1398
|
+
mirrors.push({ file, path: p, ...r });
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const makeBranch = opts.branch !== false && !opts.noBranch;
|
|
1402
|
+
const releaseBranchName = releaseBranch(version);
|
|
1403
|
+
|
|
1404
|
+
process.stdout.write("\n" + renderManifest(m, { from, to, current: current || "(sin archivo)", proposed: version, bump: `pediste ${version}`, reason }) + "\n");
|
|
1405
|
+
process.stdout.write(`\n ── Se va a preparar ─────────────────────────────────\n`);
|
|
1406
|
+
process.stdout.write(` versión: ${version}${suggested && suggested !== version ? C.dim(` (dai habría propuesto ${suggested} — ${bump})`) : ""}\n`);
|
|
1407
|
+
process.stdout.write(` branch: ${makeBranch ? `${releaseBranchName} (desde ${branch})` : C.dim("ninguna — se taguea desde la rama de integración")}\n`);
|
|
1408
|
+
for (const e of mirrors) process.stdout.write(` ${e.file.padEnd(9)} ${e.changed ? `${e.from} → ${version}` : C.dim(`ya está en ${version}`)}\n`);
|
|
1409
|
+
if (mirrors.length === 0) process.stdout.write(` ${C.dim("este repo no espeja el número en ningún archivo que dai reconozca: la versión es el tag")}\n`);
|
|
1410
|
+
process.stdout.write(` CHANGELOG entrada nueva para ${version} (con el manifiesto para repartir)\n`);
|
|
1411
|
+
process.stdout.write(` commit: chore(release): v${version}\n`);
|
|
1412
|
+
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
1413
|
+
if (m.counts.stale > 0) warn(`vas a cortar una versión con ${m.counts.stale} US ATRASADA(S). Revisalas o resincronizá antes.`);
|
|
1414
|
+
if (m.orphans.length) warn(`hay ${m.orphans.length} branch(es) sin US ni prefijo exento en el rango.`);
|
|
1415
|
+
|
|
1416
|
+
if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
|
|
1417
|
+
if (!opts.yes) {
|
|
1418
|
+
if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no preparo la versión. Revisá el plan y re-ejecutá con --yes."); return; }
|
|
1419
|
+
const a = (await askOrCancel(` ¿Preparo la versión ${version}? (s/N) `) || "").toLowerCase();
|
|
1420
|
+
if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (makeBranch) {
|
|
1424
|
+
try { git(["checkout", "-b", releaseBranchName]); ok(`branch ${releaseBranchName}`); }
|
|
1425
|
+
catch (e) { fail(`no pude crear '${releaseBranchName}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
1426
|
+
}
|
|
1427
|
+
const touched = [];
|
|
1428
|
+
for (const e of mirrors) {
|
|
1429
|
+
if (!e.changed) continue;
|
|
1430
|
+
writeFileSync(e.path, e.text);
|
|
1431
|
+
touched.push(e.file);
|
|
1432
|
+
ok(`${e.file}: ${e.from} → ${version}`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// CHANGELOG: el andamio y el material. La prosa la escribe una persona (o la skill).
|
|
1436
|
+
const chPath = join(process.cwd(), "CHANGELOG.md");
|
|
1437
|
+
const entry = changelogEntry({ version, date: new Date().toISOString().slice(0, 10), manifest: m });
|
|
1438
|
+
const previousChangelog = existsSync(chPath) ? readFileSync(chPath, "utf8") : "# Changelog\n";
|
|
1439
|
+
const repoUrl = (() => { const p = parseRemote(gitRemote()); return p ? `https://${p.host}/${p.path}` : null; })();
|
|
1440
|
+
const res = insertChangelogEntry(previousChangelog, entry, { version, repoUrl });
|
|
1441
|
+
if (res.changed) { writeFileSync(chPath, res.text); touched.push("CHANGELOG.md"); ok(`CHANGELOG.md: entrada para ${version}`); }
|
|
1442
|
+
else warn(`CHANGELOG.md sin tocar — ${res.reason}.`);
|
|
1443
|
+
|
|
1444
|
+
if (touched.length === 0) { warn("no hubo nada que cambiar: no hago un commit vacío."); return; }
|
|
1445
|
+
git(["add", ...touched]);
|
|
1446
|
+
git(["commit", "-m", `chore(release): v${version}`, "-m", `Manifiesto: ${m.counts.stories} US · ${m.counts.commits} commits desde ${from || "el principio"}.`]);
|
|
1447
|
+
ok(`commit chore(release): v${version}`);
|
|
1448
|
+
|
|
1449
|
+
process.stdout.write("\n");
|
|
1450
|
+
info("Falta lo que dai no puede escribir por vos: repartí el manifiesto del CHANGELOG y contá el porqué.");
|
|
1451
|
+
info(`Después: dai pr${makeBranch ? "" : ""} → se mergea → dai release done ${version}`);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ── release done: cerrar la versión ────────────────────────────────────────
|
|
1455
|
+
// DESPUÉS del merge. Tag + release note + back-merge + aviso. Es la mitad que se olvida
|
|
1456
|
+
// cuando la ceremonia se hace a mano, y la que habla hacia afuera: cada paso se reporta
|
|
1457
|
+
// por separado, porque si el release note falla el tag YA existe y hay que decirlo.
|
|
1458
|
+
async function cmdReleaseDone(versionArg, opts = {}) {
|
|
1459
|
+
loadDaiEnv();
|
|
1460
|
+
let version;
|
|
1461
|
+
try { version = normalizeVersion(versionArg); }
|
|
1462
|
+
catch (e) { fail(`${e.message}\n Uso: dai release done 1.2.0`, 1); }
|
|
1463
|
+
const tag = tagName(version);
|
|
1464
|
+
const flow = branchFlow(process.env);
|
|
1465
|
+
const prod = textOpt(opts, "base") || flow.prod || parseOriginHead(safeGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])) || "main";
|
|
1466
|
+
const dev = flow.dev;
|
|
1467
|
+
|
|
1468
|
+
if (safeGit(["tag", "-l", tag])?.trim() === tag && !opts.force) {
|
|
1469
|
+
fail(`el tag ${tag} ya existe. Una versión no se re-taguea: si querés rehacerla, borrá el tag a mano (local y remoto) sabiendo lo que eso implica.`, 1);
|
|
1470
|
+
}
|
|
1471
|
+
try { git(["checkout", prod]); } catch (e) { fail(`no pude cambiar a '${prod}': ${String(e.message).split("\n")[0]}`, 1); }
|
|
1472
|
+
try { git(["pull", "--ff-only"]); } catch { warn(`no pude actualizar '${prod}' con pull --ff-only. Revisá a mano antes de taguear.`); }
|
|
1473
|
+
|
|
1474
|
+
const head = gitCommit();
|
|
1475
|
+
const { version: enArchivo } = currentVersion();
|
|
1476
|
+
if (enArchivo && enArchivo !== version) {
|
|
1477
|
+
fail(`'${prod}' declara la versión ${enArchivo}, no ${version}.\n ¿Se mergeó la PR de release? El tag tiene que apuntar al commit final.`, 1);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const { manifest: m } = await releaseManifest(opts, { to: prod });
|
|
1481
|
+
const notes = existsSync(join(process.cwd(), "CHANGELOG.md"))
|
|
1482
|
+
? changelogSection(readFileSync(join(process.cwd(), "CHANGELOG.md"), "utf8"), version) : null;
|
|
1483
|
+
const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
|
|
1484
|
+
const willNotify = cfg && !opts.noNotify;
|
|
1485
|
+
|
|
1486
|
+
process.stdout.write(`\n ── Cerrar la versión ${version} ──────────────────────────\n`);
|
|
1487
|
+
process.stdout.write(` tag: ${tag} → ${prod} @ ${String(head).slice(0, 8)}\n`);
|
|
1488
|
+
process.stdout.write(` release: ${opts.noRelease ? C.dim("no (--no-release)") : `nota en el forge${notes ? "" : C.dim(" (sin sección del CHANGELOG: sale con las notas del forge)")}`}\n`);
|
|
1489
|
+
process.stdout.write(` back-merge: ${dev ? `${prod} → ${dev}` : C.dim("no (DAI_BRANCH_DEV no declarada)")}\n`);
|
|
1490
|
+
const relBranch = releaseBranch(version);
|
|
1491
|
+
const hasLocal = Boolean(safeGit(["rev-parse", "--verify", "--quiet", relBranch]));
|
|
1492
|
+
// Se pregunta al REMOTO, no a `origin/<branch>`: esa ref local queda desactualizada
|
|
1493
|
+
// cuando el forge borra la rama al mergear (auto-delete on merge, que es lo normal), y
|
|
1494
|
+
// dai terminaba intentando borrar algo que ya no estaba y reportando un ⚠ por un estado
|
|
1495
|
+
// que en realidad era el correcto. Avisar de un problema inexistente enseña a ignorar los avisos.
|
|
1496
|
+
const hasRemote = Boolean(safeGit(["ls-remote", "--heads", "origin", relBranch])?.trim());
|
|
1497
|
+
const willCleanup = (hasLocal || hasRemote) && !opts.keepBranch;
|
|
1498
|
+
process.stdout.write(` limpieza: ${willCleanup
|
|
1499
|
+
? `borrar ${relBranch}${hasLocal ? " (local)" : ""}${hasLocal && hasRemote ? " +" : ""}${hasRemote ? " (remota)" : ""}`
|
|
1500
|
+
: C.dim(hasLocal || hasRemote ? "no (--keep-branch)" : "nada que borrar")}\n`);
|
|
1501
|
+
process.stdout.write(` aviso: ${willNotify ? describeTarget(cfg) : C.dim(cfg ? "no (--no-notify)" : "no (DAI_NOTIFY no declarado)")}\n`);
|
|
1502
|
+
process.stdout.write(` ─────────────────────────────────────────────────────\n`);
|
|
1503
|
+
if (opts.dryRun) { info("[dry-run] no se tocó nada."); return; }
|
|
1504
|
+
if (!opts.yes) {
|
|
1505
|
+
if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no cierro la versión."); return; }
|
|
1506
|
+
const a = (await askOrCancel(` ¿Cierro la versión ${version}? (s/N) `) || "").toLowerCase();
|
|
1507
|
+
if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("Cancelado — no se tocó nada."); return; }
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// 1. El tag. Es LA versión: si esto falla, no sigue nada.
|
|
1511
|
+
try {
|
|
1512
|
+
git(["tag", "-a", tag, "-m", `${tag}`, ...(opts.force ? ["-f"] : [])]);
|
|
1513
|
+
git(["push", "origin", tag, ...(opts.force ? ["--force"] : [])], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
|
|
1514
|
+
ok(`tag ${tag} creado y publicado`);
|
|
1515
|
+
} catch (e) { fail(`no pude crear o publicar el tag ${tag}: ${String(e.message).split("\n")[0]}`, 1); }
|
|
1516
|
+
|
|
1517
|
+
// 2. Release note en el forge. De acá en adelante, cada paso reporta y sigue: el tag ya
|
|
1518
|
+
// existe, y abortar dejaría la versión a medio cerrar sin decir en qué mitad quedó.
|
|
1519
|
+
let releaseUrl = null;
|
|
1520
|
+
if (!opts.noRelease) {
|
|
1521
|
+
const forge = detectForge(parseRemote(gitRemote())?.host);
|
|
1522
|
+
const tool = forgeTool(forge);
|
|
1523
|
+
const notesFile = join(mkdtempSync(join(tmpdir(), "dai-rel-")), "notes.md");
|
|
1524
|
+
if (notes) writeFileSync(notesFile, notes + "\n");
|
|
1525
|
+
const cmd = tool === "gh"
|
|
1526
|
+
? ["release", "create", tag, "--target", prod, "--title", `${tag}`, ...(notes ? ["--notes-file", notesFile] : ["--generate-notes"]), "--latest"]
|
|
1527
|
+
: ["release", "create", tag, "--name", `${tag}`, ...(notes ? ["--notes-file", notesFile] : [])];
|
|
1528
|
+
try {
|
|
1529
|
+
const out = execFileSync(tool, cmd, { encoding: "utf8", cwd: process.cwd() });
|
|
1530
|
+
releaseUrl = (String(out).match(/https?:\/\/\S+/) || [null])[0];
|
|
1531
|
+
process.stdout.write(out);
|
|
1532
|
+
ok(`release note publicada${releaseUrl ? ` — ${releaseUrl}` : ""}`);
|
|
1533
|
+
} catch (e) {
|
|
1534
|
+
warn(`no pude publicar la release note con ${tool}: ${String(e.stderr || e.message).split("\n")[0]}`);
|
|
1535
|
+
process.stdout.write(shellHint(tool, cmd));
|
|
1536
|
+
process.stdout.write(` El tag ${tag} SÍ está publicado: la versión existe, le falta la nota.\n`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// 3. Back-merge: el otro paso que se olvida. Sin esto, integración queda una versión atrás.
|
|
1541
|
+
if (dev) {
|
|
1542
|
+
try {
|
|
1543
|
+
git(["checkout", dev]); git(["pull", "--ff-only"]);
|
|
1544
|
+
git(["merge", "--no-edit", prod]);
|
|
1545
|
+
git(["push", "origin", dev], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
|
|
1546
|
+
ok(`back-merge ${prod} → ${dev}`);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
warn(`no pude hacer el back-merge ${prod} → ${dev}: ${String(e.message).split("\n")[0]}`);
|
|
1549
|
+
process.stdout.write(` Hacelo a mano: git checkout ${dev} && git merge ${prod} && git push origin ${dev}\n`);
|
|
1550
|
+
process.stdout.write(` Si no, '${dev}' queda una versión atrás y el próximo corte arranca torcido.\n`);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// 4. Borrar la rama de release. Es el ÚNICO punto del ciclo donde dai puede afirmar que
|
|
1555
|
+
// es seguro: ya está mergeada en producción, tagueada y con el back-merge hecho. Si esto
|
|
1556
|
+
// no pasa acá, se acumulan — y una rama de release que sobrevive a su release es un fork.
|
|
1557
|
+
// Misma disciplina que `dai done`: `git branch -d` (minúscula) se niega si no está
|
|
1558
|
+
// mergeada, así que la red de seguridad la pone git, no una suposición nuestra.
|
|
1559
|
+
if (willCleanup) {
|
|
1560
|
+
if (hasLocal) {
|
|
1561
|
+
try { git(["branch", "-d", relBranch]); ok(`borrada la branch local ${relBranch}`); }
|
|
1562
|
+
catch (e) {
|
|
1563
|
+
warn(`no borré '${relBranch}' local: ${String(e.message).split("\n")[0]}`);
|
|
1564
|
+
process.stdout.write(` git se niega a borrar una branch sin mergear. Revisala: quizá tiene commits que no llegaron a '${prod}'.\n`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
if (hasRemote) {
|
|
1568
|
+
try {
|
|
1569
|
+
git(["push", "origin", "--delete", relBranch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
|
|
1570
|
+
ok(`borrada la branch remota origin/${relBranch}`);
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
warn(`no borré 'origin/${relBranch}': ${String(e.message).split("\n")[0]}`);
|
|
1573
|
+
process.stdout.write(` Borrala a mano: git push origin --delete ${relBranch}\n`);
|
|
1574
|
+
}
|
|
1575
|
+
} else if (hasLocal) {
|
|
1576
|
+
// La ref local puede seguir apuntando a una rama que el forge ya borró. Se limpia
|
|
1577
|
+
// para que el próximo `git branch -r` no muestre un fantasma.
|
|
1578
|
+
if (safeGit(["rev-parse", "--verify", "--quiet", `origin/${relBranch}`])) {
|
|
1579
|
+
safeGit(["branch", "-dr", `origin/${relBranch}`]);
|
|
1580
|
+
info(`la branch remota ya no estaba (la borró el forge al mergear); limpié la referencia local`);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// 5. El aviso. Lo último a propósito: anunciar algo que después falla es peor que no anunciar.
|
|
1586
|
+
if (willNotify) {
|
|
1587
|
+
const ev = {
|
|
1588
|
+
event: "released", app: releaseApp(opts), version, environment: null,
|
|
1589
|
+
author: gitUser(), date: formatFecha(), url: releaseUrl,
|
|
1590
|
+
stories: m.stories.map((s) => ({ id: s.id, title: s.title })),
|
|
1591
|
+
};
|
|
1592
|
+
await confirmAndNotify(cfg, ev, opts);
|
|
1593
|
+
}
|
|
1594
|
+
process.stdout.write("\n");
|
|
1595
|
+
ok(`versión ${version} cerrada.`);
|
|
1596
|
+
info(`Cuando la despliegues: dai release stamp ${version} --env <ambiente> (opcional: la release ya está hecha)`);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// ── release stamp: avisarle a cada US en qué versión y ambiente salió ────────
|
|
1600
|
+
//
|
|
1601
|
+
// Es el comando que más cuidado necesita del lote: escribe N veces hacia afuera, en
|
|
1602
|
+
// tickets de gente distinta, y no se deshace. Por eso muestra el ALCANCE REAL antes de
|
|
1603
|
+
// escribir —cuántos comentarios, en qué tickets, cuáles se saltean— y pide confirmación.
|
|
1604
|
+
//
|
|
1605
|
+
// Y es OPCIONAL: decir que no acá no rompe nada. Para cuando esto corre, el tag existe y
|
|
1606
|
+
// el release note existe; la versión ya está. Que alguien elija no hacer ruido en veinte
|
|
1607
|
+
// tickets es una decisión legítima, no un error a corregir — así que sale con 0.
|
|
1608
|
+
async function cmdReleaseStamp(versionArg, opts = {}) {
|
|
1609
|
+
loadDaiEnv();
|
|
1610
|
+
let version;
|
|
1611
|
+
try { version = normalizeVersion(versionArg); }
|
|
1612
|
+
catch (e) { fail(`${e.message}\n Uso: dai release stamp 1.2.0 --env prod`, 1); }
|
|
1613
|
+
const environment = textOpt(opts, "env");
|
|
1614
|
+
if (!environment) {
|
|
1615
|
+
fail("falta --env: a qué ambiente se desplegó esta versión (prod, pre, test, el nombre que uses).\n" +
|
|
1616
|
+
" dai no tiene catálogo de ambientes: el que pases es el que se estampa.", 1);
|
|
1617
|
+
}
|
|
1618
|
+
const app = releaseApp(opts);
|
|
1619
|
+
const tag = tagName(version);
|
|
1620
|
+
|
|
1621
|
+
// El manifiesto de ESA versión: lo que entró entre el tag anterior y este. Se pide por
|
|
1622
|
+
// tag, no por rama, porque estampar es contar qué se desplegó — y lo desplegado es el tag.
|
|
1623
|
+
const previousTag = safeGit(["describe", "--tags", "--abbrev=0", `${tag}^`])?.trim() || null;
|
|
1624
|
+
if (!safeGit(["rev-parse", "--verify", "--quiet", `${tag}^{commit}`])) {
|
|
1625
|
+
fail(`no existe el tag ${tag} en este repo.\n Cerrá la versión primero: dai release done ${version}\n (o traé los tags: git fetch --tags)`, 1);
|
|
1626
|
+
}
|
|
1627
|
+
const { manifest: m } = await releaseManifest(opts, { from: previousTag, to: tag });
|
|
1628
|
+
|
|
1629
|
+
// ¿Cuáles ya están estampadas? Sin poder leer los comentarios dai NO afirma que no las
|
|
1630
|
+
// estampó: lo dice, y quien decide es la persona. Afirmar de más acá se paga en duplicados.
|
|
1631
|
+
const marker = releaseMarker({ app, version, environment });
|
|
1632
|
+
const adapter = getAdapter(process.env);
|
|
1633
|
+
const stamped = new Set();
|
|
1634
|
+
let unknown = false;
|
|
1635
|
+
if (typeof adapter.listComments === "function") {
|
|
1636
|
+
for (const s of m.stories) {
|
|
1637
|
+
try { if (alreadyStamped(await adapter.listComments(s.id), marker)) stamped.add(s.id); }
|
|
1638
|
+
catch { unknown = true; }
|
|
1639
|
+
}
|
|
1640
|
+
} else unknown = true;
|
|
1641
|
+
|
|
1642
|
+
const plan = stampPlan({ stories: m.stories, stamped, unknown });
|
|
1643
|
+
process.stdout.write("\n" + renderStampPlan(plan, { app, version, environment, tracker: adapter.kind }) + "\n");
|
|
1644
|
+
warn(stampWarning(plan));
|
|
1645
|
+
if (plan.pending.length === 0) { info("Nada que hacer."); return; }
|
|
1646
|
+
|
|
1647
|
+
if (opts.dryRun) { info("[dry-run] no se escribió nada."); return; }
|
|
1648
|
+
if (!opts.yes) {
|
|
1649
|
+
if (!process.stdin.isTTY) {
|
|
1650
|
+
info(`Sin TTY y sin --yes: no estampo. Re-ejecutá con --yes si querés los ${plan.pending.length} comentarios.`);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const a = (await askOrCancel(` ¿Estampo ${plan.pending.length} comentario(s)? (s/N) `) || "").toLowerCase();
|
|
1654
|
+
if (!["s", "si", "sí", "y", "yes"].includes(a)) {
|
|
1655
|
+
info("No se estampó nada — la release está hecha igual.");
|
|
1656
|
+
info(NOT_STAMPED_NOTE);
|
|
1657
|
+
return; // salida 0: decir que no es una respuesta válida, no un error
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const body = renderReleaseStamp({
|
|
1662
|
+
app, version, environment, date: formatFecha(), commit: safeGit(["rev-list", "-n", "1", tag])?.trim(),
|
|
1663
|
+
url: textOpt(opts, "url") || null,
|
|
1664
|
+
});
|
|
1665
|
+
let stampedCount = 0;
|
|
1666
|
+
const failed = [];
|
|
1667
|
+
for (const s of plan.pending) {
|
|
1668
|
+
try { await adapter.comment(s.id, body); stampedCount++; ok(`${s.id} estampada`); }
|
|
1669
|
+
catch (e) { failed.push(s.id); warn(`${s.id}: ${String(e.message).split("\n")[0]}`); }
|
|
1670
|
+
}
|
|
1671
|
+
if (failed.length) {
|
|
1672
|
+
warn(`quedaron ${failed.length} sin estampar: ${failed.join(", ")}. Volvé a correr el comando — las ya hechas se saltean.`);
|
|
1673
|
+
process.exitCode = 1;
|
|
1674
|
+
} else ok(`${stampedCount} US estampada(s) con ${app} ${tag} → ${environment.toUpperCase()}.`);
|
|
1675
|
+
|
|
1676
|
+
// El aviso al canal es del EVENTO de despliegue, no del estampado: sale aunque no se
|
|
1677
|
+
// haya estampado nada (para eso está `--only-notify`), y no sale si el repo no lo declaró.
|
|
1678
|
+
const cfg = (() => { try { return notifyConfig(process.env); } catch (e) { warn(String(e.message)); return null; } })();
|
|
1679
|
+
if (cfg && !opts.noNotify) {
|
|
1680
|
+
await confirmAndNotify(cfg, {
|
|
1681
|
+
event: "deployed", app, version, environment, author: gitUser(), date: formatFecha(),
|
|
1682
|
+
url: textOpt(opts, "url") || null, stories: m.stories.map((x) => ({ id: x.id, title: x.title })),
|
|
1683
|
+
}, opts);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// ── release status: ¿dónde estoy en el ciclo? ────────────────────────────────
|
|
1688
|
+
// Todo local + el forge: rápido y sin tocar el tracker. Lo que NO contesta —qué versión
|
|
1689
|
+
// hay en cada ambiente— vive en los stamps de las US, porque un despliegue es un evento
|
|
1690
|
+
// y no un archivo del repo: cambia sin que cambie el código.
|
|
1691
|
+
async function cmdReleaseStatus(opts = {}) {
|
|
1692
|
+
loadDaiEnv();
|
|
1693
|
+
if (!gitBranch()) fail("esto no parece un repo git.", 1);
|
|
1694
|
+
const flow = branchFlow(process.env);
|
|
1695
|
+
const { version: declarada, file } = currentVersion();
|
|
1696
|
+
const tag = lastTag();
|
|
1697
|
+
const dev = flow.dev || gitBranch();
|
|
1698
|
+
const prod = flow.prod;
|
|
1699
|
+
|
|
1700
|
+
info(`versión declarada: ${declarada ? `${declarada} ${C.dim(`(${file})`)}` : C.dim("ningún archivo la espeja — la versión es el tag")}`);
|
|
1701
|
+
info(`último tag: ${tag || C.dim("(ninguno)")}`);
|
|
1702
|
+
if (tag && declarada && tagName(declarada) !== tag) {
|
|
1703
|
+
warn(`el archivo dice ${declarada} y el último tag es ${tag}: hay una versión preparada sin cerrar, o un tag sin bump.`);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ¿Hay algo sin promover? Es la pregunta que dispara el ciclo.
|
|
1707
|
+
const { manifest: m, commits } = await releaseManifest({ ...opts, noNetwork: true }, { from: tag, to: dev });
|
|
1708
|
+
if (m.counts.commits === 0) info(`'${dev}' no tiene nada nuevo sobre ${tag || "el principio"}: no hay versión que cortar.`);
|
|
1709
|
+
else {
|
|
1710
|
+
const { bump } = proposeBump(commits);
|
|
1711
|
+
warn(`'${dev}' tiene ${m.counts.commits} commit(s) sin promover · ${m.counts.stories} US · bump propuesto: ${bump}.`);
|
|
1712
|
+
process.stdout.write(` Ver el detalle: dai release plan\n`);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Back-merge pendiente: producción adelante de integración es el olvido clásico.
|
|
1716
|
+
if (prod && flow.dev) {
|
|
1717
|
+
const behind = safeGit(["rev-list", "--count", `${flow.dev}..${prod}`])?.trim();
|
|
1718
|
+
if (behind && Number(behind) > 0) {
|
|
1719
|
+
warn(`'${prod}' está ${behind} commit(s) adelante de '${flow.dev}': falta el back-merge.`);
|
|
1720
|
+
process.stdout.write(` git checkout ${flow.dev} && git merge ${prod} && git push origin ${flow.dev}\n`);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Branches de release abiertas (model B): una que sobrevive a su release es un fork.
|
|
1725
|
+
const openBranches = (safeGit(["branch", "--list", "release/*", "--format=%(refname:short)"]) || "")
|
|
1726
|
+
.split("\n").map((x) => x.trim()).filter(Boolean);
|
|
1727
|
+
if (openBranches.length) {
|
|
1728
|
+
warn(`${openBranches.length} branch(es) de release sin borrar: ${openBranches.join(", ")}`);
|
|
1729
|
+
process.stdout.write(` Una rama de release que sobrevive a su release es un fork. Desde 0.15.0 las borra\n`);
|
|
1730
|
+
process.stdout.write(` \`dai release done\`; las de antes se limpian a mano (git se niega si alguna no está mergeada):\n`);
|
|
1731
|
+
process.stdout.write(` git branch -d ${openBranches.join(" ")}\n`);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const cfg = (() => { try { return notifyConfig(process.env); } catch { return null; } })();
|
|
1735
|
+
info(`aviso al canal: ${cfg ? `${describeTarget(cfg)} ${C.dim("(no verificado: eso lo dice `dai release notify --test`)")}` : C.dim("sin declarar (DAI_NOTIFY)")}`);
|
|
1736
|
+
|
|
1737
|
+
// Los últimos releases publicados, según el forge. Es lo más cerca de "qué hay afuera"
|
|
1738
|
+
// que dai puede saber sin preguntarle al tracker ni al pipeline.
|
|
1739
|
+
if (!opts.noNetwork) {
|
|
1740
|
+
const tool = forgeTool(detectForge(parseRemote(gitRemote())?.host));
|
|
1741
|
+
try {
|
|
1742
|
+
const out = execFileSync(tool, ["release", "list", ...(tool === "gh" ? ["--limit", "3"] : ["--per-page", "3"])],
|
|
1743
|
+
{ encoding: "utf8", cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
1744
|
+
const outLines = String(out).split("\n").filter(Boolean).slice(0, 3);
|
|
1745
|
+
if (outLines.length) { info("últimos releases publicados:"); for (const l of outLines) process.stdout.write(` ${l}\n`); }
|
|
1746
|
+
} catch { /* sin binario, sin auth o sin releases: no es un problema que resolver acá */ }
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// ── release notify --test: probar el canal sin esperar a una release ─────────
|
|
1751
|
+
// Un webhook no se puede validar sin postear. Fingir que sí sería justo lo que dai no hace,
|
|
1752
|
+
// así que esto POSTEA de verdad — y lo dice antes.
|
|
1753
|
+
async function cmdReleaseNotify(opts = {}) {
|
|
1754
|
+
loadDaiEnv();
|
|
1755
|
+
let cfg;
|
|
1756
|
+
try { cfg = notifyConfig(process.env); } catch (e) { fail(String(e.message), 1); }
|
|
1757
|
+
if (!cfg) fail("no hay canal declarado: poné DAI_NOTIFY (y DAI_NOTIFY_WEBHOOK) en el .env.dai.", 1);
|
|
1758
|
+
if (!opts.test) fail("por ahora `dai release notify` solo sabe probar el canal: dai release notify --test", 2);
|
|
1759
|
+
await confirmAndNotify(cfg, { event: "test" }, opts);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// El nombre de la app en los avisos y en el stamp. Sale del repo, no de una variable
|
|
1763
|
+
// nueva: `--app` lo pisa cuando el nombre lindo no es el del directorio.
|
|
1764
|
+
function releaseApp(opts) {
|
|
1765
|
+
return textOpt(opts, "app") || gitRepoName() || basename(process.cwd()) || null;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Mostrar el mensaje EXACTO que va a salir y pedir confirmación. Un mensaje a un canal de
|
|
1769
|
+
// equipo no se desmanda: se muestra antes, siempre, aunque el aviso sea de una sola línea.
|
|
1770
|
+
async function confirmAndNotify(cfg, ev, opts = {}) {
|
|
1771
|
+
const msg = renderNotice(ev);
|
|
1772
|
+
process.stdout.write(`\n ── Aviso a ${describeTarget(cfg)} ──\n`);
|
|
1773
|
+
process.stdout.write(msg.split("\n").map((l) => ` │ ${l}`).join("\n") + "\n");
|
|
1774
|
+
process.stdout.write(` ──────────────────────────────────────\n`);
|
|
1775
|
+
if (opts.dryRun) { info("[dry-run] no se envió."); return; }
|
|
1776
|
+
if (!opts.yes) {
|
|
1777
|
+
if (!process.stdin.isTTY) { info("Sin TTY y sin --yes: no aviso al canal."); return; }
|
|
1778
|
+
const a = (await askOrCancel(` ¿Aviso al canal? (s/N) `) || "").toLowerCase();
|
|
1779
|
+
if (!["s", "si", "sí", "y", "yes"].includes(a)) { info("No se avisó — la release está hecha igual."); return; }
|
|
1780
|
+
}
|
|
1781
|
+
const r = await sendNotice(cfg, ev);
|
|
1782
|
+
if (r.ok) ok(`avisado a ${describeTarget(cfg)}`);
|
|
1783
|
+
else { warn(r.error); process.stdout.write(` El aviso no salió, pero la release SÍ está hecha.\n`); }
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function safeGit(args) { try { return git(args); } catch { return null; } }
|
|
1787
|
+
|
|
1788
|
+
// ── install: skills → ~/.claude/skills o <repo>/.claude/skills ────────────────// ── install: skills → ~/.claude/skills o <repo>/.claude/skills ────────────────
|
|
1244
1789
|
async function cmdInstall(opts) {
|
|
1245
1790
|
if (opts.from !== undefined) return cmdInstallFrom(opts); // skills externas (ADR-0013)
|
|
1246
1791
|
const skillsSrc = join(ROOT, "skills");
|
|
@@ -1635,14 +2180,14 @@ function cmdDocs(dest) {
|
|
|
1635
2180
|
// repo de nadie: las capturas de los tutoriales ni siquiera viajan en el paquete npm
|
|
1636
2181
|
// (issue #37). Se saltea, y los links que las nombran se absolutizan contra el sitio.
|
|
1637
2182
|
cpSync(join(ROOT, "docs"), dest, { recursive: true, filter: (src) => !/[/\\]public([/\\]|$)/.test(src) });
|
|
1638
|
-
let
|
|
2183
|
+
let rewritten = 0;
|
|
1639
2184
|
for (const f of walkMd(dest)) {
|
|
1640
2185
|
const md = readFileSync(f, "utf8");
|
|
1641
2186
|
const out = absolutizeSiteLinks(md);
|
|
1642
|
-
if (out !== md) { writeFileSync(f, out);
|
|
2187
|
+
if (out !== md) { writeFileSync(f, out); rewritten++; }
|
|
1643
2188
|
}
|
|
1644
2189
|
ok(`documentación copiada a ${dest}`);
|
|
1645
|
-
if (
|
|
2190
|
+
if (rewritten) info(`${rewritten} documento(s) con capturas: los links apuntan al sitio publicado.`);
|
|
1646
2191
|
}
|
|
1647
2192
|
|
|
1648
2193
|
// Los .md de un árbol, para la reescritura de links de cmdDocs.
|
|
@@ -1903,13 +2448,13 @@ function cmdDoctor() {
|
|
|
1903
2448
|
if (localActive.length && existsSync(join(cwd, "openspec"))) {
|
|
1904
2449
|
info("OpenSpec — los comandos van en el chat del asistente, no en la terminal:");
|
|
1905
2450
|
for (const a of localActive) {
|
|
1906
|
-
const
|
|
1907
|
-
if (
|
|
2451
|
+
const missing = OPSX_IDS.filter((id) => !existsSync(opsxPath(a.kind, id)));
|
|
2452
|
+
if (missing.length === OPSX_IDS.length) {
|
|
1908
2453
|
warn(`${a.label}: no hay comandos opsx. Generalos: openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force`);
|
|
1909
2454
|
} else {
|
|
1910
2455
|
const hay = OPSX_IDS.filter((id) => existsSync(opsxPath(a.kind, id)));
|
|
1911
2456
|
ok(`${a.label}: ${hay.map((id) => opsxCommand(a.kind, id)).join(" · ")}`);
|
|
1912
|
-
if (
|
|
2457
|
+
if (missing.length) warn(`${a.label}: faltan ${missing.join(", ")} — regenerá con \`openspec init --tools ${OPENSPEC_TOOL[a.kind]} --force\``);
|
|
1913
2458
|
}
|
|
1914
2459
|
}
|
|
1915
2460
|
process.stdout.write(" (el nombre sale del archivo que genera OpenSpec: solo Claude usa los dos puntos)\n");
|
|
@@ -2051,6 +2596,15 @@ switch (cmd) {
|
|
|
2051
2596
|
case "pr":
|
|
2052
2597
|
case "mr": cmdPr(opts).catch((e) => failSoft(String(e.message))); break; // `mr` = alias para GitLab (merge request)
|
|
2053
2598
|
case "done": cmdDone(opts); break;
|
|
2599
|
+
case "release":
|
|
2600
|
+
if (pos[0] === "plan") cmdReleasePlan(opts).catch((e) => failSoft(String(e.message)));
|
|
2601
|
+
else if (pos[0] === "cut") cmdReleaseCut(pos[1], opts).catch((e) => failSoft(String(e.message)));
|
|
2602
|
+
else if (pos[0] === "done") cmdReleaseDone(pos[1], opts).catch((e) => failSoft(String(e.message)));
|
|
2603
|
+
else if (pos[0] === "stamp") cmdReleaseStamp(pos[1], opts).catch((e) => failSoft(String(e.message)));
|
|
2604
|
+
else if (pos[0] === "status") cmdReleaseStatus(opts).catch((e) => failSoft(String(e.message)));
|
|
2605
|
+
else if (pos[0] === "notify") cmdReleaseNotify(opts).catch((e) => failSoft(String(e.message)));
|
|
2606
|
+
else fail(`subcomando de release desconocido: '${pos[0] ?? "(ninguno)"}' (plan | cut | done | stamp | status | notify)`, 2);
|
|
2607
|
+
break;
|
|
2054
2608
|
case "archive": cmdArchive(pos[0], opts); break;
|
|
2055
2609
|
case "install": cmdInstall(opts).catch((e) => failSoft(String(e.message))); break; // alias de `dai skills install`
|
|
2056
2610
|
case "skills":
|