alita-sdk 0.3.257__py3-none-any.whl → 0.3.584__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.
- alita_sdk/cli/__init__.py +10 -0
- alita_sdk/cli/__main__.py +17 -0
- alita_sdk/cli/agent/__init__.py +5 -0
- alita_sdk/cli/agent/default.py +258 -0
- alita_sdk/cli/agent_executor.py +155 -0
- alita_sdk/cli/agent_loader.py +215 -0
- alita_sdk/cli/agent_ui.py +228 -0
- alita_sdk/cli/agents.py +3794 -0
- alita_sdk/cli/callbacks.py +647 -0
- alita_sdk/cli/cli.py +168 -0
- alita_sdk/cli/config.py +306 -0
- alita_sdk/cli/context/__init__.py +30 -0
- alita_sdk/cli/context/cleanup.py +198 -0
- alita_sdk/cli/context/manager.py +731 -0
- alita_sdk/cli/context/message.py +285 -0
- alita_sdk/cli/context/strategies.py +289 -0
- alita_sdk/cli/context/token_estimation.py +127 -0
- alita_sdk/cli/formatting.py +182 -0
- alita_sdk/cli/input_handler.py +419 -0
- alita_sdk/cli/inventory.py +1073 -0
- alita_sdk/cli/mcp_loader.py +315 -0
- alita_sdk/cli/toolkit.py +327 -0
- alita_sdk/cli/toolkit_loader.py +85 -0
- alita_sdk/cli/tools/__init__.py +43 -0
- alita_sdk/cli/tools/approval.py +224 -0
- alita_sdk/cli/tools/filesystem.py +1751 -0
- alita_sdk/cli/tools/planning.py +389 -0
- alita_sdk/cli/tools/terminal.py +414 -0
- alita_sdk/community/__init__.py +72 -12
- alita_sdk/community/inventory/__init__.py +236 -0
- alita_sdk/community/inventory/config.py +257 -0
- alita_sdk/community/inventory/enrichment.py +2137 -0
- alita_sdk/community/inventory/extractors.py +1469 -0
- alita_sdk/community/inventory/ingestion.py +3172 -0
- alita_sdk/community/inventory/knowledge_graph.py +1457 -0
- alita_sdk/community/inventory/parsers/__init__.py +218 -0
- alita_sdk/community/inventory/parsers/base.py +295 -0
- alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
- alita_sdk/community/inventory/parsers/go_parser.py +851 -0
- alita_sdk/community/inventory/parsers/html_parser.py +389 -0
- alita_sdk/community/inventory/parsers/java_parser.py +593 -0
- alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
- alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
- alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
- alita_sdk/community/inventory/parsers/python_parser.py +604 -0
- alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
- alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
- alita_sdk/community/inventory/parsers/text_parser.py +322 -0
- alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
- alita_sdk/community/inventory/patterns/__init__.py +61 -0
- alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
- alita_sdk/community/inventory/patterns/loader.py +348 -0
- alita_sdk/community/inventory/patterns/registry.py +198 -0
- alita_sdk/community/inventory/presets.py +535 -0
- alita_sdk/community/inventory/retrieval.py +1403 -0
- alita_sdk/community/inventory/toolkit.py +173 -0
- alita_sdk/community/inventory/toolkit_utils.py +176 -0
- alita_sdk/community/inventory/visualize.py +1370 -0
- alita_sdk/configurations/__init__.py +11 -0
- alita_sdk/configurations/ado.py +148 -2
- alita_sdk/configurations/azure_search.py +1 -1
- alita_sdk/configurations/bigquery.py +1 -1
- alita_sdk/configurations/bitbucket.py +94 -2
- alita_sdk/configurations/browser.py +18 -0
- alita_sdk/configurations/carrier.py +19 -0
- alita_sdk/configurations/confluence.py +130 -1
- alita_sdk/configurations/delta_lake.py +1 -1
- alita_sdk/configurations/figma.py +76 -5
- alita_sdk/configurations/github.py +65 -1
- alita_sdk/configurations/gitlab.py +81 -0
- alita_sdk/configurations/google_places.py +17 -0
- alita_sdk/configurations/jira.py +103 -0
- alita_sdk/configurations/openapi.py +323 -0
- alita_sdk/configurations/postman.py +1 -1
- alita_sdk/configurations/qtest.py +72 -3
- alita_sdk/configurations/report_portal.py +115 -0
- alita_sdk/configurations/salesforce.py +19 -0
- alita_sdk/configurations/service_now.py +1 -12
- alita_sdk/configurations/sharepoint.py +167 -0
- alita_sdk/configurations/sonar.py +18 -0
- alita_sdk/configurations/sql.py +20 -0
- alita_sdk/configurations/testio.py +101 -0
- alita_sdk/configurations/testrail.py +88 -0
- alita_sdk/configurations/xray.py +94 -1
- alita_sdk/configurations/zephyr_enterprise.py +94 -1
- alita_sdk/configurations/zephyr_essential.py +95 -0
- alita_sdk/runtime/clients/artifact.py +21 -4
- alita_sdk/runtime/clients/client.py +458 -67
- alita_sdk/runtime/clients/mcp_discovery.py +342 -0
- alita_sdk/runtime/clients/mcp_manager.py +262 -0
- alita_sdk/runtime/clients/sandbox_client.py +352 -0
- alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
- alita_sdk/runtime/langchain/assistant.py +183 -43
- alita_sdk/runtime/langchain/constants.py +647 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
- alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
- alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -3
- alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
- alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
- alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
- alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
- alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
- alita_sdk/runtime/langchain/document_loaders/constants.py +189 -41
- alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
- alita_sdk/runtime/langchain/langraph_agent.py +493 -105
- alita_sdk/runtime/langchain/utils.py +118 -8
- alita_sdk/runtime/llms/preloaded.py +2 -6
- alita_sdk/runtime/models/mcp_models.py +61 -0
- alita_sdk/runtime/skills/__init__.py +91 -0
- alita_sdk/runtime/skills/callbacks.py +498 -0
- alita_sdk/runtime/skills/discovery.py +540 -0
- alita_sdk/runtime/skills/executor.py +610 -0
- alita_sdk/runtime/skills/input_builder.py +371 -0
- alita_sdk/runtime/skills/models.py +330 -0
- alita_sdk/runtime/skills/registry.py +355 -0
- alita_sdk/runtime/skills/skill_runner.py +330 -0
- alita_sdk/runtime/toolkits/__init__.py +28 -0
- alita_sdk/runtime/toolkits/application.py +14 -4
- alita_sdk/runtime/toolkits/artifact.py +25 -9
- alita_sdk/runtime/toolkits/datasource.py +13 -6
- alita_sdk/runtime/toolkits/mcp.py +782 -0
- alita_sdk/runtime/toolkits/planning.py +178 -0
- alita_sdk/runtime/toolkits/skill_router.py +238 -0
- alita_sdk/runtime/toolkits/subgraph.py +11 -6
- alita_sdk/runtime/toolkits/tools.py +314 -70
- alita_sdk/runtime/toolkits/vectorstore.py +11 -5
- alita_sdk/runtime/tools/__init__.py +24 -0
- alita_sdk/runtime/tools/application.py +16 -4
- alita_sdk/runtime/tools/artifact.py +367 -33
- alita_sdk/runtime/tools/data_analysis.py +183 -0
- alita_sdk/runtime/tools/function.py +100 -4
- alita_sdk/runtime/tools/graph.py +81 -0
- alita_sdk/runtime/tools/image_generation.py +218 -0
- alita_sdk/runtime/tools/llm.py +1032 -177
- alita_sdk/runtime/tools/loop.py +3 -1
- alita_sdk/runtime/tools/loop_output.py +3 -1
- alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
- alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
- alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
- alita_sdk/runtime/tools/planning/__init__.py +36 -0
- alita_sdk/runtime/tools/planning/models.py +246 -0
- alita_sdk/runtime/tools/planning/wrapper.py +607 -0
- alita_sdk/runtime/tools/router.py +2 -1
- alita_sdk/runtime/tools/sandbox.py +375 -0
- alita_sdk/runtime/tools/skill_router.py +776 -0
- alita_sdk/runtime/tools/tool.py +3 -1
- alita_sdk/runtime/tools/vectorstore.py +69 -65
- alita_sdk/runtime/tools/vectorstore_base.py +163 -90
- alita_sdk/runtime/utils/AlitaCallback.py +137 -21
- alita_sdk/runtime/utils/constants.py +5 -1
- alita_sdk/runtime/utils/mcp_client.py +492 -0
- alita_sdk/runtime/utils/mcp_oauth.py +361 -0
- alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
- alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
- alita_sdk/runtime/utils/streamlit.py +41 -14
- alita_sdk/runtime/utils/toolkit_utils.py +28 -9
- alita_sdk/runtime/utils/utils.py +48 -0
- alita_sdk/tools/__init__.py +135 -37
- alita_sdk/tools/ado/__init__.py +2 -2
- alita_sdk/tools/ado/repos/__init__.py +16 -19
- alita_sdk/tools/ado/repos/repos_wrapper.py +12 -20
- alita_sdk/tools/ado/test_plan/__init__.py +27 -8
- alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -28
- alita_sdk/tools/ado/wiki/__init__.py +28 -12
- alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -40
- alita_sdk/tools/ado/work_item/__init__.py +28 -12
- alita_sdk/tools/ado/work_item/ado_wrapper.py +95 -11
- alita_sdk/tools/advanced_jira_mining/__init__.py +13 -8
- alita_sdk/tools/aws/delta_lake/__init__.py +15 -11
- alita_sdk/tools/aws/delta_lake/tool.py +5 -1
- alita_sdk/tools/azure_ai/search/__init__.py +14 -8
- alita_sdk/tools/base/tool.py +5 -1
- alita_sdk/tools/base_indexer_toolkit.py +454 -110
- alita_sdk/tools/bitbucket/__init__.py +28 -19
- alita_sdk/tools/bitbucket/api_wrapper.py +285 -27
- alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
- alita_sdk/tools/browser/__init__.py +41 -16
- alita_sdk/tools/browser/crawler.py +3 -1
- alita_sdk/tools/browser/utils.py +15 -6
- alita_sdk/tools/carrier/__init__.py +18 -17
- alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
- alita_sdk/tools/carrier/excel_reporter.py +8 -4
- alita_sdk/tools/chunkers/__init__.py +3 -1
- alita_sdk/tools/chunkers/code/codeparser.py +1 -1
- alita_sdk/tools/chunkers/sematic/json_chunker.py +2 -1
- alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
- alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
- alita_sdk/tools/chunkers/universal_chunker.py +270 -0
- alita_sdk/tools/cloud/aws/__init__.py +12 -7
- alita_sdk/tools/cloud/azure/__init__.py +12 -7
- alita_sdk/tools/cloud/gcp/__init__.py +12 -7
- alita_sdk/tools/cloud/k8s/__init__.py +12 -7
- alita_sdk/tools/code/linter/__init__.py +10 -8
- alita_sdk/tools/code/loaders/codesearcher.py +3 -2
- alita_sdk/tools/code/sonar/__init__.py +21 -13
- alita_sdk/tools/code_indexer_toolkit.py +199 -0
- alita_sdk/tools/confluence/__init__.py +22 -14
- alita_sdk/tools/confluence/api_wrapper.py +197 -58
- alita_sdk/tools/confluence/loader.py +14 -2
- alita_sdk/tools/custom_open_api/__init__.py +12 -5
- alita_sdk/tools/elastic/__init__.py +11 -8
- alita_sdk/tools/elitea_base.py +546 -64
- alita_sdk/tools/figma/__init__.py +60 -11
- alita_sdk/tools/figma/api_wrapper.py +1400 -167
- alita_sdk/tools/figma/figma_client.py +73 -0
- alita_sdk/tools/figma/toon_tools.py +2748 -0
- alita_sdk/tools/github/__init__.py +18 -17
- alita_sdk/tools/github/api_wrapper.py +9 -26
- alita_sdk/tools/github/github_client.py +81 -12
- alita_sdk/tools/github/schemas.py +2 -1
- alita_sdk/tools/github/tool.py +5 -1
- alita_sdk/tools/gitlab/__init__.py +19 -13
- alita_sdk/tools/gitlab/api_wrapper.py +256 -80
- alita_sdk/tools/gitlab_org/__init__.py +14 -10
- alita_sdk/tools/google/bigquery/__init__.py +14 -13
- alita_sdk/tools/google/bigquery/tool.py +5 -1
- alita_sdk/tools/google_places/__init__.py +21 -11
- alita_sdk/tools/jira/__init__.py +22 -11
- alita_sdk/tools/jira/api_wrapper.py +315 -168
- alita_sdk/tools/keycloak/__init__.py +11 -8
- alita_sdk/tools/localgit/__init__.py +9 -3
- alita_sdk/tools/localgit/local_git.py +62 -54
- alita_sdk/tools/localgit/tool.py +5 -1
- alita_sdk/tools/memory/__init__.py +38 -14
- alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
- alita_sdk/tools/ocr/__init__.py +11 -8
- alita_sdk/tools/openapi/__init__.py +491 -106
- alita_sdk/tools/openapi/api_wrapper.py +1357 -0
- alita_sdk/tools/openapi/tool.py +20 -0
- alita_sdk/tools/pandas/__init__.py +20 -12
- alita_sdk/tools/pandas/api_wrapper.py +40 -45
- alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
- alita_sdk/tools/postman/__init__.py +11 -11
- alita_sdk/tools/postman/api_wrapper.py +19 -8
- alita_sdk/tools/postman/postman_analysis.py +8 -1
- alita_sdk/tools/pptx/__init__.py +11 -10
- alita_sdk/tools/qtest/__init__.py +22 -14
- alita_sdk/tools/qtest/api_wrapper.py +1784 -88
- alita_sdk/tools/rally/__init__.py +13 -10
- alita_sdk/tools/report_portal/__init__.py +23 -16
- alita_sdk/tools/salesforce/__init__.py +22 -16
- alita_sdk/tools/servicenow/__init__.py +21 -16
- alita_sdk/tools/servicenow/api_wrapper.py +1 -1
- alita_sdk/tools/sharepoint/__init__.py +17 -14
- alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
- alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
- alita_sdk/tools/sharepoint/utils.py +8 -2
- alita_sdk/tools/slack/__init__.py +13 -8
- alita_sdk/tools/sql/__init__.py +22 -19
- alita_sdk/tools/sql/api_wrapper.py +71 -23
- alita_sdk/tools/testio/__init__.py +21 -13
- alita_sdk/tools/testrail/__init__.py +13 -11
- alita_sdk/tools/testrail/api_wrapper.py +214 -46
- alita_sdk/tools/utils/__init__.py +28 -4
- alita_sdk/tools/utils/content_parser.py +241 -55
- alita_sdk/tools/utils/text_operations.py +254 -0
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
- alita_sdk/tools/xray/__init__.py +18 -14
- alita_sdk/tools/xray/api_wrapper.py +58 -113
- alita_sdk/tools/yagmail/__init__.py +9 -3
- alita_sdk/tools/zephyr/__init__.py +12 -7
- alita_sdk/tools/zephyr_enterprise/__init__.py +16 -9
- alita_sdk/tools/zephyr_enterprise/api_wrapper.py +30 -15
- alita_sdk/tools/zephyr_essential/__init__.py +16 -10
- alita_sdk/tools/zephyr_essential/api_wrapper.py +297 -54
- alita_sdk/tools/zephyr_essential/client.py +6 -4
- alita_sdk/tools/zephyr_scale/__init__.py +13 -8
- alita_sdk/tools/zephyr_scale/api_wrapper.py +39 -31
- alita_sdk/tools/zephyr_squad/__init__.py +12 -7
- {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.584.dist-info}/METADATA +184 -37
- alita_sdk-0.3.584.dist-info/RECORD +452 -0
- alita_sdk-0.3.584.dist-info/entry_points.txt +2 -0
- alita_sdk/tools/bitbucket/tools.py +0 -304
- alita_sdk-0.3.257.dist-info/RECORD +0 -343
- {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.584.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.584.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.584.dist-info}/top_level.txt +0 -0
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
4
|
+
import re
|
|
3
5
|
from traceback import format_exc
|
|
4
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Optional
|
|
5
7
|
|
|
8
|
+
import requests
|
|
6
9
|
import swagger_client
|
|
7
10
|
from langchain_core.tools import ToolException
|
|
8
11
|
from pydantic import Field, PrivateAttr, model_validator, create_model, SecretStr
|
|
9
12
|
from sklearn.feature_extraction.text import strip_tags
|
|
10
|
-
from swagger_client import TestCaseApi, SearchApi, PropertyResource
|
|
13
|
+
from swagger_client import TestCaseApi, SearchApi, PropertyResource, ModuleApi, ProjectApi, FieldApi
|
|
11
14
|
from swagger_client.rest import ApiException
|
|
12
15
|
|
|
13
16
|
from ..elitea_base import BaseToolApiWrapper
|
|
17
|
+
from ..utils.content_parser import parse_file_content
|
|
14
18
|
|
|
15
19
|
QTEST_ID = "QTest Id"
|
|
16
20
|
|
|
@@ -23,21 +27,34 @@ If generated data was used, put appropriate note to the test case description fi
|
|
|
23
27
|
### CRITERIA
|
|
24
28
|
1. The structure should be as in EXAMPLE.
|
|
25
29
|
2. Case and spaces for field names should be exactly the same as in NOTES.
|
|
26
|
-
3. Extra fields are allowed.
|
|
30
|
+
3. Extra fields are allowed and will be mapped to project's custom fields if they exist.
|
|
27
31
|
4. "{QTEST_ID}" is required to update, change or replace values in test case.
|
|
28
32
|
5. Do not provide "Id" and "{QTEST_ID}" to create test case.
|
|
29
|
-
6
|
|
33
|
+
6. "Steps" is a list of test step objects with fields "Test Step Number", "Test Step Description", "Test Step Expected Result".
|
|
34
|
+
7. For updates, provide ONLY the fields you want to change. Omitted fields will remain unchanged.
|
|
30
35
|
|
|
31
36
|
### NOTES
|
|
32
|
-
Id: Unique identifier (e.g., TC-123).
|
|
33
|
-
QTest id: Unique identifier (e.g., 4626964).
|
|
34
|
-
Name: Brief title.
|
|
35
|
-
Description: Short purpose.
|
|
36
|
-
Type: 'Manual'
|
|
37
|
-
Status:
|
|
38
|
-
Priority:
|
|
39
|
-
Test Type:
|
|
40
|
-
Precondition:
|
|
37
|
+
Id: Unique identifier (e.g., TC-123). Read-only.
|
|
38
|
+
QTest id: Unique identifier (e.g., 4626964). Required for updates.
|
|
39
|
+
Name: Brief title of the test case.
|
|
40
|
+
Description: Short description of the test purpose.
|
|
41
|
+
Type: Type of test (e.g., 'Manual', 'Automation - UTAF').
|
|
42
|
+
Status: Current status (e.g., 'New', 'In Progress', 'Completed').
|
|
43
|
+
Priority: Priority level (e.g., 'High', 'Medium', 'Low').
|
|
44
|
+
Test Type: Category of test (e.g., 'Functional', 'Regression', 'Smoke').
|
|
45
|
+
Precondition: Prerequisites for the test, formatted as: <Step1> <Step2> Leave blank if none.
|
|
46
|
+
Steps: Array of test steps with Description and Expected Result.
|
|
47
|
+
|
|
48
|
+
**Multi-select fields**: For fields that allow multiple values (e.g., Team, Assigned To etc.), you can provide:
|
|
49
|
+
- Single value: "Team": "Epam"
|
|
50
|
+
- Multiple values: "Team": ["Epam", "EJ"]
|
|
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
|
+
|
|
57
|
+
**For Updates**: Include only the fields you want to modify. The system will validate property values against project configuration.
|
|
41
58
|
|
|
42
59
|
### EXAMPLE
|
|
43
60
|
{{
|
|
@@ -50,6 +67,7 @@ Precondition: List prerequisites in one cell, formatted as: <Step1> <Step2> Leav
|
|
|
50
67
|
"Priority": "",
|
|
51
68
|
"Test Type": "Functional",
|
|
52
69
|
"Precondition": "<ONLY provided by user precondition>",
|
|
70
|
+
"Team": ["Epam", "EJ"],
|
|
53
71
|
"Steps": [
|
|
54
72
|
{{ "Test Step Number": 1, "Test Step Description": "Navigate to url", "Test Step Expected Result": "Page content is loaded"}},
|
|
55
73
|
{{ "Test Step Number": 2, "Test Step Description": "Click 'Login'", "Test Step Expected Result": "Form is expanded"}},
|
|
@@ -60,11 +78,81 @@ Precondition: List prerequisites in one cell, formatted as: <Step1> <Step2> Leav
|
|
|
60
78
|
Json object
|
|
61
79
|
"""
|
|
62
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
|
+
|
|
63
148
|
logger = logging.getLogger(__name__)
|
|
64
149
|
|
|
65
150
|
QtestDataQuerySearch = create_model(
|
|
66
151
|
"QtestDataQuerySearch",
|
|
67
|
-
dql=(str, Field(description="Qtest Data Query Language (DQL) query string"))
|
|
152
|
+
dql=(str, Field(description="Qtest Data Query Language (DQL) query string")),
|
|
153
|
+
extract_images=(Optional[bool], Field(description="Should images be processed by llm", default=False)),
|
|
154
|
+
prompt=(Optional[str], Field(description="Prompt for image processing", default=None))
|
|
155
|
+
)
|
|
68
156
|
|
|
69
157
|
QtestCreateTestCase = create_model(
|
|
70
158
|
"QtestCreateTestCase",
|
|
@@ -76,8 +164,16 @@ QtestCreateTestCase = create_model(
|
|
|
76
164
|
|
|
77
165
|
QtestLinkTestCaseToJiraRequirement = create_model(
|
|
78
166
|
"QtestLinkTestCaseToJiraRequirement",
|
|
79
|
-
requirement_external_id=(str, Field("Qtest requirement external id which represent jira issue id linked to Qtest as a requirement e.g. SITEPOD-4038")),
|
|
80
|
-
json_list_of_test_case_ids=(str, Field("""List of the test case ids to be linked to particular requirement.
|
|
167
|
+
requirement_external_id=(str, Field(description="Qtest requirement external id which represent jira issue id linked to Qtest as a requirement e.g. SITEPOD-4038")),
|
|
168
|
+
json_list_of_test_case_ids=(str, Field(description="""List of the test case ids to be linked to particular requirement.
|
|
169
|
+
Create a list of the test case ids in the following format '["TC-123", "TC-234", "TC-456"]' which represents json array as a string.
|
|
170
|
+
It should be capable to be extracted directly by python json.loads method."""))
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
QtestLinkTestCaseToQtestRequirement = create_model(
|
|
174
|
+
"QtestLinkTestCaseToQtestRequirement",
|
|
175
|
+
requirement_id=(str, Field(description="QTest internal requirement ID in format RQ-123")),
|
|
176
|
+
json_list_of_test_case_ids=(str, Field(description="""List of the test case ids to be linked to particular requirement.
|
|
81
177
|
Create a list of the test case ids in the following format '["TC-123", "TC-234", "TC-456"]' which represents json array as a string.
|
|
82
178
|
It should be capable to be extracted directly by python json.loads method."""))
|
|
83
179
|
)
|
|
@@ -92,6 +188,8 @@ UpdateTestCase = create_model(
|
|
|
92
188
|
FindTestCaseById = create_model(
|
|
93
189
|
"FindTestCaseById",
|
|
94
190
|
test_id=(str, Field(description="Test case ID e.g. TC-1234")),
|
|
191
|
+
extract_images=(Optional[bool], Field(description="Should images be processed by llm", default=False)),
|
|
192
|
+
prompt=(Optional[str], Field(description="Prompt for image processing", default=None))
|
|
95
193
|
)
|
|
96
194
|
|
|
97
195
|
DeleteTestCase = create_model(
|
|
@@ -99,6 +197,62 @@ DeleteTestCase = create_model(
|
|
|
99
197
|
qtest_id=(int, Field(description="Qtest id e.g. 3253490123")),
|
|
100
198
|
)
|
|
101
199
|
|
|
200
|
+
GetModules = create_model(
|
|
201
|
+
"GetModules",
|
|
202
|
+
parent_id=(Optional[int],
|
|
203
|
+
Field(description="ID of the parent Module. Leave it blank to retrieve Modules under root",
|
|
204
|
+
default=None)),
|
|
205
|
+
search=(Optional[str],
|
|
206
|
+
Field(description="The free-text to search for Modules by names. You can utilize this parameter to search for Modules. Leave it blank to retrieve all Modules under root or the parent Module",
|
|
207
|
+
default=None)),
|
|
208
|
+
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
GetAllTestCasesFieldsForProject = create_model(
|
|
212
|
+
"GetAllTestCasesFieldsForProject",
|
|
213
|
+
force_refresh=(Optional[bool],
|
|
214
|
+
Field(description="Set to true to reload field definitions from API if project configuration has changed (new fields added, dropdown values modified). Default: false (uses cached data).",
|
|
215
|
+
default=False)),
|
|
216
|
+
)
|
|
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
|
+
|
|
252
|
+
NoInput = create_model(
|
|
253
|
+
"NoInput"
|
|
254
|
+
)
|
|
255
|
+
|
|
102
256
|
class QtestApiWrapper(BaseToolApiWrapper):
|
|
103
257
|
base_url: str
|
|
104
258
|
qtest_project_id: int
|
|
@@ -107,6 +261,9 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
107
261
|
page: int = 1
|
|
108
262
|
no_of_tests_shown_in_dql_search: int = 10
|
|
109
263
|
_client: Any = PrivateAttr()
|
|
264
|
+
_field_definitions_cache: Optional[dict] = PrivateAttr(default=None)
|
|
265
|
+
_modules_cache: Optional[list] = PrivateAttr(default=None)
|
|
266
|
+
llm: Any
|
|
110
267
|
|
|
111
268
|
@model_validator(mode='before')
|
|
112
269
|
@classmethod
|
|
@@ -115,9 +272,8 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
115
272
|
values['qtest_project_id'] = values.pop('project_id')
|
|
116
273
|
return values
|
|
117
274
|
|
|
118
|
-
@model_validator(mode='
|
|
119
|
-
|
|
120
|
-
def validate_toolkit(cls, values):
|
|
275
|
+
@model_validator(mode='after')
|
|
276
|
+
def validate_toolkit(self):
|
|
121
277
|
try:
|
|
122
278
|
import swagger_client # noqa: F401
|
|
123
279
|
except ImportError:
|
|
@@ -126,47 +282,278 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
126
282
|
"`pip install git+https://github.com/Roman-Mitusov/qtest-api.git`"
|
|
127
283
|
)
|
|
128
284
|
|
|
129
|
-
|
|
130
|
-
api_token = values.get('qtest_api_token')
|
|
131
|
-
if api_token:
|
|
285
|
+
if self.qtest_api_token:
|
|
132
286
|
configuration = swagger_client.Configuration()
|
|
133
|
-
configuration.host =
|
|
134
|
-
configuration.api_key['Authorization'] =
|
|
287
|
+
configuration.host = self.base_url
|
|
288
|
+
configuration.api_key['Authorization'] = self.qtest_api_token.get_secret_value()
|
|
135
289
|
configuration.api_key_prefix['Authorization'] = 'Bearer'
|
|
136
|
-
|
|
137
|
-
return
|
|
290
|
+
self._client = swagger_client.ApiClient(configuration)
|
|
291
|
+
return self
|
|
138
292
|
|
|
139
293
|
def __instantiate_test_api_instance(self) -> TestCaseApi:
|
|
140
294
|
# Instantiate the TestCaseApi instance according to the qtest api documentation and swagger client
|
|
141
295
|
return swagger_client.TestCaseApi(self._client)
|
|
142
296
|
|
|
297
|
+
def __instantiate_module_api_instance(self) -> ModuleApi:
|
|
298
|
+
return swagger_client.ModuleApi(self._client)
|
|
299
|
+
|
|
300
|
+
def __instantiate_fields_api_instance(self) -> FieldApi:
|
|
301
|
+
return swagger_client.FieldApi(self._client)
|
|
302
|
+
|
|
303
|
+
def __get_field_definitions_cached(self) -> dict:
|
|
304
|
+
"""Get field definitions with session-level caching.
|
|
305
|
+
|
|
306
|
+
Field definitions are cached for the lifetime of this wrapper instance.
|
|
307
|
+
If project field configuration changes, call refresh_field_definitions_cache()
|
|
308
|
+
to reload the definitions.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
dict: Field definitions mapping
|
|
312
|
+
"""
|
|
313
|
+
if self._field_definitions_cache is None:
|
|
314
|
+
self._field_definitions_cache = self.__get_project_field_definitions()
|
|
315
|
+
return self._field_definitions_cache
|
|
316
|
+
|
|
317
|
+
def refresh_field_definitions_cache(self) -> dict:
|
|
318
|
+
"""Manually refresh the field definitions cache.
|
|
319
|
+
|
|
320
|
+
Call this method if project field configuration has been updated
|
|
321
|
+
(new fields added, dropdown values changed, etc.) and you need to
|
|
322
|
+
reload the definitions within the same agent session.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
dict: Freshly loaded field definitions
|
|
326
|
+
"""
|
|
327
|
+
self._field_definitions_cache = None
|
|
328
|
+
return self.__get_field_definitions_cached()
|
|
329
|
+
|
|
330
|
+
def __map_properties_to_api_format(self, test_case_data: dict, field_definitions: dict,
|
|
331
|
+
base_properties: list = None) -> list:
|
|
332
|
+
"""
|
|
333
|
+
Convert user-friendly property names/values to QTest API PropertyResource format.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
test_case_data: Dict with property names as keys (e.g., {"Status": "New", "Priority": "High"})
|
|
337
|
+
field_definitions: Output from __get_project_field_definitions()
|
|
338
|
+
base_properties: Existing properties from a test case (for updates, optional)
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
list[PropertyResource]: Properties ready for API submission
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If any field names are unknown or values are invalid (shows ALL errors)
|
|
345
|
+
"""
|
|
346
|
+
# Start with base properties or empty dict
|
|
347
|
+
props_dict = {}
|
|
348
|
+
if base_properties:
|
|
349
|
+
for prop in base_properties:
|
|
350
|
+
field_name = prop.get('field_name')
|
|
351
|
+
if field_name:
|
|
352
|
+
props_dict[field_name] = {
|
|
353
|
+
'field_id': prop['field_id'],
|
|
354
|
+
'field_name': field_name,
|
|
355
|
+
'field_value': prop['field_value'],
|
|
356
|
+
'field_value_name': prop.get('field_value_name')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Collect ALL validation errors before raising
|
|
360
|
+
validation_errors = []
|
|
361
|
+
|
|
362
|
+
# Map incoming properties from test_case_data
|
|
363
|
+
for field_name, field_value in test_case_data.items():
|
|
364
|
+
# Skip non-property fields (these are handled separately)
|
|
365
|
+
if field_name in ['Name', 'Description', 'Precondition', 'Steps', 'Id', QTEST_ID]:
|
|
366
|
+
continue
|
|
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
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# Validate field exists in project - STRICT validation
|
|
414
|
+
if field_name not in field_definitions:
|
|
415
|
+
validation_errors.append(
|
|
416
|
+
f"❌ Unknown field '{field_name}' - not defined in project configuration"
|
|
417
|
+
)
|
|
418
|
+
continue # Skip to next field, keep collecting errors
|
|
419
|
+
|
|
420
|
+
field_def = field_definitions[field_name]
|
|
421
|
+
field_id = field_def['field_id']
|
|
422
|
+
data_type = field_def.get('data_type')
|
|
423
|
+
is_multiple = field_def.get('multiple', False)
|
|
424
|
+
|
|
425
|
+
# Normalize field_value to list for consistent processing
|
|
426
|
+
# Multi-select fields can receive: "value", ["value1", "value2"], or ["value1"]
|
|
427
|
+
# Single-select fields: "value" only
|
|
428
|
+
if is_multiple:
|
|
429
|
+
# Convert to list if not already
|
|
430
|
+
values_to_process = field_value if isinstance(field_value, list) else [field_value]
|
|
431
|
+
else:
|
|
432
|
+
# Single-select: keep as single value
|
|
433
|
+
values_to_process = [field_value]
|
|
434
|
+
|
|
435
|
+
# Validate value(s) for dropdown fields (only if field has allowed values)
|
|
436
|
+
if field_def['values']:
|
|
437
|
+
# Field has allowed values (dropdown/combobox/user fields) - validate strictly
|
|
438
|
+
value_ids = []
|
|
439
|
+
value_names = []
|
|
440
|
+
|
|
441
|
+
for single_value in values_to_process:
|
|
442
|
+
if single_value not in field_def['values']:
|
|
443
|
+
available = ", ".join(sorted(field_def['values'].keys()))
|
|
444
|
+
validation_errors.append(
|
|
445
|
+
f"❌ Invalid value '{single_value}' for field '{field_name}'. "
|
|
446
|
+
f"Allowed values: {available}"
|
|
447
|
+
)
|
|
448
|
+
continue # Skip this value, but continue validating others
|
|
449
|
+
|
|
450
|
+
# Valid value - add to lists
|
|
451
|
+
value_ids.append(field_def['values'][single_value])
|
|
452
|
+
value_names.append(single_value)
|
|
453
|
+
|
|
454
|
+
# If all values were invalid, skip this field
|
|
455
|
+
if not value_ids:
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
# Format based on field type and value count
|
|
459
|
+
if is_multiple and len(value_ids) == 1:
|
|
460
|
+
# Single value in multi-select field: bracketed string "[419950]"
|
|
461
|
+
# This includes single user assignment: "[626983]"
|
|
462
|
+
field_value_id = f"[{value_ids[0]}]"
|
|
463
|
+
field_value_name = f"[{value_names[0]}]" if data_type == 5 else value_names[0]
|
|
464
|
+
elif is_multiple:
|
|
465
|
+
# Multiple values in multi-select: bracketed string with comma-separated IDs
|
|
466
|
+
ids_str = ",".join(str(vid) for vid in value_ids)
|
|
467
|
+
field_value_id = f"[{ids_str}]"
|
|
468
|
+
field_value_name = ", ".join(value_names)
|
|
469
|
+
else:
|
|
470
|
+
# Regular single-select dropdown: plain ID
|
|
471
|
+
field_value_id = value_ids[0]
|
|
472
|
+
field_value_name = value_names[0]
|
|
473
|
+
else:
|
|
474
|
+
# Text field or field without restricted values - use value directly
|
|
475
|
+
# No validation needed - users can write anything (by design)
|
|
476
|
+
field_value_id = field_value
|
|
477
|
+
field_value_name = field_value if isinstance(field_value, str) else None
|
|
478
|
+
|
|
479
|
+
# Update or add property (only if no errors for this field)
|
|
480
|
+
props_dict[field_name] = {
|
|
481
|
+
'field_id': field_id,
|
|
482
|
+
'field_name': field_name,
|
|
483
|
+
'field_value': field_value_id,
|
|
484
|
+
'field_value_name': field_value_name
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
# If ANY validation errors found, raise comprehensive error with all issues
|
|
488
|
+
if validation_errors:
|
|
489
|
+
available_fields = ", ".join(sorted(field_definitions.keys()))
|
|
490
|
+
error_msg = (
|
|
491
|
+
f"Found {len(validation_errors)} validation error(s) in test case properties:\n\n" +
|
|
492
|
+
"\n".join(validation_errors) +
|
|
493
|
+
f"\n\n📋 Available fields for this project: {available_fields}\n\n"
|
|
494
|
+
f"💡 Tip: Use 'get_all_test_cases_fields_for_project' tool to see all fields with their allowed values."
|
|
495
|
+
)
|
|
496
|
+
raise ValueError(error_msg)
|
|
497
|
+
|
|
498
|
+
# Convert to PropertyResource list, filtering out special fields
|
|
499
|
+
result = []
|
|
500
|
+
for field_name, prop_data in props_dict.items():
|
|
501
|
+
if field_name in ['Shared', 'Projects Shared to']:
|
|
502
|
+
continue
|
|
503
|
+
result.append(PropertyResource(
|
|
504
|
+
field_id=prop_data['field_id'],
|
|
505
|
+
field_name=prop_data['field_name'],
|
|
506
|
+
field_value=prop_data['field_value'],
|
|
507
|
+
field_value_name=prop_data.get('field_value_name')
|
|
508
|
+
))
|
|
509
|
+
|
|
510
|
+
return result
|
|
511
|
+
|
|
143
512
|
def __build_body_for_create_test_case(self, test_cases_data: list[dict],
|
|
144
513
|
folder_to_place_test_cases_to: str = '') -> list:
|
|
145
|
-
|
|
514
|
+
# Get field definitions for property mapping (cached for session)
|
|
515
|
+
field_definitions = self.__get_field_definitions_cached()
|
|
516
|
+
|
|
146
517
|
modules = self._parse_modules()
|
|
147
518
|
parent_id = ''.join(str(module['module_id']) for module in modules if
|
|
148
519
|
folder_to_place_test_cases_to and module['full_module_name'] == folder_to_place_test_cases_to)
|
|
149
|
-
|
|
150
|
-
for prop in initial_project_properties:
|
|
151
|
-
if prop.get('field_name', '') == 'Shared' or prop.get('field_name', '') == 'Projects Shared to':
|
|
152
|
-
continue
|
|
153
|
-
props.append(PropertyResource(field_id=prop['field_id'], field_name=prop['field_name'],
|
|
154
|
-
field_value_name=prop.get('field_value_name', None),
|
|
155
|
-
field_value=prop['field_value']))
|
|
520
|
+
|
|
156
521
|
bodies = []
|
|
157
522
|
for test_case in test_cases_data:
|
|
523
|
+
# Map properties from user format to API format
|
|
524
|
+
props = self.__map_properties_to_api_format(test_case, field_definitions)
|
|
525
|
+
|
|
158
526
|
body = swagger_client.TestCaseWithCustomFieldResource(properties=props)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
527
|
+
|
|
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)
|
|
531
|
+
if 'Name' in test_case:
|
|
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
|
+
|
|
536
|
+
if 'Precondition' in test_case:
|
|
537
|
+
# Allow clearing with None or empty string
|
|
538
|
+
body.precondition = test_case['Precondition'] if test_case['Precondition'] is not None else ''
|
|
539
|
+
|
|
540
|
+
if 'Description' in test_case:
|
|
541
|
+
# Allow clearing with None or empty string
|
|
542
|
+
body.description = test_case['Description'] if test_case['Description'] is not None else ''
|
|
543
|
+
|
|
162
544
|
if parent_id:
|
|
163
545
|
body.parent_id = parent_id
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
546
|
+
|
|
547
|
+
# Only set test_steps if Steps are provided in the input
|
|
548
|
+
# This prevents overwriting existing steps during partial updates
|
|
549
|
+
if 'Steps' in test_case and test_case['Steps'] is not None:
|
|
550
|
+
test_steps_resources = []
|
|
551
|
+
for step in test_case['Steps']:
|
|
552
|
+
test_steps_resources.append(
|
|
553
|
+
swagger_client.TestStepResource(description=step.get('Test Step Description'),
|
|
554
|
+
expected=step.get('Test Step Expected Result')))
|
|
555
|
+
body.test_steps = test_steps_resources
|
|
556
|
+
|
|
170
557
|
bodies.append(body)
|
|
171
558
|
return bodies
|
|
172
559
|
|
|
@@ -183,7 +570,273 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
183
570
|
Exception: \n {stacktrace}""")
|
|
184
571
|
return modules
|
|
185
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
|
+
|
|
710
|
+
def __get_project_field_definitions(self) -> dict:
|
|
711
|
+
"""
|
|
712
|
+
Get structured field definitions for test cases in the project.
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
dict: Mapping of field names to their IDs and allowed values.
|
|
716
|
+
Example: {
|
|
717
|
+
'Status': {
|
|
718
|
+
'field_id': 12345,
|
|
719
|
+
'required': True,
|
|
720
|
+
'values': {'New': 1, 'In Progress': 2, 'Completed': 3}
|
|
721
|
+
},
|
|
722
|
+
'Priority': {
|
|
723
|
+
'field_id': 12346,
|
|
724
|
+
'required': False,
|
|
725
|
+
'values': {'High': 1, 'Medium': 2, 'Low': 3}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
"""
|
|
729
|
+
fields_api = self.__instantiate_fields_api_instance()
|
|
730
|
+
qtest_object = 'test-cases'
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
fields = fields_api.get_fields(self.qtest_project_id, qtest_object)
|
|
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
|
|
744
|
+
stacktrace = format_exc()
|
|
745
|
+
logger.error(f"Exception when calling FieldAPI->get_fields:\n {stacktrace}")
|
|
746
|
+
raise ValueError(
|
|
747
|
+
f"Unable to get test case fields for project {self.qtest_project_id}. Exception: \n {stacktrace}")
|
|
748
|
+
|
|
749
|
+
# Build structured mapping
|
|
750
|
+
field_mapping = {}
|
|
751
|
+
for field in fields:
|
|
752
|
+
field_name = field.label
|
|
753
|
+
field_mapping[field_name] = {
|
|
754
|
+
'field_id': field.id,
|
|
755
|
+
'required': getattr(field, 'required', False),
|
|
756
|
+
'data_type': getattr(field, 'data_type', None), # 5 = user field
|
|
757
|
+
'multiple': getattr(field, 'multiple', False), # True = multi-select, needs array format
|
|
758
|
+
'values': {}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
# Map allowed values if field has them (dropdown/combobox/user fields)
|
|
762
|
+
# Only include active values (is_active=True)
|
|
763
|
+
if hasattr(field, 'allowed_values') and field.allowed_values:
|
|
764
|
+
for allowed_value in field.allowed_values:
|
|
765
|
+
# Skip inactive values (deleted/deprecated options)
|
|
766
|
+
if hasattr(allowed_value, 'is_active') and not allowed_value.is_active:
|
|
767
|
+
continue
|
|
768
|
+
|
|
769
|
+
# AllowedValueResource has 'label' for the display name and 'value' for the ID
|
|
770
|
+
# Note: 'value' is the field_value, not 'id'
|
|
771
|
+
# For user fields (data_type=5), label is user name and value is user ID
|
|
772
|
+
value_label = allowed_value.label
|
|
773
|
+
value_id = allowed_value.value
|
|
774
|
+
field_mapping[field_name]['values'][value_label] = value_id
|
|
775
|
+
|
|
776
|
+
return field_mapping
|
|
777
|
+
|
|
778
|
+
def __format_field_info_for_display(self, field_definitions: dict) -> str:
|
|
779
|
+
"""
|
|
780
|
+
Format field definitions in human-readable format for LLM.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
field_definitions: Output from __get_project_field_definitions()
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Formatted string with field information
|
|
787
|
+
"""
|
|
788
|
+
output = [f"Available Test Case Fields for Project {self.qtest_project_id}:\n"]
|
|
789
|
+
|
|
790
|
+
for field_name, field_info in sorted(field_definitions.items()):
|
|
791
|
+
required_marker = " (Required)" if field_info.get('required') else ""
|
|
792
|
+
has_values = bool(field_info.get('values'))
|
|
793
|
+
is_multiple = field_info.get('multiple', False)
|
|
794
|
+
|
|
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:
|
|
806
|
+
for value_name, value_id in sorted(field_info['values'].items()):
|
|
807
|
+
output.append(f" - {value_name}")
|
|
808
|
+
else:
|
|
809
|
+
output.append(" Free text input. Set to null to clear.")
|
|
810
|
+
|
|
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)
|
|
816
|
+
|
|
817
|
+
def get_all_test_cases_fields_for_project(self, force_refresh: bool = False) -> str:
|
|
818
|
+
"""
|
|
819
|
+
Get formatted information about available test case fields and their values.
|
|
820
|
+
This method is exposed as a tool for LLM to query field information.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
force_refresh: If True, reload field definitions from API instead of using cache.
|
|
824
|
+
Use this if project configuration has changed (new fields added,
|
|
825
|
+
dropdown values modified, etc.).
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
Formatted string with field names and allowed values
|
|
829
|
+
"""
|
|
830
|
+
if force_refresh:
|
|
831
|
+
self.refresh_field_definitions_cache()
|
|
832
|
+
field_defs = self.__get_field_definitions_cached()
|
|
833
|
+
return self.__format_field_info_for_display(field_defs)
|
|
834
|
+
|
|
186
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
|
+
|
|
187
840
|
modules = self.__get_all_modules_for_project()
|
|
188
841
|
result = []
|
|
189
842
|
|
|
@@ -204,6 +857,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
204
857
|
for module in modules:
|
|
205
858
|
parse_module(module)
|
|
206
859
|
|
|
860
|
+
self._modules_cache = result
|
|
207
861
|
return result
|
|
208
862
|
|
|
209
863
|
def __execute_single_create_test_case_request(self, test_case_api_instance: TestCaseApi, body,
|
|
@@ -220,32 +874,90 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
220
874
|
raise ToolException(
|
|
221
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
|
|
222
876
|
|
|
223
|
-
def
|
|
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
|
+
|
|
908
|
+
def __parse_data(self, response_to_parse: dict, parsed_data: list, extract_images: bool=False, prompt: str=None):
|
|
224
909
|
import html
|
|
910
|
+
|
|
911
|
+
# PERMISSION-FREE: Parse properties directly from API response
|
|
912
|
+
# No get_fields call needed - works for all users
|
|
913
|
+
|
|
225
914
|
for item in response_to_parse['items']:
|
|
915
|
+
# Start with core fields (always present)
|
|
226
916
|
parsed_data_row = {
|
|
227
917
|
'Id': item['pid'],
|
|
918
|
+
'Name': item['name'],
|
|
228
919
|
'Description': html.unescape(strip_tags(item['description'])),
|
|
229
920
|
'Precondition': html.unescape(strip_tags(item['precondition'])),
|
|
230
|
-
'Name': item['name'],
|
|
231
921
|
QTEST_ID: item['id'],
|
|
232
922
|
'Steps': list(map(lambda step: {
|
|
233
923
|
'Test Step Number': step[0] + 1,
|
|
234
|
-
'Test Step Description': step[1]['description'],
|
|
235
|
-
'Test Step Expected Result':
|
|
924
|
+
'Test Step Description': self._process_image(step[1]['description'], extract_images, prompt),
|
|
925
|
+
'Test Step Expected Result': self._process_image(step[1]['expected'], extract_images, prompt)
|
|
236
926
|
}, enumerate(item['test_steps']))),
|
|
237
|
-
'Status': ''.join([properties['field_value_name'] for properties in item['properties']
|
|
238
|
-
if properties['field_name'] == 'Status']),
|
|
239
|
-
'Automation': ''.join([properties['field_value_name'] for properties in item['properties']
|
|
240
|
-
if properties['field_name'] == 'Automation']),
|
|
241
|
-
'Type': ''.join([properties['field_value_name'] for properties in item['properties']
|
|
242
|
-
if properties['field_name'] == 'Type']),
|
|
243
|
-
'Priority': ''.join([properties['field_value_name'] for properties in item['properties']
|
|
244
|
-
if properties['field_name'] == 'Priority']),
|
|
245
927
|
}
|
|
928
|
+
|
|
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
|
|
934
|
+
|
|
935
|
+
# Format value based on field type (multi-select as array, etc.)
|
|
936
|
+
parsed_data_row[field_name] = self.__format_property_value(prop)
|
|
937
|
+
|
|
246
938
|
parsed_data.append(parsed_data_row)
|
|
247
939
|
|
|
248
|
-
def
|
|
940
|
+
def _process_image(self, content: str, extract: bool=False, prompt: str=None):
|
|
941
|
+
#extract image by regex
|
|
942
|
+
img_regex = r'<img\s+src="data:image\/[^;]+;base64,([^"]+)"\s+[^>]*data-filename="([^"]+)"[^>]*>'
|
|
943
|
+
|
|
944
|
+
def replace_image(match):
|
|
945
|
+
base64_content = match.group(1)
|
|
946
|
+
file_name = match.group(2)
|
|
947
|
+
|
|
948
|
+
file_content = base64.b64decode(base64_content)
|
|
949
|
+
|
|
950
|
+
if extract:
|
|
951
|
+
description = f"<img description=\"{parse_file_content(file_content=file_content, file_name=file_name, prompt=prompt, llm=self.llm)}\">"
|
|
952
|
+
else:
|
|
953
|
+
description = ""
|
|
954
|
+
|
|
955
|
+
return description
|
|
956
|
+
#replace image tag by description
|
|
957
|
+
content = re.sub(img_regex, replace_image, content)
|
|
958
|
+
return content
|
|
959
|
+
|
|
960
|
+
def __perform_search_by_dql(self, dql: str, extract_images:bool=False, prompt: str=None) -> list:
|
|
249
961
|
search_instance: SearchApi = swagger_client.SearchApi(self._client)
|
|
250
962
|
body = swagger_client.ArtifactSearchParams(object_type='test-cases', fields=['*'],
|
|
251
963
|
query=dql)
|
|
@@ -256,7 +968,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
256
968
|
api_response = search_instance.search_artifact(self.qtest_project_id, body, append_test_steps=append_test_steps,
|
|
257
969
|
include_external_properties=include_external_properties,
|
|
258
970
|
page_size=self.no_of_items_per_page, page=self.page)
|
|
259
|
-
self.__parse_data(api_response, parsed_data)
|
|
971
|
+
self.__parse_data(api_response, parsed_data, extract_images, prompt)
|
|
260
972
|
|
|
261
973
|
if api_response['links']:
|
|
262
974
|
while api_response['links'][0]['rel'] == 'next':
|
|
@@ -265,7 +977,7 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
265
977
|
append_test_steps=append_test_steps,
|
|
266
978
|
include_external_properties=include_external_properties,
|
|
267
979
|
page_size=self.no_of_items_per_page, page=next_page)
|
|
268
|
-
self.__parse_data(api_response, parsed_data)
|
|
980
|
+
self.__parse_data(api_response, parsed_data, extract_images, prompt)
|
|
269
981
|
except ApiException as e:
|
|
270
982
|
stacktrace = format_exc()
|
|
271
983
|
logger.error(f"Exception when calling SearchApi->search_artifact: \n {stacktrace}")
|
|
@@ -280,18 +992,140 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
280
992
|
parsed_data = self.__perform_search_by_dql(dql)
|
|
281
993
|
return parsed_data[0]['QTest Id']
|
|
282
994
|
|
|
283
|
-
def
|
|
284
|
-
|
|
285
|
-
|
|
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.).
|
|
1000
|
+
|
|
1001
|
+
Args:
|
|
1002
|
+
object_type: QTest object type ('test-runs', 'defects', 'requirements', etc.)
|
|
1003
|
+
entity_id: Entity ID in format TR-123, DF-456, etc.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
int: Internal QTest ID for the entity
|
|
1007
|
+
|
|
1008
|
+
Raises:
|
|
1009
|
+
ValueError: If entity is not found
|
|
1010
|
+
"""
|
|
1011
|
+
dql = f"Id = '{entity_id}'"
|
|
1012
|
+
search_instance: SearchApi = swagger_client.SearchApi(self._client)
|
|
1013
|
+
body = swagger_client.ArtifactSearchParams(object_type=object_type, fields=['*'], query=dql)
|
|
1014
|
+
|
|
286
1015
|
try:
|
|
287
|
-
response =
|
|
288
|
-
|
|
1016
|
+
response = search_instance.search_artifact(self.qtest_project_id, body)
|
|
1017
|
+
if response['total'] == 0:
|
|
1018
|
+
raise ValueError(
|
|
1019
|
+
f"{object_type.capitalize()} '{entity_id}' not found in project {self.qtest_project_id}. "
|
|
1020
|
+
f"Please verify the {entity_id} ID exists."
|
|
1021
|
+
)
|
|
1022
|
+
return response['items'][0]['id']
|
|
289
1023
|
except ApiException as e:
|
|
290
1024
|
stacktrace = format_exc()
|
|
291
|
-
logger.error(f"Exception when
|
|
292
|
-
raise
|
|
1025
|
+
logger.error(f"Exception when searching for '{object_type}': '{entity_id}': \n {stacktrace}")
|
|
1026
|
+
raise ToolException(
|
|
1027
|
+
f"Unable to search for {object_type} '{entity_id}' in project {self.qtest_project_id}. "
|
|
1028
|
+
f"Exception: \n{stacktrace}"
|
|
1029
|
+
) from e
|
|
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)
|
|
293
1044
|
|
|
294
|
-
def
|
|
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
|
+
|
|
1128
|
+
def __is_jira_requirement_present(self, jira_issue_id: str) -> tuple[bool, dict]:
|
|
295
1129
|
""" Define if particular Jira requirement is present in qtest or not """
|
|
296
1130
|
dql = f"'External Id' = '{jira_issue_id}'"
|
|
297
1131
|
search_instance: SearchApi = swagger_client.SearchApi(self._client)
|
|
@@ -307,40 +1141,682 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
307
1141
|
logger.error(f"Error: {format_exc()}")
|
|
308
1142
|
raise e
|
|
309
1143
|
|
|
310
|
-
def _get_jira_requirement_id(self, jira_issue_id: str) -> int
|
|
311
|
-
"""
|
|
1144
|
+
def _get_jira_requirement_id(self, jira_issue_id: str) -> int:
|
|
1145
|
+
"""Search for requirement id using the linked jira_issue_id.
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
jira_issue_id: External Jira issue ID (e.g., PLAN-128)
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
int: Internal QTest ID for the Jira requirement
|
|
1152
|
+
|
|
1153
|
+
Raises:
|
|
1154
|
+
ValueError: If Jira requirement is not found in QTest
|
|
1155
|
+
"""
|
|
312
1156
|
is_present, response = self.__is_jira_requirement_present(jira_issue_id)
|
|
313
1157
|
if not is_present:
|
|
314
|
-
|
|
1158
|
+
raise ValueError(
|
|
1159
|
+
f"Jira requirement '{jira_issue_id}' not found in QTest project {self.qtest_project_id}. "
|
|
1160
|
+
f"Please ensure the Jira issue is linked to QTest as a requirement."
|
|
1161
|
+
)
|
|
315
1162
|
return response['items'][0]['id']
|
|
316
1163
|
|
|
317
1164
|
|
|
318
1165
|
def link_tests_to_jira_requirement(self, requirement_external_id: str, json_list_of_test_case_ids: str) -> str:
|
|
319
|
-
"""
|
|
1166
|
+
"""Link test cases to external Jira requirement.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
requirement_external_id: Jira issue ID (e.g., PLAN-128)
|
|
1170
|
+
json_list_of_test_case_ids: JSON array string of test case IDs (e.g., '["TC-123", "TC-234"]')
|
|
1171
|
+
|
|
1172
|
+
Returns:
|
|
1173
|
+
Success message with linked test case IDs
|
|
1174
|
+
"""
|
|
320
1175
|
link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
|
|
321
1176
|
source_type = "requirements"
|
|
322
1177
|
linked_type = "test-cases"
|
|
323
|
-
|
|
1178
|
+
test_case_ids = json.loads(json_list_of_test_case_ids)
|
|
1179
|
+
qtest_test_case_ids = [self.__find_qtest_id_by_test_id(tc_id) for tc_id in test_case_ids]
|
|
324
1180
|
requirement_id = self._get_jira_requirement_id(requirement_external_id)
|
|
325
1181
|
|
|
326
1182
|
try:
|
|
327
|
-
response = link_object_api_instance.link_artifacts(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
1183
|
+
response = link_object_api_instance.link_artifacts(
|
|
1184
|
+
self.qtest_project_id,
|
|
1185
|
+
object_id=requirement_id,
|
|
1186
|
+
type=linked_type,
|
|
1187
|
+
object_type=source_type,
|
|
1188
|
+
body=qtest_test_case_ids
|
|
1189
|
+
)
|
|
1190
|
+
linked_test_cases = [link.pid for link in response[0].objects]
|
|
1191
|
+
return (
|
|
1192
|
+
f"Successfully linked {len(linked_test_cases)} test case(s) to Jira requirement '{requirement_external_id}' "
|
|
1193
|
+
f"in project {self.qtest_project_id}.\n"
|
|
1194
|
+
f"Linked test cases: {', '.join(linked_test_cases)}"
|
|
1195
|
+
)
|
|
1196
|
+
except ApiException as e:
|
|
1197
|
+
stacktrace = format_exc()
|
|
1198
|
+
logger.error(f"Error linking to Jira requirement: {stacktrace}")
|
|
1199
|
+
raise ToolException(
|
|
1200
|
+
f"Unable to link test cases to Jira requirement '{requirement_external_id}' "
|
|
1201
|
+
f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
|
|
1202
|
+
) from e
|
|
1203
|
+
|
|
1204
|
+
def link_tests_to_qtest_requirement(self, requirement_id: str, json_list_of_test_case_ids: str) -> str:
|
|
1205
|
+
"""Link test cases to internal QTest requirement.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
requirement_id: QTest requirement ID in format RQ-123
|
|
1209
|
+
json_list_of_test_case_ids: JSON array string of test case IDs (e.g., '["TC-123", "TC-234"]')
|
|
1210
|
+
|
|
1211
|
+
Returns:
|
|
1212
|
+
Success message with linked test case IDs
|
|
1213
|
+
|
|
1214
|
+
Raises:
|
|
1215
|
+
ValueError: If requirement or test cases are not found
|
|
1216
|
+
ToolException: If linking fails
|
|
1217
|
+
"""
|
|
1218
|
+
link_object_api_instance = swagger_client.ObjectLinkApi(self._client)
|
|
1219
|
+
source_type = "requirements"
|
|
1220
|
+
linked_type = "test-cases"
|
|
1221
|
+
|
|
1222
|
+
# Parse and convert test case IDs
|
|
1223
|
+
test_case_ids = json.loads(json_list_of_test_case_ids)
|
|
1224
|
+
qtest_test_case_ids = [self.__find_qtest_id_by_test_id(tc_id) for tc_id in test_case_ids]
|
|
1225
|
+
|
|
1226
|
+
# Get internal QTest ID for the requirement
|
|
1227
|
+
qtest_requirement_id = self.__find_qtest_requirement_id_by_id(requirement_id)
|
|
1228
|
+
|
|
1229
|
+
try:
|
|
1230
|
+
response = link_object_api_instance.link_artifacts(
|
|
1231
|
+
self.qtest_project_id,
|
|
1232
|
+
object_id=qtest_requirement_id,
|
|
1233
|
+
type=linked_type,
|
|
1234
|
+
object_type=source_type,
|
|
1235
|
+
body=qtest_test_case_ids
|
|
1236
|
+
)
|
|
1237
|
+
linked_test_cases = [link.pid for link in response[0].objects]
|
|
1238
|
+
return (
|
|
1239
|
+
f"Successfully linked {len(linked_test_cases)} test case(s) to QTest requirement '{requirement_id}' "
|
|
1240
|
+
f"in project {self.qtest_project_id}.\n"
|
|
1241
|
+
f"Linked test cases: {', '.join(linked_test_cases)}"
|
|
1242
|
+
)
|
|
1243
|
+
except ApiException as e:
|
|
1244
|
+
stacktrace = format_exc()
|
|
1245
|
+
logger.error(f"Error linking to QTest requirement: {stacktrace}")
|
|
1246
|
+
raise ToolException(
|
|
1247
|
+
f"Unable to link test cases to QTest requirement '{requirement_id}' "
|
|
1248
|
+
f"in project {self.qtest_project_id}. Exception: \n{stacktrace}"
|
|
1249
|
+
) from e
|
|
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
|
|
335
1364
|
|
|
336
|
-
def
|
|
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
|
+
|
|
1641
|
+
def search_by_dql(self, dql: str, extract_images:bool=False, prompt: str=None):
|
|
337
1642
|
"""Search for the test cases in qTest using Data Query Language """
|
|
338
|
-
parsed_data = self.__perform_search_by_dql(dql)
|
|
1643
|
+
parsed_data = self.__perform_search_by_dql(dql, extract_images, prompt)
|
|
339
1644
|
return "Found " + str(
|
|
340
1645
|
len(parsed_data)) + f" Qtest test cases:\n" + str(parsed_data[:self.no_of_tests_shown_in_dql_search])
|
|
341
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
|
+
|
|
342
1818
|
def create_test_cases(self, test_case_content: str, folder_to_place_test_cases_to: str) -> dict:
|
|
343
|
-
""" Create the
|
|
1819
|
+
""" Create the test case based on the incoming content. The input should be in json format. """
|
|
344
1820
|
test_cases_api_instance: TestCaseApi = self.__instantiate_test_api_instance()
|
|
345
1821
|
input_obj = json.loads(test_case_content)
|
|
346
1822
|
test_cases = input_obj if isinstance(input_obj, list) else [input_obj]
|
|
@@ -384,10 +1860,10 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
384
1860
|
raise ToolException(
|
|
385
1861
|
f"""Unable to update test case in project with id - {self.qtest_project_id} and test id - {test_id}.\n Exception: \n {stacktrace}""") from e
|
|
386
1862
|
|
|
387
|
-
def find_test_case_by_id(self, test_id: str) -> str:
|
|
1863
|
+
def find_test_case_by_id(self, test_id: str, extract_images=False, prompt=None) -> str:
|
|
388
1864
|
""" Find the test case by its id. Id should be in format TC-123. """
|
|
389
1865
|
dql: str = f"Id = '{test_id}'"
|
|
390
|
-
return f"{self.search_by_dql(dql=dql)}"
|
|
1866
|
+
return f"{self.search_by_dql(dql=dql, extract_images=extract_images, prompt=prompt)}"
|
|
391
1867
|
|
|
392
1868
|
def delete_test_case(self, qtest_id: int) -> str:
|
|
393
1869
|
""" Delete the test case by its id. Id should be in format 3534653120. """
|
|
@@ -401,12 +1877,56 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
401
1877
|
raise ToolException(
|
|
402
1878
|
f"""Unable to delete test case in project with id - {self.qtest_project_id} and qtest_id - {qtest_id}. \n Exception: \n {stacktrace}""") from e
|
|
403
1879
|
|
|
1880
|
+
def get_modules(self, parent_id: int = None, search: str = None):
|
|
1881
|
+
"""
|
|
1882
|
+
:param int project_id: ID of the project (required)
|
|
1883
|
+
:param int parent_id: ID of the parent Module. Leave it blank to retrieve Modules under root
|
|
1884
|
+
:param str search: The free-text to search for Modules by names. You can utilize this parameter to search for Modules. Leave it blank to retrieve all Modules under root or the parent Module
|
|
1885
|
+
"""
|
|
1886
|
+
module_api = self.__instantiate_module_api_instance()
|
|
1887
|
+
kwargs = {}
|
|
1888
|
+
if parent_id:
|
|
1889
|
+
kwargs["parent_id"] = parent_id
|
|
1890
|
+
if search:
|
|
1891
|
+
kwargs["search"] = search
|
|
1892
|
+
return module_api.get_sub_modules_of(project_id=self.qtest_project_id, **kwargs)
|
|
1893
|
+
|
|
404
1894
|
def get_available_tools(self):
|
|
405
1895
|
return [
|
|
406
1896
|
{
|
|
407
1897
|
"name": "search_by_dql",
|
|
408
1898
|
"mode": "search_by_dql",
|
|
409
|
-
"description":
|
|
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
|
+
""",
|
|
410
1930
|
"args_schema": QtestDataQuerySearch,
|
|
411
1931
|
"ref": self.search_by_dql,
|
|
412
1932
|
},
|
|
@@ -439,10 +1959,186 @@ class QtestApiWrapper(BaseToolApiWrapper):
|
|
|
439
1959
|
"ref": self.delete_test_case,
|
|
440
1960
|
},
|
|
441
1961
|
{
|
|
442
|
-
"name": "
|
|
443
|
-
"mode": "
|
|
444
|
-
"description": "
|
|
1962
|
+
"name": "link_tests_to_jira_requirement",
|
|
1963
|
+
"mode": "link_tests_to_jira_requirement",
|
|
1964
|
+
"description": "Link test cases to external Jira requirement. Provide Jira issue ID (e.g., PLAN-128) and list of test case IDs in format '[\"TC-123\", \"TC-234\"]'",
|
|
445
1965
|
"args_schema": QtestLinkTestCaseToJiraRequirement,
|
|
446
1966
|
"ref": self.link_tests_to_jira_requirement,
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
"name": "link_tests_to_qtest_requirement",
|
|
1970
|
+
"mode": "link_tests_to_qtest_requirement",
|
|
1971
|
+
"description": "Link test cases to internal QTest requirement. Provide QTest requirement ID (e.g., RQ-15) and list of test case IDs in format '[\"TC-123\", \"TC-234\"]'",
|
|
1972
|
+
"args_schema": QtestLinkTestCaseToQtestRequirement,
|
|
1973
|
+
"ref": self.link_tests_to_qtest_requirement,
|
|
1974
|
+
},
|
|
1975
|
+
{
|
|
1976
|
+
"name": "get_modules",
|
|
1977
|
+
"mode": "get_modules",
|
|
1978
|
+
"description": self.get_modules.__doc__,
|
|
1979
|
+
"args_schema": GetModules,
|
|
1980
|
+
"ref": self.get_modules,
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
"name": "get_all_test_cases_fields_for_project",
|
|
1984
|
+
"mode": "get_all_test_cases_fields_for_project",
|
|
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.",
|
|
1986
|
+
"args_schema": GetAllTestCasesFieldsForProject,
|
|
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,
|
|
447
2143
|
}
|
|
448
2144
|
]
|