@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 +56 -33
- package/dist/chunk-QR7XJSI6.js +334 -0
- package/dist/index.js +39 -371
- package/dist/run-3BP46L5I.js +286 -0
- package/package.json +8 -1
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
|
|
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
|
|
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
|
|
14
|
+
git-helper
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
Requiere **Node.js >= 18**.
|
|
17
18
|
|
|
18
|
-
##
|
|
19
|
+
## Inicio rápido (interfaz interactiva)
|
|
19
20
|
|
|
20
|
-
|
|
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` | — |
|
|
25
|
-
| `git-helper list` | `ls` | Lista tus Pull Requests pendientes
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
20
|
+
import figlet from "figlet";
|
|
303
21
|
|
|
304
22
|
// src/ui/theme.ts
|
|
305
|
-
|
|
306
|
-
|
|
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:
|
|
312
|
-
purpleBold:
|
|
313
|
-
light:
|
|
314
|
-
dim:
|
|
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:
|
|
34
|
+
gray: chalk.hex("#9ca3af"),
|
|
317
35
|
// gray-400
|
|
318
|
-
white:
|
|
319
|
-
bold:
|
|
320
|
-
ok:
|
|
36
|
+
white: chalk.whiteBright,
|
|
37
|
+
bold: chalk.bold,
|
|
38
|
+
ok: chalk.hex("#22c55e"),
|
|
321
39
|
// green-500
|
|
322
|
-
bad:
|
|
40
|
+
bad: chalk.hex("#ef4444"),
|
|
323
41
|
// red-500
|
|
324
|
-
warn:
|
|
42
|
+
warn: chalk.hex("#f59e0b")
|
|
325
43
|
// amber-500
|
|
326
44
|
};
|
|
327
|
-
var purpleGradient = (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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
231
|
+
dotenv.config({ quiet: true });
|
|
565
232
|
loadGlobalConfig();
|
|
566
|
-
var program = new
|
|
567
|
-
program.name("git-helper").description("Code review de Pull Requests de GitHub con IA, desde la terminal").version("0.
|
|
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 : (
|
|
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 : (
|
|
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
|
-
|
|
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.
|
|
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",
|