Merge ~twom/launchpad:git-branch-picker-unpicked into launchpad: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)
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) wrote :

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.

review: Needs Information
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
diff --git a/lib/lp/app/javascript/autocomplete.js b/lib/lp/app/javascript/autocomplete.js
0new file mode 1006440new file mode 100644
index 0000000..44b7c00
--- /dev/null
+++ b/lib/lp/app/javascript/autocomplete.js
@@ -0,0 +1,102 @@
1/* Copyright 2017 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 */
4
5YUI.add('lp.app.autocomplete', function (Y) {
6
7 var namespace = Y.namespace('lp.app.autocomplete');
8
9 namespace.getRepositoryCompletionURI = function (repo_node) {
10 var entered_uri = repo_node.get('value');
11 if (entered_uri.startsWith("lp:")) {
12 var split = "+code/" + entered_uri.split("lp:")[1]
13 entered_uri = encodeURI(split);
14 }
15 else if (entered_uri.startsWith("~")) {
16 entered_uri = encodeURI(entered_uri);
17 }
18 else if (entered_uri.includes("://")) {
19 return null;
20 }
21 else if (entered_uri.startsWith("git@")) {
22 return null;
23 }
24 else {
25 entered_uri = encodeURI("+code/" + entered_uri);
26 }
27
28 var uri = '/';
29 uri += entered_uri;
30 uri += '/@@+huge-vocabulary';
31 return uri;
32 }
33
34 namespace.getRepoNode = function (path_node) {
35 var split = path_node._node['id'].split('.');
36 split[2] = 'repository';
37 var repository_target = split.join('.');
38 var target_repo = Y.one('[id="' + repository_target + '"]');
39 return target_repo;
40 }
41
42 namespace.getPathNode = function (path_node) {
43 var split = path_node._node['id'].split('.');
44 split[2] = 'path';
45 var path_target = split.join('.');
46 var target_path = Y.one('[id="' + path_target + '"]');
47 return target_path;
48 }
49
50 namespace.setupVocabAutocomplete = function (config, node) {
51 var qs = 'name=' + encodeURIComponent(config.vocabulary_name);
52
53 /* autocomplete will substitute these with appropriate encoding. */
54 /* XXX cjwatson 2017-07-24: Perhaps we should pass batch={maxResults}
55 * too, but we need to make sure that it doesn't exceed max_batch_size.
56 */
57 qs += '&search_text={query}';
58
59 var repo_node = namespace.getRepoNode(node);
60 var uri = namespace.getRepositoryCompletionURI(repo_node);
61
62 node.plug(Y.Plugin.AutoComplete, {
63 queryDelay: 500, // milliseconds
64 requestTemplate: '?' + qs,
65 resultHighlighter: 'wordMatch',
66 resultListLocator: 'entries',
67 resultTextLocator: 'value',
68 source: uri
69 });
70
71 repo_node.updated = function () {
72 var uri = namespace.getRepositoryCompletionURI(this);
73 path_node = namespace.getPathNode(this);
74 path_node.ac.set("source", uri);
75 }
76 // ideally this should take node to rebind `this` in the function
77 // but we're also calling it from the popup picker, which has a direct
78 // reference to the repo_node, so maintain the local `this` binding.
79 repo_node.on('valuechange', repo_node.updated);
80 };
81
82 /**
83 * Add autocompletion to a text field.
84 * @param {Object} config Object literal of config name/value pairs.
85 * config.vocabulary_name: the named vocabulary to select from.
86 * config.input_element: the id of the text field to update with the
87 * selected value.
88 */
89 namespace.addAutocomplete = function (config) {
90 var input_element = Y.one('[id="' + config.input_element + '"]');
91 // The node may already have been processed.
92 if (input_element.ac) {
93 return;
94 }
95 namespace.setupVocabAutocomplete(config, input_element);
96 };
97
98}, '0.1', {
99 'requires': [
100 'autocomplete', 'autocomplete-sources', 'datasource', 'lp'
101 ]
102});
diff --git a/lib/lp/app/javascript/picker/picker.js b/lib/lp/app/javascript/picker/picker.js
index e97b517..ad94344 100644
--- a/lib/lp/app/javascript/picker/picker.js
+++ b/lib/lp/app/javascript/picker/picker.js
@@ -24,7 +24,7 @@ ns.Picker = Y.Base.create('picker', Y.lp.ui.PrettyOverlay, [], {
24 /**24 /**
25 * The search input node.25 * The search input node.
26 *26 *
27 * @property _search_button27 * @property _search_input
28 * @type Node28 * @type Node
29 * @private29 * @private
30 */30 */
@@ -1301,6 +1301,9 @@ Y.extend(TextFieldPickerPlugin, Y.Plugin.Base, {
1301 // then the I-beam disappears.1301 // then the I-beam disappears.
1302 input.blur();1302 input.blur();
1303 input.focus();1303 input.focus();
1304 if (typeof(input.updated) === "function") {
1305 input.updated();
1306 }
1304 });1307 });
1305 this.doAfter('show', function() {1308 this.doAfter('show', function() {
1306 var selected_value = null;1309 var selected_value = null;
diff --git a/lib/lp/app/javascript/picker/picker_patcher.js b/lib/lp/app/javascript/picker/picker_patcher.js
index e220aa8..895b5b7 100644
--- a/lib/lp/app/javascript/picker/picker_patcher.js
+++ b/lib/lp/app/javascript/picker/picker_patcher.js
@@ -50,7 +50,7 @@ var _addPicker = function(config, show_widget_id) {
50 * @param {Object} config Object literal of config name/value pairs. The50 * @param {Object} config Object literal of config name/value pairs. The
51 * values listed below are common for all picker types.51 * values listed below are common for all picker types.
52 * config.vocabulary_name: the named vocabulary to select from.52 * config.vocabulary_name: the named vocabulary to select from.
53 * config.vocabulary_filters: any vocaulary filters to use.53 * config.vocabulary_filters: any vocabulary filters to use.
54 * config.input_element: the id of the text field to update with the54 * config.input_element: the id of the text field to update with the
55 * selected value.55 * selected value.
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).
@@ -619,23 +619,35 @@ namespace.setup_vocab_picker = function (picker, vocabulary, config) {
619 // use the context to limit the results to the same project.619 // use the context to limit the results to the same project.
620620
621 var uri = '';621 var uri = '';
622 var context_ok = true;
622 if (Y.Lang.isFunction(config.getContextPath)) {623 if (Y.Lang.isFunction(config.getContextPath)) {
623 uri += config.getContextPath() + '/';624 var context_path = config.getContextPath();
625 if (context_path !== null) {
626 uri += config.getContextPath() + '/';
627 } else {
628 // No context, so proceed straight to validation.
629 picker.set('error', e);
630 picker.set('search_mode', false);
631 picker.fire('validate', search_text);
632 context_ok = false;
633 }
624 } else if (Y.Lang.isValue(config.context)) {634 } else if (Y.Lang.isValue(config.context)) {
625 uri += Y.lp.get_url_path(635 uri += Y.lp.get_url_path(
626 config.context.get('web_link')) + '/';636 config.context.get('web_link')) + '/';
627 }637 }
628 uri += '@@+huge-vocabulary?' + qs;638 uri += '@@+huge-vocabulary?' + qs;
629639
630 var yio = (config.yio !== undefined) ? config.yio : Y;640 if (context_ok) {
631 yio.io(uri, {641 var yio = (config.yio !== undefined) ? config.yio : Y;
632 headers: {'Accept': 'application/json'},642 yio.io(uri, {
633 timeout: 20000,643 headers: {'Accept': 'application/json'},
634 on: {644 timeout: 20000,
635 success: success_handler,645 on: {
636 failure: failure_handler646 success: success_handler,
637 }647 failure: failure_handler
638 });648 }
649 });
650 }
639 // Or we can pass in a vocabulary directly.651 // Or we can pass in a vocabulary directly.
640 } else {652 } else {
641 display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1);653 display_vocabulary(vocabulary, Y.Object.size(vocabulary), 1);
diff --git a/lib/lp/app/widgets/templates/form-picker-macros.pt b/lib/lp/app/widgets/templates/form-picker-macros.pt
index 80f5be1..9710e52 100644
--- a/lib/lp/app/widgets/templates/form-picker-macros.pt
+++ b/lib/lp/app/widgets/templates/form-picker-macros.pt
@@ -33,9 +33,7 @@
33 LPJS.use('node', 'lp.app.picker', function(Y) {33 LPJS.use('node', 'lp.app.picker', function(Y) {
34 var config = ${view/json_config};34 var config = ${view/json_config};
35 var show_widget_id = '${view/show_widget_id}';35 var show_widget_id = '${view/show_widget_id}';
36 Y.on('domready', function(e) {36 Y.lp.app.picker.addPicker(config, show_widget_id);
37 Y.lp.app.picker.addPicker(config, show_widget_id);
38 });
39 });37 });
40 "/>38 "/>
41 </metal:form-picker>39 </metal:form-picker>
diff --git a/lib/lp/code/adapters/gitrepository.py b/lib/lp/code/adapters/gitrepository.py
index e55d5bb..d265ca9 100644
--- a/lib/lp/code/adapters/gitrepository.py
+++ b/lib/lp/code/adapters/gitrepository.py
@@ -1,7 +1,7 @@
1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the1# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Components related to Git repositories."""4"""Components and adapters related to Git repositories."""
55
6__metaclass__ = type6__metaclass__ = type
7__all__ = [7__all__ = [
diff --git a/lib/lp/code/browser/widgets/configure.zcml b/lib/lp/code/browser/widgets/configure.zcml
index 913d8c6..e056e42 100644
--- a/lib/lp/code/browser/widgets/configure.zcml
+++ b/lib/lp/code/browser/widgets/configure.zcml
@@ -16,4 +16,13 @@
16 permission="zope.Public"16 permission="zope.Public"
17 />17 />
1818
19 <view
20 type="zope.publisher.interfaces.browser.IBrowserRequest"
21 for="zope.schema.interfaces.IChoice
22 lp.code.vocabularies.gitref.GitRefVocabulary"
23 provides="zope.formlib.interfaces.IInputWidget"
24 factory="lp.code.browser.widgets.gitref.GitRefPickerWidget"
25 permission="zope.Public"
26 />
27
19</configure>28</configure>
diff --git a/lib/lp/code/browser/widgets/gitref.py b/lib/lp/code/browser/widgets/gitref.py
index cfe8e40..b4acd01 100644
--- a/lib/lp/code/browser/widgets/gitref.py
+++ b/lib/lp/code/browser/widgets/gitref.py
@@ -9,7 +9,6 @@ __all__ = [
99
10import six10import six
11from zope.browserpage import ViewPageTemplateFile11from zope.browserpage import ViewPageTemplateFile
12from zope.component import getUtility
13from zope.formlib.interfaces import (12from zope.formlib.interfaces import (
14 ConversionError,13 ConversionError,
15 IInputWidget,14 IInputWidget,
@@ -23,16 +22,12 @@ from zope.formlib.widget import (
23 InputWidget,22 InputWidget,
24 )23 )
25from zope.interface import implementer24from zope.interface import implementer
26from zope.schema import (25from zope.schema import Choice
27 Choice,
28 TextLine,
29 )
30from zope.schema.interfaces import IChoice26from zope.schema.interfaces import IChoice
3127
32from lp.app.errors import UnexpectedFormData28from lp.app.errors import UnexpectedFormData
33from lp.app.validators import LaunchpadValidationError29from lp.app.validators import LaunchpadValidationError
34from lp.app.widgets.popup import VocabularyPickerWidget30from lp.app.widgets.popup import VocabularyPickerWidget
35from lp.code.interfaces.gitref import IGitRefRemoteSet
36from lp.code.interfaces.gitrepository import IGitRepository31from lp.code.interfaces.gitrepository import IGitRepository
37from lp.services.fields import URIField32from lp.services.fields import URIField
38from lp.services.webapp.interfaces import (33from lp.services.webapp.interfaces import (
@@ -104,28 +99,34 @@ class GitRepositoryPickerWidget(VocabularyPickerWidget):
104class GitRefWidget(BrowserWidget, InputWidget):99class GitRefWidget(BrowserWidget, InputWidget):
105100
106 template = ViewPageTemplateFile("templates/gitref.pt")101 template = ViewPageTemplateFile("templates/gitref.pt")
107 display_label = False
108 _widgets_set_up = False102 _widgets_set_up = False
109103
110 # If True, allow entering external repository URLs.104 # If True, allow entering external repository URLs.
111 allow_external = False105 allow_external = False
112106
107 # If True, only allow reference paths to be branches (refs/heads/*).
108 require_branch = False
109
113 def setUpSubWidgets(self):110 def setUpSubWidgets(self):
114 if self._widgets_set_up:111 if self._widgets_set_up:
115 return112 return
113 path_vocabulary = "GitBranch" if self.require_branch else "GitRef"
116 fields = [114 fields = [
117 GitRepositoryField(115 GitRepositoryField(
118 __name__="repository", title=u"Git repository",116 __name__="repository", title=u"Repository",
119 required=False, vocabulary="GitRepository",117 required=self.context.required, vocabulary="GitRepository",
120 allow_external=self.allow_external),118 allow_external=self.allow_external),
121 TextLine(__name__="path", title=u"Git branch", required=False),119 Choice(
120 __name__="path", title=u"Branch",
121 required=self.context.required,
122 vocabulary=path_vocabulary),
122 ]123 ]
123 for field in fields:124 for field in fields:
124 setUpWidget(125 setUpWidget(
125 self, field.__name__, field, IInputWidget, prefix=self.name)126 self, field.__name__, field, IInputWidget, prefix=self.name)
126 self._widgets_set_up = True127 self._widgets_set_up = True
127128
128 def setRenderedValue(self, value):129 def setRenderedValue(self, value, with_path=True):
129 """See `IWidget`."""130 """See `IWidget`."""
130 self.setUpSubWidgets()131 self.setUpSubWidgets()
131 if value is not None:132 if value is not None:
@@ -133,7 +134,13 @@ class GitRefWidget(BrowserWidget, InputWidget):
133 self.repository_widget.setRenderedValue(value.repository_url)134 self.repository_widget.setRenderedValue(value.repository_url)
134 else:135 else:
135 self.repository_widget.setRenderedValue(value.repository)136 self.repository_widget.setRenderedValue(value.repository)
136 self.path_widget.setRenderedValue(value.path)137 # if we're only talking about branches, we can deal in the
138 # name, rather than the full ref/heads/* path
139 if with_path:
140 if self.require_branch:
141 self.path_widget.setRenderedValue(value.name)
142 else:
143 self.path_widget.setRenderedValue(value.path)
137 else:144 else:
138 self.repository_widget.setRenderedValue(None)145 self.repository_widget.setRenderedValue(None)
139 self.path_widget.setRenderedValue(None)146 self.path_widget.setRenderedValue(None)
@@ -174,27 +181,31 @@ class GitRefWidget(BrowserWidget, InputWidget):
174 "There is no Git repository named '%s' registered in "181 "There is no Git repository named '%s' registered in "
175 "Launchpad." % entered_name))182 "Launchpad." % entered_name))
176 if self.path_widget.hasInput():183 if self.path_widget.hasInput():
177 path = self.path_widget.getInputValue()184 # We've potentially just tried to change the repository that is
178 else:185 # involved, or changing from a bzr branch to a git repo, so there
179 path = None186 # is no existing repository set up. We need to set this so we
180 if not path:187 # can compare the ref against the 'new' repo.
181 if self.context.required:188 if IGitRepository.providedBy(repository):
189 self.path_widget.vocabulary.setRepository(repository)
190 else:
191 self.path_widget.vocabulary.setRepositoryURL(repository)
192 try:
193 ref = self.path_widget.getInputValue()
194 except ConversionError:
182 raise WidgetInputError(195 raise WidgetInputError(
183 self.name, self.label,196 self.name, self.label,
184 LaunchpadValidationError(197 LaunchpadValidationError(
185 "Please enter a Git branch path."))198 "The repository at %s does not contain a branch named "
186 else:199 "'%s'." % (
187 return200 repository.display_name,
188 if self.allow_external and not IGitRepository.providedBy(repository):201 self.path_widget._getFormInput())))
189 ref = getUtility(IGitRefRemoteSet).new(repository, path)
190 else:202 else:
191 ref = repository.getRefByPath(path)203 ref = None
192 if ref is None:204 if not ref and (repository or self.context.required):
193 raise WidgetInputError(205 raise WidgetInputError(
194 self.name, self.label,206 self.name, self.label,
195 LaunchpadValidationError(207 LaunchpadValidationError(
196 "The repository at %s does not contain a branch named "208 "Please enter a Git branch path."))
197 "'%s'." % (repository.display_name, path)))
198 return ref209 return ref
199210
200 def error(self):211 def error(self):
@@ -210,3 +221,12 @@ class GitRefWidget(BrowserWidget, InputWidget):
210 """See `IBrowserWidget`."""221 """See `IBrowserWidget`."""
211 self.setUpSubWidgets()222 self.setUpSubWidgets()
212 return self.template()223 return self.template()
224
225
226class GitRefPickerWidget(VocabularyPickerWidget):
227
228 __call__ = ViewPageTemplateFile("templates/gitref-picker.pt")
229
230 @property
231 def repository_id(self):
232 return self._prefix + "repository"
diff --git a/lib/lp/code/browser/widgets/templates/gitref-picker.pt b/lib/lp/code/browser/widgets/templates/gitref-picker.pt
213new file mode 100644233new file mode 100644
index 0000000..fbc32dd
--- /dev/null
+++ b/lib/lp/code/browser/widgets/templates/gitref-picker.pt
@@ -0,0 +1,37 @@
1<tal:root
2 xmlns:tal="http://xml.zope.org/namespaces/tal"
3 xmlns:metal="http://xml.zope.org/namespaces/metal"
4 omit-tag="">
5
6<metal:form-picker use-macro="context/@@form-picker-macros/form-picker">
7 <script metal:fill-slot="add-picker" tal:content="structure string:
8 LPJS.use('node', 'lp.app.autocomplete', 'lp.app.picker', function(Y) {
9 var config = ${view/json_config};
10 var repository_id = '${view/repository_id}';
11 if (repository_id !== '') {
12 config.getContextPath = function() {
13 var repository_value = Y.DOM.byId(repository_id).value;
14 // XXX cjwatson 2017-06-24: We don't have a straightforward
15 // URL parser available to us at the moment. This will do for
16 // now, since we just want to tell the difference between
17 // internal and external repositories.
18 // XXX twom 2020-11-18
19 // This just closes the picker, not ideal.
20 // but would need some refactoring of picker_patcher.js
21 if (repository_value.indexOf('://') !== -1) {
22 return null;
23 }
24 else if (repository_value.indexOf('git@') !== -1) {
25 return null;
26 }
27 return '/' + repository_value;
28 };
29 }
30 var show_widget_id = '${view/show_widget_id}';
31 Y.lp.app.picker.addPicker(config, show_widget_id);
32 Y.lp.app.autocomplete.addAutocomplete(config);
33 });
34 "/>
35</metal:form-picker>
36
37</tal:root>
diff --git a/lib/lp/code/browser/widgets/templates/gitref.pt b/lib/lp/code/browser/widgets/templates/gitref.pt
index f88a652..f49dfb7 100644
--- a/lib/lp/code/browser/widgets/templates/gitref.pt
+++ b/lib/lp/code/browser/widgets/templates/gitref.pt
@@ -1,18 +1,3 @@
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" />
2 <tr>2&nbsp;
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" />
4 <tal:widget define="widget nocall:view/repository_widget">
5 <metal:block
6 use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
7 </tal:widget>
8 </td>
9 </tr>
10 <tr>
11 <td>
12 <tal:widget define="widget nocall:view/path_widget">
13 <metal:block
14 use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
15 </tal:widget>
16 </td>
17 </tr>
18</table>
diff --git a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
index b411651..2e2526c 100644
--- a/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
+++ b/lib/lp/code/browser/widgets/tests/test_gitrefwidget.py
@@ -194,7 +194,7 @@ class TestGitRefWidget(WithScenarios, TestCaseWithFactory):
194 [ref] = self.factory.makeGitRefs()194 [ref] = self.factory.makeGitRefs()
195 form = {195 form = {
196 "field.git_ref.repository": ref.repository.unique_name,196 "field.git_ref.repository": ref.repository.unique_name,
197 "field.git_ref.path": "non-existent",197 "field.git_ref.path": u"non-existent",
198 }198 }
199 self.assertGetInputValueError(199 self.assertGetInputValueError(
200 form,200 form,
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 192133a..ed17183 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -13,6 +13,7 @@ __all__ = [
13 'IGitRepository',13 'IGitRepository',
14 'IGitRepositoryDelta',14 'IGitRepositoryDelta',
15 'IGitRepositorySet',15 'IGitRepositorySet',
16 'IHasGitRepositoryURL',
16 'user_has_special_git_repository_access',17 'user_has_special_git_repository_access',
17 ]18 ]
1819
@@ -1227,6 +1228,13 @@ class GitIdentityMixin:
1227 return identities1228 return identities
12281229
12291230
1231class IHasGitRepositoryURL(Interface):
1232 """Marker interface for objects that have a Git repository URL."""
1233
1234 git_repository_url = Attribute(
1235 "The Git repository URL (possibly external)")
1236
1237
1230def user_has_special_git_repository_access(user, repository=None):1238def user_has_special_git_repository_access(user, repository=None):
1231 """Admins have special access.1239 """Admins have special access.
12321240
diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
index f7dc142..406c8de 100644
--- a/lib/lp/code/model/gitref.py
+++ b/lib/lp/code/model/gitref.py
@@ -933,6 +933,7 @@ class GitRefRemote(GitRefMixin):
933933
934 def __eq__(self, other):934 def __eq__(self, other):
935 return (935 return (
936 other is not None and
936 self.repository_url == other.repository_url and937 self.repository_url == other.repository_url and
937 self.path == other.path)938 self.path == other.path)
938939
diff --git a/lib/lp/code/vocabularies/configure.zcml b/lib/lp/code/vocabularies/configure.zcml
index 5782992..ef0933d 100644
--- a/lib/lp/code/vocabularies/configure.zcml
+++ b/lib/lp/code/vocabularies/configure.zcml
@@ -88,4 +88,28 @@
88 <allow interface="zope.schema.interfaces.IVocabularyTokenized"/>88 <allow interface="zope.schema.interfaces.IVocabularyTokenized"/>
89 </class>89 </class>
9090
91 <securedutility
92 name="GitRef"
93 component=".gitref.GitRefVocabulary"
94 provides="zope.schema.interfaces.IVocabularyFactory">
95 <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
96 </securedutility>
97
98 <class class=".gitref.GitRefVocabulary">
99 <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
100 <allow interface=".gitref.IRepositoryManagerGitRefVocabulary"/>
101 </class>
102
103 <securedutility
104 name="GitBranch"
105 component=".gitref.GitBranchVocabulary"
106 provides="zope.schema.interfaces.IVocabularyFactory">
107 <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
108 </securedutility>
109
110 <class class=".gitref.GitBranchVocabulary">
111 <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
112 <allow interface=".gitref.IRepositoryManagerGitRefVocabulary"/>
113 </class>
114
91</configure>115</configure>
diff --git a/lib/lp/code/vocabularies/gitref.py b/lib/lp/code/vocabularies/gitref.py
92new file mode 100644116new file mode 100644
index 0000000..7d98208
--- /dev/null
+++ b/lib/lp/code/vocabularies/gitref.py
@@ -0,0 +1,175 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Vocabularies that contain Git references."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8
9__metaclass__ = type
10__all__ = [
11 "GitBranchVocabulary",
12 "GitRefVocabulary",
13 ]
14
15from lazr.restful.interfaces import IReference
16from storm.databases.postgres import Case
17from storm.expr import (
18 Desc,
19 Like,
20 like_escape,
21 )
22from zope.component import getUtility
23from zope.interface import (
24 implementer,
25 Interface,
26 )
27from zope.schema.vocabulary import SimpleTerm
28from zope.security.proxy import isinstance as zope_isinstance
29
30from lp.code.interfaces.gitref import IGitRefRemoteSet
31from lp.code.interfaces.gitrepository import (
32 IGitRepository,
33 IHasGitRepositoryURL,
34 )
35from lp.code.model.gitref import (
36 GitRef,
37 GitRefRemote,
38 )
39from lp.services.database.interfaces import IStore
40from lp.services.webapp.vocabulary import (
41 CountableIterator,
42 IHugeVocabulary,
43 StormVocabularyBase,
44 )
45
46
47class IRepositoryManagerGitRefVocabulary(Interface):
48
49 def setRepository(self, repository):
50 """Set the repository after the vocabulary was instantiated."""
51
52 def setRepositoryURL(self, repository_url):
53 """Set the repository URL after the vocabulary was instantiated."""
54
55
56@implementer(IHugeVocabulary)
57@implementer(IRepositoryManagerGitRefVocabulary)
58class GitRefVocabulary(StormVocabularyBase):
59 """A vocabulary for references in a given Git repository."""
60
61 _table = GitRef
62 displayname = "Select a branch or tag"
63 step_title = "Search"
64
65 def __init__(self, context):
66 super(GitRefVocabulary, self).__init__(context=context)
67 if IReference.providedBy(context):
68 context = context.context
69 try:
70 self.repository = IGitRepository(context)
71 except TypeError:
72 self.repository = None
73 try:
74 self.repository_url = (
75 IHasGitRepositoryURL(context).git_repository_url)
76 except TypeError:
77 self.repository_url = None
78
79 def setRepository(self, repository):
80 """See `IRepositoryManagerGitRefVocabulary`."""
81 self.repository = repository
82 self.repository_url = None
83
84 def setRepositoryURL(self, repository_url):
85 """See `IRepositoryManagerGitRefVocabulary`."""
86 self.repository = None
87 self.repository_url = repository_url
88
89 def _assertHasRepository(self):
90 if self.repository is None and self.repository_url is None:
91 raise AssertionError(
92 "GitRefVocabulary cannot be used without setting a "
93 "repository or a repository URL.")
94
95 @property
96 def _order_by(self):
97 rank = Case(
98 cases=[(self._table.path == self.repository.default_branch, 2)],
99 default=1)
100 return [
101 Desc(rank),
102 Desc(self._table.committer_date),
103 self._table.path]
104
105 def toTerm(self, ref):
106 """See `StormVocabularyBase`."""
107 return SimpleTerm(ref, ref.path, ref.name)
108
109 def getTermByToken(self, token):
110 """See `IVocabularyTokenized`."""
111 self._assertHasRepository()
112 if self.repository is not None:
113 ref = self.repository.getRefByPath(token)
114 if ref is None:
115 raise LookupError(token)
116 else:
117 ref = getUtility(IGitRefRemoteSet).new(self.repository_url, token)
118 return self.toTerm(ref)
119
120 def _makePattern(self, query=None):
121 parts = ["%"]
122 if query is not None:
123 parts.extend([query.lower().translate(like_escape), "%"])
124 return "".join(parts)
125
126 def searchForTerms(self, query=None, vocab_filter=None):
127 """See `IHugeVocabulary."""
128 self._assertHasRepository()
129 if self.repository is not None:
130 pattern = self._makePattern(query=query)
131 results = IStore(self._table).find(
132 self._table,
133 self._table.repository_id == self.repository.id,
134 Like(self._table.path, pattern, "!")).order_by(self._order_by)
135 else:
136 results = self.emptySelectResults()
137 return CountableIterator(results.count(), results, self.toTerm)
138
139 def getTerm(self, value):
140 # remote refs aren't database backed
141 if zope_isinstance(value, GitRefRemote):
142 return self.toTerm(value)
143 return super(GitRefVocabulary, self).getTerm(value)
144
145 def __len__(self):
146 """See `IVocabulary`."""
147 return self.searchForTerms().count()
148
149 def __contains__(self, obj):
150 # We know nothing about GitRefRemote, so we just have to assume
151 # that they exist in the remote repository
152 if zope_isinstance(obj, GitRefRemote):
153 return True
154 if obj in self.repository.refs:
155 return True
156 return False
157
158
159class GitBranchVocabulary(GitRefVocabulary):
160 """A vocabulary for branches in a given Git repository."""
161
162 displayname = "Select a branch"
163
164 def _makePattern(self, query=None):
165 parts = []
166 if query is None or not query.startswith("refs/heads/"):
167 parts.append("refs/heads/".translate(like_escape))
168 parts.append("%")
169 if query is not None:
170 parts.extend([query.lower().translate(like_escape), "%"])
171 return "".join(parts)
172
173 def toTerm(self, ref):
174 """See `StormVocabularyBase`."""
175 return SimpleTerm(ref, ref.name, ref.name)
diff --git a/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py
0new file mode 100644176new file mode 100644
index 0000000..41f5f83
--- /dev/null
+++ b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py
@@ -0,0 +1,189 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test the Git reference vocabularies."""
5
6from __future__ import absolute_import, print_function, unicode_literals
7
8__metaclass__ = type
9
10from datetime import (
11 datetime,
12 timedelta,
13 )
14
15import pytz
16from testtools.matchers import MatchesStructure
17from zope.schema.vocabulary import SimpleTerm
18from zope.security.proxy import removeSecurityProxy
19
20from lp.code.vocabularies.gitref import (
21 GitBranchVocabulary,
22 GitRefVocabulary,
23 )
24from lp.services.webapp.vocabulary import IHugeVocabulary
25from lp.testing import TestCaseWithFactory
26from lp.testing.layers import ZopelessDatabaseLayer
27
28
29class TestGitRefVocabulary(TestCaseWithFactory):
30
31 layer = ZopelessDatabaseLayer
32
33 vocabulary_class = GitRefVocabulary
34
35 def test_getTermByToken(self):
36 [ref] = self.factory.makeGitRefs()
37 vocab = self.vocabulary_class(ref.repository)
38 term = SimpleTerm(ref, ref.path, ref.name)
39 self.assertEqual(term.token, vocab.getTermByToken(ref.name).token)
40 self.assertEqual(term.token, vocab.getTermByToken(ref.path).token)
41 self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent")
42
43 def test_provides_IHugeVocabulary(self):
44 vocab = self.vocabulary_class(self.factory.makeGitRepository())
45 self.assertProvides(vocab, IHugeVocabulary)
46
47 def test_init_no_repository(self):
48 # The repository is None if the context cannot be adapted to a
49 # repository.
50 vocab = self.vocabulary_class(
51 self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
52 self.assertIsNone(vocab.repository)
53
54 def test_setRepository(self):
55 # Callers can set the repository after instantiation.
56 vocab = self.vocabulary_class(
57 self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
58 repository = self.factory.makeGitRepository()
59 vocab.setRepository(repository)
60 self.assertEqual(repository, vocab.repository)
61
62 def test_toTerm(self):
63 [ref] = self.factory.makeGitRefs()
64 self.assertThat(
65 self.vocabulary_class(ref.repository).toTerm(ref),
66 MatchesStructure.byEquality(
67 value=ref, token=ref.path, title=ref.name))
68
69 def test_searchForTerms(self):
70 ref_master, ref_next, ref_next_squared, _ = (
71 self.factory.makeGitRefs(paths=[
72 "refs/heads/master", "refs/heads/next",
73 "refs/heads/next-squared", "refs/tags/next-1.0"]))
74 removeSecurityProxy(ref_master.repository)._default_branch = (
75 ref_master.path)
76 vocab = self.vocabulary_class(ref_master.repository)
77 self.assertContentEqual(
78 [term.value.path for term in vocab.searchForTerms("master")],
79 ["refs/heads/master"])
80 self.assertContentEqual(
81 [term.value.path for term in vocab.searchForTerms("next")],
82 ["refs/heads/next", "refs/heads/next-squared",
83 "refs/tags/next-1.0"])
84 self.assertContentEqual(
85 [term.value.path for term in vocab.searchForTerms(
86 "refs/heads/next")],
87 ["refs/heads/next", "refs/heads/next-squared"])
88 self.assertContentEqual(
89 [term.value.path for term in vocab.searchForTerms("")],
90 ["refs/heads/master", "refs/heads/next",
91 "refs/heads/next-squared", "refs/tags/next-1.0"])
92 self.assertContentEqual(
93 [term.token for term in vocab.searchForTerms("nonexistent")], [])
94
95 def test_searchForTerms_ordering(self):
96 # The default branch (if it matches) is shown first, followed by
97 # other matches in decreasing order of last commit date.
98 ref_master, ref_master_old, ref_master_older = (
99 self.factory.makeGitRefs(paths=[
100 "refs/heads/master", "refs/heads/master-old",
101 "refs/heads/master-older"]))
102 removeSecurityProxy(ref_master.repository)._default_branch = (
103 ref_master.path)
104 now = datetime.now(pytz.UTC)
105 removeSecurityProxy(ref_master_old).committer_date = (
106 now - timedelta(days=1))
107 removeSecurityProxy(ref_master_older).committer_date = (
108 now - timedelta(days=2))
109 vocab = self.vocabulary_class(ref_master.repository)
110 self.assertEqual(
111 [term.value.path for term in vocab.searchForTerms("master")],
112 ["refs/heads/master", "refs/heads/master-old",
113 "refs/heads/master-older"])
114
115 def test_len(self):
116 ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
117 "refs/heads/master", "refs/heads/next",
118 "refs/heads/next-squared", "refs/tags/next-1.0"])
119 self.assertEqual(4, len(self.vocabulary_class(ref_master.repository)))
120
121
122class TestGitBranchVocabulary(TestCaseWithFactory):
123
124 layer = ZopelessDatabaseLayer
125
126 vocabulary_class = GitBranchVocabulary
127
128 def test_getTermByToken(self):
129 [ref] = self.factory.makeGitRefs()
130 vocab = self.vocabulary_class(ref.repository)
131 term = SimpleTerm(ref, ref.name, ref.name)
132 self.assertEqual(term.token, vocab.getTermByToken(ref.name).token)
133 self.assertEqual(term.token, vocab.getTermByToken(ref.path).token)
134 self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent")
135
136 def test_toTerm(self):
137 [ref] = self.factory.makeGitRefs()
138 self.assertThat(
139 self.vocabulary_class(ref.repository).toTerm(ref),
140 MatchesStructure.byEquality(
141 value=ref, token=ref.name, title=ref.name))
142
143 def test_searchForTerms(self):
144 ref_master, ref_next, ref_next_squared, _ = (
145 self.factory.makeGitRefs(paths=[
146 "refs/heads/master", "refs/heads/next",
147 "refs/heads/next-squared", "refs/tags/next-1.0"]))
148 removeSecurityProxy(ref_master.repository)._default_branch = (
149 ref_master.path)
150 vocab = self.vocabulary_class(ref_master.repository)
151 self.assertContentEqual(
152 [term.title for term in vocab.searchForTerms("master")],
153 ["master"])
154 self.assertContentEqual(
155 [term.title for term in vocab.searchForTerms("next")],
156 ["next", "next-squared"])
157 self.assertContentEqual(
158 [term.title for term in vocab.searchForTerms("refs/heads/next")],
159 ["next", "next-squared"])
160 self.assertContentEqual(
161 [term.title for term in vocab.searchForTerms("")],
162 ["master", "next", "next-squared"])
163 self.assertContentEqual(
164 [term.token for term in vocab.searchForTerms("nonexistent")], [])
165
166 def test_searchForTerms_ordering(self):
167 # The default branch (if it matches) is shown first, followed by
168 # other matches in decreasing order of last commit date.
169 ref_master, ref_master_old, ref_master_older = (
170 self.factory.makeGitRefs(paths=[
171 "refs/heads/master", "refs/heads/master-old",
172 "refs/heads/master-older"]))
173 removeSecurityProxy(ref_master.repository)._default_branch = (
174 ref_master.path)
175 now = datetime.now(pytz.UTC)
176 removeSecurityProxy(ref_master_old).committer_date = (
177 now - timedelta(days=1))
178 removeSecurityProxy(ref_master_older).committer_date = (
179 now - timedelta(days=2))
180 vocab = self.vocabulary_class(ref_master.repository)
181 self.assertEqual(
182 [term.title for term in vocab.searchForTerms("master")],
183 ["master", "master-old", "master-older"])
184
185 def test_len(self):
186 ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
187 "refs/heads/master", "refs/heads/next",
188 "refs/heads/next-squared", "refs/tags/next-1.0"])
189 self.assertEqual(3, len(self.vocabulary_class(ref_master.repository)))
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 82422f1..038e602 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -195,9 +195,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
195 oci_project, view_name="+new-recipe", user=self.person)195 oci_project, view_name="+new-recipe", user=self.person)
196 browser.getControl(name="field.name").value = "recipe-name"196 browser.getControl(name="field.name").value = "recipe-name"
197 browser.getControl("Description").value = "Recipe description"197 browser.getControl("Description").value = "Recipe description"
198 browser.getControl("Git repository").value = (198 browser.getControl(name="field.git_ref.repository").value = (
199 git_ref.repository.identity)199 git_ref.repository.identity)
200 browser.getControl("Git branch").value = git_ref.path200 browser.getControl(name="field.git_ref.path").value = git_ref.path
201 browser.getControl("Create OCI recipe").click()201 browser.getControl("Create OCI recipe").click()
202202
203 content = find_main_content(browser.contents)203 content = find_main_content(browser.contents)
@@ -230,9 +230,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
230 oci_project, view_name="+new-recipe", user=self.person)230 oci_project, view_name="+new-recipe", user=self.person)
231 browser.getControl(name="field.name").value = "recipe-name"231 browser.getControl(name="field.name").value = "recipe-name"
232 browser.getControl("Description").value = "Recipe description"232 browser.getControl("Description").value = "Recipe description"
233 browser.getControl("Git repository").value = (233 browser.getControl(name="field.git_ref.repository").value = (
234 git_ref.repository.identity)234 git_ref.repository.identity)
235 browser.getControl("Git branch").value = git_ref.path235 browser.getControl(name="field.git_ref.path").value = git_ref.path
236 browser.getControl("Build-time ARG variables").value = (236 browser.getControl("Build-time ARG variables").value = (
237 "VAR1=10\nVAR2=20")237 "VAR1=10\nVAR2=20")
238 browser.getControl("Create OCI recipe").click()238 browser.getControl("Create OCI recipe").click()
@@ -291,9 +291,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
291 processors = browser.getControl(name="field.processors")291 processors = browser.getControl(name="field.processors")
292 processors.value = ["386", "amd64"]292 processors.value = ["386", "amd64"]
293 browser.getControl(name="field.name").value = "recipe-name"293 browser.getControl(name="field.name").value = "recipe-name"
294 browser.getControl("Git repository").value = (294 browser.getControl(name="field.git_ref.repository").value = (
295 git_ref.repository.identity)295 git_ref.repository.identity)
296 browser.getControl("Git branch").value = git_ref.path296 browser.getControl(name="field.git_ref.path").value = git_ref.path
297 browser.getControl("Create OCI recipe").click()297 browser.getControl("Create OCI recipe").click()
298 login_person(self.person)298 login_person(self.person)
299 recipe = getUtility(IOCIRecipeSet).getByName(299 recipe = getUtility(IOCIRecipeSet).getByName(
@@ -409,9 +409,9 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
409 browser.getControl("Owner").value = ["new-team"]409 browser.getControl("Owner").value = ["new-team"]
410 browser.getControl(name="field.name").value = "new-name"410 browser.getControl(name="field.name").value = "new-name"
411 browser.getControl("Description").value = "New description"411 browser.getControl("Description").value = "New description"
412 browser.getControl("Git repository").value = (412 browser.getControl(name="field.git_ref.repository").value = (
413 new_git_ref.repository.identity)413 new_git_ref.repository.identity)
414 browser.getControl("Git branch").value = new_git_ref.path414 browser.getControl(name="field.git_ref.path").value = new_git_ref.path
415 browser.getControl("Build file path").value = "Dockerfile-2"415 browser.getControl("Build file path").value = "Dockerfile-2"
416 browser.getControl("Build directory context").value = "apath"416 browser.getControl("Build directory context").value = "apath"
417 browser.getControl("Build daily").selected = True417 browser.getControl("Build daily").selected = True
diff --git a/lib/lp/snappy/browser/tests/test_snap.py b/lib/lp/snappy/browser/tests/test_snap.py
index 10e82da..bbda603 100644
--- a/lib/lp/snappy/browser/tests/test_snap.py
+++ b/lib/lp/snappy/browser/tests/test_snap.py
@@ -733,9 +733,9 @@ class TestSnapEditView(BaseTestSnapView):
733 browser.getControl(name="field.store_distro_series").value = [733 browser.getControl(name="field.store_distro_series").value = [
734 "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]734 "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]
735 browser.getControl("Git", index=0).click()735 browser.getControl("Git", index=0).click()
736 browser.getControl("Git repository").value = (736 browser.getControl(name="field.git_ref.repository").value = (
737 new_git_ref.repository.identity)737 new_git_ref.repository.identity)
738 browser.getControl("Git branch").value = new_git_ref.path738 browser.getControl(name="field.git_ref.path").value = new_git_ref.path
739 browser.getControl("Build source tarball").selected = True739 browser.getControl("Build source tarball").selected = True
740 browser.getControl(740 browser.getControl(
741 "Automatically build when branch changes").selected = True741 "Automatically build when branch changes").selected = True
@@ -952,8 +952,9 @@ class TestSnapEditView(BaseTestSnapView):
952 private_ref_path = private_ref.path952 private_ref_path = private_ref.path
953 browser = self.getViewBrowser(snap, user=self.person)953 browser = self.getViewBrowser(snap, user=self.person)
954 browser.getLink("Edit snap package").click()954 browser.getLink("Edit snap package").click()
955 browser.getControl("Git repository").value = private_ref_identity955 browser.getControl(name="field.git_ref.repository").value = (
956 browser.getControl("Git branch").value = private_ref_path956 private_ref_identity)
957 browser.getControl(name="field.git_ref.path").value = private_ref_path
957 browser.getControl("Update snap package").click()958 browser.getControl("Update snap package").click()
958 self.assertEqual(959 self.assertEqual(
959 "A public snap cannot have a private repository.",960 "A public snap cannot have a private repository.",
@@ -973,8 +974,9 @@ class TestSnapEditView(BaseTestSnapView):
973 git_ref=old_ref, store_series=snappy_series)974 git_ref=old_ref, store_series=snappy_series)
974 browser = self.getViewBrowser(snap, user=self.person)975 browser = self.getViewBrowser(snap, user=self.person)
975 browser.getLink("Edit snap package").click()976 browser.getLink("Edit snap package").click()
976 browser.getControl("Git repository").value = new_repository_url977 browser.getControl(
977 browser.getControl("Git branch").value = new_path978 name="field.git_ref.repository").value = new_repository_url
979 browser.getControl(name="field.git_ref.path").value = new_path
978 browser.getControl("Update snap package").click()980 browser.getControl("Update snap package").click()
979 login_person(self.person)981 login_person(self.person)
980 content = find_main_content(browser.contents)982 content = find_main_content(browser.contents)
diff --git a/lib/lp/snappy/model/snap.py b/lib/lp/snappy/model/snap.py
index 1ef8f45..7e3d14c 100644
--- a/lib/lp/snappy/model/snap.py
+++ b/lib/lp/snappy/model/snap.py
@@ -46,7 +46,10 @@ from zope.component import (
46 getUtility,46 getUtility,
47 )47 )
48from zope.event import notify48from zope.event import notify
49from zope.interface import implementer49from zope.interface import (
50 directlyProvides,
51 implementer,
52 )
50from zope.security.interfaces import Unauthorized53from zope.security.interfaces import Unauthorized
51from zope.security.proxy import removeSecurityProxy54from zope.security.proxy import removeSecurityProxy
5255
@@ -82,7 +85,10 @@ from lp.code.interfaces.gitref import (
82 IGitRef,85 IGitRef,
83 IGitRefRemoteSet,86 IGitRefRemoteSet,
84 )87 )
85from lp.code.interfaces.gitrepository import IGitRepository88from lp.code.interfaces.gitrepository import (
89 IGitRepository,
90 IHasGitRepositoryURL,
91 )
86from lp.code.model.branch import Branch92from lp.code.model.branch import Branch
87from lp.code.model.branchcollection import GenericBranchCollection93from lp.code.model.branchcollection import GenericBranchCollection
88from lp.code.model.gitcollection import GenericGitCollection94from lp.code.model.gitcollection import GenericGitCollection
@@ -440,6 +446,10 @@ class Snap(Storm, WebhookTargetMixin):
440 self.git_repository = None446 self.git_repository = None
441 self.git_repository_url = None447 self.git_repository_url = None
442 self.git_path = None448 self.git_path = None
449 if self.git_repository_url is not None:
450 directlyProvides(self, IHasGitRepositoryURL)
451 else:
452 directlyProvides(self)
443453
444 @property454 @property
445 def source(self):455 def source(self):

Subscribers

People subscribed via source and target branches

to status/vote changes: