@qa-gentic/stlc-agents 1.0.5 → 1.0.7

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 (35) hide show
  1. package/README.md +175 -34
  2. package/bin/postinstall.js +125 -44
  3. package/bin/qa-stlc.js +15 -8
  4. package/package.json +2 -2
  5. package/skills/{qa-stlc/AGENT-BEHAVIOR.md → AGENT-BEHAVIOR.md} +7 -6
  6. package/{.github/copilot-instructions/deduplication-protocol.md → skills/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/{.github/copilot-instructions/generate-test-cases.md → skills/generate-test-cases/SKILL.md} +16 -2
  11. package/skills/qa-jira-manager/SKILL.md +287 -0
  12. package/{.github/copilot-instructions/write-helix-files.md → skills/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/.github/copilot-instructions/AGENT-BEHAVIOR.md +0 -448
  27. package/.github/copilot-instructions/generate-gherkin.md +0 -550
  28. package/.github/copilot-instructions/generate-playwright-code.md +0 -464
  29. package/skills/qa-stlc/deduplication-protocol.md +0 -303
  30. package/skills/qa-stlc/generate-gherkin.md +0 -550
  31. package/skills/qa-stlc/generate-test-cases.md +0 -176
  32. package/skills/qa-stlc/write-helix-files.md +0 -374
  33. package/src/cmd-init.js +0 -92
  34. package/src/cmd-skills.js +0 -124
  35. /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)