@qa-gentic/stlc-agents 1.0.4 → 1.0.6

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.
Files changed (29) hide show
  1. package/README.md +175 -34
  2. package/bin/postinstall.js +100 -44
  3. package/bin/qa-stlc.js +26 -6
  4. package/package.json +2 -2
  5. package/skills/{qa-stlc/AGENT-BEHAVIOR.md → AGENT-BEHAVIOR.md} +7 -6
  6. package/skills/{qa-stlc/deduplication-protocol.md → deduplication-protocol/SKILL.md} +16 -21
  7. package/skills/generate-gherkin/SKILL.md +287 -0
  8. package/skills/generate-gherkin/references/step-by-step.md +267 -0
  9. package/skills/{qa-stlc/generate-playwright-code.md → generate-playwright-code/SKILL.md} +13 -23
  10. package/skills/{qa-stlc/generate-test-cases.md → generate-test-cases/SKILL.md} +16 -2
  11. package/skills/qa-jira-manager/SKILL.md +287 -0
  12. package/skills/{qa-stlc/write-helix-files.md → write-helix-files/SKILL.md} +11 -17
  13. package/src/{boilerplate-bundle.js → cli/boilerplate-bundle.js} +8 -8
  14. package/src/cli/cmd-init.js +145 -0
  15. package/src/{cmd-mcp-config.js → cli/cmd-mcp-config.js} +72 -9
  16. package/src/cli/cmd-skills.js +209 -0
  17. package/src/{cmd-verify.js → cli/cmd-verify.js} +35 -3
  18. package/src/cli/prompt-integration.js +87 -0
  19. package/src/stlc_agents/agent_helix_writer/tools/boilerplate.py +8 -8
  20. package/src/stlc_agents/agent_jira_manager/__init__.py +0 -0
  21. package/src/stlc_agents/agent_jira_manager/server.py +500 -0
  22. package/src/stlc_agents/agent_jira_manager/tools/__init__.py +0 -0
  23. package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +467 -0
  24. package/src/stlc_agents/shared_jira/__init__.py +0 -0
  25. package/src/stlc_agents/shared_jira/auth.py +270 -0
  26. package/skills/qa-stlc/generate-gherkin.md +0 -550
  27. package/src/cmd-init.js +0 -92
  28. package/src/cmd-skills.js +0 -124
  29. /package/src/{cmd-scaffold.js → cli/cmd-scaffold.js} +0 -0
@@ -0,0 +1,467 @@
1
+ """
2
+ jira_workitem.py — Jira Cloud REST API v3 calls for the QA Jira Manager.
3
+
4
+ All functions use get_auth_headers() from shared_jira/auth.py — no credentials here.
5
+
6
+ Public API:
7
+ fetch_work_item(cloud_id, issue_key) -> dict
8
+ fetch_epic_hierarchy(cloud_id, epic_key) -> dict
9
+ create_test_case(cloud_id, project_key, title, steps, priority, labels, epic_link) -> dict
10
+ link_test_cases_to_issue(cloud_id, issue_key, tc_keys) -> dict
11
+ get_linked_test_cases(cloud_id, issue_key) -> dict
12
+
13
+ Jira REST v3 base: https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Dict, List, Optional
19
+
20
+ import requests
21
+
22
+ from stlc_agents.shared_jira.auth import get_auth_headers, get_cloud_id
23
+
24
+ _API_VERSION = "3"
25
+
26
+
27
+ def _base(cloud_id: str) -> str:
28
+ return f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/{_API_VERSION}"
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # fetch_work_item
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def fetch_work_item(cloud_id: str, issue_key: str) -> dict:
36
+ """Fetch a Jira issue with all relevant fields.
37
+
38
+ Returns a normalised work_item dict plus:
39
+ - parent (Epic or parent Story)
40
+ - existing_test_cases_count (issues linked via 'is tested by')
41
+ - coverage_hints (derived from description + acceptance criteria)
42
+ """
43
+ headers = get_auth_headers()
44
+ url = f"{_base(cloud_id)}/issue/{issue_key}"
45
+ resp = requests.get(
46
+ url,
47
+ headers=headers,
48
+ params={
49
+ "expand": "renderedFields,names,transitions",
50
+ "fields": (
51
+ "summary,description,issuetype,status,priority,assignee,"
52
+ "labels,customfield_10014,customfield_10016,customfield_10006,"
53
+ "customfield_10010,customfield_10008,customfield_10028,"
54
+ "parent,subtasks,issuelinks,project,fixVersions,components,"
55
+ "customfield_10000,customfield_10001,customfield_10003,"
56
+ "customfield_10004,customfield_10005,comment"
57
+ ),
58
+ },
59
+ timeout=30,
60
+ )
61
+ resp.raise_for_status()
62
+ data = resp.json()
63
+ fields = data.get("fields", {})
64
+
65
+ issue_type = (fields.get("issuetype") or {}).get("name", "")
66
+
67
+ # Epic guard
68
+ if issue_type == "Epic":
69
+ return {
70
+ "error": "epic_use_hierarchy",
71
+ "issue_type": issue_type,
72
+ "issue_key": issue_key,
73
+ "message": (
74
+ f"{issue_key} is an Epic. Use fetch_epic_hierarchy to walk its "
75
+ "child Stories/Tasks/Bugs, then call create_and_link_test_cases "
76
+ "on each individual child issue."
77
+ ),
78
+ }
79
+
80
+ description_text = _render_doc(fields.get("description") or {})
81
+ acceptance_criteria = _render_doc(
82
+ fields.get("customfield_10028") or {} # common Acceptance Criteria custom field
83
+ )
84
+
85
+ wi = {
86
+ "key": data["key"],
87
+ "id": data["id"],
88
+ "type": issue_type,
89
+ "summary": fields.get("summary", ""),
90
+ "description": description_text,
91
+ "acceptance_criteria": acceptance_criteria,
92
+ "status": (fields.get("status") or {}).get("name", ""),
93
+ "priority": (fields.get("priority") or {}).get("name", ""),
94
+ "assignee": (fields.get("assignee") or {}).get("displayName", ""),
95
+ "labels": fields.get("labels", []),
96
+ "story_points": fields.get("customfield_10016") or fields.get("customfield_10028"),
97
+ "project_key": (fields.get("project") or {}).get("key", ""),
98
+ "project_name": (fields.get("project") or {}).get("name", ""),
99
+ "fix_versions": [v.get("name", "") for v in (fields.get("fixVersions") or [])],
100
+ "components": [c.get("name", "") for c in (fields.get("components") or [])],
101
+ "epic_key": fields.get("customfield_10014", ""), # Epic Link
102
+ }
103
+
104
+ # Parent issue (Story → Epic hierarchy)
105
+ parent_issue = None
106
+ parent_raw = fields.get("parent")
107
+ if parent_raw:
108
+ parent_issue = {
109
+ "key": parent_raw.get("key", ""),
110
+ "id": parent_raw.get("id", ""),
111
+ "type": (parent_raw.get("fields", {}).get("issuetype") or {}).get("name", ""),
112
+ "summary": parent_raw.get("fields", {}).get("summary", ""),
113
+ }
114
+
115
+ # Existing "is tested by" links
116
+ existing_tc_count = sum(
117
+ 1 for link in (fields.get("issuelinks") or [])
118
+ if (link.get("type") or {}).get("name", "") in ("Test", "is tested by", "tested by")
119
+ and "inwardIssue" in link
120
+ )
121
+
122
+ coverage_hints = _extract_coverage_hints(wi)
123
+
124
+ return {
125
+ "work_item": wi,
126
+ "parent_issue": parent_issue,
127
+ "existing_test_cases_count": existing_tc_count,
128
+ "coverage_hints": coverage_hints,
129
+ }
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # fetch_epic_hierarchy
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def fetch_epic_hierarchy(cloud_id: str, epic_key: str) -> dict:
137
+ """Fetch an Epic with all child issues (Stories, Tasks, Bugs, Sub-tasks)."""
138
+ headers = get_auth_headers()
139
+
140
+ # Fetch the Epic itself
141
+ epic_resp = requests.get(
142
+ f"{_base(cloud_id)}/issue/{epic_key}",
143
+ headers=headers,
144
+ params={"fields": "summary,issuetype,status,priority,project"},
145
+ timeout=30,
146
+ )
147
+ epic_resp.raise_for_status()
148
+ epic_data = epic_resp.json()
149
+ epic_fields = epic_data.get("fields", {})
150
+
151
+ if (epic_fields.get("issuetype") or {}).get("name", "") != "Epic":
152
+ return {
153
+ "error": "not_an_epic",
154
+ "issue_key": epic_key,
155
+ "message": f"{epic_key} is not an Epic. Use fetch_work_item instead.",
156
+ }
157
+
158
+ # JQL: all issues whose parent is this epic (next-gen / team-managed projects)
159
+ # "Epic Link" field is deprecated/removed in newer Jira Cloud instances
160
+ # GET /rest/api/3/search is deprecated (410 Gone) — use POST /rest/api/3/search/jql
161
+ jql = f'parent = {epic_key}'
162
+ search_resp = requests.post(
163
+ f"{_base(cloud_id)}/search/jql",
164
+ headers=get_auth_headers(),
165
+ json={
166
+ "jql": jql,
167
+ "fields": ["summary", "issuetype", "status", "priority", "assignee", "issuelinks"],
168
+ "maxResults": 200,
169
+ },
170
+ timeout=30,
171
+ )
172
+ search_resp.raise_for_status()
173
+ issues = search_resp.json().get("issues", [])
174
+
175
+ children = [
176
+ {
177
+ "key": i["key"],
178
+ "id": i["id"],
179
+ "type": (i.get("fields", {}).get("issuetype") or {}).get("name", ""),
180
+ "summary": i.get("fields", {}).get("summary", ""),
181
+ "status": (i.get("fields", {}).get("status") or {}).get("name", ""),
182
+ "priority": (i.get("fields", {}).get("priority") or {}).get("name", ""),
183
+ "assignee": (i.get("fields", {}).get("assignee") or {}).get("displayName", ""),
184
+ }
185
+ for i in issues
186
+ ]
187
+
188
+ return {
189
+ "epic": {
190
+ "key": epic_data["key"],
191
+ "id": epic_data["id"],
192
+ "summary": epic_fields.get("summary", ""),
193
+ "project_key": (epic_fields.get("project") or {}).get("key", ""),
194
+ },
195
+ "children": children,
196
+ "child_count": len(children),
197
+ }
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # create_test_case
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def create_test_case(
205
+ cloud_id: str,
206
+ project_key: str,
207
+ summary: str,
208
+ steps: List[Dict],
209
+ priority: str = "Medium",
210
+ labels: Optional[List[str]] = None,
211
+ epic_link: Optional[str] = None,
212
+ ) -> dict:
213
+ """Create a Jira issue of type 'Test' (or 'Task' if Test type unavailable).
214
+
215
+ Steps are stored as a formatted description table since Jira core does not
216
+ have a native step field — this is compatible with both Jira Software and
217
+ Xray-free environments.
218
+
219
+ Priority values: Highest, High, Medium, Low, Lowest
220
+ """
221
+ headers = get_auth_headers()
222
+
223
+ # Build step table in Atlassian Document Format (ADF)
224
+ description_adf = _build_steps_adf(steps)
225
+
226
+ payload: dict = {
227
+ "fields": {
228
+ "project": {"key": project_key},
229
+ "summary": summary,
230
+ "issuetype": {"name": "Test"},
231
+ "priority": {"name": priority},
232
+ "description": description_adf,
233
+ }
234
+ }
235
+ if labels:
236
+ payload["fields"]["labels"] = labels
237
+ if epic_link:
238
+ payload["fields"]["customfield_10014"] = epic_link # Epic Link
239
+
240
+ resp = requests.post(
241
+ f"{_base(cloud_id)}/issue",
242
+ headers=headers,
243
+ json=payload,
244
+ timeout=30,
245
+ )
246
+
247
+ # If 'Test' issue type doesn't exist, fall back to 'Task'
248
+ if resp.status_code == 400 and "issuetype" in resp.text.lower():
249
+ payload["fields"]["issuetype"] = {"name": "Task"}
250
+ payload["fields"]["labels"] = list(set((labels or []) + ["qa-test-case"]))
251
+ resp = requests.post(
252
+ f"{_base(cloud_id)}/issue",
253
+ headers=get_auth_headers(),
254
+ json=payload,
255
+ timeout=30,
256
+ )
257
+
258
+ resp.raise_for_status()
259
+ created = resp.json()
260
+ return {
261
+ "success": True,
262
+ "issue_key": created["key"],
263
+ "issue_id": created["id"],
264
+ "summary": summary,
265
+ "url": f"https://your-domain.atlassian.net/browse/{created['key']}",
266
+ }
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # link_test_cases_to_issue
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def link_test_cases_to_issue(
274
+ cloud_id: str,
275
+ issue_key: str,
276
+ test_case_keys: List[str],
277
+ ) -> dict:
278
+ """Create 'is tested by' / 'Test' links from an issue to test case issues."""
279
+ headers = get_auth_headers()
280
+ linked = []
281
+ failed = []
282
+
283
+ for tc_key in test_case_keys:
284
+ payload = {
285
+ "type": {"name": "Test"}, # standard Jira link type; falls back gracefully
286
+ "inwardIssue": {"key": issue_key},
287
+ "outwardIssue": {"key": tc_key},
288
+ }
289
+ resp = requests.post(
290
+ f"{_base(cloud_id)}/issueLink",
291
+ headers=get_auth_headers(),
292
+ json=payload,
293
+ timeout=30,
294
+ )
295
+ if resp.status_code == 404:
296
+ # Try generic "Relates" link type
297
+ payload["type"] = {"name": "Relates"}
298
+ resp = requests.post(
299
+ f"{_base(cloud_id)}/issueLink",
300
+ headers=get_auth_headers(),
301
+ json=payload,
302
+ timeout=30,
303
+ )
304
+ if resp.ok or resp.status_code == 201:
305
+ linked.append(tc_key)
306
+ else:
307
+ failed.append({"key": tc_key, "status": resp.status_code, "error": resp.text[:200]})
308
+
309
+ return {
310
+ "success": len(failed) == 0,
311
+ "issue_key": issue_key,
312
+ "linked_count": len(linked),
313
+ "linked_keys": linked,
314
+ "failed": failed,
315
+ }
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # get_linked_test_cases
320
+ # ---------------------------------------------------------------------------
321
+
322
+ def get_linked_test_cases(cloud_id: str, issue_key: str) -> dict:
323
+ """Return all issues linked to this issue via a Test/tested-by link type."""
324
+ headers = get_auth_headers()
325
+ resp = requests.get(
326
+ f"{_base(cloud_id)}/issue/{issue_key}",
327
+ headers=headers,
328
+ params={"fields": "issuelinks,summary"},
329
+ timeout=30,
330
+ )
331
+ resp.raise_for_status()
332
+ data = resp.json()
333
+
334
+ test_link_names = {"test", "is tested by", "tested by", "relates to"}
335
+ test_cases = []
336
+ for link in (data.get("fields", {}).get("issuelinks") or []):
337
+ link_type = (link.get("type") or {}).get("name", "").lower()
338
+ if link_type in test_link_names:
339
+ related = link.get("inwardIssue") or link.get("outwardIssue")
340
+ if related:
341
+ rf = related.get("fields", {})
342
+ test_cases.append({
343
+ "key": related.get("key", ""),
344
+ "id": related.get("id", ""),
345
+ "summary": rf.get("summary", ""),
346
+ "status": (rf.get("status") or {}).get("name", ""),
347
+ "priority": (rf.get("priority") or {}).get("name", ""),
348
+ "type": (rf.get("issuetype") or {}).get("name", ""),
349
+ })
350
+
351
+ return {
352
+ "issue_key": issue_key,
353
+ "test_cases": test_cases,
354
+ "count": len(test_cases),
355
+ }
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Helpers
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def _render_doc(doc: dict) -> str:
363
+ """Recursively extract plain text from an Atlassian Document Format node."""
364
+ if not doc or not isinstance(doc, dict):
365
+ return ""
366
+ node_type = doc.get("type", "")
367
+ text = ""
368
+ if node_type == "text":
369
+ text = doc.get("text", "")
370
+ for child in doc.get("content", []):
371
+ child_text = _render_doc(child)
372
+ if node_type in ("paragraph", "listItem", "tableRow"):
373
+ child_text += "\n"
374
+ text += child_text
375
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
376
+
377
+
378
+ def _build_steps_adf(steps: List[Dict]) -> dict:
379
+ """Build an ADF document representing test steps as a table."""
380
+ if not steps:
381
+ return {
382
+ "type": "doc",
383
+ "version": 1,
384
+ "content": [
385
+ {
386
+ "type": "paragraph",
387
+ "content": [{"type": "text", "text": "No steps defined."}],
388
+ }
389
+ ],
390
+ }
391
+
392
+ header_row = {
393
+ "type": "tableRow",
394
+ "content": [
395
+ _th("Step #"),
396
+ _th("Action"),
397
+ _th("Expected Result"),
398
+ ],
399
+ }
400
+ data_rows = [
401
+ {
402
+ "type": "tableRow",
403
+ "content": [
404
+ _td(str(i + 1)),
405
+ _td(s.get("action", "")),
406
+ _td(s.get("expected_result", "")),
407
+ ],
408
+ }
409
+ for i, s in enumerate(steps)
410
+ ]
411
+
412
+ return {
413
+ "type": "doc",
414
+ "version": 1,
415
+ "content": [
416
+ {
417
+ "type": "table",
418
+ "attrs": {"isNumberColumnEnabled": False, "layout": "default"},
419
+ "content": [header_row] + data_rows,
420
+ }
421
+ ],
422
+ }
423
+
424
+
425
+ def _th(text: str) -> dict:
426
+ return {
427
+ "type": "tableHeader",
428
+ "attrs": {},
429
+ "content": [
430
+ {
431
+ "type": "paragraph",
432
+ "content": [{"type": "text", "text": text, "marks": [{"type": "strong"}]}],
433
+ }
434
+ ],
435
+ }
436
+
437
+
438
+ def _td(text: str) -> dict:
439
+ return {
440
+ "type": "tableCell",
441
+ "attrs": {},
442
+ "content": [
443
+ {"type": "paragraph", "content": [{"type": "text", "text": text}]}
444
+ ],
445
+ }
446
+
447
+
448
+ def _extract_coverage_hints(wi: dict) -> list:
449
+ text = " ".join([
450
+ wi.get("description", ""),
451
+ wi.get("acceptance_criteria", ""),
452
+ wi.get("summary", ""),
453
+ ]).lower()
454
+ checks = [
455
+ (["toast", "notification", "success message", "confirmation"], "toast_notifications"),
456
+ (["mb", "kb", "size limit", "file size", "maximum size"], "file_size_boundary"),
457
+ (["display", "shows", "reflects", "after upload", "after save", "updated"], "post_action_state"),
458
+ (["mobile", "webview", "ios", "android"], "platform_specific"),
459
+ (["initials", "first name", "last name", "derived", "generated"], "computed_values"),
460
+ (["refresh", "reload", "re-login", "persist", "retained"], "data_persistence"),
461
+ (["tab", "keyboard", "accessible", "aria", "wcag", "a11y"], "accessibility"),
462
+ (["crop", "resize", "zoom", "drag", "rotate"], "image_manipulation"),
463
+ (["format", "png", "jpg", "jpeg", "webp", "svg"], "file_formats"),
464
+ (["cancel", "close", "discard", "dismiss"], "cancel_flows"),
465
+ (["required", "invalid", "error", "validation", "must be"], "validation_errors"),
466
+ ]
467
+ return [hint for keywords, hint in checks if any(kw in text for kw in keywords)]
File without changes