← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Review: Approve code



Diff comments:

> 
> === 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
> @@ -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

Does this need to be a cachedproperty?

> +
> +    @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/")]

These three are based on the already cached rules, so caching them separately just sounds confusing.

> +
> +    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,)

Having parentheses around one tuple but not the other is weird.

> +
> +        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/"):

This is the third place that these two strings are hardcoded in this file.

> +            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")

updateRepositoryFromData is very long and complicated. I wonder if it's worth splitting it into one function that parses the widget values into a pretty simple dict, and another that takes that dict and applies it to the model without any form-specific code. Probably more sensible to test that way too.

> +
> +    @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
> 
> === 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>

I think the table would be significantly easier to parse with a heavier line after each new grant row. As it stands, the bolded "Repository owner" looks like the section delineator, when in fact it's in the middle.

> +      </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.

push isn't implicit for tags, right? May be too detailed to mention here, though.

> +      </li>
> +      <li>
> +        The effective permission set is the union of the permissions granted
> +        by all the selected grants.
> +      </li>
> +    </ol>
> +  </div>
> +
> +</body>
> +</html>


-- 
https://code.launchpad.net/~cjwatson/launchpad/git-permissions-ui-edit/+merge/358582
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References