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.
Files changed (39) hide show
  1. lucius_mcp-0.4.0.dist-info/METADATA +124 -0
  2. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/RECORD +38 -29
  3. src/client/__init__.py +6 -0
  4. src/client/client.py +271 -7
  5. src/client/exceptions.py +23 -0
  6. src/client/generated/README.md +18 -0
  7. src/client/generated/__init__.py +2 -0
  8. src/client/generated/api/__init__.py +1 -0
  9. src/client/generated/api/integration_controller_api.py +5285 -0
  10. src/client/generated/docs/IntegrationControllerApi.md +1224 -0
  11. src/services/__init__.py +9 -1
  12. src/services/custom_field_value_service.py +301 -0
  13. src/services/integration_service.py +205 -0
  14. src/services/launch_service.py +29 -1
  15. src/services/shared_step_service.py +34 -17
  16. src/services/test_case_service.py +269 -37
  17. src/services/test_layer_service.py +11 -0
  18. src/tools/__init__.py +19 -1
  19. src/tools/create_custom_field_value.py +38 -0
  20. src/tools/create_test_case.py +32 -2
  21. src/tools/delete_custom_field_value.py +57 -0
  22. src/tools/delete_test_case.py +5 -4
  23. src/tools/delete_test_layer.py +17 -4
  24. src/tools/delete_test_layer_schema.py +17 -4
  25. src/tools/launches.py +86 -0
  26. src/tools/link_shared_step.py +18 -12
  27. src/tools/list_custom_field_values.py +72 -0
  28. src/tools/list_integrations.py +77 -0
  29. src/tools/search.py +3 -3
  30. src/tools/shared_steps.py +23 -8
  31. src/tools/unlink_shared_step.py +19 -5
  32. src/tools/update_custom_field_value.py +55 -0
  33. src/tools/update_test_case.py +67 -2
  34. src/tools/update_test_layer.py +15 -4
  35. src/tools/update_test_layer_schema.py +16 -5
  36. lucius_mcp-0.3.0.dist-info/METADATA +0 -297
  37. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/WHEEL +0 -0
  38. {lucius_mcp-0.3.0.dist-info → lucius_mcp-0.4.0.dist-info}/entry_points.txt +0 -0
  39. {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__ = ["AttachmentService", "SearchService", "SharedStepService", "TestCaseService", "TestLayerService"]
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)
@@ -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):