@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.
- package/README.md +175 -34
- package/bin/postinstall.js +100 -44
- package/bin/qa-stlc.js +26 -6
- 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,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth.py — Jira Cloud authentication for the QA Jira Manager MCP Agent.
|
|
3
|
+
|
|
4
|
+
Uses Jira OAuth 2.0 (3LO) with a persistent file-based token cache —
|
|
5
|
+
same cache-first / browser-fallback pattern as the ADO MSAL auth module.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
1. Check ~/.jira-cache/jira-token.json for a cached access token.
|
|
9
|
+
If a valid (non-expired) token exists, it is returned immediately —
|
|
10
|
+
no browser prompt appears.
|
|
11
|
+
2. If the access token is expired but a refresh token is present,
|
|
12
|
+
it is silently exchanged for a new access token and persisted.
|
|
13
|
+
3. Only if no usable token exists: open the browser once for the Jira
|
|
14
|
+
OAuth 2.0 authorization URL. A local loopback server on port 8765
|
|
15
|
+
captures the authorization code and exchanges it for tokens.
|
|
16
|
+
Tokens are written to cache. The browser never opens again until the
|
|
17
|
+
refresh token expires.
|
|
18
|
+
|
|
19
|
+
Required environment variables (in .env or shell):
|
|
20
|
+
JIRA_CLIENT_ID Atlassian OAuth 2.0 app client ID
|
|
21
|
+
JIRA_CLIENT_SECRET Atlassian OAuth 2.0 app client secret
|
|
22
|
+
JIRA_REDIRECT_URI Must match the app's registered redirect URI
|
|
23
|
+
(default: http://localhost:8765/callback)
|
|
24
|
+
JIRA_CLOUD_ID Atlassian cloud ID for the target site
|
|
25
|
+
(from https://api.atlassian.com/oauth/token/accessible-resources)
|
|
26
|
+
|
|
27
|
+
Optional:
|
|
28
|
+
JIRA_SCOPES Space-separated OAuth scopes
|
|
29
|
+
(default covers all scopes needed by this agent)
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
import urllib.parse
|
|
38
|
+
import webbrowser
|
|
39
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Dict, Optional
|
|
42
|
+
|
|
43
|
+
from dotenv import load_dotenv
|
|
44
|
+
|
|
45
|
+
load_dotenv()
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Constants
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
|
|
52
|
+
_AUTH_URL = "https://auth.atlassian.com/authorize"
|
|
53
|
+
|
|
54
|
+
_DEFAULT_REDIRECT_URI = "http://localhost:8765/callback"
|
|
55
|
+
_CACHE_FILE = Path.home() / ".jira-cache" / "jira-token.json"
|
|
56
|
+
|
|
57
|
+
_DEFAULT_SCOPES = (
|
|
58
|
+
"read:jira-user "
|
|
59
|
+
"read:jira-work "
|
|
60
|
+
"write:jira-work "
|
|
61
|
+
"offline_access"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Token cache
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _load_cache() -> dict:
|
|
69
|
+
try:
|
|
70
|
+
if _CACHE_FILE.exists():
|
|
71
|
+
return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _save_cache(data: dict) -> None:
|
|
78
|
+
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
_CACHE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# OAuth helpers
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict:
|
|
87
|
+
import requests
|
|
88
|
+
resp = requests.post(
|
|
89
|
+
_TOKEN_URL,
|
|
90
|
+
json={
|
|
91
|
+
"grant_type": "authorization_code",
|
|
92
|
+
"client_id": client_id,
|
|
93
|
+
"client_secret": client_secret,
|
|
94
|
+
"code": code,
|
|
95
|
+
"redirect_uri": redirect_uri,
|
|
96
|
+
},
|
|
97
|
+
timeout=30,
|
|
98
|
+
)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
return resp.json()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _refresh_token(refresh: str, client_id: str, client_secret: str) -> dict:
|
|
104
|
+
import requests
|
|
105
|
+
resp = requests.post(
|
|
106
|
+
_TOKEN_URL,
|
|
107
|
+
json={
|
|
108
|
+
"grant_type": "refresh_token",
|
|
109
|
+
"client_id": client_id,
|
|
110
|
+
"client_secret": client_secret,
|
|
111
|
+
"refresh_token": refresh,
|
|
112
|
+
},
|
|
113
|
+
timeout=30,
|
|
114
|
+
)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
return resp.json()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Loopback server — captures the OAuth callback from the browser
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
124
|
+
auth_code: Optional[str] = None
|
|
125
|
+
error: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
def do_GET(self): # noqa: N802
|
|
128
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
129
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
130
|
+
if "code" in params:
|
|
131
|
+
_CallbackHandler.auth_code = params["code"][0]
|
|
132
|
+
self._respond("Authentication successful. You can close this tab.")
|
|
133
|
+
else:
|
|
134
|
+
_CallbackHandler.error = params.get("error", ["unknown"])[0]
|
|
135
|
+
self._respond("Authentication failed. Check the terminal for details.")
|
|
136
|
+
|
|
137
|
+
def _respond(self, msg: str):
|
|
138
|
+
body = f"<html><body><h2>{msg}</h2></body></html>".encode()
|
|
139
|
+
self.send_response(200)
|
|
140
|
+
self.send_header("Content-Type", "text/html")
|
|
141
|
+
self.send_header("Content-Length", str(len(body)))
|
|
142
|
+
self.end_headers()
|
|
143
|
+
self.wfile.write(body)
|
|
144
|
+
|
|
145
|
+
def log_message(self, *_): # suppress access log noise
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _interactive_login(client_id: str, client_secret: str, redirect_uri: str, scopes: str) -> dict:
|
|
150
|
+
"""Open browser for Jira OAuth 2.0 authorization, capture code via loopback."""
|
|
151
|
+
_CallbackHandler.auth_code = None
|
|
152
|
+
_CallbackHandler.error = None
|
|
153
|
+
|
|
154
|
+
port = int(urllib.parse.urlparse(redirect_uri).port or 8765)
|
|
155
|
+
server = HTTPServer(("localhost", port), _CallbackHandler)
|
|
156
|
+
|
|
157
|
+
# Build the authorization URL
|
|
158
|
+
params = {
|
|
159
|
+
"audience": "api.atlassian.com",
|
|
160
|
+
"client_id": client_id,
|
|
161
|
+
"scope": scopes,
|
|
162
|
+
"redirect_uri": redirect_uri,
|
|
163
|
+
"state": "qa-jira-agent",
|
|
164
|
+
"response_type": "code",
|
|
165
|
+
"prompt": "consent",
|
|
166
|
+
}
|
|
167
|
+
auth_url = f"{_AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
168
|
+
|
|
169
|
+
print(
|
|
170
|
+
f"\n[qa-jira-manager] Opening browser for Jira login...\n"
|
|
171
|
+
f" If the browser does not open, navigate to:\n {auth_url}\n",
|
|
172
|
+
flush=True,
|
|
173
|
+
)
|
|
174
|
+
webbrowser.open(auth_url)
|
|
175
|
+
|
|
176
|
+
# Block until callback received (or 120 s timeout)
|
|
177
|
+
server.timeout = 120
|
|
178
|
+
deadline = time.time() + 120
|
|
179
|
+
while _CallbackHandler.auth_code is None and _CallbackHandler.error is None:
|
|
180
|
+
server.handle_request()
|
|
181
|
+
if time.time() > deadline:
|
|
182
|
+
raise RuntimeError("[qa-jira-manager] OAuth login timed out after 120 seconds.")
|
|
183
|
+
server.server_close()
|
|
184
|
+
|
|
185
|
+
if _CallbackHandler.error:
|
|
186
|
+
raise RuntimeError(f"[qa-jira-manager] OAuth error: {_CallbackHandler.error}")
|
|
187
|
+
|
|
188
|
+
return _exchange_code(_CallbackHandler.auth_code, client_id, client_secret, redirect_uri)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Public API
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def get_auth_headers(content_type: str = "application/json") -> Dict[str, str]:
|
|
196
|
+
"""Return HTTP headers with a valid Jira Bearer token."""
|
|
197
|
+
token = _acquire_token()
|
|
198
|
+
return {
|
|
199
|
+
"Authorization": f"Bearer {token}",
|
|
200
|
+
"Content-Type": content_type,
|
|
201
|
+
"Accept": "application/json",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_cloud_id() -> str:
|
|
206
|
+
"""Return the Jira Cloud ID from env or raise."""
|
|
207
|
+
cloud_id = os.getenv("JIRA_CLOUD_ID", "").strip()
|
|
208
|
+
if not cloud_id:
|
|
209
|
+
raise RuntimeError(
|
|
210
|
+
"JIRA_CLOUD_ID is not set. "
|
|
211
|
+
"Run: curl -H 'Authorization: Bearer <token>' "
|
|
212
|
+
"https://api.atlassian.com/oauth/token/accessible-resources "
|
|
213
|
+
"to find your cloud ID."
|
|
214
|
+
)
|
|
215
|
+
return cloud_id
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_signed_in_user() -> str:
|
|
219
|
+
"""Return the display name of the cached Jira account, or empty string."""
|
|
220
|
+
cache = _load_cache()
|
|
221
|
+
return cache.get("display_name", cache.get("email", ""))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _acquire_token() -> str:
|
|
225
|
+
"""
|
|
226
|
+
1. Return cached non-expired access token.
|
|
227
|
+
2. Refresh silently if a refresh token exists.
|
|
228
|
+
3. Fall back to interactive browser login.
|
|
229
|
+
"""
|
|
230
|
+
client_id = os.getenv("JIRA_CLIENT_ID", "").strip()
|
|
231
|
+
client_secret = os.getenv("JIRA_CLIENT_SECRET", "").strip()
|
|
232
|
+
redirect_uri = os.getenv("JIRA_REDIRECT_URI", _DEFAULT_REDIRECT_URI).strip()
|
|
233
|
+
scopes = os.getenv("JIRA_SCOPES", _DEFAULT_SCOPES).strip()
|
|
234
|
+
|
|
235
|
+
if not client_id or not client_secret:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
"JIRA_CLIENT_ID and JIRA_CLIENT_SECRET must be set in your .env file. "
|
|
238
|
+
"Create an OAuth 2.0 (3LO) app at https://developer.atlassian.com/console/myapps/ "
|
|
239
|
+
"and add the callback URL: " + _DEFAULT_REDIRECT_URI
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
cache = _load_cache()
|
|
243
|
+
now = time.time()
|
|
244
|
+
|
|
245
|
+
# 1. Valid cached token
|
|
246
|
+
if cache.get("access_token") and cache.get("expires_at", 0) > now + 30:
|
|
247
|
+
return cache["access_token"]
|
|
248
|
+
|
|
249
|
+
# 2. Refresh token available
|
|
250
|
+
if cache.get("refresh_token"):
|
|
251
|
+
try:
|
|
252
|
+
tokens = _refresh_token(cache["refresh_token"], client_id, client_secret)
|
|
253
|
+
_persist_tokens(tokens, cache)
|
|
254
|
+
return tokens["access_token"]
|
|
255
|
+
except Exception:
|
|
256
|
+
pass # fall through to interactive
|
|
257
|
+
|
|
258
|
+
# 3. Interactive browser login
|
|
259
|
+
tokens = _interactive_login(client_id, client_secret, redirect_uri, scopes)
|
|
260
|
+
_persist_tokens(tokens, {})
|
|
261
|
+
return tokens["access_token"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _persist_tokens(tokens: dict, existing_cache: dict) -> None:
|
|
265
|
+
cache = dict(existing_cache)
|
|
266
|
+
cache["access_token"] = tokens["access_token"]
|
|
267
|
+
cache["expires_at"] = time.time() + tokens.get("expires_in", 3600)
|
|
268
|
+
if "refresh_token" in tokens:
|
|
269
|
+
cache["refresh_token"] = tokens["refresh_token"]
|
|
270
|
+
_save_cache(cache)
|