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 (
38
PrerequisiteBranchUnsupported,
43
class NotGitLabUrl(errors.BzrError):
45
_fmt = "Not a GitLab URL: %(url)s"
47
def __init__(self, url):
48
errors.BzrError.__init__(self)
52
class DifferentGitLabInstances(errors.BzrError):
54
_fmt = ("Can't create merge proposals across GitLab instances: "
55
"%(source_host)s and %(target_host)s")
57
def __init__(self, source_host, target_host):
58
self.source_host = source_host
59
self.target_host = target_host
62
class GitLabLoginMissing(errors.BzrError):
64
_fmt = ("Please log into GitLab")
67
def default_config_path():
68
from breezy.config import config_dir
70
return os.path.join(config_dir(), 'gitlab.conf')
73
def store_gitlab_token(name, url, private_token):
74
"""Store a GitLab token in a configuration file."""
76
config = configparser.ConfigParser()
77
path = default_config_path()
79
config.add_section(name)
80
config[name]['url'] = url
81
config[name]['private_token'] = private_token
82
with open(path, 'w') as f:
86
def connect_gitlab(host):
87
from gitlab import Gitlab
88
auth = AuthenticationConfig()
90
url = 'https://%s' % host
91
credentials = auth.get_credentials('https', host)
92
if credentials is None:
95
from gitlab.config import _DEFAULT_FILES
96
config = configparser.ConfigParser()
97
config.read(_DEFAULT_FILES + [default_config_path()])
98
for name, section in config.items():
99
if section.get('url') == url:
100
credentials = section
105
except gitlab.GitlabGetError:
106
raise GitLabLoginMissing()
108
credentials['url'] = url
109
return Gitlab(**credentials)
112
def parse_gitlab_url(branch):
113
url = urlutils.split_segment_parameters(branch.user_url)[0]
114
(scheme, user, password, host, port, path) = urlutils.parse_url(
116
if scheme not in ('git+ssh', 'https', 'http'):
117
raise NotGitLabUrl(branch.user_url)
119
raise NotGitLabUrl(branch.user_url)
120
path = path.strip('/')
121
if path.endswith('.git'):
123
return host, path, branch.name
126
class GitLabMergeProposal(MergeProposal):
128
def __init__(self, mr):
133
return self._mr.web_url
135
def get_description(self):
136
return self._mr.description
138
def set_description(self, description):
139
self._mr.description = description
142
return (self._mr.attributes['state'] == 'merged')
145
def gitlab_url_to_bzr_url(url, name):
147
name = name.encode('utf-8')
148
return urlutils.join_segment_parameters(
149
git_url_to_bzr_url(url), {"branch": name})
152
class GitLab(Hoster):
153
"""GitLab hoster implementation."""
155
supports_merge_proposal_labels = True
158
return "<GitLab(%r)>" % self.gl.url
160
def __init__(self, gl):
163
def get_push_url(self, branch):
164
(host, project_name, branch_name) = parse_gitlab_url(branch)
165
project = self.gl.projects.get(project_name)
166
return gitlab_url_to_bzr_url(
167
project.attributes['ssh_url_to_repo'], branch_name)
169
def publish_derived(self, local_branch, base_branch, name, project=None,
170
owner=None, revision_id=None, overwrite=False,
173
(host, base_project, base_branch_name) = parse_gitlab_url(base_branch)
176
base_project = self.gl.projects.get(base_project)
177
except gitlab.GitlabGetError as e:
178
if e.response_code == 404:
179
raise NoSuchProject(base_project)
183
owner = self.gl.user.username
185
project = base_project.path
187
target_project = self.gl.projects.get('%s/%s' % (owner, project))
188
except gitlab.GitlabGetError as e:
189
if e.response_code == 404:
190
target_project = base_project.forks.create({})
193
remote_repo_url = git_url_to_bzr_url(target_project.attributes['ssh_url_to_repo'])
194
remote_dir = controldir.ControlDir.open(remote_repo_url)
196
push_result = remote_dir.push_branch(
197
local_branch, revision_id=revision_id, overwrite=overwrite,
199
except errors.NoRoundtrippingSupport:
202
push_result = remote_dir.push_branch(
203
local_branch, revision_id=revision_id, overwrite=overwrite,
204
name=name, lossy=True)
205
public_url = gitlab_url_to_bzr_url(
206
target_project.attributes['http_url_to_repo'], name)
207
return push_result.target_branch, public_url
209
def get_derived_branch(self, base_branch, name, project=None, owner=None):
211
(host, base_project, base_branch_name) = parse_gitlab_url(base_branch)
214
base_project = self.gl.projects.get(base_project)
215
except gitlab.GitlabGetError as e:
216
if e.response_code == 404:
217
raise NoSuchProject(base_project)
221
owner = self.gl.user.username
223
project = base_project.path
225
target_project = self.gl.projects.get('%s/%s' % (owner, project))
226
except gitlab.GitlabGetError as e:
227
if e.response_code == 404:
228
raise errors.NotBranchError('%s/%s/%s' % (self.gl.url, owner, project))
230
return _mod_branch.Branch.open(gitlab_url_to_bzr_url(
231
target_project.attributes['ssh_url_to_repo'], name))
233
def get_proposer(self, source_branch, target_branch):
234
return GitlabMergeProposalBuilder(self.gl, source_branch, target_branch)
236
def get_proposal(self, source_branch, target_branch):
237
(source_host, source_project_name, source_branch_name) = (
238
parse_gitlab_url(source_branch))
239
(target_host, target_project_name, target_branch_name) = (
240
parse_gitlab_url(target_branch))
241
if source_host != target_host:
242
raise DifferentGitLabInstances(source_host, target_host)
244
source_project = self.gl.projects.get(source_project_name)
245
target_project = self.gl.projects.get(target_project_name)
247
for mr in target_project.mergerequests.list(state='all'):
248
attrs = mr.attributes
249
if (attrs['source_project_id'] != source_project.id or
250
attrs['source_branch'] != source_branch_name or
251
attrs['target_project_id'] != target_project.id or
252
attrs['target_branch'] != target_branch_name):
254
return GitLabMergeProposal(mr)
255
except gitlab.GitlabListError as e:
256
if e.response_code == 403:
257
raise PermissionDenied(e.error_message)
258
raise NoMergeProposal()
260
def hosts(self, branch):
262
(host, project, branch_name) = parse_gitlab_url(branch)
265
return (self.gl.url == ('https://%s' % host))
268
def probe(cls, branch):
270
(host, project, branch_name) = parse_gitlab_url(branch)
272
raise UnsupportedHoster(branch)
274
import requests.exceptions
276
gl = connect_gitlab(host)
278
except requests.exceptions.SSLError:
279
# Well, I guess it could be..
280
raise UnsupportedHoster(branch)
281
except gitlab.GitlabGetError:
282
raise UnsupportedHoster(branch)
283
except gitlab.GitlabHttpError as e:
284
if e.response_code in (404, 405, 503):
285
raise UnsupportedHoster(branch)
291
class GitlabMergeProposalBuilder(MergeProposalBuilder):
293
def __init__(self, gl, source_branch, target_branch):
295
self.source_branch = source_branch
296
(self.source_host, self.source_project_name, self.source_branch_name) = (
297
parse_gitlab_url(source_branch))
298
self.target_branch = target_branch
299
(self.target_host, self.target_project_name, self.target_branch_name) = (
300
parse_gitlab_url(target_branch))
301
if self.source_host != self.target_host:
302
raise DifferentGitLabInstances(self.source_host, self.target_host)
304
def get_infotext(self):
305
"""Determine the initial comment for the merge proposal."""
307
info.append("Gitlab instance: %s\n" % self.target_host)
308
info.append("Source: %s\n" % self.source_branch.user_url)
309
info.append("Target: %s\n" % self.target_branch.user_url)
312
def get_initial_body(self):
313
"""Get a body for the proposal for the user to modify.
315
:return: a str or None.
319
def create_proposal(self, description, reviewers=None, labels=None,
320
prerequisite_branch=None):
321
"""Perform the submission."""
322
if prerequisite_branch is not None:
323
raise PrerequisiteBranchUnsupported(self)
325
# TODO(jelmer): Support reviewers
327
source_project = self.gl.projects.get(self.source_project_name)
328
target_project = self.gl.projects.get(self.target_project_name)
329
# TODO(jelmer): Allow setting title explicitly
330
title = description.splitlines()[0]
331
# TODO(jelmer): Allow setting allow_collaboration field
332
# TODO(jelmer): Allow setting milestone field
333
# TODO(jelmer): Allow setting squash field
336
'target_project_id': target_project.id,
337
'source_branch': self.source_branch_name,
338
'target_branch': self.target_branch_name,
339
'description': description}
341
kwargs['labels'] = ','.join(labels)
343
merge_request = source_project.mergerequests.create(kwargs)
344
except gitlab.GitlabCreateError as e:
345
if e.response_code == 403:
346
raise PermissionDenied(e.error_message)
347
if e.response_code == 409:
348
raise MergeProposalExists(self.source_branch.user_url)
350
return GitLabMergeProposal(merge_request)