@alexis-reillo/git-helper 0.1.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.
Files changed (3) hide show
  1. package/README.md +91 -0
  2. package/dist/index.js +658 -0
  3. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # git-helper (CLI)
2
+
3
+ Analiza Pull Requests de GitHub usando Inteligencia Artificial, directamente desde la terminal.
4
+
5
+ ## Instalación
6
+
7
+ ```bash
8
+ # Uso puntual, sin instalar
9
+ npx @alexis-reillo/git-helper analyze -o vercel -r next.js -p 12345
10
+
11
+ # Instalación global
12
+ npm i -g @alexis-reillo/git-helper
13
+ git-helper analyze -o vercel -r next.js -p 12345
14
+ ```
15
+
16
+ Requiere **Node.js >= 18**.
17
+
18
+ ## Uso
19
+
20
+ ### Comandos
21
+
22
+ | Comando | Alias | Descripción |
23
+ | ------------------- | --------- | -------------------------------------------- |
24
+ | `git-helper` | — | Muestra el banner y la ayuda. |
25
+ | `git-helper list` | `ls` | Lista tus Pull Requests pendientes de revisar (requiere `GITHUB_TOKEN`). |
26
+ | `git-helper review` | `analyze` | Analiza un PR concreto con IA. |
27
+ | `git-helper config` | — | Gestiona la configuración guardada (claves y tokens). |
28
+
29
+ #### `review`
30
+
31
+ ```bash
32
+ git-helper review --owner <owner> --repo <repo> --pr <number>
33
+ ```
34
+
35
+ | Opción | Alias | Descripción |
36
+ | ----------------- | ----- | ------------------------------------ |
37
+ | `--owner <owner>` | `-o` | Propietario del repo (ej: `vercel`) |
38
+ | `--repo <repo>` | `-r` | Nombre del repositorio (ej: `next.js`) |
39
+ | `--pr <number>` | `-p` | Número del Pull Request |
40
+ | `--json` | — | Imprime el resultado en JSON (sin formato visual) |
41
+
42
+ #### `list`
43
+
44
+ ```bash
45
+ export GITHUB_TOKEN=ghp_...
46
+ git-helper list # tabla bonita
47
+ git-helper list --json # salida JSON para scripts
48
+ ```
49
+
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.
54
+
55
+ ```bash
56
+ git-helper config set GITHUB_TOKEN ghp_...
57
+ git-helper config set AI_PROVIDER gemini
58
+ git-helper config set GOOGLE_GENERATIVE_AI_API_KEY ...
59
+ git-helper config list # secretos enmascarados
60
+ git-helper config unset OPENAI_API_KEY
61
+ git-helper config path # ruta del archivo
62
+ ```
63
+
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
+ ## Configuración (variables de entorno)
72
+
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.
75
+
76
+ | Variable | Requerida | Descripción |
77
+ | --------------------------------- | ------------------- | -------------------------------------------------------------- |
78
+ | `AI_PROVIDER` | No (`gemini`) | Proveedor de IA: `gemini` u `openai`. |
79
+ | `GOOGLE_GENERATIVE_AI_API_KEY` | Si `AI_PROVIDER=gemini` | Clave de [Google AI Studio](https://aistudio.google.com/apikey). |
80
+ | `OPENAI_API_KEY` | Si `AI_PROVIDER=openai` | Clave de OpenAI. |
81
+ | `AI_MODEL` | No | Fuerza un modelo concreto (por defecto `gemini-2.5-flash` / `gpt-4o-mini`). |
82
+ | `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
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,658 @@
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
+ ));
25
+
26
+ // 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
+ };
300
+
301
+ // src/ui/banner.ts
302
+ var import_figlet = __toESM(require("figlet"));
303
+
304
+ // src/ui/theme.ts
305
+ var import_chalk = __toESM(require("chalk"));
306
+ var import_gradient_string = __toESM(require("gradient-string"));
307
+ var PURPLE = "#8b5cf6";
308
+ var PURPLE_DARK = "#6d28d9";
309
+ var PURPLE_LIGHT = "#a78bfa";
310
+ 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"),
315
+ // gray-500
316
+ gray: import_chalk.default.hex("#9ca3af"),
317
+ // gray-400
318
+ white: import_chalk.default.whiteBright,
319
+ bold: import_chalk.default.bold,
320
+ ok: import_chalk.default.hex("#22c55e"),
321
+ // green-500
322
+ bad: import_chalk.default.hex("#ef4444"),
323
+ // red-500
324
+ warn: import_chalk.default.hex("#f59e0b")
325
+ // amber-500
326
+ };
327
+ var purpleGradient = (text) => (0, import_gradient_string.default)([PURPLE_LIGHT, PURPLE, PURPLE_DARK]).multiline(text);
328
+
329
+ // src/ui/banner.ts
330
+ var OCTOPUS = [
331
+ " _____ ",
332
+ " /=====\\ ",
333
+ // cúpula del casco
334
+ " (_________) ",
335
+ // ala del casco de obra
336
+ " | o o | ",
337
+ // cara
338
+ " \\ ~ / ",
339
+ // boca
340
+ " _/|||||\\_ ",
341
+ // tentáculos
342
+ " ~ ~ ~ ~ ~ "
343
+ // puntas
344
+ ];
345
+ var TAGLINE = "Code review con IA, en tu terminal";
346
+ function joinSideBySide(left, right, gap = 3) {
347
+ const height = Math.max(left.length, right.length);
348
+ const padBlock = (lines, width) => {
349
+ const top = Math.floor((height - lines.length) / 2);
350
+ const out = [];
351
+ for (let i = 0; i < height; i++) {
352
+ const line = lines[i - top] ?? "";
353
+ out.push(line.padEnd(width, " "));
354
+ }
355
+ return out;
356
+ };
357
+ const lw = Math.max(...left.map((l) => l.length));
358
+ const rw = Math.max(...right.map((l) => l.length));
359
+ const L = padBlock(left, lw);
360
+ const R = padBlock(right, rw);
361
+ return L.map((l, i) => l + " ".repeat(gap) + R[i]);
362
+ }
363
+ function boxify(lines) {
364
+ const width = Math.max(...lines.map((l) => l.length));
365
+ const pad = 2;
366
+ const inner = width + pad * 2;
367
+ const top = "\u256D" + "\u2500".repeat(inner) + "\u256E";
368
+ const bottom = "\u2570" + "\u2500".repeat(inner) + "\u256F";
369
+ const body = lines.map(
370
+ (l) => "\u2502" + " ".repeat(pad) + l.padEnd(width, " ") + " ".repeat(pad) + "\u2502"
371
+ );
372
+ return [top, ...body, bottom];
373
+ }
374
+ function renderBanner() {
375
+ const title = import_figlet.default.textSync("Git-Helper", { font: "Standard" }).replace(/\s+$/gm, "").split("\n").filter((l) => l.length > 0);
376
+ const composed = joinSideBySide(OCTOPUS, title, 3);
377
+ const blockWidth = Math.max(...composed.map((l) => l.length));
378
+ const tagPad = Math.max(0, Math.floor((blockWidth - TAGLINE.length) / 2));
379
+ const tagLine = " ".repeat(tagPad) + TAGLINE;
380
+ const boxed = boxify([...composed, "", tagLine]);
381
+ return "\n" + purpleGradient(boxed.join("\n")) + "\n";
382
+ }
383
+
384
+ // src/ui/render.ts
385
+ var import_cli_table3 = __toESM(require("cli-table3"));
386
+ var ANSI = /\[[0-9;]*m/g;
387
+ var visibleLen = (s) => s.replace(ANSI, "").length;
388
+ var padVisible = (s, w) => s + " ".repeat(Math.max(0, w - visibleLen(s)));
389
+ var truncate = (s, max) => s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
390
+ function wrap(text, width) {
391
+ const words = text.split(/\s+/);
392
+ const lines = [];
393
+ let cur = "";
394
+ for (const w of words) {
395
+ if (cur && cur.length + 1 + w.length > width) {
396
+ lines.push(cur);
397
+ cur = w;
398
+ } else {
399
+ cur = cur ? `${cur} ${w}` : w;
400
+ }
401
+ }
402
+ if (cur) lines.push(cur);
403
+ return lines;
404
+ }
405
+ function relativeTime(iso) {
406
+ const diff = Date.now() - new Date(iso).getTime();
407
+ const m = Math.floor(diff / 6e4);
408
+ if (m < 60) return `${m}m`;
409
+ const h = Math.floor(m / 60);
410
+ if (h < 24) return `${h}h`;
411
+ return `${Math.floor(h / 24)}d`;
412
+ }
413
+ function box(lines, title) {
414
+ const inner = Math.max(
415
+ title ? visibleLen(title) + 4 : 0,
416
+ ...lines.map(visibleLen)
417
+ );
418
+ const pad = 1;
419
+ const total = inner + pad * 2;
420
+ let top;
421
+ if (title) {
422
+ const label = ` ${title} `;
423
+ const rest = total - visibleLen(label) - 1;
424
+ top = c.dim("\u256D\u2500") + c.purpleBold(label) + c.dim("\u2500".repeat(Math.max(0, rest)) + "\u256E");
425
+ } else {
426
+ top = c.dim("\u256D" + "\u2500".repeat(total) + "\u256E");
427
+ }
428
+ const bottom = c.dim("\u2570" + "\u2500".repeat(total) + "\u256F");
429
+ const body = lines.map(
430
+ (l) => c.dim("\u2502") + " ".repeat(pad) + padVisible(l, inner) + " ".repeat(pad) + c.dim("\u2502")
431
+ );
432
+ return [top, ...body, bottom].join("\n");
433
+ }
434
+ var RECO_STYLE = {
435
+ aprobar: (s) => c.ok(`\u25CF ${s}`),
436
+ cambios_menores: (s) => c.warn(`\u25CF ${s}`),
437
+ cambios_mayores: (s) => c.warn(`\u25CF ${s}`),
438
+ bloqueado: (s) => c.bad(`\u25CF ${s}`)
439
+ };
440
+ var recoLabel = {
441
+ aprobar: "Aprobar",
442
+ cambios_menores: "Cambios menores",
443
+ cambios_mayores: "Cambios mayores",
444
+ bloqueado: "Bloqueado"
445
+ };
446
+ function scoreBar(score) {
447
+ const filled = Math.max(0, Math.min(10, Math.round(score)));
448
+ const color = score >= 8 ? c.ok : score >= 5 ? c.warn : c.bad;
449
+ return color("\u2588".repeat(filled)) + c.dim("\u2591".repeat(10 - filled)) + " " + c.bold(color(`${score}/10`));
450
+ }
451
+ function analysisCard(ref, a) {
452
+ const lines = [];
453
+ lines.push(c.dim("Puntuaci\xF3n ") + scoreBar(a.puntuacion_codigo));
454
+ lines.push(c.dim("Veredicto ") + RECO_STYLE[a.recomendacion](recoLabel[a.recomendacion]));
455
+ lines.push(
456
+ c.dim("Merge ") + (a.apto_para_merge ? c.ok("\u2713 apto") : c.bad("\u2717 no apto"))
457
+ );
458
+ lines.push("");
459
+ wrap(a.resumen_ejecutivo, 66).forEach((l) => lines.push(c.gray(l)));
460
+ if (a.posibles_bugs.length > 0) {
461
+ lines.push("");
462
+ lines.push(c.warn("Posibles bugs:"));
463
+ a.posibles_bugs.forEach((b) => {
464
+ const parts = wrap(b, 62);
465
+ parts.forEach(
466
+ (l, i) => lines.push((i === 0 ? c.dim(" \u2022 ") : " ") + c.white(l))
467
+ );
468
+ });
469
+ } else {
470
+ lines.push("");
471
+ lines.push(c.ok("\u2713 Sin bugs evidentes."));
472
+ }
473
+ return box(lines, ref);
474
+ }
475
+ function pendingTable(prs) {
476
+ const table = new import_cli_table3.default({
477
+ head: ["", "Repo", "PR", "T\xEDtulo", "Autor", "Abierto"].map(
478
+ (h) => c.purpleBold(h)
479
+ ),
480
+ style: { head: [], border: [], "padding-left": 1, "padding-right": 1 },
481
+ chars: {
482
+ top: "\u2500",
483
+ "top-mid": "\u252C",
484
+ "top-left": "\u256D",
485
+ "top-right": "\u256E",
486
+ bottom: "\u2500",
487
+ "bottom-mid": "\u2534",
488
+ "bottom-left": "\u2570",
489
+ "bottom-right": "\u256F",
490
+ left: "\u2502",
491
+ "left-mid": "\u251C",
492
+ mid: "\u2500",
493
+ "mid-mid": "\u253C",
494
+ right: "\u2502",
495
+ "right-mid": "\u2524",
496
+ middle: "\u2502"
497
+ }
498
+ });
499
+ prs.forEach((pr, i) => {
500
+ table.push([
501
+ c.dim(String(i + 1)),
502
+ c.white(pr.full_name),
503
+ c.light(`#${pr.number}`) + (pr.draft ? c.dim(" \u2311") : ""),
504
+ truncate(pr.title, 48),
505
+ c.gray(pr.author ?? "\u2014"),
506
+ c.dim(relativeTime(pr.created_at))
507
+ ]);
508
+ });
509
+ return table.toString().split("\n").map((l) => l.replace(/[╭╮╰╯─┬┴├┤┼│]/g, (ch) => c.dim(ch))).join("\n");
510
+ }
511
+
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
+ // src/index.ts
564
+ import_dotenv2.default.config({ quiet: true });
565
+ 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());
568
+ 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
+ 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();
571
+ const service = new GitHubAIService(process.env.GITHUB_TOKEN);
572
+ try {
573
+ const analysis = await service.analyzePR(opts.owner, opts.repo, opts.pr);
574
+ spinner?.stop();
575
+ if (opts.json) {
576
+ console.log(JSON.stringify(analysis, null, 2));
577
+ } else {
578
+ console.log("\n" + analysisCard(ref, analysis) + "\n");
579
+ }
580
+ } catch (error) {
581
+ spinner?.stop();
582
+ console.error("\n" + c.bad("\u2717 " + (error?.message ?? String(error))) + "\n");
583
+ process.exitCode = 1;
584
+ }
585
+ });
586
+ program.command("list").alias("ls").description("Lista tus Pull Requests pendientes de revisar").option("--json", "imprime la lista en JSON (sin formato visual)").action(async (opts) => {
587
+ if (!process.env.GITHUB_TOKEN) {
588
+ console.error(
589
+ "\n" + c.bad("\u2717 Necesitas un GITHUB_TOKEN para listar tus PRs pendientes.") + "\n" + c.dim(" Gu\xE1rdalo con ") + c.light("git-helper config set GITHUB_TOKEN ghp_...") + "\n"
590
+ );
591
+ process.exitCode = 1;
592
+ return;
593
+ }
594
+ const spinner = opts.json ? null : (0, import_ora.default)({ text: c.gray("Buscando tus PRs pendientes\u2026"), color: "magenta" }).start();
595
+ const service = new GitHubAIService(process.env.GITHUB_TOKEN);
596
+ try {
597
+ const prs = await service.listPendingPullRequests();
598
+ spinner?.stop();
599
+ if (opts.json) {
600
+ console.log(JSON.stringify(prs, null, 2));
601
+ return;
602
+ }
603
+ if (prs.length === 0) {
604
+ console.log("\n" + c.ok("\u2713 No tienes PRs pendientes. \xA1Todo limpio!") + "\n");
605
+ return;
606
+ }
607
+ console.log("\n" + c.purpleBold(` ${prs.length} PR pendiente${prs.length === 1 ? "" : "s"}`) + "\n");
608
+ console.log(pendingTable(prs));
609
+ console.log(
610
+ "\n" + c.dim(" Analiza uno con ") + c.light("git-helper review -o <owner> -r <repo> -p <n>") + "\n"
611
+ );
612
+ } catch (error) {
613
+ spinner?.stop();
614
+ console.error("\n" + c.bad("\u2717 " + (error?.message ?? String(error))) + "\n");
615
+ process.exitCode = 1;
616
+ }
617
+ });
618
+ var config = program.command("config").description(`Gestiona la configuraci\xF3n guardada en ${CONFIG_FILE}`);
619
+ config.command("set <key> <value>").description("Guarda una clave de configuraci\xF3n").action((key, value) => {
620
+ if (!isConfigKey(key)) {
621
+ console.error(
622
+ "\n" + c.bad(`\u2717 Clave desconocida: ${key}`) + "\n" + c.dim(" Claves v\xE1lidas: ") + c.light(CONFIG_KEYS.join(", ")) + "\n"
623
+ );
624
+ process.exitCode = 1;
625
+ return;
626
+ }
627
+ setConfig(key, value);
628
+ console.log("\n" + c.ok(`\u2713 ${key} guardada`) + c.dim(` en ${CONFIG_FILE}`) + "\n");
629
+ });
630
+ config.command("list").alias("get").description("Muestra la configuraci\xF3n guardada (secretos enmascarados)").action(() => {
631
+ const cfg = readConfig();
632
+ const keys = Object.keys(cfg);
633
+ if (keys.length === 0) {
634
+ console.log("\n" + c.dim(" Sin configuraci\xF3n guardada.") + "\n");
635
+ return;
636
+ }
637
+ console.log("");
638
+ for (const k of CONFIG_KEYS) {
639
+ if (cfg[k] === void 0) continue;
640
+ console.log(" " + c.purple(k.padEnd(30)) + c.white(maskValue(k, cfg[k])));
641
+ }
642
+ console.log("");
643
+ });
644
+ config.command("unset <key>").description("Elimina una clave de configuraci\xF3n").action((key) => {
645
+ if (!isConfigKey(key)) {
646
+ console.error("\n" + c.bad(`\u2717 Clave desconocida: ${key}`) + "\n");
647
+ process.exitCode = 1;
648
+ return;
649
+ }
650
+ unsetConfig(key);
651
+ console.log("\n" + c.ok(`\u2713 ${key} eliminada`) + "\n");
652
+ });
653
+ config.command("path").description("Muestra la ruta del archivo de configuraci\xF3n").action(() => console.log(CONFIG_FILE));
654
+ config.action(() => config.help());
655
+ program.action(() => {
656
+ program.help();
657
+ });
658
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@alexis-reillo/git-helper",
3
+ "version": "0.1.0",
4
+ "description": "Analiza Pull Requests de GitHub usando Inteligencia Artificial, desde la terminal.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "git-helper": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "@ai-sdk/google": "^4.0.2",
21
+ "@ai-sdk/openai": "^4.0.1",
22
+ "@octokit/rest": "^22.0.1",
23
+ "ai": "^7.0.3",
24
+ "chalk": "^4.1.2",
25
+ "cli-table3": "^0.6.5",
26
+ "commander": "^15.0.0",
27
+ "dotenv": "^17.4.2",
28
+ "figlet": "^1.8.0",
29
+ "gradient-string": "^2.0.2",
30
+ "ora": "^5.4.1",
31
+ "zod": "^4.4.3"
32
+ },
33
+ "devDependencies": {
34
+ "@types/figlet": "^1.7.0",
35
+ "@types/gradient-string": "^1.1.6",
36
+ "@types/node": "^20",
37
+ "tsup": "^8.0.0",
38
+ "tsx": "^4.7.0",
39
+ "vitest": "^2.1.9",
40
+ "@repo/core": "0.0.0"
41
+ },
42
+ "scripts": {
43
+ "dev": "tsx watch src/index.ts",
44
+ "start": "tsx src/index.ts",
45
+ "build": "tsup",
46
+ "check-types": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
49
+ }