nat-engine 1__py3-none-any.whl

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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,764 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """BGSTM Test Plan ingestor plugin.
7
+
8
+ Connects to a running BGSTM instance's REST API and translates its test plans,
9
+ requirements, test cases, and traceability links into NAT's normalised
10
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
11
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` dataclasses.
12
+
13
+ This is **Phase 1** — a read-only API client that pulls structured data from
14
+ BGSTM and feeds it into the NAT ingestor pipeline. No bidirectional sync or
15
+ methodology-aware intelligence yet.
16
+
17
+ Authentication
18
+ --------------
19
+ Two modes are supported:
20
+
21
+ * **API token** — supply ``api_token`` in the config. The token is sent as
22
+ ``Authorization: Bearer <token>`` on every request.
23
+ * **Username/password** — supply ``username`` and ``password``. The ingestor
24
+ first calls ``POST /api/v1/auth/login`` to exchange credentials for a JWT,
25
+ then uses that JWT as a Bearer token.
26
+
27
+ BGSTM API endpoints consumed
28
+ -----------------------------
29
+ * ``POST /api/v1/auth/login`` — obtain JWT token
30
+ * ``GET /api/v1/requirements`` — paginated list of requirements
31
+ * ``GET /api/v1/test-cases`` — paginated list of test cases
32
+ * ``GET /api/v1/traceability`` — full traceability matrix
33
+ * ``GET /api/v1/links`` — paginated requirement↔test-case links
34
+ * ``GET /api/v1/suggestions`` — AI-generated link suggestions (optional)
35
+
36
+ Mapping
37
+ -------
38
+ * BGSTM **Requirement** → :class:`~mannf.product.ingestors.models.IngestedEndpoint`
39
+ (requirements carry endpoint context such as source URLs, acceptance criteria,
40
+ and methodology metadata).
41
+ * BGSTM **TestCase** → :class:`~mannf.product.ingestors.models.IngestedTestCase`
42
+ (nearly 1-to-1 field mapping).
43
+ * BGSTM **Link** → associates test cases with their parent requirements via
44
+ ``metadata["requirement_ids"]``.
45
+ * BGSTM **Suggestion** → additional
46
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` entries tagged with
47
+ ``source_hint="ai-suggestion"``.
48
+
49
+ Priority mapping
50
+ ----------------
51
+ BGSTM ``PriorityLevel`` values are mapped to NAT string priorities:
52
+
53
+ ======== ======= ===========
54
+ BGSTM NAT str NAT float
55
+ ======== ======= ===========
56
+ critical critical 1.0
57
+ high high 0.8
58
+ medium medium 0.5
59
+ low low 0.2
60
+ ======== ======= ===========
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import json
66
+ import logging
67
+ import urllib.error
68
+ import urllib.parse
69
+ import urllib.request
70
+ from typing import Any
71
+
72
+ from mannf.product.ingestors.base import IngestorPlugin
73
+ from mannf.product.ingestors.models import (
74
+ IngestedEndpoint,
75
+ IngestedTestCase,
76
+ IngestorSource,
77
+ IngestResult,
78
+ )
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Constants
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _API_PREFIX = "/api/v1"
87
+
88
+ # BGSTM priority string → NAT string priority
89
+ _PRIORITY_MAP: dict[str, str] = {
90
+ "critical": "critical",
91
+ "high": "high",
92
+ "medium": "medium",
93
+ "low": "low",
94
+ }
95
+
96
+ # BGSTM priority string → NAT float priority (stored in metadata)
97
+ _PRIORITY_FLOAT_MAP: dict[str, float] = {
98
+ "critical": 1.0,
99
+ "high": 0.8,
100
+ "medium": 0.5,
101
+ "low": 0.2,
102
+ }
103
+
104
+ # Default page size for paginated BGSTM endpoints
105
+ _PAGE_SIZE = 200
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Low-level HTTP helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def _json_request(
114
+ url: str,
115
+ headers: dict[str, str],
116
+ method: str = "GET",
117
+ body: dict[str, Any] | None = None,
118
+ ) -> Any:
119
+ """Make an HTTP request and return the parsed JSON response body.
120
+
121
+ Parameters
122
+ ----------
123
+ url:
124
+ Fully-qualified URL to request.
125
+ headers:
126
+ HTTP headers to include.
127
+ method:
128
+ HTTP method (default ``"GET"``).
129
+ body:
130
+ Optional JSON-serialisable request body (triggers POST encoding).
131
+
132
+ Returns
133
+ -------
134
+ Any
135
+ Parsed JSON response.
136
+
137
+ Raises
138
+ ------
139
+ urllib.error.HTTPError
140
+ On non-2xx HTTP responses.
141
+ urllib.error.URLError
142
+ On connection-level errors.
143
+ json.JSONDecodeError
144
+ If the response body cannot be parsed as JSON.
145
+ """
146
+ data: bytes | None = None
147
+ if body is not None:
148
+ data = json.dumps(body).encode("utf-8")
149
+ headers = dict(headers)
150
+ headers["Content-Type"] = "application/json"
151
+
152
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
153
+ with urllib.request.urlopen(req) as resp: # noqa: S310
154
+ raw = resp.read()
155
+ return json.loads(raw)
156
+
157
+
158
+ def _authenticate(base_url: str, config: dict) -> str:
159
+ """Return a Bearer token for use with subsequent BGSTM API calls.
160
+
161
+ Supports two modes:
162
+
163
+ * If ``api_token`` is present in *config*, it is returned directly.
164
+ * Otherwise ``username`` and ``password`` are exchanged for a JWT via
165
+ ``POST /api/v1/auth/login``.
166
+
167
+ Raises
168
+ ------
169
+ ValueError
170
+ If the login response is missing ``access_token``.
171
+ urllib.error.HTTPError / urllib.error.URLError
172
+ On HTTP / connection errors during login.
173
+ """
174
+ if config.get("api_token"):
175
+ return str(config["api_token"])
176
+
177
+ login_url = base_url.rstrip("/") + _API_PREFIX + "/auth/login"
178
+ payload = {"email": config["username"], "password": config["password"]}
179
+ resp = _json_request(login_url, headers={}, method="POST", body=payload)
180
+ token = resp.get("access_token") if isinstance(resp, dict) else None
181
+ if not token:
182
+ raise ValueError(
183
+ "BGSTM login response did not contain 'access_token'. "
184
+ f"Response: {resp!r}"
185
+ )
186
+ return str(token)
187
+
188
+
189
+ def _fetch_paginated(base_url: str, path: str, headers: dict[str, str]) -> list[Any]:
190
+ """Fetch all pages of a BGSTM paginated endpoint.
191
+
192
+ Parameters
193
+ ----------
194
+ base_url:
195
+ BGSTM instance base URL (without trailing slash).
196
+ path:
197
+ API path relative to *base_url* (e.g. ``"/api/v1/requirements"``).
198
+ headers:
199
+ Auth headers to include on every request.
200
+
201
+ Returns
202
+ -------
203
+ list[Any]
204
+ Concatenated ``items`` from all pages.
205
+ """
206
+ items: list[Any] = []
207
+ page = 1
208
+ while True:
209
+ params = urllib.parse.urlencode({"page": page, "page_size": _PAGE_SIZE})
210
+ url = base_url.rstrip("/") + path + "?" + params
211
+ resp = _json_request(url, headers=headers)
212
+ if not isinstance(resp, dict):
213
+ break
214
+ page_items = resp.get("items", [])
215
+ if not isinstance(page_items, list):
216
+ break
217
+ items.extend(page_items)
218
+ total_pages = resp.get("pages", 1)
219
+ if page >= total_pages:
220
+ break
221
+ page += 1
222
+ return items
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Mapping helpers
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ def _map_priority(bgstm_priority: str | None) -> str:
231
+ """Convert a BGSTM priority string to a NAT priority string."""
232
+ if not bgstm_priority:
233
+ return "medium"
234
+ return _PRIORITY_MAP.get(bgstm_priority.lower(), "medium")
235
+
236
+
237
+ def _priority_float(bgstm_priority: str | None) -> float:
238
+ """Return the NAT float priority score for a BGSTM priority string."""
239
+ if not bgstm_priority:
240
+ return 0.5
241
+ return _PRIORITY_FLOAT_MAP.get(bgstm_priority.lower(), 0.5)
242
+
243
+
244
+ def _methodology_tags(metadata: dict[str, Any] | None) -> list[str]:
245
+ """Extract methodology/phase tags from BGSTM custom_metadata.
246
+
247
+ BGSTM requirements and test cases may carry arbitrary ``custom_metadata``
248
+ including keys such as ``methodology``, ``sprint``, and ``phase``. This
249
+ function converts those well-known keys into NAT tag strings of the form
250
+ ``key:value`` so that NAT's belief-guided prioritiser can use them later.
251
+ """
252
+ if not metadata or not isinstance(metadata, dict):
253
+ return []
254
+ tags: list[str] = []
255
+ for key in ("methodology", "sprint", "phase"):
256
+ if key in metadata:
257
+ tags.append(f"{key}:{metadata[key]}")
258
+ return tags
259
+
260
+
261
+ def _requirement_to_endpoint(req: dict[str, Any]) -> IngestedEndpoint:
262
+ """Map a BGSTM RequirementResponse dict to an :class:`IngestedEndpoint`.
263
+
264
+ Requirements in BGSTM carry rich context (acceptance criteria, source URL,
265
+ module, priority) that maps naturally to the ``metadata`` of an
266
+ :class:`IngestedEndpoint`. The ``path`` is derived from the requirement's
267
+ ``source_url`` when present, or set to a synthetic ``/requirement/{id}``
268
+ fallback so that every endpoint has a non-empty path.
269
+ """
270
+ req_id = str(req.get("id", ""))
271
+ title = req.get("title", "")
272
+ description = req.get("description", "")
273
+ priority = req.get("priority", "medium")
274
+ status = req.get("status", "")
275
+ module = req.get("module", "")
276
+ tags_raw: list[str] = req.get("tags") or []
277
+ custom_metadata: dict[str, Any] = req.get("custom_metadata") or {}
278
+ source_url: str = req.get("source_url") or ""
279
+ source_system: str = req.get("source_system") or ""
280
+
281
+ # Build path from source_url if it looks like a URL path component
282
+ if source_url:
283
+ parsed = urllib.parse.urlparse(source_url)
284
+ path = parsed.path or source_url
285
+ else:
286
+ path = f"/requirement/{req_id}" if req_id else "/requirement/unknown"
287
+
288
+ tags: list[str] = list(tags_raw) + _methodology_tags(custom_metadata)
289
+ if module:
290
+ tags.append(f"module:{module}")
291
+ if status:
292
+ tags.append(f"status:{status}")
293
+ tags.append("bgstm:requirement")
294
+
295
+ metadata: dict[str, Any] = {
296
+ "bgstm_id": req_id,
297
+ "bgstm_type": "requirement",
298
+ "requirement_type": req.get("type", ""),
299
+ "priority_float": _priority_float(priority),
300
+ "status": status,
301
+ "module": module,
302
+ "source_system": source_system,
303
+ "source_url": source_url,
304
+ "external_id": req.get("external_id") or "",
305
+ "custom_metadata": custom_metadata,
306
+ "version": req.get("version"),
307
+ }
308
+
309
+ return IngestedEndpoint(
310
+ method="GET",
311
+ path=path,
312
+ summary=title,
313
+ description=description,
314
+ tags=tags,
315
+ metadata=metadata,
316
+ )
317
+
318
+
319
+ def _test_case_to_ingest(
320
+ tc: dict[str, Any],
321
+ requirement_ids: list[str] | None = None,
322
+ ) -> IngestedTestCase:
323
+ """Map a BGSTM TestCaseResponse dict to an :class:`IngestedTestCase`.
324
+
325
+ Parameters
326
+ ----------
327
+ tc:
328
+ Raw BGSTM test case dict from the API.
329
+ requirement_ids:
330
+ List of requirement UUIDs that are linked to this test case via
331
+ traceability links. Stored in ``metadata["requirement_ids"]`` so
332
+ that NAT's prioritiser can use the traceability context.
333
+ """
334
+ tc_id = str(tc.get("id", ""))
335
+ title = tc.get("title", "")
336
+ description = tc.get("description", "")
337
+ priority = tc.get("priority", "medium")
338
+ status = tc.get("status", "")
339
+ module = tc.get("module", "")
340
+ tags_raw: list[str] = tc.get("tags") or []
341
+ custom_metadata: dict[str, Any] = tc.get("custom_metadata") or {}
342
+ steps_raw: dict[str, Any] | None = tc.get("steps")
343
+ preconditions: str = tc.get("preconditions") or ""
344
+ postconditions: str = tc.get("postconditions") or ""
345
+
346
+ # Normalise steps from BGSTM's flexible dict format
347
+ steps: list[dict] = []
348
+ if steps_raw and isinstance(steps_raw, dict):
349
+ # Common BGSTM shape: {"1": "Step one", "2": "Step two", ...}
350
+ # or {"steps": [...]} or any other shape — we do a best-effort parse
351
+ if "steps" in steps_raw and isinstance(steps_raw["steps"], list):
352
+ for i, s in enumerate(steps_raw["steps"], 1):
353
+ if isinstance(s, str):
354
+ steps.append({"step": i, "action": s})
355
+ elif isinstance(s, dict):
356
+ steps.append(s)
357
+ else:
358
+ for key in sorted(str(k) for k in steps_raw.keys()):
359
+ steps.append({"step": key, "action": str(steps_raw[key])})
360
+ elif steps_raw and isinstance(steps_raw, list):
361
+ for i, s in enumerate(steps_raw, 1):
362
+ if isinstance(s, str):
363
+ steps.append({"step": i, "action": s})
364
+ elif isinstance(s, dict):
365
+ steps.append(s)
366
+
367
+ tags: list[str] = list(tags_raw) + _methodology_tags(custom_metadata)
368
+ if module:
369
+ tags.append(f"module:{module}")
370
+ if status:
371
+ tags.append(f"status:{status}")
372
+ tags.append("bgstm:test-case")
373
+
374
+ metadata: dict[str, Any] = {
375
+ "bgstm_id": tc_id,
376
+ "bgstm_type": "test_case",
377
+ "test_case_type": tc.get("type", ""),
378
+ "priority_float": _priority_float(priority),
379
+ "status": status,
380
+ "module": module,
381
+ "automation_status": tc.get("automation_status", ""),
382
+ "execution_time_minutes": tc.get("execution_time_minutes"),
383
+ "preconditions": preconditions,
384
+ "postconditions": postconditions,
385
+ "test_data": tc.get("test_data"),
386
+ "source_system": tc.get("source_system") or "",
387
+ "source_url": tc.get("source_url") or "",
388
+ "external_id": tc.get("external_id") or "",
389
+ "custom_metadata": custom_metadata,
390
+ "requirement_ids": requirement_ids or [],
391
+ "version": tc.get("version"),
392
+ }
393
+
394
+ return IngestedTestCase(
395
+ name=title,
396
+ description=description,
397
+ steps=steps,
398
+ expected_result=postconditions,
399
+ priority=_map_priority(priority),
400
+ tags=tags,
401
+ source_ref=f"bgstm:test-case:{tc_id}",
402
+ metadata=metadata,
403
+ )
404
+
405
+
406
+ def _suggestion_to_ingest(sugg: dict[str, Any]) -> IngestedTestCase:
407
+ """Map a BGSTM SuggestionResponse dict to an :class:`IngestedTestCase`.
408
+
409
+ Suggestions are AI-generated traceability hints; they do not have their
410
+ own title or description but they do carry a ``similarity_score`` and a
411
+ ``suggestion_reason``. We synthesise a name and mark the entry with a
412
+ ``source_hint="ai-suggestion"`` tag so that consumers can distinguish them
413
+ from hard test cases.
414
+ """
415
+ sugg_id = str(sugg.get("id", ""))
416
+ req_id = str(sugg.get("requirement_id", ""))
417
+ tc_id = str(sugg.get("test_case_id", ""))
418
+ score: float = float(sugg.get("similarity_score") or 0.0)
419
+ method: str = str(sugg.get("suggestion_method") or "unknown")
420
+ reason: str = sugg.get("suggestion_reason") or ""
421
+ status_val: str = str(sugg.get("status") or "pending")
422
+
423
+ name = f"AI Suggestion: req={req_id[:8]} ↔ tc={tc_id[:8]}"
424
+ description = reason or f"AI-generated link suggestion ({method}), score={score:.3f}"
425
+
426
+ tags = [
427
+ "bgstm:suggestion",
428
+ "source_hint:ai-suggestion",
429
+ f"suggestion_method:{method}",
430
+ f"suggestion_status:{status_val}",
431
+ ]
432
+
433
+ metadata: dict[str, Any] = {
434
+ "bgstm_id": sugg_id,
435
+ "bgstm_type": "suggestion",
436
+ "requirement_id": req_id,
437
+ "test_case_id": tc_id,
438
+ "similarity_score": score,
439
+ "suggestion_method": method,
440
+ "suggestion_reason": reason,
441
+ "status": status_val,
442
+ "reviewed_by": sugg.get("reviewed_by"),
443
+ "feedback": sugg.get("feedback"),
444
+ }
445
+
446
+ return IngestedTestCase(
447
+ name=name,
448
+ description=description,
449
+ priority="low",
450
+ tags=tags,
451
+ source_ref=f"bgstm:suggestion:{sugg_id}",
452
+ metadata=metadata,
453
+ )
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Build a requirement-id → test-case-id mapping from the traceability data
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ def _build_tc_to_req_map(
462
+ traceability: dict[str, Any] | None,
463
+ links: list[dict[str, Any]],
464
+ ) -> dict[str, list[str]]:
465
+ """Return a dict mapping test-case UUID → list of requirement UUIDs.
466
+
467
+ Data from both the ``/api/v1/traceability`` matrix and the ``/api/v1/links``
468
+ list are merged so that partial availability of either endpoint still
469
+ produces a useful result.
470
+ """
471
+ tc_to_reqs: dict[str, list[str]] = {}
472
+
473
+ # 1. From the traceability matrix
474
+ if traceability and isinstance(traceability, dict):
475
+ matrix = traceability.get("matrix") or []
476
+ for row in matrix:
477
+ req_id = str(row.get("requirement_id", ""))
478
+ for ltc in row.get("linked_test_cases") or []:
479
+ tc_id = str(ltc.get("test_case_id", ""))
480
+ if tc_id:
481
+ tc_to_reqs.setdefault(tc_id, [])
482
+ if req_id and req_id not in tc_to_reqs[tc_id]:
483
+ tc_to_reqs[tc_id].append(req_id)
484
+
485
+ # 2. From the links list (may contain additional accepted/pending links)
486
+ for link in links:
487
+ req_id = str(link.get("requirement_id", ""))
488
+ tc_id = str(link.get("test_case_id", ""))
489
+ if tc_id and req_id:
490
+ tc_to_reqs.setdefault(tc_id, [])
491
+ if req_id not in tc_to_reqs[tc_id]:
492
+ tc_to_reqs[tc_id].append(req_id)
493
+
494
+ return tc_to_reqs
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # BGSTMIngestor
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ class BGSTMIngestor(IngestorPlugin):
503
+ """Ingest test plans, requirements, and test cases from a BGSTM instance.
504
+
505
+ The ingestor connects to BGSTM's FastAPI REST API, authenticates, fetches
506
+ all available data, and maps it to NAT's unified
507
+ :class:`~mannf.product.ingestors.models.IngestResult`.
508
+
509
+ Config keys
510
+ -----------
511
+ base_url : str (required)
512
+ BGSTM instance base URL, e.g. ``"http://localhost:8000"``.
513
+ api_token : str (optional)
514
+ Pre-issued API/JWT token. If supplied, ``username``/``password`` are
515
+ not required.
516
+ username : str (optional)
517
+ BGSTM user email. Required when ``api_token`` is not supplied.
518
+ password : str (optional)
519
+ BGSTM user password. Required when ``api_token`` is not supplied.
520
+ include_suggestions : bool (optional, default False)
521
+ Whether to fetch AI-generated link suggestions and include them as
522
+ extra :class:`~mannf.product.ingestors.models.IngestedTestCase` entries.
523
+ """
524
+
525
+ name = "bgstm"
526
+ display_name = "BGSTM Test Plan"
527
+ description = (
528
+ "Ingest requirements, test cases, and traceability links from a "
529
+ "BGSTM instance via its REST API."
530
+ )
531
+ supported_extensions: list[str] = []
532
+ supported_formats: list[str] = ["bgstm"]
533
+
534
+ # ------------------------------------------------------------------
535
+ # can_handle
536
+ # ------------------------------------------------------------------
537
+
538
+ def can_handle(self, source: IngestorSource) -> bool:
539
+ """Return ``True`` for BGSTM sources.
540
+
541
+ Recognises sources where:
542
+
543
+ * ``source.format`` is ``"bgstm"``, or
544
+ * ``source.metadata.get("format_hint")`` is ``"bgstm"``, or
545
+ * ``source.url`` contains a path segment matching the BGSTM API
546
+ prefix ``/api/v1/``.
547
+ """
548
+ if source.format in self.supported_formats:
549
+ return True
550
+ if source.metadata.get("format_hint") == "bgstm":
551
+ return True
552
+ if source.url:
553
+ return "/api/v1/" in source.url or source.url.rstrip("/").endswith("/api/v1")
554
+ return False
555
+
556
+ # ------------------------------------------------------------------
557
+ # validate_config
558
+ # ------------------------------------------------------------------
559
+
560
+ def validate_config(self, config: dict) -> list[str]:
561
+ """Validate BGSTMIngestor configuration.
562
+
563
+ Returns a list of human-readable error strings; empty means valid.
564
+ """
565
+ errors: list[str] = []
566
+ if not config.get("base_url"):
567
+ errors.append("'base_url' is required")
568
+ has_token = bool(config.get("api_token"))
569
+ has_creds = bool(config.get("username")) and bool(config.get("password"))
570
+ if not has_token and not has_creds:
571
+ errors.append(
572
+ "Either 'api_token' or both 'username' and 'password' are required"
573
+ )
574
+ return errors
575
+
576
+ # ------------------------------------------------------------------
577
+ # ingest
578
+ # ------------------------------------------------------------------
579
+
580
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult: # noqa: C901
581
+ """Ingest from a BGSTM instance and return a unified :class:`IngestResult`.
582
+
583
+ Steps
584
+ -----
585
+ 1. Authenticate (API token or JWT login).
586
+ 2. Fetch all requirements (paginated).
587
+ 3. Fetch all test cases (paginated).
588
+ 4. Fetch traceability matrix (best-effort).
589
+ 5. Fetch links (paginated, best-effort).
590
+ 6. Optionally fetch AI suggestions.
591
+ 7. Map everything to NAT dataclasses and return.
592
+ """
593
+ errors: list[str] = []
594
+ warnings: list[str] = []
595
+
596
+ base_url: str = (config.get("base_url") or "").rstrip("/")
597
+ include_suggestions: bool = bool(config.get("include_suggestions", False))
598
+
599
+ # --- Step 1: Authenticate ---
600
+ try:
601
+ token = _authenticate(base_url, config)
602
+ except urllib.error.HTTPError as exc:
603
+ if exc.code == 401:
604
+ return self._make_error_result(
605
+ source,
606
+ "BGSTM authentication failed: invalid credentials (HTTP 401).",
607
+ )
608
+ return self._make_error_result(
609
+ source,
610
+ f"BGSTM authentication failed: HTTP {exc.code} {exc.reason}.",
611
+ )
612
+ except urllib.error.URLError as exc:
613
+ return self._make_error_result(
614
+ source,
615
+ f"Cannot connect to BGSTM at '{base_url}': {exc.reason}.",
616
+ )
617
+ except Exception as exc: # noqa: BLE001
618
+ return self._make_error_result(
619
+ source,
620
+ f"BGSTM authentication error: {exc}",
621
+ )
622
+
623
+ auth_headers = {"Authorization": f"Bearer {token}"}
624
+
625
+ # --- Step 2: Fetch requirements ---
626
+ raw_requirements: list[dict] = []
627
+ try:
628
+ raw_requirements = _fetch_paginated(
629
+ base_url, _API_PREFIX + "/requirements", auth_headers
630
+ )
631
+ except urllib.error.HTTPError as exc:
632
+ if exc.code == 401:
633
+ return self._make_error_result(
634
+ source, "BGSTM token rejected when fetching requirements (HTTP 401)."
635
+ )
636
+ warnings.append(
637
+ f"Could not fetch requirements: HTTP {exc.code} {exc.reason}. "
638
+ "Proceeding without requirements."
639
+ )
640
+ except urllib.error.URLError as exc:
641
+ warnings.append(
642
+ f"Could not fetch requirements: {exc.reason}. "
643
+ "Proceeding without requirements."
644
+ )
645
+ except Exception as exc: # noqa: BLE001
646
+ warnings.append(f"Could not fetch requirements: {exc}. Proceeding without requirements.")
647
+
648
+ # --- Step 3: Fetch test cases ---
649
+ raw_test_cases: list[dict] = []
650
+ try:
651
+ raw_test_cases = _fetch_paginated(
652
+ base_url, _API_PREFIX + "/test-cases", auth_headers
653
+ )
654
+ except urllib.error.HTTPError as exc:
655
+ if exc.code == 401:
656
+ return self._make_error_result(
657
+ source, "BGSTM token rejected when fetching test cases (HTTP 401)."
658
+ )
659
+ warnings.append(
660
+ f"Could not fetch test cases: HTTP {exc.code} {exc.reason}. "
661
+ "Proceeding without test cases."
662
+ )
663
+ except urllib.error.URLError as exc:
664
+ warnings.append(
665
+ f"Could not fetch test cases: {exc.reason}. "
666
+ "Proceeding without test cases."
667
+ )
668
+ except Exception as exc: # noqa: BLE001
669
+ warnings.append(f"Could not fetch test cases: {exc}. Proceeding without test cases.")
670
+
671
+ # --- Step 4: Fetch traceability matrix (best-effort) ---
672
+ traceability_data: dict[str, Any] | None = None
673
+ try:
674
+ trace_url = base_url + _API_PREFIX + "/traceability"
675
+ traceability_data = _json_request(trace_url, headers=auth_headers)
676
+ except urllib.error.HTTPError as exc:
677
+ warnings.append(
678
+ f"Traceability endpoint unavailable: HTTP {exc.code} {exc.reason}. "
679
+ "Test cases will not have requirement associations from traceability."
680
+ )
681
+ except Exception as exc: # noqa: BLE001
682
+ warnings.append(
683
+ f"Could not fetch traceability matrix: {exc}. "
684
+ "Test cases will not have requirement associations from traceability."
685
+ )
686
+
687
+ # --- Step 5: Fetch links (best-effort) ---
688
+ raw_links: list[dict] = []
689
+ try:
690
+ raw_links = _fetch_paginated(
691
+ base_url, _API_PREFIX + "/links", auth_headers
692
+ )
693
+ except Exception as exc: # noqa: BLE001
694
+ warnings.append(
695
+ f"Could not fetch links: {exc}. "
696
+ "Test cases will not have requirement associations from links."
697
+ )
698
+
699
+ # --- Step 6: Optionally fetch AI suggestions ---
700
+ raw_suggestions: list[dict] = []
701
+ if include_suggestions:
702
+ try:
703
+ raw_suggestions = _fetch_paginated(
704
+ base_url, _API_PREFIX + "/suggestions", auth_headers
705
+ )
706
+ except Exception as exc: # noqa: BLE001
707
+ warnings.append(
708
+ f"Could not fetch AI suggestions: {exc}. "
709
+ "Proceeding without suggestions."
710
+ )
711
+
712
+ # --- Step 7: Build traceability map ---
713
+ tc_to_reqs = _build_tc_to_req_map(traceability_data, raw_links)
714
+
715
+ # --- Step 8: Map requirements → IngestedEndpoints ---
716
+ endpoints: list[IngestedEndpoint] = []
717
+ for req in raw_requirements:
718
+ try:
719
+ endpoints.append(_requirement_to_endpoint(req))
720
+ except Exception as exc: # noqa: BLE001
721
+ req_id = req.get("id", "?")
722
+ warnings.append(f"Skipped requirement {req_id}: {exc}")
723
+
724
+ # --- Step 9: Map test cases → IngestedTestCases ---
725
+ test_cases: list[IngestedTestCase] = []
726
+ for tc in raw_test_cases:
727
+ try:
728
+ tc_id = str(tc.get("id", ""))
729
+ req_ids = tc_to_reqs.get(tc_id, [])
730
+ test_cases.append(_test_case_to_ingest(tc, requirement_ids=req_ids))
731
+ except Exception as exc: # noqa: BLE001
732
+ tc_id_s = tc.get("id", "?")
733
+ warnings.append(f"Skipped test case {tc_id_s}: {exc}")
734
+
735
+ # --- Step 10: Map suggestions → IngestedTestCases ---
736
+ suggestion_count = 0
737
+ for sugg in raw_suggestions:
738
+ try:
739
+ test_cases.append(_suggestion_to_ingest(sugg))
740
+ suggestion_count += 1
741
+ except Exception as exc: # noqa: BLE001
742
+ sugg_id = sugg.get("id", "?")
743
+ warnings.append(f"Skipped suggestion {sugg_id}: {exc}")
744
+
745
+ # --- Stats ---
746
+ stats: dict[str, Any] = {
747
+ "requirements_count": len(raw_requirements),
748
+ "endpoints_count": len(endpoints),
749
+ "test_cases_count": len(raw_test_cases),
750
+ "suggestions_count": suggestion_count,
751
+ "total_test_cases_count": len(test_cases),
752
+ "links_count": len(raw_links),
753
+ "base_url": base_url,
754
+ }
755
+
756
+ return IngestResult(
757
+ success=True,
758
+ source=source,
759
+ endpoints=endpoints,
760
+ test_cases=test_cases,
761
+ errors=errors,
762
+ warnings=warnings,
763
+ stats=stats,
764
+ )