diff --git a/KEYWORDS b/KEYWORDS index 9dcb2f91..3d42b7f5 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -31,6 +31,7 @@ Guilded Home Assistant httpSMS IFTTT +Jira Join JSON Kavenegar diff --git a/README.md b/README.md index 8772581d..04949503 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ The table below identifies the services this tool supports and some example serv | [Guilded](https://github.com/caronc/apprise/wiki/Notify_guilded) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token
guilded://avatar@webhook_id/webhook_token | [Home Assistant](https://github.com/caronc/apprise/wiki/Notify_homeassistant) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken
hassio://user@hostname/accesstoken
hassio://user:password@hostname:port/accesstoken
hassio://hostname/optional/path/accesstoken | [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 +| [Jira](https://github.com/caronc/apprise/wiki/Notify_jira) | jira:// | (TCP) 443 | jira://APIKey
jira://APIKey/UserID
jira://APIKey/#Team
jira://APIKey/\*Schedule
jira://APIKey/^Escalation | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Kumulos](https://github.com/caronc/apprise/wiki/Notify_kumulos) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey diff --git a/apprise/plugins/jira.py b/apprise/plugins/jira.py new file mode 100644 index 00000000..0acf42d8 --- /dev/null +++ b/apprise/plugins/jira.py @@ -0,0 +1,850 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Knowing this, you can build your Jira URL as follows: +# jira://{apikey}/ +# jira://{apikey}/@{user} +# jira://{apikey}/*{schedule} +# jira://{apikey}/^{escalation} +# jira://{apikey}/#{team} +# +# You can mix and match what you want to notify freely +# jira://{apikey}/@{user}/#{team}/*{schedule}/^{escalation} +# +# If no target prefix is specified, then it is assumed to be a user. +# +# API Documentation: \ +# https://developer.atlassian.com/cloud/jira/ \ +# service-desk-ops/rest/v1/api-group-integration-events/ + +import requests +from json import dumps, loads +import hashlib + +from .base import NotifyBase +from ..common import NotifyType, NOTIFY_TYPES +from ..common import PersistentStoreMode +from ..utils.parse import validate_regex, is_uuid, parse_list, parse_bool +from ..locale import gettext_lazy as _ + + +class JiraCategory(NotifyBase): + """ + We define the different category types that we can notify + """ + USER = 'user' + SCHEDULE = 'schedule' + ESCALATION = 'escalation' + TEAM = 'team' + + +JIRA_CATEGORIES = ( + JiraCategory.USER, + JiraCategory.SCHEDULE, + JiraCategory.ESCALATION, + JiraCategory.TEAM, +) + + +class JiraAlertAction: + """ + Defines the supported actions + """ + # Use mapping (specify :key=arg to over-ride) + MAP = 'map' + + # Create new alert (default) + NEW = 'new' + + # Close Alert + CLOSE = 'close' + + # Delete Alert + DELETE = 'delete' + + # Acknowledge Alert + ACKNOWLEDGE = 'acknowledge' + + # Add note to alert + NOTE = 'note' + + +JIRA_ACTIONS = ( + JiraAlertAction.MAP, + JiraAlertAction.NEW, + JiraAlertAction.CLOSE, + JiraAlertAction.DELETE, + JiraAlertAction.ACKNOWLEDGE, + JiraAlertAction.NOTE, +) + +# Map all support Apprise Categories to Jira Categories +JIRA_ALERT_MAP = { + NotifyType.INFO: JiraAlertAction.CLOSE, + NotifyType.SUCCESS: JiraAlertAction.CLOSE, + NotifyType.WARNING: JiraAlertAction.NEW, + NotifyType.FAILURE: JiraAlertAction.NEW, +} + + +# Regions +class JiraRegion: + US = 'us' + EU = 'eu' + + +# Jira APIs (OpsGenie port - so keep us/en support for easy transition) +JIRA_API_LOOKUP = { + JiraRegion.US: 'https://api.atlassian.com/jsm/ops/integration/v2/alerts', + JiraRegion.EU: 'https://api.atlassian.com/jsm/ops/integration/v2/alerts', +} + +# A List of our regions we can use for verification +JIRA_REGIONS = ( + JiraRegion.US, + JiraRegion.EU, +) + + +# Priorities +class JiraPriority: + LOW = 1 + MODERATE = 2 + NORMAL = 3 + HIGH = 4 + EMERGENCY = 5 + + +JIRA_PRIORITIES = { + # Note: This also acts as a reverse lookup mapping + JiraPriority.LOW: 'low', + JiraPriority.MODERATE: 'moderate', + JiraPriority.NORMAL: 'normal', + JiraPriority.HIGH: 'high', + JiraPriority.EMERGENCY: 'emergency', +} + +JIRA_PRIORITY_MAP = { + # Maps against string 'low' + 'l': JiraPriority.LOW, + # Maps against string 'moderate' + 'm': JiraPriority.MODERATE, + # Maps against string 'normal' + 'n': JiraPriority.NORMAL, + # Maps against string 'high' + 'h': JiraPriority.HIGH, + # Maps against string 'emergency' + 'e': JiraPriority.EMERGENCY, + + # Entries to additionally support (so more like Jira's API) + '1': JiraPriority.LOW, + '2': JiraPriority.MODERATE, + '3': JiraPriority.NORMAL, + '4': JiraPriority.HIGH, + '5': JiraPriority.EMERGENCY, + # Support p-prefix + 'p1': JiraPriority.LOW, + 'p2': JiraPriority.MODERATE, + 'p3': JiraPriority.NORMAL, + 'p4': JiraPriority.HIGH, + 'p5': JiraPriority.EMERGENCY, +} + + +class NotifyJira(NotifyBase): + """ + A wrapper for Jira Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Jira' + + # The services URL + service_url = 'https://atlassian.com/' + + # All notification requests are secure + secure_protocol = 'jira' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_jira' + + # The maximum length of the body + body_maxlen = 15000 + + # Our default is to no not use persistent storage beyond in-memory + # reference + storage_mode = PersistentStoreMode.AUTO + + # If we don't have the specified min length, then we don't bother using + # the body directive + jira_body_minlen = 130 + + # The default region to use if one isn't otherwise specified + jira_default_region = JiraRegion.US + + # The maximum allowable targets within a notification + default_batch_size = 50 + + # Defines our default message mapping + jira_message_map = { + # Add a note to existing alert + NotifyType.INFO: JiraAlertAction.NOTE, + # Close existing alert + NotifyType.SUCCESS: JiraAlertAction.CLOSE, + # Create notice + NotifyType.WARNING: JiraAlertAction.NEW, + # Create notice + NotifyType.FAILURE: JiraAlertAction.NEW, + } + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{user}@{apikey}', + '{schema}://{apikey}/{targets}', + '{schema}://{user}@{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'target_escalation': { + 'name': _('Target Escalation'), + 'prefix': '^', + 'type': 'string', + 'map_to': 'targets', + }, + 'target_schedule': { + 'name': _('Target Schedule'), + 'type': 'string', + 'prefix': '*', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_team': { + 'name': _('Target Team'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets '), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': JIRA_REGIONS, + 'default': JiraRegion.US, + 'map_to': 'region_name', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': JIRA_PRIORITIES, + 'default': JiraPriority.NORMAL, + }, + 'entity': { + 'name': _('Entity'), + 'type': 'string', + }, + 'alias': { + 'name': _('Alias'), + 'type': 'string', + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'to': { + 'alias_of': 'targets', + }, + 'action': { + 'name': _('Action'), + 'type': 'choice:string', + 'values': JIRA_ACTIONS, + 'default': JIRA_ACTIONS[0], + } + }) + + # Map of key-value pairs to use as custom properties of the alert. + template_kwargs = { + 'details': { + 'name': _('Details'), + 'prefix': '+', + }, + 'mapping': { + 'name': _('Action Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, apikey, targets, region_name=None, details=None, + priority=None, alias=None, entity=None, batch=False, + tags=None, action=None, mapping=None, **kwargs): + """ + Initialize Jira Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Jira API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The Priority of the message + self.priority = NotifyJira.template_args['priority']['default'] \ + if not priority else \ + next(( + v for k, v in JIRA_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyJira.template_args['priority']['default']) + + # Store our region + try: + self.region_name = self.jira_default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in JIRA_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The Jira region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + if action and isinstance(action, str): + self.action = next( + (a for a in JIRA_ACTIONS if a.startswith(action)), None) + if self.action not in JIRA_ACTIONS: + msg = 'The Jira action specified ({}) is invalid.'\ + .format(action) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.action = self.template_args['action']['default'] + + # Store our mappings + self.mapping = self.jira_message_map.copy() + if mapping and isinstance(mapping, dict): + for _k, _v in mapping.items(): + # Get our mapping + k = next((t for t in NOTIFY_TYPES if t.startswith(_k)), None) + if not k: + msg = 'The Jira mapping key specified ({}) ' \ + 'is invalid.'.format(_k) + self.logger.warning(msg) + raise TypeError(msg) + + _v_lower = _v.lower() + v = next((v for v in JIRA_ACTIONS[1:] + if v.startswith(_v_lower)), None) + if not v: + msg = 'The Jira mapping value (assigned to {}) ' \ + 'specified ({}) is invalid.'.format(k, _v) + self.logger.warning(msg) + raise TypeError(msg) + + # Update our mapping + self.mapping[k] = v + + self.details = {} + if details: + # Store our extra details + self.details.update(details) + + # Prepare Batch Mode Flag + self.batch_size = self.default_batch_size if batch else 1 + + # Assign our tags (if defined) + self.__tags = parse_list(tags) + + # Assign our entity (if defined) + self.entity = entity + + # Assign our alias (if defined) + self.alias = alias + + # Initialize our Targets + self.targets = [] + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring Jira Entry: %s' % target) + continue + + if target.startswith(NotifyJira.template_tokens + ['target_team']['prefix']): + + self.targets.append( + {'type': JiraCategory.TEAM, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': JiraCategory.TEAM, 'name': target[1:]}) + + elif target.startswith(NotifyJira.template_tokens + ['target_schedule']['prefix']): + + self.targets.append( + {'type': JiraCategory.SCHEDULE, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': JiraCategory.SCHEDULE, 'name': target[1:]}) + + elif target.startswith(NotifyJira.template_tokens + ['target_escalation']['prefix']): + + self.targets.append( + {'type': JiraCategory.ESCALATION, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': JiraCategory.ESCALATION, 'name': target[1:]}) + + elif target.startswith(NotifyJira.template_tokens + ['target_user']['prefix']): + + self.targets.append( + {'type': JiraCategory.USER, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': JiraCategory.USER, 'username': target[1:]}) + + else: + # Ambiguious entry; treat it as a user but not before + # displaying a warning to the end user first: + self.logger.debug( + 'Treating ambigious Jira target %s as a user', target) + self.targets.append( + {'type': JiraCategory.USER, 'id': target} + if is_uuid(target) else + {'type': JiraCategory.USER, 'username': target}) + + def _fetch(self, method, url, payload, params=None): + """ + Performs server retrieval/update and returns JSON Response + """ + headers = { + 'User-Agent': self.app_id, + 'Accepts': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': 'GenieKey {}'.format(self.apikey), + } + + # Some Debug Logging + self.logger.debug( + 'Jira POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Jira Payload: {}' .format(payload)) + + # Initialize our response object + content = {} + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = method( + url, + data=dumps(payload), + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # A Response might look like: + # { + # "result": "Request will be processed", + # "took": 0.302, + # "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120" + # } + + try: + # Update our response object + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = {} + + if r.status_code not in ( + requests.codes.accepted, requests.codes.ok): + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Jira notification:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return (False, content.get('requestId')) + + # If we reach here; the message was sent + self.logger.info('Sent Jira notification') + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return (True, content.get('requestId')) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Jira ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return (False, content.get('requestId')) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Jira Notification + """ + + # Get our Jira Action + action = JIRA_ALERT_MAP[notify_type] \ + if self.action == JiraAlertAction.MAP else self.action + + # Prepare our URL as it's based on our hostname + notify_url = JIRA_API_LOOKUP[self.region_name] + + # Initialize our has_error flag + has_error = False + + # Default method is to post + method = requests.post + + # For indexing in persistent store + key = hashlib.sha1( + (self.entity if self.entity else ( + self.alias if self.alias else ( + title if title else self.app_id))) + .encode('utf-8')).hexdigest()[0:10] + + # Get our Jira Request IDs + request_ids = self.store.get(key, []) + if not isinstance(request_ids, list): + request_ids = [] + + if action == JiraAlertAction.NEW: + # Create a copy ouf our details object + details = self.details.copy() + if 'type' not in details: + details['type'] = notify_type + + # Use body if title not set + title_body = body if not title else title + + # Prepare our payload + payload = { + 'source': self.app_desc, + 'message': title_body, + 'description': body, + 'details': details, + 'priority': 'P{}'.format(self.priority), + } + + # Use our body directive if we exceed the minimum message + # limitation + if len(payload['message']) > self.jira_body_minlen: + payload['message'] = '{}...'.format( + title_body[:self.jira_body_minlen - 3]) + + if self.__tags: + payload['tags'] = self.__tags + + if self.entity: + payload['entity'] = self.entity + + if self.alias: + payload['alias'] = self.alias + + if self.user: + payload['user'] = self.user + + # reset our request IDs - we will re-populate them + request_ids = [] + + length = len(self.targets) if self.targets else 1 + for index in range(0, length, self.batch_size): + if self.targets: + # If there were no targets identified, then we simply + # just iterate once without the responders set + payload['responders'] = \ + self.targets[index:index + self.batch_size] + + # Perform our post + success, request_id = self._fetch( + method, notify_url, payload) + + if success and request_id: + # Save our response + request_ids.append(request_id) + + else: + has_error = True + + # Store our entries for a maximum of 60 days + self.store.set(key, request_ids, expires=60 * 60 * 24 * 60) + + elif request_ids: + # Prepare our payload + payload = { + 'source': self.app_desc, + 'note': body, + } + + if self.user: + payload['user'] = self.user + + # Prepare our Identifier type + params = { + 'identifierType': 'id', + } + + for request_id in request_ids: + if action == JiraAlertAction.DELETE: + # Update our URL + url = f'{notify_url}/{request_id}' + method = requests.delete + + elif action == JiraAlertAction.ACKNOWLEDGE: + url = f'{notify_url}/{request_id}/acknowledge' + + elif action == JiraAlertAction.CLOSE: + url = f'{notify_url}/{request_id}/close' + + else: # action == JiraAlertAction.CLOSE: + url = f'{notify_url}/{request_id}/notes' + + # Perform our post + success, _ = self._fetch(method, url, payload, params) + + if not success: + has_error = True + + if not has_error and action == JiraAlertAction.DELETE: + # Remove cached entry + self.store.clear(key) + + else: + self.logger.info( + 'No Jira notification sent due to (nothing to %s) ' + 'condition', self.action) + + return not has_error + + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.region_name, self.apikey) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'action': self.action, + 'region': self.region_name, + 'priority': + JIRA_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in JIRA_PRIORITIES + else JIRA_PRIORITIES[self.priority], + 'batch': 'yes' if self.batch_size > 1 else 'no', + } + + # Assign our entity value (if defined) + if self.entity: + params['entity'] = self.entity + + # Assign our alias value (if defined) + if self.alias: + params['alias'] = self.alias + + # Assign our tags (if specifed) + if self.__tags: + params['tags'] = ','.join(self.__tags) + + # Append our details into our parameters + params.update({'+{}'.format(k): v for k, v in self.details.items()}) + + # Append our assignment extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.mapping.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # A map allows us to map our target types so they can be correctly + # placed back into your URL below. Hence map the 'user' -> '@' + __map = { + JiraCategory.USER: + NotifyJira.template_tokens['target_user']['prefix'], + JiraCategory.SCHEDULE: + NotifyJira.template_tokens['target_schedule']['prefix'], + JiraCategory.ESCALATION: + NotifyJira.template_tokens['target_escalation']['prefix'], + JiraCategory.TEAM: + NotifyJira.template_tokens['target_team']['prefix'], + } + + return '{schema}://{user}{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + user='{}@'.format(self.user) if self.user else '', + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyJira.quote('{}{}'.format( + __map[x['type']], + x.get('id', x.get('name', x.get('username'))))) + for x in self.targets]), + params=NotifyJira.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + targets = len(self.targets) + if self.batch_size > 1: + targets = int(targets / self.batch_size) + \ + (1 if targets % self.batch_size else 0) + + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The API Key is stored in the hostname + results['apikey'] = NotifyJira.unquote(results['host']) + + # Get our Targets + results['targets'] = NotifyJira.split_path(results['fullpath']) + + # Add our Meta Detail keys + results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyJira.unquote(results['qsd']['priority']) + + # Get Batch Boolean (if set) + results['batch'] = \ + parse_bool( + results['qsd'].get( + 'batch', + NotifyJira.template_args['batch']['default'])) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyJira.unquote(results['qsd']['apikey']) + + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + # Extract our tags + results['tags'] = \ + parse_list(NotifyJira.unquote(results['qsd']['tags'])) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract our region + results['region_name'] = \ + NotifyJira.unquote(results['qsd']['region']) + + if 'entity' in results['qsd'] and len(results['qsd']['entity']): + # Extract optional entity field + results['entity'] = \ + NotifyJira.unquote(results['qsd']['entity']) + + if 'alias' in results['qsd'] and len(results['qsd']['alias']): + # Extract optional alias field + results['alias'] = \ + NotifyJira.unquote(results['qsd']['alias']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Store our action (if defined) + if 'action' in results['qsd'] and len(results['qsd']['action']): + results['action'] = \ + NotifyJira.unquote(results['qsd']['action']) + + # store any custom mapping defined + results['mapping'] = \ + {NotifyJira.unquote(x): NotifyJira.unquote(y) + for x, y in results['qsd:'].items()} + + return results diff --git a/apprise/plugins/opsgenie.py b/apprise/plugins/opsgenie.py index 013c1ded..51f57139 100644 --- a/apprise/plugins/opsgenie.py +++ b/apprise/plugins/opsgenie.py @@ -339,6 +339,11 @@ class NotifyOpsgenie(NotifyBase): """ super().__init__(**kwargs) + # Notify users that this plugin will require them to switch soon + self.logger.deprecate( + 'Opsgenie will soon be depricated and moved to Jira; ' + 'visit https://atlassian.com/ for more details') + # API Key (associated with project) self.apikey = validate_regex(apikey) if not self.apikey: diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index f835e9f6..d21d429b 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -42,7 +42,7 @@ it easy to access: Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Burst SMS, BulkSMS, BulkVS, Chanify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home -Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, +Assistant, httpSMS, IFTTT, Jira, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, LunaSea, MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal, diff --git a/test/test_plugin_jira.py b/test/test_plugin_jira.py new file mode 100644 index 00000000..5c83c96e --- /dev/null +++ b/test/test_plugin_jira.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from unittest import mock +from json import dumps +import requests +import apprise +from apprise.plugins.jira import ( + NotifyType, NotifyJira, JiraPriority) +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# a test UUID we can use +UUID4 = '8b799edf-6f98-4d3a-9be7-2862fb4e5752' + +JIRA_GOOD_RESPONSE = dumps({ + "result": "Request will be processed", + "took": 0.204, + "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120" +}) + +# Our Testing URLs +apprise_url_tests = ( + ('jira://', { + # We failed to identify any valid authentication + 'instance': TypeError, + }), + ('jira://:@/', { + # We failed to identify any valid authentication + 'instance': TypeError, + }), + ('jira://%20%20/', { + # invalid apikey specified + 'instance': TypeError, + }), + ('jira://apikey/user/?region=xx', { + # invalid region id + 'instance': TypeError, + }), + ('jira://user@apikey/', { + # No targets specified; this is allowed + 'instance': NotifyJira, + 'notify_type': NotifyType.WARNING, + # Bad response returned + 'requests_response_text': '{', + # We will not be successful sending the notice + 'notify_response': False, + }), + ('jira://apikey/', { + # No targets specified; this is allowed + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/user', { + # Valid user + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + 'privacy_url': 'jira://a...y/%40user', + }), + ('jira://apikey/@user?region=eu', { + # European Region + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/@user?entity=A%20Entity', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/@user?alias=An%20Alias', { + # Assign an alias + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + # Bad Action + ('jira://apikey/@user?action=invalid', { + # Assign an entity + 'instance': TypeError, + }), + ('jira://from@apikey/@user?:invalid=note', { + # Assign an entity + 'instance': TypeError, + }), + ('jira://apikey/@user?:warning=invalid', { + # Assign an entity + 'instance': TypeError, + }), + # Creates an index entry + ('jira://apikey/@user?entity=index&action=new', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + # Now action it + ('jira://apikey/@user?entity=index&action=acknowledge', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.SUCCESS, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://from@apikey/@user?entity=index&action=note', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.SUCCESS, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://from@apikey/@user?entity=index&action=note', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.SUCCESS, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + 'response': False, + 'requests_response_code': 500, + }), + ('jira://apikey/@user?entity=index&action=close', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.SUCCESS, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/@user?entity=index&action=delete', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.SUCCESS, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + # map info messages to generate a new message + ('jira://apikey/@user?entity=index2&:info=new', { + # Assign an entity + 'instance': NotifyJira, + 'notify_type': NotifyType.INFO, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://joe@apikey/@user?priority=p3', { + # Assign our priority + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/?tags=comma,separated', { + # Test our our 'tags' (tag is reserved in Apprise) but not 'tags' + # Also test the fact we do not need to define a target + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/@user?priority=invalid', { + # Invalid priority (loads using default) + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/user@email.com/#team/*sche/^esc/%20/a', { + # Valid user (email), valid schedule, Escalated ID, + # an invalid entry (%20), and too short of an entry (a) + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/@{}/#{}/*{}/^{}/'.format( + UUID4, UUID4, UUID4, UUID4), { + # similar to the above, except we use the UUID's + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + # Same link as before but @ missing at the front causing an ambigious + # lookup however the entry is treated a though a @ was in front (user) + ('jira://apikey/{}/#{}/*{}/^{}/'.format( + UUID4, UUID4, UUID4, UUID4), { + # similar to the above, except we use the UUID's + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey?to=#team,user&+key=value&+type=override', { + # Test to= and details (key/value pair) also override 'type' + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/#team/@user/?batch=yes', { + # Test batch= + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/#team/@user/?batch=no', { + # Test batch= + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://?apikey=abc&to=user', { + # Test Kwargs + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + }), + ('jira://apikey/#team/user/', { + 'instance': NotifyJira, + # throw a bizzare code forcing us to fail to look it up + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + 'response': False, + 'requests_response_code': 999, + }), + ('jira://apikey/#topic1/device/', { + 'instance': NotifyJira, + 'notify_type': NotifyType.FAILURE, + # Our response expected server response + 'requests_response_text': JIRA_GOOD_RESPONSE, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_jira_urls(tmpdir): + """ + NotifyJira() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all(str(tmpdir)) + + +@mock.patch('requests.post') +def test_plugin_jira_config_files(mock_post): + """ + NotifyJira() Config File Cases + """ + content = """ + urls: + - jira://apikey/user: + - priority: 1 + tag: jira_int low + - priority: "1" + tag: jira_str_int low + - priority: "p1" + tag: jira_pstr_int low + - priority: low + tag: jira_str low + + # This will take on moderate (default) priority + - priority: invalid + tag: jira_invalid + + - jira://apikey2/user2: + - priority: 5 + tag: jira_int emerg + - priority: "5" + tag: jira_str_int emerg + - priority: "p5" + tag: jira_pstr_int emerg + - priority: emergency + tag: jira_str emerg + """ + + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + mock_post.return_value.content = JIRA_GOOD_RESPONSE + + # Create ourselves a config object + ac = apprise.AppriseConfig() + assert ac.add_config(content=content) is True + + aobj = apprise.Apprise() + + # Add our configuration + aobj.add(ac) + + # We should be able to read our 9 servers from that + # 4x low + # 4x emerg + # 1x invalid (so takes on normal priority) + assert len(ac.servers()) == 9 + assert len(aobj) == 9 + assert len([x for x in aobj.find(tag='low')]) == 4 + for s in aobj.find(tag='low'): + assert s.priority == JiraPriority.LOW + + assert len([x for x in aobj.find(tag='emerg')]) == 4 + for s in aobj.find(tag='emerg'): + assert s.priority == JiraPriority.EMERGENCY + + assert len([x for x in aobj.find(tag='jira_str')]) == 2 + assert len([x for x in aobj.find(tag='jira_str_int')]) == 2 + assert len([x for x in aobj.find(tag='jira_pstr_int')]) == 2 + assert len([x for x in aobj.find(tag='jira_int')]) == 2 + + assert len([x for x in aobj.find(tag='jira_invalid')]) == 1 + assert next(aobj.find(tag='jira_invalid')).priority == \ + JiraPriority.NORMAL + + +@mock.patch('requests.post') +def test_plugin_jira_edge_case(mock_post): + """ + NotifyJira() Edge Cases + """ + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + mock_post.return_value.content = JIRA_GOOD_RESPONSE + + instance = apprise.Apprise.instantiate('jira://apikey') + assert isinstance(instance, NotifyJira) + + assert len(instance.store.keys()) == 0 + assert instance.notify('test', 'key', NotifyType.FAILURE) is True + assert len(instance.store.keys()) == 1 + + # Again just causes same index to get over-written + assert instance.notify('test', 'key', NotifyType.FAILURE) is True + assert len(instance.store.keys()) == 1 + assert 'a62f2225bf' in instance.store + + # Assign it garbage + instance.store['a62f2225bf'] = 'garbage' + # This causes an internal check to fail where the keys are expected to be + # as a list (this one is now a string) + # content self corrects and things are fine + assert instance.notify('test', 'key', NotifyType.FAILURE) is True + assert len(instance.store.keys()) == 1 + + # new key is new index + assert instance.notify('test', 'key2', NotifyType.FAILURE) is True + assert len(instance.store.keys()) == 2