alita-sdk 0.3.449__py3-none-any.whl → 0.3.465__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 (74) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +0 -0
  4. alita_sdk/cli/agent/default.py +176 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +197 -0
  7. alita_sdk/cli/agent_ui.py +218 -0
  8. alita_sdk/cli/agents.py +1911 -0
  9. alita_sdk/cli/callbacks.py +576 -0
  10. alita_sdk/cli/cli.py +159 -0
  11. alita_sdk/cli/config.py +164 -0
  12. alita_sdk/cli/formatting.py +182 -0
  13. alita_sdk/cli/input_handler.py +256 -0
  14. alita_sdk/cli/mcp_loader.py +315 -0
  15. alita_sdk/cli/toolkit.py +330 -0
  16. alita_sdk/cli/toolkit_loader.py +55 -0
  17. alita_sdk/cli/tools/__init__.py +36 -0
  18. alita_sdk/cli/tools/approval.py +224 -0
  19. alita_sdk/cli/tools/filesystem.py +905 -0
  20. alita_sdk/cli/tools/planning.py +403 -0
  21. alita_sdk/cli/tools/terminal.py +280 -0
  22. alita_sdk/runtime/clients/client.py +16 -1
  23. alita_sdk/runtime/langchain/constants.py +2 -1
  24. alita_sdk/runtime/langchain/langraph_agent.py +74 -20
  25. alita_sdk/runtime/langchain/utils.py +20 -4
  26. alita_sdk/runtime/toolkits/artifact.py +5 -6
  27. alita_sdk/runtime/toolkits/mcp.py +5 -2
  28. alita_sdk/runtime/toolkits/tools.py +1 -0
  29. alita_sdk/runtime/tools/function.py +19 -6
  30. alita_sdk/runtime/tools/llm.py +65 -7
  31. alita_sdk/runtime/tools/vectorstore_base.py +17 -2
  32. alita_sdk/runtime/utils/mcp_sse_client.py +64 -6
  33. alita_sdk/tools/ado/repos/__init__.py +1 -0
  34. alita_sdk/tools/ado/test_plan/__init__.py +1 -1
  35. alita_sdk/tools/ado/wiki/__init__.py +1 -5
  36. alita_sdk/tools/ado/work_item/__init__.py +1 -5
  37. alita_sdk/tools/base_indexer_toolkit.py +64 -8
  38. alita_sdk/tools/bitbucket/__init__.py +1 -0
  39. alita_sdk/tools/code/sonar/__init__.py +1 -1
  40. alita_sdk/tools/confluence/__init__.py +2 -2
  41. alita_sdk/tools/github/__init__.py +2 -2
  42. alita_sdk/tools/gitlab/__init__.py +2 -1
  43. alita_sdk/tools/gitlab_org/__init__.py +1 -2
  44. alita_sdk/tools/google_places/__init__.py +2 -1
  45. alita_sdk/tools/jira/__init__.py +1 -0
  46. alita_sdk/tools/memory/__init__.py +1 -1
  47. alita_sdk/tools/pandas/__init__.py +1 -1
  48. alita_sdk/tools/postman/__init__.py +2 -1
  49. alita_sdk/tools/pptx/__init__.py +2 -2
  50. alita_sdk/tools/qtest/__init__.py +3 -3
  51. alita_sdk/tools/qtest/api_wrapper.py +1235 -51
  52. alita_sdk/tools/rally/__init__.py +1 -2
  53. alita_sdk/tools/report_portal/__init__.py +1 -0
  54. alita_sdk/tools/salesforce/__init__.py +1 -0
  55. alita_sdk/tools/servicenow/__init__.py +2 -3
  56. alita_sdk/tools/sharepoint/__init__.py +1 -0
  57. alita_sdk/tools/sharepoint/api_wrapper.py +22 -2
  58. alita_sdk/tools/sharepoint/authorization_helper.py +17 -1
  59. alita_sdk/tools/slack/__init__.py +1 -0
  60. alita_sdk/tools/sql/__init__.py +2 -1
  61. alita_sdk/tools/testio/__init__.py +1 -0
  62. alita_sdk/tools/testrail/__init__.py +1 -3
  63. alita_sdk/tools/xray/__init__.py +2 -1
  64. alita_sdk/tools/zephyr/__init__.py +2 -1
  65. alita_sdk/tools/zephyr_enterprise/__init__.py +1 -0
  66. alita_sdk/tools/zephyr_essential/__init__.py +1 -0
  67. alita_sdk/tools/zephyr_scale/__init__.py +1 -0
  68. alita_sdk/tools/zephyr_squad/__init__.py +1 -0
  69. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/METADATA +145 -2
  70. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/RECORD +74 -52
  71. alita_sdk-0.3.465.dist-info/entry_points.txt +2 -0
  72. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/WHEEL +0 -0
  73. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/licenses/LICENSE +0 -0
  74. {alita_sdk-0.3.449.dist-info → alita_sdk-0.3.465.dist-info}/top_level.txt +0 -0
@@ -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
@@ -72,6 +78,73 @@ Steps: Array of test steps with Description and Expected Result.
72
78
  Json object
73
79
  """
74
80
 
81
+ # DQL Syntax Documentation - reusable across all DQL-based search tools
82
+ DQL_SYNTAX_DOCS = """
83
+ CRITICAL: USE SINGLE QUOTES ONLY - DQL does not support double quotes!
84
+ - ✓ CORRECT: Description ~ 'Forgot Password'
85
+ - ✗ WRONG: Description ~ "Forgot Password"
86
+
87
+ LIMITATION - CANNOT SEARCH BY LINKED OBJECTS:
88
+ - ✗ Searching by linked requirements, test cases, defects is NOT supported
89
+ - Use dedicated find_*_by_*_id tools for relationship queries
90
+
91
+ SEARCHABLE FIELDS:
92
+ - Direct fields: Id, Name, Description, Status, Type, Priority, etc.
93
+ - Custom fields: Use exact field name from project configuration
94
+ - Date fields: MUST use ISO DateTime format (e.g., '2024-01-01T00:00:00.000Z')
95
+
96
+ ENTITY-SPECIFIC NOTES:
97
+ - test-logs: Only support 'Execution Start Date' and 'Execution End Date' queries
98
+ - builds/test-cycles: Also support 'Created Date' and 'Last Modified Date'
99
+ - defects: Can use 'Affected Release/Build' and 'Fixed Release/Build'
100
+
101
+ SYNTAX RULES:
102
+ 1. ALL string values MUST use single quotes (never double quotes)
103
+ 2. Field names with spaces MUST be in single quotes: 'Created Date' > '2024-01-01T00:00:00.000Z'
104
+ 3. Use ~ for 'contains', !~ for 'not contains': Description ~ 'login'
105
+ 4. Use 'is not empty' for non-empty check: Name is 'not empty'
106
+ 5. Operators: =, !=, <, >, <=, >=, in, ~, !~
107
+
108
+ EXAMPLES:
109
+ - Id = 'TC-123' or Id = 'RQ-15' or Id = 'DF-100' (depending on entity type)
110
+ - Description ~ 'Forgot Password'
111
+ - Status = 'New' and Priority = 'High'
112
+ - Name ~ 'login'
113
+ - 'Created Date' > '2024-01-01T00:00:00.000Z'
114
+ - 'Execution Start Date' > '2024-01-01T00:00:00.000Z' (for test-logs)
115
+ """
116
+
117
+ # Supported object types for DQL search (based on QTest Search API documentation)
118
+ # Note: Prefixes are configurable per-project but these are standard defaults
119
+ # Modules (MD) are NOT searchable via DQL - use get_modules tool instead
120
+ # Test-logs have NO prefix - they are internal records accessed via test runs
121
+
122
+ # Entity types with ID prefixes (can be looked up by ID like TC-123)
123
+ QTEST_OBJECT_TYPES = {
124
+ # Core test management entities
125
+ 'test-cases': {'prefix': 'TC', 'name': 'Test Case', 'description': 'Test case definitions with steps'},
126
+ 'test-runs': {'prefix': 'TR', 'name': 'Test Run', 'description': 'Execution instances of test cases'},
127
+ 'defects': {'prefix': 'DF', 'name': 'Defect', 'description': 'Bugs/issues found during testing'},
128
+ 'requirements': {'prefix': 'RQ', 'name': 'Requirement', 'description': 'Requirements to be tested'},
129
+
130
+ # Test organization entities
131
+ 'test-suites': {'prefix': 'TS', 'name': 'Test Suite', 'description': 'Collections of test runs'},
132
+ 'test-cycles': {'prefix': 'CL', 'name': 'Test Cycle', 'description': 'Test execution cycles'},
133
+
134
+ # Release management entities
135
+ 'releases': {'prefix': 'RL', 'name': 'Release', 'description': 'Software releases'},
136
+ 'builds': {'prefix': 'BL', 'name': 'Build', 'description': 'Builds within releases'},
137
+ }
138
+
139
+ # Entity types searchable via DQL but without ID prefixes
140
+ # These can be searched by specific fields only, not by ID
141
+ QTEST_SEARCHABLE_ONLY_TYPES = {
142
+ 'test-logs': {
143
+ 'name': 'Test Log',
144
+ 'description': "Execution logs. Only date queries supported (Execution Start Date, Execution End Date). For specific log details, use test run's 'Latest Test Log' field."
145
+ },
146
+ }
147
+
75
148
  logger = logging.getLogger(__name__)
76
149
 
77
150
  QtestDataQuerySearch = create_model(
@@ -142,6 +215,40 @@ GetAllTestCasesFieldsForProject = create_model(
142
215
  default=False)),
143
216
  )
144
217
 
218
+ FindTestCasesByRequirementId = create_model(
219
+ "FindTestCasesByRequirementId",
220
+ requirement_id=(str, Field(description="QTest requirement ID in format RQ-123. This will find all test cases linked to this requirement.")),
221
+ 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)),
222
+ )
223
+
224
+ FindRequirementsByTestCaseId = create_model(
225
+ "FindRequirementsByTestCaseId",
226
+ test_case_id=(str, Field(description="Test case ID in format TC-123. This will find all requirements linked to this test case.")),
227
+ )
228
+
229
+ FindTestRunsByTestCaseId = create_model(
230
+ "FindTestRunsByTestCaseId",
231
+ test_case_id=(str, Field(description="Test case ID in format TC-123. This will find all test runs associated with this test case.")),
232
+ )
233
+
234
+ FindDefectsByTestRunId = create_model(
235
+ "FindDefectsByTestRunId",
236
+ test_run_id=(str, Field(description="Test run ID in format TR-123. This will find all defects associated with this test run.")),
237
+ )
238
+
239
+ # Generic search model for any entity type
240
+ GenericDqlSearch = create_model(
241
+ "GenericDqlSearch",
242
+ object_type=(str, Field(description="Entity type to search: 'test-cases', 'test-runs', 'defects', 'requirements', 'test-suites', 'test-cycles', 'test-logs', 'releases', or 'builds'. Note: test-logs only support date queries; modules are NOT searchable - use get_modules tool.")),
243
+ dql=(str, Field(description="QTest Data Query Language (DQL) query string")),
244
+ )
245
+
246
+ # Generic find by ID model - only for entities with ID prefixes (NOT test-logs)
247
+ FindEntityById = create_model(
248
+ "FindEntityById",
249
+ entity_id=(str, Field(description="Entity ID with prefix: TC-123 (test case), RQ-15 (requirement), DF-100 (defect), TR-39 (test run), TS-5 (test suite), CL-3 (test cycle), RL-1 (release), or BL-2 (build). Note: test-logs and modules do NOT have ID prefixes.")),
250
+ )
251
+
145
252
  NoInput = create_model(
146
253
  "NoInput"
147
254
  )
@@ -155,6 +262,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
155
262
  no_of_tests_shown_in_dql_search: int = 10
156
263
  _client: Any = PrivateAttr()
157
264
  _field_definitions_cache: Optional[dict] = PrivateAttr(default=None)
265
+ _modules_cache: Optional[list] = PrivateAttr(default=None)
158
266
  llm: Any
159
267
 
160
268
  @model_validator(mode='before')
@@ -256,9 +364,50 @@ class QtestApiWrapper(BaseToolApiWrapper):
256
364
  # Skip non-property fields (these are handled separately)
257
365
  if field_name in ['Name', 'Description', 'Precondition', 'Steps', 'Id', QTEST_ID]:
258
366
  continue
259
-
260
- # Skip None or empty string values (don't update these fields)
261
- if field_value is None or field_value == '':
367
+
368
+ # Skip empty string values (don't update these fields)
369
+ if field_value == '':
370
+ continue
371
+
372
+ # Handle None value - this means "clear/unset this field"
373
+ if field_value is None:
374
+ # Validate field exists before attempting to clear
375
+ if field_name not in field_definitions:
376
+ validation_errors.append(
377
+ f"❌ Unknown field '{field_name}' - not defined in project configuration"
378
+ )
379
+ continue
380
+
381
+ field_def = field_definitions[field_name]
382
+ field_id = field_def['field_id']
383
+ is_multiple = field_def.get('multiple', False)
384
+ has_allowed_values = bool(field_def.get('values')) # True = dropdown, False = text
385
+
386
+ if not has_allowed_values:
387
+ # TEXT FIELD: can clear with empty string
388
+ props_dict[field_name] = {
389
+ 'field_id': field_id,
390
+ 'field_name': field_name,
391
+ 'field_value': '',
392
+ 'field_value_name': ''
393
+ }
394
+ elif is_multiple:
395
+ # MULTI-SELECT: can clear using empty array "[]"
396
+ props_dict[field_name] = {
397
+ 'field_id': field_id,
398
+ 'field_name': field_name,
399
+ 'field_value': "[]",
400
+ 'field_value_name': None
401
+ }
402
+ else:
403
+ # SINGLE-SELECT: QTest API limitation - cannot clear to empty
404
+ # Note: Users CAN clear these fields from UI, but API doesn't expose this capability
405
+ validation_errors.append(
406
+ f"⚠️ Cannot clear single-select field '{field_name}' - this is a QTest API limitation "
407
+ f"(clearing is possible from UI but not exposed via API). "
408
+ f"Please select an alternative value instead. "
409
+ f"Available values: {', '.join(field_def.get('values', {}).keys()) or 'none'}"
410
+ )
262
411
  continue
263
412
 
264
413
  # Validate field exists in project - STRICT validation
@@ -376,14 +525,21 @@ class QtestApiWrapper(BaseToolApiWrapper):
376
525
 
377
526
  body = swagger_client.TestCaseWithCustomFieldResource(properties=props)
378
527
 
379
- # Only set fields if they are explicitly provided in the input
380
- # This prevents overwriting existing values with None during partial updates
528
+ # Handle core fields: Name, Description, Precondition
529
+ # These are set if explicitly provided in the input
530
+ # None or empty string means "clear this field" (except Name which is required)
381
531
  if 'Name' in test_case:
382
- body.name = test_case['Name']
532
+ # Name is required - use 'Untitled' as fallback if null/empty
533
+ name_value = test_case['Name']
534
+ body.name = name_value if name_value else 'Untitled'
535
+
383
536
  if 'Precondition' in test_case:
384
- body.precondition = test_case['Precondition']
537
+ # Allow clearing with None or empty string
538
+ body.precondition = test_case['Precondition'] if test_case['Precondition'] is not None else ''
539
+
385
540
  if 'Description' in test_case:
386
- body.description = test_case['Description']
541
+ # Allow clearing with None or empty string
542
+ body.description = test_case['Description'] if test_case['Description'] is not None else ''
387
543
 
388
544
  if parent_id:
389
545
  body.parent_id = parent_id
@@ -414,6 +570,143 @@ class QtestApiWrapper(BaseToolApiWrapper):
414
570
  Exception: \n {stacktrace}""")
415
571
  return modules
416
572
 
573
+ def __get_field_definitions_from_properties_api(self) -> dict:
574
+ """
575
+ Fallback method: Get field definitions using /properties and /properties-info APIs.
576
+
577
+ These APIs don't require Field Management permission and are available to all users.
578
+ Requires 2 API calls + 1 search to get a test case ID.
579
+
580
+ Returns:
581
+ dict: Same structure as __get_project_field_definitions()
582
+ """
583
+ logger.info(
584
+ "Using properties API fallback (no Field Management permission). "
585
+ "This requires getting a template test case first."
586
+ )
587
+
588
+ # Step 1: Get any test case ID to query properties
589
+ search_instance = swagger_client.SearchApi(self._client)
590
+ body = swagger_client.ArtifactSearchParams(
591
+ object_type='test-cases',
592
+ fields=['*'],
593
+ query='' # Empty query returns all test cases
594
+ )
595
+
596
+ try:
597
+ # Search for any test case - just need one
598
+ response = search_instance.search_artifact(
599
+ self.qtest_project_id,
600
+ body,
601
+ page_size=1,
602
+ page=1
603
+ )
604
+ except ApiException as e:
605
+ stacktrace = format_exc()
606
+ logger.error(f"Failed to find test case for properties API: {stacktrace}")
607
+ raise ValueError(
608
+ f"Cannot find any test case to query field definitions. "
609
+ f"Please create at least one test case in project {self.qtest_project_id}"
610
+ ) from e
611
+
612
+ if not response or not response.get('items') or len(response['items']) == 0:
613
+ raise ValueError(
614
+ f"No test cases found in project {self.qtest_project_id}. "
615
+ f"Please create at least one test case to retrieve field definitions."
616
+ )
617
+
618
+ test_case_id = response['items'][0]['id']
619
+ logger.info(f"Using test case ID {test_case_id} to retrieve field definitions")
620
+
621
+ # Step 2: Call /properties API
622
+ headers = {
623
+ "Authorization": f"Bearer {self.qtest_api_token.get_secret_value()}"
624
+ }
625
+
626
+ properties_url = f"{self.base_url}/api/v3/projects/{self.qtest_project_id}/test-cases/{test_case_id}/properties"
627
+ properties_info_url = f"{self.base_url}/api/v3/projects/{self.qtest_project_id}/test-cases/{test_case_id}/properties-info"
628
+
629
+ try:
630
+ # Get properties with current values and field metadata
631
+ props_response = requests.get(
632
+ properties_url,
633
+ headers=headers,
634
+ params={'calledBy': 'testcase_properties'}
635
+ )
636
+ props_response.raise_for_status()
637
+ properties_data = props_response.json()
638
+
639
+ # Get properties-info with data types and allowed values
640
+ info_response = requests.get(properties_info_url, headers=headers)
641
+ info_response.raise_for_status()
642
+ info_data = info_response.json()
643
+
644
+ except requests.exceptions.RequestException as e:
645
+ stacktrace = format_exc()
646
+ logger.error(f"Failed to call properties API: {stacktrace}")
647
+ raise ValueError(
648
+ f"Unable to retrieve field definitions using properties API. "
649
+ f"Error: {stacktrace}"
650
+ ) from e
651
+
652
+ # Step 3: Build field mapping by merging both responses
653
+ field_mapping = {}
654
+
655
+ # Create lookup by field ID from properties-info
656
+ metadata_by_id = {item['id']: item for item in info_data['metadata']}
657
+
658
+ # Data type mapping to determine 'multiple' flag
659
+ MULTI_SELECT_TYPES = {
660
+ 'UserListDataType',
661
+ 'MultiSelectionDataType',
662
+ 'CheckListDataType'
663
+ }
664
+
665
+ USER_FIELD_TYPES = {'UserListDataType'}
666
+
667
+ # System fields to exclude (same as in property mapping)
668
+ excluded_fields = {'Shared', 'Projects Shared to'}
669
+
670
+ for prop in properties_data:
671
+ field_name = prop.get('name')
672
+ field_id = prop.get('id')
673
+
674
+ if not field_name or field_name in excluded_fields:
675
+ continue
676
+
677
+ # Get metadata for this field
678
+ metadata = metadata_by_id.get(field_id, {})
679
+ data_type_str = metadata.get('data_type')
680
+
681
+ # Determine data_type number (5 for user fields, None for others)
682
+ data_type = 5 if data_type_str in USER_FIELD_TYPES else None
683
+
684
+ # Determine if multi-select
685
+ is_multiple = data_type_str in MULTI_SELECT_TYPES
686
+
687
+ field_mapping[field_name] = {
688
+ 'field_id': field_id,
689
+ 'required': prop.get('required', False),
690
+ 'data_type': data_type,
691
+ 'multiple': is_multiple,
692
+ 'values': {}
693
+ }
694
+
695
+ # Map allowed values from metadata
696
+ allowed_values = metadata.get('allowed_values', [])
697
+ for allowed_val in allowed_values:
698
+ value_text = allowed_val.get('value_text')
699
+ value_id = allowed_val.get('id')
700
+ if value_text and value_id:
701
+ field_mapping[field_name]['values'][value_text] = value_id
702
+
703
+ logger.info(
704
+ f"Retrieved {len(field_mapping)} field definitions using properties API. "
705
+ f"This method works for all users without Field Management permission."
706
+ )
707
+
708
+ return field_mapping
709
+
417
710
  def __get_project_field_definitions(self) -> dict:
418
711
  """
419
712
  Get structured field definitions for test cases in the project.
@@ -439,6 +732,15 @@ class QtestApiWrapper(BaseToolApiWrapper):
439
732
  try:
440
733
  fields = fields_api.get_fields(self.qtest_project_id, qtest_object)
441
734
  except ApiException as e:
735
+ # Check if permission denied (403) - use fallback
736
+ if e.status == 403:
737
+ logger.warning(
738
+ "get_fields permission denied (Field Management permission required). "
739
+ "Using properties API fallback..."
740
+ )
741
+ return self.__get_field_definitions_from_properties_api()
742
+
743
+ # Other API errors
442
744
  stacktrace = format_exc()
443
745
  logger.error(f"Exception when calling FieldAPI->get_fields:\n {stacktrace}")
444
746
  raise ValueError(
@@ -487,16 +789,30 @@ class QtestApiWrapper(BaseToolApiWrapper):
487
789
 
488
790
  for field_name, field_info in sorted(field_definitions.items()):
489
791
  required_marker = " (Required)" if field_info.get('required') else ""
490
- output.append(f"\n{field_name}{required_marker}:")
792
+ has_values = bool(field_info.get('values'))
793
+ is_multiple = field_info.get('multiple', False)
491
794
 
492
- if field_info.get('values'):
795
+ # Determine field type label
796
+ if not has_values:
797
+ type_label = "Text"
798
+ elif is_multiple:
799
+ type_label = "Multi-select"
800
+ else:
801
+ type_label = "Single-select"
802
+
803
+ output.append(f"\n{field_name} ({type_label}{required_marker}):")
804
+
805
+ if has_values:
493
806
  for value_name, value_id in sorted(field_info['values'].items()):
494
- output.append(f" - {value_name} (id: {value_id})")
807
+ output.append(f" - {value_name}")
495
808
  else:
496
- output.append(" Type: text")
809
+ output.append(" Free text input. Set to null to clear.")
497
810
 
498
- output.append("\n\nUse these exact value names when creating or updating test cases.")
499
- return ''.join(output)
811
+ output.append("\n\n--- Field Type Guide ---")
812
+ output.append("\nText fields: Use null to clear, provide string value to set.")
813
+ output.append("\nSingle-select: Provide exact value name from the list above. Cannot be cleared via API.")
814
+ output.append("\nMulti-select: Provide value as array [\"val1\", \"val2\"]. Use null to clear.")
815
+ return '\n'.join(output)
500
816
 
501
817
  def get_all_test_cases_fields_for_project(self, force_refresh: bool = False) -> str:
502
818
  """
@@ -517,6 +833,10 @@ class QtestApiWrapper(BaseToolApiWrapper):
517
833
  return self.__format_field_info_for_display(field_defs)
518
834
 
519
835
  def _parse_modules(self) -> list[dict]:
836
+ """Get parsed modules list with caching for the session."""
837
+ if self._modules_cache is not None:
838
+ return self._modules_cache
839
+
520
840
  modules = self.__get_all_modules_for_project()
521
841
  result = []
522
842
 
@@ -537,6 +857,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
537
857
  for module in modules:
538
858
  parse_module(module)
539
859
 
860
+ self._modules_cache = result
540
861
  return result
541
862
 
542
863
  def __execute_single_create_test_case_request(self, test_case_api_instance: TestCaseApi, body,
@@ -553,11 +874,42 @@ class QtestApiWrapper(BaseToolApiWrapper):
553
874
  raise ToolException(
554
875
  f"Unable to create test case in project - {self.qtest_project_id} with the following content:\n{test_case_content}.\n\n Stacktrace was {stacktrace}") from e
555
876
 
877
+ def __format_property_value(self, prop: dict) -> Any:
878
+ """Format property value for display, detecting field type from response structure.
879
+
880
+ Detection rules based on API response patterns:
881
+ - Text field: field_value_name is empty/None
882
+ - Multi-select: field_value_name starts with '[' and ends with ']'
883
+ - Single-select: field_value_name is plain text (no brackets)
884
+
885
+ Args:
886
+ prop: Property dict from API response with field_value and field_value_name
887
+
888
+ Returns:
889
+ Formatted value: list for multi-select, string for others
890
+ """
891
+ field_value = prop.get('field_value') or ''
892
+ field_value_name = prop.get('field_value_name')
893
+
894
+ # Text field: no field_value_name, use field_value directly
895
+ if not field_value_name:
896
+ return field_value
897
+
898
+ # Multi-select: field_value_name is bracketed like '[value1, value2]'
899
+ if isinstance(field_value_name, str) and field_value_name.startswith('[') and field_value_name.endswith(']'):
900
+ inner = field_value_name[1:-1].strip() # Remove brackets
901
+ if inner:
902
+ return [v.strip() for v in inner.split(',')]
903
+ return [] # Empty multi-select
904
+
905
+ # Single-select: plain text value
906
+ return field_value_name
907
+
556
908
  def __parse_data(self, response_to_parse: dict, parsed_data: list, extract_images: bool=False, prompt: str=None):
557
909
  import html
558
910
 
559
- # Get field definitions to ensure all fields are included (uses cached version)
560
- field_definitions = self.__get_field_definitions_cached()
911
+ # PERMISSION-FREE: Parse properties directly from API response
912
+ # No get_fields call needed - works for all users
561
913
 
562
914
  for item in response_to_parse['items']:
563
915
  # Start with core fields (always present)
@@ -574,29 +926,14 @@ class QtestApiWrapper(BaseToolApiWrapper):
574
926
  }, enumerate(item['test_steps']))),
575
927
  }
576
928
 
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
929
+ # Add custom fields directly from API response properties
930
+ for prop in item['properties']:
931
+ field_name = prop.get('field_name')
932
+ if not field_name:
933
+ continue
590
934
 
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] = ''
935
+ # Format value based on field type (multi-select as array, etc.)
936
+ parsed_data_row[field_name] = self.__format_property_value(prop)
600
937
 
601
938
  parsed_data.append(parsed_data_row)
602
939
 
@@ -655,38 +992,139 @@ class QtestApiWrapper(BaseToolApiWrapper):
655
992
  parsed_data = self.__perform_search_by_dql(dql)
656
993
  return parsed_data[0]['QTest Id']
657
994
 
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).
995
+ def __find_qtest_internal_id(self, object_type: str, entity_id: str) -> int:
996
+ """Generic search for an entity's internal QTest ID using its external ID (e.g., TR-xxx, DF-xxx, RQ-xxx).
997
+
998
+ This is the unified method for looking up internal IDs. Use this instead of
999
+ the entity-specific methods (__find_qtest_requirement_id_by_id, etc.).
660
1000
 
661
1001
  Args:
662
- requirement_id: Requirement ID in format RQ-123
1002
+ object_type: QTest object type ('test-runs', 'defects', 'requirements', etc.)
1003
+ entity_id: Entity ID in format TR-123, DF-456, etc.
663
1004
 
664
1005
  Returns:
665
- int: Internal QTest ID for the requirement
1006
+ int: Internal QTest ID for the entity
666
1007
 
667
1008
  Raises:
668
- ValueError: If requirement is not found
1009
+ ValueError: If entity is not found
669
1010
  """
670
- dql = f"Id = '{requirement_id}'"
1011
+ dql = f"Id = '{entity_id}'"
671
1012
  search_instance: SearchApi = swagger_client.SearchApi(self._client)
672
- body = swagger_client.ArtifactSearchParams(object_type='requirements', fields=['*'], query=dql)
1013
+ body = swagger_client.ArtifactSearchParams(object_type=object_type, fields=['*'], query=dql)
673
1014
 
674
1015
  try:
675
1016
  response = search_instance.search_artifact(self.qtest_project_id, body)
676
1017
  if response['total'] == 0:
677
1018
  raise ValueError(
678
- f"Requirement '{requirement_id}' not found in project {self.qtest_project_id}. "
679
- f"Please verify the requirement ID exists."
1019
+ f"{object_type.capitalize()} '{entity_id}' not found in project {self.qtest_project_id}. "
1020
+ f"Please verify the {entity_id} ID exists."
680
1021
  )
681
1022
  return response['items'][0]['id']
682
1023
  except ApiException as e:
683
1024
  stacktrace = format_exc()
684
- logger.error(f"Exception when searching for requirement: \n {stacktrace}")
1025
+ logger.error(f"Exception when searching for '{object_type}': '{entity_id}': \n {stacktrace}")
685
1026
  raise ToolException(
686
- f"Unable to search for requirement '{requirement_id}' in project {self.qtest_project_id}. "
1027
+ f"Unable to search for {object_type} '{entity_id}' in project {self.qtest_project_id}. "
687
1028
  f"Exception: \n{stacktrace}"
688
1029
  ) from e
689
1030
 
1031
+ def __find_qtest_requirement_id_by_id(self, requirement_id: str) -> int:
1032
+ """Search for requirement's internal QTest ID using requirement ID (RQ-xxx format).
1033
+
1034
+ Args:
1035
+ requirement_id: Requirement ID in format RQ-123
1036
+
1037
+ Returns:
1038
+ int: Internal QTest ID for the requirement
1039
+
1040
+ Raises:
1041
+ ValueError: If requirement is not found
1042
+ """
1043
+ return self.__find_qtest_internal_id('requirements', requirement_id)
1044
+
1045
+ def __find_qtest_defect_id_by_id(self, defect_id: str) -> int:
1046
+ """Search for defect's internal QTest ID using defect ID (DF-xxx format).
1047
+
1048
+ Args:
1049
+ defect_id: Defect ID in format DF-123
1050
+
1051
+ Returns:
1052
+ int: Internal QTest ID for the defect
1053
+
1054
+ Raises:
1055
+ ValueError: If defect is not found
1056
+ """
1057
+ return self.__find_qtest_internal_id('defects', defect_id)
1058
+
1059
+ def __search_entity_by_id(self, object_type: str, entity_id: str) -> dict:
1060
+ """Generic search for any entity by its ID (RQ-xxx, DF-xxx, etc.).
1061
+
1062
+ Uses the unified __parse_entity_item method for consistent parsing.
1063
+
1064
+ Args:
1065
+ object_type: QTest object type ('requirements', 'defects', etc.)
1066
+ entity_id: Entity ID in format prefix-number (RQ-123, DF-456)
1067
+
1068
+ Returns:
1069
+ dict: Entity data with all parsed fields, or None if not found
1070
+ """
1071
+ dql = f"Id = '{entity_id}'"
1072
+ search_instance: SearchApi = swagger_client.SearchApi(self._client)
1073
+ body = swagger_client.ArtifactSearchParams(object_type=object_type, fields=['*'], query=dql)
1074
+
1075
+ try:
1076
+ response = search_instance.search_artifact(self.qtest_project_id, body)
1077
+ if response['total'] == 0:
1078
+ return None # Not found, but don't raise - caller handles this
1079
+
1080
+ # Use the unified parser
1081
+ return self.__parse_entity_item(object_type, response['items'][0])
1082
+
1083
+ except ApiException as e:
1084
+ logger.warning(f"Could not fetch details for {entity_id}: {e}")
1085
+ return None
1086
+
1087
+ def __get_entity_pid_by_internal_id(self, object_type: str, internal_id: int) -> str:
1088
+ """Reverse lookup: get entity PID (TC-xxx, TR-xxx, etc.) from internal QTest ID.
1089
+
1090
+ Args:
1091
+ object_type: QTest object type ('test-cases', 'test-runs', 'defects', 'requirements')
1092
+ internal_id: Internal QTest ID (numeric)
1093
+
1094
+ Returns:
1095
+ str: Entity PID in format prefix-number (TC-123, TR-456, etc.) or None if not found
1096
+ """
1097
+ search_instance = swagger_client.SearchApi(self._client)
1098
+ # Note: 'id' needs quotes for DQL when searching by internal ID
1099
+ body = swagger_client.ArtifactSearchParams(
1100
+ object_type=object_type,
1101
+ fields=['id', 'pid'],
1102
+ query=f"'id' = '{internal_id}'"
1103
+ )
1104
+
1105
+ try:
1106
+ response = search_instance.search_artifact(self.qtest_project_id, body)
1107
+ if response['total'] > 0:
1108
+ return response['items'][0].get('pid')
1109
+ return None
1110
+ except ApiException as e:
1111
+ logger.warning(f"Could not get PID for {object_type} internal ID {internal_id}: {e}")
1112
+ return None
1113
+
1114
+ def __find_qtest_test_run_id_by_id(self, test_run_id: str) -> int:
1115
+ """Search for test run's internal QTest ID using test run ID (TR-xxx format).
1116
+
1117
+ Args:
1118
+ test_run_id: Test run ID in format TR-123
1119
+
1120
+ Returns:
1121
+ int: Internal QTest ID for the test run
1122
+
1123
+ Raises:
1124
+ ValueError: If test run is not found
1125
+ """
1126
+ return self.__find_qtest_internal_id('test-runs', test_run_id)
1127
+
690
1128
  def __is_jira_requirement_present(self, jira_issue_id: str) -> tuple[bool, dict]:
691
1129
  """ Define if particular Jira requirement is present in qtest or not """
692
1130
  dql = f"'External Id' = '{jira_issue_id}'"
@@ -810,14 +1248,575 @@ class QtestApiWrapper(BaseToolApiWrapper):
810
1248
  f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
811
1249
  ) from e
812
1250
 
1251
+ def find_test_cases_by_requirement_id(self, requirement_id: str, include_details: bool = False) -> dict:
1252
+ """Find all test cases linked to a QTest requirement.
1253
+
1254
+ This method uses the ObjectLinkApi.find() to discover test cases that are
1255
+ linked to a specific requirement. This is the correct way to find linked
1256
+ test cases - DQL queries cannot search test cases by linked requirement.
1257
+
1258
+ Args:
1259
+ requirement_id: QTest requirement ID in format RQ-123
1260
+ include_details: If True, fetches full test case details. If False, returns summary with Id, Name, Description.
1261
+
1262
+ Returns:
1263
+ dict with requirement_id, total count, and test_cases list
1264
+
1265
+ Raises:
1266
+ ValueError: If requirement is not found
1267
+ ToolException: If API call fails
1268
+ """
1269
+ # Get internal QTest ID for the requirement
1270
+ qtest_requirement_id = self.__find_qtest_requirement_id_by_id(requirement_id)
1271
+
1272
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
1273
+
1274
+ try:
1275
+ # Use ObjectLinkApi.find() to get linked artifacts
1276
+ # type='requirements' means we're searching from requirements
1277
+ # ids=[qtest_requirement_id] specifies which requirement(s) to check
1278
+ response = link_object_api_instance.find(
1279
+ self.qtest_project_id,
1280
+ type='requirements',
1281
+ ids=[qtest_requirement_id]
1282
+ )
1283
+
1284
+ # Parse the response to extract linked test cases
1285
+ # Response structure: [{id: req_internal_id, pid: 'RQ-15', objects: [{id: tc_internal_id, pid: 'TC-123'}, ...]}]
1286
+ linked_test_cases = []
1287
+ if response and len(response) > 0:
1288
+ for container in response:
1289
+ # Convert to dict if it's an object
1290
+ container_data = container.to_dict() if hasattr(container, 'to_dict') else container
1291
+ objects = container_data.get('objects', []) if isinstance(container_data, dict) else []
1292
+
1293
+ for obj in objects:
1294
+ obj_data = obj.to_dict() if hasattr(obj, 'to_dict') else obj
1295
+ if isinstance(obj_data, dict):
1296
+ pid = obj_data.get('pid', '')
1297
+ internal_id = obj_data.get('id')
1298
+ if pid and pid.startswith('TC-'):
1299
+ linked_test_cases.append({
1300
+ 'Id': pid,
1301
+ QTEST_ID: internal_id
1302
+ })
1303
+
1304
+ if not linked_test_cases:
1305
+ return {
1306
+ 'requirement_id': requirement_id,
1307
+ 'total': 0,
1308
+ 'test_cases': [],
1309
+ 'message': f"No test cases are linked to requirement '{requirement_id}'"
1310
+ }
1311
+
1312
+ # Build result based on detail level
1313
+ test_cases_result = []
1314
+
1315
+ if not include_details:
1316
+ # Short view: fetch Name, Description via DQL for each test case
1317
+ for tc in linked_test_cases:
1318
+ try:
1319
+ parsed_data = self.__perform_search_by_dql(f"Id = '{tc['Id']}'")
1320
+ if parsed_data:
1321
+ tc_data = parsed_data[0]
1322
+ test_cases_result.append({
1323
+ 'Id': tc['Id'],
1324
+ QTEST_ID: tc[QTEST_ID],
1325
+ 'Name': tc_data.get('Name'),
1326
+ 'Description': tc_data.get('Description', '')
1327
+ })
1328
+ except Exception as e:
1329
+ logger.warning(f"Could not fetch details for {tc['Id']}: {e}")
1330
+ test_cases_result.append({
1331
+ 'Id': tc['Id'],
1332
+ QTEST_ID: tc[QTEST_ID],
1333
+ 'Name': 'Unable to fetch',
1334
+ 'Description': ''
1335
+ })
1336
+ else:
1337
+ # Full details: fetch complete test case data
1338
+ for tc in linked_test_cases:
1339
+ try:
1340
+ parsed_data = self.__perform_search_by_dql(f"Id = '{tc['Id']}'")
1341
+ if parsed_data:
1342
+ test_cases_result.append(parsed_data[0])
1343
+ except Exception as e:
1344
+ logger.warning(f"Could not fetch details for {tc['Id']}: {e}")
1345
+ test_cases_result.append({
1346
+ 'Id': tc['Id'],
1347
+ QTEST_ID: tc[QTEST_ID],
1348
+ 'error': f'Unable to fetch details: {str(e)}'
1349
+ })
1350
+
1351
+ return {
1352
+ 'requirement_id': requirement_id,
1353
+ 'total': len(test_cases_result),
1354
+ 'test_cases': test_cases_result
1355
+ }
1356
+
1357
+ except ApiException as e:
1358
+ stacktrace = format_exc()
1359
+ logger.error(f"Error finding test cases by requirement: {stacktrace}")
1360
+ raise ToolException(
1361
+ f"Unable to find test cases linked to requirement '{requirement_id}' "
1362
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1363
+ ) from e
1364
+
1365
+ def find_requirements_by_test_case_id(self, test_case_id: str) -> dict:
1366
+ """Find all requirements linked to a test case.
1367
+
1368
+ This method uses the ObjectLinkApi.find() to discover requirements that are
1369
+ linked to a specific test case (reverse lookup).
1370
+
1371
+ Args:
1372
+ test_case_id: Test case ID in format TC-123
1373
+
1374
+ Returns:
1375
+ dict with test_case_id, total count, and requirements list
1376
+
1377
+ Raises:
1378
+ ValueError: If test case is not found
1379
+ ToolException: If API call fails
1380
+ """
1381
+ # Get internal QTest ID for the test case
1382
+ qtest_test_case_id = self.__find_qtest_id_by_test_id(test_case_id)
1383
+
1384
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
1385
+
1386
+ try:
1387
+ # Use ObjectLinkApi.find() to get linked artifacts
1388
+ # type='test-cases' means we're searching from test cases
1389
+ response = link_object_api_instance.find(
1390
+ self.qtest_project_id,
1391
+ type='test-cases',
1392
+ ids=[qtest_test_case_id]
1393
+ )
1394
+
1395
+ # Parse the response to extract linked requirement IDs
1396
+ linked_requirement_ids = []
1397
+ if response and len(response) > 0:
1398
+ for container in response:
1399
+ container_data = container.to_dict() if hasattr(container, 'to_dict') else container
1400
+ objects = container_data.get('objects', []) if isinstance(container_data, dict) else []
1401
+
1402
+ for obj in objects:
1403
+ obj_data = obj.to_dict() if hasattr(obj, 'to_dict') else obj
1404
+ if isinstance(obj_data, dict):
1405
+ pid = obj_data.get('pid', '')
1406
+ # Requirements have RQ- prefix
1407
+ if pid and pid.startswith('RQ-'):
1408
+ linked_requirement_ids.append(pid)
1409
+
1410
+ if not linked_requirement_ids:
1411
+ return {
1412
+ 'test_case_id': test_case_id,
1413
+ 'total': 0,
1414
+ 'requirements': [],
1415
+ 'message': f"No requirements are linked to test case '{test_case_id}'"
1416
+ }
1417
+
1418
+ # Fetch actual requirement details via DQL search
1419
+ requirements_result = []
1420
+ for req_id in linked_requirement_ids:
1421
+ req_data = self.__search_entity_by_id('requirements', req_id)
1422
+ if req_data:
1423
+ requirements_result.append(req_data)
1424
+ else:
1425
+ # Fallback if search fails
1426
+ requirements_result.append({
1427
+ 'Id': req_id,
1428
+ 'QTest Id': None,
1429
+ 'Name': 'Unable to fetch',
1430
+ 'Description': ''
1431
+ })
1432
+
1433
+ return {
1434
+ 'test_case_id': test_case_id,
1435
+ 'total': len(requirements_result),
1436
+ 'requirements': requirements_result
1437
+ }
1438
+
1439
+ except ApiException as e:
1440
+ stacktrace = format_exc()
1441
+ logger.error(f"Error finding requirements by test case: {stacktrace}")
1442
+ raise ToolException(
1443
+ f"Unable to find requirements linked to test case '{test_case_id}' "
1444
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1445
+ ) from e
1446
+
1447
+ def find_test_runs_by_test_case_id(self, test_case_id: str) -> dict:
1448
+ """Find all test runs associated with a test case.
1449
+
1450
+ A test run represents an execution instance of a test case. Each test run
1451
+ tracks execution details, status, and any defects found during that run.
1452
+
1453
+ IMPORTANT: In QTest's data model, defects are linked to test runs, not directly
1454
+ to test cases. To find defects related to a test case:
1455
+ 1. Use this tool to find test runs for the test case
1456
+ 2. Use find_defects_by_test_run_id for each test run to get related defects
1457
+
1458
+ Each test run in the result includes 'Test Case Id' showing which test case
1459
+ it executes, and 'Latest Test Log' with execution status and log ID.
1460
+
1461
+ Args:
1462
+ test_case_id: Test case ID in format TC-123
1463
+
1464
+ Returns:
1465
+ dict with test_case_id, total count, and test_runs list with full details
1466
+
1467
+ Raises:
1468
+ ValueError: If test case is not found
1469
+ ToolException: If API call fails
1470
+ """
1471
+ # Get internal QTest ID for the test case
1472
+ qtest_test_case_id = self.__find_qtest_id_by_test_id(test_case_id)
1473
+
1474
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
1475
+
1476
+ try:
1477
+ # Use ObjectLinkApi.find() to get linked artifacts
1478
+ response = link_object_api_instance.find(
1479
+ self.qtest_project_id,
1480
+ type='test-cases',
1481
+ ids=[qtest_test_case_id]
1482
+ )
1483
+
1484
+ # Parse the response to extract linked test run IDs
1485
+ linked_test_run_ids = []
1486
+ if response and len(response) > 0:
1487
+ for container in response:
1488
+ container_data = container.to_dict() if hasattr(container, 'to_dict') else container
1489
+ objects = container_data.get('objects', []) if isinstance(container_data, dict) else []
1490
+
1491
+ for obj in objects:
1492
+ obj_data = obj.to_dict() if hasattr(obj, 'to_dict') else obj
1493
+ if isinstance(obj_data, dict):
1494
+ pid = obj_data.get('pid', '')
1495
+ # Test runs have TR- prefix
1496
+ if pid and pid.startswith('TR-'):
1497
+ linked_test_run_ids.append(pid)
1498
+
1499
+ if not linked_test_run_ids:
1500
+ return {
1501
+ 'test_case_id': test_case_id,
1502
+ 'total': 0,
1503
+ 'test_runs': [],
1504
+ 'message': f"No test runs are associated with test case '{test_case_id}'"
1505
+ }
1506
+
1507
+ # Fetch actual test run details via DQL search
1508
+ test_runs_result = []
1509
+ for tr_id in linked_test_run_ids:
1510
+ tr_data = self.__search_entity_by_id('test-runs', tr_id)
1511
+ if tr_data:
1512
+ test_runs_result.append(tr_data)
1513
+ else:
1514
+ # Fallback if search fails
1515
+ test_runs_result.append({
1516
+ 'Id': tr_id,
1517
+ 'QTest Id': None,
1518
+ 'Name': 'Unable to fetch',
1519
+ 'Description': ''
1520
+ })
1521
+
1522
+ return {
1523
+ 'test_case_id': test_case_id,
1524
+ 'total': len(test_runs_result),
1525
+ 'test_runs': test_runs_result,
1526
+ 'hint': 'To find defects, use find_defects_by_test_run_id for each test run.'
1527
+ }
1528
+
1529
+ except ApiException as e:
1530
+ stacktrace = format_exc()
1531
+ logger.error(f"Error finding test runs by test case: {stacktrace}")
1532
+ raise ToolException(
1533
+ f"Unable to find test runs associated with test case '{test_case_id}' "
1534
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1535
+ ) from e
1536
+
1537
+ def find_defects_by_test_run_id(self, test_run_id: str) -> dict:
1538
+ """Find all defects associated with a test run.
1539
+
1540
+ In QTest, defects are linked to test runs (not directly to test cases).
1541
+ A test run executes a specific test case, so defects found here are
1542
+ related to that test case through the test run execution context.
1543
+
1544
+ Use this tool after find_test_runs_by_test_case_id to discover defects.
1545
+ The result includes source context (test run and test case IDs) for traceability.
1546
+
1547
+ Args:
1548
+ test_run_id: Test run ID in format TR-123
1549
+
1550
+ Returns:
1551
+ dict with test_run_id, source_test_case_id, total count, and defects list with full details
1552
+
1553
+ Raises:
1554
+ ValueError: If test run is not found
1555
+ ToolException: If API call fails
1556
+ """
1557
+ # First, get test run details to get the source test case context
1558
+ test_run_data = self.__search_entity_by_id('test-runs', test_run_id)
1559
+ source_test_case_id = None
1560
+ if test_run_data:
1561
+ # testCaseId is the internal ID, we need the PID (TC-xxx format)
1562
+ internal_tc_id = test_run_data.get('Test Case Id')
1563
+ if internal_tc_id:
1564
+ source_test_case_id = self.__get_entity_pid_by_internal_id('test-cases', internal_tc_id)
1565
+ else:
1566
+ raise ValueError(f"Test run '{test_run_id}' not found")
1567
+
1568
+ # Get internal QTest ID for the test run from test_run_data (avoids duplicate API call)
1569
+ qtest_test_run_id = test_run_data.get('QTest Id')
1570
+ if not qtest_test_run_id:
1571
+ raise ValueError(f"QTest Id not found in test run data for '{test_run_id}'")
1572
+
1573
+ link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
1574
+
1575
+ try:
1576
+ # Use ObjectLinkApi.find() to get linked artifacts
1577
+ response = link_object_api_instance.find(
1578
+ self.qtest_project_id,
1579
+ type='test-runs',
1580
+ ids=[qtest_test_run_id]
1581
+ )
1582
+
1583
+ # Parse the response to extract linked defect IDs
1584
+ linked_defect_ids = []
1585
+ if response and len(response) > 0:
1586
+ for container in response:
1587
+ container_data = container.to_dict() if hasattr(container, 'to_dict') else container
1588
+ objects = container_data.get('objects', []) if isinstance(container_data, dict) else []
1589
+
1590
+ for obj in objects:
1591
+ obj_data = obj.to_dict() if hasattr(obj, 'to_dict') else obj
1592
+ if isinstance(obj_data, dict):
1593
+ pid = obj_data.get('pid', '')
1594
+ # Defects have DF- prefix
1595
+ if pid and pid.startswith('DF-'):
1596
+ linked_defect_ids.append(pid)
1597
+
1598
+ if not linked_defect_ids:
1599
+ result = {
1600
+ 'test_run_id': test_run_id,
1601
+ 'total': 0,
1602
+ 'defects': [],
1603
+ 'message': f"No defects are associated with test run '{test_run_id}'"
1604
+ }
1605
+ if source_test_case_id:
1606
+ result['source_test_case_id'] = source_test_case_id
1607
+ return result
1608
+
1609
+ # Fetch actual defect details via DQL search
1610
+ defects_result = []
1611
+ for defect_id in linked_defect_ids:
1612
+ defect_data = self.__search_entity_by_id('defects', defect_id)
1613
+ if defect_data:
1614
+ defects_result.append(defect_data)
1615
+ else:
1616
+ # Fallback if search fails
1617
+ defects_result.append({
1618
+ 'Id': defect_id,
1619
+ 'QTest Id': None,
1620
+ 'Name': 'Unable to fetch',
1621
+ 'Description': ''
1622
+ })
1623
+
1624
+ result = {
1625
+ 'test_run_id': test_run_id,
1626
+ 'total': len(defects_result),
1627
+ 'defects': defects_result
1628
+ }
1629
+ if source_test_case_id:
1630
+ result['source_test_case_id'] = source_test_case_id
1631
+ return result
1632
+
1633
+ except ApiException as e:
1634
+ stacktrace = format_exc()
1635
+ logger.error(f"Error finding defects by test run: {stacktrace}")
1636
+ raise ToolException(
1637
+ f"Unable to find defects associated with test run '{test_run_id}' "
1638
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1639
+ ) from e
1640
+
813
1641
  def search_by_dql(self, dql: str, extract_images:bool=False, prompt: str=None):
814
1642
  """Search for the test cases in qTest using Data Query Language """
815
1643
  parsed_data = self.__perform_search_by_dql(dql, extract_images, prompt)
816
1644
  return "Found " + str(
817
1645
  len(parsed_data)) + f" Qtest test cases:\n" + str(parsed_data[:self.no_of_tests_shown_in_dql_search])
818
1646
 
1647
+ def search_entities_by_dql(self, object_type: str, dql: str) -> dict:
1648
+ """Generic DQL search for any entity type (test-cases, requirements, defects, test-runs, etc.).
1649
+
1650
+ This is the unified search method that works for all QTest searchable entity types.
1651
+ Each entity type has its own properties structure, but this method parses
1652
+ them consistently using the generic entity parser.
1653
+
1654
+ Args:
1655
+ object_type: Entity type to search (see QTEST_OBJECT_TYPES and QTEST_SEARCHABLE_ONLY_TYPES)
1656
+ dql: QTest Data Query Language query string
1657
+
1658
+ Returns:
1659
+ dict with object_type, total count, and items list with full entity details
1660
+ """
1661
+ # Check if object_type is valid (either has prefix or is searchable-only)
1662
+ all_searchable = {**QTEST_OBJECT_TYPES, **QTEST_SEARCHABLE_ONLY_TYPES}
1663
+ if object_type not in all_searchable:
1664
+ raise ValueError(
1665
+ f"Invalid object_type '{object_type}'. "
1666
+ f"Must be one of: {', '.join(all_searchable.keys())}"
1667
+ )
1668
+
1669
+ entity_info = all_searchable[object_type]
1670
+ search_instance = swagger_client.SearchApi(self._client)
1671
+ body = swagger_client.ArtifactSearchParams(
1672
+ object_type=object_type,
1673
+ fields=['*'],
1674
+ query=dql
1675
+ )
1676
+
1677
+ try:
1678
+ response = search_instance.search_artifact(self.qtest_project_id, body)
1679
+
1680
+ # Parse all items using the generic parser
1681
+ items = []
1682
+ for item in response.get('items', []):
1683
+ parsed = self.__parse_entity_item(object_type, item)
1684
+ items.append(parsed)
1685
+
1686
+ return {
1687
+ 'object_type': object_type,
1688
+ 'entity_name': entity_info['name'],
1689
+ 'total': response.get('total', 0),
1690
+ 'returned': len(items),
1691
+ 'items': items[:self.no_of_tests_shown_in_dql_search]
1692
+ }
1693
+
1694
+ except ApiException as e:
1695
+ stacktrace = format_exc()
1696
+ logger.error(f"Error searching {object_type} by DQL: {stacktrace}")
1697
+ raise ToolException(
1698
+ f"Unable to search {entity_info['name']}s with DQL '{dql}' "
1699
+ f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
1700
+ ) from e
1701
+
1702
+ def find_entity_by_id(self, entity_id: str) -> dict:
1703
+ """Find any QTest entity by its ID (TC-xxx, RQ-xxx, DF-xxx, TR-xxx).
1704
+
1705
+ This is a universal lookup tool that works for any entity type.
1706
+ The entity type is automatically determined from the ID prefix.
1707
+
1708
+ Args:
1709
+ entity_id: Entity ID with prefix (TC-123, RQ-15, DF-100, TR-39, etc.)
1710
+
1711
+ Returns:
1712
+ dict with full entity details including all properties
1713
+ """
1714
+ # Determine object type from prefix - dynamically built from registry
1715
+ prefix = entity_id.split('-')[0].upper() if '-' in entity_id else ''
1716
+
1717
+ # Build reverse mapping: prefix -> object_type from QTEST_OBJECT_TYPES
1718
+ prefix_to_type = {
1719
+ info['prefix']: obj_type
1720
+ for obj_type, info in QTEST_OBJECT_TYPES.items()
1721
+ }
1722
+
1723
+ if prefix not in prefix_to_type:
1724
+ valid_prefixes = ', '.join(sorted(prefix_to_type.keys()))
1725
+ raise ValueError(
1726
+ f"Invalid entity ID format '{entity_id}'. "
1727
+ f"Expected prefix to be one of: {valid_prefixes}"
1728
+ )
1729
+
1730
+ object_type = prefix_to_type[prefix]
1731
+ result = self.__search_entity_by_id(object_type, entity_id)
1732
+
1733
+ if result is None:
1734
+ entity_name = QTEST_OBJECT_TYPES[object_type]['name']
1735
+ raise ValueError(
1736
+ f"{entity_name} '{entity_id}' not found in project {self.qtest_project_id}"
1737
+ )
1738
+
1739
+ return result
1740
+
1741
+ def __parse_entity_item(self, object_type: str, item: dict) -> dict:
1742
+ """Generic parser for any entity type from DQL search response.
1743
+
1744
+ This parses the raw API response item into a clean dictionary,
1745
+ handling the differences between entity types (some have name at top level,
1746
+ some have it in properties as Summary, etc.)
1747
+
1748
+ Args:
1749
+ object_type: QTest object type
1750
+ item: Raw item from search response
1751
+
1752
+ Returns:
1753
+ dict with parsed entity data
1754
+ """
1755
+ import html
1756
+
1757
+ result = {
1758
+ 'Id': item.get('pid'),
1759
+ 'QTest Id': item.get('id'),
1760
+ }
1761
+
1762
+ # Add top-level fields if present
1763
+ if item.get('name'):
1764
+ result['Name'] = item.get('name')
1765
+ if item.get('description'):
1766
+ result['Description'] = html.unescape(strip_tags(item.get('description', '') or ''))
1767
+ if item.get('web_url'):
1768
+ result['Web URL'] = item.get('web_url')
1769
+
1770
+ # Test-case specific fields
1771
+ if object_type == 'test-cases':
1772
+ if item.get('precondition'):
1773
+ result['Precondition'] = html.unescape(strip_tags(item.get('precondition', '') or ''))
1774
+ if item.get('test_steps'):
1775
+ result['Steps'] = [
1776
+ {
1777
+ 'Test Step Number': idx + 1,
1778
+ 'Test Step Description': html.unescape(strip_tags(step.get('description', '') or '')),
1779
+ 'Test Step Expected Result': html.unescape(strip_tags(step.get('expected', '') or ''))
1780
+ }
1781
+ for idx, step in enumerate(item.get('test_steps', []))
1782
+ ]
1783
+
1784
+ # Test-run specific fields
1785
+ if object_type == 'test-runs':
1786
+ if item.get('testCaseId'):
1787
+ result['Test Case Id'] = item.get('testCaseId')
1788
+ if item.get('automation'):
1789
+ result['Automation'] = item.get('automation')
1790
+ if item.get('latest_test_log'):
1791
+ log = item.get('latest_test_log')
1792
+ result['Latest Test Log'] = {
1793
+ 'Log Id': log.get('id'),
1794
+ 'Status': log.get('status'),
1795
+ 'Execution Start': log.get('exe_start_date'),
1796
+ 'Execution End': log.get('exe_end_date')
1797
+ }
1798
+ if item.get('test_case_version'):
1799
+ result['Test Case Version'] = item.get('test_case_version')
1800
+
1801
+ # Parse all properties - works for all entity types
1802
+ for prop in item.get('properties', []):
1803
+ field_name = prop.get('field_name')
1804
+ if not field_name:
1805
+ continue
1806
+
1807
+ # Format value based on field type (multi-select as array, etc.)
1808
+ field_value = self.__format_property_value(prop)
1809
+
1810
+ # Strip HTML from text fields (strings only, not arrays)
1811
+ if isinstance(field_value, str) and ('<' in field_value or '&' in field_value):
1812
+ field_value = html.unescape(strip_tags(field_value))
1813
+
1814
+ result[field_name] = field_value
1815
+
1816
+ return result
1817
+
819
1818
  def create_test_cases(self, test_case_content: str, folder_to_place_test_cases_to: str) -> dict:
820
- """ Create the tes case base on the incoming content. The input should be in json format. """
1819
+ """ Create the test case based on the incoming content. The input should be in json format. """
821
1820
  test_cases_api_instance: TestCaseApi = self.__instantiate_test_api_instance()
822
1821
  input_obj = json.loads(test_case_content)
823
1822
  test_cases = input_obj if isinstance(input_obj, list) else [input_obj]
@@ -897,7 +1896,37 @@ class QtestApiWrapper(BaseToolApiWrapper):
897
1896
  {
898
1897
  "name": "search_by_dql",
899
1898
  "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',
1899
+ "description": """Search test cases in qTest using Data Query Language (DQL).
1900
+
1901
+ CRITICAL: USE SINGLE QUOTES ONLY - DQL does not support double quotes!
1902
+ - ✓ CORRECT: Description ~ 'Forgot Password'
1903
+ - ✗ WRONG: Description ~ "Forgot Password"
1904
+
1905
+ LIMITATION - CANNOT SEARCH BY LINKED OBJECTS:
1906
+ - ✗ 'Requirement Id' = 'RQ-15' will fail - use 'find_test_cases_by_requirement_id' tool instead
1907
+ - ✗ Linked defects or other relationship queries are not supported
1908
+
1909
+ SEARCHABLE FIELDS:
1910
+ - Direct fields: Id, Name, Description, Status, Type, Priority, Automation, etc.
1911
+ - Module: Use 'Module in' syntax
1912
+ - Custom fields: Use exact field name from project configuration
1913
+ - Date fields: MUST use ISO DateTime format (e.g., '2024-01-01T00:00:00.000Z')
1914
+
1915
+ SYNTAX RULES:
1916
+ 1. ALL string values MUST use single quotes (never double quotes)
1917
+ 2. Field names with spaces MUST be in single quotes: 'Created Date' > '2024-01-01T00:00:00.000Z'
1918
+ 3. Use ~ for 'contains', !~ for 'not contains': Description ~ 'login'
1919
+ 4. Use 'is not empty' for non-empty check: Name is 'not empty'
1920
+ 5. Operators: =, !=, <, >, <=, >=, in, ~, !~
1921
+
1922
+ EXAMPLES:
1923
+ - Id = 'TC-123'
1924
+ - Description ~ 'Forgot Password'
1925
+ - Status = 'New' and Priority = 'High'
1926
+ - Module in 'MD-78 Master Test Suite'
1927
+ - Name ~ 'login'
1928
+ - 'Created Date' > '2024-01-01T00:00:00.000Z'
1929
+ """,
901
1930
  "args_schema": QtestDataQuerySearch,
902
1931
  "ref": self.search_by_dql,
903
1932
  },
@@ -956,5 +1985,160 @@ class QtestApiWrapper(BaseToolApiWrapper):
956
1985
  "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
1986
  "args_schema": GetAllTestCasesFieldsForProject,
958
1987
  "ref": self.get_all_test_cases_fields_for_project,
1988
+ },
1989
+ {
1990
+ "name": "find_test_cases_by_requirement_id",
1991
+ "mode": "find_test_cases_by_requirement_id",
1992
+ "description": """Find all test cases linked to a QTest requirement.
1993
+
1994
+ Use this tool to find test cases associated with a specific requirement.
1995
+ DQL search cannot query by linked requirement - use this tool instead.
1996
+
1997
+ Parameters:
1998
+ - requirement_id: QTest requirement ID in format RQ-123
1999
+ - include_details: If true, returns full test case data. If false (default), returns Id, QTest Id, Name, and Description.
2000
+
2001
+ Examples:
2002
+ - Find test cases for RQ-15: requirement_id='RQ-15'
2003
+ - Get full details: requirement_id='RQ-15', include_details=true
2004
+ """,
2005
+ "args_schema": FindTestCasesByRequirementId,
2006
+ "ref": self.find_test_cases_by_requirement_id,
2007
+ },
2008
+ {
2009
+ "name": "find_requirements_by_test_case_id",
2010
+ "mode": "find_requirements_by_test_case_id",
2011
+ "description": """Find all requirements linked to a test case (direct link: test-case 'covers' requirements).
2012
+
2013
+ Use this tool to discover which requirements a specific test case covers.
2014
+
2015
+ Parameters:
2016
+ - test_case_id: Test case ID in format TC-123
2017
+
2018
+ Returns: List of linked requirements with Id, QTest Id, Name, and Description.
2019
+
2020
+ Examples:
2021
+ - Find requirements for TC-123: test_case_id='TC-123'
2022
+ """,
2023
+ "args_schema": FindRequirementsByTestCaseId,
2024
+ "ref": self.find_requirements_by_test_case_id,
2025
+ },
2026
+ {
2027
+ "name": "find_test_runs_by_test_case_id",
2028
+ "mode": "find_test_runs_by_test_case_id",
2029
+ "description": """Find all test runs associated with a test case.
2030
+
2031
+ IMPORTANT: In QTest, defects are NOT directly linked to test cases.
2032
+ Defects are linked to TEST RUNS. To find defects related to a test case:
2033
+ 1. First use this tool to find test runs for the test case
2034
+ 2. Then use find_defects_by_test_run_id for each test run
2035
+
2036
+ Parameters:
2037
+ - test_case_id: Test case ID in format TC-123
2038
+
2039
+ Returns: List of test runs with Id, QTest Id, Name, and Description.
2040
+ Also includes a hint about finding defects via test runs.
2041
+
2042
+ Examples:
2043
+ - Find test runs for TC-123: test_case_id='TC-123'
2044
+ """,
2045
+ "args_schema": FindTestRunsByTestCaseId,
2046
+ "ref": self.find_test_runs_by_test_case_id,
2047
+ },
2048
+ {
2049
+ "name": "find_defects_by_test_run_id",
2050
+ "mode": "find_defects_by_test_run_id",
2051
+ "description": """Find all defects associated with a test run.
2052
+
2053
+ In QTest data model, defects are linked to test runs (not directly to test cases).
2054
+ A defect found here means it was reported during execution of this specific test run.
2055
+
2056
+ To find defects related to a test case:
2057
+ 1. First use find_test_runs_by_test_case_id to get test runs
2058
+ 2. Then use this tool for each test run
2059
+
2060
+ Parameters:
2061
+ - test_run_id: Test run ID in format TR-123
2062
+
2063
+ Returns: List of defects with Id, QTest Id, Name, and Description.
2064
+
2065
+ Examples:
2066
+ - Find defects for TR-39: test_run_id='TR-39'
2067
+ """,
2068
+ "args_schema": FindDefectsByTestRunId,
2069
+ "ref": self.find_defects_by_test_run_id,
2070
+ },
2071
+ {
2072
+ "name": "search_entities_by_dql",
2073
+ "mode": "search_entities_by_dql",
2074
+ "description": f"""Search any QTest entity type using Data Query Language (DQL).
2075
+
2076
+ This is a unified search tool for all searchable QTest entity types.
2077
+
2078
+ SUPPORTED ENTITY TYPES (object_type parameter):
2079
+ - 'test-cases' (TC-xxx): Test case definitions with steps
2080
+ - 'test-runs' (TR-xxx): Execution instances of test cases
2081
+ - 'defects' (DF-xxx): Bugs/issues found during testing
2082
+ - 'requirements' (RQ-xxx): Requirements to be tested
2083
+ - 'test-suites' (TS-xxx): Collections of test runs
2084
+ - 'test-cycles' (CL-xxx): Test execution cycles
2085
+ - 'test-logs': Execution logs (date queries ONLY - see notes)
2086
+ - 'releases' (RL-xxx): Software releases
2087
+ - 'builds' (BL-xxx): Builds within releases
2088
+
2089
+ NOTES:
2090
+ - Modules (MD-xxx) are NOT searchable via DQL. Use 'get_modules' tool instead.
2091
+ - Test-logs: Only date queries work (Execution Start Date, Execution End Date).
2092
+ For specific test log details, use find_test_runs_by_test_case_id -
2093
+ the test run includes 'Latest Test Log' with status and execution times.
2094
+
2095
+ {DQL_SYNTAX_DOCS}
2096
+
2097
+ EXAMPLES BY ENTITY TYPE:
2098
+ - Test cases: object_type='test-cases', dql="Name ~ 'login'"
2099
+ - Requirements: object_type='requirements', dql="Status = 'Baselined'"
2100
+ - Defects: object_type='defects', dql="Priority = 'High'"
2101
+ - Test runs: object_type='test-runs', dql="Status = 'Failed'"
2102
+ - Test logs: object_type='test-logs', dql="'Execution Start Date' > '2024-01-01T00:00:00.000Z'" (date queries only)
2103
+ - Releases: object_type='releases', dql="Name ~ '2024'"
2104
+ """,
2105
+ "args_schema": GenericDqlSearch,
2106
+ "ref": self.search_entities_by_dql,
2107
+ },
2108
+ {
2109
+ "name": "find_entity_by_id",
2110
+ "mode": "find_entity_by_id",
2111
+ "description": """Find any QTest entity by its ID.
2112
+
2113
+ This universal lookup tool works for entity types that have ID prefixes.
2114
+ The entity type is automatically determined from the ID prefix.
2115
+
2116
+ SUPPORTED ID FORMATS:
2117
+ - TC-123: Test Case
2118
+ - TR-39: Test Run
2119
+ - DF-100: Defect
2120
+ - RQ-15: Requirement
2121
+ - TS-5: Test Suite
2122
+ - CL-3: Test Cycle
2123
+ - RL-1: Release
2124
+ - BL-2: Build
2125
+
2126
+ NOT SUPPORTED (no ID prefix):
2127
+ - Test Logs: Get details from test run's 'Latest Test Log' field (contains Log Id, Status, Execution Start/End Date)
2128
+ - Modules: Use 'get_modules' tool instead
2129
+
2130
+ Parameters:
2131
+ - entity_id: Entity ID with prefix (e.g., TC-123, RQ-15, DF-100, TR-39)
2132
+
2133
+ Returns: Full entity details including all properties.
2134
+
2135
+ Examples:
2136
+ - Find test case: entity_id='TC-123'
2137
+ - Find requirement: entity_id='RQ-15'
2138
+ - Find defect: entity_id='DF-100'
2139
+ - Find test run: entity_id='TR-39'
2140
+ """,
2141
+ "args_schema": FindEntityById,
2142
+ "ref": self.find_entity_by_id,
959
2143
  }
960
2144
  ]