← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-mp-create into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-mp-create into lp:launchpad with lp:~cjwatson/launchpad/git-mp-basic-model as a prerequisite.

Commit message:
Make it possible to create Git merge proposals, currently only using the webservice.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1445017 in Launchpad itself: "Support for Launchpad Git merge proposals "
  https://bugs.launchpad.net/launchpad/+bug/1445017

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-mp-create/+merge/257138

Following on from https://code.launchpad.net/~cjwatson/launchpad/git-mp-basic-model/+merge/257122, this adds initial support for creating Git merge proposals, enough to be usable on the webservice and to bring up internal testing facilities.  While it should be self-consistent, actually trying to create these will give you something that the browser code can't render yet; the next couple of branches will improve things there.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-mp-create into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2015-04-21 16:53:45 +0000
+++ lib/lp/_schema_circular_imports.py	2015-04-22 16:18:53 +0000
@@ -563,6 +563,11 @@
 
 # IGitRef
 patch_reference_property(IGitRef, 'repository', IGitRepository)
+patch_plain_parameter_type(
+    IGitRef, 'createMergeProposal', 'merge_target', IGitRef)
+patch_plain_parameter_type(
+    IGitRef, 'createMergeProposal', 'merge_prerequisite', IGitRef)
+patch_entry_return_type(IGitRef, 'createMergeProposal', IBranchMergeProposal)
 
 # IGitRepository
 patch_collection_property(IGitRepository, 'branches', IGitRef)

=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2015-04-21 16:53:45 +0000
+++ lib/lp/code/browser/configure.zcml	2015-04-22 16:18:53 +0000
@@ -811,6 +811,9 @@
         path_expression="string:+ref/${name}"
         attribute_to_parent="repository"
         rootsite="code"/>
+    <browser:navigation
+        module="lp.code.browser.gitref"
+        classes="GitRefNavigation"/>
     <browser:pages
         for="lp.code.interfaces.gitref.IGitRef"
         class="lp.code.browser.gitref.GitRefView"

=== modified file 'lib/lp/code/browser/gitref.py'
--- lib/lp/code/browser/gitref.py	2015-03-19 17:04:22 +0000
+++ lib/lp/code/browser/gitref.py	2015-04-22 16:18:53 +0000
@@ -6,10 +6,33 @@
 __metaclass__ = type
 
 __all__ = [
+    'GitRefNavigation',
     'GitRefView',
     ]
 
-from lp.services.webapp import LaunchpadView
+from lp.code.interfaces.gitref import IGitRef
+from lp.services.webapp import (
+    LaunchpadView,
+    Navigation,
+    stepthrough,
+    )
+
+
+class GitRefNavigation(Navigation):
+
+    usedfor = IGitRef
+
+    @stepthrough("+merge")
+    def traverse_merge_proposal(self, id):
+        """Traverse to an `IBranchMergeProposal`."""
+        try:
+            id = int(id)
+        except ValueError:
+            # Not a number.
+            return None
+        for proposal in self.context.landing_targets:
+            if proposal.id == id:
+                return proposal
 
 
 class GitRefView(LaunchpadView):

=== modified file 'lib/lp/code/interfaces/gitnamespace.py'
--- lib/lp/code/interfaces/gitnamespace.py	2015-03-03 14:37:54 +0000
+++ lib/lp/code/interfaces/gitnamespace.py	2015-04-22 16:18:53 +0000
@@ -93,6 +93,9 @@
         "True iff this namespace permits automatically setting a default "
         "repository on push.")
 
+    supports_merge_proposals = Attribute(
+        "Does this namespace support merge proposals at all?")
+
     def getAllowedInformationTypes(who):
         """Get the information types that a repository in this namespace can
         have.
@@ -148,6 +151,14 @@
             already exists in the namespace.
         """
 
+    def areRepositoriesMergeable(other_namespace):
+        """Are repositories from other_namespace mergeable into this one?"""
+
+    collection = Attribute("An `IGitCollection` for this namespace.")
+
+    def assignKarma(person, action_name, date_created=None):
+        """Assign karma to the person on the appropriate target."""
+
 
 class IGitNamespaceSet(Interface):
     """Interface for getting Git repository namespaces."""

=== modified file 'lib/lp/code/interfaces/gitref.py'
--- lib/lp/code/interfaces/gitref.py	2015-04-22 16:18:53 +0000
+++ lib/lp/code/interfaces/gitref.py	2015-04-22 16:18:53 +0000
@@ -11,23 +11,34 @@
     ]
 
 from lazr.restful.declarations import (
+    call_with,
     export_as_webservice_entry,
+    export_factory_operation,
     exported,
-    )
-from lazr.restful.fields import ReferenceChoice
+    operation_for_version,
+    operation_parameters,
+    REQUEST_USER,
+    )
+from lazr.restful.fields import (
+    Reference,
+    ReferenceChoice,
+    )
 from zope.interface import (
     Attribute,
     Interface,
     )
 from zope.schema import (
+    Bool,
     Choice,
     Datetime,
+    List,
     Text,
     TextLine,
     )
 
 from lp import _
 from lp.code.enums import GitObjectType
+from lp.registry.interfaces.person import IPerson
 from lp.services.webapp.interfaces import ITableBatchNavigator
 
 
@@ -183,6 +194,68 @@
         "A collection of the merge proposals that are dependent on this "
         "reference.")
 
+    # XXX cjwatson 2015-04-16: Rename in line with landing_targets above
+    # once we have a better name.
+    def addLandingTarget(registrant, merge_target, merge_prerequisite=None,
+                         date_created=None, needs_review=None,
+                         description=None, review_requests=None,
+                         commit_message=None):
+        """Create a new BranchMergeProposal with this reference as the source.
+
+        Both the target and the prerequisite, if it is there, must be
+        references whose repositories have the same target as the source.
+
+        References in personal repositories cannot specify merge proposals.
+
+        :param registrant: The person who is adding the landing target.
+        :param merge_target: Must be another reference, and different to
+            self.
+        :param merge_prerequisite: Optional, but if it is not None it must
+            be another reference.
+        :param date_created: Used to specify the date_created value of the
+            merge request.
+        :param needs_review: Used to specify the proposal is ready for
+            review right now.
+        :param description: A description of the bugs fixed, features added,
+            or refactorings.
+        :param review_requests: An optional list of (`Person`, review_type).
+        """
+
+    @operation_parameters(
+        # merge_target and merge_prerequisite are actually IGitRef, patched
+        # in _schema_circular_imports.
+        merge_target=Reference(schema=Interface),
+        merge_prerequisite=Reference(schema=Interface),
+        needs_review=Bool(
+            title=_("Needs review"),
+            description=_(
+                "If True, the proposal needs review.  Otherwise, it will be "
+                "work in progress.")),
+        initial_comment=Text(
+            title=_("Initial comment"),
+            description=_("Registrant's initial description of proposal.")),
+        commit_message=Text(
+            title=_("Commit message"),
+            description=_("Message to use when committing this merge.")),
+        reviewers=List(value_type=Reference(schema=IPerson)),
+        review_types=List(value_type=TextLine()))
+    @call_with(registrant=REQUEST_USER)
+    # Really IBranchMergeProposal, patched in _schema_circular_imports.py.
+    @export_factory_operation(Interface, [])
+    @operation_for_version("devel")
+    def createMergeProposal(registrant, merge_target, merge_prerequisite=None,
+                            needs_review=None, initial_comment=None,
+                            commit_message=None, reviewers=None,
+                            review_types=None):
+        """Create a new BranchMergeProposal with this reference as the source.
+
+        Both the merge_target and the merge_prerequisite, if it is there,
+        must be references whose repositories have the same target as the
+        source.
+
+        References in personal repositories cannot specify merge proposals.
+        """
+
 
 class IGitRefBatchNavigator(ITableBatchNavigator):
     pass

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2015-04-21 23:25:19 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2015-04-22 16:18:53 +0000
@@ -446,6 +446,9 @@
         and their subscriptions.
         """
 
+    def isRepositoryMergeable(other):
+        """Is the other repository mergeable into this one (or vice versa)?"""
+
 
 class IGitRepositoryModerateAttributes(Interface):
     """IGitRepository attributes that can be edited by more than one community.

=== modified file 'lib/lp/code/model/gitnamespace.py'
--- lib/lp/code/model/gitnamespace.py	2015-04-21 17:36:21 +0000
+++ lib/lp/code/model/gitnamespace.py	2015-04-22 16:18:53 +0000
@@ -16,7 +16,10 @@
 from zope.component import getUtility
 from zope.event import notify
 from zope.interface import implements
-from zope.security.proxy import removeSecurityProxy
+from zope.security.proxy import (
+    isinstance as zope_isinstance,
+    removeSecurityProxy,
+    )
 
 from lp.app.enums import (
     FREE_INFORMATION_TYPES,
@@ -36,6 +39,7 @@
     GitRepositoryCreatorNotOwner,
     GitRepositoryExists,
     )
+from lp.code.interfaces.gitcollection import IAllGitRepositories
 from lp.code.interfaces.gitnamespace import (
     IGitNamespace,
     IGitNamespacePolicy,
@@ -214,6 +218,7 @@
 
     has_defaults = False
     allow_push_to_set_default = False
+    supports_merge_proposals = False
 
     def __init__(self, person):
         self.owner = person
@@ -262,6 +267,21 @@
         else:
             return InformationType.PUBLIC
 
+    def areRepositoriesMergeable(self, other_namespace):
+        """See `IGitNamespacePolicy`."""
+        return False
+
+    @property
+    def collection(self):
+        """See `IGitNamespacePolicy`."""
+        return getUtility(IAllGitRepositories).ownedBy(
+            self.person).isPersonal()
+
+    def assignKarma(self, person, action_name, date_created=None):
+        """See `IGitNamespacePolicy`."""
+        # Does nothing.  No karma for personal repositories.
+        return None
+
 
 class ProjectGitNamespace(_BaseGitNamespace):
     """A namespace for project repositories.
@@ -274,6 +294,7 @@
 
     has_defaults = True
     allow_push_to_set_default = True
+    supports_merge_proposals = True
 
     def __init__(self, person, project):
         self.owner = person
@@ -325,6 +346,27 @@
             return None
         return default_type
 
+    def areRepositoriesMergeable(self, other_namespace):
+        """See `IGitNamespacePolicy`."""
+        # Repositories are mergeable into a project repository if the
+        # project is the same.
+        # XXX cjwatson 2015-04-18: Allow merging from a package repository
+        # if any (active?) series is linked to this project.
+        if zope_isinstance(other_namespace, ProjectGitNamespace):
+            return self.target == other_namespace.target
+        else:
+            return False
+
+    @property
+    def collection(self):
+        """See `IGitNamespacePolicy`."""
+        return getUtility(IAllGitRepositories).inProject(self.project)
+
+    def assignKarma(self, person, action_name, date_created=None):
+        """See `IGitNamespacePolicy`."""
+        return person.assignKarma(
+            action_name, product=self.project, datecreated=date_created)
+
 
 class PackageGitNamespace(_BaseGitNamespace):
     """A namespace for distribution source package repositories.
@@ -337,6 +379,7 @@
 
     has_defaults = True
     allow_push_to_set_default = False
+    supports_merge_proposals = True
 
     def __init__(self, person, distro_source_package):
         self.owner = person
@@ -376,6 +419,30 @@
         """See `IGitNamespace`."""
         return InformationType.PUBLIC
 
+    def areRepositoriesMergeable(self, other_namespace):
+        """See `IGitNamespacePolicy`."""
+        # Repositories are mergeable into a package repository if the
+        # package is the same.
+        # XXX cjwatson 2015-04-18: Allow merging from a project repository
+        # if any (active?) series links this package to that project.
+        if zope_isinstance(other_namespace, PackageGitNamespace):
+            return self.target == other_namespace.target
+        else:
+            return False
+
+    @property
+    def collection(self):
+        """See `IGitNamespacePolicy`."""
+        return getUtility(IAllGitRepositories).inDistributionSourcePackage(
+            self.distro_source_package)
+
+    def assignKarma(self, person, action_name, date_created=None):
+        """See `IGitNamespacePolicy`."""
+        dsp = self.distro_source_package
+        return person.assignKarma(
+            action_name, distribution=dsp.distribution,
+            sourcepackagename=dsp.sourcepackagename, datecreated=date_created)
+
     def __eq__(self, other):
         """See `IGitNamespace`."""
         # We may have different DSP objects that are functionally the same.

=== modified file 'lib/lp/code/model/gitref.py'
--- lib/lp/code/model/gitref.py	2015-04-22 16:18:53 +0000
+++ lib/lp/code/model/gitref.py	2015-04-22 16:18:53 +0000
@@ -18,15 +18,32 @@
     Store,
     Unicode,
     )
+from zope.event import notify
 from zope.interface import implements
 
 from lp.app.errors import NotFoundError
-from lp.code.enums import GitObjectType
+from lp.code.enums import (
+    BranchMergeProposalStatus,
+    GitObjectType,
+    )
+from lp.code.errors import (
+    BranchMergeProposalExists,
+    InvalidBranchMergeProposal,
+    )
+from lp.code.event.branchmergeproposal import (
+    BranchMergeProposalNeedsReviewEvent,
+    NewBranchMergeProposalEvent,
+    )
+from lp.code.interfaces.branch import WrongNumberOfReviewTypeArguments
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES,
     )
 from lp.code.interfaces.gitref import IGitRef
-from lp.code.model.branchmergeproposal import BranchMergeProposal
+from lp.code.model.branchmergeproposal import (
+    BranchMergeProposal,
+    BranchMergeProposalGetter,
+    )
+from lp.services.database.constants import UTC_NOW
 from lp.services.database.enumcol import EnumCol
 from lp.services.database.interfaces import IStore
 from lp.services.database.stormbase import StormBase
@@ -198,6 +215,103 @@
     def commit_message_first_line(self):
         return self.commit_message.split("\n", 1)[0]
 
+    def addLandingTarget(self, registrant, merge_target,
+                         merge_prerequisite=None, whiteboard=None,
+                         date_created=None, needs_review=None,
+                         description=None, review_requests=None,
+                         commit_message=None):
+        """See `IGitRef`."""
+        if not self.namespace.supports_merge_proposals:
+            raise InvalidBranchMergeProposal(
+                "%s repositories do not support merge proposals." %
+                self.namespace.name)
+        if self == merge_target:
+            raise InvalidBranchMergeProposal(
+                "Source and target references must be different.")
+        if not merge_target.repository.isRepositoryMergeable(self.repository):
+            raise InvalidBranchMergeProposal(
+                "%s is not mergeable into %s" % (
+                    self.identity, merge_target.identity))
+        if merge_prerequisite is not None:
+            if not merge_target.repository.isRepositoryMergeable(
+                    merge_prerequisite.repository):
+                raise InvalidBranchMergeProposal(
+                    "%s is not mergeable into %s" % (
+                        merge_prerequisite.identity, self.identity))
+            if self == merge_prerequisite:
+                raise InvalidBranchMergeProposal(
+                    "Source and prerequisite references must be different.")
+            if merge_target == merge_prerequisite:
+                raise InvalidBranchMergeProposal(
+                    "Target and prerequisite references must be different.")
+
+        getter = BranchMergeProposalGetter
+        for existing_proposal in getter.activeProposalsForBranches(
+                self, merge_target):
+            raise BranchMergeProposalExists(existing_proposal)
+
+        if date_created is None:
+            date_created = UTC_NOW
+
+        if needs_review:
+            queue_status = BranchMergeProposalStatus.NEEDS_REVIEW
+            date_review_requested = date_created
+        else:
+            queue_status = BranchMergeProposalStatus.WORK_IN_PROGRESS
+            date_review_requested = None
+
+        if review_requests is None:
+            review_requests = []
+
+        # If no reviewer is specified, use the default for the branch.
+        if len(review_requests) == 0:
+            review_requests.append((merge_target.code_reviewer, None))
+
+        kwargs = {}
+        for prefix, obj in (
+                ("source", self),
+                ("target", merge_target),
+                ("prerequisite", merge_prerequisite)):
+            if obj is not None:
+                kwargs["%s_git_repository" % prefix] = obj.repository
+                kwargs["%s_git_path" % prefix] = obj.path
+                kwargs["%s_git_commit_sha1" % prefix] = obj.commit_sha1
+
+        bmp = BranchMergeProposal(
+            registrant=registrant, whiteboard=whiteboard,
+            date_created=date_created,
+            date_review_requested=date_review_requested,
+            queue_status=queue_status, commit_message=commit_message,
+            description=description, **kwargs)
+
+        for reviewer, review_type in review_requests:
+            bmp.nominateReviewer(
+                reviewer, registrant, review_type, _notify_listeners=False)
+
+        notify(NewBranchMergeProposalEvent(bmp))
+        if needs_review:
+            notify(BranchMergeProposalNeedsReviewEvent(bmp))
+
+        return bmp
+
+    def createMergeProposal(self, registrant, merge_target,
+                            merge_prerequisite=None, needs_review=True,
+                            initial_comment=None, commit_message=None,
+                            reviewers=None, review_types=None):
+        """See `IGitRef`."""
+        if reviewers is None:
+            reviewers = []
+        if review_types is None:
+            review_types = []
+        if len(reviewers) != len(review_types):
+            raise WrongNumberOfReviewTypeArguments(
+                'reviewers and review_types must be equal length.')
+        review_requests = zip(reviewers, review_types)
+        return self.addLandingTarget(
+            registrant, merge_target, merge_prerequisite,
+            needs_review=needs_review, description=initial_comment,
+            commit_message=commit_message, review_requests=review_requests)
+
 
 class GitRefFrozen(GitRefMixin):
     """A frozen Git reference.

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2015-04-21 17:36:21 +0000
+++ lib/lp/code/model/gitrepository.py	2015-04-22 16:18:53 +0000
@@ -737,6 +737,10 @@
             recipients.add(subscription.person, subscription, rationale)
         return recipients
 
+    def isRepositoryMergeable(self, other):
+        """See `IGitRepository`."""
+        return self.namespace.areRepositoriesMergeable(other.namespace)
+
     def destroySelf(self):
         raise NotImplementedError
 

=== modified file 'lib/lp/code/subscribers/karma.py'
--- lib/lp/code/subscribers/karma.py	2015-04-21 16:53:45 +0000
+++ lib/lp/code/subscribers/karma.py	2015-04-22 16:18:53 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Assign karma for code domain activity."""
@@ -17,15 +17,21 @@
 @block_implicit_flushes
 def branch_merge_proposed(proposal, event):
     """Assign karma to the user who proposed the merge."""
-    proposal.source_branch.target.assignKarma(
-        proposal.registrant, 'branchmergeproposed')
+    if proposal.source_git_repository is not None:
+        target = proposal.source_git_repository.namespace
+    else:
+        target = proposal.source_branch.target
+    target.assignKarma(proposal.registrant, 'branchmergeproposed')
 
 
 @block_implicit_flushes
 def code_review_comment_added(code_review_comment, event):
     """Assign karma to the user who commented on the review."""
     proposal = code_review_comment.branch_merge_proposal
-    target = proposal.source_branch.target
+    if proposal.source_git_repository is not None:
+        target = proposal.source_git_repository.namespace
+    else:
+        target = proposal.source_branch.target
     # If the user is commenting on their own proposal, then they don't
     # count as a reviewer for that proposal.
     user = code_review_comment.message.owner
@@ -39,7 +45,10 @@
 @block_implicit_flushes
 def branch_merge_status_changed(proposal, event):
     """Assign karma to the user who approved the merge."""
-    target = proposal.source_branch.target
+    if proposal.source_git_repository is not None:
+        target = proposal.source_git_repository.namespace
+    else:
+        target = proposal.source_branch.target
     user = IPerson(event.user)
 
     in_progress_states = (

=== modified file 'lib/lp/code/templates/branchmergeproposal-index.pt'
--- lib/lp/code/templates/branchmergeproposal-index.pt	2014-05-21 01:16:13 +0000
+++ lib/lp/code/templates/branchmergeproposal-index.pt	2015-04-22 16:18:53 +0000
@@ -158,16 +158,18 @@
     <div id="source-revisions"
          tal:condition="not: context/queue_status/enumvalue:MERGED">
 
-      <tal:history-available condition="context/source_branch/revision_count"
-                             define="branch context/source_branch;
-                                     revisions view/unlanded_revisions">
-        <h2>Unmerged revisions</h2>
-        <metal:landing-target use-macro="branch/@@+macros/branch-revisions"/>
-      </tal:history-available>
-      <tal:remote-branch condition="context/source_branch/branch_type/enumvalue:REMOTE">
-        <h2>Unmerged revisions</h2>
-        <p>Recent revisions are not available due to the source branch being remote.</p>
-      </tal:remote-branch>
+      <tal:bzr-revisions condition="context/source_branch">
+        <tal:history-available condition="context/source_branch/revision_count"
+                               define="branch context/source_branch;
+                                       revisions view/unlanded_revisions">
+          <h2>Unmerged revisions</h2>
+          <metal:landing-target use-macro="branch/@@+macros/branch-revisions"/>
+        </tal:history-available>
+        <tal:remote-branch condition="context/source_branch/branch_type/enumvalue:REMOTE">
+          <h2>Unmerged revisions</h2>
+          <p>Recent revisions are not available due to the source branch being remote.</p>
+        </tal:remote-branch>
+      </tal:bzr-revisions>
     </div>
   </div>
 

=== modified file 'lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt'
--- lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt	2015-04-21 16:53:45 +0000
+++ lib/lp/code/templates/branchmergeproposal-pagelet-summary.pt	2015-04-22 16:18:53 +0000
@@ -121,7 +121,11 @@
         <div tal:replace="structure context/@@++diff-stats" />
       </td>
     </tr>
-    <tr id="summary-row-merge-instruction">
+    <tal:comment condition="nothing">
+      <!-- XXX cjwatson 2015-04-18: Add merge instructions for Git. -->
+    </tal:comment>
+    <tr id="summary-row-merge-instruction"
+        tal:condition="context/source_branch">
       <th>To merge this branch:</th>
       <td>bzr merge <span class="branch-url" tal:content="context/source_branch/bzr_identity" /></td>
     </tr>

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2015-04-22 16:18:53 +0000
+++ lib/lp/security.py	2015-04-22 16:18:53 +0000
@@ -2266,6 +2266,15 @@
         super(ViewGitRef, self).__init__(obj, obj.repository)
 
 
+class EditGitRef(DelegatedAuthorization):
+    """Anyone who can edit a Git repository can edit references within it."""
+    permission = 'launchpad.Edit'
+    usedfor = IGitRef
+
+    def __init__(self, obj):
+        super(EditGitRef, self).__init__(obj, obj.repository)
+
+
 class AdminDistroSeriesTranslations(AuthorizationBase):
     permission = 'launchpad.TranslationsAdmin'
     usedfor = IDistroSeries

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2015-04-21 16:53:45 +0000
+++ lib/lp/testing/factory.py	2015-04-22 16:18:53 +0000
@@ -1477,6 +1477,68 @@
 
         return proposal
 
+    def makeBranchMergeProposalForGit(self, target_ref=None, registrant=None,
+                                      set_state=None, prerequisite_ref=None,
+                                      target=_DEFAULT, initial_comment=None,
+                                      source_ref=None, date_created=None,
+                                      description=None, reviewer=None,
+                                      merged_revision_id=None):
+        """Create a proposal to merge based on anonymous branches."""
+        if target is not _DEFAULT:
+            pass
+        elif target_ref is not None:
+            target = target_ref.target
+        elif source_ref is not None:
+            target = source_ref.target
+        elif prerequisite_ref is not None:
+            target = prerequisite_ref.target
+        else:
+            # Create a reference for a repository on the target, and use
+            # that target.
+            [target_ref] = self.makeGitRefs(target=target)
+            target = target_ref.target
+
+        # Fall back to initial_comment for description.
+        if description is None:
+            description = initial_comment
+
+        if target_ref is None:
+            [target_ref] = self.makeGitRefs(target=target)
+        if source_ref is None:
+            [source_ref] = self.makeGitRefs(target=target)
+        if registrant is None:
+            registrant = self.makePerson()
+        review_requests = []
+        if reviewer is not None:
+            review_requests.append((reviewer, None))
+        proposal = source_ref.addLandingTarget(
+            registrant, target_ref, review_requests=review_requests,
+            merge_prerequisite=prerequisite_ref, description=description,
+            date_created=date_created)
+
+        unsafe_proposal = removeSecurityProxy(proposal)
+        unsafe_proposal.merged_revision_id = merged_revision_id
+        if (set_state is None or
+            set_state == BranchMergeProposalStatus.WORK_IN_PROGRESS):
+            # The initial state is work in progress, so do nothing.
+            pass
+        elif set_state == BranchMergeProposalStatus.NEEDS_REVIEW:
+            unsafe_proposal.requestReview()
+        elif set_state == BranchMergeProposalStatus.CODE_APPROVED:
+            unsafe_proposal.approveBranch(
+                proposal.merge_target.owner, 'some_revision')
+        elif set_state == BranchMergeProposalStatus.REJECTED:
+            unsafe_proposal.rejectBranch(
+                proposal.merge_target.owner, 'some_revision')
+        elif set_state == BranchMergeProposalStatus.MERGED:
+            unsafe_proposal.markAsMerged()
+        elif set_state == BranchMergeProposalStatus.SUPERSEDED:
+            unsafe_proposal.resubmit(proposal.registrant)
+        else:
+            raise AssertionError('Unknown status: %s' % set_state)
+
+        return proposal
+
     def makeBranchSubscription(self, branch=None, person=None,
                                subscribed_by=None):
         """Create a BranchSubscription."""
@@ -1673,10 +1735,10 @@
             BranchSubscriptionNotificationLevel.NOEMAIL, None,
             CodeReviewNotificationLevel.NOEMAIL, subscribed_by)
 
-    def makeGitRefs(self, repository=None, paths=None):
+    def makeGitRefs(self, repository=None, paths=None, **repository_kwargs):
         """Create and return a list of new, arbitrary GitRefs."""
         if repository is None:
-            repository = self.makeGitRepository()
+            repository = self.makeGitRepository(**repository_kwargs)
         if paths is None:
             paths = [self.getUniqueString('refs/heads/path').decode('utf-8')]
         refs_info = {


Follow ups