launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #23208
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