launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #29379
[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