alita-sdk 0.3.376__py3-none-any.whl → 0.3.435__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.

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

Files changed (60) hide show
  1. alita_sdk/configurations/bitbucket.py +95 -0
  2. alita_sdk/configurations/confluence.py +96 -1
  3. alita_sdk/configurations/gitlab.py +79 -0
  4. alita_sdk/configurations/jira.py +103 -0
  5. alita_sdk/configurations/testrail.py +88 -0
  6. alita_sdk/configurations/xray.py +93 -0
  7. alita_sdk/configurations/zephyr_enterprise.py +93 -0
  8. alita_sdk/configurations/zephyr_essential.py +75 -0
  9. alita_sdk/runtime/clients/client.py +9 -4
  10. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  11. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  12. alita_sdk/runtime/clients/sandbox_client.py +8 -0
  13. alita_sdk/runtime/langchain/assistant.py +41 -38
  14. alita_sdk/runtime/langchain/constants.py +5 -1
  15. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  16. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +4 -1
  17. alita_sdk/runtime/langchain/document_loaders/constants.py +28 -12
  18. alita_sdk/runtime/langchain/langraph_agent.py +91 -27
  19. alita_sdk/runtime/langchain/utils.py +24 -4
  20. alita_sdk/runtime/models/mcp_models.py +57 -0
  21. alita_sdk/runtime/toolkits/__init__.py +24 -0
  22. alita_sdk/runtime/toolkits/application.py +8 -1
  23. alita_sdk/runtime/toolkits/mcp.py +787 -0
  24. alita_sdk/runtime/toolkits/tools.py +98 -50
  25. alita_sdk/runtime/tools/__init__.py +7 -2
  26. alita_sdk/runtime/tools/application.py +7 -0
  27. alita_sdk/runtime/tools/function.py +20 -28
  28. alita_sdk/runtime/tools/graph.py +10 -4
  29. alita_sdk/runtime/tools/image_generation.py +104 -8
  30. alita_sdk/runtime/tools/llm.py +146 -114
  31. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  32. alita_sdk/runtime/tools/mcp_server_tool.py +79 -10
  33. alita_sdk/runtime/tools/sandbox.py +166 -63
  34. alita_sdk/runtime/tools/vectorstore.py +3 -2
  35. alita_sdk/runtime/tools/vectorstore_base.py +4 -3
  36. alita_sdk/runtime/utils/streamlit.py +34 -3
  37. alita_sdk/runtime/utils/toolkit_utils.py +5 -2
  38. alita_sdk/runtime/utils/utils.py +1 -0
  39. alita_sdk/tools/__init__.py +48 -31
  40. alita_sdk/tools/ado/work_item/ado_wrapper.py +17 -8
  41. alita_sdk/tools/base_indexer_toolkit.py +75 -66
  42. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  43. alita_sdk/tools/code_indexer_toolkit.py +13 -3
  44. alita_sdk/tools/confluence/api_wrapper.py +29 -7
  45. alita_sdk/tools/confluence/loader.py +10 -0
  46. alita_sdk/tools/elitea_base.py +7 -7
  47. alita_sdk/tools/gitlab/api_wrapper.py +11 -7
  48. alita_sdk/tools/jira/api_wrapper.py +1 -1
  49. alita_sdk/tools/openapi/__init__.py +10 -1
  50. alita_sdk/tools/qtest/api_wrapper.py +522 -74
  51. alita_sdk/tools/sharepoint/api_wrapper.py +104 -33
  52. alita_sdk/tools/sharepoint/authorization_helper.py +175 -1
  53. alita_sdk/tools/sharepoint/utils.py +8 -2
  54. alita_sdk/tools/utils/content_parser.py +27 -16
  55. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +19 -6
  56. {alita_sdk-0.3.376.dist-info → alita_sdk-0.3.435.dist-info}/METADATA +1 -1
  57. {alita_sdk-0.3.376.dist-info → alita_sdk-0.3.435.dist-info}/RECORD +60 -55
  58. {alita_sdk-0.3.376.dist-info → alita_sdk-0.3.435.dist-info}/WHEEL +0 -0
  59. {alita_sdk-0.3.376.dist-info → alita_sdk-0.3.435.dist-info}/licenses/LICENSE +0 -0
  60. {alita_sdk-0.3.376.dist-info → alita_sdk-0.3.435.dist-info}/top_level.txt +0 -0
@@ -9,7 +9,7 @@ import swagger_client
9
9
  from langchain_core.tools import ToolException
10
10
  from pydantic import Field, PrivateAttr, model_validator, create_model, SecretStr
11
11
  from sklearn.feature_extraction.text import strip_tags
12
- from swagger_client import TestCaseApi, SearchApi, PropertyResource, ModuleApi
12
+ from swagger_client import TestCaseApi, SearchApi, PropertyResource, ModuleApi, ProjectApi, FieldApi
13
13
  from swagger_client.rest import ApiException
14
14
 
15
15
  from ..elitea_base import BaseToolApiWrapper
@@ -26,21 +26,29 @@ If generated data was used, put appropriate note to the test case description fi
26
26
  ### CRITERIA
27
27
  1. The structure should be as in EXAMPLE.
28
28
  2. Case and spaces for field names should be exactly the same as in NOTES.
29
- 3. Extra fields are allowed.
29
+ 3. Extra fields are allowed and will be mapped to project's custom fields if they exist.
30
30
  4. "{QTEST_ID}" is required to update, change or replace values in test case.
31
31
  5. Do not provide "Id" and "{QTEST_ID}" to create test case.
32
- 6 "Steps" is a list of test step objects with fields "Test Step Number", "Test Step Description", "Test Step Expected Result".
32
+ 6. "Steps" is a list of test step objects with fields "Test Step Number", "Test Step Description", "Test Step Expected Result".
33
+ 7. For updates, provide ONLY the fields you want to change. Omitted fields will remain unchanged.
33
34
 
34
35
  ### NOTES
35
- Id: Unique identifier (e.g., TC-123).
36
- QTest id: Unique identifier (e.g., 4626964).
37
- Name: Brief title.
38
- Description: Short purpose.
39
- Type: 'Manual' or 'Automation - UTAF'. Leave blank if unknown.
40
- Status: Default 'New'.
41
- Priority: Leave blank.
42
- Test Type: Default 'Functional'.
43
- Precondition: List prerequisites in one cell, formatted as: <Step1> <Step2> Leave blank if none..
36
+ Id: Unique identifier (e.g., TC-123). Read-only.
37
+ QTest id: Unique identifier (e.g., 4626964). Required for updates.
38
+ Name: Brief title of the test case.
39
+ Description: Short description of the test purpose.
40
+ Type: Type of test (e.g., 'Manual', 'Automation - UTAF').
41
+ Status: Current status (e.g., 'New', 'In Progress', 'Completed').
42
+ Priority: Priority level (e.g., 'High', 'Medium', 'Low').
43
+ Test Type: Category of test (e.g., 'Functional', 'Regression', 'Smoke').
44
+ Precondition: Prerequisites for the test, formatted as: <Step1> <Step2> Leave blank if none.
45
+ Steps: Array of test steps with Description and Expected Result.
46
+
47
+ **Multi-select fields**: For fields that allow multiple values (e.g., Team, Assigned To etc.), you can provide:
48
+ - Single value: "Team": "Epam"
49
+ - Multiple values: "Team": ["Epam", "EJ"]
50
+
51
+ **For Updates**: Include only the fields you want to modify. The system will validate property values against project configuration.
44
52
 
45
53
  ### EXAMPLE
46
54
  {{
@@ -53,6 +61,7 @@ Precondition: List prerequisites in one cell, formatted as: <Step1> <Step2> Leav
53
61
  "Priority": "",
54
62
  "Test Type": "Functional",
55
63
  "Precondition": "<ONLY provided by user precondition>",
64
+ "Team": ["Epam", "EJ"],
56
65
  "Steps": [
57
66
  {{ "Test Step Number": 1, "Test Step Description": "Navigate to url", "Test Step Expected Result": "Page content is loaded"}},
58
67
  {{ "Test Step Number": 2, "Test Step Description": "Click 'Login'", "Test Step Expected Result": "Form is expanded"}},
@@ -82,8 +91,16 @@ QtestCreateTestCase = create_model(
82
91
 
83
92
  QtestLinkTestCaseToJiraRequirement = create_model(
84
93
  "QtestLinkTestCaseToJiraRequirement",
85
- requirement_external_id=(str, Field("Qtest requirement external id which represent jira issue id linked to Qtest as a requirement e.g. SITEPOD-4038")),
86
- json_list_of_test_case_ids=(str, Field("""List of the test case ids to be linked to particular requirement.
94
+ requirement_external_id=(str, Field(description="Qtest requirement external id which represent jira issue id linked to Qtest as a requirement e.g. SITEPOD-4038")),
95
+ json_list_of_test_case_ids=(str, Field(description="""List of the test case ids to be linked to particular requirement.
96
+ Create a list of the test case ids in the following format '["TC-123", "TC-234", "TC-456"]' which represents json array as a string.
97
+ It should be capable to be extracted directly by python json.loads method."""))
98
+ )
99
+
100
+ QtestLinkTestCaseToQtestRequirement = create_model(
101
+ "QtestLinkTestCaseToQtestRequirement",
102
+ requirement_id=(str, Field(description="QTest internal requirement ID in format RQ-123")),
103
+ json_list_of_test_case_ids=(str, Field(description="""List of the test case ids to be linked to particular requirement.
87
104
  Create a list of the test case ids in the following format '["TC-123", "TC-234", "TC-456"]' which represents json array as a string.
88
105
  It should be capable to be extracted directly by python json.loads method."""))
89
106
  )
@@ -118,6 +135,17 @@ GetModules = create_model(
118
135
 
119
136
  )
120
137
 
138
+ GetAllTestCasesFieldsForProject = create_model(
139
+ "GetAllTestCasesFieldsForProject",
140
+ force_refresh=(Optional[bool],
141
+ Field(description="Set to true to reload field definitions from API if project configuration has changed (new fields added, dropdown values modified). Default: false (uses cached data).",
142
+ default=False)),
143
+ )
144
+
145
+ NoInput = create_model(
146
+ "NoInput"
147
+ )
148
+
121
149
  class QtestApiWrapper(BaseToolApiWrapper):
122
150
  base_url: str
123
151
  qtest_project_id: int
@@ -126,6 +154,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
126
154
  page: int = 1
127
155
  no_of_tests_shown_in_dql_search: int = 10
128
156
  _client: Any = PrivateAttr()
157
+ _field_definitions_cache: Optional[dict] = PrivateAttr(default=None)
129
158
  llm: Any
130
159
 
131
160
  @model_validator(mode='before')
@@ -135,9 +164,8 @@ class QtestApiWrapper(BaseToolApiWrapper):
135
164
  values['qtest_project_id'] = values.pop('project_id')
136
165
  return values
137
166
 
138
- @model_validator(mode='before')
139
- @classmethod
140
- def validate_toolkit(cls, values):
167
+ @model_validator(mode='after')
168
+ def validate_toolkit(self):
141
169
  try:
142
170
  import swagger_client # noqa: F401
143
171
  except ImportError:
@@ -146,15 +174,13 @@ class QtestApiWrapper(BaseToolApiWrapper):
146
174
  "`pip install git+https://github.com/Roman-Mitusov/qtest-api.git`"
147
175
  )
148
176
 
149
- url = values['base_url']
150
- api_token = values.get('qtest_api_token')
151
- if api_token:
177
+ if self.qtest_api_token:
152
178
  configuration = swagger_client.Configuration()
153
- configuration.host = url
154
- configuration.api_key['Authorization'] = api_token
179
+ configuration.host = self.base_url
180
+ configuration.api_key['Authorization'] = self.qtest_api_token.get_secret_value()
155
181
  configuration.api_key_prefix['Authorization'] = 'Bearer'
156
- cls._client = swagger_client.ApiClient(configuration)
157
- return values
182
+ self._client = swagger_client.ApiClient(configuration)
183
+ return self
158
184
 
159
185
  def __instantiate_test_api_instance(self) -> TestCaseApi:
160
186
  # Instantiate the TestCaseApi instance according to the qtest api documentation and swagger client
@@ -163,33 +189,215 @@ class QtestApiWrapper(BaseToolApiWrapper):
163
189
  def __instantiate_module_api_instance(self) -> ModuleApi:
164
190
  return swagger_client.ModuleApi(self._client)
165
191
 
192
+ def __instantiate_fields_api_instance(self) -> FieldApi:
193
+ return swagger_client.FieldApi(self._client)
194
+
195
+ def __get_field_definitions_cached(self) -> dict:
196
+ """Get field definitions with session-level caching.
197
+
198
+ Field definitions are cached for the lifetime of this wrapper instance.
199
+ If project field configuration changes, call refresh_field_definitions_cache()
200
+ to reload the definitions.
201
+
202
+ Returns:
203
+ dict: Field definitions mapping
204
+ """
205
+ if self._field_definitions_cache is None:
206
+ self._field_definitions_cache = self.__get_project_field_definitions()
207
+ return self._field_definitions_cache
208
+
209
+ def refresh_field_definitions_cache(self) -> dict:
210
+ """Manually refresh the field definitions cache.
211
+
212
+ Call this method if project field configuration has been updated
213
+ (new fields added, dropdown values changed, etc.) and you need to
214
+ reload the definitions within the same agent session.
215
+
216
+ Returns:
217
+ dict: Freshly loaded field definitions
218
+ """
219
+ self._field_definitions_cache = None
220
+ return self.__get_field_definitions_cached()
221
+
222
+ def __map_properties_to_api_format(self, test_case_data: dict, field_definitions: dict,
223
+ base_properties: list = None) -> list:
224
+ """
225
+ Convert user-friendly property names/values to QTest API PropertyResource format.
226
+
227
+ Args:
228
+ test_case_data: Dict with property names as keys (e.g., {"Status": "New", "Priority": "High"})
229
+ field_definitions: Output from __get_project_field_definitions()
230
+ base_properties: Existing properties from a test case (for updates, optional)
231
+
232
+ Returns:
233
+ list[PropertyResource]: Properties ready for API submission
234
+
235
+ Raises:
236
+ ValueError: If any field names are unknown or values are invalid (shows ALL errors)
237
+ """
238
+ # Start with base properties or empty dict
239
+ props_dict = {}
240
+ if base_properties:
241
+ for prop in base_properties:
242
+ field_name = prop.get('field_name')
243
+ if field_name:
244
+ props_dict[field_name] = {
245
+ 'field_id': prop['field_id'],
246
+ 'field_name': field_name,
247
+ 'field_value': prop['field_value'],
248
+ 'field_value_name': prop.get('field_value_name')
249
+ }
250
+
251
+ # Collect ALL validation errors before raising
252
+ validation_errors = []
253
+
254
+ # Map incoming properties from test_case_data
255
+ for field_name, field_value in test_case_data.items():
256
+ # Skip non-property fields (these are handled separately)
257
+ if field_name in ['Name', 'Description', 'Precondition', 'Steps', 'Id', QTEST_ID]:
258
+ continue
259
+
260
+ # Skip None or empty string values (don't update these fields)
261
+ if field_value is None or field_value == '':
262
+ continue
263
+
264
+ # Validate field exists in project - STRICT validation
265
+ if field_name not in field_definitions:
266
+ validation_errors.append(
267
+ f"❌ Unknown field '{field_name}' - not defined in project configuration"
268
+ )
269
+ continue # Skip to next field, keep collecting errors
270
+
271
+ field_def = field_definitions[field_name]
272
+ field_id = field_def['field_id']
273
+ data_type = field_def.get('data_type')
274
+ is_multiple = field_def.get('multiple', False)
275
+
276
+ # Normalize field_value to list for consistent processing
277
+ # Multi-select fields can receive: "value", ["value1", "value2"], or ["value1"]
278
+ # Single-select fields: "value" only
279
+ if is_multiple:
280
+ # Convert to list if not already
281
+ values_to_process = field_value if isinstance(field_value, list) else [field_value]
282
+ else:
283
+ # Single-select: keep as single value
284
+ values_to_process = [field_value]
285
+
286
+ # Validate value(s) for dropdown fields (only if field has allowed values)
287
+ if field_def['values']:
288
+ # Field has allowed values (dropdown/combobox/user fields) - validate strictly
289
+ value_ids = []
290
+ value_names = []
291
+
292
+ for single_value in values_to_process:
293
+ if single_value not in field_def['values']:
294
+ available = ", ".join(sorted(field_def['values'].keys()))
295
+ validation_errors.append(
296
+ f"❌ Invalid value '{single_value}' for field '{field_name}'. "
297
+ f"Allowed values: {available}"
298
+ )
299
+ continue # Skip this value, but continue validating others
300
+
301
+ # Valid value - add to lists
302
+ value_ids.append(field_def['values'][single_value])
303
+ value_names.append(single_value)
304
+
305
+ # If all values were invalid, skip this field
306
+ if not value_ids:
307
+ continue
308
+
309
+ # Format based on field type and value count
310
+ if is_multiple and len(value_ids) == 1:
311
+ # Single value in multi-select field: bracketed string "[419950]"
312
+ # This includes single user assignment: "[626983]"
313
+ field_value_id = f"[{value_ids[0]}]"
314
+ field_value_name = f"[{value_names[0]}]" if data_type == 5 else value_names[0]
315
+ elif is_multiple:
316
+ # Multiple values in multi-select: bracketed string with comma-separated IDs
317
+ ids_str = ",".join(str(vid) for vid in value_ids)
318
+ field_value_id = f"[{ids_str}]"
319
+ field_value_name = ", ".join(value_names)
320
+ else:
321
+ # Regular single-select dropdown: plain ID
322
+ field_value_id = value_ids[0]
323
+ field_value_name = value_names[0]
324
+ else:
325
+ # Text field or field without restricted values - use value directly
326
+ # No validation needed - users can write anything (by design)
327
+ field_value_id = field_value
328
+ field_value_name = field_value if isinstance(field_value, str) else None
329
+
330
+ # Update or add property (only if no errors for this field)
331
+ props_dict[field_name] = {
332
+ 'field_id': field_id,
333
+ 'field_name': field_name,
334
+ 'field_value': field_value_id,
335
+ 'field_value_name': field_value_name
336
+ }
337
+
338
+ # If ANY validation errors found, raise comprehensive error with all issues
339
+ if validation_errors:
340
+ available_fields = ", ".join(sorted(field_definitions.keys()))
341
+ error_msg = (
342
+ f"Found {len(validation_errors)} validation error(s) in test case properties:\n\n" +
343
+ "\n".join(validation_errors) +
344
+ f"\n\n📋 Available fields for this project: {available_fields}\n\n"
345
+ f"💡 Tip: Use 'get_all_test_cases_fields_for_project' tool to see all fields with their allowed values."
346
+ )
347
+ raise ValueError(error_msg)
348
+
349
+ # Convert to PropertyResource list, filtering out special fields
350
+ result = []
351
+ for field_name, prop_data in props_dict.items():
352
+ if field_name in ['Shared', 'Projects Shared to']:
353
+ continue
354
+ result.append(PropertyResource(
355
+ field_id=prop_data['field_id'],
356
+ field_name=prop_data['field_name'],
357
+ field_value=prop_data['field_value'],
358
+ field_value_name=prop_data.get('field_value_name')
359
+ ))
360
+
361
+ return result
362
+
166
363
  def __build_body_for_create_test_case(self, test_cases_data: list[dict],
167
364
  folder_to_place_test_cases_to: str = '') -> list:
168
- initial_project_properties = self.__get_properties_form_project()
365
+ # Get field definitions for property mapping (cached for session)
366
+ field_definitions = self.__get_field_definitions_cached()
367
+
169
368
  modules = self._parse_modules()
170
369
  parent_id = ''.join(str(module['module_id']) for module in modules if
171
370
  folder_to_place_test_cases_to and module['full_module_name'] == folder_to_place_test_cases_to)
172
- props = []
173
- for prop in initial_project_properties:
174
- if prop.get('field_name', '') == 'Shared' or prop.get('field_name', '') == 'Projects Shared to':
175
- continue
176
- props.append(PropertyResource(field_id=prop['field_id'], field_name=prop['field_name'],
177
- field_value_name=prop.get('field_value_name', None),
178
- field_value=prop['field_value']))
371
+
179
372
  bodies = []
180
373
  for test_case in test_cases_data:
374
+ # Map properties from user format to API format
375
+ props = self.__map_properties_to_api_format(test_case, field_definitions)
376
+
181
377
  body = swagger_client.TestCaseWithCustomFieldResource(properties=props)
182
- body.name = test_case.get('Name')
183
- body.precondition = test_case.get('Precondition')
184
- body.description = test_case.get('Description')
378
+
379
+ # Only set fields if they are explicitly provided in the input
380
+ # This prevents overwriting existing values with None during partial updates
381
+ if 'Name' in test_case:
382
+ body.name = test_case['Name']
383
+ if 'Precondition' in test_case:
384
+ body.precondition = test_case['Precondition']
385
+ if 'Description' in test_case:
386
+ body.description = test_case['Description']
387
+
185
388
  if parent_id:
186
389
  body.parent_id = parent_id
187
- test_steps_resources = []
188
- for step in test_case.get('Steps'):
189
- test_steps_resources.append(
190
- swagger_client.TestStepResource(description=step.get('Test Step Description'),
191
- expected=step.get('Test Step Expected Result')))
192
- body.test_steps = test_steps_resources
390
+
391
+ # Only set test_steps if Steps are provided in the input
392
+ # This prevents overwriting existing steps during partial updates
393
+ if 'Steps' in test_case and test_case['Steps'] is not None:
394
+ test_steps_resources = []
395
+ for step in test_case['Steps']:
396
+ test_steps_resources.append(
397
+ swagger_client.TestStepResource(description=step.get('Test Step Description'),
398
+ expected=step.get('Test Step Expected Result')))
399
+ body.test_steps = test_steps_resources
400
+
193
401
  bodies.append(body)
194
402
  return bodies
195
403
 
@@ -206,6 +414,108 @@ class QtestApiWrapper(BaseToolApiWrapper):
206
414
  Exception: \n {stacktrace}""")
207
415
  return modules
208
416
 
417
+ def __get_project_field_definitions(self) -> dict:
418
+ """
419
+ Get structured field definitions for test cases in the project.
420
+
421
+ Returns:
422
+ dict: Mapping of field names to their IDs and allowed values.
423
+ Example: {
424
+ 'Status': {
425
+ 'field_id': 12345,
426
+ 'required': True,
427
+ 'values': {'New': 1, 'In Progress': 2, 'Completed': 3}
428
+ },
429
+ 'Priority': {
430
+ 'field_id': 12346,
431
+ 'required': False,
432
+ 'values': {'High': 1, 'Medium': 2, 'Low': 3}
433
+ }
434
+ }
435
+ """
436
+ fields_api = self.__instantiate_fields_api_instance()
437
+ qtest_object = 'test-cases'
438
+
439
+ try:
440
+ fields = fields_api.get_fields(self.qtest_project_id, qtest_object)
441
+ except ApiException as e:
442
+ stacktrace = format_exc()
443
+ logger.error(f"Exception when calling FieldAPI->get_fields:\n {stacktrace}")
444
+ raise ValueError(
445
+ f"Unable to get test case fields for project {self.qtest_project_id}. Exception: \n {stacktrace}")
446
+
447
+ # Build structured mapping
448
+ field_mapping = {}
449
+ for field in fields:
450
+ field_name = field.label
451
+ field_mapping[field_name] = {
452
+ 'field_id': field.id,
453
+ 'required': getattr(field, 'required', False),
454
+ 'data_type': getattr(field, 'data_type', None), # 5 = user field
455
+ 'multiple': getattr(field, 'multiple', False), # True = multi-select, needs array format
456
+ 'values': {}
457
+ }
458
+
459
+ # Map allowed values if field has them (dropdown/combobox/user fields)
460
+ # Only include active values (is_active=True)
461
+ if hasattr(field, 'allowed_values') and field.allowed_values:
462
+ for allowed_value in field.allowed_values:
463
+ # Skip inactive values (deleted/deprecated options)
464
+ if hasattr(allowed_value, 'is_active') and not allowed_value.is_active:
465
+ continue
466
+
467
+ # AllowedValueResource has 'label' for the display name and 'value' for the ID
468
+ # Note: 'value' is the field_value, not 'id'
469
+ # For user fields (data_type=5), label is user name and value is user ID
470
+ value_label = allowed_value.label
471
+ value_id = allowed_value.value
472
+ field_mapping[field_name]['values'][value_label] = value_id
473
+
474
+ return field_mapping
475
+
476
+ def __format_field_info_for_display(self, field_definitions: dict) -> str:
477
+ """
478
+ Format field definitions in human-readable format for LLM.
479
+
480
+ Args:
481
+ field_definitions: Output from __get_project_field_definitions()
482
+
483
+ Returns:
484
+ Formatted string with field information
485
+ """
486
+ output = [f"Available Test Case Fields for Project {self.qtest_project_id}:\n"]
487
+
488
+ for field_name, field_info in sorted(field_definitions.items()):
489
+ required_marker = " (Required)" if field_info.get('required') else ""
490
+ output.append(f"\n{field_name}{required_marker}:")
491
+
492
+ if field_info.get('values'):
493
+ for value_name, value_id in sorted(field_info['values'].items()):
494
+ output.append(f" - {value_name} (id: {value_id})")
495
+ else:
496
+ output.append(" Type: text")
497
+
498
+ output.append("\n\nUse these exact value names when creating or updating test cases.")
499
+ return ''.join(output)
500
+
501
+ def get_all_test_cases_fields_for_project(self, force_refresh: bool = False) -> str:
502
+ """
503
+ Get formatted information about available test case fields and their values.
504
+ This method is exposed as a tool for LLM to query field information.
505
+
506
+ Args:
507
+ force_refresh: If True, reload field definitions from API instead of using cache.
508
+ Use this if project configuration has changed (new fields added,
509
+ dropdown values modified, etc.).
510
+
511
+ Returns:
512
+ Formatted string with field names and allowed values
513
+ """
514
+ if force_refresh:
515
+ self.refresh_field_definitions_cache()
516
+ field_defs = self.__get_field_definitions_cached()
517
+ return self.__format_field_info_for_display(field_defs)
518
+
209
519
  def _parse_modules(self) -> list[dict]:
210
520
  modules = self.__get_all_modules_for_project()
211
521
  result = []
@@ -245,27 +555,49 @@ class QtestApiWrapper(BaseToolApiWrapper):
245
555
 
246
556
  def __parse_data(self, response_to_parse: dict, parsed_data: list, extract_images: bool=False, prompt: str=None):
247
557
  import html
558
+
559
+ # Get field definitions to ensure all fields are included (uses cached version)
560
+ field_definitions = self.__get_field_definitions_cached()
561
+
248
562
  for item in response_to_parse['items']:
563
+ # Start with core fields (always present)
249
564
  parsed_data_row = {
250
565
  'Id': item['pid'],
566
+ 'Name': item['name'],
251
567
  'Description': html.unescape(strip_tags(item['description'])),
252
568
  'Precondition': html.unescape(strip_tags(item['precondition'])),
253
- 'Name': item['name'],
254
569
  QTEST_ID: item['id'],
255
570
  'Steps': list(map(lambda step: {
256
571
  'Test Step Number': step[0] + 1,
257
572
  'Test Step Description': self._process_image(step[1]['description'], extract_images, prompt),
258
573
  'Test Step Expected Result': self._process_image(step[1]['expected'], extract_images, prompt)
259
574
  }, enumerate(item['test_steps']))),
260
- 'Status': ''.join([properties['field_value_name'] for properties in item['properties']
261
- if properties['field_name'] == 'Status']),
262
- 'Automation': ''.join([properties['field_value_name'] for properties in item['properties']
263
- if properties['field_name'] == 'Automation']),
264
- 'Type': ''.join([properties['field_value_name'] for properties in item['properties']
265
- if properties['field_name'] == 'Type']),
266
- 'Priority': ''.join([properties['field_value_name'] for properties in item['properties']
267
- if properties['field_name'] == 'Priority']),
268
575
  }
576
+
577
+ # Dynamically add all custom fields from project configuration
578
+ # This ensures consistency and includes fields even if they have null/empty values
579
+ for field_name in field_definitions.keys():
580
+ field_def = field_definitions[field_name]
581
+ is_multiple = field_def.get('multiple', False)
582
+
583
+ # Find the property value in the response (if exists)
584
+ field_value = None
585
+ for prop in item['properties']:
586
+ if prop['field_name'] == field_name:
587
+ # Use field_value_name if available (for dropdowns), otherwise field_value
588
+ field_value = prop.get('field_value_name') or prop.get('field_value') or ''
589
+ break
590
+
591
+ # Format based on field type
592
+ if is_multiple and (field_value is None or field_value == ''):
593
+ # Multi-select field with no value: show empty array with hint
594
+ parsed_data_row[field_name] = '[] (multi-select)'
595
+ elif field_value is not None:
596
+ parsed_data_row[field_name] = field_value
597
+ else:
598
+ # Regular field with no value
599
+ parsed_data_row[field_name] = ''
600
+
269
601
  parsed_data.append(parsed_data_row)
270
602
 
271
603
  def _process_image(self, content: str, extract: bool=False, prompt: str=None):
@@ -323,18 +655,39 @@ class QtestApiWrapper(BaseToolApiWrapper):
323
655
  parsed_data = self.__perform_search_by_dql(dql)
324
656
  return parsed_data[0]['QTest Id']
325
657
 
326
- def __get_properties_form_project(self) -> list[dict] | None:
327
- test_api_instance = self.__instantiate_test_api_instance()
328
- expand_props = 'true'
658
+ def __find_qtest_requirement_id_by_id(self, requirement_id: str) -> int:
659
+ """Search for requirement's internal QTest ID using requirement ID (RQ-xxx format).
660
+
661
+ Args:
662
+ requirement_id: Requirement ID in format RQ-123
663
+
664
+ Returns:
665
+ int: Internal QTest ID for the requirement
666
+
667
+ Raises:
668
+ ValueError: If requirement is not found
669
+ """
670
+ dql = f"Id = '{requirement_id}'"
671
+ search_instance: SearchApi = swagger_client.SearchApi(self._client)
672
+ body = swagger_client.ArtifactSearchParams(object_type='requirements', fields=['*'], query=dql)
673
+
329
674
  try:
330
- response = test_api_instance.get_test_cases(self.qtest_project_id, 1, 1, expand_props=expand_props)
331
- return response[0]['properties']
675
+ response = search_instance.search_artifact(self.qtest_project_id, body)
676
+ if response['total'] == 0:
677
+ raise ValueError(
678
+ f"Requirement '{requirement_id}' not found in project {self.qtest_project_id}. "
679
+ f"Please verify the requirement ID exists."
680
+ )
681
+ return response['items'][0]['id']
332
682
  except ApiException as e:
333
683
  stacktrace = format_exc()
334
- logger.error(f"Exception when calling TestCaseApi->get_test_cases: \n {stacktrace}")
335
- raise e
684
+ logger.error(f"Exception when searching for requirement: \n {stacktrace}")
685
+ raise ToolException(
686
+ f"Unable to search for requirement '{requirement_id}' in project {self.qtest_project_id}. "
687
+ f"Exception: \n{stacktrace}"
688
+ ) from e
336
689
 
337
- def __is_jira_requirement_present(self, jira_issue_id: str) -> (bool, dict):
690
+ def __is_jira_requirement_present(self, jira_issue_id: str) -> tuple[bool, dict]:
338
691
  """ Define if particular Jira requirement is present in qtest or not """
339
692
  dql = f"'External Id' = '{jira_issue_id}'"
340
693
  search_instance: SearchApi = swagger_client.SearchApi(self._client)
@@ -350,31 +703,112 @@ class QtestApiWrapper(BaseToolApiWrapper):
350
703
  logger.error(f"Error: {format_exc()}")
351
704
  raise e
352
705
 
353
- def _get_jira_requirement_id(self, jira_issue_id: str) -> int | None:
354
- """ Search for requirement id using the linked jira_issue_id. """
706
+ def _get_jira_requirement_id(self, jira_issue_id: str) -> int:
707
+ """Search for requirement id using the linked jira_issue_id.
708
+
709
+ Args:
710
+ jira_issue_id: External Jira issue ID (e.g., PLAN-128)
711
+
712
+ Returns:
713
+ int: Internal QTest ID for the Jira requirement
714
+
715
+ Raises:
716
+ ValueError: If Jira requirement is not found in QTest
717
+ """
355
718
  is_present, response = self.__is_jira_requirement_present(jira_issue_id)
356
719
  if not is_present:
357
- return None
720
+ raise ValueError(
721
+ f"Jira requirement '{jira_issue_id}' not found in QTest project {self.qtest_project_id}. "
722
+ f"Please ensure the Jira issue is linked to QTest as a requirement."
723
+ )
358
724
  return response['items'][0]['id']
359
725
 
360
726
 
361
727
  def link_tests_to_jira_requirement(self, requirement_external_id: str, json_list_of_test_case_ids: str) -> str:
362
- """ Link the list of the test cases represented as string like this '["TC-123", "TC-234"]' to the Jira requirement represented as external id e.g. PLAN-128 which is the Jira Issue Id"""
728
+ """Link test cases to external Jira requirement.
729
+
730
+ Args:
731
+ requirement_external_id: Jira issue ID (e.g., PLAN-128)
732
+ json_list_of_test_case_ids: JSON array string of test case IDs (e.g., '["TC-123", "TC-234"]')
733
+
734
+ Returns:
735
+ Success message with linked test case IDs
736
+ """
363
737
  link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
364
738
  source_type = "requirements"
365
739
  linked_type = "test-cases"
366
- list = [self.__find_qtest_id_by_test_id(test_case_id) for test_case_id in json.loads(json_list_of_test_case_ids)]
740
+ test_case_ids = json.loads(json_list_of_test_case_ids)
741
+ qtest_test_case_ids = [self.__find_qtest_id_by_test_id(tc_id) for tc_id in test_case_ids]
367
742
  requirement_id = self._get_jira_requirement_id(requirement_external_id)
368
743
 
369
744
  try:
370
- response = link_object_api_instance.link_artifacts(self.qtest_project_id, object_id=requirement_id,
371
- type=linked_type,
372
- object_type=source_type, body=list)
373
- return f"The test cases with the following id's - {[link.pid for link in response[0].objects]} have been linked in following project {self.qtest_project_id} under following requirement {requirement_external_id}"
374
- except Exception as e:
375
- from traceback import format_exc
376
- logger.error(f"Error: {format_exc()}")
377
- raise e
745
+ response = link_object_api_instance.link_artifacts(
746
+ self.qtest_project_id,
747
+ object_id=requirement_id,
748
+ type=linked_type,
749
+ object_type=source_type,
750
+ body=qtest_test_case_ids
751
+ )
752
+ linked_test_cases = [link.pid for link in response[0].objects]
753
+ return (
754
+ f"Successfully linked {len(linked_test_cases)} test case(s) to Jira requirement '{requirement_external_id}' "
755
+ f"in project {self.qtest_project_id}.\n"
756
+ f"Linked test cases: {', '.join(linked_test_cases)}"
757
+ )
758
+ except ApiException as e:
759
+ stacktrace = format_exc()
760
+ logger.error(f"Error linking to Jira requirement: {stacktrace}")
761
+ raise ToolException(
762
+ f"Unable to link test cases to Jira requirement '{requirement_external_id}' "
763
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
764
+ ) from e
765
+
766
+ def link_tests_to_qtest_requirement(self, requirement_id: str, json_list_of_test_case_ids: str) -> str:
767
+ """Link test cases to internal QTest requirement.
768
+
769
+ Args:
770
+ requirement_id: QTest requirement ID in format RQ-123
771
+ json_list_of_test_case_ids: JSON array string of test case IDs (e.g., '["TC-123", "TC-234"]')
772
+
773
+ Returns:
774
+ Success message with linked test case IDs
775
+
776
+ Raises:
777
+ ValueError: If requirement or test cases are not found
778
+ ToolException: If linking fails
779
+ """
780
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
781
+ source_type = "requirements"
782
+ linked_type = "test-cases"
783
+
784
+ # Parse and convert test case IDs
785
+ test_case_ids = json.loads(json_list_of_test_case_ids)
786
+ qtest_test_case_ids = [self.__find_qtest_id_by_test_id(tc_id) for tc_id in test_case_ids]
787
+
788
+ # Get internal QTest ID for the requirement
789
+ qtest_requirement_id = self.__find_qtest_requirement_id_by_id(requirement_id)
790
+
791
+ try:
792
+ response = link_object_api_instance.link_artifacts(
793
+ self.qtest_project_id,
794
+ object_id=qtest_requirement_id,
795
+ type=linked_type,
796
+ object_type=source_type,
797
+ body=qtest_test_case_ids
798
+ )
799
+ linked_test_cases = [link.pid for link in response[0].objects]
800
+ return (
801
+ f"Successfully linked {len(linked_test_cases)} test case(s) to QTest requirement '{requirement_id}' "
802
+ f"in project {self.qtest_project_id}.\n"
803
+ f"Linked test cases: {', '.join(linked_test_cases)}"
804
+ )
805
+ except ApiException as e:
806
+ stacktrace = format_exc()
807
+ logger.error(f"Error linking to QTest requirement: {stacktrace}")
808
+ raise ToolException(
809
+ f"Unable to link test cases to QTest requirement '{requirement_id}' "
810
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
811
+ ) from e
378
812
 
379
813
  def search_by_dql(self, dql: str, extract_images:bool=False, prompt: str=None):
380
814
  """Search for the test cases in qTest using Data Query Language """
@@ -496,17 +930,31 @@ class QtestApiWrapper(BaseToolApiWrapper):
496
930
  "ref": self.delete_test_case,
497
931
  },
498
932
  {
499
- "name": "link_tests_to_requirement",
500
- "mode": "link_tests_to_requirement",
501
- "description": """Link tests to Jira requirements. The input is jira issue id and th list of test ids in format '["TC-123", "TC-234", "TC-345"]'""",
933
+ "name": "link_tests_to_jira_requirement",
934
+ "mode": "link_tests_to_jira_requirement",
935
+ "description": "Link test cases to external Jira requirement. Provide Jira issue ID (e.g., PLAN-128) and list of test case IDs in format '[\"TC-123\", \"TC-234\"]'",
502
936
  "args_schema": QtestLinkTestCaseToJiraRequirement,
503
937
  "ref": self.link_tests_to_jira_requirement,
504
938
  },
939
+ {
940
+ "name": "link_tests_to_qtest_requirement",
941
+ "mode": "link_tests_to_qtest_requirement",
942
+ "description": "Link test cases to internal QTest requirement. Provide QTest requirement ID (e.g., RQ-15) and list of test case IDs in format '[\"TC-123\", \"TC-234\"]'",
943
+ "args_schema": QtestLinkTestCaseToQtestRequirement,
944
+ "ref": self.link_tests_to_qtest_requirement,
945
+ },
505
946
  {
506
947
  "name": "get_modules",
507
948
  "mode": "get_modules",
508
949
  "description": self.get_modules.__doc__,
509
950
  "args_schema": GetModules,
510
951
  "ref": self.get_modules,
952
+ },
953
+ {
954
+ "name": "get_all_test_cases_fields_for_project",
955
+ "mode": "get_all_test_cases_fields_for_project",
956
+ "description": "Get information about available test case fields and their valid values for the project. Shows which property values are allowed (e.g., Status: 'New', 'In Progress', 'Completed') based on the project configuration. Use force_refresh=true if project configuration has changed.",
957
+ "args_schema": GetAllTestCasesFieldsForProject,
958
+ "ref": self.get_all_test_cases_fields_for_project,
511
959
  }
512
960
  ]