@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.
@@ -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
- return await response.json();
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
  }
@@ -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
  };