alita-sdk 0.3.435__py3-none-any.whl → 0.3.457__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 (54) hide show
  1. alita_sdk/runtime/clients/client.py +39 -7
  2. alita_sdk/runtime/langchain/assistant.py +10 -2
  3. alita_sdk/runtime/langchain/langraph_agent.py +57 -15
  4. alita_sdk/runtime/langchain/utils.py +19 -3
  5. alita_sdk/runtime/models/mcp_models.py +4 -0
  6. alita_sdk/runtime/toolkits/artifact.py +5 -6
  7. alita_sdk/runtime/toolkits/mcp.py +258 -150
  8. alita_sdk/runtime/toolkits/tools.py +44 -2
  9. alita_sdk/runtime/tools/function.py +2 -1
  10. alita_sdk/runtime/tools/mcp_remote_tool.py +166 -0
  11. alita_sdk/runtime/tools/mcp_server_tool.py +9 -76
  12. alita_sdk/runtime/tools/vectorstore_base.py +17 -2
  13. alita_sdk/runtime/utils/mcp_oauth.py +164 -0
  14. alita_sdk/runtime/utils/mcp_sse_client.py +405 -0
  15. alita_sdk/runtime/utils/toolkit_utils.py +9 -2
  16. alita_sdk/tools/ado/repos/__init__.py +1 -0
  17. alita_sdk/tools/ado/test_plan/__init__.py +1 -1
  18. alita_sdk/tools/ado/wiki/__init__.py +1 -5
  19. alita_sdk/tools/ado/work_item/__init__.py +1 -5
  20. alita_sdk/tools/base_indexer_toolkit.py +10 -6
  21. alita_sdk/tools/bitbucket/__init__.py +1 -0
  22. alita_sdk/tools/code/sonar/__init__.py +1 -1
  23. alita_sdk/tools/confluence/__init__.py +2 -2
  24. alita_sdk/tools/github/__init__.py +2 -2
  25. alita_sdk/tools/gitlab/__init__.py +2 -1
  26. alita_sdk/tools/gitlab_org/__init__.py +1 -2
  27. alita_sdk/tools/google_places/__init__.py +2 -1
  28. alita_sdk/tools/jira/__init__.py +1 -0
  29. alita_sdk/tools/memory/__init__.py +1 -1
  30. alita_sdk/tools/pandas/__init__.py +1 -1
  31. alita_sdk/tools/postman/__init__.py +2 -1
  32. alita_sdk/tools/pptx/__init__.py +2 -2
  33. alita_sdk/tools/qtest/__init__.py +3 -3
  34. alita_sdk/tools/qtest/api_wrapper.py +374 -29
  35. alita_sdk/tools/rally/__init__.py +1 -2
  36. alita_sdk/tools/report_portal/__init__.py +1 -0
  37. alita_sdk/tools/salesforce/__init__.py +1 -0
  38. alita_sdk/tools/servicenow/__init__.py +2 -3
  39. alita_sdk/tools/sharepoint/__init__.py +1 -0
  40. alita_sdk/tools/slack/__init__.py +1 -0
  41. alita_sdk/tools/sql/__init__.py +2 -1
  42. alita_sdk/tools/testio/__init__.py +1 -0
  43. alita_sdk/tools/testrail/__init__.py +1 -3
  44. alita_sdk/tools/xray/__init__.py +2 -1
  45. alita_sdk/tools/zephyr/__init__.py +2 -1
  46. alita_sdk/tools/zephyr_enterprise/__init__.py +1 -0
  47. alita_sdk/tools/zephyr_essential/__init__.py +1 -0
  48. alita_sdk/tools/zephyr_scale/__init__.py +1 -0
  49. alita_sdk/tools/zephyr_squad/__init__.py +1 -0
  50. {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.457.dist-info}/METADATA +2 -1
  51. {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.457.dist-info}/RECORD +54 -51
  52. {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.457.dist-info}/WHEEL +0 -0
  53. {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.457.dist-info}/licenses/LICENSE +0 -0
  54. {alita_sdk-0.3.435.dist-info → alita_sdk-0.3.457.dist-info}/top_level.txt +0 -0
@@ -30,7 +30,7 @@ class GooglePlacesToolkit(BaseToolkit):
30
30
  GooglePlacesToolkit.toolkit_max_length = get_max_toolkit_length(selected_tools)
31
31
  return create_model(
32
32
  name,
33
- results_count=(Optional[int], Field(description="Results number to show", default=None, json_schema_extra={'toolkit_name': True, 'max_toolkit_length': GooglePlacesToolkit.toolkit_max_length})),
33
+ results_count=(Optional[int], Field(description="Results number to show", default=None)),
34
34
  google_places_configuration=(GooglePlacesConfiguration, Field(description="Google Places Configuration", json_schema_extra={'configuration_types': ['google_places']})),
35
35
  selected_tools=(List[Literal[tuple(selected_tools)]], Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
36
36
  __config__=ConfigDict(json_schema_extra=
@@ -38,6 +38,7 @@ class GooglePlacesToolkit(BaseToolkit):
38
38
  'metadata':
39
39
  {
40
40
  "label": "Google Places", "icon_url": "gplaces-icon.svg",
41
+ "max_length": GooglePlacesToolkit.toolkit_max_length,
41
42
  "categories": ["other"],
42
43
  "extra_categories": ["google", "places", "maps", "location",
43
44
  "geolocation"],
@@ -89,6 +89,7 @@ class JiraToolkit(BaseToolkit):
89
89
  'metadata': {
90
90
  "label": "Jira",
91
91
  "icon_url": "jira-icon.svg",
92
+ "max_length": JiraToolkit.toolkit_max_length,
92
93
  "categories": ["project management"],
93
94
  "extra_categories": ["jira", "atlassian", "issue tracking", "project management", "task management"],
94
95
  }
@@ -61,7 +61,7 @@ class MemoryToolkit(BaseToolkit):
61
61
 
62
62
  return create_model(
63
63
  'memory',
64
- namespace=(str, Field(description="Memory namespace", json_schema_extra={'toolkit_name': True})),
64
+ namespace=(str, Field(description="Memory namespace")),
65
65
  pgvector_configuration=(PgVectorConfiguration, Field(description="PgVector Configuration",
66
66
  json_schema_extra={
67
67
  'configuration_types': ['pgvector']})),
@@ -29,7 +29,7 @@ class PandasToolkit(BaseToolkit):
29
29
  PandasToolkit.toolkit_max_length = get_max_toolkit_length(selected_tools)
30
30
  return create_model(
31
31
  name,
32
- bucket_name=(str, Field(default=None, title="Bucket name", description="Bucket where the content file is stored", json_schema_extra={'toolkit_name': True, 'max_toolkit_length': PandasToolkit.toolkit_max_length})),
32
+ bucket_name=(str, Field(default=None, title="Bucket name", description="Bucket where the content file is stored")),
33
33
  selected_tools=(List[Literal[tuple(selected_tools)]], Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
34
34
  __config__=ConfigDict(json_schema_extra={'metadata': {"label": "Pandas", "icon_url": "pandas-icon.svg",
35
35
  "categories": ["analysis"],
@@ -62,7 +62,8 @@ class PostmanToolkit(BaseToolkit):
62
62
  selected_tools=(List[Literal[tuple(selected_tools)]], Field(
63
63
  default=[], json_schema_extra={'args_schemas': selected_tools})),
64
64
  __config__=ConfigDict(json_schema_extra={'metadata': {
65
- "label": "Postman", "icon_url": "postman.svg"}})
65
+ "label": "Postman", "icon_url": "postman.svg",
66
+ "max_length": PostmanToolkit.toolkit_max_length,}})
66
67
  )
67
68
 
68
69
  @check_connection_response
@@ -45,13 +45,13 @@ class PPTXToolkit(BaseToolkit):
45
45
 
46
46
  return create_model(
47
47
  name,
48
- bucket_name=(str, Field(description="Bucket name where PPTX files are stored",
49
- json_schema_extra={'toolkit_name': True, 'max_toolkit_length': TOOLKIT_MAX_LENGTH})),
48
+ bucket_name=(str, Field(description="Bucket name where PPTX files are stored")),
50
49
  selected_tools=(List[Literal[tuple(selected_tools)]], Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
51
50
  __config__=ConfigDict(json_schema_extra={
52
51
  'metadata': {
53
52
  "label": "PPTX",
54
53
  "icon_url": "pptx.svg",
54
+ "max_length": TOOLKIT_MAX_LENGTH,
55
55
  "categories": ["office"],
56
56
  "extra_categories": ["presentation", "office automation", "document"]
57
57
  }
@@ -37,14 +37,14 @@ class QtestToolkit(BaseToolkit):
37
37
  name,
38
38
  qtest_configuration=(QtestConfiguration, Field(description="QTest API token", json_schema_extra={
39
39
  'configuration_types': ['qtest']})),
40
- qtest_project_id=(int, Field(default=None, description="QTest project id", json_schema_extra={'toolkit_name': True,
41
- 'max_toolkit_length': QtestToolkit.toolkit_max_length})),
40
+ qtest_project_id=(int, Field(default=None, description="QTest project id")),
42
41
  no_of_tests_shown_in_dql_search=(Optional[int], Field(description="Max number of items returned by dql search",
43
42
  default=10)),
44
43
 
45
44
  selected_tools=(List[Literal[tuple(selected_tools)]],
46
45
  Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
47
46
  __config__=ConfigDict(json_schema_extra={'metadata': {"label": "QTest", "icon_url": "qtest.svg",
47
+ "max_length": QtestToolkit.toolkit_max_length,
48
48
  "categories": ["test management"],
49
49
  "extra_categories": ["quality assurance",
50
50
  "test case management",
@@ -93,4 +93,4 @@ class QtestToolkit(BaseToolkit):
93
93
  return cls(tools=tools)
94
94
 
95
95
  def get_tools(self):
96
- return self.tools
96
+ return self.tools
@@ -5,6 +5,7 @@ import re
5
5
  from traceback import format_exc
6
6
  from typing import Any, Optional
7
7
 
8
+ import requests
8
9
  import swagger_client
9
10
  from langchain_core.tools import ToolException
10
11
  from pydantic import Field, PrivateAttr, model_validator, create_model, SecretStr
@@ -48,6 +49,11 @@ Steps: Array of test steps with Description and Expected Result.
48
49
  - Single value: "Team": "Epam"
49
50
  - Multiple values: "Team": ["Epam", "EJ"]
50
51
 
52
+ **Clearing/Unsetting fields**: To clear a field value (unassign, set to empty/blank):
53
+ - Use `null` in JSON: "Priority": null
54
+ - Works for multi-select fields, user assignments, etc. (Note: single-select dropdowns have API limitations)
55
+ - Example: {{"QTest Id": "4626964", "Assigned To": null, "Review status": null}}
56
+
51
57
  **For Updates**: Include only the fields you want to modify. The system will validate property values against project configuration.
52
58
 
53
59
  ### EXAMPLE
@@ -142,6 +148,12 @@ GetAllTestCasesFieldsForProject = create_model(
142
148
  default=False)),
143
149
  )
144
150
 
151
+ FindTestCasesByRequirementId = create_model(
152
+ "FindTestCasesByRequirementId",
153
+ requirement_id=(str, Field(description="QTest requirement ID in format RQ-123. This will find all test cases linked to this requirement.")),
154
+ include_details=(Optional[bool], Field(description="If true, returns full test case details. If false (default), returns Id, QTest Id, Name, and Description fields.", default=False)),
155
+ )
156
+
145
157
  NoInput = create_model(
146
158
  "NoInput"
147
159
  )
@@ -155,6 +167,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
155
167
  no_of_tests_shown_in_dql_search: int = 10
156
168
  _client: Any = PrivateAttr()
157
169
  _field_definitions_cache: Optional[dict] = PrivateAttr(default=None)
170
+ _modules_cache: Optional[list] = PrivateAttr(default=None)
158
171
  llm: Any
159
172
 
160
173
  @model_validator(mode='before')
@@ -256,9 +269,41 @@ class QtestApiWrapper(BaseToolApiWrapper):
256
269
  # Skip non-property fields (these are handled separately)
257
270
  if field_name in ['Name', 'Description', 'Precondition', 'Steps', 'Id', QTEST_ID]:
258
271
  continue
259
-
260
- # Skip None or empty string values (don't update these fields)
261
- if field_value is None or field_value == '':
272
+
273
+ # Skip empty string values (don't update these fields)
274
+ if field_value == '':
275
+ continue
276
+
277
+ # Handle None value - this means "clear/unset this field"
278
+ if field_value is None:
279
+ # Validate field exists before attempting to clear
280
+ if field_name not in field_definitions:
281
+ validation_errors.append(
282
+ f"❌ Unknown field '{field_name}' - not defined in project configuration"
283
+ )
284
+ continue
285
+
286
+ field_def = field_definitions[field_name]
287
+ field_id = field_def['field_id']
288
+ is_multiple = field_def.get('multiple', False)
289
+
290
+ if is_multiple:
291
+ # Multi-select/user fields: can clear using empty array "[]"
292
+ props_dict[field_name] = {
293
+ 'field_id': field_id,
294
+ 'field_name': field_name,
295
+ 'field_value': "[]",
296
+ 'field_value_name': None
297
+ }
298
+ else:
299
+ # Single-select fields: QTest API limitation - cannot clear to empty
300
+ # Note: Users CAN clear these fields from UI, but API doesn't expose this capability
301
+ validation_errors.append(
302
+ f"⚠️ Cannot clear single-select field '{field_name}' - this is a QTest API limitation "
303
+ f"(clearing is possible from UI but not exposed via API). "
304
+ f"Please select an alternative value instead. "
305
+ f"Available values: {', '.join(field_def.get('values', {}).keys()) or 'none'}"
306
+ )
262
307
  continue
263
308
 
264
309
  # Validate field exists in project - STRICT validation
@@ -414,6 +459,143 @@ class QtestApiWrapper(BaseToolApiWrapper):
414
459
  Exception: \n {stacktrace}""")
415
460
  return modules
416
461
 
462
+ def __get_field_definitions_from_properties_api(self) -> dict:
463
+ """
464
+ Fallback method: Get field definitions using /properties and /properties-info APIs.
465
+
466
+ These APIs don't require Field Management permission and are available to all users.
467
+ Requires 2 API calls + 1 search to get a test case ID.
468
+
469
+ Returns:
470
+ dict: Same structure as __get_project_field_definitions()
471
+ """
472
+ logger.info(
473
+ "Using properties API fallback (no Field Management permission). "
474
+ "This requires getting a template test case first."
475
+ )
476
+
477
+ # Step 1: Get any test case ID to query properties
478
+ search_instance = swagger_client.SearchApi(self._client)
479
+ body = swagger_client.ArtifactSearchParams(
480
+ object_type='test-cases',
481
+ fields=['*'],
482
+ query='' # Empty query returns all test cases
483
+ )
484
+
485
+ try:
486
+ # Search for any test case - just need one
487
+ response = search_instance.search_artifact(
488
+ self.qtest_project_id,
489
+ body,
490
+ page_size=1,
491
+ page=1
492
+ )
493
+ except ApiException as e:
494
+ stacktrace = format_exc()
495
+ logger.error(f"Failed to find test case for properties API: {stacktrace}")
496
+ raise ValueError(
497
+ f"Cannot find any test case to query field definitions. "
498
+ f"Please create at least one test case in project {self.qtest_project_id}"
499
+ ) from e
500
+
501
+ if not response or not response.get('items') or len(response['items']) == 0:
502
+ raise ValueError(
503
+ f"No test cases found in project {self.qtest_project_id}. "
504
+ f"Please create at least one test case to retrieve field definitions."
505
+ )
506
+
507
+ test_case_id = response['items'][0]['id']
508
+ logger.info(f"Using test case ID {test_case_id} to retrieve field definitions")
509
+
510
+ # Step 2: Call /properties API
511
+ headers = {
512
+ "Authorization": f"Bearer {self.qtest_api_token.get_secret_value()}"
513
+ }
514
+
515
+ properties_url = f"{self.base_url}/api/v3/projects/{self.qtest_project_id}/test-cases/{test_case_id}/properties"
516
+ properties_info_url = f"{self.base_url}/api/v3/projects/{self.qtest_project_id}/test-cases/{test_case_id}/properties-info"
517
+
518
+ try:
519
+ # Get properties with current values and field metadata
520
+ props_response = requests.get(
521
+ properties_url,
522
+ headers=headers,
523
+ params={'calledBy': 'testcase_properties'}
524
+ )
525
+ props_response.raise_for_status()
526
+ properties_data = props_response.json()
527
+
528
+ # Get properties-info with data types and allowed values
529
+ info_response = requests.get(properties_info_url, headers=headers)
530
+ info_response.raise_for_status()
531
+ info_data = info_response.json()
532
+
533
+ except requests.exceptions.RequestException as e:
534
+ stacktrace = format_exc()
535
+ logger.error(f"Failed to call properties API: {stacktrace}")
536
+ raise ValueError(
537
+ f"Unable to retrieve field definitions using properties API. "
538
+ f"Error: {stacktrace}"
539
+ ) from e
540
+
541
+ # Step 3: Build field mapping by merging both responses
542
+ field_mapping = {}
543
+
544
+ # Create lookup by field ID from properties-info
545
+ metadata_by_id = {item['id']: item for item in info_data['metadata']}
546
+
547
+ # Data type mapping to determine 'multiple' flag
548
+ MULTI_SELECT_TYPES = {
549
+ 'UserListDataType',
550
+ 'MultiSelectionDataType',
551
+ 'CheckListDataType'
552
+ }
553
+
554
+ USER_FIELD_TYPES = {'UserListDataType'}
555
+
556
+ # System fields to exclude (same as in property mapping)
557
+ excluded_fields = {'Shared', 'Projects Shared to'}
558
+
559
+ for prop in properties_data:
560
+ field_name = prop.get('name')
561
+ field_id = prop.get('id')
562
+
563
+ if not field_name or field_name in excluded_fields:
564
+ continue
565
+
566
+ # Get metadata for this field
567
+ metadata = metadata_by_id.get(field_id, {})
568
+ data_type_str = metadata.get('data_type')
569
+
570
+ # Determine data_type number (5 for user fields, None for others)
571
+ data_type = 5 if data_type_str in USER_FIELD_TYPES else None
572
+
573
+ # Determine if multi-select
574
+ is_multiple = data_type_str in MULTI_SELECT_TYPES
575
+
576
+ field_mapping[field_name] = {
577
+ 'field_id': field_id,
578
+ 'required': prop.get('required', False),
579
+ 'data_type': data_type,
580
+ 'multiple': is_multiple,
581
+ 'values': {}
582
+ }
583
+
584
+ # Map allowed values from metadata
585
+ allowed_values = metadata.get('allowed_values', [])
586
+ for allowed_val in allowed_values:
587
+ value_text = allowed_val.get('value_text')
588
+ value_id = allowed_val.get('id')
589
+ if value_text and value_id:
590
+ field_mapping[field_name]['values'][value_text] = value_id
591
+
592
+ logger.info(
593
+ f"Retrieved {len(field_mapping)} field definitions using properties API. "
594
+ f"This method works for all users without Field Management permission."
595
+ )
596
+
597
+ return field_mapping
598
+
417
599
  def __get_project_field_definitions(self) -> dict:
418
600
  """
419
601
  Get structured field definitions for test cases in the project.
@@ -439,6 +621,15 @@ class QtestApiWrapper(BaseToolApiWrapper):
439
621
  try:
440
622
  fields = fields_api.get_fields(self.qtest_project_id, qtest_object)
441
623
  except ApiException as e:
624
+ # Check if permission denied (403) - use fallback
625
+ if e.status == 403:
626
+ logger.warning(
627
+ "get_fields permission denied (Field Management permission required). "
628
+ "Using properties API fallback..."
629
+ )
630
+ return self.__get_field_definitions_from_properties_api()
631
+
632
+ # Other API errors
442
633
  stacktrace = format_exc()
443
634
  logger.error(f"Exception when calling FieldAPI->get_fields:\n {stacktrace}")
444
635
  raise ValueError(
@@ -517,6 +708,10 @@ class QtestApiWrapper(BaseToolApiWrapper):
517
708
  return self.__format_field_info_for_display(field_defs)
518
709
 
519
710
  def _parse_modules(self) -> list[dict]:
711
+ """Get parsed modules list with caching for the session."""
712
+ if self._modules_cache is not None:
713
+ return self._modules_cache
714
+
520
715
  modules = self.__get_all_modules_for_project()
521
716
  result = []
522
717
 
@@ -537,6 +732,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
537
732
  for module in modules:
538
733
  parse_module(module)
539
734
 
735
+ self._modules_cache = result
540
736
  return result
541
737
 
542
738
  def __execute_single_create_test_case_request(self, test_case_api_instance: TestCaseApi, body,
@@ -556,8 +752,8 @@ class QtestApiWrapper(BaseToolApiWrapper):
556
752
  def __parse_data(self, response_to_parse: dict, parsed_data: list, extract_images: bool=False, prompt: str=None):
557
753
  import html
558
754
 
559
- # Get field definitions to ensure all fields are included (uses cached version)
560
- field_definitions = self.__get_field_definitions_cached()
755
+ # PERMISSION-FREE: Parse properties directly from API response
756
+ # No get_fields call needed - works for all users
561
757
 
562
758
  for item in response_to_parse['items']:
563
759
  # Start with core fields (always present)
@@ -574,29 +770,15 @@ class QtestApiWrapper(BaseToolApiWrapper):
574
770
  }, enumerate(item['test_steps']))),
575
771
  }
576
772
 
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] = ''
773
+ # Add custom fields directly from API response properties
774
+ for prop in item['properties']:
775
+ field_name = prop.get('field_name')
776
+ if not field_name:
777
+ continue
778
+
779
+ # Use field_value_name if available (for dropdowns/users), otherwise field_value
780
+ field_value = prop.get('field_value_name') or prop.get('field_value') or ''
781
+ parsed_data_row[field_name] = field_value
600
782
 
601
783
  parsed_data.append(parsed_data_row)
602
784
 
@@ -810,6 +992,120 @@ class QtestApiWrapper(BaseToolApiWrapper):
810
992
  f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
811
993
  ) from e
812
994
 
995
+ def find_test_cases_by_requirement_id(self, requirement_id: str, include_details: bool = False) -> dict:
996
+ """Find all test cases linked to a QTest requirement.
997
+
998
+ This method uses the ObjectLinkApi.find() to discover test cases that are
999
+ linked to a specific requirement. This is the correct way to find linked
1000
+ test cases - DQL queries cannot search test cases by linked requirement.
1001
+
1002
+ Args:
1003
+ requirement_id: QTest requirement ID in format RQ-123
1004
+ include_details: If True, fetches full test case details. If False, returns summary with Id, Name, Description.
1005
+
1006
+ Returns:
1007
+ dict with requirement_id, total count, and test_cases list
1008
+
1009
+ Raises:
1010
+ ValueError: If requirement is not found
1011
+ ToolException: If API call fails
1012
+ """
1013
+ # Get internal QTest ID for the requirement
1014
+ qtest_requirement_id = self.__find_qtest_requirement_id_by_id(requirement_id)
1015
+
1016
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
1017
+
1018
+ try:
1019
+ # Use ObjectLinkApi.find() to get linked artifacts
1020
+ # type='requirements' means we're searching from requirements
1021
+ # ids=[qtest_requirement_id] specifies which requirement(s) to check
1022
+ response = link_object_api_instance.find(
1023
+ self.qtest_project_id,
1024
+ type='requirements',
1025
+ ids=[qtest_requirement_id]
1026
+ )
1027
+
1028
+ # Parse the response to extract linked test cases
1029
+ # Response structure: [{id: req_internal_id, pid: 'RQ-15', objects: [{id: tc_internal_id, pid: 'TC-123'}, ...]}]
1030
+ linked_test_cases = []
1031
+ if response and len(response) > 0:
1032
+ for container in response:
1033
+ # Convert to dict if it's an object
1034
+ container_data = container.to_dict() if hasattr(container, 'to_dict') else container
1035
+ objects = container_data.get('objects', []) if isinstance(container_data, dict) else []
1036
+
1037
+ for obj in objects:
1038
+ obj_data = obj.to_dict() if hasattr(obj, 'to_dict') else obj
1039
+ if isinstance(obj_data, dict):
1040
+ pid = obj_data.get('pid', '')
1041
+ internal_id = obj_data.get('id')
1042
+ if pid and pid.startswith('TC-'):
1043
+ linked_test_cases.append({
1044
+ 'Id': pid,
1045
+ QTEST_ID: internal_id
1046
+ })
1047
+
1048
+ if not linked_test_cases:
1049
+ return {
1050
+ 'requirement_id': requirement_id,
1051
+ 'total': 0,
1052
+ 'test_cases': [],
1053
+ 'message': f"No test cases are linked to requirement '{requirement_id}'"
1054
+ }
1055
+
1056
+ # Build result based on detail level
1057
+ test_cases_result = []
1058
+
1059
+ if not include_details:
1060
+ # Short view: fetch Name, Description via DQL for each test case
1061
+ for tc in linked_test_cases:
1062
+ try:
1063
+ parsed_data = self.__perform_search_by_dql(f"Id = '{tc['Id']}'")
1064
+ if parsed_data:
1065
+ tc_data = parsed_data[0]
1066
+ test_cases_result.append({
1067
+ 'Id': tc['Id'],
1068
+ QTEST_ID: tc[QTEST_ID],
1069
+ 'Name': tc_data.get('Name'),
1070
+ 'Description': tc_data.get('Description', '')
1071
+ })
1072
+ except Exception as e:
1073
+ logger.warning(f"Could not fetch details for {tc['Id']}: {e}")
1074
+ test_cases_result.append({
1075
+ 'Id': tc['Id'],
1076
+ QTEST_ID: tc[QTEST_ID],
1077
+ 'Name': 'Unable to fetch',
1078
+ 'Description': ''
1079
+ })
1080
+ else:
1081
+ # Full details: fetch complete test case data
1082
+ for tc in linked_test_cases:
1083
+ try:
1084
+ parsed_data = self.__perform_search_by_dql(f"Id = '{tc['Id']}'")
1085
+ if parsed_data:
1086
+ test_cases_result.append(parsed_data[0])
1087
+ except Exception as e:
1088
+ logger.warning(f"Could not fetch details for {tc['Id']}: {e}")
1089
+ test_cases_result.append({
1090
+ 'Id': tc['Id'],
1091
+ QTEST_ID: tc[QTEST_ID],
1092
+ 'error': f'Unable to fetch details: {str(e)}'
1093
+ })
1094
+
1095
+ return {
1096
+ 'requirement_id': requirement_id,
1097
+ 'total': len(test_cases_result),
1098
+ 'test_cases': test_cases_result
1099
+ }
1100
+
1101
+ except ApiException as e:
1102
+ stacktrace = format_exc()
1103
+ logger.error(f"Error finding test cases by requirement: {stacktrace}")
1104
+ raise ToolException(
1105
+ f"Unable to find test cases linked to requirement '{requirement_id}' "
1106
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1107
+ ) from e
1108
+
813
1109
  def search_by_dql(self, dql: str, extract_images:bool=False, prompt: str=None):
814
1110
  """Search for the test cases in qTest using Data Query Language """
815
1111
  parsed_data = self.__perform_search_by_dql(dql, extract_images, prompt)
@@ -897,7 +1193,37 @@ class QtestApiWrapper(BaseToolApiWrapper):
897
1193
  {
898
1194
  "name": "search_by_dql",
899
1195
  "mode": "search_by_dql",
900
- "description": 'Search the test cases in qTest using Data Query Language. The input of the tool will be in following format - Module in \'MD-78 Master Test Suite\' and Type = \'Automation - UTAF\'. If keyword or value to check against has 2 words in it it should be surrounded with single quotes',
1196
+ "description": """Search test cases in qTest using Data Query Language (DQL).
1197
+
1198
+ CRITICAL: USE SINGLE QUOTES ONLY - DQL does not support double quotes!
1199
+ - ✓ CORRECT: Description ~ 'Forgot Password'
1200
+ - ✗ WRONG: Description ~ "Forgot Password"
1201
+
1202
+ LIMITATION - CANNOT SEARCH BY LINKED OBJECTS:
1203
+ - ✗ 'Requirement Id' = 'RQ-15' will fail - use 'find_test_cases_by_requirement_id' tool instead
1204
+ - ✗ Linked defects or other relationship queries are not supported
1205
+
1206
+ SEARCHABLE FIELDS:
1207
+ - Direct fields: Id, Name, Description, Status, Type, Priority, Automation, etc.
1208
+ - Module: Use 'Module in' syntax
1209
+ - Custom fields: Use exact field name from project configuration
1210
+ - Date fields: Use ISO DateTime format (e.g., 'Created Date' > '2021-05-07T03:15:37.652Z')
1211
+
1212
+ SYNTAX RULES:
1213
+ 1. ALL string values MUST use single quotes (never double quotes)
1214
+ 2. Field names with spaces MUST be in single quotes: 'Created Date' > '2024-01-01'
1215
+ 3. Use ~ for 'contains', !~ for 'not contains': Description ~ 'login'
1216
+ 4. Use 'is not empty' for non-empty check: Name is 'not empty'
1217
+ 5. Operators: =, !=, <, >, <=, >=, in, ~, !~
1218
+
1219
+ EXAMPLES:
1220
+ - Id = 'TC-123'
1221
+ - Description ~ 'Forgot Password'
1222
+ - Status = 'New' and Priority = 'High'
1223
+ - Module in 'MD-78 Master Test Suite'
1224
+ - Name ~ 'login'
1225
+ - 'Created Date' > '2024-01-01T00:00:00.000Z'
1226
+ """,
901
1227
  "args_schema": QtestDataQuerySearch,
902
1228
  "ref": self.search_by_dql,
903
1229
  },
@@ -956,5 +1282,24 @@ class QtestApiWrapper(BaseToolApiWrapper):
956
1282
  "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
1283
  "args_schema": GetAllTestCasesFieldsForProject,
958
1284
  "ref": self.get_all_test_cases_fields_for_project,
1285
+ },
1286
+ {
1287
+ "name": "find_test_cases_by_requirement_id",
1288
+ "mode": "find_test_cases_by_requirement_id",
1289
+ "description": """Find all test cases linked to a QTest requirement.
1290
+
1291
+ Use this tool to find test cases associated with a specific requirement.
1292
+ DQL search cannot query by linked requirement - use this tool instead.
1293
+
1294
+ Parameters:
1295
+ - requirement_id: QTest requirement ID in format RQ-123
1296
+ - include_details: If true, returns full test case data. If false (default), returns Id, QTest Id, Name, and Description.
1297
+
1298
+ Examples:
1299
+ - Find test cases for RQ-15: requirement_id='RQ-15'
1300
+ - Get full details: requirement_id='RQ-15', include_details=true
1301
+ """,
1302
+ "args_schema": FindTestCasesByRequirementId,
1303
+ "ref": self.find_test_cases_by_requirement_id,
959
1304
  }
960
1305
  ]
@@ -29,8 +29,6 @@ class RallyToolkit(BaseToolkit):
29
29
  RallyToolkit.toolkit_max_length = get_max_toolkit_length(selected_tools)
30
30
  return create_model(
31
31
  name,
32
- name=(str, Field(description="Toolkit name", json_schema_extra={'toolkit_name': True,
33
- 'max_toolkit_length': RallyToolkit.toolkit_max_length})),
34
32
  rally_configuration=(RallyConfiguration, Field(description="Rally configuration", json_schema_extra={'configuration_types': ['rally']})),
35
33
  workspace=(Optional[str], Field(default=None, description="Rally workspace")),
36
34
  project=(Optional[str], Field(default=None, description="Rally project")),
@@ -39,6 +37,7 @@ class RallyToolkit(BaseToolkit):
39
37
  'metadata': {
40
38
  "label": "Rally",
41
39
  "icon_url": "rally.svg",
40
+ "max_length": RallyToolkit.toolkit_max_length,
42
41
  "categories": ["project management"],
43
42
  "extra_categories": ["agile management", "test management", "scrum", "kanban"]
44
43
  }
@@ -33,6 +33,7 @@ class ReportPortalToolkit(BaseToolkit):
33
33
  report_portal_configuration=(ReportPortalConfiguration, Field(description="Report Portal Configuration", json_schema_extra={'configuration_types': ['report_portal']})),
34
34
  selected_tools=(List[Literal[tuple(selected_tools)]], Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
35
35
  __config__=ConfigDict(json_schema_extra={'metadata': {"label": "Report Portal", "icon_url": "reportportal-icon.svg",
36
+ "max_length": ReportPortalToolkit.toolkit_max_length,
36
37
  "categories": ["testing"],
37
38
  "extra_categories": ["test reporting", "test automation"]}})
38
39
  )
@@ -31,6 +31,7 @@ class SalesforceToolkit(BaseToolkit):
31
31
  selected_tools=(List[Literal[tuple(available_tools)]], Field(default=[], json_schema_extra={'args_schemas': available_tools})),
32
32
  __config__=ConfigDict(json_schema_extra={'metadata': {
33
33
  "label": "Salesforce", "icon_url": "salesforce-icon.svg",
34
+ "max_length": SalesforceToolkit.toolkit_max_length,
34
35
  "categories": ["other"],
35
36
  "extra_categories": ["customer relationship management", "cloud computing", "marketing automation", "salesforce"]
36
37
  }})
@@ -35,9 +35,7 @@ class ServiceNowToolkit(BaseToolkit):
35
35
  ServiceNowToolkit.toolkit_max_length = get_max_toolkit_length(selected_tools)
36
36
  return create_model(
37
37
  name,
38
- name=(str, Field(description="Toolkit name",
39
- json_schema_extra={
40
- 'toolkit_name': True, 'max_toolkit_length': ServiceNowToolkit.toolkit_max_length})),
38
+ name=(str, Field(description="Toolkit name")),
41
39
  response_fields=(Optional[str], Field(description="Response fields", default=None)),
42
40
  servicenow_configuration=(ServiceNowConfiguration, Field(description="ServiceNow Configuration",
43
41
  json_schema_extra={
@@ -49,6 +47,7 @@ class ServiceNowToolkit(BaseToolkit):
49
47
  'metadata': {
50
48
  "label": "ServiceNow",
51
49
  "icon_url": "service-now.svg",
50
+ "max_length": ServiceNowToolkit.toolkit_max_length,
52
51
  "hidden": False,
53
52
  "sections": {
54
53
  "auth": {