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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
)
|