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.
Files changed (27) hide show
  1. alita_sdk/runtime/langchain/assistant.py +2 -2
  2. alita_sdk/runtime/langchain/store_manager.py +22 -1
  3. alita_sdk/runtime/toolkits/tools.py +1 -1
  4. alita_sdk/tools/__init__.py +7 -1
  5. alita_sdk/tools/carrier/api_wrapper.py +76 -4
  6. alita_sdk/tools/carrier/backend_reports_tool.py +31 -12
  7. alita_sdk/tools/carrier/backend_tests_tool.py +14 -8
  8. alita_sdk/tools/carrier/cancel_ui_test_tool.py +178 -0
  9. alita_sdk/tools/carrier/carrier_sdk.py +99 -15
  10. alita_sdk/tools/carrier/create_ui_excel_report_tool.py +473 -0
  11. alita_sdk/tools/carrier/create_ui_test_tool.py +199 -0
  12. alita_sdk/tools/carrier/lighthouse_excel_reporter.py +155 -0
  13. alita_sdk/tools/carrier/run_ui_test_tool.py +394 -0
  14. alita_sdk/tools/carrier/tools.py +11 -1
  15. alita_sdk/tools/carrier/ui_reports_tool.py +6 -2
  16. alita_sdk/tools/carrier/update_ui_test_schedule_tool.py +278 -0
  17. alita_sdk/tools/memory/__init__.py +7 -0
  18. alita_sdk/tools/postman/__init__.py +7 -0
  19. alita_sdk/tools/postman/api_wrapper.py +335 -0
  20. alita_sdk/tools/zephyr_squad/__init__.py +62 -0
  21. alita_sdk/tools/zephyr_squad/api_wrapper.py +135 -0
  22. alita_sdk/tools/zephyr_squad/zephyr_squad_cloud_client.py +79 -0
  23. {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/METADATA +4 -3
  24. {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/RECORD +27 -18
  25. {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/WHEEL +0 -0
  26. {alita_sdk-0.3.162.dist-info → alita_sdk-0.3.164.dist-info}/licenses/LICENSE +0 -0
  27. {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()