@dami_deleon/rikudo 2.0.1 → 2.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.
- package/README.md +11 -5
- package/dist/index.js +18 -1
- package/dist/mcp/server.js +41 -8
- package/dist/services/customFieldsCache.js +59 -0
- package/dist/services/git.js +15 -0
- package/dist/services/gitea.js +23 -14
- package/dist/services/redmine.js +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,6 +90,11 @@ rikudo pr -t 1234
|
|
|
90
90
|
rikudo test
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
* **Ver Ticket:**
|
|
94
|
+
```bash
|
|
95
|
+
rikudo ticket 1234
|
|
96
|
+
```
|
|
97
|
+
|
|
93
98
|
* **Configuración:**
|
|
94
99
|
```bash
|
|
95
100
|
rikudo config
|
|
@@ -112,10 +117,11 @@ El servidor se comunica mediante **stdio**, permitiendo integrarse con cualquier
|
|
|
112
117
|
| Herramienta | Descripción |
|
|
113
118
|
|------------|-------------|
|
|
114
119
|
| `validate_configuration` | Verifica que las variables de entorno necesarias (Redmine, Gitea) estén configuradas correctamente. |
|
|
115
|
-
| `get_ticket_context` | Obtiene el contexto completo de un ticket de Redmine (título, descripción, estado, prioridad). |
|
|
120
|
+
| `get_ticket_context` | Obtiene el contexto completo de un ticket de Redmine (título, descripción, estado, prioridad, custom fields). |
|
|
116
121
|
| `get_git_diff` | Retorna el diff de los archivos que están en staging (`git add`). |
|
|
117
122
|
| `shift_left_start` | Flujo shift-left completo: checkout a rama objetivo, crea nueva rama, commit vacío y abre Draft PR en Gitea. Soporta parámetro `isWip` para usar prefijo "WIP:" en el título. |
|
|
118
123
|
| `update_pm_redmine` | Agrega un comentario a un ticket de Redmine para notificar al Project Manager. |
|
|
124
|
+
| `update_custom_fields` | Actualiza los campos personalizados (Custom Fields) de un ticket de Redmine. |
|
|
119
125
|
|
|
120
126
|
#### Prompts Disponibles
|
|
121
127
|
|
|
@@ -157,8 +163,8 @@ RIKUDO_REDMINE_API_KEY=tu_redmine_key
|
|
|
157
163
|
# Gitea
|
|
158
164
|
RIKUDO_GITEA_URL=https://gitea.tuempresa.com
|
|
159
165
|
RIKUDO_GITEA_TOKEN=tu_gitea_token
|
|
160
|
-
RIKUDO_GITEA_OWNER=tu_usuario_o_organizacion
|
|
161
|
-
RIKUDO_GITEA_REPO=nombre_del_repositorio
|
|
166
|
+
RIKUDO_GITEA_OWNER=tu_usuario_o_organizacion # Opcional
|
|
167
|
+
RIKUDO_GITEA_REPO=nombre_del_repositorio # Opcional
|
|
162
168
|
```
|
|
163
169
|
|
|
164
170
|
#### Formato de Ramas
|
|
@@ -197,8 +203,8 @@ RIKUDO_REDMINE_API_KEY=tu_redmine_key
|
|
|
197
203
|
# --- Gitea (para servidor MCP) ---
|
|
198
204
|
RIKUDO_GITEA_URL=https://gitea.tuempresa.com
|
|
199
205
|
RIKUDO_GITEA_TOKEN=tu_gitea_token
|
|
200
|
-
RIKUDO_GITEA_OWNER=tu_usuario_o_organizacion
|
|
201
|
-
RIKUDO_GITEA_REPO=nombre_del_repositorio
|
|
206
|
+
RIKUDO_GITEA_OWNER=tu_usuario_o_organizacion # Opcional: se obtiene del remote si no se especifica
|
|
207
|
+
RIKUDO_GITEA_REPO=nombre_del_repositorio # Opcional: se obtiene del remote si no se especifica
|
|
202
208
|
```
|
|
203
209
|
|
|
204
210
|
### Plantillas Personalizadas
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import chalk from "chalk";
|
|
|
5
5
|
import figlet from "figlet";
|
|
6
6
|
import clipboardy from "clipboardy"; // <--- Nueva dependencia
|
|
7
7
|
import { getStagedDiff, getStagedFiles, commitChanges, getRecentCommits, getCommitList, } from "./services/git.js";
|
|
8
|
-
import { getIssueDetails, updateIssueComment, } from "./services/redmine.js";
|
|
8
|
+
import { getIssueDetails, updateIssueComment, printTicketDetails, } from "./services/redmine.js";
|
|
9
9
|
import { showConfigMenu } from "./menus/configMenu.js";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import path from "node:path";
|
|
@@ -63,6 +63,23 @@ program
|
|
|
63
63
|
.command("config")
|
|
64
64
|
.description("Abrir menú de configuración")
|
|
65
65
|
.action(() => showConfigMenu());
|
|
66
|
+
// Ticket
|
|
67
|
+
const ticketCmd = program
|
|
68
|
+
.command("ticket <ticketId>")
|
|
69
|
+
.description("Ver detalles de un ticket de Redmine");
|
|
70
|
+
ticketCmd.action(async (ticketId) => {
|
|
71
|
+
loadEnv();
|
|
72
|
+
if (!ticketId) {
|
|
73
|
+
console.log(chalk.red("❌ Debes especificar un ticket: rikudo ticket <id>"));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const ticket = await getIssueDetails(ticketId);
|
|
77
|
+
if (!ticket) {
|
|
78
|
+
console.log(chalk.red(`❌ No se encontró el ticket #${ticketId}`));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
printTicketDetails(ticket);
|
|
82
|
+
});
|
|
66
83
|
// MCP Server
|
|
67
84
|
program
|
|
68
85
|
.command("mcp")
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { simpleGit } from "simple-git";
|
|
5
|
-
import { getIssueDetails, updateIssueComment } from "../services/redmine.js";
|
|
5
|
+
import { getIssueDetails, updateIssueComment, updateIssueCustomFields } from "../services/redmine.js";
|
|
6
6
|
import { getStagedDiff } from "../services/git.js";
|
|
7
7
|
import { createPullRequest } from "../services/gitea.js";
|
|
8
8
|
import { loadEnv } from "../utils/env.js";
|
|
@@ -46,6 +46,7 @@ server.registerTool("get_ticket_context", {
|
|
|
46
46
|
priority: ticket.priority,
|
|
47
47
|
author: ticket.author,
|
|
48
48
|
url: ticket.url,
|
|
49
|
+
customFields: ticket.customFields,
|
|
49
50
|
}, null, 2),
|
|
50
51
|
},
|
|
51
52
|
],
|
|
@@ -117,11 +118,18 @@ server.registerTool("shift_left_start", {
|
|
|
117
118
|
}),
|
|
118
119
|
}, async ({ ticketId, branchName, targetBranch, isWip }) => {
|
|
119
120
|
try {
|
|
120
|
-
await git.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
const branches = await git.branchLocal();
|
|
122
|
+
const branchExists = branches.all.includes(branchName);
|
|
123
|
+
if (branchExists) {
|
|
124
|
+
await git.checkout(branchName);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
await git.checkout(targetBranch);
|
|
128
|
+
await git.pull("origin", targetBranch);
|
|
129
|
+
await git.checkoutLocalBranch(branchName);
|
|
130
|
+
await git.commit("chore: init #" + ticketId, ["--allow-empty"]);
|
|
131
|
+
await git.push(["-u", "origin", branchName]);
|
|
132
|
+
}
|
|
125
133
|
const ticket = await getIssueDetails(ticketId);
|
|
126
134
|
const prTitle = isWip
|
|
127
135
|
? `WIP: ${ticket?.subject || `Ticket #${ticketId}`}`
|
|
@@ -136,7 +144,7 @@ server.registerTool("shift_left_start", {
|
|
|
136
144
|
});
|
|
137
145
|
if (!prResult.success) {
|
|
138
146
|
return {
|
|
139
|
-
content: [{ type: "text", text: `Rama '${branchName}'
|
|
147
|
+
content: [{ type: "text", text: `Rama '${branchName}' lista. Error al crear PR: ${prResult.error}` }],
|
|
140
148
|
isError: true,
|
|
141
149
|
};
|
|
142
150
|
}
|
|
@@ -148,9 +156,12 @@ server.registerTool("shift_left_start", {
|
|
|
148
156
|
success: true,
|
|
149
157
|
branch: branchName,
|
|
150
158
|
targetBranch,
|
|
159
|
+
branchCreated: !branchExists,
|
|
151
160
|
prUrl: prResult.url,
|
|
152
161
|
prNumber: prResult.number,
|
|
153
|
-
message:
|
|
162
|
+
message: branchExists
|
|
163
|
+
? `Rama '${branchName}' verificada. Draft PR creado: ${prResult.url}`
|
|
164
|
+
: `Rama '${branchName}' creada. Draft PR creado: ${prResult.url}`,
|
|
154
165
|
}, null, 2),
|
|
155
166
|
},
|
|
156
167
|
],
|
|
@@ -180,6 +191,28 @@ server.registerTool("update_pm_redmine", {
|
|
|
180
191
|
return { content: [{ type: "text", text: `Error actualizando Redmine: ${errorMessage}` }], isError: true };
|
|
181
192
|
}
|
|
182
193
|
});
|
|
194
|
+
server.registerTool("update_custom_fields", {
|
|
195
|
+
title: "Update Custom Fields",
|
|
196
|
+
description: "Actualiza los campos personalizados (Custom Fields) de un ticket de Redmine. Usa get_ticket_context primero para ver los campos disponibles y sus IDs.",
|
|
197
|
+
inputSchema: z.object({
|
|
198
|
+
ticketId: z.string().describe("ID del ticket de Redmine (ej: '1234')"),
|
|
199
|
+
customFields: z.array(z.object({
|
|
200
|
+
id: z.number().describe("ID del custom field"),
|
|
201
|
+
value: z.string().describe("Nuevo valor para el campo"),
|
|
202
|
+
})).describe("Array de custom fields a actualizar"),
|
|
203
|
+
}),
|
|
204
|
+
}, async ({ ticketId, customFields }) => {
|
|
205
|
+
try {
|
|
206
|
+
await updateIssueCustomFields(ticketId, customFields);
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: `Custom fields actualizados exitosamente en el ticket #${ticketId}` }],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const errorMessage = error instanceof Error ? error.message : "Error desconocido";
|
|
213
|
+
return { content: [{ type: "text", text: `Error actualizando custom fields: ${errorMessage}` }], isError: true };
|
|
214
|
+
}
|
|
215
|
+
});
|
|
183
216
|
server.registerPrompt("start_feature", {
|
|
184
217
|
title: "Start Feature",
|
|
185
218
|
description: "Inicia el flujo de trabajo para una nueva feature. Obtiene contexto del ticket y prepara el entorno en Gitea.",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { getConfig } from "../utils/config.js";
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const CUSTOM_FIELDS_CACHE_FILE = path.resolve(__dirname, "../../.rikudo_custom_fields.json");
|
|
9
|
+
let cachedFields = null;
|
|
10
|
+
export function getCustomFieldsCache() {
|
|
11
|
+
if (cachedFields) {
|
|
12
|
+
return cachedFields;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(CUSTOM_FIELDS_CACHE_FILE)) {
|
|
16
|
+
const data = JSON.parse(fs.readFileSync(CUSTOM_FIELDS_CACHE_FILE, "utf-8"));
|
|
17
|
+
cachedFields = new Map(data.fields.map((f) => [f.id, f]));
|
|
18
|
+
return cachedFields;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error("Error reading custom fields cache:", error);
|
|
23
|
+
}
|
|
24
|
+
return new Map();
|
|
25
|
+
}
|
|
26
|
+
export async function initCustomFieldsCache() {
|
|
27
|
+
const config = getConfig();
|
|
28
|
+
if (!config.REDMINE_URL || !config.REDMINE_API_KEY) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const client = axios.create({
|
|
33
|
+
baseURL: config.REDMINE_URL,
|
|
34
|
+
headers: { "X-Redmine-API-Key": config.REDMINE_API_KEY },
|
|
35
|
+
});
|
|
36
|
+
const response = await client.get("/custom_fields.json");
|
|
37
|
+
const fields = response.data.custom_fields;
|
|
38
|
+
const cache = {
|
|
39
|
+
fields,
|
|
40
|
+
updatedAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
fs.writeFileSync(CUSTOM_FIELDS_CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
43
|
+
cachedFields = new Map(fields.map((f) => [f.id, f]));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error("Error initializing custom fields cache:", error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function enrichCustomFields(customFields) {
|
|
50
|
+
const cache = getCustomFieldsCache();
|
|
51
|
+
return customFields.map((field) => {
|
|
52
|
+
const definition = cache.get(field.id);
|
|
53
|
+
return {
|
|
54
|
+
id: field.id,
|
|
55
|
+
name: definition?.name || `Field ${field.id}`,
|
|
56
|
+
value: field.value,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
package/dist/services/git.js
CHANGED
|
@@ -44,3 +44,18 @@ export async function getProjectId() {
|
|
|
44
44
|
}
|
|
45
45
|
return projectId;
|
|
46
46
|
}
|
|
47
|
+
export async function getGitRemoteInfo() {
|
|
48
|
+
const remote = await git.remote(["get-url", "origin"]);
|
|
49
|
+
if (!remote) {
|
|
50
|
+
throw new Error("No se pudo obtener el remote de Git");
|
|
51
|
+
}
|
|
52
|
+
const cleanRemote = remote.trim().replace(".git", "");
|
|
53
|
+
const url = new URL(cleanRemote);
|
|
54
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
55
|
+
if (pathParts.length < 2) {
|
|
56
|
+
throw new Error("Formato de remote no válido. Expected: https://gitea.host/owner/repo.git");
|
|
57
|
+
}
|
|
58
|
+
const owner = pathParts[pathParts.length - 2];
|
|
59
|
+
const repo = pathParts[pathParts.length - 1];
|
|
60
|
+
return { owner, repo };
|
|
61
|
+
}
|
package/dist/services/gitea.js
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
1
|
import axios, { AxiosError } from "axios";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { getGitRemoteInfo } from "./git.js";
|
|
3
|
+
const getGiteaConfig = async () => {
|
|
4
|
+
const url = process.env.RIKUDO_GITEA_URL || "";
|
|
5
|
+
const token = process.env.RIKUDO_GITEA_TOKEN || "";
|
|
6
|
+
let owner = process.env.RIKUDO_GITEA_OWNER || "";
|
|
7
|
+
let repo = process.env.RIKUDO_GITEA_REPO || "";
|
|
8
|
+
if (!owner || !repo) {
|
|
9
|
+
try {
|
|
10
|
+
const remoteInfo = await getGitRemoteInfo();
|
|
11
|
+
owner = owner || remoteInfo.owner;
|
|
12
|
+
repo = repo || remoteInfo.repo;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Remote info not available, will be validated later
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { url, token, owner, repo };
|
|
9
19
|
};
|
|
10
|
-
const getGiteaClient = () => {
|
|
11
|
-
const config = getGiteaConfig();
|
|
20
|
+
const getGiteaClient = (url, token) => {
|
|
12
21
|
return axios.create({
|
|
13
|
-
baseURL:
|
|
22
|
+
baseURL: url,
|
|
14
23
|
headers: {
|
|
15
|
-
Authorization: `token ${
|
|
24
|
+
Authorization: `token ${token}`,
|
|
16
25
|
"Content-Type": "application/json",
|
|
17
26
|
},
|
|
18
27
|
});
|
|
19
28
|
};
|
|
20
29
|
export const createPullRequest = async (payload) => {
|
|
21
|
-
const config = getGiteaConfig();
|
|
30
|
+
const config = await getGiteaConfig();
|
|
22
31
|
if (!config.url || !config.token || !config.owner || !config.repo) {
|
|
23
32
|
return {
|
|
24
33
|
success: false,
|
|
25
|
-
error: "Faltan variables de entorno de Gitea (RIKUDO_GITEA_URL, RIKUDO_GITEA_TOKEN, RIKUDO_GITEA_OWNER, RIKUDO_GITEA_REPO)",
|
|
34
|
+
error: "Faltan variables de entorno de Gitea (RIKUDO_GITEA_URL, RIKUDO_GITEA_TOKEN, RIKUDO_GITEA_OWNER, RIKUDO_GITEA_REPO) o no se pudo obtener el remote de Git",
|
|
26
35
|
};
|
|
27
36
|
}
|
|
28
37
|
try {
|
|
29
|
-
const client = getGiteaClient();
|
|
38
|
+
const client = getGiteaClient(config.url, config.token);
|
|
30
39
|
const endpoint = `/api/v1/repos/${config.owner}/${config.repo}/pulls`;
|
|
31
40
|
const response = await client.post(endpoint, {
|
|
32
41
|
title: payload.title,
|
package/dist/services/redmine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import axios, { AxiosError } from "axios";
|
|
2
|
+
import chalk from "chalk";
|
|
2
3
|
import { getConfig } from "../utils/config.js";
|
|
3
4
|
import z from "zod";
|
|
4
5
|
function cleanRedmineMarkdown(text) {
|
|
@@ -112,6 +113,7 @@ const issueSchema = z
|
|
|
112
113
|
updated: issue.updated_on,
|
|
113
114
|
estimatedHours: issue.estimated_hours,
|
|
114
115
|
},
|
|
116
|
+
customFields: issue.custom_fields,
|
|
115
117
|
};
|
|
116
118
|
});
|
|
117
119
|
const getHttpRedmineClient = () => {
|
|
@@ -157,6 +159,12 @@ export const updateIssueComment = async (issueId, comment) => {
|
|
|
157
159
|
console.error("❌ Error actualizando Redmine.");
|
|
158
160
|
}
|
|
159
161
|
};
|
|
162
|
+
export const updateIssueCustomFields = async (ticketId, customFields) => {
|
|
163
|
+
const client = getHttpRedmineClient();
|
|
164
|
+
await client.put(`/issues/${ticketId}.json`, {
|
|
165
|
+
issue: { custom_fields: customFields },
|
|
166
|
+
});
|
|
167
|
+
};
|
|
160
168
|
const userSchema = z
|
|
161
169
|
.object({
|
|
162
170
|
id: z.number(),
|
|
@@ -196,9 +204,37 @@ export const checkRedmineConnection = async () => {
|
|
|
196
204
|
return false;
|
|
197
205
|
}
|
|
198
206
|
};
|
|
207
|
+
export function printTicketDetails(ticket) {
|
|
208
|
+
console.log(chalk.bold.cyan("\n━━━ Ticket #" + ticket.id + " ━━━"));
|
|
209
|
+
console.log(chalk.bold.white(ticket.subject));
|
|
210
|
+
console.log(chalk.dim("─".repeat(40)));
|
|
211
|
+
console.log(chalk.gray("Estado: ") + chalk.green(ticket.status));
|
|
212
|
+
console.log(chalk.gray("Prioridad: ") + chalk.yellow(ticket.priority));
|
|
213
|
+
console.log(chalk.gray("Tracker: ") + ticket.tracker);
|
|
214
|
+
console.log(chalk.gray("Proyecto: ") + ticket.project);
|
|
215
|
+
console.log(chalk.gray("Autor: ") + ticket.author);
|
|
216
|
+
console.log(chalk.gray("URL: ") + chalk.blue.underline(ticket.url));
|
|
217
|
+
if (ticket.time.end) {
|
|
218
|
+
console.log(chalk.gray("Vencimiento: ") + chalk.red(ticket.time.end));
|
|
219
|
+
}
|
|
220
|
+
console.log(chalk.dim("─".repeat(40)));
|
|
221
|
+
console.log(chalk.bold("Descripción:"));
|
|
222
|
+
console.log(chalk.white(ticket.description));
|
|
223
|
+
if (ticket.customFields && ticket.customFields.length > 0) {
|
|
224
|
+
console.log(chalk.dim("─".repeat(40)));
|
|
225
|
+
console.log(chalk.bold("Custom Fields:"));
|
|
226
|
+
ticket.customFields.forEach((field) => {
|
|
227
|
+
const value = field.value && String(field.value).length > 0 ? String(field.value) : chalk.dim("(vacío)");
|
|
228
|
+
console.log(chalk.gray(` ${field.name}: `) + chalk.white(value));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
console.log(chalk.bold.cyan("━━━━━━━━━━━━━━━━━━━━\n"));
|
|
232
|
+
}
|
|
199
233
|
export const Redmine = {
|
|
200
234
|
getIssueDetails,
|
|
201
235
|
updateIssueComment,
|
|
236
|
+
updateIssueCustomFields,
|
|
202
237
|
getCurrentUser,
|
|
203
238
|
checkRedmineConnection,
|
|
239
|
+
printTicketDetails,
|
|
204
240
|
};
|