Skip to content

Commit 4062472

Browse files
authored
Merge pull request #14 from arseniilozytskyi/claude/jira-status-transitions-QP62l
Add support for JIRA status transitions
2 parents 8d44594 + 14ee4c7 commit 4062472

File tree

5 files changed

+317
-7
lines changed

5 files changed

+317
-7
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/jira/jest.config.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export default {
2+
preset: 'ts-jest/presets/default-esm',
3+
extensionsToTreatAsEsm: ['.ts'],
4+
moduleNameMapper: {
5+
'^(\\.{1,2}/.*)\\.js$': '$1',
6+
'^@atlassian-dc-mcp/common$': '<rootDir>/../common/src/index.ts',
7+
},
8+
transform: {
9+
'^.+\\.ts$': ['ts-jest', {
10+
useESM: true,
11+
}],
12+
},
13+
transformIgnorePatterns: [
14+
'node_modules/(?!(@atlassian-dc-mcp)/)',
15+
],
16+
testEnvironment: 'node',
17+
testMatch: ['**/__tests__/**/*.test.ts'],
18+
collectCoverageFrom: [
19+
'src/**/*.ts',
20+
'!src/**/*.d.ts',
21+
'!src/**/index.ts',
22+
],
23+
};
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { JiraService } from '../jira-service.js';
2+
import { IssueService } from '../jira-client/index.js';
3+
4+
jest.mock('../jira-client/index.js', () => ({
5+
IssueService: {
6+
getTransitions: jest.fn(),
7+
doTransition: jest.fn(),
8+
getIssue: jest.fn(),
9+
editIssue: jest.fn(),
10+
createIssue: jest.fn(),
11+
getComments: jest.fn(),
12+
addComment: jest.fn(),
13+
},
14+
SearchService: {
15+
searchUsingSearchRequest: jest.fn(),
16+
},
17+
OpenAPI: {
18+
BASE: '',
19+
TOKEN: '',
20+
VERSION: '',
21+
},
22+
}));
23+
24+
describe('JiraService', () => {
25+
let jiraService: JiraService;
26+
const mockIssueKey = 'PROJ-123';
27+
28+
beforeEach(() => {
29+
jiraService = new JiraService('test-host', 'test-token');
30+
jest.clearAllMocks();
31+
});
32+
33+
describe('getTransitions', () => {
34+
it('should successfully get available transitions for an issue', async () => {
35+
const mockTransitionsData = {
36+
transitions: [
37+
{
38+
id: '21',
39+
name: 'Start Progress',
40+
to: {
41+
id: '3',
42+
name: 'In Progress',
43+
statusCategory: { name: 'In Progress' },
44+
},
45+
},
46+
{
47+
id: '31',
48+
name: 'Done',
49+
to: {
50+
id: '4',
51+
name: 'Done',
52+
statusCategory: { name: 'Done' },
53+
},
54+
},
55+
],
56+
};
57+
(IssueService.getTransitions as jest.Mock).mockResolvedValue(mockTransitionsData);
58+
59+
const result = await jiraService.getTransitions(mockIssueKey);
60+
61+
expect(result.success).toBe(true);
62+
expect(result.data).toBe(mockTransitionsData);
63+
expect(IssueService.getTransitions).toHaveBeenCalledWith(mockIssueKey);
64+
});
65+
66+
it('should return empty transitions array when no transitions available', async () => {
67+
const mockTransitionsData = {
68+
transitions: [],
69+
};
70+
(IssueService.getTransitions as jest.Mock).mockResolvedValue(mockTransitionsData);
71+
72+
const result = await jiraService.getTransitions(mockIssueKey);
73+
74+
expect(result.success).toBe(true);
75+
expect(result.data).toBe(mockTransitionsData);
76+
expect(result.data?.transitions).toHaveLength(0);
77+
});
78+
79+
it('should handle API errors gracefully', async () => {
80+
const mockError = new Error('Issue not found');
81+
(IssueService.getTransitions as jest.Mock).mockRejectedValue(mockError);
82+
83+
const result = await jiraService.getTransitions(mockIssueKey);
84+
85+
expect(result.success).toBe(false);
86+
expect(result.error).toBe('Issue not found');
87+
});
88+
89+
it('should handle permission errors', async () => {
90+
const mockError = new Error('Insufficient permissions to view transitions');
91+
(IssueService.getTransitions as jest.Mock).mockRejectedValue(mockError);
92+
93+
const result = await jiraService.getTransitions('RESTRICTED-1');
94+
95+
expect(result.success).toBe(false);
96+
expect(result.error).toBe('Insufficient permissions to view transitions');
97+
});
98+
});
99+
100+
describe('transitionIssue', () => {
101+
it('should successfully transition an issue to a new status', async () => {
102+
(IssueService.doTransition as jest.Mock).mockResolvedValue(undefined);
103+
104+
const result = await jiraService.transitionIssue({
105+
issueKey: mockIssueKey,
106+
transitionId: '21',
107+
});
108+
109+
expect(result.success).toBe(true);
110+
expect(IssueService.doTransition).toHaveBeenCalledWith(mockIssueKey, {
111+
transition: { id: '21' },
112+
});
113+
});
114+
115+
it('should successfully transition with additional fields', async () => {
116+
(IssueService.doTransition as jest.Mock).mockResolvedValue(undefined);
117+
118+
const result = await jiraService.transitionIssue({
119+
issueKey: mockIssueKey,
120+
transitionId: '31',
121+
fields: {
122+
resolution: { name: 'Done' },
123+
comment: { body: 'Closing this issue' },
124+
},
125+
});
126+
127+
expect(result.success).toBe(true);
128+
expect(IssueService.doTransition).toHaveBeenCalledWith(mockIssueKey, {
129+
transition: { id: '31' },
130+
fields: {
131+
resolution: { name: 'Done' },
132+
comment: { body: 'Closing this issue' },
133+
},
134+
});
135+
});
136+
137+
it('should handle invalid transition ID errors', async () => {
138+
const mockError = new Error('Invalid transition ID');
139+
(IssueService.doTransition as jest.Mock).mockRejectedValue(mockError);
140+
141+
const result = await jiraService.transitionIssue({
142+
issueKey: mockIssueKey,
143+
transitionId: '999',
144+
});
145+
146+
expect(result.success).toBe(false);
147+
expect(result.error).toBe('Invalid transition ID');
148+
});
149+
150+
it('should handle missing required fields errors', async () => {
151+
const mockError = new Error('Resolution field is required');
152+
(IssueService.doTransition as jest.Mock).mockRejectedValue(mockError);
153+
154+
const result = await jiraService.transitionIssue({
155+
issueKey: mockIssueKey,
156+
transitionId: '31',
157+
});
158+
159+
expect(result.success).toBe(false);
160+
expect(result.error).toBe('Resolution field is required');
161+
});
162+
163+
it('should handle permission errors', async () => {
164+
const mockError = new Error('User does not have permission to transition this issue');
165+
(IssueService.doTransition as jest.Mock).mockRejectedValue(mockError);
166+
167+
const result = await jiraService.transitionIssue({
168+
issueKey: 'RESTRICTED-1',
169+
transitionId: '21',
170+
});
171+
172+
expect(result.success).toBe(false);
173+
expect(result.error).toBe('User does not have permission to transition this issue');
174+
});
175+
176+
it('should handle issue not found errors', async () => {
177+
const mockError = new Error('Issue does not exist');
178+
(IssueService.doTransition as jest.Mock).mockRejectedValue(mockError);
179+
180+
const result = await jiraService.transitionIssue({
181+
issueKey: 'NONEXISTENT-999',
182+
transitionId: '21',
183+
});
184+
185+
expect(result.success).toBe(false);
186+
expect(result.error).toBe('Issue does not exist');
187+
});
188+
});
189+
190+
describe('validateConfig', () => {
191+
const originalEnv = process.env;
192+
193+
beforeEach(() => {
194+
jest.resetModules();
195+
process.env = { ...originalEnv };
196+
});
197+
198+
afterAll(() => {
199+
process.env = originalEnv;
200+
});
201+
202+
it('should return empty array when all required env vars are present', () => {
203+
process.env.JIRA_API_TOKEN = 'test-token';
204+
process.env.JIRA_HOST = 'test-host';
205+
206+
const missingVars = JiraService.validateConfig();
207+
expect(missingVars).toEqual([]);
208+
});
209+
210+
it('should return missing vars when JIRA_API_TOKEN is missing', () => {
211+
delete process.env.JIRA_API_TOKEN;
212+
process.env.JIRA_HOST = 'test-host';
213+
214+
const missingVars = JiraService.validateConfig();
215+
expect(missingVars).toContain('JIRA_API_TOKEN');
216+
});
217+
218+
it('should return missing vars when both host options are missing', () => {
219+
process.env.JIRA_API_TOKEN = 'test-token';
220+
delete process.env.JIRA_HOST;
221+
delete process.env.JIRA_API_BASE_PATH;
222+
223+
const missingVars = JiraService.validateConfig();
224+
expect(missingVars).toContain('JIRA_HOST or JIRA_API_BASE_PATH');
225+
});
226+
227+
it('should accept JIRA_API_BASE_PATH as alternative to JIRA_HOST', () => {
228+
process.env.JIRA_API_TOKEN = 'test-token';
229+
delete process.env.JIRA_HOST;
230+
process.env.JIRA_API_BASE_PATH = 'https://test-host/rest';
231+
232+
const missingVars = JiraService.validateConfig();
233+
expect(missingVars).toEqual([]);
234+
});
235+
});
236+
});

packages/jira/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,24 @@ server.tool(
7979
}
8080
)
8181

82+
server.tool(
83+
"jira_getTransitions",
84+
`Get available status transitions for a JIRA issue in the ${jiraInstanceType}. Returns a list of transitions with their IDs, names, and target statuses.`,
85+
jiraToolSchemas.getTransitions,
86+
async ({ issueKey }) => {
87+
const result = await jiraService.getTransitions(issueKey);
88+
return formatToolResponse(result);
89+
}
90+
);
91+
92+
server.tool(
93+
"jira_transitionIssue",
94+
`Transition a JIRA issue to a new status in the ${jiraInstanceType}. Use jira_getTransitions first to get available transition IDs.`,
95+
jiraToolSchemas.transitionIssue,
96+
async (params) => {
97+
const result = await jiraService.transitionIssue(params);
98+
return formatToolResponse(result);
99+
}
100+
);
101+
82102
await connectServer(server);

packages/jira/src/jira-service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,29 @@ export class JiraService {
8282
}, 'Error updating issue');
8383
}
8484

85+
async getTransitions(issueKey: string) {
86+
return handleApiOperation(
87+
() => IssueService.getTransitions(issueKey),
88+
'Error getting transitions'
89+
);
90+
}
91+
92+
async transitionIssue(params: {
93+
issueKey: string;
94+
transitionId: string;
95+
fields?: Record<string, any>;
96+
}) {
97+
return handleApiOperation(async () => {
98+
const requestBody: { transition: { id: string }; fields?: Record<string, any> } = {
99+
transition: { id: params.transitionId }
100+
};
101+
if (params.fields) {
102+
requestBody.fields = params.fields;
103+
}
104+
return IssueService.doTransition(params.issueKey, requestBody);
105+
}, 'Error transitioning issue');
106+
}
107+
85108
static validateConfig(): string[] {
86109
const requiredEnvVars = ['JIRA_API_TOKEN'] as const;
87110
const missingVars: string[] = requiredEnvVars.filter(varName => !process.env[varName]);
@@ -125,5 +148,13 @@ export const jiraToolSchemas = {
125148
description: z.string().optional().describe("New description in JIRA Wiki Markup (optional)"),
126149
issueTypeId: z.string().optional().describe("New issue type id (optional)"),
127150
customFields: z.record(z.any()).optional().describe("Optional custom fields to update as key-value pairs. Examples: {'customfield_10001': 'Custom Value', 'priority': {'id': '1'}, 'assignee': {'name': 'john.doe'}, 'labels': ['urgent', 'bug']}")
151+
},
152+
getTransitions: {
153+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)")
154+
},
155+
transitionIssue: {
156+
issueKey: z.string().describe("JIRA issue key (e.g., PROJ-123)"),
157+
transitionId: z.string().describe("The ID of the transition to perform. Use jira_getTransitions to find available transitions and their IDs."),
158+
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.")
128159
}
129160
};

0 commit comments

Comments
 (0)