1
# Copyright (C) 2018-2019 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
"""Helper functions for proposing merges."""
27
class NoSuchProject(errors.BzrError):
29
_fmt = "Project does not exist: %(project)s."
31
def __init__(self, project):
32
errors.BzrError.__init__(self)
33
self.project = project
36
class MergeProposalExists(errors.BzrError):
38
_fmt = "A merge proposal already exists: %(url)s."
40
def __init__(self, url):
41
errors.BzrError.__init__(self)
45
class UnsupportedHoster(errors.BzrError):
47
_fmt = "No supported hoster for %(branch)s."
49
def __init__(self, branch):
50
errors.BzrError.__init__(self)
54
class ReopenFailed(errors.BzrError):
56
_fmt = "Reopening the merge proposal failed: %(error)s."
59
class ProposeMergeHooks(hooks.Hooks):
60
"""Hooks for proposing a merge on Launchpad."""
63
hooks.Hooks.__init__(self, __name__, "Proposer.hooks")
66
"Return the prerequisite branch for proposing as merge.", (3, 0))
68
'merge_proposal_body',
69
"Return an initial body for the merge proposal message.", (3, 0))
72
class LabelsUnsupported(errors.BzrError):
73
"""Labels not supported by this hoster."""
75
_fmt = "Labels are not supported by %(hoster)r."
77
def __init__(self, hoster):
78
errors.BzrError.__init__(self)
82
class PrerequisiteBranchUnsupported(errors.BzrError):
83
"""Prerequisite branch not supported by this hoster."""
85
def __init__(self, hoster):
86
errors.BzrError.__init__(self)
90
class HosterLoginRequired(errors.BzrError):
91
"""Action requires hoster login credentials."""
93
_fmt = "Action requires credentials for hosting site %(hoster)r."""
95
def __init__(self, hoster):
96
errors.BzrError.__init__(self)
100
class MergeProposal(object):
103
:ivar url: URL for the merge proposal
106
def __init__(self, url=None):
109
def get_description(self):
110
"""Get the description of the merge proposal."""
111
raise NotImplementedError(self.get_description)
113
def set_description(self, description):
114
"""Set the description of the merge proposal."""
115
raise NotImplementedError(self.set_description)
117
def get_commit_message(self):
118
"""Get the proposed commit message."""
119
raise NotImplementedError(self.get_commit_message)
121
def set_commit_message(self, commit_message):
122
"""Set the propose commit message."""
123
raise NotImplementedError(self.set_commit_message)
125
def get_source_branch_url(self):
126
"""Return the source branch."""
127
raise NotImplementedError(self.get_source_branch_url)
129
def get_source_revision(self):
130
"""Return the latest revision for the source branch."""
131
raise NotImplementedError(self.get_source_revision)
133
def get_target_branch_url(self):
134
"""Return the target branch."""
135
raise NotImplementedError(self.get_target_branch_url)
137
def get_source_project(self):
138
raise NotImplementedError(self.get_source_project)
140
def get_target_project(self):
141
raise NotImplementedError(self.get_target_project)
144
"""Close the merge proposal (without merging it)."""
145
raise NotImplementedError(self.close)
148
"""Check whether this merge proposal has been merged."""
149
raise NotImplementedError(self.is_merged)
152
"""Check whether this merge proposal is closed
154
This can either mean that it is merged or rejected.
156
raise NotImplementedError(self.is_closed)
158
def merge(self, commit_message=None):
159
"""Merge this merge proposal."""
160
raise NotImplementedError(self.merge)
162
def can_be_merged(self):
163
"""Can this merge proposal be merged?
165
The answer to this can be no if e.g. it has conflics.
167
raise NotImplementedError(self.can_be_merged)
169
def get_merged_by(self):
170
"""If this proposal was merged, who merged it.
172
raise NotImplementedError(self.get_merged_by)
174
def get_merged_at(self):
175
"""If this proposal was merged, when it was merged.
177
raise NotImplementedError(self.get_merged_at)
179
def post_comment(self, body):
180
"""Post a comment on the merge proposal.
183
body: Body of the comment
185
raise NotImplementedError(self.post_comment)
188
class MergeProposalBuilder(object):
189
"""Merge proposal creator.
191
:param source_branch: Branch to propose for merging
192
:param target_branch: Target branch
195
hooks = ProposeMergeHooks()
197
def __init__(self, source_branch, target_branch):
198
self.source_branch = source_branch
199
self.target_branch = target_branch
201
def get_initial_body(self):
202
"""Get a body for the proposal for the user to modify.
204
:return: a str or None.
206
raise NotImplementedError(self.get_initial_body)
208
def get_infotext(self):
209
"""Determine the initial comment for the merge proposal.
211
raise NotImplementedError(self.get_infotext)
213
def create_proposal(self, description, reviewers=None, labels=None,
214
prerequisite_branch=None, commit_message=None,
215
work_in_progress=False, allow_collaboration=False):
216
"""Create a proposal to merge a branch for merging.
218
:param description: Description for the merge proposal
219
:param reviewers: Optional list of people to ask reviews from
220
:param labels: Labels to attach to the proposal
221
:param prerequisite_branch: Optional prerequisite branch
222
:param commit_message: Optional commit message
223
:param work_in_progress:
224
Whether this merge proposal is still a work-in-progress
225
:param allow_collaboration:
226
Whether to allow changes to the branch from the target branch
228
:return: A `MergeProposal` object
230
raise NotImplementedError(self.create_proposal)
233
class Hoster(object):
234
"""A hosting site manager.
237
# Does this hoster support arbitrary labels being attached to merge
239
supports_merge_proposal_labels = None
243
"""Name of this instance."""
244
return "%s at %s" % (type(self).__name__, self.base_url)
246
# Does this hoster support suggesting a commit message in the
248
supports_merge_proposal_commit_message = None
250
# The base_url that would be visible to users. I.e. https://github.com/
251
# rather than https://api.github.com/
254
# The syntax to use for formatting merge proposal descriptions.
255
# Common values: 'plain', 'markdown'
256
merge_proposal_description_format = None
258
# Does this hoster support the allow_collaboration flag?
259
supports_allow_collaboration = False
261
def publish_derived(self, new_branch, base_branch, name, project=None,
262
owner=None, revision_id=None, overwrite=False,
263
allow_lossy=True, tag_selector=None):
264
"""Publish a branch to the site, derived from base_branch.
266
:param base_branch: branch to derive the new branch from
267
:param new_branch: branch to publish
268
:return: resulting branch, public URL
269
:raise HosterLoginRequired: Action requires a hoster login, but none is
272
raise NotImplementedError(self.publish_derived)
274
def get_derived_branch(self, base_branch, name, project=None, owner=None):
275
"""Get a derived branch ('a fork').
277
raise NotImplementedError(self.get_derived_branch)
279
def get_push_url(self, branch):
280
"""Get the push URL for a branch."""
281
raise NotImplementedError(self.get_push_url)
283
def get_proposer(self, source_branch, target_branch):
284
"""Get a merge proposal creator.
286
:note: source_branch does not have to be hosted by the hoster.
288
:param source_branch: Source branch
289
:param target_branch: Target branch
290
:return: A MergeProposalBuilder object
292
raise NotImplementedError(self.get_proposer)
294
def iter_proposals(self, source_branch, target_branch, status='open'):
295
"""Get the merge proposals for a specified branch tuple.
297
:param source_branch: Source branch
298
:param target_branch: Target branch
299
:param status: Status of proposals to iterate over
300
:return: Iterate over MergeProposal object
302
raise NotImplementedError(self.iter_proposals)
304
def get_proposal_by_url(self, url):
305
"""Retrieve a branch proposal by URL.
307
:param url: Merge proposal URL.
308
:return: MergeProposal object
309
:raise UnsupportedHoster: Hoster does not support this URL
311
raise NotImplementedError(self.get_proposal_by_url)
313
def hosts(self, branch):
314
"""Return true if this hoster hosts given branch."""
315
raise NotImplementedError(self.hosts)
318
def probe_from_branch(cls, branch):
319
"""Create a Hoster object if this hoster knows about a branch."""
320
url = urlutils.strip_segment_parameters(branch.user_url)
321
return cls.probe_from_url(
322
url, possible_transports=[branch.control_transport])
325
def probe_from_url(cls, url, possible_hosters=None):
326
"""Create a Hoster object if this hoster knows about a URL."""
327
raise NotImplementedError(cls.probe_from_url)
329
def iter_my_proposals(self, status='open'):
330
"""Iterate over the proposals created by the currently logged in user.
332
:param status: Only yield proposals with this status
333
(one of: 'open', 'closed', 'merged', 'all')
334
:return: Iterator over MergeProposal objects
335
:raise HosterLoginRequired: Action requires a hoster login, but none is
338
raise NotImplementedError(self.iter_my_proposals)
340
def iter_my_forks(self):
341
"""Iterate over the currently logged in users' forks.
343
:return: Iterator over project_name
345
raise NotImplementedError(self.iter_my_forks)
347
def delete_project(self, name):
350
raise NotImplementedError(self.delete_project)
353
def iter_instances(cls):
354
"""Iterate instances.
356
:return: Hoster instances
358
raise NotImplementedError(cls.iter_instances)
360
def get_current_user(self):
361
"""Retrieve the name of the currently logged in user.
363
:return: Username or None if not logged in
365
raise NotImplementedError(self.get_current_user)
367
def get_user_url(self, user):
368
"""Rerieve the web URL for a user."""
369
raise NotImplementedError(self.get_user_url)
372
def determine_title(description):
373
"""Determine the title for a merge proposal based on full description."""
374
return description.splitlines()[0].split('.')[0]
377
def get_hoster(branch, possible_hosters=None):
378
"""Find the hoster for a branch.
380
:param branch: Branch to find hoster for
381
:param possible_hosters: Optional list of hosters to reuse
382
:raise UnsupportedHoster: if there is no hoster that supports `branch`
383
:return: A `Hoster` object
386
for hoster in possible_hosters:
387
if hoster.hosts(branch):
389
for name, hoster_cls in hosters.items():
391
hoster = hoster_cls.probe_from_branch(branch)
392
except UnsupportedHoster:
395
if possible_hosters is not None:
396
possible_hosters.append(hoster)
398
raise UnsupportedHoster(branch)
401
def iter_hoster_instances():
402
"""Iterate over all known hoster instances.
404
:return: Iterator over Hoster instances
406
for name, hoster_cls in hosters.items():
407
for instance in hoster_cls.iter_instances():
411
def get_proposal_by_url(url):
412
"""Get the proposal object associated with a URL.
414
:param url: URL of the proposal
415
:raise UnsupportedHoster: if there is no hoster that supports the URL
416
:return: A `MergeProposal` object
418
for instance in iter_hoster_instances():
420
return instance.get_proposal_by_url(url)
421
except UnsupportedHoster:
423
raise UnsupportedHoster(url)
426
hosters = registry.Registry()