launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25691
[Merge] ~twom/launchpad:git-branch-picker-unpicked into launchpad:master
Tom Wardill has proposed merging ~twom/launchpad:git-branch-picker-unpicked into launchpad:master.
Commit message:
Add an autocompleting branch picker box to edit snap and oci recipes.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~twom/launchpad/+git/launchpad/+merge/393928
Add vocabularies and widgets based on them.
Add autocomplete JS.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~twom/launchpad:git-branch-picker-unpicked into launchpad:master.
diff --git a/lib/lp/app/javascript/autocomplete.js b/lib/lp/app/javascript/autocomplete.js
new file mode 100644
index 0000000..42f66a9
--- /dev/null
+++ b/lib/lp/app/javascript/autocomplete.js
@@ -0,0 +1,85 @@
+/* Copyright 2017 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ */
+
+YUI.add('lp.app.autocomplete', function (Y) {
+
+ var namespace = Y.namespace('lp.app.autocomplete');
+
+ namespace.getRepositoryCompletionURI = function (repo_node) {
+ var uri = '/';
+ uri += repo_node.get('value');
+ uri += '/@@+huge-vocabulary';
+ return uri;
+ }
+
+ namespace.getRepoNode = function (path_node) {
+ var split = path_node._node['id'].split('.');
+ split[2] = 'repository';
+ var repository_target = split.join('.');
+ var target_repo = Y.one('[id="' + repository_target + '"]');
+ return target_repo;
+ }
+
+ namespace.getPathNode = function (path_node) {
+ var split = path_node._node['id'].split('.');
+ split[2] = 'path';
+ var path_target = split.join('.');
+ var target_path = Y.one('[id="' + path_target + '"]');
+ return target_path;
+ }
+
+ namespace.setupVocabAutocomplete = function (config, node) {
+ var qs = 'name=' + encodeURIComponent(config.vocabulary_name);
+
+ /* autocomplete will substitute these with appropriate encoding. */
+ /* XXX cjwatson 2017-07-24: Perhaps we should pass batch={maxResults}
+ * too, but we need to make sure that it doesn't exceed max_batch_size.
+ */
+ qs += '&search_text={query}';
+
+ var repo_node = namespace.getRepoNode(node);
+ var uri = namespace.getRepositoryCompletionURI(repo_node);
+
+ node.plug(Y.Plugin.AutoComplete, {
+ queryDelay: 500, // milliseconds
+ requestTemplate: '?' + qs,
+ resultHighlighter: 'wordMatch',
+ resultListLocator: 'entries',
+ resultTextLocator: 'value',
+ source: uri
+ });
+
+ repo_node.updated = function () {
+ var uri = namespace.getRepositoryCompletionURI(this);
+ path_node = namespace.getPathNode(this);
+ path_node.ac.set("source", uri);
+ console.log("Source set to " + uri);
+ }
+ // ideally this should take node to rebind `this` in the function
+ // but we're also calling it from the popup picker, which has a direct
+ // reference to the repo_node, so maintain the local `this` binding.
+ repo_node.on('valuechange', repo_node.updated);
+ };
+
+ /**
+ * Add autocompletion to a text field.
+ * @param {Object} config Object literal of config name/value pairs.
+ * config.vocabulary_name: the named vocabulary to select from.
+ * config.input_element: the id of the text field to update with the
+ * selected value.
+ */
+ namespace.addAutocomplete = function (config) {
+ var input_element = Y.one('[id="' + config.input_element + '"]');
+ // The node may already have been processed.
+ if (input_element.ac) {
+ return;
+ }
+ namespace.setupVocabAutocomplete(config, input_element);
+ };
+
+}, '0.1', {
+ 'requires': [
+ 'autocomplete', 'autocomplete-sources', 'datasource', 'lp'
+ ]
+});
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, [], {
/**
* The search input node.
*
- * @property _search_button
+ * @property _search_input
* @type Node
* @private
*/
@@ -1301,6 +1301,9 @@ Y.extend(TextFieldPickerPlugin, Y.Plugin.Base, {
// then the I-beam disappears.
input.blur();
input.focus();
+ if (typeof(input.updated) === "function") {
+ input.updated();
+ }
});
this.doAfter('show', function() {
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) {
* @param {Object} config Object literal of config name/value pairs. The
* values listed below are common for all picker types.
* config.vocabulary_name: the named vocabulary to select from.
- * config.vocabulary_filters: any vocaulary filters to use.
+ * config.vocabulary_filters: any vocabulary filters to use.
* config.input_element: the id of the text field to update with the
* selected value.
* config.picker_type: the type of picker to create (default or person).
@@ -619,23 +619,35 @@ namespace.setup_vocab_picker = function (picker, vocabulary, config) {
// use the context to limit the results to the same project.
var uri = '';
+ var context_ok = true;
if (Y.Lang.isFunction(config.getContextPath)) {
- uri += config.getContextPath() + '/';
+ var context_path = config.getContextPath();
+ if (context_path !== null) {
+ uri += config.getContextPath() + '/';
+ } else {
+ // No context, so proceed straight to validation.
+ picker.set('error', e);
+ picker.set('search_mode', false);
+ picker.fire('validate', search_text);
+ context_ok = false;
+ }
} else if (Y.Lang.isValue(config.context)) {
uri += Y.lp.get_url_path(
config.context.get('web_link')) + '/';
}
uri += '@@+huge-vocabulary?' + qs;
- var yio = (config.yio !== undefined) ? config.yio : Y;
- yio.io(uri, {
- headers: {'Accept': 'application/json'},
- timeout: 20000,
- on: {
- success: success_handler,
- failure: failure_handler
- }
- });
+ if (context_ok) {
+ var yio = (config.yio !== undefined) ? config.yio : Y;
+ yio.io(uri, {
+ headers: {'Accept': 'application/json'},
+ timeout: 20000,
+ on: {
+ success: success_handler,
+ failure: failure_handler
+ }
+ });
+ }
// Or we can pass in a vocabulary directly.
} else {
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 @@
LPJS.use('node', 'lp.app.picker', function(Y) {
var config = ${view/json_config};
var show_widget_id = '${view/show_widget_id}';
- Y.on('domready', function(e) {
- Y.lp.app.picker.addPicker(config, show_widget_id);
- });
+ Y.lp.app.picker.addPicker(config, show_widget_id);
});
"/>
</metal:form-picker>
diff --git a/lib/lp/code/adapters/gitrepository.py b/lib/lp/code/adapters/gitrepository.py
index e55d5bb..653b03e 100644
--- a/lib/lp/code/adapters/gitrepository.py
+++ b/lib/lp/code/adapters/gitrepository.py
@@ -1,14 +1,16 @@
# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
-"""Components related to Git repositories."""
+"""Components and adapters related to Git repositories."""
__metaclass__ = type
__all__ = [
"GitRepositoryDelta",
+ "git_repository_for_snap",
]
from lazr.lifecycle.objectdelta import ObjectDelta
+from zope.component.interfaces import ComponentLookupError
from zope.interface import implementer
from lp.code.interfaces.gitrepository import (
@@ -56,3 +58,10 @@ class GitRepositoryDelta:
return GitRepositoryDelta(**changes)
else:
return None
+
+
+def git_repository_for_snap(snap):
+ """Adapt a snap package to a Git repository."""
+ if snap.git_repository is None:
+ raise ComponentLookupError
+ return snap.git_repository
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 @@
permission="zope.Public"
/>
+ <view
+ type="zope.publisher.interfaces.browser.IBrowserRequest"
+ for="zope.schema.interfaces.IChoice
+ lp.code.vocabularies.gitref.GitRefVocabulary"
+ provides="zope.formlib.interfaces.IInputWidget"
+ factory="lp.code.browser.widgets.gitref.GitRefPickerWidget"
+ permission="zope.Public"
+ />
+
</configure>
diff --git a/lib/lp/code/browser/widgets/gitref.py b/lib/lp/code/browser/widgets/gitref.py
index cfe8e40..194fc43 100644
--- a/lib/lp/code/browser/widgets/gitref.py
+++ b/lib/lp/code/browser/widgets/gitref.py
@@ -9,7 +9,6 @@ __all__ = [
import six
from zope.browserpage import ViewPageTemplateFile
-from zope.component import getUtility
from zope.formlib.interfaces import (
ConversionError,
IInputWidget,
@@ -23,16 +22,11 @@ from zope.formlib.widget import (
InputWidget,
)
from zope.interface import implementer
-from zope.schema import (
- Choice,
- TextLine,
- )
+from zope.schema import Choice
from zope.schema.interfaces import IChoice
-
from lp.app.errors import UnexpectedFormData
from lp.app.validators import LaunchpadValidationError
from lp.app.widgets.popup import VocabularyPickerWidget
-from lp.code.interfaces.gitref import IGitRefRemoteSet
from lp.code.interfaces.gitrepository import IGitRepository
from lp.services.fields import URIField
from lp.services.webapp.interfaces import (
@@ -104,28 +98,34 @@ class GitRepositoryPickerWidget(VocabularyPickerWidget):
class GitRefWidget(BrowserWidget, InputWidget):
template = ViewPageTemplateFile("templates/gitref.pt")
- display_label = False
_widgets_set_up = False
# If True, allow entering external repository URLs.
allow_external = False
+ # If True, only allow reference paths to be branches (refs/heads/*).
+ require_branch = False
+
def setUpSubWidgets(self):
if self._widgets_set_up:
return
+ path_vocabulary = "GitBranch" if self.require_branch else "GitRef"
fields = [
GitRepositoryField(
__name__="repository", title=u"Git repository",
- required=False, vocabulary="GitRepository",
+ required=self.context.required, vocabulary="GitRepository",
allow_external=self.allow_external),
- TextLine(__name__="path", title=u"Git branch", required=False),
+ Choice(
+ __name__="path", title=u"Git branch",
+ required=self.context.required,
+ vocabulary=path_vocabulary),
]
for field in fields:
setUpWidget(
self, field.__name__, field, IInputWidget, prefix=self.name)
self._widgets_set_up = True
- def setRenderedValue(self, value):
+ def setRenderedValue(self, value, no_path=False):
"""See `IWidget`."""
self.setUpSubWidgets()
if value is not None:
@@ -133,7 +133,13 @@ class GitRefWidget(BrowserWidget, InputWidget):
self.repository_widget.setRenderedValue(value.repository_url)
else:
self.repository_widget.setRenderedValue(value.repository)
- self.path_widget.setRenderedValue(value.path)
+ # if we're only talking about branches, we can deal in the
+ # name, rather than the full ref/heads/* path
+ if not no_path:
+ if self.require_branch:
+ self.path_widget.setRenderedValue(value.name)
+ else:
+ self.path_widget.setRenderedValue(value.path)
else:
self.repository_widget.setRenderedValue(None)
self.path_widget.setRenderedValue(None)
@@ -174,27 +180,33 @@ class GitRefWidget(BrowserWidget, InputWidget):
"There is no Git repository named '%s' registered in "
"Launchpad." % entered_name))
if self.path_widget.hasInput():
- path = self.path_widget.getInputValue()
- else:
- path = None
- if not path:
- if self.context.required:
+ # We've potentially just tried to change the repository that is
+ # involved, or changing from a bzr branch to a git repo, so there
+ # is no existing repository set up. We need to set this so we
+ # can compare the ref against the 'new' repo.
+ from zope.security.proxy import removeSecurityProxy
+ unsecure_vocab = removeSecurityProxy(self.path_widget.vocabulary)
+ if IGitRepository.providedBy(repository):
+ unsecure_vocab.setRepository(repository)
+ else:
+ unsecure_vocab.setRepositoryURL(repository)
+ try:
+ ref = self.path_widget.getInputValue()
+ except ConversionError:
raise WidgetInputError(
self.name, self.label,
LaunchpadValidationError(
- "Please enter a Git branch path."))
- else:
- return
- if self.allow_external and not IGitRepository.providedBy(repository):
- ref = getUtility(IGitRefRemoteSet).new(repository, path)
+ "The repository at %s does not contain a branch named "
+ "'%s'." % (
+ repository.display_name,
+ self.path_widget._getFormInput())))
else:
- ref = repository.getRefByPath(path)
- if ref is None:
- raise WidgetInputError(
+ ref = None
+ if not ref and (repository or self.context.required):
+ raise WidgetInputError(
self.name, self.label,
LaunchpadValidationError(
- "The repository at %s does not contain a branch named "
- "'%s'." % (repository.display_name, path)))
+ "Please enter a Git branch path."))
return ref
def error(self):
@@ -210,3 +222,12 @@ class GitRefWidget(BrowserWidget, InputWidget):
"""See `IBrowserWidget`."""
self.setUpSubWidgets()
return self.template()
+
+
+class GitRefPickerWidget(VocabularyPickerWidget):
+
+ __call__ = ViewPageTemplateFile("templates/gitref-picker.pt")
+
+ @property
+ def repository_id(self):
+ 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
new file mode 100644
index 0000000..d378846
--- /dev/null
+++ b/lib/lp/code/browser/widgets/templates/gitref-picker.pt
@@ -0,0 +1,37 @@
+<tal:root
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ omit-tag="">
+
+<metal:form-picker use-macro="context/@@form-picker-macros/form-picker">
+ <script metal:fill-slot="add-picker" tal:content="structure string:
+ LPJS.use('node', 'lp.app.autocomplete', 'lp.app.picker', function(Y) {
+ var config = ${view/json_config};
+ var repository_id = '${view/repository_id}';
+ if (repository_id !== '') {
+ config.getContextPath = function() {
+ var repository_value = Y.DOM.byId(repository_id).value;
+ // XXX cjwatson 2017-06-24: We don't have a straightforward
+ // URL parser available to us at the moment. This will do for
+ // now, since we just want to tell the difference between
+ // internal and external repositories.
+ if (repository_value.indexOf('://') !== -1) {
+ // XXX cjwatson 2017-07-24: Is throwing an exception too
+ // much here? This currently bubbles up to the browser
+ // console on e.g. Snap:+edit.
+ throw (
+ 'Searching for branches in external repositories is ' +
+ 'not available. Enter the branch name directly ' +
+ 'instead.');
+ }
+ return '/' + repository_value;
+ };
+ }
+ var show_widget_id = '${view/show_widget_id}';
+ Y.lp.app.picker.addPicker(config, show_widget_id);
+ Y.lp.app.autocomplete.addAutocomplete(config);
+ });
+ "/>
+</metal:form-picker>
+
+</tal:root>
diff --git a/lib/lp/code/browser/widgets/templates/gitref.pt b/lib/lp/code/browser/widgets/templates/gitref.pt
index f88a652..f972b89 100644
--- a/lib/lp/code/browser/widgets/templates/gitref.pt
+++ b/lib/lp/code/browser/widgets/templates/gitref.pt
@@ -1,18 +1,3 @@
-<table>
- <tr>
- <td>
- <tal:widget define="widget nocall:view/repository_widget">
- <metal:block
- use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
- </tal:widget>
- </td>
- </tr>
- <tr>
- <td>
- <tal:widget define="widget nocall:view/path_widget">
- <metal:block
- use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
- </tal:widget>
- </td>
- </tr>
-</table>
+<span title="Repository" tal:content="structure view/repository_widget" />
+
+<span title="Branch" tal:content="structure view/path_widget" />
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):
[ref] = self.factory.makeGitRefs()
form = {
"field.git_ref.repository": ref.repository.unique_name,
- "field.git_ref.path": "non-existent",
+ "field.git_ref.path": u"non-existent",
}
self.assertGetInputValueError(
form,
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..ace3140 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -841,6 +841,10 @@
for="lp.code.interfaces.gitrepository.IGitRepository"
provides="lp.services.webapp.interfaces.ILaunchpadContainer"
factory="lp.code.publisher.LaunchpadGitRepositoryContainer"/>
+ <adapter
+ for="lp.snappy.interfaces.snap.ISnap"
+ provides="lp.code.interfaces.gitrepository.IGitRepository"
+ factory="lp.code.adapters.gitrepository.git_repository_for_snap"/>
<subscriber
for="lp.code.interfaces.gitrepository.IGitRepository zope.lifecycleevent.interfaces.IObjectModifiedEvent"
handler="lp.code.model.gitrepository.git_repository_modified"/>
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__ = [
'IGitRepository',
'IGitRepositoryDelta',
'IGitRepositorySet',
+ 'IHasGitRepositoryURL',
'user_has_special_git_repository_access',
]
@@ -1227,6 +1228,13 @@ class GitIdentityMixin:
return identities
+class IHasGitRepositoryURL(Interface):
+ """Marker interface for objects that have a Git repository URL."""
+
+ git_repository_url = Attribute(
+ "The Git repository URL (possibly external)")
+
+
def user_has_special_git_repository_access(user, repository=None):
"""Admins have special access.
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):
def __eq__(self, other):
return (
+ other is not None and
self.repository_url == other.repository_url and
self.path == other.path)
diff --git a/lib/lp/code/vocabularies/configure.zcml b/lib/lp/code/vocabularies/configure.zcml
index 5782992..fe8f73b 100644
--- a/lib/lp/code/vocabularies/configure.zcml
+++ b/lib/lp/code/vocabularies/configure.zcml
@@ -88,4 +88,26 @@
<allow interface="zope.schema.interfaces.IVocabularyTokenized"/>
</class>
+ <securedutility
+ name="GitRef"
+ component=".gitref.GitRefVocabulary"
+ provides="zope.schema.interfaces.IVocabularyFactory">
+ <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
+ </securedutility>
+
+ <class class=".gitref.GitRefVocabulary">
+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
+ </class>
+
+ <securedutility
+ name="GitBranch"
+ component=".gitref.GitBranchVocabulary"
+ provides="zope.schema.interfaces.IVocabularyFactory">
+ <allow interface="zope.schema.interfaces.IVocabularyFactory"/>
+ </securedutility>
+
+ <class class=".gitref.GitBranchVocabulary">
+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary"/>
+ </class>
+
</configure>
diff --git a/lib/lp/code/vocabularies/gitref.py b/lib/lp/code/vocabularies/gitref.py
new file mode 100644
index 0000000..16622f6
--- /dev/null
+++ b/lib/lp/code/vocabularies/gitref.py
@@ -0,0 +1,161 @@
+# Copyright 2017 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Vocabularies that contain Git references."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ "GitBranchVocabulary",
+ "GitRefVocabulary",
+ ]
+
+from lazr.restful.interfaces import IReference
+from storm.expr import (
+ Desc,
+ Like,
+ like_escape,
+ )
+from zope.component import getUtility
+from zope.interface import implementer
+from zope.schema.vocabulary import SimpleTerm
+from zope.security.proxy import isinstance as zope_isinstance
+
+from lp.code.interfaces.gitref import IGitRefRemoteSet
+from lp.code.interfaces.gitrepository import (
+ IGitRepository,
+ IHasGitRepositoryURL,
+ )
+from lp.code.model.gitref import (
+ GitRef,
+ GitRefRemote,
+ )
+from lp.services.database.interfaces import IStore
+from storm.databases.postgres import Case
+from lp.services.webapp.vocabulary import (
+ CountableIterator,
+ IHugeVocabulary,
+ StormVocabularyBase,
+ )
+
+
+@implementer(IHugeVocabulary)
+class GitRefVocabulary(StormVocabularyBase):
+ """A vocabulary for references in a given Git repository."""
+
+ _table = GitRef
+ # In the base case (i.e. not GitBranchVocabulary) this may also be a
+ # more general reference such as refs/tags/foo, but experience suggests
+ # that people find talking about references in the web UI to be
+ # baffling, so we tell a white lie here.
+ displayname = "Select a branch"
+ step_title = "Search"
+
+ def __init__(self, context):
+ super(GitRefVocabulary, self).__init__(context=context)
+ if IReference.providedBy(context):
+ context = context.context
+ try:
+ self.repository = IGitRepository(context)
+ except TypeError:
+ self.repository = None
+ try:
+ self.repository_url = (
+ IHasGitRepositoryURL(context).git_repository_url)
+ except TypeError:
+ self.repository_url = None
+
+ def setRepository(self, repository):
+ """Set the repository after the vocabulary was instantiated."""
+ self.repository = repository
+ self.repository_url = None
+
+ def setRepositoryURL(self, repository_url):
+ """Set the repository URL after the vocabulary was instantiated."""
+ self.repository = None
+ self.repository_url = repository_url
+
+ def _assertHasRepository(self):
+ if self.repository is None and self.repository_url is None:
+ raise AssertionError(
+ "GitRefVocabulary cannot be used without setting a "
+ "repository or a repository URL.")
+
+ @property
+ def _order_by(self):
+ rank = Case(
+ cases=[(self._table.path == self.repository.default_branch, 2)],
+ default=1)
+ return [Desc(rank), Desc(self._table.committer_date)]
+
+ def toTerm(self, ref):
+ """See `StormVocabularyBase`."""
+ return SimpleTerm(ref, ref.path, ref.name)
+
+ def getTermByToken(self, token):
+ """See `IVocabularyTokenized`."""
+ self._assertHasRepository()
+ if self.repository is not None:
+ ref = self.repository.getRefByPath(token)
+ if ref is None:
+ raise LookupError(token)
+ else:
+ ref = getUtility(IGitRefRemoteSet).new(self.repository_url, token)
+ return self.toTerm(ref)
+
+ def _makePattern(self, query=None):
+ parts = ["%"]
+ if query is not None:
+ parts.extend([query.lower().translate(like_escape), "%"])
+ return "".join(parts)
+
+ def searchForTerms(self, query=None, vocab_filter=None):
+ """See `IHugeVocabulary."""
+ self._assertHasRepository()
+ if self.repository is not None:
+ pattern = self._makePattern(query=query)
+ results = IStore(self._table).find(
+ self._table,
+ self._table.repository_id == self.repository.id,
+ Like(self._table.path, pattern, "!")).order_by(self._order_by)
+ else:
+ results = self.emptySelectResults()
+ return CountableIterator(results.count(), results, self.toTerm)
+
+ def getTerm(self, value):
+ # remote refs aren't database backed
+ if zope_isinstance(value, GitRefRemote):
+ return self.toTerm(value)
+ return super(GitRefVocabulary, self).getTerm(value)
+
+ def __len__(self):
+ """See `IVocabulary`."""
+ return self.searchForTerms().count()
+
+ def __contains__(self, obj):
+ # We know nothing about GitRefRemote, so we just have to assume
+ # that they exist in the remote repository
+ if zope_isinstance(obj, GitRefRemote):
+ return True
+ if obj in self.repository.refs:
+ return True
+ return False
+
+
+class GitBranchVocabulary(GitRefVocabulary):
+ """A vocabulary for branches in a given Git repository."""
+
+ def _makePattern(self, query=None):
+ parts = []
+ # XXX allow HEAD?
+ if query is None or not query.startswith("refs/heads/"):
+ parts.append("refs/heads/".translate(like_escape))
+ parts.append("%")
+ if query is not None:
+ parts.extend([query.lower().translate(like_escape), "%"])
+ return "".join(parts)
+
+ def toTerm(self, ref):
+ """See `StormVocabularyBase`."""
+ 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
new file mode 100644
index 0000000..918d9e9
--- /dev/null
+++ b/lib/lp/code/vocabularies/tests/test_gitref_vocabularies.py
@@ -0,0 +1,196 @@
+# Copyright 2017 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the Git reference vocabularies."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from datetime import (
+ datetime,
+ timedelta,
+ )
+
+import pytz
+from testtools.matchers import MatchesStructure
+from zope.schema.vocabulary import SimpleTerm
+from zope.security.proxy import removeSecurityProxy
+
+from lp.code.vocabularies.gitref import (
+ GitBranchVocabulary,
+ GitRefVocabulary,
+ )
+from lp.services.webapp.vocabulary import IHugeVocabulary
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestGitRefVocabulary(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ vocabulary_class = GitRefVocabulary
+
+ def test_getTermByToken(self):
+ [ref] = self.factory.makeGitRefs()
+ vocab = self.vocabulary_class(ref.repository)
+ term = SimpleTerm(ref, ref.path, ref.name)
+ self.assertEqual(term.token, vocab.getTermByToken(ref.name).token)
+ self.assertEqual(term.token, vocab.getTermByToken(ref.path).token)
+ self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent")
+
+ def test_provides_IHugeVocabulary(self):
+ vocab = self.vocabulary_class(self.factory.makeGitRepository())
+ self.assertProvides(vocab, IHugeVocabulary)
+
+ def test_init_snap(self):
+ # A vocabulary may be instantiated with anything that can be adapted
+ # to an IGitRepository, such as a Snap configured to build from one.
+ [ref] = self.factory.makeGitRefs()
+ vocab = self.vocabulary_class(self.factory.makeSnap(git_ref=ref))
+ self.assertEqual(ref.repository, vocab.repository)
+
+ def test_init_no_repository(self):
+ # The repository is None if the context cannot be adapted to a
+ # repository.
+ vocab = self.vocabulary_class(
+ self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
+ self.assertIsNone(vocab.repository)
+
+ def test_setRepository(self):
+ # Callers can set the repository after instantiation.
+ vocab = self.vocabulary_class(
+ self.factory.makeSnap(branch=self.factory.makeAnyBranch()))
+ repository = self.factory.makeGitRepository()
+ vocab.setRepository(repository)
+ self.assertEqual(repository, vocab.repository)
+
+ def test_toTerm(self):
+ [ref] = self.factory.makeGitRefs()
+ self.assertThat(
+ self.vocabulary_class(ref.repository).toTerm(ref),
+ MatchesStructure.byEquality(
+ value=ref, token=ref.path, title=ref.name))
+
+ def test_searchForTerms(self):
+ ref_master, ref_next, ref_next_squared, _ = (
+ self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/next",
+ "refs/heads/next-squared", "refs/tags/next-1.0"]))
+ removeSecurityProxy(ref_master.repository)._default_branch = (
+ ref_master.path)
+ vocab = self.vocabulary_class(ref_master.repository)
+ self.assertContentEqual(
+ [term.value.path for term in vocab.searchForTerms("master")],
+ ["refs/heads/master"])
+ self.assertContentEqual(
+ [term.value.path for term in vocab.searchForTerms("next")],
+ ["refs/heads/next", "refs/heads/next-squared",
+ "refs/tags/next-1.0"])
+ self.assertContentEqual(
+ [term.value.path for term in vocab.searchForTerms(
+ "refs/heads/next")],
+ ["refs/heads/next", "refs/heads/next-squared"])
+ self.assertContentEqual(
+ [term.value.path for term in vocab.searchForTerms("")],
+ ["refs/heads/master", "refs/heads/next",
+ "refs/heads/next-squared", "refs/tags/next-1.0"])
+ self.assertContentEqual(
+ [term.token for term in vocab.searchForTerms("nonexistent")], [])
+
+ def test_searchForTerms_ordering(self):
+ # The default branch (if it matches) is shown first, followed by
+ # other matches in decreasing order of last commit date.
+ ref_master, ref_master_old, ref_master_older = (
+ self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/master-old",
+ "refs/heads/master-older"]))
+ removeSecurityProxy(ref_master.repository)._default_branch = (
+ ref_master.path)
+ now = datetime.now(pytz.UTC)
+ removeSecurityProxy(ref_master_old).committer_date = (
+ now - timedelta(days=1))
+ removeSecurityProxy(ref_master_older).committer_date = (
+ now - timedelta(days=2))
+ vocab = self.vocabulary_class(ref_master.repository)
+ self.assertEqual(
+ [term.value.path for term in vocab.searchForTerms("master")],
+ ["refs/heads/master", "refs/heads/master-old",
+ "refs/heads/master-older"])
+
+ def test_len(self):
+ ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/next",
+ "refs/heads/next-squared", "refs/tags/next-1.0"])
+ self.assertEqual(4, len(self.vocabulary_class(ref_master.repository)))
+
+
+class TestGitBranchVocabulary(TestCaseWithFactory):
+
+ layer = ZopelessDatabaseLayer
+
+ vocabulary_class = GitBranchVocabulary
+
+ def test_getTermByToken(self):
+ [ref] = self.factory.makeGitRefs()
+ vocab = self.vocabulary_class(ref.repository)
+ term = SimpleTerm(ref, ref.name, ref.name)
+ self.assertEqual(term.token, vocab.getTermByToken(ref.name).token)
+ self.assertEqual(term.token, vocab.getTermByToken(ref.path).token)
+ self.assertRaises(LookupError, vocab.getTermByToken, "nonexistent")
+
+ def test_toTerm(self):
+ [ref] = self.factory.makeGitRefs()
+ self.assertThat(
+ self.vocabulary_class(ref.repository).toTerm(ref),
+ MatchesStructure.byEquality(
+ value=ref, token=ref.name, title=ref.name))
+
+ def test_searchForTerms(self):
+ ref_master, ref_next, ref_next_squared, _ = (
+ self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/next",
+ "refs/heads/next-squared", "refs/tags/next-1.0"]))
+ removeSecurityProxy(ref_master.repository)._default_branch = (
+ ref_master.path)
+ vocab = self.vocabulary_class(ref_master.repository)
+ self.assertContentEqual(
+ [term.title for term in vocab.searchForTerms("master")],
+ ["master"])
+ self.assertContentEqual(
+ [term.title for term in vocab.searchForTerms("next")],
+ ["next", "next-squared"])
+ self.assertContentEqual(
+ [term.title for term in vocab.searchForTerms("refs/heads/next")],
+ ["next", "next-squared"])
+ self.assertContentEqual(
+ [term.title for term in vocab.searchForTerms("")],
+ ["master", "next", "next-squared"])
+ self.assertContentEqual(
+ [term.token for term in vocab.searchForTerms("nonexistent")], [])
+
+ def test_searchForTerms_ordering(self):
+ # The default branch (if it matches) is shown first, followed by
+ # other matches in decreasing order of last commit date.
+ ref_master, ref_master_old, ref_master_older = (
+ self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/master-old",
+ "refs/heads/master-older"]))
+ removeSecurityProxy(ref_master.repository)._default_branch = (
+ ref_master.path)
+ now = datetime.now(pytz.UTC)
+ removeSecurityProxy(ref_master_old).committer_date = (
+ now - timedelta(days=1))
+ removeSecurityProxy(ref_master_older).committer_date = (
+ now - timedelta(days=2))
+ vocab = self.vocabulary_class(ref_master.repository)
+ self.assertEqual(
+ [term.title for term in vocab.searchForTerms("master")],
+ ["master", "master-old", "master-older"])
+
+ def test_len(self):
+ ref_master, _, _, _ = self.factory.makeGitRefs(paths=[
+ "refs/heads/master", "refs/heads/next",
+ "refs/heads/next-squared", "refs/tags/next-1.0"])
+ 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):
oci_project, view_name="+new-recipe", user=self.person)
browser.getControl(name="field.name").value = "recipe-name"
browser.getControl("Description").value = "Recipe description"
- browser.getControl("Git repository").value = (
+ browser.getControl(name="field.git_ref.repository").value = (
git_ref.repository.identity)
- browser.getControl("Git branch").value = git_ref.path
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
browser.getControl("Create OCI recipe").click()
content = find_main_content(browser.contents)
@@ -230,9 +230,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
oci_project, view_name="+new-recipe", user=self.person)
browser.getControl(name="field.name").value = "recipe-name"
browser.getControl("Description").value = "Recipe description"
- browser.getControl("Git repository").value = (
+ browser.getControl(name="field.git_ref.repository").value = (
git_ref.repository.identity)
- browser.getControl("Git branch").value = git_ref.path
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
browser.getControl("Build-time ARG variables").value = (
"VAR1=10\nVAR2=20")
browser.getControl("Create OCI recipe").click()
@@ -291,9 +291,9 @@ class TestOCIRecipeAddView(BaseTestOCIRecipeView):
processors = browser.getControl(name="field.processors")
processors.value = ["386", "amd64"]
browser.getControl(name="field.name").value = "recipe-name"
- browser.getControl("Git repository").value = (
+ browser.getControl(name="field.git_ref.repository").value = (
git_ref.repository.identity)
- browser.getControl("Git branch").value = git_ref.path
+ browser.getControl(name="field.git_ref.path").value = git_ref.path
browser.getControl("Create OCI recipe").click()
login_person(self.person)
recipe = getUtility(IOCIRecipeSet).getByName(
@@ -409,9 +409,9 @@ class TestOCIRecipeEditView(OCIConfigHelperMixin, BaseTestOCIRecipeView):
browser.getControl("Owner").value = ["new-team"]
browser.getControl(name="field.name").value = "new-name"
browser.getControl("Description").value = "New description"
- browser.getControl("Git repository").value = (
+ browser.getControl(name="field.git_ref.repository").value = (
new_git_ref.repository.identity)
- browser.getControl("Git branch").value = new_git_ref.path
+ browser.getControl(name="field.git_ref.path").value = new_git_ref.path
browser.getControl("Build file path").value = "Dockerfile-2"
browser.getControl("Build directory context").value = "apath"
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):
browser.getControl(name="field.store_distro_series").value = [
"ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]
browser.getControl("Git", index=0).click()
- browser.getControl("Git repository").value = (
+ browser.getControl(name="field.git_ref.repository").value = (
new_git_ref.repository.identity)
- browser.getControl("Git branch").value = new_git_ref.path
+ browser.getControl(name="field.git_ref.path").value = new_git_ref.path
browser.getControl("Build source tarball").selected = True
browser.getControl(
"Automatically build when branch changes").selected = True
@@ -952,8 +952,9 @@ class TestSnapEditView(BaseTestSnapView):
private_ref_path = private_ref.path
browser = self.getViewBrowser(snap, user=self.person)
browser.getLink("Edit snap package").click()
- browser.getControl("Git repository").value = private_ref_identity
- browser.getControl("Git branch").value = private_ref_path
+ browser.getControl(name="field.git_ref.repository").value = (
+ private_ref_identity)
+ browser.getControl(name="field.git_ref.path").value = private_ref_path
browser.getControl("Update snap package").click()
self.assertEqual(
"A public snap cannot have a private repository.",
@@ -973,8 +974,9 @@ class TestSnapEditView(BaseTestSnapView):
git_ref=old_ref, store_series=snappy_series)
browser = self.getViewBrowser(snap, user=self.person)
browser.getLink("Edit snap package").click()
- browser.getControl("Git repository").value = new_repository_url
- browser.getControl("Git branch").value = new_path
+ browser.getControl(
+ name="field.git_ref.repository").value = new_repository_url
+ browser.getControl(name="field.git_ref.path").value = new_path
browser.getControl("Update snap package").click()
login_person(self.person)
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 (
getUtility,
)
from zope.event import notify
-from zope.interface import implementer
+from zope.interface import (
+ directlyProvides,
+ implementer,
+ )
from zope.security.interfaces import Unauthorized
from zope.security.proxy import removeSecurityProxy
@@ -82,7 +85,10 @@ from lp.code.interfaces.gitref import (
IGitRef,
IGitRefRemoteSet,
)
-from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrepository import (
+ IGitRepository,
+ IHasGitRepositoryURL,
+ )
from lp.code.model.branch import Branch
from lp.code.model.branchcollection import GenericBranchCollection
from lp.code.model.gitcollection import GenericGitCollection
@@ -440,6 +446,10 @@ class Snap(Storm, WebhookTargetMixin):
self.git_repository = None
self.git_repository_url = None
self.git_path = None
+ if self.git_repository_url is not None:
+ directlyProvides(self, IHasGitRepositoryURL)
+ else:
+ directlyProvides(self)
@property
def source(self):