@alexis-reillo/git-helper 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,32 +1,75 @@
1
1
  # git-helper (CLI)
2
2
 
3
- Analiza Pull Requests de GitHub usando Inteligencia Artificial, directamente desde la terminal.
3
+ Analiza Pull Requests de GitHub usando Inteligencia Artificial, directamente desde
4
+ la terminal — con una **interfaz interactiva a pantalla completa** estilo Claude Code.
4
5
 
5
6
  ## Instalación
6
7
 
7
8
  ```bash
8
9
  # Uso puntual, sin instalar
9
- npx @alexis-reillo/git-helper analyze -o vercel -r next.js -p 12345
10
+ npx @alexis-reillo/git-helper
10
11
 
11
12
  # Instalación global
12
13
  npm i -g @alexis-reillo/git-helper
13
- git-helper analyze -o vercel -r next.js -p 12345
14
+ git-helper
14
15
  ```
15
16
 
16
17
  Requiere **Node.js >= 18**.
17
18
 
18
- ## Uso
19
+ ## Inicio rápido (interfaz interactiva)
19
20
 
20
- ### Comandos
21
+ Ejecuta `git-helper` **sin argumentos** para abrir la TUI a pantalla completa:
22
+
23
+ ```bash
24
+ git-helper
25
+ ```
26
+
27
+ - La **primera vez** te pedirá tu **token de GitHub** (no hace falta configurarlo
28
+ a mano antes): lo pegas, se guarda y entras directo. Ver
29
+ [Token de GitHub](#token-de-github-primer-uso).
30
+ - Verás la lista de tus **PRs pendientes**. Navega con `↑↓`, pulsa `⏎` para
31
+ analizar uno con IA, `r` para refrescar y `q` para salir.
32
+
33
+ > **Tip visual:** usa una terminal moderna (Windows Terminal, iTerm2…) con una
34
+ > [Nerd Font](https://www.nerdfonts.com/) para que la mascota y los iconos se
35
+ > vean perfectos.
36
+
37
+ ## Token de GitHub (primer uso)
38
+
39
+ git-helper necesita un **personal access token** de GitHub para listar tus Pull
40
+ Requests pendientes. Tienes tres formas de dárselo (de más a menos cómoda):
41
+
42
+ 1. **Automática (recomendada):** abre `git-helper` y, si no hay token, la propia
43
+ interfaz te lo pedirá y lo guardará por ti. Nada más que hacer.
44
+ 2. **Con el comando `config`** (persiste en `~/.config/git-helper/.env`, permisos `0600`):
45
+ ```bash
46
+ git-helper config set GITHUB_TOKEN ghp_xxx
47
+ ```
48
+ 3. **Variable de entorno** (puntual, no se guarda):
49
+ ```bash
50
+ export GITHUB_TOKEN=ghp_xxx # bash/zsh
51
+ $env:GITHUB_TOKEN="ghp_xxx" # PowerShell
52
+ ```
53
+
54
+ **Cómo crear el token:** ve a
55
+ [github.com/settings/tokens](https://github.com/settings/tokens) → *Generate new
56
+ token* → marca el scope **`repo`** (o un fine-grained con lectura de repos y PRs).
57
+
58
+ > El token se guarda **solo en tu equipo**. Para verlo/borrarlo:
59
+ > `git-helper config list` (enmascarado) · `git-helper config unset GITHUB_TOKEN`.
60
+
61
+ ## Comandos (modo no interactivo)
62
+
63
+ Además de la TUI, todo está disponible como comandos sueltos (útil para scripting):
21
64
 
22
65
  | Comando | Alias | Descripción |
23
66
  | ------------------- | --------- | -------------------------------------------- |
24
- | `git-helper` | — | Muestra el banner y la ayuda. |
25
- | `git-helper list` | `ls` | Lista tus Pull Requests pendientes de revisar (requiere `GITHUB_TOKEN`). |
67
+ | `git-helper` | — | Abre la interfaz interactiva a pantalla completa. |
68
+ | `git-helper list` | `ls` | Lista tus Pull Requests pendientes (requiere `GITHUB_TOKEN`). |
26
69
  | `git-helper review` | `analyze` | Analiza un PR concreto con IA. |
27
70
  | `git-helper config` | — | Gestiona la configuración guardada (claves y tokens). |
28
71
 
29
- #### `review`
72
+ ### `review`
30
73
 
31
74
  ```bash
32
75
  git-helper review --owner <owner> --repo <repo> --pr <number>
@@ -39,53 +82,33 @@ git-helper review --owner <owner> --repo <repo> --pr <number>
39
82
  | `--pr <number>` | `-p` | Número del Pull Request |
40
83
  | `--json` | — | Imprime el resultado en JSON (sin formato visual) |
41
84
 
42
- #### `list`
85
+ ### `list`
43
86
 
44
87
  ```bash
45
- export GITHUB_TOKEN=ghp_...
46
88
  git-helper list # tabla bonita
47
89
  git-helper list --json # salida JSON para scripts
48
90
  ```
49
91
 
50
- #### `config`
51
-
52
- Guarda tus claves de forma persistente en `~/.config/git-helper/.env` (permisos
53
- `0600`), como alternativa a exportar variables de entorno cada vez.
92
+ ### `config`
54
93
 
55
94
  ```bash
56
95
  git-helper config set GITHUB_TOKEN ghp_...
57
- git-helper config set AI_PROVIDER gemini
58
96
  git-helper config set GOOGLE_GENERATIVE_AI_API_KEY ...
59
97
  git-helper config list # secretos enmascarados
60
98
  git-helper config unset OPENAI_API_KEY
61
99
  git-helper config path # ruta del archivo
62
100
  ```
63
101
 
64
- Prioridad de configuración: **variables de entorno reales** > `.env` del directorio
65
- actual > config global guardada.
66
-
67
- > **Tip visual:** la interfaz usa color truecolor y arte ASCII. Para que la
68
- > mascota y los iconos se vean perfectos, usa una terminal moderna (Windows
69
- > Terminal, iTerm2…) con una [Nerd Font](https://www.nerdfonts.com/).
70
-
71
102
  ## Configuración (variables de entorno)
72
103
 
73
- La CLI lee la configuración del entorno del sistema (o de un archivo `.env` en el
74
- directorio actual). Necesitas, como mínimo, la clave del proveedor de IA elegido.
104
+ Prioridad: **variables de entorno reales** > `.env` del directorio actual >
105
+ config global guardada (`config set`).
75
106
 
76
107
  | Variable | Requerida | Descripción |
77
108
  | --------------------------------- | ------------------- | -------------------------------------------------------------- |
109
+ | `GITHUB_TOKEN` | Para `list` y la TUI | Token de GitHub (scope `repo`). Sube el rate limit y permite repos privados. |
78
110
  | `AI_PROVIDER` | No (`gemini`) | Proveedor de IA: `gemini` u `openai`. |
79
111
  | `GOOGLE_GENERATIVE_AI_API_KEY` | Si `AI_PROVIDER=gemini` | Clave de [Google AI Studio](https://aistudio.google.com/apikey). |
80
112
  | `OPENAI_API_KEY` | Si `AI_PROVIDER=openai` | Clave de OpenAI. |
81
113
  | `AI_MODEL` | No | Fuerza un modelo concreto (por defecto `gemini-2.5-flash` / `gpt-4o-mini`). |
82
114
  | `AI_ENSEMBLE_RUNS` | No (`3`) | Nº de ejecuciones del ensemble por análisis (mediana). `1` lo desactiva. |
83
- | `GITHUB_TOKEN` | No | Sube el rate limit y permite analizar repos privados. |
84
-
85
- Ejemplo en bash:
86
-
87
- ```bash
88
- export AI_PROVIDER=gemini
89
- export GOOGLE_GENERATIVE_AI_API_KEY=tu_clave
90
- git-helper analyze -o vercel -r next.js -p 12345
91
- ```
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../packages/core/src/index.ts
4
+ import { Octokit } from "@octokit/rest";
5
+ import { generateObject } from "ai";
6
+ import { openai } from "@ai-sdk/openai";
7
+ import { google } from "@ai-sdk/google";
8
+ import { z } from "zod";
9
+ var RECOMENDACIONES = [
10
+ "aprobar",
11
+ "cambios_menores",
12
+ "cambios_mayores",
13
+ "bloqueado"
14
+ ];
15
+ var PRAnalysisSchema = z.object({
16
+ resumen_ejecutivo: z.string().describe("Resumen de 2 frases de lo que hace el PR"),
17
+ posibles_bugs: z.array(z.string()).describe("Lista de posibles bugs o errores l\xF3gicos"),
18
+ apto_para_merge: z.boolean().describe("True si no hay bugs cr\xEDticos ni mayores"),
19
+ puntuacion_codigo: z.number().min(1).max(10).describe("Puntuaci\xF3n de calidad del 1 al 10"),
20
+ recomendacion: z.enum(RECOMENDACIONES).describe("Recomendaci\xF3n categ\xF3rica de revisi\xF3n")
21
+ });
22
+ var DEFAULT_MODELS = {
23
+ gemini: "gemini-2.5-flash",
24
+ // Rápido y con free tier en Google AI Studio
25
+ openai: "gpt-4o-mini"
26
+ // Rápido y barato
27
+ };
28
+ function resolveModel(provider, model) {
29
+ const selected = (provider ?? process.env.AI_PROVIDER ?? "gemini").toLowerCase();
30
+ const modelName = model ?? process.env.AI_MODEL;
31
+ switch (selected) {
32
+ case "gemini":
33
+ case "google":
34
+ if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
35
+ throw new Error(
36
+ 'Falta la variable de entorno GOOGLE_GENERATIVE_AI_API_KEY requerida para el proveedor "gemini".'
37
+ );
38
+ }
39
+ return google(modelName ?? DEFAULT_MODELS.gemini);
40
+ case "openai":
41
+ if (!process.env.OPENAI_API_KEY) {
42
+ throw new Error(
43
+ 'Falta la variable de entorno OPENAI_API_KEY requerida para el proveedor "openai".'
44
+ );
45
+ }
46
+ return openai(modelName ?? DEFAULT_MODELS.openai);
47
+ default:
48
+ throw new Error(
49
+ `AI_PROVIDER desconocido: "${selected}". Valores v\xE1lidos: "gemini" u "openai".`
50
+ );
51
+ }
52
+ }
53
+ var SCORING_RUBRIC = `Eres un ingeniero de software Senior revisando un Pull Request. Eval\xFAa \xDANICAMENTE el diff proporcionado.
54
+
55
+ Criterios a revisar, por orden de importancia (basados en el est\xE1ndar de Google):
56
+ 1. Dise\xF1o: \xBFencajan e interact\xFAan bien las piezas del cambio? \xBFpertenece aqu\xED y se integra con el resto del sistema?
57
+ 2. Complejidad / over-engineering: \xBFes m\xE1s complejo o gen\xE9rico de lo necesario? \xBFse entiende r\xE1pido al leerlo?
58
+ 3. Correctitud y bugs: errores l\xF3gicos, casos l\xEDmite, concurrencia.
59
+ 4. Seguridad: inyecci\xF3n, fuga de secretos, autenticaci\xF3n y permisos.
60
+ 5. Manejo de errores y casos l\xEDmite.
61
+ 6. Tests: \xBFhay pruebas correctas para los cambios?
62
+ 7. Legibilidad/mantenibilidad y rendimiento.
63
+
64
+ Clasifica cada problema que encuentres por severidad, de forma ESTRICTA y CONSISTENTE:
65
+ - "critico": bug que rompe funcionalidad, vulnerabilidad de seguridad, fuga de secretos, o cualquier cosa que bloquee el merge.
66
+ - "mayor": problema notable que conviene resolver (dise\xF1o deficiente, validaci\xF3n ausente, manejo de errores pobre, deuda t\xE9cnica relevante, regresi\xF3n funcional, l\xF3gica nueva sin tests).
67
+ - "menor": mejora opcional no bloqueante (estilo, naming, micro-optimizaci\xF3n, documentaci\xF3n).
68
+
69
+ Ejemplos de calibraci\xF3n (para anclar la severidad de forma consistente):
70
+ - "Concatena entrada del usuario en una query SQL sin parametrizar" => critico (seguridad).
71
+ - "Expone una clave/API key o secreto en el c\xF3digo" => critico (fuga de secretos).
72
+ - "No comprueba si el array est\xE1 vac\xEDo antes de acceder a [0]" => mayor (caso l\xEDmite no manejado).
73
+ - "A\xF1ade una rama de error nueva pero ning\xFAn test que la cubra" => mayor (tests ausentes en l\xF3gica relevante).
74
+ - "Abstracci\xF3n gen\xE9rica para un \xFAnico caso de uso actual" => mayor (over-engineering).
75
+ - "Variable poco descriptiva como 'x' o falta un comentario" => menor (legibilidad).
76
+
77
+ Reglas obligatorias:
78
+ - Incluye SOLO problemas reales y concretos presentes en el diff; nada gen\xE9rico ni especulativo. Prioriza precisi\xF3n sobre exhaustividad (menos ruido, m\xE1s se\xF1al).
79
+ - El mismo diff debe producir SIEMPRE la misma lista de problemas con la misma severidad. S\xE9 reproducible y objetivo.
80
+ - Si el diff est\xE1 truncado, eval\xFAa solo lo visible y no penalices por lo que falte.
81
+ - "resumen_ejecutivo": 2 frases neutrales sobre qu\xE9 hace el PR.
82
+ - NO asignes una puntuaci\xF3n num\xE9rica: la calcula el sistema a partir de tus problemas. Tu trabajo es detectarlos y clasificarlos correctamente.`;
83
+ var ReviewModelSchema = z.object({
84
+ resumen_ejecutivo: z.string().describe("Resumen de 2 frases de lo que hace el PR"),
85
+ problemas: z.array(
86
+ z.object({
87
+ severidad: z.enum(["critico", "mayor", "menor"]),
88
+ descripcion: z.string().describe("Problema concreto y real presente en el diff")
89
+ })
90
+ ).describe("Lista de problemas detectados; vac\xEDa si no hay ninguno")
91
+ });
92
+ var PENALIZACION = {
93
+ critico: 3,
94
+ mayor: 1.5,
95
+ menor: 0.5
96
+ };
97
+ function computeScore(problemas) {
98
+ const criticos = problemas.filter((p) => p.severidad === "critico").length;
99
+ const mayores = problemas.filter((p) => p.severidad === "mayor").length;
100
+ const menores = problemas.filter((p) => p.severidad === "menor").length;
101
+ const penalizacion = problemas.reduce(
102
+ (acc, p) => acc + PENALIZACION[p.severidad],
103
+ 0
104
+ );
105
+ const puntuacion = Math.max(1, Math.min(10, Math.round(10 - penalizacion)));
106
+ let recomendacion;
107
+ if (criticos > 0) {
108
+ recomendacion = "bloqueado";
109
+ } else if (mayores > 0) {
110
+ recomendacion = "cambios_mayores";
111
+ } else if (menores > 0) {
112
+ recomendacion = "cambios_menores";
113
+ } else {
114
+ recomendacion = "aprobar";
115
+ }
116
+ const apto = criticos === 0 && mayores === 0;
117
+ return { puntuacion, apto, recomendacion };
118
+ }
119
+ var DEFAULT_ENSEMBLE_RUNS = 3;
120
+ var GitHubAIService = class {
121
+ // Aceptamos el token, pero si no se pasa, funciona para repos públicos (con límite de peticiones).
122
+ // aiOptions permite forzar proveedor/modelo/nº de ejecuciones; si se omite, se usa la config por entorno.
123
+ constructor(token, aiOptions) {
124
+ this.token = token;
125
+ this.aiOptions = aiOptions;
126
+ this.octokit = new Octokit({ auth: token });
127
+ }
128
+ // Obtener el diff (código añadido/borrado) de un PR
129
+ async getPRDiff(owner, repo, prNumber) {
130
+ const response = await this.octokit.pulls.get({
131
+ owner,
132
+ repo,
133
+ pull_number: prNumber,
134
+ mediaType: { format: "diff" }
135
+ // Mágia de Octokit para pedir el diff en texto plano
136
+ });
137
+ return response.data;
138
+ }
139
+ // Comprueba que tenemos un token antes de llamar a endpoints que requieren un usuario autenticado.
140
+ requireAuth(accion) {
141
+ if (!this.token) {
142
+ throw new Error(`Se requiere autenticaci\xF3n de GitHub para ${accion}.`);
143
+ }
144
+ }
145
+ // Obtiene los datos del usuario autenticado (sirve para verificar el token y mostrar quién está logueado)
146
+ async getAuthenticatedUser() {
147
+ this.requireAuth("obtener tu usuario de GitHub");
148
+ const { data } = await this.octokit.users.getAuthenticated();
149
+ return {
150
+ login: data.login,
151
+ name: data.name ?? null,
152
+ avatar_url: data.avatar_url
153
+ };
154
+ }
155
+ // Lista los repositorios del usuario autenticado, ordenados por última actualización
156
+ async listUserRepos() {
157
+ this.requireAuth("listar tus repositorios");
158
+ const { data } = await this.octokit.repos.listForAuthenticatedUser({
159
+ per_page: 100,
160
+ sort: "updated"
161
+ });
162
+ return data.map((repo) => ({
163
+ owner: repo.owner.login,
164
+ name: repo.name,
165
+ full_name: repo.full_name,
166
+ private: repo.private,
167
+ description: repo.description ?? null,
168
+ updated_at: repo.updated_at ?? null
169
+ }));
170
+ }
171
+ // Lista los Pull Requests abiertos de un repositorio
172
+ async listOpenPullRequests(owner, repo) {
173
+ const { data } = await this.octokit.pulls.list({
174
+ owner,
175
+ repo,
176
+ state: "open"
177
+ });
178
+ return data.map((pull) => ({
179
+ number: pull.number,
180
+ title: pull.title,
181
+ author: pull.user?.login ?? null,
182
+ created_at: pull.created_at,
183
+ url: pull.html_url
184
+ }));
185
+ }
186
+ // Lista los Pull Requests abiertos en los repos del usuario autenticado
187
+ // (los que tiene "pendientes de aceptar/revisar"). Usa la búsqueda de GitHub
188
+ // para resolverlo en una sola petición en lugar de iterar repo por repo.
189
+ async listPendingPullRequests() {
190
+ const { login } = await this.getAuthenticatedUser();
191
+ const { data } = await this.octokit.search.issuesAndPullRequests({
192
+ q: `is:pr is:open archived:false user:${login}`,
193
+ sort: "updated",
194
+ order: "desc",
195
+ per_page: 50
196
+ });
197
+ return data.items.map((item) => {
198
+ const segments = item.repository_url.split("/");
199
+ const repo = segments.pop() ?? "";
200
+ const owner = segments.pop() ?? "";
201
+ return {
202
+ owner,
203
+ repo,
204
+ full_name: `${owner}/${repo}`,
205
+ number: item.number,
206
+ title: item.title,
207
+ author: item.user?.login ?? null,
208
+ created_at: item.created_at,
209
+ url: item.html_url,
210
+ draft: item.draft ?? false
211
+ };
212
+ });
213
+ }
214
+ // Una única revisión: pide al modelo los problemas y deriva el análisis.
215
+ async runReview(model, diff) {
216
+ const { object } = await generateObject({
217
+ model,
218
+ schema: ReviewModelSchema,
219
+ // temperature 0 = detección (casi) determinista
220
+ temperature: 0,
221
+ // Los estándares van como `system` (estable); el diff como `prompt` (variable)
222
+ system: SCORING_RUBRIC,
223
+ prompt: `Detecta y clasifica los problemas del siguiente diff siguiendo los est\xE1ndares.
224
+
225
+ Diff:
226
+ ${diff}`
227
+ });
228
+ const { puntuacion, apto, recomendacion } = computeScore(object.problemas);
229
+ return {
230
+ resumen_ejecutivo: object.resumen_ejecutivo,
231
+ // Prefijamos cada bug con su severidad para que sea visible en la UI
232
+ posibles_bugs: object.problemas.map(
233
+ (p) => `[${p.severidad}] ${p.descripcion}`
234
+ ),
235
+ apto_para_merge: apto,
236
+ puntuacion_codigo: puntuacion,
237
+ recomendacion
238
+ };
239
+ }
240
+ // El método que une GitHub + IA.
241
+ // Ejecuta el análisis varias veces (ensemble) y se queda con el resultado
242
+ // de la MEDIANA por puntuación, lo que filtra ejecuciones atípicas y reduce
243
+ // la varianza entre sesiones (práctica recomendada en LLM-as-a-judge).
244
+ async analyzePR(owner, repo, prNumber) {
245
+ console.log(`[Core] Descargando diff de ${owner}/${repo}#${prNumber}...`);
246
+ const diff = await this.getPRDiff(owner, repo, prNumber);
247
+ const diffLimitado = diff.substring(0, 2e4);
248
+ if (diffLimitado.trim().length === 0) {
249
+ throw new Error("El PR no tiene cambios de c\xF3digo (diff vac\xEDo).");
250
+ }
251
+ const model = resolveModel(this.aiOptions?.provider, this.aiOptions?.model);
252
+ const envRuns = Number(process.env.AI_ENSEMBLE_RUNS);
253
+ const configuredRuns = this.aiOptions?.ensembleRuns ?? (Number.isFinite(envRuns) && envRuns > 0 ? envRuns : DEFAULT_ENSEMBLE_RUNS);
254
+ const runs = Math.max(1, configuredRuns);
255
+ console.log(`[Core] Analizando con ${runs} ejecuci\xF3n(es) (${diffLimitado.length} caracteres)...`);
256
+ const settled = await Promise.allSettled(
257
+ Array.from({ length: runs }, () => this.runReview(model, diffLimitado))
258
+ );
259
+ const exitosas = settled.filter(
260
+ (r) => r.status === "fulfilled"
261
+ ).map((r) => r.value);
262
+ if (exitosas.length === 0) {
263
+ const motivo = settled[0]?.status === "rejected" ? settled[0].reason : "desconocido";
264
+ throw new Error(
265
+ `No se pudo completar el an\xE1lisis: ${motivo instanceof Error ? motivo.message : motivo}`
266
+ );
267
+ }
268
+ exitosas.sort((a, b) => a.puntuacion_codigo - b.puntuacion_codigo);
269
+ return exitosas[Math.floor(exitosas.length / 2)];
270
+ }
271
+ };
272
+
273
+ // src/config.ts
274
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
275
+ import { homedir } from "os";
276
+ import { join } from "path";
277
+ import dotenv from "dotenv";
278
+ var CONFIG_DIR = join(homedir(), ".config", "git-helper");
279
+ var CONFIG_FILE = join(CONFIG_DIR, ".env");
280
+ var CONFIG_KEYS = [
281
+ "AI_PROVIDER",
282
+ "AI_MODEL",
283
+ "AI_ENSEMBLE_RUNS",
284
+ "GOOGLE_GENERATIVE_AI_API_KEY",
285
+ "OPENAI_API_KEY",
286
+ "GITHUB_TOKEN"
287
+ ];
288
+ var SECRET_KEYS = /* @__PURE__ */ new Set([
289
+ "GOOGLE_GENERATIVE_AI_API_KEY",
290
+ "OPENAI_API_KEY",
291
+ "GITHUB_TOKEN"
292
+ ]);
293
+ var isConfigKey = (k) => CONFIG_KEYS.includes(k);
294
+ function maskValue(key, value) {
295
+ if (!SECRET_KEYS.has(key) || value.length <= 4) return value;
296
+ return "\u2022".repeat(Math.min(6, value.length - 4)) + value.slice(-4);
297
+ }
298
+ function loadGlobalConfig() {
299
+ if (existsSync(CONFIG_FILE)) {
300
+ dotenv.config({ path: CONFIG_FILE, quiet: true });
301
+ }
302
+ }
303
+ function readConfig() {
304
+ if (!existsSync(CONFIG_FILE)) return {};
305
+ return dotenv.parse(readFileSync(CONFIG_FILE));
306
+ }
307
+ function writeConfig(values) {
308
+ mkdirSync(CONFIG_DIR, { recursive: true });
309
+ const body = CONFIG_KEYS.filter((k) => values[k] !== void 0).map((k) => `${k}=${values[k]}`).join("\n") + "\n";
310
+ writeFileSync(CONFIG_FILE, body, { mode: 384 });
311
+ }
312
+ function setConfig(key, value) {
313
+ const current = readConfig();
314
+ current[key] = value;
315
+ writeConfig(current);
316
+ }
317
+ function unsetConfig(key) {
318
+ const current = readConfig();
319
+ if (current[key] === void 0) return;
320
+ delete current[key];
321
+ writeConfig(current);
322
+ }
323
+
324
+ export {
325
+ GitHubAIService,
326
+ CONFIG_FILE,
327
+ CONFIG_KEYS,
328
+ isConfigKey,
329
+ maskValue,
330
+ loadGlobalConfig,
331
+ readConfig,
332
+ setConfig,
333
+ unsetConfig
334
+ };
package/dist/index.js CHANGED
@@ -1,330 +1,48 @@
1
1
  #!/usr/bin/env node
2
- "use strict";
3
- var __create = Object.create;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __getProtoOf = Object.getPrototypeOf;
8
- var __hasOwnProp = Object.prototype.hasOwnProperty;
9
- var __copyProps = (to, from, except, desc) => {
10
- if (from && typeof from === "object" || typeof from === "function") {
11
- for (let key of __getOwnPropNames(from))
12
- if (!__hasOwnProp.call(to, key) && key !== except)
13
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
- }
15
- return to;
16
- };
17
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
- // If the importer is in node compatibility mode or this is not an ESM
19
- // file that has been converted to a CommonJS file using a Babel-
20
- // compatible transform (i.e. "__esModule" has not been set), then set
21
- // "default" to the CommonJS "module.exports" for node compatibility.
22
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
- mod
24
- ));
2
+ import {
3
+ CONFIG_FILE,
4
+ CONFIG_KEYS,
5
+ GitHubAIService,
6
+ isConfigKey,
7
+ loadGlobalConfig,
8
+ maskValue,
9
+ readConfig,
10
+ setConfig,
11
+ unsetConfig
12
+ } from "./chunk-QR7XJSI6.js";
25
13
 
26
14
  // src/index.ts
27
- var import_dotenv2 = __toESM(require("dotenv"));
28
- var import_commander = require("commander");
29
- var import_ora = __toESM(require("ora"));
30
-
31
- // ../../packages/core/src/index.ts
32
- var import_rest = require("@octokit/rest");
33
- var import_ai = require("ai");
34
- var import_openai = require("@ai-sdk/openai");
35
- var import_google = require("@ai-sdk/google");
36
- var import_zod = require("zod");
37
- var RECOMENDACIONES = [
38
- "aprobar",
39
- "cambios_menores",
40
- "cambios_mayores",
41
- "bloqueado"
42
- ];
43
- var PRAnalysisSchema = import_zod.z.object({
44
- resumen_ejecutivo: import_zod.z.string().describe("Resumen de 2 frases de lo que hace el PR"),
45
- posibles_bugs: import_zod.z.array(import_zod.z.string()).describe("Lista de posibles bugs o errores l\xF3gicos"),
46
- apto_para_merge: import_zod.z.boolean().describe("True si no hay bugs cr\xEDticos ni mayores"),
47
- puntuacion_codigo: import_zod.z.number().min(1).max(10).describe("Puntuaci\xF3n de calidad del 1 al 10"),
48
- recomendacion: import_zod.z.enum(RECOMENDACIONES).describe("Recomendaci\xF3n categ\xF3rica de revisi\xF3n")
49
- });
50
- var DEFAULT_MODELS = {
51
- gemini: "gemini-2.5-flash",
52
- // Rápido y con free tier en Google AI Studio
53
- openai: "gpt-4o-mini"
54
- // Rápido y barato
55
- };
56
- function resolveModel(provider, model) {
57
- const selected = (provider ?? process.env.AI_PROVIDER ?? "gemini").toLowerCase();
58
- const modelName = model ?? process.env.AI_MODEL;
59
- switch (selected) {
60
- case "gemini":
61
- case "google":
62
- if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
63
- throw new Error(
64
- 'Falta la variable de entorno GOOGLE_GENERATIVE_AI_API_KEY requerida para el proveedor "gemini".'
65
- );
66
- }
67
- return (0, import_google.google)(modelName ?? DEFAULT_MODELS.gemini);
68
- case "openai":
69
- if (!process.env.OPENAI_API_KEY) {
70
- throw new Error(
71
- 'Falta la variable de entorno OPENAI_API_KEY requerida para el proveedor "openai".'
72
- );
73
- }
74
- return (0, import_openai.openai)(modelName ?? DEFAULT_MODELS.openai);
75
- default:
76
- throw new Error(
77
- `AI_PROVIDER desconocido: "${selected}". Valores v\xE1lidos: "gemini" u "openai".`
78
- );
79
- }
80
- }
81
- var SCORING_RUBRIC = `Eres un ingeniero de software Senior revisando un Pull Request. Eval\xFAa \xDANICAMENTE el diff proporcionado.
82
-
83
- Criterios a revisar, por orden de importancia (basados en el est\xE1ndar de Google):
84
- 1. Dise\xF1o: \xBFencajan e interact\xFAan bien las piezas del cambio? \xBFpertenece aqu\xED y se integra con el resto del sistema?
85
- 2. Complejidad / over-engineering: \xBFes m\xE1s complejo o gen\xE9rico de lo necesario? \xBFse entiende r\xE1pido al leerlo?
86
- 3. Correctitud y bugs: errores l\xF3gicos, casos l\xEDmite, concurrencia.
87
- 4. Seguridad: inyecci\xF3n, fuga de secretos, autenticaci\xF3n y permisos.
88
- 5. Manejo de errores y casos l\xEDmite.
89
- 6. Tests: \xBFhay pruebas correctas para los cambios?
90
- 7. Legibilidad/mantenibilidad y rendimiento.
91
-
92
- Clasifica cada problema que encuentres por severidad, de forma ESTRICTA y CONSISTENTE:
93
- - "critico": bug que rompe funcionalidad, vulnerabilidad de seguridad, fuga de secretos, o cualquier cosa que bloquee el merge.
94
- - "mayor": problema notable que conviene resolver (dise\xF1o deficiente, validaci\xF3n ausente, manejo de errores pobre, deuda t\xE9cnica relevante, regresi\xF3n funcional, l\xF3gica nueva sin tests).
95
- - "menor": mejora opcional no bloqueante (estilo, naming, micro-optimizaci\xF3n, documentaci\xF3n).
96
-
97
- Ejemplos de calibraci\xF3n (para anclar la severidad de forma consistente):
98
- - "Concatena entrada del usuario en una query SQL sin parametrizar" => critico (seguridad).
99
- - "Expone una clave/API key o secreto en el c\xF3digo" => critico (fuga de secretos).
100
- - "No comprueba si el array est\xE1 vac\xEDo antes de acceder a [0]" => mayor (caso l\xEDmite no manejado).
101
- - "A\xF1ade una rama de error nueva pero ning\xFAn test que la cubra" => mayor (tests ausentes en l\xF3gica relevante).
102
- - "Abstracci\xF3n gen\xE9rica para un \xFAnico caso de uso actual" => mayor (over-engineering).
103
- - "Variable poco descriptiva como 'x' o falta un comentario" => menor (legibilidad).
104
-
105
- Reglas obligatorias:
106
- - Incluye SOLO problemas reales y concretos presentes en el diff; nada gen\xE9rico ni especulativo. Prioriza precisi\xF3n sobre exhaustividad (menos ruido, m\xE1s se\xF1al).
107
- - El mismo diff debe producir SIEMPRE la misma lista de problemas con la misma severidad. S\xE9 reproducible y objetivo.
108
- - Si el diff est\xE1 truncado, eval\xFAa solo lo visible y no penalices por lo que falte.
109
- - "resumen_ejecutivo": 2 frases neutrales sobre qu\xE9 hace el PR.
110
- - NO asignes una puntuaci\xF3n num\xE9rica: la calcula el sistema a partir de tus problemas. Tu trabajo es detectarlos y clasificarlos correctamente.`;
111
- var ReviewModelSchema = import_zod.z.object({
112
- resumen_ejecutivo: import_zod.z.string().describe("Resumen de 2 frases de lo que hace el PR"),
113
- problemas: import_zod.z.array(
114
- import_zod.z.object({
115
- severidad: import_zod.z.enum(["critico", "mayor", "menor"]),
116
- descripcion: import_zod.z.string().describe("Problema concreto y real presente en el diff")
117
- })
118
- ).describe("Lista de problemas detectados; vac\xEDa si no hay ninguno")
119
- });
120
- var PENALIZACION = {
121
- critico: 3,
122
- mayor: 1.5,
123
- menor: 0.5
124
- };
125
- function computeScore(problemas) {
126
- const criticos = problemas.filter((p) => p.severidad === "critico").length;
127
- const mayores = problemas.filter((p) => p.severidad === "mayor").length;
128
- const menores = problemas.filter((p) => p.severidad === "menor").length;
129
- const penalizacion = problemas.reduce(
130
- (acc, p) => acc + PENALIZACION[p.severidad],
131
- 0
132
- );
133
- const puntuacion = Math.max(1, Math.min(10, Math.round(10 - penalizacion)));
134
- let recomendacion;
135
- if (criticos > 0) {
136
- recomendacion = "bloqueado";
137
- } else if (mayores > 0) {
138
- recomendacion = "cambios_mayores";
139
- } else if (menores > 0) {
140
- recomendacion = "cambios_menores";
141
- } else {
142
- recomendacion = "aprobar";
143
- }
144
- const apto = criticos === 0 && mayores === 0;
145
- return { puntuacion, apto, recomendacion };
146
- }
147
- var DEFAULT_ENSEMBLE_RUNS = 3;
148
- var GitHubAIService = class {
149
- // Aceptamos el token, pero si no se pasa, funciona para repos públicos (con límite de peticiones).
150
- // aiOptions permite forzar proveedor/modelo/nº de ejecuciones; si se omite, se usa la config por entorno.
151
- constructor(token, aiOptions) {
152
- this.token = token;
153
- this.aiOptions = aiOptions;
154
- this.octokit = new import_rest.Octokit({ auth: token });
155
- }
156
- // Obtener el diff (código añadido/borrado) de un PR
157
- async getPRDiff(owner, repo, prNumber) {
158
- const response = await this.octokit.pulls.get({
159
- owner,
160
- repo,
161
- pull_number: prNumber,
162
- mediaType: { format: "diff" }
163
- // Mágia de Octokit para pedir el diff en texto plano
164
- });
165
- return response.data;
166
- }
167
- // Comprueba que tenemos un token antes de llamar a endpoints que requieren un usuario autenticado.
168
- requireAuth(accion) {
169
- if (!this.token) {
170
- throw new Error(`Se requiere autenticaci\xF3n de GitHub para ${accion}.`);
171
- }
172
- }
173
- // Obtiene los datos del usuario autenticado (sirve para verificar el token y mostrar quién está logueado)
174
- async getAuthenticatedUser() {
175
- this.requireAuth("obtener tu usuario de GitHub");
176
- const { data } = await this.octokit.users.getAuthenticated();
177
- return {
178
- login: data.login,
179
- name: data.name ?? null,
180
- avatar_url: data.avatar_url
181
- };
182
- }
183
- // Lista los repositorios del usuario autenticado, ordenados por última actualización
184
- async listUserRepos() {
185
- this.requireAuth("listar tus repositorios");
186
- const { data } = await this.octokit.repos.listForAuthenticatedUser({
187
- per_page: 100,
188
- sort: "updated"
189
- });
190
- return data.map((repo) => ({
191
- owner: repo.owner.login,
192
- name: repo.name,
193
- full_name: repo.full_name,
194
- private: repo.private,
195
- description: repo.description ?? null,
196
- updated_at: repo.updated_at ?? null
197
- }));
198
- }
199
- // Lista los Pull Requests abiertos de un repositorio
200
- async listOpenPullRequests(owner, repo) {
201
- const { data } = await this.octokit.pulls.list({
202
- owner,
203
- repo,
204
- state: "open"
205
- });
206
- return data.map((pull) => ({
207
- number: pull.number,
208
- title: pull.title,
209
- author: pull.user?.login ?? null,
210
- created_at: pull.created_at,
211
- url: pull.html_url
212
- }));
213
- }
214
- // Lista los Pull Requests abiertos en los repos del usuario autenticado
215
- // (los que tiene "pendientes de aceptar/revisar"). Usa la búsqueda de GitHub
216
- // para resolverlo en una sola petición en lugar de iterar repo por repo.
217
- async listPendingPullRequests() {
218
- const { login } = await this.getAuthenticatedUser();
219
- const { data } = await this.octokit.search.issuesAndPullRequests({
220
- q: `is:pr is:open archived:false user:${login}`,
221
- sort: "updated",
222
- order: "desc",
223
- per_page: 50
224
- });
225
- return data.items.map((item) => {
226
- const segments = item.repository_url.split("/");
227
- const repo = segments.pop() ?? "";
228
- const owner = segments.pop() ?? "";
229
- return {
230
- owner,
231
- repo,
232
- full_name: `${owner}/${repo}`,
233
- number: item.number,
234
- title: item.title,
235
- author: item.user?.login ?? null,
236
- created_at: item.created_at,
237
- url: item.html_url,
238
- draft: item.draft ?? false
239
- };
240
- });
241
- }
242
- // Una única revisión: pide al modelo los problemas y deriva el análisis.
243
- async runReview(model, diff) {
244
- const { object } = await (0, import_ai.generateObject)({
245
- model,
246
- schema: ReviewModelSchema,
247
- // temperature 0 = detección (casi) determinista
248
- temperature: 0,
249
- // Los estándares van como `system` (estable); el diff como `prompt` (variable)
250
- system: SCORING_RUBRIC,
251
- prompt: `Detecta y clasifica los problemas del siguiente diff siguiendo los est\xE1ndares.
252
-
253
- Diff:
254
- ${diff}`
255
- });
256
- const { puntuacion, apto, recomendacion } = computeScore(object.problemas);
257
- return {
258
- resumen_ejecutivo: object.resumen_ejecutivo,
259
- // Prefijamos cada bug con su severidad para que sea visible en la UI
260
- posibles_bugs: object.problemas.map(
261
- (p) => `[${p.severidad}] ${p.descripcion}`
262
- ),
263
- apto_para_merge: apto,
264
- puntuacion_codigo: puntuacion,
265
- recomendacion
266
- };
267
- }
268
- // El método que une GitHub + IA.
269
- // Ejecuta el análisis varias veces (ensemble) y se queda con el resultado
270
- // de la MEDIANA por puntuación, lo que filtra ejecuciones atípicas y reduce
271
- // la varianza entre sesiones (práctica recomendada en LLM-as-a-judge).
272
- async analyzePR(owner, repo, prNumber) {
273
- console.log(`[Core] Descargando diff de ${owner}/${repo}#${prNumber}...`);
274
- const diff = await this.getPRDiff(owner, repo, prNumber);
275
- const diffLimitado = diff.substring(0, 2e4);
276
- if (diffLimitado.trim().length === 0) {
277
- throw new Error("El PR no tiene cambios de c\xF3digo (diff vac\xEDo).");
278
- }
279
- const model = resolveModel(this.aiOptions?.provider, this.aiOptions?.model);
280
- const envRuns = Number(process.env.AI_ENSEMBLE_RUNS);
281
- const configuredRuns = this.aiOptions?.ensembleRuns ?? (Number.isFinite(envRuns) && envRuns > 0 ? envRuns : DEFAULT_ENSEMBLE_RUNS);
282
- const runs = Math.max(1, configuredRuns);
283
- console.log(`[Core] Analizando con ${runs} ejecuci\xF3n(es) (${diffLimitado.length} caracteres)...`);
284
- const settled = await Promise.allSettled(
285
- Array.from({ length: runs }, () => this.runReview(model, diffLimitado))
286
- );
287
- const exitosas = settled.filter(
288
- (r) => r.status === "fulfilled"
289
- ).map((r) => r.value);
290
- if (exitosas.length === 0) {
291
- const motivo = settled[0]?.status === "rejected" ? settled[0].reason : "desconocido";
292
- throw new Error(
293
- `No se pudo completar el an\xE1lisis: ${motivo instanceof Error ? motivo.message : motivo}`
294
- );
295
- }
296
- exitosas.sort((a, b) => a.puntuacion_codigo - b.puntuacion_codigo);
297
- return exitosas[Math.floor(exitosas.length / 2)];
298
- }
299
- };
15
+ import dotenv from "dotenv";
16
+ import { Command } from "commander";
17
+ import ora from "ora";
300
18
 
301
19
  // src/ui/banner.ts
302
- var import_figlet = __toESM(require("figlet"));
20
+ import figlet from "figlet";
303
21
 
304
22
  // src/ui/theme.ts
305
- var import_chalk = __toESM(require("chalk"));
306
- var import_gradient_string = __toESM(require("gradient-string"));
23
+ import chalk from "chalk";
24
+ import gradient from "gradient-string";
307
25
  var PURPLE = "#8b5cf6";
308
26
  var PURPLE_DARK = "#6d28d9";
309
27
  var PURPLE_LIGHT = "#a78bfa";
310
28
  var c = {
311
- purple: import_chalk.default.hex(PURPLE),
312
- purpleBold: import_chalk.default.hex(PURPLE).bold,
313
- light: import_chalk.default.hex(PURPLE_LIGHT),
314
- dim: import_chalk.default.hex("#6b7280"),
29
+ purple: chalk.hex(PURPLE),
30
+ purpleBold: chalk.hex(PURPLE).bold,
31
+ light: chalk.hex(PURPLE_LIGHT),
32
+ dim: chalk.hex("#6b7280"),
315
33
  // gray-500
316
- gray: import_chalk.default.hex("#9ca3af"),
34
+ gray: chalk.hex("#9ca3af"),
317
35
  // gray-400
318
- white: import_chalk.default.whiteBright,
319
- bold: import_chalk.default.bold,
320
- ok: import_chalk.default.hex("#22c55e"),
36
+ white: chalk.whiteBright,
37
+ bold: chalk.bold,
38
+ ok: chalk.hex("#22c55e"),
321
39
  // green-500
322
- bad: import_chalk.default.hex("#ef4444"),
40
+ bad: chalk.hex("#ef4444"),
323
41
  // red-500
324
- warn: import_chalk.default.hex("#f59e0b")
42
+ warn: chalk.hex("#f59e0b")
325
43
  // amber-500
326
44
  };
327
- var purpleGradient = (text) => (0, import_gradient_string.default)([PURPLE_LIGHT, PURPLE, PURPLE_DARK]).multiline(text);
45
+ var purpleGradient = (text) => gradient([PURPLE_LIGHT, PURPLE, PURPLE_DARK]).multiline(text);
328
46
 
329
47
  // src/ui/banner.ts
330
48
  var OCTOPUS = [
@@ -372,7 +90,7 @@ function boxify(lines) {
372
90
  return [top, ...body, bottom];
373
91
  }
374
92
  function renderBanner() {
375
- const title = import_figlet.default.textSync("Git-Helper", { font: "Standard" }).replace(/\s+$/gm, "").split("\n").filter((l) => l.length > 0);
93
+ const title = figlet.textSync("Git-Helper", { font: "Standard" }).replace(/\s+$/gm, "").split("\n").filter((l) => l.length > 0);
376
94
  const composed = joinSideBySide(OCTOPUS, title, 3);
377
95
  const blockWidth = Math.max(...composed.map((l) => l.length));
378
96
  const tagPad = Math.max(0, Math.floor((blockWidth - TAGLINE.length) / 2));
@@ -382,7 +100,7 @@ function renderBanner() {
382
100
  }
383
101
 
384
102
  // src/ui/render.ts
385
- var import_cli_table3 = __toESM(require("cli-table3"));
103
+ import Table from "cli-table3";
386
104
  var ANSI = /\[[0-9;]*m/g;
387
105
  var visibleLen = (s) => s.replace(ANSI, "").length;
388
106
  var padVisible = (s, w) => s + " ".repeat(Math.max(0, w - visibleLen(s)));
@@ -473,7 +191,7 @@ function analysisCard(ref, a) {
473
191
  return box(lines, ref);
474
192
  }
475
193
  function pendingTable(prs) {
476
- const table = new import_cli_table3.default({
194
+ const table = new Table({
477
195
  head: ["", "Repo", "PR", "T\xEDtulo", "Autor", "Abierto"].map(
478
196
  (h) => c.purpleBold(h)
479
197
  ),
@@ -509,65 +227,14 @@ function pendingTable(prs) {
509
227
  return table.toString().split("\n").map((l) => l.replace(/[╭╮╰╯─┬┴├┤┼│]/g, (ch) => c.dim(ch))).join("\n");
510
228
  }
511
229
 
512
- // src/config.ts
513
- var import_node_fs = require("fs");
514
- var import_node_os = require("os");
515
- var import_node_path = require("path");
516
- var import_dotenv = __toESM(require("dotenv"));
517
- var CONFIG_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "git-helper");
518
- var CONFIG_FILE = (0, import_node_path.join)(CONFIG_DIR, ".env");
519
- var CONFIG_KEYS = [
520
- "AI_PROVIDER",
521
- "AI_MODEL",
522
- "AI_ENSEMBLE_RUNS",
523
- "GOOGLE_GENERATIVE_AI_API_KEY",
524
- "OPENAI_API_KEY",
525
- "GITHUB_TOKEN"
526
- ];
527
- var SECRET_KEYS = /* @__PURE__ */ new Set([
528
- "GOOGLE_GENERATIVE_AI_API_KEY",
529
- "OPENAI_API_KEY",
530
- "GITHUB_TOKEN"
531
- ]);
532
- var isConfigKey = (k) => CONFIG_KEYS.includes(k);
533
- function maskValue(key, value) {
534
- if (!SECRET_KEYS.has(key) || value.length <= 4) return value;
535
- return "\u2022".repeat(Math.min(6, value.length - 4)) + value.slice(-4);
536
- }
537
- function loadGlobalConfig() {
538
- if ((0, import_node_fs.existsSync)(CONFIG_FILE)) {
539
- import_dotenv.default.config({ path: CONFIG_FILE, quiet: true });
540
- }
541
- }
542
- function readConfig() {
543
- if (!(0, import_node_fs.existsSync)(CONFIG_FILE)) return {};
544
- return import_dotenv.default.parse((0, import_node_fs.readFileSync)(CONFIG_FILE));
545
- }
546
- function writeConfig(values) {
547
- (0, import_node_fs.mkdirSync)(CONFIG_DIR, { recursive: true });
548
- const body = CONFIG_KEYS.filter((k) => values[k] !== void 0).map((k) => `${k}=${values[k]}`).join("\n") + "\n";
549
- (0, import_node_fs.writeFileSync)(CONFIG_FILE, body, { mode: 384 });
550
- }
551
- function setConfig(key, value) {
552
- const current = readConfig();
553
- current[key] = value;
554
- writeConfig(current);
555
- }
556
- function unsetConfig(key) {
557
- const current = readConfig();
558
- if (current[key] === void 0) return;
559
- delete current[key];
560
- writeConfig(current);
561
- }
562
-
563
230
  // src/index.ts
564
- import_dotenv2.default.config({ quiet: true });
231
+ dotenv.config({ quiet: true });
565
232
  loadGlobalConfig();
566
- var program = new import_commander.Command();
567
- program.name("git-helper").description("Code review de Pull Requests de GitHub con IA, desde la terminal").version("0.1.0", "-V, --version", "muestra la versi\xF3n").addHelpText("beforeAll", renderBanner());
233
+ var program = new Command();
234
+ program.name("git-helper").description("Code review de Pull Requests de GitHub con IA, desde la terminal").version("0.2.0", "-V, --version", "muestra la versi\xF3n").addHelpText("beforeAll", renderBanner());
568
235
  program.command("review").alias("analyze").description("Analiza un PR concreto con IA").requiredOption("-o, --owner <owner>", "propietario del repo (ej: vercel)").requiredOption("-r, --repo <repo>", "nombre del repositorio (ej: next.js)").requiredOption("-p, --pr <number>", "n\xFAmero del Pull Request", (v) => parseInt(v, 10)).option("--json", "imprime el resultado en JSON (sin formato visual)").action(async (opts) => {
569
236
  const ref = `${opts.owner}/${opts.repo} #${opts.pr}`;
570
- const spinner = opts.json ? null : (0, import_ora.default)({ text: c.gray(`Analizando ${ref} con IA\u2026`), color: "magenta" }).start();
237
+ const spinner = opts.json ? null : ora({ text: c.gray(`Analizando ${ref} con IA\u2026`), color: "magenta" }).start();
571
238
  const service = new GitHubAIService(process.env.GITHUB_TOKEN);
572
239
  try {
573
240
  const analysis = await service.analyzePR(opts.owner, opts.repo, opts.pr);
@@ -591,7 +258,7 @@ program.command("list").alias("ls").description("Lista tus Pull Requests pendien
591
258
  process.exitCode = 1;
592
259
  return;
593
260
  }
594
- const spinner = opts.json ? null : (0, import_ora.default)({ text: c.gray("Buscando tus PRs pendientes\u2026"), color: "magenta" }).start();
261
+ const spinner = opts.json ? null : ora({ text: c.gray("Buscando tus PRs pendientes\u2026"), color: "magenta" }).start();
595
262
  const service = new GitHubAIService(process.env.GITHUB_TOKEN);
596
263
  try {
597
264
  const prs = await service.listPendingPullRequests();
@@ -652,7 +319,8 @@ config.command("unset <key>").description("Elimina una clave de configuraci\xF3n
652
319
  });
653
320
  config.command("path").description("Muestra la ruta del archivo de configuraci\xF3n").action(() => console.log(CONFIG_FILE));
654
321
  config.action(() => config.help());
655
- program.action(() => {
656
- program.help();
322
+ program.action(async () => {
323
+ const { runTui } = await import("./run-3BP46L5I.js");
324
+ await runTui(process.env.GITHUB_TOKEN);
657
325
  });
658
326
  program.parse();
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ GitHubAIService,
4
+ setConfig
5
+ } from "./chunk-QR7XJSI6.js";
6
+
7
+ // src/tui/run.tsx
8
+ import { render } from "ink";
9
+
10
+ // src/tui/App.tsx
11
+ import { useEffect, useState } from "react";
12
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
13
+ import Spinner from "ink-spinner";
14
+ import TextInput from "ink-text-input";
15
+
16
+ // src/tui/theme.ts
17
+ var colors = {
18
+ purple: "#8b5cf6",
19
+ purpleLight: "#a78bfa",
20
+ dim: "#6b7280",
21
+ gray: "#9ca3af",
22
+ fg: "#e5e7eb",
23
+ ok: "#22c55e",
24
+ warn: "#f59e0b",
25
+ bad: "#ef4444",
26
+ sky: "#38bdf8"
27
+ };
28
+
29
+ // src/tui/App.tsx
30
+ import { jsx, jsxs } from "react/jsx-runtime";
31
+ function timeAgo(iso) {
32
+ const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 6e4);
33
+ if (mins < 60) return `${mins}m`;
34
+ const h = Math.floor(mins / 60);
35
+ if (h < 24) return `${h}h`;
36
+ return `${Math.floor(h / 24)}d`;
37
+ }
38
+ var RECO_LABEL = {
39
+ aprobar: "Aprobar",
40
+ cambios_menores: "Cambios menores",
41
+ cambios_mayores: "Cambios mayores",
42
+ bloqueado: "Bloqueado"
43
+ };
44
+ var RECO_COLOR = {
45
+ aprobar: colors.ok,
46
+ cambios_menores: colors.sky,
47
+ cambios_mayores: colors.warn,
48
+ bloqueado: colors.bad
49
+ };
50
+ function Header() {
51
+ return /* @__PURE__ */ jsxs(
52
+ Box,
53
+ {
54
+ borderStyle: "round",
55
+ borderColor: colors.purple,
56
+ paddingX: 1,
57
+ justifyContent: "space-between",
58
+ children: [
59
+ /* @__PURE__ */ jsxs(Box, { children: [
60
+ /* @__PURE__ */ jsx(Text, { color: colors.purpleLight, children: "\u2B21 " }),
61
+ /* @__PURE__ */ jsx(Text, { color: colors.purple, bold: true, children: "Git-Helper" }),
62
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: " \xB7 code review con IA" })
63
+ ] }),
64
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: "\u{1F419}" })
65
+ ]
66
+ }
67
+ );
68
+ }
69
+ function StatusBar({ view }) {
70
+ const keys = view === "list" ? [
71
+ ["\u2191\u2193", "navegar"],
72
+ ["\u23CE", "analizar"],
73
+ ["r", "refrescar"],
74
+ ["q", "salir"]
75
+ ] : view === "analysis" ? [
76
+ ["esc", "volver"],
77
+ ["q", "salir"]
78
+ ] : [
79
+ ["\u23CE", "guardar"],
80
+ ["ctrl+c", "salir"]
81
+ ];
82
+ return /* @__PURE__ */ jsx(Box, { paddingX: 1, gap: 2, children: keys.map(([k, label]) => /* @__PURE__ */ jsxs(Box, { children: [
83
+ /* @__PURE__ */ jsx(Text, { color: colors.purpleLight, bold: true, children: k }),
84
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: ` ${label}` })
85
+ ] }, k)) });
86
+ }
87
+ function TokenPrompt({ onSubmit }) {
88
+ const [value, setValue] = useState("");
89
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
90
+ /* @__PURE__ */ jsx(Text, { color: colors.fg, bold: true, children: "Configura tu token de GitHub" }),
91
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
92
+ /* @__PURE__ */ jsx(Text, { color: colors.gray, children: "Lo necesito para listar tus Pull Requests pendientes." }),
93
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: "Cr\xE9alo en https://github.com/settings/tokens (scope: repo) y p\xE9galo aqu\xED." })
94
+ ] }),
95
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
96
+ /* @__PURE__ */ jsx(Text, { color: colors.purpleLight, children: "GITHUB_TOKEN \u25B8 " }),
97
+ /* @__PURE__ */ jsx(
98
+ TextInput,
99
+ {
100
+ value,
101
+ onChange: setValue,
102
+ onSubmit,
103
+ mask: "\u2022",
104
+ placeholder: "ghp_\u2026"
105
+ }
106
+ )
107
+ ] }),
108
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.dim, children: "Se guardar\xE1 en ~/.config/git-helper/.env (solo en tu equipo)." }) })
109
+ ] });
110
+ }
111
+ function PRListView({
112
+ prs,
113
+ selected,
114
+ loading,
115
+ error
116
+ }) {
117
+ if (loading) {
118
+ return /* @__PURE__ */ jsxs(Box, { paddingX: 1, paddingY: 1, children: [
119
+ /* @__PURE__ */ jsx(Text, { color: colors.purple, children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
120
+ /* @__PURE__ */ jsx(Text, { color: colors.gray, children: " Cargando tus PRs pendientes\u2026" })
121
+ ] });
122
+ }
123
+ if (error) {
124
+ return /* @__PURE__ */ jsx(Box, { paddingX: 1, paddingY: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.bad, children: `\u2717 ${error}` }) });
125
+ }
126
+ if (prs.length === 0) {
127
+ return /* @__PURE__ */ jsx(Box, { paddingX: 1, paddingY: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.ok, children: "\u2713 No tienes PRs pendientes. \xA1Todo limpio!" }) });
128
+ }
129
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: prs.map((pr, i) => {
130
+ const active = i === selected;
131
+ return /* @__PURE__ */ jsxs(Box, { children: [
132
+ /* @__PURE__ */ jsx(Text, { color: active ? colors.purple : colors.dim, children: active ? "\u25B6 " : " " }),
133
+ /* @__PURE__ */ jsx(Box, { width: 50, children: /* @__PURE__ */ jsx(
134
+ Text,
135
+ {
136
+ color: active ? colors.fg : colors.gray,
137
+ bold: active,
138
+ wrap: "truncate-end",
139
+ children: pr.title
140
+ }
141
+ ) }),
142
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: ` ${pr.full_name} #${pr.number} \xB7 ${timeAgo(pr.created_at)}` })
143
+ ] }, `${pr.full_name}#${pr.number}`);
144
+ }) });
145
+ }
146
+ function AnalysisView({
147
+ refLabel,
148
+ analyzing,
149
+ analysis,
150
+ error
151
+ }) {
152
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
153
+ /* @__PURE__ */ jsx(Text, { color: colors.purpleLight, bold: true, children: refLabel }),
154
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: "column", children: analyzing ? /* @__PURE__ */ jsxs(Box, { children: [
155
+ /* @__PURE__ */ jsx(Text, { color: colors.purple, children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
156
+ /* @__PURE__ */ jsx(Text, { color: colors.gray, children: " Analizando con IA\u2026" })
157
+ ] }) : error ? /* @__PURE__ */ jsx(Text, { color: colors.bad, children: `\u2717 ${error}` }) : analysis ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
158
+ /* @__PURE__ */ jsxs(Box, { children: [
159
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: "Puntuaci\xF3n " }),
160
+ /* @__PURE__ */ jsx(
161
+ Text,
162
+ {
163
+ color: analysis.puntuacion_codigo >= 8 ? colors.ok : analysis.puntuacion_codigo >= 5 ? colors.warn : colors.bad,
164
+ bold: true,
165
+ children: `${analysis.puntuacion_codigo}/10`
166
+ }
167
+ ),
168
+ /* @__PURE__ */ jsx(Text, { color: colors.dim, children: " Veredicto " }),
169
+ /* @__PURE__ */ jsx(Text, { color: RECO_COLOR[analysis.recomendacion], bold: true, children: `\u25CF ${RECO_LABEL[analysis.recomendacion]}` })
170
+ ] }),
171
+ /* @__PURE__ */ jsx(Text, { color: colors.gray, children: analysis.resumen_ejecutivo }),
172
+ analysis.posibles_bugs.length > 0 ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
173
+ /* @__PURE__ */ jsx(Text, { color: colors.warn, children: "Posibles bugs:" }),
174
+ analysis.posibles_bugs.map((b, i) => /* @__PURE__ */ jsx(Text, { color: colors.fg, children: ` \u2022 ${b}` }, i))
175
+ ] }) : /* @__PURE__ */ jsx(Text, { color: colors.ok, children: "\u2713 Sin bugs evidentes." })
176
+ ] }) : null })
177
+ ] });
178
+ }
179
+ function App({ token: initialToken }) {
180
+ const { exit } = useApp();
181
+ const { stdout } = useStdout();
182
+ const rows = stdout?.rows ?? 24;
183
+ const [token, setToken] = useState(initialToken);
184
+ const [prs, setPrs] = useState([]);
185
+ const [loading, setLoading] = useState(Boolean(initialToken));
186
+ const [listError, setListError] = useState(null);
187
+ const [selected, setSelected] = useState(0);
188
+ const [view, setView] = useState(initialToken ? "list" : "token");
189
+ const [refLabel, setRefLabel] = useState("");
190
+ const [analysis, setAnalysis] = useState(null);
191
+ const [analyzing, setAnalyzing] = useState(false);
192
+ const [analyzeError, setAnalyzeError] = useState(null);
193
+ async function loadList(tok) {
194
+ setLoading(true);
195
+ setListError(null);
196
+ try {
197
+ const data = await new GitHubAIService(tok).listPendingPullRequests();
198
+ setPrs(data);
199
+ setSelected(0);
200
+ } catch (e) {
201
+ setListError(e?.message ?? String(e));
202
+ } finally {
203
+ setLoading(false);
204
+ }
205
+ }
206
+ useEffect(() => {
207
+ if (initialToken) void loadList(initialToken);
208
+ }, []);
209
+ function handleToken(value) {
210
+ const t = value.trim();
211
+ if (!t) return;
212
+ setConfig("GITHUB_TOKEN", t);
213
+ process.env.GITHUB_TOKEN = t;
214
+ setToken(t);
215
+ setView("list");
216
+ void loadList(t);
217
+ }
218
+ async function analyze(pr) {
219
+ if (!token) return;
220
+ setRefLabel(`${pr.full_name} #${pr.number}`);
221
+ setView("analysis");
222
+ setAnalysis(null);
223
+ setAnalyzeError(null);
224
+ setAnalyzing(true);
225
+ try {
226
+ const result = await new GitHubAIService(token).analyzePR(
227
+ pr.owner,
228
+ pr.repo,
229
+ pr.number
230
+ );
231
+ setAnalysis(result);
232
+ } catch (e) {
233
+ setAnalyzeError(e?.message ?? String(e));
234
+ } finally {
235
+ setAnalyzing(false);
236
+ }
237
+ }
238
+ useInput(
239
+ (input, key) => {
240
+ if (input === "q") {
241
+ exit();
242
+ return;
243
+ }
244
+ if (view === "list") {
245
+ if (key.upArrow) setSelected((s) => Math.max(0, s - 1));
246
+ if (key.downArrow) setSelected((s) => Math.min(prs.length - 1, s + 1));
247
+ if (key.return && prs[selected]) void analyze(prs[selected]);
248
+ if (input === "r" && token) void loadList(token);
249
+ } else if (view === "analysis") {
250
+ if (key.escape) setView("list");
251
+ }
252
+ },
253
+ // En la vista de token, el TextInput gestiona el teclado (no capturamos 'q').
254
+ { isActive: view !== "token" }
255
+ );
256
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: rows, children: [
257
+ /* @__PURE__ */ jsx(Header, {}),
258
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, flexDirection: "column", children: view === "token" ? /* @__PURE__ */ jsx(TokenPrompt, { onSubmit: handleToken }) : view === "list" ? /* @__PURE__ */ jsx(PRListView, { prs, selected, loading, error: listError }) : /* @__PURE__ */ jsx(
259
+ AnalysisView,
260
+ {
261
+ refLabel,
262
+ analyzing,
263
+ analysis,
264
+ error: analyzeError
265
+ }
266
+ ) }),
267
+ /* @__PURE__ */ jsx(StatusBar, { view })
268
+ ] });
269
+ }
270
+
271
+ // src/tui/run.tsx
272
+ import { jsx as jsx2 } from "react/jsx-runtime";
273
+ async function runTui(token) {
274
+ const enterAltScreen = "\x1B[?1049h\x1B[H";
275
+ const leaveAltScreen = "\x1B[?1049l";
276
+ process.stdout.write(enterAltScreen);
277
+ const { waitUntilExit } = render(/* @__PURE__ */ jsx2(App, { token }));
278
+ try {
279
+ await waitUntilExit();
280
+ } finally {
281
+ process.stdout.write(leaveAltScreen);
282
+ }
283
+ }
284
+ export {
285
+ runTui
286
+ };
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@alexis-reillo/git-helper",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Analiza Pull Requests de GitHub usando Inteligencia Artificial, desde la terminal.",
5
5
  "license": "MIT",
6
+ "type": "module",
6
7
  "bin": {
7
8
  "git-helper": "./dist/index.js"
8
9
  },
@@ -27,13 +28,19 @@
27
28
  "dotenv": "^17.4.2",
28
29
  "figlet": "^1.8.0",
29
30
  "gradient-string": "^2.0.2",
31
+ "ink": "^7.1.0",
32
+ "ink-spinner": "^5.0.0",
33
+ "ink-text-input": "^6.0.0",
30
34
  "ora": "^5.4.1",
35
+ "react": "^19.2.0",
31
36
  "zod": "^4.4.3"
32
37
  },
33
38
  "devDependencies": {
34
39
  "@types/figlet": "^1.7.0",
35
40
  "@types/gradient-string": "^1.1.6",
36
41
  "@types/node": "^20",
42
+ "@types/react": "^19.0.0",
43
+ "ink-testing-library": "^4.0.0",
37
44
  "tsup": "^8.0.0",
38
45
  "tsx": "^4.7.0",
39
46
  "vitest": "^2.1.9",