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
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp