@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.
@@ -9,7 +9,7 @@ jest.mock('../jira-client/index.js', () => ({
9
9
  getAttachment: jest.fn(),
10
10
  },
11
11
  IssueService: {
12
- getIssue: jest.fn(),
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.getIssue as jest.Mock).mockResolvedValue({
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.getIssue).toHaveBeenCalledWith('PROJ-1', undefined, ['attachment']);
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.getIssue as jest.Mock).mockResolvedValue({
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.getIssue as jest.Mock).mockResolvedValue({ fields: { attachment: [] } });
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
- getIssue: jest.fn(),
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.getIssue as jest.Mock).mockResolvedValue(mockIssue);
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.getIssue).toHaveBeenCalledWith(mockIssueKey, undefined, [
159
- 'summary',
160
- 'description',
161
- 'status',
162
- 'assignee',
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.getIssue as jest.Mock).mockResolvedValue({ key: mockIssueKey });
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.getIssue).toHaveBeenCalledWith(mockIssueKey, 'renderedFields', ['summary', 'status']);
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.getIssue as jest.Mock).mockResolvedValue({ id: '1314681', key: mockIssueKey });
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.getIssue).toHaveBeenCalledWith(mockIssueKey, undefined, ['id']);
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.getIssue as jest.Mock).mockResolvedValue({ id: '42' });
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.getIssue as jest.Mock).mockResolvedValue({ key: mockIssueKey });
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.getIssue as jest.Mock).mockResolvedValue({ id: '1314681' });
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.
@@ -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.getIssue(issueKey, expand, toIssueFieldSelection(fields ?? DEFAULT_ISSUE_FIELDS)),
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.getIssue(issueKey, undefined, toIssueFieldSelection(['id']));
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.getIssue(params.issueKey, undefined, toIssueFieldSelection(['attachment']));
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
  };