launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #23334
[Merge] lp:~cjwatson/launchpad/gitlab-link into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/gitlab-link into lp:launchpad.
Commit message:
Add basic GitLab bug linking.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1603679 in Launchpad itself: "Support GitLab bug watches"
https://bugs.launchpad.net/launchpad/+bug/1603679
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/gitlab-link/+merge/363576
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/gitlab-link into lp:launchpad.
=== modified file 'lib/lp/bugs/externalbugtracker/__init__.py'
--- lib/lp/bugs/externalbugtracker/__init__.py 2018-06-23 09:46:28 +0000
+++ lib/lp/bugs/externalbugtracker/__init__.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""__init__ module for the externalbugtracker package."""
@@ -15,6 +15,7 @@
'DebBugsDatabaseNotFound',
'ExternalBugTracker',
'GitHub',
+ 'GitLab',
'InvalidBugId',
'LookupTree',
'Mantis',
@@ -53,6 +54,7 @@
DebBugsDatabaseNotFound,
)
from lp.bugs.externalbugtracker.github import GitHub
+from lp.bugs.externalbugtracker.gitlab import GitLab
from lp.bugs.externalbugtracker.mantis import Mantis
from lp.bugs.externalbugtracker.roundup import Roundup
from lp.bugs.externalbugtracker.rt import RequestTracker
@@ -65,6 +67,7 @@
BugTrackerType.BUGZILLA: Bugzilla,
BugTrackerType.DEBBUGS: DebBugs,
BugTrackerType.GITHUB: GitHub,
+ BugTrackerType.GITLAB: GitLab,
BugTrackerType.MANTIS: Mantis,
BugTrackerType.TRAC: Trac,
BugTrackerType.ROUNDUP: Roundup,
=== added file 'lib/lp/bugs/externalbugtracker/gitlab.py'
--- lib/lp/bugs/externalbugtracker/gitlab.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/externalbugtracker/gitlab.py 2019-02-23 08:19:13 +0000
@@ -0,0 +1,158 @@
+# Copyright 2019 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""GitLab ExternalBugTracker utility."""
+
+__metaclass__ = type
+__all__ = [
+ 'BadGitLabURL',
+ 'GitLab',
+ ]
+
+import httplib
+
+import pytz
+from six.moves.urllib.parse import (
+ quote,
+ quote_plus,
+ urlunsplit,
+ )
+
+from lp.bugs.externalbugtracker import (
+ BugTrackerConnectError,
+ ExternalBugTracker,
+ UnknownRemoteStatusError,
+ UnparsableBugTrackerVersion,
+ )
+from lp.bugs.interfaces.bugtask import (
+ BugTaskImportance,
+ BugTaskStatus,
+ )
+from lp.bugs.interfaces.externalbugtracker import UNKNOWN_REMOTE_IMPORTANCE
+from lp.services.webapp.url import urlsplit
+
+
+class BadGitLabURL(UnparsableBugTrackerVersion):
+ """The GitLab Issues URL is malformed."""
+
+
+class GitLab(ExternalBugTracker):
+ """An `ExternalBugTracker` for dealing with GitLab issues."""
+
+ batch_query_threshold = 0 # Always use the batch method.
+
+ def __init__(self, baseurl):
+ _, host, path, query, fragment = urlsplit(baseurl)
+ path = path.strip("/")
+ if not path.endswith("/issues"):
+ raise BadGitLabURL(baseurl)
+ path = "/api/v4/projects/%s" % quote(path[:-len("/issues")], safe="")
+ baseurl = urlunsplit(("https", host, path, query, fragment))
+ super(GitLab, self).__init__(baseurl)
+ self.cached_bugs = {}
+
+ 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):
+ """See `ExternalBugTracker`."""
+ bug_id = int(bug_id)
+ if bug_id not in self.cached_bugs:
+ self.cached_bugs[bug_id] = (
+ self._getPage("issues/%s" % bug_id).json())
+ return bug_id, self.cached_bugs[bug_id]
+
+ def getRemoteBugBatch(self, bug_ids, last_accessed=None):
+ """See `ExternalBugTracker`."""
+ bug_ids = [int(bug_id) for bug_id in bug_ids]
+ 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
+ params = []
+ if last_accessed is not None:
+ since = last_accessed.astimezone(pytz.UTC).strftime(
+ "%Y-%m-%dT%H:%M:%SZ")
+ params.append(("updated_after", since))
+ params.extend(
+ [("iids[]", str(bug_id))
+ for bug_id in bug_ids if bug_id not in bugs])
+ # Don't use urlencode, since we need to leave the key "iids[]"
+ # unquoted, and we have no other keys that require quoting.
+ qs = []
+ for k, v in params:
+ qs.append(k + "=" + quote_plus(v))
+ page = "issues?%s" % "&".join(qs)
+ for remote_bug in self._getCollection(page):
+ # We're only interested in the bug if it's one of the ones in
+ # bug_ids.
+ if remote_bug["iid"] not in bug_ids:
+ continue
+ bugs[remote_bug["iid"]] = remote_bug
+ self.cached_bugs[remote_bug["iid"]] = remote_bug
+ return bugs
+
+ def getRemoteImportance(self, bug_id):
+ """See `ExternalBugTracker`."""
+ return UNKNOWN_REMOTE_IMPORTANCE
+
+ def getRemoteStatus(self, bug_id):
+ """See `ExternalBugTracker`."""
+ remote_bug = self.bugs[int(bug_id)]
+ return " ".join([remote_bug["state"]] + remote_bug["labels"])
+
+ def convertRemoteImportance(self, remote_importance):
+ """See `IExternalBugTracker`."""
+ return BugTaskImportance.UNKNOWN
+
+ def convertRemoteStatus(self, remote_status):
+ """See `IExternalBugTracker`.
+
+ A GitLab status consists of the state followed by optional labels.
+ """
+ state = remote_status.split(" ", 1)[0]
+ if state == "opened":
+ return BugTaskStatus.NEW
+ elif state == "closed":
+ return BugTaskStatus.FIXRELEASED
+ else:
+ raise UnknownRemoteStatusError(remote_status)
+
+ def makeRequest(self, method, url, headers=None, last_accessed=None,
+ **kwargs):
+ """See `ExternalBugTracker`."""
+ if headers is None:
+ headers = {}
+ if last_accessed is not None:
+ headers["If-Modified-Since"] = (
+ last_accessed.astimezone(pytz.UTC).strftime(
+ "%a, %d %b %Y %H:%M:%S GMT"))
+ return super(GitLab, self).makeRequest(method, url, headers=headers)
+
+ def _getCollection(self, base_page, last_accessed=None):
+ """Yield each item from a batched remote collection.
+
+ If the collection has not been modified since `last_accessed`, yield
+ no items.
+ """
+ page = base_page
+ while page is not None:
+ try:
+ response = self._getPage(page, last_accessed=last_accessed)
+ except BugTrackerConnectError as e:
+ if (e.error.response is not None and
+ e.error.response.status_code == httplib.NOT_MODIFIED):
+ return
+ else:
+ raise
+ for item in response.json():
+ yield item
+ if "next" in response.links:
+ page = response.links["next"]["url"]
+ else:
+ page = None
=== added file 'lib/lp/bugs/externalbugtracker/tests/test_gitlab.py'
--- lib/lp/bugs/externalbugtracker/tests/test_gitlab.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/externalbugtracker/tests/test_gitlab.py 2019-02-23 08:19:13 +0000
@@ -0,0 +1,306 @@
+# Copyright 2019 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the GitLab Issues BugTracker."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import datetime
+import json
+
+import pytz
+import responses
+from six.moves.urllib_parse import (
+ parse_qs,
+ urlsplit,
+ urlunsplit,
+ )
+from testtools.matchers import (
+ MatchesListwise,
+ MatchesStructure,
+ )
+import transaction
+from zope.component import getUtility
+
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.externalbugtracker import get_external_bugtracker
+from lp.bugs.externalbugtracker.gitlab import (
+ BadGitLabURL,
+ GitLab,
+ )
+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 TestGitLab(TestCase):
+
+ layer = ZopelessLayer
+
+ def setUp(self):
+ super(TestGitLab, self).setUp()
+ self.sample_bugs = [
+ {"id": 101, "iid": 1, "state": "opened", "labels": []},
+ {"id": 102, "iid": 2, "state": "opened", "labels": ["feature"]},
+ {"id": 103, "iid": 3, "state": "opened",
+ "labels": ["feature", "ui"]},
+ {"id": 104, "iid": 4, "state": "closed", "labels": []},
+ {"id": 105, "iid": 5, "state": "closed", "labels": ["feature"]},
+ ]
+
+ def test_implements_interface(self):
+ self.assertTrue(verifyObject(
+ IExternalBugTracker,
+ GitLab("https://gitlab.com/user/repository/issues")))
+
+ def test_requires_issues_url(self):
+ self.assertRaises(
+ BadGitLabURL, GitLab, "https://gitlab.com/user/repository")
+
+ @responses.activate
+ def test__getPage_authenticated(self):
+ responses.add(
+ "GET", "https://gitlab.com/api/v4/projects/user%2Frepository/test",
+ json="success")
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ self.assertEqual("success", tracker._getPage("test").json())
+ requests = [call.request for call in responses.calls]
+ self.assertThat(requests, MatchesListwise([
+ MatchesStructure.byEquality(
+ path_url="/api/v4/projects/user%2Frepository/test"),
+ ]))
+
+ @responses.activate
+ def test__getPage_unauthenticated(self):
+ responses.add(
+ "GET", "https://gitlab.com/api/v4/projects/user%2Frepository/test",
+ json="success")
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ self.assertEqual("success", tracker._getPage("test").json())
+ requests = [call.request for call in responses.calls]
+ self.assertThat(requests, MatchesListwise([
+ MatchesStructure.byEquality(
+ path_url="/api/v4/projects/user%2Frepository/test"),
+ ]))
+
+ @responses.activate
+ def test_getRemoteBug(self):
+ responses.add(
+ "GET",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues/1",
+ json=self.sample_bugs[0])
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ self.assertEqual((1, self.sample_bugs[0]), tracker.getRemoteBug("1"))
+ self.assertEqual(
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues/1",
+ responses.calls[-1].request.url)
+
+ def _addIssuesResponse(self):
+ responses.add(
+ "GET",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues",
+ json=self.sample_bugs)
+
+ @responses.activate
+ def test_getRemoteBugBatch(self):
+ self._addIssuesResponse()
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ self.assertEqual(
+ {bug["iid"]: bug for bug in self.sample_bugs[:2]},
+ tracker.getRemoteBugBatch(["1", "2"]))
+ self.assertEqual(
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?"
+ "iids[]=1&iids[]=2",
+ responses.calls[-1].request.url)
+
+ @responses.activate
+ def test_getRemoteBugBatch_last_accessed(self):
+ self._addIssuesResponse()
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ since = datetime(2015, 1, 1, 12, 0, 0, tzinfo=pytz.UTC)
+ self.assertEqual(
+ {bug["iid"]: bug for bug in self.sample_bugs[:2]},
+ tracker.getRemoteBugBatch(["1", "2"], last_accessed=since))
+ self.assertEqual(
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?"
+ "updated_after=2015-01-01T12%3A00%3A00Z&iids[]=1&iids[]=2",
+ responses.calls[-1].request.url)
+
+ @responses.activate
+ def test_getRemoteBugBatch_caching(self):
+ self._addIssuesResponse()
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ tracker.initializeRemoteBugDB(
+ [str(bug["iid"]) for bug in self.sample_bugs])
+ responses.reset()
+ self.assertEqual(
+ {bug["iid"]: bug for bug in self.sample_bugs[:2]},
+ tracker.getRemoteBugBatch(["1", "2"]))
+ self.assertEqual(0, len(responses.calls))
+
+ @responses.activate
+ def test_getRemoteBugBatch_pagination(self):
+ def issues_callback(request):
+ url = urlsplit(request.url)
+ base_url = urlunsplit(list(url[:3]) + ["", ""])
+ page = int(parse_qs(url.query).get("page", ["1"])[0])
+ links = []
+ if page != 3:
+ links.append('<%s?page=%d>; rel="next"' % (base_url, page + 1))
+ links.append('<%s?page=3>; rel="last"' % base_url)
+ if page != 1:
+ links.append('<%s?page=1>; rel="first"' % base_url)
+ links.append('<%s?page=%d>; rel="prev"' % (base_url, page - 1))
+ start = (page - 1) * 2
+ end = page * 2
+ return (
+ 200, {"Link": ", ".join(links)},
+ json.dumps(self.sample_bugs[start:end]))
+
+ responses.add_callback(
+ "GET",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues",
+ callback=issues_callback, content_type="application/json")
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ self.assertEqual(
+ {bug["iid"]: bug for bug in self.sample_bugs},
+ tracker.getRemoteBugBatch(
+ [str(bug["iid"]) for bug in self.sample_bugs]))
+ expected_urls = [
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?" +
+ "&".join("iids[]=%s" % bug["iid"] for bug in self.sample_bugs),
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?"
+ "page=2",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?"
+ "page=3",
+ ]
+ self.assertEqual(
+ expected_urls, [call.request.url for call in responses.calls])
+
+ @responses.activate
+ def test_status_opened(self):
+ self.sample_bugs = [
+ {"id": 101, "iid": 1, "state": "opened", "labels": []},
+ # Labels do not affect status, even if names collide.
+ {"id": 102, "iid": 2, "state": "opened",
+ "labels": ["feature", "closed"]},
+ ]
+ self._addIssuesResponse()
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ tracker.initializeRemoteBugDB(["1", "2"])
+ remote_status = tracker.getRemoteStatus("1")
+ self.assertEqual("opened", remote_status)
+ lp_status = tracker.convertRemoteStatus(remote_status)
+ self.assertEqual(BugTaskStatus.NEW, lp_status)
+ remote_status = tracker.getRemoteStatus("2")
+ self.assertEqual("opened feature closed", remote_status)
+ lp_status = tracker.convertRemoteStatus(remote_status)
+ self.assertEqual(BugTaskStatus.NEW, lp_status)
+
+ @responses.activate
+ def test_status_closed(self):
+ self.sample_bugs = [
+ {"id": 101, "iid": 1, "state": "closed", "labels": []},
+ # Labels do not affect status, even if names collide.
+ {"id": 102, "iid": 2, "state": "closed",
+ "labels": ["feature", "opened"]},
+ ]
+ self._addIssuesResponse()
+ tracker = GitLab("https://gitlab.com/user/repository/issues")
+ tracker.initializeRemoteBugDB(["1", "2"])
+ remote_status = tracker.getRemoteStatus("1")
+ self.assertEqual("closed", remote_status)
+ lp_status = tracker.convertRemoteStatus(remote_status)
+ self.assertEqual(BugTaskStatus.FIXRELEASED, lp_status)
+ remote_status = tracker.getRemoteStatus("2")
+ self.assertEqual("closed feature opened", remote_status)
+ lp_status = tracker.convertRemoteStatus(remote_status)
+ self.assertEqual(BugTaskStatus.FIXRELEASED, lp_status)
+
+
+class TestGitLabUpdateBugWatches(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ @responses.activate
+ def test_process_one(self):
+ remote_bug = [
+ {"id": "12345", "iid": 1234, "state": "opened", "labels": []},
+ ]
+ responses.add(
+ "GET",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues?"
+ "iids[]=1234",
+ json=remote_bug, match_querystring=True)
+ bug = self.factory.makeBug()
+ bug_tracker = self.factory.makeBugTracker(
+ base_url="https://gitlab.com/user/repository/issues",
+ bugtrackertype=BugTrackerType.GITLAB)
+ bug.addWatch(
+ bug_tracker, "1234", getUtility(ILaunchpadCelebrities).janitor)
+ self.assertEqual(
+ [("1234", None)],
+ [(watch.remotebug, watch.remotestatus)
+ for watch in bug_tracker.watches])
+ transaction.commit()
+ logger = BufferLogger()
+ bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
+ gitlab = get_external_bugtracker(bug_tracker)
+ bug_watch_updater.updateBugWatches(gitlab, bug_tracker.watches)
+ self.assertEqual(
+ "INFO Updating 1 watches for 1 bugs on "
+ "https://gitlab.com/api/v4/projects/user%2Frepository\n",
+ logger.getLogBuffer())
+ self.assertEqual(
+ [("1234", BugTaskStatus.NEW)],
+ [(watch.remotebug, gitlab.convertRemoteStatus(watch.remotestatus))
+ for watch in bug_tracker.watches])
+
+ @responses.activate
+ def test_process_many(self):
+ remote_bugs = [
+ {"id": bug_id + 1, "iid": bug_id,
+ "state": "opened" if (bug_id % 2) == 0 else "closed",
+ "labels": []}
+ for bug_id in range(1000, 1010)]
+ responses.add(
+ "GET",
+ "https://gitlab.com/api/v4/projects/user%2Frepository/issues",
+ json=remote_bugs)
+ bug = self.factory.makeBug()
+ bug_tracker = self.factory.makeBugTracker(
+ base_url="https://gitlab.com/user/repository/issues",
+ bugtrackertype=BugTrackerType.GITLAB)
+ for remote_bug in remote_bugs:
+ bug.addWatch(
+ bug_tracker, str(remote_bug["iid"]),
+ getUtility(ILaunchpadCelebrities).janitor)
+ transaction.commit()
+ logger = BufferLogger()
+ bug_watch_updater = CheckwatchesMaster(transaction, logger=logger)
+ gitlab = get_external_bugtracker(bug_tracker)
+ bug_watch_updater.updateBugWatches(gitlab, bug_tracker.watches)
+ self.assertEqual(
+ "INFO Updating 10 watches for 10 bugs on "
+ "https://gitlab.com/api/v4/projects/user%2Frepository\n",
+ logger.getLogBuffer())
+ self.assertContentEqual(
+ [(str(bug_id), BugTaskStatus.NEW)
+ 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, gitlab.convertRemoteStatus(watch.remotestatus))
+ for watch in bug_tracker.watches])
=== modified file 'lib/lp/bugs/interfaces/bugtracker.py'
--- lib/lp/bugs/interfaces/bugtracker.py 2016-07-04 17:11:29 +0000
+++ lib/lp/bugs/interfaces/bugtracker.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Bug tracker interfaces."""
@@ -192,6 +192,13 @@
The issue tracker for projects hosted on GitHub.
""")
+ GITLAB = DBItem(13, """
+ GitLab Issues
+
+ GitLab is a single application for the entire software development
+ lifecycle.
+ """)
+
# 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
@@ -200,6 +207,7 @@
# embarrassingly useless URLs to users.
SINGLE_PRODUCT_BUGTRACKERTYPES = [
BugTrackerType.GITHUB,
+ BugTrackerType.GITLAB,
BugTrackerType.GOOGLE_CODE,
BugTrackerType.MANTIS,
BugTrackerType.PHPPROJECT,
=== modified file 'lib/lp/bugs/model/bugtracker.py'
--- lib/lp/bugs/model/bugtracker.py 2017-10-24 13:08:32 +0000
+++ lib/lp/bugs/model/bugtracker.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
@@ -151,6 +151,12 @@
elif base_uri.host == 'github.com' and base_uri.path.endswith('/issues'):
repository_id = base_uri.path[:-len('/issues')].lstrip('/')
base_name = 'github-' + repository_id.replace('/', '-').lower()
+ elif (('gitlab' in base_uri.host or
+ base_uri.host == 'salsa.debian.org') and
+ base_uri.path.endswith('/issues')):
+ repository_id = base_uri.path[:-len('/issues')].lstrip('/')
+ base_name = '%s-%s' % (
+ base_uri.host, repository_id.replace('/', '-').lower())
else:
base_name = base_uri.host
@@ -336,6 +342,9 @@
"&short_desc=%(summary)s&long_desc=%(description)s"),
BugTrackerType.GITHUB: (
"%(base_url)s/new?title=%(summary)s&body=%(description)s"),
+ BugTrackerType.GITLAB: (
+ "%(base_url)s/new"
+ "?issue[title]=%(summary)s&issue[description]=%(description)s"),
BugTrackerType.GOOGLE_CODE: (
"%(base_url)s/entry?summary=%(summary)s&"
"comment=%(description)s"),
@@ -368,6 +377,9 @@
BugTrackerType.GITHUB: (
"%(base_url)s?utf8=%%E2%%9C%%93"
"&q=is%%3Aissue%%20is%%3Aopen%%20%(summary)s"),
+ BugTrackerType.GITLAB: (
+ "%(base_url)s?scope=all&utf8=%%E2%%9C%%93&state=opened"
+ "&search=%(summary)s"),
BugTrackerType.GOOGLE_CODE: "%(base_url)s/list?q=%(summary)s",
BugTrackerType.DEBBUGS: (
"%(base_url)s/cgi-bin/search.cgi?phrase=%(summary)s"
=== modified file 'lib/lp/bugs/model/bugwatch.py'
--- lib/lp/bugs/model/bugwatch.py 2018-11-11 21:41:13 +0000
+++ lib/lp/bugs/model/bugwatch.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
@@ -82,6 +82,7 @@
BugTrackerType.BUGZILLA: 'show_bug.cgi?id=%s',
BugTrackerType.DEBBUGS: 'cgi-bin/bugreport.cgi?bug=%s',
BugTrackerType.GITHUB: '%s',
+ BugTrackerType.GITLAB: '%s',
BugTrackerType.GOOGLE_CODE: 'detail?id=%s',
BugTrackerType.MANTIS: 'view.php?id=%s',
BugTrackerType.ROUNDUP: 'issue%s',
@@ -390,6 +391,7 @@
BugTrackerType.DEBBUGS: self.parseDebbugsURL,
BugTrackerType.EMAILADDRESS: self.parseEmailAddressURL,
BugTrackerType.GITHUB: self.parseGitHubURL,
+ BugTrackerType.GITLAB: self.parseGitLabURL,
BugTrackerType.GOOGLE_CODE: self.parseGoogleCodeURL,
BugTrackerType.MANTIS: self.parseMantisURL,
BugTrackerType.PHPPROJECT: self.parsePHPProjectURL,
@@ -703,6 +705,16 @@
base_url = urlunsplit((scheme, host, base_path, '', ''))
return base_url, remote_bug
+ def parseGitLabURL(self, scheme, host, path, query):
+ """Extract a GitLab Issues base URL and bug ID."""
+ match = re.match(r'(.*/issues)/(\d+)$', path)
+ if not match:
+ return None
+ base_path = match.group(1)
+ remote_bug = match.group(2)
+ base_url = urlunsplit((scheme, host, base_path, '', ''))
+ return base_url, remote_bug
+
def extractBugTrackerAndBug(self, url):
"""See `IBugWatchSet`."""
for trackertype, parse_func in (
=== modified file 'lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt'
--- lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt 2018-06-30 16:09:44 +0000
+++ lib/lp/bugs/stories/bugtracker/xx-bugtracker.txt 2019-02-23 08:19:13 +0000
@@ -41,6 +41,7 @@
PHP Project Bugtracker
Google Code
GitHub Issues
+ GitLab Issues
The bug tracker name is used in URLs and certain characters (like '!')
aren't allowed.
=== modified file 'lib/lp/bugs/tests/test_bugtracker.py'
--- lib/lp/bugs/tests/test_bugtracker.py 2018-06-23 00:23:41 +0000
+++ lib/lp/bugs/tests/test_bugtracker.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
@@ -413,6 +413,29 @@
'auto-github-user-foo-bar',
make_bugtracker_name('https://github.com/user/foo/bar/issues'))
+ def test_gitlab(self):
+ self.assertEqual(
+ 'auto-gitlab.com-user-repository',
+ make_bugtracker_name('https://gitlab.com/user/repository/issues'))
+ self.assertEqual(
+ 'auto-gitlab.com-user-repository',
+ make_bugtracker_name('https://gitlab.com/user/Repository/issues'))
+ self.assertEqual(
+ 'auto-salsa.debian.org-user-repository',
+ make_bugtracker_name(
+ 'https://salsa.debian.org/user/repository/issues'))
+ self.assertEqual(
+ 'auto-salsa.debian.org-user-repository',
+ make_bugtracker_name(
+ 'https://salsa.debian.org/user/Repository/issues'))
+ # Invalid on the GitLab side, but let's make sure these don't blow up.
+ self.assertEqual(
+ 'auto-gitlab.com-user',
+ make_bugtracker_name('https://gitlab.com/user/issues'))
+ self.assertEqual(
+ 'auto-gitlab.com-user-foo-bar',
+ make_bugtracker_name('https://gitlab.com/user/foo/bar/issues'))
+
class TestMakeBugtrackerTitle(TestCase):
"""Tests for make_bugtracker_title."""
=== modified file 'lib/lp/bugs/tests/test_bugwatch.py'
--- lib/lp/bugs/tests/test_bugwatch.py 2018-11-11 21:41:13 +0000
+++ lib/lp/bugs/tests/test_bugwatch.py 2019-02-23 08:19:13 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Tests for BugWatchSet."""
@@ -177,6 +177,12 @@
'base_url': 'https://github.com/user/repository/issues',
'bug_id': '12345',
}),
+ ('GitLab', {
+ 'bugtracker_type': BugTrackerType.GITLAB,
+ 'bug_url': 'https://gitlab.com/user/repository/issues/12345',
+ 'base_url': 'https://gitlab.com/user/repository/issues',
+ 'bug_id': '12345',
+ }),
]
layer = LaunchpadFunctionalLayer
Follow ups