@atlassian-dc-mcp/jira 0.26.0 → 0.27.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/CHANGELOG.md +16 -0
- package/README.md +24 -0
- package/build/__tests__/jira-service.test.js +85 -1
- package/build/__tests__/jira-service.test.js.map +1 -1
- package/build/__tests__/response-body.test.d.ts +2 -0
- package/build/__tests__/response-body.test.d.ts.map +1 -0
- package/build/__tests__/response-body.test.js +34 -0
- package/build/__tests__/response-body.test.js.map +1 -0
- package/build/index.js +12 -0
- package/build/index.js.map +1 -1
- package/build/jira-client/core/request.d.ts.map +1 -1
- package/build/jira-client/core/request.js +5 -1
- package/build/jira-client/core/request.js.map +1 -1
- package/build/jira-service.d.ts +18 -0
- package/build/jira-service.d.ts.map +1 -1
- package/build/jira-service.js +27 -1
- package/build/jira-service.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/jira-service.test.ts +106 -1
- package/src/__tests__/response-body.test.ts +43 -0
- package/src/index.ts +30 -0
- package/src/jira-client/core/request.ts +5 -1
- package/src/jira-service.ts +41 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
|
|
5
5
|
import { JiraService } from '../jira-service.js';
|
|
6
|
-
import { IssueService, OpenAPI, SearchService } from '../jira-client/index.js';
|
|
6
|
+
import { IssueLinkService, IssueLinkTypeService, IssueService, OpenAPI, SearchService } from '../jira-client/index.js';
|
|
7
7
|
import { request as __request } from '../jira-client/core/request.js';
|
|
8
8
|
|
|
9
9
|
jest.mock('../jira-client/core/request.js', () => ({
|
|
@@ -23,6 +23,13 @@ jest.mock('../jira-client/index.js', () => ({
|
|
|
23
23
|
SearchService: {
|
|
24
24
|
searchUsingSearchRequest: jest.fn(),
|
|
25
25
|
},
|
|
26
|
+
IssueLinkService: {
|
|
27
|
+
linkIssues: jest.fn(),
|
|
28
|
+
deleteIssueLink: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
IssueLinkTypeService: {
|
|
31
|
+
getIssueLinkTypes: jest.fn(),
|
|
32
|
+
},
|
|
26
33
|
OpenAPI: {
|
|
27
34
|
BASE: '',
|
|
28
35
|
TOKEN: '',
|
|
@@ -424,4 +431,102 @@ describe('JiraService', () => {
|
|
|
424
431
|
expect(missingVars).toEqual([]);
|
|
425
432
|
});
|
|
426
433
|
});
|
|
434
|
+
|
|
435
|
+
describe('getIssueLinkTypes', () => {
|
|
436
|
+
it('should return the available issue link types', async () => {
|
|
437
|
+
const mockLinkTypes = {
|
|
438
|
+
issueLinkTypes: [
|
|
439
|
+
{ id: '10000', name: 'Blocks', inward: 'is blocked by', outward: 'blocks' },
|
|
440
|
+
{ id: '10001', name: 'Relates', inward: 'relates to', outward: 'relates to' },
|
|
441
|
+
],
|
|
442
|
+
};
|
|
443
|
+
(IssueLinkTypeService.getIssueLinkTypes as jest.Mock).mockResolvedValue(mockLinkTypes);
|
|
444
|
+
|
|
445
|
+
const result = await jiraService.getIssueLinkTypes();
|
|
446
|
+
|
|
447
|
+
expect(result.success).toBe(true);
|
|
448
|
+
expect(result.data).toBe(mockLinkTypes);
|
|
449
|
+
expect(IssueLinkTypeService.getIssueLinkTypes).toHaveBeenCalledWith();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should handle errors when issue linking is disabled', async () => {
|
|
453
|
+
(IssueLinkTypeService.getIssueLinkTypes as jest.Mock).mockRejectedValue(new Error('Issue linking is disabled'));
|
|
454
|
+
|
|
455
|
+
const result = await jiraService.getIssueLinkTypes();
|
|
456
|
+
|
|
457
|
+
expect(result.success).toBe(false);
|
|
458
|
+
expect(result.error).toBe('Issue linking is disabled');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe('linkIssues', () => {
|
|
463
|
+
it('should create a link between two issues', async () => {
|
|
464
|
+
(IssueLinkService.linkIssues as jest.Mock).mockResolvedValue(undefined);
|
|
465
|
+
|
|
466
|
+
const result = await jiraService.linkIssues({
|
|
467
|
+
inwardIssueKey: 'PROJ-123',
|
|
468
|
+
outwardIssueKey: 'PROJ-456',
|
|
469
|
+
linkType: 'Blocks',
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(result.success).toBe(true);
|
|
473
|
+
expect(IssueLinkService.linkIssues).toHaveBeenCalledWith({
|
|
474
|
+
type: { name: 'Blocks' },
|
|
475
|
+
inwardIssue: { key: 'PROJ-123' },
|
|
476
|
+
outwardIssue: { key: 'PROJ-456' },
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should include an optional comment on the inward issue', async () => {
|
|
481
|
+
(IssueLinkService.linkIssues as jest.Mock).mockResolvedValue(undefined);
|
|
482
|
+
|
|
483
|
+
const result = await jiraService.linkIssues({
|
|
484
|
+
inwardIssueKey: 'PROJ-123',
|
|
485
|
+
outwardIssueKey: 'PROJ-456',
|
|
486
|
+
linkType: 'Relates',
|
|
487
|
+
comment: 'Linking these for tracking',
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
expect(result.success).toBe(true);
|
|
491
|
+
expect(IssueLinkService.linkIssues).toHaveBeenCalledWith({
|
|
492
|
+
type: { name: 'Relates' },
|
|
493
|
+
inwardIssue: { key: 'PROJ-123' },
|
|
494
|
+
outwardIssue: { key: 'PROJ-456' },
|
|
495
|
+
comment: { body: 'Linking these for tracking' },
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should handle API errors gracefully', async () => {
|
|
500
|
+
(IssueLinkService.linkIssues as jest.Mock).mockRejectedValue(new Error('Issue link type not found'));
|
|
501
|
+
|
|
502
|
+
const result = await jiraService.linkIssues({
|
|
503
|
+
inwardIssueKey: 'PROJ-123',
|
|
504
|
+
outwardIssueKey: 'PROJ-456',
|
|
505
|
+
linkType: 'Nonexistent',
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(result.success).toBe(false);
|
|
509
|
+
expect(result.error).toBe('Issue link type not found');
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('unlinkIssues', () => {
|
|
514
|
+
it('should delete an issue link by id', async () => {
|
|
515
|
+
(IssueLinkService.deleteIssueLink as jest.Mock).mockResolvedValue(undefined);
|
|
516
|
+
|
|
517
|
+
const result = await jiraService.unlinkIssues('10500');
|
|
518
|
+
|
|
519
|
+
expect(result.success).toBe(true);
|
|
520
|
+
expect(IssueLinkService.deleteIssueLink).toHaveBeenCalledWith('10500');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('should handle errors when the link id is invalid', async () => {
|
|
524
|
+
(IssueLinkService.deleteIssueLink as jest.Mock).mockRejectedValue(new Error('Invalid issue link id'));
|
|
525
|
+
|
|
526
|
+
const result = await jiraService.unlinkIssues('bad-id');
|
|
527
|
+
|
|
528
|
+
expect(result.success).toBe(false);
|
|
529
|
+
expect(result.error).toBe('Invalid issue link id');
|
|
530
|
+
});
|
|
531
|
+
});
|
|
427
532
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getResponseBody } from '../jira-client/core/request.js';
|
|
2
|
+
|
|
3
|
+
function jsonResponse(status: number, body: string): Response {
|
|
4
|
+
return {
|
|
5
|
+
status,
|
|
6
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
7
|
+
text: async () => body,
|
|
8
|
+
json: async () => JSON.parse(body),
|
|
9
|
+
} as unknown as Response;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('getResponseBody', () => {
|
|
13
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
errorSpy.mockClear();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
errorSpy.mockRestore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns undefined for an empty JSON body without logging (e.g. POST /issueLink)', async () => {
|
|
24
|
+
const result = await getResponseBody(jsonResponse(201, ''));
|
|
25
|
+
|
|
26
|
+
expect(result).toBeUndefined();
|
|
27
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses a non-empty JSON body', async () => {
|
|
31
|
+
const result = await getResponseBody(jsonResponse(200, '{"id":"1158012"}'));
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual({ id: '1158012' });
|
|
34
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns undefined for a 204 No Content response (e.g. DELETE /issueLink)', async () => {
|
|
38
|
+
const result = await getResponseBody(jsonResponse(204, ''));
|
|
39
|
+
|
|
40
|
+
expect(result).toBeUndefined();
|
|
41
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -117,4 +117,34 @@ server.tool(
|
|
|
117
117
|
}
|
|
118
118
|
);
|
|
119
119
|
|
|
120
|
+
server.tool(
|
|
121
|
+
"jira_getIssueLinkTypes",
|
|
122
|
+
`Get the list of available issue link types (e.g. Blocks, Relates, Duplicate) in the ${jiraInstanceType}. Use this to discover valid link type names before calling jira_linkIssues.`,
|
|
123
|
+
jiraToolSchemas.getIssueLinkTypes,
|
|
124
|
+
async () => {
|
|
125
|
+
const result = await jiraService.getIssueLinkTypes();
|
|
126
|
+
return formatToolResponse(result);
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
server.tool(
|
|
131
|
+
"jira_linkIssues",
|
|
132
|
+
`Create a link between two JIRA issues in the ${jiraInstanceType}. Use jira_getIssueLinkTypes to find valid link type names.`,
|
|
133
|
+
jiraToolSchemas.linkIssues,
|
|
134
|
+
async (params) => {
|
|
135
|
+
const result = await jiraService.linkIssues(params);
|
|
136
|
+
return formatToolResponse(result);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
server.tool(
|
|
141
|
+
"jira_unlinkIssues",
|
|
142
|
+
`Delete an existing link between two JIRA issues in the ${jiraInstanceType}. Requires the issue link id (found in the 'issuelinks' field of an issue).`,
|
|
143
|
+
jiraToolSchemas.unlinkIssues,
|
|
144
|
+
async ({ linkId }) => {
|
|
145
|
+
const result = await jiraService.unlinkIssues(linkId);
|
|
146
|
+
return formatToolResponse(result);
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
120
150
|
await connectServer(server);
|
|
@@ -257,7 +257,11 @@ export const getResponseBody = async (response: Response): Promise<any> => {
|
|
|
257
257
|
const jsonTypes = ['application/json', 'application/problem+json']
|
|
258
258
|
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type));
|
|
259
259
|
if (isJSON) {
|
|
260
|
-
|
|
260
|
+
// Some endpoints (e.g. POST /issueLink) return a success status with
|
|
261
|
+
// Content-Type: application/json but an empty body; parsing that as JSON
|
|
262
|
+
// throws. Read the text first and only parse when there is something to parse.
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
return text ? JSON.parse(text) : undefined;
|
|
261
265
|
} else {
|
|
262
266
|
return await response.text();
|
|
263
267
|
}
|
package/src/jira-service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { handleApiOperation, resolveOpenApiBase } from '@atlassian-dc-mcp/common';
|
|
3
|
-
import { IssueService, MyselfService, OpenAPI, SearchService } from './jira-client/index.js';
|
|
3
|
+
import { IssueLinkService, IssueLinkTypeService, IssueService, MyselfService, OpenAPI, SearchService } from './jira-client/index.js';
|
|
4
4
|
import { request as __request } from './jira-client/core/request.js';
|
|
5
5
|
import type { StringList } from './jira-client/models/StringList.js';
|
|
6
6
|
import { getDefaultPageSize, getMissingConfig, JIRA_PRODUCT } from './config.js';
|
|
@@ -177,6 +177,36 @@ export class JiraService {
|
|
|
177
177
|
}, 'Error transitioning issue');
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
async getIssueLinkTypes() {
|
|
181
|
+
return handleApiOperation(
|
|
182
|
+
() => IssueLinkTypeService.getIssueLinkTypes(),
|
|
183
|
+
'Error getting issue link types'
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async linkIssues(params: {
|
|
188
|
+
inwardIssueKey: string;
|
|
189
|
+
outwardIssueKey: string;
|
|
190
|
+
linkType: string;
|
|
191
|
+
comment?: string;
|
|
192
|
+
}) {
|
|
193
|
+
return handleApiOperation(() => {
|
|
194
|
+
return IssueLinkService.linkIssues({
|
|
195
|
+
type: { name: params.linkType },
|
|
196
|
+
inwardIssue: { key: params.inwardIssueKey },
|
|
197
|
+
outwardIssue: { key: params.outwardIssueKey },
|
|
198
|
+
...(params.comment ? { comment: { body: params.comment } } : {}),
|
|
199
|
+
});
|
|
200
|
+
}, 'Error linking issues');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async unlinkIssues(linkId: string) {
|
|
204
|
+
return handleApiOperation(
|
|
205
|
+
() => IssueLinkService.deleteIssueLink(linkId),
|
|
206
|
+
'Error unlinking issues'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
async validateSetup(): Promise<void> {
|
|
181
211
|
await MyselfService.getUser();
|
|
182
212
|
}
|
|
@@ -236,5 +266,15 @@ export const jiraToolSchemas = {
|
|
|
236
266
|
transitionId: z.string().describe("The ID of the transition to perform. Use jira_getTransitions to find available transitions and their IDs."),
|
|
237
267
|
fields: z.record(z.any()).optional().describe("Optional fields required by the transition screen. Use jira_getTransitions to see which fields are available for each transition."),
|
|
238
268
|
customFields: z.record(z.any()).optional().describe("Optional fields merged into the JIRA transition payload. Can be used for update operations such as comments. Example: {'update': {'comment': [{'add': {'body': 'text'}}]}}")
|
|
269
|
+
},
|
|
270
|
+
getIssueLinkTypes: {},
|
|
271
|
+
linkIssues: {
|
|
272
|
+
inwardIssueKey: z.string().describe("Key of the inward issue (the one the inward link description applies to, e.g. the issue that 'is blocked by'). Example: PROJ-123"),
|
|
273
|
+
outwardIssueKey: z.string().describe("Key of the outward issue (the one the outward link description applies to, e.g. the issue that 'blocks'). Example: PROJ-456"),
|
|
274
|
+
linkType: z.string().describe("Name of the issue link type to apply (e.g. 'Blocks', 'Relates', 'Duplicate'). Use jira_getIssueLinkTypes to discover valid names for this JIRA installation."),
|
|
275
|
+
comment: z.string().optional().describe("Optional comment added to the inward issue when the link is created, in JIRA Wiki Markup.")
|
|
276
|
+
},
|
|
277
|
+
unlinkIssues: {
|
|
278
|
+
linkId: z.string().describe("The id of the issue link to delete. Link ids can be found in the 'issuelinks' field of an issue (retrieve it via jira_getIssue with the 'issuelinks' field).")
|
|
239
279
|
}
|
|
240
280
|
};
|