← Back to team overview

launchpad-reviewers team mailing list archive

Re: [Merge] lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad

 

I think I've fixed all this now, aside from one of your comments to which I've replied inline.

Diff comments:

> === added file 'lib/lp/code/githosting.py'
> --- lib/lp/code/githosting.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/githosting.py	2015-03-03 02:01:47 +0000
> @@ -0,0 +1,48 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Communication with the Git hosting service."""
> +
> +__metaclass__ = type
> +__all__ = [
> +    'GitHostingClient',
> +    ]
> +
> +import json
> +from urlparse import urljoin
> +
> +import requests
> +
> +from lp.code.errors import GitRepositoryCreationFault
> +
> +
> +class GitHostingClient:
> +    """A client for the internal API provided by the Git hosting system."""
> +
> +    def __init__(self, endpoint):
> +        self.endpoint = endpoint
> +
> +    def _makeSession(self):
> +        session = requests.Session()
> +        session.trust_env = False
> +        return session
> +
> +    @property
> +    def timeout(self):
> +        # XXX cjwatson 2015-03-01: The hardcoded timeout at least means that
> +        # we don't lock tables indefinitely if the hosting service falls
> +        # over, but is there some more robust way to do this?
> +        return 5.0
> +
> +    def create(self, path):
> +        # XXX cjwatson 2015-03-01: Once we're on requests >= 2.4.2, we
> +        # should just use post(json=) and drop the explicit Content-Type
> +        # header.
> +        response = self._makeSession().post(
> +            urljoin(self.endpoint, "repo"),
> +            headers={"Content-Type": "application/json"},
> +            data=json.dumps({"repo_path": path, "bare_repo": True}),
> +            timeout=self.timeout)
> +        if response.status_code != 200:
> +            raise GitRepositoryCreationFault(
> +                "Failed to create Git repository: %s" % response.text)
> 
> === added file 'lib/lp/code/interfaces/gitapi.py'
> --- lib/lp/code/interfaces/gitapi.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/interfaces/gitapi.py	2015-03-03 02:01:47 +0000
> @@ -0,0 +1,52 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Interfaces for internal Git APIs."""
> +
> +__metaclass__ = type
> +__all__ = [
> +    'IGitAPI',
> +    'IGitApplication',
> +    ]
> +
> +from zope.interface import Interface
> +
> +from lp.services.webapp.interfaces import ILaunchpadApplication
> +
> +
> +class IGitApplication(ILaunchpadApplication):
> +    """Git application root."""
> +
> +
> +class IGitAPI(Interface):
> +    """The Git XML-RPC interface to Launchpad.
> +
> +    Published at "git" on the private XML-RPC server.
> +
> +    The Git pack frontend uses this to translate user-visible paths to
> +    internal ones, and to notify Launchpad of ref changes.
> +    """
> +
> +    def translatePath(path, permission, requester_id, can_authenticate):
> +        """Translate 'path' so that the Git pack frontend can access it.
> +
> +        If the repository does not exist and write permission was requested,
> +        register a new repostory if possible.
> +
> +        :param path: The path being translated.  This should be a
> +            URL-escaped string representing an absolute path to a Git
> +            repository.
> +        :param permission: "read" or "write".
> +        :param requester_id: The database ID of the person requesting the
> +            path translation, or None for an anonymous request.
> +        :param can_authenticate: True if the requester is anonymous but
> +            could authenticate, otherwise False.
> +
> +        :returns: A `PathTranslationError` fault if 'path' cannot be
> +            translated; a `PermissionDenied` fault if the requester cannot
> +            see or create the repository; otherwise, a dict containing at
> +            least the following keys::
> +                "path", whose value is the repository's storage path;
> +                "writable", whose value is True if the requester can push to
> +                this repository, otherwise False.
> +        """
> 
> === modified file 'lib/lp/code/interfaces/gitrepository.py'
> --- lib/lp/code/interfaces/gitrepository.py	2015-02-26 11:34:47 +0000
> +++ lib/lp/code/interfaces/gitrepository.py	2015-03-03 02:01:47 +0000
> @@ -7,6 +7,7 @@
>  
>  __all__ = [
>      'GitIdentityMixin',
> +    'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
>      'git_repository_name_validator',
>      'IGitRepository',
>      'IGitRepositorySet',
> @@ -191,6 +192,12 @@
>          If the user is a Launchpad admin, any type is acceptable.
>          """
>  
> +    def getInternalPathForId(repository_id):
> +        """Get the internal path to the repository with this ID.
> +
> +        This is used on the storage backend.
> +        """
> +
>      def getInternalPath():
>          """Get the internal path to this repository.
>  
> 
> === modified file 'lib/lp/code/model/gitlookup.py'
> --- lib/lp/code/model/gitlookup.py	2015-02-27 10:22:24 +0000
> +++ lib/lp/code/model/gitlookup.py	2015-03-03 02:01:47 +0000
> @@ -342,6 +342,8 @@
>              return None
>          if repository is not None:
>              return repository
> +        if IPerson.providedBy(target):
> +            return None
>          repository_set = getUtility(IGitRepositorySet)
>          if owner is None:
>              return repository_set.getDefaultRepository(target)
> 
> === modified file 'lib/lp/code/model/gitrepository.py'
> --- lib/lp/code/model/gitrepository.py	2015-02-26 17:32:56 +0000
> +++ lib/lp/code/model/gitrepository.py	2015-03-03 02:01:47 +0000
> @@ -232,10 +232,16 @@
>      def display_name(self):
>          return self.git_identity
>  
> +    @staticmethod
> +    def getInternalPathForId(repository_id):
> +        """See `IGitRepository`."""
> +        # This may need to change later to improve support for sharding.
> +        return str(repository_id)
> +
>      def getInternalPath(self):
>          """See `IGitRepository`."""
>          # This may need to change later to improve support for sharding.
> -        return str(self.id)
> +        return self.getInternalPathForId(self.id)
>  
>      def getCodebrowseUrl(self):
>          """See `IGitRepository`."""
> 
> === modified file 'lib/lp/code/model/tests/test_gitlookup.py'
> --- lib/lp/code/model/tests/test_gitlookup.py	2015-02-27 10:22:24 +0000
> +++ lib/lp/code/model/tests/test_gitlookup.py	2015-03-03 02:01:47 +0000
> @@ -117,6 +117,12 @@
>          project = self.factory.makeProduct()
>          self.assertIsNone(self.lookup.getByPath(project.name))
>  
> +    def test_bare_person(self):
> +        # If `getByPath` is given a path to a person but nothing further, it
> +        # returns None even if the person exists.
> +        owner = self.factory.makePerson()
> +        self.assertIsNone(self.lookup.getByPath("~%s" % owner.name))
> +
>  
>  class TestGetByUrl(TestCaseWithFactory):
>      """Test `IGitLookup.getByUrl`."""
> 
> === modified file 'lib/lp/code/xmlrpc/codehosting.py'
> --- lib/lp/code/xmlrpc/codehosting.py	2012-11-26 08:33:03 +0000
> +++ lib/lp/code/xmlrpc/codehosting.py	2015-03-03 02:01:47 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Implementations of the XML-RPC APIs for codehosting."""
> @@ -7,6 +7,7 @@
>  __all__ = [
>      'CodehostingAPI',
>      'datetime_from_tuple',
> +    'run_with_login',
>      ]
>  
>  
> 
> === added file 'lib/lp/code/xmlrpc/git.py'
> --- lib/lp/code/xmlrpc/git.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/xmlrpc/git.py	2015-03-03 02:01:47 +0000
> @@ -0,0 +1,220 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Implementations of the XML-RPC APIs for Git."""
> +
> +__metaclass__ = type
> +__all__ = [
> +    'GitAPI',
> +    ]
> +
> +from bzrlib.urlutils import (
> +    escape,
> +    unescape,
> +    )
> +from storm.store import Store
> +import transaction
> +from zope.component import getUtility
> +from zope.interface import implements
> +from zope.security.interfaces import Unauthorized
> +
> +from lp.app.errors import NameLookupFailed
> +from lp.app.validators import LaunchpadValidationError
> +from lp.code.errors import (
> +    GitRepositoryCreationException,
> +    GitRepositoryCreationForbidden,
> +    InvalidNamespace,
> +    )
> +from lp.code.githosting import GitHostingClient
> +from lp.code.interfaces.codehosting import LAUNCHPAD_ANONYMOUS
> +from lp.code.interfaces.gitapi import IGitAPI
> +from lp.code.interfaces.gitlookup import (
> +    IGitLookup,
> +    IGitTraverser,
> +    )
> +from lp.code.interfaces.gitnamespace import (
> +    get_git_namespace,
> +    split_git_unique_name,
> +    )
> +from lp.code.interfaces.gitrepository import IGitRepositorySet
> +from lp.code.xmlrpc.codehosting import run_with_login
> +from lp.registry.errors import (
> +    InvalidName,
> +    NoSuchSourcePackageName,
> +    )
> +from lp.registry.interfaces.distributionsourcepackage import (
> +    IDistributionSourcePackage,
> +    )
> +from lp.registry.interfaces.person import (
> +    IPerson,
> +    NoSuchPerson,
> +    )
> +from lp.registry.interfaces.product import (
> +    InvalidProductName,
> +    NoSuchProduct,
> +    )
> +from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
> +from lp.services.config import config
> +from lp.services.webapp import LaunchpadXMLRPCView
> +from lp.services.webapp.authorization import check_permission
> +from lp.xmlrpc import faults
> +from lp.xmlrpc.helpers import return_fault
> +
> +
> +class GitAPI(LaunchpadXMLRPCView):
> +    """See `IGitAPI`."""
> +
> +    implements(IGitAPI)
> +
> +    def __init__(self, *args, **kwargs):
> +        super(GitAPI, self).__init__(*args, **kwargs)
> +        self.hosting_client = GitHostingClient(
> +            config.codehosting.internal_git_api_endpoint)
> +
> +    def _performLookup(self, path):
> +        repository = getUtility(IGitLookup).getByPath(path)
> +        if repository is None:
> +            return None
> +        try:
> +            hosting_path = repository.getInternalPath()
> +        except Unauthorized:
> +            raise faults.PermissionDenied()
> +        writable = check_permission("launchpad.Edit", repository)
> +        return {"path": hosting_path, "writable": writable}
> +
> +    def _getGitNamespaceExtras(self, path, requester):
> +        """Get the namespace, repository name, and callback for the path.
> +
> +        If the path defines a full Git repository path including the owner
> +        and repository name, then the namespace that is returned is the
> +        namespace for the owner and the repository target specified.
> +
> +        If the path uses a shortcut name, then we only allow the requester
> +        to create a repository if they have permission to make the newly
> +        created repository the default for the shortcut target.  If there is
> +        an existing default repository, then GitRepositoryExists is raised.
> +        The repository name that is used is determined by the namespace as
> +        the first unused name starting with the leaf part of the namespace
> +        name.  In this case, the repository owner will be set to the
> +        namespace owner, and distribution source package namespaces are
> +        currently disallowed due to the complexities of ownership there.
> +        """
> +        try:
> +            namespace_name, repository_name = split_git_unique_name(path)
> +        except InvalidNamespace:
> +            namespace_name = path
> +            repository_name = None
> +        owner, target, repository = getUtility(IGitTraverser).traverse_path(
> +            namespace_name)
> +        # split_git_unique_name should have left us without a repository name.
> +        assert repository is None
> +        if repository_name is None and IPerson.providedBy(target):
> +            raise InvalidNamespace(path)
> +        if owner is None:
> +            if IDistributionSourcePackage.providedBy(target):
> +                raise GitRepositoryCreationForbidden(
> +                    "Cannot create default package repository; push to a "
> +                    "per-owner repository instead.")
> +            repository_owner = requester
> +        else:
> +            repository_owner = owner
> +        namespace = get_git_namespace(target, repository_owner)
> +        if repository_name is None:
> +            def default_func(new_repository):
> +                repository_set = getUtility(IGitRepositorySet)
> +                if owner is None:
> +                    repository_set.setDefaultRepository(
> +                        target, new_repository)
> +                else:
> +                    repository_set.setDefaultRepositoryForOwner(
> +                        owner, target, new_repository)
> +
> +            repository_name = namespace.findUnusedName(target.name)
> +            return namespace, repository_name, default_func
> +        else:
> +            return namespace, repository_name, None
> +
> +    def _createRepository(self, requester, path):
> +        try:
> +            namespace, repository_name, default_func = (
> +                self._getGitNamespaceExtras(path, requester))
> +        except (ValueError, InvalidNamespace):
> +            raise faults.PermissionDenied(
> +                "Cannot create Git repository at '%s'" % path)
> +        except NoSuchPerson as e:
> +            raise faults.NotFound("User/team '%s' does not exist." % e.name)
> +        except NoSuchProduct as e:
> +            raise faults.NotFound("Project '%s' does not exist." % e.name)
> +        except InvalidProductName as e:
> +            raise faults.InvalidProductName(escape(e.name))
> +        except NoSuchSourcePackageName as e:
> +            try:
> +                getUtility(ISourcePackageNameSet).new(e.name)
> +            except InvalidName:
> +                raise faults.InvalidSourcePackageName(e.name)
> +            return self._createRepository(requester, path)
> +        except NameLookupFailed as e:
> +            raise faults.NotFound(str(e))
> +        except GitRepositoryCreationForbidden as e:
> +            raise faults.PermissionDenied(str(e))
> +
> +        try:
> +            repository = namespace.createRepository(
> +                requester, repository_name)
> +        except LaunchpadValidationError as e:
> +            msg = e.args[0]
> +            if isinstance(msg, unicode):
> +                msg = msg.encode('utf-8')
> +            raise faults.PermissionDenied(msg)
> +        except GitRepositoryCreationException as e:
> +            raise faults.PermissionDenied(str(e))
> +
> +        try:
> +            if default_func:
> +                try:
> +                    default_func(repository)
> +                except Unauthorized:
> +                    raise faults.PermissionDenied(
> +                        "Cannot create default Git repository at '%s'." % path)
> +
> +            # The transaction hasn't been committed yet (and shouldn't be
> +            # until the non-transactional work is complete), so
> +            # repository.id will not yet have been filled in, but we need it
> +            # to create the hosting path.
> +            store = Store.of(repository)
> +            repository_id = store.execute(
> +                """SELECT currval('gitrepository_id_seq')""").get_one()[0]
> +
> +            hosting_path = repository.getInternalPathForId(repository_id)
> +            # XXX cjwatson 2015-02-27: Turn any exceptions into proper faults.
> +            self.hosting_client.create(hosting_path)
> +        except Exception:
> +            # We don't want to keep the repository we created.
> +            transaction.abort()
> +            raise

Yes, I just confirmed this in a test.  I don't know exactly where the abort normally happens, but my guess is that the return_fault decorator defeats it here.

> +
> +    @return_fault
> +    def _translatePath(self, requester, path, permission, can_authenticate):
> +        if requester == LAUNCHPAD_ANONYMOUS:
> +            requester = None
> +        result = self._performLookup(path)
> +        if result is None and requester is not None and permission == "write":
> +            self._createRepository(requester, path)
> +            result = self._performLookup(path)
> +        if result is None:
> +            raise faults.PathTranslationError(path)
> +        if permission != "read" and not result["writable"]:
> +            # XXX 2015-02-03 cjwatson: We should make use of
> +            # can_authenticate to distinguish between "read-only to
> +            # authenticated user" and "might be able to write if logged
> +            # in".
> +            raise faults.PermissionDenied()
> +        return result
> +
> +    def translatePath(self, path, permission, requester_id, can_authenticate):
> +        """See `IGitAPI`."""
> +        if requester_id is None:
> +            requester_id = LAUNCHPAD_ANONYMOUS
> +        return run_with_login(
> +            requester_id, self._translatePath,
> +            unescape(path).strip("/"), permission, can_authenticate)
> 
> === added file 'lib/lp/code/xmlrpc/tests/test_git.py'
> --- lib/lp/code/xmlrpc/tests/test_git.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/xmlrpc/tests/test_git.py	2015-03-03 02:01:47 +0000
> @@ -0,0 +1,489 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Tests for the internal Git API."""
> +
> +__metaclass__ = type
> +
> +from bzrlib.urlutils import escape
> +from zope.component import getUtility
> +from zope.security.proxy import removeSecurityProxy
> +
> +from lp.app.enums import InformationType
> +from lp.code.interfaces.codehosting import (
> +    LAUNCHPAD_ANONYMOUS,
> +    LAUNCHPAD_SERVICES,
> +    )
> +from lp.code.interfaces.gitrepository import (
> +    GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
> +    IGitRepositorySet,
> +    )
> +from lp.code.xmlrpc.git import GitAPI
> +from lp.services.webapp.escaping import html_escape
> +from lp.testing import (
> +    ANONYMOUS,
> +    login,
> +    person_logged_in,
> +    TestCaseWithFactory,
> +    )
> +from lp.testing.layers import LaunchpadFunctionalLayer
> +from lp.xmlrpc import faults
> +
> +
> +class FakeGitHostingClient:
> +
> +    def __init__(self):
> +        self.calls = []
> +
> +    def create(self, path):
> +        self.calls.append(("create", path))
> +
> +
> +class TestGitAPI(TestCaseWithFactory):
> +    """Tests for the implementation of `IGitAPI`."""
> +
> +    layer = LaunchpadFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestGitAPI, self).setUp()
> +        self.git_api = GitAPI(None, None)
> +        self.git_api.hosting_client = FakeGitHostingClient()
> +
> +    def assertPathTranslationError(self, requester, path, permission="read",
> +                                   can_authenticate=False):
> +        """Assert that the given path cannot be translated."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            escape(path), permission, requester, can_authenticate)
> +        self.assertEqual(faults.PathTranslationError(path.strip("/")), fault)
> +
> +    def assertPermissionDenied(self, requester, path,
> +                               message="Permission denied.",
> +                               permission="read", can_authenticate=False):
> +        """Assert that looking at the given path gives permission denied."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            escape(path), permission, requester, can_authenticate)
> +        self.assertEqual(faults.PermissionDenied(message), fault)
> +
> +    def assertNotFound(self, requester, path, message, permission="read",
> +                       can_authenticate=False):
> +        """Assert that looking at the given path returns NotFound."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            escape(path), permission, requester, can_authenticate)
> +        self.assertEqual(faults.NotFound(message), fault)
> +
> +    def assertInvalidProductName(self, requester, path, name,
> +                                 permission="read", can_authenticate=False):
> +        """Assert that looking at the given path returns InvalidProductName."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            escape(path), permission, requester, can_authenticate)
> +        self.assertEqual(faults.InvalidProductName(name), fault)
> +
> +    def assertInvalidSourcePackageName(self, requester, path, name,
> +                                       permission="read",
> +                                       can_authenticate=False):
> +        """Assert that looking at the given path returns
> +        InvalidSourcePackageName."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            escape(path), permission, requester, can_authenticate)
> +        self.assertEqual(faults.InvalidSourcePackageName(name), fault)
> +
> +    def assertTranslates(self, requester, path, repository, writable,
> +                         permission="read"):
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        translation = self.git_api.translatePath(
> +            escape(path), permission, requester, False)
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            {"path": repository.getInternalPath(), "writable": writable},
> +            translation)
> +
> +    def test_translatePath_cannot_translate(self):
> +        # Sometimes translatePath will not know how to translate a path.
> +        # When this happens, it returns a Fault saying so, including the
> +        # path it couldn't translate.
> +        requester = self.factory.makePerson()
> +        self.assertPathTranslationError(requester, u"/untranslatable")
> +
> +    def test_translatePath_repository(self):
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository()
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(requester, path, repository, False)
> +
> +    def test_translatePath_repository_with_no_leading_slash(self):
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository()
> +        path = repository.unique_name
> +        self.assertTranslates(requester, path, repository, False)
> +
> +    def test_translatePath_repository_with_trailing_slash(self):
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository()
> +        path = u"/%s/" % repository.unique_name
> +        self.assertTranslates(requester, path, repository, False)
> +
> +    def test_translatePath_repository_with_trailing_segments(self):
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository()
> +        path = u"/%s/junk" % repository.unique_name
> +        self.assertPathTranslationError(requester, path)
> +
> +    def test_translatePath_no_such_repository(self):
> +        requester = self.factory.makePerson()
> +        path = u"/%s/+git/no-such-repository" % requester.name
> +        self.assertPathTranslationError(requester, path)
> +
> +    def test_translatePath_no_such_repository_non_ascii(self):
> +        requester = self.factory.makePerson()
> +        path = u"/%s/+git/\N{LATIN SMALL LETTER I WITH DIAERESIS}" % (
> +            requester.name)
> +        self.assertPathTranslationError(requester, path)
> +
> +    def test_translatePath_private_repository(self):
> +        requester = self.factory.makePerson()
> +        repository = removeSecurityProxy(
> +            self.factory.makeGitRepository(
> +                owner=requester, information_type=InformationType.USERDATA))
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(requester, path, repository, True)
> +
> +    def test_translatePath_cannot_see_private_repository(self):
> +        requester = self.factory.makePerson()
> +        repository = removeSecurityProxy(
> +            self.factory.makeGitRepository(
> +                information_type=InformationType.USERDATA))
> +        path = u"/%s" % repository.unique_name
> +        self.assertPermissionDenied(requester, path)
> +
> +    def test_translatePath_anonymous_cannot_see_private_repository(self):
> +        repository = removeSecurityProxy(
> +            self.factory.makeGitRepository(
> +                information_type=InformationType.USERDATA))
> +        path = u"/%s" % repository.unique_name
> +        self.assertPermissionDenied(LAUNCHPAD_ANONYMOUS, path)
> +
> +    def test_translatePath_anonymous_public_repository(self):
> +        repository = self.factory.makeGitRepository()
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(LAUNCHPAD_ANONYMOUS, path, repository, False)
> +
> +    def test_translatePath_owned(self):
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository(owner=requester)
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(
> +            requester, path, repository, True, permission="write")
> +
> +    def test_translatePath_team_owned(self):
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(requester)
> +        repository = self.factory.makeGitRepository(owner=team)
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(
> +            requester, path, repository, True, permission="write")
> +
> +    def test_translatePath_team_unowned(self):
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(self.factory.makePerson())
> +        repository = self.factory.makeGitRepository(owner=team)
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(requester, path, repository, False)
> +        self.assertPermissionDenied(requester, path, permission="write")
> +
> +    def test_translatePath_shortened_path(self):
> +        # translatePath translates the shortened path to a repository.
> +        requester = self.factory.makePerson()
> +        repository = self.factory.makeGitRepository()
> +        with person_logged_in(repository.target.owner):
> +            getUtility(IGitRepositorySet).setDefaultRepository(
> +                repository.target, repository)
> +        path = u"/%s" % repository.target.name
> +        self.assertTranslates(requester, path, repository, False)
> +
> +    def assertCreates(self, requester, path):
> +        if requester in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester_id = requester
> +        else:
> +            requester_id = requester.id
> +        translation = self.git_api.translatePath(
> +            escape(path), "write", requester_id, False)
> +        login(ANONYMOUS)
> +        repository = getUtility(IGitRepositorySet).getByPath(
> +            requester, path.lstrip("/"))
> +        self.assertIsNotNone(repository)
> +        self.assertEqual(requester, repository.registrant)
> +        self.assertEqual(
> +            {"path": repository.getInternalPath(), "writable": True},
> +            translation)
> +        self.assertEqual(
> +            [("create", repository.getInternalPath())],
> +            self.git_api.hosting_client.calls)
> +        return repository
> +
> +    def test_translatePath_create_project(self):
> +        # translatePath creates a project repository that doesn't exist, if
> +        # it can.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        self.assertCreates(
> +            requester, u"/~%s/%s/+git/random" % (requester.name, project.name))
> +
> +    def test_translatePath_create_package(self):
> +        # translatePath creates a package repository that doesn't exist, if
> +        # it can.
> +        requester = self.factory.makePerson()
> +        dsp = self.factory.makeDistributionSourcePackage()
> +        self.assertCreates(
> +            requester,
> +            u"/~%s/%s/+source/%s/+git/random" % (
> +                requester.name,
> +                dsp.distribution.name, dsp.sourcepackagename.name))
> +
> +    def test_translatePath_create_personal(self):
> +        # translatePath creates a personal repository that doesn't exist, if
> +        # it can.
> +        requester = self.factory.makePerson()
> +        self.assertCreates(requester, u"/~%s/+git/random" % requester.name)
> +
> +    def test_translatePath_create_personal_team(self):
> +        # translatePath creates a personal repository for a team of which
> +        # the requester is a member.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(members=[requester])
> +        self.assertCreates(requester, u"/~%s/+git/random" % team.name)
> +
> +    def test_translatePath_create_personal_team_denied(self):
> +        # translatePath refuses to create a personal repository for a team
> +        # of which the requester is not a member.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam()
> +        message = "%s is not a member of %s" % (
> +            requester.displayname, team.displayname)
> +        self.assertPermissionDenied(
> +            requester, u"/~%s/+git/random" % team.name, message=message,
> +            permission="write")
> +
> +    def test_translatePath_anonymous_cannot_create(self):
> +        # Anonymous users cannot create repositories.
> +        project = self.factory.makeProject()
> +        self.assertPathTranslationError(
> +            LAUNCHPAD_ANONYMOUS, u"/%s" % project.name, permission="write")
> +
> +    def test_translatePath_create_invalid_namespace(self):
> +        # Trying to create a repository at a path that isn't valid for Git
> +        # repositories returns a PermissionDenied fault.
> +        requester = self.factory.makePerson()
> +        path = u"/~%s" % requester.name
> +        self.assertPermissionDenied(
> +            requester, path,
> +            message="Cannot create Git repository at '%s'" % path.strip("/"),
> +            permission="write")
> +
> +    def test_translatePath_create_no_such_person(self):
> +        # Creating a repository for a non-existent person fails.
> +        requester = self.factory.makePerson()
> +        self.assertNotFound(
> +            requester, u"/~nonexistent/+git/random",
> +            "User/team 'nonexistent' does not exist.", permission="write")
> +
> +    def test_translatePath_create_no_such_project(self):
> +        # Creating a repository for a non-existent project fails.
> +        requester = self.factory.makePerson()
> +        self.assertNotFound(
> +            requester, u"/~%s/nonexistent/+git/random" % requester.name,
> +            "Project 'nonexistent' does not exist.", permission="write")
> +
> +    def test_translatePath_create_no_such_person_or_project(self):
> +        # If neither the person nor the project are found, then the missing
> +        # person is reported in preference.
> +        requester = self.factory.makePerson()
> +        self.assertNotFound(
> +            requester, u"/~nonexistent/nonexistent/+git/random",
> +            "User/team 'nonexistent' does not exist.", permission="write")
> +
> +    def test_translatePath_create_invalid_project(self):
> +        # Creating a repository with an invalid project name fails.
> +        requester = self.factory.makePerson()
> +        self.assertInvalidProductName(
> +            requester, u"/_bad_project/+git/random", "_bad_project",
> +            permission="write")
> +
> +    def test_translatePath_create_missing_sourcepackagename(self):
> +        # If translatePath is asked to create a repository for a missing
> +        # source package, it will create the source package.
> +        requester = self.factory.makePerson()
> +        distro = self.factory.makeDistribution()
> +        repository_name = self.factory.getUniqueString()
> +        path = u"/~%s/%s/+source/new-package/+git/%s" % (
> +            requester.name, distro.name, repository_name)
> +        repository = self.assertCreates(requester, path)
> +        self.assertEqual(
> +            "new-package", repository.target.sourcepackagename.name)
> +
> +    def test_translatePath_create_invalid_sourcepackagename(self):
> +        # Creating a repository for an invalid source package name fails.
> +        requester = self.factory.makePerson()
> +        distro = self.factory.makeDistribution()
> +        repository_name = self.factory.getUniqueString()
> +        path = u"/~%s/%s/+source/new package/+git/%s" % (
> +            requester.name, distro.name, repository_name)
> +        self.assertInvalidSourcePackageName(
> +            requester, path, "new package", permission="write")
> +
> +    def test_translatePath_create_other_user(self):
> +        # Creating a repository for another user fails.
> +        requester = self.factory.makePerson()
> +        other_person = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        name = self.factory.getUniqueString()
> +        path = u"/~%s/%s/+git/%s" % (other_person.name, project.name, name)
> +        message = "%s cannot create Git repositories owned by %s" % (
> +            requester.displayname, other_person.displayname)
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +
> +    def test_translatePath_create_bad_name(self):
> +        # Creating a repository with an invalid name fails.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        invalid_name = "invalid name!"
> +        path = u"/~%s/%s/+git/%s" % (
> +            requester.name, project.name, invalid_name)
> +        # LaunchpadValidationError unfortunately assumes its output is
> +        # always HTML, so it ends up double-escaped in XML-RPC faults.
> +        message = html_escape(
> +            "Invalid Git repository name '%s'. %s" %
> +            (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +
> +    def test_translatePath_create_unicode_name(self):
> +        # Creating a repository with a non-ASCII invalid name fails.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        invalid_name = u"invalid\N{LATIN SMALL LETTER E WITH ACUTE}"
> +        path = u"/~%s/%s/+git/%s" % (
> +            requester.name, project.name, invalid_name)
> +        # LaunchpadValidationError unfortunately assumes its output is
> +        # always HTML, so it ends up double-escaped in XML-RPC faults.
> +        message = html_escape(
> +            "Invalid Git repository name '%s'. %s" %
> +            (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
> +        self.assertPermissionDenied(
> +            requester, path, message=message.encode("UTF-8"),
> +            permission="write")
> +
> +    def test_translatePath_create_project_default(self):
> +        # A repository can be created and immediately set as the default for
> +        # a project.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct(owner=requester)
> +        repository = self.assertCreates(requester, u"/%s" % project.name)
> +        self.assertTrue(repository.target_default)
> +        self.assertFalse(repository.owner_default)
> +
> +    def test_translatePath_create_project_not_owner(self):
> +        # Somebody without edit permission on the project cannot create a
> +        # repository and immediately set it as the default for that project.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        path = u"/%s" % project.name
> +        message = "Cannot create default Git repository at '%s'." % (
> +            path.strip("/"))
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +
> +    def test_translatePath_create_package_default_denied(self):
> +        # A repository cannot (yet) be created and immediately set as the
> +        # default for a package.
> +        requester = self.factory.makePerson()
> +        dsp = self.factory.makeDistributionSourcePackage()
> +        path = u"/%s/+source/%s" % (
> +            dsp.distribution.name, dsp.sourcepackagename.name)
> +        message = (
> +            "Cannot create default package repository; push to a per-owner "
> +            "repository instead.")
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +
> +    def test_translatePath_create_project_owner_default(self):
> +        # A repository can be created and immediately set as its owner's
> +        # default for a project.
> +        requester = self.factory.makePerson()
> +        project = self.factory.makeProduct()
> +        repository = self.assertCreates(
> +            requester, u"/~%s/%s" % (requester.name, project.name))
> +        self.assertFalse(repository.target_default)
> +        self.assertTrue(repository.owner_default)
> +
> +    def test_translatePath_create_project_team_owner_default(self):
> +        # The owner of a team can create a team-owned repository and
> +        # immediately set it as that team's default for a project.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(owner=requester)
> +        project = self.factory.makeProduct()
> +        repository = self.assertCreates(
> +            requester, u"/~%s/%s" % (team.name, project.name))
> +        self.assertFalse(repository.target_default)
> +        self.assertTrue(repository.owner_default)
> +
> +    def test_translatePath_create_project_not_team_owner_default(self):
> +        # A non-owner member of a team cannot immediately set a
> +        # newly-created team-owned repository as that team's default for a
> +        # project.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(members=[requester])
> +        project = self.factory.makeProduct()
> +        path = u"/~%s/%s" % (team.name, project.name)
> +        message = "Cannot create default Git repository at '%s'." % (
> +            path.strip("/"))
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +
> +    def test_translatePath_create_package_owner_default(self):
> +        # A repository can be created and immediately set as its owner's
> +        # default for a package.
> +        requester = self.factory.makePerson()
> +        dsp = self.factory.makeDistributionSourcePackage()
> +        path = u"/~%s/%s/+source/%s" % (
> +            requester.name, dsp.distribution.name, dsp.sourcepackagename.name)
> +        repository = self.assertCreates(requester, path)
> +        self.assertFalse(repository.target_default)
> +        self.assertTrue(repository.owner_default)
> +
> +    def test_translatePath_create_package_team_owner_default(self):
> +        # The owner of a team can create a team-owned repository and
> +        # immediately set it as that team's default for a package.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(owner=requester)
> +        dsp = self.factory.makeDistributionSourcePackage()
> +        path = u"/~%s/%s/+source/%s" % (
> +            team.name, dsp.distribution.name, dsp.sourcepackagename.name)
> +        repository = self.assertCreates(requester, path)
> +        self.assertFalse(repository.target_default)
> +        self.assertTrue(repository.owner_default)
> +
> +    def test_translatePath_create_package_not_team_owner_default(self):
> +        # A non-owner member of a team cannot immediately set a
> +        # newly-created team-owned repository as that team's default for a
> +        # package.
> +        requester = self.factory.makePerson()
> +        team = self.factory.makeTeam(members=[requester])
> +        dsp = self.factory.makeDistributionSourcePackage()
> +        path = u"/~%s/%s/+source/%s" % (
> +            team.name, dsp.distribution.name, dsp.sourcepackagename.name)
> +        message = "Cannot create default Git repository at '%s'." % (
> +            path.strip("/"))
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> 
> === modified file 'lib/lp/systemhomes.py'
> --- lib/lp/systemhomes.py	2013-06-20 05:50:00 +0000
> +++ lib/lp/systemhomes.py	2015-03-03 02:01:47 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Content classes for the 'home pages' of the subsystems of Launchpad."""
> @@ -47,6 +47,7 @@
>  from lp.code.interfaces.codeimportscheduler import (
>      ICodeImportSchedulerApplication,
>      )
> +from lp.code.interfaces.gitapi import IGitApplication
>  from lp.hardwaredb.interfaces.hwdb import (
>      IHWDBApplication,
>      IHWDeviceSet,
> @@ -92,6 +93,12 @@
>      title = "Code Import Scheduler"
>  
>  
> +class GitApplication:
> +    implements(IGitApplication)
> +
> +    title = "Git API"
> +
> +
>  class PrivateMaloneApplication:
>      """ExternalBugTracker authentication token end-point."""
>      implements(IPrivateMaloneApplication)
> 
> === modified file 'lib/lp/xmlrpc/application.py'
> --- lib/lp/xmlrpc/application.py	2013-01-07 02:40:55 +0000
> +++ lib/lp/xmlrpc/application.py	2015-03-03 02:01:47 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """XML-RPC API to the application roots."""
> @@ -24,6 +24,7 @@
>  from lp.code.interfaces.codeimportscheduler import (
>      ICodeImportSchedulerApplication,
>      )
> +from lp.code.interfaces.gitapi import IGitApplication
>  from lp.registry.interfaces.mailinglist import IMailingListApplication
>  from lp.registry.interfaces.person import (
>      ICanonicalSSOApplication,
> @@ -80,6 +81,11 @@
>          """See `IPrivateApplication`."""
>          return getUtility(IFeatureFlagApplication)
>  
> +    @property
> +    def git(self):
> +        """See `IPrivateApplication`."""
> +        return getUtility(IGitApplication)
> +
>  
>  class ISelfTest(Interface):
>      """XMLRPC external interface for testing the XMLRPC external interface."""
> 
> === modified file 'lib/lp/xmlrpc/configure.zcml'
> --- lib/lp/xmlrpc/configure.zcml	2012-10-31 14:29:13 +0000
> +++ lib/lp/xmlrpc/configure.zcml	2015-03-03 02:01:47 +0000
> @@ -1,4 +1,4 @@
> -<!-- Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
> +<!-- Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>       GNU Affero General Public License version 3 (see the file LICENSE).
>  -->
>  
> @@ -48,6 +48,19 @@
>      />
>  
>    <securedutility
> +    class="lp.systemhomes.GitApplication"
> +    provides="lp.code.interfaces.gitapi.IGitApplication">
> +    <allow interface="lp.code.interfaces.gitapi.IGitApplication"/>
> +  </securedutility>
> +
> +  <xmlrpc:view
> +    for="lp.code.interfaces.gitapi.IGitApplication"
> +    interface="lp.code.interfaces.gitapi.IGitAPI"
> +    class="lp.code.xmlrpc.git.GitAPI"
> +    permission="zope.Public"
> +    />
> +
> +  <securedutility
>      class="lp.systemhomes.PrivateMaloneApplication"
>      provides="lp.bugs.interfaces.malone.IPrivateMaloneApplication">
>      <allow interface="lp.bugs.interfaces.malone.IPrivateMaloneApplication"/>
> 
> === modified file 'lib/lp/xmlrpc/interfaces.py'
> --- lib/lp/xmlrpc/interfaces.py	2012-01-15 21:06:58 +0000
> +++ lib/lp/xmlrpc/interfaces.py	2015-03-03 02:01:47 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2011 Canonical Ltd.  This software is licensed under the
> +# Copyright 2011-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Interfaces for the Launchpad application."""
> @@ -34,3 +34,5 @@
>          """Canonical SSO XML-RPC end point.""")
>  
>      featureflags = Attribute("""Feature flag information endpoint""")
> +
> +    git = Attribute("Git end point.")
> 
> === modified file 'setup.py'
> --- setup.py	2015-01-06 12:47:59 +0000
> +++ setup.py	2015-03-03 02:01:47 +0000
> @@ -79,6 +79,7 @@
>          'python-openid',
>          'pytz',
>          'rabbitfixture',
> +        'requests',
>          's4',
>          'setproctitle',
>          'setuptools',
> 


-- 
https://code.launchpad.net/~cjwatson/launchpad/git-xmlrpc/+merge/251541
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.


References