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 NotMergeRequestUrl(errors.BzrError):
59
_fmt = "Not a merge proposal URL: %(url)s"
61
def __init__(self, host, url):
62
errors.BzrError.__init__(self)
67
class DifferentGitLabInstances(errors.BzrError):
69
_fmt = ("Can't create merge proposals across GitLab instances: "
70
"%(source_host)s and %(target_host)s")
72
def __init__(self, source_host, target_host):
73
self.source_host = source_host
74
self.target_host = target_host
77
class GitLabLoginMissing(errors.BzrError):
79
_fmt = ("Please log into GitLab")
82
def default_config_path():
83
from breezy.config import config_dir
85
return os.path.join(config_dir(), 'gitlab.conf')
88
def store_gitlab_token(name, url, private_token):
89
"""Store a GitLab token in a configuration file."""
91
config = configparser.ConfigParser()
92
path = default_config_path()
94
config.add_section(name)
95
config[name]['url'] = url
96
config[name]['private_token'] = private_token
97
with open(path, 'w') as f:
103
from gitlab.config import _DEFAULT_FILES
104
config = configparser.ConfigParser()
105
config.read(_DEFAULT_FILES + [default_config_path()])
106
for name, section in config.items():
110
def connect_gitlab(host):
111
from gitlab import Gitlab, GitlabGetError
112
url = 'https://%s' % host
113
for name, section in iter_tokens():
114
if section.get('url') == url:
115
return Gitlab(**section)
119
except GitlabGetError:
120
raise GitLabLoginMissing()
123
def parse_gitlab_url(url):
124
(scheme, user, password, host, port, path) = urlutils.parse_url(
126
if scheme not in ('git+ssh', 'https', 'http'):
127
raise NotGitLabUrl(url)
129
raise NotGitLabUrl(url)
130
path = path.strip('/')
131
if path.endswith('.git'):
136
def parse_gitlab_branch_url(branch):
137
url = urlutils.split_segment_parameters(branch.user_url)[0]
138
host, path = parse_gitlab_url(url)
139
return host, path, branch.name
142
def parse_gitlab_merge_request_url(url):
143
(scheme, user, password, host, port, path) = urlutils.parse_url(
145
if scheme not in ('git+ssh', 'https', 'http'):
146
raise NotGitLabUrl(url)
148
raise NotGitLabUrl(url)
149
path = path.strip('/')
150
parts = path.split('/')
151
if parts[-2] != 'merge_requests':
152
raise NotMergeRequestUrl(host, url)
153
return host, '/'.join(parts[:-2]), int(parts[-1])
156
class GitLabMergeProposal(MergeProposal):
158
def __init__(self, mr):
163
return self._mr.web_url
165
def get_description(self):
166
return self._mr.description
168
def set_description(self, description):
169
self._mr.description = description
172
def get_commit_message(self):
175
def _branch_url_from_project(self, project_id, branch_name):
176
project = self._mr.manager.gitlab.projects.get(project_id)
177
return gitlab_url_to_bzr_url(project.http_url_to_repo, branch_name)
179
def get_source_branch_url(self):
180
return self._branch_url_from_project(
181
self._mr.source_project_id, self._mr.source_branch)
183
def get_target_branch_url(self):
184
return self._branch_url_from_project(
185
self._mr.target_project_id, self._mr.target_branch)
188
return (self._mr.state == 'merged')
191
self._mr.state_event = 'close'
194
def merge(self, commit_message=None):
195
# https://docs.gitlab.com/ee/api/merge_requests.html#accept-mr
196
self._mr.merge(merge_commit_message=commit_message)
199
def gitlab_url_to_bzr_url(url, name):
201
name = name.encode('utf-8')
202
return urlutils.join_segment_parameters(
203
git_url_to_bzr_url(url), {"branch": name})
206
class GitLab(Hoster):
207
"""GitLab hoster implementation."""
209
supports_merge_proposal_labels = True
210
supports_merge_proposal_commit_message = False
213
return "<GitLab(%r)>" % self.gl.url
219
def __init__(self, gl):
222
def get_push_url(self, branch):
223
(host, project_name, branch_name) = parse_gitlab_branch_url(branch)
224
project = self.gl.projects.get(project_name)
225
return gitlab_url_to_bzr_url(
226
project.ssh_url_to_repo, branch_name)
228
def publish_derived(self, local_branch, base_branch, name, project=None,
229
owner=None, revision_id=None, overwrite=False,
232
(host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
235
base_project = self.gl.projects.get(base_project)
236
except gitlab.GitlabGetError as e:
237
if e.response_code == 404:
238
raise NoSuchProject(base_project)
242
owner = self.gl.user.username
244
project = base_project.path
246
target_project = self.gl.projects.get('%s/%s' % (owner, project))
247
except gitlab.GitlabGetError as e:
248
if e.response_code == 404:
249
target_project = base_project.forks.create({})
252
remote_repo_url = git_url_to_bzr_url(target_project.ssh_url_to_repo)
253
remote_dir = controldir.ControlDir.open(remote_repo_url)
255
push_result = remote_dir.push_branch(
256
local_branch, revision_id=revision_id, overwrite=overwrite,
258
except errors.NoRoundtrippingSupport:
261
push_result = remote_dir.push_branch(
262
local_branch, revision_id=revision_id, overwrite=overwrite,
263
name=name, lossy=True)
264
public_url = gitlab_url_to_bzr_url(
265
target_project.http_url_to_repo, name)
266
return push_result.target_branch, public_url
268
def get_derived_branch(self, base_branch, name, project=None, owner=None):
270
(host, base_project, base_branch_name) = parse_gitlab_branch_url(base_branch)
273
base_project = self.gl.projects.get(base_project)
274
except gitlab.GitlabGetError as e:
275
if e.response_code == 404:
276
raise NoSuchProject(base_project)
280
owner = self.gl.user.username
282
project = base_project.path
284
target_project = self.gl.projects.get('%s/%s' % (owner, project))
285
except gitlab.GitlabGetError as e:
286
if e.response_code == 404:
287
raise errors.NotBranchError('%s/%s/%s' % (self.gl.url, owner, project))
289
return _mod_branch.Branch.open(gitlab_url_to_bzr_url(
290
target_project.ssh_url_to_repo, name))
292
def get_proposer(self, source_branch, target_branch):
293
return GitlabMergeProposalBuilder(self.gl, source_branch, target_branch)
295
def iter_proposals(self, source_branch, target_branch, status):
297
(source_host, source_project_name, source_branch_name) = (
298
parse_gitlab_branch_url(source_branch))
299
(target_host, target_project_name, target_branch_name) = (
300
parse_gitlab_branch_url(target_branch))
301
if source_host != target_host:
302
raise DifferentGitLabInstances(source_host, target_host)
304
source_project = self.gl.projects.get(source_project_name)
305
target_project = self.gl.projects.get(target_project_name)
306
state = mp_status_to_status(status)
308
for mr in target_project.mergerequests.list(state=state):
309
if (mr.source_project_id != source_project.id or
310
mr.source_branch != source_branch_name or
311
mr.target_project_id != target_project.id or
312
mr.target_branch != target_branch_name):
314
yield GitLabMergeProposal(mr)
315
except gitlab.GitlabListError as e:
316
if e.response_code == 403:
317
raise errors.PermissionDenied(e.error_message)
319
def hosts(self, branch):
321
(host, project, branch_name) = parse_gitlab_branch_url(branch)
324
return (self.gl.url == ('https://%s' % host))
327
def probe_from_url(cls, url):
329
(host, project) = parse_gitlab_url(url)
331
raise UnsupportedHoster(url)
333
import requests.exceptions
335
gl = connect_gitlab(host)
337
except requests.exceptions.SSLError:
338
# Well, I guess it could be..
339
raise UnsupportedHoster(url)
340
except gitlab.GitlabGetError:
341
raise UnsupportedHoster(url)
342
except gitlab.GitlabHttpError as e:
343
if e.response_code in (404, 405, 503):
344
raise UnsupportedHoster(url)
350
def iter_instances(cls):
351
from gitlab import Gitlab
352
for name, credentials in iter_tokens():
353
if 'url' not in credentials:
355
gl = Gitlab(**credentials)
358
def iter_my_proposals(self, status='open'):
359
state = mp_status_to_status(status)
361
for mp in self.gl.mergerequests.list(
362
owner=self.gl.user.username, state=state):
363
yield GitLabMergeProposal(mp)
365
def get_proposal_by_url(self, url):
367
(host, project, merge_id) = parse_gitlab_merge_request_url(url)
369
raise UnsupportedHoster(url)
370
except NotMergeRequestUrl as e:
371
if self.gl.url == ('https://%s' % e.host):
374
raise UnsupportedHoster(url)
375
if self.gl.url != ('https://%s' % host):
376
raise UnsupportedHoster(url)
377
project = self.gl.projects.get(project)
378
mr = project.mergerequests.get(merge_id)
379
return GitLabMergeProposal(mr)
382
class GitlabMergeProposalBuilder(MergeProposalBuilder):
384
def __init__(self, gl, source_branch, target_branch):
386
self.source_branch = source_branch
387
(self.source_host, self.source_project_name, self.source_branch_name) = (
388
parse_gitlab_branch_url(source_branch))
389
self.target_branch = target_branch
390
(self.target_host, self.target_project_name, self.target_branch_name) = (
391
parse_gitlab_branch_url(target_branch))
392
if self.source_host != self.target_host:
393
raise DifferentGitLabInstances(self.source_host, self.target_host)
395
def get_infotext(self):
396
"""Determine the initial comment for the merge proposal."""
398
info.append("Gitlab instance: %s\n" % self.target_host)
399
info.append("Source: %s\n" % self.source_branch.user_url)
400
info.append("Target: %s\n" % self.target_branch.user_url)
403
def get_initial_body(self):
404
"""Get a body for the proposal for the user to modify.
406
:return: a str or None.
410
def create_proposal(self, description, reviewers=None, labels=None,
411
prerequisite_branch=None, commit_message=None):
412
"""Perform the submission."""
413
# https://docs.gitlab.com/ee/api/merge_requests.html#create-mr
414
if prerequisite_branch is not None:
415
raise PrerequisiteBranchUnsupported(self)
417
# Note that commit_message is ignored, since Gitlab doesn't support it.
418
# TODO(jelmer): Support reviewers
420
source_project = self.gl.projects.get(self.source_project_name)
421
target_project = self.gl.projects.get(self.target_project_name)
422
# TODO(jelmer): Allow setting title explicitly
423
title = description.splitlines()[0]
424
# TODO(jelmer): Allow setting allow_collaboration field
425
# TODO(jelmer): Allow setting milestone field
426
# TODO(jelmer): Allow setting squash field
429
'target_project_id': target_project.id,
430
'source_branch': self.source_branch_name,
431
'target_branch': self.target_branch_name,
432
'description': description}
434
kwargs['labels'] = ','.join(labels)
436
merge_request = source_project.mergerequests.create(kwargs)
437
except gitlab.GitlabCreateError as e:
438
if e.response_code == 403:
439
raise errors.PermissionDenied(e.error_message)
440
if e.response_code == 409:
441
raise MergeProposalExists(self.source_branch.user_url)
443
return GitLabMergeProposal(merge_request)
446
def register_gitlab_instance(shortname, url):
447
"""Register a gitlab instance.
449
:param shortname: Short name (e.g. "gitlab")
450
:param url: URL to the gitlab instance
452
from breezy.bugtracker import (
454
ProjectIntegerBugTracker,
456
tracker_registry.register(
457
shortname, ProjectIntegerBugTracker(
458
shortname, url + '/{project}/issues/{id}'))