zephyr-scale-mcp-server 0.6.0 → 0.8.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/README.md CHANGED
@@ -107,17 +107,22 @@ The server provides access to various resources through URI schemes:
107
107
 
108
108
  ### Test Run Management
109
109
  - `create_test_run`: Create a new test run.
110
- - `get_test_run`: Get detailed information about a specific test run.
110
+ - `get_test_run`: Get detailed information about a specific test run, including resolved status name.
111
+ - `update_test_run`: Update an existing test cycle — set owner, name, description, dates, or status. *(Cloud only)*
111
112
  - `get_test_run_cases`: Get test case keys from a test run.
112
113
  - `add_test_cases_to_run`: Add test cases to an existing test run. *(Cloud only)*
113
114
 
114
115
  ### Test Execution & Search
115
116
  - `get_test_execution`: Get detailed individual test execution results.
116
- - `search_test_cases_by_folder`: Search for test cases in a specific folder.
117
+ - `list_executions_by_cycle`: List all test executions for a specific test cycle with status, executor, and date. *(Cloud only)*
118
+ - `update_test_execution`: Update a test case execution's status within a cycle (Pass/Fail/etc.), add a comment, and attach bug(s) as Jira issue links. Identify the execution by `execution_id`, or by `test_cycle_key` + `test_case_key`. *(Cloud only)*
119
+ - `search_test_cases_by_folder`: Search for test cases in a specific folder. Automatically paginates through all results.
117
120
  - `search_test_runs`: Search for test runs by project key and/or folder path.
121
+ - `get_test_cycles_for_issue`: Get the Zephyr test cycles linked to a Jira issue (story/epic). Resolves each cycle ID to its key (e.g. `PROJ-R123`) and name so you can feed it straight into `list_executions_by_cycle` / `update_test_execution`. *(Cloud only)*
118
122
 
119
123
  ### Organization
120
124
  - `create_folder`: Create a new folder in Zephyr Scale.
125
+ - `get_folders`: List folders, optionally filtered by project, type, and path. When `folder_path` is given, returns the matching folder and its full subtree at every depth.
121
126
 
122
127
  ## Usage Examples
123
128
 
@@ -159,6 +164,33 @@ The server provides access to various resources through URI schemes:
159
164
  ```
160
165
  **Note**: The server will convert markdown-style BDD into Gherkin when possible and will preserve all other existing test case fields.
161
166
 
167
+ ### Mark an Execution as Failed and Attach a Bug
168
+ ```json
169
+ {
170
+ "test_cycle_key": "PROJ-R123",
171
+ "test_case_key": "PROJ-T456",
172
+ "status": "Fail",
173
+ "comment": "Login button unresponsive on submit.",
174
+ "bug_keys": ["PROJ-789"]
175
+ }
176
+ ```
177
+ Or target an execution directly by key:
178
+ ```json
179
+ {
180
+ "execution_id": "PROJ-E123",
181
+ "status": "Pass"
182
+ }
183
+ ```
184
+ **Note**: `update_test_execution` is Cloud only. `bug_keys` requires `JIRA_USERNAME` and `JIRA_API_TOKEN`; link failures are reported as warnings while the status update still succeeds.
185
+
186
+ ### Find the Test Cycle Linked to a Jira Ticket
187
+ ```json
188
+ {
189
+ "issue_key": "PROJ-6752"
190
+ }
191
+ ```
192
+ Returns the linked cycles with resolved keys, e.g. `[{ "id": "110702963", "key": "PROJ-R467", "name": "..." }]`. This is the bridge from a Jira ticket to its Zephyr cycle — the association is stored on the Zephyr side, not in Jira's issue fields. Chain it: `get_test_cycles_for_issue` → `list_executions_by_cycle` → `update_test_execution`. Pass `"resolve_keys": false` to skip the per-cycle key/name lookup and return raw IDs only. **Cloud only.**
193
+
162
194
  ## Authentication
163
195
 
164
196
  ### Jira Cloud Configuration
package/build/index.js CHANGED
@@ -51,6 +51,8 @@ class ZephyrServer {
51
51
  return await this.toolHandlers.updateTestCaseBdd(args);
52
52
  case 'create_folder':
53
53
  return await this.toolHandlers.createFolder(args);
54
+ case 'get_folders':
55
+ return await this.toolHandlers.getFolders(args);
54
56
  case 'get_test_run_cases':
55
57
  return await this.toolHandlers.getTestRunCases(args);
56
58
  case 'delete_test_case':
@@ -73,6 +75,10 @@ class ZephyrServer {
73
75
  return await this.toolHandlers.addTestCasesToRun(args);
74
76
  case 'list_executions_by_cycle':
75
77
  return await this.toolHandlers.listExecutionsByCycle(args);
78
+ case 'update_test_execution':
79
+ return await this.toolHandlers.updateTestExecution(args);
80
+ case 'get_test_cycles_for_issue':
81
+ return await this.toolHandlers.getTestCyclesForIssue(args);
76
82
  default:
77
83
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
78
84
  }
@@ -743,6 +743,79 @@ export class ZephyrToolHandlers {
743
743
  throw new McpError(ErrorCode.InternalError, `Failed to create test run: ${this.formatError(error)}`);
744
744
  }
745
745
  }
746
+ async getFolders(args) {
747
+ const { project_key, folder_type, folder_path, max_results } = args;
748
+ try {
749
+ const pageSize = 1000;
750
+ const allFolders = [];
751
+ let startAt = 0;
752
+ let isLast = false;
753
+ while (!isLast) {
754
+ const params = { maxResults: pageSize, startAt };
755
+ if (project_key)
756
+ params.projectKey = project_key;
757
+ if (folder_type)
758
+ params.folderType = folder_type;
759
+ const response = await this.axiosInstance.get('/folders', { params });
760
+ const page = Array.isArray(response.data)
761
+ ? response.data
762
+ : response.data?.values ?? [];
763
+ allFolders.push(...page);
764
+ isLast = response.data?.isLast === true || page.length < pageSize;
765
+ startAt += page.length;
766
+ if (max_results && allFolders.length >= max_results)
767
+ break;
768
+ }
769
+ let results;
770
+ if (folder_path) {
771
+ // Resolve the root folder ID from the path, then collect full subtree via BFS
772
+ const rootId = await resolveFolderIdByPath(this.axiosInstance, project_key, folder_path, folder_type ?? 'TEST_CASE');
773
+ if (rootId === null) {
774
+ return {
775
+ content: [{
776
+ type: 'text',
777
+ text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}.`,
778
+ }],
779
+ };
780
+ }
781
+ // BFS over the already-fetched flat list — no extra API calls
782
+ const subtreeIds = new Set([rootId]);
783
+ const queue = [rootId];
784
+ while (queue.length > 0) {
785
+ const current = queue.shift();
786
+ for (const f of allFolders) {
787
+ if ((f.parentId ?? null) === current && !subtreeIds.has(f.id)) {
788
+ subtreeIds.add(f.id);
789
+ queue.push(f.id);
790
+ }
791
+ }
792
+ }
793
+ results = allFolders.filter(f => subtreeIds.has(f.id));
794
+ }
795
+ else {
796
+ results = allFolders;
797
+ }
798
+ if (max_results)
799
+ results = results.slice(0, max_results);
800
+ return {
801
+ content: [{
802
+ type: 'text',
803
+ text: JSON.stringify({
804
+ totalCount: results.length,
805
+ folders: results.map((f) => ({
806
+ id: f.id,
807
+ name: f.name,
808
+ parentId: f.parentId ?? null,
809
+ folderType: f.folderType,
810
+ })),
811
+ }, null, 2),
812
+ }],
813
+ };
814
+ }
815
+ catch (error) {
816
+ throw new McpError(ErrorCode.InternalError, `Failed to get folders: ${this.formatError(error)}`);
817
+ }
818
+ }
746
819
  async resolveStatusName(statusId) {
747
820
  try {
748
821
  const response = await this.axiosInstance.get(`/statuses/${statusId}`);
@@ -837,12 +910,27 @@ export class ZephyrToolHandlers {
837
910
  }],
838
911
  };
839
912
  }
840
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
841
- params: { projectKey: project_key, folderId, maxResults: max_results },
842
- });
843
- const testCases = Array.isArray(response.data)
844
- ? response.data
845
- : response.data?.values ?? [];
913
+ // Paginate through all results — the API returns up to 1000 per page
914
+ const pageSize = Math.min(max_results, 1000);
915
+ const allTestCases = [];
916
+ let startAt = 0;
917
+ let isLast = false;
918
+ while (!isLast && allTestCases.length < max_results) {
919
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
920
+ params: { projectKey: project_key, folderId, maxResults: pageSize, startAt },
921
+ });
922
+ const page = Array.isArray(response.data)
923
+ ? response.data
924
+ : response.data?.values ?? [];
925
+ allTestCases.push(...page);
926
+ // Stop if the API signals last page, or we got fewer results than requested
927
+ isLast = response.data?.isLast === true || page.length < pageSize;
928
+ startAt += page.length;
929
+ // Safety: never exceed max_results
930
+ if (allTestCases.length >= max_results)
931
+ break;
932
+ }
933
+ const testCases = allTestCases.slice(0, max_results);
846
934
  return {
847
935
  content: [{
848
936
  type: 'text',
@@ -1063,6 +1151,171 @@ export class ZephyrToolHandlers {
1063
1151
  throw new McpError(ErrorCode.InternalError, `Failed to list executions: ${this.formatError(error)}`);
1064
1152
  }
1065
1153
  }
1154
+ async updateTestExecution(args) {
1155
+ if (this.jiraConfig.type !== 'cloud') {
1156
+ throw new McpError(ErrorCode.InvalidRequest, 'update_test_execution is only supported on Zephyr Scale Cloud. The Data Center API (v1) uses a different test-result model.');
1157
+ }
1158
+ const { execution_id, test_cycle_key, test_case_key, project_key, status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, bug_keys, } = args;
1159
+ // Resolve which execution to update: explicit execution_id wins, otherwise cycle + test case.
1160
+ let execKey = execution_id;
1161
+ if (!execKey) {
1162
+ if (!test_cycle_key || !test_case_key) {
1163
+ throw new McpError(ErrorCode.InvalidParams, 'Provide either execution_id, or both test_cycle_key and test_case_key to identify the execution.');
1164
+ }
1165
+ execKey = await this.resolveExecutionByCycleAndCase(test_cycle_key, test_case_key, project_key);
1166
+ }
1167
+ // Build the update payload — the PUT ignores null/undefined and only touches provided fields.
1168
+ const payload = {};
1169
+ if (status !== undefined)
1170
+ payload.statusName = status;
1171
+ if (comment !== undefined)
1172
+ payload.comment = comment;
1173
+ if (environment !== undefined)
1174
+ payload.environmentName = environment;
1175
+ if (execution_time !== undefined)
1176
+ payload.executionTime = execution_time;
1177
+ if (actual_end_date !== undefined)
1178
+ payload.actualEndDate = actual_end_date;
1179
+ if (executed_by_id !== undefined)
1180
+ payload.executedById = executed_by_id;
1181
+ if (assigned_to_id !== undefined)
1182
+ payload.assignedToId = assigned_to_id;
1183
+ const hasBugs = Array.isArray(bug_keys) && bug_keys.length > 0;
1184
+ if (Object.keys(payload).length === 0 && !hasBugs) {
1185
+ throw new McpError(ErrorCode.InvalidParams, 'Nothing to update — provide at least one of: status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, or bug_keys.');
1186
+ }
1187
+ try {
1188
+ if (Object.keys(payload).length > 0) {
1189
+ await this.axiosInstance.put(`/testexecutions/${execKey}`, payload);
1190
+ }
1191
+ // Attach bugs / Jira issues. IssueLinkInput requires a numeric issueId, so resolve each
1192
+ // key via the Jira REST API (needs JIRA_USERNAME + JIRA_API_TOKEN on Cloud), mirroring create_test_case.
1193
+ const linkWarnings = [];
1194
+ let linkedCount = 0;
1195
+ if (hasBugs) {
1196
+ for (const bugKey of bug_keys) {
1197
+ try {
1198
+ const issueId = await this.resolveJiraIssueId(bugKey);
1199
+ await this.axiosInstance.post(`/testexecutions/${execKey}/links/issues`, { issueId });
1200
+ linkedCount++;
1201
+ }
1202
+ catch (e) {
1203
+ linkWarnings.push(`${bugKey}: ${this.formatError(e)}`);
1204
+ }
1205
+ }
1206
+ }
1207
+ const missingCreds = !process.env.JIRA_USERNAME || !process.env.JIRA_API_TOKEN;
1208
+ const credHint = missingCreds && linkWarnings.length > 0
1209
+ ? '\n💡 Tip: Set JIRA_USERNAME and JIRA_API_TOKEN env vars to enable bug/issue linking on Cloud.'
1210
+ : '';
1211
+ const warningText = linkWarnings.length > 0
1212
+ ? `\n⚠️ Some bug links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}${credHint}`
1213
+ : '';
1214
+ return {
1215
+ content: [{
1216
+ type: 'text',
1217
+ text: `✅ Updated test execution ${execKey} successfully.\n${JSON.stringify({
1218
+ executionKey: execKey,
1219
+ status: status ?? '(unchanged)',
1220
+ updatedFields: Object.keys(payload),
1221
+ linkedBugs: linkedCount,
1222
+ }, null, 2)}${warningText}`,
1223
+ }],
1224
+ };
1225
+ }
1226
+ catch (error) {
1227
+ if (error instanceof McpError)
1228
+ throw error;
1229
+ throw new McpError(ErrorCode.InternalError, `Failed to update test execution: ${this.formatError(error)}`);
1230
+ }
1231
+ }
1232
+ async getTestCyclesForIssue(args) {
1233
+ if (this.jiraConfig.type !== 'cloud') {
1234
+ throw new McpError(ErrorCode.InvalidRequest, 'get_test_cycles_for_issue is only supported on Zephyr Scale Cloud. The Data Center API (v1) does not expose issue-link lookups.');
1235
+ }
1236
+ const { issue_key, resolve_keys = true } = args;
1237
+ if (!issue_key) {
1238
+ throw new McpError(ErrorCode.InvalidParams, 'issue_key is required (e.g. "PROJ-123").');
1239
+ }
1240
+ try {
1241
+ // GET /issuelinks/{issueKey}/testcycles → TestCycleIdList: [{ id, self }, ...]
1242
+ const response = await this.axiosInstance.get(`/issuelinks/${issue_key}/testcycles`);
1243
+ const raw = Array.isArray(response.data)
1244
+ ? response.data
1245
+ : response.data?.values ?? [];
1246
+ const cycleIds = raw
1247
+ .map((c) => c.id ?? c.self?.match(/testcycles\/(\d+)/)?.[1])
1248
+ .filter((id) => id !== undefined && id !== null)
1249
+ .map((id) => String(id));
1250
+ // Optionally resolve each numeric cycle ID to its human-readable key + name.
1251
+ let cycles;
1252
+ if (resolve_keys) {
1253
+ cycles = [];
1254
+ for (const id of cycleIds) {
1255
+ try {
1256
+ const cyc = await this.axiosInstance.get(`/testcycles/${id}`);
1257
+ cycles.push({ id, key: cyc.data?.key ?? null, name: cyc.data?.name ?? null });
1258
+ }
1259
+ catch (e) {
1260
+ cycles.push({ id, key: null, name: null, error: this.formatError(e) });
1261
+ }
1262
+ }
1263
+ }
1264
+ else {
1265
+ cycles = cycleIds.map((id) => ({ id }));
1266
+ }
1267
+ return {
1268
+ content: [{
1269
+ type: 'text',
1270
+ text: `✅ Found ${cycles.length} test cycle(s) linked to ${issue_key}:\n${JSON.stringify({
1271
+ issueKey: issue_key,
1272
+ totalCount: cycles.length,
1273
+ testCycles: cycles,
1274
+ }, null, 2)}`,
1275
+ }],
1276
+ };
1277
+ }
1278
+ catch (error) {
1279
+ if (error instanceof McpError)
1280
+ throw error;
1281
+ throw new McpError(ErrorCode.InternalError, `Failed to get test cycles for issue: ${this.formatError(error)}`);
1282
+ }
1283
+ }
1284
+ /** Find the latest execution key for a test case within a cycle (Cloud). */
1285
+ async resolveExecutionByCycleAndCase(cycleKey, caseKey, projectKey) {
1286
+ const derivedProject = projectKey || cycleKey.split('-')[0];
1287
+ try {
1288
+ const response = await this.axiosInstance.get('/testexecutions', {
1289
+ params: {
1290
+ projectKey: derivedProject,
1291
+ testCycle: cycleKey,
1292
+ onlyLastExecutions: true,
1293
+ maxResults: 1000,
1294
+ },
1295
+ });
1296
+ const executions = Array.isArray(response.data)
1297
+ ? response.data
1298
+ : response.data?.values ?? [];
1299
+ const match = executions.find((ex) => {
1300
+ const key = ex.testCase?.self?.match(/testcases\/(.+?)\/versions/)?.[1]
1301
+ ?? ex.testCase?.key;
1302
+ return key === caseKey;
1303
+ });
1304
+ if (!match) {
1305
+ throw new McpError(ErrorCode.InvalidParams, `No execution found for test case ${caseKey} in cycle ${cycleKey}. Ensure the test case is part of the cycle.`);
1306
+ }
1307
+ const execKey = match.key ?? (match.id !== undefined ? String(match.id) : undefined);
1308
+ if (!execKey) {
1309
+ throw new McpError(ErrorCode.InternalError, `Found a matching execution for ${caseKey} in ${cycleKey} but it has no key or id.`);
1310
+ }
1311
+ return execKey;
1312
+ }
1313
+ catch (error) {
1314
+ if (error instanceof McpError)
1315
+ throw error;
1316
+ throw new McpError(ErrorCode.InternalError, `Failed to resolve execution for ${caseKey} in ${cycleKey}: ${this.formatError(error)}`);
1317
+ }
1318
+ }
1066
1319
  async resolveJiraIssueId(issueKey) {
1067
1320
  // The Zephyr API key is NOT valid for the Jira REST API — Jira Cloud requires
1068
1321
  // Basic Auth: base64(email:api_token) via JIRA_USERNAME + JIRA_API_TOKEN env vars.
@@ -196,6 +196,32 @@ export const toolSchemas = [
196
196
  required: ['project_key', 'name'],
197
197
  },
198
198
  },
199
+ {
200
+ name: 'get_folders',
201
+ description: 'Get folders from Zephyr Scale. All parameters are optional. If folder_path is provided, returns the full subtree (the folder and all its descendants at every depth) under that path. Otherwise returns all folders matching the filters.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ project_key: {
206
+ type: 'string',
207
+ description: 'Jira project key filter (e.g., "PROJ"). Optional.',
208
+ },
209
+ folder_type: {
210
+ type: 'string',
211
+ description: 'Folder type filter (optional)',
212
+ enum: ['TEST_CASE', 'TEST_PLAN', 'TEST_CYCLE'],
213
+ },
214
+ folder_path: {
215
+ type: 'string',
216
+ description: 'Folder path to filter by (e.g., "/CRM on Wechat"). Returns the matching folder and all folders nested beneath it at any depth. Requires project_key and folder_type to resolve the path.',
217
+ },
218
+ max_results: {
219
+ type: 'number',
220
+ description: 'Maximum total number of folders to return (optional). Defaults to all folders.',
221
+ },
222
+ },
223
+ },
224
+ },
199
225
  {
200
226
  name: 'get_test_run_cases',
201
227
  description: 'Get test case keys from a test run',
@@ -440,6 +466,83 @@ export const toolSchemas = [
440
466
  required: ['test_run_key'],
441
467
  },
442
468
  },
469
+ {
470
+ name: 'update_test_execution',
471
+ description: 'Update a test case execution\'s status within a test cycle, and optionally attach bug(s) as Jira issue links. Identify the execution either by execution_id directly, or by test_cycle_key + test_case_key (the latest execution is used). Cloud only.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ execution_id: {
476
+ type: 'string',
477
+ description: 'Test execution key or ID (e.g. "PROJ-E123" or 5805255). Takes precedence over test_cycle_key/test_case_key.',
478
+ },
479
+ test_cycle_key: {
480
+ type: 'string',
481
+ description: 'Test cycle key (e.g. "PROJ-R123"). Used together with test_case_key to locate the execution when execution_id is not given.',
482
+ },
483
+ test_case_key: {
484
+ type: 'string',
485
+ description: 'Test case key (e.g. "PROJ-T456"). Used together with test_cycle_key to locate the execution.',
486
+ },
487
+ project_key: {
488
+ type: 'string',
489
+ description: 'Project key for the cycle lookup (optional — derived from test_cycle_key when omitted).',
490
+ },
491
+ status: {
492
+ type: 'string',
493
+ description: 'New execution status name. Common values: "Pass", "Fail", "In Progress", "Blocked", "Not Executed". Must match a status configured in your Zephyr project.',
494
+ },
495
+ comment: {
496
+ type: 'string',
497
+ description: 'Comment against the overall execution (e.g. failure details).',
498
+ },
499
+ environment: {
500
+ type: 'string',
501
+ description: 'Environment name assigned to the execution (e.g. "Chrome Latest Version").',
502
+ },
503
+ execution_time: {
504
+ type: 'number',
505
+ description: 'Actual execution time in milliseconds (optional).',
506
+ },
507
+ actual_end_date: {
508
+ type: 'string',
509
+ description: 'Actual end date in ISO format, e.g. "2024-05-20T13:15:13Z" (optional).',
510
+ },
511
+ executed_by_id: {
512
+ type: 'string',
513
+ description: 'Jira Account ID of the user who executed the test (optional).',
514
+ },
515
+ assigned_to_id: {
516
+ type: 'string',
517
+ description: 'Jira Account ID of the user the execution is assigned to (optional).',
518
+ },
519
+ bug_keys: {
520
+ type: 'array',
521
+ description: 'Jira issue keys to attach to the execution as bugs (e.g. ["PROJ-789"]). Each key is resolved to a numeric ID via the Jira REST API (requires JIRA_USERNAME + JIRA_API_TOKEN) and linked via POST /testexecutions/{key}/links/issues. Failures are reported as warnings and do not fail the call.',
522
+ items: { type: 'string' },
523
+ },
524
+ },
525
+ },
526
+ },
527
+ {
528
+ name: 'get_test_cycles_for_issue',
529
+ description: 'Get the Zephyr test cycles linked to a Jira issue (e.g. a story or epic). Calls GET /issuelinks/{issueKey}/testcycles and, by default, resolves each numeric cycle ID to its key (e.g. "PROJ-R123") and name. Use this to discover the test cycle referenced by a Jira ticket, then feed the key into list_executions_by_cycle / update_test_execution. Cloud only.',
530
+ inputSchema: {
531
+ type: 'object',
532
+ properties: {
533
+ issue_key: {
534
+ type: 'string',
535
+ description: 'Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123").',
536
+ },
537
+ resolve_keys: {
538
+ type: 'boolean',
539
+ description: 'When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. Set false to return raw numeric IDs only (faster, no extra calls).',
540
+ default: true,
541
+ },
542
+ },
543
+ required: ['issue_key'],
544
+ },
545
+ },
443
546
  {
444
547
  name: 'delete_test_run',
445
548
  description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
package/build/utils.js CHANGED
@@ -4,18 +4,27 @@ export async function resolveFolderIdByPath(axiosInstance, projectKey, folderPat
4
4
  if (segments.length === 0)
5
5
  return null;
6
6
  try {
7
- // Fetch all folders for the project + type (up to 1000)
8
- const response = await axiosInstance.get('/folders', {
9
- params: { projectKey, folderType, maxResults: 1000 },
10
- });
11
- const folders = Array.isArray(response.data)
12
- ? response.data
13
- : response.data?.values ?? [];
7
+ // Paginate through all folders for the project + type
8
+ const pageSize = 1000;
9
+ const allFolders = [];
10
+ let startAt = 0;
11
+ let isLast = false;
12
+ while (!isLast) {
13
+ const response = await axiosInstance.get('/folders', {
14
+ params: { projectKey, folderType, maxResults: pageSize, startAt },
15
+ });
16
+ const page = Array.isArray(response.data)
17
+ ? response.data
18
+ : response.data?.values ?? [];
19
+ allFolders.push(...page);
20
+ isLast = response.data?.isLast === true || page.length < pageSize;
21
+ startAt += page.length;
22
+ }
14
23
  // Walk segments top-down, matching by name under the correct parent
15
24
  let parentId = null;
16
25
  let matchedId = null;
17
26
  for (const segment of segments) {
18
- const match = folders.find((f) => f.name === segment && (f.parentId ?? null) === parentId);
27
+ const match = allFolders.find((f) => f.name === segment && (f.parentId ?? null) === parentId);
19
28
  if (!match)
20
29
  return null;
21
30
  matchedId = match.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zephyr-scale-mcp-server",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Model Context Protocol (MCP) server for Zephyr Scale test case management with comprehensive STEP_BY_STEP, PLAIN_TEXT, and BDD support",
5
5
  "type": "module",
6
6
  "main": "./build/index.js",
package/src/index.ts CHANGED
@@ -71,6 +71,8 @@ class ZephyrServer {
71
71
  return await this.toolHandlers.updateTestCaseBdd(args as any);
72
72
  case 'create_folder':
73
73
  return await this.toolHandlers.createFolder(args as any);
74
+ case 'get_folders':
75
+ return await this.toolHandlers.getFolders(args as any);
74
76
  case 'get_test_run_cases':
75
77
  return await this.toolHandlers.getTestRunCases(args);
76
78
  case 'delete_test_case':
@@ -93,6 +95,10 @@ class ZephyrServer {
93
95
  return await this.toolHandlers.addTestCasesToRun(args as any);
94
96
  case 'list_executions_by_cycle':
95
97
  return await this.toolHandlers.listExecutionsByCycle(args as any);
98
+ case 'update_test_execution':
99
+ return await this.toolHandlers.updateTestExecution(args as any);
100
+ case 'get_test_cycles_for_issue':
101
+ return await this.toolHandlers.getTestCyclesForIssue(args as any);
96
102
  default:
97
103
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
98
104
  }
@@ -11,6 +11,8 @@ import {
11
11
  SearchTestRunsArgs,
12
12
  GetTestExecutionArgs,
13
13
  ListExecutionsByCycleArgs,
14
+ UpdateTestExecutionArgs,
15
+ GetTestCyclesForIssueArgs,
14
16
  JiraConfig
15
17
  } from './types.js';
16
18
  import { convertToGherkin, resolveFolderIdByPath, getAccountIdFromApiKey } from './utils.js';
@@ -800,6 +802,90 @@ export class ZephyrToolHandlers {
800
802
  }
801
803
  }
802
804
 
805
+ async getFolders(args: any) {
806
+ const { project_key, folder_type, folder_path, max_results } = args;
807
+
808
+ try {
809
+ const pageSize = 1000;
810
+ const allFolders: any[] = [];
811
+ let startAt = 0;
812
+ let isLast = false;
813
+
814
+ while (!isLast) {
815
+ const params: Record<string, any> = { maxResults: pageSize, startAt };
816
+ if (project_key) params.projectKey = project_key;
817
+ if (folder_type) params.folderType = folder_type;
818
+
819
+ const response = await this.axiosInstance.get('/folders', { params });
820
+
821
+ const page: any[] = Array.isArray(response.data)
822
+ ? response.data
823
+ : response.data?.values ?? [];
824
+
825
+ allFolders.push(...page);
826
+
827
+ isLast = response.data?.isLast === true || page.length < pageSize;
828
+ startAt += page.length;
829
+
830
+ if (max_results && allFolders.length >= max_results) break;
831
+ }
832
+
833
+ let results: any[];
834
+
835
+ if (folder_path) {
836
+ // Resolve the root folder ID from the path, then collect full subtree via BFS
837
+ const rootId = await resolveFolderIdByPath(
838
+ this.axiosInstance, project_key, folder_path, folder_type ?? 'TEST_CASE'
839
+ );
840
+
841
+ if (rootId === null) {
842
+ return {
843
+ content: [{
844
+ type: 'text',
845
+ text: `⚠️ Folder not found: "${folder_path}" in project ${project_key}.`,
846
+ }],
847
+ };
848
+ }
849
+
850
+ // BFS over the already-fetched flat list — no extra API calls
851
+ const subtreeIds = new Set<number>([rootId]);
852
+ const queue = [rootId];
853
+ while (queue.length > 0) {
854
+ const current = queue.shift()!;
855
+ for (const f of allFolders) {
856
+ if ((f.parentId ?? null) === current && !subtreeIds.has(f.id)) {
857
+ subtreeIds.add(f.id);
858
+ queue.push(f.id);
859
+ }
860
+ }
861
+ }
862
+
863
+ results = allFolders.filter(f => subtreeIds.has(f.id));
864
+ } else {
865
+ results = allFolders;
866
+ }
867
+
868
+ if (max_results) results = results.slice(0, max_results);
869
+
870
+ return {
871
+ content: [{
872
+ type: 'text',
873
+ text: JSON.stringify({
874
+ totalCount: results.length,
875
+ folders: results.map((f: any) => ({
876
+ id: f.id,
877
+ name: f.name,
878
+ parentId: f.parentId ?? null,
879
+ folderType: f.folderType,
880
+ })),
881
+ }, null, 2),
882
+ }],
883
+ };
884
+ } catch (error) {
885
+ throw new McpError(ErrorCode.InternalError, `Failed to get folders: ${this.formatError(error)}`);
886
+ }
887
+ }
888
+
803
889
  private async resolveStatusName(statusId: number): Promise<string | null> {
804
890
  try {
805
891
  const response = await this.axiosInstance.get(`/statuses/${statusId}`);
@@ -908,13 +994,32 @@ export class ZephyrToolHandlers {
908
994
  };
909
995
  }
910
996
 
911
- const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
912
- params: { projectKey: project_key, folderId, maxResults: max_results },
913
- });
997
+ // Paginate through all results — the API returns up to 1000 per page
998
+ const pageSize = Math.min(max_results, 1000);
999
+ const allTestCases: any[] = [];
1000
+ let startAt = 0;
1001
+ let isLast = false;
914
1002
 
915
- const testCases = Array.isArray(response.data)
916
- ? response.data
917
- : response.data?.values ?? [];
1003
+ while (!isLast && allTestCases.length < max_results) {
1004
+ const response = await this.axiosInstance.get(this.jiraConfig.apiEndpoints.testcase, {
1005
+ params: { projectKey: project_key, folderId, maxResults: pageSize, startAt },
1006
+ });
1007
+
1008
+ const page = Array.isArray(response.data)
1009
+ ? response.data
1010
+ : response.data?.values ?? [];
1011
+
1012
+ allTestCases.push(...page);
1013
+
1014
+ // Stop if the API signals last page, or we got fewer results than requested
1015
+ isLast = response.data?.isLast === true || page.length < pageSize;
1016
+ startAt += page.length;
1017
+
1018
+ // Safety: never exceed max_results
1019
+ if (allTestCases.length >= max_results) break;
1020
+ }
1021
+
1022
+ const testCases = allTestCases.slice(0, max_results);
918
1023
 
919
1024
  return {
920
1025
  content: [{
@@ -1156,6 +1261,199 @@ export class ZephyrToolHandlers {
1156
1261
  }
1157
1262
  }
1158
1263
 
1264
+ async updateTestExecution(args: UpdateTestExecutionArgs) {
1265
+ if (this.jiraConfig.type !== 'cloud') {
1266
+ throw new McpError(
1267
+ ErrorCode.InvalidRequest,
1268
+ 'update_test_execution is only supported on Zephyr Scale Cloud. The Data Center API (v1) uses a different test-result model.'
1269
+ );
1270
+ }
1271
+
1272
+ const {
1273
+ execution_id, test_cycle_key, test_case_key, project_key,
1274
+ status, comment, environment, execution_time, actual_end_date,
1275
+ executed_by_id, assigned_to_id, bug_keys,
1276
+ } = args;
1277
+
1278
+ // Resolve which execution to update: explicit execution_id wins, otherwise cycle + test case.
1279
+ let execKey = execution_id;
1280
+ if (!execKey) {
1281
+ if (!test_cycle_key || !test_case_key) {
1282
+ throw new McpError(
1283
+ ErrorCode.InvalidParams,
1284
+ 'Provide either execution_id, or both test_cycle_key and test_case_key to identify the execution.'
1285
+ );
1286
+ }
1287
+ execKey = await this.resolveExecutionByCycleAndCase(test_cycle_key, test_case_key, project_key);
1288
+ }
1289
+
1290
+ // Build the update payload — the PUT ignores null/undefined and only touches provided fields.
1291
+ const payload: any = {};
1292
+ if (status !== undefined) payload.statusName = status;
1293
+ if (comment !== undefined) payload.comment = comment;
1294
+ if (environment !== undefined) payload.environmentName = environment;
1295
+ if (execution_time !== undefined) payload.executionTime = execution_time;
1296
+ if (actual_end_date !== undefined) payload.actualEndDate = actual_end_date;
1297
+ if (executed_by_id !== undefined) payload.executedById = executed_by_id;
1298
+ if (assigned_to_id !== undefined) payload.assignedToId = assigned_to_id;
1299
+
1300
+ const hasBugs = Array.isArray(bug_keys) && bug_keys.length > 0;
1301
+ if (Object.keys(payload).length === 0 && !hasBugs) {
1302
+ throw new McpError(
1303
+ ErrorCode.InvalidParams,
1304
+ 'Nothing to update — provide at least one of: status, comment, environment, execution_time, actual_end_date, executed_by_id, assigned_to_id, or bug_keys.'
1305
+ );
1306
+ }
1307
+
1308
+ try {
1309
+ if (Object.keys(payload).length > 0) {
1310
+ await this.axiosInstance.put(`/testexecutions/${execKey}`, payload);
1311
+ }
1312
+
1313
+ // Attach bugs / Jira issues. IssueLinkInput requires a numeric issueId, so resolve each
1314
+ // key via the Jira REST API (needs JIRA_USERNAME + JIRA_API_TOKEN on Cloud), mirroring create_test_case.
1315
+ const linkWarnings: string[] = [];
1316
+ let linkedCount = 0;
1317
+ if (hasBugs) {
1318
+ for (const bugKey of bug_keys!) {
1319
+ try {
1320
+ const issueId = await this.resolveJiraIssueId(bugKey);
1321
+ await this.axiosInstance.post(`/testexecutions/${execKey}/links/issues`, { issueId });
1322
+ linkedCount++;
1323
+ } catch (e) {
1324
+ linkWarnings.push(`${bugKey}: ${this.formatError(e)}`);
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ const missingCreds = !process.env.JIRA_USERNAME || !process.env.JIRA_API_TOKEN;
1330
+ const credHint = missingCreds && linkWarnings.length > 0
1331
+ ? '\n💡 Tip: Set JIRA_USERNAME and JIRA_API_TOKEN env vars to enable bug/issue linking on Cloud.'
1332
+ : '';
1333
+ const warningText = linkWarnings.length > 0
1334
+ ? `\n⚠️ Some bug links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}${credHint}`
1335
+ : '';
1336
+
1337
+ return {
1338
+ content: [{
1339
+ type: 'text',
1340
+ text: `✅ Updated test execution ${execKey} successfully.\n${JSON.stringify({
1341
+ executionKey: execKey,
1342
+ status: status ?? '(unchanged)',
1343
+ updatedFields: Object.keys(payload),
1344
+ linkedBugs: linkedCount,
1345
+ }, null, 2)}${warningText}`,
1346
+ }],
1347
+ };
1348
+ } catch (error) {
1349
+ if (error instanceof McpError) throw error;
1350
+ throw new McpError(ErrorCode.InternalError, `Failed to update test execution: ${this.formatError(error)}`);
1351
+ }
1352
+ }
1353
+
1354
+ async getTestCyclesForIssue(args: GetTestCyclesForIssueArgs) {
1355
+ if (this.jiraConfig.type !== 'cloud') {
1356
+ throw new McpError(
1357
+ ErrorCode.InvalidRequest,
1358
+ 'get_test_cycles_for_issue is only supported on Zephyr Scale Cloud. The Data Center API (v1) does not expose issue-link lookups.'
1359
+ );
1360
+ }
1361
+
1362
+ const { issue_key, resolve_keys = true } = args;
1363
+ if (!issue_key) {
1364
+ throw new McpError(ErrorCode.InvalidParams, 'issue_key is required (e.g. "PROJ-123").');
1365
+ }
1366
+
1367
+ try {
1368
+ // GET /issuelinks/{issueKey}/testcycles → TestCycleIdList: [{ id, self }, ...]
1369
+ const response = await this.axiosInstance.get(`/issuelinks/${issue_key}/testcycles`);
1370
+ const raw = Array.isArray(response.data)
1371
+ ? response.data
1372
+ : response.data?.values ?? [];
1373
+
1374
+ const cycleIds: string[] = raw
1375
+ .map((c: any) => c.id ?? c.self?.match(/testcycles\/(\d+)/)?.[1])
1376
+ .filter((id: any) => id !== undefined && id !== null)
1377
+ .map((id: any) => String(id));
1378
+
1379
+ // Optionally resolve each numeric cycle ID to its human-readable key + name.
1380
+ let cycles: any[];
1381
+ if (resolve_keys) {
1382
+ cycles = [];
1383
+ for (const id of cycleIds) {
1384
+ try {
1385
+ const cyc = await this.axiosInstance.get(`/testcycles/${id}`);
1386
+ cycles.push({ id, key: cyc.data?.key ?? null, name: cyc.data?.name ?? null });
1387
+ } catch (e) {
1388
+ cycles.push({ id, key: null, name: null, error: this.formatError(e) });
1389
+ }
1390
+ }
1391
+ } else {
1392
+ cycles = cycleIds.map((id) => ({ id }));
1393
+ }
1394
+
1395
+ return {
1396
+ content: [{
1397
+ type: 'text',
1398
+ text: `✅ Found ${cycles.length} test cycle(s) linked to ${issue_key}:\n${JSON.stringify({
1399
+ issueKey: issue_key,
1400
+ totalCount: cycles.length,
1401
+ testCycles: cycles,
1402
+ }, null, 2)}`,
1403
+ }],
1404
+ };
1405
+ } catch (error) {
1406
+ if (error instanceof McpError) throw error;
1407
+ throw new McpError(ErrorCode.InternalError, `Failed to get test cycles for issue: ${this.formatError(error)}`);
1408
+ }
1409
+ }
1410
+
1411
+ /** Find the latest execution key for a test case within a cycle (Cloud). */
1412
+ private async resolveExecutionByCycleAndCase(
1413
+ cycleKey: string, caseKey: string, projectKey?: string
1414
+ ): Promise<string> {
1415
+ const derivedProject = projectKey || cycleKey.split('-')[0];
1416
+ try {
1417
+ const response = await this.axiosInstance.get('/testexecutions', {
1418
+ params: {
1419
+ projectKey: derivedProject,
1420
+ testCycle: cycleKey,
1421
+ onlyLastExecutions: true,
1422
+ maxResults: 1000,
1423
+ },
1424
+ });
1425
+
1426
+ const executions = Array.isArray(response.data)
1427
+ ? response.data
1428
+ : response.data?.values ?? [];
1429
+
1430
+ const match = executions.find((ex: any) => {
1431
+ const key = ex.testCase?.self?.match(/testcases\/(.+?)\/versions/)?.[1]
1432
+ ?? ex.testCase?.key;
1433
+ return key === caseKey;
1434
+ });
1435
+
1436
+ if (!match) {
1437
+ throw new McpError(
1438
+ ErrorCode.InvalidParams,
1439
+ `No execution found for test case ${caseKey} in cycle ${cycleKey}. Ensure the test case is part of the cycle.`
1440
+ );
1441
+ }
1442
+
1443
+ const execKey = match.key ?? (match.id !== undefined ? String(match.id) : undefined);
1444
+ if (!execKey) {
1445
+ throw new McpError(
1446
+ ErrorCode.InternalError,
1447
+ `Found a matching execution for ${caseKey} in ${cycleKey} but it has no key or id.`
1448
+ );
1449
+ }
1450
+ return execKey;
1451
+ } catch (error) {
1452
+ if (error instanceof McpError) throw error;
1453
+ throw new McpError(ErrorCode.InternalError, `Failed to resolve execution for ${caseKey} in ${cycleKey}: ${this.formatError(error)}`);
1454
+ }
1455
+ }
1456
+
1159
1457
  private async resolveJiraIssueId(issueKey: string): Promise<number> {
1160
1458
  // The Zephyr API key is NOT valid for the Jira REST API — Jira Cloud requires
1161
1459
  // Basic Auth: base64(email:api_token) via JIRA_USERNAME + JIRA_API_TOKEN env vars.
@@ -196,6 +196,32 @@ export const toolSchemas = [
196
196
  required: ['project_key', 'name'],
197
197
  },
198
198
  },
199
+ {
200
+ name: 'get_folders',
201
+ description: 'Get folders from Zephyr Scale. All parameters are optional. If folder_path is provided, returns the full subtree (the folder and all its descendants at every depth) under that path. Otherwise returns all folders matching the filters.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ project_key: {
206
+ type: 'string',
207
+ description: 'Jira project key filter (e.g., "PROJ"). Optional.',
208
+ },
209
+ folder_type: {
210
+ type: 'string',
211
+ description: 'Folder type filter (optional)',
212
+ enum: ['TEST_CASE', 'TEST_PLAN', 'TEST_CYCLE'],
213
+ },
214
+ folder_path: {
215
+ type: 'string',
216
+ description: 'Folder path to filter by (e.g., "/CRM on Wechat"). Returns the matching folder and all folders nested beneath it at any depth. Requires project_key and folder_type to resolve the path.',
217
+ },
218
+ max_results: {
219
+ type: 'number',
220
+ description: 'Maximum total number of folders to return (optional). Defaults to all folders.',
221
+ },
222
+ },
223
+ },
224
+ },
199
225
  {
200
226
  name: 'get_test_run_cases',
201
227
  description: 'Get test case keys from a test run',
@@ -440,6 +466,83 @@ export const toolSchemas = [
440
466
  required: ['test_run_key'],
441
467
  },
442
468
  },
469
+ {
470
+ name: 'update_test_execution',
471
+ description: 'Update a test case execution\'s status within a test cycle, and optionally attach bug(s) as Jira issue links. Identify the execution either by execution_id directly, or by test_cycle_key + test_case_key (the latest execution is used). Cloud only.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ execution_id: {
476
+ type: 'string',
477
+ description: 'Test execution key or ID (e.g. "PROJ-E123" or 5805255). Takes precedence over test_cycle_key/test_case_key.',
478
+ },
479
+ test_cycle_key: {
480
+ type: 'string',
481
+ description: 'Test cycle key (e.g. "PROJ-R123"). Used together with test_case_key to locate the execution when execution_id is not given.',
482
+ },
483
+ test_case_key: {
484
+ type: 'string',
485
+ description: 'Test case key (e.g. "PROJ-T456"). Used together with test_cycle_key to locate the execution.',
486
+ },
487
+ project_key: {
488
+ type: 'string',
489
+ description: 'Project key for the cycle lookup (optional — derived from test_cycle_key when omitted).',
490
+ },
491
+ status: {
492
+ type: 'string',
493
+ description: 'New execution status name. Common values: "Pass", "Fail", "In Progress", "Blocked", "Not Executed". Must match a status configured in your Zephyr project.',
494
+ },
495
+ comment: {
496
+ type: 'string',
497
+ description: 'Comment against the overall execution (e.g. failure details).',
498
+ },
499
+ environment: {
500
+ type: 'string',
501
+ description: 'Environment name assigned to the execution (e.g. "Chrome Latest Version").',
502
+ },
503
+ execution_time: {
504
+ type: 'number',
505
+ description: 'Actual execution time in milliseconds (optional).',
506
+ },
507
+ actual_end_date: {
508
+ type: 'string',
509
+ description: 'Actual end date in ISO format, e.g. "2024-05-20T13:15:13Z" (optional).',
510
+ },
511
+ executed_by_id: {
512
+ type: 'string',
513
+ description: 'Jira Account ID of the user who executed the test (optional).',
514
+ },
515
+ assigned_to_id: {
516
+ type: 'string',
517
+ description: 'Jira Account ID of the user the execution is assigned to (optional).',
518
+ },
519
+ bug_keys: {
520
+ type: 'array',
521
+ description: 'Jira issue keys to attach to the execution as bugs (e.g. ["PROJ-789"]). Each key is resolved to a numeric ID via the Jira REST API (requires JIRA_USERNAME + JIRA_API_TOKEN) and linked via POST /testexecutions/{key}/links/issues. Failures are reported as warnings and do not fail the call.',
522
+ items: { type: 'string' },
523
+ },
524
+ },
525
+ },
526
+ },
527
+ {
528
+ name: 'get_test_cycles_for_issue',
529
+ description: 'Get the Zephyr test cycles linked to a Jira issue (e.g. a story or epic). Calls GET /issuelinks/{issueKey}/testcycles and, by default, resolves each numeric cycle ID to its key (e.g. "PROJ-R123") and name. Use this to discover the test cycle referenced by a Jira ticket, then feed the key into list_executions_by_cycle / update_test_execution. Cloud only.',
530
+ inputSchema: {
531
+ type: 'object',
532
+ properties: {
533
+ issue_key: {
534
+ type: 'string',
535
+ description: 'Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123").',
536
+ },
537
+ resolve_keys: {
538
+ type: 'boolean',
539
+ description: 'When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. Set false to return raw numeric IDs only (faster, no extra calls).',
540
+ default: true,
541
+ },
542
+ },
543
+ required: ['issue_key'],
544
+ },
545
+ },
443
546
  {
444
547
  name: 'delete_test_run',
445
548
  description: 'Delete a specific test run (Data Center only — not supported on Cloud v2)',
package/src/types.ts CHANGED
@@ -102,6 +102,34 @@ export interface ListExecutionsByCycleArgs {
102
102
  max_results?: number;
103
103
  }
104
104
 
105
+ export interface UpdateTestExecutionArgs {
106
+ /** Direct execution key/ID (e.g. "PROJ-E123" or 5805255). Takes precedence over cycle+case lookup. */
107
+ execution_id?: string;
108
+ /** Test cycle key (e.g. "PROJ-R123") — used with test_case_key to locate the execution. */
109
+ test_cycle_key?: string;
110
+ /** Test case key (e.g. "PROJ-T456") — used with test_cycle_key to locate the execution. */
111
+ test_case_key?: string;
112
+ /** Project key for the cycle lookup. Derived from test_cycle_key when omitted. */
113
+ project_key?: string;
114
+ /** New execution status name (e.g. "Pass", "Fail", "In Progress", "Blocked", "Not Executed"). */
115
+ status?: string;
116
+ comment?: string;
117
+ environment?: string;
118
+ execution_time?: number;
119
+ actual_end_date?: string;
120
+ executed_by_id?: string;
121
+ assigned_to_id?: string;
122
+ /** Jira issue keys to link to the execution as bugs (e.g. ["PROJ-789"]). Resolved to numeric IDs via the Jira REST API. */
123
+ bug_keys?: string[];
124
+ }
125
+
126
+ export interface GetTestCyclesForIssueArgs {
127
+ /** Jira issue key whose linked test cycles to fetch (e.g. "PROJ-123"). */
128
+ issue_key: string;
129
+ /** When true (default), resolve each cycle ID to its key + name via GET /testcycles/{id}. */
130
+ resolve_keys?: boolean;
131
+ }
132
+
105
133
  export type JiraType = 'cloud' | 'datacenter';
106
134
 
107
135
  export interface ApiEndpoints {
package/src/utils.ts CHANGED
@@ -11,22 +11,34 @@ export async function resolveFolderIdByPath(
11
11
  if (segments.length === 0) return null;
12
12
 
13
13
  try {
14
- // Fetch all folders for the project + type (up to 1000)
15
- const response = await axiosInstance.get('/folders', {
16
- params: { projectKey, folderType, maxResults: 1000 },
17
- });
18
-
19
- const folders: Array<{ id: number; parentId: number | null; name: string }> =
20
- Array.isArray(response.data)
21
- ? response.data
22
- : response.data?.values ?? [];
14
+ // Paginate through all folders for the project + type
15
+ const pageSize = 1000;
16
+ const allFolders: Array<{ id: number; parentId: number | null; name: string }> = [];
17
+ let startAt = 0;
18
+ let isLast = false;
19
+
20
+ while (!isLast) {
21
+ const response = await axiosInstance.get('/folders', {
22
+ params: { projectKey, folderType, maxResults: pageSize, startAt },
23
+ });
24
+
25
+ const page: Array<{ id: number; parentId: number | null; name: string }> =
26
+ Array.isArray(response.data)
27
+ ? response.data
28
+ : response.data?.values ?? [];
29
+
30
+ allFolders.push(...page);
31
+
32
+ isLast = response.data?.isLast === true || page.length < pageSize;
33
+ startAt += page.length;
34
+ }
23
35
 
24
36
  // Walk segments top-down, matching by name under the correct parent
25
37
  let parentId: number | null = null;
26
38
  let matchedId: number | null = null;
27
39
 
28
40
  for (const segment of segments) {
29
- const match = folders.find(
41
+ const match = allFolders.find(
30
42
  (f) => f.name === segment && (f.parentId ?? null) === parentId
31
43
  );
32
44
  if (!match) return null;