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."""
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, existing_proposal=None):
43
errors.BzrError.__init__(self)
45
self.existing_proposal = existing_proposal
48
class UnsupportedHoster(errors.BzrError):
50
_fmt = "No supported hoster for %(branch)s."
52
def __init__(self, branch):
53
errors.BzrError.__init__(self)
57
class ReopenFailed(errors.BzrError):
59
_fmt = "Reopening the merge proposal failed: %(error)s."
62
class ProposeMergeHooks(hooks.Hooks):
63
"""Hooks for proposing a merge on Launchpad."""
66
hooks.Hooks.__init__(self, __name__, "Proposer.hooks")
69
"Return the prerequisite branch for proposing as merge.", (3, 0))
71
'merge_proposal_body',
72
"Return an initial body for the merge proposal message.", (3, 0))
75
class LabelsUnsupported(errors.BzrError):
76
"""Labels not supported by this hoster."""
78
_fmt = "Labels are not supported by %(hoster)r."
80
def __init__(self, hoster):
81
errors.BzrError.__init__(self)
85
class PrerequisiteBranchUnsupported(errors.BzrError):
86
"""Prerequisite branch not supported by this hoster."""
88
def __init__(self, hoster):
89
errors.BzrError.__init__(self)
93
class HosterLoginRequired(errors.BzrError):
94
"""Action requires hoster login credentials."""
96
_fmt = "Action requires credentials for hosting site %(hoster)r."""
98
def __init__(self, hoster):
99
errors.BzrError.__init__(self)
103
class SourceNotDerivedFromTarget(errors.BzrError):
104
"""Source branch is not derived from target branch."""
106
_fmt = ("Source %(source_branch)r not derived from "
107
"target %(target_branch)r.")
109
def __init__(self, source_branch, target_branch):
110
errors.BzrError.__init__(
111
self, source_branch=source_branch,
112
target_branch=target_branch)
115
class MergeProposal(object):
118
:ivar url: URL for the merge proposal
121
def __init__(self, url=None):
124
def get_description(self):
125
"""Get the description of the merge proposal."""
126
raise NotImplementedError(self.get_description)
128
def set_description(self, description):
129
"""Set the description of the merge proposal."""
130
raise NotImplementedError(self.set_description)
132
def get_commit_message(self):
133
"""Get the proposed commit message."""
134
raise NotImplementedError(self.get_commit_message)
136
def set_commit_message(self, commit_message):
137
"""Set the propose commit message."""
138
raise NotImplementedError(self.set_commit_message)
140
def get_source_branch_url(self):
141
"""Return the source branch."""
142
raise NotImplementedError(self.get_source_branch_url)
144
def get_source_revision(self):
145
"""Return the latest revision for the source branch."""
146
raise NotImplementedError(self.get_source_revision)
148
def get_target_branch_url(self):
149
"""Return the target branch."""
150
raise NotImplementedError(self.get_target_branch_url)
152
def get_source_project(self):
153
raise NotImplementedError(self.get_source_project)
155
def get_target_project(self):
156
raise NotImplementedError(self.get_target_project)
159
"""Close the merge proposal (without merging it)."""
160
raise NotImplementedError(self.close)
163
"""Check whether this merge proposal has been merged."""
164
raise NotImplementedError(self.is_merged)
167
"""Check whether this merge proposal is closed
169
This can either mean that it is merged or rejected.
171
raise NotImplementedError(self.is_closed)
173
def merge(self, commit_message=None):
174
"""Merge this merge proposal."""
175
raise NotImplementedError(self.merge)
177
def can_be_merged(self):
178
"""Can this merge proposal be merged?
180
The answer to this can be no if e.g. it has conflics.
182
raise NotImplementedError(self.can_be_merged)
184
def get_merged_by(self):
185
"""If this proposal was merged, who merged it.
187
raise NotImplementedError(self.get_merged_by)
189
def get_merged_at(self):
190
"""If this proposal was merged, when it was merged.
192
raise NotImplementedError(self.get_merged_at)
194
def post_comment(self, body):
195
"""Post a comment on the merge proposal.
198
body: Body of the comment
200
raise NotImplementedError(self.post_comment)
203
class MergeProposalBuilder(object):
204
"""Merge proposal creator.
206
:param source_branch: Branch to propose for merging
207
:param target_branch: Target branch
210
hooks = ProposeMergeHooks()
212
def __init__(self, source_branch, target_branch):
213
self.source_branch = source_branch
214
self.target_branch = target_branch
216
def get_initial_body(self):
217
"""Get a body for the proposal for the user to modify.
219
:return: a str or None.
221
raise NotImplementedError(self.get_initial_body)
223
def get_infotext(self):
224
"""Determine the initial comment for the merge proposal.
226
raise NotImplementedError(self.get_infotext)
228
def create_proposal(self, description, reviewers=None, labels=None,
229
prerequisite_branch=None, commit_message=None,
230
work_in_progress=False, allow_collaboration=False):
231
"""Create a proposal to merge a branch for merging.
233
:param description: Description for the merge proposal
234
:param reviewers: Optional list of people to ask reviews from
235
:param labels: Labels to attach to the proposal
236
:param prerequisite_branch: Optional prerequisite branch
237
:param commit_message: Optional commit message
238
:param work_in_progress:
239
Whether this merge proposal is still a work-in-progress
240
:param allow_collaboration:
241
Whether to allow changes to the branch from the target branch
243
:return: A `MergeProposal` object
245
raise NotImplementedError(self.create_proposal)
248
class Hoster(object):
249
"""A hosting site manager.
252
# Does this hoster support arbitrary labels being attached to merge
254
supports_merge_proposal_labels = None
258
"""Name of this instance."""
259
return "%s at %s" % (type(self).__name__, self.base_url)
261
# Does this hoster support suggesting a commit message in the
263
supports_merge_proposal_commit_message = None
265
# The base_url that would be visible to users. I.e. https://github.com/
266
# rather than https://api.github.com/
269
# The syntax to use for formatting merge proposal descriptions.
270
# Common values: 'plain', 'markdown'
271
merge_proposal_description_format = None
273
# Does this hoster support the allow_collaboration flag?
274
supports_allow_collaboration = False
276
def publish_derived(self, new_branch, base_branch, name, project=None,
277
owner=None, revision_id=None, overwrite=False,
278
allow_lossy=True, tag_selector=None):
279
"""Publish a branch to the site, derived from base_branch.
281
:param base_branch: branch to derive the new branch from
282
:param new_branch: branch to publish
283
:return: resulting branch, public URL
284
:raise HosterLoginRequired: Action requires a hoster login, but none is
287
raise NotImplementedError(self.publish_derived)
289
def get_derived_branch(self, base_branch, name, project=None, owner=None):
290
"""Get a derived branch ('a fork').
292
raise NotImplementedError(self.get_derived_branch)
294
def get_push_url(self, branch):
295
"""Get the push URL for a branch."""
296
raise NotImplementedError(self.get_push_url)
298
def get_proposer(self, source_branch, target_branch):
299
"""Get a merge proposal creator.
301
:note: source_branch does not have to be hosted by the hoster.
303
:param source_branch: Source branch
304
:param target_branch: Target branch
305
:return: A MergeProposalBuilder object
307
raise NotImplementedError(self.get_proposer)
309
def iter_proposals(self, source_branch, target_branch, status='open'):
310
"""Get the merge proposals for a specified branch tuple.
312
:param source_branch: Source branch
313
:param target_branch: Target branch
314
:param status: Status of proposals to iterate over
315
:return: Iterate over MergeProposal object
317
raise NotImplementedError(self.iter_proposals)
319
def get_proposal_by_url(self, url):
320
"""Retrieve a branch proposal by URL.
322
:param url: Merge proposal URL.
323
:return: MergeProposal object
324
:raise UnsupportedHoster: Hoster does not support this URL
326
raise NotImplementedError(self.get_proposal_by_url)
328
def hosts(self, branch):
329
"""Return true if this hoster hosts given branch."""
330
raise NotImplementedError(self.hosts)
333
def probe_from_branch(cls, branch):
334
"""Create a Hoster object if this hoster knows about a branch."""
335
url = urlutils.strip_segment_parameters(branch.user_url)
336
return cls.probe_from_url(
337
url, possible_transports=[branch.control_transport])
340
def probe_from_url(cls, url, possible_hosters=None):
341
"""Create a Hoster object if this hoster knows about a URL."""
342
raise NotImplementedError(cls.probe_from_url)
344
def iter_my_proposals(self, status='open', author=None):
345
"""Iterate over the proposals created by the currently logged in user.
347
:param status: Only yield proposals with this status
348
(one of: 'open', 'closed', 'merged', 'all')
349
:param author: Name of author to query (defaults to current user)
350
:return: Iterator over MergeProposal objects
351
:raise HosterLoginRequired: Action requires a hoster login, but none is
354
raise NotImplementedError(self.iter_my_proposals)
356
def iter_my_forks(self, owner=None):
357
"""Iterate over the currently logged in users' forks.
359
:param owner: Name of owner to query (defaults to current user)
360
:return: Iterator over project_name
362
raise NotImplementedError(self.iter_my_forks)
364
def delete_project(self, name):
367
raise NotImplementedError(self.delete_project)
370
def iter_instances(cls):
371
"""Iterate instances.
373
:return: Hoster instances
375
raise NotImplementedError(cls.iter_instances)
377
def get_current_user(self):
378
"""Retrieve the name of the currently logged in user.
380
:return: Username or None if not logged in
382
raise NotImplementedError(self.get_current_user)
384
def get_user_url(self, user):
385
"""Rerieve the web URL for a user."""
386
raise NotImplementedError(self.get_user_url)
389
def determine_title(description):
390
"""Determine the title for a merge proposal based on full description."""
391
firstline = description.splitlines()[0]
393
i = firstline.index('. ')
395
return firstline.rstrip('.')
400
def get_hoster(branch, possible_hosters=None):
401
"""Find the hoster for a branch.
403
:param branch: Branch to find hoster for
404
:param possible_hosters: Optional list of hosters to reuse
405
:raise UnsupportedHoster: if there is no hoster that supports `branch`
406
:return: A `Hoster` object
409
for hoster in possible_hosters:
410
if hoster.hosts(branch):
412
for name, hoster_cls in hosters.items():
414
hoster = hoster_cls.probe_from_branch(branch)
415
except UnsupportedHoster:
418
if possible_hosters is not None:
419
possible_hosters.append(hoster)
421
raise UnsupportedHoster(branch)
424
def iter_hoster_instances(hoster=None):
425
"""Iterate over all known hoster instances.
427
:return: Iterator over Hoster instances
430
hoster_clses = [hoster_cls for name, hoster_cls in hosters.items()]
432
hoster_clses = [hoster]
433
for hoster_cls in hoster_clses:
434
for instance in hoster_cls.iter_instances():
438
def get_proposal_by_url(url):
439
"""Get the proposal object associated with a URL.
441
:param url: URL of the proposal
442
:raise UnsupportedHoster: if there is no hoster that supports the URL
443
:return: A `MergeProposal` object
445
for instance in iter_hoster_instances():
447
return instance.get_proposal_by_url(url)
448
except UnsupportedHoster:
450
raise UnsupportedHoster(url)
453
hosters = registry.Registry()