@qa-gentic/stlc-agents 1.0.16 → 1.0.17
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 +59 -314
- package/bin/postinstall.js +17 -1
- package/bin/qa-stlc.js +23 -0
- package/package.json +1 -1
- package/skills/write-helix-files/SKILL.md +6 -0
- package/src/cli/cmd-cost.js +253 -0
- package/src/cli/cmd-mcp-config.js +124 -59
- package/src/stlc_agents/agent_gherkin_generator/server.py +88 -4
- package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +60 -28
- package/src/stlc_agents/agent_jira_manager/server.py +209 -2
- package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +36 -0
- package/src/stlc_agents/agent_playwright_generator/server.py +968 -105
- package/src/stlc_agents/agent_test_case_manager/server.py +121 -2
- package/src/stlc_agents/shared/cost_tracker.py +395 -0
- package/src/stlc_agents/shared/pricing.py +72 -0
- package/src/stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-310.pyc +0 -0
|
@@ -40,6 +40,10 @@ Within-file deduplication
|
|
|
40
40
|
src/test/features/ are scanned for existing scenario titles.
|
|
41
41
|
A title already present in any sibling file is treated as a
|
|
42
42
|
duplicate and dropped from the incoming content.
|
|
43
|
+
For cucumber.config.ts : new profiles are appended ONLY if the file already exists.
|
|
44
|
+
If the file does not exist, the profile is skipped entirely.
|
|
45
|
+
This prevents orphaned config files from being created when
|
|
46
|
+
no Cucumber config yet exists on disk.
|
|
43
47
|
|
|
44
48
|
Interface adapter
|
|
45
49
|
The generator emits repo.updateHealed / repo.incrementSuccess / repo.getBBox etc.
|
|
@@ -140,11 +144,26 @@ def _adapt_to_helix_interface(content: str) -> str:
|
|
|
140
144
|
'import { EnvironmentManager } from "@helper/environment/environmentManager.util";',
|
|
141
145
|
'import { environment } from "@config/environment";',
|
|
142
146
|
)
|
|
147
|
+
# If content uses environment.getConfig() but lacks the import, add it after the last import line
|
|
148
|
+
if "environment.getConfig()" in content and 'from "@config/environment"' not in content:
|
|
149
|
+
import_insert = 'import { environment } from "@config/environment";'
|
|
150
|
+
# Insert after the last import statement
|
|
151
|
+
last_import = content.rfind("\nimport ")
|
|
152
|
+
if last_import != -1:
|
|
153
|
+
end_of_line = content.find("\n", last_import + 1)
|
|
154
|
+
if end_of_line != -1:
|
|
155
|
+
content = content[:end_of_line + 1] + import_insert + "\n" + content[end_of_line + 1:]
|
|
143
156
|
content = re.sub(r"\s*this\.env\s*=\s*new EnvironmentManager\(\);?\s*\n", "\n", content)
|
|
144
157
|
content = content.replace("new EnvironmentManager()", "environment")
|
|
145
158
|
content = content.replace("this.env.getBaseUrl()", "environment.getConfig().baseUrl")
|
|
146
159
|
content = re.sub(r"this\.env\.getPath\(['\"]([^'\"]+)['\"]\)", r'"\1"', content)
|
|
147
160
|
|
|
161
|
+
# Fix super() — cannot access this before super() in TS
|
|
162
|
+
content = content.replace(
|
|
163
|
+
"super(page ?? this.page);",
|
|
164
|
+
"super(page ?? fixture().page);",
|
|
165
|
+
)
|
|
166
|
+
|
|
148
167
|
return content
|
|
149
168
|
|
|
150
169
|
|
|
@@ -162,11 +181,22 @@ def _resolve_destination(helix_root: Path, file_key: str) -> Path:
|
|
|
162
181
|
if _STEPS_RE.search(key):
|
|
163
182
|
return helix_root / "src" / "test" / "steps" / Path(key).name
|
|
164
183
|
if _LOCATOR_RE.search(key):
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
# Extract the page/feature name from the path
|
|
185
|
+
# Input: src/pages/login-page/locators.ts or src/locators/login-page.locators.ts
|
|
186
|
+
# Output stem: login-page
|
|
187
|
+
path_obj = Path(key)
|
|
188
|
+
|
|
189
|
+
# First try: if filename is exactly 'locators.ts', use parent directory name
|
|
190
|
+
if path_obj.name == "locators.ts" and path_obj.parent.name:
|
|
191
|
+
stem = path_obj.parent.name
|
|
192
|
+
else:
|
|
193
|
+
# Otherwise extract stem from filename
|
|
194
|
+
fname = path_obj.name
|
|
195
|
+
stem = re.sub(r"\.locators\.ts$", "", fname)
|
|
196
|
+
stem = re.sub(r"\.ts$", "", stem)
|
|
197
|
+
if not stem or stem in ("src", "locators", "pages", "utils"):
|
|
198
|
+
stem = "page"
|
|
199
|
+
|
|
170
200
|
return helix_root / "src" / "locators" / f"{stem}.locators.ts"
|
|
171
201
|
if _PAGE_RE.search(key):
|
|
172
202
|
return helix_root / "src" / "pages" / Path(key).name
|
|
@@ -242,7 +272,7 @@ def _collect_all_scenario_titles(features_dir: Path, exclude_file: Path | None =
|
|
|
242
272
|
return titles
|
|
243
273
|
|
|
244
274
|
|
|
245
|
-
|
|
275
|
+
def _collect_all_step_patterns(steps_dir: Path, exclude_file: Path | None = None) -> set[str]:
|
|
246
276
|
"""Return every /^pattern$/ defined in all *.steps.ts files in steps_dir,
|
|
247
277
|
optionally excluding one file (the one currently being written)."""
|
|
248
278
|
patterns: set[str] = set()
|
|
@@ -627,29 +657,31 @@ def write_files_to_helix(
|
|
|
627
657
|
skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
|
|
628
658
|
continue
|
|
629
659
|
|
|
630
|
-
# ── Cucumber config: append profile,
|
|
660
|
+
# ── Cucumber config: append profile only if file exists, never create ────────────
|
|
631
661
|
if _CUCUMBER_RE.search(file_key):
|
|
662
|
+
if not dest.exists():
|
|
663
|
+
# RULE: if cucumber config does not exist, skip creation
|
|
664
|
+
skipped.append({
|
|
665
|
+
"file_key": file_key, "dest": dest_rel,
|
|
666
|
+
"reason": "cucumber.config.ts does not exist; only append to existing configs",
|
|
667
|
+
})
|
|
668
|
+
continue
|
|
632
669
|
try:
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
"bytes": len(content.encode()), "action": "appended"})
|
|
649
|
-
else:
|
|
650
|
-
dest.write_text(content, encoding="utf-8")
|
|
651
|
-
written.append({"file_key": file_key, "dest": dest_rel,
|
|
652
|
-
"bytes": len(content.encode()), "action": "created"})
|
|
670
|
+
existing_text = dest.read_text(encoding="utf-8")
|
|
671
|
+
profile_match = re.match(r"\s*(\w+)\s*:", content.strip())
|
|
672
|
+
profile_name = profile_match.group(1) if profile_match else None
|
|
673
|
+
if profile_name and profile_name in existing_text:
|
|
674
|
+
skipped.append({
|
|
675
|
+
"file_key": file_key, "dest": dest_rel,
|
|
676
|
+
"reason": f"profile '{profile_name}' already exists in cucumber.config.ts",
|
|
677
|
+
})
|
|
678
|
+
continue
|
|
679
|
+
dest.write_text(
|
|
680
|
+
existing_text.rstrip() + "\n\n// --- generated profile ---\n" + content,
|
|
681
|
+
encoding="utf-8",
|
|
682
|
+
)
|
|
683
|
+
written.append({"file_key": file_key, "dest": dest_rel,
|
|
684
|
+
"bytes": len(content.encode()), "action": "appended"})
|
|
653
685
|
except OSError as exc:
|
|
654
686
|
skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
|
|
655
687
|
continue
|
|
@@ -867,4 +899,4 @@ def list_helix_tree(helix_root: str) -> dict[str, Any]:
|
|
|
867
899
|
else:
|
|
868
900
|
tree["other"].append(rel)
|
|
869
901
|
|
|
870
|
-
return {"success": True, "helix_root": str(root), "tree": tree}
|
|
902
|
+
return {"success": True, "helix_root": str(root), "tree": tree}
|
|
@@ -34,6 +34,7 @@ from stlc_agents.agent_jira_manager.tools.jira_workitem import (
|
|
|
34
34
|
create_test_case as _create_test_case,
|
|
35
35
|
link_test_cases_to_issue as _link_test_cases,
|
|
36
36
|
get_linked_test_cases as _get_linked_test_cases,
|
|
37
|
+
attach_gherkin_to_issue as _attach_gherkin,
|
|
37
38
|
)
|
|
38
39
|
|
|
39
40
|
load_dotenv()
|
|
@@ -148,9 +149,25 @@ def _validate_linked_test_cases_response(result: dict) -> dict:
|
|
|
148
149
|
|
|
149
150
|
|
|
150
151
|
# ---------------------------------------------------------------------------
|
|
151
|
-
#
|
|
152
|
+
# Deduplication helper
|
|
152
153
|
# ---------------------------------------------------------------------------
|
|
153
154
|
|
|
155
|
+
_TC_STOP_WORDS = frozenset({
|
|
156
|
+
"verify", "ensure", "validate", "check", "test", "the", "a", "an",
|
|
157
|
+
"that", "is", "are", "can", "user", "should", "able", "to", "with",
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalise_summary(summary: str) -> str:
|
|
162
|
+
"""Lowercase, strip punctuation, remove stop words — for dedup comparison."""
|
|
163
|
+
import re
|
|
164
|
+
cleaned = re.sub(r"[^a-z0-9\s]", "", summary.lower())
|
|
165
|
+
tokens = [w for w in cleaned.split() if w not in _TC_STOP_WORDS]
|
|
166
|
+
return " ".join(tokens)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
154
171
|
@app.list_tools()
|
|
155
172
|
async def list_tools() -> list[types.Tool]:
|
|
156
173
|
return [
|
|
@@ -275,6 +292,116 @@ async def list_tools() -> list[types.Tool]:
|
|
|
275
292
|
"required": ["issue_key"],
|
|
276
293
|
},
|
|
277
294
|
),
|
|
295
|
+
types.Tool(
|
|
296
|
+
name="attach_gherkin_to_jira_issue",
|
|
297
|
+
description=(
|
|
298
|
+
"Upload a validated .feature file as an attachment on a Jira issue. "
|
|
299
|
+
"Use this after generate_gherkin + validate_gherkin_content to link the "
|
|
300
|
+
"Gherkin artefact back to the source Jira issue — mirrors the ADO "
|
|
301
|
+
"attach_gherkin_to_work_item / attach_gherkin_to_feature tools. "
|
|
302
|
+
"File is named {issue_key}_{summary_kebab}_regression.feature automatically. "
|
|
303
|
+
"Requires the Jira OAuth token to have 'write:jira-work' scope."
|
|
304
|
+
),
|
|
305
|
+
inputSchema={
|
|
306
|
+
"type": "object",
|
|
307
|
+
"properties": {
|
|
308
|
+
"issue_key": {
|
|
309
|
+
"type": "string",
|
|
310
|
+
"description": "Jira issue key, e.g. 'PROJ-123'",
|
|
311
|
+
},
|
|
312
|
+
"gherkin_content": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "Complete validated .feature file content",
|
|
315
|
+
},
|
|
316
|
+
"cloud_id": {
|
|
317
|
+
"type": "string",
|
|
318
|
+
"description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
"required": ["issue_key", "gherkin_content"],
|
|
322
|
+
},
|
|
323
|
+
),
|
|
324
|
+
types.Tool(
|
|
325
|
+
name="create_deduped_test_cases", description=(
|
|
326
|
+
"Create test cases in Jira, skipping any whose summary already exists as a "
|
|
327
|
+
"linked test case on the issue. "
|
|
328
|
+
"Internally calls get_linked_test_cases, filters the incoming batch against "
|
|
329
|
+
"existing summaries (case-insensitive, stop-word-normalised), then calls "
|
|
330
|
+
"create_and_link_test_cases on the net-new subset only. "
|
|
331
|
+
"Use this instead of create_and_link_test_cases for webhook/headless runs "
|
|
332
|
+
"where re-triggers would otherwise produce duplicates. "
|
|
333
|
+
"Returns skipped_count, created_count, and the full create result."
|
|
334
|
+
),
|
|
335
|
+
inputSchema={
|
|
336
|
+
"type": "object",
|
|
337
|
+
"properties": {
|
|
338
|
+
"issue_key": {
|
|
339
|
+
"type": "string",
|
|
340
|
+
"description": "Jira issue key to link test cases to, e.g. 'PROJ-123'",
|
|
341
|
+
},
|
|
342
|
+
"cloud_id": {
|
|
343
|
+
"type": "string",
|
|
344
|
+
"description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
|
|
345
|
+
},
|
|
346
|
+
"test_cases": {
|
|
347
|
+
"type": "array",
|
|
348
|
+
"description": "Full proposed test case batch (duplicates will be filtered out)",
|
|
349
|
+
"items": {
|
|
350
|
+
"type": "object",
|
|
351
|
+
"properties": {
|
|
352
|
+
"summary": {"type": "string", "description": "Test case title/summary"},
|
|
353
|
+
"priority": {"type": "string", "description": "Highest|High|Medium|Low|Lowest"},
|
|
354
|
+
"steps": {
|
|
355
|
+
"type": "array",
|
|
356
|
+
"items": {
|
|
357
|
+
"type": "object",
|
|
358
|
+
"properties": {
|
|
359
|
+
"action": {"type": "string"},
|
|
360
|
+
"expected_result": {"type": "string"},
|
|
361
|
+
},
|
|
362
|
+
"required": ["action", "expected_result"],
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
"required": ["summary", "steps"],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
"required": ["issue_key", "test_cases"],
|
|
371
|
+
},
|
|
372
|
+
),
|
|
373
|
+
types.Tool(
|
|
374
|
+
name="attach_gherkin_to_issue",
|
|
375
|
+
description=(
|
|
376
|
+
"Upload and attach a validated .feature file to a Jira issue as an attachment. "
|
|
377
|
+
"File is named {issue_key}_{summary_kebab}_regression.feature automatically. "
|
|
378
|
+
"Use this after validate_gherkin_content passes. "
|
|
379
|
+
"Called by the pipeline's Jira Gherkin path to link the .feature back to "
|
|
380
|
+
"the Jira issue instead of saving to disk only."
|
|
381
|
+
),
|
|
382
|
+
inputSchema={
|
|
383
|
+
"type": "object",
|
|
384
|
+
"properties": {
|
|
385
|
+
"issue_key": {
|
|
386
|
+
"type": "string",
|
|
387
|
+
"description": "Jira issue key, e.g. 'PROJ-123'",
|
|
388
|
+
},
|
|
389
|
+
"gherkin_content": {
|
|
390
|
+
"type": "string",
|
|
391
|
+
"description": "Validated .feature file content",
|
|
392
|
+
},
|
|
393
|
+
"issue_summary": {
|
|
394
|
+
"type": "string",
|
|
395
|
+
"description": "Issue summary used in the filename (optional)",
|
|
396
|
+
},
|
|
397
|
+
"cloud_id": {
|
|
398
|
+
"type": "string",
|
|
399
|
+
"description": "Atlassian cloud ID. Leave blank to use JIRA_CLOUD_ID env var.",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
"required": ["issue_key", "gherkin_content"],
|
|
403
|
+
},
|
|
404
|
+
),
|
|
278
405
|
]
|
|
279
406
|
|
|
280
407
|
|
|
@@ -420,6 +547,26 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
420
547
|
},
|
|
421
548
|
}
|
|
422
549
|
|
|
550
|
+
elif name == "attach_gherkin_to_jira_issue":
|
|
551
|
+
issue_key = arguments["issue_key"]
|
|
552
|
+
gherkin_content = arguments["gherkin_content"]
|
|
553
|
+
|
|
554
|
+
# Fetch issue summary for filename (best-effort)
|
|
555
|
+
issue_summary = ""
|
|
556
|
+
try:
|
|
557
|
+
peek = await asyncio.to_thread(_fetch_work_item, cloud_id, issue_key)
|
|
558
|
+
issue_summary = (peek.get("work_item") or {}).get("summary", "")
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
result = await asyncio.to_thread(
|
|
563
|
+
_attach_gherkin,
|
|
564
|
+
cloud_id,
|
|
565
|
+
issue_key,
|
|
566
|
+
gherkin_content,
|
|
567
|
+
issue_summary,
|
|
568
|
+
)
|
|
569
|
+
|
|
423
570
|
elif name == "get_linked_test_cases":
|
|
424
571
|
result = await asyncio.to_thread(
|
|
425
572
|
_get_linked_test_cases,
|
|
@@ -428,6 +575,66 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
428
575
|
)
|
|
429
576
|
result["_validation"] = _validate_linked_test_cases_response(result)
|
|
430
577
|
|
|
578
|
+
elif name == "create_deduped_test_cases":
|
|
579
|
+
issue_key = arguments["issue_key"]
|
|
580
|
+
proposed = arguments["test_cases"]
|
|
581
|
+
|
|
582
|
+
# Normalise title/summary on input
|
|
583
|
+
for tc in proposed:
|
|
584
|
+
if "title" in tc and "summary" not in tc:
|
|
585
|
+
tc["summary"] = tc.pop("title")
|
|
586
|
+
|
|
587
|
+
# Step 1: fetch existing linked TCs
|
|
588
|
+
existing_result = await asyncio.to_thread(
|
|
589
|
+
_get_linked_test_cases, cloud_id, issue_key
|
|
590
|
+
)
|
|
591
|
+
existing_summaries: set[str] = {
|
|
592
|
+
_normalise_summary(tc.get("summary", ""))
|
|
593
|
+
for tc in existing_result.get("test_cases", [])
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
# Step 2: filter — keep only net-new
|
|
597
|
+
net_new = [
|
|
598
|
+
tc for tc in proposed
|
|
599
|
+
if _normalise_summary(tc.get("summary", "")) not in existing_summaries
|
|
600
|
+
]
|
|
601
|
+
skipped = len(proposed) - len(net_new)
|
|
602
|
+
|
|
603
|
+
if not net_new:
|
|
604
|
+
result = {
|
|
605
|
+
"status": "all_duplicates",
|
|
606
|
+
"skipped_count": skipped,
|
|
607
|
+
"created_count": 0,
|
|
608
|
+
"message": (
|
|
609
|
+
f"All {skipped} proposed test case(s) already exist as linked "
|
|
610
|
+
"test cases on this issue. Nothing was created."
|
|
611
|
+
),
|
|
612
|
+
}
|
|
613
|
+
else:
|
|
614
|
+
# Step 3: delegate to create_and_link_test_cases
|
|
615
|
+
create_result_raw = await call_tool("create_and_link_test_cases", {
|
|
616
|
+
"issue_key": issue_key,
|
|
617
|
+
"cloud_id": cloud_id,
|
|
618
|
+
"test_cases": net_new,
|
|
619
|
+
"confirmed": True,
|
|
620
|
+
})
|
|
621
|
+
inner = json.loads(create_result_raw[0].text)
|
|
622
|
+
result = {
|
|
623
|
+
"status": "ok",
|
|
624
|
+
"skipped_count": skipped,
|
|
625
|
+
"created_count": len(net_new),
|
|
626
|
+
"create_result": inner,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
elif name == "attach_gherkin_to_issue":
|
|
630
|
+
result = await asyncio.to_thread(
|
|
631
|
+
_attach_gherkin,
|
|
632
|
+
cloud_id,
|
|
633
|
+
arguments["issue_key"],
|
|
634
|
+
arguments["gherkin_content"],
|
|
635
|
+
arguments.get("issue_summary", ""),
|
|
636
|
+
)
|
|
637
|
+
|
|
431
638
|
else:
|
|
432
639
|
result = {"error": f"Unknown tool: {name}"}
|
|
433
640
|
|
|
@@ -465,4 +672,4 @@ def main():
|
|
|
465
672
|
|
|
466
673
|
|
|
467
674
|
if __name__ == "__main__":
|
|
468
|
-
main()
|
|
675
|
+
main()
|
|
@@ -8,6 +8,7 @@ Public API:
|
|
|
8
8
|
create_test_case(cloud_id, project_key, title, steps, priority, labels, epic_link) -> dict
|
|
9
9
|
link_test_cases_to_issue(cloud_id, issue_key, tc_keys) -> dict
|
|
10
10
|
get_linked_test_cases(cloud_id, issue_key) -> dict
|
|
11
|
+
attach_gherkin_to_issue(cloud_id, issue_key, gherkin_content, file_name) -> dict
|
|
11
12
|
|
|
12
13
|
Jira REST v3 base: https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3
|
|
13
14
|
"""
|
|
@@ -280,6 +281,41 @@ def get_linked_test_cases(cloud_id: str, issue_key: str) -> dict:
|
|
|
280
281
|
}
|
|
281
282
|
|
|
282
283
|
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# attach_gherkin_to_issue
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def attach_gherkin_to_issue(cloud_id: str, issue_key: str, gherkin_content: str, file_name: str = "feature.feature") -> dict:
|
|
289
|
+
"""Attach a .feature file to a Jira issue.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
cloud_id: Jira cloud ID
|
|
293
|
+
issue_key: Issue key (e.g. PROJ-123)
|
|
294
|
+
gherkin_content: Full Gherkin feature file content
|
|
295
|
+
file_name: Name of the file to attach (default: feature.feature)
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
dict with attachment metadata
|
|
299
|
+
"""
|
|
300
|
+
headers = get_auth_headers()
|
|
301
|
+
|
|
302
|
+
# Upload attachment
|
|
303
|
+
url = f"{_base(cloud_id)}/issue/{issue_key}/attachments"
|
|
304
|
+
files = {"file": (file_name, gherkin_content, "text/plain")}
|
|
305
|
+
|
|
306
|
+
resp = requests.post(url, headers=headers, files=files, timeout=30)
|
|
307
|
+
resp.raise_for_status()
|
|
308
|
+
data = resp.json()
|
|
309
|
+
|
|
310
|
+
attachment = (data.get("results") or [{}])[0] if data.get("results") else {}
|
|
311
|
+
return {
|
|
312
|
+
"issue_key": issue_key,
|
|
313
|
+
"file_name": file_name,
|
|
314
|
+
"attachment_id": attachment.get("id", ""),
|
|
315
|
+
"success": bool(attachment.get("id")),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
283
319
|
# ---------------------------------------------------------------------------
|
|
284
320
|
# Helpers
|
|
285
321
|
# ---------------------------------------------------------------------------
|