← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:jira-bug-watch into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:jira-bug-watch into launchpad:master.

Commit message:
Add external bug tracker for JIRA

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/433103
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:jira-bug-watch into launchpad:master.
diff --git a/lib/lp/bugs/externalbugtracker/__init__.py b/lib/lp/bugs/externalbugtracker/__init__.py
index 65c5333..d4b887d 100644
--- a/lib/lp/bugs/externalbugtracker/__init__.py
+++ b/lib/lp/bugs/externalbugtracker/__init__.py
@@ -51,6 +51,7 @@ from lp.bugs.externalbugtracker.bugzilla import Bugzilla
 from lp.bugs.externalbugtracker.debbugs import DebBugs, DebBugsDatabaseNotFound
 from lp.bugs.externalbugtracker.github import GitHub
 from lp.bugs.externalbugtracker.gitlab import GitLab
+from lp.bugs.externalbugtracker.jira import Jira
 from lp.bugs.externalbugtracker.mantis import Mantis
 from lp.bugs.externalbugtracker.roundup import Roundup
 from lp.bugs.externalbugtracker.rt import RequestTracker
@@ -68,6 +69,7 @@ BUG_TRACKER_CLASSES = {
     BugTrackerType.ROUNDUP: Roundup,
     BugTrackerType.RT: RequestTracker,
     BugTrackerType.SOURCEFORGE: SourceForge,
+    BugTrackerType.JIRA: Jira,
 }
 
 
diff --git a/lib/lp/bugs/externalbugtracker/base.py b/lib/lp/bugs/externalbugtracker/base.py
index ee7be38..0d2efd4 100644
--- a/lib/lp/bugs/externalbugtracker/base.py
+++ b/lib/lp/bugs/externalbugtracker/base.py
@@ -279,16 +279,17 @@ class ExternalBugTracker:
         except requests.RequestException as e:
             raise BugTrackerConnectError(self.baseurl, e)
 
-    def _postPage(self, page, form, repost_on_redirect=False):
+    def _postPage(self, page, data, repost_on_redirect=False, json=False):
         """POST to the specified page and form.
 
-        :param form: is a dict of form variables being POSTed.
+        :param data: is a dict of form variables being POSTed.
         :param repost_on_redirect: override RFC-compliant redirect handling.
             By default, if the POST receives a redirect response, the
             request to the redirection's target URL will be a GET.  If
             `repost_on_redirect` is True, this method will do a second POST
             instead.  Do this only if you are sure that repeated POST to
             this page is safe, as is usually the case with search forms.
+        :param json: if True, the data will be JSON encoded.
         :return: A `requests.Response` object.
         """
         hooks = (
@@ -301,8 +302,12 @@ class ExternalBugTracker:
             if not url.endswith("/"):
                 url += "/"
             url = urljoin(url, page)
+            if json:
+                kwargs = {"json": data}
+            else:
+                kwargs = {"data": data}
             response = self.makeRequest(
-                "POST", url, headers=self._getHeaders(), data=form, hooks=hooks
+                "POST", url, headers=self._getHeaders(), hooks=hooks, **kwargs
             )
             raise_for_status_redacted(response)
             return response
diff --git a/lib/lp/bugs/externalbugtracker/jira.py b/lib/lp/bugs/externalbugtracker/jira.py
new file mode 100644
index 0000000..6f9541f
--- /dev/null
+++ b/lib/lp/bugs/externalbugtracker/jira.py
@@ -0,0 +1,268 @@
+# Copyright 2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Jira ExternalBugTracker utility."""
+
+__all__ = [
+    "Jira",
+    "JiraCredentials",
+    "JiraBug",
+    "JiraStatus",
+    "JiraPriority",
+]
+
+import base64
+import datetime
+from enum import Enum
+from typing import Dict, Iterable, NamedTuple, Optional, Tuple
+from urllib.parse import urlunsplit
+
+import dateutil.parser
+
+from lp.bugs.externalbugtracker import (
+    BugTrackerConnectError,
+    ExternalBugTracker,
+)
+from lp.bugs.interfaces.bugtask import BugTaskImportance, BugTaskStatus
+from lp.services.config import config
+from lp.services.webapp.url import urlsplit
+
+JiraCredentials = NamedTuple(
+    "JiraCredentials",
+    (
+        ("username", str),
+        ("password", str),
+    ),
+)
+
+
+class JiraStatus(Enum):
+
+    UNDEFINED = "undefined"
+    NEW = "new"
+    INDETERMINATE = "indeterminate"
+    DONE = "done"
+
+    @property
+    def launchpad_status(self):
+        if self == JiraStatus.UNDEFINED:
+            return BugTaskStatus.UNKNOWN
+        elif self == JiraStatus.NEW:
+            return BugTaskStatus.NEW
+        elif self == JiraStatus.INDETERMINATE:
+            return BugTaskStatus.INPROGRESS
+        elif self == JiraStatus.DONE:
+            return BugTaskStatus.FIXRELEASED
+        else:
+            raise AssertionError()
+
+
+class JiraPriority(Enum):
+
+    UNDEFINED = "undefined"
+    LOWEST = "Lowest"
+    LOW = "Low"
+    MEDIUM = "Medium"
+    HIGH = "High"
+    HIGHEST = "Highest"
+
+    @property
+    def launchpad_importance(self):
+        if self == JiraPriority.UNDEFINED:
+            return BugTaskImportance.UNKNOWN
+        elif self == JiraPriority.LOWEST:
+            return BugTaskImportance.WISHLIST
+        elif self == JiraPriority.LOW:
+            return BugTaskImportance.LOW
+        elif self == JiraPriority.MEDIUM:
+            return BugTaskImportance.MEDIUM
+        elif self == JiraPriority.HIGH:
+            return BugTaskImportance.HIGH
+        elif self == JiraPriority.HIGHEST:
+            return BugTaskImportance.CRITICAL
+        else:
+            raise AssertionError()
+
+
+class JiraBug:
+    def __init__(self, key: str, status: JiraStatus, priority: JiraPriority):
+        self.key = key
+        self.status = status
+        self.priority = priority
+
+    @classmethod
+    def from_api_data(cls, bug_data) -> "JiraBug":
+        try:
+            status = JiraStatus(
+                bug_data["fields"]["status"]["statusCategory"]["key"]
+            )
+        except ValueError:
+            status = JiraStatus.UNDEFINED
+
+        try:
+            priority = JiraPriority(bug_data["fields"]["priority"]["name"])
+        except ValueError:
+            priority = JiraPriority.UNDEFINED
+
+        return cls(
+            key=bug_data["key"],
+            status=status,
+            priority=priority,
+        )
+
+    def __eq__(self, other):
+        if not isinstance(other, JiraBug):
+            raise ValueError()
+        return (
+            self.key == other.key
+            and self.status == other.status
+            and self.priority == other.priority
+        )
+
+
+class Jira(ExternalBugTracker):
+    """An `ExternalBugTracker` for dealing with Jira issues."""
+
+    batch_query_threshold = 0  # Always use the batch method.
+
+    def __init__(self, baseurl):
+        _, host, path, query, fragment = urlsplit(baseurl)
+        path = "/rest/api/2/"
+        baseurl = urlunsplit(("https", host, path, "", ""))
+        super().__init__(baseurl)
+        self.cached_bugs = {}  # type: Dict[str, Optional[JiraBug]]
+
+    @property
+    def credentials(self) -> Optional[JiraCredentials]:
+        credentials_config = config["checkwatches.credentials"]
+        # lazr.config.Section doesn't support get().
+        try:
+            username = credentials_config["{}.username".format(self.basehost)]
+            password = credentials_config["{}.password".format(self.basehost)]
+            return JiraCredentials(
+                username=username,
+                password=password,
+            )
+        except KeyError:
+            return
+
+    def getCurrentDBTime(self):
+        # See https://docs.atlassian.com/software/jira/docs/api/REST/9.3.1/#api/2/serverInfo-getServerInfo  # noqa
+        response_data = self._getPage("serverInfo").json()
+        return dateutil.parser.parse(response_data["serverTime"]).astimezone(
+            datetime.timezone.utc
+        )
+
+    def getModifiedRemoteBugs(self, bug_ids, last_accessed):
+        """See `IExternalBugTracker`."""
+        modified_bugs = self.getRemoteBugBatch(
+            bug_ids, last_accessed=last_accessed
+        )
+        self.cached_bugs.update(modified_bugs)
+        return list(modified_bugs)
+
+    def getRemoteBug(self, bug_id: str) -> Tuple[str, Optional[JiraBug]]:
+        """See `ExternalBugTracker`."""
+        if bug_id not in self.cached_bugs:
+            self.cached_bugs[bug_id] = self._loadJiraBug(bug_id)
+        return bug_id, self.cached_bugs[bug_id]
+
+    def getRemoteBugBatch(
+        self, bug_ids, last_accessed=None
+    ) -> Dict[str, Optional[JiraBug]]:
+        """See `ExternalBugTracker`."""
+        bugs = {
+            bug_id: self.cached_bugs[bug_id]
+            for bug_id in bug_ids
+            if bug_id in self.cached_bugs
+        }
+        if set(bugs) == set(bug_ids):
+            return bugs
+
+        for jira_bug in self._loadJiraBugs(
+            bug_ids, last_accessed=last_accessed
+        ):
+            if jira_bug.key not in bug_ids:
+                continue
+            bugs[jira_bug.key] = self.cached_bugs[jira_bug.key] = jira_bug
+
+        return bugs
+
+    def getRemoteImportance(self, bug_id) -> str:
+        """See `ExternalBugTracker`."""
+        remote_bug = self.bugs[bug_id]  # type: JiraBug
+        return remote_bug.priority.value
+
+    def getRemoteStatus(self, bug_id) -> str:
+        """See `ExternalBugTracker`."""
+        remote_bug = self.bugs[bug_id]  # type: JiraBug
+        return remote_bug.status.value
+
+    def convertRemoteImportance(
+        self, remote_importance: str
+    ) -> BugTaskImportance:
+        """See `IExternalBugTracker`."""
+        return JiraPriority(remote_importance).launchpad_importance
+
+    def convertRemoteStatus(self, remote_status: str) -> BugTaskStatus:
+        """See `IExternalBugTracker`."""
+        return JiraStatus(remote_status).launchpad_status
+
+    def _getHeaders(self):
+        headers = super()._getHeaders()
+        credentials = self.credentials
+        if credentials:
+            headers["Authorization"] = "Basic {}".format(
+                base64.b64encode(
+                    "{}:{}".format(
+                        credentials.username, credentials.password
+                    ).encode()
+                ).decode()
+            )
+        return headers
+
+    def _loadJiraBug(self, bug_id: str) -> Optional[JiraBug]:
+        # See https://docs.atlassian.com/software/jira/docs/api/REST/9.3.1/#api/2/issue-getIssue  # noqa
+        try:
+            response = self._getPage(
+                "issue/{}".format(bug_id),
+                params={
+                    "fields": "status,priority",
+                },
+            )
+        except BugTrackerConnectError:
+            return
+
+        return JiraBug.from_api_data(response.json())
+
+    def _loadJiraBugs(
+        self, bug_ids, last_accessed=None, start_at=0
+    ) -> Iterable[JiraBug]:
+        # See https://docs.atlassian.com/software/jira/docs/api/REST/9.3.1/#api/2/search-searchUsingSearchRequest  # noqa
+
+        jql_query = "id in ({})".format(",".join(bug_ids))
+        if last_accessed is not None:
+            jql_query = "{} AND updated >= {}".format(
+                jql_query, last_accessed.strftime("%Y-%m-%d %H:%M")
+            )
+
+        params = {
+            "jql": jql_query,
+            "fields": ["status", "priority"],
+            "startAt": start_at,
+        }
+
+        response_data = self._postPage("search", data=params, json=True).json()
+
+        max_results = response_data["maxResults"]
+        total = response_data["total"]
+
+        for bug_data in response_data["issues"]:
+            yield JiraBug.from_api_data(bug_data)
+
+        if total > (start_at + max_results):
+            yield from self._loadJiraBugs(
+                bug_ids,
+                last_accessed=last_accessed,
+                start_at=start_at + max_results,
+            )
diff --git a/lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py b/lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py
index 03b3197..2da0866 100644
--- a/lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py
+++ b/lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py
@@ -167,7 +167,7 @@ class TestCheckwatchesConfig(TestCase):
         )
         responses.add("POST", base_url + target, body=fake_form)
 
-        bugtracker._postPage(form, form={}, repost_on_redirect=True)
+        bugtracker._postPage(form, {}, repost_on_redirect=True)
 
         requests = [call.request for call in responses.calls]
         self.assertThat(
diff --git a/lib/lp/bugs/externalbugtracker/tests/test_jira.py b/lib/lp/bugs/externalbugtracker/tests/test_jira.py
new file mode 100644
index 0000000..1301dce
--- /dev/null
+++ b/lib/lp/bugs/externalbugtracker/tests/test_jira.py
@@ -0,0 +1,432 @@
+#  Copyright 2022 Canonical Ltd.  This software is licensed under the
+#  GNU Affero General Public License version 3 (see the file LICENSE).
+import datetime
+import json
+
+import responses
+import transaction
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    MatchesListwise,
+    MatchesStructure,
+    StartsWith,
+)
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.externalbugtracker import get_external_bugtracker
+from lp.bugs.externalbugtracker.jira import (
+    Jira,
+    JiraBug,
+    JiraCredentials,
+    JiraPriority,
+    JiraStatus,
+)
+from lp.bugs.interfaces.bugtask import BugTaskStatus
+from lp.bugs.interfaces.bugtracker import BugTrackerType
+from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+from lp.services.log.logger import BufferLogger
+from lp.testing import TestCase, TestCaseWithFactory, verifyObject
+from lp.testing.layers import ZopelessDatabaseLayer, ZopelessLayer
+
+
+class TestJira(TestCase):
+
+    layer = ZopelessLayer
+
+    def setUp(self):
+        super().setUp()
+        self.jira = Jira("https://warthogs.atlassian.net";)
+        self.pushConfig(
+            "checkwatches.credentials",
+            **{
+                "warthogs.atlassian.net.username": "launchpad",
+                "warthogs.atlassian.net.password": "launchpad",
+            },
+        )
+
+    def test_implements_interface(self):
+        self.assertTrue(verifyObject(IExternalBugTracker, self.jira))
+
+    def test_convert_jira_url_to_api_endpoint(self):
+        self.assertEqual(
+            "https://warthogs.atlassian.net/rest/api/2";, self.jira.baseurl
+        )
+
+    def test_credentials(self):
+        self.assertEqual(
+            JiraCredentials(
+                username="launchpad",
+                password="launchpad",
+            ),
+            self.jira.credentials,
+        )
+
+    def test_getHeaders(self):
+        headers = self.jira._getHeaders()
+        self.assertThat(
+            headers,
+            ContainsDict(
+                {"Authorization": Equals("Basic bGF1bmNocGFkOmxhdW5jaHBhZA==")}
+            ),
+        )
+
+    @responses.activate
+    def test_getCurrentDBTime(self):
+        responses.add(
+            "GET",
+            self.jira.baseurl + "/serverInfo",
+            json={
+                "baseUrl": "https://warthogs.atlassian.net";,
+                "buildDate": "2022-11-15T06:27:18.000+0800",
+                "buildNumber": 100210,
+                "defaultLocale": {"locale": "en_US"},
+                "deploymentType": "Cloud",
+                "scmInfo": "28a36363a81be3fec088cc03de57ea0d3b868a26",
+                "serverTime": "2022-11-15T14:11:11.818+0800",
+                "serverTitle": "Jira",
+                "version": "1001.0.0-SNAPSHOT",
+                "versionNumbers": [1001, 0, 0],
+            },
+        )
+        self.assertEqual(
+            self.jira.getCurrentDBTime(),
+            datetime.datetime(
+                2022, 11, 15, 6, 11, 11, 818000, tzinfo=datetime.timezone.utc
+            ),
+        )
+        requests = [call.request for call in responses.calls]
+        self.assertThat(
+            requests,
+            MatchesListwise(
+                [
+                    MatchesStructure(
+                        method=Equals("GET"),
+                        path_url=Equals("/rest/api/2/serverInfo"),
+                        headers=ContainsDict(
+                            {"Authorization": StartsWith("Basic ")}
+                        ),
+                    ),
+                ]
+            ),
+        )
+
+    @responses.activate
+    def test_getRemoteBug(self):
+        responses.add(
+            "GET",
+            self.jira.baseurl + "/issue/LP-984",
+            json={
+                "fields": {
+                    "priority": {"name": "Medium"},
+                    "status": {"statusCategory": {"key": "indeterminate"}},
+                },
+                "key": "LP-984",
+            },
+        )
+        responses.add("GET", self.jira.baseurl + "/issue/LP-123", status=404)
+        self.assertEqual(
+            (
+                "LP-984",
+                JiraBug(
+                    key="LP-984",
+                    status=JiraStatus.INDETERMINATE,
+                    priority=JiraPriority.MEDIUM,
+                ),
+            ),
+            self.jira.getRemoteBug("LP-984"),
+        )
+        self.assertEqual(("LP-123", None), self.jira.getRemoteBug("LP-123"))
+
+        requests = [call.request for call in responses.calls]
+        self.assertThat(
+            requests,
+            MatchesListwise(
+                [
+                    MatchesStructure(
+                        method=Equals("GET"),
+                        path_url=Equals(
+                            "/rest/api/2/issue/LP-984?fields=status%2Cpriority"
+                        ),
+                    ),
+                    MatchesStructure(
+                        method=Equals("GET"),
+                        path_url=Equals(
+                            "/rest/api/2/issue/LP-123?fields=status%2Cpriority"
+                        ),
+                    ),
+                ]
+            ),
+        )
+
+        # Getting the same bug the second time should fetch it from the cache
+        # without making another request to JIRA API
+        self.assertEqual(
+            (
+                "LP-984",
+                JiraBug(
+                    key="LP-984",
+                    status=JiraStatus.INDETERMINATE,
+                    priority=JiraPriority.MEDIUM,
+                ),
+            ),
+            self.jira.getRemoteBug("LP-984"),
+        )
+        self.assertEqual(("LP-123", None), self.jira.getRemoteBug("LP-123"))
+        self.assertEqual(2, len(responses.calls))
+
+    @responses.activate
+    def test_getRemoteBugBatch(self):
+
+        existing_bugs = [
+            {
+                "fields": {
+                    "priority": {"name": "High"},
+                    "status": {"statusCategory": {"key": "indeterminate"}},
+                },
+                "key": "1",
+            },
+            {
+                "fields": {
+                    "priority": {"name": "Medium"},
+                    "status": {"statusCategory": {"key": "done"}},
+                },
+                "key": "2",
+            },
+        ]
+
+        def search_callback(request):
+            payload = json.loads(request.body.decode())
+            start_at = payload["startAt"]
+
+            if start_at >= len(existing_bugs):
+                return 404, {}, ""
+
+            return (
+                200,
+                {},
+                json.dumps(
+                    {
+                        "issues": existing_bugs[start_at : start_at + 1],
+                        "total": len(existing_bugs),
+                        "startAt": start_at,
+                        "maxResults": 1,
+                    }
+                ),
+            )
+
+        responses.add_callback(
+            "POST",
+            self.jira.baseurl + "/search",
+            callback=search_callback,
+            content_type="application/json",
+        )
+
+        self.assertDictEqual(
+            {
+                "1": JiraBug(
+                    key="1",
+                    status=JiraStatus.INDETERMINATE,
+                    priority=JiraPriority.HIGH,
+                ),
+                "2": JiraBug(
+                    key="2",
+                    status=JiraStatus.DONE,
+                    priority=JiraPriority.MEDIUM,
+                ),
+            },
+            self.jira.getRemoteBugBatch(["1", "2"]),
+        )
+
+        requests = [call.request for call in responses.calls]
+        self.assertThat(
+            requests,
+            MatchesListwise(
+                [
+                    MatchesStructure(
+                        method=Equals("POST"),
+                        path_url=Equals("/rest/api/2/search"),
+                    ),
+                    MatchesStructure(
+                        method=Equals("POST"),
+                        path_url=Equals("/rest/api/2/search"),
+                    ),
+                ]
+            ),
+        )
+
+        for i, call in enumerate(responses.calls):
+            payload = json.loads(call.request.body.decode())
+            self.assertEqual("id in (1,2)", payload["jql"])
+            self.assertEqual(["status", "priority"], payload["fields"])
+            self.assertEqual(i, payload["startAt"])
+
+        # Getting the same bugs the second time should fetch it from the cache
+        # without making another request to JIRA API
+        self.assertDictEqual(
+            {
+                "1": JiraBug(
+                    key="1",
+                    status=JiraStatus.INDETERMINATE,
+                    priority=JiraPriority.HIGH,
+                ),
+                "2": JiraBug(
+                    key="2",
+                    status=JiraStatus.DONE,
+                    priority=JiraPriority.MEDIUM,
+                ),
+            },
+            self.jira.getRemoteBugBatch(["1", "2"]),
+        )
+        self.assertEqual(2, len(responses.calls))
+
+        # Verify JQL query when `last_accessed` is specified
+        self.jira.getRemoteBugBatch(
+            ["3"], last_accessed=datetime.datetime(2000, 1, 1, 1, 2, 3)
+        )
+        payload = json.loads(responses.calls[-1].request.body.decode())
+        self.assertEqual(
+            "id in (3) AND updated >= 2000-01-01 01:02", payload["jql"]
+        )
+
+
+class TestJiraUpdateBugWatches(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    @responses.activate
+    def test_process_one(self):
+        responses.add(
+            "GET",
+            "https://warthogs.atlassian.net/rest/api/2/issue/LP-984";,
+            json={
+                "fields": {
+                    "priority": {"name": "Medium"},
+                    "status": {"statusCategory": {"key": "indeterminate"}},
+                },
+                "key": "LP-984",
+            },
+        )
+        responses.add(
+            "GET",
+            "https://warthogs.atlassian.net/rest/api/2/serverInfo";,
+            json={
+                "serverTime": datetime.datetime.now(
+                    tz=datetime.timezone.utc
+                ).isoformat()
+            },
+        )
+        bug_tracker = self.factory.makeBugTracker(
+            base_url="https://warthogs.atlassian.net";,
+            bugtrackertype=BugTrackerType.JIRA,
+        )
+        bug = self.factory.makeBug()
+        bug.addWatch(
+            bug_tracker, "LP-984", getUtility(ILaunchpadCelebrities).janitor
+        )
+        self.assertEqual(
+            [("LP-984", None)],
+            [
+                (watch.remotebug, watch.remotestatus)
+                for watch in bug_tracker.watches
+            ],
+        )
+        transaction.commit()
+        logger = BufferLogger()
+        bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
+        jira = get_external_bugtracker(bug_tracker)
+        jira.batch_query_threshold = 1
+        bug_watch_updater.updateBugWatches(jira, bug_tracker.watches)
+        self.assertEqual(
+            "INFO Updating 1 watches for 1 bugs on "
+            "https://warthogs.atlassian.net/rest/api/2\n";,
+            logger.getLogBuffer(),
+        )
+        self.assertEqual(
+            [("LP-984", BugTaskStatus.INPROGRESS)],
+            [
+                (
+                    watch.remotebug,
+                    jira.convertRemoteStatus(watch.remotestatus),
+                )
+                for watch in bug_tracker.watches
+            ],
+        )
+
+    @responses.activate
+    def test_process_many(self):
+        remote_bugs = [
+            {
+                "fields": {
+                    "priority": {"name": "Medium"},
+                    "status": {
+                        "statusCategory": {
+                            "key": "indeterminate"
+                            if (bug_id % 2) == 0
+                            else "done"
+                        }
+                    },
+                },
+                "key": str(bug_id),
+            }
+            for bug_id in range(1000, 1010)
+        ]
+        responses.add(
+            "POST",
+            "https://warthogs.atlassian.net/rest/api/2/search";,
+            json={
+                "startAt": 0,
+                "maxResults": 100,
+                "total": len(remote_bugs),
+                "issues": remote_bugs,
+            },
+        )
+        responses.add(
+            "GET",
+            "https://warthogs.atlassian.net/rest/api/2/serverInfo";,
+            json={
+                "serverTime": datetime.datetime.now(
+                    tz=datetime.timezone.utc
+                ).isoformat()
+            },
+        )
+        bug = self.factory.makeBug()
+        bug_tracker = self.factory.makeBugTracker(
+            base_url="https://warthogs.atlassian.net";,
+            bugtrackertype=BugTrackerType.JIRA,
+        )
+        for remote_bug in remote_bugs:
+            bug.addWatch(
+                bug_tracker,
+                remote_bug["key"],
+                getUtility(ILaunchpadCelebrities).janitor,
+            )
+        transaction.commit()
+        logger = BufferLogger()
+        bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
+        jira = get_external_bugtracker(bug_tracker)
+        bug_watch_updater.updateBugWatches(jira, bug_tracker.watches)
+        self.assertEqual(
+            "INFO Updating 10 watches for 10 bugs on "
+            "https://warthogs.atlassian.net/rest/api/2\n";,
+            logger.getLogBuffer(),
+        )
+        self.assertContentEqual(
+            [
+                (str(bug_id), BugTaskStatus.INPROGRESS)
+                for bug_id in (1000, 1002, 1004, 1006, 1008)
+            ]
+            + [
+                (str(bug_id), BugTaskStatus.FIXRELEASED)
+                for bug_id in (1001, 1003, 1005, 1007, 1009)
+            ],
+            [
+                (
+                    watch.remotebug,
+                    jira.convertRemoteStatus(watch.remotestatus),
+                )
+                for watch in bug_tracker.watches
+            ],
+        )
diff --git a/lib/lp/bugs/interfaces/bugtracker.py b/lib/lp/bugs/interfaces/bugtracker.py
index 3f383e3..a44c99b 100644
--- a/lib/lp/bugs/interfaces/bugtracker.py
+++ b/lib/lp/bugs/interfaces/bugtracker.py
@@ -219,6 +219,15 @@ class BugTrackerType(DBEnumeratedType):
         """,
     )
 
+    JIRA = DBItem(
+        14,
+        """
+        JIRA Issues
+
+        The issue tracker for JIRA-based projects.
+        """,
+    )
+
 
 # A list of the BugTrackerTypes that don't need a remote product to be
 # able to return a bug filing URL. We use a whitelist rather than a
diff --git a/lib/lp/bugs/model/bugwatch.py b/lib/lp/bugs/model/bugwatch.py
index 2bbef55..83fe3cd 100644
--- a/lib/lp/bugs/model/bugwatch.py
+++ b/lib/lp/bugs/model/bugwatch.py
@@ -70,6 +70,7 @@ BUG_TRACKER_URL_FORMATS = {
     BugTrackerType.TRAC: "ticket/%s",
     BugTrackerType.SAVANE: "bugs/?%s",
     BugTrackerType.PHPPROJECT: "bug.php?id=%s",
+    BugTrackerType.JIRA: "%s",
 }
 
 
@@ -418,6 +419,7 @@ class BugWatchSet:
             BugTrackerType.SAVANE: self.parseSavaneURL,
             BugTrackerType.SOURCEFORGE: self.parseSourceForgeLikeURL,
             BugTrackerType.TRAC: self.parseTracURL,
+            BugTrackerType.JIRA: self.parseJiraURL,
         }
 
     def get(self, watch_id):
@@ -745,6 +747,15 @@ class BugWatchSet:
         base_url = urlunsplit((scheme, host, base_path, "", ""))
         return base_url, remote_bug
 
+    def parseJiraURL(self, scheme, host, path, query):
+        """Extract a JIRA issue base URL and bug ID."""
+        match = re.match(r"^/browse/([A-Z]{1,10}-\d+)$", path)
+        if not match:
+            return None
+        remote_bug = match.group(1)
+        base_url = urlunsplit((scheme, host, "/", "", ""))
+        return base_url, remote_bug
+
     def extractBugTrackerAndBug(self, url):
         """See `IBugWatchSet`."""
         for trackertype, parse_func in self.bugtracker_parse_functions.items():
diff --git a/lib/lp/bugs/tests/test_bugwatch.py b/lib/lp/bugs/tests/test_bugwatch.py
index a71a7d0..275761b 100644
--- a/lib/lp/bugs/tests/test_bugwatch.py
+++ b/lib/lp/bugs/tests/test_bugwatch.py
@@ -224,6 +224,15 @@ class ExtractBugTrackerAndBugTest(WithScenarios, TestCase):
                 "bug_id": "12345",
             },
         ),
+        (
+            "JIRA",
+            {
+                "bugtracker_type": BugTrackerType.JIRA,
+                "bug_url": "https://warthogs.atlassian.net/browse/LP-984";,
+                "base_url": "https://warthogs.atlassian.net/";,
+                "bug_id": "LP-984",
+            },
+        ),
     ]
 
     layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 640865d..da9477e 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -237,7 +237,8 @@ api.github.com.token: none
 gitlab.com.token: none
 gitlab.gnome.org.token: none
 salsa.debian.org.token: none
-
+warthogs.atlassian.net.username: none
+warthogs.atlassian.net.password: none
 
 [cibuild.soss]
 # value is a JSON Object