lucius-mcp 0.3.0__py3-none-any.whl → 0.4.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.
- lucius_mcp-0.4.0.dist-info/METADATA +124 -0
- {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/RECORD +38 -29
- src/client/__init__.py +6 -0
- src/client/client.py +271 -7
- src/client/exceptions.py +23 -0
- src/client/generated/README.md +18 -0
- src/client/generated/__init__.py +2 -0
- src/client/generated/api/__init__.py +1 -0
- src/client/generated/api/integration_controller_api.py +5285 -0
- src/client/generated/docs/IntegrationControllerApi.md +1224 -0
- src/services/__init__.py +9 -1
- src/services/custom_field_value_service.py +301 -0
- src/services/integration_service.py +205 -0
- src/services/launch_service.py +29 -1
- src/services/shared_step_service.py +34 -17
- src/services/test_case_service.py +269 -37
- src/services/test_layer_service.py +11 -0
- src/tools/__init__.py +19 -1
- src/tools/create_custom_field_value.py +38 -0
- src/tools/create_test_case.py +32 -2
- src/tools/delete_custom_field_value.py +57 -0
- src/tools/delete_test_case.py +5 -4
- src/tools/delete_test_layer.py +17 -4
- src/tools/delete_test_layer_schema.py +17 -4
- src/tools/launches.py +86 -0
- src/tools/link_shared_step.py +18 -12
- src/tools/list_custom_field_values.py +72 -0
- src/tools/list_integrations.py +77 -0
- src/tools/search.py +3 -3
- src/tools/shared_steps.py +23 -8
- src/tools/unlink_shared_step.py +19 -5
- src/tools/update_custom_field_value.py +55 -0
- src/tools/update_test_case.py +67 -2
- src/tools/update_test_layer.py +15 -4
- src/tools/update_test_layer_schema.py +16 -5
- lucius_mcp-0.3.0.dist-info/METADATA +0 -297
- {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/WHEEL +0 -0
- {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
- {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/licenses/LICENSE +0 -0
src/services/__init__.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
"""Service layer modules for lucius-mcp."""
|
|
2
2
|
|
|
3
3
|
from .attachment_service import AttachmentService
|
|
4
|
+
from .custom_field_value_service import CustomFieldValueService
|
|
4
5
|
from .search_service import SearchService
|
|
5
6
|
from .shared_step_service import SharedStepService
|
|
6
7
|
from .test_case_service import TestCaseService
|
|
7
8
|
from .test_layer_service import TestLayerService
|
|
8
9
|
|
|
9
|
-
__all__ = [
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AttachmentService",
|
|
12
|
+
"CustomFieldValueService",
|
|
13
|
+
"SearchService",
|
|
14
|
+
"SharedStepService",
|
|
15
|
+
"TestCaseService",
|
|
16
|
+
"TestLayerService",
|
|
17
|
+
]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Service for managing Custom Field Values in Allure TestOps."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
6
|
+
|
|
7
|
+
from src.client import AllureClient
|
|
8
|
+
from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
|
|
9
|
+
from src.client.generated.models.custom_field_value_project_create_dto import CustomFieldValueProjectCreateDto
|
|
10
|
+
from src.client.generated.models.custom_field_value_project_patch_dto import CustomFieldValueProjectPatchDto
|
|
11
|
+
from src.client.generated.models.custom_field_value_with_cf_dto import CustomFieldValueWithCfDto
|
|
12
|
+
from src.client.generated.models.id_only_dto import IdOnlyDto
|
|
13
|
+
from src.client.generated.models.page_custom_field_value_with_tc_count_dto import PageCustomFieldValueWithTcCountDto
|
|
14
|
+
from src.services.test_case_service import ResolvedCustomFieldInfo, TestCaseService
|
|
15
|
+
from src.utils.schema_hint import generate_schema_hint
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MAX_NAME_LENGTH = 255
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CustomFieldValueService:
|
|
23
|
+
"""Service for CRUD operations on custom field values."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: AllureClient) -> None:
|
|
26
|
+
self._client = client
|
|
27
|
+
self._project_id = client.get_project()
|
|
28
|
+
self._test_case_service = TestCaseService(client=client)
|
|
29
|
+
|
|
30
|
+
async def list_custom_field_values(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
project_id: int | None = None,
|
|
34
|
+
custom_field_id: int | None = None,
|
|
35
|
+
custom_field_name: str | None = None,
|
|
36
|
+
query: str | None = None,
|
|
37
|
+
var_global: bool | None = None,
|
|
38
|
+
test_case_search: str | None = None,
|
|
39
|
+
page: int | None = None,
|
|
40
|
+
size: int | None = None,
|
|
41
|
+
sort: list[str] | None = None,
|
|
42
|
+
) -> PageCustomFieldValueWithTcCountDto:
|
|
43
|
+
"""List custom field values for a project-scoped custom field."""
|
|
44
|
+
resolved_project_id = self._resolve_project_id(project_id)
|
|
45
|
+
resolved_custom_field_id = await self._resolve_custom_field_id(
|
|
46
|
+
resolved_project_id,
|
|
47
|
+
custom_field_id=custom_field_id,
|
|
48
|
+
custom_field_name=custom_field_name,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return await self._client.list_custom_field_values(
|
|
52
|
+
project_id=resolved_project_id,
|
|
53
|
+
custom_field_id=resolved_custom_field_id,
|
|
54
|
+
query=query,
|
|
55
|
+
var_global=var_global,
|
|
56
|
+
test_case_search=test_case_search,
|
|
57
|
+
page=page,
|
|
58
|
+
size=size,
|
|
59
|
+
sort=sort,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def create_custom_field_value(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
project_id: int | None = None,
|
|
66
|
+
custom_field_id: int | None = None,
|
|
67
|
+
custom_field_name: str | None = None,
|
|
68
|
+
name: str,
|
|
69
|
+
) -> CustomFieldValueWithCfDto:
|
|
70
|
+
"""Create a new custom field value for the given field."""
|
|
71
|
+
resolved_project_id = self._resolve_project_id(project_id)
|
|
72
|
+
resolved_custom_field_id = await self._resolve_custom_field_id(
|
|
73
|
+
resolved_project_id,
|
|
74
|
+
custom_field_id=custom_field_id,
|
|
75
|
+
custom_field_name=custom_field_name,
|
|
76
|
+
)
|
|
77
|
+
self._validate_name(name)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
dto = CustomFieldValueProjectCreateDto(
|
|
81
|
+
custom_field=IdOnlyDto(id=resolved_custom_field_id),
|
|
82
|
+
name=name,
|
|
83
|
+
project_id=resolved_project_id,
|
|
84
|
+
)
|
|
85
|
+
except PydanticValidationError as exc:
|
|
86
|
+
hint = generate_schema_hint(CustomFieldValueProjectCreateDto)
|
|
87
|
+
raise AllureValidationError(f"Invalid custom field value data: {exc}", suggestions=[hint]) from exc
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
created = await self._client.create_custom_field_value(resolved_project_id, dto)
|
|
91
|
+
except AllureValidationError as exc:
|
|
92
|
+
if exc.status_code in (400, 409, 422):
|
|
93
|
+
raise AllureValidationError(
|
|
94
|
+
"Custom field value already exists or is invalid.",
|
|
95
|
+
status_code=exc.status_code,
|
|
96
|
+
response_body=exc.response_body,
|
|
97
|
+
suggestions=[
|
|
98
|
+
"Use a unique name for the custom field value",
|
|
99
|
+
"List existing values with list_custom_field_values",
|
|
100
|
+
],
|
|
101
|
+
) from exc
|
|
102
|
+
raise
|
|
103
|
+
except AllureAPIError as exc:
|
|
104
|
+
if exc.status_code in (400, 409, 422):
|
|
105
|
+
raise AllureValidationError(
|
|
106
|
+
"Custom field value already exists or is invalid.",
|
|
107
|
+
status_code=exc.status_code,
|
|
108
|
+
response_body=exc.response_body,
|
|
109
|
+
suggestions=[
|
|
110
|
+
"Use a unique name for the custom field value",
|
|
111
|
+
"List existing values with list_custom_field_values",
|
|
112
|
+
],
|
|
113
|
+
) from exc
|
|
114
|
+
raise
|
|
115
|
+
await self._test_case_service.refresh_resolved_custom_fields(resolved_project_id)
|
|
116
|
+
return created
|
|
117
|
+
|
|
118
|
+
async def update_custom_field_value(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
project_id: int | None = None,
|
|
122
|
+
cfv_id: int | None = None,
|
|
123
|
+
custom_field_id: int | None = None,
|
|
124
|
+
custom_field_name: str | None = None,
|
|
125
|
+
name: str | None = None,
|
|
126
|
+
force: bool = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Update a custom field value (rename)."""
|
|
129
|
+
resolved_project_id = self._resolve_project_id(project_id)
|
|
130
|
+
resolved_custom_field_id = await self._resolve_custom_field_id(
|
|
131
|
+
resolved_project_id,
|
|
132
|
+
custom_field_id=custom_field_id,
|
|
133
|
+
custom_field_name=custom_field_name,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
resolved_cfv_id, usage_count = await self._resolve_cfv_and_check_usage(
|
|
137
|
+
resolved_project_id,
|
|
138
|
+
resolved_custom_field_id,
|
|
139
|
+
cfv_id=cfv_id,
|
|
140
|
+
cfv_name=None, # We don't support finding by name for update yet based on existing params
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._validate_cfv_id(resolved_cfv_id)
|
|
144
|
+
if name is None:
|
|
145
|
+
raise AllureValidationError("Name is required for update")
|
|
146
|
+
self._validate_name(name)
|
|
147
|
+
|
|
148
|
+
if usage_count > 0 and not force:
|
|
149
|
+
raise AllureValidationError(
|
|
150
|
+
f"Custom field value is used in {usage_count} test cases. Use force=True to proceed."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
dto = CustomFieldValueProjectPatchDto(name=name)
|
|
155
|
+
except PydanticValidationError as exc:
|
|
156
|
+
hint = generate_schema_hint(CustomFieldValueProjectPatchDto)
|
|
157
|
+
raise AllureValidationError(f"Invalid custom field value patch data: {exc}", suggestions=[hint]) from exc
|
|
158
|
+
|
|
159
|
+
await self._client.update_custom_field_value(resolved_project_id, resolved_cfv_id, dto)
|
|
160
|
+
await self._test_case_service.refresh_resolved_custom_fields(resolved_project_id)
|
|
161
|
+
|
|
162
|
+
async def delete_custom_field_value(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
project_id: int | None = None,
|
|
166
|
+
cfv_id: int | None = None,
|
|
167
|
+
cfv_name: str | None = None,
|
|
168
|
+
custom_field_id: int | None = None,
|
|
169
|
+
custom_field_name: str | None = None,
|
|
170
|
+
force: bool = False,
|
|
171
|
+
) -> bool:
|
|
172
|
+
"""Delete a custom field value (idempotent).
|
|
173
|
+
|
|
174
|
+
Returns True if deletion occurred, False if the value was already removed.
|
|
175
|
+
"""
|
|
176
|
+
resolved_project_id = self._resolve_project_id(project_id)
|
|
177
|
+
resolved_custom_field_id = await self._resolve_custom_field_id(
|
|
178
|
+
resolved_project_id,
|
|
179
|
+
custom_field_id=custom_field_id,
|
|
180
|
+
custom_field_name=custom_field_name,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
resolved_cfv_id, usage_count = await self._resolve_cfv_and_check_usage(
|
|
185
|
+
resolved_project_id,
|
|
186
|
+
resolved_custom_field_id,
|
|
187
|
+
cfv_id=cfv_id,
|
|
188
|
+
cfv_name=cfv_name,
|
|
189
|
+
)
|
|
190
|
+
except AllureValidationError:
|
|
191
|
+
# If resolving fails (e.g. not found), treat as idempotent access if filtering by ID?
|
|
192
|
+
# If scanning by name fails, it doesn't exist.
|
|
193
|
+
logger.debug("Custom field value not found for deletion")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
if usage_count > 0 and not force:
|
|
197
|
+
raise AllureValidationError(
|
|
198
|
+
f"Custom field value is used in {usage_count} test cases. Use force=True to proceed."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self._validate_cfv_id(resolved_cfv_id)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
await self._client.delete_custom_field_value(resolved_project_id, resolved_cfv_id)
|
|
205
|
+
await self._test_case_service.refresh_resolved_custom_fields(resolved_project_id)
|
|
206
|
+
return True
|
|
207
|
+
except AllureNotFoundError:
|
|
208
|
+
logger.debug("Custom field value %s already removed", resolved_cfv_id)
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
async def _resolve_cfv_and_check_usage(
|
|
212
|
+
self,
|
|
213
|
+
project_id: int,
|
|
214
|
+
custom_field_id: int,
|
|
215
|
+
cfv_id: int | None,
|
|
216
|
+
cfv_name: str | None,
|
|
217
|
+
) -> tuple[int, int]:
|
|
218
|
+
"""Resolve CFV ID and usage count.
|
|
219
|
+
|
|
220
|
+
Returns (cfv_id, usage_count).
|
|
221
|
+
"""
|
|
222
|
+
if cfv_id is None and not (cfv_name and cfv_name.strip()):
|
|
223
|
+
raise AllureValidationError("Either cfv_id or cfv_name must be provided")
|
|
224
|
+
|
|
225
|
+
query = cfv_name if cfv_name else None
|
|
226
|
+
# List values to find usage and ID
|
|
227
|
+
page = await self.list_custom_field_values(
|
|
228
|
+
project_id=project_id,
|
|
229
|
+
custom_field_id=custom_field_id,
|
|
230
|
+
query=query,
|
|
231
|
+
size=100, # Fetch enough to hopefully find it
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
found = None
|
|
235
|
+
for item in page.content or []:
|
|
236
|
+
if cfv_id is not None:
|
|
237
|
+
if item.id == cfv_id:
|
|
238
|
+
found = item
|
|
239
|
+
break
|
|
240
|
+
elif cfv_name:
|
|
241
|
+
if item.name == cfv_name:
|
|
242
|
+
found = item
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
if found:
|
|
246
|
+
return found.id or 0, found.test_cases_count or 0
|
|
247
|
+
|
|
248
|
+
if cfv_id is not None:
|
|
249
|
+
# If ID passed but not found in list (maybe truncated?), we assume usage is unknown(unsafe) or 0?
|
|
250
|
+
# For safety, if we can't verify usage, we might warn.
|
|
251
|
+
# But if ID exists but is not in first 1000, we can't check usage easily.
|
|
252
|
+
# Let's return ID and 0 usage but log warning?
|
|
253
|
+
# Or rely on client to handle 404 if invalid.
|
|
254
|
+
return cfv_id, 0
|
|
255
|
+
|
|
256
|
+
raise AllureValidationError(f"Custom field value specificied by name '{cfv_name}' not found")
|
|
257
|
+
|
|
258
|
+
def _resolve_project_id(self, project_id: int | None) -> int:
|
|
259
|
+
resolved_project_id = project_id if project_id is not None else self._project_id
|
|
260
|
+
if not isinstance(resolved_project_id, int) or resolved_project_id <= 0:
|
|
261
|
+
raise AllureValidationError("Project ID must be a positive integer")
|
|
262
|
+
return resolved_project_id
|
|
263
|
+
|
|
264
|
+
async def _resolve_custom_field_id(
|
|
265
|
+
self,
|
|
266
|
+
project_id: int,
|
|
267
|
+
*,
|
|
268
|
+
custom_field_id: int | None,
|
|
269
|
+
custom_field_name: str | None,
|
|
270
|
+
) -> int:
|
|
271
|
+
if custom_field_id is not None:
|
|
272
|
+
if not isinstance(custom_field_id, int) or custom_field_id == 0:
|
|
273
|
+
raise AllureValidationError("Custom Field ID must be a non-zero integer")
|
|
274
|
+
return custom_field_id
|
|
275
|
+
|
|
276
|
+
if custom_field_name is None or not custom_field_name.strip():
|
|
277
|
+
raise AllureValidationError("Custom field name is required when custom_field_id is not provided")
|
|
278
|
+
|
|
279
|
+
mapping = await self._get_resolved_custom_fields(project_id)
|
|
280
|
+
normalized_name = custom_field_name.strip()
|
|
281
|
+
info = mapping.get(normalized_name)
|
|
282
|
+
if info is None:
|
|
283
|
+
suggestions = sorted(mapping.keys())
|
|
284
|
+
raise AllureValidationError(
|
|
285
|
+
f"Custom field '{normalized_name}' not found.",
|
|
286
|
+
suggestions=suggestions if suggestions else ["Use get_custom_fields to list available fields."],
|
|
287
|
+
)
|
|
288
|
+
return info["project_cf_id"]
|
|
289
|
+
|
|
290
|
+
async def _get_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
|
|
291
|
+
return await self._test_case_service._get_resolved_custom_fields(project_id)
|
|
292
|
+
|
|
293
|
+
def _validate_name(self, name: str) -> None:
|
|
294
|
+
if not name or not name.strip():
|
|
295
|
+
raise AllureValidationError("Name is required")
|
|
296
|
+
if len(name) > MAX_NAME_LENGTH:
|
|
297
|
+
raise AllureValidationError(f"Name too long (max {MAX_NAME_LENGTH})")
|
|
298
|
+
|
|
299
|
+
def _validate_cfv_id(self, cfv_id: int) -> None:
|
|
300
|
+
if not isinstance(cfv_id, int) or cfv_id <= 0:
|
|
301
|
+
raise AllureValidationError("Custom Field Value ID must be a positive integer")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Service for managing Integrations in Allure TestOps."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from src.client import AllureClient
|
|
6
|
+
from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
|
|
7
|
+
from src.client.generated.models.integration_dto import IntegrationDto
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IntegrationService:
|
|
13
|
+
"""Service for managing Integrations in Allure TestOps.
|
|
14
|
+
|
|
15
|
+
Integrations are external issue trackers (Jira, GitHub, etc.) that are configured
|
|
16
|
+
at the instance level. This service provides methods to discover available integrations
|
|
17
|
+
and resolve integration IDs for use in issue linking.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: AllureClient) -> None:
|
|
21
|
+
"""Initialize IntegrationService.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
client: AllureClient instance
|
|
25
|
+
"""
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
async def list_integrations(self) -> list[IntegrationDto]:
|
|
29
|
+
"""List all available integrations.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of IntegrationDto objects
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
AllureAPIError: If the API request fails
|
|
36
|
+
"""
|
|
37
|
+
return await self._client.get_integrations()
|
|
38
|
+
|
|
39
|
+
async def get_integration_by_id(self, integration_id: int) -> IntegrationDto:
|
|
40
|
+
"""Get an integration by its ID.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
integration_id: The unique ID of the integration
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The IntegrationDto
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
AllureNotFoundError: If integration doesn't exist
|
|
50
|
+
AllureValidationError: If the ID is invalid
|
|
51
|
+
AllureAPIError: If the API request fails
|
|
52
|
+
"""
|
|
53
|
+
if integration_id <= 0:
|
|
54
|
+
raise AllureValidationError("Integration ID must be a positive integer")
|
|
55
|
+
|
|
56
|
+
integrations = await self.list_integrations()
|
|
57
|
+
for integration in integrations:
|
|
58
|
+
if integration.id == integration_id:
|
|
59
|
+
return integration
|
|
60
|
+
|
|
61
|
+
raise AllureNotFoundError(
|
|
62
|
+
f"Integration with ID {integration_id} not found. "
|
|
63
|
+
f"Available integrations: {self._format_integration_list(integrations)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def get_integration_by_name(self, name: str) -> IntegrationDto:
|
|
67
|
+
"""Get an integration by its exact name (case-sensitive).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: The exact name of the integration
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The IntegrationDto
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
AllureNotFoundError: If integration doesn't exist
|
|
77
|
+
AllureValidationError: If the name is empty
|
|
78
|
+
AllureAPIError: If the API request fails
|
|
79
|
+
"""
|
|
80
|
+
if not name or not name.strip():
|
|
81
|
+
raise AllureValidationError("Integration name is required")
|
|
82
|
+
|
|
83
|
+
integrations = await self.list_integrations()
|
|
84
|
+
for integration in integrations:
|
|
85
|
+
if integration.name == name:
|
|
86
|
+
return integration
|
|
87
|
+
|
|
88
|
+
raise AllureNotFoundError(
|
|
89
|
+
f"Integration '{name}' not found. Available integrations: {self._format_integration_list(integrations)}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def resolve_integration(
|
|
93
|
+
self,
|
|
94
|
+
integration_id: int | None = None,
|
|
95
|
+
integration_name: str | None = None,
|
|
96
|
+
) -> IntegrationDto | None:
|
|
97
|
+
"""Resolve an integration from either ID or name.
|
|
98
|
+
|
|
99
|
+
This method handles mutual exclusivity validation and resolution.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
integration_id: Optional integration ID
|
|
103
|
+
integration_name: Optional integration name
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The resolved IntegrationDto if either parameter is provided, None otherwise
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
AllureValidationError: If both parameters are provided (mutual exclusivity)
|
|
110
|
+
AllureNotFoundError: If the specified integration doesn't exist
|
|
111
|
+
AllureAPIError: If the API request fails
|
|
112
|
+
"""
|
|
113
|
+
# 1. Mutual exclusivity check
|
|
114
|
+
if integration_id is not None and integration_name is not None:
|
|
115
|
+
raise AllureValidationError(
|
|
116
|
+
"Cannot specify both 'integration_id' and 'integration_name'. "
|
|
117
|
+
"Use only one parameter to identify the integration."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# 2. Resolve by ID
|
|
121
|
+
if integration_id is not None:
|
|
122
|
+
return await self.get_integration_by_id(integration_id)
|
|
123
|
+
|
|
124
|
+
# 3. Resolve by Name
|
|
125
|
+
if integration_name is not None:
|
|
126
|
+
return await self.get_integration_by_name(integration_name)
|
|
127
|
+
|
|
128
|
+
# 4. Neither provided
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
async def resolve_integration_for_issues(
|
|
132
|
+
self,
|
|
133
|
+
integration_id: int | None = None,
|
|
134
|
+
integration_name: str | None = None,
|
|
135
|
+
) -> int:
|
|
136
|
+
"""Resolve integration ID for issue linking with strict behavior.
|
|
137
|
+
|
|
138
|
+
This method enforces AC#6 and AC#7:
|
|
139
|
+
- Single integration: Auto-select
|
|
140
|
+
- Multiple integrations: Error if no selection provided
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
integration_id: Optional integration ID to use
|
|
144
|
+
integration_name: Optional integration name to use
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The resolved integration ID
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
AllureValidationError: If multiple integrations exist and none specified,
|
|
151
|
+
or if both parameters are provided
|
|
152
|
+
AllureNotFoundError: If the specified integration doesn't exist
|
|
153
|
+
AllureAPIError: If the API request fails or no integrations configured
|
|
154
|
+
"""
|
|
155
|
+
# First check if explicit integration is provided
|
|
156
|
+
resolved = await self.resolve_integration(integration_id, integration_name)
|
|
157
|
+
if resolved:
|
|
158
|
+
if resolved.id is None:
|
|
159
|
+
raise AllureAPIError("Resolved integration has no ID")
|
|
160
|
+
return resolved.id
|
|
161
|
+
|
|
162
|
+
# No explicit selection - check available integrations
|
|
163
|
+
integrations = await self.list_integrations()
|
|
164
|
+
|
|
165
|
+
if not integrations:
|
|
166
|
+
raise AllureAPIError(
|
|
167
|
+
"No integrations configured in Allure TestOps. "
|
|
168
|
+
"Please configure an integration (Jira, GitHub, etc.) before linking issues."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if len(integrations) == 1:
|
|
172
|
+
# AC#6: Single integration - auto-select
|
|
173
|
+
if integrations[0].id is None:
|
|
174
|
+
raise AllureAPIError("Integration has no ID")
|
|
175
|
+
return integrations[0].id
|
|
176
|
+
|
|
177
|
+
# AC#7: Multiple integrations - require explicit selection
|
|
178
|
+
raise AllureValidationError(
|
|
179
|
+
f"Multiple integrations found. Please specify which integration to use.\n"
|
|
180
|
+
f"Available integrations:\n{self._format_integration_list(integrations)}\n\n"
|
|
181
|
+
"Hint: Use 'integration_id' or 'integration_name' parameter to specify the integration."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _format_integration_list(self, integrations: list[IntegrationDto]) -> str:
|
|
185
|
+
"""Format a list of integrations for display in error messages.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
integrations: List of IntegrationDto objects
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Formatted string with integration names and IDs
|
|
192
|
+
"""
|
|
193
|
+
if not integrations:
|
|
194
|
+
return "(none configured)"
|
|
195
|
+
|
|
196
|
+
lines: list[str] = []
|
|
197
|
+
for integration in integrations:
|
|
198
|
+
name = integration.name or "(unnamed)"
|
|
199
|
+
int_id = integration.id or "N/A"
|
|
200
|
+
info_type = ""
|
|
201
|
+
if integration.info and hasattr(integration.info, "type") and integration.info.type:
|
|
202
|
+
info_type = f" [{integration.info.type}]"
|
|
203
|
+
lines.append(f"- {name} (ID: {int_id}){info_type}")
|
|
204
|
+
|
|
205
|
+
return "\n".join(lines)
|
src/services/launch_service.py
CHANGED
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from pydantic import ValidationError as PydanticValidationError
|
|
7
7
|
|
|
8
8
|
from src.client import AllureClient, FindAll29200Response, LaunchCreateDto, LaunchDto
|
|
9
|
-
from src.client.exceptions import AllureValidationError
|
|
9
|
+
from src.client.exceptions import AllureNotFoundError, AllureValidationError, LaunchNotFoundError
|
|
10
10
|
from src.client.generated.models.aql_validate_response_dto import AqlValidateResponseDto
|
|
11
11
|
from src.client.generated.models.external_link_dto import ExternalLinkDto
|
|
12
12
|
from src.client.generated.models.issue_dto import IssueDto
|
|
@@ -173,6 +173,27 @@ class LaunchService:
|
|
|
173
173
|
total_pages=response.total_pages or 1,
|
|
174
174
|
)
|
|
175
175
|
|
|
176
|
+
async def get_launch(self, launch_id: int) -> LaunchDto:
|
|
177
|
+
"""Retrieve a specific launch by its ID.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
launch_id: The unique ID of the launch.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The launch data.
|
|
184
|
+
"""
|
|
185
|
+
self._validate_project_id(self._project_id)
|
|
186
|
+
self._validate_launch_id(launch_id)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
return await self._client.get_launch(launch_id)
|
|
190
|
+
except AllureNotFoundError as exc:
|
|
191
|
+
raise LaunchNotFoundError(
|
|
192
|
+
launch_id=launch_id,
|
|
193
|
+
status_code=exc.status_code,
|
|
194
|
+
response_body=exc.response_body,
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
176
197
|
async def validate_launch_query(self, rql: str) -> tuple[bool, int | None]:
|
|
177
198
|
"""Validate an AQL query for launches."""
|
|
178
199
|
if not isinstance(rql, str) or not rql.strip():
|
|
@@ -203,6 +224,13 @@ class LaunchService:
|
|
|
203
224
|
if not isinstance(size, int) or size <= 0 or size > 100:
|
|
204
225
|
raise AllureValidationError("Size must be between 1 and 100")
|
|
205
226
|
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _validate_launch_id(launch_id: int) -> None:
|
|
229
|
+
if not isinstance(launch_id, int):
|
|
230
|
+
raise AllureValidationError(f"Launch ID must be an integer, got {type(launch_id).__name__}")
|
|
231
|
+
if launch_id <= 0:
|
|
232
|
+
raise AllureValidationError("Launch ID must be a positive integer")
|
|
233
|
+
|
|
206
234
|
@staticmethod
|
|
207
235
|
def _validate_name(name: str) -> None:
|
|
208
236
|
if not isinstance(name, str):
|