@itzjoordi/jira-mcp 1.0.1 → 1.0.3
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/.env-example +2 -1
- package/.husky/pre-commit +1 -0
- package/.prettierignore +0 -1
- package/package.json +14 -2
- package/server.mjs +4 -223
- package/{jiraClient.mjs → src/config.mjs} +20 -34
- package/src/domain/issues.mjs +67 -0
- package/src/domain/transitions.mjs +28 -0
- package/src/domain/user.mjs +8 -0
- package/src/domain/worklogs.mjs +225 -0
- package/src/infrastructure/jira-client.mjs +30 -0
- package/src/infrastructure/tempo-client.mjs +35 -0
- package/src/tools/index.mjs +12 -0
- package/src/tools/issues.mjs +157 -0
- package/src/tools/transitions.mjs +97 -0
- package/src/tools/worklogs.mjs +235 -0
- package/jiraApi.mjs +0 -61
package/.env-example
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx lint-staged
|
package/.prettierignore
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itzjoordi/jira-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Servidor MCP para Jira Mercadona usable desde Github Copilot Chat",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"lint": "eslint .",
|
|
11
11
|
"lint:fix": "eslint . --fix",
|
|
12
|
-
"format": "prettier --write ."
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"prepare": "husky"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
@@ -24,6 +25,17 @@
|
|
|
24
25
|
"@eslint/js": "^9.39.1",
|
|
25
26
|
"eslint": "^9.39.1",
|
|
26
27
|
"globals": "^16.5.0",
|
|
28
|
+
"husky": "^9.1.7",
|
|
29
|
+
"lint-staged": "^16.2.7",
|
|
27
30
|
"prettier": "^3.6.2"
|
|
31
|
+
},
|
|
32
|
+
"lint-staged": {
|
|
33
|
+
"*.{mjs,js}": [
|
|
34
|
+
"eslint --fix",
|
|
35
|
+
"prettier --write"
|
|
36
|
+
],
|
|
37
|
+
"*.{json,md}": [
|
|
38
|
+
"prettier --write"
|
|
39
|
+
]
|
|
28
40
|
}
|
|
29
41
|
}
|
package/server.mjs
CHANGED
|
@@ -1,235 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import { searchIssues, getIssueTransitions, transitionIssue } from './jiraApi.mjs';
|
|
4
|
+
import { registerAllTools } from './src/tools/index.mjs';
|
|
7
5
|
|
|
8
6
|
// 1. Crear servidor MCP
|
|
9
7
|
const server = new McpServer({
|
|
10
8
|
name: 'jira-mcp',
|
|
11
|
-
version: '
|
|
9
|
+
version: '1.0.3',
|
|
12
10
|
});
|
|
13
11
|
|
|
14
|
-
// 2.
|
|
15
|
-
|
|
16
|
-
server.registerTool(
|
|
17
|
-
'jira_list_filtered',
|
|
18
|
-
{
|
|
19
|
-
title: 'Jira: listar issues con filtros simples',
|
|
20
|
-
description:
|
|
21
|
-
'Lista issues de Jira usando filtros de alto nivel (solo mías, en curso, backlog, etc.) y construye la JQL automáticamente.',
|
|
22
|
-
inputSchema: {
|
|
23
|
-
projectKey: z
|
|
24
|
-
.string()
|
|
25
|
-
.optional()
|
|
26
|
-
.describe("Clave de proyecto, p.ej. 'SIM1'. Si se omite, busca en todos."),
|
|
27
|
-
onlyAssignedToMe: z
|
|
28
|
-
.boolean()
|
|
29
|
-
.optional()
|
|
30
|
-
.describe('Si es true, solo issues asignadas al usuario actual.'),
|
|
31
|
-
excludeAssignedToMe: z
|
|
32
|
-
.boolean()
|
|
33
|
-
.optional()
|
|
34
|
-
.describe('Si es true, excluye las issues asignadas al usuario actual.'),
|
|
35
|
-
statusCategory: z
|
|
36
|
-
.enum(['To Do', 'In Progress', 'Done'])
|
|
37
|
-
.optional()
|
|
38
|
-
.describe('Filtrar por categoría de estado de Jira.'),
|
|
39
|
-
statusIn: z
|
|
40
|
-
.array(z.string())
|
|
41
|
-
.optional()
|
|
42
|
-
.describe('Lista de nombres de estado, p.ej. ["Por Hacer", "Validar"].'),
|
|
43
|
-
backlogOnly: z
|
|
44
|
-
.boolean()
|
|
45
|
-
.optional()
|
|
46
|
-
.describe('Si es true, devuelve issues de backlog (aprox: no Done, normalmente To Do).'),
|
|
47
|
-
maxResults: z
|
|
48
|
-
.number()
|
|
49
|
-
.int()
|
|
50
|
-
.min(1)
|
|
51
|
-
.max(50)
|
|
52
|
-
.optional()
|
|
53
|
-
.describe('Máximo de issues a devolver (por defecto 20).'),
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
async ({
|
|
57
|
-
projectKey,
|
|
58
|
-
onlyAssignedToMe,
|
|
59
|
-
excludeAssignedToMe,
|
|
60
|
-
statusCategory,
|
|
61
|
-
statusIn,
|
|
62
|
-
backlogOnly,
|
|
63
|
-
maxResults,
|
|
64
|
-
}) => {
|
|
65
|
-
const clauses = [];
|
|
66
|
-
|
|
67
|
-
if (projectKey) {
|
|
68
|
-
clauses.push(`project = ${projectKey}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (onlyAssignedToMe) {
|
|
72
|
-
clauses.push('assignee = currentUser()');
|
|
73
|
-
} else if (excludeAssignedToMe) {
|
|
74
|
-
clauses.push('assignee != currentUser()');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (statusCategory) {
|
|
78
|
-
clauses.push(`statusCategory = "${statusCategory}"`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (statusIn && statusIn.length > 0) {
|
|
82
|
-
const quoted = statusIn.map((s) => `"${s}"`).join(', ');
|
|
83
|
-
clauses.push(`status in (${quoted})`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (backlogOnly) {
|
|
87
|
-
// Aproximación: backlog = no Done
|
|
88
|
-
clauses.push('statusCategory != "Done"');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
let jql = clauses.join(' AND ');
|
|
92
|
-
const order = 'ORDER BY created DESC';
|
|
93
|
-
|
|
94
|
-
if (jql.trim().length === 0) {
|
|
95
|
-
jql = order;
|
|
96
|
-
} else {
|
|
97
|
-
jql = `${jql} ${order}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const issues = await searchIssues(jql, maxResults ?? 20);
|
|
101
|
-
|
|
102
|
-
const humanText =
|
|
103
|
-
issues.length === 0
|
|
104
|
-
? `No se han encontrado issues para la JQL: ${jql}`
|
|
105
|
-
: `JQL usada: ${jql}\n\n` +
|
|
106
|
-
issues
|
|
107
|
-
.map(
|
|
108
|
-
(i) =>
|
|
109
|
-
`${i.key} | ${i.status} | ${i.summary}` + (i.assignee ? ` (${i.assignee})` : ''),
|
|
110
|
-
)
|
|
111
|
-
.join('\n');
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
content: [
|
|
115
|
-
{ type: 'text', text: humanText },
|
|
116
|
-
{
|
|
117
|
-
type: 'text',
|
|
118
|
-
text: '\n\n[JSON crudo para el modelo]\n' + JSON.stringify({ jql, issues }, null, 2),
|
|
119
|
-
},
|
|
120
|
-
],
|
|
121
|
-
structuredContent: { jql, issues },
|
|
122
|
-
};
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// Tool: obtener transiciones de un issue
|
|
127
|
-
server.registerTool(
|
|
128
|
-
'jira_get_transitions',
|
|
129
|
-
{
|
|
130
|
-
title: 'Jira: obtener transiciones de un issue',
|
|
131
|
-
description:
|
|
132
|
-
'Devuelve las transiciones posibles (id, name, toStatus) para un issue de Jira concreto.',
|
|
133
|
-
inputSchema: {
|
|
134
|
-
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
135
|
-
},
|
|
136
|
-
// outputSchema opcional:
|
|
137
|
-
// outputSchema: z.array(
|
|
138
|
-
// z.object({
|
|
139
|
-
// id: z.string(),
|
|
140
|
-
// name: z.string(),
|
|
141
|
-
// toStatus: z.string(),
|
|
142
|
-
// })
|
|
143
|
-
// ),
|
|
144
|
-
},
|
|
145
|
-
async ({ issueKey }) => {
|
|
146
|
-
const transitions = await getIssueTransitions(issueKey);
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
content: [
|
|
150
|
-
{
|
|
151
|
-
type: 'text',
|
|
152
|
-
text: JSON.stringify(transitions, null, 2),
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
structuredContent: transitions,
|
|
156
|
-
};
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Tool: hacer transición de un issue
|
|
161
|
-
server.registerTool(
|
|
162
|
-
'jira_transition_issue',
|
|
163
|
-
{
|
|
164
|
-
title: 'Jira: cambiar estado de un issue',
|
|
165
|
-
description: 'Ejecuta una transición de workflow en un issue de Jira usando su transitionId.',
|
|
166
|
-
inputSchema: {
|
|
167
|
-
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
168
|
-
transitionId: z.string().describe('ID de la transición (devuelta por jira_get_transitions)'),
|
|
169
|
-
},
|
|
170
|
-
// outputSchema opcional:
|
|
171
|
-
// outputSchema: z.object({ ok: z.boolean() }),
|
|
172
|
-
},
|
|
173
|
-
async ({ issueKey, transitionId }) => {
|
|
174
|
-
const result = await transitionIssue(issueKey, transitionId);
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
content: [
|
|
178
|
-
{
|
|
179
|
-
type: 'text',
|
|
180
|
-
text: JSON.stringify(result, null, 2),
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
structuredContent: result,
|
|
184
|
-
};
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
// Tool: mover issue por nombre de transición
|
|
189
|
-
server.registerTool(
|
|
190
|
-
'jira_move_issue_by_transition_name',
|
|
191
|
-
{
|
|
192
|
-
title: 'Jira: mover issue por nombre de transición',
|
|
193
|
-
description:
|
|
194
|
-
"Busca la transición por nombre (p.ej. 'Construir', 'Validar') y la ejecuta sobre el issue.",
|
|
195
|
-
inputSchema: {
|
|
196
|
-
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
197
|
-
transitionName: z.string().describe("Nombre exacto de la transición, p.ej. 'Construir'"),
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
async ({ issueKey, transitionName }) => {
|
|
201
|
-
const transitions = await getIssueTransitions(issueKey);
|
|
202
|
-
const match = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase());
|
|
203
|
-
|
|
204
|
-
if (!match) {
|
|
205
|
-
const available = transitions.map((t) => t.name).join(', ');
|
|
206
|
-
const msg =
|
|
207
|
-
`No se ha encontrado ninguna transición llamada "${transitionName}" ` +
|
|
208
|
-
`para el issue ${issueKey}. Transiciones disponibles: ${available}`;
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
content: [{ type: 'text', text: msg }],
|
|
212
|
-
structuredContent: { ok: false, error: msg, transitions },
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const result = await transitionIssue(issueKey, match.id);
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
content: [
|
|
220
|
-
{
|
|
221
|
-
type: 'text',
|
|
222
|
-
text: `Transición "${match.name}" aplicada a ${issueKey}. Nuevo estado: ${match.toStatus}`,
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
type: 'text',
|
|
226
|
-
text: '\n[Detalle JSON]\n' + JSON.stringify({ result, usedTransition: match }, null, 2),
|
|
227
|
-
},
|
|
228
|
-
],
|
|
229
|
-
structuredContent: { ok: true, usedTransition: match },
|
|
230
|
-
};
|
|
231
|
-
},
|
|
232
|
-
);
|
|
12
|
+
// 2. Registrar todas las tools
|
|
13
|
+
registerAllTools(server);
|
|
233
14
|
|
|
234
15
|
// 3. Conectar por STDIO
|
|
235
16
|
const transport = new StdioServerTransport();
|
|
@@ -2,21 +2,22 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import dotenv from 'dotenv';
|
|
5
|
-
import { Buffer } from 'buffer';
|
|
6
5
|
import process from 'process';
|
|
7
6
|
|
|
7
|
+
// ── Cargar .env global (una sola vez) ───────────────────────────
|
|
8
8
|
let envLoaded = false;
|
|
9
9
|
|
|
10
10
|
function loadGlobalEnvOnce() {
|
|
11
11
|
if (envLoaded) return;
|
|
12
12
|
envLoaded = true;
|
|
13
|
+
|
|
13
14
|
const home = os.homedir();
|
|
14
15
|
const candidates = [path.join(home, '.jira-mcp.env'), path.join(home, '.jira-mcp', '.env')];
|
|
15
16
|
|
|
16
17
|
for (const p of candidates) {
|
|
17
18
|
try {
|
|
18
19
|
if (fs.existsSync(p)) {
|
|
19
|
-
dotenv.config({ path: p });
|
|
20
|
+
dotenv.config({ path: p, quiet: true });
|
|
20
21
|
break;
|
|
21
22
|
}
|
|
22
23
|
} catch {
|
|
@@ -27,10 +28,21 @@ function loadGlobalEnvOnce() {
|
|
|
27
28
|
|
|
28
29
|
loadGlobalEnvOnce();
|
|
29
30
|
|
|
30
|
-
//
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// ── Configuración exportada ─────────────────────────────────────
|
|
32
|
+
export const config = {
|
|
33
|
+
jira: {
|
|
34
|
+
baseUrl: process.env.JIRA_BASE_URL,
|
|
35
|
+
email: process.env.JIRA_EMAIL,
|
|
36
|
+
apiToken: process.env.JIRA_API_TOKEN,
|
|
37
|
+
},
|
|
38
|
+
tempo: {
|
|
39
|
+
apiToken: process.env.TEMPO_API_TOKEN || null,
|
|
40
|
+
baseUrl: 'https://api.tempo.io/4',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── Validación de credenciales obligatorias ──────────────────────
|
|
45
|
+
const { baseUrl, email, apiToken } = config.jira;
|
|
34
46
|
|
|
35
47
|
if (!baseUrl || !email || !apiToken) {
|
|
36
48
|
throw new Error(
|
|
@@ -40,33 +52,7 @@ if (!baseUrl || !email || !apiToken) {
|
|
|
40
52
|
'Variables requeridas:\n' +
|
|
41
53
|
'JIRA_BASE_URL=https://<tu-org>.atlassian.net\n' +
|
|
42
54
|
'JIRA_EMAIL=tu-email@empresa.com\n' +
|
|
43
|
-
'JIRA_API_TOKEN=<tu-token>\n'
|
|
55
|
+
'JIRA_API_TOKEN=<tu-token>\n' +
|
|
56
|
+
'TEMPO_API_TOKEN=<tu-token-de-tempo> (opcional, para worklogs Tempo)\n',
|
|
44
57
|
);
|
|
45
58
|
}
|
|
46
|
-
|
|
47
|
-
const authHeader = 'Basic ' + Buffer.from(`${email}:${apiToken}`).toString('base64');
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Llamada genérica a Jira con manejo de errores
|
|
51
|
-
*/
|
|
52
|
-
export async function jiraRequest(path, options = {}) {
|
|
53
|
-
const url = `${baseUrl}${path}`;
|
|
54
|
-
|
|
55
|
-
const response = await fetch(url, {
|
|
56
|
-
...options,
|
|
57
|
-
headers: {
|
|
58
|
-
Authorization: authHeader,
|
|
59
|
-
Accept: 'application/json',
|
|
60
|
-
'Content-Type': 'application/json',
|
|
61
|
-
...(options.headers || {}),
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const text = await response.text();
|
|
66
|
-
|
|
67
|
-
if (!response.ok) {
|
|
68
|
-
throw new Error(`Jira error ${response.status} ${response.statusText}: ${text.slice(0, 400)}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return text ? JSON.parse(text) : {};
|
|
72
|
-
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jiraRequest } from '../infrastructure/jira-client.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Buscar issues usando JQL.
|
|
5
|
+
*/
|
|
6
|
+
export async function searchIssues(jql, maxResults = 20) {
|
|
7
|
+
const body = {
|
|
8
|
+
jql,
|
|
9
|
+
maxResults,
|
|
10
|
+
fields: ['summary', 'status', 'assignee', 'priority', 'subtasks'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const data = await jiraRequest('/rest/api/3/search/jql', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
body: JSON.stringify(body),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const issues = data.issues ?? [];
|
|
19
|
+
|
|
20
|
+
return issues.map((issue) => ({
|
|
21
|
+
key: issue.key,
|
|
22
|
+
summary: issue.fields.summary,
|
|
23
|
+
status: issue.fields.status.name,
|
|
24
|
+
assignee: issue.fields.assignee?.displayName ?? null,
|
|
25
|
+
priority: issue.fields.priority?.name ?? null,
|
|
26
|
+
subtasks:
|
|
27
|
+
issue.fields.subtasks?.map((s) => ({
|
|
28
|
+
key: s.key,
|
|
29
|
+
summary: s.fields.summary,
|
|
30
|
+
status: s.fields.status.name,
|
|
31
|
+
statusCategory: s.fields.status.statusCategory?.name,
|
|
32
|
+
})) ?? [],
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Obtener detalle de un issue por su clave.
|
|
38
|
+
*/
|
|
39
|
+
export async function getIssue(issueKey) {
|
|
40
|
+
const data = await jiraRequest(
|
|
41
|
+
`/rest/api/3/issue/${issueKey}?fields=summary,status,assignee,priority,description,created,comment,subtasks`,
|
|
42
|
+
{ method: 'GET' },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
key: data.key,
|
|
47
|
+
summary: data.fields.summary,
|
|
48
|
+
status: data.fields.status.name,
|
|
49
|
+
assignee: data.fields.assignee?.displayName ?? null,
|
|
50
|
+
priority: data.fields.priority?.name ?? null,
|
|
51
|
+
created: data.fields.created,
|
|
52
|
+
description: data.fields.description,
|
|
53
|
+
comments:
|
|
54
|
+
data.fields.comment?.comments.map((c) => ({
|
|
55
|
+
author: c.author.displayName,
|
|
56
|
+
body: c.body,
|
|
57
|
+
created: c.created,
|
|
58
|
+
})) ?? [],
|
|
59
|
+
subtasks:
|
|
60
|
+
data.fields.subtasks?.map((s) => ({
|
|
61
|
+
key: s.key,
|
|
62
|
+
summary: s.fields.summary,
|
|
63
|
+
status: s.fields.status.name,
|
|
64
|
+
statusCategory: s.fields.status.statusCategory?.name,
|
|
65
|
+
})) ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jiraRequest } from '../infrastructure/jira-client.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Obtener las transiciones posibles para un issue.
|
|
5
|
+
*/
|
|
6
|
+
export async function getTransitions(issueKey) {
|
|
7
|
+
const data = await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
|
|
8
|
+
method: 'GET',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return (data.transitions ?? []).map((t) => ({
|
|
12
|
+
id: t.id,
|
|
13
|
+
name: t.name,
|
|
14
|
+
toStatus: t.to.name,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ejecutar una transición (mover a otro estado/columna).
|
|
20
|
+
*/
|
|
21
|
+
export async function transitionIssue(issueKey, transitionId) {
|
|
22
|
+
await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: JSON.stringify({ transition: { id: transitionId } }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return { ok: true };
|
|
28
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { jiraRequest } from '../infrastructure/jira-client.mjs';
|
|
2
|
+
import { tempoRequest } from '../infrastructure/tempo-client.mjs';
|
|
3
|
+
|
|
4
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
5
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
6
|
+
const toLocalDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolver IDs numéricos de Jira a { key, summary } en batch.
|
|
10
|
+
*/
|
|
11
|
+
async function resolveIssueKeys(issueIds) {
|
|
12
|
+
const idToKey = {};
|
|
13
|
+
if (issueIds.length === 0) return idToKey;
|
|
14
|
+
|
|
15
|
+
const jql = `id in (${issueIds.join(',')})`;
|
|
16
|
+
const data = await jiraRequest('/rest/api/3/search/jql', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
body: JSON.stringify({ jql, maxResults: 50, fields: ['summary'] }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
for (const issue of data.issues ?? []) {
|
|
22
|
+
idToKey[issue.id] = { key: issue.key, summary: issue.fields.summary };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return idToKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generar lista de días laborables (L-V) en un rango.
|
|
30
|
+
*/
|
|
31
|
+
function getBusinessDays(startDate, endDate) {
|
|
32
|
+
const days = [];
|
|
33
|
+
const cursor = new Date(`${startDate}T12:00:00`);
|
|
34
|
+
const end = new Date(`${endDate}T12:00:00`);
|
|
35
|
+
|
|
36
|
+
while (cursor <= end) {
|
|
37
|
+
const day = cursor.getDay();
|
|
38
|
+
if (day !== 0 && day !== 6) {
|
|
39
|
+
days.push(toLocalDate(cursor));
|
|
40
|
+
}
|
|
41
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return days;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Jira nativo: registrar worklog ──────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Registrar horas de trabajo en un issue (API nativa de Jira).
|
|
51
|
+
*/
|
|
52
|
+
export async function addWorklog(issueKey, timeSpent, commentText = '', started = null) {
|
|
53
|
+
const body = { timeSpent };
|
|
54
|
+
|
|
55
|
+
if (started) {
|
|
56
|
+
body.started = started;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (commentText) {
|
|
60
|
+
body.comment = {
|
|
61
|
+
type: 'doc',
|
|
62
|
+
version: 1,
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: 'paragraph',
|
|
66
|
+
content: [{ text: commentText, type: 'text' }],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await jiraRequest(`/rest/api/3/issue/${issueKey}/worklog`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Tempo: consultar worklogs ───────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Obtener worklogs de un usuario en un rango de fechas (Tempo API).
|
|
84
|
+
*/
|
|
85
|
+
export async function getTempoWorklogs(accountId, from, to) {
|
|
86
|
+
let allWorklogs = [];
|
|
87
|
+
let url = `/worklogs/user/${accountId}?from=${from}&to=${to}&limit=1000`;
|
|
88
|
+
|
|
89
|
+
while (url) {
|
|
90
|
+
const data = await tempoRequest(url);
|
|
91
|
+
const results = data.results ?? [];
|
|
92
|
+
allWorklogs = allWorklogs.concat(results);
|
|
93
|
+
|
|
94
|
+
if (data.metadata?.next) {
|
|
95
|
+
const nextUrl = new URL(data.metadata.next);
|
|
96
|
+
url = nextUrl.pathname.replace('/4', '') + nextUrl.search;
|
|
97
|
+
} else {
|
|
98
|
+
url = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return allWorklogs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Detectar días laborables sin horas registradas (Tempo).
|
|
107
|
+
*/
|
|
108
|
+
export async function getMissingWorklogDays(accountId, startDate, endDate) {
|
|
109
|
+
const worklogs = await getTempoWorklogs(accountId, startDate, endDate);
|
|
110
|
+
|
|
111
|
+
// Agrupar horas por día
|
|
112
|
+
const dailyHours = {};
|
|
113
|
+
for (const wl of worklogs) {
|
|
114
|
+
const date = wl.startDate;
|
|
115
|
+
dailyHours[date] = (dailyHours[date] || 0) + wl.timeSpentSeconds / 3600;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const businessDays = getBusinessDays(startDate, endDate);
|
|
119
|
+
const today = toLocalDate(new Date());
|
|
120
|
+
const missingDays = businessDays.filter((d) => d <= today && !dailyHours[d]);
|
|
121
|
+
|
|
122
|
+
// Redondear
|
|
123
|
+
for (const d of Object.keys(dailyHours)) {
|
|
124
|
+
dailyHours[d] = Math.round(dailyHours[d] * 100) / 100;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
startDate,
|
|
129
|
+
endDate,
|
|
130
|
+
totalBusinessDays: businessDays.filter((d) => d <= today).length,
|
|
131
|
+
daysWithWorklogs: Object.keys(dailyHours).length,
|
|
132
|
+
missingDays,
|
|
133
|
+
dailyHours,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Obtener worklogs agrupados por issue (Tempo).
|
|
139
|
+
*/
|
|
140
|
+
export async function getWorklogsByIssue(accountId, from, to) {
|
|
141
|
+
const worklogs = await getTempoWorklogs(accountId, from, to);
|
|
142
|
+
|
|
143
|
+
const issueIds = [...new Set(worklogs.map((wl) => wl.issue?.id).filter(Boolean))];
|
|
144
|
+
const idToKey = await resolveIssueKeys(issueIds);
|
|
145
|
+
|
|
146
|
+
const byIssue = {};
|
|
147
|
+
let totalHours = 0;
|
|
148
|
+
|
|
149
|
+
for (const wl of worklogs) {
|
|
150
|
+
const resolved = idToKey[wl.issue?.id];
|
|
151
|
+
const key = resolved?.key ?? `ID-${wl.issue?.id}`;
|
|
152
|
+
const summary = resolved?.summary ?? '(sin título)';
|
|
153
|
+
const hours = Math.round((wl.timeSpentSeconds / 3600) * 100) / 100;
|
|
154
|
+
|
|
155
|
+
if (!byIssue[key]) {
|
|
156
|
+
byIssue[key] = { key, summary, totalHours: 0, entries: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
byIssue[key].totalHours += hours;
|
|
160
|
+
totalHours += hours;
|
|
161
|
+
|
|
162
|
+
byIssue[key].entries.push({
|
|
163
|
+
date: wl.startDate,
|
|
164
|
+
hours,
|
|
165
|
+
description: wl.description || '(sin descripción)',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const issue of Object.values(byIssue)) {
|
|
170
|
+
issue.totalHours = Math.round(issue.totalHours * 100) / 100;
|
|
171
|
+
issue.entries.sort((a, b) => a.date.localeCompare(b.date));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sorted = Object.values(byIssue).sort((a, b) => b.totalHours - a.totalHours);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
from,
|
|
178
|
+
to,
|
|
179
|
+
totalHours: Math.round(totalHours * 100) / 100,
|
|
180
|
+
totalEntries: worklogs.length,
|
|
181
|
+
issues: sorted,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Obtener worklogs agrupados por día (Tempo).
|
|
187
|
+
*/
|
|
188
|
+
export async function getWorklogsByDay(accountId, from, to) {
|
|
189
|
+
const worklogs = await getTempoWorklogs(accountId, from, to);
|
|
190
|
+
|
|
191
|
+
const issueIds = [...new Set(worklogs.map((wl) => wl.issue?.id).filter(Boolean))];
|
|
192
|
+
const idToKey = await resolveIssueKeys(issueIds);
|
|
193
|
+
|
|
194
|
+
const byDay = {};
|
|
195
|
+
let totalHours = 0;
|
|
196
|
+
|
|
197
|
+
for (const wl of worklogs) {
|
|
198
|
+
const date = wl.startDate;
|
|
199
|
+
const resolved = idToKey[wl.issue?.id];
|
|
200
|
+
const key = resolved?.key ?? `ID-${wl.issue?.id}`;
|
|
201
|
+
const summary = resolved?.summary ?? '(sin título)';
|
|
202
|
+
const hours = Math.round((wl.timeSpentSeconds / 3600) * 100) / 100;
|
|
203
|
+
|
|
204
|
+
if (!byDay[date]) {
|
|
205
|
+
byDay[date] = { date, totalHours: 0, entries: [] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
byDay[date].totalHours += hours;
|
|
209
|
+
totalHours += hours;
|
|
210
|
+
|
|
211
|
+
byDay[date].entries.push({ key, summary, hours, description: wl.description || '' });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sortedDays = Object.values(byDay)
|
|
215
|
+
.map((d) => ({ ...d, totalHours: Math.round(d.totalHours * 100) / 100 }))
|
|
216
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
from,
|
|
220
|
+
to,
|
|
221
|
+
totalHours: Math.round(totalHours * 100) / 100,
|
|
222
|
+
totalEntries: worklogs.length,
|
|
223
|
+
days: sortedDays,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { config } from '../config.mjs';
|
|
3
|
+
|
|
4
|
+
const { baseUrl, email, apiToken } = config.jira;
|
|
5
|
+
const authHeader = 'Basic ' + Buffer.from(`${email}:${apiToken}`).toString('base64');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Llamada genérica a la API de Jira con manejo de errores.
|
|
9
|
+
*/
|
|
10
|
+
export async function jiraRequest(path, options = {}) {
|
|
11
|
+
const url = `${baseUrl}${path}`;
|
|
12
|
+
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
...options,
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: authHeader,
|
|
17
|
+
Accept: 'application/json',
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
...(options.headers || {}),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`Jira error ${response.status} ${response.statusText}: ${text.slice(0, 400)}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return text ? JSON.parse(text) : {};
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { config } from '../config.mjs';
|
|
2
|
+
|
|
3
|
+
const { apiToken, baseUrl } = config.tempo;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Llamada genérica a la API de Tempo con manejo de errores.
|
|
7
|
+
*/
|
|
8
|
+
export async function tempoRequest(path, options = {}) {
|
|
9
|
+
if (!apiToken) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'Falta TEMPO_API_TOKEN. Añádelo a tu .env global (~/.jira-mcp.env).\n' +
|
|
12
|
+
'Puedes generarlo en Tempo → Configuración → API Integration → New Token.',
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const url = `${baseUrl}${path}`;
|
|
17
|
+
|
|
18
|
+
const response = await fetch(url, {
|
|
19
|
+
...options,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${apiToken}`,
|
|
22
|
+
Accept: 'application/json',
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
...(options.headers || {}),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`Tempo error ${response.status} ${response.statusText}: ${text.slice(0, 400)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return text ? JSON.parse(text) : {};
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { registerIssueTools } from './issues.mjs';
|
|
2
|
+
import { registerTransitionTools } from './transitions.mjs';
|
|
3
|
+
import { registerWorklogTools } from './worklogs.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registra todas las tools MCP en el servidor.
|
|
7
|
+
*/
|
|
8
|
+
export function registerAllTools(server) {
|
|
9
|
+
registerIssueTools(server);
|
|
10
|
+
registerTransitionTools(server);
|
|
11
|
+
registerWorklogTools(server);
|
|
12
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchIssues, getIssue } from '../domain/issues.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registra las tools MCP relacionadas con issues.
|
|
6
|
+
*/
|
|
7
|
+
export function registerIssueTools(server) {
|
|
8
|
+
// ── Listar issues con filtros ───────────────────────────────
|
|
9
|
+
server.registerTool(
|
|
10
|
+
'jira_list_filtered',
|
|
11
|
+
{
|
|
12
|
+
title: 'Jira: listar issues con filtros simples',
|
|
13
|
+
description:
|
|
14
|
+
'Lista issues de Jira usando filtros de alto nivel (solo mías, en curso, backlog, etc.) y construye la JQL automáticamente.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
projectKey: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Clave de proyecto, p.ej. 'SIM1'. Si se omite, busca en todos."),
|
|
20
|
+
onlyAssignedToMe: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Si es true, solo issues asignadas al usuario actual.'),
|
|
24
|
+
excludeAssignedToMe: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Si es true, excluye las issues asignadas al usuario actual.'),
|
|
28
|
+
statusCategory: z
|
|
29
|
+
.enum(['To Do', 'In Progress', 'Done'])
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Filtrar por categoría de estado de Jira.'),
|
|
32
|
+
statusIn: z
|
|
33
|
+
.array(z.string())
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Lista de nombres de estado, p.ej. ["Por Hacer", "Validar"].'),
|
|
36
|
+
backlogOnly: z
|
|
37
|
+
.boolean()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Si es true, devuelve issues de backlog (aprox: no Done, normalmente To Do).'),
|
|
40
|
+
maxResults: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(50)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe('Máximo de issues a devolver (por defecto 20).'),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
async ({
|
|
50
|
+
projectKey,
|
|
51
|
+
onlyAssignedToMe,
|
|
52
|
+
excludeAssignedToMe,
|
|
53
|
+
statusCategory,
|
|
54
|
+
statusIn,
|
|
55
|
+
backlogOnly,
|
|
56
|
+
maxResults,
|
|
57
|
+
}) => {
|
|
58
|
+
const clauses = [];
|
|
59
|
+
|
|
60
|
+
if (projectKey) clauses.push(`project = "${projectKey}"`);
|
|
61
|
+
|
|
62
|
+
if (onlyAssignedToMe) {
|
|
63
|
+
clauses.push('assignee = currentUser()');
|
|
64
|
+
} else if (excludeAssignedToMe) {
|
|
65
|
+
clauses.push('assignee != currentUser()');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (statusCategory) {
|
|
69
|
+
clauses.push(`statusCategory = "${statusCategory}"`);
|
|
70
|
+
if (statusCategory === 'In Progress') {
|
|
71
|
+
clauses.push('status != "Validar"');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (statusIn && statusIn.length > 0) {
|
|
76
|
+
const quoted = statusIn.map((s) => `"${s}"`).join(', ');
|
|
77
|
+
clauses.push(`status in (${quoted})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (backlogOnly) {
|
|
81
|
+
clauses.push('statusCategory != "Done"');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let jql = clauses.join(' AND ');
|
|
85
|
+
const order = 'ORDER BY created DESC';
|
|
86
|
+
jql = jql.trim().length === 0 ? order : `${jql} ${order}`;
|
|
87
|
+
|
|
88
|
+
const issues = await searchIssues(jql, maxResults ?? 20);
|
|
89
|
+
|
|
90
|
+
const humanText =
|
|
91
|
+
issues.length === 0
|
|
92
|
+
? `No se han encontrado issues para la JQL: ${jql}`
|
|
93
|
+
: `JQL usada: ${jql}\n\n` +
|
|
94
|
+
issues
|
|
95
|
+
.map((i) => {
|
|
96
|
+
let text =
|
|
97
|
+
`${i.key} | ${i.status} | ${i.summary}` + (i.assignee ? ` (${i.assignee})` : '');
|
|
98
|
+
if (i.subtasks && i.subtasks.length > 0) {
|
|
99
|
+
const subs = i.subtasks
|
|
100
|
+
.map((s) => ` - [Sub] ${s.key} | ${s.status} | ${s.summary}`)
|
|
101
|
+
.join('\n');
|
|
102
|
+
text += `\n${subs}`;
|
|
103
|
+
}
|
|
104
|
+
return text;
|
|
105
|
+
})
|
|
106
|
+
.join('\n\n');
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
content: [
|
|
110
|
+
{ type: 'text', text: humanText },
|
|
111
|
+
{
|
|
112
|
+
type: 'text',
|
|
113
|
+
text: '\n\n[JSON crudo para el modelo]\n' + JSON.stringify({ jql, issues }, null, 2),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
structuredContent: { jql, issues },
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// ── Detalle de un issue ─────────────────────────────────────
|
|
122
|
+
server.registerTool(
|
|
123
|
+
'jira_get_issue',
|
|
124
|
+
{
|
|
125
|
+
title: 'Jira: obtener detalle de un issue',
|
|
126
|
+
description: 'Devuelve información detallada de un issue (descripción, comentarios, etc.).',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
async ({ issueKey }) => {
|
|
132
|
+
const issue = await getIssue(issueKey);
|
|
133
|
+
|
|
134
|
+
const pendingSubs = (issue.subtasks || []).filter((s) => s.statusCategory !== 'Done');
|
|
135
|
+
let summaryText = `Detalle de ${issue.key}: ${issue.summary}\n`;
|
|
136
|
+
summaryText += `Estado: ${issue.status} | Asignado: ${issue.assignee}\n`;
|
|
137
|
+
|
|
138
|
+
if (pendingSubs.length > 0) {
|
|
139
|
+
summaryText += `\n⚠️ Subtareas pendientes (${pendingSubs.length}):\n`;
|
|
140
|
+
summaryText += pendingSubs.map((s) => `- ${s.key} [${s.status}]: ${s.summary}`).join('\n');
|
|
141
|
+
} else if (issue.subtasks?.length > 0) {
|
|
142
|
+
summaryText += `\n✅ Todas las subtareas (${issue.subtasks.length}) están finalizadas.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
content: [
|
|
147
|
+
{ type: 'text', text: summaryText },
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: '\n[JSON completo]\n' + JSON.stringify(issue, null, 2),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
structuredContent: issue,
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getTransitions, transitionIssue } from '../domain/transitions.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registra las tools MCP relacionadas con transiciones.
|
|
6
|
+
*/
|
|
7
|
+
export function registerTransitionTools(server) {
|
|
8
|
+
// ── Obtener transiciones ────────────────────────────────────
|
|
9
|
+
server.registerTool(
|
|
10
|
+
'jira_get_transitions',
|
|
11
|
+
{
|
|
12
|
+
title: 'Jira: obtener transiciones de un issue',
|
|
13
|
+
description:
|
|
14
|
+
'Devuelve las transiciones posibles (id, name, toStatus) para un issue de Jira concreto.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async ({ issueKey }) => {
|
|
20
|
+
const transitions = await getTransitions(issueKey);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: JSON.stringify(transitions, null, 2) }],
|
|
24
|
+
structuredContent: transitions,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// ── Ejecutar transición por ID ──────────────────────────────
|
|
30
|
+
server.registerTool(
|
|
31
|
+
'jira_transition_issue',
|
|
32
|
+
{
|
|
33
|
+
title: 'Jira: cambiar estado de un issue',
|
|
34
|
+
description: 'Ejecuta una transición de workflow en un issue de Jira usando su transitionId.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
37
|
+
transitionId: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe('ID de la transición (devuelta por jira_get_transitions)'),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async ({ issueKey, transitionId }) => {
|
|
43
|
+
const result = await transitionIssue(issueKey, transitionId);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
47
|
+
structuredContent: result,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ── Mover por nombre de transición ──────────────────────────
|
|
53
|
+
server.registerTool(
|
|
54
|
+
'jira_move_issue_by_transition_name',
|
|
55
|
+
{
|
|
56
|
+
title: 'Jira: mover issue por nombre de transición',
|
|
57
|
+
description:
|
|
58
|
+
"Busca la transición por nombre (p.ej. 'Construir', 'Validar') y la ejecuta sobre el issue.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
61
|
+
transitionName: z.string().describe("Nombre exacto de la transición, p.ej. 'Construir'"),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
async ({ issueKey, transitionName }) => {
|
|
65
|
+
const transitions = await getTransitions(issueKey);
|
|
66
|
+
const match = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase());
|
|
67
|
+
|
|
68
|
+
if (!match) {
|
|
69
|
+
const available = transitions.map((t) => t.name).join(', ');
|
|
70
|
+
const msg =
|
|
71
|
+
`No se ha encontrado ninguna transición llamada "${transitionName}" ` +
|
|
72
|
+
`para el issue ${issueKey}. Transiciones disponibles: ${available}`;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: msg }],
|
|
76
|
+
structuredContent: { ok: false, error: msg, transitions },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = await transitionIssue(issueKey, match.id);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: `Transición "${match.name}" aplicada a ${issueKey}. Nuevo estado: ${match.toStatus}`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: '\n[Detalle JSON]\n' + JSON.stringify({ result, usedTransition: match }, null, 2),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
structuredContent: { ok: true, usedTransition: match },
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { config } from '../config.mjs';
|
|
3
|
+
import { getCurrentUser } from '../domain/user.mjs';
|
|
4
|
+
import {
|
|
5
|
+
addWorklog,
|
|
6
|
+
getMissingWorklogDays,
|
|
7
|
+
getWorklogsByIssue,
|
|
8
|
+
getWorklogsByDay,
|
|
9
|
+
} from '../domain/worklogs.mjs';
|
|
10
|
+
|
|
11
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
12
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
13
|
+
|
|
14
|
+
function getDefaultDateRange() {
|
|
15
|
+
const now = new Date();
|
|
16
|
+
return {
|
|
17
|
+
start: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-01`,
|
|
18
|
+
end: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireTempo() {
|
|
23
|
+
if (!config.tempo.apiToken) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text:
|
|
29
|
+
'⚠️ Falta TEMPO_API_TOKEN en tu .env global (~/.jira-mcp.env).\n' +
|
|
30
|
+
'Genera uno en Tempo → Configuración → API Integration → New Token.',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dateRangeSchema = {
|
|
39
|
+
startDate: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('Fecha de inicio en formato YYYY-MM-DD. Por defecto: primer día del mes actual.'),
|
|
43
|
+
endDate: z.string().optional().describe('Fecha de fin en formato YYYY-MM-DD. Por defecto: hoy.'),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Registra las tools MCP relacionadas con worklogs.
|
|
48
|
+
*/
|
|
49
|
+
export function registerWorklogTools(server) {
|
|
50
|
+
// ── Registrar horas ─────────────────────────────────────────
|
|
51
|
+
server.registerTool(
|
|
52
|
+
'jira_add_worklog',
|
|
53
|
+
{
|
|
54
|
+
title: 'Jira: registrar horas de trabajo',
|
|
55
|
+
description: 'Registra tiempo trabajado en un issue o subtarea (p.ej. "1h 30m").',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
|
|
58
|
+
timeSpent: z.string().describe('Tiempo trabajado, p.ej. "30m", "1h", "2h 15m"'),
|
|
59
|
+
comment: z
|
|
60
|
+
.string()
|
|
61
|
+
.describe(
|
|
62
|
+
'Comentario obligatorio sobre el trabajo realizado (p.ej. "Desarrollo de componentes")',
|
|
63
|
+
),
|
|
64
|
+
started: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(
|
|
68
|
+
'Fecha y hora de inicio en formato ISO 8601 (p.ej. 2026-02-18T09:00:00.000+0000). Úsalo si el usuario menciona una fecha pasada como "ayer".',
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async ({ issueKey, timeSpent, comment, started }) => {
|
|
73
|
+
const result = await addWorklog(issueKey, timeSpent, comment, started);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{ type: 'text', text: `Se han registrado ${timeSpent} en el issue ${issueKey}.` },
|
|
78
|
+
],
|
|
79
|
+
structuredContent: result,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// ── Días sin horas registradas (Tempo) ──────────────────────
|
|
85
|
+
server.registerTool(
|
|
86
|
+
'jira_missing_worklogs',
|
|
87
|
+
{
|
|
88
|
+
title: 'Jira: días sin horas registradas',
|
|
89
|
+
description:
|
|
90
|
+
'Detecta qué días laborables (L-V) NO tienen horas registradas para el usuario actual. ' +
|
|
91
|
+
'Por defecto analiza el mes en curso.',
|
|
92
|
+
inputSchema: dateRangeSchema,
|
|
93
|
+
},
|
|
94
|
+
async ({ startDate, endDate }) => {
|
|
95
|
+
const tempoError = requireTempo();
|
|
96
|
+
if (tempoError) return tempoError;
|
|
97
|
+
|
|
98
|
+
const defaults = getDefaultDateRange();
|
|
99
|
+
const from = startDate || defaults.start;
|
|
100
|
+
const to = endDate || defaults.end;
|
|
101
|
+
|
|
102
|
+
const currentUser = await getCurrentUser();
|
|
103
|
+
const result = await getMissingWorklogDays(currentUser.accountId, from, to);
|
|
104
|
+
|
|
105
|
+
let text = `📊 Reporte de worklogs: ${from} → ${to}\n`;
|
|
106
|
+
text += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
107
|
+
text += `Días laborables en el rango: ${result.totalBusinessDays}\n`;
|
|
108
|
+
text += `Días con horas registradas: ${result.daysWithWorklogs}\n`;
|
|
109
|
+
text += `Días SIN horas registradas: ${result.missingDays.length}\n\n`;
|
|
110
|
+
|
|
111
|
+
if (result.missingDays.length > 0) {
|
|
112
|
+
text += `❌ Días pendientes:\n`;
|
|
113
|
+
for (const d of result.missingDays) {
|
|
114
|
+
const dayName = new Date(`${d}T12:00:00`).toLocaleDateString('es-ES', {
|
|
115
|
+
weekday: 'long',
|
|
116
|
+
});
|
|
117
|
+
text += ` • ${d} (${dayName})\n`;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
text += `✅ ¡Todos los días laborables tienen horas registradas!\n`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (Object.keys(result.dailyHours).length > 0) {
|
|
124
|
+
text += `\n📝 Horas registradas por día:\n`;
|
|
125
|
+
for (const d of Object.keys(result.dailyHours).sort()) {
|
|
126
|
+
const h = result.dailyHours[d];
|
|
127
|
+
const dayName = new Date(`${d}T12:00:00`).toLocaleDateString('es-ES', {
|
|
128
|
+
weekday: 'short',
|
|
129
|
+
});
|
|
130
|
+
text += ` • ${d} (${dayName}): ${h}h\n`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
content: [
|
|
136
|
+
{ type: 'text', text },
|
|
137
|
+
{ type: 'text', text: '\n[JSON detallado]\n' + JSON.stringify(result, null, 2) },
|
|
138
|
+
],
|
|
139
|
+
structuredContent: result,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── Horas por tarea (Tempo) ─────────────────────────────────
|
|
145
|
+
server.registerTool(
|
|
146
|
+
'jira_worklogs_by_issue',
|
|
147
|
+
{
|
|
148
|
+
title: 'Jira: horas registradas por tarea',
|
|
149
|
+
description:
|
|
150
|
+
'Devuelve las horas registradas (Tempo) del usuario actual agrupadas por issue/tarea. ' +
|
|
151
|
+
'Por defecto analiza el mes en curso.',
|
|
152
|
+
inputSchema: dateRangeSchema,
|
|
153
|
+
},
|
|
154
|
+
async ({ startDate, endDate }) => {
|
|
155
|
+
const tempoError = requireTempo();
|
|
156
|
+
if (tempoError) return tempoError;
|
|
157
|
+
|
|
158
|
+
const defaults = getDefaultDateRange();
|
|
159
|
+
const from = startDate || defaults.start;
|
|
160
|
+
const to = endDate || defaults.end;
|
|
161
|
+
|
|
162
|
+
const currentUser = await getCurrentUser();
|
|
163
|
+
const result = await getWorklogsByIssue(currentUser.accountId, from, to);
|
|
164
|
+
|
|
165
|
+
let text = `📋 Horas por tarea: ${from} → ${to}\n`;
|
|
166
|
+
text += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
167
|
+
text += `Total horas: ${result.totalHours}h | Entradas: ${result.totalEntries} | Issues: ${result.issues.length}\n\n`;
|
|
168
|
+
|
|
169
|
+
for (const issue of result.issues) {
|
|
170
|
+
text += `🔹 ${issue.key} — ${issue.summary} (${issue.totalHours}h)\n`;
|
|
171
|
+
for (const entry of issue.entries) {
|
|
172
|
+
const dayName = new Date(`${entry.date}T12:00:00`).toLocaleDateString('es-ES', {
|
|
173
|
+
weekday: 'short',
|
|
174
|
+
});
|
|
175
|
+
text += ` ${entry.date} (${dayName}): ${entry.hours}h — ${entry.description}\n`;
|
|
176
|
+
}
|
|
177
|
+
text += '\n';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{ type: 'text', text },
|
|
183
|
+
{ type: 'text', text: '\n[JSON detallado]\n' + JSON.stringify(result, null, 2) },
|
|
184
|
+
],
|
|
185
|
+
structuredContent: result,
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// ── Horas por día (Tempo) ───────────────────────────────────
|
|
191
|
+
server.registerTool(
|
|
192
|
+
'jira_worklogs_by_day',
|
|
193
|
+
{
|
|
194
|
+
title: 'Jira: horas registradas por día',
|
|
195
|
+
description:
|
|
196
|
+
'Devuelve las horas registradas (Tempo) del usuario actual agrupadas por día. ' +
|
|
197
|
+
'Por defecto analiza el mes en curso.',
|
|
198
|
+
inputSchema: dateRangeSchema,
|
|
199
|
+
},
|
|
200
|
+
async ({ startDate, endDate }) => {
|
|
201
|
+
const tempoError = requireTempo();
|
|
202
|
+
if (tempoError) return tempoError;
|
|
203
|
+
|
|
204
|
+
const defaults = getDefaultDateRange();
|
|
205
|
+
const from = startDate || defaults.start;
|
|
206
|
+
const to = endDate || defaults.end;
|
|
207
|
+
|
|
208
|
+
const currentUser = await getCurrentUser();
|
|
209
|
+
const result = await getWorklogsByDay(currentUser.accountId, from, to);
|
|
210
|
+
|
|
211
|
+
let text = `📅 Horas por día: ${from} → ${to}\n`;
|
|
212
|
+
text += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
213
|
+
text += `Total horas: ${result.totalHours}h | Entradas: ${result.totalEntries} | Días: ${result.days.length}\n\n`;
|
|
214
|
+
|
|
215
|
+
for (const day of result.days) {
|
|
216
|
+
const dayName = new Date(`${day.date}T12:00:00`).toLocaleDateString('es-ES', {
|
|
217
|
+
weekday: 'long',
|
|
218
|
+
});
|
|
219
|
+
text += `📌 ${day.date} (${dayName}) — ${day.totalHours}h\n`;
|
|
220
|
+
for (const entry of day.entries) {
|
|
221
|
+
text += ` ${entry.key}: ${entry.hours}h — ${entry.description || entry.summary}\n`;
|
|
222
|
+
}
|
|
223
|
+
text += '\n';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{ type: 'text', text },
|
|
229
|
+
{ type: 'text', text: '\n[JSON detallado]\n' + JSON.stringify(result, null, 2) },
|
|
230
|
+
],
|
|
231
|
+
structuredContent: result,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
}
|
package/jiraApi.mjs
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// jiraApi.mjs
|
|
2
|
-
import { jiraRequest } from './jiraClient.mjs';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Buscar issues usando JQL (API nueva /search/jql)
|
|
6
|
-
*/
|
|
7
|
-
export async function searchIssues(jql, maxResults = 20) {
|
|
8
|
-
const body = {
|
|
9
|
-
jql,
|
|
10
|
-
maxResults,
|
|
11
|
-
fields: ['summary', 'status', 'assignee', 'priority'],
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const data = await jiraRequest('/rest/api/3/search/jql', {
|
|
15
|
-
method: 'POST',
|
|
16
|
-
body: JSON.stringify(body),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const issues = data.issues ?? [];
|
|
20
|
-
|
|
21
|
-
return issues.map((issue) => ({
|
|
22
|
-
key: issue.key,
|
|
23
|
-
summary: issue.fields.summary,
|
|
24
|
-
status: issue.fields.status.name,
|
|
25
|
-
assignee: issue.fields.assignee?.displayName ?? null,
|
|
26
|
-
priority: issue.fields.priority?.name ?? null,
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Obtener las transiciones posibles para un issue
|
|
32
|
-
*/
|
|
33
|
-
export async function getIssueTransitions(issueKey) {
|
|
34
|
-
const data = await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
|
|
35
|
-
method: 'GET',
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const transitions = data.transitions ?? [];
|
|
39
|
-
|
|
40
|
-
return transitions.map((t) => ({
|
|
41
|
-
id: t.id,
|
|
42
|
-
name: t.name,
|
|
43
|
-
toStatus: t.to.name,
|
|
44
|
-
}));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Ejecutar una transición (mover a otro estado/columna)
|
|
49
|
-
*/
|
|
50
|
-
export async function transitionIssue(issueKey, transitionId) {
|
|
51
|
-
const body = {
|
|
52
|
-
transition: { id: transitionId },
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const jiraRequest = await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
|
|
56
|
-
method: 'POST',
|
|
57
|
-
body: JSON.stringify(body),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
return { ok: true };
|
|
61
|
-
}
|