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 ...config import AuthenticationConfig
28
from ...git.urls import git_url_to_bzr_url
29
from ...sixish import PY3
31
from .propose import (
37
PrerequisiteBranchUnsupported,
41
def mp_status_to_status(status):
46
'closed': 'closed'}[status]
49
class NotGitLabUrl(errors.BzrError):
51
_fmt = "Not a GitLab URL: %(url)s"
53
def __init__(self, url):
54
errors.BzrError.__init__(self)
58
class DifferentGitLabInstances(errors.BzrError):
60
_fmt = ("Can't create merge proposals across GitLab instances: "
61
"%(source_host)s and %(target_host)s")
63
def __init__(self, source_host, target_host):
64
self.source_host = source_host
65
self.target_host = target_host
68
class GitLabLoginMissing(errors.BzrError):
70
_fmt = ("Please log into GitLab")
73
def default_config_path():
74
from breezy.config import config_dir
76
return os.path.join(config_dir(), 'gitlab.conf')
79
def store_gitlab_token(name, url, private_token):
80
"""Store a GitLab token in a configuration file."""
82
config = configparser.ConfigParser()
83
path = default_config_path()
85
config.add_section(name)
86
config[name]['url'] = url
87
config[name]['private_token'] = private_token
88
with open(path, 'w') as f:
94
from gitlab.config import _DEFAULT_FILES
95
config = configparser.ConfigParser()
96
config.read(_DEFAULT_FILES + [default_config_path()])
97
for name, section in config.items():
101
def connect_gitlab(host):
102
from gitlab import Gitlab, GitlabGetError
103
auth = AuthenticationConfig()
105
url = 'https://%s' % host
106
credentials = auth.get_credentials('https', host)
107
if credentials is None:
108
for name, section in iter_tokens():
109
if section.get('url') == url:
110
credentials = section
115
except GitlabGetError:
116
raise GitLabLoginMissing()
118
credentials['url'] = url
119
return Gitlab(**credentials)
122
def parse_gitlab_url(branch):
123
url = urlutils.split_segment_parameters(branch.user_url)[0]
124
(scheme, user, password, host, port, path) = urlutils.parse_url(
126
if scheme not in ('git+ssh', 'https', 'http'):
127
raise NotGitLabUrl(branch.user_url)
129
raise NotGitLabUrl(branch.user_url)
130
path = path.strip('/')
131
if path.endswith('.git'):
133
return host, path, branch.name
136
class GitLabMergeProposal(MergeProposal):
138
def __init__(self, mr):
143
return self._mr.web_url
145
def get_description(self):
146
return self._mr.description
148
def set_description(self, description):
149
self._mr.description = description
151
def _branch_url_from_project(self, project_id, branch_name):
152
project = self._mr.manager.gitlab.projects.get(project_id)
153
return gitlab_url_to_bzr_url(project.http_url_to_repo, branch_name)
155
def get_source_branch_url(self):
156
return self._branch_url_from_project(
157
self._mr.source_project_id, self._mr.source_branch)
159
def get_target_branch_url(self):
160
return self._branch_url_from_project(
161
self._mr.target_project_id, self._mr.target_branch)
164
return (self._mr.state == 'merged')
167
self._mr.state_event = 'close'
171
def gitlab_url_to_bzr_url(url, name):
173
name = name.encode('utf-8')
174
return urlutils.join_segment_parameters(
175
git_url_to_bzr_url(url), {"branch": name})
178
class GitLab(Hoster):
179
"""GitLab hoster implementation."""
181
supports_merge_proposal_labels = True
184
return "<GitLab(%r)>" % self.gl.url
190
def __init__(self, gl):
193
def get_push_url(self, branch):
194
(host, project_name, branch_name) = parse_gitlab_url(branch)
195
project = self.gl.projects.get(project_name)
196
return gitlab_url_to_bzr_url(
197
project.ssh_url_to_repo, branch_name)
199
def publish_derived(self, local_branch, base_branch, name, project=None,
200
owner=None, revision_id=None, overwrite=False,
203
(host, base_project, base_branch_name) = parse_gitlab_url(base_branch)
206
base_project = self.gl.projects.get(base_project)
207
except gitlab.GitlabGetError as e:
208
if e.response_code == 404:
209
raise NoSuchProject(base_project)
213
owner = self.gl.user.username
215
project = base_project.path
217
target_project = self.gl.projects.get('%s/%s' % (owner, project))
218
except gitlab.GitlabGetError as e:
219
if e.response_code == 404:
220
target_project = base_project.forks.create({})
223
remote_repo_url = git_url_to_bzr_url(target_project.ssh_url_to_repo)
224
remote_dir = controldir.ControlDir.open(remote_repo_url)
226
push_result = remote_dir.push_branch(
227
local_branch, revision_id=revision_id, overwrite=overwrite,
229
except errors.NoRoundtrippingSupport:
232
push_result = remote_dir.push_branch(
233
local_branch, revision_id=revision_id, overwrite=overwrite,
234
name=name, lossy=True)
235
public_url = gitlab_url_to_bzr_url(
236
target_project.http_url_to_repo, name)
237
return push_result.target_branch, public_url
239
def get_derived_branch(self, base_branch, name, project=None, owner=None):
241
(host, base_project, base_branch_name) = parse_gitlab_url(base_branch)
244
base_project = self.gl.projects.get(base_project)
245
except gitlab.GitlabGetError as e:
246
if e.response_code == 404:
247
raise NoSuchProject(base_project)
251
owner = self.gl.user.username
253
project = base_project.path
255
target_project = self.gl.projects.get('%s/%s' % (owner, project))
256
except gitlab.GitlabGetError as e:
257
if e.response_code == 404:
258
raise errors.NotBranchError('%s/%s/%s' % (self.gl.url, owner, project))
260
return _mod_branch.Branch.open(gitlab_url_to_bzr_url(
261
target_project.ssh_url_to_repo, name))
263
def get_proposer(self, source_branch, target_branch):
264
return GitlabMergeProposalBuilder(self.gl, source_branch, target_branch)
266
def iter_proposals(self, source_branch, target_branch, status):
268
(source_host, source_project_name, source_branch_name) = (
269
parse_gitlab_url(source_branch))
270
(target_host, target_project_name, target_branch_name) = (
271
parse_gitlab_url(target_branch))
272
if source_host != target_host:
273
raise DifferentGitLabInstances(source_host, target_host)
275
source_project = self.gl.projects.get(source_project_name)
276
target_project = self.gl.projects.get(target_project_name)
277
state = mp_status_to_status(status)
279
for mr in target_project.mergerequests.list(state=state):
280
if (mr.source_project_id != source_project.id or
281
mr.source_branch != source_branch_name or
282
mr.target_project_id != target_project.id or
283
mr.target_branch != target_branch_name):
285
yield GitLabMergeProposal(mr)
286
except gitlab.GitlabListError as e:
287
if e.response_code == 403:
288
raise errors.PermissionDenied(e.error_message)
290
def hosts(self, branch):
292
(host, project, branch_name) = parse_gitlab_url(branch)
295
return (self.gl.url == ('https://%s' % host))
298
def probe(cls, branch):
300
(host, project, branch_name) = parse_gitlab_url(branch)
302
raise UnsupportedHoster(branch)
304
import requests.exceptions
306
gl = connect_gitlab(host)
308
except requests.exceptions.SSLError:
309
# Well, I guess it could be..
310
raise UnsupportedHoster(branch)
311
except gitlab.GitlabGetError:
312
raise UnsupportedHoster(branch)
313
except gitlab.GitlabHttpError as e:
314
if e.response_code in (404, 405, 503):
315
raise UnsupportedHoster(branch)
321
def iter_instances(cls):
322
from gitlab import Gitlab
323
for name, credentials in iter_tokens():
324
if 'url' not in credentials:
326
gl = Gitlab(**credentials)
329
def iter_my_proposals(self, status='open'):
330
state = mp_status_to_status(status)
332
for mp in self.gl.mergerequests.list(
333
owner=self.gl.user.username, state=state):
334
yield GitLabMergeProposal(mp)
337
class GitlabMergeProposalBuilder(MergeProposalBuilder):
339
def __init__(self, gl, source_branch, target_branch):
341
self.source_branch = source_branch
342
(self.source_host, self.source_project_name, self.source_branch_name) = (
343
parse_gitlab_url(source_branch))
344
self.target_branch = target_branch
345
(self.target_host, self.target_project_name, self.target_branch_name) = (
346
parse_gitlab_url(target_branch))
347
if self.source_host != self.target_host:
348
raise DifferentGitLabInstances(self.source_host, self.target_host)
350
def get_infotext(self):
351
"""Determine the initial comment for the merge proposal."""
353
info.append("Gitlab instance: %s\n" % self.target_host)
354
info.append("Source: %s\n" % self.source_branch.user_url)
355
info.append("Target: %s\n" % self.target_branch.user_url)
358
def get_initial_body(self):
359
"""Get a body for the proposal for the user to modify.
361
:return: a str or None.
365
def create_proposal(self, description, reviewers=None, labels=None,
366
prerequisite_branch=None):
367
"""Perform the submission."""
368
if prerequisite_branch is not None:
369
raise PrerequisiteBranchUnsupported(self)
371
# TODO(jelmer): Support reviewers
373
source_project = self.gl.projects.get(self.source_project_name)
374
target_project = self.gl.projects.get(self.target_project_name)
375
# TODO(jelmer): Allow setting title explicitly
376
title = description.splitlines()[0]
377
# TODO(jelmer): Allow setting allow_collaboration field
378
# TODO(jelmer): Allow setting milestone field
379
# TODO(jelmer): Allow setting squash field
382
'target_project_id': target_project.id,
383
'source_branch': self.source_branch_name,
384
'target_branch': self.target_branch_name,
385
'description': description}
387
kwargs['labels'] = ','.join(labels)
389
merge_request = source_project.mergerequests.create(kwargs)
390
except gitlab.GitlabCreateError as e:
391
if e.response_code == 403:
392
raise errors.PermissionDenied(e.error_message)
393
if e.response_code == 409:
394
raise MergeProposalExists(self.source_branch.user_url)
396
return GitLabMergeProposal(merge_request)
399
def register_gitlab_instance(shortname, url):
400
"""Register a gitlab instance.
402
:param shortname: Short name (e.g. "gitlab")
403
:param url: URL to the gitlab instance
405
from breezy.bugtracker import (
407
ProjectIntegerBugTracker,
409
tracker_registry.register(
410
shortname, ProjectIntegerBugTracker(
411
shortname, url + '/{project}/issues/{id}'))