Merge ~twom/launchpad:git-branch-picker-unpicked into launchpad:master
- Git
- lp:~twom/launchpad
- git-branch-picker-unpicked
- Merge into master
Proposed by
Tom Wardill
Status: | Merged |
---|---|
Approved by: | Tom Wardill |
Approved revision: | b8f49cfde988017d2bb71c6bb52485d39072df21 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~twom/launchpad:git-branch-picker-unpicked |
Merge into: | launchpad:master |
Diff against target: |
1041 lines (+653/-78) 18 files modified
lib/lp/app/javascript/autocomplete.js (+102/-0) lib/lp/app/javascript/picker/picker.js (+4/-1) lib/lp/app/javascript/picker/picker_patcher.js (+23/-11) lib/lp/app/widgets/templates/form-picker-macros.pt (+1/-3) lib/lp/code/adapters/gitrepository.py (+1/-1) lib/lp/code/browser/widgets/configure.zcml (+9/-0) lib/lp/code/browser/widgets/gitref.py (+47/-27) lib/lp/code/browser/widgets/templates/gitref-picker.pt (+37/-0) lib/lp/code/browser/widgets/templates/gitref.pt (+3/-18) lib/lp/code/browser/widgets/tests/test_gitrefwidget.py (+1/-1) lib/lp/code/interfaces/gitrepository.py (+8/-0) lib/lp/code/model/gitref.py (+1/-0) lib/lp/code/vocabularies/configure.zcml (+24/-0) lib/lp/code/vocabularies/gitref.py (+175/-0) lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py (+189/-0) lib/lp/oci/browser/tests/test_ocirecipe.py (+8/-8) lib/lp/snappy/browser/tests/test_snap.py (+8/-6) lib/lp/snappy/model/snap.py (+12/-2) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+393928@code.launchpad.net |
Commit message
Add an autocompleting branch picker box to edit snap and oci recipes.
Description of the change
Add vocabularies and widgets based on them.
Add autocomplete JS.
To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) : | # |
- 4b87be0... by Tom Wardill
-
Remove unnecessary adapter
- 61f9bae... by Tom Wardill
-
Swap negative variable name
- f5b8d15... by Tom Wardill
-
Format imports
- 54b71ff... by Tom Wardill
-
Disallow head in pattern searches
- 2133a71... by Tom Wardill
-
Add tiebreak to order
- 4273b74... by Tom Wardill
-
Better display names
- bc82a23... by Tom Wardill
-
Handle remote refs
- 46de666... by Tom Wardill
-
Just close the picker
- 196f150... by Tom Wardill
-
Use an interface for the vocabulary
Revision history for this message
Tom Wardill (twom) : | # |
Revision history for this message
Colin Watson (cjwatson) wrote : | # |
I'd still like to see screenshots (let me know if you posted them somewhere and I missed it), but this otherwise mostly looks fine.
Revision history for this message
Tom Wardill (twom) wrote : | # |
- e97d357... by Tom Wardill
-
Better url detection
- 9ae4173... by Tom Wardill
-
Close the picker on git@ too
- 6743238... by Tom Wardill
-
Remove comment
- 576c5ea... by Tom Wardill
-
Remove blocking of HEAD, fix tie break in ordering
- 693a089... by Tom Wardill
-
Add labels
Revision history for this message
Colin Watson (cjwatson) : | # |
review:
Approve
- b8f49cf... by Tom Wardill
-
Labels with colons
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/app/javascript/autocomplete.js b/lib/lp/app/javascript/autocomplete.js | |||
2 | 0 | new file mode 100644 | 0 | new file mode 100644 |
3 | index 0000000..44b7c00 | |||
4 | --- /dev/null | |||
5 | +++ b/lib/lp/app/javascript/autocomplete.js | |||
6 | @@ -0,0 +1,102 @@ | |||
7 | 1 | /* Copyright 2017 Canonical Ltd. This software is licensed under the | ||
8 | 2 | * GNU Affero General Public License version 3 (see the file LICENSE). | ||
9 | 3 | */ | ||
10 | 4 | |||
11 | 5 | YUI.add('lp.app.autocomplete', function (Y) { | ||
12 | 6 | |||
13 | 7 | var namespace = Y.namespace('lp.app.autocomplete'); | ||
14 | 8 | |||
15 | 9 | namespace.getRepositoryCompletionURI = function (repo_node) { | ||
16 | 10 | var entered_uri = repo_node.get('value'); | ||
17 | 11 | if (entered_uri.startsWith("lp:")) { | ||
18 | 12 | var split = "+code/" + entered_uri.split("lp:")[1] | ||
19 | 13 | entered_uri = encodeURI(split); | ||
20 | 14 | } | ||
21 | 15 | else if (entered_uri.startsWith("~")) { | ||
22 | 16 | entered_uri = encodeURI(entered_uri); | ||
23 | 17 | } | ||
24 | 18 | else if (entered_uri.includes("://")) { | ||
25 | 19 | return null; | ||
26 | 20 | } | ||
27 | 21 | else if (entered_uri.startsWith("git@")) { | ||
28 | 22 | return null; | ||
29 | 23 | } | ||
30 | 24 | else { | ||
31 | 25 | entered_uri = encodeURI("+code/" + entered_uri); | ||
32 | 26 | } | ||
33 | 27 | |||
34 | 28 | var uri = '/'; | ||
35 | 29 | uri += entered_uri; | ||
36 | 30 | uri += '/@@+huge-vocabulary'; | ||
37 | 31 | return uri; | ||
38 | 32 | } | ||
39 | 33 | |||
40 | 34 | namespace.getRepoNode = function (path_node) { | ||
41 | 35 | var split = path_node._node['id'].split('.'); | ||
42 | 36 | split[2] = 'repository'; | ||
43 | 37 | var repository_target = split.join('.'); | ||
44 | 38 | var target_repo = Y.one('[id="' + repository_target + '"]'); | ||
45 | 39 | return target_repo; | ||
46 | 40 | } | ||
47 | 41 | |||
48 | 42 | namespace.getPathNode = function (path_node) { | ||
49 | 43 | var split = path_node._node['id'].split('.'); | ||
50 | 44 | split[2] = 'path'; | ||
51 | 45 | var path_target = split.join('.'); | ||
52 | 46 | var target_path = Y.one('[id="' + path_target + '"]'); | ||
53 | 47 | return target_path; | ||
54 | 48 | } | ||
55 | 49 | |||
56 | 50 | namespace.setupVocabAutocomplete = function (config, node) { | ||
57 | 51 | var qs = 'name=' + encodeURIComponent(config.vocabulary_name); | ||
58 | 52 | |||
59 | 53 | /* autocomplete will substitute these with appropriate encoding. */ | ||
60 | 54 | /* XXX cjwatson 2017-07-24: Perhaps we should pass batch={maxResults} | ||
61 | 55 | * too, but we need to make sure that it doesn't exceed max_batch_size. | ||
62 | 56 | */ | ||
63 | 57 | qs += '&search_text={query}'; | ||
64 | 58 | |||
65 | 59 | var repo_node = namespace.getRepoNode(node); | ||
66 | 60 | var uri = namespace.getRepositoryCompletionURI(repo_node); | ||
67 | 61 | |||
68 | 62 | node.plug(Y.Plugin.AutoComplete, { | ||
69 | 63 | queryDelay: 500, // milliseconds | ||
70 | 64 | requestTemplate: '?' + qs, | ||
71 | 65 | resultHighlighter: 'wordMatch', | ||
72 | 66 | resultListLocator: 'entries', | ||
73 | 67 | resultTextLocator: 'value', | ||
74 | 68 | source: uri | ||
75 | 69 | }); | ||
76 | 70 | |||
77 | 71 | repo_node.updated = function () { | ||
78 | 72 | var uri = namespace.getRepositoryCompletionURI(this); | ||
79 | 73 | path_node = namespace.getPathNode(this); | ||
80 | 74 | path_node.ac.set("source", uri); | ||
81 | 75 | } | ||
82 | 76 | // ideally this should take node to rebind `this` in the function | ||
83 | 77 | // but we're also calling it from the popup picker, which has a direct | ||
84 | 78 | // reference to the repo_node, so maintain the local `this` binding. | ||
85 | 79 | repo_node.on('valuechange', repo_node.updated); | ||
86 | 80 | }; | ||
87 | 81 | |||
88 | 82 | /** | ||
89 | 83 | * Add autocompletion to a text field. | ||
90 | 84 | * @param {Object} config Object literal of config name/value pairs. | ||
91 | 85 | * config.vocabulary_name: the named vocabulary to select from. | ||
92 | 86 | * config.input_element: the id of the text field to update with the | ||
93 | 87 | * selected value. | ||
94 | 88 | */ | ||
95 | 89 | namespace.addAutocomplete = function (config) { | ||
96 | 90 | var input_element = Y.one('[id="' + config.input_element + '"]'); | ||
97 | 91 | // The node may already have been processed. | ||
98 | 92 | if (input_element.ac) { | ||
99 | 93 | return; | ||
100 | 94 | } | ||
101 | 95 | namespace.setupVocabAutocomplete(config, input_element); | ||
102 | 96 | }; | ||
103 | 97 | |||
104 | 98 | }, '0.1', { | ||
105 | 99 | 'requires': [ | ||
106 | 100 | 'autocomplete', 'autocomplete-sources', 'datasource', 'lp' | ||
107 | 101 | ] | ||
108 | 102 | }); | ||
109 | diff --git a/lib/lp/app/javascript/picker/picker.js b/lib/lp/app/javascript/picker/picker.js | |||
110 | index e97b517..ad94344 100644 | |||
111 | --- a/lib/lp/app/javascript/picker/picker.js | |||
112 | +++ b/lib/lp/app/javascript/picker/picker.js | |||
113 | @@ -24,7 +24,7 @@ ns.Picker = Y.Base.create('picker', Y.lp.ui.PrettyOverlay, [], { | |||
114 | 24 | /** | 24 | /** |
115 | 25 | * The search input node. | 25 | * The search input node. |
116 | 26 | * | 26 | * |
118 | 27 | * @property _search_button | 27 | * @property _search_input |
119 | 28 | * @type Node | 28 | * @type Node |
120 | 29 | * @private | 29 | * @private |
121 | 30 | */ | 30 | */ |
122 | @@ -1301,6 +1301,9 @@ Y.extend(TextFieldPickerPlugin, Y.Plugin.Base, { | |||
123 | 1301 | // then the I-beam disappears. | 1301 | // then the I-beam disappears. |
124 | 1302 | input.blur(); | 1302 | input.blur(); |
125 | 1303 | input.focus(); | 1303 | input.focus(); |
126 | 1304 | if (typeof(input.updated) === "function") { | ||
127 | 1305 | input.updated(); | ||
128 | 1306 | } | ||
129 | 1304 | }); | 1307 | }); |
130 | 1305 | this.doAfter('show', function() { | 1308 | this.doAfter('show', function() { |
131 | 1306 | var selected_value = null; | 1309 | var selected_value = null; |
132 | diff --git a/lib/lp/app/javascript/picker/picker_patcher.js b/lib/lp/app/javascript/picker/picker_patcher.js | |||
133 | index e220aa8..895b5b7 100644 | |||
134 | --- a/lib/lp/app/javascript/picker/picker_patcher.js | |||
135 | +++ b/lib/lp/app/javascript/picker/picker_patcher.js | |||
136 | @@ -50,7 +50,7 @@ var _addPicker = function(config, show_widget_id) { | |||
137 | 50 | * @param {Object} config Object literal of config name/value pairs. The | 50 | * @param {Object} config Object literal of config name/value pairs. The |
138 | 51 | * values listed below are common for all picker types. | 51 | * values listed below are common for all picker types. |
139 | 52 | * config.vocabulary_name: the named vocabulary to select from. | 52 | * config.vocabulary_name: the named vocabulary to select from. |
141 | 53 | * config.vocabulary_filters: any vocaulary filters to use. | 53 | * config.vocabulary_filters: any vocabulary filters to use. |
142 | 54 | * config.input_element: the id of the text field to update with the | 54 | * config.input_element: the id of the text field to update with the |
143 | 55 | * selected value. | 55 | * selected value. |
144 | 56 | * config.picker_type: the type of picker to create (default or person). | 56 | * config.picker_type: the type of picker to create (default or person). |
145 | @@ -619,23 +619,35 @@ namespace.setup_vocab_picker = function (picker, vocabulary, config) { | |||
146 | 619 | // use the context to limit the results to the same project. | 619 | // use the context to limit the results to the same project. |
147 | 620 | 620 | ||
148 | 621 | var uri = ''; | 621 | var uri = ''; |
149 | 622 | var context_ok = true; | ||
150 | 622 | if (Y.Lang.isFunction(config.getContextPath)) { | 623 | if (Y.Lang.isFunction(config.getContextPath)) { |
152 | 623 | uri += config.getContextPath() + '/'; | 624 | var context_path = config.getContextPath(); |
153 | 625 | if (context_path !== null) { | ||
154 | 626 | uri += config.getContextPath() + '/'; | ||
155 | 627 | } else { | ||
156 | 628 | // No context, so proceed straight to validation. | ||
157 | 629 | picker.set('error', e); | ||
158 | 630 | picker.set('search_mode', false); | ||
159 | 631 | picker.fire('validate', search_text); | ||
160 | 632 | context_ok = false; | ||
161 | 633 | } | ||
162 | 624 | } else if (Y.Lang.isValue(config.context)) { | 634 | } else if (Y.Lang.isValue(config.context)) { |
163 | 625 | uri += Y.lp.get_url_path( | 635 | uri += Y.lp.get_url_path( |
164 | 626 | config.context.get('web_link')) + '/'; | 636 | config.context.get('web_link')) + '/'; |
165 | 627 | } | 637 | } |
166 | 628 | uri += '@@+huge-vocabulary?' + qs; | 638 | uri += '@@+huge-vocabulary?' + qs; |
167 | 629 | 639 | ||
177 | 630 | var yio = (config.yio !== undefined) ? config.yio : Y; | 640 | if (context_ok) { |
178 | 631 | yio.io(uri, { | 641 | var yio = (config.yio !== undefined) ? config.yio : Y; |
179 | 632 | headers: {'Accept': 'application/json'}, | 642 | yio.io(uri, { |
180 | 633 | timeout: 20000, | 643 | headers: {'Accept': 'application/json'}, |
181 | 634 | on: { | 644 | timeout: 20000, |
182 | 635 | success: success_handler, | 645 | on: { |
183 | 636 | failure: failure_handler | 646 | success: success_handler, |
184 | 637 | } | 647 | failure: failure_handler |
185 | 638 | }); | 648 | } |
186 | 649 | }); | ||
187 | 650 | } | ||
188 | 639 | // Or we can pass in a vocabulary directly. | 651 | // Or we can pass in a vocabulary directly. |
189 | 640 | } else { | 652 | } else { |
190 | 641 | display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1); | 653 | display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1); |
191 | diff --git a/lib/lp/app/widgets/templates/form-picker-macros.pt b/lib/lp/app/widgets/templates/form-picker-macros.pt | |||
192 | index 80f5be1..9710e52 100644 | |||
193 | --- a/lib/lp/app/widgets/templates/form-picker-macros.pt | |||
194 | +++ b/lib/lp/app/widgets/templates/form-picker-macros.pt | |||
195 | @@ -33,9 +33,7 @@ | |||
196 | 33 | LPJS.use('node', 'lp.app.picker', function(Y) { | 33 | LPJS.use('node', 'lp.app.picker', function(Y) { |
197 | 34 | var config = ${view/json_config}; | 34 | var config = ${view/json_config}; |
198 | 35 | var show_widget_id = '${view/show_widget_id}'; | 35 | var show_widget_id = '${view/show_widget_id}'; |
202 | 36 | Y.on('domready', function(e) { | 36 | Y.lp.app.picker.addPicker(config, show_widget_id); |
200 | 37 | Y.lp.app.picker.addPicker(config, show_widget_id); | ||
201 | 38 | }); | ||
203 | 39 | }); | 37 | }); |
204 | 40 | "/> | 38 | "/> |
205 | 41 | </metal:form-picker> | 39 | </metal:form-picker> |
206 | diff --git a/lib/lp/code/adapters/gitrepository.py b/lib/lp/code/adapters/gitrepository.py | |||
207 | index e55d5bb..d265ca9 100644 | |||
208 | --- a/lib/lp/code/adapters/gitrepository.py | |||
209 | +++ b/lib/lp/code/adapters/gitrepository.py | |||
210 | @@ -1,7 +1,7 @@ | |||
211 | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
212 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
213 | 3 | 3 | ||
215 | 4 | """Components related to Git repositories.""" | 4 | """Components and adapters related to Git repositories.""" |
216 | 5 | 5 | ||
217 | 6 | __metaclass__ = type | 6 | __metaclass__ = type |
218 | 7 | __all__ = [ | 7 | __all__ = [ |
219 | diff --git a/lib/lp/code/browser/widgets/configure.zcml b/lib/lp/code/browser/widgets/configure.zcml | |||
220 | index 913d8c6..e056e42 100644 | |||
221 | --- a/lib/lp/code/browser/widgets/configure.zcml | |||
222 | +++ b/lib/lp/code/browser/widgets/configure.zcml | |||
223 | @@ -16,4 +16,13 @@ | |||
224 | 16 | permission="zope.Public" | 16 | permission="zope.Public" |
225 | 17 | /> | 17 | /> |
226 | 18 | 18 | ||
227 | 19 | <view | ||
228 | 20 | type="zope.publisher.interfaces.browser.IBrowserRequest" | ||
229 | 21 | for="zope.schema.interfaces.IChoice | ||
230 | 22 | lp.code.vocabularies.gitref.GitRefVocabulary" | ||
231 | 23 | provides="zope.formlib.interfaces.IInputWidget" | ||
232 | 24 | factory="lp.code.browser.widgets.gitref.GitRefPickerWidget" | ||
233 | 25 | permission="zope.Public" | ||
234 | 26 | /> | ||
235 | 27 | |||
236 | 19 | </configure> | 28 | </configure> |
237 | diff --git a/lib/lp/code/browser/widgets/gitref.py b/lib/lp/code/browser/widgets/gitref.py | |||
238 | index cfe8e40..b4acd01 100644 | |||
239 | --- a/lib/lp/code/browser/widgets/gitref.py | |||
240 | +++ b/lib/lp/code/browser/widgets/gitref.py | |||
241 | @@ -9,7 +9,6 @@ __all__ = [ | |||
242 | 9 | 9 | ||
243 | 10 | import six | 10 | import six |
244 | 11 | from zope.browserpage import ViewPageTemplateFile | 11 | from zope.browserpage import ViewPageTemplateFile |
245 | 12 | from zope.component import getUtility | ||
246 | 13 | from zope.formlib.interfaces import ( | 12 | from zope.formlib.interfaces import ( |
247 | 14 | ConversionError, | 13 | ConversionError, |
248 | 15 | IInputWidget, | 14 | IInputWidget, |
249 | @@ -23,16 +22,12 @@ from zope.formlib.widget import ( | |||
250 | 23 | InputWidget, | 22 | InputWidget, |
251 | 24 | ) | 23 | ) |
252 | 25 | from zope.interface import implementer | 24 | from zope.interface import implementer |
257 | 26 | from zope.schema import ( | 25 | from zope.schema import Choice |
254 | 27 | Choice, | ||
255 | 28 | TextLine, | ||
256 | 29 | ) | ||
258 | 30 | from zope.schema.interfaces import IChoice | 26 | from zope.schema.interfaces import IChoice |
259 | 31 | 27 | ||
260 | 32 | from lp.app.errors import UnexpectedFormData | 28 | from lp.app.errors import UnexpectedFormData |
261 | 33 | from lp.app.validators import LaunchpadValidationError | 29 | from lp.app.validators import LaunchpadValidationError |
262 | 34 | from lp.app.widgets.popup import VocabularyPickerWidget | 30 | from lp.app.widgets.popup import VocabularyPickerWidget |
263 | 35 | from lp.code.interfaces.gitref import IGitRefRemoteSet | ||
264 | 36 | from lp.code.interfaces.gitrepository import IGitRepository | 31 | from lp.code.interfaces.gitrepository import IGitRepository |
265 | 37 | from lp.services.fields import URIField | 32 | from lp.services.fields import URIField |
266 | 38 | from lp.services.webapp.interfaces import ( | 33 | from lp.services.webapp.interfaces import ( |
267 | @@ -104,28 +99,34 @@ class GitRepositoryPickerWidget(VocabularyPickerWidget): | |||
268 | 104 | class GitRefWidget(BrowserWidget, InputWidget): | 99 | class GitRefWidget(BrowserWidget, InputWidget): |
269 | 105 | 100 | ||
270 | 106 | template = ViewPageTemplateFile("templates/gitref.pt") | 101 | template = ViewPageTemplateFile("templates/gitref.pt") |
271 | 107 | display_label = False | ||
272 | 108 | _widgets_set_up = False | 102 | _widgets_set_up = False |
273 | 109 | 103 | ||
274 | 110 | # If True, allow entering external repository URLs. | 104 | # If True, allow entering external repository URLs. |
275 | 111 | allow_external = False | 105 | allow_external = False |
276 | 112 | 106 | ||
277 | 107 | # If True, only allow reference paths to be branches (refs/heads/*). | ||
278 | 108 | require_branch = False | ||
279 | 109 | |||
280 | 113 | def setUpSubWidgets(self): | 110 | def setUpSubWidgets(self): |
281 | 114 | if self._widgets_set_up: | 111 | if self._widgets_set_up: |
282 | 115 | return | 112 | return |
283 | 113 | path_vocabulary = "GitBranch" if self.require_branch else "GitRef" | ||
284 | 116 | fields = [ | 114 | fields = [ |
285 | 117 | GitRepositoryField( | 115 | GitRepositoryField( |
288 | 118 | __name__="repository", title=u"Git repository", | 116 | __name__="repository", title=u"Repository", |
289 | 119 | required=False, vocabulary="GitRepository", | 117 | required=self.context.required, vocabulary="GitRepository", |
290 | 120 | allow_external=self.allow_external), | 118 | allow_external=self.allow_external), |
292 | 121 | TextLine(__name__="path", title=u"Git branch", required=False), | 119 | Choice( |
293 | 120 | __name__="path", title=u"Branch", | ||
294 | 121 | required=self.context.required, | ||
295 | 122 | vocabulary=path_vocabulary), | ||
296 | 122 | ] | 123 | ] |
297 | 123 | for field in fields: | 124 | for field in fields: |
298 | 124 | setUpWidget( | 125 | setUpWidget( |
299 | 125 | self, field.__name__, field, IInputWidget, prefix=self.name) | 126 | self, field.__name__, field, IInputWidget, prefix=self.name) |
300 | 126 | self._widgets_set_up = True | 127 | self._widgets_set_up = True |
301 | 127 | 128 | ||
303 | 128 | def setRenderedValue(self, value): | 129 | def setRenderedValue(self, value, with_path=True): |
304 | 129 | """See `IWidget`.""" | 130 | """See `IWidget`.""" |
305 | 130 | self.setUpSubWidgets() | 131 | self.setUpSubWidgets() |
306 | 131 | if value is not None: | 132 | if value is not None: |
307 | @@ -133,7 +134,13 @@ class GitRefWidget(BrowserWidget, InputWidget): | |||
308 | 133 | self.repository_widget.setRenderedValue(value.repository_url) | 134 | self.repository_widget.setRenderedValue(value.repository_url) |
309 | 134 | else: | 135 | else: |
310 | 135 | self.repository_widget.setRenderedValue(value.repository) | 136 | self.repository_widget.setRenderedValue(value.repository) |
312 | 136 | self.path_widget.setRenderedValue(value.path) | 137 | # if we're only talking about branches, we can deal in the |
313 | 138 | # name, rather than the full ref/heads/* path | ||
314 | 139 | if with_path: | ||
315 | 140 | if self.require_branch: | ||
316 | 141 | self.path_widget.setRenderedValue(value.name) | ||
317 | 142 | else: | ||
318 | 143 | self.path_widget.setRenderedValue(value.path) | ||
319 | 137 | else: | 144 | else: |
320 | 138 | self.repository_widget.setRenderedValue(None) | 145 | self.repository_widget.setRenderedValue(None) |
321 | 139 | self.path_widget.setRenderedValue(None) | 146 | self.path_widget.setRenderedValue(None) |
322 | @@ -174,27 +181,31 @@ class GitRefWidget(BrowserWidget, InputWidget): | |||
323 | 174 | "There is no Git repository named '%s' registered in " | 181 | "There is no Git repository named '%s' registered in " |
324 | 175 | "Launchpad." % entered_name)) | 182 | "Launchpad." % entered_name)) |
325 | 176 | if self.path_widget.hasInput(): | 183 | if self.path_widget.hasInput(): |
331 | 177 | path = self.path_widget.getInputValue() | 184 | # We've potentially just tried to change the repository that is |
332 | 178 | else: | 185 | # involved, or changing from a bzr branch to a git repo, so there |
333 | 179 | path = None | 186 | # is no existing repository set up. We need to set this so we |
334 | 180 | if not path: | 187 | # can compare the ref against the 'new' repo. |
335 | 181 | if self.context.required: | 188 | if IGitRepository.providedBy(repository): |
336 | 189 | self.path_widget.vocabulary.setRepository(repository) | ||
337 | 190 | else: | ||
338 | 191 | self.path_widget.vocabulary.setRepositoryURL(repository) | ||
339 | 192 | try: | ||
340 | 193 | ref = self.path_widget.getInputValue() | ||
341 | 194 | except ConversionError: | ||
342 | 182 | raise WidgetInputError( | 195 | raise WidgetInputError( |
343 | 183 | self.name, self.label, | 196 | self.name, self.label, |
344 | 184 | LaunchpadValidationError( | 197 | LaunchpadValidationError( |
350 | 185 | "Please enter a Git branch path.")) | 198 | "The repository at %s does not contain a branch named " |
351 | 186 | else: | 199 | "'%s'." % ( |
352 | 187 | return | 200 | repository.display_name, |
353 | 188 | if self.allow_external and not IGitRepository.providedBy(repository): | 201 | self.path_widget._getFormInput()))) |
349 | 189 | ref = getUtility(IGitRefRemoteSet).new(repository, path) | ||
354 | 190 | else: | 202 | else: |
358 | 191 | ref = repository.getRefByPath(path) | 203 | ref = None |
359 | 192 | if ref is None: | 204 | if not ref and (repository or self.context.required): |
360 | 193 | raise WidgetInputError( | 205 | raise WidgetInputError( |
361 | 194 | self.name, self.label, | 206 | self.name, self.label, |
362 | 195 | LaunchpadValidationError( | 207 | LaunchpadValidationError( |
365 | 196 | "The repository at %s does not contain a branch named " | 208 | "Please enter a Git branch path.")) |
364 | 197 | "'%s'." % (repository.display_name, path))) | ||
366 | 198 | return ref | 209 | return ref |
367 | 199 | 210 | ||
368 | 200 | def error(self): | 211 | def error(self): |
369 | @@ -210,3 +221,12 @@ class GitRefWidget(BrowserWidget, InputWidget): | |||
370 | 210 | """See `IBrowserWidget`.""" | 221 | """See `IBrowserWidget`.""" |
371 | 211 | self.setUpSubWidgets() | 222 | self.setUpSubWidgets() |
372 | 212 | return self.template() | 223 | return self.template() |
373 | 224 | |||
374 | 225 | |||
375 | 226 | class GitRefPickerWidget(VocabularyPickerWidget): | ||
376 | 227 | |||
377 | 228 | __call__ = ViewPageTemplateFile("templates/gitref-picker.pt") | ||
378 | 229 | |||
379 | 230 | @property | ||
380 | 231 | def repository_id(self): | ||
381 | 232 | return self._prefix + "repository" | ||
382 | diff --git a/lib/lp/code/browser/widgets/templates/gitref-picker.pt b/lib/lp/code/browser/widgets/templates/gitref-picker.pt | |||
383 | 213 | new file mode 100644 | 233 | new file mode 100644 |
384 | index 0000000..fbc32dd | |||
385 | --- /dev/null | |||
386 | +++ b/lib/lp/code/browser/widgets/templates/gitref-picker.pt | |||
387 | @@ -0,0 +1,37 @@ | |||
388 | 1 | <tal:root | ||
389 | 2 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
390 | 3 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
391 | 4 | omit-tag=""> | ||
392 | 5 | |||
393 | 6 | <metal:form-picker use-macro="context/@@form-picker-macros/form-picker"> | ||
394 | 7 | <script metal:fill-slot="add-picker" tal:content="structure string: | ||
395 | 8 | LPJS.use('node', 'lp.app.autocomplete', 'lp.app.picker', function(Y) { | ||
396 | 9 | var config = ${view/json_config}; | ||
397 | 10 | var repository_id = '${view/repository_id}'; | ||
398 | 11 | if (repository_id !== '') { | ||
399 | 12 | config.getContextPath = function() { | ||
400 | 13 | var repository_value = Y.DOM.byId(repository_id).value; | ||
401 | 14 | // XXX cjwatson 2017-06-24: We don't have a straightforward | ||
402 | 15 | // URL parser available to us at the moment. This will do for | ||
403 | 16 | // now, since we just want to tell the difference between | ||
404 | 17 | // internal and external repositories. | ||
405 | 18 | // XXX twom 2020-11-18 | ||
406 | 19 | // This just closes the picker, not ideal. | ||
407 | 20 | // but would need some refactoring of picker_patcher.js | ||
408 | 21 | if (repository_value.indexOf('://') !== -1) { | ||
409 | 22 | return null; | ||
410 | 23 | } | ||
411 | 24 | else if (repository_value.indexOf('git@') !== -1) { | ||
412 | 25 | return null; | ||
413 | 26 | } | ||
414 | 27 | return '/' + repository_value; | ||
415 | 28 | }; | ||
416 | 29 | } | ||
417 | 30 | var show_widget_id = '${view/show_widget_id}'; | ||
418 | 31 | Y.lp.app.picker.addPicker(config, show_widget_id); | ||
419 | 32 | Y.lp.app.autocomplete.addAutocomplete(config); | ||
420 | 33 | }); | ||
421 | 34 | "/> | ||
422 | 35 | </metal:form-picker> | ||
423 | 36 | |||
424 | 37 | </tal:root> | ||
425 | diff --git a/lib/lp/code/browser/widgets/templates/gitref.pt b/lib/lp/code/browser/widgets/templates/gitref.pt | |||
426 | index f88a652..f49dfb7 100644 | |||
427 | --- a/lib/lp/code/browser/widgets/templates/gitref.pt | |||
428 | +++ b/lib/lp/code/browser/widgets/templates/gitref.pt | |||
429 | @@ -1,18 +1,3 @@ | |||
448 | 1 | <table> | 1 | <label tal:attributes="for view/repository_widget/name" tal:content="string:${view/repository_widget/label}:">Label</label><span title="Repository" tal:content="structure view/repository_widget" /> |
449 | 2 | <tr> | 2 | |
450 | 3 | <td> | 3 | <label tal:attributes="for view/path_widget/name" tal:content="string:${view/path_widget/label}:">Label</label><span title="Branch" tal:content="structure view/path_widget" /> |
433 | 4 | <tal:widget define="widget nocall:view/repository_widget"> | ||
434 | 5 | <metal:block | ||
435 | 6 | use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" /> | ||
436 | 7 | </tal:widget> | ||
437 | 8 | </td> | ||
438 | 9 | </tr> | ||
439 | 10 | <tr> | ||
440 | 11 | <td> | ||
441 | 12 | <tal:widget define="widget nocall:view/path_widget"> | ||
442 | 13 | <metal:block | ||
443 | 14 | use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" /> | ||
444 | 15 | </tal:widget> | ||
445 | 16 | </td> | ||
446 | 17 | </tr> | ||
447 | 18 | </table> | ||
451 | diff --git a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py | |||
452 | index b411651..2e2526c 100644 | |||
453 | --- a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py | |||
454 | +++ b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py | |||
455 | @@ -194,7 +194,7 @@ class TestGitRefWidget(WithScenarios, TestCaseWithFactory): | |||
456 | 194 | [ref] = self.factory.makeGitRefs() | 194 | [ref] = self.factory.makeGitRefs() |
457 | 195 | form = { | 195 | form = { |
458 | 196 | "field.git_ref.repository": ref.repository.unique_name, | 196 | "field.git_ref.repository": ref.repository.unique_name, |
460 | 197 | "field.git_ref.path": "non-existent", | 197 | "field.git_ref.path": u"non-existent", |
461 | 198 | } | 198 | } |
462 | 199 | self.assertGetInputValueError( | 199 | self.assertGetInputValueError( |
463 | 200 | form, | 200 | form, |
464 | diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py | |||
465 | index 192133a..ed17183 100644 | |||
466 | --- a/lib/lp/code/interfaces/gitrepository.py | |||
467 | +++ b/lib/lp/code/interfaces/gitrepository.py | |||
468 | @@ -13,6 +13,7 @@ __all__ = [ | |||
469 | 13 | 'IGitRepository', | 13 | 'IGitRepository', |
470 | 14 | 'IGitRepositoryDelta', | 14 | 'IGitRepositoryDelta', |
471 | 15 | 'IGitRepositorySet', | 15 | 'IGitRepositorySet', |
472 | 16 | 'IHasGitRepositoryURL', | ||
473 | 16 | 'user_has_special_git_repository_access', | 17 | 'user_has_special_git_repository_access', |
474 | 17 | ] | 18 | ] |
475 | 18 | 19 | ||
476 | @@ -1227,6 +1228,13 @@ class GitIdentityMixin: | |||
477 | 1227 | return identities | 1228 | return identities |
478 | 1228 | 1229 | ||
479 | 1229 | 1230 | ||
480 | 1231 | class IHasGitRepositoryURL(Interface): | ||
481 | 1232 | """Marker interface for objects that have a Git repository URL.""" | ||
482 | 1233 | |||
483 | 1234 | git_repository_url = Attribute( | ||
484 | 1235 | "The Git repository URL (possibly external)") | ||
485 | 1236 | |||
486 | 1237 | |||
487 | 1230 | def user_has_special_git_repository_access(user, repository=None): | 1238 | def user_has_special_git_repository_access(user, repository=None): |
488 | 1231 | """Admins have special access. | 1239 | """Admins have special access. |
489 | 1232 | 1240 | ||
490 | diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py | |||
491 | index f7dc142..406c8de 100644 | |||
492 | --- a/lib/lp/code/model/gitref.py | |||
493 | +++ b/lib/lp/code/model/gitref.py | |||
494 | @@ -933,6 +933,7 @@ class GitRefRemote(GitRefMixin): | |||
495 | 933 | 933 | ||
496 | 934 | def __eq__(self, other): | 934 | def __eq__(self, other): |
497 | 935 | return ( | 935 | return ( |
498 | 936 | other is not None and | ||
499 | 936 | self.repository_url == other.repository_url and | 937 | self.repository_url == other.repository_url and |
500 | 937 | self.path == other.path) | 938 | self.path == other.path) |
501 | 938 | 939 | ||
502 | diff --git a/lib/lp/code/vocabularies/configure.zcml b/lib/lp/code/vocabularies/configure.zcml | |||
503 | index 5782992..ef0933d 100644 | |||
504 | --- a/lib/lp/code/vocabularies/configure.zcml | |||
505 | +++ b/lib/lp/code/vocabularies/configure.zcml | |||
506 | @@ -88,4 +88,28 @@ | |||
507 | 88 | <allow interface="zope.schema.interfaces.IVocabularyTokenized"/> | 88 | <allow interface="zope.schema.interfaces.IVocabularyTokenized"/> |
508 | 89 | </class> | 89 | </class> |
509 | 90 | 90 | ||
510 | 91 | <securedutility | ||
511 | 92 | name="GitRef" | ||
512 | 93 | component=".gitref.GitRefVocabulary" | ||
513 | 94 | provides="zope.schema.interfaces.IVocabularyFactory"> | ||
514 | 95 | <allow interface="zope.schema.interfaces.IVocabularyFactory"/> | ||
515 | 96 | </securedutility> | ||
516 | 97 | |||
517 | 98 | <class class=".gitref.GitRefVocabulary"> | ||
518 | 99 | <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/> | ||
519 | 100 | <allow interface=".gitref.IRepositoryManagerGitRefVocabulary"/> | ||
520 | 101 | </class> | ||
521 | 102 | |||
522 | 103 | <securedutility | ||
523 | 104 | name="GitBranch" | ||
524 | 105 | component=".gitref.GitBranchVocabulary" | ||
525 | 106 | provides="zope.schema.interfaces.IVocabularyFactory"> | ||
526 | 107 | <allow interface="zope.schema.interfaces.IVocabularyFactory"/> | ||
527 | 108 | </securedutility> | ||
528 | 109 | |||
529 | 110 | <class class=".gitref.GitBranchVocabulary"> | ||
530 | 111 | <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/> | ||
531 | 112 | <allow interface=".gitref.IRepositoryManagerGitRefVocabulary"/> | ||
532 | 113 | </class> | ||
533 | 114 | |||
534 | 91 | </configure> | 115 | </configure> |
535 | diff --git a/lib/lp/code/vocabularies/gitref.py b/lib/lp/code/vocabularies/gitref.py | |||
536 | 92 | new file mode 100644 | 116 | new file mode 100644 |
537 | index 0000000..7d98208 | |||
538 | --- /dev/null | |||
539 | +++ b/lib/lp/code/vocabularies/gitref.py | |||
540 | @@ -0,0 +1,175 @@ | |||
541 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
542 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
543 | 3 | |||
544 | 4 | """Vocabularies that contain Git references.""" | ||
545 | 5 | |||
546 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
547 | 7 | |||
548 | 8 | |||
549 | 9 | __metaclass__ = type | ||
550 | 10 | __all__ = [ | ||
551 | 11 | "GitBranchVocabulary", | ||
552 | 12 | "GitRefVocabulary", | ||
553 | 13 | ] | ||
554 | 14 | |||
555 | 15 | from lazr.restful.interfaces import IReference | ||
556 | 16 | from storm.databases.postgres import Case | ||
557 | 17 | from storm.expr import ( | ||
558 | 18 | Desc, | ||
559 | 19 | Like, | ||
560 | 20 | like_escape, | ||
561 | 21 | ) | ||
562 | 22 | from zope.component import getUtility | ||
563 | 23 | from zope.interface import ( | ||
564 | 24 | implementer, | ||
565 | 25 | Interface, | ||
566 | 26 | ) | ||
567 | 27 | from zope.schema.vocabulary import SimpleTerm | ||
568 | 28 | from zope.security.proxy import isinstance as zope_isinstance | ||
569 | 29 | |||
570 | 30 | from lp.code.interfaces.gitref import IGitRefRemoteSet | ||
571 | 31 | from lp.code.interfaces.gitrepository import ( | ||
572 | 32 | IGitRepository, | ||
573 | 33 | IHasGitRepositoryURL, | ||
574 | 34 | ) | ||
575 | 35 | from lp.code.model.gitref import ( | ||
576 | 36 | GitRef, | ||
577 | 37 | GitRefRemote, | ||
578 | 38 | ) | ||
579 | 39 | from lp.services.database.interfaces import IStore | ||
580 | 40 | from lp.services.webapp.vocabulary import ( | ||
581 | 41 | CountableIterator, | ||
582 | 42 | IHugeVocabulary, | ||
583 | 43 | StormVocabularyBase, | ||
584 | 44 | ) | ||
585 | 45 | |||
586 | 46 | |||
587 | 47 | class IRepositoryManagerGitRefVocabulary(Interface): | ||
588 | 48 | |||
589 | 49 | def setRepository(self, repository): | ||
590 | 50 | """Set the repository after the vocabulary was instantiated.""" | ||
591 | 51 | |||
592 | 52 | def setRepositoryURL(self, repository_url): | ||
593 | 53 | """Set the repository URL after the vocabulary was instantiated.""" | ||
594 | 54 | |||
595 | 55 | |||
596 | 56 | @implementer(IHugeVocabulary) | ||
597 | 57 | @implementer(IRepositoryManagerGitRefVocabulary) | ||
598 | 58 | class GitRefVocabulary(StormVocabularyBase): | ||
599 | 59 | """A vocabulary for references in a given Git repository.""" | ||
600 | 60 | |||
601 | 61 | _table = GitRef | ||
602 | 62 | displayname = "Select a branch or tag" | ||
603 | 63 | step_title = "Search" | ||
604 | 64 | |||
605 | 65 | def __init__(self, context): | ||
606 | 66 | super(GitRefVocabulary, self).__init__(context=context) | ||
607 | 67 | if IReference.providedBy(context): | ||
608 | 68 | context = context.context | ||
609 | 69 | try: | ||
610 | 70 | self.repository = IGitRepository(context) | ||
611 | 71 | except TypeError: | ||
612 | 72 | self.repository = None | ||
613 | 73 | try: | ||
614 | 74 | self.repository_url = ( | ||
615 | 75 | IHasGitRepositoryURL(context).git_repository_url) | ||
616 | 76 | except TypeError: | ||
617 | 77 | self.repository_url = None | ||
618 | 78 | |||
619 | 79 | def setRepository(self, repository): | ||
620 | 80 | """See `IRepositoryManagerGitRefVocabulary`.""" | ||
621 | 81 | self.repository = repository | ||
622 | 82 | self.repository_url = None | ||
623 | 83 | |||
624 | 84 | def setRepositoryURL(self, repository_url): | ||
625 | 85 | """See `IRepositoryManagerGitRefVocabulary`.""" | ||
626 | 86 | self.repository = None | ||
627 | 87 | self.repository_url = repository_url | ||
628 | 88 | |||
629 | 89 | def _assertHasRepository(self): | ||
630 | 90 | if self.repository is None and self.repository_url is None: | ||
631 | 91 | raise AssertionError( | ||
632 | 92 | "GitRefVocabulary cannot be used without setting a " | ||
633 | 93 | "repository or a repository URL.") | ||
634 | 94 | |||
635 | 95 | @property | ||
636 | 96 | def _order_by(self): | ||
637 | 97 | rank = Case( | ||
638 | 98 | cases=[(self._table.path == self.repository.default_branch, 2)], | ||
639 | 99 | default=1) | ||
640 | 100 | return [ | ||
641 | 101 | Desc(rank), | ||
642 | 102 | Desc(self._table.committer_date), | ||
643 | 103 | self._table.path] | ||
644 | 104 | |||
645 | 105 | def toTerm(self, ref): | ||
646 | 106 | """See `StormVocabularyBase`.""" | ||
647 | 107 | return SimpleTerm(ref, ref.path, ref.name) | ||
648 | 108 | |||
649 | 109 | def getTermByToken(self, token): | ||
650 | 110 | """See `IVocabularyTokenized`.""" | ||
651 | 111 | self._assertHasRepository() | ||
652 | 112 | if self.repository is not None: | ||
653 | 113 | ref = self.repository.getRefByPath(token) | ||
654 | 114 | if ref is None: | ||
655 | 115 | raise LookupError(token) | ||
656 | 116 | else: | ||
657 | 117 | ref = getUtility(IGitRefRemoteSet).new(self.repository_url, token) | ||
658 | 118 | return self.toTerm(ref) | ||
659 | 119 | |||
660 | 120 | def _makePattern(self, query=None): | ||
661 | 121 | parts = ["%"] | ||
662 | 122 | if query is not None: | ||
663 | 123 | parts.extend([query.lower().translate(like_escape), "%"]) | ||
664 | 124 | return "".join(parts) | ||
665 | 125 | |||
666 | 126 | def searchForTerms(self, query=None, vocab_filter=None): | ||
667 | 127 | """See `IHugeVocabulary.""" | ||
668 | 128 | self._assertHasRepository() | ||
669 | 129 | if self.repository is not None: | ||
670 | 130 | pattern = self._makePattern(query=query) | ||
671 | 131 | results = IStore(self._table).find( | ||
672 | 132 | self._table, | ||
673 | 133 | self._table.repository_id == self.repository.id, | ||
674 | 134 | Like(self._table.path, pattern, "!")).order_by(self._order_by) | ||
675 | 135 | else: | ||
676 | 136 | results = self.emptySelectResults() | ||
677 | 137 | return CountableIterator(results.count(), results, self.toTerm) | ||
678 | 138 | |||
679 | 139 | def getTerm(self, value): | ||
680 | 140 | # remote refs aren't database backed | ||
681 | 141 | if zope_isinstance(value, GitRefRemote): | ||
682 | 142 | return self.toTerm(value) | ||
683 | 143 | return super(GitRefVocabulary, self).getTerm(value) | ||
684 | 144 | |||
685 | 145 | def __len__(self): | ||
686 | 146 | """See `IVocabulary`.""" | ||
687 | 147 | return self.searchForTerms().count() | ||
688 | 148 | |||
689 | 149 | def __contains__(self, obj): | ||
690 | 150 | # We know nothing about GitRefRemote, so we just have to assume | ||
691 | 151 | # that they exist in the remote repository | ||
692 | 152 | if zope_isinstance(obj, GitRefRemote): | ||
693 | 153 | return True | ||
694 | 154 | if obj in self.repository.refs: | ||
695 | 155 | return True | ||
696 | 156 | return False | ||
697 | 157 | |||
698 | 158 | |||
699 | 159 | class GitBranchVocabulary(GitRefVocabulary): | ||
700 | 160 | """A vocabulary for branches in a given Git repository.""" | ||
701 | 161 | |||
702 | 162 | displayname = "Select a branch" | ||
703 | 163 | |||
704 | 164 | def _makePattern(self, query=None): | ||
705 | 165 | parts = [] | ||
706 | 166 | if query is None or not query.startswith("refs/heads/"): | ||
707 | 167 | parts.append("refs/heads/".translate(like_escape)) | ||
708 | 168 | parts.append("%") | ||
709 | 169 | if query is not None: | ||
710 | 170 | parts.extend([query.lower().translate(like_escape), "%"]) | ||
711 | 171 | return "".join(parts) | ||
712 | 172 | |||
713 | 173 | def toTerm(self, ref): | ||
714 | 174 | """See `StormVocabularyBase`.""" | ||
715 | 175 | return SimpleTerm(ref, ref.name, ref.name) | ||
716 | diff --git a/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py | |||
717 | 0 | new file mode 100644 | 176 | new file mode 100644 |
718 | index 0000000..41f5f83 | |||
719 | --- /dev/null | |||
720 | +++ b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py | |||
721 | @@ -0,0 +1,189 @@ | |||
722 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
723 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
724 | 3 | |||
725 | 4 | """Test the Git reference vocabularies.""" | ||
726 | 5 | |||
727 | 6 | from __future__ import absolute_import, print_function, unicode_literals | ||
728 | 7 | |||
729 | 8 | __metaclass__ = type | ||
730 | 9 | |||
731 | 10 | from datetime import ( | ||
732 | 11 | datetime, | ||
733 | 12 | timedelta, | ||
734 | 13 | ) | ||
735 | 14 | |||
736 | 15 | import pytz | ||
737 | 16 | from testtools.matchers import MatchesStructure | ||
738 | 17 | from zope.schema.vocabulary import SimpleTerm | ||
739 | 18 | from zope.security.proxy import removeSecurityProxy | ||
740 | 19 | |||
741 | 20 | from lp.code.vocabularies.gitref import ( | ||
742 | 21 | GitBranchVocabulary, | ||
743 | 22 | GitRefVocabulary, | ||
744 | 23 | ) | ||
745 | 24 | from lp.services.webapp.vocabulary import IHugeVocabulary | ||
746 | 25 | from lp.testing import TestCaseWithFactory | ||
747 | 26 | from lp.testing.layers import ZopelessDatabaseLayer | ||
748 | 27 | |||
749 | 28 | |||
750 | 29 | class TestGitRefVocabulary(TestCaseWithFactory): | ||
751 | 30 | |||
752 | 31 | layer = ZopelessDatabaseLayer | ||
753 | 32 | |||
754 | 33 | vocabulary_class = GitRefVocabulary | ||
755 | 34 | |||
756 | 35 | def test_getTermByToken(self): | ||
757 | 36 | [ref] = self.factory.makeGitRefs() | ||
758 | 37 | vocab = self.vocabulary_class(ref.repository) | ||
759 | 38 | term = SimpleTerm(ref, ref.path, ref.name) | ||
760 | 39 | self.assertEqual(term.token, vocab.getTermByToken(ref.name).token) | ||
761 | 40 | self.assertEqual(term.token, vocab.getTermByToken(ref.path).token) | ||
762 | 41 | self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent") | ||
763 | 42 | |||
764 | 43 | def test_provides_IHugeVocabulary(self): | ||
765 | 44 | vocab = self.vocabulary_class(self.factory.makeGitRepository()) | ||
766 | 45 | self.assertProvides(vocab, IHugeVocabulary) | ||
767 | 46 | |||
768 | 47 | def test_init_no_repository(self): | ||
769 | 48 | # The repository is None if the context cannot be adapted to a | ||
770 | 49 | # repository. | ||
771 | 50 | vocab = self.vocabulary_class( | ||
772 | 51 | self.factory.makeSnap(branch=self.factory.makeAnyBranch())) | ||
773 | 52 | self.assertIsNone(vocab.repository) | ||
774 | 53 | |||
775 | 54 | def test_setRepository(self): | ||
776 | 55 | # Callers can set the repository after instantiation. | ||
777 | 56 | vocab = self.vocabulary_class( | ||
778 | 57 | self.factory.makeSnap(branch=self.factory.makeAnyBranch())) | ||
779 | 58 | repository = self.factory.makeGitRepository() | ||
780 | 59 | vocab.setRepository(repository) | ||
781 | 60 | self.assertEqual(repository, vocab.repository) | ||
782 | 61 | |||
783 | 62 | def test_toTerm(self): | ||
784 | 63 | [ref] = self.factory.makeGitRefs() | ||
785 | 64 | self.assertThat( | ||
786 | 65 | self.vocabulary_class(ref.repository).toTerm(ref), | ||
787 | 66 | MatchesStructure.byEquality( | ||
788 | 67 | value=ref, token=ref.path, title=ref.name)) | ||
789 | 68 | |||
790 | 69 | def test_searchForTerms(self): | ||
791 | 70 | ref_master, ref_next, ref_next_squared, _ = ( | ||
792 | 71 | self.factory.makeGitRefs(paths=[ | ||
793 | 72 | "refs/heads/master", "refs/heads/next", | ||
794 | 73 | "refs/heads/next-squared", "refs/tags/next-1.0"])) | ||
795 | 74 | removeSecurityProxy(ref_master.repository)._default_branch = ( | ||
796 | 75 | ref_master.path) | ||
797 | 76 | vocab = self.vocabulary_class(ref_master.repository) | ||
798 | 77 | self.assertContentEqual( | ||
799 | 78 | [term.value.path for term in vocab.searchForTerms("master")], | ||
800 | 79 | ["refs/heads/master"]) | ||
801 | 80 | self.assertContentEqual( | ||
802 | 81 | [term.value.path for term in vocab.searchForTerms("next")], | ||
803 | 82 | ["refs/heads/next", "refs/heads/next-squared", | ||
804 | 83 | "refs/tags/next-1.0"]) | ||
805 | 84 | self.assertContentEqual( | ||
806 | 85 | [term.value.path for term in vocab.searchForTerms( | ||
807 | 86 | "refs/heads/next")], | ||
808 | 87 | ["refs/heads/next", "refs/heads/next-squared"]) | ||
809 | 88 | self.assertContentEqual( | ||
810 | 89 | [term.value.path for term in vocab.searchForTerms("")], | ||
811 | 90 | ["refs/heads/master", "refs/heads/next", | ||
812 | 91 | "refs/heads/next-squared", "refs/tags/next-1.0"]) | ||
813 | 92 | self.assertContentEqual( | ||
814 | 93 | [term.token for term in vocab.searchForTerms("nonexistent")], []) | ||
815 | 94 | |||
816 | 95 | def test_searchForTerms_ordering(self): | ||
817 | 96 | # The default branch (if it matches) is shown first, followed by | ||
818 | 97 | # other matches in decreasing order of last commit date. | ||
819 | 98 | ref_master, ref_master_old, ref_master_older = ( | ||
820 | 99 | self.factory.makeGitRefs(paths=[ | ||
821 | 100 | "refs/heads/master", "refs/heads/master-old", | ||
822 | 101 | "refs/heads/master-older"])) | ||
823 | 102 | removeSecurityProxy(ref_master.repository)._default_branch = ( | ||
824 | 103 | ref_master.path) | ||
825 | 104 | now = datetime.now(pytz.UTC) | ||
826 | 105 | removeSecurityProxy(ref_master_old).committer_date = ( | ||
827 | 106 | now - timedelta(days=1)) | ||
828 | 107 | removeSecurityProxy(ref_master_older).committer_date = ( | ||
829 | 108 | now - timedelta(days=2)) | ||
830 | 109 | vocab = self.vocabulary_class(ref_master.repository) | ||
831 | 110 | self.assertEqual( | ||
832 | 111 | [term.value.path for term in vocab.searchForTerms("master")], | ||
833 | 112 | ["refs/heads/master", "refs/heads/master-old", | ||
834 | 113 | "refs/heads/master-older"]) | ||
835 | 114 | |||
836 | 115 | def test_len(self): | ||
837 | 116 | ref_master, _, _, _ = self.factory.makeGitRefs(paths=[ | ||
838 | 117 | "refs/heads/master", "refs/heads/next", | ||
839 | 118 | "refs/heads/next-squared", "refs/tags/next-1.0"]) | ||
840 | 119 | self.assertEqual(4, len(self.vocabulary_class(ref_master.repository))) | ||
841 | 120 | |||
842 | 121 | |||
843 | 122 | class TestGitBranchVocabulary(TestCaseWithFactory): | ||
844 | 123 | |||
845 | 124 | layer = ZopelessDatabaseLayer | ||
846 | 125 | |||
847 | 126 | vocabulary_class = GitBranchVocabulary | ||
848 | 127 | |||
849 | 128 | def test_getTermByToken(self): | ||
850 | 129 | [ref] = self.factory.makeGitRefs() | ||
851 | 130 | vocab = self.vocabulary_class(ref.repository) | ||
852 | 131 | term = SimpleTerm(ref, ref.name, ref.name) | ||
853 | 132 | self.assertEqual(term.token, vocab.getTermByToken(ref.name).token) | ||
854 | 133 | self.assertEqual(term.token, vocab.getTermByToken(ref.path).token) | ||
855 | 134 | self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent") | ||
856 | 135 | |||
857 | 136 | def test_toTerm(self): | ||
858 | 137 | [ref] = self.factory.makeGitRefs() | ||
859 | 138 | self.assertThat( | ||
860 | 139 | self.vocabulary_class(ref.repository).toTerm(ref), | ||
861 | 140 | MatchesStructure.byEquality( | ||
862 | 141 | value=ref, token=ref.name, title=ref.name)) | ||
863 | 142 | |||
864 | 143 | def test_searchForTerms(self): | ||
865 | 144 | ref_master, ref_next, ref_next_squared, _ = ( | ||
866 | 145 | self.factory.makeGitRefs(paths=[ | ||
867 | 146 | "refs/heads/master", "refs/heads/next", | ||
868 | 147 | "refs/heads/next-squared", "refs/tags/next-1.0"])) | ||
869 | 148 | removeSecurityProxy(ref_master.repository)._default_branch = ( | ||
870 | 149 | ref_master.path) | ||
871 | 150 | vocab = self.vocabulary_class(ref_master.repository) | ||
872 | 151 | self.assertContentEqual( | ||
873 | 152 | [term.title for term in vocab.searchForTerms("master")], | ||
874 | 153 | ["master"]) | ||
875 | 154 | self.assertContentEqual( | ||
876 | 155 | [term.title for term in vocab.searchForTerms("next")], | ||
877 | 156 | ["next", "next-squared"]) | ||
878 | 157 | self.assertContentEqual( | ||
879 | 158 | [term.title for term in vocab.searchForTerms("refs/heads/next")], | ||
880 | 159 | ["next", "next-squared"]) | ||
881 | 160 | self.assertContentEqual( | ||
882 | 161 | [term.title for term in vocab.searchForTerms("")], | ||
883 | 162 | ["master", "next", "next-squared"]) | ||
884 | 163 | self.assertContentEqual( | ||
885 | 164 | [term.token for term in vocab.searchForTerms("nonexistent")], []) | ||
886 | 165 | |||
887 | 166 | def test_searchForTerms_ordering(self): | ||
888 | 167 | # The default branch (if it matches) is shown first, followed by | ||
889 | 168 | # other matches in decreasing order of last commit date. | ||
890 | 169 | ref_master, ref_master_old, ref_master_older = ( | ||
891 | 170 | self.factory.makeGitRefs(paths=[ | ||
892 | 171 | "refs/heads/master", "refs/heads/master-old", | ||
893 | 172 | "refs/heads/master-older"])) | ||
894 | 173 | removeSecurityProxy(ref_master.repository)._default_branch = ( | ||
895 | 174 | ref_master.path) | ||
896 | 175 | now = datetime.now(pytz.UTC) | ||
897 | 176 | removeSecurityProxy(ref_master_old).committer_date = ( | ||
898 | 177 | now - timedelta(days=1)) | ||
899 | 178 | removeSecurityProxy(ref_master_older).committer_date = ( | ||
900 | 179 | now - timedelta(days=2)) | ||
901 | 180 | vocab = self.vocabulary_class(ref_master.repository) | ||
902 | 181 | self.assertEqual( | ||
903 | 182 | [term.title for term in vocab.searchForTerms("master")], | ||
904 | 183 | ["master", "master-old", "master-older"]) | ||
905 | 184 | |||
906 | 185 | def test_len(self): | ||
907 | 186 | ref_master, _, _, _ = self.factory.makeGitRefs(paths=[ | ||
908 | 187 | "refs/heads/master", "refs/heads/next", | ||
909 | 188 | "refs/heads/next-squared", "refs/tags/next-1.0"]) | ||
910 | 189 | self.assertEqual(3, len(self.vocabulary_class(ref_master.repository))) | ||
911 | diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py | |||
912 | index 82422f1..038e602 100644 | |||
913 | --- a/lib/lp/oci/browser/tests/test_ocirecipe.py | |||
914 | +++ b/lib/lp/oci/browser/tests/test_ocirecipe.py | |||
915 | @@ -195,9 +195,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView): | |||
916 | 195 | oci_project, view_name="+new-recipe", user=self.person) | 195 | oci_project, view_name="+new-recipe", user=self.person) |
917 | 196 | browser.getControl(name="field.name").value = "recipe-name" | 196 | browser.getControl(name="field.name").value = "recipe-name" |
918 | 197 | browser.getControl("Description").value = "Recipe description" | 197 | browser.getControl("Description").value = "Recipe description" |
920 | 198 | browser.getControl("Git repository").value = ( | 198 | browser.getControl(name="field.git_ref.repository").value = ( |
921 | 199 | git_ref.repository.identity) | 199 | git_ref.repository.identity) |
923 | 200 | browser.getControl("Git branch").value = git_ref.path | 200 | browser.getControl(name="field.git_ref.path").value = git_ref.path |
924 | 201 | browser.getControl("Create OCI recipe").click() | 201 | browser.getControl("Create OCI recipe").click() |
925 | 202 | 202 | ||
926 | 203 | content = find_main_content(browser.contents) | 203 | content = find_main_content(browser.contents) |
927 | @@ -230,9 +230,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView): | |||
928 | 230 | oci_project, view_name="+new-recipe", user=self.person) | 230 | oci_project, view_name="+new-recipe", user=self.person) |
929 | 231 | browser.getControl(name="field.name").value = "recipe-name" | 231 | browser.getControl(name="field.name").value = "recipe-name" |
930 | 232 | browser.getControl("Description").value = "Recipe description" | 232 | browser.getControl("Description").value = "Recipe description" |
932 | 233 | browser.getControl("Git repository").value = ( | 233 | browser.getControl(name="field.git_ref.repository").value = ( |
933 | 234 | git_ref.repository.identity) | 234 | git_ref.repository.identity) |
935 | 235 | browser.getControl("Git branch").value = git_ref.path | 235 | browser.getControl(name="field.git_ref.path").value = git_ref.path |
936 | 236 | browser.getControl("Build-time ARG variables").value = ( | 236 | browser.getControl("Build-time ARG variables").value = ( |
937 | 237 | "VAR1=10\nVAR2=20") | 237 | "VAR1=10\nVAR2=20") |
938 | 238 | browser.getControl("Create OCI recipe").click() | 238 | browser.getControl("Create OCI recipe").click() |
939 | @@ -291,9 +291,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView): | |||
940 | 291 | processors = browser.getControl(name="field.processors") | 291 | processors = browser.getControl(name="field.processors") |
941 | 292 | processors.value = ["386", "amd64"] | 292 | processors.value = ["386", "amd64"] |
942 | 293 | browser.getControl(name="field.name").value = "recipe-name" | 293 | browser.getControl(name="field.name").value = "recipe-name" |
944 | 294 | browser.getControl("Git repository").value = ( | 294 | browser.getControl(name="field.git_ref.repository").value = ( |
945 | 295 | git_ref.repository.identity) | 295 | git_ref.repository.identity) |
947 | 296 | browser.getControl("Git branch").value = git_ref.path | 296 | browser.getControl(name="field.git_ref.path").value = git_ref.path |
948 | 297 | browser.getControl("Create OCI recipe").click() | 297 | browser.getControl("Create OCI recipe").click() |
949 | 298 | login_person(self.person) | 298 | login_person(self.person) |
950 | 299 | recipe = getUtility(IOCIRecipeSet).getByName( | 299 | recipe = getUtility(IOCIRecipeSet).getByName( |
951 | @@ -409,9 +409,9 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView): | |||
952 | 409 | browser.getControl("Owner").value = ["new-team"] | 409 | browser.getControl("Owner").value = ["new-team"] |
953 | 410 | browser.getControl(name="field.name").value = "new-name" | 410 | browser.getControl(name="field.name").value = "new-name" |
954 | 411 | browser.getControl("Description").value = "New description" | 411 | browser.getControl("Description").value = "New description" |
956 | 412 | browser.getControl("Git repository").value = ( | 412 | browser.getControl(name="field.git_ref.repository").value = ( |
957 | 413 | new_git_ref.repository.identity) | 413 | new_git_ref.repository.identity) |
959 | 414 | browser.getControl("Git branch").value = new_git_ref.path | 414 | browser.getControl(name="field.git_ref.path").value = new_git_ref.path |
960 | 415 | browser.getControl("Build file path").value = "Dockerfile-2" | 415 | browser.getControl("Build file path").value = "Dockerfile-2" |
961 | 416 | browser.getControl("Build directory context").value = "apath" | 416 | browser.getControl("Build directory context").value = "apath" |
962 | 417 | browser.getControl("Build daily").selected = True | 417 | browser.getControl("Build daily").selected = True |
963 | diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py | |||
964 | index 10e82da..bbda603 100644 | |||
965 | --- a/lib/lp/snappy/browser/tests/test_snap.py | |||
966 | +++ b/lib/lp/snappy/browser/tests/test_snap.py | |||
967 | @@ -733,9 +733,9 @@ class TestSnapEditView(BaseTestSnapView): | |||
968 | 733 | browser.getControl(name="field.store_distro_series").value = [ | 733 | browser.getControl(name="field.store_distro_series").value = [ |
969 | 734 | "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)] | 734 | "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)] |
970 | 735 | browser.getControl("Git", index=0).click() | 735 | browser.getControl("Git", index=0).click() |
972 | 736 | browser.getControl("Git repository").value = ( | 736 | browser.getControl(name="field.git_ref.repository").value = ( |
973 | 737 | new_git_ref.repository.identity) | 737 | new_git_ref.repository.identity) |
975 | 738 | browser.getControl("Git branch").value = new_git_ref.path | 738 | browser.getControl(name="field.git_ref.path").value = new_git_ref.path |
976 | 739 | browser.getControl("Build source tarball").selected = True | 739 | browser.getControl("Build source tarball").selected = True |
977 | 740 | browser.getControl( | 740 | browser.getControl( |
978 | 741 | "Automatically build when branch changes").selected = True | 741 | "Automatically build when branch changes").selected = True |
979 | @@ -952,8 +952,9 @@ class TestSnapEditView(BaseTestSnapView): | |||
980 | 952 | private_ref_path = private_ref.path | 952 | private_ref_path = private_ref.path |
981 | 953 | browser = self.getViewBrowser(snap, user=self.person) | 953 | browser = self.getViewBrowser(snap, user=self.person) |
982 | 954 | browser.getLink("Edit snap package").click() | 954 | browser.getLink("Edit snap package").click() |
985 | 955 | browser.getControl("Git repository").value = private_ref_identity | 955 | browser.getControl(name="field.git_ref.repository").value = ( |
986 | 956 | browser.getControl("Git branch").value = private_ref_path | 956 | private_ref_identity) |
987 | 957 | browser.getControl(name="field.git_ref.path").value = private_ref_path | ||
988 | 957 | browser.getControl("Update snap package").click() | 958 | browser.getControl("Update snap package").click() |
989 | 958 | self.assertEqual( | 959 | self.assertEqual( |
990 | 959 | "A public snap cannot have a private repository.", | 960 | "A public snap cannot have a private repository.", |
991 | @@ -973,8 +974,9 @@ class TestSnapEditView(BaseTestSnapView): | |||
992 | 973 | git_ref=old_ref, store_series=snappy_series) | 974 | git_ref=old_ref, store_series=snappy_series) |
993 | 974 | browser = self.getViewBrowser(snap, user=self.person) | 975 | browser = self.getViewBrowser(snap, user=self.person) |
994 | 975 | browser.getLink("Edit snap package").click() | 976 | browser.getLink("Edit snap package").click() |
997 | 976 | browser.getControl("Git repository").value = new_repository_url | 977 | browser.getControl( |
998 | 977 | browser.getControl("Git branch").value = new_path | 978 | name="field.git_ref.repository").value = new_repository_url |
999 | 979 | browser.getControl(name="field.git_ref.path").value = new_path | ||
1000 | 978 | browser.getControl("Update snap package").click() | 980 | browser.getControl("Update snap package").click() |
1001 | 979 | login_person(self.person) | 981 | login_person(self.person) |
1002 | 980 | content = find_main_content(browser.contents) | 982 | content = find_main_content(browser.contents) |
1003 | diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py | |||
1004 | index 1ef8f45..7e3d14c 100644 | |||
1005 | --- a/lib/lp/snappy/model/snap.py | |||
1006 | +++ b/lib/lp/snappy/model/snap.py | |||
1007 | @@ -46,7 +46,10 @@ from zope.component import ( | |||
1008 | 46 | getUtility, | 46 | getUtility, |
1009 | 47 | ) | 47 | ) |
1010 | 48 | from zope.event import notify | 48 | from zope.event import notify |
1012 | 49 | from zope.interface import implementer | 49 | from zope.interface import ( |
1013 | 50 | directlyProvides, | ||
1014 | 51 | implementer, | ||
1015 | 52 | ) | ||
1016 | 50 | from zope.security.interfaces import Unauthorized | 53 | from zope.security.interfaces import Unauthorized |
1017 | 51 | from zope.security.proxy import removeSecurityProxy | 54 | from zope.security.proxy import removeSecurityProxy |
1018 | 52 | 55 | ||
1019 | @@ -82,7 +85,10 @@ from lp.code.interfaces.gitref import ( | |||
1020 | 82 | IGitRef, | 85 | IGitRef, |
1021 | 83 | IGitRefRemoteSet, | 86 | IGitRefRemoteSet, |
1022 | 84 | ) | 87 | ) |
1024 | 85 | from lp.code.interfaces.gitrepository import IGitRepository | 88 | from lp.code.interfaces.gitrepository import ( |
1025 | 89 | IGitRepository, | ||
1026 | 90 | IHasGitRepositoryURL, | ||
1027 | 91 | ) | ||
1028 | 86 | from lp.code.model.branch import Branch | 92 | from lp.code.model.branch import Branch |
1029 | 87 | from lp.code.model.branchcollection import GenericBranchCollection | 93 | from lp.code.model.branchcollection import GenericBranchCollection |
1030 | 88 | from lp.code.model.gitcollection import GenericGitCollection | 94 | from lp.code.model.gitcollection import GenericGitCollection |
1031 | @@ -440,6 +446,10 @@ class Snap(Storm, WebhookTargetMixin): | |||
1032 | 440 | self.git_repository = None | 446 | self.git_repository = None |
1033 | 441 | self.git_repository_url = None | 447 | self.git_repository_url = None |
1034 | 442 | self.git_path = None | 448 | self.git_path = None |
1035 | 449 | if self.git_repository_url is not None: | ||
1036 | 450 | directlyProvides(self, IHasGitRepositoryURL) | ||
1037 | 451 | else: | ||
1038 | 452 | directlyProvides(self) | ||
1039 | 443 | 453 | ||
1040 | 444 | @property | 454 | @property |
1041 | 445 | def source(self): | 455 | def source(self): |
Mostly looks good - just a few small comments and questions. I'd like to see some screenshots before approving this, though, since it's a UI change.