← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad with lp:~cjwatson/launchpad/git-grantee-widgets as a prerequisite.

Commit message:
Add a Git repository permissions view.

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-ui-edit/+merge/358582

This was very substantially complicated by not wanting to invest the time in a JS-based interface just yet (and wanting to have a non-JS fallback, in any case).  Given that, we need to be able to cram everything into a single form which can be submitted in one go and specify the new state of the entire permissions structure.  The UI-level separation between protected branches and protected tags also makes things hard, particularly when it comes to lining up columns consistently.  This is the best I was able to do given those constraints; some bits are quite ugly (especially rule positions), but I think it's tolerable, and it should allow for future JS-based enhancement.

Example screenshot: https://people.canonical.com/~cjwatson/tmp/lp-git-repository-permissions.png
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad.
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2018-11-08 11:40:25 +0000
+++ lib/lp/code/browser/configure.zcml	2018-11-09 22:50:29 +0000
@@ -871,6 +871,12 @@
     </class>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitrepository.GitRepositoryPermissionsView"
+        name="+permissions"
+        permission="launchpad.Edit"
+        template="../templates/gitrepository-permissions.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
         permission="launchpad.Edit"
         name="+delete"

=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py	2018-11-08 15:53:56 +0000
+++ lib/lp/code/browser/gitrepository.py	2018-11-09 22:50:29 +0000
@@ -17,10 +17,13 @@
     'GitRepositoryEditReviewerView',
     'GitRepositoryEditView',
     'GitRepositoryNavigation',
+    'GitRepositoryPermissionsView',
     'GitRepositoryURL',
     'GitRepositoryView',
     ]
 
+import base64
+
 from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.lifecycle.snapshot import Snapshot
 from lazr.restful.interface import (
@@ -34,14 +37,21 @@
 from zope.component import getUtility
 from zope.event import notify
 from zope.formlib import form
+from zope.formlib.textwidgets import IntWidget
+from zope.formlib.widget import CustomWidgetFactory
 from zope.interface import (
     implementer,
     Interface,
     providedBy,
     )
 from zope.publisher.interfaces.browser import IBrowserPublisher
-from zope.schema import Choice
+from zope.schema import (
+    Bool,
+    Choice,
+    Int,
+    )
 from zope.schema.vocabulary import (
+    getVocabularyRegistry,
     SimpleTerm,
     SimpleVocabulary,
     )
@@ -53,7 +63,10 @@
     LaunchpadEditFormView,
     LaunchpadFormView,
     )
-from lp.app.errors import NotFoundError
+from lp.app.errors import (
+    NotFoundError,
+    UnexpectedFormData,
+    )
 from lp.app.vocabularies import InformationTypeVocabulary
 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
 from lp.code.browser.branch import CodeEditOwnerMixin
@@ -62,11 +75,19 @@
     )
 from lp.code.browser.codeimport import CodeImportTargetMixin
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
+from lp.code.browser.widgets.gitgrantee import (
+    GitGranteeDisplayWidget,
+    GitGranteeField,
+    GitGranteeWidget,
+    )
 from lp.code.browser.widgets.gitrepositorytarget import (
     GitRepositoryTargetDisplayWidget,
     GitRepositoryTargetWidget,
     )
-from lp.code.enums import GitRepositoryType
+from lp.code.enums import (
+    GitGranteeType,
+    GitRepositoryType,
+    )
 from lp.code.errors import (
     GitDefaultConflict,
     GitRepositoryCreationForbidden,
@@ -76,6 +97,7 @@
 from lp.code.interfaces.gitnamespace import get_git_namespace
 from lp.code.interfaces.gitref import IGitRefBatchNavigator
 from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
 from lp.registry.interfaces.person import (
     IPerson,
     IPersonSet,
@@ -84,6 +106,7 @@
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.features import getFeatureFlag
+from lp.services.fields import UniqueField
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     canonical_url,
@@ -105,6 +128,7 @@
 from lp.services.webapp.escaping import structured
 from lp.services.webapp.interfaces import ICanonicalUrlData
 from lp.services.webapp.publisher import DataDownloadView
+from lp.services.webapp.snapshot import notify_modified
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.hassnaps import HasSnapsViewMixin
 
@@ -211,7 +235,14 @@
     usedfor = IGitRepository
     facet = "branches"
     title = "Edit Git repository"
-    links = ["edit", "reviewer", "webhooks", "activity", "delete"]
+    links = [
+        "edit",
+        "reviewer",
+        "permissions",
+        "activity",
+        "webhooks",
+        "delete",
+        ]
 
     @enabled_with_permission("launchpad.Edit")
     def edit(self):
@@ -224,6 +255,11 @@
         return Link("+reviewer", text, icon="edit")
 
     @enabled_with_permission("launchpad.Edit")
+    def permissions(self):
+        text = "Manage permissions"
+        return Link("+permissions", text, icon="edit")
+
+    @enabled_with_permission("launchpad.Edit")
     def webhooks(self):
         text = "Manage webhooks"
         return Link(
@@ -709,6 +745,452 @@
         return self, ()
 
 
+def encode_form_field_id(value):
+    """Encode text for use in form field names.
+
+    We use a modified version of base32 which fits into CSS identifiers and
+    so doesn't cause FormattersAPI.zope_css_id to do unhelpful things.
+    """
+    return base64.b32encode(
+        value.encode("UTF-8")).decode("UTF-8").replace("=", "_")
+
+
+def decode_form_field_id(encoded):
+    """Inverse of `encode_form_field_id`."""
+    return base64.b32decode(
+        encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8")
+
+
+class GitRulePatternField(UniqueField):
+
+    errormessage = _("%s is already in use by another rule")
+    attribute = "ref_pattern"
+    _content_iface = IGitRepository
+
+    def __init__(self, ref_prefix, rule=None, *args, **kwargs):
+        self.ref_prefix = ref_prefix
+        self.rule = rule
+        super(GitRulePatternField, self).__init__(*args, **kwargs)
+
+    def _getByAttribute(self, ref_pattern):
+        """See `UniqueField`."""
+        if self._content_iface.providedBy(self.context):
+            return self.context.getRule(self.ref_prefix + ref_pattern)
+        else:
+            return None
+
+    def unchanged(self, input):
+        """See `UniqueField`."""
+        return (
+            self.rule is not None and
+            self.ref_prefix + input == self.rule.ref_pattern)
+
+    def set(self, object, value):
+        """See `IField`."""
+        if value is not None:
+            value = value.strip()
+        super(GitRulePatternField, self).set(object, value)
+
+
+class GitRepositoryPermissionsView(LaunchpadFormView):
+    """A view to manage repository permissions."""
+
+    @property
+    def label(self):
+        return "Manage permissions for %s" % self.context.identity
+
+    page_title = "Manage permissions"
+
+    @cachedproperty
+    def repository(self):
+        return self.context
+
+    @cachedproperty
+    def rules(self):
+        return self.repository.getRules()
+
+    @cachedproperty
+    def branch_rules(self):
+        return [
+            rule for rule in self.rules
+            if rule.ref_pattern.startswith(u"refs/heads/")]
+
+    @cachedproperty
+    def tag_rules(self):
+        return [
+            rule for rule in self.rules
+            if rule.ref_pattern.startswith(u"refs/tags/")]
+
+    @cachedproperty
+    def other_rules(self):
+        return [
+            rule for rule in self.rules
+            if not rule.ref_pattern.startswith(u"refs/heads/") and
+               not rule.ref_pattern.startswith(u"refs/tags/")]
+
+    def _getRuleGrants(self, rule):
+        def grantee_key(grant):
+            if grant.grantee is not None:
+                return grant.grantee_type, grant.grantee.name
+            else:
+                return (grant.grantee_type,)
+
+        return sorted(rule.grants, key=grantee_key)
+
+    def _parseRefPattern(self, ref_pattern):
+        """Parse a pattern into a prefix and the displayed portion."""
+        for prefix in (u"refs/heads/", u"refs/tags/"):
+            if ref_pattern.startswith(prefix):
+                return prefix, ref_pattern[len(prefix):]
+        return u"", ref_pattern
+
+    def _getFieldName(self, name, ref_pattern, grantee=None):
+        """Get the combined field name for a ref pattern and optional grantee.
+
+        In order to be able to render a permissions table, we encode the ref
+        pattern and the grantee in the form field name.
+        """
+        suffix = "." + encode_form_field_id(ref_pattern)
+        if grantee is not None:
+            if IPerson.providedBy(grantee):
+                suffix += "." + str(grantee.id)
+            else:
+                suffix += "._" + grantee.name.lower()
+        return name + suffix
+
+    def _parseFieldName(self, field_name):
+        """Parse a combined field name as described in `_getFieldName`.
+
+        :raises UnexpectedFormData: if the field name cannot be parsed or
+            the grantee cannot be found.
+        """
+        field_bits = field_name.split(".")
+        if len(field_bits) < 2:
+            raise UnexpectedFormData(
+                "Cannot parse field name: %s" % field_name)
+        field_type = field_bits[0]
+        try:
+            ref_pattern = decode_form_field_id(field_bits[1])
+        except TypeError:
+            raise UnexpectedFormData(
+                "Cannot parse field name: %s" % field_name)
+        if len(field_bits) > 2:
+            grantee_id = field_bits[2]
+            if grantee_id.startswith("_"):
+                grantee_id = grantee_id[1:]
+                try:
+                    grantee = GitGranteeType.getTermByToken(grantee_id).value
+                except LookupError:
+                    grantee = None
+            else:
+                try:
+                    grantee_id = int(grantee_id)
+                except ValueError:
+                    grantee = None
+                else:
+                    grantee = getUtility(IPersonSet).get(grantee_id)
+            if grantee is None or grantee == GitGranteeType.PERSON:
+                raise UnexpectedFormData("No such grantee: %s" % grantee_id)
+        else:
+            grantee = None
+        return field_type, ref_pattern, grantee
+
+    def _getPermissionsTerm(self, grant):
+        """Return a term from `GitPermissionsVocabulary` for this grant."""
+        vocabulary = getVocabularyRegistry().get(grant, "GitPermissions")
+        try:
+            return vocabulary.getTerm(grant.permissions)
+        except LookupError:
+            # This should never happen, because GitPermissionsVocabulary
+            # adds a custom term for the context grant if necessary.
+            raise AssertionError(
+                "Could not find GitPermissions term for %r" % grant)
+
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        position_fields = []
+        pattern_fields = []
+        delete_fields = []
+        readonly_grantee_fields = []
+        grantee_fields = []
+        permissions_fields = []
+
+        default_permissions_by_prefix = {
+            "refs/heads/": "can_push",
+            "refs/tags/": "can_create",
+            "": "can_push",
+            }
+
+        for rule_index, rule in enumerate(self.rules):
+            # Remove the usual branch/tag prefixes from patterns.  The full
+            # pattern goes into form field names, so no data is lost here.
+            ref_pattern = rule.ref_pattern
+            ref_prefix, short_pattern = self._parseRefPattern(ref_pattern)
+            position_fields.append(
+                Int(
+                    __name__=self._getFieldName("position", ref_pattern),
+                    required=True, readonly=False, default=rule_index + 1))
+            pattern_fields.append(
+                GitRulePatternField(
+                    __name__=self._getFieldName("pattern", ref_pattern),
+                    required=True, readonly=False, ref_prefix=ref_prefix,
+                    rule=rule, default=short_pattern))
+            delete_fields.append(
+                Bool(
+                    __name__=self._getFieldName("delete", ref_pattern),
+                    readonly=False, default=False))
+            for grant in self._getRuleGrants(rule):
+                grantee = grant.combined_grantee
+                readonly_grantee_fields.append(
+                    GitGranteeField(
+                        __name__=self._getFieldName(
+                            "grantee", ref_pattern, grantee),
+                        required=False, readonly=True, default=grantee,
+                        rule=rule))
+                permissions_fields.append(
+                    Choice(
+                        __name__=self._getFieldName(
+                            "permissions", ref_pattern, grantee),
+                        source=GitPermissionsVocabulary(grant),
+                        readonly=False,
+                        default=self._getPermissionsTerm(grant).value))
+                delete_fields.append(
+                    Bool(
+                        __name__=self._getFieldName(
+                            "delete", ref_pattern, grantee),
+                        readonly=False, default=False))
+            grantee_fields.append(
+                GitGranteeField(
+                    __name__=self._getFieldName("grantee", ref_pattern),
+                    required=False, readonly=False, rule=rule))
+            permissions_vocabulary = GitPermissionsVocabulary(rule)
+            permissions_fields.append(
+                Choice(
+                    __name__=self._getFieldName(
+                        "permissions", ref_pattern),
+                    source=permissions_vocabulary, readonly=False,
+                    default=permissions_vocabulary.getTermByToken(
+                        default_permissions_by_prefix[ref_prefix]).value))
+        for ref_prefix in ("refs/heads/", "refs/tags/"):
+            position_fields.append(
+                Int(
+                    __name__=self._getFieldName("new-position", ref_prefix),
+                    required=False, readonly=True))
+            pattern_fields.append(
+                GitRulePatternField(
+                    __name__=self._getFieldName("new-pattern", ref_prefix),
+                    required=False, readonly=False, ref_prefix=ref_prefix))
+
+        self.form_fields = (
+            form.FormFields(
+                *position_fields,
+                custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) +
+            form.FormFields(*pattern_fields) +
+            form.FormFields(*delete_fields) +
+            form.FormFields(
+                *readonly_grantee_fields,
+                custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) +
+            form.FormFields(
+                *grantee_fields,
+                custom_widget=CustomWidgetFactory(GitGranteeWidget)) +
+            form.FormFields(*permissions_fields))
+
+    def setUpWidgets(self, context=None):
+        """See `LaunchpadFormView`."""
+        super(GitRepositoryPermissionsView, self).setUpWidgets(
+            context=context)
+        for widget in self.widgets:
+            widget.display_label = False
+            widget.hint = None
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    def getRuleWidgets(self, rule):
+        widgets_by_name = {widget.name: widget for widget in self.widgets}
+        ref_pattern = rule.ref_pattern
+        position_field_name = (
+            "field." + self._getFieldName("position", ref_pattern))
+        pattern_field_name = (
+            "field." + self._getFieldName("pattern", ref_pattern))
+        delete_field_name = (
+            "field." + self._getFieldName("delete", ref_pattern))
+        grant_widgets = []
+        for grant in self._getRuleGrants(rule):
+            grantee = grant.combined_grantee
+            grantee_field_name = (
+                "field." + self._getFieldName("grantee", ref_pattern, grantee))
+            permissions_field_name = (
+                "field." +
+                self._getFieldName("permissions", ref_pattern, grantee))
+            delete_grant_field_name = (
+                "field." + self._getFieldName("delete", ref_pattern, grantee))
+            grant_widgets.append({
+                "grantee": widgets_by_name[grantee_field_name],
+                "permissions": widgets_by_name[permissions_field_name],
+                "delete": widgets_by_name[delete_grant_field_name],
+                })
+        new_grantee_field_name = (
+            "field." + self._getFieldName("grantee", ref_pattern))
+        new_permissions_field_name = (
+            "field." + self._getFieldName("permissions", ref_pattern))
+        new_grant_widgets = {
+            "grantee": widgets_by_name[new_grantee_field_name],
+            "permissions": widgets_by_name[new_permissions_field_name],
+            }
+        return {
+            "position": widgets_by_name[position_field_name],
+            "pattern": widgets_by_name[pattern_field_name],
+            "delete": widgets_by_name.get(delete_field_name),
+            "grants": grant_widgets,
+            "new_grant": new_grant_widgets,
+            }
+
+    def getNewRuleWidgets(self, ref_prefix):
+        widgets_by_name = {widget.name: widget for widget in self.widgets}
+        new_position_field_name = (
+            "field." + self._getFieldName("new-position", ref_prefix))
+        new_pattern_field_name = (
+            "field." + self._getFieldName("new-pattern", ref_prefix))
+        return {
+            "position": widgets_by_name[new_position_field_name],
+            "pattern": widgets_by_name[new_pattern_field_name],
+            }
+
+    def updateRepositoryFromData(self, repository, data):
+        pattern_field_names = sorted(
+            name for name in data if name.split(".")[0] == "pattern")
+        new_pattern_field_names = sorted(
+            name for name in data if name.split(".")[0] == "new-pattern")
+        permissions_field_names = sorted(
+            name for name in data if name.split(".")[0] == "permissions")
+
+        # Fetch rules before making any changes, since their ref_patterns
+        # may change as a result of this update.
+        rule_map = {rule.ref_pattern: rule for rule in self.repository.rules}
+        grant_map = {
+            (grant.rule.ref_pattern, grant.combined_grantee): grant
+            for grant in self.repository.grants}
+
+        # Patterns must be processed in rule order so that position changes
+        # work in a reasonably natural way.
+        ordered_patterns = []
+        for pattern_field_name in pattern_field_names:
+            _, ref_pattern, _ = self._parseFieldName(pattern_field_name)
+            if ref_pattern is not None:
+                rule = rule_map.get(ref_pattern)
+                ordered_patterns.append(
+                    (pattern_field_name, ref_pattern, rule))
+        ordered_patterns.sort(key=lambda item: item[2].position)
+
+        for pattern_field_name, ref_pattern, rule in ordered_patterns:
+            prefix, _ = self._parseRefPattern(ref_pattern)
+            rule = rule_map.get(ref_pattern)
+            delete_field_name = self._getFieldName("delete", ref_pattern)
+            # If the rule was already deleted by somebody else, then we
+            # have nothing to do.
+            if rule is not None and data.get(delete_field_name):
+                rule.destroySelf(self.user)
+                rule_map[ref_pattern] = rule = None
+            position_field_name = self._getFieldName("position", ref_pattern)
+            if rule is not None:
+                new_position = max(0, data[position_field_name] - 1)
+                self.repository.moveRule(rule, new_position, self.user)
+            new_pattern = prefix + data[pattern_field_name]
+            if rule is not None and new_pattern != rule.ref_pattern:
+                with notify_modified(rule, ["ref_pattern"]):
+                    rule.ref_pattern = new_pattern
+
+        for new_pattern_field_name in new_pattern_field_names:
+            _, prefix, _ = self._parseFieldName(new_pattern_field_name)
+            if data[new_pattern_field_name]:
+                # This is an "add rule" entry.
+                new_position_field_name = self._getFieldName(
+                    "position", prefix)
+                new_pattern = prefix + data[new_pattern_field_name]
+                rule = rule_map.get(new_pattern)
+                if rule is None:
+                    if new_position_field_name in data:
+                        new_position = max(
+                            0, data[new_position_field_name] - 1)
+                    else:
+                        new_position = None
+                    rule = repository.addRule(
+                        new_pattern, self.user, position=new_position)
+                    if prefix == "refs/tags/":
+                        # Tags are a special case: on creation, they
+                        # automatically get a grant of create permissions to
+                        # the repository owner (suppressing the normal
+                        # ability of the repository owner to push protected
+                        # references).
+                        rule.addGrant(
+                            GitGranteeType.REPOSITORY_OWNER, self.user,
+                            can_create=True)
+
+        for permissions_field_name in permissions_field_names:
+            _, ref_pattern, grantee = self._parseFieldName(
+                permissions_field_name)
+            if ref_pattern not in rule_map:
+                self.addError(structured(
+                    "Cannot edit grants for nonexistent rule %s", ref_pattern))
+                return
+            rule = rule_map.get(ref_pattern)
+            if rule is None:
+                # Already deleted.
+                continue
+
+            # Find or create the corresponding grant.  We only create a
+            # grant if explicitly processing an "add grant" entry in the UI;
+            # if there isn't already a grant for an existing entry that's
+            # being modified, implicitly adding it is probably too
+            # confusing.
+            permissions = data[permissions_field_name]
+            grant = None
+            if grantee is not None:
+                # This entry should correspond to an existing grant.  Make
+                # whatever changes were requested to it.
+                grant = grant_map.get((ref_pattern, grantee))
+                delete_field_name = self._getFieldName(
+                    "delete", ref_pattern, grantee)
+                # If the grant was already deleted by somebody else, then we
+                # have nothing to do.
+                if grant is not None and data.get(delete_field_name):
+                    grant.destroySelf(self.user)
+                    grant = None
+                if grant is not None and permissions != grant.permissions:
+                    with notify_modified(
+                            grant,
+                            ["can_create", "can_push", "can_force_push"]):
+                        grant.permissions = permissions
+            else:
+                # This is an "add grant" entry.
+                grantee_field_name = self._getFieldName("grantee", ref_pattern)
+                grantee = data.get(grantee_field_name)
+                if grantee:
+                    grant = grant_map.get((ref_pattern, grantee))
+                    if grant is None:
+                        rule.addGrant(
+                            grantee, self.user, permissions=permissions)
+                    elif permissions != grant.permissions:
+                        # Somebody else added the grant since the form was
+                        # last rendered.  Updating it with the permissions
+                        # from this request seems best.
+                        with notify_modified(
+                                grant,
+                                ["can_create", "can_push", "can_force_push"]):
+                            grant.permissions = permissions
+
+        self.request.response.addNotification(
+            "Saved permissions for %s" % self.context.identity)
+        self.next_url = canonical_url(self.context, view_name="+permissions")
+
+    @action("Save", name="save")
+    def save_action(self, action, data):
+        with notify_modified(self.repository, []):
+            self.updateRepositoryFromData(self.repository, data)
+
+
 class GitRepositoryDeletionView(LaunchpadFormView):
 
     schema = IGitRepository

=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py	2018-11-08 15:33:03 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py	2018-11-09 22:50:29 +0000
@@ -7,16 +7,26 @@
 
 __metaclass__ = type
 
+import base64
 from datetime import datetime
 import doctest
+from operator import attrgetter
+import re
 from textwrap import dedent
 
 from fixtures import FakeLogger
 import pytz
+import soupmatchers
 from storm.store import Store
 from testtools.matchers import (
+    AfterPreprocessing,
     DocTestMatches,
     Equals,
+    Is,
+    MatchesDict,
+    MatchesListwise,
+    MatchesSetwise,
+    MatchesStructure,
     )
 import transaction
 from zope.component import getUtility
@@ -26,11 +36,16 @@
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
+from lp.app.errors import UnexpectedFormData
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.app.interfaces.services import IService
+from lp.code.browser.gitrepository import encode_form_field_id
 from lp.code.enums import (
     BranchMergeProposalStatus,
     CodeReviewVote,
+    GitActivityType,
+    GitGranteeType,
+    GitPermissionType,
     GitRepositoryType,
     )
 from lp.code.interfaces.revision import IRevisionSet
@@ -40,7 +55,10 @@
     VCSType,
     )
 from lp.registry.interfaces.accesspolicy import IAccessPolicySource
-from lp.registry.interfaces.person import PersonVisibility
+from lp.registry.interfaces.person import (
+    IPerson,
+    PersonVisibility,
+    )
 from lp.services.beautifulsoup import BeautifulSoup
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
@@ -1097,6 +1115,599 @@
             browser.headers["Content-Disposition"])
 
 
+class TestGitRepositoryPermissionsView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_rules_properties(self):
+        repository = self.factory.makeGitRepository()
+        heads_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        tags_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/tags/*")
+        catch_all_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="*")
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        self.assertEqual([heads_rule], view.branch_rules)
+        self.assertEqual([tags_rule], view.tag_rules)
+        self.assertEqual([catch_all_rule], view.other_rules)
+
+    def test__getRuleGrants(self):
+        rule = self.factory.makeGitRule()
+        grantees = sorted(
+            [self.factory.makePerson() for _ in range(3)],
+            key=attrgetter("name"))
+        for grantee in (grantees[1], grantees[0], grantees[2]):
+            self.factory.makeGitRuleGrant(rule=rule, grantee=grantee)
+        self.factory.makeGitRuleGrant(
+            rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
+        login_person(rule.repository.owner)
+        view = create_initialized_view(rule.repository, name="+permissions")
+        self.assertThat(view._getRuleGrants(rule), MatchesListwise([
+            MatchesStructure.byEquality(
+                grantee_type=GitGranteeType.REPOSITORY_OWNER),
+            MatchesStructure.byEquality(grantee=grantees[0]),
+            MatchesStructure.byEquality(grantee=grantees[1]),
+            MatchesStructure.byEquality(grantee=grantees[2]),
+            ]))
+
+    def test__parseRefPattern(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        self.assertEqual(
+            ("refs/heads/", "stable/*"),
+            view._parseRefPattern("refs/heads/stable/*"))
+        self.assertEqual(
+            ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0"))
+        self.assertEqual(
+            ("", "refs/other/*"), view._parseRefPattern("refs/other/*"))
+        self.assertEqual(("", "*"), view._parseRefPattern("*"))
+
+    def test__getFieldName_no_grantee(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/heads/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            "field.%s" % encoded_ref_pattern,
+            view._getFieldName("field", "refs/heads/*"))
+
+    def test__getFieldName_grantee_repository_owner(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/tags/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            "field.%s._repository_owner" % encoded_ref_pattern,
+            view._getFieldName(
+                "field", "refs/tags/*",
+                grantee=GitGranteeType.REPOSITORY_OWNER))
+
+    def test__getFieldName_grantee_person(self):
+        repository = self.factory.makeGitRepository()
+        grantee = self.factory.makePerson()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            "field.%s.%s" % (encoded_ref_pattern, grantee.id),
+            view._getFieldName("field", "refs/*", grantee=grantee))
+
+    def test__parseFieldName_too_few_components(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        self.assertRaises(UnexpectedFormData, view._parseFieldName, "field")
+
+    def test__parseFieldName_bad_ref_pattern(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        self.assertRaises(
+            UnexpectedFormData, view._parseFieldName, "field.nonsense")
+
+    def test__parseFieldName_no_grantee(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/heads/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            ("permissions", "refs/heads/*", None),
+            view._parseFieldName("permissions.%s" % encoded_ref_pattern))
+
+    def test__parseFieldName_grantee_unknown_type(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/tags/*").replace("=", "_").decode("UTF-8")
+        self.assertRaises(
+            UnexpectedFormData, view._parseFieldName,
+            "field.%s._nonsense" % encoded_ref_pattern)
+        self.assertRaises(
+            UnexpectedFormData, view._parseFieldName,
+            "field.%s._person" % encoded_ref_pattern)
+
+    def test__parseFieldName_grantee_repository_owner(self):
+        repository = self.factory.makeGitRepository()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/tags/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER),
+            view._parseFieldName(
+                "pattern.%s._repository_owner" % encoded_ref_pattern))
+
+    def test__parseFieldName_grantee_unknown_person(self):
+        repository = self.factory.makeGitRepository()
+        grantee = self.factory.makePerson()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/*").replace("=", "_").decode("UTF-8")
+        self.assertRaises(
+            UnexpectedFormData, view._parseFieldName,
+            "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2))
+
+    def test__parseFieldName_grantee_person(self):
+        repository = self.factory.makeGitRepository()
+        grantee = self.factory.makePerson()
+        login_person(repository.owner)
+        view = create_initialized_view(repository, name="+permissions")
+        encoded_ref_pattern = base64.b32encode(
+            b"refs/*").replace("=", "_").decode("UTF-8")
+        self.assertEqual(
+            ("delete", "refs/*", grantee),
+            view._parseFieldName(
+                "delete.%s.%s" % (encoded_ref_pattern, grantee.id)))
+
+    def test__getPermissionsTerm_standard(self):
+        grant = self.factory.makeGitRuleGrant(
+            ref_pattern="refs/heads/*", can_create=True, can_push=True)
+        login_person(grant.repository.owner)
+        view = create_initialized_view(grant.repository, name="+permissions")
+        self.assertThat(
+            view._getPermissionsTerm(grant), MatchesStructure.byEquality(
+                value={
+                    GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH},
+                token="can_push",
+                title="Can push"))
+
+    def test__getPermissionsTerm_custom(self):
+        grant = self.factory.makeGitRuleGrant(
+            ref_pattern="refs/heads/*", can_force_push=True)
+        login_person(grant.repository.owner)
+        view = create_initialized_view(grant.repository, name="+permissions")
+        self.assertThat(
+            view._getPermissionsTerm(grant), MatchesStructure.byEquality(
+                value={GitPermissionType.CAN_FORCE_PUSH},
+                token="custom",
+                title="Custom permissions: force-push"))
+
+    def _matchesCells(self, row_tag, cell_matchers):
+        return AfterPreprocessing(
+            str, soupmatchers.HTMLContains(*(
+                soupmatchers.Within(row_tag, cell_matcher)
+                for cell_matcher in cell_matchers)))
+
+    def _matchesRule(self, position, pattern, short_pattern):
+        rule_tag = soupmatchers.Tag(
+            "rule row", "tr", attrs={"class": "git-rule"})
+        suffix = "." + encode_form_field_id(pattern)
+        position_field_name = "field.position" + suffix
+        pattern_field_name = "field.pattern" + suffix
+        delete_field_name = "field.delete" + suffix
+        return self._matchesCells(rule_tag, [
+            soupmatchers.Within(
+                soupmatchers.Tag("position cell", "td"),
+                soupmatchers.Tag(
+                    "position widget", "input",
+                    attrs={"name": position_field_name, "value": position})),
+            soupmatchers.Within(
+                soupmatchers.Tag("pattern cell", "td"),
+                soupmatchers.Tag(
+                    "pattern widget", "input",
+                    attrs={
+                        "name": pattern_field_name,
+                        "value": short_pattern,
+                        })),
+            soupmatchers.Within(
+                soupmatchers.Tag("delete cell", "td"),
+                soupmatchers.Tag(
+                    "delete widget", "input",
+                    attrs={"name": delete_field_name})),
+            ])
+
+    def _matchesNewRule(self, ref_prefix):
+        new_rule_tag = soupmatchers.Tag(
+            "new rule row", "tr", attrs={"class": "git-new-rule"})
+        suffix = "." + encode_form_field_id(ref_prefix)
+        new_position_field_name = "field.new-position" + suffix
+        new_pattern_field_name = "field.new-pattern" + suffix
+        return self._matchesCells(new_rule_tag, [
+            soupmatchers.Within(
+                soupmatchers.Tag("position cell", "td"),
+                soupmatchers.Tag(
+                    "position widget", "input",
+                    attrs={"name": new_position_field_name, "value": ""})),
+            soupmatchers.Within(
+                soupmatchers.Tag("pattern cell", "td"),
+                soupmatchers.Tag(
+                    "pattern widget", "input",
+                    attrs={"name": new_pattern_field_name, "value": ""})),
+            ])
+
+    def _matchesRuleGrant(self, pattern, grantee, permissions_token,
+                          permissions_title):
+        rule_grant_tag = soupmatchers.Tag(
+            "rule grant row", "tr", attrs={"class": "git-rule-grant"})
+        suffix = "." + encode_form_field_id(pattern)
+        if IPerson.providedBy(grantee):
+            suffix += "." + str(grantee.id)
+            grantee_widget_matcher = soupmatchers.Tag(
+                "grantee widget", "a", attrs={"href": canonical_url(grantee)},
+                text=" " + grantee.display_name)
+        else:
+            suffix += "._" + grantee.name.lower()
+            grantee_widget_matcher = soupmatchers.Tag(
+                "grantee widget", "label",
+                text=re.compile(re.escape(grantee.title)))
+        permissions_field_name = "field.permissions" + suffix
+        delete_field_name = "field.delete" + suffix
+        return self._matchesCells(rule_grant_tag, [
+            soupmatchers.Within(
+                soupmatchers.Tag("grantee cell", "td"),
+                grantee_widget_matcher),
+            soupmatchers.Within(
+                soupmatchers.Tag("permissions cell", "td"),
+                soupmatchers.Within(
+                    soupmatchers.Tag(
+                        "permissions widget", "select",
+                        attrs={"name": permissions_field_name}),
+                    soupmatchers.Tag(
+                        "selected permissions option", "option",
+                        attrs={
+                            "selected": "selected",
+                            "value": permissions_token,
+                            },
+                        text=permissions_title))),
+            soupmatchers.Within(
+                soupmatchers.Tag("delete cell", "td"),
+                soupmatchers.Tag(
+                    "delete widget", "input",
+                    attrs={"name": delete_field_name})),
+            ])
+
+    def _matchesNewRuleGrant(self, pattern, permissions_token):
+        rule_grant_tag = soupmatchers.Tag(
+            "rule grant row", "tr", attrs={"class": "git-new-rule-grant"})
+        suffix = "." + encode_form_field_id(pattern)
+        grantee_field_name = "field.grantee" + suffix
+        permissions_field_name = "field.permissions" + suffix
+        return self._matchesCells(rule_grant_tag, [
+            soupmatchers.Within(
+                soupmatchers.Tag("grantee cell", "td"),
+                soupmatchers.Tag(
+                    "grantee widget", "input",
+                    attrs={"name": grantee_field_name})),
+            soupmatchers.Within(
+                soupmatchers.Tag("permissions cell", "td"),
+                soupmatchers.Within(
+                    soupmatchers.Tag(
+                        "permissions widget", "select",
+                        attrs={"name": permissions_field_name}),
+                    soupmatchers.Tag(
+                        "selected permissions option", "option",
+                        attrs={
+                            "selected": "selected",
+                            "value": permissions_token,
+                            }))),
+            ])
+
+    def test_rules_table(self):
+        repository = self.factory.makeGitRepository()
+        heads_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        heads_grantee_1 = self.factory.makePerson(
+            name=self.factory.getUniqueString("person-name-a"))
+        heads_grantee_2 = self.factory.makePerson(
+            name=self.factory.getUniqueString("person-name-b"))
+        self.factory.makeGitRuleGrant(
+            rule=heads_rule, grantee=heads_grantee_1, can_push=True)
+        self.factory.makeGitRuleGrant(
+            rule=heads_rule, grantee=heads_grantee_2, can_force_push=True)
+        tags_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/tags/*")
+        self.factory.makeGitRuleGrant(
+            rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER)
+        login_person(repository.owner)
+        view = create_initialized_view(
+            repository, name="+permissions", principal=repository.owner)
+        rules_table = find_tag_by_id(view(), "rules-table")
+        rows = rules_table.findAll("tr", {"class": True})
+        self.assertThat(rows, MatchesListwise([
+            self._matchesRule("1", "refs/heads/stable/*", "stable/*"),
+            self._matchesRuleGrant(
+                "refs/heads/stable/*", heads_grantee_1, "can_push_existing",
+                "Can push if the branch already exists"),
+            self._matchesRuleGrant(
+                "refs/heads/stable/*", heads_grantee_2, "custom",
+                "Custom permissions: force-push"),
+            self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"),
+            self._matchesNewRule("refs/heads/"),
+            self._matchesRule("2", "refs/tags/*", "*"),
+            self._matchesRuleGrant(
+                "refs/tags/*", GitGranteeType.REPOSITORY_OWNER,
+                "cannot_create", "Cannot create"),
+            self._matchesNewRuleGrant("refs/tags/*", "can_create"),
+            self._matchesNewRule("refs/tags/"),
+            ]))
+
+    def assertHasRules(self, repository, ref_patterns):
+        self.assertThat(list(repository.rules), MatchesListwise([
+            MatchesStructure.byEquality(ref_pattern=ref_pattern)
+            for ref_pattern in ref_patterns
+            ]))
+
+    def assertHasSavedNotification(self, view, repository):
+        self.assertThat(view.request.response.notifications, MatchesListwise([
+            MatchesStructure.byEquality(
+                message="Saved permissions for %s" % repository.identity),
+            ]))
+
+    def test_save_add_rules(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        removeSecurityProxy(repository.getActivity()).remove()
+        login_person(repository.owner)
+        encoded_heads_prefix = encode_form_field_id("refs/heads/")
+        encoded_tags_prefix = encode_form_field_id("refs/tags/")
+        form = {
+            "field.new-pattern." + encoded_heads_prefix: "*",
+            "field.new-pattern." + encoded_tags_prefix: "1.0",
+            "field.actions.save": "Save",
+            }
+        view = create_initialized_view(
+            repository, name="+permissions", form=form,
+            principal=repository.owner)
+        self.assertHasRules(
+            repository,
+            ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"])
+        self.assertThat(list(repository.getActivity()), MatchesListwise([
+            # Adding a tag rule automatically adds a repository owner grant.
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                changee=Is(None),
+                what_changed=Equals(GitActivityType.GRANT_ADDED),
+                new_value=MatchesDict({
+                    "changee_type": Equals("Repository owner"),
+                    "ref_pattern": Equals("refs/tags/1.0"),
+                    "can_create": Is(True),
+                    "can_push": Is(False),
+                    "can_force_push": Is(False),
+                    })),
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                what_changed=Equals(GitActivityType.RULE_ADDED),
+                new_value=MatchesDict({
+                    "ref_pattern": Equals("refs/tags/1.0"),
+                    "position": Equals(0),
+                    })),
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                what_changed=Equals(GitActivityType.RULE_ADDED),
+                new_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/*"),
+                    # Initially inserted at 1, although refs/tags/1.0 was
+                    # later inserted before it.
+                    "position": Equals(1),
+                    })),
+            ]))
+        self.assertHasSavedNotification(view, repository)
+
+    def test_save_add_duplicate_rule(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        transaction.commit()
+        login_person(repository.owner)
+        encoded_heads_prefix = encode_form_field_id("refs/heads/")
+        form = {
+            "field.new-pattern." + encoded_heads_prefix: "stable/*",
+            "field.actions.save": "Save",
+            }
+        view = create_initialized_view(
+            repository, name="+permissions", form=form,
+            principal=repository.owner)
+        self.assertThat(view.errors, MatchesListwise([
+            MatchesStructure(
+                field_name=Equals("new-pattern." + encoded_heads_prefix),
+                errors=MatchesStructure.byEquality(
+                    args=("stable/* is already in use by another rule",))),
+            ]))
+        self.assertHasRules(repository, ["refs/heads/stable/*"])
+
+    def test_save_move_rule(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*/next")
+        encoded_patterns = [
+            encode_form_field_id(rule.ref_pattern)
+            for rule in repository.rules]
+        removeSecurityProxy(repository.getActivity()).remove()
+        login_person(repository.owner)
+        # Positions are 1-based in the UI.
+        form = {
+            "field.position." + encoded_patterns[0]: "2",
+            "field.pattern." + encoded_patterns[0]: "stable/*",
+            "field.position." + encoded_patterns[1]: "1",
+            "field.pattern." + encoded_patterns[1]: "*/more-next",
+            "field.actions.save": "Save",
+            }
+        view = create_initialized_view(
+            repository, name="+permissions", form=form,
+            principal=repository.owner)
+        self.assertHasRules(
+            repository, ["refs/heads/*/more-next", "refs/heads/stable/*"])
+        self.assertThat(list(repository.getActivity()), MatchesListwise([
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                what_changed=Equals(GitActivityType.RULE_CHANGED),
+                old_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/*/next"),
+                    "position": Equals(0),
+                    }),
+                new_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/*/more-next"),
+                    "position": Equals(0),
+                    })),
+            # Only one rule is recorded as moving; the other is already in
+            # its new position by the time it's processed.
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                what_changed=Equals(GitActivityType.RULE_MOVED),
+                old_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/stable/*"),
+                    "position": Equals(0),
+                    }),
+                new_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/stable/*"),
+                    "position": Equals(1),
+                    })),
+            ]))
+        self.assertHasSavedNotification(view, repository)
+
+    def test_save_change_grants(self):
+        repository = self.factory.makeGitRepository()
+        stable_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        next_rule = self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*/next")
+        grantees = [self.factory.makePerson() for _ in range(3)]
+        self.factory.makeGitRuleGrant(
+            rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
+            can_create=True)
+        self.factory.makeGitRuleGrant(
+            rule=stable_rule,
+            grantee=grantees[0], can_create=True, can_push=True)
+        self.factory.makeGitRuleGrant(
+            rule=next_rule, grantee=grantees[1],
+            can_create=True, can_push=True, can_force_push=True)
+        encoded_patterns = [
+            encode_form_field_id(rule.ref_pattern)
+            for rule in repository.rules]
+        removeSecurityProxy(repository.getActivity()).remove()
+        login_person(repository.owner)
+        form = {
+            "field.permissions.%s._repository_owner" % encoded_patterns[0]: (
+                "can_push"),
+            "field.permissions.%s.%s" % (
+                encoded_patterns[0], grantees[0].id): "can_push",
+            "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on",
+            "field.grantee.%s" % encoded_patterns[1]: "person",
+            "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name,
+            "field.permissions.%s" % encoded_patterns[1]: "can_push_existing",
+            "field.actions.save": "Save",
+            }
+        view = create_initialized_view(
+            repository, name="+permissions", form=form,
+            principal=repository.owner)
+        self.assertHasRules(
+            repository, ["refs/heads/stable/*", "refs/heads/*/next"])
+        self.assertThat(stable_rule.grants, MatchesSetwise(
+            MatchesStructure.byEquality(
+                grantee_type=GitGranteeType.REPOSITORY_OWNER,
+                can_create=True, can_push=True, can_force_push=False)))
+        self.assertThat(next_rule.grants, MatchesSetwise(
+            MatchesStructure.byEquality(
+                grantee=grantees[1],
+                can_create=True, can_push=True, can_force_push=True),
+            MatchesStructure.byEquality(
+                grantee=grantees[2],
+                can_create=False, can_push=True, can_force_push=False)))
+        self.assertThat(repository.getActivity(), MatchesSetwise(
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                changee=Is(None),
+                what_changed=Equals(GitActivityType.GRANT_CHANGED),
+                old_value=Equals({
+                    "changee_type": "Repository owner",
+                    "ref_pattern": "refs/heads/stable/*",
+                    "can_create": True,
+                    "can_push": False,
+                    "can_force_push": False,
+                    }),
+                new_value=Equals({
+                    "changee_type": "Repository owner",
+                    "ref_pattern": "refs/heads/stable/*",
+                    "can_create": True,
+                    "can_push": True,
+                    "can_force_push": False,
+                    })),
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                changee=Equals(grantees[0]),
+                what_changed=Equals(GitActivityType.GRANT_REMOVED),
+                old_value=Equals({
+                    "changee_type": "Person",
+                    "ref_pattern": "refs/heads/stable/*",
+                    "can_create": True,
+                    "can_push": True,
+                    "can_force_push": False,
+                    })),
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                changee=Equals(grantees[2]),
+                what_changed=Equals(GitActivityType.GRANT_ADDED),
+                new_value=Equals({
+                    "changee_type": "Person",
+                    "ref_pattern": "refs/heads/*/next",
+                    "can_create": False,
+                    "can_push": True,
+                    "can_force_push": False,
+                    }))))
+        self.assertHasSavedNotification(view, repository)
+
+    def test_save_delete_rule(self):
+        repository = self.factory.makeGitRepository()
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/stable/*")
+        self.factory.makeGitRule(
+            repository=repository, ref_pattern="refs/heads/*")
+        removeSecurityProxy(repository.getActivity()).remove()
+        login_person(repository.owner)
+        encoded_pattern = encode_form_field_id("refs/heads/*")
+        form = {
+            "field.pattern." + encoded_pattern: "*",
+            "field.delete." + encoded_pattern: "on",
+            "field.actions.save": "Save",
+            }
+        view = create_initialized_view(
+            repository, name="+permissions", form=form,
+            principal=repository.owner)
+        self.assertHasRules(repository, ["refs/heads/stable/*"])
+        self.assertThat(list(repository.getActivity()), MatchesListwise([
+            MatchesStructure(
+                changer=Equals(repository.owner),
+                what_changed=Equals(GitActivityType.RULE_REMOVED),
+                old_value=MatchesDict({
+                    "ref_pattern": Equals("refs/heads/*"),
+                    "position": Equals(1),
+                    })),
+            ]))
+        self.assertHasSavedNotification(view, repository)
+
+
 class TestGitRepositoryDeletionView(BrowserTestCase):
 
     layer = DatabaseFunctionalLayer

=== modified file 'lib/lp/code/interfaces/gitrule.py'
--- lib/lp/code/interfaces/gitrule.py	2018-10-23 16:17:39 +0000
+++ lib/lp/code/interfaces/gitrule.py	2018-11-09 22:50:29 +0000
@@ -158,6 +158,10 @@
         vocabulary="ValidPersonOrTeam",
         description=_("The person being granted access."))
 
+    combined_grantee = Attribute(
+        "The overall grantee of this grant: either a `GitGranteeType` (other "
+        "than `PERSON`) or an `IPerson`.")
+
     date_created = Datetime(
         title=_("Date created"), required=True, readonly=True,
         description=_("The time when this grant was created."))

=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	2018-10-29 14:27:36 +0000
+++ lib/lp/code/model/gitrule.py	2018-11-09 22:50:29 +0000
@@ -310,6 +310,13 @@
         self.date_created = date_created
         self.date_last_modified = date_created
 
+    @property
+    def combined_grantee(self):
+        if self.grantee_type == GitGranteeType.PERSON:
+            return self.grantee
+        else:
+            return self.grantee_type
+
     def __repr__(self):
         if self.grantee_type == GitGranteeType.PERSON:
             grantee_name = "~%s" % self.grantee.name

=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py	2018-10-21 17:38:05 +0000
+++ lib/lp/code/model/tests/test_gitrule.py	2018-11-09 22:50:29 +0000
@@ -594,6 +594,7 @@
             rule=Equals(rule),
             grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
             grantee=Is(None),
+            combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER),
             can_create=Is(True),
             can_push=Is(False),
             can_force_push=Is(True),
@@ -614,6 +615,7 @@
             rule=Equals(rule),
             grantee_type=Equals(GitGranteeType.PERSON),
             grantee=Equals(grantee),
+            combined_grantee=Equals(grantee),
             can_create=Is(False),
             can_push=Is(True),
             can_force_push=Is(False),

=== added file 'lib/lp/code/templates/gitrepository-permissions.pt'
--- lib/lp/code/templates/gitrepository-permissions.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-permissions.pt	2018-11-09 22:50:29 +0000
@@ -0,0 +1,192 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+  <metal:macros fill-slot="bogus">
+    <metal:macro define-macro="rule-rows">
+      <tal:rule repeat="rule rules">
+        <tal:rule_widgets
+            define="rule_widgets python:view.getRuleWidgets(rule)">
+          <tr class="git-rule">
+            <td tal:define="widget nocall:rule_widgets/position">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:rule_widgets/pattern" colspan="2">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:rule_widgets/delete">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+          </tr>
+          <tr class="git-rule-grant"
+              tal:repeat="grant_widgets rule_widgets/grants">
+            <td></td>
+            <td tal:define="widget nocall:grant_widgets/grantee">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:grant_widgets/permissions">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:grant_widgets/delete">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+          </tr>
+          <tr class="git-new-rule-grant"
+              tal:define="new_grant_widgets rule_widgets/new_grant">
+            <td></td>
+            <td tal:define="widget nocall:new_grant_widgets/grantee">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td tal:define="widget nocall:new_grant_widgets/permissions">
+              <metal:block use-macro="context/@@launchpad_form/widget_div" />
+            </td>
+            <td></td>
+          </tr>
+        </tal:rule_widgets>
+      </tal:rule>
+      <tal:allows-new-rule condition="ref_prefix">
+        <tr class="git-new-rule"
+            tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)">
+          <td tal:define="widget nocall:new_rule_widgets/position">
+            <metal:block use-macro="context/@@launchpad_form/widget_div" />
+          </td>
+          <td tal:define="widget nocall:new_rule_widgets/pattern" colspan="2">
+            <metal:block use-macro="context/@@launchpad_form/widget_div" />
+          </td>
+          <td></td>
+        </tr>
+      </tal:allows-new-rule>
+    </metal:macro>
+  </metal:macros>
+
+  <div metal:fill-slot="main">
+    <p>
+      By default, repository owners may create, push, force-push, or delete
+      any branch or tag in their repositories, and nobody else may modify
+      them in any way.
+    </p>
+    <p>
+      If any of the rules below matches a branch or tag, then it is
+      <em>protected</em>.  By default, protecting a branch implicitly
+      prevents repository owners from force-pushing to it or deleting it,
+      while protecting a tag prevents repository owners from moving it.
+      Protecting a branch or tag also allows you to grant other permissions.
+    </p>
+    <p>
+      You may create rules that match a single branch or tag, or wildcard
+      rules that match a pattern: for example, <code>*</code> matches
+      everything, while <code>stable/*</code> matches
+      <code>stable/1.0</code> but not <code>master</code>.
+    </p>
+
+    <metal:grants-form use-macro="context/@@launchpad_form/form">
+      <div class="form" metal:fill-slot="widgets">
+        <table id="rules-table" class="listing"
+               style="max-width: 60em; margin-bottom: 1em;">
+          <thead>
+            <tr>
+              <th>Position</th>
+              <th colspan="2">Rule</th>
+              <th>Delete?</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td colspan="4">
+                <h3>Protected branches (under <code>refs/heads/</code>)</h3>
+              </td>
+            </tr>
+            <tal:branches define="rules view/branch_rules;
+                                  ref_prefix string:refs/heads/">
+              <metal:grants use-macro="template/macros/rule-rows" />
+            </tal:branches>
+
+            <tr>
+              <td colspan="4">
+                <h3>Protected tags (under <code>refs/tags/</code>)</h3>
+              </td>
+            </tr>
+            <tal:tags define="rules view/tag_rules;
+                              ref_prefix string:refs/tags/">
+              <metal:grants use-macro="template/macros/rule-rows" />
+            </tal:tags>
+
+            <tal:has-other condition="view/other_rules">
+              <tr><td colspan="4"><h3>Other protected references</h3></td></tr>
+              <tal:other define="rules view/other_rules; ref_prefix nothing">
+                <metal:grants use-macro="template/macros/rule-rows" />
+              </tal:other>
+            </tal:has-other>
+          </tbody>
+        </table>
+
+        <p class="actions">
+          <input tal:replace="structure view/save_action/render" />
+          or <a tal:attributes="href view/cancel_url">Cancel</a>
+        </p>
+      </div>
+
+      <metal:buttons fill-slot="buttons" />
+    </metal:grants-form>
+
+    <h2>Wildcards</h2>
+    <p>The special characters used in wildcard rules are:</p>
+    <table class="listing narrow-listing">
+      <thead>
+        <tr>
+          <th>Pattern</th>
+          <th>Meaning</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <td><code>*</code></td>
+          <td>matches zero or more characters</td>
+        </tr>
+        <tr>
+          <td><code>?</code></td>
+          <td>matches any single character</td>
+        </tr>
+        <tr>
+          <td><code>[chars]</code></td>
+          <td>matches any character in <em>chars</em></td>
+        </tr>
+        <tr>
+          <td><code>[!chars]</code></td>
+          <td>matches any character not in <em>chars</em></td>
+        </tr>
+      </tbody>
+    </table>
+
+    <h2>Effective permissions</h2>
+    <p>
+      Launchpad works out the effective permissions that a user has on a
+      protected branch as follows:
+    </p>
+    <ol>
+      <li>Take all the rules that match the branch.</li>
+      <li>
+        For each matching rule, select any grants whose grantee matches the
+        user, as long as the same grantee has not already been seen in an
+        earlier matching rule.  (A user can be matched by more than one
+        grantee: for example, they might be in multiple teams.)
+      </li>
+      <li>
+        If the user is an owner of the repository and there was no previous
+        “Repository owner” grant, then add an implicit grant allowing them
+        to create or push.
+      </li>
+      <li>
+        The effective permission set is the union of the permissions granted
+        by all the selected grants.
+      </li>
+    </ol>
+  </div>
+
+</body>
+</html>


Follow ups