leganux-commit-describer 1.0.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/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/lnxcommit.js +149 -0
- package/describe-changes.js +156 -0
- package/lib/core.js +204 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Angel Erick Cruz Olivera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# lnxcommit — Leganux Commit Describer
|
|
2
|
+
|
|
3
|
+
> CLI que analiza los cambios de tus commits de Git y genera una descripción legible en español usando IA (DeepSeek).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Requisitos
|
|
8
|
+
|
|
9
|
+
- Node.js >= 18
|
|
10
|
+
- Una API key de [DeepSeek](https://platform.deepseek.com/)
|
|
11
|
+
- El directorio donde lo uses debe ser un repositorio Git
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Instalación
|
|
16
|
+
|
|
17
|
+
### Opción A — Global desde npm
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g leganux-commit-describer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Opción B — Clonar y enlazar localmente
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/leganux/leganux-commit-describer.git
|
|
27
|
+
cd leganux-commit-describer
|
|
28
|
+
npm install
|
|
29
|
+
npm link
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Tras la instalación el binario `lnxcommit` quedará disponible en tu `PATH`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Comandos
|
|
37
|
+
|
|
38
|
+
### `lnxcommit configure`
|
|
39
|
+
|
|
40
|
+
Guarda tu API key de DeepSeek en `~/.lnx_commit_describer/config.json`.
|
|
41
|
+
Solo necesitas ejecutarlo **una vez** (o cuando quieras cambiar la key).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
lnxcommit configure
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Flujo interactivo:**
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
🔧 Configuración de lnxcommit
|
|
51
|
+
|
|
52
|
+
API key actual: (no configurada)
|
|
53
|
+
|
|
54
|
+
? Ingresa tu API key de DeepSeek: ****************************
|
|
55
|
+
|
|
56
|
+
✅ API key guardada en: /Users/<tu-usuario>/.lnx_commit_describer/config.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### `lnxcommit describe`
|
|
62
|
+
|
|
63
|
+
Analiza el repositorio Git del directorio actual (o del indicado con `--path`) y
|
|
64
|
+
muestra la descripción de cambios directamente en la consola.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# En el directorio del repo
|
|
68
|
+
lnxcommit describe
|
|
69
|
+
|
|
70
|
+
# Apuntando a otro repositorio
|
|
71
|
+
lnxcommit describe --path /ruta/al/repo
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Salida de ejemplo:**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
🔍 Analizando cambios en: /Users/leganux/mi-proyecto
|
|
78
|
+
|
|
79
|
+
:sparkles: Título: Migración de script a librería npm con CLI interactivo
|
|
80
|
+
|
|
81
|
+
- :sparkles: Se agrega el archivo `bin/lnxcommit.js` que expone el CLI con commander
|
|
82
|
+
- :recycle: Se refactoriza `describe-changes.js` separando la lógica en `lib/core.js`
|
|
83
|
+
- :memo: Se actualiza `README.md` con instrucciones completas de uso
|
|
84
|
+
- :package: Se crea `package.json` definiendo el binario `lnxcommit`
|
|
85
|
+
|
|
86
|
+
Resumen: Se convirtió el script original en una herramienta de línea de comandos instalable...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Opciones
|
|
90
|
+
|
|
91
|
+
| Opción | Descripción |
|
|
92
|
+
|--------|-------------|
|
|
93
|
+
| `--path <ruta>` | Ruta al repositorio git (por defecto: directorio actual) |
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `lnxcommit describeandexport`
|
|
98
|
+
|
|
99
|
+
Igual que `describe`, pero además exporta el resultado a un archivo `.txt`.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Exporta a commit-description.txt en el directorio actual
|
|
103
|
+
lnxcommit describeandexport
|
|
104
|
+
|
|
105
|
+
# Con ruta y nombre de archivo personalizado
|
|
106
|
+
lnxcommit describeandexport --path /ruta/al/repo --output ./changelog/cambios.txt
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Salida de ejemplo:**
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
🔍 Analizando cambios en: /Users/leganux/mi-proyecto
|
|
113
|
+
|
|
114
|
+
- :sparkles: ...
|
|
115
|
+
- :bug: ...
|
|
116
|
+
|
|
117
|
+
📄 Descripción exportada a: /Users/leganux/mi-proyecto/commit-description.txt
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
El archivo generado tiene el siguiente formato:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
# Descripción de cambios
|
|
124
|
+
Generado: 2026-05-06 14:00:00
|
|
125
|
+
Directorio: /Users/leganux/mi-proyecto
|
|
126
|
+
|
|
127
|
+
- :sparkles: Título: ...
|
|
128
|
+
- :recycle: ...
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Opciones
|
|
133
|
+
|
|
134
|
+
| Opción | Descripción |
|
|
135
|
+
|--------|-------------|
|
|
136
|
+
| `--path <ruta>` | Ruta al repositorio git (por defecto: directorio actual) |
|
|
137
|
+
| `--output <archivo>` | Archivo de salida (por defecto: `commit-description.txt` en el repo) |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Configuración almacenada
|
|
142
|
+
|
|
143
|
+
La API key se guarda de forma local en tu máquina en:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
~/.lnx_commit_describer/config.json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Ejemplo del contenido:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Este archivo es **solo tuyo** y nunca se sube al repositorio (está en `.gitignore`).
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Cómo funciona internamente
|
|
162
|
+
|
|
163
|
+
1. Detecta la rama base de comparación (`origin/main`, `origin/master` o `HEAD~1`).
|
|
164
|
+
2. Obtiene `git diff --name-status` y el patch unificado contra esa base.
|
|
165
|
+
3. Construye un prompt en español y lo envía a la API de DeepSeek (`deepseek-chat`).
|
|
166
|
+
4. Formatea la respuesta como viñetas con gitmoji y la muestra / guarda.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Estructura del proyecto
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
leganux-commit-describer/
|
|
174
|
+
├── bin/
|
|
175
|
+
│ └── lnxcommit.js # Punto de entrada del CLI (commander)
|
|
176
|
+
├── lib/
|
|
177
|
+
│ └── core.js # Lógica reutilizable (git, AI, config)
|
|
178
|
+
├── describe-changes.js # Script original (standalone)
|
|
179
|
+
├── package.json
|
|
180
|
+
└── README.md
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Licencia
|
|
186
|
+
|
|
187
|
+
MIT © [leganux](https://github.com/leganux)
|
package/bin/lnxcommit.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { program } = require('commander');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
readConfig,
|
|
12
|
+
writeConfig,
|
|
13
|
+
CONFIG_FILE,
|
|
14
|
+
CONFIG_DIR,
|
|
15
|
+
isGitRepo,
|
|
16
|
+
describeChanges,
|
|
17
|
+
} = require('../lib/core');
|
|
18
|
+
|
|
19
|
+
const { version } = require('../package.json');
|
|
20
|
+
|
|
21
|
+
// ─── Helper: leer input oculto desde terminal ─────────────────────────────────
|
|
22
|
+
function askSecret(prompt) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Silencia la salida para ocultar lo que se escribe
|
|
30
|
+
process.stdout.write(prompt);
|
|
31
|
+
let input = '';
|
|
32
|
+
|
|
33
|
+
// Desactivar echo en terminales que lo soporten
|
|
34
|
+
if (process.stdin.isTTY) {
|
|
35
|
+
process.stdin.setRawMode(true);
|
|
36
|
+
process.stdin.resume();
|
|
37
|
+
process.stdin.setEncoding('utf8');
|
|
38
|
+
|
|
39
|
+
process.stdin.on('data', function handler(char) {
|
|
40
|
+
if (char === '\n' || char === '\r' || char === '\u0004') {
|
|
41
|
+
process.stdin.setRawMode(false);
|
|
42
|
+
process.stdin.removeListener('data', handler);
|
|
43
|
+
process.stdout.write('\n');
|
|
44
|
+
rl.close();
|
|
45
|
+
resolve(input);
|
|
46
|
+
} else if (char === '\u0003') {
|
|
47
|
+
// Ctrl+C
|
|
48
|
+
process.stdout.write('\n');
|
|
49
|
+
process.exit(0);
|
|
50
|
+
} else if (char === '\u007f') {
|
|
51
|
+
// Backspace
|
|
52
|
+
input = input.slice(0, -1);
|
|
53
|
+
} else {
|
|
54
|
+
input += char;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
// Sin TTY (p.ej. pipe): leer directamente
|
|
59
|
+
rl.question('', (answer) => {
|
|
60
|
+
rl.close();
|
|
61
|
+
resolve(answer);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.name('lnxcommit')
|
|
69
|
+
.description('CLI para describir cambios de commits con IA (DeepSeek)')
|
|
70
|
+
.version(version);
|
|
71
|
+
|
|
72
|
+
// ─── configure ────────────────────────────────────────────────────────────────
|
|
73
|
+
program
|
|
74
|
+
.command('configure')
|
|
75
|
+
.description('Configura la API key de DeepSeek y la guarda en ~/.lnx_commit_describer/config.json')
|
|
76
|
+
.action(async () => {
|
|
77
|
+
console.log('🔧 Configuración de lnxcommit\n');
|
|
78
|
+
|
|
79
|
+
const existing = readConfig();
|
|
80
|
+
const maskedKey = existing.apiKey
|
|
81
|
+
? `${existing.apiKey.slice(0, 6)}${'*'.repeat(Math.max(0, existing.apiKey.length - 10))}${existing.apiKey.slice(-4)}`
|
|
82
|
+
: '(no configurada)';
|
|
83
|
+
|
|
84
|
+
console.log(`API key actual: ${maskedKey}\n`);
|
|
85
|
+
|
|
86
|
+
const apiKey = await askSecret('Ingresa tu API key de DeepSeek: ');
|
|
87
|
+
if (!apiKey || !apiKey.trim()) {
|
|
88
|
+
console.error('❌ La API key no puede estar vacía.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const newConfig = { ...existing, apiKey: apiKey.trim() };
|
|
93
|
+
writeConfig(newConfig);
|
|
94
|
+
|
|
95
|
+
console.log(`\n✅ API key guardada en: ${CONFIG_FILE}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── describe ─────────────────────────────────────────────────────────────────
|
|
99
|
+
program
|
|
100
|
+
.command('describe')
|
|
101
|
+
.description('Analiza los cambios del repositorio git en el directorio actual y muestra la descripción en consola')
|
|
102
|
+
.option('--path <ruta>', 'Ruta del repositorio git (por defecto: directorio actual)')
|
|
103
|
+
.action(async (opts) => {
|
|
104
|
+
const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
|
|
105
|
+
|
|
106
|
+
console.log(`🔍 Analizando cambios en: ${cwd}\n`);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const output = await describeChanges(cwd);
|
|
110
|
+
console.log(output);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const detail = err?.response?.data || err.message;
|
|
113
|
+
console.error('❌ Error:', detail);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── describeandexport ────────────────────────────────────────────────────────
|
|
119
|
+
program
|
|
120
|
+
.command('describeandexport')
|
|
121
|
+
.description('Analiza los cambios del repositorio git y exporta la descripción a un archivo .txt')
|
|
122
|
+
.option('--path <ruta>', 'Ruta del repositorio git (por defecto: directorio actual)')
|
|
123
|
+
.option('--output <archivo>', 'Nombre del archivo de salida (por defecto: commit-description.txt)')
|
|
124
|
+
.action(async (opts) => {
|
|
125
|
+
const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
|
|
126
|
+
const outputFile = opts.output
|
|
127
|
+
? path.resolve(opts.output)
|
|
128
|
+
: path.join(cwd, 'commit-description.txt');
|
|
129
|
+
|
|
130
|
+
console.log(`🔍 Analizando cambios en: ${cwd}\n`);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const output = await describeChanges(cwd);
|
|
134
|
+
|
|
135
|
+
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
136
|
+
const fileContent = `# Descripción de cambios\nGenerado: ${timestamp}\nDirectorio: ${cwd}\n\n${output}\n`;
|
|
137
|
+
|
|
138
|
+
fs.writeFileSync(outputFile, fileContent, 'utf8');
|
|
139
|
+
|
|
140
|
+
console.log(output);
|
|
141
|
+
console.log(`\n📄 Descripción exportada a: ${outputFile}`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const detail = err?.response?.data || err.message;
|
|
144
|
+
console.error('❌ Error:', detail);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
|
|
5
|
+
const axios = require('axios');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
function run(command) {
|
|
9
|
+
return execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function runSafe(command) {
|
|
13
|
+
try {
|
|
14
|
+
return run(command);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getBaseRef() {
|
|
21
|
+
const checkMain = runSafe('git show-ref --verify --quiet refs/remotes/origin/main; echo $?');
|
|
22
|
+
if (checkMain.endsWith('0')) return 'origin/main';
|
|
23
|
+
|
|
24
|
+
const checkMaster = runSafe('git show-ref --verify --quiet refs/remotes/origin/master; echo $?');
|
|
25
|
+
if (checkMaster.endsWith('0')) return 'origin/master';
|
|
26
|
+
|
|
27
|
+
const hasPreviousCommit = runSafe('git rev-parse --verify HEAD~1');
|
|
28
|
+
if (hasPreviousCommit) return 'HEAD~1';
|
|
29
|
+
|
|
30
|
+
return 'HEAD';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getGitChanges(baseRef) {
|
|
34
|
+
const branch = runSafe('git rev-parse --abbrev-ref HEAD') || 'unknown-branch';
|
|
35
|
+
const changedFiles = runSafe(`git diff --name-status ${baseRef}...HEAD`);
|
|
36
|
+
const patch = runSafe(`git diff --unified=1 ${baseRef}...HEAD`);
|
|
37
|
+
const status = runSafe('git status --short');
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
branch,
|
|
41
|
+
baseRef,
|
|
42
|
+
changedFiles,
|
|
43
|
+
patch: patch.slice(0, 20000),
|
|
44
|
+
status,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildPrompt({ branch, baseRef, changedFiles, patch, status }) {
|
|
49
|
+
return `Analiza estos cambios de Git y crea una descripción humana en español.
|
|
50
|
+
|
|
51
|
+
Reglas de salida:
|
|
52
|
+
0) aniade un titulo para todos los cambios
|
|
53
|
+
1) Responde SOLO con viñetas, un renglón por cada cambio relevante.
|
|
54
|
+
2) Cada viñeta debe iniciar con gitmoji (ejemplo: :sparkles:, :bug:, :recycle:, :art:, :zap:).
|
|
55
|
+
3) Formato exacto: "- :gitmoji: descripción corta".
|
|
56
|
+
4) Máximo 1 línea por punto, sin párrafos, sin encabezados, sin texto extra.
|
|
57
|
+
5) Si hay varios fixes, sepáralos en puntos distintos.
|
|
58
|
+
6) Si no hay cambios relevantes, responde: "- :white_check_mark: No se detectaron cambios relevantes".
|
|
59
|
+
7) Haz referencia detallada a los botones, Ids, funciones, clases, rutas de archivos, ramas; describiendo con base a sus nombres de esas clases o funciones que hacen internamente o que funcionalidad resuelven.
|
|
60
|
+
8) Y al final pon un resumen corto de 4 o 5 renglones en lenguaje no tecnico de lo que implico esos cambios.
|
|
61
|
+
9) Dame el titulo del commit en una sola frase con gitmoji al inicio.
|
|
62
|
+
|
|
63
|
+
Contexto:
|
|
64
|
+
- Rama: ${branch}
|
|
65
|
+
- Base de comparación: ${baseRef}
|
|
66
|
+
|
|
67
|
+
Archivos cambiados:
|
|
68
|
+
${changedFiles || '(sin archivos en diff commit)'}
|
|
69
|
+
|
|
70
|
+
Estado de working tree:
|
|
71
|
+
${status || '(limpio)'}
|
|
72
|
+
|
|
73
|
+
Patch (resumido):
|
|
74
|
+
${patch || '(sin patch disponible)'}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function requestDeepseek(prompt, apiKey) {
|
|
79
|
+
const response = await axios.post(
|
|
80
|
+
'https://api.deepseek.com/v1/chat/completions',
|
|
81
|
+
{
|
|
82
|
+
model: 'deepseek-chat',
|
|
83
|
+
temperature: 0.2,
|
|
84
|
+
max_tokens: 700,
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
role: 'system',
|
|
88
|
+
content:
|
|
89
|
+
'Eres un generador de changelogs técnicos cortos. Cumple formato solicitado exactamente.',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
role: 'user',
|
|
93
|
+
content: prompt,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${apiKey}`,
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
},
|
|
102
|
+
timeout: 60000,
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return response?.data?.choices?.[0]?.message?.content?.trim() || '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeOutput(text) {
|
|
110
|
+
if (!text) return '- :white_check_mark: No se detectaron cambios relevantes';
|
|
111
|
+
|
|
112
|
+
const lines = text
|
|
113
|
+
.split('\n')
|
|
114
|
+
.map((line) => line.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((line) => {
|
|
117
|
+
const clean = line.replace(/^[-*]\s*/, '').trim();
|
|
118
|
+
return clean.startsWith(':') ? `- ${clean}` : `- :wrench: ${clean}`;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return lines.length
|
|
122
|
+
? lines.join('\n')
|
|
123
|
+
: '- :white_check_mark: No se detectaron cambios relevantes';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
127
|
+
const isDryRun = process.argv.includes('--dry-run');
|
|
128
|
+
const apiKey = process.env.AI_API_KEY;
|
|
129
|
+
if (!apiKey && !isDryRun) {
|
|
130
|
+
console.error('Error: falta AI_API_KEY en el .env');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const baseRef = getBaseRef();
|
|
135
|
+
const changes = getGitChanges(baseRef);
|
|
136
|
+
const prompt = buildPrompt(changes);
|
|
137
|
+
|
|
138
|
+
if (isDryRun) {
|
|
139
|
+
console.log('DRY RUN - Resumen local de cambios detectados');
|
|
140
|
+
console.log(`Base: ${baseRef}`);
|
|
141
|
+
console.log(changes.changedFiles || 'Sin cambios detectados en diff contra base');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const aiText = await requestDeepseek(prompt, apiKey);
|
|
147
|
+
const output = normalizeOutput(aiText);
|
|
148
|
+
console.log(output);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const detail = error?.response?.data || error.message;
|
|
151
|
+
console.error('Error al generar descripción con IA:', detail);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main();
|
package/lib/core.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lnx_commit_describer');
|
|
12
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
function readConfig() {
|
|
15
|
+
if (!fs.existsSync(CONFIG_FILE)) return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeConfig(data) {
|
|
24
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getApiKey() {
|
|
31
|
+
const config = readConfig();
|
|
32
|
+
return config.apiKey || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function run(command, cwd) {
|
|
38
|
+
return execSync(command, {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
+
cwd: cwd || process.cwd(),
|
|
42
|
+
}).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runSafe(command, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
return run(command, cwd);
|
|
48
|
+
} catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isGitRepo(cwd) {
|
|
54
|
+
const result = runSafe('git rev-parse --is-inside-work-tree', cwd);
|
|
55
|
+
return result === 'true';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getBaseRef(cwd) {
|
|
59
|
+
const checkMain = runSafe(
|
|
60
|
+
'git show-ref --verify --quiet refs/remotes/origin/main; echo $?',
|
|
61
|
+
cwd
|
|
62
|
+
);
|
|
63
|
+
if (checkMain.endsWith('0')) return 'origin/main';
|
|
64
|
+
|
|
65
|
+
const checkMaster = runSafe(
|
|
66
|
+
'git show-ref --verify --quiet refs/remotes/origin/master; echo $?',
|
|
67
|
+
cwd
|
|
68
|
+
);
|
|
69
|
+
if (checkMaster.endsWith('0')) return 'origin/master';
|
|
70
|
+
|
|
71
|
+
const hasPreviousCommit = runSafe('git rev-parse --verify HEAD~1', cwd);
|
|
72
|
+
if (hasPreviousCommit) return 'HEAD~1';
|
|
73
|
+
|
|
74
|
+
return 'HEAD';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getGitChanges(baseRef, cwd) {
|
|
78
|
+
const branch =
|
|
79
|
+
runSafe('git rev-parse --abbrev-ref HEAD', cwd) || 'unknown-branch';
|
|
80
|
+
const changedFiles = runSafe(`git diff --name-status ${baseRef}...HEAD`, cwd);
|
|
81
|
+
const patch = runSafe(`git diff --unified=1 ${baseRef}...HEAD`, cwd);
|
|
82
|
+
const status = runSafe('git status --short', cwd);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
branch,
|
|
86
|
+
baseRef,
|
|
87
|
+
changedFiles,
|
|
88
|
+
patch: patch.slice(0, 20000),
|
|
89
|
+
status,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── AI helpers ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function buildPrompt({ branch, baseRef, changedFiles, patch, status }) {
|
|
96
|
+
return `Analiza estos cambios de Git y crea una descripción humana en español.
|
|
97
|
+
|
|
98
|
+
Reglas de salida:
|
|
99
|
+
0) aniade un titulo para todos los cambios
|
|
100
|
+
1) Responde SOLO con viñetas, un renglón por cada cambio relevante.
|
|
101
|
+
2) Cada viñeta debe iniciar con gitmoji (ejemplo: :sparkles:, :bug:, :recycle:, :art:, :zap:).
|
|
102
|
+
3) Formato exacto: "- :gitmoji: descripción corta".
|
|
103
|
+
4) Máximo 1 línea por punto, sin párrafos, sin encabezados, sin texto extra.
|
|
104
|
+
5) Si hay varios fixes, sepáralos en puntos distintos.
|
|
105
|
+
6) Si no hay cambios relevantes, responde: "- :white_check_mark: No se detectaron cambios relevantes".
|
|
106
|
+
7) Haz referencia detallada a los botones, Ids, funciones, clases, rutas de archivos, ramas; describiendo con base a sus nombres de esas clases o funciones que hacen internamente o que funcionalidad resuelven.
|
|
107
|
+
8) Y al final pon un resumen corto de 4 o 5 renglones en lenguaje no tecnico de lo que implico esos cambios.
|
|
108
|
+
9) Dame el titulo del commit en una sola frase con gitmoji al inicio.
|
|
109
|
+
|
|
110
|
+
Contexto:
|
|
111
|
+
- Rama: ${branch}
|
|
112
|
+
- Base de comparación: ${baseRef}
|
|
113
|
+
|
|
114
|
+
Archivos cambiados:
|
|
115
|
+
${changedFiles || '(sin archivos en diff commit)'}
|
|
116
|
+
|
|
117
|
+
Estado de working tree:
|
|
118
|
+
${status || '(limpio)'}
|
|
119
|
+
|
|
120
|
+
Patch (resumido):
|
|
121
|
+
${patch || '(sin patch disponible)'}
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function requestDeepseek(prompt, apiKey) {
|
|
126
|
+
const response = await axios.post(
|
|
127
|
+
'https://api.deepseek.com/v1/chat/completions',
|
|
128
|
+
{
|
|
129
|
+
model: 'deepseek-chat',
|
|
130
|
+
temperature: 0.2,
|
|
131
|
+
max_tokens: 700,
|
|
132
|
+
messages: [
|
|
133
|
+
{
|
|
134
|
+
role: 'system',
|
|
135
|
+
content:
|
|
136
|
+
'Eres un generador de changelogs técnicos cortos. Cumple formato solicitado exactamente.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
role: 'user',
|
|
140
|
+
content: prompt,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
headers: {
|
|
146
|
+
Authorization: `Bearer ${apiKey}`,
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
},
|
|
149
|
+
timeout: 60000,
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return response?.data?.choices?.[0]?.message?.content?.trim() || '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeOutput(text) {
|
|
157
|
+
if (!text) return '- :white_check_mark: No se detectaron cambios relevantes';
|
|
158
|
+
|
|
159
|
+
const lines = text
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.map((line) => {
|
|
164
|
+
const clean = line.replace(/^[-*]\s*/, '').trim();
|
|
165
|
+
return clean.startsWith(':') ? `- ${clean}` : `- :wrench: ${clean}`;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return lines.length
|
|
169
|
+
? lines.join('\n')
|
|
170
|
+
: '- :white_check_mark: No se detectaron cambios relevantes';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Main describe logic ──────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function describeChanges(cwd) {
|
|
176
|
+
const apiKey = getApiKey();
|
|
177
|
+
if (!apiKey) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
'No se encontró la API key. Ejecuta primero: lnxcommit configure'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!isGitRepo(cwd)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`El directorio "${cwd}" no es un repositorio git válido.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baseRef = getBaseRef(cwd);
|
|
190
|
+
const changes = getGitChanges(baseRef, cwd);
|
|
191
|
+
const prompt = buildPrompt(changes);
|
|
192
|
+
const aiText = await requestDeepseek(prompt, apiKey);
|
|
193
|
+
return normalizeOutput(aiText);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
readConfig,
|
|
198
|
+
writeConfig,
|
|
199
|
+
getApiKey,
|
|
200
|
+
CONFIG_FILE,
|
|
201
|
+
CONFIG_DIR,
|
|
202
|
+
isGitRepo,
|
|
203
|
+
describeChanges,
|
|
204
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "leganux-commit-describer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to describe git commit changes using DeepSeek AI",
|
|
5
|
+
"main": "lib/core.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lnxcommit": "./bin/lnxcommit.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/lnxcommit.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"git",
|
|
14
|
+
"commit",
|
|
15
|
+
"changelog",
|
|
16
|
+
"ai",
|
|
17
|
+
"deepseek",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"author": "leganux",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"axios": "^1.6.0",
|
|
24
|
+
"commander": "^12.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|