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

Proposed by Colin Watson on 2018-11-09
Status: Needs review
Proposed branch: lp:~cjwatson/launchpad/git-permissions-ui-edit
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-grantee-widgets
Diff against target: 1505 lines (+1309/-5)
7 files modified
lib/lp/code/browser/configure.zcml (+6/-0)
lib/lp/code/browser/gitrepository.py (+486/-4)
lib/lp/code/browser/tests/test_gitrepository.py (+612/-1)
lib/lp/code/interfaces/gitrule.py (+4/-0)
lib/lp/code/model/gitrule.py (+7/-0)
lib/lp/code/model/tests/test_gitrule.py (+2/-0)
lib/lp/code/templates/gitrepository-permissions.pt (+192/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-permissions-ui-edit
Reviewer Review Type Date Requested Status
Launchpad code reviewers 2018-11-09 Pending
Review via email: mp+358582@code.launchpad.net

This proposal supersedes a proposal from 2018-10-31.

Commit message

Add a Git repository permissions view.

Description of the change

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

To post a comment you must log in.

Unmerged revisions

18811. By Colin Watson on 2018-11-09

Merge git-grantee-widgets.

18810. By Colin Watson on 2018-10-31

Add a +permissions link to GitRepositoryEditMenu.

18809. By Colin Watson on 2018-10-31

Test duplicate rule handling.

18808. By Colin Watson on 2018-10-31

Add a Git repository permissions view.

18807. By Colin Watson on 2018-10-31

Merge git-grantee-widgets.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/browser/configure.zcml'
2--- lib/lp/code/browser/configure.zcml 2018-11-08 11:40:25 +0000
3+++ lib/lp/code/browser/configure.zcml 2018-11-09 22:50:29 +0000
4@@ -871,6 +871,12 @@
5 </class>
6 <browser:page
7 for="lp.code.interfaces.gitrepository.IGitRepository"
8+ class="lp.code.browser.gitrepository.GitRepositoryPermissionsView"
9+ name="+permissions"
10+ permission="launchpad.Edit"
11+ template="../templates/gitrepository-permissions.pt"/>
12+ <browser:page
13+ for="lp.code.interfaces.gitrepository.IGitRepository"
14 class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
15 permission="launchpad.Edit"
16 name="+delete"
17
18=== modified file 'lib/lp/code/browser/gitrepository.py'
19--- lib/lp/code/browser/gitrepository.py 2018-11-08 15:53:56 +0000
20+++ lib/lp/code/browser/gitrepository.py 2018-11-09 22:50:29 +0000
21@@ -17,10 +17,13 @@
22 'GitRepositoryEditReviewerView',
23 'GitRepositoryEditView',
24 'GitRepositoryNavigation',
25+ 'GitRepositoryPermissionsView',
26 'GitRepositoryURL',
27 'GitRepositoryView',
28 ]
29
30+import base64
31+
32 from lazr.lifecycle.event import ObjectModifiedEvent
33 from lazr.lifecycle.snapshot import Snapshot
34 from lazr.restful.interface import (
35@@ -34,14 +37,21 @@
36 from zope.component import getUtility
37 from zope.event import notify
38 from zope.formlib import form
39+from zope.formlib.textwidgets import IntWidget
40+from zope.formlib.widget import CustomWidgetFactory
41 from zope.interface import (
42 implementer,
43 Interface,
44 providedBy,
45 )
46 from zope.publisher.interfaces.browser import IBrowserPublisher
47-from zope.schema import Choice
48+from zope.schema import (
49+ Bool,
50+ Choice,
51+ Int,
52+ )
53 from zope.schema.vocabulary import (
54+ getVocabularyRegistry,
55 SimpleTerm,
56 SimpleVocabulary,
57 )
58@@ -53,7 +63,10 @@
59 LaunchpadEditFormView,
60 LaunchpadFormView,
61 )
62-from lp.app.errors import NotFoundError
63+from lp.app.errors import (
64+ NotFoundError,
65+ UnexpectedFormData,
66+ )
67 from lp.app.vocabularies import InformationTypeVocabulary
68 from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
69 from lp.code.browser.branch import CodeEditOwnerMixin
70@@ -62,11 +75,19 @@
71 )
72 from lp.code.browser.codeimport import CodeImportTargetMixin
73 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
74+from lp.code.browser.widgets.gitgrantee import (
75+ GitGranteeDisplayWidget,
76+ GitGranteeField,
77+ GitGranteeWidget,
78+ )
79 from lp.code.browser.widgets.gitrepositorytarget import (
80 GitRepositoryTargetDisplayWidget,
81 GitRepositoryTargetWidget,
82 )
83-from lp.code.enums import GitRepositoryType
84+from lp.code.enums import (
85+ GitGranteeType,
86+ GitRepositoryType,
87+ )
88 from lp.code.errors import (
89 GitDefaultConflict,
90 GitRepositoryCreationForbidden,
91@@ -76,6 +97,7 @@
92 from lp.code.interfaces.gitnamespace import get_git_namespace
93 from lp.code.interfaces.gitref import IGitRefBatchNavigator
94 from lp.code.interfaces.gitrepository import IGitRepository
95+from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
96 from lp.registry.interfaces.person import (
97 IPerson,
98 IPersonSet,
99@@ -84,6 +106,7 @@
100 from lp.services.config import config
101 from lp.services.database.constants import UTC_NOW
102 from lp.services.features import getFeatureFlag
103+from lp.services.fields import UniqueField
104 from lp.services.propertycache import cachedproperty
105 from lp.services.webapp import (
106 canonical_url,
107@@ -105,6 +128,7 @@
108 from lp.services.webapp.escaping import structured
109 from lp.services.webapp.interfaces import ICanonicalUrlData
110 from lp.services.webapp.publisher import DataDownloadView
111+from lp.services.webapp.snapshot import notify_modified
112 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
113 from lp.snappy.browser.hassnaps import HasSnapsViewMixin
114
115@@ -211,7 +235,14 @@
116 usedfor = IGitRepository
117 facet = "branches"
118 title = "Edit Git repository"
119- links = ["edit", "reviewer", "webhooks", "activity", "delete"]
120+ links = [
121+ "edit",
122+ "reviewer",
123+ "permissions",
124+ "activity",
125+ "webhooks",
126+ "delete",
127+ ]
128
129 @enabled_with_permission("launchpad.Edit")
130 def edit(self):
131@@ -224,6 +255,11 @@
132 return Link("+reviewer", text, icon="edit")
133
134 @enabled_with_permission("launchpad.Edit")
135+ def permissions(self):
136+ text = "Manage permissions"
137+ return Link("+permissions", text, icon="edit")
138+
139+ @enabled_with_permission("launchpad.Edit")
140 def webhooks(self):
141 text = "Manage webhooks"
142 return Link(
143@@ -709,6 +745,452 @@
144 return self, ()
145
146
147+def encode_form_field_id(value):
148+ """Encode text for use in form field names.
149+
150+ We use a modified version of base32 which fits into CSS identifiers and
151+ so doesn't cause FormattersAPI.zope_css_id to do unhelpful things.
152+ """
153+ return base64.b32encode(
154+ value.encode("UTF-8")).decode("UTF-8").replace("=", "_")
155+
156+
157+def decode_form_field_id(encoded):
158+ """Inverse of `encode_form_field_id`."""
159+ return base64.b32decode(
160+ encoded.replace("_", "=").encode("UTF-8")).decode("UTF-8")
161+
162+
163+class GitRulePatternField(UniqueField):
164+
165+ errormessage = _("%s is already in use by another rule")
166+ attribute = "ref_pattern"
167+ _content_iface = IGitRepository
168+
169+ def __init__(self, ref_prefix, rule=None, *args, **kwargs):
170+ self.ref_prefix = ref_prefix
171+ self.rule = rule
172+ super(GitRulePatternField, self).__init__(*args, **kwargs)
173+
174+ def _getByAttribute(self, ref_pattern):
175+ """See `UniqueField`."""
176+ if self._content_iface.providedBy(self.context):
177+ return self.context.getRule(self.ref_prefix + ref_pattern)
178+ else:
179+ return None
180+
181+ def unchanged(self, input):
182+ """See `UniqueField`."""
183+ return (
184+ self.rule is not None and
185+ self.ref_prefix + input == self.rule.ref_pattern)
186+
187+ def set(self, object, value):
188+ """See `IField`."""
189+ if value is not None:
190+ value = value.strip()
191+ super(GitRulePatternField, self).set(object, value)
192+
193+
194+class GitRepositoryPermissionsView(LaunchpadFormView):
195+ """A view to manage repository permissions."""
196+
197+ @property
198+ def label(self):
199+ return "Manage permissions for %s" % self.context.identity
200+
201+ page_title = "Manage permissions"
202+
203+ @cachedproperty
204+ def repository(self):
205+ return self.context
206+
207+ @cachedproperty
208+ def rules(self):
209+ return self.repository.getRules()
210+
211+ @cachedproperty
212+ def branch_rules(self):
213+ return [
214+ rule for rule in self.rules
215+ if rule.ref_pattern.startswith(u"refs/heads/")]
216+
217+ @cachedproperty
218+ def tag_rules(self):
219+ return [
220+ rule for rule in self.rules
221+ if rule.ref_pattern.startswith(u"refs/tags/")]
222+
223+ @cachedproperty
224+ def other_rules(self):
225+ return [
226+ rule for rule in self.rules
227+ if not rule.ref_pattern.startswith(u"refs/heads/") and
228+ not rule.ref_pattern.startswith(u"refs/tags/")]
229+
230+ def _getRuleGrants(self, rule):
231+ def grantee_key(grant):
232+ if grant.grantee is not None:
233+ return grant.grantee_type, grant.grantee.name
234+ else:
235+ return (grant.grantee_type,)
236+
237+ return sorted(rule.grants, key=grantee_key)
238+
239+ def _parseRefPattern(self, ref_pattern):
240+ """Parse a pattern into a prefix and the displayed portion."""
241+ for prefix in (u"refs/heads/", u"refs/tags/"):
242+ if ref_pattern.startswith(prefix):
243+ return prefix, ref_pattern[len(prefix):]
244+ return u"", ref_pattern
245+
246+ def _getFieldName(self, name, ref_pattern, grantee=None):
247+ """Get the combined field name for a ref pattern and optional grantee.
248+
249+ In order to be able to render a permissions table, we encode the ref
250+ pattern and the grantee in the form field name.
251+ """
252+ suffix = "." + encode_form_field_id(ref_pattern)
253+ if grantee is not None:
254+ if IPerson.providedBy(grantee):
255+ suffix += "." + str(grantee.id)
256+ else:
257+ suffix += "._" + grantee.name.lower()
258+ return name + suffix
259+
260+ def _parseFieldName(self, field_name):
261+ """Parse a combined field name as described in `_getFieldName`.
262+
263+ :raises UnexpectedFormData: if the field name cannot be parsed or
264+ the grantee cannot be found.
265+ """
266+ field_bits = field_name.split(".")
267+ if len(field_bits) < 2:
268+ raise UnexpectedFormData(
269+ "Cannot parse field name: %s" % field_name)
270+ field_type = field_bits[0]
271+ try:
272+ ref_pattern = decode_form_field_id(field_bits[1])
273+ except TypeError:
274+ raise UnexpectedFormData(
275+ "Cannot parse field name: %s" % field_name)
276+ if len(field_bits) > 2:
277+ grantee_id = field_bits[2]
278+ if grantee_id.startswith("_"):
279+ grantee_id = grantee_id[1:]
280+ try:
281+ grantee = GitGranteeType.getTermByToken(grantee_id).value
282+ except LookupError:
283+ grantee = None
284+ else:
285+ try:
286+ grantee_id = int(grantee_id)
287+ except ValueError:
288+ grantee = None
289+ else:
290+ grantee = getUtility(IPersonSet).get(grantee_id)
291+ if grantee is None or grantee == GitGranteeType.PERSON:
292+ raise UnexpectedFormData("No such grantee: %s" % grantee_id)
293+ else:
294+ grantee = None
295+ return field_type, ref_pattern, grantee
296+
297+ def _getPermissionsTerm(self, grant):
298+ """Return a term from `GitPermissionsVocabulary` for this grant."""
299+ vocabulary = getVocabularyRegistry().get(grant, "GitPermissions")
300+ try:
301+ return vocabulary.getTerm(grant.permissions)
302+ except LookupError:
303+ # This should never happen, because GitPermissionsVocabulary
304+ # adds a custom term for the context grant if necessary.
305+ raise AssertionError(
306+ "Could not find GitPermissions term for %r" % grant)
307+
308+ def setUpFields(self):
309+ """See `LaunchpadFormView`."""
310+ position_fields = []
311+ pattern_fields = []
312+ delete_fields = []
313+ readonly_grantee_fields = []
314+ grantee_fields = []
315+ permissions_fields = []
316+
317+ default_permissions_by_prefix = {
318+ "refs/heads/": "can_push",
319+ "refs/tags/": "can_create",
320+ "": "can_push",
321+ }
322+
323+ for rule_index, rule in enumerate(self.rules):
324+ # Remove the usual branch/tag prefixes from patterns. The full
325+ # pattern goes into form field names, so no data is lost here.
326+ ref_pattern = rule.ref_pattern
327+ ref_prefix, short_pattern = self._parseRefPattern(ref_pattern)
328+ position_fields.append(
329+ Int(
330+ __name__=self._getFieldName("position", ref_pattern),
331+ required=True, readonly=False, default=rule_index + 1))
332+ pattern_fields.append(
333+ GitRulePatternField(
334+ __name__=self._getFieldName("pattern", ref_pattern),
335+ required=True, readonly=False, ref_prefix=ref_prefix,
336+ rule=rule, default=short_pattern))
337+ delete_fields.append(
338+ Bool(
339+ __name__=self._getFieldName("delete", ref_pattern),
340+ readonly=False, default=False))
341+ for grant in self._getRuleGrants(rule):
342+ grantee = grant.combined_grantee
343+ readonly_grantee_fields.append(
344+ GitGranteeField(
345+ __name__=self._getFieldName(
346+ "grantee", ref_pattern, grantee),
347+ required=False, readonly=True, default=grantee,
348+ rule=rule))
349+ permissions_fields.append(
350+ Choice(
351+ __name__=self._getFieldName(
352+ "permissions", ref_pattern, grantee),
353+ source=GitPermissionsVocabulary(grant),
354+ readonly=False,
355+ default=self._getPermissionsTerm(grant).value))
356+ delete_fields.append(
357+ Bool(
358+ __name__=self._getFieldName(
359+ "delete", ref_pattern, grantee),
360+ readonly=False, default=False))
361+ grantee_fields.append(
362+ GitGranteeField(
363+ __name__=self._getFieldName("grantee", ref_pattern),
364+ required=False, readonly=False, rule=rule))
365+ permissions_vocabulary = GitPermissionsVocabulary(rule)
366+ permissions_fields.append(
367+ Choice(
368+ __name__=self._getFieldName(
369+ "permissions", ref_pattern),
370+ source=permissions_vocabulary, readonly=False,
371+ default=permissions_vocabulary.getTermByToken(
372+ default_permissions_by_prefix[ref_prefix]).value))
373+ for ref_prefix in ("refs/heads/", "refs/tags/"):
374+ position_fields.append(
375+ Int(
376+ __name__=self._getFieldName("new-position", ref_prefix),
377+ required=False, readonly=True))
378+ pattern_fields.append(
379+ GitRulePatternField(
380+ __name__=self._getFieldName("new-pattern", ref_prefix),
381+ required=False, readonly=False, ref_prefix=ref_prefix))
382+
383+ self.form_fields = (
384+ form.FormFields(
385+ *position_fields,
386+ custom_widget=CustomWidgetFactory(IntWidget, displayWidth=2)) +
387+ form.FormFields(*pattern_fields) +
388+ form.FormFields(*delete_fields) +
389+ form.FormFields(
390+ *readonly_grantee_fields,
391+ custom_widget=CustomWidgetFactory(GitGranteeDisplayWidget)) +
392+ form.FormFields(
393+ *grantee_fields,
394+ custom_widget=CustomWidgetFactory(GitGranteeWidget)) +
395+ form.FormFields(*permissions_fields))
396+
397+ def setUpWidgets(self, context=None):
398+ """See `LaunchpadFormView`."""
399+ super(GitRepositoryPermissionsView, self).setUpWidgets(
400+ context=context)
401+ for widget in self.widgets:
402+ widget.display_label = False
403+ widget.hint = None
404+
405+ @property
406+ def cancel_url(self):
407+ return canonical_url(self.context)
408+
409+ def getRuleWidgets(self, rule):
410+ widgets_by_name = {widget.name: widget for widget in self.widgets}
411+ ref_pattern = rule.ref_pattern
412+ position_field_name = (
413+ "field." + self._getFieldName("position", ref_pattern))
414+ pattern_field_name = (
415+ "field." + self._getFieldName("pattern", ref_pattern))
416+ delete_field_name = (
417+ "field." + self._getFieldName("delete", ref_pattern))
418+ grant_widgets = []
419+ for grant in self._getRuleGrants(rule):
420+ grantee = grant.combined_grantee
421+ grantee_field_name = (
422+ "field." + self._getFieldName("grantee", ref_pattern, grantee))
423+ permissions_field_name = (
424+ "field." +
425+ self._getFieldName("permissions", ref_pattern, grantee))
426+ delete_grant_field_name = (
427+ "field." + self._getFieldName("delete", ref_pattern, grantee))
428+ grant_widgets.append({
429+ "grantee": widgets_by_name[grantee_field_name],
430+ "permissions": widgets_by_name[permissions_field_name],
431+ "delete": widgets_by_name[delete_grant_field_name],
432+ })
433+ new_grantee_field_name = (
434+ "field." + self._getFieldName("grantee", ref_pattern))
435+ new_permissions_field_name = (
436+ "field." + self._getFieldName("permissions", ref_pattern))
437+ new_grant_widgets = {
438+ "grantee": widgets_by_name[new_grantee_field_name],
439+ "permissions": widgets_by_name[new_permissions_field_name],
440+ }
441+ return {
442+ "position": widgets_by_name[position_field_name],
443+ "pattern": widgets_by_name[pattern_field_name],
444+ "delete": widgets_by_name.get(delete_field_name),
445+ "grants": grant_widgets,
446+ "new_grant": new_grant_widgets,
447+ }
448+
449+ def getNewRuleWidgets(self, ref_prefix):
450+ widgets_by_name = {widget.name: widget for widget in self.widgets}
451+ new_position_field_name = (
452+ "field." + self._getFieldName("new-position", ref_prefix))
453+ new_pattern_field_name = (
454+ "field." + self._getFieldName("new-pattern", ref_prefix))
455+ return {
456+ "position": widgets_by_name[new_position_field_name],
457+ "pattern": widgets_by_name[new_pattern_field_name],
458+ }
459+
460+ def updateRepositoryFromData(self, repository, data):
461+ pattern_field_names = sorted(
462+ name for name in data if name.split(".")[0] == "pattern")
463+ new_pattern_field_names = sorted(
464+ name for name in data if name.split(".")[0] == "new-pattern")
465+ permissions_field_names = sorted(
466+ name for name in data if name.split(".")[0] == "permissions")
467+
468+ # Fetch rules before making any changes, since their ref_patterns
469+ # may change as a result of this update.
470+ rule_map = {rule.ref_pattern: rule for rule in self.repository.rules}
471+ grant_map = {
472+ (grant.rule.ref_pattern, grant.combined_grantee): grant
473+ for grant in self.repository.grants}
474+
475+ # Patterns must be processed in rule order so that position changes
476+ # work in a reasonably natural way.
477+ ordered_patterns = []
478+ for pattern_field_name in pattern_field_names:
479+ _, ref_pattern, _ = self._parseFieldName(pattern_field_name)
480+ if ref_pattern is not None:
481+ rule = rule_map.get(ref_pattern)
482+ ordered_patterns.append(
483+ (pattern_field_name, ref_pattern, rule))
484+ ordered_patterns.sort(key=lambda item: item[2].position)
485+
486+ for pattern_field_name, ref_pattern, rule in ordered_patterns:
487+ prefix, _ = self._parseRefPattern(ref_pattern)
488+ rule = rule_map.get(ref_pattern)
489+ delete_field_name = self._getFieldName("delete", ref_pattern)
490+ # If the rule was already deleted by somebody else, then we
491+ # have nothing to do.
492+ if rule is not None and data.get(delete_field_name):
493+ rule.destroySelf(self.user)
494+ rule_map[ref_pattern] = rule = None
495+ position_field_name = self._getFieldName("position", ref_pattern)
496+ if rule is not None:
497+ new_position = max(0, data[position_field_name] - 1)
498+ self.repository.moveRule(rule, new_position, self.user)
499+ new_pattern = prefix + data[pattern_field_name]
500+ if rule is not None and new_pattern != rule.ref_pattern:
501+ with notify_modified(rule, ["ref_pattern"]):
502+ rule.ref_pattern = new_pattern
503+
504+ for new_pattern_field_name in new_pattern_field_names:
505+ _, prefix, _ = self._parseFieldName(new_pattern_field_name)
506+ if data[new_pattern_field_name]:
507+ # This is an "add rule" entry.
508+ new_position_field_name = self._getFieldName(
509+ "position", prefix)
510+ new_pattern = prefix + data[new_pattern_field_name]
511+ rule = rule_map.get(new_pattern)
512+ if rule is None:
513+ if new_position_field_name in data:
514+ new_position = max(
515+ 0, data[new_position_field_name] - 1)
516+ else:
517+ new_position = None
518+ rule = repository.addRule(
519+ new_pattern, self.user, position=new_position)
520+ if prefix == "refs/tags/":
521+ # Tags are a special case: on creation, they
522+ # automatically get a grant of create permissions to
523+ # the repository owner (suppressing the normal
524+ # ability of the repository owner to push protected
525+ # references).
526+ rule.addGrant(
527+ GitGranteeType.REPOSITORY_OWNER, self.user,
528+ can_create=True)
529+
530+ for permissions_field_name in permissions_field_names:
531+ _, ref_pattern, grantee = self._parseFieldName(
532+ permissions_field_name)
533+ if ref_pattern not in rule_map:
534+ self.addError(structured(
535+ "Cannot edit grants for nonexistent rule %s", ref_pattern))
536+ return
537+ rule = rule_map.get(ref_pattern)
538+ if rule is None:
539+ # Already deleted.
540+ continue
541+
542+ # Find or create the corresponding grant. We only create a
543+ # grant if explicitly processing an "add grant" entry in the UI;
544+ # if there isn't already a grant for an existing entry that's
545+ # being modified, implicitly adding it is probably too
546+ # confusing.
547+ permissions = data[permissions_field_name]
548+ grant = None
549+ if grantee is not None:
550+ # This entry should correspond to an existing grant. Make
551+ # whatever changes were requested to it.
552+ grant = grant_map.get((ref_pattern, grantee))
553+ delete_field_name = self._getFieldName(
554+ "delete", ref_pattern, grantee)
555+ # If the grant was already deleted by somebody else, then we
556+ # have nothing to do.
557+ if grant is not None and data.get(delete_field_name):
558+ grant.destroySelf(self.user)
559+ grant = None
560+ if grant is not None and permissions != grant.permissions:
561+ with notify_modified(
562+ grant,
563+ ["can_create", "can_push", "can_force_push"]):
564+ grant.permissions = permissions
565+ else:
566+ # This is an "add grant" entry.
567+ grantee_field_name = self._getFieldName("grantee", ref_pattern)
568+ grantee = data.get(grantee_field_name)
569+ if grantee:
570+ grant = grant_map.get((ref_pattern, grantee))
571+ if grant is None:
572+ rule.addGrant(
573+ grantee, self.user, permissions=permissions)
574+ elif permissions != grant.permissions:
575+ # Somebody else added the grant since the form was
576+ # last rendered. Updating it with the permissions
577+ # from this request seems best.
578+ with notify_modified(
579+ grant,
580+ ["can_create", "can_push", "can_force_push"]):
581+ grant.permissions = permissions
582+
583+ self.request.response.addNotification(
584+ "Saved permissions for %s" % self.context.identity)
585+ self.next_url = canonical_url(self.context, view_name="+permissions")
586+
587+ @action("Save", name="save")
588+ def save_action(self, action, data):
589+ with notify_modified(self.repository, []):
590+ self.updateRepositoryFromData(self.repository, data)
591+
592+
593 class GitRepositoryDeletionView(LaunchpadFormView):
594
595 schema = IGitRepository
596
597=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
598--- lib/lp/code/browser/tests/test_gitrepository.py 2018-11-08 15:33:03 +0000
599+++ lib/lp/code/browser/tests/test_gitrepository.py 2018-11-09 22:50:29 +0000
600@@ -7,16 +7,26 @@
601
602 __metaclass__ = type
603
604+import base64
605 from datetime import datetime
606 import doctest
607+from operator import attrgetter
608+import re
609 from textwrap import dedent
610
611 from fixtures import FakeLogger
612 import pytz
613+import soupmatchers
614 from storm.store import Store
615 from testtools.matchers import (
616+ AfterPreprocessing,
617 DocTestMatches,
618 Equals,
619+ Is,
620+ MatchesDict,
621+ MatchesListwise,
622+ MatchesSetwise,
623+ MatchesStructure,
624 )
625 import transaction
626 from zope.component import getUtility
627@@ -26,11 +36,16 @@
628 from zope.security.proxy import removeSecurityProxy
629
630 from lp.app.enums import InformationType
631+from lp.app.errors import UnexpectedFormData
632 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
633 from lp.app.interfaces.services import IService
634+from lp.code.browser.gitrepository import encode_form_field_id
635 from lp.code.enums import (
636 BranchMergeProposalStatus,
637 CodeReviewVote,
638+ GitActivityType,
639+ GitGranteeType,
640+ GitPermissionType,
641 GitRepositoryType,
642 )
643 from lp.code.interfaces.revision import IRevisionSet
644@@ -40,7 +55,10 @@
645 VCSType,
646 )
647 from lp.registry.interfaces.accesspolicy import IAccessPolicySource
648-from lp.registry.interfaces.person import PersonVisibility
649+from lp.registry.interfaces.person import (
650+ IPerson,
651+ PersonVisibility,
652+ )
653 from lp.services.beautifulsoup import BeautifulSoup
654 from lp.services.database.constants import UTC_NOW
655 from lp.services.features.testing import FeatureFixture
656@@ -1097,6 +1115,599 @@
657 browser.headers["Content-Disposition"])
658
659
660+class TestGitRepositoryPermissionsView(BrowserTestCase):
661+
662+ layer = DatabaseFunctionalLayer
663+
664+ def test_rules_properties(self):
665+ repository = self.factory.makeGitRepository()
666+ heads_rule = self.factory.makeGitRule(
667+ repository=repository, ref_pattern="refs/heads/*")
668+ tags_rule = self.factory.makeGitRule(
669+ repository=repository, ref_pattern="refs/tags/*")
670+ catch_all_rule = self.factory.makeGitRule(
671+ repository=repository, ref_pattern="*")
672+ login_person(repository.owner)
673+ view = create_initialized_view(repository, name="+permissions")
674+ self.assertEqual([heads_rule], view.branch_rules)
675+ self.assertEqual([tags_rule], view.tag_rules)
676+ self.assertEqual([catch_all_rule], view.other_rules)
677+
678+ def test__getRuleGrants(self):
679+ rule = self.factory.makeGitRule()
680+ grantees = sorted(
681+ [self.factory.makePerson() for _ in range(3)],
682+ key=attrgetter("name"))
683+ for grantee in (grantees[1], grantees[0], grantees[2]):
684+ self.factory.makeGitRuleGrant(rule=rule, grantee=grantee)
685+ self.factory.makeGitRuleGrant(
686+ rule=rule, grantee=GitGranteeType.REPOSITORY_OWNER)
687+ login_person(rule.repository.owner)
688+ view = create_initialized_view(rule.repository, name="+permissions")
689+ self.assertThat(view._getRuleGrants(rule), MatchesListwise([
690+ MatchesStructure.byEquality(
691+ grantee_type=GitGranteeType.REPOSITORY_OWNER),
692+ MatchesStructure.byEquality(grantee=grantees[0]),
693+ MatchesStructure.byEquality(grantee=grantees[1]),
694+ MatchesStructure.byEquality(grantee=grantees[2]),
695+ ]))
696+
697+ def test__parseRefPattern(self):
698+ repository = self.factory.makeGitRepository()
699+ login_person(repository.owner)
700+ view = create_initialized_view(repository, name="+permissions")
701+ self.assertEqual(
702+ ("refs/heads/", "stable/*"),
703+ view._parseRefPattern("refs/heads/stable/*"))
704+ self.assertEqual(
705+ ("refs/tags/", "1.0"), view._parseRefPattern("refs/tags/1.0"))
706+ self.assertEqual(
707+ ("", "refs/other/*"), view._parseRefPattern("refs/other/*"))
708+ self.assertEqual(("", "*"), view._parseRefPattern("*"))
709+
710+ def test__getFieldName_no_grantee(self):
711+ repository = self.factory.makeGitRepository()
712+ login_person(repository.owner)
713+ view = create_initialized_view(repository, name="+permissions")
714+ encoded_ref_pattern = base64.b32encode(
715+ b"refs/heads/*").replace("=", "_").decode("UTF-8")
716+ self.assertEqual(
717+ "field.%s" % encoded_ref_pattern,
718+ view._getFieldName("field", "refs/heads/*"))
719+
720+ def test__getFieldName_grantee_repository_owner(self):
721+ repository = self.factory.makeGitRepository()
722+ login_person(repository.owner)
723+ view = create_initialized_view(repository, name="+permissions")
724+ encoded_ref_pattern = base64.b32encode(
725+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
726+ self.assertEqual(
727+ "field.%s._repository_owner" % encoded_ref_pattern,
728+ view._getFieldName(
729+ "field", "refs/tags/*",
730+ grantee=GitGranteeType.REPOSITORY_OWNER))
731+
732+ def test__getFieldName_grantee_person(self):
733+ repository = self.factory.makeGitRepository()
734+ grantee = self.factory.makePerson()
735+ login_person(repository.owner)
736+ view = create_initialized_view(repository, name="+permissions")
737+ encoded_ref_pattern = base64.b32encode(
738+ b"refs/*").replace("=", "_").decode("UTF-8")
739+ self.assertEqual(
740+ "field.%s.%s" % (encoded_ref_pattern, grantee.id),
741+ view._getFieldName("field", "refs/*", grantee=grantee))
742+
743+ def test__parseFieldName_too_few_components(self):
744+ repository = self.factory.makeGitRepository()
745+ login_person(repository.owner)
746+ view = create_initialized_view(repository, name="+permissions")
747+ self.assertRaises(UnexpectedFormData, view._parseFieldName, "field")
748+
749+ def test__parseFieldName_bad_ref_pattern(self):
750+ repository = self.factory.makeGitRepository()
751+ login_person(repository.owner)
752+ view = create_initialized_view(repository, name="+permissions")
753+ self.assertRaises(
754+ UnexpectedFormData, view._parseFieldName, "field.nonsense")
755+
756+ def test__parseFieldName_no_grantee(self):
757+ repository = self.factory.makeGitRepository()
758+ login_person(repository.owner)
759+ view = create_initialized_view(repository, name="+permissions")
760+ encoded_ref_pattern = base64.b32encode(
761+ b"refs/heads/*").replace("=", "_").decode("UTF-8")
762+ self.assertEqual(
763+ ("permissions", "refs/heads/*", None),
764+ view._parseFieldName("permissions.%s" % encoded_ref_pattern))
765+
766+ def test__parseFieldName_grantee_unknown_type(self):
767+ repository = self.factory.makeGitRepository()
768+ login_person(repository.owner)
769+ view = create_initialized_view(repository, name="+permissions")
770+ encoded_ref_pattern = base64.b32encode(
771+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
772+ self.assertRaises(
773+ UnexpectedFormData, view._parseFieldName,
774+ "field.%s._nonsense" % encoded_ref_pattern)
775+ self.assertRaises(
776+ UnexpectedFormData, view._parseFieldName,
777+ "field.%s._person" % encoded_ref_pattern)
778+
779+ def test__parseFieldName_grantee_repository_owner(self):
780+ repository = self.factory.makeGitRepository()
781+ login_person(repository.owner)
782+ view = create_initialized_view(repository, name="+permissions")
783+ encoded_ref_pattern = base64.b32encode(
784+ b"refs/tags/*").replace("=", "_").decode("UTF-8")
785+ self.assertEqual(
786+ ("pattern", "refs/tags/*", GitGranteeType.REPOSITORY_OWNER),
787+ view._parseFieldName(
788+ "pattern.%s._repository_owner" % encoded_ref_pattern))
789+
790+ def test__parseFieldName_grantee_unknown_person(self):
791+ repository = self.factory.makeGitRepository()
792+ grantee = self.factory.makePerson()
793+ login_person(repository.owner)
794+ view = create_initialized_view(repository, name="+permissions")
795+ encoded_ref_pattern = base64.b32encode(
796+ b"refs/*").replace("=", "_").decode("UTF-8")
797+ self.assertRaises(
798+ UnexpectedFormData, view._parseFieldName,
799+ "delete.%s.%s" % (encoded_ref_pattern, grantee.id * 2))
800+
801+ def test__parseFieldName_grantee_person(self):
802+ repository = self.factory.makeGitRepository()
803+ grantee = self.factory.makePerson()
804+ login_person(repository.owner)
805+ view = create_initialized_view(repository, name="+permissions")
806+ encoded_ref_pattern = base64.b32encode(
807+ b"refs/*").replace("=", "_").decode("UTF-8")
808+ self.assertEqual(
809+ ("delete", "refs/*", grantee),
810+ view._parseFieldName(
811+ "delete.%s.%s" % (encoded_ref_pattern, grantee.id)))
812+
813+ def test__getPermissionsTerm_standard(self):
814+ grant = self.factory.makeGitRuleGrant(
815+ ref_pattern="refs/heads/*", can_create=True, can_push=True)
816+ login_person(grant.repository.owner)
817+ view = create_initialized_view(grant.repository, name="+permissions")
818+ self.assertThat(
819+ view._getPermissionsTerm(grant), MatchesStructure.byEquality(
820+ value={
821+ GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH},
822+ token="can_push",
823+ title="Can push"))
824+
825+ def test__getPermissionsTerm_custom(self):
826+ grant = self.factory.makeGitRuleGrant(
827+ ref_pattern="refs/heads/*", can_force_push=True)
828+ login_person(grant.repository.owner)
829+ view = create_initialized_view(grant.repository, name="+permissions")
830+ self.assertThat(
831+ view._getPermissionsTerm(grant), MatchesStructure.byEquality(
832+ value={GitPermissionType.CAN_FORCE_PUSH},
833+ token="custom",
834+ title="Custom permissions: force-push"))
835+
836+ def _matchesCells(self, row_tag, cell_matchers):
837+ return AfterPreprocessing(
838+ str, soupmatchers.HTMLContains(*(
839+ soupmatchers.Within(row_tag, cell_matcher)
840+ for cell_matcher in cell_matchers)))
841+
842+ def _matchesRule(self, position, pattern, short_pattern):
843+ rule_tag = soupmatchers.Tag(
844+ "rule row", "tr", attrs={"class": "git-rule"})
845+ suffix = "." + encode_form_field_id(pattern)
846+ position_field_name = "field.position" + suffix
847+ pattern_field_name = "field.pattern" + suffix
848+ delete_field_name = "field.delete" + suffix
849+ return self._matchesCells(rule_tag, [
850+ soupmatchers.Within(
851+ soupmatchers.Tag("position cell", "td"),
852+ soupmatchers.Tag(
853+ "position widget", "input",
854+ attrs={"name": position_field_name, "value": position})),
855+ soupmatchers.Within(
856+ soupmatchers.Tag("pattern cell", "td"),
857+ soupmatchers.Tag(
858+ "pattern widget", "input",
859+ attrs={
860+ "name": pattern_field_name,
861+ "value": short_pattern,
862+ })),
863+ soupmatchers.Within(
864+ soupmatchers.Tag("delete cell", "td"),
865+ soupmatchers.Tag(
866+ "delete widget", "input",
867+ attrs={"name": delete_field_name})),
868+ ])
869+
870+ def _matchesNewRule(self, ref_prefix):
871+ new_rule_tag = soupmatchers.Tag(
872+ "new rule row", "tr", attrs={"class": "git-new-rule"})
873+ suffix = "." + encode_form_field_id(ref_prefix)
874+ new_position_field_name = "field.new-position" + suffix
875+ new_pattern_field_name = "field.new-pattern" + suffix
876+ return self._matchesCells(new_rule_tag, [
877+ soupmatchers.Within(
878+ soupmatchers.Tag("position cell", "td"),
879+ soupmatchers.Tag(
880+ "position widget", "input",
881+ attrs={"name": new_position_field_name, "value": ""})),
882+ soupmatchers.Within(
883+ soupmatchers.Tag("pattern cell", "td"),
884+ soupmatchers.Tag(
885+ "pattern widget", "input",
886+ attrs={"name": new_pattern_field_name, "value": ""})),
887+ ])
888+
889+ def _matchesRuleGrant(self, pattern, grantee, permissions_token,
890+ permissions_title):
891+ rule_grant_tag = soupmatchers.Tag(
892+ "rule grant row", "tr", attrs={"class": "git-rule-grant"})
893+ suffix = "." + encode_form_field_id(pattern)
894+ if IPerson.providedBy(grantee):
895+ suffix += "." + str(grantee.id)
896+ grantee_widget_matcher = soupmatchers.Tag(
897+ "grantee widget", "a", attrs={"href": canonical_url(grantee)},
898+ text=" " + grantee.display_name)
899+ else:
900+ suffix += "._" + grantee.name.lower()
901+ grantee_widget_matcher = soupmatchers.Tag(
902+ "grantee widget", "label",
903+ text=re.compile(re.escape(grantee.title)))
904+ permissions_field_name = "field.permissions" + suffix
905+ delete_field_name = "field.delete" + suffix
906+ return self._matchesCells(rule_grant_tag, [
907+ soupmatchers.Within(
908+ soupmatchers.Tag("grantee cell", "td"),
909+ grantee_widget_matcher),
910+ soupmatchers.Within(
911+ soupmatchers.Tag("permissions cell", "td"),
912+ soupmatchers.Within(
913+ soupmatchers.Tag(
914+ "permissions widget", "select",
915+ attrs={"name": permissions_field_name}),
916+ soupmatchers.Tag(
917+ "selected permissions option", "option",
918+ attrs={
919+ "selected": "selected",
920+ "value": permissions_token,
921+ },
922+ text=permissions_title))),
923+ soupmatchers.Within(
924+ soupmatchers.Tag("delete cell", "td"),
925+ soupmatchers.Tag(
926+ "delete widget", "input",
927+ attrs={"name": delete_field_name})),
928+ ])
929+
930+ def _matchesNewRuleGrant(self, pattern, permissions_token):
931+ rule_grant_tag = soupmatchers.Tag(
932+ "rule grant row", "tr", attrs={"class": "git-new-rule-grant"})
933+ suffix = "." + encode_form_field_id(pattern)
934+ grantee_field_name = "field.grantee" + suffix
935+ permissions_field_name = "field.permissions" + suffix
936+ return self._matchesCells(rule_grant_tag, [
937+ soupmatchers.Within(
938+ soupmatchers.Tag("grantee cell", "td"),
939+ soupmatchers.Tag(
940+ "grantee widget", "input",
941+ attrs={"name": grantee_field_name})),
942+ soupmatchers.Within(
943+ soupmatchers.Tag("permissions cell", "td"),
944+ soupmatchers.Within(
945+ soupmatchers.Tag(
946+ "permissions widget", "select",
947+ attrs={"name": permissions_field_name}),
948+ soupmatchers.Tag(
949+ "selected permissions option", "option",
950+ attrs={
951+ "selected": "selected",
952+ "value": permissions_token,
953+ }))),
954+ ])
955+
956+ def test_rules_table(self):
957+ repository = self.factory.makeGitRepository()
958+ heads_rule = self.factory.makeGitRule(
959+ repository=repository, ref_pattern="refs/heads/stable/*")
960+ heads_grantee_1 = self.factory.makePerson(
961+ name=self.factory.getUniqueString("person-name-a"))
962+ heads_grantee_2 = self.factory.makePerson(
963+ name=self.factory.getUniqueString("person-name-b"))
964+ self.factory.makeGitRuleGrant(
965+ rule=heads_rule, grantee=heads_grantee_1, can_push=True)
966+ self.factory.makeGitRuleGrant(
967+ rule=heads_rule, grantee=heads_grantee_2, can_force_push=True)
968+ tags_rule = self.factory.makeGitRule(
969+ repository=repository, ref_pattern="refs/tags/*")
970+ self.factory.makeGitRuleGrant(
971+ rule=tags_rule, grantee=GitGranteeType.REPOSITORY_OWNER)
972+ login_person(repository.owner)
973+ view = create_initialized_view(
974+ repository, name="+permissions", principal=repository.owner)
975+ rules_table = find_tag_by_id(view(), "rules-table")
976+ rows = rules_table.findAll("tr", {"class": True})
977+ self.assertThat(rows, MatchesListwise([
978+ self._matchesRule("1", "refs/heads/stable/*", "stable/*"),
979+ self._matchesRuleGrant(
980+ "refs/heads/stable/*", heads_grantee_1, "can_push_existing",
981+ "Can push if the branch already exists"),
982+ self._matchesRuleGrant(
983+ "refs/heads/stable/*", heads_grantee_2, "custom",
984+ "Custom permissions: force-push"),
985+ self._matchesNewRuleGrant("refs/heads/stable/*", "can_push"),
986+ self._matchesNewRule("refs/heads/"),
987+ self._matchesRule("2", "refs/tags/*", "*"),
988+ self._matchesRuleGrant(
989+ "refs/tags/*", GitGranteeType.REPOSITORY_OWNER,
990+ "cannot_create", "Cannot create"),
991+ self._matchesNewRuleGrant("refs/tags/*", "can_create"),
992+ self._matchesNewRule("refs/tags/"),
993+ ]))
994+
995+ def assertHasRules(self, repository, ref_patterns):
996+ self.assertThat(list(repository.rules), MatchesListwise([
997+ MatchesStructure.byEquality(ref_pattern=ref_pattern)
998+ for ref_pattern in ref_patterns
999+ ]))
1000+
1001+ def assertHasSavedNotification(self, view, repository):
1002+ self.assertThat(view.request.response.notifications, MatchesListwise([
1003+ MatchesStructure.byEquality(
1004+ message="Saved permissions for %s" % repository.identity),
1005+ ]))
1006+
1007+ def test_save_add_rules(self):
1008+ repository = self.factory.makeGitRepository()
1009+ self.factory.makeGitRule(
1010+ repository=repository, ref_pattern="refs/heads/stable/*")
1011+ removeSecurityProxy(repository.getActivity()).remove()
1012+ login_person(repository.owner)
1013+ encoded_heads_prefix = encode_form_field_id("refs/heads/")
1014+ encoded_tags_prefix = encode_form_field_id("refs/tags/")
1015+ form = {
1016+ "field.new-pattern." + encoded_heads_prefix: "*",
1017+ "field.new-pattern." + encoded_tags_prefix: "1.0",
1018+ "field.actions.save": "Save",
1019+ }
1020+ view = create_initialized_view(
1021+ repository, name="+permissions", form=form,
1022+ principal=repository.owner)
1023+ self.assertHasRules(
1024+ repository,
1025+ ["refs/tags/1.0", "refs/heads/stable/*", "refs/heads/*"])
1026+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1027+ # Adding a tag rule automatically adds a repository owner grant.
1028+ MatchesStructure(
1029+ changer=Equals(repository.owner),
1030+ changee=Is(None),
1031+ what_changed=Equals(GitActivityType.GRANT_ADDED),
1032+ new_value=MatchesDict({
1033+ "changee_type": Equals("Repository owner"),
1034+ "ref_pattern": Equals("refs/tags/1.0"),
1035+ "can_create": Is(True),
1036+ "can_push": Is(False),
1037+ "can_force_push": Is(False),
1038+ })),
1039+ MatchesStructure(
1040+ changer=Equals(repository.owner),
1041+ what_changed=Equals(GitActivityType.RULE_ADDED),
1042+ new_value=MatchesDict({
1043+ "ref_pattern": Equals("refs/tags/1.0"),
1044+ "position": Equals(0),
1045+ })),
1046+ MatchesStructure(
1047+ changer=Equals(repository.owner),
1048+ what_changed=Equals(GitActivityType.RULE_ADDED),
1049+ new_value=MatchesDict({
1050+ "ref_pattern": Equals("refs/heads/*"),
1051+ # Initially inserted at 1, although refs/tags/1.0 was
1052+ # later inserted before it.
1053+ "position": Equals(1),
1054+ })),
1055+ ]))
1056+ self.assertHasSavedNotification(view, repository)
1057+
1058+ def test_save_add_duplicate_rule(self):
1059+ repository = self.factory.makeGitRepository()
1060+ self.factory.makeGitRule(
1061+ repository=repository, ref_pattern="refs/heads/stable/*")
1062+ transaction.commit()
1063+ login_person(repository.owner)
1064+ encoded_heads_prefix = encode_form_field_id("refs/heads/")
1065+ form = {
1066+ "field.new-pattern." + encoded_heads_prefix: "stable/*",
1067+ "field.actions.save": "Save",
1068+ }
1069+ view = create_initialized_view(
1070+ repository, name="+permissions", form=form,
1071+ principal=repository.owner)
1072+ self.assertThat(view.errors, MatchesListwise([
1073+ MatchesStructure(
1074+ field_name=Equals("new-pattern." + encoded_heads_prefix),
1075+ errors=MatchesStructure.byEquality(
1076+ args=("stable/* is already in use by another rule",))),
1077+ ]))
1078+ self.assertHasRules(repository, ["refs/heads/stable/*"])
1079+
1080+ def test_save_move_rule(self):
1081+ repository = self.factory.makeGitRepository()
1082+ self.factory.makeGitRule(
1083+ repository=repository, ref_pattern="refs/heads/stable/*")
1084+ self.factory.makeGitRule(
1085+ repository=repository, ref_pattern="refs/heads/*/next")
1086+ encoded_patterns = [
1087+ encode_form_field_id(rule.ref_pattern)
1088+ for rule in repository.rules]
1089+ removeSecurityProxy(repository.getActivity()).remove()
1090+ login_person(repository.owner)
1091+ # Positions are 1-based in the UI.
1092+ form = {
1093+ "field.position." + encoded_patterns[0]: "2",
1094+ "field.pattern." + encoded_patterns[0]: "stable/*",
1095+ "field.position." + encoded_patterns[1]: "1",
1096+ "field.pattern." + encoded_patterns[1]: "*/more-next",
1097+ "field.actions.save": "Save",
1098+ }
1099+ view = create_initialized_view(
1100+ repository, name="+permissions", form=form,
1101+ principal=repository.owner)
1102+ self.assertHasRules(
1103+ repository, ["refs/heads/*/more-next", "refs/heads/stable/*"])
1104+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1105+ MatchesStructure(
1106+ changer=Equals(repository.owner),
1107+ what_changed=Equals(GitActivityType.RULE_CHANGED),
1108+ old_value=MatchesDict({
1109+ "ref_pattern": Equals("refs/heads/*/next"),
1110+ "position": Equals(0),
1111+ }),
1112+ new_value=MatchesDict({
1113+ "ref_pattern": Equals("refs/heads/*/more-next"),
1114+ "position": Equals(0),
1115+ })),
1116+ # Only one rule is recorded as moving; the other is already in
1117+ # its new position by the time it's processed.
1118+ MatchesStructure(
1119+ changer=Equals(repository.owner),
1120+ what_changed=Equals(GitActivityType.RULE_MOVED),
1121+ old_value=MatchesDict({
1122+ "ref_pattern": Equals("refs/heads/stable/*"),
1123+ "position": Equals(0),
1124+ }),
1125+ new_value=MatchesDict({
1126+ "ref_pattern": Equals("refs/heads/stable/*"),
1127+ "position": Equals(1),
1128+ })),
1129+ ]))
1130+ self.assertHasSavedNotification(view, repository)
1131+
1132+ def test_save_change_grants(self):
1133+ repository = self.factory.makeGitRepository()
1134+ stable_rule = self.factory.makeGitRule(
1135+ repository=repository, ref_pattern="refs/heads/stable/*")
1136+ next_rule = self.factory.makeGitRule(
1137+ repository=repository, ref_pattern="refs/heads/*/next")
1138+ grantees = [self.factory.makePerson() for _ in range(3)]
1139+ self.factory.makeGitRuleGrant(
1140+ rule=stable_rule, grantee=GitGranteeType.REPOSITORY_OWNER,
1141+ can_create=True)
1142+ self.factory.makeGitRuleGrant(
1143+ rule=stable_rule,
1144+ grantee=grantees[0], can_create=True, can_push=True)
1145+ self.factory.makeGitRuleGrant(
1146+ rule=next_rule, grantee=grantees[1],
1147+ can_create=True, can_push=True, can_force_push=True)
1148+ encoded_patterns = [
1149+ encode_form_field_id(rule.ref_pattern)
1150+ for rule in repository.rules]
1151+ removeSecurityProxy(repository.getActivity()).remove()
1152+ login_person(repository.owner)
1153+ form = {
1154+ "field.permissions.%s._repository_owner" % encoded_patterns[0]: (
1155+ "can_push"),
1156+ "field.permissions.%s.%s" % (
1157+ encoded_patterns[0], grantees[0].id): "can_push",
1158+ "field.delete.%s.%s" % (encoded_patterns[0], grantees[0].id): "on",
1159+ "field.grantee.%s" % encoded_patterns[1]: "person",
1160+ "field.grantee.%s.person" % encoded_patterns[1]: grantees[2].name,
1161+ "field.permissions.%s" % encoded_patterns[1]: "can_push_existing",
1162+ "field.actions.save": "Save",
1163+ }
1164+ view = create_initialized_view(
1165+ repository, name="+permissions", form=form,
1166+ principal=repository.owner)
1167+ self.assertHasRules(
1168+ repository, ["refs/heads/stable/*", "refs/heads/*/next"])
1169+ self.assertThat(stable_rule.grants, MatchesSetwise(
1170+ MatchesStructure.byEquality(
1171+ grantee_type=GitGranteeType.REPOSITORY_OWNER,
1172+ can_create=True, can_push=True, can_force_push=False)))
1173+ self.assertThat(next_rule.grants, MatchesSetwise(
1174+ MatchesStructure.byEquality(
1175+ grantee=grantees[1],
1176+ can_create=True, can_push=True, can_force_push=True),
1177+ MatchesStructure.byEquality(
1178+ grantee=grantees[2],
1179+ can_create=False, can_push=True, can_force_push=False)))
1180+ self.assertThat(repository.getActivity(), MatchesSetwise(
1181+ MatchesStructure(
1182+ changer=Equals(repository.owner),
1183+ changee=Is(None),
1184+ what_changed=Equals(GitActivityType.GRANT_CHANGED),
1185+ old_value=Equals({
1186+ "changee_type": "Repository owner",
1187+ "ref_pattern": "refs/heads/stable/*",
1188+ "can_create": True,
1189+ "can_push": False,
1190+ "can_force_push": False,
1191+ }),
1192+ new_value=Equals({
1193+ "changee_type": "Repository owner",
1194+ "ref_pattern": "refs/heads/stable/*",
1195+ "can_create": True,
1196+ "can_push": True,
1197+ "can_force_push": False,
1198+ })),
1199+ MatchesStructure(
1200+ changer=Equals(repository.owner),
1201+ changee=Equals(grantees[0]),
1202+ what_changed=Equals(GitActivityType.GRANT_REMOVED),
1203+ old_value=Equals({
1204+ "changee_type": "Person",
1205+ "ref_pattern": "refs/heads/stable/*",
1206+ "can_create": True,
1207+ "can_push": True,
1208+ "can_force_push": False,
1209+ })),
1210+ MatchesStructure(
1211+ changer=Equals(repository.owner),
1212+ changee=Equals(grantees[2]),
1213+ what_changed=Equals(GitActivityType.GRANT_ADDED),
1214+ new_value=Equals({
1215+ "changee_type": "Person",
1216+ "ref_pattern": "refs/heads/*/next",
1217+ "can_create": False,
1218+ "can_push": True,
1219+ "can_force_push": False,
1220+ }))))
1221+ self.assertHasSavedNotification(view, repository)
1222+
1223+ def test_save_delete_rule(self):
1224+ repository = self.factory.makeGitRepository()
1225+ self.factory.makeGitRule(
1226+ repository=repository, ref_pattern="refs/heads/stable/*")
1227+ self.factory.makeGitRule(
1228+ repository=repository, ref_pattern="refs/heads/*")
1229+ removeSecurityProxy(repository.getActivity()).remove()
1230+ login_person(repository.owner)
1231+ encoded_pattern = encode_form_field_id("refs/heads/*")
1232+ form = {
1233+ "field.pattern." + encoded_pattern: "*",
1234+ "field.delete." + encoded_pattern: "on",
1235+ "field.actions.save": "Save",
1236+ }
1237+ view = create_initialized_view(
1238+ repository, name="+permissions", form=form,
1239+ principal=repository.owner)
1240+ self.assertHasRules(repository, ["refs/heads/stable/*"])
1241+ self.assertThat(list(repository.getActivity()), MatchesListwise([
1242+ MatchesStructure(
1243+ changer=Equals(repository.owner),
1244+ what_changed=Equals(GitActivityType.RULE_REMOVED),
1245+ old_value=MatchesDict({
1246+ "ref_pattern": Equals("refs/heads/*"),
1247+ "position": Equals(1),
1248+ })),
1249+ ]))
1250+ self.assertHasSavedNotification(view, repository)
1251+
1252+
1253 class TestGitRepositoryDeletionView(BrowserTestCase):
1254
1255 layer = DatabaseFunctionalLayer
1256
1257=== modified file 'lib/lp/code/interfaces/gitrule.py'
1258--- lib/lp/code/interfaces/gitrule.py 2018-10-23 16:17:39 +0000
1259+++ lib/lp/code/interfaces/gitrule.py 2018-11-09 22:50:29 +0000
1260@@ -158,6 +158,10 @@
1261 vocabulary="ValidPersonOrTeam",
1262 description=_("The person being granted access."))
1263
1264+ combined_grantee = Attribute(
1265+ "The overall grantee of this grant: either a `GitGranteeType` (other "
1266+ "than `PERSON`) or an `IPerson`.")
1267+
1268 date_created = Datetime(
1269 title=_("Date created"), required=True, readonly=True,
1270 description=_("The time when this grant was created."))
1271
1272=== modified file 'lib/lp/code/model/gitrule.py'
1273--- lib/lp/code/model/gitrule.py 2018-10-29 14:27:36 +0000
1274+++ lib/lp/code/model/gitrule.py 2018-11-09 22:50:29 +0000
1275@@ -310,6 +310,13 @@
1276 self.date_created = date_created
1277 self.date_last_modified = date_created
1278
1279+ @property
1280+ def combined_grantee(self):
1281+ if self.grantee_type == GitGranteeType.PERSON:
1282+ return self.grantee
1283+ else:
1284+ return self.grantee_type
1285+
1286 def __repr__(self):
1287 if self.grantee_type == GitGranteeType.PERSON:
1288 grantee_name = "~%s" % self.grantee.name
1289
1290=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
1291--- lib/lp/code/model/tests/test_gitrule.py 2018-10-21 17:38:05 +0000
1292+++ lib/lp/code/model/tests/test_gitrule.py 2018-11-09 22:50:29 +0000
1293@@ -594,6 +594,7 @@
1294 rule=Equals(rule),
1295 grantee_type=Equals(GitGranteeType.REPOSITORY_OWNER),
1296 grantee=Is(None),
1297+ combined_grantee=Equals(GitGranteeType.REPOSITORY_OWNER),
1298 can_create=Is(True),
1299 can_push=Is(False),
1300 can_force_push=Is(True),
1301@@ -614,6 +615,7 @@
1302 rule=Equals(rule),
1303 grantee_type=Equals(GitGranteeType.PERSON),
1304 grantee=Equals(grantee),
1305+ combined_grantee=Equals(grantee),
1306 can_create=Is(False),
1307 can_push=Is(True),
1308 can_force_push=Is(False),
1309
1310=== added file 'lib/lp/code/templates/gitrepository-permissions.pt'
1311--- lib/lp/code/templates/gitrepository-permissions.pt 1970-01-01 00:00:00 +0000
1312+++ lib/lp/code/templates/gitrepository-permissions.pt 2018-11-09 22:50:29 +0000
1313@@ -0,0 +1,192 @@
1314+<html
1315+ xmlns="http://www.w3.org/1999/xhtml"
1316+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1317+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1318+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1319+ metal:use-macro="view/macro:page/main_only"
1320+ i18n:domain="launchpad">
1321+<body>
1322+
1323+ <metal:macros fill-slot="bogus">
1324+ <metal:macro define-macro="rule-rows">
1325+ <tal:rule repeat="rule rules">
1326+ <tal:rule_widgets
1327+ define="rule_widgets python:view.getRuleWidgets(rule)">
1328+ <tr class="git-rule">
1329+ <td tal:define="widget nocall:rule_widgets/position">
1330+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1331+ </td>
1332+ <td tal:define="widget nocall:rule_widgets/pattern" colspan="2">
1333+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1334+ </td>
1335+ <td tal:define="widget nocall:rule_widgets/delete">
1336+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1337+ </td>
1338+ </tr>
1339+ <tr class="git-rule-grant"
1340+ tal:repeat="grant_widgets rule_widgets/grants">
1341+ <td></td>
1342+ <td tal:define="widget nocall:grant_widgets/grantee">
1343+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1344+ </td>
1345+ <td tal:define="widget nocall:grant_widgets/permissions">
1346+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1347+ </td>
1348+ <td tal:define="widget nocall:grant_widgets/delete">
1349+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1350+ </td>
1351+ </tr>
1352+ <tr class="git-new-rule-grant"
1353+ tal:define="new_grant_widgets rule_widgets/new_grant">
1354+ <td></td>
1355+ <td tal:define="widget nocall:new_grant_widgets/grantee">
1356+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1357+ </td>
1358+ <td tal:define="widget nocall:new_grant_widgets/permissions">
1359+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1360+ </td>
1361+ <td></td>
1362+ </tr>
1363+ </tal:rule_widgets>
1364+ </tal:rule>
1365+ <tal:allows-new-rule condition="ref_prefix">
1366+ <tr class="git-new-rule"
1367+ tal:define="new_rule_widgets python:view.getNewRuleWidgets(ref_prefix)">
1368+ <td tal:define="widget nocall:new_rule_widgets/position">
1369+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1370+ </td>
1371+ <td tal:define="widget nocall:new_rule_widgets/pattern" colspan="2">
1372+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1373+ </td>
1374+ <td></td>
1375+ </tr>
1376+ </tal:allows-new-rule>
1377+ </metal:macro>
1378+ </metal:macros>
1379+
1380+ <div metal:fill-slot="main">
1381+ <p>
1382+ By default, repository owners may create, push, force-push, or delete
1383+ any branch or tag in their repositories, and nobody else may modify
1384+ them in any way.
1385+ </p>
1386+ <p>
1387+ If any of the rules below matches a branch or tag, then it is
1388+ <em>protected</em>. By default, protecting a branch implicitly
1389+ prevents repository owners from force-pushing to it or deleting it,
1390+ while protecting a tag prevents repository owners from moving it.
1391+ Protecting a branch or tag also allows you to grant other permissions.
1392+ </p>
1393+ <p>
1394+ You may create rules that match a single branch or tag, or wildcard
1395+ rules that match a pattern: for example, <code>*</code> matches
1396+ everything, while <code>stable/*</code> matches
1397+ <code>stable/1.0</code> but not <code>master</code>.
1398+ </p>
1399+
1400+ <metal:grants-form use-macro="context/@@launchpad_form/form">
1401+ <div class="form" metal:fill-slot="widgets">
1402+ <table id="rules-table" class="listing"
1403+ style="max-width: 60em; margin-bottom: 1em;">
1404+ <thead>
1405+ <tr>
1406+ <th>Position</th>
1407+ <th colspan="2">Rule</th>
1408+ <th>Delete?</th>
1409+ </tr>
1410+ </thead>
1411+ <tbody>
1412+ <tr>
1413+ <td colspan="4">
1414+ <h3>Protected branches (under <code>refs/heads/</code>)</h3>
1415+ </td>
1416+ </tr>
1417+ <tal:branches define="rules view/branch_rules;
1418+ ref_prefix string:refs/heads/">
1419+ <metal:grants use-macro="template/macros/rule-rows" />
1420+ </tal:branches>
1421+
1422+ <tr>
1423+ <td colspan="4">
1424+ <h3>Protected tags (under <code>refs/tags/</code>)</h3>
1425+ </td>
1426+ </tr>
1427+ <tal:tags define="rules view/tag_rules;
1428+ ref_prefix string:refs/tags/">
1429+ <metal:grants use-macro="template/macros/rule-rows" />
1430+ </tal:tags>
1431+
1432+ <tal:has-other condition="view/other_rules">
1433+ <tr><td colspan="4"><h3>Other protected references</h3></td></tr>
1434+ <tal:other define="rules view/other_rules; ref_prefix nothing">
1435+ <metal:grants use-macro="template/macros/rule-rows" />
1436+ </tal:other>
1437+ </tal:has-other>
1438+ </tbody>
1439+ </table>
1440+
1441+ <p class="actions">
1442+ <input tal:replace="structure view/save_action/render" />
1443+ or <a tal:attributes="href view/cancel_url">Cancel</a>
1444+ </p>
1445+ </div>
1446+
1447+ <metal:buttons fill-slot="buttons" />
1448+ </metal:grants-form>
1449+
1450+ <h2>Wildcards</h2>
1451+ <p>The special characters used in wildcard rules are:</p>
1452+ <table class="listing narrow-listing">
1453+ <thead>
1454+ <tr>
1455+ <th>Pattern</th>
1456+ <th>Meaning</th>
1457+ </tr>
1458+ </thead>
1459+ <tbody>
1460+ <tr>
1461+ <td><code>*</code></td>
1462+ <td>matches zero or more characters</td>
1463+ </tr>
1464+ <tr>
1465+ <td><code>?</code></td>
1466+ <td>matches any single character</td>
1467+ </tr>
1468+ <tr>
1469+ <td><code>[chars]</code></td>
1470+ <td>matches any character in <em>chars</em></td>
1471+ </tr>
1472+ <tr>
1473+ <td><code>[!chars]</code></td>
1474+ <td>matches any character not in <em>chars</em></td>
1475+ </tr>
1476+ </tbody>
1477+ </table>
1478+
1479+ <h2>Effective permissions</h2>
1480+ <p>
1481+ Launchpad works out the effective permissions that a user has on a
1482+ protected branch as follows:
1483+ </p>
1484+ <ol>
1485+ <li>Take all the rules that match the branch.</li>
1486+ <li>
1487+ For each matching rule, select any grants whose grantee matches the
1488+ user, as long as the same grantee has not already been seen in an
1489+ earlier matching rule. (A user can be matched by more than one
1490+ grantee: for example, they might be in multiple teams.)
1491+ </li>
1492+ <li>
1493+ If the user is an owner of the repository and there was no previous
1494+ “Repository owner” grant, then add an implicit grant allowing them
1495+ to create or push.
1496+ </li>
1497+ <li>
1498+ The effective permission set is the union of the permissions granted
1499+ by all the selected grants.
1500+ </li>
1501+ </ol>
1502+ </div>
1503+
1504+</body>
1505+</html>