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