@qa-gentic/agents 1.1.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 +203 -0
- package/bin/postinstall.js +75 -0
- package/bin/qa-stlc.js +76 -0
- package/package.json +48 -0
- package/skills/qa-stlc/AGENT-BEHAVIOR.md +373 -0
- package/skills/qa-stlc/deduplication-protocol.md +303 -0
- package/skills/qa-stlc/generate-gherkin.md +550 -0
- package/skills/qa-stlc/generate-playwright-code.md +439 -0
- package/skills/qa-stlc/generate-test-cases.md +176 -0
- package/skills/qa-stlc/write-helix-files.md +349 -0
- package/src/cmd-init.js +84 -0
- package/src/cmd-mcp-config.js +177 -0
- package/src/cmd-skills.js +124 -0
- package/src/cmd-verify.js +129 -0
- package/src/qa_stlc_agents/__init__.py +0 -0
- package/src/qa_stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/server.py +502 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/tools/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_gherkin_generator/tools/ado_gherkin.py +854 -0
- package/src/qa_stlc_agents/agent_helix_writer/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/server.py +529 -0
- package/src/qa_stlc_agents/agent_helix_writer/tools/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_helix_writer/tools/helix_write.py +622 -0
- package/src/qa_stlc_agents/agent_playwright_generator/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/server.py +2771 -0
- package/src/qa_stlc_agents/agent_playwright_generator/tools/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_playwright_generator/tools/ado_attach.py +62 -0
- package/src/qa_stlc_agents/agent_test_case_manager/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/server.py +483 -0
- package/src/qa_stlc_agents/agent_test_case_manager/tools/__init__.py +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/agent_test_case_manager/tools/ado_workitem.py +302 -0
- package/src/qa_stlc_agents/shared/__init__.py +0 -0
- package/src/qa_stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
- package/src/qa_stlc_agents/shared/auth.py +119 -0
|
File without changes
|
package/src/qa_stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file
|
package/src/qa_stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc
ADDED
|
Binary file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ado_attach.py — Attach generated Playwright files to an ADO work item.
|
|
3
|
+
|
|
4
|
+
Used by the Playwright Code Generator agent to persist generated files
|
|
5
|
+
back to ADO as attachments on the Feature or PBI work item.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from qa_stlc_agents.shared.auth import get_auth_headers
|
|
13
|
+
|
|
14
|
+
_API = "7.1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def attach_file_to_work_item(
|
|
18
|
+
org_url: str,
|
|
19
|
+
project: str,
|
|
20
|
+
work_item_id: int,
|
|
21
|
+
file_name: str,
|
|
22
|
+
file_content: str,
|
|
23
|
+
comment: str = "Generated by QA Playwright Code Generator",
|
|
24
|
+
) -> dict:
|
|
25
|
+
"""Upload a text file and attach it to any ADO work item."""
|
|
26
|
+
org_url = org_url.rstrip("/")
|
|
27
|
+
|
|
28
|
+
safe_name = re.sub(r"[^a-zA-Z0-9._\-]", "_", file_name)
|
|
29
|
+
|
|
30
|
+
upload = requests.post(
|
|
31
|
+
f"{org_url}/{project}/_apis/wit/attachments",
|
|
32
|
+
headers=get_auth_headers("application/octet-stream"),
|
|
33
|
+
params={"fileName": safe_name, "api-version": _API},
|
|
34
|
+
data=file_content.encode("utf-8"),
|
|
35
|
+
timeout=30,
|
|
36
|
+
)
|
|
37
|
+
upload.raise_for_status()
|
|
38
|
+
attachment_url = upload.json()["url"]
|
|
39
|
+
|
|
40
|
+
link = requests.patch(
|
|
41
|
+
f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
|
|
42
|
+
headers=get_auth_headers("application/json-patch+json"),
|
|
43
|
+
params={"api-version": _API},
|
|
44
|
+
json=[{
|
|
45
|
+
"op": "add",
|
|
46
|
+
"path": "/relations/-",
|
|
47
|
+
"value": {
|
|
48
|
+
"rel": "AttachedFile",
|
|
49
|
+
"url": attachment_url,
|
|
50
|
+
"attributes": {"comment": comment},
|
|
51
|
+
},
|
|
52
|
+
}],
|
|
53
|
+
timeout=30,
|
|
54
|
+
)
|
|
55
|
+
link.raise_for_status()
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"success": True,
|
|
59
|
+
"work_item_id": work_item_id,
|
|
60
|
+
"file_name": safe_name,
|
|
61
|
+
"attachment_url": attachment_url,
|
|
62
|
+
}
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent 1: QA Test Case Manager — MCP Server
|
|
3
|
+
|
|
4
|
+
Tools:
|
|
5
|
+
fetch_work_item — Fetch a PBI or Bug with acceptance criteria
|
|
6
|
+
create_and_link_test_cases — Create test cases in ADO and link to work item
|
|
7
|
+
get_linked_test_cases — Return existing test cases linked to a work item
|
|
8
|
+
|
|
9
|
+
Authentication:
|
|
10
|
+
Silent from ~/.msal-cache/msal-cache.json (shared with microsoft/azure-devops-mcp).
|
|
11
|
+
If no cached token exists, the browser opens once for interactive login.
|
|
12
|
+
No credentials appear in the UI or in configuration files.
|
|
13
|
+
|
|
14
|
+
Skills: see skills/generate-test-cases.md
|
|
15
|
+
"""
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
from mcp.server import Server
|
|
23
|
+
from mcp.server.stdio import stdio_server
|
|
24
|
+
from mcp import types
|
|
25
|
+
|
|
26
|
+
from qa_stlc_agents.shared.auth import get_auth_headers, get_signed_in_user
|
|
27
|
+
from qa_stlc_agents.agent_test_case_manager.tools.ado_workitem import (
|
|
28
|
+
fetch_work_item as _fetch_work_item,
|
|
29
|
+
create_test_case as _create_test_case,
|
|
30
|
+
link_test_cases_to_work_item as _link_test_cases,
|
|
31
|
+
get_linked_test_cases as _get_linked_test_cases,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
load_dotenv()
|
|
35
|
+
|
|
36
|
+
app = Server("qa-test-case-manager")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Pre-output validation helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _validate_test_case_inputs(test_cases: list[dict]) -> dict:
|
|
44
|
+
"""Validate test case payloads before sending to ADO.
|
|
45
|
+
|
|
46
|
+
Checks:
|
|
47
|
+
1. Every test case has a non-empty title.
|
|
48
|
+
2. Every test case has at least one step.
|
|
49
|
+
3. Every step has both action and expected_result fields.
|
|
50
|
+
4. No duplicate titles within the batch.
|
|
51
|
+
5. Priority is in range 1-4 when specified.
|
|
52
|
+
|
|
53
|
+
Returns { valid: bool, errors: list[str], warnings: list[str] }.
|
|
54
|
+
"""
|
|
55
|
+
errors: list[str] = []
|
|
56
|
+
warnings: list[str] = []
|
|
57
|
+
seen_titles: set[str] = set()
|
|
58
|
+
|
|
59
|
+
for i, tc in enumerate(test_cases, start=1):
|
|
60
|
+
title = (tc.get("title") or "").strip()
|
|
61
|
+
if not title:
|
|
62
|
+
errors.append(f"Test case #{i}: title is empty or missing.")
|
|
63
|
+
else:
|
|
64
|
+
normalised = title.lower()
|
|
65
|
+
if normalised in seen_titles:
|
|
66
|
+
errors.append(f"Test case #{i}: duplicate title '{title}'.")
|
|
67
|
+
seen_titles.add(normalised)
|
|
68
|
+
|
|
69
|
+
steps = tc.get("steps", [])
|
|
70
|
+
if not steps:
|
|
71
|
+
errors.append(f"Test case #{i} ('{title}'): no steps defined.")
|
|
72
|
+
for j, step in enumerate(steps, start=1):
|
|
73
|
+
action = (step.get("action") or "").strip()
|
|
74
|
+
expected = (step.get("expected_result") or "").strip()
|
|
75
|
+
if not action:
|
|
76
|
+
errors.append(
|
|
77
|
+
f"Test case #{i} ('{title}'), step #{j}: 'action' is empty."
|
|
78
|
+
)
|
|
79
|
+
if not expected:
|
|
80
|
+
errors.append(
|
|
81
|
+
f"Test case #{i} ('{title}'), step #{j}: 'expected_result' is empty."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
priority = tc.get("priority")
|
|
85
|
+
if priority is not None and priority not in (1, 2, 3, 4):
|
|
86
|
+
errors.append(
|
|
87
|
+
f"Test case #{i} ('{title}'): priority {priority} is out of range (1-4)."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if len(test_cases) > 25:
|
|
91
|
+
warnings.append(
|
|
92
|
+
f"Large batch: {len(test_cases)} test cases. Consider splitting "
|
|
93
|
+
f"into smaller batches for easier review."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_fetch_response(result: dict) -> dict:
|
|
100
|
+
"""Validate the response from fetch_work_item before returning to user.
|
|
101
|
+
|
|
102
|
+
Checks:
|
|
103
|
+
1. Work item has a title.
|
|
104
|
+
2. Acceptance criteria is not empty (warns if missing).
|
|
105
|
+
3. Work item type is recognised.
|
|
106
|
+
|
|
107
|
+
Returns { valid: bool, errors: list[str], warnings: list[str] }.
|
|
108
|
+
"""
|
|
109
|
+
errors: list[str] = []
|
|
110
|
+
warnings: list[str] = []
|
|
111
|
+
|
|
112
|
+
if "error" in result:
|
|
113
|
+
return {"valid": True, "errors": [], "warnings": []}
|
|
114
|
+
|
|
115
|
+
wi = result.get("work_item", {})
|
|
116
|
+
if not wi.get("title"):
|
|
117
|
+
errors.append("Work item has no title — ADO data may be incomplete.")
|
|
118
|
+
|
|
119
|
+
if not wi.get("acceptance_criteria"):
|
|
120
|
+
warnings.append(
|
|
121
|
+
"Work item has no acceptance criteria. Test case generation may "
|
|
122
|
+
"produce low-quality results — ask the user for acceptance criteria."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
wi_type = wi.get("type", "")
|
|
126
|
+
known_types = {"Product Backlog Item", "Bug", "Feature", "User Story", "Task", "Epic"}
|
|
127
|
+
if wi_type and wi_type not in known_types:
|
|
128
|
+
warnings.append(f"Unrecognised work item type: '{wi_type}'.")
|
|
129
|
+
|
|
130
|
+
if result.get("existing_test_cases_count", 0) > 0:
|
|
131
|
+
warnings.append(
|
|
132
|
+
f"Work item already has {result['existing_test_cases_count']} linked test case(s). "
|
|
133
|
+
f"Use get_linked_test_cases to check for duplicates before creating new ones."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_linked_test_cases_response(result: dict) -> dict:
|
|
140
|
+
"""Validate the response from get_linked_test_cases before returning to user.
|
|
141
|
+
|
|
142
|
+
Checks:
|
|
143
|
+
1. Response is not an error.
|
|
144
|
+
2. Test case list is present.
|
|
145
|
+
3. Each test case has an ID and title.
|
|
146
|
+
4. Warns about test cases with no steps.
|
|
147
|
+
|
|
148
|
+
Returns { valid: bool, errors: list[str], warnings: list[str] }.
|
|
149
|
+
"""
|
|
150
|
+
errors: list[str] = []
|
|
151
|
+
warnings: list[str] = []
|
|
152
|
+
|
|
153
|
+
if "error" in result:
|
|
154
|
+
return {"valid": True, "errors": [], "warnings": []}
|
|
155
|
+
|
|
156
|
+
test_cases = result.get("test_cases", [])
|
|
157
|
+
if not test_cases:
|
|
158
|
+
warnings.append(
|
|
159
|
+
"No linked test cases found. This work item has no TestedBy relations."
|
|
160
|
+
)
|
|
161
|
+
return {"valid": True, "errors": [], "warnings": warnings}
|
|
162
|
+
|
|
163
|
+
for i, tc in enumerate(test_cases, start=1):
|
|
164
|
+
if not tc.get("id"):
|
|
165
|
+
errors.append(f"Test case #{i}: missing ID.")
|
|
166
|
+
if not tc.get("title"):
|
|
167
|
+
warnings.append(f"Test case #{i} (ID={tc.get('id', '?')}): missing title.")
|
|
168
|
+
steps = tc.get("steps", [])
|
|
169
|
+
if not steps:
|
|
170
|
+
warnings.append(
|
|
171
|
+
f"Test case '{tc.get('title', '?')}' (ID={tc.get('id', '?')}): has no steps defined."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Tool definitions
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
@app.list_tools()
|
|
182
|
+
async def list_tools() -> list[types.Tool]:
|
|
183
|
+
return [
|
|
184
|
+
types.Tool(
|
|
185
|
+
name="fetch_work_item",
|
|
186
|
+
description=(
|
|
187
|
+
"Fetch a Product Backlog Item or Bug from Azure DevOps. "
|
|
188
|
+
"Returns work item fields (title, acceptance criteria, description, "
|
|
189
|
+
"story points, priority, area path, iteration path), parent feature, "
|
|
190
|
+
"count of existing linked test cases, and coverage hints derived from "
|
|
191
|
+
"the acceptance criteria text."
|
|
192
|
+
),
|
|
193
|
+
inputSchema={
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"work_item_id": {"type": "integer", "description": "ADO work item ID (PBI or Bug)"},
|
|
197
|
+
"organization_url": {"type": "string", "description": "e.g. https://dev.azure.com/myorg"},
|
|
198
|
+
"project_name": {"type": "string", "description": "ADO project name"},
|
|
199
|
+
},
|
|
200
|
+
"required": ["work_item_id", "organization_url", "project_name"],
|
|
201
|
+
},
|
|
202
|
+
),
|
|
203
|
+
types.Tool(
|
|
204
|
+
name="create_and_link_test_cases",
|
|
205
|
+
description=(
|
|
206
|
+
"Create one or more test cases in Azure DevOps and link them to a work item "
|
|
207
|
+
"via a TestedBy-Forward relation. Area path and iteration path are automatically "
|
|
208
|
+
"inherited from the parent work item when not specified. "
|
|
209
|
+
"Priority: 1=Critical, 2=High (default), 3=Medium, 4=Low.\n\n"
|
|
210
|
+
"Feature confirmation gate:\n"
|
|
211
|
+
" When the target work item is a Feature, the first call returns "
|
|
212
|
+
"confirmation_required=true and does NOT create any test cases. "
|
|
213
|
+
"Surface the message to the user and wait for their reply. "
|
|
214
|
+
"Once the user confirms, retry the EXACT same call with confirmed=true added — "
|
|
215
|
+
"do NOT change work_item_id, work_item_type, or any other parameter. "
|
|
216
|
+
"Reply 'no' / 'cancel' → do not retry; abort the operation entirely."
|
|
217
|
+
),
|
|
218
|
+
inputSchema={
|
|
219
|
+
"type": "object",
|
|
220
|
+
"properties": {
|
|
221
|
+
"work_item_id": {"type": "integer", "description": "Work item to link test cases to"},
|
|
222
|
+
"organization_url": {"type": "string"},
|
|
223
|
+
"project_name": {"type": "string"},
|
|
224
|
+
"test_cases": {
|
|
225
|
+
"type": "array",
|
|
226
|
+
"description": "Test cases to create",
|
|
227
|
+
"items": {
|
|
228
|
+
"type": "object",
|
|
229
|
+
"properties": {
|
|
230
|
+
"title": {"type": "string"},
|
|
231
|
+
"priority": {"type": "integer", "description": "1-4, default 2"},
|
|
232
|
+
"steps": {
|
|
233
|
+
"type": "array",
|
|
234
|
+
"items": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"properties": {
|
|
237
|
+
"action": {"type": "string"},
|
|
238
|
+
"expected_result": {"type": "string"},
|
|
239
|
+
},
|
|
240
|
+
"required": ["action", "expected_result"],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
"required": ["title", "steps"],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
"confirmed": {
|
|
248
|
+
"type": "boolean",
|
|
249
|
+
"description": (
|
|
250
|
+
"Set to true only when retrying after the user has explicitly confirmed "
|
|
251
|
+
"a Feature-scoped test case upload in response to a confirmation_required "
|
|
252
|
+
"response. Omit (or false) on the first call — the gate will fire "
|
|
253
|
+
"automatically if the work item is a Feature."
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
"required": ["work_item_id", "organization_url", "project_name", "test_cases"],
|
|
258
|
+
},
|
|
259
|
+
),
|
|
260
|
+
types.Tool(
|
|
261
|
+
name="get_linked_test_cases",
|
|
262
|
+
description=(
|
|
263
|
+
"Return all test cases already linked to a work item via TestedBy. "
|
|
264
|
+
"Use this before creating new test cases to avoid duplicates."
|
|
265
|
+
),
|
|
266
|
+
inputSchema={
|
|
267
|
+
"type": "object",
|
|
268
|
+
"properties": {
|
|
269
|
+
"work_item_id": {"type": "integer"},
|
|
270
|
+
"organization_url": {"type": "string"},
|
|
271
|
+
"project_name": {"type": "string"},
|
|
272
|
+
},
|
|
273
|
+
"required": ["work_item_id", "organization_url", "project_name"],
|
|
274
|
+
},
|
|
275
|
+
),
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Tool dispatcher
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
@app.call_tool()
|
|
284
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
285
|
+
try:
|
|
286
|
+
if name == "fetch_work_item":
|
|
287
|
+
result = await asyncio.to_thread(
|
|
288
|
+
_fetch_work_item,
|
|
289
|
+
arguments["organization_url"],
|
|
290
|
+
arguments["project_name"],
|
|
291
|
+
arguments["work_item_id"],
|
|
292
|
+
)
|
|
293
|
+
# ── Pre-output validation ─────────────────────────────────────
|
|
294
|
+
validation = _validate_fetch_response(result)
|
|
295
|
+
result["_validation"] = validation
|
|
296
|
+
|
|
297
|
+
elif name == "create_and_link_test_cases":
|
|
298
|
+
org = arguments["organization_url"]
|
|
299
|
+
project = arguments["project_name"]
|
|
300
|
+
wi_id = arguments["work_item_id"]
|
|
301
|
+
test_cases = arguments["test_cases"]
|
|
302
|
+
|
|
303
|
+
# ── Input validation — reject before hitting ADO ──────────────
|
|
304
|
+
input_validation = _validate_test_case_inputs(test_cases)
|
|
305
|
+
if not input_validation["valid"]:
|
|
306
|
+
result = {
|
|
307
|
+
"error": "input_validation_failed",
|
|
308
|
+
"validation": input_validation,
|
|
309
|
+
"message": (
|
|
310
|
+
"Test case inputs failed validation. Fix the errors "
|
|
311
|
+
"below and retry. No test cases were created in ADO."
|
|
312
|
+
),
|
|
313
|
+
}
|
|
314
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
|
|
315
|
+
|
|
316
|
+
# ── Epic guard — always check work item type before creating TCs ─
|
|
317
|
+
try:
|
|
318
|
+
wi_check = await asyncio.to_thread(_fetch_work_item, org, project, wi_id)
|
|
319
|
+
if wi_check.get("error") == "epic_not_supported":
|
|
320
|
+
result = {
|
|
321
|
+
"error": "epic_not_supported",
|
|
322
|
+
"work_item_id": wi_id,
|
|
323
|
+
"message": (
|
|
324
|
+
f"Work item {wi_id} is an Epic. "
|
|
325
|
+
"Manual test cases cannot be created or linked to an Epic. "
|
|
326
|
+
"ADO test cases are always scoped to PBIs, Bugs, or Features — "
|
|
327
|
+
"never to Epics. Please use fetch_epic_hierarchy (in the "
|
|
328
|
+
"qa-gherkin-generator agent) to enumerate the child Features and "
|
|
329
|
+
"PBIs/Bugs, then call create_and_link_test_cases on each "
|
|
330
|
+
"individual Feature or PBI/Bug ID instead."
|
|
331
|
+
),
|
|
332
|
+
}
|
|
333
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
|
|
334
|
+
except Exception:
|
|
335
|
+
pass # if the peek fails, continue and let the real call surface the error
|
|
336
|
+
|
|
337
|
+
# Inherit area_path / iteration_path from parent work item
|
|
338
|
+
parent_area_path = None
|
|
339
|
+
parent_iteration_path = None
|
|
340
|
+
try:
|
|
341
|
+
wi_data = await asyncio.to_thread(_fetch_work_item, org, project, wi_id)
|
|
342
|
+
parent_area_path = wi_data["work_item"].get("area_path")
|
|
343
|
+
parent_iteration_path = wi_data["work_item"].get("iteration_path")
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
# ── Feature confirmation gate ─────────────────────────────────────
|
|
348
|
+
# When the target work item is a Feature, pause and ask the user
|
|
349
|
+
# to confirm before creating and uploading test cases. The
|
|
350
|
+
# server cannot block here (it is async / stateless), so we rely
|
|
351
|
+
# on the skill / agent layer to enforce this. The server signals
|
|
352
|
+
# the need for confirmation by returning a structured response that
|
|
353
|
+
# the LLM skill interprets as a hard stop.
|
|
354
|
+
# Retry protocol: after the user confirms, call this tool again
|
|
355
|
+
# with the EXACT same arguments plus confirmed=true. The gate
|
|
356
|
+
# is then bypassed and creation proceeds normally.
|
|
357
|
+
try:
|
|
358
|
+
wi_type_check = wi_data.get("work_item", {}).get("type", "")
|
|
359
|
+
if wi_type_check == "Feature" and not arguments.get("confirmed", False):
|
|
360
|
+
feature_title = wi_data.get("work_item", {}).get("title", str(wi_id))
|
|
361
|
+
result = {
|
|
362
|
+
"confirmation_required": True,
|
|
363
|
+
"work_item_id": wi_id,
|
|
364
|
+
"work_item_type": "Feature",
|
|
365
|
+
"feature_title": feature_title,
|
|
366
|
+
"proposed_test_case_count": len(test_cases),
|
|
367
|
+
"retry_instructions": (
|
|
368
|
+
"Surface this message to the user. "
|
|
369
|
+
"If they confirm, retry this tool call with the IDENTICAL "
|
|
370
|
+
"arguments and add confirmed=true. "
|
|
371
|
+
"Do NOT change work_item_id or any other parameter. "
|
|
372
|
+
"If they decline, abort — do not call any other tool."
|
|
373
|
+
),
|
|
374
|
+
"message": (
|
|
375
|
+
f"Work item {wi_id} is a Feature: \"{feature_title}\". "
|
|
376
|
+
f"You are about to create and upload {len(test_cases)} manual "
|
|
377
|
+
f"test case(s) linked to this Feature in ADO. "
|
|
378
|
+
"Please confirm before proceeding. "
|
|
379
|
+
"Reply 'yes' or 'confirm' to proceed, or 'no' / 'cancel' to abort."
|
|
380
|
+
),
|
|
381
|
+
}
|
|
382
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
|
|
383
|
+
except Exception:
|
|
384
|
+
pass # wi_data may not be set if the earlier peek failed; continue normally
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
failed = []
|
|
388
|
+
created = []
|
|
389
|
+
for tc in test_cases:
|
|
390
|
+
try:
|
|
391
|
+
r = await asyncio.to_thread(
|
|
392
|
+
_create_test_case,
|
|
393
|
+
org, project,
|
|
394
|
+
tc["title"],
|
|
395
|
+
tc.get("steps", []),
|
|
396
|
+
tc.get("priority", 2),
|
|
397
|
+
tc.get("area_path") or parent_area_path,
|
|
398
|
+
tc.get("iteration_path") or parent_iteration_path,
|
|
399
|
+
)
|
|
400
|
+
created.append(r)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
failed.append({"title": tc["title"], "error": str(e)})
|
|
403
|
+
|
|
404
|
+
link_result = {}
|
|
405
|
+
if created:
|
|
406
|
+
tc_ids = [r["test_case_id"] for r in created]
|
|
407
|
+
try:
|
|
408
|
+
link_result = await asyncio.to_thread(
|
|
409
|
+
_link_test_cases, org, project, wi_id, tc_ids
|
|
410
|
+
)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
link_result = {"error": str(e)}
|
|
413
|
+
|
|
414
|
+
result = {
|
|
415
|
+
"summary": {
|
|
416
|
+
"requested": len(test_cases),
|
|
417
|
+
"created": len(created),
|
|
418
|
+
"failed": len(failed),
|
|
419
|
+
"linked_to_work_item": wi_id,
|
|
420
|
+
},
|
|
421
|
+
"created_test_cases": created,
|
|
422
|
+
"failed": failed,
|
|
423
|
+
"link_result": link_result,
|
|
424
|
+
"_validation": {
|
|
425
|
+
"valid": len(failed) == 0 and bool(link_result.get("success", True)),
|
|
426
|
+
"input_validation": input_validation,
|
|
427
|
+
"errors": [f["error"] for f in failed] if failed else [],
|
|
428
|
+
"warnings": input_validation.get("warnings", []),
|
|
429
|
+
"post_creation_check": {
|
|
430
|
+
"all_created": len(created) == len(test_cases),
|
|
431
|
+
"all_linked": link_result.get("success", False) if link_result else False,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
elif name == "get_linked_test_cases":
|
|
437
|
+
result = await asyncio.to_thread(
|
|
438
|
+
_get_linked_test_cases,
|
|
439
|
+
arguments["organization_url"],
|
|
440
|
+
arguments["project_name"],
|
|
441
|
+
arguments["work_item_id"],
|
|
442
|
+
)
|
|
443
|
+
# ── Pre-output validation ─────────────────────────────────────
|
|
444
|
+
result["_validation"] = _validate_linked_test_cases_response(result)
|
|
445
|
+
|
|
446
|
+
else:
|
|
447
|
+
result = {"error": f"Unknown tool: {name}"}
|
|
448
|
+
|
|
449
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))]
|
|
450
|
+
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
return [types.TextContent(
|
|
453
|
+
type="text",
|
|
454
|
+
text=json.dumps({"error": str(exc), "tool": name}, indent=2),
|
|
455
|
+
)]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# Entry point
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
async def _run():
|
|
463
|
+
async with stdio_server() as (r, w):
|
|
464
|
+
await app.run(r, w, app.create_initialization_options())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main():
|
|
468
|
+
# Validate auth on startup — triggers interactive login if needed
|
|
469
|
+
try:
|
|
470
|
+
get_auth_headers()
|
|
471
|
+
except Exception as e:
|
|
472
|
+
print(f"[qa-test-case-manager] Auth error: {e}", file=sys.stderr)
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
|
|
475
|
+
user = get_signed_in_user()
|
|
476
|
+
if user:
|
|
477
|
+
print(f"[qa-test-case-manager] Authenticated as: {user}", file=sys.stderr)
|
|
478
|
+
|
|
479
|
+
asyncio.run(_run())
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
if __name__ == "__main__":
|
|
483
|
+
main()
|
|
File without changes
|
|
Binary file
|