@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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ado_workitem.py — Azure DevOps Work Item REST API calls for the Test Case Manager.
|
|
3
|
+
|
|
4
|
+
All functions use get_auth_headers() from auth.py — no credentials in this file.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
fetch_work_item(org_url, project, work_item_id) -> dict
|
|
8
|
+
create_test_case(org_url, project, title, steps, ...) -> dict
|
|
9
|
+
link_test_cases_to_work_item(org_url, project, wi_id, tc_ids) -> dict
|
|
10
|
+
get_linked_test_cases(org_url, project, work_item_id) -> dict
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from qa_stlc_agents.shared.auth import get_auth_headers
|
|
21
|
+
|
|
22
|
+
_API = "7.1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# fetch_work_item
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def fetch_work_item(org_url: str, project: str, work_item_id: int) -> dict:
|
|
30
|
+
"""Fetch a PBI or Bug with all relevant fields and parent feature."""
|
|
31
|
+
org_url = org_url.rstrip("/")
|
|
32
|
+
headers = get_auth_headers()
|
|
33
|
+
|
|
34
|
+
resp = requests.get(
|
|
35
|
+
f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
|
|
36
|
+
headers=headers,
|
|
37
|
+
params={"$expand": "relations", "api-version": _API},
|
|
38
|
+
timeout=30,
|
|
39
|
+
)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
data = resp.json()
|
|
42
|
+
fields = data.get("fields", {})
|
|
43
|
+
|
|
44
|
+
wi_type = fields.get("System.WorkItemType", "")
|
|
45
|
+
|
|
46
|
+
# ── Epic guard — test cases must NEVER be created for Epics ─────────────
|
|
47
|
+
if wi_type == "Epic":
|
|
48
|
+
return {
|
|
49
|
+
"error": "epic_not_supported",
|
|
50
|
+
"work_item_type": wi_type,
|
|
51
|
+
"work_item_id": work_item_id,
|
|
52
|
+
"message": (
|
|
53
|
+
f"Work item {work_item_id} is an Epic. "
|
|
54
|
+
"Manual test cases cannot be created or linked to an Epic. "
|
|
55
|
+
"Test cases in ADO are always scoped to PBIs, Bugs, or Features — "
|
|
56
|
+
"never to Epics. To generate tests for this Epic, use "
|
|
57
|
+
"qa-gherkin-generator:fetch_epic_hierarchy to walk its child Features "
|
|
58
|
+
"and PBIs/Bugs, then call create_and_link_test_cases on each individual "
|
|
59
|
+
"Feature or PBI/Bug work item ID, not on the Epic itself."
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
wi = {
|
|
64
|
+
"id": data["id"],
|
|
65
|
+
"type": fields.get("System.WorkItemType", ""),
|
|
66
|
+
"title": fields.get("System.Title", ""),
|
|
67
|
+
"description": _strip_html(fields.get("System.Description", "") or ""),
|
|
68
|
+
"acceptance_criteria": _strip_html(
|
|
69
|
+
fields.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or ""
|
|
70
|
+
),
|
|
71
|
+
"repro_steps": _strip_html(
|
|
72
|
+
fields.get("Microsoft.VSTS.TCM.ReproSteps", "") or ""
|
|
73
|
+
),
|
|
74
|
+
"state": fields.get("System.State", ""),
|
|
75
|
+
"priority": fields.get("Microsoft.VSTS.Common.Priority", ""),
|
|
76
|
+
"story_points": fields.get("Microsoft.VSTS.Scheduling.StoryPoints", ""),
|
|
77
|
+
"tags": fields.get("System.Tags", ""),
|
|
78
|
+
"assigned_to": (
|
|
79
|
+
(fields.get("System.AssignedTo") or {}).get("displayName", "")
|
|
80
|
+
),
|
|
81
|
+
"area_path": fields.get("System.AreaPath", ""),
|
|
82
|
+
"iteration_path": fields.get("System.IterationPath", ""),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Fetch parent feature if present
|
|
86
|
+
parent_feature = None
|
|
87
|
+
for rel in data.get("relations", []):
|
|
88
|
+
if rel.get("rel") == "System.LinkTypes.Hierarchy-Reverse":
|
|
89
|
+
try:
|
|
90
|
+
pr = requests.get(
|
|
91
|
+
rel["url"] + f"?api-version={_API}",
|
|
92
|
+
headers=get_auth_headers(),
|
|
93
|
+
timeout=30,
|
|
94
|
+
)
|
|
95
|
+
if pr.ok:
|
|
96
|
+
pf = pr.json().get("fields", {})
|
|
97
|
+
parent_feature = {
|
|
98
|
+
"id": pr.json().get("id"),
|
|
99
|
+
"type": pf.get("System.WorkItemType", ""),
|
|
100
|
+
"title": pf.get("System.Title", ""),
|
|
101
|
+
}
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
# Existing linked test cases count
|
|
107
|
+
existing_tc_count = sum(
|
|
108
|
+
1 for r in data.get("relations", [])
|
|
109
|
+
if r.get("rel") == "Microsoft.VSTS.Common.TestedBy-Forward"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Coverage hints derived from text
|
|
113
|
+
coverage_hints = _extract_coverage_hints(wi)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"work_item": wi,
|
|
117
|
+
"parent_feature": parent_feature,
|
|
118
|
+
"existing_test_cases_count": existing_tc_count,
|
|
119
|
+
"coverage_hints": coverage_hints,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# create_test_case
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def create_test_case(
|
|
128
|
+
org_url: str,
|
|
129
|
+
project: str,
|
|
130
|
+
title: str,
|
|
131
|
+
steps: List[Dict],
|
|
132
|
+
priority: int = 2,
|
|
133
|
+
area_path: Optional[str] = None,
|
|
134
|
+
iteration_path: Optional[str] = None,
|
|
135
|
+
) -> dict:
|
|
136
|
+
"""Create a Test Case work item with XML steps in ADO."""
|
|
137
|
+
org_url = org_url.rstrip("/")
|
|
138
|
+
headers = get_auth_headers("application/json-patch+json")
|
|
139
|
+
|
|
140
|
+
steps_xml = _build_steps_xml(steps)
|
|
141
|
+
|
|
142
|
+
patch = [
|
|
143
|
+
{"op": "add", "path": "/fields/System.Title", "value": title},
|
|
144
|
+
{"op": "add", "path": "/fields/System.State", "value": "Design"},
|
|
145
|
+
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": priority},
|
|
146
|
+
]
|
|
147
|
+
if steps_xml:
|
|
148
|
+
patch.append({"op": "add", "path": "/fields/Microsoft.VSTS.TCM.Steps", "value": steps_xml})
|
|
149
|
+
if area_path:
|
|
150
|
+
patch.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path})
|
|
151
|
+
if iteration_path:
|
|
152
|
+
patch.append({"op": "add", "path": "/fields/System.IterationPath", "value": iteration_path})
|
|
153
|
+
|
|
154
|
+
resp = requests.post(
|
|
155
|
+
f"{org_url}/{project}/_apis/wit/workitems/$Test%20Case",
|
|
156
|
+
headers=headers,
|
|
157
|
+
params={"api-version": _API},
|
|
158
|
+
json=patch,
|
|
159
|
+
timeout=30,
|
|
160
|
+
)
|
|
161
|
+
resp.raise_for_status()
|
|
162
|
+
created = resp.json()
|
|
163
|
+
return {
|
|
164
|
+
"success": True,
|
|
165
|
+
"test_case_id": created["id"],
|
|
166
|
+
"title": title,
|
|
167
|
+
"url": created.get("_links", {}).get("html", {}).get("href", ""),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# link_test_cases_to_work_item
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def link_test_cases_to_work_item(
|
|
176
|
+
org_url: str,
|
|
177
|
+
project: str,
|
|
178
|
+
work_item_id: int,
|
|
179
|
+
test_case_ids: List[int],
|
|
180
|
+
) -> dict:
|
|
181
|
+
"""Create TestedBy-Forward links from a work item to test cases."""
|
|
182
|
+
org_url = org_url.rstrip("/")
|
|
183
|
+
headers = get_auth_headers("application/json-patch+json")
|
|
184
|
+
|
|
185
|
+
relations = [
|
|
186
|
+
{
|
|
187
|
+
"op": "add",
|
|
188
|
+
"path": "/relations/-",
|
|
189
|
+
"value": {
|
|
190
|
+
"rel": "Microsoft.VSTS.Common.TestedBy-Forward",
|
|
191
|
+
"url": f"{org_url}/{project}/_apis/wit/workItems/{tc_id}",
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
for tc_id in test_case_ids
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
resp = requests.patch(
|
|
198
|
+
f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
|
|
199
|
+
headers=headers,
|
|
200
|
+
params={"api-version": _API},
|
|
201
|
+
json=relations,
|
|
202
|
+
timeout=30,
|
|
203
|
+
)
|
|
204
|
+
resp.raise_for_status()
|
|
205
|
+
return {
|
|
206
|
+
"success": True,
|
|
207
|
+
"work_item_id": work_item_id,
|
|
208
|
+
"linked_count": len(test_case_ids),
|
|
209
|
+
"test_case_ids": test_case_ids,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# get_linked_test_cases
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def get_linked_test_cases(org_url: str, project: str, work_item_id: int) -> dict:
|
|
218
|
+
"""Return all test cases linked via TestedBy to a work item."""
|
|
219
|
+
org_url = org_url.rstrip("/")
|
|
220
|
+
headers = get_auth_headers()
|
|
221
|
+
|
|
222
|
+
resp = requests.get(
|
|
223
|
+
f"{org_url}/{project}/_apis/wit/workitems/{work_item_id}",
|
|
224
|
+
headers=headers,
|
|
225
|
+
params={"$expand": "relations", "api-version": _API},
|
|
226
|
+
timeout=30,
|
|
227
|
+
)
|
|
228
|
+
resp.raise_for_status()
|
|
229
|
+
|
|
230
|
+
linked = []
|
|
231
|
+
for rel in resp.json().get("relations", []):
|
|
232
|
+
if rel.get("rel") == "Microsoft.VSTS.Common.TestedBy-Forward":
|
|
233
|
+
tc_id = int(rel["url"].split("/")[-1])
|
|
234
|
+
try:
|
|
235
|
+
tc = requests.get(
|
|
236
|
+
rel["url"] + f"?api-version={_API}",
|
|
237
|
+
headers=get_auth_headers(),
|
|
238
|
+
timeout=30,
|
|
239
|
+
)
|
|
240
|
+
if tc.ok:
|
|
241
|
+
f = tc.json().get("fields", {})
|
|
242
|
+
linked.append({
|
|
243
|
+
"id": tc_id,
|
|
244
|
+
"title": f.get("System.Title", ""),
|
|
245
|
+
"state": f.get("System.State", ""),
|
|
246
|
+
"priority": f.get("Microsoft.VSTS.Common.Priority", ""),
|
|
247
|
+
})
|
|
248
|
+
except Exception:
|
|
249
|
+
linked.append({"id": tc_id})
|
|
250
|
+
|
|
251
|
+
return {"work_item_id": work_item_id, "linked_test_cases": linked, "count": len(linked)}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Helpers
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _build_steps_xml(steps: List[Dict]) -> str:
|
|
259
|
+
if not steps:
|
|
260
|
+
return ""
|
|
261
|
+
|
|
262
|
+
def esc(s: str) -> str:
|
|
263
|
+
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
264
|
+
|
|
265
|
+
inner = "".join(
|
|
266
|
+
f'<step id="{i+1}" type="ValidateStep">'
|
|
267
|
+
f'<parameterizedString isformatted="false">{esc(s.get("action",""))}</parameterizedString>'
|
|
268
|
+
f'<parameterizedString isformatted="false">{esc(s.get("expected_result",""))}</parameterizedString>'
|
|
269
|
+
f"<description/></step>"
|
|
270
|
+
for i, s in enumerate(steps)
|
|
271
|
+
)
|
|
272
|
+
return f'<steps id="0" last="{len(steps)}">{inner}</steps>'
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _strip_html(html: str) -> str:
|
|
276
|
+
if not html:
|
|
277
|
+
return ""
|
|
278
|
+
import re
|
|
279
|
+
text = re.sub(r"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
|
|
280
|
+
text = re.sub(r"</p>|</li>|</tr>", "\n", text, flags=re.IGNORECASE)
|
|
281
|
+
text = re.sub(r"<li[^>]*>", "• ", text, flags=re.IGNORECASE)
|
|
282
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
283
|
+
for old, new in [(" ", " "), ("&", "&"), ("<", "<"), (">", ">"), (""", '"')]:
|
|
284
|
+
text = text.replace(old, new)
|
|
285
|
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_coverage_hints(wi: dict) -> list:
|
|
289
|
+
text = " ".join([wi.get("description", ""), wi.get("acceptance_criteria", "")]).lower()
|
|
290
|
+
checks = [
|
|
291
|
+
(["toast", "notification", "success message", "confirmation"], "toast_notifications"),
|
|
292
|
+
(["mb", "kb", "size limit", "file size", "maximum size"], "file_size_boundary"),
|
|
293
|
+
(["display", "shows", "reflects", "after upload", "after save", "updated"], "post_action_state"),
|
|
294
|
+
(["mobile", "webview", "ios", "android"], "platform_specific"),
|
|
295
|
+
(["initials", "first name", "last name", "derived", "generated"], "computed_values"),
|
|
296
|
+
(["refresh", "reload", "re-login", "persist", "retained"], "data_persistence"),
|
|
297
|
+
(["tab", "keyboard", "accessible", "aria", "wcag", "a11y"], "accessibility"),
|
|
298
|
+
(["crop", "resize", "zoom", "drag", "rotate"], "image_manipulation"),
|
|
299
|
+
(["format", "png", "jpg", "jpeg", "webp", "svg"], "file_formats"),
|
|
300
|
+
(["cancel", "close", "discard", "dismiss"], "cancel_flows"),
|
|
301
|
+
]
|
|
302
|
+
return [hint for keywords, hint in checks if any(kw in text for kw in keywords)]
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth.py — Authentication for the QA Test Case Manager MCP Agent.
|
|
3
|
+
|
|
4
|
+
Uses MSAL with a persistent file-based token cache — identical approach to
|
|
5
|
+
the official microsoft/azure-devops-mcp server and the ado-qa-mcp-agent.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. Check ~/.msal-cache/msal-cache.json for a cached token.
|
|
9
|
+
If the browser is already signed into a Microsoft account (via VS Code,
|
|
10
|
+
az login, or a previous run), the token is loaded silently — no browser
|
|
11
|
+
prompt appears.
|
|
12
|
+
2. Only if no valid cached token exists: open browser once for interactive
|
|
13
|
+
login. Token is written to cache. Browser never opens again until the
|
|
14
|
+
refresh token expires (~90 days).
|
|
15
|
+
|
|
16
|
+
The cache path is shared with microsoft/azure-devops-mcp and ado-qa-mcp-agent,
|
|
17
|
+
so signing in via any of those tools means this agent is already authenticated.
|
|
18
|
+
|
|
19
|
+
Optional env vars:
|
|
20
|
+
AZURE_TENANT_ID Pin to a specific Entra tenant (default: "organizations")
|
|
21
|
+
AZURE_CLIENT_ID Custom app registration (default: Azure CLI client ID)
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict
|
|
28
|
+
|
|
29
|
+
# ADO OAuth scope — same as microsoft/azure-devops-mcp
|
|
30
|
+
_ADO_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default"
|
|
31
|
+
|
|
32
|
+
# Azure CLI public client ID — works without a custom app registration
|
|
33
|
+
_DEFAULT_CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
|
|
34
|
+
|
|
35
|
+
# Shared cache location with microsoft/azure-devops-mcp
|
|
36
|
+
_CACHE_FILE = Path.home() / ".msal-cache" / "msal-cache.json"
|
|
37
|
+
|
|
38
|
+
_msal_app = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_auth_headers(content_type: str = "application/json") -> Dict[str, str]:
|
|
42
|
+
"""Return HTTP headers with a valid ADO Bearer token."""
|
|
43
|
+
token = _acquire_token()
|
|
44
|
+
return {
|
|
45
|
+
"Authorization": f"Bearer {token}",
|
|
46
|
+
"Content-Type": content_type,
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_signed_in_user() -> str:
|
|
52
|
+
"""Return the display name / UPN of the cached account, or empty string."""
|
|
53
|
+
try:
|
|
54
|
+
app, _ = _get_msal_app()
|
|
55
|
+
accounts = app.get_accounts()
|
|
56
|
+
if accounts:
|
|
57
|
+
return accounts[0].get("name") or accounts[0].get("username") or ""
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_msal_app():
|
|
64
|
+
global _msal_app
|
|
65
|
+
if _msal_app is None:
|
|
66
|
+
_msal_app = _create_msal_app()
|
|
67
|
+
return _msal_app
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _create_msal_app():
|
|
71
|
+
import msal # type: ignore
|
|
72
|
+
|
|
73
|
+
client_id = os.getenv("AZURE_CLIENT_ID", _DEFAULT_CLIENT_ID)
|
|
74
|
+
tenant_id = os.getenv("AZURE_TENANT_ID", "organizations")
|
|
75
|
+
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
|
76
|
+
|
|
77
|
+
cache = msal.SerializableTokenCache()
|
|
78
|
+
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
if _CACHE_FILE.exists():
|
|
80
|
+
cache.deserialize(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
81
|
+
|
|
82
|
+
app = msal.PublicClientApplication(
|
|
83
|
+
client_id=client_id,
|
|
84
|
+
authority=authority,
|
|
85
|
+
token_cache=cache,
|
|
86
|
+
)
|
|
87
|
+
return app, cache
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _acquire_token() -> str:
|
|
91
|
+
"""
|
|
92
|
+
1. Try silent acquisition from the shared cache.
|
|
93
|
+
2. Fall back to acquire_token_interactive (opens browser once).
|
|
94
|
+
"""
|
|
95
|
+
app, cache = _get_msal_app()
|
|
96
|
+
scopes = [_ADO_SCOPE]
|
|
97
|
+
|
|
98
|
+
accounts = app.get_accounts()
|
|
99
|
+
if accounts:
|
|
100
|
+
result = app.acquire_token_silent(scopes, account=accounts[0])
|
|
101
|
+
if result and "access_token" in result:
|
|
102
|
+
_persist_cache(cache)
|
|
103
|
+
return result["access_token"]
|
|
104
|
+
|
|
105
|
+
# No cached token — open browser
|
|
106
|
+
result = app.acquire_token_interactive(scopes=scopes)
|
|
107
|
+
|
|
108
|
+
if "access_token" not in result:
|
|
109
|
+
err = result.get("error_description") or result.get("error") or str(result)
|
|
110
|
+
raise RuntimeError(f"MSAL authentication failed: {err}")
|
|
111
|
+
|
|
112
|
+
_persist_cache(cache)
|
|
113
|
+
return result["access_token"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _persist_cache(cache) -> None:
|
|
117
|
+
if cache.has_state_changed:
|
|
118
|
+
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
_CACHE_FILE.write_text(cache.serialize(), encoding="utf-8")
|