@dforce2055/dai 0.13.2 → 0.14.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 +15 -0
- package/CHANGELOG.md +112 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/cli/dai.mjs +198 -71
- package/cli/lib/bootstrap.mjs +14 -3
- package/cli/lib/branch-flow.mjs +82 -0
- package/cli/lib/branch-scope.mjs +10 -1
- package/cli/lib/help.mjs +448 -0
- package/cli/lib/pm-adapter.mjs +19 -2
- package/cli/lib/pm-clickup.mjs +1 -0
- package/cli/lib/pm-jira.mjs +1 -0
- package/cli/lib/pr-remote.mjs +80 -0
- package/cli/lib/pr.mjs +20 -6
- package/cli/lib/us-format.mjs +14 -3
- package/cli/lib/us.mjs +39 -4
- package/governance/branch-naming.md +15 -1
- package/package.json +1 -1
- package/templates/pull-request.md +13 -9
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
// ── "ya existe una PR para esta branch" ──────────────────────────────────────
|
|
57
|
+
// El forge lo dice de formas distintas y en inglés. Se reconoce para poder pasar al camino
|
|
58
|
+
// de actualizar en vez de morir con el comando crudo en pantalla.
|
|
59
|
+
const ALREADY = [
|
|
60
|
+
/already exists/i,
|
|
61
|
+
/a merge request already exists/i,
|
|
62
|
+
/existing (?:pull request|merge request)/i,
|
|
63
|
+
/pull request for branch .* already exists/i,
|
|
64
|
+
/open merge request already exists/i,
|
|
65
|
+
];
|
|
66
|
+
export function isAlreadyExistsError(msg) {
|
|
67
|
+
const s = String(msg ?? "");
|
|
68
|
+
return ALREADY.some((re) => re.test(s));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Lo que se le muestra al dev cuando dai detecta la PR existente: qué va a pasar con ella.
|
|
72
|
+
export function describeUpdate(pr, { base, tool }) {
|
|
73
|
+
const lines = [`ya hay una PR/MR abierta para esta branch: #${pr.number}${pr.url ? ` — ${pr.url}` : ""}`];
|
|
74
|
+
if (pr.base && base && pr.base !== base) {
|
|
75
|
+
lines.push(`ojo: apunta a '${pr.base}' y vos pediste '${base}'. dai NO cambia la base de una PR abierta: ` +
|
|
76
|
+
`si querés otra base, cerrala y creá una nueva.`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(`dai va a ACTUALIZAR su título y su descripción con ${tool} (el diff ya lo actualiza el push).`);
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
package/cli/lib/pr.mjs
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// Parte pura y testeable: rellena el template con los datos del link + git + check.
|
|
3
3
|
// Los efectos (git push, gh/glab create) viven en dai.mjs.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Más explícito que el label del CLI a propósito: esto queda PUBLICADO en la PR, donde
|
|
6
|
+
// quien lee no tiene el contexto de la corrida que la creó.
|
|
7
|
+
const EMOJI = {
|
|
8
|
+
"al-dia": "✅ al día", atrasado: "⚠️ atrasado", "sin-us": "❓ sin US",
|
|
9
|
+
"sin-respuesta": "⚠️ no verificado (el tracker no respondió)",
|
|
10
|
+
};
|
|
6
11
|
|
|
7
12
|
// Las secciones que dai se compromete a entregar llenas. Si alguna sale con el molde
|
|
8
13
|
// del template, la PR se publica vacía y el review no tiene qué mirar (era el bug:
|
|
@@ -115,12 +120,21 @@ export function composePrBody(template, d) {
|
|
|
115
120
|
return upsertLinksBlock(b, d);
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
// Los placeholders se rellenan DENTRO de la sección, no en todo el body: la misma frase
|
|
124
|
+
// ("verificado con `dai check` ✅") aparecía en la prosa que explica el método, así que un
|
|
125
|
+
// replace global le metía el estado de ESTA PR a una oración general y quedaba
|
|
126
|
+
// "verificado con dai check: ✅ al día. Sin esto, el código no sabe…". dai reescribiendo
|
|
127
|
+
// la prosa de otro es justo lo que el bloque de links ya evitaba con sus marcadores.
|
|
128
|
+
// Sin la sección (un template ajeno con otra forma) se cae al body entero: rellenar de más
|
|
129
|
+
// es recuperable, no rellenar deja la PR con `ABC-###` publicado.
|
|
118
130
|
function fillUsHeader(template, d) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
const fill = (t) => t
|
|
132
|
+
.replace(/`ABC-###`/g, `\`${d.id}\``)
|
|
133
|
+
.replace(/@ `vX`/g, `@ \`${d.version}\``)
|
|
134
|
+
.replace(/`<hash>`/g, `\`${d.ac_hash}\``)
|
|
135
|
+
.replace(/verificado con `dai check` ✅/g, `verificado con \`dai check\`: ${EMOJI[d.status] || d.status}`);
|
|
136
|
+
const sec = sectionBody(template, "🔗 Implementa");
|
|
137
|
+
return sec == null ? fill(template) : replaceSection(template, "🔗 Implementa", fill(sec).trim());
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
// PR de una branch exenta (chore/, docs/, release/…): no implementa una US y no se le
|
package/cli/lib/us-format.mjs
CHANGED
|
@@ -108,8 +108,19 @@ export function validateUS(md) {
|
|
|
108
108
|
// (METODOLOGIA §4). Sube cuando el cambio es material, y eso lo sabe el PO: dai
|
|
109
109
|
// mirando el hash no puede distinguir un criterio nuevo de un typo corregido.
|
|
110
110
|
|
|
111
|
+
// La US se escribe a mano en un tracker, así que el nombre del campo llega como salga:
|
|
112
|
+
// `spec_version`, `spec version`, `spec-version` y —visto en un Jira real— `specversion`
|
|
113
|
+
// pegado. Con el `[_ ]` obligatorio de antes, `| specversion | v4 |` no matcheaba y el
|
|
114
|
+
// link se estampaba en `v1` sin que nada avisara (issue #46). El separador es opcional.
|
|
115
|
+
export const SPEC_VERSION_RE = /spec[\s_-]*version[^\n]*?\b(v\d+)\b/i;
|
|
116
|
+
|
|
117
|
+
// El valor que se escribe cuando la US NO declara versión. Un placeholder VISIBLE es
|
|
118
|
+
// mejor que un `v1` que parece correcto: el `v1` se publica en la PR y se estampa en el
|
|
119
|
+
// tracker como si fuera un dato, cuando es una suposición de dai.
|
|
120
|
+
export const PENDING_VERSION = "pendiente";
|
|
121
|
+
|
|
111
122
|
export const parseSpecVersion = (md) => {
|
|
112
|
-
const m = String(md || "").match(
|
|
123
|
+
const m = String(md || "").match(SPEC_VERSION_RE);
|
|
113
124
|
return m ? m[1] : null;
|
|
114
125
|
};
|
|
115
126
|
|
|
@@ -123,8 +134,8 @@ export const bumpSpecVersion = (v) => {
|
|
|
123
134
|
// al final, donde nadie lo ve, ni en una tabla que no existe.
|
|
124
135
|
export function setSpecVersion(md, version) {
|
|
125
136
|
const text = String(md || "");
|
|
126
|
-
if (
|
|
127
|
-
return text.replace(/(spec[
|
|
137
|
+
if (SPEC_VERSION_RE.test(text)) {
|
|
138
|
+
return text.replace(/(spec[\s_-]*version[^\n]*?\b)v\d+\b/i, `$1${version}`);
|
|
128
139
|
}
|
|
129
140
|
const lines = text.split(/\r?\n/);
|
|
130
141
|
const i = lines.findIndex((l) => /^#\s+\S/.test(l));
|
package/cli/lib/us.mjs
CHANGED
|
@@ -3,21 +3,34 @@
|
|
|
3
3
|
|
|
4
4
|
import { acHash } from "./ac-hash.mjs";
|
|
5
5
|
import { extractTitle } from "./link-us.mjs";
|
|
6
|
+
import { parseSpecVersion } from "./us-format.mjs";
|
|
6
7
|
|
|
7
8
|
// Parseo puro de una US (markdown o texto) → identidad + hash vivo.
|
|
8
9
|
export function parseUS(raw) {
|
|
9
10
|
const title = extractTitle(raw);
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
// El regex vive en us-format.mjs: es UNO solo para leer, escribir y bumpear la versión.
|
|
12
|
+
// Duplicado acá, la variante `specversion` (sin separador) se arreglaba en un lado y
|
|
13
|
+
// seguía rota en el otro — que es exactamente como nació el issue #46.
|
|
14
|
+
return { title, spec_version: parseSpecVersion(raw), ac_hash: acHash(raw) };
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
// Compara el hash estampado (implements.yaml) con el hash vivo de la US.
|
|
15
|
-
|
|
18
|
+
//
|
|
19
|
+
// `sin-us` y `sin-respuesta` NO son lo mismo, y confundirlos es caro: el primero es una
|
|
20
|
+
// RESPUESTA del tracker (preguntamos y la US no está), el segundo es la AUSENCIA de
|
|
21
|
+
// respuesta (sin red, sin token, 5xx, certificado corporativo). Colapsados en un mismo
|
|
22
|
+
// `null`, dai terminaba AFIRMANDO que no había US cuando lo único cierto era que no había
|
|
23
|
+
// podido preguntar — y eso se publicaba en el cuerpo de una PR, al lado del id de la US.
|
|
24
|
+
export function coverageStatus(stampedHash, liveHash, { unreachable = false } = {}) {
|
|
25
|
+
if (unreachable) return "sin-respuesta";
|
|
16
26
|
if (liveHash == null) return "sin-us";
|
|
17
27
|
return stampedHash === liveHash ? "al-dia" : "atrasado";
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
const STATUS_LABEL = {
|
|
30
|
+
const STATUS_LABEL = {
|
|
31
|
+
"al-dia": "✅ al día", atrasado: "⚠️ atrasado",
|
|
32
|
+
"sin-us": "❓ sin US", "sin-respuesta": "⚠️ no verificado",
|
|
33
|
+
};
|
|
21
34
|
export const statusLabel = (s) => STATUS_LABEL[s] || s;
|
|
22
35
|
|
|
23
36
|
// Render de la cobertura como markdown (lo que un backend "estampa").
|
|
@@ -34,3 +47,25 @@ export function renderCoverage(id, r) {
|
|
|
34
47
|
if (r.commitUrl) lines.push(`- commit: ${r.commit} → ${r.commitUrl} (ancla durable)`);
|
|
35
48
|
return lines.join("\n") + "\n";
|
|
36
49
|
}
|
|
50
|
+
|
|
51
|
+
// `fetch failed` es TODO lo que dice undici cuando no hay red, el host no resuelve, el DNS
|
|
52
|
+
// se cayó o el certificado no valida. Sin contexto es indistinguible de un bug de dai: el
|
|
53
|
+
// dev lee "dai: fetch failed" y no sabe ni qué se estaba consultando. Es el mismo modo de
|
|
54
|
+
// falla que el push por SSH de la 0.13.1 — el dato que resuelve el problema existe, y no
|
|
55
|
+
// llega. Acá se le pone alrededor qué, contra qué, y qué mirar.
|
|
56
|
+
export function explainFetchError(err, { kind, endpoint, id } = {}) {
|
|
57
|
+
const raw = String(err?.message ?? err ?? "").split("\n")[0] || "error desconocido";
|
|
58
|
+
const donde = [kind, endpoint].filter(Boolean).join(" · ");
|
|
59
|
+
const cabeza = `no pude consultar ${id ? `la US ${id}` : "el tracker"}${donde ? ` en ${donde}` : ""}: ${raw}`;
|
|
60
|
+
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|certificate|self.signed/i.test(raw)) {
|
|
61
|
+
return `${cabeza}\n No llegó a haber respuesta. Revisá la red y el host del backend; si estás detrás de un\n` +
|
|
62
|
+
` proxy corporativo, declará la CA con NODE_EXTRA_CA_CERTS (nunca NODE_TLS_REJECT_UNAUTHORIZED=0).\n` +
|
|
63
|
+
` Diagnóstico: dai doctor`;
|
|
64
|
+
}
|
|
65
|
+
if (/\b40[13]\b|unauthorized|forbidden/i.test(raw)) {
|
|
66
|
+
return `${cabeza}\n El tracker rechazó las credenciales: revisá el token del .env.dai (¿venció?) y sus permisos.\n` +
|
|
67
|
+
` Diagnóstico: dai doctor`;
|
|
68
|
+
}
|
|
69
|
+
if (/\b5\d\d\b/.test(raw)) return `${cabeza}\n El error es del tracker, no tuyo: probá de nuevo en un rato.`;
|
|
70
|
+
return cabeza;
|
|
71
|
+
}
|
|
@@ -20,7 +20,21 @@ Ejemplo: `feature/ABC-482-finalizar-compra-sin-duplicado`
|
|
|
20
20
|
- El **ID nunca se tipea a mano**: sale del argumento de `/link-us ABC-###`. Elimina
|
|
21
21
|
el error de tipeo que rompe el link ([Art. 8](../docs/MANIFIESTO.md#art-8), Art. 9).
|
|
22
22
|
- El **slug** deriva del título de la US: minúsculas, sin acentos ni ñ, espacios → `-`.
|
|
23
|
-
- La **base** de
|
|
23
|
+
- La **base** de una PR no se recuerda ni se configura una por una: sale del **tipo de
|
|
24
|
+
rama**, leyendo las dos ramas de vida larga que el repo declara en su `.env.dai`.
|
|
25
|
+
|
|
26
|
+
| Tipo de rama | PR contra | Variable |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `feature/`, `fix/`, el resto | la rama que integra | `DAI_BRANCH_DEV` |
|
|
29
|
+
| `release/`, `hotfix/` | la rama que despliega a producción | `DAI_BRANCH_PROD` |
|
|
30
|
+
|
|
31
|
+
`dai pr` y `dai done` usan ese mapa; `--base` siempre gana. Si `DAI_BRANCH_DEV` no está
|
|
32
|
+
declarada, dai usa la rama default del remoto y **avisa que la está adivinando**.
|
|
33
|
+
- Apuntarle a `DAI_BRANCH_PROD` pide una **confirmación explícita**: hay que escribir el
|
|
34
|
+
nombre de la rama, y con `--yes` hace falta `--to-prod`. En repos con ramas de ambiente
|
|
35
|
+
(`testing` integra, `main` va a producción) esto es lo que evita que una PR quede
|
|
36
|
+
proponiendo un despliegue que nadie pidió. Sin la variable declarada, dai no marca
|
|
37
|
+
ninguna rama como producción: no adivina cuál es.
|
|
24
38
|
- Una rama, una US. Si una US toca varios repos, es una rama por repo, **todas con el
|
|
25
39
|
mismo `ABC-###`** — así el índice las agrupa en una fila (federación).
|
|
26
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforce2055/dai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
|
|
5
5
|
"repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
|
|
6
6
|
"homepage": "https://dforce2055.github.io/dai/",
|
|
@@ -5,19 +5,23 @@
|
|
|
5
5
|
Lo puede pre-llenar `dai`/una skill a partir del implements.yaml y el diff.
|
|
6
6
|
-->
|
|
7
7
|
|
|
8
|
-
> **Un PR en dai entrega dos activos, y el review cubre los dos:**
|
|
9
|
-
> 1. **La implementación** — el código que resuelve la US.
|
|
10
|
-
> 2. **El spec trazable** — el `implements.yaml` con el link a la US y el `@version`
|
|
11
|
-
> (`ac_hash`) verificado con `dai check` ✅. Sin esto, el código no sabe *a qué QUÉ*
|
|
12
|
-
> responde, y el CI bloquea el PR (ver `governance/ci-rules.md`).
|
|
13
|
-
|
|
14
8
|
## 🔗 Implementa
|
|
15
9
|
|
|
16
10
|
- **US:** `ABC-###` @ `vX` · ac_hash: `<hash>` · verificado con `dai check` ✅
|
|
17
|
-
- **Link:** este PR está atado a la US vía `implements.yaml`.
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
<!--
|
|
13
|
+
Un PR en dai entrega DOS activos, y el review cubre los dos: la implementación (el código
|
|
14
|
+
que resuelve la US) y el spec trazable (el `implements.yaml` con el link a la US y el
|
|
15
|
+
`@version`/`ac_hash` verificado). Sin el link, el código no sabe a qué QUÉ responde y el
|
|
16
|
+
CI bloquea el PR — ver `governance/ci-rules.md`.
|
|
17
|
+
|
|
18
|
+
¿Chore o fix sin ticket? No hay nada que borrar: `dai pr` lo detecta por el nombre de la
|
|
19
|
+
branch y escribe "Sin US" con el motivo. No se le exige link.
|
|
20
|
+
|
|
21
|
+
Esto es un comentario a propósito: es doctrina del método, igual en las 500 PRs del repo.
|
|
22
|
+
Repetirla a la vista en cada una entrena a saltear el principio del cuerpo, que es
|
|
23
|
+
justamente donde va la descripción.
|
|
24
|
+
-->
|
|
21
25
|
|
|
22
26
|
## Descripción
|
|
23
27
|
|