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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import logging
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import cast
|
|
4
4
|
|
|
5
5
|
from pydantic import ValidationError as PydanticValidationError
|
|
6
6
|
|
|
@@ -8,10 +8,11 @@ from src.client import (
|
|
|
8
8
|
AllureClient,
|
|
9
9
|
PageSharedStepDto,
|
|
10
10
|
ScenarioStepCreateDto,
|
|
11
|
+
ScenarioStepPatchDto,
|
|
11
12
|
SharedStepAttachmentRowDto,
|
|
12
13
|
SharedStepDto,
|
|
13
14
|
)
|
|
14
|
-
from src.client.exceptions import AllureAPIError, AllureValidationError
|
|
15
|
+
from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
|
|
15
16
|
from src.services.attachment_service import AttachmentService
|
|
16
17
|
from src.utils.schema_hint import generate_schema_hint
|
|
17
18
|
|
|
@@ -37,7 +38,7 @@ class SharedStepService:
|
|
|
37
38
|
async def create_shared_step(
|
|
38
39
|
self,
|
|
39
40
|
name: str,
|
|
40
|
-
steps: list[dict[str,
|
|
41
|
+
steps: list[dict[str, object]] | None = None,
|
|
41
42
|
) -> SharedStepDto:
|
|
42
43
|
"""Create a new shared step with optional steps.
|
|
43
44
|
|
|
@@ -125,7 +126,12 @@ class SharedStepService:
|
|
|
125
126
|
AllureNotFoundError: If shared step doesn't exist.
|
|
126
127
|
AllureAPIError: If the server returns an error.
|
|
127
128
|
"""
|
|
128
|
-
|
|
129
|
+
try:
|
|
130
|
+
return await self._client.get_shared_step(step_id)
|
|
131
|
+
except (AllureNotFoundError, AllureValidationError, AllureAPIError):
|
|
132
|
+
raise
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise AllureAPIError(f"Failed to get shared step {step_id}: {e}") from e
|
|
129
135
|
|
|
130
136
|
async def update_shared_step(
|
|
131
137
|
self,
|
|
@@ -176,27 +182,31 @@ class SharedStepService:
|
|
|
176
182
|
updated = await self._client.update_shared_step(step_id, patch_data)
|
|
177
183
|
return updated, True
|
|
178
184
|
|
|
179
|
-
async def delete_shared_step(self, step_id: int) ->
|
|
185
|
+
async def delete_shared_step(self, step_id: int) -> bool:
|
|
180
186
|
"""Delete (archive) a shared step with idempotent behavior.
|
|
181
187
|
|
|
182
188
|
Args:
|
|
183
189
|
step_id: The shared step ID to delete.
|
|
184
190
|
|
|
191
|
+
Returns:
|
|
192
|
+
True if deleted, False if already deleted/not found.
|
|
193
|
+
|
|
185
194
|
Raises:
|
|
186
195
|
AllureAPIError: If the API request fails (other than 404).
|
|
187
196
|
|
|
188
197
|
Note:
|
|
189
198
|
This operation is idempotent following Story 1.5 pattern.
|
|
190
|
-
If already deleted (404), returns
|
|
199
|
+
If already deleted (404), returns False.
|
|
191
200
|
"""
|
|
192
|
-
from src.client.exceptions import AllureNotFoundError
|
|
193
|
-
|
|
194
201
|
try:
|
|
202
|
+
# Check existence first for accurate feedback
|
|
203
|
+
await self.get_shared_step(step_id)
|
|
195
204
|
await self._client.delete_shared_step(step_id)
|
|
205
|
+
return True
|
|
196
206
|
except AllureNotFoundError:
|
|
197
207
|
# Idempotent: if already deleted, this is fine
|
|
198
208
|
logger.debug(f"Shared step {step_id} already deleted or not found")
|
|
199
|
-
|
|
209
|
+
return False
|
|
200
210
|
|
|
201
211
|
# ==========================================
|
|
202
212
|
# Helper Methods
|
|
@@ -205,7 +215,7 @@ class SharedStepService:
|
|
|
205
215
|
async def _add_steps(
|
|
206
216
|
self,
|
|
207
217
|
shared_step_id: int,
|
|
208
|
-
steps: list[dict[str,
|
|
218
|
+
steps: list[dict[str, object]],
|
|
209
219
|
parent_step_id: int | None = None,
|
|
210
220
|
) -> int | None:
|
|
211
221
|
"""Recursively add steps to a shared step."""
|
|
@@ -214,7 +224,7 @@ class SharedStepService:
|
|
|
214
224
|
for s in steps:
|
|
215
225
|
action = str(s.get("action", ""))
|
|
216
226
|
expected = str(s.get("expected", ""))
|
|
217
|
-
step_attachments
|
|
227
|
+
step_attachments = cast(list[dict[str, str]], s.get("attachments", []))
|
|
218
228
|
|
|
219
229
|
current_parent_id: int | None = parent_step_id
|
|
220
230
|
|
|
@@ -226,12 +236,19 @@ class SharedStepService:
|
|
|
226
236
|
response = await self._client.create_shared_step_scenario_step(step_dto)
|
|
227
237
|
current_parent_id = response.created_step_id
|
|
228
238
|
|
|
229
|
-
# 2. Expected Result (
|
|
230
|
-
if expected:
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
# 2. Expected Result (patch action step)
|
|
240
|
+
if expected and current_parent_id is not None:
|
|
241
|
+
await self._client.patch_shared_step_scenario_step(
|
|
242
|
+
step_id=current_parent_id,
|
|
243
|
+
patch=ScenarioStepPatchDto(body=action, expected_result=expected),
|
|
244
|
+
)
|
|
245
|
+
await self._client.create_shared_step_scenario_step(
|
|
246
|
+
self._build_scenario_step_dto(
|
|
247
|
+
shared_step_id=shared_step_id,
|
|
248
|
+
body=expected,
|
|
249
|
+
parent_id=current_parent_id,
|
|
250
|
+
)
|
|
233
251
|
)
|
|
234
|
-
await self._client.create_shared_step_scenario_step(expected_dto)
|
|
235
252
|
|
|
236
253
|
# 3. Attachments (children of action)
|
|
237
254
|
if step_attachments:
|
|
@@ -317,7 +334,7 @@ class SharedStepService:
|
|
|
317
334
|
if len(name) > MAX_NAME_LENGTH:
|
|
318
335
|
raise AllureValidationError(f"Name too long (max {MAX_NAME_LENGTH})")
|
|
319
336
|
|
|
320
|
-
def _validate_steps(self, steps: list[dict[str,
|
|
337
|
+
def _validate_steps(self, steps: list[dict[str, object]] | None) -> None:
|
|
321
338
|
if steps is None:
|
|
322
339
|
return
|
|
323
340
|
if not isinstance(steps, list):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from typing import TypedDict, cast
|
|
4
5
|
|
|
@@ -7,7 +8,9 @@ from pydantic import ValidationError as PydanticValidationError
|
|
|
7
8
|
from src.client import (
|
|
8
9
|
AllureClient,
|
|
9
10
|
AttachmentStepDtoWithName,
|
|
10
|
-
|
|
11
|
+
ScenarioStepPatchDto,
|
|
12
|
+
StepWithExpected,
|
|
13
|
+
TestCaseDtoWithCF,
|
|
11
14
|
)
|
|
12
15
|
from src.client.exceptions import AllureAPIError, AllureNotFoundError, AllureValidationError
|
|
13
16
|
from src.client.generated.models import (
|
|
@@ -15,11 +18,14 @@ from src.client.generated.models import (
|
|
|
15
18
|
CustomFieldValueDto,
|
|
16
19
|
CustomFieldValueWithCfDto,
|
|
17
20
|
ExternalLinkDto,
|
|
21
|
+
IssueDto,
|
|
18
22
|
ScenarioStepCreateDto,
|
|
19
23
|
SharedStepScenarioDtoStepsInner,
|
|
24
|
+
TestCaseBulkIssueDto,
|
|
20
25
|
TestCaseCreateV2Dto,
|
|
21
26
|
TestCaseDto,
|
|
22
27
|
TestCaseOverviewDto,
|
|
28
|
+
TestCaseTreeSelectionDto,
|
|
23
29
|
TestLayerDto,
|
|
24
30
|
TestTagDto,
|
|
25
31
|
)
|
|
@@ -72,8 +78,14 @@ class TestCaseUpdate:
|
|
|
72
78
|
expected_result: str | None = None
|
|
73
79
|
status_id: int | None = None
|
|
74
80
|
test_layer_id: int | None = None
|
|
81
|
+
test_layer_name: str | None = None
|
|
75
82
|
workflow_id: int | None = None
|
|
76
83
|
links: list[dict[str, str]] | None = None
|
|
84
|
+
issues: list[str] | None = None
|
|
85
|
+
remove_issues: list[str] | None = None
|
|
86
|
+
clear_issues: bool | None = None
|
|
87
|
+
integration_id: int | None = None
|
|
88
|
+
integration_name: str | None = None
|
|
77
89
|
|
|
78
90
|
|
|
79
91
|
@dataclass
|
|
@@ -112,6 +124,9 @@ class TestCaseService:
|
|
|
112
124
|
custom_fields: dict[str, str | list[str]] | None = None,
|
|
113
125
|
test_layer_id: int | None = None,
|
|
114
126
|
test_layer_name: str | None = None,
|
|
127
|
+
issues: list[str] | None = None,
|
|
128
|
+
integration_id: int | None = None,
|
|
129
|
+
integration_name: str | None = None,
|
|
115
130
|
) -> TestCaseOverviewDto:
|
|
116
131
|
"""Create a new test case.
|
|
117
132
|
|
|
@@ -124,6 +139,9 @@ class TestCaseService:
|
|
|
124
139
|
custom_fields: Optional dictionary of custom fields (Name -> Value).
|
|
125
140
|
test_layer_id: Optional test layer ID to assign to the test case.
|
|
126
141
|
test_layer_name: Optional test layer name to assign to the test case.
|
|
142
|
+
issues: Optional list of issue keys to link (e.g., 'PROJ-123').
|
|
143
|
+
integration_id: Optional integration ID for issue linking (required when multiple exist).
|
|
144
|
+
integration_name: Optional integration name for issue linking (mutually exclusive with integration_id).
|
|
127
145
|
|
|
128
146
|
Returns:
|
|
129
147
|
The created test case overview.
|
|
@@ -232,6 +250,15 @@ class TestCaseService:
|
|
|
232
250
|
|
|
233
251
|
# Add global attachments (appended at end of steps)
|
|
234
252
|
await self._add_global_attachments(test_case_id, attachments, last_step_id)
|
|
253
|
+
|
|
254
|
+
# Add issue links
|
|
255
|
+
if issues:
|
|
256
|
+
await self.add_issues_to_test_case(
|
|
257
|
+
test_case_id,
|
|
258
|
+
issues,
|
|
259
|
+
integration_id=integration_id,
|
|
260
|
+
integration_name=integration_name,
|
|
261
|
+
)
|
|
235
262
|
except Exception as e:
|
|
236
263
|
# Rollback: delete the partially created test case
|
|
237
264
|
try:
|
|
@@ -248,7 +275,7 @@ class TestCaseService:
|
|
|
248
275
|
|
|
249
276
|
return created_test_case
|
|
250
277
|
|
|
251
|
-
async def get_test_case(self, test_case_id: int) ->
|
|
278
|
+
async def get_test_case(self, test_case_id: int) -> TestCaseDtoWithCF:
|
|
252
279
|
"""Retrieve a test case by ID.
|
|
253
280
|
|
|
254
281
|
Args:
|
|
@@ -345,6 +372,9 @@ class TestCaseService:
|
|
|
345
372
|
data.test_layer_id is not None,
|
|
346
373
|
data.workflow_id is not None,
|
|
347
374
|
data.links is not None,
|
|
375
|
+
data.issues is not None,
|
|
376
|
+
data.remove_issues is not None,
|
|
377
|
+
data.clear_issues is not None,
|
|
348
378
|
]
|
|
349
379
|
)
|
|
350
380
|
has_scenario_changes = data.steps is not None or data.attachments is not None
|
|
@@ -358,16 +388,54 @@ class TestCaseService:
|
|
|
358
388
|
updated_case = await self.get_test_case(test_case_id)
|
|
359
389
|
return updated_case if updated_case is not None else current_case
|
|
360
390
|
|
|
361
|
-
if data.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
391
|
+
if data.clear_issues:
|
|
392
|
+
# Use current_case fetched at step 1 instead of re-fetching
|
|
393
|
+
if current_case and current_case.issues:
|
|
394
|
+
existing_keys = [i.name for i in current_case.issues if i.name]
|
|
395
|
+
if existing_keys:
|
|
396
|
+
await self.remove_issues_from_test_case(test_case_id, existing_keys)
|
|
397
|
+
|
|
398
|
+
if data.remove_issues:
|
|
399
|
+
await self.remove_issues_from_test_case(test_case_id, data.remove_issues)
|
|
400
|
+
|
|
401
|
+
if data.issues:
|
|
402
|
+
await self.add_issues_to_test_case(
|
|
403
|
+
test_case_id,
|
|
404
|
+
data.issues,
|
|
405
|
+
integration_id=data.integration_id,
|
|
406
|
+
integration_name=data.integration_name,
|
|
407
|
+
)
|
|
366
408
|
|
|
367
|
-
#
|
|
409
|
+
# 4. Handle Metadata Patches
|
|
368
410
|
patch_kwargs, has_changes = await self._prepare_field_updates(current_case, data)
|
|
369
411
|
|
|
370
|
-
|
|
412
|
+
if data.test_layer_id is not None or data.test_layer_name is not None:
|
|
413
|
+
current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
|
|
414
|
+
resolved_layer_id = None
|
|
415
|
+
skip_validation = False
|
|
416
|
+
|
|
417
|
+
if data.test_layer_id is not None and data.test_layer_name is None:
|
|
418
|
+
if data.test_layer_id == current_test_layer_id:
|
|
419
|
+
skip_validation = True
|
|
420
|
+
resolved_layer_id = data.test_layer_id
|
|
421
|
+
|
|
422
|
+
if not skip_validation:
|
|
423
|
+
# Resolve the ID using the validation helper
|
|
424
|
+
# Note: _validate_test_layer handles mutual exclusivity check
|
|
425
|
+
resolved_layer_id = await self._validate_test_layer(
|
|
426
|
+
test_layer_id=data.test_layer_id,
|
|
427
|
+
test_layer_name=data.test_layer_name,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
current_test_layer_id = current_case.test_layer.id if current_case.test_layer else None
|
|
431
|
+
|
|
432
|
+
# Only update if the resolved ID is different (and not None)
|
|
433
|
+
if resolved_layer_id is not None and resolved_layer_id != current_test_layer_id:
|
|
434
|
+
# Add to patch_kwargs which is now initialized
|
|
435
|
+
patch_kwargs["test_layer_id"] = resolved_layer_id
|
|
436
|
+
has_changes = True
|
|
437
|
+
|
|
438
|
+
# 5. Handle Custom Fields in mixed update (replacement)
|
|
371
439
|
if data.custom_fields is not None:
|
|
372
440
|
# For mixed updates via patch13, we need to provide the FULL list of custom fields
|
|
373
441
|
# since the API replaces them. We merge current + updates.
|
|
@@ -422,7 +490,7 @@ class TestCaseService:
|
|
|
422
490
|
patch_kwargs["customFields"] = final_cfs
|
|
423
491
|
has_changes = True
|
|
424
492
|
|
|
425
|
-
#
|
|
493
|
+
# 6. Handle Scenario
|
|
426
494
|
scenario_dto_v2 = await self._prepare_scenario_update(test_case_id, data)
|
|
427
495
|
if scenario_dto_v2:
|
|
428
496
|
has_changes = True
|
|
@@ -430,7 +498,7 @@ class TestCaseService:
|
|
|
430
498
|
if not has_changes:
|
|
431
499
|
return current_case
|
|
432
500
|
|
|
433
|
-
#
|
|
501
|
+
# 7. Apply Update
|
|
434
502
|
try:
|
|
435
503
|
patch_data = TestCasePatchV2Dto(**patch_kwargs)
|
|
436
504
|
await self._client.update_test_case(test_case_id, patch_data)
|
|
@@ -438,7 +506,7 @@ class TestCaseService:
|
|
|
438
506
|
hint = generate_schema_hint(TestCasePatchV2Dto)
|
|
439
507
|
raise AllureValidationError(f"Invalid update data: {e}", suggestions=[hint]) from e
|
|
440
508
|
|
|
441
|
-
#
|
|
509
|
+
# 8. Apply Scenario Re-creation
|
|
442
510
|
if scenario_dto_v2 and scenario_dto_v2.steps is not None:
|
|
443
511
|
await self._recreate_scenario(test_case_id, scenario_dto_v2.steps)
|
|
444
512
|
|
|
@@ -572,6 +640,139 @@ class TestCaseService:
|
|
|
572
640
|
|
|
573
641
|
return await self.get_test_case(test_case_id)
|
|
574
642
|
|
|
643
|
+
async def add_issues_to_test_case(
|
|
644
|
+
self,
|
|
645
|
+
test_case_id: int,
|
|
646
|
+
issues: list[str],
|
|
647
|
+
integration_id: int | None = None,
|
|
648
|
+
integration_name: str | None = None,
|
|
649
|
+
) -> None:
|
|
650
|
+
"""Add issue links to a test case.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
test_case_id: The test case ID.
|
|
654
|
+
issues: List of issue keys (e.g. "PROJ-123").
|
|
655
|
+
integration_id: Optional integration ID (required when multiple exist).
|
|
656
|
+
integration_name: Optional integration name (mutually exclusive with integration_id).
|
|
657
|
+
"""
|
|
658
|
+
if not issues:
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
# 1. Resolve integrations and build IssueDtos
|
|
662
|
+
issue_dtos = await self._build_issue_dtos(
|
|
663
|
+
issues,
|
|
664
|
+
integration_id=integration_id,
|
|
665
|
+
integration_name=integration_name,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# Idempotency Filter: Only link issues not already present
|
|
669
|
+
current_case = await self.get_test_case(test_case_id)
|
|
670
|
+
existing_names = {i.name.upper() for i in (current_case.issues or []) if i.name}
|
|
671
|
+
issue_dtos = [io for io in issue_dtos if io.name not in existing_names]
|
|
672
|
+
|
|
673
|
+
if not issue_dtos:
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
# 2. Build selection
|
|
677
|
+
selection = TestCaseTreeSelectionDto(project_id=self._project_id, leafs_include=[test_case_id])
|
|
678
|
+
|
|
679
|
+
# 3. Call Bulk API
|
|
680
|
+
from src.client.generated.api.test_case_bulk_controller_api import TestCaseBulkControllerApi
|
|
681
|
+
|
|
682
|
+
bulk_api = TestCaseBulkControllerApi(self._client.api_client)
|
|
683
|
+
|
|
684
|
+
bulk_dto = TestCaseBulkIssueDto(issues=issue_dtos, selection=selection)
|
|
685
|
+
await bulk_api.issue_add1(bulk_dto)
|
|
686
|
+
|
|
687
|
+
async def remove_issues_from_test_case(self, test_case_id: int, issues: list[str]) -> None:
|
|
688
|
+
"""Remove issue links from a test case.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
test_case_id: The test case ID.
|
|
692
|
+
issues: List of issue keys to remove.
|
|
693
|
+
"""
|
|
694
|
+
if not issues:
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
# 1. Fetch current issues to get their IDs
|
|
698
|
+
current_case = await self.get_test_case(test_case_id)
|
|
699
|
+
if not current_case.issues:
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# 2. Find IDs of issues to remove
|
|
703
|
+
issues_to_remove_ids = []
|
|
704
|
+
issues_set = set(issues)
|
|
705
|
+
|
|
706
|
+
for issue in current_case.issues:
|
|
707
|
+
if issue.name and issue.name in issues_set and issue.id:
|
|
708
|
+
issues_to_remove_ids.append(issue.id)
|
|
709
|
+
|
|
710
|
+
if not issues_to_remove_ids:
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
# 3. Call Bulk API with IDs
|
|
714
|
+
selection = TestCaseTreeSelectionDto(project_id=self._project_id, leafs_include=[test_case_id])
|
|
715
|
+
|
|
716
|
+
from src.client.generated.api.test_case_bulk_controller_api import TestCaseBulkControllerApi
|
|
717
|
+
from src.client.generated.models.test_case_bulk_entity_ids_dto import TestCaseBulkEntityIdsDto
|
|
718
|
+
|
|
719
|
+
bulk_api = TestCaseBulkControllerApi(self._client.api_client)
|
|
720
|
+
|
|
721
|
+
bulk_dto = TestCaseBulkEntityIdsDto(ids=issues_to_remove_ids, selection=selection)
|
|
722
|
+
await bulk_api.issue_remove1(bulk_dto)
|
|
723
|
+
|
|
724
|
+
MAX_ISSUE_KEY_LENGTH = 50
|
|
725
|
+
ISSUE_KEY_PATTERN = re.compile(r"^[A-Z0-9]+-\d+$", re.IGNORECASE)
|
|
726
|
+
|
|
727
|
+
async def _build_issue_dtos(
|
|
728
|
+
self,
|
|
729
|
+
issues: list[str],
|
|
730
|
+
integration_id: int | None = None,
|
|
731
|
+
integration_name: str | None = None,
|
|
732
|
+
) -> list[IssueDto]:
|
|
733
|
+
"""Convert issue keys to IssueDto objects with resolved integration IDs.
|
|
734
|
+
|
|
735
|
+
Validation:
|
|
736
|
+
- Checks issue key format (e.g., PROJ-123).
|
|
737
|
+
- Resolves integration ID using IntegrationService.
|
|
738
|
+
- AC#6: Auto-selects when only one integration exists.
|
|
739
|
+
- AC#7: Errors when multiple integrations exist and none specified.
|
|
740
|
+
"""
|
|
741
|
+
if not issues:
|
|
742
|
+
return []
|
|
743
|
+
|
|
744
|
+
# 1. Validate Issue Keys
|
|
745
|
+
invalid_keys = []
|
|
746
|
+
for key in issues:
|
|
747
|
+
if not key or len(key) > self.MAX_ISSUE_KEY_LENGTH:
|
|
748
|
+
invalid_keys.append(f"{key} (too long)")
|
|
749
|
+
elif not self.ISSUE_KEY_PATTERN.match(key):
|
|
750
|
+
invalid_keys.append(f"{key} (invalid format, expected PROJECT-123)")
|
|
751
|
+
|
|
752
|
+
if invalid_keys:
|
|
753
|
+
raise AllureValidationError(
|
|
754
|
+
"Invalid issue keys provided:\n"
|
|
755
|
+
+ "\n".join(f"- {k}" for k in invalid_keys)
|
|
756
|
+
+ "\n\nHint: Issue keys must follow the format 'PROJECT-123' (alphanumeric prefix, hyphen, number)."
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# 2. Resolve Integration using IntegrationService
|
|
760
|
+
from src.services.integration_service import IntegrationService
|
|
761
|
+
|
|
762
|
+
integration_service = IntegrationService(self._client)
|
|
763
|
+
target_integration_id = await integration_service.resolve_integration_for_issues(
|
|
764
|
+
integration_id=integration_id,
|
|
765
|
+
integration_name=integration_name,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# 3. Build DTOs
|
|
769
|
+
dtos = []
|
|
770
|
+
for key in issues:
|
|
771
|
+
dto = IssueDto(name=key.upper(), integration_id=target_integration_id) # Normalize to uppercase
|
|
772
|
+
dtos.append(dto)
|
|
773
|
+
|
|
774
|
+
return dtos
|
|
775
|
+
|
|
575
776
|
async def _prepare_field_updates( # noqa: C901
|
|
576
777
|
self, current_case: TestCaseDto, data: TestCaseUpdate
|
|
577
778
|
) -> tuple[dict[str, object], bool]:
|
|
@@ -605,11 +806,8 @@ class TestCaseService:
|
|
|
605
806
|
patch_kwargs["status_id"] = data.status_id
|
|
606
807
|
has_changes = True
|
|
607
808
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if data.test_layer_id != current_test_layer_id:
|
|
611
|
-
patch_kwargs["test_layer_id"] = data.test_layer_id
|
|
612
|
-
has_changes = True
|
|
809
|
+
# test_layer_id is handled centrally in update_test_case logic due to name resolution complexity
|
|
810
|
+
# and validation requirements. We skip it here to avoid duplication.
|
|
613
811
|
|
|
614
812
|
if data.workflow_id is not None:
|
|
615
813
|
current_workflow_id = current_case.workflow.id if current_case.workflow else None
|
|
@@ -721,13 +919,6 @@ class TestCaseService:
|
|
|
721
919
|
|
|
722
920
|
children: list[SharedStepScenarioDtoStepsInner] = []
|
|
723
921
|
|
|
724
|
-
if expected:
|
|
725
|
-
children.append(
|
|
726
|
-
SharedStepScenarioDtoStepsInner(
|
|
727
|
-
actual_instance=ExpectedBodyStepDto(type="ExpectedBodyStepDto", body=expected)
|
|
728
|
-
)
|
|
729
|
-
)
|
|
730
|
-
|
|
731
922
|
if step_attachments and isinstance(step_attachments, list):
|
|
732
923
|
for sa in step_attachments:
|
|
733
924
|
if isinstance(sa, dict):
|
|
@@ -748,7 +939,12 @@ class TestCaseService:
|
|
|
748
939
|
|
|
749
940
|
dtos.append(
|
|
750
941
|
SharedStepScenarioDtoStepsInner(
|
|
751
|
-
actual_instance=
|
|
942
|
+
actual_instance=StepWithExpected(
|
|
943
|
+
type="BodyStepDto",
|
|
944
|
+
body=action,
|
|
945
|
+
expected_result=expected or None,
|
|
946
|
+
steps=children,
|
|
947
|
+
)
|
|
752
948
|
)
|
|
753
949
|
)
|
|
754
950
|
return dtos
|
|
@@ -892,8 +1088,8 @@ class TestCaseService:
|
|
|
892
1088
|
"""Validate test layer ID format."""
|
|
893
1089
|
if test_layer_id is None:
|
|
894
1090
|
return
|
|
895
|
-
if not isinstance(test_layer_id, int) or test_layer_id
|
|
896
|
-
raise AllureValidationError("Test layer ID must be a positive integer")
|
|
1091
|
+
if not isinstance(test_layer_id, int) or test_layer_id < 0:
|
|
1092
|
+
raise AllureValidationError("Test layer ID must be a positive integer or 0 (to unset)")
|
|
897
1093
|
|
|
898
1094
|
def _format_available_layers(self, layers: list[TestLayerDto]) -> str:
|
|
899
1095
|
display_lines: list[str] = []
|
|
@@ -940,6 +1136,9 @@ class TestCaseService:
|
|
|
940
1136
|
|
|
941
1137
|
if test_layer_id is not None:
|
|
942
1138
|
self._validate_test_layer_id(test_layer_id)
|
|
1139
|
+
if test_layer_id == 0:
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
943
1142
|
try:
|
|
944
1143
|
await self._test_layer_service.get_test_layer(test_layer_id)
|
|
945
1144
|
except AllureNotFoundError as e:
|
|
@@ -1019,7 +1218,15 @@ class TestCaseService:
|
|
|
1019
1218
|
"""Get or fetch custom field name-to-info mapping for a project."""
|
|
1020
1219
|
if project_id in self._cf_cache:
|
|
1021
1220
|
return self._cf_cache[project_id]
|
|
1221
|
+
return await self._fetch_resolved_custom_fields(project_id)
|
|
1022
1222
|
|
|
1223
|
+
async def refresh_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
|
|
1224
|
+
"""Refresh cached custom field name-to-info mapping for a project."""
|
|
1225
|
+
if project_id in self._cf_cache:
|
|
1226
|
+
del self._cf_cache[project_id]
|
|
1227
|
+
return await self._fetch_resolved_custom_fields(project_id)
|
|
1228
|
+
|
|
1229
|
+
async def _fetch_resolved_custom_fields(self, project_id: int) -> dict[str, ResolvedCustomFieldInfo]:
|
|
1023
1230
|
# Use the client wrapper method for consistent error handling and response processing
|
|
1024
1231
|
cfs = await self._client.get_custom_fields_with_values(project_id)
|
|
1025
1232
|
logger.debug("Fetched %d custom fields for project %d", len(cfs), project_id)
|
|
@@ -1277,19 +1484,21 @@ class TestCaseService:
|
|
|
1277
1484
|
current_parent_id = response.created_step_id
|
|
1278
1485
|
last_step_id = current_parent_id
|
|
1279
1486
|
|
|
1280
|
-
# If there's an expected result,
|
|
1281
|
-
if expected:
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
body=expected,
|
|
1285
|
-
parent_id=current_parent_id,
|
|
1487
|
+
# If there's an expected result, patch the action step
|
|
1488
|
+
if expected and current_parent_id is not None:
|
|
1489
|
+
await self._client.patch_test_case_scenario_step(
|
|
1490
|
+
step_id=current_parent_id,
|
|
1491
|
+
patch=ScenarioStepPatchDto(body=action, expected_result=expected),
|
|
1286
1492
|
)
|
|
1287
|
-
|
|
1493
|
+
await self._client.create_scenario_step(
|
|
1288
1494
|
test_case_id=test_case_id,
|
|
1289
|
-
step=
|
|
1290
|
-
|
|
1495
|
+
step=self._build_scenario_step_dto(
|
|
1496
|
+
test_case_id=test_case_id,
|
|
1497
|
+
body=expected,
|
|
1498
|
+
parent_id=current_parent_id,
|
|
1499
|
+
),
|
|
1500
|
+
after_id=None,
|
|
1291
1501
|
)
|
|
1292
|
-
last_child_id = expected_response.created_step_id
|
|
1293
1502
|
|
|
1294
1503
|
# Step Attachments (added as children of the action step)
|
|
1295
1504
|
if step_attachments and isinstance(step_attachments, list):
|
|
@@ -1376,6 +1585,22 @@ class TestCaseService:
|
|
|
1376
1585
|
resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
|
|
1377
1586
|
created_id = resp.created_step_id
|
|
1378
1587
|
|
|
1588
|
+
expected_result = getattr(instance, "expected_result", None)
|
|
1589
|
+
if expected_result and created_id is not None:
|
|
1590
|
+
await self._client.patch_test_case_scenario_step(
|
|
1591
|
+
step_id=created_id,
|
|
1592
|
+
patch=ScenarioStepPatchDto(body=instance.body, expected_result=expected_result),
|
|
1593
|
+
)
|
|
1594
|
+
await self._client.create_scenario_step(
|
|
1595
|
+
test_case_id=test_case_id,
|
|
1596
|
+
step=self._build_scenario_step_dto(
|
|
1597
|
+
test_case_id=test_case_id,
|
|
1598
|
+
body=expected_result,
|
|
1599
|
+
parent_id=created_id,
|
|
1600
|
+
),
|
|
1601
|
+
after_id=None,
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1379
1604
|
# Add children (steps may not be a defined field in generated DTO,
|
|
1380
1605
|
# but we set it via model_construct)
|
|
1381
1606
|
child_steps = getattr(instance, "steps", None)
|
|
@@ -1398,6 +1623,13 @@ class TestCaseService:
|
|
|
1398
1623
|
resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
|
|
1399
1624
|
created_id = resp.created_step_id
|
|
1400
1625
|
|
|
1626
|
+
elif isinstance(instance, SharedStepStepDto):
|
|
1627
|
+
step_dto = self._build_scenario_step_dto(
|
|
1628
|
+
test_case_id=test_case_id, shared_step_id=instance.shared_step_id, parent_id=parent_id
|
|
1629
|
+
)
|
|
1630
|
+
resp = await self._client.create_scenario_step(test_case_id=test_case_id, step=step_dto, after_id=after_id)
|
|
1631
|
+
created_id = resp.created_step_id
|
|
1632
|
+
|
|
1401
1633
|
return created_id if created_id else after_id
|
|
1402
1634
|
|
|
1403
1635
|
async def _add_global_attachments(
|
|
@@ -123,6 +123,8 @@ class TestLayerService:
|
|
|
123
123
|
except ApiException as e:
|
|
124
124
|
self._client._handle_api_exception(e)
|
|
125
125
|
raise
|
|
126
|
+
except (AllureNotFoundError, AllureValidationError, AllureAPIError):
|
|
127
|
+
raise
|
|
126
128
|
except Exception as e:
|
|
127
129
|
raise AllureAPIError(f"Failed to get test layer {layer_id}: {e}") from e
|
|
128
130
|
|
|
@@ -192,6 +194,8 @@ class TestLayerService:
|
|
|
192
194
|
raise AllureAPIError("Test Layer API is not initialized")
|
|
193
195
|
|
|
194
196
|
try:
|
|
197
|
+
# Check existence first for accurate feedback
|
|
198
|
+
await self.get_test_layer(layer_id)
|
|
195
199
|
await self._client._test_layer_api.delete9(id=layer_id)
|
|
196
200
|
return True
|
|
197
201
|
except AllureNotFoundError:
|
|
@@ -300,6 +304,11 @@ class TestLayerService:
|
|
|
300
304
|
|
|
301
305
|
try:
|
|
302
306
|
return await self._client._test_layer_schema_api.find_one7(id=schema_id)
|
|
307
|
+
except ApiException as e:
|
|
308
|
+
self._client._handle_api_exception(e)
|
|
309
|
+
raise
|
|
310
|
+
except (AllureNotFoundError, AllureValidationError, AllureAPIError):
|
|
311
|
+
raise
|
|
303
312
|
except Exception as e:
|
|
304
313
|
raise AllureAPIError(f"Failed to get test layer schema {schema_id}: {e}") from e
|
|
305
314
|
|
|
@@ -385,6 +394,8 @@ class TestLayerService:
|
|
|
385
394
|
raise AllureAPIError("Test Layer Schema API is not initialized")
|
|
386
395
|
|
|
387
396
|
try:
|
|
397
|
+
# Check existence first for accurate feedback
|
|
398
|
+
await self.get_test_layer_schema(schema_id)
|
|
388
399
|
await self._client._test_layer_schema_api.delete8(id=schema_id)
|
|
389
400
|
return True
|
|
390
401
|
except AllureNotFoundError:
|