← Back to team overview

launchpad-reviewers team mailing list archive

[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