testops-mirror 0.1.0__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.
- testops_mirror/__init__.py +3 -0
- testops_mirror/cli.py +149 -0
- testops_mirror/connectors/__init__.py +1 -0
- testops_mirror/connectors/allure_testops.py +380 -0
- testops_mirror/connectors/base.py +31 -0
- testops_mirror/exceptions.py +25 -0
- testops_mirror/gitstore.py +154 -0
- testops_mirror/models.py +45 -0
- testops_mirror/serializer.py +217 -0
- testops_mirror/sync.py +64 -0
- testops_mirror-0.1.0.dist-info/METADATA +164 -0
- testops_mirror-0.1.0.dist-info/RECORD +15 -0
- testops_mirror-0.1.0.dist-info/WHEEL +4 -0
- testops_mirror-0.1.0.dist-info/entry_points.txt +2 -0
- testops_mirror-0.1.0.dist-info/licenses/LICENSE +21 -0
testops_mirror/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Command-line interface for testops-mirror."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
13
|
+
|
|
14
|
+
from testops_mirror.connectors.allure_testops import AllureTestOpsConnector
|
|
15
|
+
from testops_mirror.exceptions import AuthError, TestopsMirrorError
|
|
16
|
+
from testops_mirror.gitstore import GitStore
|
|
17
|
+
from testops_mirror.models import TestCase
|
|
18
|
+
from testops_mirror.sync import run_sync
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="testops-mirror",
|
|
22
|
+
help="Mirror test cases from a TMS into a Git repository.",
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
err_console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ProjectId = Annotated[
|
|
30
|
+
str, typer.Option("--project-id", help="TMS project ID.", envvar="TESTOPS_PROJECT_ID")
|
|
31
|
+
]
|
|
32
|
+
_Endpoint = Annotated[
|
|
33
|
+
str, typer.Option("--endpoint", help="Base URL of the TMS.", envvar="TESTOPS_ENDPOINT")
|
|
34
|
+
]
|
|
35
|
+
_Token = Annotated[str, typer.Option("--token", help="API token.", envvar="TESTOPS_TOKEN")]
|
|
36
|
+
_Repo = Annotated[str, typer.Option("--repo", help="Local git repository path.")]
|
|
37
|
+
_SuiteField = Annotated[
|
|
38
|
+
str,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--suite-field",
|
|
41
|
+
help="Custom field for folder structure.",
|
|
42
|
+
envvar="TESTOPS_SUITE_FIELD",
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
_DryRun = Annotated[bool, typer.Option("--dry-run", help="Preview changes without writing.")]
|
|
46
|
+
_Verbose = Annotated[bool, typer.Option("-v", "--verbose", help="Verbose logging.")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def sync(
|
|
51
|
+
project_id: _ProjectId,
|
|
52
|
+
endpoint: _Endpoint,
|
|
53
|
+
token: _Token,
|
|
54
|
+
repo: _Repo = "./mirror",
|
|
55
|
+
suite_field: _SuiteField = "Suite",
|
|
56
|
+
dry_run: _DryRun = False,
|
|
57
|
+
verbose: _Verbose = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Mirror all test cases from a TMS project into a local Git repository."""
|
|
60
|
+
load_dotenv()
|
|
61
|
+
|
|
62
|
+
logging.basicConfig(
|
|
63
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
64
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
connector = AllureTestOpsConnector(
|
|
68
|
+
endpoint=endpoint,
|
|
69
|
+
api_token=token,
|
|
70
|
+
suite_field=suite_field,
|
|
71
|
+
)
|
|
72
|
+
store = GitStore(repo)
|
|
73
|
+
|
|
74
|
+
fetched: list[TestCase] = []
|
|
75
|
+
|
|
76
|
+
def _on_case(case: TestCase) -> None:
|
|
77
|
+
fetched.append(case)
|
|
78
|
+
if len(fetched) % 50 == 0:
|
|
79
|
+
logging.getLogger(__name__).info("Fetched %d cases so far...", len(fetched))
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if dry_run:
|
|
83
|
+
with Progress(
|
|
84
|
+
SpinnerColumn(),
|
|
85
|
+
TextColumn("[progress.description]{task.description}"),
|
|
86
|
+
console=console,
|
|
87
|
+
transient=True,
|
|
88
|
+
) as progress:
|
|
89
|
+
task = progress.add_task("Fetching test cases...", total=None)
|
|
90
|
+
changes, _ = run_sync(
|
|
91
|
+
connector,
|
|
92
|
+
store,
|
|
93
|
+
project_id,
|
|
94
|
+
dry_run=True,
|
|
95
|
+
on_case=_on_case,
|
|
96
|
+
)
|
|
97
|
+
progress.update(task, completed=True)
|
|
98
|
+
|
|
99
|
+
console.print(f"Found [bold]{len(fetched)}[/bold] test cases")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
for path in changes.added:
|
|
103
|
+
console.print(f"[green]+[/green] {path}")
|
|
104
|
+
for path in changes.updated:
|
|
105
|
+
console.print(f"[yellow]~[/yellow] {path}")
|
|
106
|
+
for path in changes.deleted:
|
|
107
|
+
console.print(f"[red]-[/red] {path}")
|
|
108
|
+
|
|
109
|
+
if not changes.empty:
|
|
110
|
+
console.print()
|
|
111
|
+
console.print(
|
|
112
|
+
f"[bold]{len(changes.added)}[/bold] to add, "
|
|
113
|
+
f"[bold]{len(changes.updated)}[/bold] to update, "
|
|
114
|
+
f"[bold]{len(changes.deleted)}[/bold] to delete"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
else:
|
|
118
|
+
with Progress(
|
|
119
|
+
SpinnerColumn(),
|
|
120
|
+
TextColumn("[progress.description]{task.description}"),
|
|
121
|
+
console=console,
|
|
122
|
+
) as progress:
|
|
123
|
+
task = progress.add_task("Fetching test cases...", total=None)
|
|
124
|
+
changes, sha = run_sync(
|
|
125
|
+
connector,
|
|
126
|
+
store,
|
|
127
|
+
project_id,
|
|
128
|
+
on_case=_on_case,
|
|
129
|
+
)
|
|
130
|
+
progress.update(
|
|
131
|
+
task,
|
|
132
|
+
description=f"Fetched {len(fetched)} test cases",
|
|
133
|
+
completed=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if sha:
|
|
137
|
+
console.print(f"[green]Committed[/green] {sha[:8]} — {changes.summary()}")
|
|
138
|
+
else:
|
|
139
|
+
console.print("[dim]Nothing to commit[/dim]")
|
|
140
|
+
|
|
141
|
+
except AuthError as exc:
|
|
142
|
+
err_console.print(f"[red]Authentication failed:[/red] {exc}")
|
|
143
|
+
raise typer.Exit(1) from exc
|
|
144
|
+
except TestopsMirrorError as exc:
|
|
145
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
146
|
+
raise typer.Exit(1) from exc
|
|
147
|
+
except KeyboardInterrupt:
|
|
148
|
+
err_console.print("\n[yellow]Interrupted[/yellow]")
|
|
149
|
+
sys.exit(130)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TMS connector implementations."""
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Allure TestOps connector.
|
|
2
|
+
|
|
3
|
+
Implements the TmsConnector protocol for Allure TestOps instances.
|
|
4
|
+
|
|
5
|
+
API paths are defined as module-level constants — verify them against your
|
|
6
|
+
instance's Swagger UI (<endpoint>/swagger-ui/) as paths may differ between
|
|
7
|
+
TestOps versions.
|
|
8
|
+
|
|
9
|
+
Authentication flow (confirmed by official docs):
|
|
10
|
+
POST /api/uaa/oauth/token (form: grant_type=apitoken, scope=openid, token=<api_token>)
|
|
11
|
+
-> { access_token, expires_in }
|
|
12
|
+
The JWT is then used as Bearer for all /api/rs/* requests.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from testops_mirror.exceptions import AuthError, ConnectorError, NotFoundError, RateLimitError
|
|
25
|
+
from testops_mirror.models import Link, Step, TestCase
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# API path constants
|
|
31
|
+
# Verify against <endpoint>/swagger-ui/ for your TestOps version.
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
_PATH_TOKEN = "/api/uaa/oauth/token"
|
|
34
|
+
_PATH_TESTCASE_LIST = "/api/rs/testcase" # ?projectId=&page=&size=
|
|
35
|
+
_PATH_TESTCASE_DETAIL = "/api/rs/testcase/{id}"
|
|
36
|
+
_PATH_TESTCASE_SCENARIO = "/api/rs/testcase/{id}/scenario"
|
|
37
|
+
_PATH_TESTCASE_STEP = "/api/rs/testcase/{id}/step"
|
|
38
|
+
|
|
39
|
+
_PAGE_SIZE = 100
|
|
40
|
+
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
41
|
+
_MAX_RETRIES = 3
|
|
42
|
+
_RETRY_BASE_DELAY = 1.0 # seconds; doubled on each attempt
|
|
43
|
+
_TOKEN_REFRESH_BUFFER = 60 # seconds before expiry to proactively refresh
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AllureTestOpsConnector:
|
|
47
|
+
"""Connector for Allure TestOps.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
endpoint:
|
|
52
|
+
Base URL of the TestOps instance, e.g. ``https://testops.example.com``.
|
|
53
|
+
api_token:
|
|
54
|
+
API token generated in TestOps → Profile → API tokens.
|
|
55
|
+
suite_field:
|
|
56
|
+
Name of the custom field used to derive the folder hierarchy.
|
|
57
|
+
Values like ``"Shipments/Negative"`` are split on ``/`` to produce
|
|
58
|
+
nested directories.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
endpoint: str,
|
|
64
|
+
api_token: str,
|
|
65
|
+
suite_field: str = "Suite",
|
|
66
|
+
*,
|
|
67
|
+
http_client: httpx.Client | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._endpoint = endpoint.rstrip("/")
|
|
70
|
+
self._api_token = api_token
|
|
71
|
+
self._suite_field = suite_field
|
|
72
|
+
self._client = http_client or httpx.Client(timeout=30)
|
|
73
|
+
self._jwt: str | None = None
|
|
74
|
+
self._jwt_expires_at: float = 0.0
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Authentication
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _authenticate(self) -> None:
|
|
81
|
+
"""Exchange the API token for a short-lived JWT."""
|
|
82
|
+
url = self._endpoint + _PATH_TOKEN
|
|
83
|
+
try:
|
|
84
|
+
resp = self._client.post(
|
|
85
|
+
url,
|
|
86
|
+
data={
|
|
87
|
+
"grant_type": "apitoken",
|
|
88
|
+
"scope": "openid",
|
|
89
|
+
"token": self._api_token,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
except httpx.HTTPError as exc:
|
|
93
|
+
raise ConnectorError(f"Auth request failed: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
if resp.status_code in (401, 403):
|
|
96
|
+
raise AuthError(f"Authentication failed ({resp.status_code}): {resp.text}")
|
|
97
|
+
if not resp.is_success:
|
|
98
|
+
raise ConnectorError(f"Auth endpoint returned {resp.status_code}: {resp.text}")
|
|
99
|
+
|
|
100
|
+
data = resp.json()
|
|
101
|
+
self._jwt = data["access_token"]
|
|
102
|
+
expires_in: int = data.get("expires_in", 3600)
|
|
103
|
+
self._jwt_expires_at = time.monotonic() + expires_in
|
|
104
|
+
|
|
105
|
+
def _get_bearer(self) -> str:
|
|
106
|
+
"""Return a valid JWT, refreshing proactively if close to expiry."""
|
|
107
|
+
if self._jwt is None or time.monotonic() >= self._jwt_expires_at - _TOKEN_REFRESH_BUFFER:
|
|
108
|
+
self._authenticate()
|
|
109
|
+
assert self._jwt is not None
|
|
110
|
+
return self._jwt
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# HTTP with retry
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
117
|
+
"""Send an authenticated request with retry logic.
|
|
118
|
+
|
|
119
|
+
Retries on 429 and 5xx up to _MAX_RETRIES times using exponential
|
|
120
|
+
backoff. On 401 attempts one token refresh before retrying.
|
|
121
|
+
Raises typed exceptions for all terminal error conditions.
|
|
122
|
+
"""
|
|
123
|
+
url = self._endpoint + path
|
|
124
|
+
delay = _RETRY_BASE_DELAY
|
|
125
|
+
last_exc: Exception | None = None
|
|
126
|
+
|
|
127
|
+
for attempt in range(_MAX_RETRIES):
|
|
128
|
+
headers = {"Authorization": f"Bearer {self._get_bearer()}"}
|
|
129
|
+
try:
|
|
130
|
+
resp = self._client.request(method, url, headers=headers, **kwargs)
|
|
131
|
+
except httpx.HTTPError as exc:
|
|
132
|
+
last_exc = exc
|
|
133
|
+
if attempt < _MAX_RETRIES - 1:
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
delay *= 2
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if resp.status_code == 401:
|
|
139
|
+
if attempt < _MAX_RETRIES - 1:
|
|
140
|
+
# Force token refresh and retry once
|
|
141
|
+
self._jwt = None
|
|
142
|
+
continue
|
|
143
|
+
raise AuthError(f"Persistent 401 on {path}")
|
|
144
|
+
|
|
145
|
+
if resp.status_code == 403:
|
|
146
|
+
raise AuthError(f"Forbidden (403) on {path}")
|
|
147
|
+
|
|
148
|
+
if resp.status_code == 404:
|
|
149
|
+
raise NotFoundError(f"Resource not found: {path}")
|
|
150
|
+
|
|
151
|
+
if resp.status_code == 429:
|
|
152
|
+
retry_after = float(resp.headers.get("Retry-After", delay))
|
|
153
|
+
logger.warning("Rate limited (429); sleeping %.1fs", retry_after)
|
|
154
|
+
if attempt < _MAX_RETRIES - 1:
|
|
155
|
+
time.sleep(retry_after)
|
|
156
|
+
delay *= 2
|
|
157
|
+
last_exc = RateLimitError(f"Rate limit on {path}")
|
|
158
|
+
continue
|
|
159
|
+
raise RateLimitError(f"Rate limit exceeded after {_MAX_RETRIES} attempts on {path}")
|
|
160
|
+
|
|
161
|
+
if resp.status_code >= 500:
|
|
162
|
+
logger.warning(
|
|
163
|
+
"Server error %d on %s; attempt %d/%d",
|
|
164
|
+
resp.status_code,
|
|
165
|
+
path,
|
|
166
|
+
attempt + 1,
|
|
167
|
+
_MAX_RETRIES,
|
|
168
|
+
)
|
|
169
|
+
if attempt < _MAX_RETRIES - 1:
|
|
170
|
+
time.sleep(delay)
|
|
171
|
+
delay *= 2
|
|
172
|
+
last_exc = ConnectorError(f"Server error {resp.status_code} on {path}")
|
|
173
|
+
continue
|
|
174
|
+
raise ConnectorError(
|
|
175
|
+
f"Server error {resp.status_code} after {_MAX_RETRIES} attempts on {path}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if not resp.is_success:
|
|
179
|
+
raise ConnectorError(f"Unexpected {resp.status_code} on {path}: {resp.text}")
|
|
180
|
+
|
|
181
|
+
return resp
|
|
182
|
+
|
|
183
|
+
if last_exc is not None:
|
|
184
|
+
raise ConnectorError(f"All {_MAX_RETRIES} attempts failed for {path}") from last_exc
|
|
185
|
+
raise ConnectorError(f"Request failed for {path}") # unreachable, satisfies mypy
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Steps — fallback chain
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _fetch_steps(self, case_id: str) -> list[dict[str, Any]]:
|
|
192
|
+
"""Fetch steps using a fallback chain.
|
|
193
|
+
|
|
194
|
+
Known bug in some TestOps versions: /scenario returns empty steps.
|
|
195
|
+
See: github.com/orgs/allure-framework/discussions/3190
|
|
196
|
+
Try /scenario first; if the result is empty fall back to /step.
|
|
197
|
+
4xx responses are silently ignored (return empty list).
|
|
198
|
+
"""
|
|
199
|
+
for path_tpl in (_PATH_TESTCASE_SCENARIO, _PATH_TESTCASE_STEP):
|
|
200
|
+
path = path_tpl.format(id=case_id)
|
|
201
|
+
try:
|
|
202
|
+
resp = self._request("GET", path)
|
|
203
|
+
data = resp.json()
|
|
204
|
+
steps: list[dict[str, Any]] = (
|
|
205
|
+
data if isinstance(data, list) else data.get("steps", data.get("content", []))
|
|
206
|
+
)
|
|
207
|
+
if steps:
|
|
208
|
+
return steps
|
|
209
|
+
except (NotFoundError, ConnectorError):
|
|
210
|
+
pass
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Mapping helpers
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _extract_status(raw: Any) -> str | None:
|
|
219
|
+
if isinstance(raw, dict):
|
|
220
|
+
return str(raw["name"]) if "name" in raw else None
|
|
221
|
+
if isinstance(raw, str):
|
|
222
|
+
return raw or None
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _extract_tags(raw: Any) -> list[str]:
|
|
227
|
+
if not isinstance(raw, list):
|
|
228
|
+
return []
|
|
229
|
+
result = []
|
|
230
|
+
for item in raw:
|
|
231
|
+
if isinstance(item, dict):
|
|
232
|
+
name = item.get("name")
|
|
233
|
+
if name:
|
|
234
|
+
result.append(str(name))
|
|
235
|
+
elif isinstance(item, str) and item:
|
|
236
|
+
result.append(item)
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
def _extract_custom_fields(self, raw: Any) -> dict[str, list[str]]:
|
|
240
|
+
"""Extract custom fields from the API response.
|
|
241
|
+
|
|
242
|
+
Expected shape (verify against your instance):
|
|
243
|
+
customFields[].customField.name — field name
|
|
244
|
+
customFields[].name — field value
|
|
245
|
+
"""
|
|
246
|
+
if not isinstance(raw, list):
|
|
247
|
+
return {}
|
|
248
|
+
result: dict[str, list[str]] = {}
|
|
249
|
+
for item in raw:
|
|
250
|
+
if not isinstance(item, dict):
|
|
251
|
+
continue
|
|
252
|
+
cf = item.get("customField", {})
|
|
253
|
+
field_name: str = cf.get("name", "") if isinstance(cf, dict) else ""
|
|
254
|
+
value: str = item.get("name", "")
|
|
255
|
+
if field_name and value:
|
|
256
|
+
result.setdefault(field_name, []).append(value)
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
def _extract_suite_path(self, custom_fields: dict[str, list[str]]) -> tuple[str, ...]:
|
|
260
|
+
"""Derive suite folder path from the configured custom field.
|
|
261
|
+
|
|
262
|
+
A value of ``"Shipments/Negative"`` becomes ``("Shipments", "Negative")``.
|
|
263
|
+
Cases without the field land in the repo root.
|
|
264
|
+
"""
|
|
265
|
+
values = custom_fields.get(self._suite_field, [])
|
|
266
|
+
if not values:
|
|
267
|
+
return ()
|
|
268
|
+
# Use the first value; split on "/" for nested paths
|
|
269
|
+
return tuple(part.strip() for part in values[0].split("/") if part.strip())
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _map_steps(raw: list[dict[str, Any]]) -> list[Step]:
|
|
273
|
+
result = []
|
|
274
|
+
for item in raw:
|
|
275
|
+
if not isinstance(item, dict):
|
|
276
|
+
continue
|
|
277
|
+
name: str = item.get("name") or item.get("keyword") or ""
|
|
278
|
+
if not name:
|
|
279
|
+
continue
|
|
280
|
+
expected: str | None = item.get("expectedResult") or item.get("expected_result") or None
|
|
281
|
+
nested_raw: list[dict[str, Any]] = item.get("steps", [])
|
|
282
|
+
result.append(
|
|
283
|
+
Step(
|
|
284
|
+
name=name,
|
|
285
|
+
expected_result=expected,
|
|
286
|
+
steps=AllureTestOpsConnector._map_steps(nested_raw),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _map_links(raw: Any) -> list[Link]:
|
|
293
|
+
if not isinstance(raw, list):
|
|
294
|
+
return []
|
|
295
|
+
result = []
|
|
296
|
+
for item in raw:
|
|
297
|
+
if not isinstance(item, dict):
|
|
298
|
+
continue
|
|
299
|
+
url: str = item.get("url", "")
|
|
300
|
+
if not url:
|
|
301
|
+
continue
|
|
302
|
+
result.append(
|
|
303
|
+
Link(
|
|
304
|
+
name=item.get("name") or None,
|
|
305
|
+
url=url,
|
|
306
|
+
type=item.get("type") or None,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
def _map_case(
|
|
312
|
+
self,
|
|
313
|
+
detail: dict[str, Any],
|
|
314
|
+
steps_raw: list[dict[str, Any]],
|
|
315
|
+
project_id: str,
|
|
316
|
+
) -> TestCase:
|
|
317
|
+
case_id = str(detail["id"])
|
|
318
|
+
custom_fields = self._extract_custom_fields(detail.get("customFields"))
|
|
319
|
+
suite_path = self._extract_suite_path(custom_fields)
|
|
320
|
+
|
|
321
|
+
return TestCase(
|
|
322
|
+
id=case_id,
|
|
323
|
+
name=detail.get("name", ""),
|
|
324
|
+
description=detail.get("description") or None,
|
|
325
|
+
precondition=detail.get("precondition") or None,
|
|
326
|
+
expected_result=detail.get("expectedResult") or None,
|
|
327
|
+
status=self._extract_status(detail.get("status")),
|
|
328
|
+
automated=bool(detail.get("automated", False)),
|
|
329
|
+
tags=self._extract_tags(detail.get("tags")),
|
|
330
|
+
custom_fields=custom_fields,
|
|
331
|
+
links=self._map_links(detail.get("links")),
|
|
332
|
+
suite_path=suite_path,
|
|
333
|
+
steps=self._map_steps(steps_raw),
|
|
334
|
+
source_url=f"{self._endpoint}/project/{project_id}/test-cases/{case_id}",
|
|
335
|
+
source_project=project_id,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
# Public interface
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def iter_test_cases(self, project_id: str) -> Iterator[TestCase]:
|
|
343
|
+
"""Yield all test cases for *project_id*.
|
|
344
|
+
|
|
345
|
+
Fetches the full list page by page, then loads details and steps for
|
|
346
|
+
each case. A ConnectorError on a single case is logged as WARNING and
|
|
347
|
+
that case is skipped; it does not abort the entire sync.
|
|
348
|
+
|
|
349
|
+
Raises AuthError immediately if token exchange fails — this is not
|
|
350
|
+
recoverable per-case.
|
|
351
|
+
"""
|
|
352
|
+
page = 0
|
|
353
|
+
while True:
|
|
354
|
+
resp = self._request(
|
|
355
|
+
"GET",
|
|
356
|
+
_PATH_TESTCASE_LIST,
|
|
357
|
+
params={"projectId": project_id, "page": page, "size": _PAGE_SIZE},
|
|
358
|
+
)
|
|
359
|
+
data = resp.json()
|
|
360
|
+
items: list[dict[str, Any]] = data.get("content", [])
|
|
361
|
+
total_pages: int = data.get("totalPages", 1)
|
|
362
|
+
|
|
363
|
+
for item in items:
|
|
364
|
+
case_id = str(item.get("id", ""))
|
|
365
|
+
if not case_id:
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
detail_resp = self._request("GET", _PATH_TESTCASE_DETAIL.format(id=case_id))
|
|
369
|
+
detail: dict[str, Any] = detail_resp.json()
|
|
370
|
+
steps_raw = self._fetch_steps(case_id)
|
|
371
|
+
yield self._map_case(detail, steps_raw, project_id)
|
|
372
|
+
except AuthError:
|
|
373
|
+
raise
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
logger.warning("Skipping test case %s due to error: %s", case_id, exc)
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
page += 1
|
|
379
|
+
if page >= total_pages:
|
|
380
|
+
break
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Base protocol for TMS connectors.
|
|
2
|
+
|
|
3
|
+
Any new connector must implement :class:`TmsConnector` — a single method
|
|
4
|
+
``iter_test_cases`` that yields canonical :class:`~testops_mirror.models.TestCase`
|
|
5
|
+
objects. No connector-specific types should leak outside the connector module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from testops_mirror.models import TestCase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class TmsConnector(Protocol):
|
|
18
|
+
"""Structural protocol for TMS connectors.
|
|
19
|
+
|
|
20
|
+
To add a new connector, implement this method and wire it up in the CLI.
|
|
21
|
+
See CONTRIBUTING.md for details.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def iter_test_cases(self, project_id: str) -> Iterator[TestCase]:
|
|
25
|
+
"""Yield all test cases for *project_id* as canonical TestCase objects.
|
|
26
|
+
|
|
27
|
+
Must raise:
|
|
28
|
+
- :class:`~testops_mirror.exceptions.AuthError` on 401/403
|
|
29
|
+
- :class:`~testops_mirror.exceptions.ConnectorError` on unrecoverable errors
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exception hierarchy for testops-mirror.
|
|
2
|
+
|
|
3
|
+
All exceptions raised by connectors and core modules are subclasses of
|
|
4
|
+
TestopsMirrorError so callers can catch them with a single except clause.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestopsMirrorError(Exception):
|
|
9
|
+
"""Base exception for all testops-mirror errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(TestopsMirrorError):
|
|
13
|
+
"""Authentication or authorisation failure (401/403)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(TestopsMirrorError):
|
|
17
|
+
"""Requested resource does not exist (404)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(TestopsMirrorError):
|
|
21
|
+
"""API rate limit exceeded (429)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConnectorError(TestopsMirrorError):
|
|
25
|
+
"""Generic connector-level error (5xx, network, parse)."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Git-backed storage for mirrored test cases.
|
|
2
|
+
|
|
3
|
+
All test-case files live under the ``cases/`` subdirectory of the managed
|
|
4
|
+
repository. The store is intentionally TMS-agnostic: it operates on
|
|
5
|
+
(relpath -> content) mappings produced by the serializer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from git import Actor, InvalidGitRepositoryError, Repo
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
CASES_DIR = "cases"
|
|
20
|
+
DEFAULT_AUTHOR_NAME = "testops-mirror"
|
|
21
|
+
DEFAULT_AUTHOR_EMAIL = "testops-mirror@localhost"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ChangeSet:
|
|
26
|
+
added: list[str] = field(default_factory=list)
|
|
27
|
+
updated: list[str] = field(default_factory=list)
|
|
28
|
+
deleted: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def empty(self) -> bool:
|
|
32
|
+
return not (self.added or self.updated or self.deleted)
|
|
33
|
+
|
|
34
|
+
def summary(self) -> str:
|
|
35
|
+
parts = []
|
|
36
|
+
if self.added:
|
|
37
|
+
parts.append(f"{len(self.added)} added")
|
|
38
|
+
if self.updated:
|
|
39
|
+
parts.append(f"{len(self.updated)} updated")
|
|
40
|
+
if self.deleted:
|
|
41
|
+
parts.append(f"{len(self.deleted)} deleted")
|
|
42
|
+
return ", ".join(parts) if parts else "no changes"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GitStore:
|
|
46
|
+
"""Manage a ``cases/`` directory inside a Git repository."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
repo_path: str | Path,
|
|
51
|
+
author_name: str = DEFAULT_AUTHOR_NAME,
|
|
52
|
+
author_email: str = DEFAULT_AUTHOR_EMAIL,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._root = Path(repo_path)
|
|
55
|
+
self._author = Actor(author_name, author_email)
|
|
56
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
try:
|
|
58
|
+
self._repo = Repo(self._root)
|
|
59
|
+
except InvalidGitRepositoryError:
|
|
60
|
+
self._repo = Repo.init(self._root)
|
|
61
|
+
logger.info("Initialised new git repository at %s", self._root)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def cases_dir(self) -> Path:
|
|
65
|
+
return self._root / CASES_DIR
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Plan
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def plan(self, desired: dict[str, str]) -> ChangeSet:
|
|
72
|
+
"""Compare *desired* (relpath -> content) against the working tree.
|
|
73
|
+
|
|
74
|
+
Returns a :class:`ChangeSet` describing what needs to change.
|
|
75
|
+
All lists in the result are sorted for deterministic output.
|
|
76
|
+
"""
|
|
77
|
+
existing = self._read_existing()
|
|
78
|
+
|
|
79
|
+
desired_keys = set(desired)
|
|
80
|
+
existing_keys = set(existing)
|
|
81
|
+
|
|
82
|
+
added = sorted(desired_keys - existing_keys)
|
|
83
|
+
deleted = sorted(existing_keys - desired_keys)
|
|
84
|
+
updated = sorted(k for k in desired_keys & existing_keys if desired[k] != existing[k])
|
|
85
|
+
|
|
86
|
+
return ChangeSet(added=added, updated=updated, deleted=deleted)
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Apply
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def apply(
|
|
93
|
+
self,
|
|
94
|
+
desired: dict[str, str],
|
|
95
|
+
changes: ChangeSet,
|
|
96
|
+
message: str | None = None,
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Write/delete files according to *changes* and create a git commit.
|
|
99
|
+
|
|
100
|
+
Returns the commit SHA, or ``None`` if *changes* is empty.
|
|
101
|
+
"""
|
|
102
|
+
if changes.empty:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
for relpath in changes.added + changes.updated:
|
|
106
|
+
abs_path = self.cases_dir / relpath
|
|
107
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
abs_path.write_text(desired[relpath], encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
for relpath in changes.deleted:
|
|
111
|
+
abs_path = self.cases_dir / relpath
|
|
112
|
+
if abs_path.exists():
|
|
113
|
+
abs_path.unlink()
|
|
114
|
+
|
|
115
|
+
self._prune_empty_dirs()
|
|
116
|
+
|
|
117
|
+
self._repo.git.add(str(self.cases_dir))
|
|
118
|
+
|
|
119
|
+
if message is None:
|
|
120
|
+
ts = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
121
|
+
message = f"sync: {changes.summary()} ({ts})"
|
|
122
|
+
|
|
123
|
+
commit = self._repo.index.commit(
|
|
124
|
+
message,
|
|
125
|
+
author=self._author,
|
|
126
|
+
committer=self._author,
|
|
127
|
+
)
|
|
128
|
+
logger.info("Created commit %s: %s", commit.hexsha[:8], message)
|
|
129
|
+
return str(commit.hexsha)
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Internal helpers
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _read_existing(self) -> dict[str, str]:
|
|
136
|
+
"""Return all *.md files under cases/ as {relpath: content}."""
|
|
137
|
+
if not self.cases_dir.exists():
|
|
138
|
+
return {}
|
|
139
|
+
result: dict[str, str] = {}
|
|
140
|
+
for md_file in self.cases_dir.rglob("*.md"):
|
|
141
|
+
relpath = str(md_file.relative_to(self.cases_dir))
|
|
142
|
+
result[relpath] = md_file.read_text(encoding="utf-8")
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
def _prune_empty_dirs(self) -> None:
|
|
146
|
+
"""Remove empty subdirectories under cases/ (bottom-up)."""
|
|
147
|
+
if not self.cases_dir.exists():
|
|
148
|
+
return
|
|
149
|
+
for dirpath in sorted(self.cases_dir.rglob("*"), reverse=True):
|
|
150
|
+
if dirpath.is_dir() and dirpath != self.cases_dir:
|
|
151
|
+
try:
|
|
152
|
+
dirpath.rmdir()
|
|
153
|
+
except OSError:
|
|
154
|
+
pass
|
testops_mirror/models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Canonical TMS-agnostic data models.
|
|
2
|
+
|
|
3
|
+
These models are the single source of truth between connectors and the
|
|
4
|
+
serializer. No connector-specific fields belong here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Step(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
expected_result: str | None = None
|
|
15
|
+
steps: list[Step] = []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Required for Pydantic v2 self-referential model
|
|
19
|
+
Step.model_rebuild()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Link(BaseModel):
|
|
23
|
+
name: str | None = None
|
|
24
|
+
url: str
|
|
25
|
+
type: str | None = None # "issue" | "tms" | ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestCase(BaseModel):
|
|
29
|
+
# pytest must not collect this as a test class
|
|
30
|
+
__test__ = False
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
name: str
|
|
34
|
+
description: str | None = None
|
|
35
|
+
precondition: str | None = None
|
|
36
|
+
expected_result: str | None = None
|
|
37
|
+
status: str | None = None
|
|
38
|
+
automated: bool = False
|
|
39
|
+
tags: list[str] = []
|
|
40
|
+
custom_fields: dict[str, list[str]] = {}
|
|
41
|
+
links: list[Link] = []
|
|
42
|
+
suite_path: tuple[str, ...] = ()
|
|
43
|
+
steps: list[Step] = []
|
|
44
|
+
source_url: str | None = None
|
|
45
|
+
source_project: str | None = None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Serialize TestCase instances to Markdown files with YAML front matter.
|
|
2
|
+
|
|
3
|
+
Invariants enforced here:
|
|
4
|
+
- Determinism: identical input always produces identical bytes.
|
|
5
|
+
- Stable file names: TC-{id}-{slug}.md, slug is cosmetic only.
|
|
6
|
+
- Collision handling: if a different slug exists for the same ID, append -2, -3, ...
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from testops_mirror.models import Step, TestCase
|
|
19
|
+
|
|
20
|
+
# Characters illegal in directory names on Windows/Linux/macOS
|
|
21
|
+
_DIR_FORBIDDEN = re.compile(r'[/\\:*?"<>|]')
|
|
22
|
+
|
|
23
|
+
# Transliteration table for common Cyrillic characters
|
|
24
|
+
_CYRILLIC: dict[str, str] = {
|
|
25
|
+
"а": "a",
|
|
26
|
+
"б": "b",
|
|
27
|
+
"в": "v",
|
|
28
|
+
"г": "g",
|
|
29
|
+
"д": "d",
|
|
30
|
+
"е": "e",
|
|
31
|
+
"ё": "yo",
|
|
32
|
+
"ж": "zh",
|
|
33
|
+
"з": "z",
|
|
34
|
+
"и": "i",
|
|
35
|
+
"й": "j",
|
|
36
|
+
"к": "k",
|
|
37
|
+
"л": "l",
|
|
38
|
+
"м": "m",
|
|
39
|
+
"н": "n",
|
|
40
|
+
"о": "o",
|
|
41
|
+
"п": "p",
|
|
42
|
+
"р": "r",
|
|
43
|
+
"с": "s",
|
|
44
|
+
"т": "t",
|
|
45
|
+
"у": "u",
|
|
46
|
+
"ф": "f",
|
|
47
|
+
"х": "kh",
|
|
48
|
+
"ц": "ts",
|
|
49
|
+
"ч": "ch",
|
|
50
|
+
"ш": "sh",
|
|
51
|
+
"щ": "shch",
|
|
52
|
+
"ъ": "",
|
|
53
|
+
"ы": "y",
|
|
54
|
+
"ь": "",
|
|
55
|
+
"э": "e",
|
|
56
|
+
"ю": "yu",
|
|
57
|
+
"я": "ya",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def slugify(text: str, max_len: int = 60) -> str:
|
|
62
|
+
"""Convert arbitrary text to a URL-safe slug.
|
|
63
|
+
|
|
64
|
+
Transliterates Cyrillic, lowercases, strips everything outside [a-z0-9-],
|
|
65
|
+
collapses hyphens, trims to *max_len* characters. Returns ``"case"`` if
|
|
66
|
+
the result would otherwise be empty.
|
|
67
|
+
"""
|
|
68
|
+
result = text.lower()
|
|
69
|
+
|
|
70
|
+
# Transliterate Cyrillic
|
|
71
|
+
result = "".join(_CYRILLIC.get(ch, ch) for ch in result)
|
|
72
|
+
|
|
73
|
+
# Decompose accented characters and drop combining marks
|
|
74
|
+
result = unicodedata.normalize("NFKD", result)
|
|
75
|
+
result = "".join(ch for ch in result if not unicodedata.combining(ch))
|
|
76
|
+
|
|
77
|
+
# Replace non-alphanumeric with hyphens
|
|
78
|
+
result = re.sub(r"[^a-z0-9]+", "-", result)
|
|
79
|
+
|
|
80
|
+
# Collapse and strip leading/trailing hyphens
|
|
81
|
+
result = result.strip("-")
|
|
82
|
+
|
|
83
|
+
# Truncate at a word boundary when possible
|
|
84
|
+
if len(result) > max_len:
|
|
85
|
+
truncated = result[:max_len]
|
|
86
|
+
last_hyphen = truncated.rfind("-")
|
|
87
|
+
result = truncated[:last_hyphen] if last_hyphen > 0 else truncated
|
|
88
|
+
result = result.strip("-")
|
|
89
|
+
|
|
90
|
+
return result or "case"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _clean_dir_name(name: str) -> str:
|
|
94
|
+
"""Remove characters that are forbidden in directory names."""
|
|
95
|
+
return _DIR_FORBIDDEN.sub("_", name).strip()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def case_relpath(case: TestCase, existing_paths: set[str] | None = None) -> str:
|
|
99
|
+
"""Compute the relative path for *case* inside the ``cases/`` directory.
|
|
100
|
+
|
|
101
|
+
Format: ``{suite_folders}/TC-{id}-{slug}.md``
|
|
102
|
+
|
|
103
|
+
Collision handling: if *existing_paths* already contains a path with the
|
|
104
|
+
same ``TC-{id}-`` prefix but a different slug, append ``-2``, ``-3``, ...
|
|
105
|
+
until the name is unique.
|
|
106
|
+
"""
|
|
107
|
+
slug = slugify(case.name)
|
|
108
|
+
dir_parts = [_clean_dir_name(part) for part in case.suite_path if part]
|
|
109
|
+
base_stem = f"TC-{case.id}-{slug}"
|
|
110
|
+
|
|
111
|
+
def _build(stem: str) -> str:
|
|
112
|
+
filename = f"{stem}.md"
|
|
113
|
+
parts = [*dir_parts, filename]
|
|
114
|
+
return str(PurePosixPath(*parts)) if parts else filename
|
|
115
|
+
|
|
116
|
+
candidate = _build(base_stem)
|
|
117
|
+
if existing_paths is None:
|
|
118
|
+
return candidate
|
|
119
|
+
|
|
120
|
+
# Check for existing file with same ID but different slug
|
|
121
|
+
prefix = f"TC-{case.id}-"
|
|
122
|
+
conflicting = {
|
|
123
|
+
p for p in existing_paths if p != candidate and PurePosixPath(p).stem.startswith(prefix)
|
|
124
|
+
}
|
|
125
|
+
if not conflicting:
|
|
126
|
+
return candidate
|
|
127
|
+
|
|
128
|
+
# Generate unique suffix
|
|
129
|
+
counter = 2
|
|
130
|
+
while True:
|
|
131
|
+
stem = f"{base_stem}-{counter}"
|
|
132
|
+
candidate = _build(stem)
|
|
133
|
+
if candidate not in existing_paths:
|
|
134
|
+
return candidate
|
|
135
|
+
counter += 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _render_steps(steps: list[Step], indent: int = 0) -> list[str]:
|
|
139
|
+
"""Render a (possibly nested) step list as numbered Markdown lines."""
|
|
140
|
+
lines: list[str] = []
|
|
141
|
+
prefix = " " * indent
|
|
142
|
+
for i, step in enumerate(steps, 1):
|
|
143
|
+
lines.append(f"{prefix}{i}. {step.name}")
|
|
144
|
+
if step.expected_result:
|
|
145
|
+
lines.append(f"{prefix} - **Expected:** {step.expected_result}")
|
|
146
|
+
if step.steps:
|
|
147
|
+
lines.extend(_render_steps(step.steps, indent + 1))
|
|
148
|
+
return lines
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def serialize(case: TestCase) -> str:
|
|
152
|
+
"""Render *case* as a Markdown string with YAML front matter.
|
|
153
|
+
|
|
154
|
+
The output is deterministic: same input → same bytes every time.
|
|
155
|
+
"""
|
|
156
|
+
# --- Build front matter dict (fixed key order, skip None/empty) ---
|
|
157
|
+
fm: dict[str, Any] = {}
|
|
158
|
+
fm["id"] = case.id
|
|
159
|
+
fm["name"] = case.name
|
|
160
|
+
|
|
161
|
+
if case.status is not None:
|
|
162
|
+
fm["status"] = case.status
|
|
163
|
+
|
|
164
|
+
fm["automated"] = case.automated
|
|
165
|
+
|
|
166
|
+
if case.tags:
|
|
167
|
+
fm["tags"] = sorted(case.tags)
|
|
168
|
+
|
|
169
|
+
if case.custom_fields:
|
|
170
|
+
fm["custom_fields"] = {k: sorted(v) for k, v in sorted(case.custom_fields.items())}
|
|
171
|
+
|
|
172
|
+
if case.links:
|
|
173
|
+
fm["links"] = [
|
|
174
|
+
{
|
|
175
|
+
k: v
|
|
176
|
+
for k, v in {"name": lnk.name, "url": lnk.url, "type": lnk.type}.items()
|
|
177
|
+
if v is not None
|
|
178
|
+
}
|
|
179
|
+
for lnk in case.links
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if case.source_url or case.source_project:
|
|
183
|
+
source: dict[str, str] = {}
|
|
184
|
+
if case.source_url:
|
|
185
|
+
source["url"] = case.source_url
|
|
186
|
+
if case.source_project:
|
|
187
|
+
source["project"] = case.source_project
|
|
188
|
+
fm["source"] = source
|
|
189
|
+
|
|
190
|
+
front_matter = yaml.safe_dump(
|
|
191
|
+
fm,
|
|
192
|
+
sort_keys=False,
|
|
193
|
+
allow_unicode=True,
|
|
194
|
+
default_flow_style=False,
|
|
195
|
+
).rstrip("\n")
|
|
196
|
+
|
|
197
|
+
# --- Build Markdown body ---
|
|
198
|
+
body_lines: list[str] = [f"# {case.name}", ""]
|
|
199
|
+
|
|
200
|
+
if case.description:
|
|
201
|
+
body_lines += [case.description, ""]
|
|
202
|
+
|
|
203
|
+
if case.precondition:
|
|
204
|
+
body_lines += ["## Preconditions", "", case.precondition, ""]
|
|
205
|
+
|
|
206
|
+
if case.steps:
|
|
207
|
+
body_lines.append("## Steps")
|
|
208
|
+
body_lines.append("")
|
|
209
|
+
body_lines.extend(_render_steps(case.steps))
|
|
210
|
+
body_lines.append("")
|
|
211
|
+
|
|
212
|
+
if case.expected_result:
|
|
213
|
+
body_lines += ["## Expected result", "", case.expected_result, ""]
|
|
214
|
+
|
|
215
|
+
body = "\n".join(body_lines).rstrip("\n") + "\n"
|
|
216
|
+
|
|
217
|
+
return f"---\n{front_matter}\n---\n\n{body}"
|
testops_mirror/sync.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Orchestrator: connector -> serialize -> plan -> commit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from testops_mirror.connectors.base import TmsConnector
|
|
9
|
+
from testops_mirror.gitstore import ChangeSet, GitStore
|
|
10
|
+
from testops_mirror.models import TestCase
|
|
11
|
+
from testops_mirror.serializer import case_relpath, serialize
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_sync(
|
|
17
|
+
connector: TmsConnector,
|
|
18
|
+
store: GitStore,
|
|
19
|
+
project_id: str,
|
|
20
|
+
*,
|
|
21
|
+
dry_run: bool = False,
|
|
22
|
+
on_case: Callable[[TestCase], None] | None = None,
|
|
23
|
+
) -> tuple[ChangeSet, str | None]:
|
|
24
|
+
"""Pull all test cases and mirror them into the git store.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
connector:
|
|
29
|
+
Any object implementing the TmsConnector protocol.
|
|
30
|
+
store:
|
|
31
|
+
Initialised GitStore pointing at the target repository.
|
|
32
|
+
project_id:
|
|
33
|
+
TMS project identifier passed to the connector.
|
|
34
|
+
dry_run:
|
|
35
|
+
If True, compute the plan but do not write files or create commits.
|
|
36
|
+
on_case:
|
|
37
|
+
Optional callback invoked for each fetched TestCase (used by CLI for
|
|
38
|
+
progress reporting).
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
(ChangeSet, sha | None)
|
|
43
|
+
The planned change set and the commit SHA (None when dry_run or no changes).
|
|
44
|
+
"""
|
|
45
|
+
desired: dict[str, str] = {}
|
|
46
|
+
seen_paths: set[str] = set()
|
|
47
|
+
|
|
48
|
+
for case in connector.iter_test_cases(project_id):
|
|
49
|
+
if on_case is not None:
|
|
50
|
+
on_case(case)
|
|
51
|
+
relpath = case_relpath(case, existing_paths=seen_paths)
|
|
52
|
+
seen_paths.add(relpath)
|
|
53
|
+
desired[relpath] = serialize(case)
|
|
54
|
+
logger.debug("Serialized %s -> %s", case.id, relpath)
|
|
55
|
+
|
|
56
|
+
logger.info("Fetched %d test cases", len(desired))
|
|
57
|
+
|
|
58
|
+
changes = store.plan(desired)
|
|
59
|
+
|
|
60
|
+
if dry_run or changes.empty:
|
|
61
|
+
return changes, None
|
|
62
|
+
|
|
63
|
+
sha = store.apply(desired, changes)
|
|
64
|
+
return changes, sha
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: testops-mirror
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mirror test cases from any TMS into a Git repository as Markdown files
|
|
5
|
+
Project-URL: Homepage, https://github.com/exzist-qa/testops-mirror
|
|
6
|
+
Project-URL: Repository, https://github.com/exzist-qa/testops-mirror
|
|
7
|
+
Project-URL: Issues, https://github.com/exzist-qa/testops-mirror/issues
|
|
8
|
+
Author: testops-mirror contributors
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 testops-mirror contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: allure,git,mirror,test-management,testing,testops,tms
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Software Development :: Testing
|
|
40
|
+
Requires-Python: >=3.11
|
|
41
|
+
Requires-Dist: gitpython>=3.1
|
|
42
|
+
Requires-Dist: httpx>=0.27
|
|
43
|
+
Requires-Dist: pydantic>=2
|
|
44
|
+
Requires-Dist: python-dotenv>=1.0
|
|
45
|
+
Requires-Dist: pyyaml>=6
|
|
46
|
+
Requires-Dist: rich>=13
|
|
47
|
+
Requires-Dist: typer>=0.12
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
52
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
53
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
54
|
+
Description-Content-Type: text/markdown
|
|
55
|
+
|
|
56
|
+
# testops-mirror
|
|
57
|
+
|
|
58
|
+
> Mirror test cases from any TMS into a Git repository — one file per test case, history forever.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
mirror/
|
|
62
|
+
├── Transfers/
|
|
63
|
+
│ ├── Negative/
|
|
64
|
+
│ │ └── TC-2301-transfer-to-blocked-account.md
|
|
65
|
+
│ └── TC-2201-transfer-between-own-accounts.md
|
|
66
|
+
└── Auth/
|
|
67
|
+
└── TC-1001-login-with-valid-credentials.md
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Why
|
|
71
|
+
|
|
72
|
+
Your TMS is a single point of failure. Test cases deserve version control just like code —
|
|
73
|
+
history, diff, blame, and code review through merge requests.
|
|
74
|
+
|
|
75
|
+
testops-mirror pulls every test case on a schedule and commits changes to a plain Git repo.
|
|
76
|
+
No vendor lock-in, no proprietary format.
|
|
77
|
+
|
|
78
|
+
## Quick Start
|
|
79
|
+
|
|
80
|
+
**pip:**
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install testops-mirror
|
|
84
|
+
cp .env.example .env # fill in TESTOPS_ENDPOINT and TESTOPS_TOKEN
|
|
85
|
+
testops-mirror sync --project-id 42 --repo ./mirror
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Docker:**
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
docker run --rm \
|
|
92
|
+
-e TESTOPS_ENDPOINT=https://testops.example.com \
|
|
93
|
+
-e TESTOPS_TOKEN=your-token \
|
|
94
|
+
-v $(pwd)/mirror:/mirror \
|
|
95
|
+
ghcr.io/exzist-qa/testops-mirror \
|
|
96
|
+
sync --project-id 42 --repo /mirror
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Example output file
|
|
100
|
+
|
|
101
|
+
```markdown
|
|
102
|
+
---
|
|
103
|
+
id: '2301'
|
|
104
|
+
name: Transfer to blocked account
|
|
105
|
+
status: Ready
|
|
106
|
+
automated: false
|
|
107
|
+
tags: [api, negative]
|
|
108
|
+
links:
|
|
109
|
+
- {name: BAN-17, url: 'https://jira.example.com/browse/BAN-17', type: issue}
|
|
110
|
+
source: {url: 'https://testops.example.com/project/42/test-cases/2301', project: '42'}
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
# Transfer to blocked account
|
|
114
|
+
|
|
115
|
+
## Preconditions
|
|
116
|
+
|
|
117
|
+
Sender account has sufficient balance. Recipient account status is BLOCKED.
|
|
118
|
+
|
|
119
|
+
## Steps
|
|
120
|
+
|
|
121
|
+
1. Send `POST /transfers`
|
|
122
|
+
- **Expected:** `422 Unprocessable Entity`
|
|
123
|
+
2. Check response body
|
|
124
|
+
1. Field `code` equals `ACCOUNT_BLOCKED`
|
|
125
|
+
|
|
126
|
+
## Expected result
|
|
127
|
+
|
|
128
|
+
API returns 422 with error code ACCOUNT_BLOCKED.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Supported TMS
|
|
132
|
+
|
|
133
|
+
| TMS | Status |
|
|
134
|
+
|-----|--------|
|
|
135
|
+
| Allure TestOps | ✅ |
|
|
136
|
+
| TestIT | 🚧 planned |
|
|
137
|
+
| TestRail | 🤝 contributions welcome |
|
|
138
|
+
|
|
139
|
+
## Configuration
|
|
140
|
+
|
|
141
|
+
| Option | Env | Default | Description |
|
|
142
|
+
|--------|-----|---------|-------------|
|
|
143
|
+
| `--project-id` | — | required | TMS project ID |
|
|
144
|
+
| `--endpoint` | `TESTOPS_ENDPOINT` | required | Base URL of TMS |
|
|
145
|
+
| `--token` | `TESTOPS_TOKEN` | required | API token |
|
|
146
|
+
| `--repo` | — | `./mirror` | Local Git repo path |
|
|
147
|
+
| `--suite-field` | `TESTOPS_SUITE_FIELD` | `Suite` | Custom field for folder structure |
|
|
148
|
+
| `--dry-run` | — | false | Preview changes without writing |
|
|
149
|
+
| `-v/--verbose` | — | false | Verbose logging |
|
|
150
|
+
|
|
151
|
+
> **Note:** API endpoint paths are verified against Allure TestOps documented API.
|
|
152
|
+
> If your instance uses different paths, check `<endpoint>/swagger-ui/` and
|
|
153
|
+
> update the constants in `connectors/allure_testops.py`.
|
|
154
|
+
|
|
155
|
+
## Roadmap
|
|
156
|
+
|
|
157
|
+
- Attachments download
|
|
158
|
+
- TestIT connector
|
|
159
|
+
- Index file (`cases/INDEX.md`) with a table of all cases
|
|
160
|
+
- Reverse import (Markdown → TMS)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
testops_mirror/__init__.py,sha256=uKrAWK61H5tHGo_1Kn1BTKo40j_S6DR1rPFf5F8_TP0,90
|
|
2
|
+
testops_mirror/cli.py,sha256=kenD3lFD9KwI8R-dfaU3HKm7rDFfiT_SUJutOJVqsLE,4922
|
|
3
|
+
testops_mirror/exceptions.py,sha256=25D1RFhJChQ5ZnRRcY2R_3Oy-FBAK3AzlKmZcjU5vwY,681
|
|
4
|
+
testops_mirror/gitstore.py,sha256=NcJpPH7Hkygfij7LfxhaHZgt-XKDJ3uIhQMDn9Wu4ng,5213
|
|
5
|
+
testops_mirror/models.py,sha256=FMfPGFKafT_JTFVhmMWaD0c039FDs1eHb7obqyua4bg,1063
|
|
6
|
+
testops_mirror/serializer.py,sha256=H5Kpy_8U3RjmCOmi5XLWOcesFiJiJKt1svaHI_ErzaU,6197
|
|
7
|
+
testops_mirror/sync.py,sha256=5VV-K6NGm6ydHYT46Mdz9unqGJkqDFFXAYMlSgUW0qI,1887
|
|
8
|
+
testops_mirror/connectors/__init__.py,sha256=6-ozPiilbQFvpWdLLoh_doVNAE9QMRTxK_9EII46IH8,37
|
|
9
|
+
testops_mirror/connectors/allure_testops.py,sha256=iDYOGPi0CQSEufEk7Y4mVlQYjENt2vtLOhutZjXklEI,14535
|
|
10
|
+
testops_mirror/connectors/base.py,sha256=FXfMaFzJxua1MVFgPpt4RkJHYjCf8K5MpuqcnctsHD0,1014
|
|
11
|
+
testops_mirror-0.1.0.dist-info/METADATA,sha256=Mr_-nOAxRjvaZEizgm0xolSvOpkghvcqF7CiCJrXBf4,5421
|
|
12
|
+
testops_mirror-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
testops_mirror-0.1.0.dist-info/entry_points.txt,sha256=JiPErL91CBJpsPSLZBHehfZ82pCpEPjXrvwagkVd3UA,58
|
|
14
|
+
testops_mirror-0.1.0.dist-info/licenses/LICENSE,sha256=NzNVXU-PP5CoKV09KwYEV7ZEpUpbeGq159cVj6udtcQ,1084
|
|
15
|
+
testops_mirror-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 testops-mirror contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|