Repository: caronc/apprise (python)

Issue:
OpsGenie becoming Jira Service Management

:loudspeaker: **OpsGenie Clients Must Migrate to Jira Service Management**


:link: **In February OpsGenie becomes read only and alerts will be managed through JSM**
- API Reference: [here](https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v1/api-group-integration-events/#api-group-integration-events)

:bulb: **Anything else?**
I believe the API works the same as the OpsGenie api, the only difference is that the endpoint changes to an Atlassian endpoint


Patch:
diff --git a/README.md b/README.md
index 64d2f18f4..d5b1a9b13 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,7 @@ The table below identifies the services this tool supports and some example serv
 | [IFTTT](https://appriseit.com/services/ifttt/) | ifttt://    | (TCP) 443    | ifttt://webhooksID/Event<br />ifttt://webhooksID/Event1/Event2/EventN<br/>ifttt://webhooksID/Event1/?+Key=Value<br/>ifttt://webhooksID/Event1/?-Key=value1
 | [IRC](https://appriseit.com/services/irc/) | irc:// or ircs://   | (TCP) 6667 or 6697 | ircs://user:pass@irc.server/@user<br /> ircs://user:pass@irc.server/#channel?join=true&mode=nickserv<br/>ircs://user:pass@znc.server/@user1/@user2/@user3/#channel1
 | [Jellyfin](https://appriseit.com/services/jellyfin/)  | jellyfin:// or jellyfins:// | (TCP) 8096 | jellyfin://user@hostname/<br />jellyfins://user:password@hostname
+| [Jira](https://appriseit.com/services/jira/) | jira:// | (TCP) 443 | jira://APIKey<br/>jira://APIKey/@UserID<br/>jira://APIKey/#Team<br/>jira://APIKey/\*Schedule<br/>jira://APIKey/^Escalation
 | [Join](https://appriseit.com/services/join/) | join://   | (TCP) 443    | join://apikey/device<br />join://apikey/device1/device2/deviceN/<br />join://apikey/group<br />join://apikey/groupA/groupB/groupN<br />join://apikey/DeviceA/groupA/groupN/DeviceN/
 | [KODI](https://appriseit.com/services/kodi/) | kodi:// or kodis://    | (TCP) 8080 or 443   | kodi://hostname<br />kodi://user@hostname<br />kodi://user:password@hostname:port
 | [Kumulos](https://appriseit.com/services/kumulos/) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey
diff --git a/apprise/plugins/jira.py b/apprise/plugins/jira.py
new file mode 100644
index 000000000..a4735d8f1
--- /dev/null
+++ b/apprise/plugins/jira.py
@@ -0,0 +1,911 @@
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
+#
+# 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 hashlib
+from json import dumps, loads
+
+import requests
+
+from ..common import NotifyType, PersistentStoreMode
+from ..locale import gettext_lazy as _
+from ..utils.parse import is_uuid, parse_bool, parse_list, validate_regex
+from .base import NotifyBase
+
+
+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,
+)
+
+
+# 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 an 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) from None
+
+        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 NotifyType 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: {}".format(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: {}".format(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 = (
+            self.mapping[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 = title if title else body
+
+            # 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.NOTE:
+                    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.value): 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, safe=":"),
+        )
+
+    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 0a71aea68..c9f0b1355 100644
--- a/apprise/plugins/opsgenie.py
+++ b/apprise/plugins/opsgenie.py
@@ -50,7 +50,7 @@
 
 import requests
 
-from ..common import NOTIFY_TYPES, NotifyType, PersistentStoreMode
+from ..common import NotifyType, PersistentStoreMode
 from ..locale import gettext_lazy as _
 from ..utils.parse import is_uuid, parse_bool, parse_list, validate_regex
 from .base import NotifyBase
@@ -104,14 +104,6 @@ class OpsgenieAlertAction:
     OpsgenieAlertAction.NOTE,
 )
 
-# Map all support Apprise Categories to Opsgenie Categories
-OPSGENIE_ALERT_MAP = {
-    NotifyType.INFO: OpsgenieAlertAction.CLOSE,
-    NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,
-    NotifyType.WARNING: OpsgenieAlertAction.NEW,
-    NotifyType.FAILURE: OpsgenieAlertAction.NEW,
-}
-
 
 # Regions
 class OpsgenieRegion:
@@ -210,7 +202,7 @@ class NotifyOpsgenie(NotifyBase):
 
     # Defines our default message mapping
     opsgenie_message_map = {
-        # Add a note to existing alert
+        # Add a note to an existing alert
         NotifyType.INFO: OpsgenieAlertAction.NOTE,
         # Close existing alert
         NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,
@@ -349,6 +341,12 @@ def __init__(
         """Initialize Opsgenie Object."""
         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:
@@ -403,7 +401,7 @@ def __init__(
         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)
+                k = next((t for t in NotifyType if t.startswith(k_)), None)
                 if not k:
                     msg = (
                         f"The Opsgenie mapping key specified ({k_}) "
@@ -597,7 +595,7 @@ def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs):
 
         # Get our Opsgenie Action
         action = (
-            OPSGENIE_ALERT_MAP[notify_type]
+            self.mapping[notify_type]
             if self.action == OpsgenieAlertAction.MAP
             else self.action
         )
@@ -817,7 +815,7 @@ def url(self, privacy=False, *args, **kwargs):
                     for x in self.targets
                 ]
             ),
-            params=NotifyOpsgenie.urlencode(params),
+            params=NotifyOpsgenie.urlencode(params, safe=":"),
         )
 
     def __len__(self):
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index 00948937d..e54a93f58 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -63,23 +63,23 @@ notification services. It supports sending alerts to platforms such as: \
 `Clickatell`, `ClickSend`, `DAPNET`, `DingTalk`, `Discord`, \
 `Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer`, \
 `Free Mobile`, `Google Chat`, `Gotify`, `Growl`, `Guilded`, \
-`Home Assistant`, `httpSMS`, `IFTTT`, `IRC`, `Jellyfin`, `Join`, `Kavenegar`, \
-`KODI`, `Kumulos`, `LaMetric`, `Lark`, `Line`, `MacOSX`, `Mailgun`, \
-`Mastodon`, `Mattermost`, `Matrix`, `MessageBird`, `Microsoft Windows`, \
-`Microsoft Teams`, `Misskey`, `MQTT`, `MSG91`, `MyAndroid`, `Nexmo`, \
-`Nextcloud`, `NextcloudTalk`, `Notica`, `NotificationAPI`, `Notifiarr`, \
-`Notifico`, `ntfy`, `Office365`, `OneSignal`, `Opsgenie`, `PagerDuty`, \
-`PagerTree`, `ParsePlatform`, `Plivo`, `PopcornNotify`, `Prowl`, `Pushalot`, \
-`PushBullet`, `Pushjet`, `PushMe`, `Pushover`, `Pushplus`, `PushSafer`, \
-`Pushy`, `PushDeer`, `QQ Push`, `Revolt`, `Reddit`, `Resend`, \
-`Rocket.Chat`, `RSyslog`, `SendGrid`, `SendPulse`, `ServerChan`, `Seven`, \
-`SFR`, `Signal`, `SIGNL4`, `SimplePush`, `Sinch`, `Slack`, `SMPP`, \
-`SMSEagle`, `SMS Manager`, `SMTP2Go`, `SparkPost`, `Splunk`, `Spike`, \
-`Spug Push`, `Super Toasty`, `Streamlabs`, `Stride`, `Synology Chat`, \
-`Syslog`, `Techulus Push`, `Telegram`, `Threema Gateway`, `Twilio`, \
-`Twitter`, `Twist`, `Vapid`, `Viber`, `VictorOps`, `Voipms`, `Vonage`, \
-`WebPush`, `WeCom Bot`, `WhatsApp`, `Webex Teams`, `Workflows`, `WxPusher`,
-`XBMC`, `XMPP`, and `Zulip`.}
+`Home Assistant`, `httpSMS`, `IFTTT`, `IRC`, `Jellyfin`, `Jira`, `Join`, \
+`Kavenegar`, `KODI`, `Kumulos`, `LaMetric`, `Lark`, `Line`, `MacOSX`, \
+`Mailgun`, `Mastodon`, `Mattermost`, `Matrix`, `MessageBird`, \
+`Microsoft Windows`, `Microsoft Teams`, `Misskey`, `MQTT`, `MSG91`, \
+`MyAndroid`, `Nexmo`, `Nextcloud`, `NextcloudTalk`, `Notica`, \
+`NotificationAPI`, `Notifiarr`, `Notifico`, `ntfy`, `Office365`, \
+`OneSignal`, `Opsgenie`, `PagerDuty`, `PagerTree`, `ParsePlatform`, `Plivo`, \
+`PopcornNotify`, `Prowl`, `Pushalot`, `PushBullet`, `Pushjet`, `PushMe`, \
+`Pushover`, `Pushplus`, `PushSafer`, `Pushy`, `PushDeer`, `QQ Push`, \
+`Revolt`, `Reddit`, `Resend`, `Rocket.Chat`, `RSyslog`, `SendGrid`, \
+`SendPulse`, `ServerChan`, `Seven`, `SFR`, `Signal`, `SIGNL4`, `SimplePush`, \
+`Sinch`, `Slack`, `SMPP`, `SMSEagle`, `SMS Manager`, `SMTP2Go`, `SparkPost`, \
+`Splunk`, `Spike`, `Spug Push`, `Super Toasty`, `Streamlabs`, `Stride`, \
+`Synology Chat`, `Syslog`, `Techulus Push`, `Telegram`, `Threema Gateway`, \
+`Twilio`, `Twitter`, `Twist`, `Vapid`, `Viber`, `VictorOps`, `Voipms`, \
+`Vonage`, `WebPush`, `WeCom Bot`, `WhatsApp`, `Webex Teams`, `Workflows`, \
+`WxPusher`, `XBMC`, `XMPP`, and `Zulip`.}
 
 Name:           python-%{pypi_name}
 Version:        1.9.9
diff --git a/pyproject.toml b/pyproject.toml
index 74822dab7..de5c0cf5f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -93,6 +93,7 @@ keywords = [
     "IFTTT",
     "IRC",
     "Jellyfin",
+    "Jira",
     "Join",
     "JSON",
     "Kavenegar",


Test patch:
diff --git a/tests/test_plugin_jira.py b/tests/test_plugin_jira.py
new file mode 100644
index 000000000..abfcad960
--- /dev/null
+++ b/tests/test_plugin_jira.py
@@ -0,0 +1,563 @@
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
+#
+# 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 json import dumps
+
+# Disable logging for a cleaner testing output
+import logging
+from unittest import mock
+
+from helpers import AppriseURLTester
+import requests
+
+import apprise
+from apprise.plugins.jira import JiraPriority, NotifyJira, NotifyType
+
+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(list(aobj.find(tag="low"))) == 4
+    for s in aobj.find(tag="low"):
+        assert s.priority == JiraPriority.LOW
+
+    assert len(list(aobj.find(tag="emerg"))) == 4
+    for s in aobj.find(tag="emerg"):
+        assert s.priority == JiraPriority.EMERGENCY
+
+    assert len(list(aobj.find(tag="jira_str"))) == 2
+    assert len(list(aobj.find(tag="jira_str_int"))) == 2
+    assert len(list(aobj.find(tag="jira_pstr_int"))) == 2
+    assert len(list(aobj.find(tag="jira_int"))) == 2
+
+    assert len(list(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
+
+
+@mock.patch("requests.post")
+def test_plugin_jira_mapping(mock_post):
+    """
+    NotifyJira() Action Mapping
+
+    Verify that custom :key=value URL mappings correctly override the default
+    action taken for each notification type, and that self.mapping (not the
+    removed module-level constant) is used in send().
+    """
+    # 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
+
+    base_url = "https://api.atlassian.com/jsm/ops/integration/v2/alerts"
+
+    # --- Default mapping: INFO → NOTE ---
+    # INFO adds a note to an existing alert; with no stored request IDs there
+    # is nothing to annotate, so no HTTP call is made.  send() still returns
+    # True (nothing to do is not an error).
+    instance = apprise.Apprise.instantiate("jira://apikey/@user")
+    assert isinstance(instance, NotifyJira)
+
+    assert instance.notify("body", "title", NotifyType.INFO) is True
+    assert mock_post.call_count == 0
+
+    # --- Default mapping: WARNING/FAILURE → NEW ---
+    # These should POST to the base alerts URL immediately.
+    mock_post.reset_mock()
+    assert instance.notify("body", "title", NotifyType.WARNING) is True
+    assert mock_post.call_count == 1
+    assert mock_post.call_args[0][0] == base_url
+
+    mock_post.reset_mock()
+    assert instance.notify("body", "title", NotifyType.FAILURE) is True
+    assert mock_post.call_count == 1
+    assert mock_post.call_args[0][0] == base_url
+
+    # --- Default mapping: SUCCESS → CLOSE ---
+    # instance already has stored request IDs from the WARNING/FAILURE calls
+    # above, so SUCCESS should POST to the /close endpoint.
+    mock_post.reset_mock()
+    assert instance.notify("body", "title", NotifyType.SUCCESS) is True
+    assert mock_post.call_count >= 1
+    assert mock_post.call_args[0][0].endswith("/close")
+
+    # Fresh instance: SUCCESS with no stored IDs → nothing to close, no call.
+    fresh = apprise.Apprise.instantiate("jira://apikey/@user")
+    mock_post.reset_mock()
+    assert fresh.notify("body", "title", NotifyType.SUCCESS) is True
+    assert mock_post.call_count == 0
+
+    # --- Custom mapping: :info=new overrides INFO → NOTE with INFO → NEW ---
+    instance2 = apprise.Apprise.instantiate("jira://apikey/@user?:info=new")
+    assert isinstance(instance2, NotifyJira)
+
+    mock_post.reset_mock()
+    assert instance2.notify("body", "title", NotifyType.INFO) is True
+    # A POST to the base alerts URL must have been made
+    assert mock_post.call_count == 1
+    assert mock_post.call_args[0][0] == base_url
+
+    # --- URL round-trip preserves custom mapping ---
+    # The colon prefix is kept unencoded for readability.
+    url = instance2.url()
+    assert ":info=new" in url
+
+    # Re-instantiate from the reconstructed URL and verify the mapping
+    instance3 = apprise.Apprise.instantiate(url)
+    assert isinstance(instance3, NotifyJira)
+    assert instance3.mapping[NotifyType.INFO] == "new"
+
+    mock_post.reset_mock()
+    assert instance3.notify("body", "title", NotifyType.INFO) is True
+    assert mock_post.call_count == 1
+    assert mock_post.call_args[0][0] == base_url