← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-permissions-webservice-repository into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-permissions-webservice-repository into lp:launchpad with lp:~cjwatson/launchpad/git-permissions-webservice-ref as a prerequisite.

Commit message:
Allow getting and setting rules and grants for a Git repository over the webservice.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1517559 in Launchpad itself: "git fine-grained permissions"
  https://bugs.launchpad.net/launchpad/+bug/1517559

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-permissions-webservice-repository/+merge/355610
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-permissions-webservice-repository into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py	2018-09-25 16:51:42 +0000
+++ lib/lp/_schema_circular_imports.py	2018-09-25 16:51:42 +0000
@@ -68,7 +68,10 @@
 from lp.code.interfaces.diff import IPreviewDiff
 from lp.code.interfaces.gitref import IGitRef
 from lp.code.interfaces.gitrepository import IGitRepository
-from lp.code.interfaces.gitrule import IGitNascentRuleGrant
+from lp.code.interfaces.gitrule import (
+    IGitNascentRule,
+    IGitNascentRuleGrant,
+    )
 from lp.code.interfaces.gitsubscription import IGitSubscription
 from lp.code.interfaces.hasbranches import (
     IHasBranches,
@@ -527,6 +530,8 @@
     IGitRepository, 'dependent_landings', IBranchMergeProposal)
 patch_collection_return_type(
     IGitRepository, 'getMergeProposals', IBranchMergeProposal)
+patch_list_parameter_type(
+    IGitRepository, 'setRules', 'rules', InlineObject(schema=IGitNascentRule))
 
 # ILiveFSFile
 patch_reference_property(ILiveFSFile, 'livefsbuild', ILiveFSBuild)

=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml	2018-09-25 16:51:42 +0000
+++ lib/lp/code/configure.zcml	2018-09-25 16:51:42 +0000
@@ -942,6 +942,7 @@
         permission="launchpad.Edit"
         interface="lp.code.interfaces.gitrule.IGitRuleEdit"
         set_schema="lp.code.interfaces.gitrule.IGitRuleEditableAttributes" />
+    <allow interface="lazr.restful.interfaces.IJSONPublishable" />
   </class>
   <subscriber
       for="lp.code.interfaces.gitrule.IGitRule zope.lifecycleevent.interfaces.IObjectModifiedEvent"
@@ -960,9 +961,13 @@
   <subscriber
       for="lp.code.interfaces.gitrule.IGitRuleGrant zope.lifecycleevent.interfaces.IObjectModifiedEvent"
       handler="lp.code.model.gitrule.git_rule_grant_modified"/>
+  <class class="lp.code.model.gitrule.GitNascentRule">
+    <allow interface="lp.code.interfaces.gitrule.IGitNascentRule" />
+  </class>
   <class class="lp.code.model.gitrule.GitNascentRuleGrant">
     <allow interface="lp.code.interfaces.gitrule.IGitNascentRuleGrant" />
   </class>
+  <adapter factory="lp.code.model.gitrule.nascent_rule_from_dict" />
   <adapter factory="lp.code.model.gitrule.nascent_rule_grant_from_dict" />
 
   <!-- GitActivity -->

=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	2018-09-25 16:51:42 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2018-09-25 16:51:42 +0000
@@ -78,6 +78,7 @@
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.role import IPersonRoles
 from lp.services.fields import (
+    InlineObject,
     PersonChoice,
     PublicPersonChoice,
     )
@@ -749,6 +750,23 @@
 
     @export_read_operation()
     @operation_for_version("devel")
+    def getRules():
+        """Get the access rules for this repository."""
+
+    @operation_parameters(
+        rules=List(
+            title=_("Rules"),
+            # Really IGitNascentRule, patched in
+            # _schema_circular_imports.py.
+            value_type=InlineObject(schema=Interface)))
+    @call_with(user=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version("devel")
+    def setRules(rules, user):
+        """Set the access rules for this repository."""
+
+    @export_read_operation()
+    @operation_for_version("devel")
     def canBeDeleted():
         """Can this repository be deleted in its current state?
 

=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py	2018-09-25 16:51:42 +0000
+++ lib/lp/code/interfaces/gitrule.py	2018-09-25 16:51:42 +0000
@@ -7,6 +7,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'IGitNascentRule',
     'IGitNascentRuleGrant',
     'IGitRule',
     'IGitRuleGrant',
@@ -23,6 +24,7 @@
     Choice,
     Datetime,
     Int,
+    List,
     TextLine,
     )
 
@@ -30,6 +32,7 @@
 from lp.code.enums import GitGranteeType
 from lp.code.interfaces.gitrepository import IGitRepository
 from lp.services.fields import (
+    InlineObject,
     PersonChoice,
     PublicPersonChoice,
     )
@@ -209,3 +212,15 @@
 
     can_force_push = copy_field(
         IGitRuleGrant["can_force_push"], required=False, default=False)
+
+
+class IGitNascentRule(Interface):
+    """An access rule in the process of being created.
+
+    This represents parameters for a rule that have been deserialised from a
+    webservice request, but that have not yet been attached to a repository.
+    """
+
+    ref_pattern = copy_field(IGitRule["ref_pattern"])
+
+    grants = List(value_type=InlineObject(schema=IGitNascentRuleGrant))

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2018-09-25 16:51:42 +0000
+++ lib/lp/code/model/gitrepository.py	2018-09-25 16:51:42 +0000
@@ -9,6 +9,7 @@
     'parse_git_commits',
     ]
 
+from collections import OrderedDict
 from datetime import datetime
 import email
 from functools import partial
@@ -20,6 +21,8 @@
 from urllib import quote_plus
 
 from bzrlib import urlutils
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
 import pytz
 from storm.databases.postgres import Returning
 from storm.expr import (
@@ -47,7 +50,10 @@
 from storm.store import Store
 from zope.component import getUtility
 from zope.event import notify
-from zope.interface import implementer
+from zope.interface import (
+    implementer,
+    providedBy,
+    )
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import (
     ProxyFactory,
@@ -1192,6 +1198,64 @@
         return Store.of(self).find(
             GitRuleGrant, GitRuleGrant.repository_id == self.id)
 
+    def getRules(self):
+        """See `IGitRepository`."""
+        rules = list(self.rules)
+        GitRule.preloadGrantsForRules(rules)
+        return rules
+
+    @staticmethod
+    def _validateRules(rules):
+        """Validate a new iterable of access rules."""
+        patterns = set()
+        for rule in rules:
+            if rule.ref_pattern in patterns:
+                raise ValueError(
+                    "New rules may not contain duplicate ref patterns "
+                    "(e.g. %s)" % rule.ref_pattern)
+            patterns.add(rule.ref_pattern)
+
+    def setRules(self, rules, user):
+        """See `IGitRepository`."""
+        self._validateRules(rules)
+        existing_rules = {rule.ref_pattern: rule for rule in self.rules}
+        new_rules = OrderedDict((rule.ref_pattern, rule) for rule in rules)
+        GitRule.preloadGrantsForRules(existing_rules.values())
+
+        # Remove old rules first so that we don't generate unnecessary move
+        # events.
+        for ref_pattern, rule in existing_rules.items():
+            if ref_pattern not in new_rules:
+                rule.destroySelf(user)
+
+        # Do our best to match up the new rules and grants to any existing
+        # ones, in order to preserve creator and date-created information.
+        # XXX cjwatson 2018-09-11: We could optimise this to create the new
+        # rules in bulk, but it probably isn't worth the extra complexity.
+        for position, (ref_pattern, new_rule) in enumerate(new_rules.items()):
+            rule = existing_rules.get(ref_pattern)
+            if rule is None:
+                rule = self.addRule(
+                    new_rule.ref_pattern, user, position=position)
+            else:
+                rule_before_modification = Snapshot(
+                    rule, providing=providedBy(rule))
+                self.moveRule(rule, position, user)
+                if rule.position != rule_before_modification.position:
+                    notify(ObjectModifiedEvent(
+                        rule, rule_before_modification, ["position"]))
+            rule.setGrants(new_rule.grants, user)
+
+        # The individual moves and adds above should have resulted in
+        # correct rule ordering, but check this.
+        requested_rule_order = list(new_rules)
+        observed_rule_order = [rule.ref_pattern for rule in self.rules]
+        if requested_rule_order != observed_rule_order:
+            raise AssertionError(
+                "setRules failed to establish requested rule order %s "
+                "(got %s instead)" %
+                (requested_rule_order, observed_rule_order))
+
     def getActivity(self, changed_after=None):
         """See `IGitRepository`."""
         clauses = [GitActivity.repository_id == self.id]

=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	2018-09-25 16:51:42 +0000
+++ lib/lp/code/model/gitrule.py	2018-09-25 16:51:42 +0000
@@ -11,7 +11,10 @@
     'GitRuleGrant',
     ]
 
-from collections import OrderedDict
+from collections import (
+    defaultdict,
+    OrderedDict,
+    )
 import re
 
 from lazr.enum import DBItem
@@ -42,6 +45,7 @@
 from lp.code.enums import GitGranteeType
 from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitrule import (
+    IGitNascentRule,
     IGitNascentRuleGrant,
     IGitRule,
     IGitRuleGrant,
@@ -51,12 +55,21 @@
     validate_person,
     validate_public_person,
     )
+from lp.registry.model.person import Person
+from lp.services.database.bulk import (
+    load_referencing,
+    load_related,
+    )
 from lp.services.database.constants import (
     DEFAULT,
     UTC_NOW,
     )
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.stormbase import StormBase
+from lp.services.propertycache import (
+    cachedproperty,
+    get_property_cache,
+    )
 from lp.services.webapp.publisher import canonical_url
 
 
@@ -73,7 +86,7 @@
         removeSecurityProxy(rule).date_last_modified = UTC_NOW
 
 
-@implementer(IGitRule)
+@implementer(IGitRule, IJSONPublishable)
 class GitRule(StormBase):
     """See `IGitRule`."""
 
@@ -106,6 +119,7 @@
         self.creator = creator
         self.date_created = date_created
         self.date_last_modified = date_created
+        get_property_cache(self).grants = []
 
     def __repr__(self):
         return "<GitRule '%s'> for %r" % (self.ref_pattern, self.repository)
@@ -118,11 +132,20 @@
         # must be an exact-match rule.
         return re.search(r"[*?[]", self.ref_pattern) is None
 
-    @property
+    def toDataForJSON(self, media_type):
+        """See `IJSONPublishable`."""
+        if media_type != "application/json":
+            raise ValueError("Unhandled media type %s" % media_type)
+        return {
+            "ref_pattern": self.ref_pattern,
+            "grants": self.grants,
+            }
+
+    @cachedproperty
     def grants(self):
         """See `IGitRule`."""
-        return Store.of(self).find(
-            GitRuleGrant, GitRuleGrant.rule_id == self.id)
+        return list(Store.of(self).find(
+            GitRuleGrant, GitRuleGrant.rule_id == self.id))
 
     def addGrant(self, grantee, grantor, can_create=False, can_push=False,
                  can_force_push=False):
@@ -132,6 +155,7 @@
             can_push=can_push, can_force_push=can_force_push, grantor=grantor,
             date_created=DEFAULT)
         getUtility(IGitActivitySet).logGrantAdded(grant, grantor)
+        del get_property_cache(self).grants
         return grant
 
     def _validateGrants(self, grants):
@@ -182,6 +206,17 @@
             notify(ObjectModifiedEvent(
                 grant, grant_before_modification, edited_fields))
 
+    @staticmethod
+    def preloadGrantsForRules(rules):
+        """Preload the access grants related to an iterable of rules."""
+        grants = load_referencing(GitRuleGrant, rules, ["rule_id"])
+        grants_map = defaultdict(list)
+        for grant in grants:
+            grants_map[grant.rule_id].append(grant)
+        for rule in rules:
+            get_property_cache(rule).grants = grants_map[rule.id]
+        load_related(Person, grants, ["grantee_id"])
+
     def destroySelf(self, user):
         """See `IGitRule`."""
         getUtility(IGitActivitySet).logRuleRemoved(self, user)
@@ -297,9 +332,24 @@
         """See `IGitRuleGrant`."""
         if user is not None:
             getUtility(IGitActivitySet).logGrantRemoved(self, user)
+        del get_property_cache(self.rule).grants
         Store.of(self).remove(self)
 
 
+@implementer(IGitNascentRule)
+class GitNascentRule:
+
+    def __init__(self, ref_pattern, grants):
+        self.ref_pattern = ref_pattern
+        self.grants = grants
+
+
+@adapter(dict)
+@implementer(IGitNascentRule)
+def nascent_rule_from_dict(template):
+    return GitNascentRule(**template)
+
+
 @implementer(IGitNascentRuleGrant)
 class GitNascentRuleGrant:
 

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2018-09-25 16:51:42 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2018-09-25 16:51:42 +0000
@@ -22,7 +22,10 @@
 from storm.store import Store
 from testtools.matchers import (
     EndsWith,
+    Equals,
+    Is,
     LessThan,
+    MatchesDict,
     MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
@@ -47,6 +50,7 @@
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
     CodeReviewNotificationLevel,
+    GitGranteeType,
     GitObjectType,
     GitRepositoryType,
     TargetRevisionControlSystems,
@@ -79,6 +83,10 @@
     IGitRepositorySet,
     IGitRepositoryView,
     )
+from lp.code.interfaces.gitrule import (
+    IGitNascentRule,
+    IGitNascentRuleGrant,
+    )
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.model.branchmergeproposal import BranchMergeProposal
 from lp.code.model.branchmergeproposaljob import (
@@ -121,6 +129,7 @@
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.database.interfaces import IStore
+from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
 from lp.services.job.runner import JobRunner
@@ -2315,6 +2324,262 @@
             rule=other_rule, grantee=self.factory.makePerson())
         self.assertContentEqual(grants, repository.grants)
 
+    def test_getRules_query_count(self):
+        repository = self.factory.makeGitRepository()
+        owner = repository.owner
+
+        def create_rule_and_grants():
+            with person_logged_in(owner):
+                rule = self.factory.makeGitRule(
+                    repository=repository,
+                    ref_pattern=self.factory.getUniqueUnicode(
+                        prefix="refs/heads/"))
+                for i in range(2):
+                    self.factory.makeGitRuleGrant(rule=rule)
+
+        def get_rules():
+            with person_logged_in(owner):
+                return repository.getRules()
+
+        recorder1, recorder2 = record_two_runs(
+            get_rules, create_rule_and_grants, 2)
+        self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
+
+    def test__validateRules_ok(self):
+        repository = self.factory.makeGitRepository()
+        rules = [
+            IGitNascentRule({
+                "ref_pattern": "refs/heads/*",
+                "grants": [],
+                }),
+            IGitNascentRule({
+                "ref_pattern": "refs/heads/stable/*",
+                "grants": [],
+                }),
+            ]
+        removeSecurityProxy(repository)._validateRules(rules)
+
+    def test__validateRules_duplicate_ref_pattern(self):
+        repository = self.factory.makeGitRepository()
+        rules = [
+            IGitNascentRule({
+                "ref_pattern": "refs/heads/*",
+                "grants": [],
+                }),
+            IGitNascentRule({
+                "ref_pattern": "refs/heads/*",
+                "grants": [],
+                }),
+            ]
+        self.assertRaisesWithContent(
+            ValueError,
+            "New rules may not contain duplicate ref patterns "
+            "(e.g. refs/heads/*)",
+            removeSecurityProxy(repository)._validateRules, rules)
+
+    def test_setRules_add(self):
+        owner = self.factory.makeTeam()
+        member = self.factory.makePerson(member_of=[owner])
+        repository = self.factory.makeGitRepository(owner=owner)
+        grantee = self.factory.makePerson()
+        with person_logged_in(member):
+            repository.setRules([
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/stable/*",
+                    "grants": [
+                        IGitNascentRuleGrant({
+                            "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                            "can_create": True,
+                            "can_force_push": True,
+                            }),
+                        ],
+                    }),
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/*",
+                    "grants": [
+                        IGitNascentRuleGrant({
+                            "grantee_type": GitGranteeType.PERSON,
+                            "grantee": grantee,
+                            "can_push": True,
+                            }),
+                        ],
+                    }),
+                ], member)
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/stable/*"),
+                creator=Equals(member),
+                grants=MatchesSetwise(
+                    MatchesStructure(
+                        grantor=Equals(member),
+                        grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                        grantee=Is(None),
+                        can_create=Is(True),
+                        can_push=Is(False),
+                        can_force_push=Is(True)))),
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*"),
+                creator=Equals(member),
+                grants=MatchesSetwise(
+                    MatchesStructure(
+                        grantor=Equals(member),
+                        grantee_type=Equals(GitGranteeType.PERSON),
+                        grantee=Equals(grantee),
+                        can_create=Is(False),
+                        can_push=Is(True),
+                        can_force_push=Is(False)))),
+            ]))
+
+    def test_setRules_move(self):
+        owner = self.factory.makeTeam()
+        members = [
+            self.factory.makePerson(member_of=[owner]) for _ in range(2)]
+        repository = self.factory.makeGitRepository(owner=owner)
+        for ref_pattern in (
+                "refs/heads/stable/*", "refs/heads/*/next", "refs/heads/*"):
+            self.factory.makeGitRule(
+                repository=repository, ref_pattern=ref_pattern,
+                creator=members[0])
+        date_created = get_transaction_timestamp(Store.of(repository))
+        with person_logged_in(members[1]):
+            repository.setRules([
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/*/next",
+                    "grants": [],
+                    }),
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/stable/*",
+                    "grants": [],
+                    }),
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/*",
+                    "grants": [],
+                    }),
+                ], members[1])
+            date_modified = get_transaction_timestamp(Store.of(repository))
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*/next"),
+                creator=Equals(members[0]),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_modified)),
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/stable/*"),
+                creator=Equals(members[0]),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created)),
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*"),
+                creator=Equals(members[0]),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created)),
+            ]))
+
+    def test_setRules_modify_grants(self):
+        owner = self.factory.makeTeam()
+        members = [
+            self.factory.makePerson(member_of=[owner]) for _ in range(2)]
+        repository = self.factory.makeGitRepository(owner=owner)
+        stable_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*",
+            creator=members[0])
+        grantee = self.factory.makePerson()
+        self.factory.makeGitRuleGrant(
+            rule=stable_rule, grantee=grantee, grantor=members[0],
+            can_push=True)
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*",
+            creator=members[0])
+        date_created = get_transaction_timestamp(Store.of(repository))
+        transaction.commit()
+        with person_logged_in(members[1]):
+            repository.setRules([
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/stable/*",
+                    "grants": [
+                        IGitNascentRuleGrant({
+                            "grantee_type": GitGranteeType.REPOSITORY_OWNER,
+                            "can_create": True,
+                            }),
+                        IGitNascentRuleGrant({
+                            "grantee_type": GitGranteeType.PERSON,
+                            "grantee": grantee,
+                            "can_push": True,
+                            "can_force_push": True,
+                            }),
+                        ],
+                    }),
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/*",
+                    "grants": [],
+                    }),
+                ], members[1])
+            date_modified = get_transaction_timestamp(
+                Store.of(repository))
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/stable/*"),
+                creator=Equals(members[0]),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created),
+                grants=MatchesSetwise(
+                    MatchesStructure(
+                        grantor=Equals(members[1]),
+                        grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
+                        grantee=Is(None),
+                        can_create=Is(True),
+                        can_push=Is(False),
+                        can_force_push=Is(False),
+                        date_created=Equals(date_modified),
+                        date_last_modified=Equals(date_modified)),
+                    MatchesStructure(
+                        grantor=Equals(members[0]),
+                        grantee_type=Equals(GitGranteeType.PERSON),
+                        grantee=Equals(grantee),
+                        can_create=Is(False),
+                        can_push=Is(True),
+                        can_force_push=Is(True),
+                        date_created=Equals(date_created),
+                        date_last_modified=Equals(date_modified)))),
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*"),
+                creator=Equals(members[0]),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created),
+                grants=MatchesSetwise()),
+            ]))
+
+    def test_setRules_remove(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        date_created = get_transaction_timestamp(Store.of(repository))
+        transaction.commit()
+        with person_logged_in(repository.owner):
+            repository.setRules([
+                IGitNascentRule({
+                    "ref_pattern": "refs/heads/*",
+                    "grants": [],
+                    }),
+                ], repository.owner)
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure(
+                repository=Equals(repository),
+                ref_pattern=Equals("refs/heads/*"),
+                date_created=Equals(date_created),
+                date_last_modified=Equals(date_created),
+                grants=MatchesSetwise()),
+            ]))
+
 
 class TestGitRepositorySet(TestCaseWithFactory):
 
@@ -3088,3 +3353,117 @@
         self.assertEqual(1, len(dependent_landings["entries"]))
         self.assertThat(
             dependent_landings["entries"][0]["self_link"], EndsWith(bmp_url))
+
+    def test_getRules(self):
+        repository = self.factory.makeGitRepository()
+        rules = [
+            self.factory.makeGitRule(
+                repository=repository, ref_pattern="refs/heads/stable/*"),
+            self.factory.makeGitRule(
+                repository=repository, ref_pattern="refs/heads/*"),
+            ]
+        self.factory.makeGitRuleGrant(
+            rule=rules[0], grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True, can_force_push=True)
+        grantees = [self.factory.makePerson() for _ in range(2)]
+        for grantee in grantees:
+            self.factory.makeGitRuleGrant(
+                rule=rules[1], grantee=grantee, can_push=True)
+        with person_logged_in(repository.owner):
+            repository_url = api_url(repository)
+            grantee_urls = [api_url(grantee) for grantee in grantees]
+        webservice = webservice_for_person(
+            repository.owner, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_get(repository_url, "getRules")
+        self.assertThat(json.loads(response.body), MatchesListwise([
+            MatchesDict({
+                "ref_pattern": Equals("refs/heads/stable/*"),
+                "grants": MatchesSetwise(
+                    MatchesDict({
+                        "grantee_type": Equals("Repository owner"),
+                        "grantee": Is(None),
+                        "can_create": Is(True),
+                        "can_push": Is(False),
+                        "can_force_push": Is(True),
+                        }),
+                    ),
+                }),
+            MatchesDict({
+                "ref_pattern": Equals("refs/heads/*"),
+                "grants": MatchesSetwise(*(
+                    MatchesDict({
+                        "grantee_type": Equals("Person"),
+                        "grantee": Equals(
+                            webservice.getAbsoluteUrl(grantee_url)),
+                        "can_create": Is(False),
+                        "can_push": Is(True),
+                        "can_force_push": Is(False),
+                        })
+                    for grantee_url in grantee_urls)),
+                }),
+            ]))
+
+    def test_setRules(self):
+        repository = self.factory.makeGitRepository()
+        owner = repository.owner
+        grantee = self.factory.makePerson()
+        with person_logged_in(owner):
+            repository_url = api_url(repository)
+            grantee_url = api_url(grantee)
+        webservice = webservice_for_person(
+            owner, permission=OAuthPermission.WRITE_PUBLIC)
+        webservice.default_api_version = "devel"
+        response = webservice.named_post(
+            repository_url, "setRules",
+            rules=[
+                {
+                    "ref_pattern": "refs/heads/stable/*",
+                    "grants": [
+                        {
+                            "grantee_type": "Repository owner",
+                            "can_create": True,
+                            "can_force_push": True,
+                            },
+                        ],
+                    },
+                {
+                    "ref_pattern": "refs/heads/*",
+                    "grants": [
+                        {
+                            "grantee_type": "Person",
+                            "grantee": grantee_url,
+                            "can_push": True,
+                            },
+                        ],
+                    },
+                ])
+        self.assertEqual(200, response.status)
+        with person_logged_in(owner):
+            self.assertThat(list(repository.rules), MatchesListwise([
+                MatchesStructure(
+                    repository=Equals(repository),
+                    ref_pattern=Equals("refs/heads/stable/*"),
+                    creator=Equals(owner),
+                    grants=MatchesSetwise(
+                        MatchesStructure(
+                            grantor=Equals(owner),
+                            grantee_type=Equals(
+                                GitGranteeType.REPOSITORY_OWNER),
+                            grantee=Is(None),
+                            can_create=Is(True),
+                            can_push=Is(False),
+                            can_force_push=Is(True)))),
+                MatchesStructure(
+                    repository=Equals(repository),
+                    ref_pattern=Equals("refs/heads/*"),
+                    creator=Equals(owner),
+                    grants=MatchesSetwise(
+                        MatchesStructure(
+                            grantor=Equals(owner),
+                            grantee_type=Equals(GitGranteeType.PERSON),
+                            grantee=Equals(grantee),
+                            can_create=Is(False),
+                            can_push=Is(True),
+                            can_force_push=Is(False)))),
+                ]))

=== modified file 'lib/lp/registry/tests/test_personmerge.py'
--- lib/lp/registry/tests/test_personmerge.py	2018-09-25 16:51:42 +0000
+++ lib/lp/registry/tests/test_personmerge.py	2018-09-25 16:51:42 +0000
@@ -7,7 +7,10 @@
 from operator import attrgetter
 
 import pytz
-from testtools.matchers import MatchesStructure
+from testtools.matchers import (
+    MatchesListwise,
+    MatchesStructure,
+    )
 import transaction
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
@@ -501,10 +504,12 @@
         grant = self.factory.makeGitRuleGrant(rule=rule)
         self._do_premerge(grant.grantee, person)
 
-        self.assertEqual(grant.grantee, rule.grants.one().grantee)
+        self.assertEqual(1, len(rule.grants))
+        self.assertEqual(grant.grantee, rule.grants[0].grantee)
         with person_logged_in(person):
             self._do_merge(grant.grantee, person)
-        self.assertEqual(person, rule.grants.one().grantee)
+        self.assertEqual(1, len(rule.grants))
+        self.assertEqual(person, rule.grants[0].grantee)
 
     def test_merge_gitrulegrants_conflicts(self):
         # Conflicting GitRuleGrants are deleted.
@@ -522,12 +527,11 @@
             self._do_merge(duplicate, person)
 
         # Only one grant for the rule exists: the retained person's.
-        self.assertThat(
-            rule.grants.one(),
+        self.assertThat(rule.grants, MatchesListwise([
             MatchesStructure.byEquality(
                 rule=rule,
                 grantee=person,
-                date_created=person_grant_date))
+                date_created=person_grant_date)]))
 
     def test_mergeAsync(self):
         # mergeAsync() creates a new `PersonMergeJob`.


Follow ups