1
# Copyright (C) 2010, 2011 Canonical Ltd
2
# Copyright (C) 2018 Breezy Developers
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18
"""Support for Launchpad."""
20
from __future__ import absolute_import
26
from .propose import (
36
branch as _mod_branch,
42
from ...git.refs import ref_to_branch_name
43
from ...lazy_import import lazy_import
44
lazy_import(globals(), """
45
from breezy.plugins.launchpad import (
49
from launchpadlib import uris
51
from ...transport import get_transport
54
# TODO(jelmer): Make selection of launchpad staging a configuration option.
56
def status_to_lp_mp_statuses(status):
58
if status in ('open', 'all'):
63
'Code failed to merge',
65
if status in ('closed', 'all'):
66
statuses.extend(['Rejected', 'Superseded'])
67
if status in ('merged', 'all'):
68
statuses.append('Merged')
72
def plausible_launchpad_url(url):
75
if url.startswith('lp:'):
77
regex = re.compile('([a-z]*\+)*(bzr\+ssh|http|ssh|git|https)'
78
'://(bazaar|git).*\.launchpad\.net')
79
return bool(regex.match(url))
82
class WebserviceFailure(Exception):
84
def __init__(self, message):
85
self.message = message
88
def _call_webservice(call, *args, **kwargs):
89
"""Make a call to the webservice, wrapping failures.
91
:param call: The call to make.
92
:param *args: *args for the call.
93
:param **kwargs: **kwargs for the call.
94
:return: The result of calling call(*args, *kwargs).
96
from lazr.restfulclient import errors as restful_errors
98
return call(*args, **kwargs)
99
except restful_errors.HTTPError as e:
101
for line in e.content.splitlines():
102
if line.startswith(b'Traceback (most recent call last):'):
104
error_lines.append(line)
105
raise WebserviceFailure(b''.join(error_lines))
108
class LaunchpadMergeProposal(MergeProposal):
110
def __init__(self, mp):
113
def get_source_branch_url(self):
114
if self._mp.source_branch:
115
return self._mp.source_branch.bzr_identity
117
branch_name = ref_to_branch_name(
118
self._mp.source_git_path.encode('utf-8'))
119
return urlutils.join_segment_parameters(
120
self._mp.source_git_repository.git_identity,
121
{"branch": branch_name})
123
def get_target_branch_url(self):
124
if self._mp.target_branch:
125
return self._mp.target_branch.bzr_identity
127
branch_name = ref_to_branch_name(
128
self._mp.target_git_path.encode('utf-8'))
129
return urlutils.join_segment_parameters(
130
self._mp.target_git_repository.git_identity,
131
{"branch": branch_name})
135
return lp_api.canonical_url(self._mp)
138
return (self._mp.queue_status == 'Merged')
140
def get_description(self):
141
return self._mp.description
143
def set_description(self, description):
144
self._mp.description = description
147
def get_commit_message(self):
148
return self._mp.commit_message
150
def set_commit_message(self, commit_message):
151
self._mp.commit_message = commit_message
155
self._mp.setStatus(status='Rejected')
157
def merge(self, commit_message=None):
158
target_branch = _mod_branch.Branch.open(
159
self.get_target_branch_url())
160
source_branch = _mod_branch.Branch.open(
161
self.get_source_branch_url())
162
# TODO(jelmer): Ideally this would use a memorytree, but merge doesn't
164
# tree = target_branch.create_memorytree()
165
tmpdir = tempfile.mkdtemp()
167
tree = target_branch.create_checkout(
168
to_location=tmpdir, lightweight=True)
169
tree.merge_from_branch(source_branch)
170
tree.commit(commit_message or self._mp.commit_message)
172
shutil.rmtree(tmpdir)
175
class Launchpad(Hoster):
176
"""The Launchpad hosting service."""
180
# https://bugs.launchpad.net/launchpad/+bug/397676
181
supports_merge_proposal_labels = False
183
supports_merge_proposal_commit_message = True
185
def __init__(self, staging=False):
186
self._staging = staging
188
lp_base_url = uris.STAGING_SERVICE_ROOT
190
lp_base_url = uris.LPNET_SERVICE_ROOT
191
self.launchpad = lp_api.connect_launchpad(lp_base_url)
195
return lp_api.uris.web_root_for_service_root(
196
str(self.launchpad._root_uri))
199
return "Launchpad(staging=%s)" % self._staging
201
def hosts(self, branch):
202
# TODO(jelmer): staging vs non-staging?
203
return plausible_launchpad_url(branch.user_url)
206
def probe_from_url(cls, url):
207
if plausible_launchpad_url(url):
209
raise UnsupportedHoster(url)
211
def _get_lp_git_ref_from_branch(self, branch):
212
url, params = urlutils.split_segment_parameters(branch.user_url)
213
(scheme, user, password, host, port, path) = urlutils.parse_url(
215
repo_lp = self.launchpad.git_repositories.getByPath(
216
path=path.strip('/'))
218
ref_path = params['ref']
220
branch_name = params.get('branch', branch.name)
222
ref_path = 'refs/heads/%s' % branch_name
224
ref_path = repo_lp.default_branch
225
ref_lp = repo_lp.getRefByPath(path=ref_path)
226
return (repo_lp, ref_lp)
228
def _get_lp_bzr_branch_from_branch(self, branch):
229
return self.launchpad.branches.getByUrl(
230
url=urlutils.unescape(branch.user_url))
232
def _get_derived_git_path(self, base_path, owner, project):
233
base_repo = self.launchpad.git_repositories.getByPath(path=base_path)
235
project = urlutils.parse_url(base_repo.git_ssh_url)[-1].strip('/')
236
if project.startswith('~'):
237
project = '/'.join(base_path.split('/')[1:])
238
# TODO(jelmer): Surely there is a better way of creating one of these
240
return "~%s/%s" % (owner, project)
242
def _publish_git(self, local_branch, base_path, name, owner, project=None,
243
revision_id=None, overwrite=False, allow_lossy=True):
244
to_path = self._get_derived_git_path(base_path, owner, project)
245
to_transport = get_transport("git+ssh://git.launchpad.net/" + to_path)
247
dir_to = controldir.ControlDir.open_from_transport(to_transport)
248
except errors.NotBranchError:
249
# Didn't find anything
254
br_to = local_branch.create_clone_on_transport(
255
to_transport, revision_id=revision_id, name=name)
256
except errors.NoRoundtrippingSupport:
257
br_to = local_branch.create_clone_on_transport(
258
to_transport, revision_id=revision_id, name=name,
262
dir_to = dir_to.push_branch(
263
local_branch, revision_id, overwrite=overwrite, name=name)
264
except errors.NoRoundtrippingSupport:
267
dir_to = dir_to.push_branch(
268
local_branch, revision_id, overwrite=overwrite, name=name,
270
br_to = dir_to.target_branch
272
"https://git.launchpad.net/%s/+ref/%s" % (to_path, name))
274
def _get_derived_bzr_path(self, base_branch, name, owner, project):
276
base_branch_lp = self._get_lp_bzr_branch_from_branch(base_branch)
277
project = '/'.join(base_branch_lp.unique_name.split('/')[1:-1])
278
# TODO(jelmer): Surely there is a better way of creating one of these
280
return "~%s/%s/%s" % (owner, project, name)
282
def get_push_url(self, branch):
283
(vcs, user, password, path, params) = self._split_url(branch.user_url)
285
branch_lp = self._get_lp_bzr_branch_from_branch(branch)
286
return branch_lp.bzr_identity
288
return urlutils.join_segment_parameters(
289
"git+ssh://git.launchpad.net/" + path, params)
293
def _publish_bzr(self, local_branch, base_branch, name, owner,
294
project=None, revision_id=None, overwrite=False,
296
to_path = self._get_derived_bzr_path(base_branch, name, owner, project)
297
to_transport = get_transport("lp:" + to_path)
299
dir_to = controldir.ControlDir.open_from_transport(to_transport)
300
except errors.NotBranchError:
301
# Didn't find anything
305
br_to = local_branch.create_clone_on_transport(
306
to_transport, revision_id=revision_id)
308
br_to = dir_to.push_branch(
309
local_branch, revision_id, overwrite=overwrite).target_branch
310
return br_to, ("https://code.launchpad.net/" + to_path)
312
def _split_url(self, url):
313
url, params = urlutils.split_segment_parameters(url)
314
(scheme, user, password, host, port, path) = urlutils.parse_url(url)
315
path = path.strip('/')
316
if host.startswith('bazaar.'):
318
elif host.startswith('git.'):
321
raise ValueError("unknown host %s" % host)
322
return (vcs, user, password, path, params)
324
def publish_derived(self, local_branch, base_branch, name, project=None,
325
owner=None, revision_id=None, overwrite=False,
327
"""Publish a branch to the site, derived from base_branch.
329
:param base_branch: branch to derive the new branch from
330
:param new_branch: branch to publish
331
:param name: Name of the new branch on the remote host
332
:param project: Optional project name
333
:param owner: Optional owner
334
:return: resulting branch
337
owner = self.launchpad.me.name
338
(base_vcs, base_user, base_password, base_path,
339
base_params) = self._split_url(base_branch.user_url)
340
# TODO(jelmer): Prevent publishing to development focus
341
if base_vcs == 'bzr':
342
return self._publish_bzr(
343
local_branch, base_branch, name, project=project, owner=owner,
344
revision_id=revision_id, overwrite=overwrite,
345
allow_lossy=allow_lossy)
346
elif base_vcs == 'git':
347
return self._publish_git(
348
local_branch, base_path, name, project=project, owner=owner,
349
revision_id=revision_id, overwrite=overwrite,
350
allow_lossy=allow_lossy)
352
raise AssertionError('not a valid Launchpad URL')
354
def get_derived_branch(self, base_branch, name, project=None, owner=None):
356
owner = self.launchpad.me.name
357
(base_vcs, base_user, base_password, base_path,
358
base_params) = self._split_url(base_branch.user_url)
359
if base_vcs == 'bzr':
360
to_path = self._get_derived_bzr_path(
361
base_branch, name, owner, project)
362
return _mod_branch.Branch.open("lp:" + to_path)
363
elif base_vcs == 'git':
364
to_path = self._get_derived_git_path(
365
base_path.strip('/'), owner, project)
366
to_url = urlutils.join_segment_parameters(
367
"git+ssh://git.launchpad.net/" + to_path,
369
return _mod_branch.Branch.open(to_url)
371
raise AssertionError('not a valid Launchpad URL')
373
def iter_proposals(self, source_branch, target_branch, status='open'):
374
(base_vcs, base_user, base_password, base_path,
375
base_params) = self._split_url(target_branch.user_url)
376
statuses = status_to_lp_mp_statuses(status)
377
if base_vcs == 'bzr':
378
target_branch_lp = self.launchpad.branches.getByUrl(
379
url=target_branch.user_url)
380
source_branch_lp = self.launchpad.branches.getByUrl(
381
url=source_branch.user_url)
382
for mp in target_branch_lp.getMergeProposals(status=statuses):
383
if mp.source_branch_link != source_branch_lp.self_link:
385
yield LaunchpadMergeProposal(mp)
386
elif base_vcs == 'git':
387
(source_repo_lp, source_branch_lp) = (
388
self._get_lp_git_ref_from_branch(source_branch))
389
(target_repo_lp, target_branch_lp) = (
390
self._get_lp_git_ref_from_branch(target_branch))
391
for mp in target_branch_lp.getMergeProposals(status=statuses):
392
if (target_branch_lp.path != mp.target_git_path or
393
target_repo_lp != mp.target_git_repository or
394
source_branch_lp.path != mp.source_git_path or
395
source_repo_lp != mp.source_git_repository):
397
yield LaunchpadMergeProposal(mp)
399
raise AssertionError('not a valid Launchpad URL')
401
def get_proposer(self, source_branch, target_branch):
402
(base_vcs, base_user, base_password, base_path,
403
base_params) = self._split_url(target_branch.user_url)
404
if base_vcs == 'bzr':
405
return LaunchpadBazaarMergeProposalBuilder(
406
self, source_branch, target_branch)
407
elif base_vcs == 'git':
408
return LaunchpadGitMergeProposalBuilder(
409
self, source_branch, target_branch)
411
raise AssertionError('not a valid Launchpad URL')
414
def iter_instances(cls):
417
def iter_my_proposals(self, status='open'):
418
statuses = status_to_lp_mp_statuses(status)
419
for mp in self.launchpad.me.getMergeProposals(status=statuses):
420
yield LaunchpadMergeProposal(mp)
422
def get_proposal_by_url(self, url):
423
# Launchpad doesn't have a way to find a merge proposal by URL.
424
(scheme, user, password, host, port, path) = urlutils.parse_url(
426
LAUNCHPAD_CODE_DOMAINS = [
427
('code.%s' % domain) for domain in lp_api.LAUNCHPAD_DOMAINS.values()]
428
if host not in LAUNCHPAD_CODE_DOMAINS:
429
raise UnsupportedHoster(url)
430
# TODO(jelmer): Check if this is a launchpad URL. Otherwise, raise
432
# See https://api.launchpad.net/devel/#branch_merge_proposal
434
# https://api.launchpad.net/devel/~<author.name>/<project.name>/<branch.name>/+merge/<id>
435
api_url = str(self.launchpad._root_uri) + path
436
mp = self.launchpad.load(api_url)
437
return LaunchpadMergeProposal(mp)
440
class LaunchpadBazaarMergeProposalBuilder(MergeProposalBuilder):
442
def __init__(self, lp_host, source_branch, target_branch,
443
staging=None, approve=None, fixes=None):
446
:param source_branch: The branch to propose for merging.
447
:param target_branch: The branch to merge into.
448
:param staging: If True, propose the merge against staging instead of
450
:param approve: If True, mark the new proposal as approved immediately.
451
This is useful when a project permits some things to be approved
452
by the submitter (e.g. merges between release and deployment
455
self.lp_host = lp_host
456
self.launchpad = lp_host.launchpad
457
self.source_branch = source_branch
458
self.source_branch_lp = self.launchpad.branches.getByUrl(
459
url=source_branch.user_url)
460
if target_branch is None:
461
self.target_branch_lp = self.source_branch_lp.get_target()
462
self.target_branch = _mod_branch.Branch.open(
463
self.target_branch_lp.bzr_identity)
465
self.target_branch = target_branch
466
self.target_branch_lp = self.launchpad.branches.getByUrl(
467
url=target_branch.user_url)
468
self.approve = approve
471
def get_infotext(self):
472
"""Determine the initial comment for the merge proposal."""
473
info = ["Source: %s\n" % self.source_branch_lp.bzr_identity]
474
info.append("Target: %s\n" % self.target_branch_lp.bzr_identity)
477
def get_initial_body(self):
478
"""Get a body for the proposal for the user to modify.
480
:return: a str or None.
482
if not self.hooks['merge_proposal_body']:
485
def list_modified_files():
486
lca_tree = self.source_branch_lp.find_lca_tree(
487
self.target_branch_lp)
488
source_tree = self.source_branch.basis_tree()
489
files = modified_files(lca_tree, source_tree)
491
with self.target_branch.lock_read(), \
492
self.source_branch.lock_read():
494
for hook in self.hooks['merge_proposal_body']:
496
'target_branch': self.target_branch_lp.bzr_identity,
497
'modified_files_callback': list_modified_files,
502
def check_proposal(self):
503
"""Check that the submission is sensible."""
504
if self.source_branch_lp.self_link == self.target_branch_lp.self_link:
505
raise errors.BzrCommandError(
506
'Source and target branches must be different.')
507
for mp in self.source_branch_lp.landing_targets:
508
if mp.queue_status in ('Merged', 'Rejected'):
510
if mp.target_branch.self_link == self.target_branch_lp.self_link:
511
raise MergeProposalExists(lp_api.canonical_url(mp))
513
def approve_proposal(self, mp):
514
with self.source_branch.lock_read():
518
subject='', # Use the default subject.
519
content=u"Rubberstamp! Proposer approves of own proposal.")
520
_call_webservice(mp.setStatus, status=u'Approved',
521
revid=self.source_branch.last_revision())
523
def create_proposal(self, description, reviewers=None, labels=None,
524
prerequisite_branch=None, commit_message=None):
525
"""Perform the submission."""
527
raise LabelsUnsupported(self)
528
if prerequisite_branch is not None:
529
prereq = self.launchpad.branches.getByUrl(
530
url=prerequisite_branch.user_url)
533
if reviewers is None:
536
mp = _call_webservice(
537
self.source_branch_lp.createMergeProposal,
538
target_branch=self.target_branch_lp,
539
prerequisite_branch=prereq,
540
initial_comment=description.strip(),
541
commit_message=commit_message,
542
reviewers=[self.launchpad.people[reviewer].self_link
543
for reviewer in reviewers],
544
review_types=[None for reviewer in reviewers])
545
except WebserviceFailure as e:
547
if (b'There is already a branch merge proposal '
548
b'registered for branch ') in e.message:
549
raise MergeProposalExists(self.source_branch.user_url)
553
self.approve_proposal(mp)
555
if self.fixes.startswith('lp:'):
556
self.fixes = self.fixes[3:]
559
bug=self.launchpad.bugs[int(self.fixes)])
560
return LaunchpadMergeProposal(mp)
563
class LaunchpadGitMergeProposalBuilder(MergeProposalBuilder):
565
def __init__(self, lp_host, source_branch, target_branch,
566
staging=None, approve=None, fixes=None):
569
:param source_branch: The branch to propose for merging.
570
:param target_branch: The branch to merge into.
571
:param staging: If True, propose the merge against staging instead of
573
:param approve: If True, mark the new proposal as approved immediately.
574
This is useful when a project permits some things to be approved
575
by the submitter (e.g. merges between release and deployment
578
self.lp_host = lp_host
579
self.launchpad = lp_host.launchpad
580
self.source_branch = source_branch
581
(self.source_repo_lp,
582
self.source_branch_lp) = self.lp_host._get_lp_git_ref_from_branch(
584
if target_branch is None:
585
self.target_branch_lp = self.source_branch.get_target()
586
self.target_branch = _mod_branch.Branch.open(
587
self.target_branch_lp.git_https_url)
589
self.target_branch = target_branch
590
(self.target_repo_lp, self.target_branch_lp) = (
591
self.lp_host._get_lp_git_ref_from_branch(target_branch))
592
self.approve = approve
595
def get_infotext(self):
596
"""Determine the initial comment for the merge proposal."""
597
info = ["Source: %s\n" % self.source_branch.user_url]
598
info.append("Target: %s\n" % self.target_branch.user_url)
601
def get_initial_body(self):
602
"""Get a body for the proposal for the user to modify.
604
:return: a str or None.
606
if not self.hooks['merge_proposal_body']:
609
def list_modified_files():
610
lca_tree = self.source_branch_lp.find_lca_tree(
611
self.target_branch_lp)
612
source_tree = self.source_branch.basis_tree()
613
files = modified_files(lca_tree, source_tree)
615
with self.target_branch.lock_read(), \
616
self.source_branch.lock_read():
618
for hook in self.hooks['merge_proposal_body']:
620
'target_branch': self.target_branch,
621
'modified_files_callback': list_modified_files,
626
def check_proposal(self):
627
"""Check that the submission is sensible."""
628
if self.source_branch_lp.self_link == self.target_branch_lp.self_link:
629
raise errors.BzrCommandError(
630
'Source and target branches must be different.')
631
for mp in self.source_branch_lp.landing_targets:
632
if mp.queue_status in ('Merged', 'Rejected'):
634
if mp.target_branch.self_link == self.target_branch_lp.self_link:
635
raise MergeProposalExists(lp_api.canonical_url(mp))
637
def approve_proposal(self, mp):
638
with self.source_branch.lock_read():
642
subject='', # Use the default subject.
643
content=u"Rubberstamp! Proposer approves of own proposal.")
645
mp.setStatus, status=u'Approved',
646
revid=self.source_branch.last_revision())
648
def create_proposal(self, description, reviewers=None, labels=None,
649
prerequisite_branch=None, commit_message=None):
650
"""Perform the submission."""
652
raise LabelsUnsupported(self)
653
if prerequisite_branch is not None:
654
(prereq_repo_lp, prereq_branch_lp) = (
655
self.lp_host._get_lp_git_ref_from_branch(prerequisite_branch))
657
prereq_branch_lp = None
658
if reviewers is None:
661
mp = _call_webservice(
662
self.source_branch_lp.createMergeProposal,
663
merge_target=self.target_branch_lp,
664
merge_prerequisite=prereq_branch_lp,
665
initial_comment=description.strip(),
666
commit_message=commit_message,
668
reviewers=[self.launchpad.people[reviewer].self_link
669
for reviewer in reviewers],
670
review_types=[None for reviewer in reviewers])
671
except WebserviceFailure as e:
673
if ('There is already a branch merge proposal '
674
'registered for branch ') in e.message:
675
raise MergeProposalExists(self.source_branch.user_url)
678
self.approve_proposal(mp)
680
if self.fixes.startswith('lp:'):
681
self.fixes = self.fixes[3:]
684
bug=self.launchpad.bugs[int(self.fixes)])
685
return LaunchpadMergeProposal(mp)
688
def modified_files(old_tree, new_tree):
689
"""Return a list of paths in the new tree with modified contents."""
690
for f, (op, path), c, v, p, n, (ok, k), e in new_tree.iter_changes(
692
if c and k == 'file':