1
# Copyright (C) 2018 Breezy Developers
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
"""Support for GitLab."""
19
from __future__ import absolute_import
22
branch as _mod_branch,
27
from ...git.urls import git_url_to_bzr_url
28
from ...sixish import PY3
30
from .propose import (
36
PrerequisiteBranchUnsupported,
40
def mp_status_to_status(status):
45
'closed': 'closed'}[status]
48
class NotGitLabUrl(errors.BzrError):
50
_fmt = "Not a GitLab URL: %(url)s"
52
def __init__(self, url):
53
errors.BzrError.__init__(self)
57
class DifferentGitLabInstances(errors.BzrError):
59
_fmt = ("Can't create merge proposals across GitLab instances: "
60
"%(source_host)s and %(target_host)s")
62
def __init__(self, source_host, target_host):
63
self.source_host = source_host
64
self.target_host = target_host
67
class GitLabLoginMissing(errors.BzrError):
69
_fmt = ("Please log into GitLab")
72
def default_config_path():
73
from breezy.config import config_dir
75
return os.path.join(config_dir(), 'gitlab.conf')
78
def store_gitlab_token(name, url, private_token):
79
"""Store a GitLab token in a configuration file."""
81
config = configparser.ConfigParser()
82
path = default_config_path()
84
config.add_section(name)
85
config[name]['url'] = url
86
config[name]['private_token'] = private_token
87
with open(path, 'w') as f:
93
from gitlab.config import _DEFAULT_FILES
94
config = configparser.ConfigParser()
95
config.read(_DEFAULT_FILES + [default_config_path()])
96
for name, section in config.items():
100
def connect_gitlab(host):
101
from gitlab import Gitlab, GitlabGetError
102
url = 'https://%s' % host
103
for name, section in iter_tokens():
104
if section.get('url') == url:
105
return Gitlab(**section)
109
except GitlabGetError:
110
raise GitLabLoginMissing()
113
def parse_gitlab_url(url):
114
(scheme, user, password, host, port, path) = urlutils.parse_url(
116
if scheme not in ('git+ssh', 'https', 'http'):
117
raise NotGitLabUrl(url)
119
raise NotGitLabUrl(url)
120
path = path.strip('/')
121
if path.endswith('.git'):
126
def parse_gitlab_branch_url(branch):
127
url = urlutils.split_segment_parameters(branch.user_url)[0]
128
host, path = parse_gitlab_url(url)
129
return host, path, branch.name
132
class GitLabMergeProposal(MergeProposal):
134
def __init__(self, mr):
139
return self._mr.web_url
141
def get_description(self):
142
return self._mr.description
144
def set_description(self, description):
145
self._mr.description = description
148
def _branch_url_from_project(self, project_id, branch_name):
149
project = self._mr.manager.gitlab.projects.get(project_id)
150
return gitlab_url_to_bzr_url(project.http_url_to_repo, branch_name)
152
def get_source_branch_url(self):
153
return self._branch_url_from_project(
154
self._mr.source_project_id, self._mr.source_branch)
156
def get_target_branch_url(self):
157
return self._branch_url_from_project(
158
self._mr.target_project_id, self._mr.target_branch)
161
return (self._mr.state == 'merged')
164
self._mr.state_event = 'close'
168
def gitlab_url_to_bzr_url(url, name):
170
name = name.encode('utf-8')
171
return urlutils.join_segment_parameters(
172
git_url_to_bzr_url(url), {"branch": name})
175
class GitLab(Hoster):
176
"""GitLab hoster implementation."""
178
supports_merge_proposal_labels = True
181
return "<GitLab(%r)>" % self.gl.url
187
def __init__(self, gl):
190
def get_push_url(self, branch):
191
(host, project_name, branch_name) = parse_gitlab_branch_url(branch)
192
project = self.gl.projects.get(project_name)
193
return gitlab_url_to_bzr_url(
194
project.ssh_url_to_repo, branch_name)
196
def publish_derived(self, local_branch, base_branch, name, project=None,
197
owner=None, revision_id=None, overwrite=False,
200
(host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
203
base_project = self.gl.projects.get(base_project)
204
except gitlab.GitlabGetError as e:
205
if e.response_code == 404:
206
raise NoSuchProject(base_project)
210
owner = self.gl.user.username
212
project = base_project.path
214
target_project = self.gl.projects.get('%s/%s' % (owner, project))
215
except gitlab.GitlabGetError as e:
216
if e.response_code == 404:
217
target_project = base_project.forks.create({})
220
remote_repo_url = git_url_to_bzr_url(target_project.ssh_url_to_repo)
221
remote_dir = controldir.ControlDir.open(remote_repo_url)
223
push_result = remote_dir.push_branch(
224
local_branch, revision_id=revision_id, overwrite=overwrite,
226
except errors.NoRoundtrippingSupport:
229
push_result = remote_dir.push_branch(
230
local_branch, revision_id=revision_id, overwrite=overwrite,
231
name=name, lossy=True)
232
public_url = gitlab_url_to_bzr_url(
233
target_project.http_url_to_repo, name)
234
return push_result.target_branch, public_url
236
def get_derived_branch(self, base_branch, name, project=None, owner=None):
238
(host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
241
base_project = self.gl.projects.get(base_project)
242
except gitlab.GitlabGetError as e:
243
if e.response_code == 404:
244
raise NoSuchProject(base_project)
248
owner = self.gl.user.username
250
project = base_project.path
252
target_project = self.gl.projects.get('%s/%s' % (owner, project))
253
except gitlab.GitlabGetError as e:
254
if e.response_code == 404:
255
raise errors.NotBranchError('%s/%s/%s' % (self.gl.url, owner, project))
257
return _mod_branch.Branch.open(gitlab_url_to_bzr_url(
258
target_project.ssh_url_to_repo, name))
260
def get_proposer(self, source_branch, target_branch):
261
return GitlabMergeProposalBuilder(self.gl, source_branch, target_branch)
263
def iter_proposals(self, source_branch, target_branch, status):
265
(source_host, source_project_name, source_branch_name) = (
266
parse_gitlab_branch_url(source_branch))
267
(target_host, target_project_name, target_branch_name) = (
268
parse_gitlab_branch_url(target_branch))
269
if source_host != target_host:
270
raise DifferentGitLabInstances(source_host, target_host)
272
source_project = self.gl.projects.get(source_project_name)
273
target_project = self.gl.projects.get(target_project_name)
274
state = mp_status_to_status(status)
276
for mr in target_project.mergerequests.list(state=state):
277
if (mr.source_project_id != source_project.id or
278
mr.source_branch != source_branch_name or
279
mr.target_project_id != target_project.id or
280
mr.target_branch != target_branch_name):
282
yield GitLabMergeProposal(mr)
283
except gitlab.GitlabListError as e:
284
if e.response_code == 403:
285
raise errors.PermissionDenied(e.error_message)
287
def hosts(self, branch):
289
(host, project, branch_name) = parse_gitlab_branch_url(branch)
292
return (self.gl.url == ('https://%s' % host))
295
def probe_from_url(cls, url):
297
(host, project) = parse_gitlab_url(url)
299
raise UnsupportedHoster(url)
301
import requests.exceptions
303
gl = connect_gitlab(host)
305
except requests.exceptions.SSLError:
306
# Well, I guess it could be..
307
raise UnsupportedHoster(url)
308
except gitlab.GitlabGetError:
309
raise UnsupportedHoster(url)
310
except gitlab.GitlabHttpError as e:
311
if e.response_code in (404, 405, 503):
312
raise UnsupportedHoster(url)
318
def iter_instances(cls):
319
from gitlab import Gitlab
320
for name, credentials in iter_tokens():
321
if 'url' not in credentials:
323
gl = Gitlab(**credentials)
326
def iter_my_proposals(self, status='open'):
327
state = mp_status_to_status(status)
329
for mp in self.gl.mergerequests.list(
330
owner=self.gl.user.username, state=state):
331
yield GitLabMergeProposal(mp)
334
class GitlabMergeProposalBuilder(MergeProposalBuilder):
336
def __init__(self, gl, source_branch, target_branch):
338
self.source_branch = source_branch
339
(self.source_host, self.source_project_name, self.source_branch_name) = (
340
parse_gitlab_branch_url(source_branch))
341
self.target_branch = target_branch
342
(self.target_host, self.target_project_name, self.target_branch_name) = (
343
parse_gitlab_branch_url(target_branch))
344
if self.source_host != self.target_host:
345
raise DifferentGitLabInstances(self.source_host, self.target_host)
347
def get_infotext(self):
348
"""Determine the initial comment for the merge proposal."""
350
info.append("Gitlab instance: %s\n" % self.target_host)
351
info.append("Source: %s\n" % self.source_branch.user_url)
352
info.append("Target: %s\n" % self.target_branch.user_url)
355
def get_initial_body(self):
356
"""Get a body for the proposal for the user to modify.
358
:return: a str or None.
362
def create_proposal(self, description, reviewers=None, labels=None,
363
prerequisite_branch=None):
364
"""Perform the submission."""
365
if prerequisite_branch is not None:
366
raise PrerequisiteBranchUnsupported(self)
368
# TODO(jelmer): Support reviewers
370
source_project = self.gl.projects.get(self.source_project_name)
371
target_project = self.gl.projects.get(self.target_project_name)
372
# TODO(jelmer): Allow setting title explicitly
373
title = description.splitlines()[0]
374
# TODO(jelmer): Allow setting allow_collaboration field
375
# TODO(jelmer): Allow setting milestone field
376
# TODO(jelmer): Allow setting squash field
379
'target_project_id': target_project.id,
380
'source_branch': self.source_branch_name,
381
'target_branch': self.target_branch_name,
382
'description': description}
384
kwargs['labels'] = ','.join(labels)
386
merge_request = source_project.mergerequests.create(kwargs)
387
except gitlab.GitlabCreateError as e:
388
if e.response_code == 403:
389
raise errors.PermissionDenied(e.error_message)
390
if e.response_code == 409:
391
raise MergeProposalExists(self.source_branch.user_url)
393
return GitLabMergeProposal(merge_request)
396
def register_gitlab_instance(shortname, url):
397
"""Register a gitlab instance.
399
:param shortname: Short name (e.g. "gitlab")
400
:param url: URL to the gitlab instance
402
from breezy.bugtracker import (
404
ProjectIntegerBugTracker,
406
tracker_registry.register(
407
shortname, ProjectIntegerBugTracker(
408
shortname, url + '/{project}/issues/{id}'))