qubership-pipelines-common-library 2.0.1__py3-none-any.whl → 2.0.3__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.
- qubership_pipelines_common_library/v1/utils/utils_cli.py +37 -26
- qubership_pipelines_common_library/v1/utils/utils_logging.py +0 -1
- qubership_pipelines_common_library/v2/jira/__init__.py +0 -0
- qubership_pipelines_common_library/v2/jira/jira_add_ticket_comment_command.py +100 -0
- qubership_pipelines_common_library/v2/jira/jira_client.py +215 -0
- qubership_pipelines_common_library/v2/jira/jira_create_ticket_command.py +126 -0
- qubership_pipelines_common_library/v2/jira/jira_update_ticket_command.py +139 -0
- qubership_pipelines_common_library/v2/jira/jira_utils.py +19 -0
- qubership_pipelines_common_library/v2/notifications/__init__.py +0 -0
- qubership_pipelines_common_library/v2/notifications/send_email_command.py +150 -0
- qubership_pipelines_common_library/v2/notifications/send_webex_message_command.py +131 -0
- qubership_pipelines_common_library/v2/utils/crypto_utils.py +2 -0
- {qubership_pipelines_common_library-2.0.1.dist-info → qubership_pipelines_common_library-2.0.3.dist-info}/METADATA +2 -2
- {qubership_pipelines_common_library-2.0.1.dist-info → qubership_pipelines_common_library-2.0.3.dist-info}/RECORD +16 -7
- {qubership_pipelines_common_library-2.0.1.dist-info → qubership_pipelines_common_library-2.0.3.dist-info}/WHEEL +1 -1
- {qubership_pipelines_common_library-2.0.1.dist-info → qubership_pipelines_common_library-2.0.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
import re
|
|
3
|
-
|
|
4
|
+
import sys
|
|
4
5
|
import click
|
|
5
|
-
from rich import box
|
|
6
|
-
from rich.logging import RichHandler
|
|
7
|
-
from rich.panel import Panel
|
|
8
6
|
|
|
9
7
|
from qubership_pipelines_common_library.v1.execution.exec_logger import ExecutionLogger
|
|
10
|
-
from qubership_pipelines_common_library.v1.utils.
|
|
11
|
-
LevelColorFilter
|
|
8
|
+
from qubership_pipelines_common_library.v1.utils.utils_string import UtilsString
|
|
12
9
|
|
|
13
10
|
DEFAULT_CONTEXT_FILE_PATH = 'context.yaml'
|
|
14
11
|
|
|
@@ -37,26 +34,34 @@ def utils_cli(func):
|
|
|
37
34
|
|
|
38
35
|
def _configure_global_logger(global_logger: logging.Logger, log_level: str):
|
|
39
36
|
"""Configure the global logger with a specific log level and formatter."""
|
|
37
|
+
log_level_value = getattr(logging, log_level.upper(), logging.INFO)
|
|
40
38
|
global_logger.setLevel(logging.DEBUG)
|
|
41
39
|
if global_logger.hasHandlers():
|
|
42
40
|
global_logger.handlers.clear()
|
|
43
41
|
global_logger.propagate = True
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
if UtilsString.convert_to_bool(os.getenv('NO_RICH', False)):
|
|
43
|
+
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
44
|
+
stdout_handler.setLevel(log_level_value)
|
|
45
|
+
stdout_handler.setFormatter(logging.Formatter(ExecutionLogger.DEFAULT_FORMAT))
|
|
46
|
+
global_logger.addHandler(stdout_handler)
|
|
47
|
+
else:
|
|
48
|
+
from rich.logging import RichHandler
|
|
49
|
+
from qubership_pipelines_common_library.v1.utils.utils_logging import rich_console, ExtendedReprHighlighter, LevelColorFilter
|
|
50
|
+
rich_handler = RichHandler(
|
|
51
|
+
console=rich_console,
|
|
52
|
+
show_time=False,
|
|
53
|
+
show_level=False,
|
|
54
|
+
show_path=False,
|
|
55
|
+
enable_link_path=False,
|
|
56
|
+
rich_tracebacks=True,
|
|
57
|
+
tracebacks_show_locals=False,
|
|
58
|
+
markup=True,
|
|
59
|
+
highlighter=ExtendedReprHighlighter(),
|
|
60
|
+
)
|
|
61
|
+
rich_handler.addFilter(LevelColorFilter())
|
|
62
|
+
rich_handler.setFormatter(logging.Formatter(ExecutionLogger.LEVELNAME_COLORED_FORMAT))
|
|
63
|
+
rich_handler.setLevel(log_level_value)
|
|
64
|
+
global_logger.addHandler(rich_handler)
|
|
60
65
|
|
|
61
66
|
|
|
62
67
|
def _print_command_name():
|
|
@@ -67,10 +72,16 @@ def _print_command_name():
|
|
|
67
72
|
logging.getLogger().warning("Can't find command name.")
|
|
68
73
|
command_name = ""
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
if UtilsString.convert_to_bool(os.getenv('NO_RICH', False)):
|
|
76
|
+
logging.info(f"command_name = {command_name}")
|
|
77
|
+
else:
|
|
78
|
+
from rich import box
|
|
79
|
+
from rich.panel import Panel
|
|
80
|
+
from qubership_pipelines_common_library.v1.utils.utils_logging import rich_console
|
|
81
|
+
command_panel = Panel(f"command_name = {command_name}", expand=False, padding=(0, 1), box=box.ROUNDED)
|
|
82
|
+
rich_console.print()
|
|
83
|
+
rich_console.print(command_panel)
|
|
84
|
+
rich_console.print()
|
|
74
85
|
|
|
75
86
|
|
|
76
87
|
def _transform_kwargs(kwargs):
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
2
|
+
from qubership_pipelines_common_library.v2.jira.jira_client import JiraClient, AuthType
|
|
3
|
+
from qubership_pipelines_common_library.v2.jira.jira_utils import JiraUtils
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JiraAddTicketComment(ExecutionCommand):
|
|
7
|
+
"""
|
|
8
|
+
Adds comment to JIRA ticket and retrieves latest comments.
|
|
9
|
+
|
|
10
|
+
Input Parameters Structure (this structure is expected inside "input_params.params" block):
|
|
11
|
+
```
|
|
12
|
+
{
|
|
13
|
+
"ticket": {
|
|
14
|
+
"id": "BUG-567", # REQUIRED: Ticket ID
|
|
15
|
+
"comment": "your comment body", # REQUIRED: Comment body
|
|
16
|
+
"latest_comments_count": 50, # OPTIONAL: Number of latest comments to fetch
|
|
17
|
+
},
|
|
18
|
+
"retry_timeout_seconds": 180, # OPTIONAL: Timeout for JIRA client operations in seconds (default: 180)
|
|
19
|
+
"retry_wait_seconds": 1, # OPTIONAL: Wait interval between retries in seconds (default: 1)
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Systems Configuration (expected in "systems.jira" block):
|
|
24
|
+
```
|
|
25
|
+
{
|
|
26
|
+
"url": "https://your_cloud_jira.atlassian.net", # REQUIRED: JIRA server URL
|
|
27
|
+
"username": "your_username_or_email", # REQUIRED: JIRA user login or email
|
|
28
|
+
"password": "<your_token>", # REQUIRED: JIRA user token
|
|
29
|
+
"auth_type": "basic" # OPTIONAL: 'basic' or 'bearer'
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Command name: "jira-add-ticket-comment"
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
RETRY_TIMEOUT_SECONDS = 180 # default value, how many seconds to try
|
|
37
|
+
RETRY_WAIT_SECONDS = 1 # default value, how many seconds between tries
|
|
38
|
+
LATEST_COMMENTS_COUNT = 50 # default value, max amount of comments in response
|
|
39
|
+
|
|
40
|
+
def _validate(self):
|
|
41
|
+
names = [
|
|
42
|
+
"paths.input.params",
|
|
43
|
+
"paths.output.params",
|
|
44
|
+
"systems.jira.url",
|
|
45
|
+
"systems.jira.username",
|
|
46
|
+
"systems.jira.password",
|
|
47
|
+
"params.ticket.id",
|
|
48
|
+
"params.ticket.comment",
|
|
49
|
+
]
|
|
50
|
+
if not self.context.validate(names):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
self.retry_timeout_seconds = int(self.context.input_param_get("params.retry_timeout_seconds", self.RETRY_TIMEOUT_SECONDS))
|
|
54
|
+
self.retry_wait_seconds = int(self.context.input_param_get("params.retry_wait_seconds", self.RETRY_WAIT_SECONDS))
|
|
55
|
+
|
|
56
|
+
self.jira_url = self.context.input_param_get("systems.jira.url").rstrip('/')
|
|
57
|
+
self.jira_username = self.context.input_param_get("systems.jira.username")
|
|
58
|
+
self.jira_password = self.context.input_param_get("systems.jira.password")
|
|
59
|
+
self.auth_type = self.context.input_param_get("systems.jira.auth_type", AuthType.BASIC)
|
|
60
|
+
|
|
61
|
+
self.ticket_key = self.context.input_param_get("params.ticket.id")
|
|
62
|
+
self.ticket_comment = self.context.input_param_get("params.ticket.comment")
|
|
63
|
+
self.latest_comments_count = int(self.context.input_param_get("params.ticket.latest_comments_count", self.LATEST_COMMENTS_COUNT))
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def _execute(self):
|
|
67
|
+
self.context.logger.info("Running jira-add-ticket-comment")
|
|
68
|
+
self.jira_client = JiraClient.create_jira_client(
|
|
69
|
+
self.jira_url, self.jira_username, self.jira_password, self.auth_type,
|
|
70
|
+
self.retry_timeout_seconds, self.retry_wait_seconds,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if self.ticket_comment:
|
|
74
|
+
JiraUtils.add_ticket_comment(self)
|
|
75
|
+
|
|
76
|
+
total_comments = 0
|
|
77
|
+
parsed_latest_comments = []
|
|
78
|
+
try:
|
|
79
|
+
self.latest_comments = self.jira_client.get_latest_ticket_comments(self.ticket_key, max_results=self.latest_comments_count)
|
|
80
|
+
total_comments = len(self.latest_comments)
|
|
81
|
+
parsed_latest_comments = [self._parse_comment(comment) for comment in self.latest_comments]
|
|
82
|
+
except Exception as e:
|
|
83
|
+
self.context.logger.warning(f"Can't get latest ticket comments. Response exception: {str(e)}")
|
|
84
|
+
|
|
85
|
+
self.context.output_param_set("params.ticket.id", self.ticket_key)
|
|
86
|
+
self.context.output_param_set("params.ticket.url", f"{self.jira_url}/browse/{self.ticket_key}")
|
|
87
|
+
self.context.output_param_set("params.ticket.total_comments", total_comments)
|
|
88
|
+
self.context.output_param_set("params.ticket.latest_comments", parsed_latest_comments)
|
|
89
|
+
self.context.output_params_save()
|
|
90
|
+
self.context.logger.info("Add ticket comment request executed. See output params for details")
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _parse_comment(comment):
|
|
94
|
+
return {
|
|
95
|
+
"body": comment.get("body"),
|
|
96
|
+
"created": comment.get("created"),
|
|
97
|
+
"updated": comment.get("updated"),
|
|
98
|
+
"author": JiraClient.serialize_person_ref(comment.get("author", {})),
|
|
99
|
+
"updateAuthor": JiraClient.serialize_person_ref(comment.get("updateAuthor", {})),
|
|
100
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
from requests import Response
|
|
9
|
+
from requests.auth import HTTPBasicAuth
|
|
10
|
+
from qubership_pipelines_common_library.v2.utils.retry_decorator import RetryDecorator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthType(StrEnum):
|
|
14
|
+
BASIC = 'basic'
|
|
15
|
+
BEARER = 'bearer'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JiraClient:
|
|
19
|
+
|
|
20
|
+
DEFAULT_FIELD_NAMES_FILTER = [
|
|
21
|
+
"fixVersions",
|
|
22
|
+
"resolution",
|
|
23
|
+
"priority",
|
|
24
|
+
"labels",
|
|
25
|
+
"versions",
|
|
26
|
+
"assignee",
|
|
27
|
+
"status",
|
|
28
|
+
"components",
|
|
29
|
+
"creator",
|
|
30
|
+
"reporter",
|
|
31
|
+
"issuetype",
|
|
32
|
+
"project",
|
|
33
|
+
"resolutiondate",
|
|
34
|
+
"created",
|
|
35
|
+
"updated",
|
|
36
|
+
"description",
|
|
37
|
+
"summary",
|
|
38
|
+
"customfield_10014", # Found in
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
API_VERSION = "2"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
@RetryDecorator(condition_func=lambda client: client is not None)
|
|
45
|
+
def create_jira_client(cls, host: str, user: str, password: str, auth_type: str,
|
|
46
|
+
retry_timeout_seconds: int = 180, retry_wait_seconds: int = 1):
|
|
47
|
+
return cls(host, user, password, auth_type)
|
|
48
|
+
|
|
49
|
+
def __init__(self, host: str, user: str, password: str, auth_type: str = AuthType.BASIC):
|
|
50
|
+
self.host = host.rstrip("/")
|
|
51
|
+
self.user = user
|
|
52
|
+
self.password = password
|
|
53
|
+
self.session = requests.Session()
|
|
54
|
+
self.session.verify = os.getenv("PYTHONHTTPSVERIFY", "1") != "0"
|
|
55
|
+
if auth_type.lower() == AuthType.BEARER:
|
|
56
|
+
self.session.headers.update({"Authorization": f"Bearer {password}"})
|
|
57
|
+
else:
|
|
58
|
+
self.session.auth = HTTPBasicAuth(user, password)
|
|
59
|
+
self.headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
60
|
+
self.session.headers.update(self.headers)
|
|
61
|
+
self.logger = logging.getLogger()
|
|
62
|
+
try:
|
|
63
|
+
self.server_info = self.get_server_info()
|
|
64
|
+
self.deployment_type = self.server_info.get("deploymentType", "Server")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
self.logger.info(f"Could not get Jira instance version, assuming 'Server': {e}")
|
|
67
|
+
self.deployment_type = "Server"
|
|
68
|
+
self.logger.info(f"Jira Client configured for {self.host}, deployment type: {self.deployment_type}")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def _is_cloud(self) -> bool:
|
|
72
|
+
return self.deployment_type == "Cloud"
|
|
73
|
+
|
|
74
|
+
def get_server_info(self) -> dict[str, Any]:
|
|
75
|
+
retry = 0
|
|
76
|
+
j = self._get_json("serverInfo")
|
|
77
|
+
while not j and retry < 3:
|
|
78
|
+
retry += 1
|
|
79
|
+
j = self._get_json("serverInfo")
|
|
80
|
+
return j
|
|
81
|
+
|
|
82
|
+
@RetryDecorator(condition_func=lambda response: response is not None and (response.ok or response.status_code == 400)) # do not retry BadRequest
|
|
83
|
+
def add_ticket_comment(self, ticket_id: str, comment: str, retry_timeout_seconds: int = 180, retry_wait_seconds: int = 1) -> Response:
|
|
84
|
+
response = self.session.post(
|
|
85
|
+
url=f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}/comment",
|
|
86
|
+
data=json.dumps({"body": comment})
|
|
87
|
+
)
|
|
88
|
+
self.logger.debug(f"Add ticket (id={ticket_id}) comment response: status_code = {response.status_code}, body = {response.text}")
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
def get_latest_ticket_comments(self, ticket_id: str, max_results: int = 50) -> list:
|
|
92
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}/comment?maxResults={max_results}&orderBy=-created")
|
|
93
|
+
self.logger.debug(f"Get ticket (id={ticket_id}) comments response: status_code = {response.status_code}, body = {response.text}")
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
return response.json().get("comments", [])
|
|
96
|
+
|
|
97
|
+
@RetryDecorator(condition_func=lambda response: response is not None and (response.ok or response.status_code == 400))
|
|
98
|
+
def create_ticket(self, ticket_fields: dict, retry_timeout_seconds: int = 180, retry_wait_seconds: int = 1) -> Response:
|
|
99
|
+
body = {"fields": ticket_fields}
|
|
100
|
+
response = self.session.post(f"{self.host}/rest/api/{self.API_VERSION}/issue", data=json.dumps(body))
|
|
101
|
+
self.logger.debug(f"Create ticket response: status_code = {response.status_code}, body = {response.text}")
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
def get_createmeta_fields(self, project_key: str, issue_type_name: str) -> dict:
|
|
105
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/createmeta/{project_key}/issuetypes?maxResults=100")
|
|
106
|
+
self.logger.debug(f"Get createmeta for project (id={project_key}) response: status_code = {response.status_code}, body = {response.text}")
|
|
107
|
+
if not response.ok:
|
|
108
|
+
self.logger.warning(f"Can't get issuetypes by projectKey = {project_key}. Response status = {response.status_code}")
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
issue_types = response.json().get("issueTypes" if self._is_cloud else "values", [])
|
|
112
|
+
issue_type_ids = [issue_type.get("id") for issue_type in issue_types if issue_type.get("name") == issue_type_name]
|
|
113
|
+
if not issue_type_ids:
|
|
114
|
+
self.logger.warning(f"Can't find issue type id by issue_type_name = {issue_type_name}")
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/createmeta/{project_key}/issuetypes/{issue_type_ids[0]}?maxResults=100")
|
|
118
|
+
self.logger.debug(f"Get createmeta for issuetype (id={issue_type_ids[0]}) response: status_code = {response.status_code}, body = {response.text}")
|
|
119
|
+
if not response.ok:
|
|
120
|
+
self.logger.warning(f"Can't get createmeta by projectKey = {project_key} and issue_type_id = {issue_type_ids[0]}. Response status = {response.status_code}")
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
fields = response.json().get("fields" if self._is_cloud else "values", [])
|
|
124
|
+
return {field["fieldId"]: field for field in fields}
|
|
125
|
+
|
|
126
|
+
def get_ticket_fields(self, ticket_id: str, field_names_filter: list) -> dict:
|
|
127
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}?fields={','.join(field_names_filter)}")
|
|
128
|
+
self.logger.debug(f"Get ticket fields (id={ticket_id}) response: status_code = {response.status_code}, body = {response.text}")
|
|
129
|
+
if not response.ok:
|
|
130
|
+
self.logger.warning(f"Can't get ticket info by ticket_id = {ticket_id}. Response status = {response.status_code}")
|
|
131
|
+
return {}
|
|
132
|
+
return self._transform_ticket_fields(response.json().get("fields", {}))
|
|
133
|
+
|
|
134
|
+
def get_editmeta_fields(self, ticket_id: str) -> dict:
|
|
135
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}/editmeta")
|
|
136
|
+
self.logger.debug(f"Get editmeta for ticket (id={ticket_id}) response: status_code = {response.status_code}, body = {response.text}")
|
|
137
|
+
if not response.ok:
|
|
138
|
+
self.logger.warning(f"Can't get ticket {ticket_id} editmeta. Response status = {response.status_code}")
|
|
139
|
+
return {}
|
|
140
|
+
return response.json().get("fields", {})
|
|
141
|
+
|
|
142
|
+
@RetryDecorator(condition_func=lambda response: response is not None and (response.ok or response.status_code == 400))
|
|
143
|
+
def update_ticket(self, ticket_id: str, ticket_fields: dict,
|
|
144
|
+
retry_timeout_seconds: int = 180, retry_wait_seconds: int = 1) -> Response:
|
|
145
|
+
body = {"fields": ticket_fields}
|
|
146
|
+
response = self.session.put(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}", data=json.dumps(body))
|
|
147
|
+
self.logger.debug(f"Update ticket (id={ticket_id}) response: status_code = {response.status_code}, body = {response.text}")
|
|
148
|
+
return response
|
|
149
|
+
|
|
150
|
+
def get_ticket_transitions(self, ticket_id: str):
|
|
151
|
+
response = self.session.get(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}/transitions?expand=transitions.fields")
|
|
152
|
+
self.logger.debug(f"Get ticket (id={ticket_id}) transitions response: status_code = {response.status_code}, body = {response.text}")
|
|
153
|
+
if not response.ok:
|
|
154
|
+
self.logger.warning(f"Ticket {ticket_id} transitions are not found in response. Response status = {response.status_code}")
|
|
155
|
+
return []
|
|
156
|
+
return response.json().get("transitions", [])
|
|
157
|
+
|
|
158
|
+
def find_applicable_transition(self, transitions: list, status_name: str, transition_name: str = ""):
|
|
159
|
+
transitions_by_next_status = [transition for transition in transitions if status_name.lower() == transition.get("to", {}).get("name", "").lower()]
|
|
160
|
+
if not transitions_by_next_status:
|
|
161
|
+
self.logger.error(f"Status '{status_name}' is not found in transitions.")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if len(transitions_by_next_status) > 1:
|
|
165
|
+
self.logger.warning(f"Found more than one transition to status '{status_name}'")
|
|
166
|
+
if transition_name:
|
|
167
|
+
transitions_by_name = [transition for transition in transitions_by_next_status if transition_name.lower() == transition.get("name", "").lower()]
|
|
168
|
+
if transitions_by_name:
|
|
169
|
+
return transitions_by_name[0]
|
|
170
|
+
else:
|
|
171
|
+
self.logger.warning(f"Transition '{transition_name}' is not found in transitions. Will use first found transition by status '{status_name}'.")
|
|
172
|
+
|
|
173
|
+
return transitions_by_next_status[0]
|
|
174
|
+
|
|
175
|
+
def perform_ticket_transition(self, ticket_id: str, transition_id: str, transition_fields):
|
|
176
|
+
body = {"transition": {"id": transition_id}}
|
|
177
|
+
if transition_fields:
|
|
178
|
+
body["fields"] = transition_fields
|
|
179
|
+
response = self.session.post(f"{self.host}/rest/api/{self.API_VERSION}/issue/{ticket_id}/transitions", data=json.dumps(body))
|
|
180
|
+
self.logger.debug(f"Perform transition for ticket (id={ticket_id}) response: status_code = {response.status_code}, body = {response.text}")
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
def _get_json(self, path: str, params: dict[str, Any] | None = None, use_post: bool = False) -> dict | list:
|
|
184
|
+
url = f"{self.host}/rest/api/{self.API_VERSION}/{path}"
|
|
185
|
+
response = (
|
|
186
|
+
self.session.post(url, data=json.dumps(params))
|
|
187
|
+
if use_post
|
|
188
|
+
else self.session.get(url, params=params)
|
|
189
|
+
)
|
|
190
|
+
return response.json()
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def filter_ticket_fields(ticket_fields: dict, meta_fields_filter: dict) -> dict:
|
|
194
|
+
return {field_key: field_value for field_key, field_value in ticket_fields.items() if field_key in meta_fields_filter.keys()}
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _transform_ticket_fields(ticket_fields_json: dict):
|
|
198
|
+
filtered_fields = {}
|
|
199
|
+
for k, v in ticket_fields_json.items():
|
|
200
|
+
if isinstance(v, dict) and "emailAddress" in v:
|
|
201
|
+
filtered_fields[k] = JiraClient.serialize_person_ref(v)
|
|
202
|
+
else:
|
|
203
|
+
filtered_fields[k] = v
|
|
204
|
+
return filtered_fields
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def serialize_person_ref(person: dict) -> dict | None:
|
|
208
|
+
if person:
|
|
209
|
+
return {
|
|
210
|
+
"displayName": person.get("displayName", None),
|
|
211
|
+
"emailAddress": person.get("emailAddress", None),
|
|
212
|
+
"name": person.get("name", None),
|
|
213
|
+
"key": person.get("key", None),
|
|
214
|
+
}
|
|
215
|
+
return None
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
4
|
+
from qubership_pipelines_common_library.v2.jira.jira_client import JiraClient, AuthType
|
|
5
|
+
from qubership_pipelines_common_library.v2.jira.jira_utils import JiraUtils
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JiraCreateTicket(ExecutionCommand):
|
|
9
|
+
"""
|
|
10
|
+
Creates new issue/ticket in JIRA project.
|
|
11
|
+
|
|
12
|
+
Input Parameters Structure (this structure is expected inside "input_params.params" block):
|
|
13
|
+
```
|
|
14
|
+
{
|
|
15
|
+
"ticket": {
|
|
16
|
+
"fields: { # REQUIRED: Dict structure that will be used as ticket-creation-body, without transformations
|
|
17
|
+
"project": {"key": "<YOUR_PROJECT_KEY>"}, # REQUIRED: Project Key
|
|
18
|
+
"issuetype": {"name": "Bug"}, # REQUIRED: Issue type name
|
|
19
|
+
"priority": {"name": "High"}, # OPTIONAL: Other ticket fields with different formats, depending on your Project configuration
|
|
20
|
+
"duedate": "2030-02-20", # OPTIONAL: Text-value fields need no dict wrappers
|
|
21
|
+
"summary": "[SOME_LABEL] Ticket Subject",
|
|
22
|
+
"description": "Ticket body",
|
|
23
|
+
"components": [{"name":"COMPONENT NAME"}],
|
|
24
|
+
"labels": ["Test_Label1"],
|
|
25
|
+
},
|
|
26
|
+
"comment": "your comment body", # OPTIONAL: Comment to add to created ticket
|
|
27
|
+
"field_names_filter": "summary,issuetype,creator,status", # OPTIONAL: Comma-separated names of fields to extract from created ticket to output params
|
|
28
|
+
},
|
|
29
|
+
"retry_timeout_seconds": 180, # OPTIONAL: Timeout for JIRA client operations in seconds (default: 180)
|
|
30
|
+
"retry_wait_seconds": 1, # OPTIONAL: Wait interval between retries in seconds (default: 1)
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Systems Configuration (expected in "systems.jira" block):
|
|
35
|
+
```
|
|
36
|
+
{
|
|
37
|
+
"url": "https://your_cloud_jira.atlassian.net", # REQUIRED: JIRA server URL
|
|
38
|
+
"username": "your_username_or_email", # REQUIRED: JIRA user login or email
|
|
39
|
+
"password": "<your_token>", # REQUIRED: JIRA user token
|
|
40
|
+
"auth_type": "basic" # OPTIONAL: 'basic' or 'bearer'
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Command name: "jira-create-ticket"
|
|
45
|
+
"""
|
|
46
|
+
RETRY_TIMEOUT_SECONDS = 180 # default value, how many seconds to try
|
|
47
|
+
RETRY_WAIT_SECONDS = 1 # default value, how many seconds between tries
|
|
48
|
+
|
|
49
|
+
def _validate(self):
|
|
50
|
+
names = [
|
|
51
|
+
"paths.input.params",
|
|
52
|
+
"paths.output.params",
|
|
53
|
+
"systems.jira.url",
|
|
54
|
+
"systems.jira.username",
|
|
55
|
+
"systems.jira.password",
|
|
56
|
+
"params.ticket.fields",
|
|
57
|
+
]
|
|
58
|
+
if not self.context.validate(names):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
self.retry_timeout_seconds = int(self.context.input_param_get("params.retry_timeout_seconds", self.RETRY_TIMEOUT_SECONDS))
|
|
62
|
+
self.retry_wait_seconds = int(self.context.input_param_get("params.retry_wait_seconds", self.RETRY_WAIT_SECONDS))
|
|
63
|
+
|
|
64
|
+
self.jira_url = self.context.input_param_get("systems.jira.url").rstrip('/')
|
|
65
|
+
self.jira_username = self.context.input_param_get("systems.jira.username")
|
|
66
|
+
self.jira_password = self.context.input_param_get("systems.jira.password")
|
|
67
|
+
self.auth_type = self.context.input_param_get("systems.jira.auth_type", AuthType.BASIC)
|
|
68
|
+
|
|
69
|
+
self.ticket_comment = self.context.input_param_get("params.ticket.comment")
|
|
70
|
+
self.ticket_fields = self.context.input_param_get("params.ticket.fields", {})
|
|
71
|
+
self.project_key = self.ticket_fields.get('project', {}).get('key')
|
|
72
|
+
self.issue_type_name = self.ticket_fields.get('issuetype', {}).get('name')
|
|
73
|
+
|
|
74
|
+
if not self.project_key or not self.issue_type_name:
|
|
75
|
+
self.context.logger.error("Can't find project.key and/or issuetype.name in input parameters")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
if not self._validate_mandatory_ticket_fields(self.ticket_fields):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
if field_names_filter := self.context.input_param_get("params.ticket.field_names_filter"):
|
|
82
|
+
self.field_names_filter = [x.strip() for x in re.split(r'[,;]+', field_names_filter)]
|
|
83
|
+
else:
|
|
84
|
+
self.field_names_filter = JiraClient.DEFAULT_FIELD_NAMES_FILTER
|
|
85
|
+
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def _validate_mandatory_ticket_fields(self, ticket_fields):
|
|
89
|
+
valid = True
|
|
90
|
+
for field_key in ["project", "issuetype", "summary"]:
|
|
91
|
+
if field_key not in ticket_fields:
|
|
92
|
+
valid = False
|
|
93
|
+
self.context.logger.error(f"Parameter '{field_key}' is mandatory but not found in ticket params map")
|
|
94
|
+
return valid
|
|
95
|
+
|
|
96
|
+
def _execute(self):
|
|
97
|
+
self.context.logger.info("Running jira-create-ticket")
|
|
98
|
+
self.context.logger.info(f"Creating ticket in project {self.project_key}, type {self.issue_type_name}")
|
|
99
|
+
self.jira_client = JiraClient.create_jira_client(
|
|
100
|
+
self.jira_url, self.jira_username, self.jira_password, self.auth_type,
|
|
101
|
+
self.retry_timeout_seconds, self.retry_wait_seconds,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
createmeta_fields = self.jira_client.get_createmeta_fields(self.project_key, self.issue_type_name)
|
|
105
|
+
self.filtered_ticket_fields = JiraClient.filter_ticket_fields(self.ticket_fields, createmeta_fields)
|
|
106
|
+
self.context.logger.debug(f"Filtered ticket fields: {self.filtered_ticket_fields}")
|
|
107
|
+
|
|
108
|
+
create_ticket_response = self.jira_client.create_ticket(self.filtered_ticket_fields,
|
|
109
|
+
retry_timeout_seconds=self.retry_timeout_seconds,
|
|
110
|
+
retry_wait_seconds=self.retry_wait_seconds)
|
|
111
|
+
if not create_ticket_response.ok:
|
|
112
|
+
self._exit(False, f"Can't create ticket. Response status: {create_ticket_response.status_code}")
|
|
113
|
+
self.ticket_key = create_ticket_response.json().get('key')
|
|
114
|
+
self.context.logger.info(f"Ticket created successfully: {self.ticket_key}")
|
|
115
|
+
|
|
116
|
+
if self.ticket_comment:
|
|
117
|
+
JiraUtils.add_ticket_comment(self)
|
|
118
|
+
|
|
119
|
+
self.context.output_param_set("params.ticket.id", self.ticket_key)
|
|
120
|
+
self.context.output_param_set("params.ticket.url", f"{self.jira_url}/browse/{self.ticket_key}")
|
|
121
|
+
|
|
122
|
+
filtered_response_fields = self.jira_client.get_ticket_fields(self.ticket_key, self.field_names_filter)
|
|
123
|
+
self.context.output_param_set("params.ticket.fields", filtered_response_fields)
|
|
124
|
+
|
|
125
|
+
self.context.output_params_save()
|
|
126
|
+
self.context.logger.info("JIRA ticket creation completed successfully")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
4
|
+
from qubership_pipelines_common_library.v2.jira.jira_client import JiraClient, AuthType
|
|
5
|
+
from qubership_pipelines_common_library.v2.jira.jira_utils import JiraUtils
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JiraUpdateTicket(ExecutionCommand):
|
|
9
|
+
"""
|
|
10
|
+
Updates ticket fields and transitions status.
|
|
11
|
+
|
|
12
|
+
Input Parameters Structure (this structure is expected inside "input_params.params" block):
|
|
13
|
+
```
|
|
14
|
+
{
|
|
15
|
+
"ticket": {
|
|
16
|
+
"fields: { # REQUIRED: Dict structure that will be used as ticket-update-body, without transformations
|
|
17
|
+
"status": {"name": "Done"}, # OPTIONAL: Next status name
|
|
18
|
+
"transition": {"name": "From Review to Done"}, # OPTIONAL: Transition name
|
|
19
|
+
"priority": {"name": "High"}, # OPTIONAL: Other ticket fields with different formats, depending on your Project configuration
|
|
20
|
+
"duedate": "2030-02-20", # OPTIONAL: Text-value fields need no dict wrappers
|
|
21
|
+
"description": "Ticket body",
|
|
22
|
+
"labels": ["Test_Label1"],
|
|
23
|
+
},
|
|
24
|
+
"id": "BUG-567", # REQUIRED: Ticket ID
|
|
25
|
+
"comment": "your comment body", # OPTIONAL: Comment to add to created ticket
|
|
26
|
+
"field_names_filter": "summary,issuetype,creator,status", # OPTIONAL: Comma-separated names of fields to extract from created ticket to output params
|
|
27
|
+
},
|
|
28
|
+
"retry_timeout_seconds": 180, # OPTIONAL: Timeout for JIRA client operations in seconds (default: 180)
|
|
29
|
+
"retry_wait_seconds": 1, # OPTIONAL: Wait interval between retries in seconds (default: 1)
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Systems Configuration (expected in "systems.jira" block):
|
|
34
|
+
```
|
|
35
|
+
{
|
|
36
|
+
"url": "https://your_cloud_jira.atlassian.net", # REQUIRED: JIRA server URL
|
|
37
|
+
"username": "your_username_or_email", # REQUIRED: JIRA user login or email
|
|
38
|
+
"password": "<your_token>", # REQUIRED: JIRA user token
|
|
39
|
+
"auth_type": "basic" # OPTIONAL: 'basic' or 'bearer'
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Command name: "jira-update-ticket"
|
|
44
|
+
"""
|
|
45
|
+
RETRY_TIMEOUT_SECONDS = 180 # default value, how many seconds to try
|
|
46
|
+
RETRY_WAIT_SECONDS = 1 # default value, how many seconds between tries
|
|
47
|
+
|
|
48
|
+
def _validate(self):
|
|
49
|
+
names = [
|
|
50
|
+
"paths.input.params",
|
|
51
|
+
"paths.output.params",
|
|
52
|
+
"systems.jira.url",
|
|
53
|
+
"systems.jira.username",
|
|
54
|
+
"systems.jira.password",
|
|
55
|
+
"params.ticket.id",
|
|
56
|
+
"params.ticket.fields",
|
|
57
|
+
]
|
|
58
|
+
if not self.context.validate(names):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
self.retry_timeout_seconds = int(self.context.input_param_get("params.retry_timeout_seconds", self.RETRY_TIMEOUT_SECONDS))
|
|
62
|
+
self.retry_wait_seconds = int(self.context.input_param_get("params.retry_wait_seconds", self.RETRY_WAIT_SECONDS))
|
|
63
|
+
|
|
64
|
+
self.jira_url = self.context.input_param_get("systems.jira.url").rstrip('/')
|
|
65
|
+
self.jira_username = self.context.input_param_get("systems.jira.username")
|
|
66
|
+
self.jira_password = self.context.input_param_get("systems.jira.password")
|
|
67
|
+
self.auth_type = self.context.input_param_get("systems.jira.auth_type", AuthType.BASIC)
|
|
68
|
+
|
|
69
|
+
self.ticket_key = self.context.input_param_get("params.ticket.id")
|
|
70
|
+
self.ticket_comment = self.context.input_param_get("params.ticket.comment")
|
|
71
|
+
self.ticket_fields = self.context.input_param_get("params.ticket.fields")
|
|
72
|
+
self.next_status_name = self.ticket_fields.pop('status', {}).get('name')
|
|
73
|
+
self.transition_name = self.ticket_fields.pop('transition', {}).get('name')
|
|
74
|
+
|
|
75
|
+
if field_names_filter := self.context.input_param_get("params.ticket.field_names_filter"):
|
|
76
|
+
self.field_names_filter = [x.strip() for x in re.split(r'[,;]+', field_names_filter)]
|
|
77
|
+
else:
|
|
78
|
+
self.field_names_filter = JiraClient.DEFAULT_FIELD_NAMES_FILTER
|
|
79
|
+
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def _execute(self):
|
|
83
|
+
self.context.logger.info("Running jira-update-ticket")
|
|
84
|
+
self.context.logger.info(f"Updating ticket {self.ticket_key}...")
|
|
85
|
+
self.jira_client = JiraClient.create_jira_client(
|
|
86
|
+
self.jira_url, self.jira_username, self.jira_password, self.auth_type,
|
|
87
|
+
self.retry_timeout_seconds, self.retry_wait_seconds,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
editmeta_fields = self.jira_client.get_editmeta_fields(self.ticket_key)
|
|
91
|
+
self.filtered_ticket_fields = JiraClient.filter_ticket_fields(self.ticket_fields, editmeta_fields)
|
|
92
|
+
self.context.logger.debug(f"Filtered ticket fields: {self.filtered_ticket_fields}")
|
|
93
|
+
|
|
94
|
+
update_ticket_response = self.jira_client.update_ticket(
|
|
95
|
+
self.ticket_key, self.filtered_ticket_fields,
|
|
96
|
+
retry_timeout_seconds=self.retry_timeout_seconds, retry_wait_seconds=self.retry_wait_seconds
|
|
97
|
+
)
|
|
98
|
+
if not update_ticket_response.ok:
|
|
99
|
+
self._exit(False, f"Can't update ticket. Response status: {update_ticket_response.status_code}")
|
|
100
|
+
|
|
101
|
+
if self.next_status_name:
|
|
102
|
+
self.context.logger.info(f"Updating ticket status to '{self.next_status_name}'...")
|
|
103
|
+
self._perform_status_transition()
|
|
104
|
+
|
|
105
|
+
if self.ticket_comment:
|
|
106
|
+
JiraUtils.add_ticket_comment(self)
|
|
107
|
+
|
|
108
|
+
self.context.output_param_set("params.ticket.id", self.ticket_key)
|
|
109
|
+
self.context.output_param_set("params.ticket.url", f"{self.jira_url}/browse/{self.ticket_key}")
|
|
110
|
+
|
|
111
|
+
filtered_response_fields = self.jira_client.get_ticket_fields(self.ticket_key, self.field_names_filter)
|
|
112
|
+
self.context.output_param_set("params.ticket.fields", filtered_response_fields)
|
|
113
|
+
|
|
114
|
+
self.context.output_params_save()
|
|
115
|
+
self.context.logger.info("Update ticket request executed. See output params for details")
|
|
116
|
+
|
|
117
|
+
def _perform_status_transition(self):
|
|
118
|
+
|
|
119
|
+
ticket_current_status = self.jira_client.get_ticket_fields(self.ticket_key, ["status"]).get("status", {}).get("name")
|
|
120
|
+
if str(self.next_status_name).strip().lower() == str(ticket_current_status).strip().lower():
|
|
121
|
+
self.context.logger.info(f"Ticket {self.ticket_key} already has '{self.next_status_name}' status. Skipping status transition.")
|
|
122
|
+
|
|
123
|
+
else:
|
|
124
|
+
transitions = self.jira_client.get_ticket_transitions(self.ticket_key)
|
|
125
|
+
if not transitions:
|
|
126
|
+
self._exit(False, f"Can't find ticket {self.ticket_key} transitions.")
|
|
127
|
+
|
|
128
|
+
transition = self.jira_client.find_applicable_transition(
|
|
129
|
+
transitions, self.next_status_name, self.transition_name
|
|
130
|
+
)
|
|
131
|
+
if transition is None:
|
|
132
|
+
self._exit(False, f"Can't find transition with next status '{self.next_status_name}'.")
|
|
133
|
+
|
|
134
|
+
ticket_transition_fields = JiraClient.filter_ticket_fields(self.ticket_fields, transition.get("fields"))
|
|
135
|
+
transition_response = self.jira_client.perform_ticket_transition(
|
|
136
|
+
self.ticket_key, transition.get("id"), ticket_transition_fields
|
|
137
|
+
)
|
|
138
|
+
if not transition_response.ok:
|
|
139
|
+
self._exit(False, f"Can't perform ticket status transition. Response status: {transition_response.status_code}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JiraUtils:
|
|
5
|
+
|
|
6
|
+
@staticmethod
|
|
7
|
+
def add_ticket_comment(command: ExecutionCommand):
|
|
8
|
+
try:
|
|
9
|
+
command.context.logger.info(f"Adding comment to ticket {command.ticket_key}...")
|
|
10
|
+
add_ticket_comment_response = command.jira_client.add_ticket_comment(
|
|
11
|
+
command.ticket_key, command.ticket_comment,
|
|
12
|
+
retry_timeout_seconds=command.retry_timeout_seconds,
|
|
13
|
+
retry_wait_seconds=command.retry_wait_seconds
|
|
14
|
+
)
|
|
15
|
+
if not add_ticket_comment_response.ok:
|
|
16
|
+
command._exit(False, f"Can't add ticket comment. Response status: {add_ticket_comment_response.status_code}")
|
|
17
|
+
command.context.logger.info(f"Comment added to ticket {command.ticket_key}")
|
|
18
|
+
except Exception as e:
|
|
19
|
+
command._exit(False, f"Can't add ticket comment. Response exception: {str(e)}")
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
import socket
|
|
3
|
+
import ssl
|
|
4
|
+
|
|
5
|
+
from email.utils import formatdate
|
|
6
|
+
from email.mime.multipart import MIMEMultipart
|
|
7
|
+
from email.mime.text import MIMEText
|
|
8
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
9
|
+
from qubership_pipelines_common_library.v1.utils.utils_string import UtilsString
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SendEmail(ExecutionCommand):
|
|
13
|
+
"""
|
|
14
|
+
This command sends email notification with optional attachments.
|
|
15
|
+
|
|
16
|
+
Input Parameters Structure (this structure is expected inside "input_params.params" block):
|
|
17
|
+
```
|
|
18
|
+
{
|
|
19
|
+
"email_subject": "Report for 01.01.2026", # REQUIRED: E-mail subject
|
|
20
|
+
"email_body": "Following jobs were completed: ...", # REQUIRED: E-mail message
|
|
21
|
+
"email_recipients": "user1@qubership.org,user2@qubership.org", # REQUIRED: Comma-separated list of recipients
|
|
22
|
+
"email_body_type": "plain", # OPTIONAL: Either "plain" or "html
|
|
23
|
+
"attachments": { # OPTIONAL: Dict with attachments
|
|
24
|
+
"unique_attachment_key": {
|
|
25
|
+
"name": "HTML_report.html", # REQUIRED: File name used for attachment
|
|
26
|
+
"content": "<html>...</html>", # REQUIRED: Text content put inside attachment
|
|
27
|
+
"mime_type": "text/html",
|
|
28
|
+
},
|
|
29
|
+
"another_attachment_key": {...}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Systems Configuration (expected in "systems.email" block):
|
|
35
|
+
```
|
|
36
|
+
{
|
|
37
|
+
"server": "your.mail.server.org", # REQUIRED: E-mail host server
|
|
38
|
+
"port": "3025" # REQUIRED: E-mail port
|
|
39
|
+
"user": "your@email.bot" # REQUIRED: E-mail user
|
|
40
|
+
"password": "<email_password>" # OPTIONAL: E-mail password
|
|
41
|
+
"use_ssl": "False" # OPTIONAL: SMTP connection will use SSL mode (default: False)
|
|
42
|
+
"use_tls": "False" # OPTIONAL: SMTP connection will use TLS mode (default: False)
|
|
43
|
+
"verify": "False" # OPTIONAL: SSL Certificate verification (default: False)
|
|
44
|
+
"timeout_seconds": "60" # OPTIONAL: SMTP connection timeout in seconds (default: 60)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Command name: "send-email"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
EMAIL_BODY_TYPE_PLAIN = "plain"
|
|
52
|
+
EMAIL_BODY_TYPE_HTML = "html"
|
|
53
|
+
EMAIL_BODY_TYPES = [EMAIL_BODY_TYPE_PLAIN, EMAIL_BODY_TYPE_HTML]
|
|
54
|
+
WAIT_TIMEOUT = 60
|
|
55
|
+
|
|
56
|
+
def _validate(self):
|
|
57
|
+
required_params = [
|
|
58
|
+
"paths.input.params",
|
|
59
|
+
"systems.email.server",
|
|
60
|
+
"systems.email.port",
|
|
61
|
+
"systems.email.user",
|
|
62
|
+
"params.email_subject",
|
|
63
|
+
"params.email_body",
|
|
64
|
+
"params.email_recipients",
|
|
65
|
+
]
|
|
66
|
+
if not self.context.validate(required_params):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
self.email_server = self.context.input_param_get("systems.email.server")
|
|
70
|
+
self.email_port = self.context.input_param_get("systems.email.port")
|
|
71
|
+
self.email_user = self.context.input_param_get("systems.email.user")
|
|
72
|
+
self.email_password = self.context.input_param_get("systems.email.password")
|
|
73
|
+
self.email_password = self.context.input_param_get("systems.email.password")
|
|
74
|
+
self.timeout_seconds = max(1, int(self.context.input_param_get("systems.email.timeout_seconds", self.WAIT_TIMEOUT)))
|
|
75
|
+
self.use_ssl = UtilsString.convert_to_bool(self.context.input_param_get("systems.email.use_ssl", False))
|
|
76
|
+
self.use_tls = UtilsString.convert_to_bool(self.context.input_param_get("systems.email.use_tls", False))
|
|
77
|
+
self.verify = UtilsString.convert_to_bool(self.context.input_param_get("systems.email.verify", False))
|
|
78
|
+
|
|
79
|
+
self.email_subject = self.context.input_param_get("params.email_subject")
|
|
80
|
+
self.email_body = self.context.input_param_get("params.email_body")
|
|
81
|
+
self.email_recipients = [x.strip() for x in self.context.input_param_get("params.email_recipients").split(",")]
|
|
82
|
+
|
|
83
|
+
self.email_body_type = self.context.input_param_get("params.email_body_type", SendEmail.EMAIL_BODY_TYPE_PLAIN)
|
|
84
|
+
if self.email_body_type not in SendEmail.EMAIL_BODY_TYPES:
|
|
85
|
+
self.context.logger.error(f"Incorrect email_body_type value: {self.email_body_type}. Only '{SendEmail.EMAIL_BODY_TYPES}' are supported")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
self.attachments = []
|
|
89
|
+
for key, data in self.context.input_param_get("params.attachments", {}).items():
|
|
90
|
+
if not data.get("content") or not data.get("name"):
|
|
91
|
+
self.context.logger.error(f"Attachment with key [{key}] is missing content and/or name!")
|
|
92
|
+
return False
|
|
93
|
+
self.attachments.append({
|
|
94
|
+
"name": data.get("name"),
|
|
95
|
+
"mime_type": data.get("mime_type", "text/plain"),
|
|
96
|
+
"content": data.get("content")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def _execute(self):
|
|
102
|
+
try:
|
|
103
|
+
msg = MIMEMultipart()
|
|
104
|
+
msg["From"] = self.email_user
|
|
105
|
+
msg["To"] = ", ".join(self.email_recipients)
|
|
106
|
+
msg["Subject"] = self.email_subject
|
|
107
|
+
msg["Date"] = formatdate(localtime=True)
|
|
108
|
+
msg.attach(MIMEText(self.email_body, self.email_body_type))
|
|
109
|
+
|
|
110
|
+
if self.attachments:
|
|
111
|
+
for attachment in self.attachments:
|
|
112
|
+
part = MIMEText(attachment["content"], attachment["mime_type"])
|
|
113
|
+
part.add_header('Content-Disposition', f'attachment; filename="{attachment["name"]}"')
|
|
114
|
+
msg.attach(part)
|
|
115
|
+
|
|
116
|
+
self.context.logger.debug(f"Connecting to SMTP server: {self.email_server}:{self.email_port}"
|
|
117
|
+
f", using SSL: {self.use_ssl}, TLS: {self.use_tls}, verify: {self.verify}")
|
|
118
|
+
ssl_context = ssl.create_default_context() if self.verify else ssl._create_unverified_context()
|
|
119
|
+
if self.use_ssl:
|
|
120
|
+
smtp = smtplib.SMTP_SSL(self.email_server, self.email_port, timeout=self.timeout_seconds, context=ssl_context)
|
|
121
|
+
else:
|
|
122
|
+
smtp = smtplib.SMTP(self.email_server, self.email_port, timeout=self.timeout_seconds)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
if self.use_tls and not self.use_ssl:
|
|
126
|
+
smtp.starttls(context=ssl_context)
|
|
127
|
+
|
|
128
|
+
if self.email_password:
|
|
129
|
+
self.context.logger.debug("Authenticating with server...")
|
|
130
|
+
smtp.login(self.email_user, self.email_password)
|
|
131
|
+
|
|
132
|
+
self.context.logger.debug("Sending email...")
|
|
133
|
+
smtp.send_message(msg)
|
|
134
|
+
self.context.logger.info(f"Email sent successfully to {len(self.email_recipients)} recipients: {self.email_recipients}")
|
|
135
|
+
finally:
|
|
136
|
+
try:
|
|
137
|
+
smtp.quit()
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
except socket.gaierror as e:
|
|
142
|
+
self._exit(False, f"Invalid SMTP server. Cannot resolve hostname '{self.email_server}': {str(e)}")
|
|
143
|
+
except socket.timeout as e:
|
|
144
|
+
self._exit(False, f"Connection timeout to SMTP server: {str(e)}")
|
|
145
|
+
except smtplib.SMTPAuthenticationError as e:
|
|
146
|
+
self._exit(False, f"SMTP authentication failed: {str(e)}")
|
|
147
|
+
except smtplib.SMTPRecipientsRefused as e:
|
|
148
|
+
self._exit(False, f"Email recipients rejected - verify email addresses are valid and domain exists: {str(e)}. SMTP may reject domains like: @gmail.com and etc.")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
self._exit(False, f"Failed to send email: {str(e)}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import os
|
|
3
|
+
import traceback
|
|
4
|
+
|
|
5
|
+
from qubership_pipelines_common_library.v1.execution.exec_command import ExecutionCommand
|
|
6
|
+
from qubership_pipelines_common_library.v1.webex_client import WebexClient
|
|
7
|
+
from webexpythonsdk.exceptions import ApiError
|
|
8
|
+
from requests.exceptions import ProxyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SendWebexMessage(ExecutionCommand):
|
|
12
|
+
"""
|
|
13
|
+
This command sends Webex message with optional attachments.
|
|
14
|
+
|
|
15
|
+
Input Parameters Structure (this structure is expected inside "input_params.params" block):
|
|
16
|
+
```
|
|
17
|
+
{
|
|
18
|
+
"webex_message": "Hello, world!", # REQUIRED: Text message (Markdown format is supported)
|
|
19
|
+
"parent_id": "1234321", # OPTIONAL: The parent message to reply to
|
|
20
|
+
"attachments": { # OPTIONAL: Dict with attachments
|
|
21
|
+
"unique_attachment_key": {
|
|
22
|
+
"name": "HTML_report.html", # REQUIRED: File name used for attachment
|
|
23
|
+
"content": "<html>...</html>", # REQUIRED: Text content put inside attachment
|
|
24
|
+
"mime_type": "text/html",
|
|
25
|
+
},
|
|
26
|
+
"another_attachment_key": {...}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Systems Configuration (expected in "systems.webex" block):
|
|
32
|
+
```
|
|
33
|
+
{
|
|
34
|
+
"room_id": "...Y2lzY29zc...", # REQUIRED: Webex unique room_id where message will be posted
|
|
35
|
+
"token": "your_bot_account_token" # REQUIRED: Bot/Service account token that will be used to send message
|
|
36
|
+
"proxy": "https://127.0.0.1" # OPTIONAL: Host to be used as a webex-proxy
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Output Parameters:
|
|
41
|
+
- params.message_id: Received `message_id` of sent message
|
|
42
|
+
- params.attachment_message_ids: dict of `attachment_name` -> `message_id`
|
|
43
|
+
|
|
44
|
+
Command name: "send-webex-message"
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def _validate(self):
|
|
48
|
+
required_params = [
|
|
49
|
+
"paths.input.params",
|
|
50
|
+
"paths.output.params",
|
|
51
|
+
"systems.webex.room_id",
|
|
52
|
+
"systems.webex.token",
|
|
53
|
+
"params.webex_message",
|
|
54
|
+
]
|
|
55
|
+
if not self.context.validate(required_params):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
self.webex_room_id = self.context.input_param_get("systems.webex.room_id")
|
|
59
|
+
self.webex_token = self.context.input_param_get("systems.webex.token")
|
|
60
|
+
self.webex_proxy = self.context.input_param_get("systems.webex.proxy")
|
|
61
|
+
self.webex_message = self.context.input_param_get("params.webex_message")
|
|
62
|
+
self.webex_parent_id = self.context.input_param_get("params.parent_id", "")
|
|
63
|
+
|
|
64
|
+
self.attachments = []
|
|
65
|
+
for key, data in self.context.input_param_get("params.attachments", {}).items():
|
|
66
|
+
if not data.get("content") or not data.get("name"):
|
|
67
|
+
self.context.logger.error(f"Attachment with key [{key}] is missing content and/or name!")
|
|
68
|
+
return False
|
|
69
|
+
self.attachments.append({
|
|
70
|
+
"name": data.get("name"),
|
|
71
|
+
"mime_type": data.get("mime_type"),
|
|
72
|
+
"content": data.get("content")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
self.webex_client = WebexClient(
|
|
76
|
+
bot_token=self.webex_token,
|
|
77
|
+
proxies={"https": self.webex_proxy} if self.webex_proxy else None
|
|
78
|
+
)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def _execute(self):
|
|
82
|
+
try:
|
|
83
|
+
response = self.webex_client.send_message(
|
|
84
|
+
room_id=self.webex_room_id,
|
|
85
|
+
parent_id=self.webex_parent_id,
|
|
86
|
+
markdown=self.webex_message
|
|
87
|
+
)
|
|
88
|
+
message_id = response.id if response else None
|
|
89
|
+
self.context.output_param_set("params.message_id", message_id)
|
|
90
|
+
|
|
91
|
+
if self.attachments:
|
|
92
|
+
attachment_message_ids = {}
|
|
93
|
+
for attachment in self.attachments:
|
|
94
|
+
attachment_message_ids[attachment["name"]] = self._send_attachment(attachment)
|
|
95
|
+
self.context.output_param_set("params.attachment_message_ids", attachment_message_ids)
|
|
96
|
+
|
|
97
|
+
self.context.output_params_save()
|
|
98
|
+
self.context.logger.info("Webex message sent successfully.")
|
|
99
|
+
|
|
100
|
+
except ApiError as e:
|
|
101
|
+
self.context.logger.debug("Full traceback: %s", traceback.format_exc())
|
|
102
|
+
error_str = str(e)
|
|
103
|
+
if "404" in error_str or "Not Found" in error_str:
|
|
104
|
+
error_msg = f"Webex room not found - verify room_id '{self.webex_room_id}' exists. {error_str}"
|
|
105
|
+
elif "401" in error_str or "Unauthorized" in error_str:
|
|
106
|
+
error_msg = f"Webex authentication failed - verify token is valid: {error_str}"
|
|
107
|
+
else:
|
|
108
|
+
error_msg = f"Webex API error: {error_str}"
|
|
109
|
+
self._exit(False, error_msg)
|
|
110
|
+
|
|
111
|
+
except ProxyError as e:
|
|
112
|
+
self.context.logger.debug("Full traceback: %s", traceback.format_exc())
|
|
113
|
+
self._exit(False, f"Invalid proxy: {str(e)}")
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
self._exit(False, f"Failed to send Webex message: {e}\n{traceback.format_exc()}")
|
|
117
|
+
|
|
118
|
+
def _send_attachment(self, attachment: dict):
|
|
119
|
+
temp_dir = tempfile.gettempdir()
|
|
120
|
+
temp_file_path = os.path.join(temp_dir, attachment["name"])
|
|
121
|
+
with open(temp_file_path, "w") as temp_file:
|
|
122
|
+
temp_file.write(attachment["content"])
|
|
123
|
+
temp_file_path = temp_file.name
|
|
124
|
+
try:
|
|
125
|
+
attachment_response = self.webex_client.send_message(room_id=self.webex_room_id,
|
|
126
|
+
parent_id=self.webex_parent_id,
|
|
127
|
+
markdown=attachment["name"],
|
|
128
|
+
attachment_path=temp_file_path)
|
|
129
|
+
return attachment_response.id if attachment_response else None
|
|
130
|
+
finally:
|
|
131
|
+
os.remove(temp_file_path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qubership-pipelines-common-library
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: Qubership Pipelines common library
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,7 +20,7 @@ Requires-Dist: google-cloud-artifact-registry (>=1.16.1,<2.0.0)
|
|
|
20
20
|
Requires-Dist: http-exceptions (>=0.2.10,<0.3.0)
|
|
21
21
|
Requires-Dist: kubernetes (>=34.1.0,<35.0.0)
|
|
22
22
|
Requires-Dist: minio (>=7.2.12,<8.0.0)
|
|
23
|
-
Requires-Dist: python-gitlab (>=4.13.0,<
|
|
23
|
+
Requires-Dist: python-gitlab (>=4.13.0,<6.0.0)
|
|
24
24
|
Requires-Dist: python-jenkins (>=1.8.2,<2.0.0)
|
|
25
25
|
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
|
26
26
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
@@ -19,12 +19,12 @@ qubership_pipelines_common_library/v1/utils/__init__.py,sha256=QczIlSYNOtXMuMWSz
|
|
|
19
19
|
qubership_pipelines_common_library/v1/utils/rest.py,sha256=OfYGPE2tM-vDUg__ytZKfpCeGVCDePSjXDHK61A2KDM,3069
|
|
20
20
|
qubership_pipelines_common_library/v1/utils/utils.py,sha256=aMyUrJqlnRcFSjNtiyKa5TDJ31m2ABpljeQdnil3A20,1866
|
|
21
21
|
qubership_pipelines_common_library/v1/utils/utils_aws.py,sha256=BPPnHBzPPXPqFijtAiw16sTPu1tFZjS95GkSMX_HdjA,808
|
|
22
|
-
qubership_pipelines_common_library/v1/utils/utils_cli.py,sha256=
|
|
22
|
+
qubership_pipelines_common_library/v1/utils/utils_cli.py,sha256=jkhBswmYqJHfszaYhWknCTxaBx0tEh42McFx5dP6EXQ,4931
|
|
23
23
|
qubership_pipelines_common_library/v1/utils/utils_context.py,sha256=IlMFXGxS8zJw33Gu3SbOUcj88wquIkobBlWkdFbR7MA,3767
|
|
24
24
|
qubership_pipelines_common_library/v1/utils/utils_dictionary.py,sha256=xe8ftnigzyCKWCZaUnI2Xu2ykYlOl1Nc9ATimXuCbgM,1366
|
|
25
25
|
qubership_pipelines_common_library/v1/utils/utils_file.py,sha256=E4RhpeCRhUt-EQ_6pUz-QEKkH8JidJWwYxZmrAvpHBk,2905
|
|
26
26
|
qubership_pipelines_common_library/v1/utils/utils_json.py,sha256=QczIlSYNOtXMuMWSznhV_BkXMM5KLn1wOogtlT2kcy0,598
|
|
27
|
-
qubership_pipelines_common_library/v1/utils/utils_logging.py,sha256=
|
|
27
|
+
qubership_pipelines_common_library/v1/utils/utils_logging.py,sha256=rVkx1OnyL9WmvLvvYij9Fn9tM16geqsCzeeVwObPhRw,1505
|
|
28
28
|
qubership_pipelines_common_library/v1/utils/utils_string.py,sha256=Phx5ZXPRjhjg9AaSPx6WLX9zQvwJH1txslfnG3jJ43w,993
|
|
29
29
|
qubership_pipelines_common_library/v1/webex_client.py,sha256=JU_0NgLu_p6zgaUi-ixgZeFMlJaTAvXwrU1oA607Bv0,2997
|
|
30
30
|
qubership_pipelines_common_library/v2/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -63,14 +63,23 @@ qubership_pipelines_common_library/v2/jenkins/jenkins_client.py,sha256=h-EWIBYHx
|
|
|
63
63
|
qubership_pipelines_common_library/v2/jenkins/jenkins_pipeline_data_importer.py,sha256=zWNejXoAw1y25Jf6cl6f7UhwnJsyEK87hw7MDdoL0Mk,1572
|
|
64
64
|
qubership_pipelines_common_library/v2/jenkins/jenkins_run_pipeline_command.py,sha256=7ws_V2QO8QwpOKUtgl8PbcnhsUo_pHruue-if-79ee8,10136
|
|
65
65
|
qubership_pipelines_common_library/v2/jenkins/safe_jenkins_client.py,sha256=PD1BaF4TbvzIy49g3EdxsliRyUp_zy6TFAuhBt29FEw,620
|
|
66
|
+
qubership_pipelines_common_library/v2/jira/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
67
|
+
qubership_pipelines_common_library/v2/jira/jira_add_ticket_comment_command.py,sha256=kHqrtYg519oVAAfH3iZJtlTWROduBAONplz2cv6qA5E,4844
|
|
68
|
+
qubership_pipelines_common_library/v2/jira/jira_client.py,sha256=wXWQyJiI0ZTFslEjcddLgLxPQUA5BcoulUbAFKF2Cio,11079
|
|
69
|
+
qubership_pipelines_common_library/v2/jira/jira_create_ticket_command.py,sha256=djPY_rH3rhjrhSNaC8SrDItBgGkGniUYXJXHe98pSi8,6741
|
|
70
|
+
qubership_pipelines_common_library/v2/jira/jira_update_ticket_command.py,sha256=JpkS-n-CepqwWEVV_mIP6XTGwQl4fk8KH8V6bBJrAmA,7453
|
|
71
|
+
qubership_pipelines_common_library/v2/jira/jira_utils.py,sha256=o6BLsPSKQjn-j0Ht8lGsF4_1PT9s7vgHvhMMxiMvt3Q,965
|
|
72
|
+
qubership_pipelines_common_library/v2/notifications/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
73
|
+
qubership_pipelines_common_library/v2/notifications/send_email_command.py,sha256=wlFdJaUyqROfVxBoSChdl4wjcxCJJ4t-vSQ-JCkjjd0,7665
|
|
74
|
+
qubership_pipelines_common_library/v2/notifications/send_webex_message_command.py,sha256=NuFVCijo0bnnL80mWvf_RknjgX76ZzIOuqdtynZPHRk,5819
|
|
66
75
|
qubership_pipelines_common_library/v2/podman/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
67
76
|
qubership_pipelines_common_library/v2/podman/podman_command.md,sha256=EKctWLIXZhoaxe579f-OgqFfjISk2UYgFD8JyzJeAVU,6500
|
|
68
77
|
qubership_pipelines_common_library/v2/podman/podman_command.py,sha256=o78MYtieAD4rdOyRQEioQREhhw0e0Zqa1kNyygvOOSI,15541
|
|
69
78
|
qubership_pipelines_common_library/v2/sops/sops_client.py,sha256=vB1czEqIQ6aRywkfvIQfqGZOTtPy39cnOExhfJbPw4s,4946
|
|
70
|
-
qubership_pipelines_common_library/v2/utils/crypto_utils.py,sha256=
|
|
79
|
+
qubership_pipelines_common_library/v2/utils/crypto_utils.py,sha256=FkVmRZVYZRNg0SzpO43cQc_PXJNSiQHlvJzxRGpVrI4,1567
|
|
71
80
|
qubership_pipelines_common_library/v2/utils/extension_utils.py,sha256=-OyT6xrIg-PdHHy2Y712rbOAB6Q7WXTqGwP7oVne4k4,965
|
|
72
81
|
qubership_pipelines_common_library/v2/utils/retry_decorator.py,sha256=ItYYL7zam4LrpQrsihis_n6aFCAa32ZAOKKSmxkaENM,4391
|
|
73
|
-
qubership_pipelines_common_library-2.0.
|
|
74
|
-
qubership_pipelines_common_library-2.0.
|
|
75
|
-
qubership_pipelines_common_library-2.0.
|
|
76
|
-
qubership_pipelines_common_library-2.0.
|
|
82
|
+
qubership_pipelines_common_library-2.0.3.dist-info/METADATA,sha256=eGwWa2rTjnE2mv1U6lIp6W19-8FbwH7-T6qK9WYAPO8,3063
|
|
83
|
+
qubership_pipelines_common_library-2.0.3.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
84
|
+
qubership_pipelines_common_library-2.0.3.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
85
|
+
qubership_pipelines_common_library-2.0.3.dist-info/RECORD,,
|