Merge lp:~cjwatson/launchpad/git-ref-remote into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18286
Proposed branch: lp:~cjwatson/launchpad/git-ref-remote
Merge into: lp:launchpad
Diff against target: 614 lines (+343/-16)
11 files modified
lib/lp/app/browser/tales.py (+9/-0)
lib/lp/code/browser/configure.zcml (+2/-0)
lib/lp/code/browser/widgets/configure.zcml (+19/-0)
lib/lp/code/browser/widgets/gitref.py (+87/-11)
lib/lp/code/browser/widgets/tests/test_gitrefwidget.py (+56/-2)
lib/lp/code/configure.zcml (+10/-0)
lib/lp/code/enums.py (+7/-0)
lib/lp/code/interfaces/gitref.py (+11/-0)
lib/lp/code/model/gitref.py (+118/-2)
lib/lp/security.py (+12/-0)
lib/lp/testing/factory.py (+12/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-ref-remote
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+312346@code.launchpad.net

Commit message

Add GitRefRemote to encapsulate the notion of a ref in an external repository, and extend GitRefWidget to optionally support it.

Description of the change

This has no visible effect yet; the next branch in the series will hook it up.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/browser/tales.py'
2--- lib/lp/app/browser/tales.py 2016-07-12 13:32:10 +0000
3+++ lib/lp/app/browser/tales.py 2016-12-02 13:13:38 +0000
4@@ -1687,6 +1687,15 @@
5
6 _link_summary_template = '%(display_name)s'
7
8+ def url(self, view_name=None, rootsite=None):
9+ """See `ObjectFormatterAPI`.
10+
11+ `GitRefRemote` objects have no canonical URL.
12+ """
13+ if self._context.repository_url is not None:
14+ return None
15+ return super(GitRefFormatterAPI, self).url(view_name, rootsite)
16+
17 def _link_summary_values(self):
18 return {'display_name': self._context.display_name}
19
20
21=== modified file 'lib/lp/code/browser/configure.zcml'
22--- lib/lp/code/browser/configure.zcml 2016-10-15 01:12:01 +0000
23+++ lib/lp/code/browser/configure.zcml 2016-12-02 13:13:38 +0000
24@@ -24,6 +24,8 @@
25 PersonRevisionFeed ProductRevisionFeed ProjectRevisionFeed"
26 />
27
28+ <include package=".widgets"/>
29+
30 <facet facet="branches">
31
32 <browser:defaultView
33
34=== added file 'lib/lp/code/browser/widgets/configure.zcml'
35--- lib/lp/code/browser/widgets/configure.zcml 1970-01-01 00:00:00 +0000
36+++ lib/lp/code/browser/widgets/configure.zcml 2016-12-02 13:13:38 +0000
37@@ -0,0 +1,19 @@
38+<!-- Copyright 2016 Canonical Ltd. This software is licensed under the
39+ GNU Affero General Public License version 3 (see the file LICENSE).
40+-->
41+
42+<configure
43+ xmlns="http://namespaces.zope.org/zope"
44+ xmlns:i18n="http://namespaces.zope.org/i18n"
45+ i18n_domain="launchpad">
46+
47+ <view
48+ type="zope.publisher.interfaces.browser.IBrowserRequest"
49+ for="lp.code.browser.widgets.gitref.IGitRepositoryField
50+ lp.services.webapp.vocabulary.IHugeVocabulary"
51+ provides="zope.formlib.interfaces.IInputWidget"
52+ factory="lp.code.browser.widgets.gitref.GitRepositoryPickerWidget"
53+ permission="zope.Public"
54+ />
55+
56+</configure>
57
58=== modified file 'lib/lp/code/browser/widgets/gitref.py'
59--- lib/lp/code/browser/widgets/gitref.py 2015-09-25 17:25:23 +0000
60+++ lib/lp/code/browser/widgets/gitref.py 2016-12-02 13:13:38 +0000
61@@ -1,4 +1,4 @@
62-# Copyright 2015 Canonical Ltd. This software is licensed under the
63+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
64 # GNU Affero General Public License version 3 (see the file LICENSE).
65
66 __metaclass__ = type
67@@ -7,7 +7,9 @@
68 'GitRefWidget',
69 ]
70
71+import six
72 from z3c.ptcompat import ViewPageTemplateFile
73+from zope.component import getUtility
74 from zope.formlib.interfaces import (
75 ConversionError,
76 IInputWidget,
77@@ -25,15 +27,79 @@
78 Choice,
79 TextLine,
80 )
81+from zope.schema.interfaces import IChoice
82
83 from lp.app.errors import UnexpectedFormData
84 from lp.app.validators import LaunchpadValidationError
85+from lp.app.widgets.popup import VocabularyPickerWidget
86+from lp.code.interfaces.gitref import IGitRefRemoteSet
87+from lp.code.interfaces.gitrepository import IGitRepository
88+from lp.services.fields import URIField
89 from lp.services.webapp.interfaces import (
90 IAlwaysSubmittedWidget,
91 IMultiLineWidgetLayout,
92 )
93
94
95+class IGitRepositoryField(IChoice):
96+ pass
97+
98+
99+@implementer(IGitRepositoryField)
100+class GitRepositoryField(Choice):
101+ """A field identifying a Git repository.
102+
103+ This may always be set to the unique name of a Launchpad-hosted
104+ repository. If `allow_external` is True, then it may also be set to a
105+ valid external repository URL.
106+ """
107+
108+ def __init__(self, allow_external=False, **kwargs):
109+ super(GitRepositoryField, self).__init__(**kwargs)
110+ if allow_external:
111+ self._uri_field = URIField(
112+ __name__=self.__name__, title=self.title,
113+ allowed_schemes=["git", "http", "https"],
114+ allow_userinfo=True,
115+ allow_port=True,
116+ allow_query=False,
117+ allow_fragment=False,
118+ trailing_slash=False)
119+ else:
120+ self._uri_field = None
121+
122+ def set(self, object, value):
123+ if self._uri_field is not None and isinstance(value, six.string_types):
124+ try:
125+ self._uri_field.set(object, value)
126+ return
127+ except LaunchpadValidationError:
128+ pass
129+ super(GitRepositoryField, self).set(object, value)
130+
131+ def _validate(self, value):
132+ if self._uri_field is not None and isinstance(value, six.string_types):
133+ try:
134+ self._uri_field._validate(value)
135+ return
136+ except LaunchpadValidationError:
137+ pass
138+ super(GitRepositoryField, self)._validate(value)
139+
140+
141+class GitRepositoryPickerWidget(VocabularyPickerWidget):
142+
143+ def convertTokensToValues(self, tokens):
144+ if self.context._uri_field is not None:
145+ try:
146+ self.context._uri_field._validate(tokens[0])
147+ return [tokens[0]]
148+ except LaunchpadValidationError:
149+ pass
150+ return super(GitRepositoryPickerWidget, self).convertTokensToValues(
151+ tokens)
152+
153+
154 @implementer(IMultiLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget)
155 class GitRefWidget(BrowserWidget, InputWidget):
156
157@@ -41,13 +107,17 @@
158 display_label = False
159 _widgets_set_up = False
160
161+ # If True, allow entering external repository URLs.
162+ allow_external = False
163+
164 def setUpSubWidgets(self):
165 if self._widgets_set_up:
166 return
167 fields = [
168- Choice(
169+ GitRepositoryField(
170 __name__="repository", title=u"Git repository",
171- required=False, vocabulary="GitRepository"),
172+ required=False, vocabulary="GitRepository",
173+ allow_external=self.allow_external),
174 TextLine(__name__="path", title=u"Git branch", required=False),
175 ]
176 for field in fields:
177@@ -59,7 +129,10 @@
178 """See `IWidget`."""
179 self.setUpSubWidgets()
180 if value is not None:
181- self.repository_widget.setRenderedValue(value.repository)
182+ if self.allow_external and value.repository_url is not None:
183+ self.repository_widget.setRenderedValue(value.repository_url)
184+ else:
185+ self.repository_widget.setRenderedValue(value.repository)
186 self.path_widget.setRenderedValue(value.path)
187 else:
188 self.repository_widget.setRenderedValue(None)
189@@ -112,13 +185,16 @@
190 "Please enter a Git branch path."))
191 else:
192 return
193- ref = repository.getRefByPath(path)
194- if ref is None:
195- raise WidgetInputError(
196- self.name, self.label,
197- LaunchpadValidationError(
198- "The repository at %s does not contain a branch named "
199- "'%s'." % (repository.display_name, path)))
200+ if self.allow_external and not IGitRepository.providedBy(repository):
201+ ref = getUtility(IGitRefRemoteSet).new(repository, path)
202+ else:
203+ ref = repository.getRefByPath(path)
204+ if ref is None:
205+ raise WidgetInputError(
206+ self.name, self.label,
207+ LaunchpadValidationError(
208+ "The repository at %s does not contain a branch named "
209+ "'%s'." % (repository.display_name, path)))
210 return ref
211
212 def error(self):
213
214=== modified file 'lib/lp/code/browser/widgets/tests/test_gitrefwidget.py'
215--- lib/lp/code/browser/widgets/tests/test_gitrefwidget.py 2015-09-25 17:25:23 +0000
216+++ lib/lp/code/browser/widgets/tests/test_gitrefwidget.py 2016-12-02 13:13:38 +0000
217@@ -1,10 +1,14 @@
218-# Copyright 2015 Canonical Ltd. This software is licensed under the
219+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
220 # GNU Affero General Public License version 3 (see the file LICENSE).
221
222 __metaclass__ = type
223
224 from BeautifulSoup import BeautifulSoup
225 from lazr.restful.fields import Reference
226+from testscenarios import (
227+ load_tests_apply_scenarios,
228+ WithScenarios,
229+ )
230 from zope.formlib.interfaces import (
231 IBrowserWidget,
232 IInputWidget,
233@@ -36,10 +40,15 @@
234 pass
235
236
237-class TestGitRefWidget(TestCaseWithFactory):
238+class TestGitRefWidget(WithScenarios, TestCaseWithFactory):
239
240 layer = DatabaseFunctionalLayer
241
242+ scenarios = [
243+ ("disallow_external", {"allow_external": False}),
244+ ("allow_external", {"allow_external": True}),
245+ ]
246+
247 def setUp(self):
248 super(TestGitRefWidget, self).setUp()
249 field = Reference(
250@@ -48,6 +57,7 @@
251 field = field.bind(self.context)
252 request = LaunchpadTestRequest()
253 self.widget = GitRefWidget(field, request)
254+ self.widget.allow_external = self.allow_external
255
256 def test_implements(self):
257 self.assertTrue(verifyObject(IBrowserWidget, self.widget))
258@@ -85,6 +95,19 @@
259 ref.repository, self.widget.repository_widget._getCurrentValue())
260 self.assertEqual(ref.path, self.widget.path_widget._getCurrentValue())
261
262+ def test_setRenderedValue_external(self):
263+ # If allow_external is True, providing a reference in an external
264+ # repository works.
265+ self.widget.setUpSubWidgets()
266+ ref = self.factory.makeGitRefRemote()
267+ self.widget.setRenderedValue(ref)
268+ repository_value = self.widget.repository_widget._getCurrentValue()
269+ if self.allow_external:
270+ self.assertEqual(ref.repository_url, repository_value)
271+ else:
272+ self.assertIsNone(repository_value)
273+ self.assertEqual(ref.path, self.widget.path_widget._getCurrentValue())
274+
275 def test_hasInput_false(self):
276 # hasInput is false when the widget's name is not in the form data.
277 self.widget.request = LaunchpadTestRequest(form={})
278@@ -144,6 +167,18 @@
279 "There is no Git repository named 'non-existent' registered in "
280 "Launchpad.")
281
282+ def test_getInputValue_repository_invalid_url(self):
283+ # An error is raised when the repository field is set to an invalid
284+ # URL.
285+ form = {
286+ "field.git_ref.repository": "file:///etc/shadow",
287+ "field.git_ref.path": "master",
288+ }
289+ self.assertGetInputValueError(
290+ form,
291+ "There is no Git repository named 'file:///etc/shadow' "
292+ "registered in Launchpad.")
293+
294 def test_getInputValue_path_empty(self):
295 # An error is raised when the path field is empty.
296 repository = self.factory.makeGitRepository()
297@@ -197,6 +232,22 @@
298 self.widget.request = LaunchpadTestRequest(form=form)
299 self.assertEqual(ref, self.widget.getInputValue())
300
301+ def test_getInputValue_valid_url(self):
302+ # If allow_external is True, the repository may be a URL.
303+ ref = self.factory.makeGitRefRemote()
304+ form = {
305+ "field.git_ref.repository": ref.repository_url,
306+ "field.git_ref.path": ref.path,
307+ }
308+ if self.allow_external:
309+ self.widget.request = LaunchpadTestRequest(form=form)
310+ self.assertEqual(ref, self.widget.getInputValue())
311+ else:
312+ self.assertGetInputValueError(
313+ form,
314+ "There is no Git repository named '%s' registered in "
315+ "Launchpad." % ref.repository_url)
316+
317 def test_call(self):
318 # The __call__ method sets up the widgets.
319 markup = self.widget()
320@@ -207,3 +258,6 @@
321 ids = [field["id"] for field in fields]
322 self.assertContentEqual(
323 ["field.git_ref.repository", "field.git_ref.path"], ids)
324+
325+
326+load_tests = load_tests_apply_scenarios
327
328=== modified file 'lib/lp/code/configure.zcml'
329--- lib/lp/code/configure.zcml 2016-10-13 12:43:14 +0000
330+++ lib/lp/code/configure.zcml 2016-12-02 13:13:38 +0000
331@@ -901,6 +901,16 @@
332 permission="launchpad.View"
333 interface="lp.code.interfaces.gitref.IGitRef" />
334 </class>
335+ <class class="lp.code.model.gitref.GitRefRemote">
336+ <require
337+ permission="launchpad.View"
338+ interface="lp.code.interfaces.gitref.IGitRef" />
339+ </class>
340+ <securedutility
341+ component="lp.code.model.gitref.GitRefRemote"
342+ provides="lp.code.interfaces.gitref.IGitRefRemoteSet">
343+ <allow interface="lp.code.interfaces.gitref.IGitRefRemoteSet" />
344+ </securedutility>
345
346 <!-- GitCollection -->
347
348
349=== modified file 'lib/lp/code/enums.py'
350--- lib/lp/code/enums.py 2016-10-05 15:12:48 +0000
351+++ lib/lp/code/enums.py 2016-12-02 13:13:38 +0000
352@@ -135,6 +135,13 @@
353 repository and is made available through Launchpad.
354 """)
355
356+ REMOTE = DBItem(4, """
357+ Remote
358+
359+ Registered in Launchpad with an external location,
360+ but is not to be mirrored, nor available through Launchpad.
361+ """)
362+
363
364 class GitObjectType(DBEnumeratedType):
365 """Git Object Type
366
367=== modified file 'lib/lp/code/interfaces/gitref.py'
368--- lib/lp/code/interfaces/gitref.py 2016-12-02 13:01:53 +0000
369+++ lib/lp/code/interfaces/gitref.py 2016-12-02 13:13:38 +0000
370@@ -8,6 +8,7 @@
371 __all__ = [
372 'IGitRef',
373 'IGitRefBatchNavigator',
374+ 'IGitRefRemoteSet',
375 ]
376
377 from lazr.restful.declarations import (
378@@ -67,6 +68,9 @@
379 schema=Interface,
380 description=_("The Git repository containing this reference.")))
381
382+ repository_url = Attribute(
383+ "The repository URL, if this is a reference in a remote repository.")
384+
385 path = exported(TextLine(
386 title=_("Path"), required=True, readonly=True,
387 description=_(
388@@ -381,3 +385,10 @@
389
390 class IGitRefBatchNavigator(ITableBatchNavigator):
391 pass
392+
393+
394+class IGitRefRemoteSet(Interface):
395+ """Interface allowing creation of `GitRefRemote`s."""
396+
397+ def new(repository_url, path):
398+ """Create a new remote reference."""
399
400=== modified file 'lib/lp/code/model/gitref.py'
401--- lib/lp/code/model/gitref.py 2016-12-02 13:01:53 +0000
402+++ lib/lp/code/model/gitref.py 2016-12-02 13:13:38 +0000
403@@ -5,6 +5,7 @@
404 __all__ = [
405 'GitRef',
406 'GitRefFrozen',
407+ 'GitRefRemote',
408 ]
409
410 from datetime import datetime
411@@ -25,13 +26,18 @@
412 )
413 from zope.component import getUtility
414 from zope.event import notify
415-from zope.interface import implementer
416+from zope.interface import (
417+ implementer,
418+ provider,
419+ )
420 from zope.security.proxy import removeSecurityProxy
421
422+from lp.app.enums import InformationType
423 from lp.app.errors import NotFoundError
424 from lp.code.enums import (
425 BranchMergeProposalStatus,
426 GitObjectType,
427+ GitRepositoryType,
428 )
429 from lp.code.errors import (
430 BranchMergeProposalExists,
431@@ -46,7 +52,10 @@
432 )
433 from lp.code.interfaces.gitcollection import IAllGitRepositories
434 from lp.code.interfaces.githosting import IGitHostingClient
435-from lp.code.interfaces.gitref import IGitRef
436+from lp.code.interfaces.gitref import (
437+ IGitRef,
438+ IGitRefRemoteSet,
439+ )
440 from lp.code.model.branchmergeproposal import (
441 BranchMergeProposal,
442 BranchMergeProposalGetter,
443@@ -69,6 +78,8 @@
444 require a database record.
445 """
446
447+ repository_url = None
448+
449 @property
450 def display_name(self):
451 """See `IGitRef`."""
452@@ -573,3 +584,108 @@
453
454 def __hash__(self):
455 return hash(self.repository) ^ hash(self.path) ^ hash(self.commit_sha1)
456+
457+
458+@implementer(IGitRef)
459+@provider(IGitRefRemoteSet)
460+class GitRefRemote(GitRefMixin):
461+ """A reference in a remotely-hosted Git repository.
462+
463+ We can do very little with these - for example, we don't know their tip
464+ commit ID - but it's useful to be able to pass the repository URL and
465+ path around as a unit.
466+ """
467+
468+ def __init__(self, repository_url, path):
469+ self.repository_url = repository_url
470+ self.path = path
471+
472+ @classmethod
473+ def new(cls, repository_url, path):
474+ """See `IGitRefRemoteSet`."""
475+ return cls(repository_url, path)
476+
477+ def _unimplemented(self, *args, **kwargs):
478+ raise NotImplementedError("Not implemented for remote repositories.")
479+
480+ repository = None
481+ commit_sha1 = property(_unimplemented)
482+ object_type = property(_unimplemented)
483+ author = None
484+ author_date = None
485+ committer = None
486+ committer_date = None
487+ commit_message = None
488+ commit_message_first_line = property(_unimplemented)
489+
490+ @property
491+ def identity(self):
492+ """See `IGitRef`."""
493+ return "%s %s" % (self.repository_url, self.name)
494+
495+ @property
496+ def unique_name(self):
497+ """See `IGitRef`."""
498+ return "%s %s" % (self.repository_url, self.name)
499+
500+ repository_type = GitRepositoryType.REMOTE
501+ owner = property(_unimplemented)
502+ target = property(_unimplemented)
503+ namespace = property(_unimplemented)
504+ getCodebrowseUrl = _unimplemented
505+ getCodebrowseUrlForRevision = _unimplemented
506+ information_type = InformationType.PUBLIC
507+ private = False
508+
509+ def visibleByUser(self, user):
510+ """See `IGitRef`."""
511+ return True
512+
513+ transitionToInformationType = _unimplemented
514+ reviewer = property(_unimplemented)
515+ code_reviewer = property(_unimplemented)
516+ isPersonTrustedReviewer = _unimplemented
517+
518+ @property
519+ def subscriptions(self):
520+ """See `IGitRef`."""
521+ return []
522+
523+ @property
524+ def subscribers(self):
525+ """See `IGitRef`."""
526+ return []
527+
528+ subscribe = _unimplemented
529+ getSubscription = _unimplemented
530+ unsubscribe = _unimplemented
531+ getNotificationRecipients = _unimplemented
532+ landing_targets = property(_unimplemented)
533+ landing_candidates = property(_unimplemented)
534+ dependent_landings = property(_unimplemented)
535+ addLandingTarget = _unimplemented
536+ createMergeProposal = _unimplemented
537+ getMergeProposals = _unimplemented
538+ getDependentMergeProposals = _unimplemented
539+ pending_writes = False
540+
541+ def getCommits(self, *args, **kwargs):
542+ """See `IGitRef`."""
543+ return []
544+
545+ def getLatestCommits(self, *args, **kwargs):
546+ """See `IGitRef`."""
547+ return []
548+
549+ @property
550+ def recipes(self):
551+ """See `IHasRecipes`."""
552+ return []
553+
554+ def __eq__(self, other):
555+ return (
556+ self.repository_url == other.repository_url and
557+ self.path == other.path)
558+
559+ def __ne__(self, other):
560+ return not self == other
561
562=== modified file 'lib/lp/security.py'
563--- lib/lp/security.py 2016-11-15 11:44:27 +0000
564+++ lib/lp/security.py 2016-12-02 13:13:38 +0000
565@@ -2298,6 +2298,18 @@
566 def __init__(self, obj):
567 super(ViewGitRef, self).__init__(obj, obj.repository)
568
569+ def checkAuthenticated(self, user):
570+ if self.obj.repository is not None:
571+ return super(ViewGitRef, self).checkAuthenticated(user)
572+ else:
573+ return True
574+
575+ def checkUnauthenticated(self):
576+ if self.obj.repository is not None:
577+ return super(ViewGitRef, self).checkUnauthenticated()
578+ else:
579+ return True
580+
581
582 class EditGitRef(DelegatedAuthorization):
583 """Anyone who can edit a Git repository can edit references within it."""
584
585=== modified file 'lib/lp/testing/factory.py'
586--- lib/lp/testing/factory.py 2016-11-07 18:14:32 +0000
587+++ lib/lp/testing/factory.py 2016-12-02 13:13:38 +0000
588@@ -124,7 +124,10 @@
589 from lp.code.interfaces.codeimportmachine import ICodeImportMachineSet
590 from lp.code.interfaces.codeimportresult import ICodeImportResultSet
591 from lp.code.interfaces.gitnamespace import get_git_namespace
592-from lp.code.interfaces.gitref import IGitRef
593+from lp.code.interfaces.gitref import (
594+ IGitRef,
595+ IGitRefRemoteSet,
596+ )
597 from lp.code.interfaces.gitrepository import IGitRepository
598 from lp.code.interfaces.linkedbranch import ICanHasLinkedBranch
599 from lp.code.interfaces.revision import IRevisionSet
600@@ -1809,6 +1812,14 @@
601 refs_info, get_objects=True)}
602 return [refs_by_path[path] for path in paths]
603
604+ def makeGitRefRemote(self, repository_url=None, path=None):
605+ """Create an object representing a ref in a remote repository."""
606+ if repository_url is None:
607+ repository_url = self.getUniqueURL().decode('utf-8')
608+ if path is None:
609+ path = self.getUniqueString('refs/heads/path').decode('utf-8')
610+ return getUtility(IGitRefRemoteSet).new(repository_url, path)
611+
612 def makeBug(self, target=None, owner=None, bug_watch_url=None,
613 information_type=None, date_closed=None, title=None,
614 date_created=None, description=None, comment=None,