@atlassian-dc-mcp/jira 0.33.0 → 0.35.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 +23 -1
- package/README.md +17 -2
- package/build/__tests__/jira-download.test.js +5 -5
- package/build/__tests__/jira-download.test.js.map +1 -1
- package/build/__tests__/jira-service.test.js +53 -23
- package/build/__tests__/jira-service.test.js.map +1 -1
- package/build/index.js +8 -0
- package/build/index.js.map +1 -1
- package/build/jira-service.d.ts +8 -0
- package/build/jira-service.d.ts.map +1 -1
- package/build/jira-service.js +23 -3
- package/build/jira-service.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/jira-download.test.ts +5 -5
- package/src/__tests__/jira-service.test.ts +72 -23
- package/src/index.ts +20 -0
- package/src/jira-service.ts +25 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -9,7 +9,7 @@ jest.mock('../jira-client/index.js', () => ({
|
|
|
9
9
|
getAttachment: jest.fn(),
|
|
10
10
|
},
|
|
11
11
|
IssueService: {
|
|
12
|
-
|
|
12
|
+
getIssue1: jest.fn(),
|
|
13
13
|
},
|
|
14
14
|
MyselfService: {},
|
|
15
15
|
SearchService: {},
|
|
@@ -58,7 +58,7 @@ describe('JiraService.downloadAttachments', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
it('downloads all attachments on an issue', async () => {
|
|
61
|
-
(IssueService.
|
|
61
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({
|
|
62
62
|
fields: {
|
|
63
63
|
attachment: [
|
|
64
64
|
{ id: '1', filename: 'a.txt', content: 'https://test-host/a' },
|
|
@@ -70,13 +70,13 @@ describe('JiraService.downloadAttachments', () => {
|
|
|
70
70
|
|
|
71
71
|
const result = await service.downloadAttachments({ issueKey: 'PROJ-1', downloadSide: DISABLED_DOWNLOAD });
|
|
72
72
|
|
|
73
|
-
expect(IssueService.
|
|
73
|
+
expect(IssueService.getIssue1).toHaveBeenCalledWith('PROJ-1', undefined, 'attachment');
|
|
74
74
|
expect(result.success).toBe(true);
|
|
75
75
|
expect(result.data!.count).toBe(2);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it('filters issue attachments by filename', async () => {
|
|
79
|
-
(IssueService.
|
|
79
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({
|
|
80
80
|
fields: {
|
|
81
81
|
attachment: [
|
|
82
82
|
{ id: '1', filename: 'a.txt', content: 'https://test-host/a' },
|
|
@@ -101,7 +101,7 @@ describe('JiraService.downloadAttachments', () => {
|
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('fails when an issue has no matching attachment', async () => {
|
|
104
|
-
(IssueService.
|
|
104
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ fields: { attachment: [] } });
|
|
105
105
|
|
|
106
106
|
const result = await service.downloadAttachments({ issueKey: 'PROJ-1', filename: 'x.txt', downloadSide: DISABLED_DOWNLOAD });
|
|
107
107
|
|
|
@@ -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 { IssueLinkService, IssueLinkTypeService, IssueService, OpenAPI, SearchService } from '../jira-client/index.js';
|
|
6
|
+
import { IssueLinkService, IssueLinkTypeService, IssueService, MyselfService, 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', () => ({
|
|
@@ -14,12 +14,14 @@ jest.mock('../jira-client/index.js', () => ({
|
|
|
14
14
|
IssueService: {
|
|
15
15
|
getTransitions: jest.fn(),
|
|
16
16
|
doTransition: jest.fn(),
|
|
17
|
-
|
|
17
|
+
getIssue1: jest.fn(),
|
|
18
18
|
editIssue: jest.fn(),
|
|
19
19
|
createIssue: jest.fn(),
|
|
20
20
|
getComments: jest.fn(),
|
|
21
21
|
addComment: jest.fn(),
|
|
22
22
|
updateComment: jest.fn(),
|
|
23
|
+
addWatcher1: jest.fn(),
|
|
24
|
+
removeWatcher1: jest.fn(),
|
|
23
25
|
},
|
|
24
26
|
SearchService: {
|
|
25
27
|
searchUsingSearchRequest: jest.fn(),
|
|
@@ -31,6 +33,9 @@ jest.mock('../jira-client/index.js', () => ({
|
|
|
31
33
|
IssueLinkTypeService: {
|
|
32
34
|
getIssueLinkTypes: jest.fn(),
|
|
33
35
|
},
|
|
36
|
+
MyselfService: {
|
|
37
|
+
getUser: jest.fn(),
|
|
38
|
+
},
|
|
34
39
|
OpenAPI: {
|
|
35
40
|
BASE: '',
|
|
36
41
|
TOKEN: '',
|
|
@@ -149,33 +154,25 @@ describe('JiraService', () => {
|
|
|
149
154
|
|
|
150
155
|
it('uses the richer default field profile for single issue reads', async () => {
|
|
151
156
|
const mockIssue = { key: mockIssueKey };
|
|
152
|
-
(IssueService.
|
|
157
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue(mockIssue);
|
|
153
158
|
|
|
154
159
|
const result = await jiraService.getIssue(mockIssueKey);
|
|
155
160
|
|
|
156
161
|
expect(result.success).toBe(true);
|
|
157
162
|
expect(result.data).toBe(mockIssue);
|
|
158
|
-
expect(IssueService.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
'status',
|
|
162
|
-
|
|
163
|
-
'reporter',
|
|
164
|
-
'priority',
|
|
165
|
-
'issuetype',
|
|
166
|
-
'labels',
|
|
167
|
-
'updated',
|
|
168
|
-
'parent',
|
|
169
|
-
'subtasks',
|
|
170
|
-
]);
|
|
163
|
+
expect(IssueService.getIssue1).toHaveBeenCalledWith(
|
|
164
|
+
mockIssueKey,
|
|
165
|
+
undefined,
|
|
166
|
+
'summary,description,status,assignee,reporter,priority,issuetype,labels,updated,parent,subtasks',
|
|
167
|
+
);
|
|
171
168
|
});
|
|
172
169
|
|
|
173
170
|
it('honors explicit issue fields', async () => {
|
|
174
|
-
(IssueService.
|
|
171
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ key: mockIssueKey });
|
|
175
172
|
|
|
176
173
|
await jiraService.getIssue(mockIssueKey, 'renderedFields', ['summary', 'status']);
|
|
177
174
|
|
|
178
|
-
expect(IssueService.
|
|
175
|
+
expect(IssueService.getIssue1).toHaveBeenCalledWith(mockIssueKey, 'renderedFields', 'summary,status');
|
|
179
176
|
});
|
|
180
177
|
|
|
181
178
|
it('uses the package default page size for issue comments', async () => {
|
|
@@ -223,14 +220,14 @@ describe('JiraService', () => {
|
|
|
223
220
|
describe('getIssueDevelopmentInfo', () => {
|
|
224
221
|
it('resolves the numeric issue id then requests pull requests by default', async () => {
|
|
225
222
|
const mockDevInfo = { detail: [{ pullRequests: [] }] };
|
|
226
|
-
(IssueService.
|
|
223
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ id: '1314681', key: mockIssueKey });
|
|
227
224
|
(__request as jest.Mock).mockResolvedValue(mockDevInfo);
|
|
228
225
|
|
|
229
226
|
const result = await jiraService.getIssueDevelopmentInfo(mockIssueKey);
|
|
230
227
|
|
|
231
228
|
expect(result.success).toBe(true);
|
|
232
229
|
expect(result.data).toBe(mockDevInfo);
|
|
233
|
-
expect(IssueService.
|
|
230
|
+
expect(IssueService.getIssue1).toHaveBeenCalledWith(mockIssueKey, undefined, 'id');
|
|
234
231
|
expect(__request).toHaveBeenCalledWith(OpenAPI, {
|
|
235
232
|
method: 'GET',
|
|
236
233
|
url: '/dev-status/1.0/issue/detail',
|
|
@@ -239,7 +236,7 @@ describe('JiraService', () => {
|
|
|
239
236
|
});
|
|
240
237
|
|
|
241
238
|
it('honors explicit dataType and applicationType', async () => {
|
|
242
|
-
(IssueService.
|
|
239
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ id: '42' });
|
|
243
240
|
(__request as jest.Mock).mockResolvedValue({});
|
|
244
241
|
|
|
245
242
|
await jiraService.getIssueDevelopmentInfo(mockIssueKey, 'repository', 'github');
|
|
@@ -252,7 +249,7 @@ describe('JiraService', () => {
|
|
|
252
249
|
});
|
|
253
250
|
|
|
254
251
|
it('fails without calling dev-status when the numeric id is missing', async () => {
|
|
255
|
-
(IssueService.
|
|
252
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ key: mockIssueKey });
|
|
256
253
|
|
|
257
254
|
const result = await jiraService.getIssueDevelopmentInfo(mockIssueKey);
|
|
258
255
|
|
|
@@ -262,7 +259,7 @@ describe('JiraService', () => {
|
|
|
262
259
|
});
|
|
263
260
|
|
|
264
261
|
it('surfaces dev-status request errors', async () => {
|
|
265
|
-
(IssueService.
|
|
262
|
+
(IssueService.getIssue1 as jest.Mock).mockResolvedValue({ id: '1314681' });
|
|
266
263
|
(__request as jest.Mock).mockRejectedValue(new Error('View Development Tools permission required'));
|
|
267
264
|
|
|
268
265
|
const result = await jiraService.getIssueDevelopmentInfo(mockIssueKey);
|
|
@@ -554,4 +551,56 @@ describe('JiraService', () => {
|
|
|
554
551
|
expect(result.error).toBe('Invalid issue link id');
|
|
555
552
|
});
|
|
556
553
|
});
|
|
554
|
+
|
|
555
|
+
describe('watchIssue', () => {
|
|
556
|
+
it('should add the calling user as a watcher', async () => {
|
|
557
|
+
(IssueService.addWatcher1 as jest.Mock).mockResolvedValue(undefined);
|
|
558
|
+
|
|
559
|
+
const result = await jiraService.watchIssue(mockIssueKey);
|
|
560
|
+
|
|
561
|
+
expect(result.success).toBe(true);
|
|
562
|
+
// No userName is passed — the API defaults to the authenticated caller.
|
|
563
|
+
expect(IssueService.addWatcher1).toHaveBeenCalledWith(mockIssueKey);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should handle API errors gracefully', async () => {
|
|
567
|
+
(IssueService.addWatcher1 as jest.Mock).mockRejectedValue(new Error('Issue does not exist'));
|
|
568
|
+
|
|
569
|
+
const result = await jiraService.watchIssue(mockIssueKey);
|
|
570
|
+
|
|
571
|
+
expect(result.success).toBe(false);
|
|
572
|
+
expect(result.error).toBe('Issue does not exist');
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe('unwatchIssue', () => {
|
|
577
|
+
it('should remove the calling user as a watcher', async () => {
|
|
578
|
+
(MyselfService.getUser as jest.Mock).mockResolvedValue({ name: 'jdoe' });
|
|
579
|
+
(IssueService.removeWatcher1 as jest.Mock).mockResolvedValue(undefined);
|
|
580
|
+
|
|
581
|
+
const result = await jiraService.unwatchIssue(mockIssueKey);
|
|
582
|
+
|
|
583
|
+
expect(result.success).toBe(true);
|
|
584
|
+
expect(IssueService.removeWatcher1).toHaveBeenCalledWith(mockIssueKey, 'jdoe');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should fail if the current user name cannot be resolved', async () => {
|
|
588
|
+
(MyselfService.getUser as jest.Mock).mockResolvedValue({});
|
|
589
|
+
|
|
590
|
+
const result = await jiraService.unwatchIssue(mockIssueKey);
|
|
591
|
+
|
|
592
|
+
expect(result.success).toBe(false);
|
|
593
|
+
expect(IssueService.removeWatcher1).not.toHaveBeenCalled();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('should handle API errors gracefully', async () => {
|
|
597
|
+
(MyselfService.getUser as jest.Mock).mockResolvedValue({ name: 'jdoe' });
|
|
598
|
+
(IssueService.removeWatcher1 as jest.Mock).mockRejectedValue(new Error('Issue does not exist'));
|
|
599
|
+
|
|
600
|
+
const result = await jiraService.unwatchIssue(mockIssueKey);
|
|
601
|
+
|
|
602
|
+
expect(result.success).toBe(false);
|
|
603
|
+
expect(result.error).toBe('Issue does not exist');
|
|
604
|
+
});
|
|
605
|
+
});
|
|
557
606
|
});
|
package/src/index.ts
CHANGED
|
@@ -157,6 +157,26 @@ server.tool(
|
|
|
157
157
|
}
|
|
158
158
|
);
|
|
159
159
|
|
|
160
|
+
server.tool(
|
|
161
|
+
"jira_watchIssue",
|
|
162
|
+
`Add yourself (the authenticated user) as a watcher on a JIRA issue in the ${jiraInstanceType}. Only the calling user can be added; there is no way to watch on behalf of someone else.`,
|
|
163
|
+
jiraToolSchemas.watchIssue,
|
|
164
|
+
async ({ issueKey }) => {
|
|
165
|
+
const result = await jiraService.watchIssue(issueKey);
|
|
166
|
+
return formatToolResponse(result);
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
server.tool(
|
|
171
|
+
"jira_unwatchIssue",
|
|
172
|
+
`Remove yourself (the authenticated user) as a watcher from a JIRA issue in the ${jiraInstanceType}. Only the calling user can be removed; there is no way to unwatch on behalf of someone else.`,
|
|
173
|
+
jiraToolSchemas.unwatchIssue,
|
|
174
|
+
async ({ issueKey }) => {
|
|
175
|
+
const result = await jiraService.unwatchIssue(issueKey);
|
|
176
|
+
return formatToolResponse(result);
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
160
180
|
const attachmentGateway = resolveAttachmentGateway(JIRA_PRODUCT);
|
|
161
181
|
|
|
162
182
|
// Filesystem-reading upload is only registered when the operator explicitly enables it.
|
package/src/jira-service.ts
CHANGED
|
@@ -74,7 +74,7 @@ export class JiraService {
|
|
|
74
74
|
|
|
75
75
|
async getIssue(issueKey: string, expand?: string, fields?: string[]) {
|
|
76
76
|
return handleApiOperation(
|
|
77
|
-
() => IssueService.
|
|
77
|
+
() => IssueService.getIssue1(issueKey, expand, (fields ?? DEFAULT_ISSUE_FIELDS).join(',')),
|
|
78
78
|
'Error getting issue'
|
|
79
79
|
);
|
|
80
80
|
}
|
|
@@ -171,7 +171,7 @@ export class JiraService {
|
|
|
171
171
|
|
|
172
172
|
private async resolveIssueId(issueKey: string): Promise<string> {
|
|
173
173
|
// The dev-status API is keyed by the numeric issue id, not the issue key.
|
|
174
|
-
const issue = await IssueService.
|
|
174
|
+
const issue = await IssueService.getIssue1(issueKey, undefined, 'id');
|
|
175
175
|
if (!issue?.id) {
|
|
176
176
|
throw new Error(`Could not resolve numeric id for issue ${issueKey}`);
|
|
177
177
|
}
|
|
@@ -283,7 +283,7 @@ export class JiraService {
|
|
|
283
283
|
if (!params.issueKey) {
|
|
284
284
|
throw new Error('Either attachmentId or issueKey must be provided');
|
|
285
285
|
}
|
|
286
|
-
const issue = await IssueService.
|
|
286
|
+
const issue = await IssueService.getIssue1(params.issueKey, undefined, 'attachment');
|
|
287
287
|
const all = (((issue as any)?.fields?.attachment) ?? []) as Array<Record<string, any>>;
|
|
288
288
|
const filtered = params.filename ? all.filter((a) => a?.filename === params.filename) : all;
|
|
289
289
|
if (filtered.length === 0) {
|
|
@@ -326,6 +326,22 @@ export class JiraService {
|
|
|
326
326
|
);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
// Only the calling user can be (un)watched — the API supports an arbitrary userName,
|
|
330
|
+
// but exposing that would let the tool subscribe/unsubscribe other users.
|
|
331
|
+
async watchIssue(issueKey: string) {
|
|
332
|
+
return handleApiOperation(() => IssueService.addWatcher1(issueKey), 'Error watching issue');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async unwatchIssue(issueKey: string) {
|
|
336
|
+
return handleApiOperation(async () => {
|
|
337
|
+
const { name } = await MyselfService.getUser();
|
|
338
|
+
if (!name) {
|
|
339
|
+
throw new Error('Could not resolve current user name');
|
|
340
|
+
}
|
|
341
|
+
return IssueService.removeWatcher1(issueKey, name);
|
|
342
|
+
}, 'Error unwatching issue');
|
|
343
|
+
}
|
|
344
|
+
|
|
329
345
|
async validateSetup(): Promise<void> {
|
|
330
346
|
await MyselfService.getUser();
|
|
331
347
|
}
|
|
@@ -416,5 +432,11 @@ export const jiraToolSchemas = {
|
|
|
416
432
|
},
|
|
417
433
|
unlinkIssues: {
|
|
418
434
|
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).")
|
|
435
|
+
},
|
|
436
|
+
watchIssue: {
|
|
437
|
+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)")
|
|
438
|
+
},
|
|
439
|
+
unwatchIssue: {
|
|
440
|
+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)")
|
|
419
441
|
}
|
|
420
442
|
};
|