alita-sdk 0.3.162__py3-none-any.whl → 0.3.164__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.
- alita_sdk/runtime/langchain/assistant.py +2 -2
- alita_sdk/runtime/langchain/store_manager.py +22 -1
- alita_sdk/runtime/toolkits/tools.py +1 -1
- alita_sdk/tools/__init__.py +7 -1
- alita_sdk/tools/carrier/api_wrapper.py +76 -4
- alita_sdk/tools/carrier/backend_reports_tool.py +31 -12
- alita_sdk/tools/carrier/backend_tests_tool.py +14 -8
- alita_sdk/tools/carrier/cancel_ui_test_tool.py +178 -0
- alita_sdk/tools/carrier/carrier_sdk.py +99 -15
- alita_sdk/tools/carrier/create_ui_excel_report_tool.py +473 -0
- alita_sdk/tools/carrier/create_ui_test_tool.py +199 -0
- alita_sdk/tools/carrier/lighthouse_excel_reporter.py +155 -0
- alita_sdk/tools/carrier/run_ui_test_tool.py +394 -0
- alita_sdk/tools/carrier/tools.py +11 -1
- alita_sdk/tools/carrier/ui_reports_tool.py +6 -2
- alita_sdk/tools/carrier/update_ui_test_schedule_tool.py +278 -0
- alita_sdk/tools/memory/__init__.py +7 -0
- alita_sdk/tools/postman/__init__.py +7 -0
- alita_sdk/tools/postman/api_wrapper.py +335 -0
- alita_sdk/tools/zephyr_squad/__init__.py +62 -0
- alita_sdk/tools/zephyr_squad/api_wrapper.py +135 -0
- alita_sdk/tools/zephyr_squad/zephyr_squad_cloud_client.py +79 -0
- {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/METADATA +4 -3
- {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/RECORD +27 -18
- {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/top_level.txt +0 -0
@@ -256,6 +256,12 @@ PostmanGetRequestScript = create_model(
|
|
256
256
|
script_type=(str, Field(description="The type of script to retrieve: 'test' or 'prerequest'", default="prerequest"))
|
257
257
|
)
|
258
258
|
|
259
|
+
PostmanExecuteRequest = create_model(
|
260
|
+
"PostmanExecuteRequest",
|
261
|
+
request_path=(str, Field(description="The path to the request in the collection (e.g., 'API/Users/Get User')")),
|
262
|
+
override_variables=(Optional[Dict[str, Any]], Field(description="Optional variables to override environment/collection variables", default=None))
|
263
|
+
)
|
264
|
+
|
259
265
|
|
260
266
|
class PostmanApiWrapper(BaseToolApiWrapper):
|
261
267
|
"""Wrapper for Postman API."""
|
@@ -264,6 +270,7 @@ class PostmanApiWrapper(BaseToolApiWrapper):
|
|
264
270
|
base_url: str = "https://api.getpostman.com"
|
265
271
|
collection_id: Optional[str] = None
|
266
272
|
workspace_id: Optional[str] = None
|
273
|
+
environment_config: dict = {}
|
267
274
|
timeout: int = 30
|
268
275
|
session: Any = None
|
269
276
|
analyzer: PostmanAnalyzer = None
|
@@ -318,6 +325,108 @@ class PostmanApiWrapper(BaseToolApiWrapper):
|
|
318
325
|
raise ToolException(
|
319
326
|
f"Invalid JSON response from Postman API: {str(e)}")
|
320
327
|
|
328
|
+
def _apply_authentication(self, headers, params, all_variables, resolve_variables):
|
329
|
+
"""Apply authentication based on environment_config auth settings.
|
330
|
+
|
331
|
+
Supports multiple authentication types:
|
332
|
+
- bearer: Bearer token in Authorization header
|
333
|
+
- basic: Basic authentication in Authorization header
|
334
|
+
- api_key: API key in header, query parameter, or cookie
|
335
|
+
- oauth2: OAuth2 access token in Authorization header
|
336
|
+
- custom: Custom headers, cookies, or query parameters
|
337
|
+
|
338
|
+
Required format:
|
339
|
+
environment_config = {
|
340
|
+
"auth": {
|
341
|
+
"type": "bearer|basic|api_key|oauth2|custom",
|
342
|
+
"params": {
|
343
|
+
# type-specific parameters
|
344
|
+
}
|
345
|
+
}
|
346
|
+
}
|
347
|
+
"""
|
348
|
+
import base64
|
349
|
+
|
350
|
+
# Handle structured auth configuration only - no backward compatibility
|
351
|
+
auth_config = self.environment_config.get('auth')
|
352
|
+
if auth_config and isinstance(auth_config, dict):
|
353
|
+
auth_type = auth_config.get('type', '').lower()
|
354
|
+
auth_params = auth_config.get('params', {})
|
355
|
+
|
356
|
+
if auth_type == 'bearer':
|
357
|
+
# Bearer token authentication
|
358
|
+
token = resolve_variables(str(auth_params.get('token', '')))
|
359
|
+
if token:
|
360
|
+
headers['Authorization'] = f'Bearer {token}'
|
361
|
+
|
362
|
+
elif auth_type == 'basic':
|
363
|
+
# Basic authentication
|
364
|
+
username = resolve_variables(str(auth_params.get('username', '')))
|
365
|
+
password = resolve_variables(str(auth_params.get('password', '')))
|
366
|
+
if username and password:
|
367
|
+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
368
|
+
headers['Authorization'] = f'Basic {credentials}'
|
369
|
+
|
370
|
+
elif auth_type == 'api_key':
|
371
|
+
# API key authentication
|
372
|
+
key_name = resolve_variables(str(auth_params.get('key', '')))
|
373
|
+
key_value = resolve_variables(str(auth_params.get('value', '')))
|
374
|
+
key_location = auth_params.get('in', 'header').lower()
|
375
|
+
|
376
|
+
if key_name and key_value:
|
377
|
+
if key_location == 'header':
|
378
|
+
headers[key_name] = key_value
|
379
|
+
elif key_location == 'query':
|
380
|
+
params[key_name] = key_value
|
381
|
+
elif key_location == 'cookie':
|
382
|
+
# Add to Cookie header
|
383
|
+
existing_cookies = headers.get('Cookie', '')
|
384
|
+
new_cookie = f"{key_name}={key_value}"
|
385
|
+
if existing_cookies:
|
386
|
+
headers['Cookie'] = f"{existing_cookies}; {new_cookie}"
|
387
|
+
else:
|
388
|
+
headers['Cookie'] = new_cookie
|
389
|
+
|
390
|
+
elif auth_type == 'oauth2':
|
391
|
+
# OAuth2 access token
|
392
|
+
access_token = resolve_variables(str(auth_params.get('access_token', '')))
|
393
|
+
if access_token:
|
394
|
+
headers['Authorization'] = f'Bearer {access_token}'
|
395
|
+
|
396
|
+
elif auth_type == 'custom':
|
397
|
+
# Custom authentication - allows full control
|
398
|
+
custom_headers = auth_params.get('headers', {})
|
399
|
+
custom_cookies = auth_params.get('cookies', {})
|
400
|
+
custom_query = auth_params.get('query', {})
|
401
|
+
|
402
|
+
# Add custom headers
|
403
|
+
for key, value in custom_headers.items():
|
404
|
+
resolved_key = resolve_variables(str(key))
|
405
|
+
resolved_value = resolve_variables(str(value))
|
406
|
+
headers[resolved_key] = resolved_value
|
407
|
+
|
408
|
+
# Add custom query parameters
|
409
|
+
for key, value in custom_query.items():
|
410
|
+
resolved_key = resolve_variables(str(key))
|
411
|
+
resolved_value = resolve_variables(str(value))
|
412
|
+
params[resolved_key] = resolved_value
|
413
|
+
|
414
|
+
# Add custom cookies
|
415
|
+
if custom_cookies:
|
416
|
+
cookie_parts = []
|
417
|
+
for key, value in custom_cookies.items():
|
418
|
+
resolved_key = resolve_variables(str(key))
|
419
|
+
resolved_value = resolve_variables(str(value))
|
420
|
+
cookie_parts.append(f"{resolved_key}={resolved_value}")
|
421
|
+
|
422
|
+
existing_cookies = headers.get('Cookie', '')
|
423
|
+
new_cookies = "; ".join(cookie_parts)
|
424
|
+
if existing_cookies:
|
425
|
+
headers['Cookie'] = f"{existing_cookies}; {new_cookies}"
|
426
|
+
else:
|
427
|
+
headers['Cookie'] = new_cookies
|
428
|
+
|
429
|
+
|
321
430
|
def get_available_tools(self):
|
322
431
|
"""Return list of available tools with their configurations."""
|
323
432
|
return [
|
@@ -398,6 +507,13 @@ class PostmanApiWrapper(BaseToolApiWrapper):
|
|
398
507
|
"args_schema": PostmanAnalyze,
|
399
508
|
"ref": self.analyze
|
400
509
|
},
|
510
|
+
{
|
511
|
+
"name": "execute_request",
|
512
|
+
"mode": "execute_request",
|
513
|
+
"description": "Execute a Postman request with environment variables and custom configuration",
|
514
|
+
"args_schema": PostmanExecuteRequest,
|
515
|
+
"ref": self.execute_request
|
516
|
+
},
|
401
517
|
# {
|
402
518
|
# "name": "create_collection",
|
403
519
|
# "mode": "create_collection",
|
@@ -583,6 +699,224 @@ class PostmanApiWrapper(BaseToolApiWrapper):
|
|
583
699
|
logger.error(f"Exception when getting collections: {stacktrace}")
|
584
700
|
raise ToolException(f"Unable to get collections: {str(e)}")
|
585
701
|
|
702
|
+
def execute_request(self, request_path: str, override_variables: Dict = None, **kwargs) -> str:
|
703
|
+
"""Execute a Postman request with environment variables and custom configuration.
|
704
|
+
|
705
|
+
This method uses the environment_config to make actual HTTP requests
|
706
|
+
using the requests library with structured authentication.
|
707
|
+
|
708
|
+
Args:
|
709
|
+
request_path: The path to the request in the collection
|
710
|
+
override_variables: Optional variables to override environment/collection variables
|
711
|
+
|
712
|
+
Returns:
|
713
|
+
JSON string with comprehensive response data
|
714
|
+
"""
|
715
|
+
try:
|
716
|
+
import time
|
717
|
+
from urllib.parse import urlencode, parse_qs, urlparse
|
718
|
+
|
719
|
+
# Get the request from the collection
|
720
|
+
request_item, _, collection_data = self._get_request_item_and_id(request_path)
|
721
|
+
request_data = request_item.get('request', {})
|
722
|
+
|
723
|
+
# Gather all variables from different sources
|
724
|
+
all_variables = {}
|
725
|
+
|
726
|
+
# 1. Start with environment_config variables (lowest priority)
|
727
|
+
all_variables.update(self.environment_config)
|
728
|
+
|
729
|
+
# 2. Add collection variables
|
730
|
+
collection_variables = collection_data.get('variable', [])
|
731
|
+
for var in collection_variables:
|
732
|
+
if isinstance(var, dict) and 'key' in var:
|
733
|
+
all_variables[var['key']] = var.get('value', '')
|
734
|
+
|
735
|
+
# 3. Add override variables (highest priority)
|
736
|
+
if override_variables:
|
737
|
+
all_variables.update(override_variables)
|
738
|
+
|
739
|
+
# Helper function to resolve variables in strings
|
740
|
+
def resolve_variables(text):
|
741
|
+
if not isinstance(text, str):
|
742
|
+
return text
|
743
|
+
|
744
|
+
# Replace {{variable}} patterns
|
745
|
+
import re
|
746
|
+
def replace_var(match):
|
747
|
+
var_name = match.group(1)
|
748
|
+
return str(all_variables.get(var_name, match.group(0)))
|
749
|
+
|
750
|
+
return re.sub(r'\{\{([^}]+)\}\}', replace_var, text)
|
751
|
+
|
752
|
+
# Prepare the request
|
753
|
+
method = request_data.get('method', 'GET').upper()
|
754
|
+
|
755
|
+
# Handle URL
|
756
|
+
url_data = request_data.get('url', '')
|
757
|
+
if isinstance(url_data, str):
|
758
|
+
url = resolve_variables(url_data)
|
759
|
+
params = {}
|
760
|
+
else:
|
761
|
+
# URL is an object
|
762
|
+
raw_url = resolve_variables(url_data.get('raw', ''))
|
763
|
+
url = raw_url
|
764
|
+
|
765
|
+
# Extract query parameters
|
766
|
+
params = {}
|
767
|
+
query_params = url_data.get('query', [])
|
768
|
+
for param in query_params:
|
769
|
+
if isinstance(param, dict) and not param.get('disabled', False):
|
770
|
+
key = resolve_variables(param.get('key', ''))
|
771
|
+
value = resolve_variables(param.get('value', ''))
|
772
|
+
if key:
|
773
|
+
params[key] = value
|
774
|
+
|
775
|
+
# Prepare headers
|
776
|
+
headers = {}
|
777
|
+
|
778
|
+
# Handle authentication from environment_config
|
779
|
+
self._apply_authentication(headers, params, all_variables, resolve_variables)
|
780
|
+
|
781
|
+
# Add headers from request
|
782
|
+
request_headers = request_data.get('header', [])
|
783
|
+
for header in request_headers:
|
784
|
+
if isinstance(header, dict) and not header.get('disabled', False):
|
785
|
+
key = resolve_variables(header.get('key', ''))
|
786
|
+
value = resolve_variables(header.get('value', ''))
|
787
|
+
if key:
|
788
|
+
headers[key] = value
|
789
|
+
|
790
|
+
# Prepare body
|
791
|
+
body = None
|
792
|
+
content_type = headers.get('Content-Type', '').lower()
|
793
|
+
|
794
|
+
request_body = request_data.get('body', {})
|
795
|
+
if request_body:
|
796
|
+
body_mode = request_body.get('mode', '')
|
797
|
+
|
798
|
+
if body_mode == 'raw':
|
799
|
+
raw_body = request_body.get('raw', '')
|
800
|
+
body = resolve_variables(raw_body)
|
801
|
+
|
802
|
+
# Try to parse as JSON if content type suggests it
|
803
|
+
if 'application/json' in content_type:
|
804
|
+
try:
|
805
|
+
# Validate JSON
|
806
|
+
json.loads(body)
|
807
|
+
except json.JSONDecodeError:
|
808
|
+
logger.warning("Body is not valid JSON despite Content-Type")
|
809
|
+
|
810
|
+
elif body_mode == 'formdata':
|
811
|
+
# Handle form data
|
812
|
+
form_data = {}
|
813
|
+
formdata_items = request_body.get('formdata', [])
|
814
|
+
for item in formdata_items:
|
815
|
+
if isinstance(item, dict) and not item.get('disabled', False):
|
816
|
+
key = resolve_variables(item.get('key', ''))
|
817
|
+
value = resolve_variables(item.get('value', ''))
|
818
|
+
if key:
|
819
|
+
form_data[key] = value
|
820
|
+
body = form_data
|
821
|
+
|
822
|
+
elif body_mode == 'urlencoded':
|
823
|
+
# Handle URL encoded data
|
824
|
+
urlencoded_data = {}
|
825
|
+
urlencoded_items = request_body.get('urlencoded', [])
|
826
|
+
for item in urlencoded_items:
|
827
|
+
if isinstance(item, dict) and not item.get('disabled', False):
|
828
|
+
key = resolve_variables(item.get('key', ''))
|
829
|
+
value = resolve_variables(item.get('value', ''))
|
830
|
+
if key:
|
831
|
+
urlencoded_data[key] = value
|
832
|
+
body = urlencode(urlencoded_data)
|
833
|
+
if 'content-type' not in [h.lower() for h in headers.keys()]:
|
834
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
835
|
+
|
836
|
+
# Execute the request
|
837
|
+
start_time = time.time()
|
838
|
+
|
839
|
+
logger.info(f"Executing {method} request to {url}")
|
840
|
+
|
841
|
+
# Create a new session for this request (separate from Postman API session)
|
842
|
+
exec_session = requests.Session()
|
843
|
+
|
844
|
+
# Prepare request kwargs
|
845
|
+
request_kwargs = {
|
846
|
+
'timeout': self.timeout,
|
847
|
+
'params': params if params else None,
|
848
|
+
'headers': headers if headers else None
|
849
|
+
}
|
850
|
+
|
851
|
+
# Add body based on content type and method
|
852
|
+
if body is not None and method in ['POST', 'PUT', 'PATCH']:
|
853
|
+
if isinstance(body, dict):
|
854
|
+
# Form data
|
855
|
+
request_kwargs['data'] = body
|
856
|
+
elif isinstance(body, str):
|
857
|
+
if 'application/json' in content_type:
|
858
|
+
request_kwargs['json'] = json.loads(body) if body.strip() else {}
|
859
|
+
else:
|
860
|
+
request_kwargs['data'] = body
|
861
|
+
else:
|
862
|
+
request_kwargs['data'] = body
|
863
|
+
|
864
|
+
# Execute the request
|
865
|
+
response = exec_session.request(method, url, **request_kwargs)
|
866
|
+
|
867
|
+
end_time = time.time()
|
868
|
+
elapsed_time = end_time - start_time
|
869
|
+
|
870
|
+
# Parse response
|
871
|
+
response_data = {
|
872
|
+
"request": {
|
873
|
+
"path": request_path,
|
874
|
+
"method": method,
|
875
|
+
"url": url,
|
876
|
+
"headers": dict(headers) if headers else {},
|
877
|
+
"params": dict(params) if params else {},
|
878
|
+
"body": body if body is not None else None
|
879
|
+
},
|
880
|
+
"response": {
|
881
|
+
"status_code": response.status_code,
|
882
|
+
"status_text": response.reason,
|
883
|
+
"headers": dict(response.headers),
|
884
|
+
"elapsed_time_seconds": round(elapsed_time, 3),
|
885
|
+
"size_bytes": len(response.content)
|
886
|
+
},
|
887
|
+
"variables_used": dict(all_variables),
|
888
|
+
"success": response.ok
|
889
|
+
}
|
890
|
+
|
891
|
+
# Add response body
|
892
|
+
try:
|
893
|
+
# Try to parse as JSON
|
894
|
+
response_data["response"]["body"] = response.json()
|
895
|
+
response_data["response"]["content_type"] = "application/json"
|
896
|
+
except json.JSONDecodeError:
|
897
|
+
# Fall back to text
|
898
|
+
try:
|
899
|
+
response_data["response"]["body"] = response.text
|
900
|
+
response_data["response"]["content_type"] = "text/plain"
|
901
|
+
except UnicodeDecodeError:
|
902
|
+
# Binary content
|
903
|
+
response_data["response"]["body"] = f"<binary content: {len(response.content)} bytes>"
|
904
|
+
response_data["response"]["content_type"] = "binary"
|
905
|
+
|
906
|
+
# Add error details if request failed
|
907
|
+
if not response.ok:
|
908
|
+
response_data["error"] = {
|
909
|
+
"message": f"HTTP {response.status_code}: {response.reason}",
|
910
|
+
"status_code": response.status_code
|
911
|
+
}
|
912
|
+
|
913
|
+
return json.dumps(response_data, indent=2)
|
914
|
+
|
915
|
+
except Exception as e:
|
916
|
+
stacktrace = format_exc()
|
917
|
+
logger.error(f"Exception when executing request: {stacktrace}")
|
918
|
+
raise ToolException(f"Unable to execute request '{request_path}': {str(e)}")
|
919
|
+
|
586
920
|
def get_collection(self, **kwargs) -> str:
|
587
921
|
"""Get a specific collection by ID."""
|
588
922
|
try:
|
@@ -1699,6 +2033,7 @@ class PostmanApiWrapper(BaseToolApiWrapper):
|
|
1699
2033
|
# Check if this is a folder (has 'item' property) or a request
|
1700
2034
|
if 'item' in item:
|
1701
2035
|
# This is a folder
|
2036
|
+
|
1702
2037
|
result['items'][current_path] = {
|
1703
2038
|
"type": "folder",
|
1704
2039
|
"id": item.get('id'),
|
@@ -0,0 +1,62 @@
|
|
1
|
+
from typing import List, Literal, Optional
|
2
|
+
|
3
|
+
from langchain_community.agent_toolkits.base import BaseToolkit
|
4
|
+
from langchain_core.tools import BaseTool
|
5
|
+
from pydantic import create_model, BaseModel, Field, SecretStr
|
6
|
+
|
7
|
+
from .api_wrapper import ZephyrSquadApiWrapper
|
8
|
+
from ..base.tool import BaseAction
|
9
|
+
from ..utils import clean_string, TOOLKIT_SPLITTER, get_max_toolkit_length
|
10
|
+
|
11
|
+
name = "zephyr"
|
12
|
+
|
13
|
+
def get_tools(tool):
|
14
|
+
return ZephyrSquadToolkit().get_toolkit(
|
15
|
+
selected_tools=tool['settings'].get('selected_tools', []),
|
16
|
+
account_id=tool['settings']["account_id"],
|
17
|
+
access_key=tool['settings']["access_key"],
|
18
|
+
secret_key=tool['settings']["secret_key"],
|
19
|
+
toolkit_name=tool.get('toolkit_name')
|
20
|
+
).get_tools()
|
21
|
+
|
22
|
+
class ZephyrSquadToolkit(BaseToolkit):
|
23
|
+
tools: List[BaseTool] = []
|
24
|
+
toolkit_max_length: int = 0
|
25
|
+
|
26
|
+
@staticmethod
|
27
|
+
def toolkit_config_schema() -> BaseModel:
|
28
|
+
selected_tools = {x['name']: x['args_schema'].schema() for x in ZephyrSquadApiWrapper.model_construct().get_available_tools()}
|
29
|
+
ZephyrSquadToolkit.toolkit_max_length = get_max_toolkit_length(selected_tools)
|
30
|
+
return create_model(
|
31
|
+
"zephyr_squad",
|
32
|
+
account_id=(str, Field(description="AccountID for the user that is going to be authenticating")),
|
33
|
+
access_key=(str, Field(description="Generated access key")),
|
34
|
+
secret_key=(SecretStr, Field(description="Generated secret key")),
|
35
|
+
selected_tools=(List[Literal[tuple(selected_tools)]], Field(default=[], json_schema_extra={'args_schemas': selected_tools})),
|
36
|
+
__config__={'json_schema_extra': {'metadata': {"label": "Zephyr Squad", "icon_url": "zephyr.svg",
|
37
|
+
"categories": ["test management"],
|
38
|
+
"extra_categories": ["test automation", "test case management", "test planning"]
|
39
|
+
}}}
|
40
|
+
)
|
41
|
+
|
42
|
+
@classmethod
|
43
|
+
def get_toolkit(cls, selected_tools: list[str] | None = None, toolkit_name: Optional[str] = None, **kwargs):
|
44
|
+
zephyr_api_wrapper = ZephyrSquadApiWrapper(**kwargs)
|
45
|
+
prefix = clean_string(toolkit_name, cls.toolkit_max_length) + TOOLKIT_SPLITTER if toolkit_name else ''
|
46
|
+
available_tools = zephyr_api_wrapper.get_available_tools()
|
47
|
+
tools = []
|
48
|
+
for tool in available_tools:
|
49
|
+
if selected_tools:
|
50
|
+
if tool["name"] not in selected_tools:
|
51
|
+
continue
|
52
|
+
tools.append(BaseAction(
|
53
|
+
api_wrapper=zephyr_api_wrapper,
|
54
|
+
name=prefix + tool["name"],
|
55
|
+
description=tool["description"],
|
56
|
+
args_schema=tool["args_schema"]
|
57
|
+
))
|
58
|
+
return cls(tools=tools)
|
59
|
+
|
60
|
+
def get_tools(self):
|
61
|
+
return self.tools
|
62
|
+
|
@@ -0,0 +1,135 @@
|
|
1
|
+
from typing import List, Literal
|
2
|
+
|
3
|
+
from pydantic import model_validator, create_model, Field, SecretStr, BaseModel, PrivateAttr
|
4
|
+
|
5
|
+
from .zephyr_squad_cloud_client import ZephyrSquadCloud
|
6
|
+
from ..elitea_base import BaseToolApiWrapper
|
7
|
+
|
8
|
+
|
9
|
+
class ZephyrSquadApiWrapper(BaseToolApiWrapper):
|
10
|
+
account_id: str
|
11
|
+
access_key: str
|
12
|
+
secret_key: SecretStr
|
13
|
+
_client: ZephyrSquadCloud = PrivateAttr()
|
14
|
+
|
15
|
+
@model_validator(mode='before')
|
16
|
+
@classmethod
|
17
|
+
def validate_toolkit(cls, values):
|
18
|
+
account_id = values.get("account_id", None)
|
19
|
+
access_key = values.get("access_key", None)
|
20
|
+
secret_key = values.get("secret_key", None)
|
21
|
+
if not account_id:
|
22
|
+
raise ValueError("account_id is required.")
|
23
|
+
if not access_key:
|
24
|
+
raise ValueError("access_key is required.")
|
25
|
+
if not secret_key:
|
26
|
+
raise ValueError("secret_key is required.")
|
27
|
+
cls._client = ZephyrSquadCloud(
|
28
|
+
account_id=account_id,
|
29
|
+
access_key=access_key,
|
30
|
+
secret_key=secret_key
|
31
|
+
)
|
32
|
+
return values
|
33
|
+
|
34
|
+
def get_test_step(self, issue_id, step_id, project_id):
|
35
|
+
"""Retrieve details for a specific test step in a Jira test case."""
|
36
|
+
return self._client.get_test_step(issue_id, step_id, project_id)
|
37
|
+
|
38
|
+
def update_test_step(self, issue_id, step_id, project_id, json):
|
39
|
+
"""Update the content or a specific test step in a Jira test case."""
|
40
|
+
return self._client.update_test_step(issue_id, step_id, project_id, json)
|
41
|
+
|
42
|
+
def delete_test_step(self, issue_id, step_id, project_id):
|
43
|
+
"""Remove a specific test step from a Jira test case."""
|
44
|
+
return self._client.delete_test_step(issue_id, step_id, project_id)
|
45
|
+
|
46
|
+
def create_new_test_step(self, issue_id, project_id, json):
|
47
|
+
"""Add a new test step to a Jira test case."""
|
48
|
+
return self._client.create_new_test_step(issue_id, project_id, json)
|
49
|
+
|
50
|
+
def get_all_test_steps(self, issue_id, project_id):
|
51
|
+
"""List all test steps associated with a Jira test case."""
|
52
|
+
return self._client.get_all_test_steps(issue_id, project_id)
|
53
|
+
|
54
|
+
def get_all_test_step_statuses(self):
|
55
|
+
"""Retrieve all possible statuses for test steps in Jira."""
|
56
|
+
return self._client.get_all_test_step_statuses()
|
57
|
+
|
58
|
+
def get_available_tools(self):
|
59
|
+
return [
|
60
|
+
{
|
61
|
+
"name": "get_test_step",
|
62
|
+
"description": self.get_test_step.__doc__,
|
63
|
+
"args_schema": ProjectIssueStep,
|
64
|
+
"ref": self.get_test_step,
|
65
|
+
},
|
66
|
+
{
|
67
|
+
"name": "update_test_step",
|
68
|
+
"description": self.update_test_step.__doc__,
|
69
|
+
"args_schema": UpdateTestStep,
|
70
|
+
"ref": self.update_test_step,
|
71
|
+
},
|
72
|
+
{
|
73
|
+
"name": "delete_test_step",
|
74
|
+
"description": self.delete_test_step.__doc__,
|
75
|
+
"args_schema": ProjectIssueStep,
|
76
|
+
"ref": self.delete_test_step,
|
77
|
+
},
|
78
|
+
{
|
79
|
+
"name": "create_new_test_step",
|
80
|
+
"description": self.create_new_test_step.__doc__,
|
81
|
+
"args_schema": CreateNewTestStep,
|
82
|
+
"ref": self.create_new_test_step,
|
83
|
+
},
|
84
|
+
{
|
85
|
+
"name": "get_all_test_steps",
|
86
|
+
"description": self.get_all_test_steps.__doc__,
|
87
|
+
"args_schema": ProjectIssue,
|
88
|
+
"ref": self.get_all_test_steps,
|
89
|
+
},
|
90
|
+
{
|
91
|
+
"name": "get_all_test_step_statuses",
|
92
|
+
"description": self.get_all_test_step_statuses.__doc__,
|
93
|
+
"args_schema": create_model("NoInput"),
|
94
|
+
"ref": self.get_all_test_step_statuses,
|
95
|
+
}
|
96
|
+
]
|
97
|
+
|
98
|
+
|
99
|
+
ProjectIssue = create_model(
|
100
|
+
"ProjectIssue",
|
101
|
+
issue_id=(int, Field(description="Jira ticket id of test case to which the test step belongs.")),
|
102
|
+
project_id=(int, Field(description="Jira project id to which test case belongs."))
|
103
|
+
)
|
104
|
+
|
105
|
+
ProjectIssueStep = create_model(
|
106
|
+
"ProjectIssueStep",
|
107
|
+
step_id=(str, Field(description="Test step id to operate.")),
|
108
|
+
__base__=ProjectIssue
|
109
|
+
)
|
110
|
+
|
111
|
+
UpdateTestStep = create_model(
|
112
|
+
"UpdateTestStep",
|
113
|
+
json=(str, Field(description=(
|
114
|
+
"JSON body to update a Zephyr test step. Fields:\n"
|
115
|
+
"- id (string, required): Unique identifier for the test step. Example: \"0001481146115453-3a0480a3ffffc384-0001\"\n"
|
116
|
+
"- step (string, required): Description of the test step. Example: \"Sample Test Step\"\n"
|
117
|
+
"- data (string, optional): Test data used in this step. Example: \"Sample Test Data\"\n"
|
118
|
+
"- result (string, optional): Expected result after executing the step. Example: \"Expected Test Result\"\n"
|
119
|
+
"- customFieldValues (array[object], optional): List of custom field values for the test step. Each object contains:\n"
|
120
|
+
" - customFieldId (string, required): ID of the custom field. Example: \"3ce1c679-7c43-4d37-89f6-757603379e31\"\n"
|
121
|
+
" - value (object, required): Value for the custom field. Example: {\"value\": \"08/21/2018\"}\n"
|
122
|
+
"*IMPORTANT*: Use double quotes for all field names and string values."))),
|
123
|
+
__base__=ProjectIssueStep
|
124
|
+
)
|
125
|
+
|
126
|
+
CreateNewTestStep = create_model(
|
127
|
+
"CreateNewTestStep",
|
128
|
+
json=(str, Field(description=(
|
129
|
+
"JSON body to create a Zephyr test step. Fields:\n"
|
130
|
+
"- step (string, required): Description of the test step. Example: \"Sample Test Step\"\n"
|
131
|
+
"- data (string, optional): Test data used in this step. Example: \"Sample Test Data\"\n"
|
132
|
+
"- result (string, optional): Expected result after executing the step. Example: \"Expected Test Result\"\n"
|
133
|
+
"*IMPORTANT*: Use double quotes for all field names and string values."))),
|
134
|
+
__base__=ProjectIssue
|
135
|
+
)
|
@@ -0,0 +1,79 @@
|
|
1
|
+
import hashlib
|
2
|
+
import time
|
3
|
+
|
4
|
+
import jwt
|
5
|
+
import requests
|
6
|
+
from langchain_core.tools import ToolException
|
7
|
+
|
8
|
+
|
9
|
+
class ZephyrSquadCloud(object):
|
10
|
+
"""
|
11
|
+
Reference: https://zephyrsquad.docs.apiary.io//
|
12
|
+
"""
|
13
|
+
|
14
|
+
def __init__(self, account_id, access_key, secret_key):
|
15
|
+
self.account_id = account_id
|
16
|
+
self.access_key = access_key
|
17
|
+
self.secret_key = secret_key
|
18
|
+
self.base_url = "https://prod-api.zephyr4jiracloud.com/connect"
|
19
|
+
|
20
|
+
def get_test_step(self, issue_id, step_id, project_id):
|
21
|
+
canonical_path = "/public/rest/api/1.0/teststep/issueId/id?projectId="
|
22
|
+
api_path = f"/public/rest/api/1.0/teststep/{issue_id}/{step_id}?projectId={project_id}"
|
23
|
+
return self._do_request(method="GET", api_path=api_path, canonical_path=canonical_path)
|
24
|
+
|
25
|
+
def update_test_step(self, issue_id, step_id, project_id, json):
|
26
|
+
canonical_path = "/public/rest/api/1.0/teststep/issueId/id?projectId="
|
27
|
+
api_path = f"/public/rest/api/1.0/teststep/{issue_id}/{step_id}?projectId={project_id}"
|
28
|
+
return self._do_request(method="PUT", api_path=api_path, canonical_path=canonical_path, json=json)
|
29
|
+
|
30
|
+
def delete_test_step(self, issue_id, step_id, project_id):
|
31
|
+
canonical_path = "/public/rest/api/1.0/teststep/issueId/id?projectId="
|
32
|
+
api_path = f"/public/rest/api/1.0/teststep/{issue_id}/{step_id}?projectId={project_id}"
|
33
|
+
return self._do_request(method="DELETE", api_path=api_path, canonical_path=canonical_path)
|
34
|
+
|
35
|
+
def create_new_test_step(self, issue_id, project_id, json):
|
36
|
+
canonical_path = "/public/rest/api/1.0/teststep/issueId?projectId="
|
37
|
+
api_path = f"/public/rest/api/1.0/teststep/{issue_id}?projectId={project_id}"
|
38
|
+
return self._do_request(method="POST", api_path=api_path, canonical_path=canonical_path, json=json)
|
39
|
+
|
40
|
+
def get_all_test_steps(self, issue_id, project_id):
|
41
|
+
canonical_path = "/public/rest/api/2.0/teststep/issueId?projectId="
|
42
|
+
api_path = f"/public/rest/api/2.0/teststep/{issue_id}?projectId={project_id}"
|
43
|
+
return self._do_request(method='GET', api_path=api_path, canonical_path=canonical_path)
|
44
|
+
|
45
|
+
def get_all_test_step_statuses(self):
|
46
|
+
api_path = "/public/rest/api/1.0/teststep/statuses"
|
47
|
+
return self._do_request(method='GET', api_path=api_path)
|
48
|
+
|
49
|
+
def _do_request(self, method, api_path, canonical_path=None, json=None):
|
50
|
+
url = f"{self.base_url}{api_path}"
|
51
|
+
headers = {
|
52
|
+
"Authorization": f"JWT {self._generate_jwt_token(method, canonical_path or api_path)}",
|
53
|
+
"zapiAccessKey": self.access_key,
|
54
|
+
"Content-Type": "application/json"
|
55
|
+
}
|
56
|
+
|
57
|
+
try:
|
58
|
+
resp = requests.request(method=method, url=url, json=json, headers=headers)
|
59
|
+
|
60
|
+
if resp.ok:
|
61
|
+
if resp.headers.get("Content-Type", "").startswith("application/json"):
|
62
|
+
return str(resp.json())
|
63
|
+
else:
|
64
|
+
return resp.text
|
65
|
+
else:
|
66
|
+
raise ToolException(f"Request failed with status {resp.status_code}: {resp.text}")
|
67
|
+
except Exception as e:
|
68
|
+
raise ToolException(f"Error performing request {method}:{api_path}: {e}")
|
69
|
+
|
70
|
+
def _generate_jwt_token(self, method, path):
|
71
|
+
canonical_path = f"{method}&{path}&"
|
72
|
+
payload_token = {
|
73
|
+
'sub': self.account_id,
|
74
|
+
'qsh': hashlib.sha256(canonical_path.encode('utf-8')).hexdigest(),
|
75
|
+
'iss': self.access_key,
|
76
|
+
'exp': int(time.time()) + 3600,
|
77
|
+
'iat': int(time.time())
|
78
|
+
}
|
79
|
+
return jwt.encode(payload_token, self.secret_key, algorithm='HS256').strip()
|