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."""
19
from __future__ import absolute_import
29
class NoSuchProject(errors.BzrError):
31
_fmt = "Project does not exist: %(project)s."
33
def __init__(self, project):
34
errors.BzrError.__init__(self)
35
self.project = project
38
class MergeProposalExists(errors.BzrError):
40
_fmt = "A merge proposal already exists: %(url)s."
42
def __init__(self, url):
43
errors.BzrError.__init__(self)
47
class UnsupportedHoster(errors.BzrError):
49
_fmt = "No supported hoster for %(branch)s."
51
def __init__(self, branch):
52
errors.BzrError.__init__(self)
56
class ReopenFailed(errors.BzrError):
58
_fmt = "Reopening the merge proposal failed: %(error)s."
61
class ProposeMergeHooks(hooks.Hooks):
62
"""Hooks for proposing a merge on Launchpad."""
65
hooks.Hooks.__init__(self, __name__, "Proposer.hooks")
68
"Return the prerequisite branch for proposing as merge.", (3, 0))
70
'merge_proposal_body',
71
"Return an initial body for the merge proposal message.", (3, 0))
74
class LabelsUnsupported(errors.BzrError):
75
"""Labels not supported by this hoster."""
77
_fmt = "Labels are not supported by %(hoster)r."
79
def __init__(self, hoster):
80
errors.BzrError.__init__(self)
84
class PrerequisiteBranchUnsupported(errors.BzrError):
85
"""Prerequisite branch not supported by this hoster."""
87
def __init__(self, hoster):
88
errors.BzrError.__init__(self)
92
class HosterLoginRequired(errors.BzrError):
93
"""Action requires hoster login credentials."""
95
_fmt = "Action requires credentials for hosting site %(hoster)r."""
97
def __init__(self, hoster):
98
errors.BzrError.__init__(self)
102
class MergeProposal(object):
105
:ivar url: URL for the merge proposal
108
def __init__(self, url=None):
111
def get_description(self):
112
"""Get the description of the merge proposal."""
113
raise NotImplementedError(self.get_description)
115
def set_description(self, description):
116
"""Set the description of the merge proposal."""
117
raise NotImplementedError(self.set_description)
119
def get_commit_message(self):
120
"""Get the proposed commit message."""
121
raise NotImplementedError(self.get_commit_message)
123
def set_commit_message(self, commit_message):
124
"""Set the propose commit message."""
125
raise NotImplementedError(self.set_commit_message)
127
def get_source_branch_url(self):
128
"""Return the source branch."""
129
raise NotImplementedError(self.get_source_branch_url)
131
def get_target_branch_url(self):
132
"""Return the target branch."""
133
raise NotImplementedError(self.get_target_branch_url)
135
def get_source_project(self):
136
raise NotImplementedError(self.get_source_project)
138
def get_target_project(self):
139
raise NotImplementedError(self.get_target_project)
142
"""Close the merge proposal (without merging it)."""
143
raise NotImplementedError(self.close)
146
"""Check whether this merge proposal has been merged."""
147
raise NotImplementedError(self.is_merged)
150
"""Check whether this merge proposal is closed
152
This can either mean that it is merged or rejected.
154
raise NotImplementedError(self.is_closed)
156
def merge(self, commit_message=None):
157
"""Merge this merge proposal."""
158
raise NotImplementedError(self.merge)
160
def can_be_merged(self):
161
"""Can this merge proposal be merged?
163
The answer to this can be no if e.g. it has conflics.
165
raise NotImplementedError(self.can_be_merged)
167
def get_merged_by(self):
168
"""If this proposal was merged, who merged it.
170
raise NotImplementedError(self.get_merged_by)
172
def get_merged_at(self):
173
"""If this proposal was merged, when it was merged.
175
raise NotImplementedError(self.get_merged_at)
178
class MergeProposalBuilder(object):
179
"""Merge proposal creator.
181
:param source_branch: Branch to propose for merging
182
:param target_branch: Target branch
185
hooks = ProposeMergeHooks()
187
def __init__(self, source_branch, target_branch):
188
self.source_branch = source_branch
189
self.target_branch = target_branch
191
def get_initial_body(self):
192
"""Get a body for the proposal for the user to modify.
194
:return: a str or None.
196
raise NotImplementedError(self.get_initial_body)
198
def get_infotext(self):
199
"""Determine the initial comment for the merge proposal.
201
raise NotImplementedError(self.get_infotext)
203
def create_proposal(self, description, reviewers=None, labels=None,
204
prerequisite_branch=None, commit_message=None,
205
work_in_progress=False, allow_collaboration=False):
206
"""Create a proposal to merge a branch for merging.
208
:param description: Description for the merge proposal
209
:param reviewers: Optional list of people to ask reviews from
210
:param labels: Labels to attach to the proposal
211
:param prerequisite_branch: Optional prerequisite branch
212
:param commit_message: Optional commit message
213
:param work_in_progress:
214
Whether this merge proposal is still a work-in-progress
215
:param allow_collaboration:
216
Whether to allow changes to the branch from the target branch
218
:return: A `MergeProposal` object
220
raise NotImplementedError(self.create_proposal)
223
class Hoster(object):
224
"""A hosting site manager.
227
# Does this hoster support arbitrary labels being attached to merge
229
supports_merge_proposal_labels = None
231
# Does this hoster support suggesting a commit message in the
233
supports_merge_proposal_commit_message = None
235
# The base_url that would be visible to users. I.e. https://github.com/
236
# rather than https://api.github.com/
239
# The syntax to use for formatting merge proposal descriptions.
240
# Common values: 'plain', 'markdown'
241
merge_proposal_description_format = None
243
# Does this hoster support the allow_collaboration flag?
244
supports_allow_collaboration = False
246
def publish_derived(self, new_branch, base_branch, name, project=None,
247
owner=None, revision_id=None, overwrite=False,
248
allow_lossy=True, tag_selector=None):
249
"""Publish a branch to the site, derived from base_branch.
251
:param base_branch: branch to derive the new branch from
252
:param new_branch: branch to publish
253
:return: resulting branch, public URL
254
:raise HosterLoginRequired: Action requires a hoster login, but none is
257
raise NotImplementedError(self.publish)
259
def get_derived_branch(self, base_branch, name, project=None, owner=None):
260
"""Get a derived branch ('a fork').
262
raise NotImplementedError(self.get_derived_branch)
264
def get_push_url(self, branch):
265
"""Get the push URL for a branch."""
266
raise NotImplementedError(self.get_push_url)
268
def get_proposer(self, source_branch, target_branch):
269
"""Get a merge proposal creator.
271
:note: source_branch does not have to be hosted by the hoster.
273
:param source_branch: Source branch
274
:param target_branch: Target branch
275
:return: A MergeProposalBuilder object
277
raise NotImplementedError(self.get_proposer)
279
def iter_proposals(self, source_branch, target_branch, status='open'):
280
"""Get the merge proposals for a specified branch tuple.
282
:param source_branch: Source branch
283
:param target_branch: Target branch
284
:param status: Status of proposals to iterate over
285
:return: Iterate over MergeProposal object
287
raise NotImplementedError(self.iter_proposals)
289
def get_proposal_by_url(self, url):
290
"""Retrieve a branch proposal by URL.
292
:param url: Merge proposal URL.
293
:return: MergeProposal object
294
:raise UnsupportedHoster: Hoster does not support this URL
296
raise NotImplementedError(self.get_proposal_by_url)
298
def hosts(self, branch):
299
"""Return true if this hoster hosts given branch."""
300
raise NotImplementedError(self.hosts)
303
def probe_from_branch(cls, branch):
304
"""Create a Hoster object if this hoster knows about a branch."""
305
url = urlutils.strip_segment_parameters(branch.user_url)
306
return cls.probe_from_url(
307
url, possible_transports=[branch.control_transport])
310
def probe_from_url(cls, url, possible_hosters=None):
311
"""Create a Hoster object if this hoster knows about a URL."""
312
raise NotImplementedError(cls.probe_from_url)
314
def iter_my_proposals(self, status='open'):
315
"""Iterate over the proposals created by the currently logged in user.
317
:param status: Only yield proposals with this status
318
(one of: 'open', 'closed', 'merged', 'all')
319
:return: Iterator over MergeProposal objects
320
:raise HosterLoginRequired: Action requires a hoster login, but none is
323
raise NotImplementedError(self.iter_my_proposals)
325
def iter_my_forks(self):
326
"""Iterate over the currently logged in users' forks.
328
:return: Iterator over project_name
330
raise NotImplementedError(self.iter_my_forks)
332
def delete_project(self, name):
335
raise NotImplementedError(self.delete_project)
338
def iter_instances(cls):
339
"""Iterate instances.
341
:return: Hoster instances
343
raise NotImplementedError(cls.iter_instances)
346
def determine_title(description):
347
"""Determine the title for a merge proposal based on full description."""
348
return description.splitlines()[0].split('.')[0]
351
def get_hoster(branch, possible_hosters=None):
352
"""Find the hoster for a branch.
354
:param branch: Branch to find hoster for
355
:param possible_hosters: Optional list of hosters to reuse
356
:raise UnsupportedHoster: if there is no hoster that supports `branch`
357
:return: A `Hoster` object
360
for hoster in possible_hosters:
361
if hoster.hosts(branch):
363
for name, hoster_cls in hosters.items():
365
hoster = hoster_cls.probe_from_branch(branch)
366
except UnsupportedHoster:
369
if possible_hosters is not None:
370
possible_hosters.append(hoster)
372
raise UnsupportedHoster(branch)
375
def get_proposal_by_url(url):
376
"""Get the proposal object associated with a URL.
378
:param url: URL of the proposal
379
:raise UnsupportedHoster: if there is no hoster that supports the URL
380
:return: A `MergeProposal` object
382
for name, hoster_cls in hosters.items():
383
for instance in hoster_cls.iter_instances():
385
return instance.get_proposal_by_url(url)
386
except UnsupportedHoster:
388
raise UnsupportedHoster(url)
391
hosters = registry.Registry()