zephyr-scale-mcp-server 0.4.0 → 0.4.2

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
@@ -142,6 +142,20 @@ The server provides access to various resources through URI schemes:
142
142
  - `ZEPHYR_BASE_URL`: `https://your-company.atlassian.net`
143
143
  - `ZEPHYR_API_KEY`: Your Zephyr Scale API key (JWT). Generate it in Jira by clicking your profile picture (bottom left) → **Zephyr API keys**.
144
144
 
145
+ ### Jira Cloud – regional API endpoint (optional)
146
+ Zephyr Scale Cloud has regional API hosts. By default the server uses the **US** endpoint (`https://api.zephyrscale.smartbear.com/v2`). If your instance uses the **EU** API (e.g. for EU data residency), set:
147
+ - **`ZEPHYR_API_BASE_URL`**: Full base URL for the Zephyr Scale Cloud API (no trailing slash).
148
+
149
+ **Example – EU:**
150
+ ```json
151
+ "env": {
152
+ "ZEPHYR_BASE_URL": "https://your-company.atlassian.net",
153
+ "ZEPHYR_API_KEY": "your-zephyr-api-token",
154
+ "ZEPHYR_API_BASE_URL": "https://eu.api.zephyrscale.smartbear.com/v2"
155
+ }
156
+ ```
157
+ If `ZEPHYR_API_BASE_URL` is not set, the US URL is used. Omit this variable for US instances.
158
+
145
159
  ### Jira Data Center Configuration
146
160
  - `ZEPHYR_BASE_URL`: `https://your-jira-server.com`
147
161
  - `ZEPHYR_API_KEY`: Your Zephyr Scale API token from your Jira profile settings.
@@ -20,13 +20,21 @@ export class ZephyrToolHandlers {
20
20
  }
21
21
  }
22
22
  async createTestCase(args) {
23
+ // Some MCP clients (e.g. Claude Code) pass nested object parameters as JSON strings.
24
+ // Parse test_script if it arrived as a string.
25
+ if (typeof args.test_script === 'string') {
26
+ try {
27
+ args.test_script = JSON.parse(args.test_script);
28
+ }
29
+ catch { }
30
+ }
23
31
  if (this.jiraConfig.type === 'cloud') {
24
32
  return this.createTestCaseCloud(args);
25
33
  }
26
34
  return this.createTestCaseDC(args);
27
35
  }
28
36
  async createTestCaseCloud(args) {
29
- const { project_key, name, test_script, folder, priority, precondition, objective, estimated_time, labels, custom_fields, } = args;
37
+ const { project_key, name, test_script, folder, priority, precondition, objective, estimated_time, labels, custom_fields, issue_links, } = args;
30
38
  const payload = { projectKey: project_key, name };
31
39
  // Cloud v2 uses statusName/priorityName (strings), folderId (integer)
32
40
  payload.statusName = 'Draft';
@@ -58,10 +66,27 @@ export class ZephyrToolHandlers {
58
66
  if (test_script) {
59
67
  await this.upsertTestScriptCloud(testKey, test_script);
60
68
  }
69
+ // Step 3: link Jira issues via POST /testcases/{key}/links/issues
70
+ // IssueLinkInput requires a numeric issueId — resolve each key via Jira REST API
71
+ const linkWarnings = [];
72
+ if (issue_links && issue_links.length > 0) {
73
+ for (const issueKey of issue_links) {
74
+ try {
75
+ const issueId = await this.resolveJiraIssueId(issueKey);
76
+ await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${testKey}/links/issues`, { issueId });
77
+ }
78
+ catch (e) {
79
+ linkWarnings.push(`${issueKey}: ${this.formatError(e)}`);
80
+ }
81
+ }
82
+ }
83
+ const warningText = linkWarnings.length > 0
84
+ ? `\n⚠️ Some issue links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}`
85
+ : '';
61
86
  return {
62
87
  content: [{
63
88
  type: 'text',
64
- text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none' }, null, 2)}`,
89
+ text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none', linkedIssues: (issue_links ?? []).length - linkWarnings.length }, null, 2)}${warningText}`,
65
90
  }],
66
91
  };
67
92
  }
@@ -169,40 +194,22 @@ export class ZephyrToolHandlers {
169
194
  }
170
195
  async updateTestCaseBddCloud(args) {
171
196
  const { test_case_key, bdd_content, name } = args;
197
+ const converted = convertToGherkin(bdd_content);
198
+ const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
172
199
  try {
173
- // Step 1: GET existing test case to extract required fields for PUT
174
- const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
175
- const tc = getResponse.data;
176
- // Cloud v2 PUT requires: id, key, name, project, priority, status
177
- const requiredFields = ['id', 'key', 'name', 'project', 'priority', 'status'];
178
- for (const field of requiredFields) {
179
- if (tc[field] === undefined || tc[field] === null) {
180
- throw new McpError(ErrorCode.InternalError, `Existing test case is missing required field '${field}' needed for Cloud v2 update.`);
181
- }
182
- }
183
- const putPayload = {
184
- id: tc.id,
185
- key: tc.key,
186
- name: typeof name === 'string' && name.trim().length > 0 ? name : tc.name,
187
- project: tc.project,
188
- priority: tc.priority,
189
- status: tc.status,
190
- };
191
- // Preserve optional fields
192
- for (const field of ['objective', 'precondition', 'estimatedTime', 'folder', 'component', 'owner']) {
193
- if (tc[field] !== undefined)
194
- putPayload[field] = tc[field];
195
- }
196
- if (Array.isArray(tc.labels))
197
- putPayload.labels = tc.labels;
198
- if (tc.customFields)
199
- putPayload.customFields = tc.customFields;
200
- // Step 2: PUT to update metadata
201
- await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, putPayload);
202
- // Step 3: POST testscript with BDD content
203
- const converted = convertToGherkin(bdd_content);
204
- const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
205
200
  await this.axiosInstance.post(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}/testscript`, { type: 'bdd', text: finalText });
201
+ // Only fetch and PUT metadata when the caller also wants to rename the test case
202
+ if (typeof name === 'string' && name.trim().length > 0) {
203
+ const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
204
+ const tc = getResponse.data;
205
+ const projectKey = tc.projectKey ?? test_case_key.replace(/-T\d+$/, '');
206
+ await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, {
207
+ projectKey,
208
+ name,
209
+ status: tc.status,
210
+ priority: tc.priority,
211
+ });
212
+ }
206
213
  return {
207
214
  content: [{
208
215
  type: 'text',
@@ -224,8 +231,9 @@ export class ZephyrToolHandlers {
224
231
  const converted = convertToGherkin(bdd_content);
225
232
  const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
226
233
  const payload = {};
234
+ const projectKey = testCaseData.projectKey ?? test_case_key.replace(/-T\d+$/, '');
227
235
  const requiredFields = [
228
- ['projectKey', testCaseData.projectKey],
236
+ ['projectKey', projectKey],
229
237
  ['name', testCaseData.name],
230
238
  ['status', testCaseData.status],
231
239
  ['priority', testCaseData.priority]
@@ -760,6 +768,18 @@ export class ZephyrToolHandlers {
760
768
  throw new McpError(ErrorCode.InternalError, `Failed to add test cases: ${this.formatError(error)}`);
761
769
  }
762
770
  }
771
+ async resolveJiraIssueId(issueKey) {
772
+ // The Zephyr API key is a Jira-issued token — it works against the Jira REST API too.
773
+ // We call GET {jiraBaseUrl}/rest/api/3/issue/{key}?fields=id to get the numeric issue ID
774
+ // required by POST /testcases/{key}/links/issues { issueId: <integer> }.
775
+ const url = `${this.jiraConfig.jiraBaseUrl}/rest/api/3/issue/${issueKey}?fields=id`;
776
+ const response = await this.axiosInstance.get(url, { baseURL: '' });
777
+ const id = parseInt(response.data.id, 10);
778
+ if (!id || isNaN(id)) {
779
+ throw new Error(`Could not resolve numeric ID for Jira issue "${issueKey}"`);
780
+ }
781
+ return id;
782
+ }
763
783
  formatError(error) {
764
784
  if (error instanceof Error && 'response' in error) {
765
785
  const axiosError = error;
@@ -110,7 +110,7 @@ export const toolSchemas = [
110
110
  },
111
111
  issue_links: {
112
112
  type: 'array',
113
- description: 'Array of issue links (optional) - will be mapped to issueLinks in API',
113
+ description: 'Array of Jira issue keys to link to this test case (e.g. ["PROJ-123", "PROJ-456"]). On Cloud, each key is resolved to a numeric Jira issue ID via the Jira REST API, then linked via POST /testcases/{key}/links/issues — failures are reported as warnings but do not fail the tool call. On Data Center, sent directly in the create payload.',
114
114
  items: { type: 'string' }
115
115
  },
116
116
  custom_fields: {
package/build/utils.js CHANGED
@@ -30,28 +30,48 @@ export async function resolveFolderIdByPath(axiosInstance, projectKey, folderPat
30
30
  export function convertToGherkin(bddContent) {
31
31
  const bddLines = [];
32
32
  const lines = bddContent.split('\n');
33
+ // Bold-markdown step keywords (e.g. **Given**, **When**, etc.)
34
+ const boldStepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
35
+ // Plain step keyword prefixes
36
+ const stepKeywords = ['Given ', 'When ', 'Then ', 'And ', 'But '];
37
+ // Zephyr Scale Cloud only accepts steps and table rows — Feature:/Scenario: wrappers
38
+ // cause a 400 "Invalid Gherkin script" error and must be stripped.
39
+ const strippedPrefixes = [
40
+ 'Feature:',
41
+ 'Background:',
42
+ 'Scenario Outline:',
43
+ 'Scenario:',
44
+ 'Examples:',
45
+ ];
33
46
  for (const line of lines) {
34
47
  const trimmedLine = line.trim();
35
48
  if (!trimmedLine || trimmedLine.startsWith('---'))
36
49
  continue;
37
- if (trimmedLine.startsWith('**Given**')) {
38
- bddLines.push(`Given ${trimmedLine.replace('**Given**', '').trim()}`);
39
- }
40
- else if (trimmedLine.startsWith('**When**')) {
41
- bddLines.push(`When ${trimmedLine.replace('**When**', '').trim()}`);
42
- }
43
- else if (trimmedLine.startsWith('**Then**')) {
44
- bddLines.push(`Then ${trimmedLine.replace('**Then**', '').trim()}`);
50
+ // Strip structural Gherkin keywords — not accepted by the Zephyr Scale Cloud testscript API
51
+ if (strippedPrefixes.some(p => trimmedLine.startsWith(p)))
52
+ continue;
53
+ // Convert **Keyword** markdown bold to plain Gherkin keyword
54
+ let matchedBold = false;
55
+ for (const kw of boldStepKeywords) {
56
+ if (trimmedLine.startsWith(`**${kw}**`)) {
57
+ bddLines.push(`${kw} ${trimmedLine.replace(`**${kw}**`, '').trim()}`);
58
+ matchedBold = true;
59
+ break;
60
+ }
45
61
  }
46
- else if (trimmedLine.startsWith('**And**')) {
47
- bddLines.push(`And ${trimmedLine.replace('**And**', '').trim()}`);
62
+ if (matchedBold)
63
+ continue;
64
+ // Plain step keywords — pass through unchanged
65
+ if (stepKeywords.some(k => trimmedLine.startsWith(k))) {
66
+ bddLines.push(trimmedLine);
67
+ continue;
48
68
  }
49
- else if (trimmedLine.startsWith('Given ') || trimmedLine.startsWith('When ') ||
50
- trimmedLine.startsWith('Then ') || trimmedLine.startsWith('And ')) {
69
+ // Table rows pass through unchanged
70
+ if (trimmedLine.startsWith('|')) {
51
71
  bddLines.push(trimmedLine);
52
72
  }
53
73
  }
54
- return bddLines.length > 0 ? ' ' + bddLines.join('\n ') : '';
74
+ return bddLines.join('\n');
55
75
  }
56
76
  export const customPriorityMapping = {
57
77
  'High': 'P0',
@@ -111,8 +131,11 @@ export function createJiraConfig() {
111
131
  }
112
132
  const type = detectJiraType(jiraBaseUrl);
113
133
  const apiEndpoints = getApiEndpoints(type);
134
+ // Cloud: use ZEPHYR_API_BASE_URL if set (e.g. for EU: https://eu.api.zephyrscale.smartbear.com/v2), else default US
135
+ const defaultCloudBaseUrl = 'https://api.zephyrscale.smartbear.com/v2';
136
+ const cloudBaseUrl = process.env.ZEPHYR_API_BASE_URL?.trim();
114
137
  const baseUrl = type === 'cloud'
115
- ? 'https://api.zephyrscale.smartbear.com/v2'
138
+ ? (cloudBaseUrl || defaultCloudBaseUrl)
116
139
  : jiraBaseUrl;
117
140
  const authHeaders = {
118
141
  'Authorization': `Bearer ${apiKey}`,
@@ -122,6 +145,7 @@ export function createJiraConfig() {
122
145
  return {
123
146
  type,
124
147
  baseUrl,
148
+ jiraBaseUrl,
125
149
  authHeaders,
126
150
  apiEndpoints,
127
151
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zephyr-scale-mcp-server",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@modelcontextprotocol/sdk": "0.6.0",
60
- "axios": "^1.10.0"
60
+ "axios": "^1.15.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^20.11.24",
@@ -32,6 +32,11 @@ export class ZephyrToolHandlers {
32
32
  }
33
33
 
34
34
  async createTestCase(args: TestCaseArgs) {
35
+ // Some MCP clients (e.g. Claude Code) pass nested object parameters as JSON strings.
36
+ // Parse test_script if it arrived as a string.
37
+ if (typeof (args as any).test_script === 'string') {
38
+ try { (args as any).test_script = JSON.parse((args as any).test_script); } catch {}
39
+ }
35
40
  if (this.jiraConfig.type === 'cloud') {
36
41
  return this.createTestCaseCloud(args);
37
42
  }
@@ -41,7 +46,7 @@ export class ZephyrToolHandlers {
41
46
  private async createTestCaseCloud(args: TestCaseArgs) {
42
47
  const {
43
48
  project_key, name, test_script, folder, priority, precondition,
44
- objective, estimated_time, labels, custom_fields,
49
+ objective, estimated_time, labels, custom_fields, issue_links,
45
50
  } = args;
46
51
 
47
52
  const payload: any = { projectKey: project_key, name };
@@ -74,10 +79,31 @@ export class ZephyrToolHandlers {
74
79
  await this.upsertTestScriptCloud(testKey, test_script);
75
80
  }
76
81
 
82
+ // Step 3: link Jira issues via POST /testcases/{key}/links/issues
83
+ // IssueLinkInput requires a numeric issueId — resolve each key via Jira REST API
84
+ const linkWarnings: string[] = [];
85
+ if (issue_links && issue_links.length > 0) {
86
+ for (const issueKey of issue_links) {
87
+ try {
88
+ const issueId = await this.resolveJiraIssueId(issueKey);
89
+ await this.axiosInstance.post(
90
+ `${this.jiraConfig.apiEndpoints.testcase}/${testKey}/links/issues`,
91
+ { issueId }
92
+ );
93
+ } catch (e) {
94
+ linkWarnings.push(`${issueKey}: ${this.formatError(e)}`);
95
+ }
96
+ }
97
+ }
98
+
99
+ const warningText = linkWarnings.length > 0
100
+ ? `\n⚠️ Some issue links failed:\n${linkWarnings.map(w => ` - ${w}`).join('\n')}`
101
+ : '';
102
+
77
103
  return {
78
104
  content: [{
79
105
  type: 'text',
80
- text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none' }, null, 2)}`,
106
+ text: `✅ Test case created successfully: ${testKey}\n${JSON.stringify({ key: testKey, type: test_script?.type || 'none', linkedIssues: (issue_links ?? []).length - linkWarnings.length }, null, 2)}${warningText}`,
81
107
  }],
82
108
  };
83
109
  } catch (error) {
@@ -185,47 +211,28 @@ export class ZephyrToolHandlers {
185
211
  private async updateTestCaseBddCloud(args: UpdateBddArgs) {
186
212
  const { test_case_key, bdd_content, name } = args;
187
213
 
188
- try {
189
- // Step 1: GET existing test case to extract required fields for PUT
190
- const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
191
- const tc = getResponse.data;
192
-
193
- // Cloud v2 PUT requires: id, key, name, project, priority, status
194
- const requiredFields = ['id', 'key', 'name', 'project', 'priority', 'status'];
195
- for (const field of requiredFields) {
196
- if (tc[field] === undefined || tc[field] === null) {
197
- throw new McpError(ErrorCode.InternalError,
198
- `Existing test case is missing required field '${field}' needed for Cloud v2 update.`);
199
- }
200
- }
201
-
202
- const putPayload: any = {
203
- id: tc.id,
204
- key: tc.key,
205
- name: typeof name === 'string' && name.trim().length > 0 ? name : tc.name,
206
- project: tc.project,
207
- priority: tc.priority,
208
- status: tc.status,
209
- };
210
-
211
- // Preserve optional fields
212
- for (const field of ['objective', 'precondition', 'estimatedTime', 'folder', 'component', 'owner']) {
213
- if (tc[field] !== undefined) putPayload[field] = tc[field];
214
- }
215
- if (Array.isArray(tc.labels)) putPayload.labels = tc.labels;
216
- if (tc.customFields) putPayload.customFields = tc.customFields;
214
+ const converted = convertToGherkin(bdd_content);
215
+ const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
217
216
 
218
- // Step 2: PUT to update metadata
219
- await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, putPayload);
220
-
221
- // Step 3: POST testscript with BDD content
222
- const converted = convertToGherkin(bdd_content);
223
- const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
217
+ try {
224
218
  await this.axiosInstance.post(
225
219
  `${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}/testscript`,
226
220
  { type: 'bdd', text: finalText }
227
221
  );
228
222
 
223
+ // Only fetch and PUT metadata when the caller also wants to rename the test case
224
+ if (typeof name === 'string' && name.trim().length > 0) {
225
+ const getResponse = await this.axiosInstance.get(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`);
226
+ const tc = getResponse.data;
227
+ const projectKey = tc.projectKey ?? test_case_key.replace(/-T\d+$/, '');
228
+ await this.axiosInstance.put(`${this.jiraConfig.apiEndpoints.testcase}/${test_case_key}`, {
229
+ projectKey,
230
+ name,
231
+ status: tc.status,
232
+ priority: tc.priority,
233
+ });
234
+ }
235
+
229
236
  return {
230
237
  content: [{
231
238
  type: 'text',
@@ -249,8 +256,9 @@ export class ZephyrToolHandlers {
249
256
  const finalText = converted && converted.trim().length > 0 ? converted : bdd_content;
250
257
 
251
258
  const payload: any = {};
259
+ const projectKey = testCaseData.projectKey ?? test_case_key.replace(/-T\d+$/, '');
252
260
  const requiredFields: Array<[string, any]> = [
253
- ['projectKey', testCaseData.projectKey],
261
+ ['projectKey', projectKey],
254
262
  ['name', testCaseData.name],
255
263
  ['status', testCaseData.status],
256
264
  ['priority', testCaseData.priority]
@@ -837,6 +845,19 @@ export class ZephyrToolHandlers {
837
845
  }
838
846
  }
839
847
 
848
+ private async resolveJiraIssueId(issueKey: string): Promise<number> {
849
+ // The Zephyr API key is a Jira-issued token — it works against the Jira REST API too.
850
+ // We call GET {jiraBaseUrl}/rest/api/3/issue/{key}?fields=id to get the numeric issue ID
851
+ // required by POST /testcases/{key}/links/issues { issueId: <integer> }.
852
+ const url = `${this.jiraConfig.jiraBaseUrl}/rest/api/3/issue/${issueKey}?fields=id`;
853
+ const response = await this.axiosInstance.get(url, { baseURL: '' });
854
+ const id = parseInt(response.data.id, 10);
855
+ if (!id || isNaN(id)) {
856
+ throw new Error(`Could not resolve numeric ID for Jira issue "${issueKey}"`);
857
+ }
858
+ return id;
859
+ }
860
+
840
861
  private formatError(error: unknown): string {
841
862
  if (error instanceof Error && 'response' in error) {
842
863
  const axiosError = error as any;
@@ -110,7 +110,7 @@ export const toolSchemas = [
110
110
  },
111
111
  issue_links: {
112
112
  type: 'array',
113
- description: 'Array of issue links (optional) - will be mapped to issueLinks in API',
113
+ description: 'Array of Jira issue keys to link to this test case (e.g. ["PROJ-123", "PROJ-456"]). On Cloud, each key is resolved to a numeric Jira issue ID via the Jira REST API, then linked via POST /testcases/{key}/links/issues — failures are reported as warnings but do not fail the tool call. On Data Center, sent directly in the create payload.',
114
114
  items: { type: 'string' }
115
115
  },
116
116
  custom_fields: {
package/src/types.ts CHANGED
@@ -104,6 +104,7 @@ export interface ApiEndpoints {
104
104
  export interface JiraConfig {
105
105
  type: JiraType;
106
106
  baseUrl: string;
107
+ jiraBaseUrl: string;
107
108
  authHeaders: Record<string, string>;
108
109
  apiEndpoints: ApiEndpoints;
109
110
  }
package/src/utils.ts CHANGED
@@ -43,28 +43,53 @@ export async function resolveFolderIdByPath(
43
43
  export function convertToGherkin(bddContent: string): string {
44
44
  const bddLines: string[] = [];
45
45
  const lines = bddContent.split('\n');
46
-
46
+
47
+ // Bold-markdown step keywords (e.g. **Given**, **When**, etc.)
48
+ const boldStepKeywords = ['Given', 'When', 'Then', 'And', 'But'];
49
+ // Plain step keyword prefixes
50
+ const stepKeywords = ['Given ', 'When ', 'Then ', 'And ', 'But '];
51
+ // Zephyr Scale Cloud only accepts steps and table rows — Feature:/Scenario: wrappers
52
+ // cause a 400 "Invalid Gherkin script" error and must be stripped.
53
+ const strippedPrefixes = [
54
+ 'Feature:',
55
+ 'Background:',
56
+ 'Scenario Outline:',
57
+ 'Scenario:',
58
+ 'Examples:',
59
+ ];
60
+
47
61
  for (const line of lines) {
48
62
  const trimmedLine = line.trim();
49
63
  if (!trimmedLine || trimmedLine.startsWith('---')) continue;
50
-
51
- if (trimmedLine.startsWith('**Given**')) {
52
- bddLines.push(`Given ${trimmedLine.replace('**Given**', '').trim()}`);
53
- } else if (trimmedLine.startsWith('**When**')) {
54
- bddLines.push(`When ${trimmedLine.replace('**When**', '').trim()}`);
55
- } else if (trimmedLine.startsWith('**Then**')) {
56
- bddLines.push(`Then ${trimmedLine.replace('**Then**', '').trim()}`);
57
- } else if (trimmedLine.startsWith('**And**')) {
58
- bddLines.push(`And ${trimmedLine.replace('**And**', '').trim()}`);
59
- } else if (trimmedLine.startsWith('Given ') || trimmedLine.startsWith('When ') ||
60
- trimmedLine.startsWith('Then ') || trimmedLine.startsWith('And ')) {
64
+
65
+ // Strip structural Gherkin keywords — not accepted by the Zephyr Scale Cloud testscript API
66
+ if (strippedPrefixes.some(p => trimmedLine.startsWith(p))) continue;
67
+
68
+ // Convert **Keyword** markdown bold to plain Gherkin keyword
69
+ let matchedBold = false;
70
+ for (const kw of boldStepKeywords) {
71
+ if (trimmedLine.startsWith(`**${kw}**`)) {
72
+ bddLines.push(`${kw} ${trimmedLine.replace(`**${kw}**`, '').trim()}`);
73
+ matchedBold = true;
74
+ break;
75
+ }
76
+ }
77
+ if (matchedBold) continue;
78
+
79
+ // Plain step keywords — pass through unchanged
80
+ if (stepKeywords.some(k => trimmedLine.startsWith(k))) {
81
+ bddLines.push(trimmedLine);
82
+ continue;
83
+ }
84
+
85
+ // Table rows — pass through unchanged
86
+ if (trimmedLine.startsWith('|')) {
61
87
  bddLines.push(trimmedLine);
62
88
  }
63
89
  }
64
-
65
- return bddLines.length > 0 ? ' ' + bddLines.join('\n ') : '';
66
- }
67
90
 
91
+ return bddLines.join('\n');
92
+ }
68
93
  export const customPriorityMapping: { [key: string]: string } = {
69
94
  'High': 'P0',
70
95
  'Normal': 'P1',
@@ -128,9 +153,12 @@ export function createJiraConfig() {
128
153
 
129
154
  const type = detectJiraType(jiraBaseUrl);
130
155
  const apiEndpoints = getApiEndpoints(type);
131
-
132
- const baseUrl = type === 'cloud'
133
- ? 'https://api.zephyrscale.smartbear.com/v2'
156
+
157
+ // Cloud: use ZEPHYR_API_BASE_URL if set (e.g. for EU: https://eu.api.zephyrscale.smartbear.com/v2), else default US
158
+ const defaultCloudBaseUrl = 'https://api.zephyrscale.smartbear.com/v2';
159
+ const cloudBaseUrl = process.env.ZEPHYR_API_BASE_URL?.trim();
160
+ const baseUrl = type === 'cloud'
161
+ ? (cloudBaseUrl || defaultCloudBaseUrl)
134
162
  : jiraBaseUrl;
135
163
 
136
164
  const authHeaders = {
@@ -142,6 +170,7 @@ export function createJiraConfig() {
142
170
  return {
143
171
  type,
144
172
  baseUrl,
173
+ jiraBaseUrl,
145
174
  authHeaders,
146
175
  apiEndpoints,
147
176
  };