@qa-gentic/stlc-agents 1.0.5 → 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.
- package/README.md +175 -34
- package/bin/postinstall.js +100 -44
- package/bin/qa-stlc.js +15 -8
- package/package.json +2 -2
- package/skills/{qa-stlc/AGENT-BEHAVIOR.md → AGENT-BEHAVIOR.md} +7 -6
- package/skills/{qa-stlc/deduplication-protocol.md → deduplication-protocol/SKILL.md} +16 -21
- package/skills/generate-gherkin/SKILL.md +287 -0
- package/skills/generate-gherkin/references/step-by-step.md +267 -0
- package/skills/{qa-stlc/generate-playwright-code.md → generate-playwright-code/SKILL.md} +13 -23
- package/skills/{qa-stlc/generate-test-cases.md → generate-test-cases/SKILL.md} +16 -2
- package/skills/qa-jira-manager/SKILL.md +287 -0
- package/skills/{qa-stlc/write-helix-files.md → write-helix-files/SKILL.md} +11 -17
- package/src/{boilerplate-bundle.js → cli/boilerplate-bundle.js} +8 -8
- package/src/cli/cmd-init.js +145 -0
- package/src/{cmd-mcp-config.js → cli/cmd-mcp-config.js} +72 -9
- package/src/cli/cmd-skills.js +209 -0
- package/src/{cmd-verify.js → cli/cmd-verify.js} +35 -3
- package/src/cli/prompt-integration.js +87 -0
- package/src/stlc_agents/agent_helix_writer/tools/boilerplate.py +8 -8
- package/src/stlc_agents/agent_jira_manager/__init__.py +0 -0
- package/src/stlc_agents/agent_jira_manager/server.py +500 -0
- package/src/stlc_agents/agent_jira_manager/tools/__init__.py +0 -0
- package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +467 -0
- package/src/stlc_agents/shared_jira/__init__.py +0 -0
- package/src/stlc_agents/shared_jira/auth.py +270 -0
- package/skills/qa-stlc/generate-gherkin.md +0 -550
- package/src/cmd-init.js +0 -92
- package/src/cmd-skills.js +0 -124
- /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
|