Merge lp:~cjwatson/launchpad/git-default-branch into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 17568
Proposed branch: lp:~cjwatson/launchpad/git-default-branch
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/git-detect-merges
Diff against target: 436 lines (+196/-6)
11 files modified
lib/lp/code/browser/gitrepository.py (+14/-1)
lib/lp/code/browser/tests/test_gitrepository.py (+48/-0)
lib/lp/code/configure.zcml (+4/-2)
lib/lp/code/errors.py (+20/-1)
lib/lp/code/interfaces/githosting.py (+14/-0)
lib/lp/code/interfaces/gitrepository.py (+15/-1)
lib/lp/code/model/githosting.py (+19/-0)
lib/lp/code/model/gitjob.py (+7/-0)
lib/lp/code/model/gitrepository.py (+19/-0)
lib/lp/code/model/tests/test_gitjob.py (+6/-1)
lib/lp/code/model/tests/test_gitrepository.py (+30/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-default-branch
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+261892@code.launchpad.net

Commit message

Allow getting and setting the default branch of a Git repository.

Description of the change

Allow getting and setting the default branch of a Git repository.

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
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-06-12 08:04:42 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-06-15 10:02:42 +0000
@@ -21,7 +21,10 @@
2121
22from lazr.lifecycle.event import ObjectModifiedEvent22from lazr.lifecycle.event import ObjectModifiedEvent
23from lazr.lifecycle.snapshot import Snapshot23from lazr.lifecycle.snapshot import Snapshot
24from lazr.restful.interface import copy_field24from lazr.restful.interface import (
25 copy_field,
26 use_template,
27 )
25from storm.expr import Desc28from storm.expr import Desc
26from zope.event import notify29from zope.event import notify
27from zope.interface import (30from zope.interface import (
@@ -266,6 +269,7 @@
266 This is necessary to make various fields editable that are not269 This is necessary to make various fields editable that are not
267 normally editable through the interface.270 normally editable through the interface.
268 """271 """
272 use_template(IGitRepository, include=["default_branch"])
269 information_type = copy_field(273 information_type = copy_field(
270 IGitRepository["information_type"], readonly=False,274 IGitRepository["information_type"], readonly=False,
271 vocabulary=InformationTypeVocabulary(types=info_types))275 vocabulary=InformationTypeVocabulary(types=info_types))
@@ -378,6 +382,7 @@
378 "owner",382 "owner",
379 "name",383 "name",
380 "information_type",384 "information_type",
385 "default_branch",
381 ]386 ]
382387
383 custom_widget("information_type", LaunchpadRadioWidgetWithDescription)388 custom_widget("information_type", LaunchpadRadioWidgetWithDescription)
@@ -416,6 +421,14 @@
416 (owner.displayname, target.displayname))421 (owner.displayname, target.displayname))
417 except GitRepositoryExists as e:422 except GitRepositoryExists as e:
418 self._setRepositoryExists(e.existing_repository)423 self._setRepositoryExists(e.existing_repository)
424 if "default_branch" in data:
425 default_branch = data["default_branch"]
426 if (default_branch is not None and
427 self.context.getRefByPath(default_branch) is None):
428 self.setFieldError(
429 "default_branch",
430 "This repository does not contain a reference named "
431 "'%s'." % default_branch)
419432
420433
421class GitRepositoryDeletionView(LaunchpadFormView):434class GitRepositoryDeletionView(LaunchpadFormView):
422435
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2015-06-05 13:10:18 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2015-06-15 10:02:42 +0000
@@ -15,6 +15,7 @@
15 DocTestMatches,15 DocTestMatches,
16 Equals,16 Equals,
17 )17 )
18import transaction
18from zope.component import getUtility19from zope.component import getUtility
19from zope.publisher.interfaces import NotFound20from zope.publisher.interfaces import NotFound
20from zope.security.proxy import removeSecurityProxy21from zope.security.proxy import removeSecurityProxy
@@ -22,6 +23,7 @@
22from lp.app.enums import InformationType23from lp.app.enums import InformationType
23from lp.app.interfaces.launchpad import ILaunchpadCelebrities24from lp.app.interfaces.launchpad import ILaunchpadCelebrities
24from lp.app.interfaces.services import IService25from lp.app.interfaces.services import IService
26from lp.code.interfaces.githosting import IGitHostingClient
25from lp.code.interfaces.revision import IRevisionSet27from lp.code.interfaces.revision import IRevisionSet
26from lp.registry.enums import BranchSharingPolicy28from lp.registry.enums import BranchSharingPolicy
27from lp.registry.interfaces.person import PersonVisibility29from lp.registry.interfaces.person import PersonVisibility
@@ -37,6 +39,8 @@
37 record_two_runs,39 record_two_runs,
38 TestCaseWithFactory,40 TestCaseWithFactory,
39 )41 )
42from lp.testing.fakemethod import FakeMethod
43from lp.testing.fixture import ZopeUtilityFixture
40from lp.testing.layers import DatabaseFunctionalLayer44from lp.testing.layers import DatabaseFunctionalLayer
41from lp.testing.matchers import HasQueryCount45from lp.testing.matchers import HasQueryCount
42from lp.testing.pages import (46from lp.testing.pages import (
@@ -343,6 +347,50 @@
343 self.assertEqual(347 self.assertEqual(
344 repository.information_type, InformationType.PUBLICSECURITY)348 repository.information_type, InformationType.PUBLICSECURITY)
345349
350 def test_change_default_branch(self):
351 # An authorised user can change the default branch to one that
352 # exists. They may omit "refs/heads/".
353 hosting_client = FakeMethod()
354 hosting_client.setProperties = FakeMethod()
355 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
356 person = self.factory.makePerson()
357 repository = self.factory.makeGitRepository(owner=person)
358 master, new = self.factory.makeGitRefs(
359 repository=repository,
360 paths=[u"refs/heads/master", u"refs/heads/new"])
361 removeSecurityProxy(repository)._default_branch = u"refs/heads/master"
362 browser = self.getUserBrowser(
363 canonical_url(repository) + "/+edit", user=person)
364 browser.getControl(name="field.default_branch").value = u"new"
365 browser.getControl("Change Git Repository").click()
366 with person_logged_in(person):
367 self.assertEqual(
368 [((repository.getInternalPath(),),
369 {u"default_branch": u"refs/heads/new"})],
370 hosting_client.setProperties.calls)
371 self.assertEqual(u"refs/heads/new", repository.default_branch)
372
373 def test_change_default_branch_nonexistent(self):
374 # Trying to change the default branch to one that doesn't exist
375 # displays an error.
376 person = self.factory.makePerson()
377 repository = self.factory.makeGitRepository(owner=person)
378 [master] = self.factory.makeGitRefs(
379 repository=repository, paths=[u"refs/heads/master"])
380 removeSecurityProxy(repository)._default_branch = u"refs/heads/master"
381 form = {
382 "field.default_branch": "refs/heads/new",
383 "field.actions.change": "Change Git Repository",
384 }
385 transaction.commit()
386 with person_logged_in(person):
387 view = create_initialized_view(repository, name="+edit", form=form)
388 self.assertEqual(
389 ["This repository does not contain a reference named "
390 "'refs/heads/new'."],
391 view.errors)
392 self.assertEqual(u"refs/heads/master", repository.default_branch)
393
346394
347class TestGitRepositoryEditViewInformationTypes(TestCaseWithFactory):395class TestGitRepositoryEditViewInformationTypes(TestCaseWithFactory):
348 """Tests for GitRepositoryEditView.getInformationTypesToShow."""396 """Tests for GitRepositoryEditView.getInformationTypesToShow."""
349397
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml 2015-06-12 17:29:59 +0000
+++ lib/lp/code/configure.zcml 2015-06-15 10:02:42 +0000
@@ -787,7 +787,8 @@
787 permission="launchpad.View"787 permission="launchpad.View"
788 interface="lp.app.interfaces.launchpad.IPrivacy788 interface="lp.app.interfaces.launchpad.IPrivacy
789 lp.code.interfaces.gitrepository.IGitRepositoryView789 lp.code.interfaces.gitrepository.IGitRepositoryView
790 lp.code.interfaces.gitrepository.IGitRepositoryModerateAttributes" />790 lp.code.interfaces.gitrepository.IGitRepositoryModerateAttributes
791 lp.code.interfaces.gitrepository.IGitRepositoryEditableAttributes" />
791 <require792 <require
792 permission="launchpad.Moderate"793 permission="launchpad.Moderate"
793 interface="lp.code.interfaces.gitrepository.IGitRepositoryModerate"794 interface="lp.code.interfaces.gitrepository.IGitRepositoryModerate"
@@ -795,7 +796,8 @@
795 set_attributes="date_last_modified" />796 set_attributes="date_last_modified" />
796 <require797 <require
797 permission="launchpad.Edit"798 permission="launchpad.Edit"
798 interface="lp.code.interfaces.gitrepository.IGitRepositoryEdit" />799 interface="lp.code.interfaces.gitrepository.IGitRepositoryEdit"
800 set_schema="lp.code.interfaces.gitrepository.IGitRepositoryEditableAttributes" />
799 </class>801 </class>
800 <subscriber802 <subscriber
801 for="lp.code.interfaces.gitrepository.IGitRepository zope.lifecycleevent.interfaces.IObjectModifiedEvent"803 for="lp.code.interfaces.gitrepository.IGitRepository zope.lifecycleevent.interfaces.IObjectModifiedEvent"
802804
=== modified file 'lib/lp/code/errors.py'
--- lib/lp/code/errors.py 2015-05-19 11:29:24 +0000
+++ lib/lp/code/errors.py 2015-06-15 10:02:42 +0000
@@ -43,6 +43,7 @@
43 'InvalidNamespace',43 'InvalidNamespace',
44 'NoLinkedBranch',44 'NoLinkedBranch',
45 'NoSuchBranch',45 'NoSuchBranch',
46 'NoSuchGitReference',
46 'NoSuchGitRepository',47 'NoSuchGitRepository',
47 'PrivateBranchRecipe',48 'PrivateBranchRecipe',
48 'ReviewNotPending',49 'ReviewNotPending',
@@ -61,7 +62,10 @@
61from bzrlib.plugins.builder.recipe import RecipeParseError62from bzrlib.plugins.builder.recipe import RecipeParseError
62from lazr.restful.declarations import error_status63from lazr.restful.declarations import error_status
6364
64from lp.app.errors import NameLookupFailed65from lp.app.errors import (
66 NameLookupFailed,
67 NotFoundError,
68 )
6569
66# Annotate the RecipeParseError's with a 400 webservice status.70# Annotate the RecipeParseError's with a 400 webservice status.
67error_status(httplib.BAD_REQUEST)(RecipeParseError)71error_status(httplib.BAD_REQUEST)(RecipeParseError)
@@ -402,6 +406,21 @@
402 _message_prefix = "No such Git repository"406 _message_prefix = "No such Git repository"
403407
404408
409class NoSuchGitReference(NotFoundError):
410 """Raised when we try to look up a Git reference that does not exist."""
411
412 def __init__(self, repository, path):
413 self.repository = repository
414 self.path = path
415 self.message = (
416 "The repository at %s does not contain a reference named '%s'." %
417 (repository.display_name, path))
418 NotFoundError.__init__(self, self.message)
419
420 def __str__(self):
421 return self.message
422
423
405@error_status(httplib.CONFLICT)424@error_status(httplib.CONFLICT)
406class GitDefaultConflict(Exception):425class GitDefaultConflict(Exception):
407 """Raised when trying to set a Git repository as the default for426 """Raised when trying to set a Git repository as the default for
408427
=== modified file 'lib/lp/code/interfaces/githosting.py'
--- lib/lp/code/interfaces/githosting.py 2015-06-15 09:49:42 +0000
+++ lib/lp/code/interfaces/githosting.py 2015-06-15 10:02:42 +0000
@@ -23,6 +23,20 @@
23 other physical path.23 other physical path.
24 """24 """
2525
26 def getProperties(path):
27 """Get properties of this repository.
28
29 :param path: Physical path of the repository on the hosting service.
30 :return: A dict of properties.
31 """
32
33 def setProperties(path, **props):
34 """Set properties of this repository.
35
36 :param path: Physical path of the repository on the hosting service.
37 :param props: Properties to set.
38 """
39
26 def getRefs(path):40 def getRefs(path):
27 """Get all refs in this repository.41 """Get all refs in this repository.
2842
2943
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2015-06-12 17:55:28 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2015-06-15 10:02:42 +0000
@@ -552,6 +552,19 @@
552 """552 """
553553
554554
555class IGitRepositoryEditableAttributes(Interface):
556 """IGitRepository attributes that can be edited.
557
558 These attributes need launchpad.View to see, and launchpad.Edit to change.
559 """
560
561 default_branch = exported(TextLine(
562 title=_("Default branch"), required=False, readonly=False,
563 description=_(
564 "The full path to the default branch for this repository, e.g. "
565 "refs/heads/master.")))
566
567
555class IGitRepositoryEdit(Interface):568class IGitRepositoryEdit(Interface):
556 """IGitRepository methods that require launchpad.Edit permission."""569 """IGitRepository methods that require launchpad.Edit permission."""
557570
@@ -619,7 +632,8 @@
619632
620633
621class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,634class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,
622 IGitRepositoryModerate, IGitRepositoryEdit):635 IGitRepositoryModerate, IGitRepositoryEditableAttributes,
636 IGitRepositoryEdit):
623 """A Git repository."""637 """A Git repository."""
624638
625 # Mark repositories as exported entries for the Launchpad API.639 # Mark repositories as exported entries for the Launchpad API.
626640
=== modified file 'lib/lp/code/model/githosting.py'
--- lib/lp/code/model/githosting.py 2015-06-15 09:49:42 +0000
+++ lib/lp/code/model/githosting.py 2015-06-15 10:02:42 +0000
@@ -68,6 +68,9 @@
68 def _post(self, path, **kwargs):68 def _post(self, path, **kwargs):
69 return self._request("post", path, **kwargs)69 return self._request("post", path, **kwargs)
7070
71 def _patch(self, path, **kwargs):
72 return self._request("patch", path, **kwargs)
73
71 def _delete(self, path, **kwargs):74 def _delete(self, path, **kwargs):
72 return self._request("delete", path, **kwargs)75 return self._request("delete", path, **kwargs)
7376
@@ -83,6 +86,22 @@
83 raise GitRepositoryCreationFault(86 raise GitRepositoryCreationFault(
84 "Failed to create Git repository: %s" % unicode(e))87 "Failed to create Git repository: %s" % unicode(e))
8588
89 def getProperties(self, path):
90 """See `IGitHostingClient`."""
91 try:
92 return self._get("/repo/%s" % path)
93 except Exception as e:
94 raise GitRepositoryScanFault(
95 "Failed to get properties of Git repository: %s" % unicode(e))
96
97 def setProperties(self, path, **props):
98 """See `IGitHostingClient`."""
99 try:
100 self._patch("/repo/%s" % path, json_data=props)
101 except Exception as e:
102 raise GitRepositoryScanFault(
103 "Failed to set properties of Git repository: %s" % unicode(e))
104
86 def getRefs(self, path):105 def getRefs(self, path):
87 """See `IGitHostingClient`."""106 """See `IGitHostingClient`."""
88 try:107 try:
89108
=== modified file 'lib/lp/code/model/gitjob.py'
--- lib/lp/code/model/gitjob.py 2015-06-12 17:55:28 +0000
+++ lib/lp/code/model/gitjob.py 2015-06-15 10:02:42 +0000
@@ -28,6 +28,7 @@
28 classProvides,28 classProvides,
29 implements,29 implements,
30 )30 )
31from zope.security.proxy import removeSecurityProxy
3132
32from lp.app.errors import NotFoundError33from lp.app.errors import NotFoundError
33from lp.code.interfaces.githosting import IGitHostingClient34from lp.code.interfaces.githosting import IGitHostingClient
@@ -212,6 +213,12 @@
212 hosting_path, refs_to_upsert, logger=log)213 hosting_path, refs_to_upsert, logger=log)
213 self.repository.synchroniseRefs(214 self.repository.synchroniseRefs(
214 refs_to_upsert, refs_to_remove, logger=log)215 refs_to_upsert, refs_to_remove, logger=log)
216 props = getUtility(IGitHostingClient).getProperties(
217 hosting_path)
218 # We don't want ref canonicalisation, nor do we want to send
219 # this change back to the hosting service.
220 removeSecurityProxy(self.repository)._default_branch = (
221 props["default_branch"])
215 except LostObjectError:222 except LostObjectError:
216 log.info(223 log.info(
217 "Skipping repository %s because it has been deleted." %224 "Skipping repository %s because it has been deleted." %
218225
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2015-06-12 17:55:28 +0000
+++ lib/lp/code/model/gitrepository.py 2015-06-15 10:02:42 +0000
@@ -72,6 +72,7 @@
72 CannotDeleteGitRepository,72 CannotDeleteGitRepository,
73 GitDefaultConflict,73 GitDefaultConflict,
74 GitTargetError,74 GitTargetError,
75 NoSuchGitReference,
75 )76 )
76from lp.code.event.git import GitRefsUpdatedEvent77from lp.code.event.git import GitRefsUpdatedEvent
77from lp.code.interfaces.branchmergeproposal import (78from lp.code.interfaces.branchmergeproposal import (
@@ -206,6 +207,8 @@
206 owner_default = Bool(name='owner_default', allow_none=False)207 owner_default = Bool(name='owner_default', allow_none=False)
207 target_default = Bool(name='target_default', allow_none=False)208 target_default = Bool(name='target_default', allow_none=False)
208209
210 _default_branch = Unicode(name='default_branch', allow_none=True)
211
209 def __init__(self, registrant, owner, target, name, information_type,212 def __init__(self, registrant, owner, target, name, information_type,
210 date_created, reviewer=None, description=None):213 date_created, reviewer=None, description=None):
211 super(GitRepository, self).__init__()214 super(GitRepository, self).__init__()
@@ -407,6 +410,22 @@
407 GitRef.repository_id == self.id,410 GitRef.repository_id == self.id,
408 GitRef.path.startswith(u"refs/heads/")).order_by(GitRef.path)411 GitRef.path.startswith(u"refs/heads/")).order_by(GitRef.path)
409412
413 @property
414 def default_branch(self):
415 """See `IGitRepository`."""
416 return self._default_branch
417
418 @default_branch.setter
419 def default_branch(self, value):
420 """See `IGitRepository`."""
421 ref = self.getRefByPath(value)
422 if ref is None:
423 raise NoSuchGitReference(self, value)
424 if self._default_branch != ref.path:
425 self._default_branch = ref.path
426 getUtility(IGitHostingClient).setProperties(
427 self.getInternalPath(), default_branch=ref.path)
428
410 def getRefByPath(self, path):429 def getRefByPath(self, path):
411 paths = [path]430 paths = [path]
412 if not path.startswith(u"refs/heads/"):431 if not path.startswith(u"refs/heads/"):
413432
=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
--- lib/lp/code/model/tests/test_gitjob.py 2015-06-15 09:27:33 +0000
+++ lib/lp/code/model/tests/test_gitjob.py 2015-06-15 10:02:42 +0000
@@ -52,9 +52,10 @@
5252
53 implements(IGitHostingClient)53 implements(IGitHostingClient)
5454
55 def __init__(self, refs, commits):55 def __init__(self, refs, commits, default_branch=u"refs/heads/master"):
56 self._refs = refs56 self._refs = refs
57 self._commits = commits57 self._commits = commits
58 self._default_branch = default_branch
5859
59 def getRefs(self, paths):60 def getRefs(self, paths):
60 return self._refs61 return self._refs
@@ -62,6 +63,9 @@
62 def getCommits(self, path, commit_oids, logger=None):63 def getCommits(self, path, commit_oids, logger=None):
63 return self._commits64 return self._commits
6465
66 def getProperties(self, path):
67 return {u"default_branch": self._default_branch}
68
6569
66class TestGitJob(TestCaseWithFactory):70class TestGitJob(TestCaseWithFactory):
67 """Tests for `GitJob`."""71 """Tests for `GitJob`."""
@@ -160,6 +164,7 @@
160 with dbuser("branchscanner"):164 with dbuser("branchscanner"):
161 JobRunner([job]).runAll()165 JobRunner([job]).runAll()
162 self.assertRefsMatch(repository.refs, repository, paths)166 self.assertRefsMatch(repository.refs, repository, paths)
167 self.assertEqual(u"refs/heads/master", repository.default_branch)
163168
164 def test_logs_bad_ref_info(self):169 def test_logs_bad_ref_info(self):
165 repository = self.factory.makeGitRepository()170 repository = self.factory.makeGitRepository()
166171
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2015-06-12 17:55:28 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2015-06-15 10:02:42 +0000
@@ -1217,6 +1217,36 @@
1217 ) for path, sha1 in expected_sha1s]1217 ) for path, sha1 in expected_sha1s]
1218 self.assertThat(repository.refs, MatchesSetwise(*matchers))1218 self.assertThat(repository.refs, MatchesSetwise(*matchers))
12191219
1220 def test_set_default_branch(self):
1221 hosting_client = FakeMethod()
1222 hosting_client.setProperties = FakeMethod()
1223 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
1224 repository = self.factory.makeGitRepository()
1225 self.factory.makeGitRefs(
1226 repository=repository,
1227 paths=(u"refs/heads/master", u"refs/heads/new"))
1228 removeSecurityProxy(repository)._default_branch = u"refs/heads/master"
1229 with person_logged_in(repository.owner):
1230 repository.default_branch = u"new"
1231 self.assertEqual(
1232 [((repository.getInternalPath(),),
1233 {u"default_branch": u"refs/heads/new"})],
1234 hosting_client.setProperties.calls)
1235 self.assertEqual(u"refs/heads/new", repository.default_branch)
1236
1237 def test_set_default_branch_unchanged(self):
1238 hosting_client = FakeMethod()
1239 hosting_client.setProperties = FakeMethod()
1240 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
1241 repository = self.factory.makeGitRepository()
1242 self.factory.makeGitRefs(
1243 repository=repository, paths=[u"refs/heads/master"])
1244 removeSecurityProxy(repository)._default_branch = u"refs/heads/master"
1245 with person_logged_in(repository.owner):
1246 repository.default_branch = u"master"
1247 self.assertEqual([], hosting_client.setProperties.calls)
1248 self.assertEqual(u"refs/heads/master", repository.default_branch)
1249
12201250
1221class TestGitRepositoryGetAllowedInformationTypes(TestCaseWithFactory):1251class TestGitRepositoryGetAllowedInformationTypes(TestCaseWithFactory):
1222 """Test `IGitRepository.getAllowedInformationTypes`."""1252 """Test `IGitRepository.getAllowedInformationTypes`."""