launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #23214
Re: [Merge] lp:~cjwatson/launchpad/git-permissions-ui-edit into lp:launchpad
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
> +
> + @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")
I've made a stab at this, though it's still moderately complicated. I ran out of time for splitting up the tests; that can always be done later. The existing tests did a good job at spotting regressions in my parse/apply split.
> +
> + @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>
As discussed on IRC, I dealt with most of this by applying alternating row background colours, with a few small spacing tweaks. I also switched over to having one table per section and using CSS column widths to try to keep them all in roughly the same layout, which I think has made it less weird-looking.
> + </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.
The point of this documentation is to let people run the rule processing algorithm in their head given the rules that they can see in the preceding tables. The special case for tags is handled by automatically adding a grant for the repository owner; but that grant is explicit and is shown. We therefore don't need to describe that case here (and I think it would actually be confusing to do so), because the condition of 'there was no previous "Repository owner" grant' doesn't hold.
The text at the top of the page explains the default behaviour when you protect a tag.
> + </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