← Back to team overview

launchpad-reviewers team mailing list archive

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

 


Diff comments:

> === modified file 'lib/lp/code/errors.py'
> --- lib/lp/code/errors.py	2015-02-26 12:10:20 +0000
> +++ lib/lp/code/errors.py	2015-03-03 17:10:43 +0000
> @@ -367,7 +367,7 @@
>      """
>  
>  
> -class GitRepositoryCreationFault(GitRepositoryCreationException):
> +class GitRepositoryCreationFault(Exception):
>      """Raised when there is a hosting fault creating a Git repository."""
>  
>  
> 
> === 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 17:10:43 +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).
> +
> +"""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):
> +        try:
> +            # 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)
> +        except Exception as e:
> +            raise GitRepositoryCreationFault(
> +                "Failed to create Git repository: %s" % unicode(e))
> +        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 17:10:43 +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 frontend can request
> +            authentication, 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/gitnamespace.py'
> --- lib/lp/code/interfaces/gitnamespace.py	2015-02-26 17:16:57 +0000
> +++ lib/lp/code/interfaces/gitnamespace.py	2015-03-03 17:10:43 +0000
> @@ -86,6 +86,13 @@
>  class IGitNamespacePolicy(Interface):
>      """Methods relating to Git repository creation and validation."""
>  
> +    has_defaults = Attribute(
> +        "True iff the target of this namespace may have a default repository.")
> +
> +    allow_push_to_set_default = Attribute(
> +        "True iff this namespace permits automatically setting a default "
> +        "repository on push.")
> +
>      def getAllowedInformationTypes(who):
>          """Get the information types that a repository in this namespace can
>          have.
> 
> === 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 17:10:43 +0000
> @@ -7,6 +7,7 @@
>  
>  __all__ = [
>      'GitIdentityMixin',
> +    'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
>      'git_repository_name_validator',
>      'IGitRepository',
>      'IGitRepositorySet',
> 
> === 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 17:10:43 +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/gitnamespace.py'
> --- lib/lp/code/model/gitnamespace.py	2015-02-26 17:16:57 +0000
> +++ lib/lp/code/model/gitnamespace.py	2015-03-03 17:10:43 +0000
> @@ -190,6 +190,9 @@
>  
>      implements(IGitNamespace, IGitNamespacePolicy)
>  
> +    has_defaults = False
> +    allow_push_to_set_default = False
> +
>      def __init__(self, person):
>          self.owner = person
>  
> @@ -247,6 +250,9 @@
>  
>      implements(IGitNamespace, IGitNamespacePolicy)
>  
> +    has_defaults = True
> +    allow_push_to_set_default = True
> +
>      def __init__(self, person, project):
>          self.owner = person
>          self.project = project
> @@ -307,6 +313,9 @@
>  
>      implements(IGitNamespace, IGitNamespacePolicy)
>  
> +    has_defaults = True
> +    allow_push_to_set_default = False
> +
>      def __init__(self, person, distro_source_package):
>          self.owner = person
>          self.distro_source_package = distro_source_package
> 
> === 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 17:10:43 +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 17:10:43 +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 17:10:43 +0000
> @@ -0,0 +1,234 @@
> +# 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',
> +    ]
> +
> +import sys
> +
> +from storm.store import Store
> +import transaction
> +from zope.component import getUtility
> +from zope.error.interfaces import IErrorReportingUtility
> +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,
> +    GitRepositoryCreationFault,
> +    GitRepositoryExists,
> +    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.person import 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.services.webapp.errorlog import ScriptRequest
> +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 owner is None:
> +            repository_owner = requester
> +        else:
> +            repository_owner = owner
> +        namespace = get_git_namespace(target, repository_owner)
> +        if repository_name is None and not namespace.has_defaults:
> +            raise InvalidNamespace(path)
> +        if owner is None and not namespace.allow_push_to_set_default:
> +            raise GitRepositoryCreationForbidden(
> +                "Cannot automatically set the default repository for this "
> +                "target; push to a named repository instead.")
> +        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 _reportError(self, path, exception, hosting_path=None):
> +        properties = [
> +            ("path", path),
> +            ("error-explanation", unicode(exception)),
> +            ]
> +        if hosting_path is not None:
> +            properties.append(("hosting_path", hosting_path))
> +        request = ScriptRequest(properties)
> +        getUtility(IErrorReportingUtility).raising(sys.exc_info(), request)
> +        raise faults.OopsOccurred("creating a Git repository", request.oopsid)
> +
> +    def _createRepository(self, requester, path):
> +        try:
> +            namespace, repository_name, default_func = (
> +                self._getGitNamespaceExtras(path, requester))
> +        except InvalidNamespace:
> +            raise faults.PermissionDenied(
> +                "'%s' is not a valid Git repository path." % path)
> +        except NoSuchPerson as e:
> +            raise faults.NotFound("User/team '%s' does not exist." % e.name)
> +        except (NoSuchProduct, InvalidProductName) as e:
> +            raise faults.NotFound("Project '%s' does not exist." % 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(unicode(e))
> +        except GitRepositoryCreationForbidden as e:
> +            raise faults.PermissionDenied(unicode(e))
> +
> +        try:
> +            repository = namespace.createRepository(
> +                requester, repository_name)
> +        except LaunchpadValidationError as e:
> +            # Despite the fault name, this just passes through the exception
> +            # text so there's no need for a new Git-specific fault.
> +            raise faults.InvalidBranchName(e)
> +        except GitRepositoryExists as e:
> +            # We should never get here, as we just tried to translate the
> +            # path and found nothing (not even an inaccessible private
> +            # repository).  Log an OOPS for investigation.
> +            self._reportError(path, e)
> +        except GitRepositoryCreationException as e:
> +            raise faults.PermissionDenied(unicode(e))

No, because (1) turnip isn't called by namespace.createRepository (any more), but rather later in this method; and (2) I changed GitRepositoryCreationFault to subclass Exception directly rather than subclassing GitRepositoryCreationException.

> +
> +        try:
> +            if default_func:
> +                try:
> +                    default_func(repository)
> +                except Unauthorized:
> +                    raise faults.PermissionDenied(
> +                        "You cannot set the default Git repository for '%s'." %
> +                        path)
> +
> +            # Flush to make sure that repository.id is populated.
> +            Store.of(repository).flush()
> +            assert repository.id is not None
> +
> +            hosting_path = repository.getInternalPath()
> +            try:
> +                self.hosting_client.create(hosting_path)
> +            except GitRepositoryCreationFault as e:
> +                # The hosting service failed.  Log an OOPS for investigation.
> +                self._reportError(path, e, hosting_path=hosting_path)
> +        except Exception:
> +            # We don't want to keep the repository we created.
> +            transaction.abort()
> +            raise
> +
> +    @return_fault
> +    def _translatePath(self, requester, path, permission, can_authenticate):
> +        if requester == LAUNCHPAD_ANONYMOUS:
> +            requester = None
> +        try:
> +            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"]:
> +                raise faults.PermissionDenied()
> +            return result
> +        except faults.PermissionDenied:
> +            # Turn "permission denied" for anonymous HTTP requests into
> +            # "authorisation required", so that the user-agent has a chance
> +            # to try HTTP basic auth.
> +            if can_authenticate and requester is None:
> +                raise faults.Unauthorized()
> +            raise
> +
> +    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,
> +            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 17:10:43 +0000
> @@ -0,0 +1,584 @@
> +# 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 zope.component import getUtility
> +from zope.security.proxy import removeSecurityProxy
> +
> +from lp.app.enums import InformationType
> +from lp.code.errors import GitRepositoryCreationFault
> +from lp.code.interfaces.codehosting import (
> +    LAUNCHPAD_ANONYMOUS,
> +    LAUNCHPAD_SERVICES,
> +    )
> +from lp.code.interfaces.gitcollection import IAllGitRepositories
> +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 (
> +    AppServerLayer,
> +    LaunchpadFunctionalLayer,
> +    )
> +from lp.xmlrpc import faults
> +
> +
> +class FakeGitHostingClient:
> +    """A GitHostingClient lookalike that just logs calls."""
> +
> +    def __init__(self):
> +        self.calls = []
> +
> +    def create(self, path):
> +        self.calls.append(("create", path))
> +
> +
> +class BrokenGitHostingClient:
> +    """A GitHostingClient lookalike that pretends the remote end is down."""
> +
> +    def create(self, path):
> +        raise GitRepositoryCreationFault("nothing here")
> +
> +
> +class TestGitAPIMixin:
> +    """Helper methods for `IGitAPI` tests, and security-relevant tests."""
> +
> +    def setUp(self):
> +        super(TestGitAPIMixin, 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(
> +            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 returns PermissionDenied."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            path, permission, requester, can_authenticate)
> +        self.assertEqual(faults.PermissionDenied(message), fault)
> +
> +    def assertUnauthorized(self, requester, path,
> +                           message="Authorisation required.",
> +                           permission="read", can_authenticate=False):
> +        """Assert that looking at the given path returns Unauthorized."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            path, permission, requester, can_authenticate)
> +        self.assertEqual(faults.Unauthorized(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(
> +            path, permission, requester, can_authenticate)
> +        self.assertEqual(faults.NotFound(message), 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(
> +            path, permission, requester, can_authenticate)
> +        self.assertEqual(faults.InvalidSourcePackageName(name), fault)
> +
> +    def assertInvalidBranchName(self, requester, path, message,
> +                                permission="read", can_authenticate=False):
> +        """Assert that looking at the given path returns InvalidBranchName."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            path, permission, requester, can_authenticate)
> +        self.assertEqual(faults.InvalidBranchName(Exception(message)), fault)
> +
> +    def assertOopsOccurred(self, requester, path,
> +                           permission="read", can_authenticate=False):
> +        """Assert that looking at the given path OOPSes."""
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        fault = self.git_api.translatePath(
> +            path, permission, requester, can_authenticate)
> +        self.assertIsInstance(fault, faults.OopsOccurred)
> +        prefix = (
> +            "An unexpected error has occurred while creating a Git "
> +            "repository. Please report a Launchpad bug and quote: ")
> +        self.assertStartsWith(fault.faultString, prefix)
> +        return fault.faultString[len(prefix):].rstrip(".")
> +
> +    def assertTranslates(self, requester, path, repository, writable,
> +                         permission="read", can_authenticate=False):
> +        if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester = requester.id
> +        translation = self.git_api.translatePath(
> +            path, permission, requester, can_authenticate)
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            {"path": repository.getInternalPath(), "writable": writable},
> +            translation)
> +
> +    def assertCreates(self, requester, path, can_authenticate=False):
> +        if requester in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
> +            requester_id = requester
> +        else:
> +            requester_id = requester.id
> +        translation = self.git_api.translatePath(
> +            path, "write", requester_id, can_authenticate)
> +        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_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, can_authenticate=False)
> +        self.assertUnauthorized(
> +            LAUNCHPAD_ANONYMOUS, path, can_authenticate=True)
> +
> +    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_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_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_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 = "You cannot set the default Git repository for '%s'." % (
> +            path.strip("/"))
> +        initial_count = getUtility(IAllGitRepositories).count()
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +        # No repository was created.
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            initial_count, getUtility(IAllGitRepositories).count())
> +
> +    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 = "You cannot set the default Git repository for '%s'." % (
> +            path.strip("/"))
> +        initial_count = getUtility(IAllGitRepositories).count()
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +        # No repository was created.
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            initial_count, getUtility(IAllGitRepositories).count())
> +
> +    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 = "You cannot set the default Git repository for '%s'." % (
> +            path.strip("/"))
> +        initial_count = getUtility(IAllGitRepositories).count()
> +        self.assertPermissionDenied(
> +            requester, path, message=message, permission="write")
> +        # No repository was created.
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            initial_count, getUtility(IAllGitRepositories).count())
> +
> +
> +class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
> +    """Tests for the implementation of `IGitAPI`."""
> +
> +    layer = LaunchpadFunctionalLayer
> +
> +    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_anonymous_public_repository(self):
> +        repository = self.factory.makeGitRepository()
> +        path = u"/%s" % repository.unique_name
> +        self.assertTranslates(
> +            LAUNCHPAD_ANONYMOUS, path, repository, False,
> +            can_authenticate=False)
> +        self.assertTranslates(
> +            LAUNCHPAD_ANONYMOUS, path, repository, False,
> +            can_authenticate=True)
> +
> +    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_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 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_anonymous_cannot_create(self):
> +        # Anonymous users cannot create repositories.
> +        project = self.factory.makeProject()
> +        self.assertPathTranslationError(
> +            LAUNCHPAD_ANONYMOUS, u"/%s" % project.name,
> +            permission="write", can_authenticate=False)
> +        self.assertPathTranslationError(
> +            LAUNCHPAD_ANONYMOUS, u"/%s" % project.name,
> +            permission="write", can_authenticate=True)
> +
> +    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
> +        message = "'%s' is not a valid Git repository path." % path.strip("/")
> +        self.assertPermissionDenied(
> +            requester, path, message=message, 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.assertNotFound(
> +            requester, u"/_bad_project/+git/random",
> +            "Project '_bad_project' does not exist.", 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_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.assertInvalidBranchName(
> +            requester, path, 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.assertInvalidBranchName(
> +            requester, path, message, 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_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 automatically set the default repository for this target; "
> +            "push to a named 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_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_broken_hosting_service(self):
> +        # If the hosting service is down, trying to create a repository
> +        # fails and doesn't leave junk around in the Launchpad database.
> +        self.git_api.hosting_client = BrokenGitHostingClient()
> +        requester = self.factory.makePerson()
> +        initial_count = getUtility(IAllGitRepositories).count()
> +        oops_id = self.assertOopsOccurred(
> +            requester, u"/~%s/+git/random" % requester.name,
> +            permission="write")
> +        login(ANONYMOUS)
> +        self.assertEqual(
> +            initial_count, getUtility(IAllGitRepositories).count())
> +        # The error report OOPS ID should match the fault, and the traceback
> +        # text should show the underlying exception.
> +        self.assertEqual(1, len(self.oopses))
> +        self.assertEqual(oops_id, self.oopses[0]["id"])
> +        self.assertIn(
> +            "GitRepositoryCreationFault: nothing here",
> +            self.oopses[0]["tb_text"])
> +
> +
> +class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
> +    """Slow tests for `IGitAPI`.
> +
> +    These use AppServerLayer to check that `run_with_login` is behaving
> +    itself properly.
> +    """
> +
> +    layer = AppServerLayer
> 
> === modified file 'lib/lp/systemhomes.py'
> --- lib/lp/systemhomes.py	2013-06-20 05:50:00 +0000
> +++ lib/lp/systemhomes.py	2015-03-03 17:10:43 +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 17:10:43 +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 17:10:43 +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"/>
> @@ -207,4 +220,8 @@
>    <class class="lp.xmlrpc.faults.InvalidSourcePackageName">
>      <require like_class="xmlrpclib.Fault" />
>    </class>
> +
> +  <class class="lp.xmlrpc.faults.Unauthorized">
> +    <require like_class="xmlrpclib.Fault" />
> +  </class>
>  </configure>
> 
> === modified file 'lib/lp/xmlrpc/faults.py'
> --- lib/lp/xmlrpc/faults.py	2012-10-31 19:13:34 +0000
> +++ lib/lp/xmlrpc/faults.py	2015-03-03 17:10:43 +0000
> @@ -9,6 +9,7 @@
>  __metaclass__ = type
>  
>  __all__ = [
> +    'AccountSuspended',
>      'BadStatus',
>      'BranchAlreadyRegistered',
>      'BranchCreationForbidden',
> @@ -20,8 +21,9 @@
>      'InvalidBranchIdentifier',
>      'InvalidBranchName',
>      'InvalidBranchUniqueName',
> +    'InvalidBranchUrl',
> +    'InvalidPath',
>      'InvalidProductName',
> -    'InvalidBranchUrl',
>      'InvalidSourcePackageName',
>      'OopsOccurred',
>      'NoBranchWithID',
> @@ -30,16 +32,22 @@
>      'NoSuchBug',
>      'NoSuchCodeImportJob',
>      'NoSuchDistribution',
> +    'NoSuchDistroSeries',
>      'NoSuchPackage',
>      'NoSuchPerson',
>      'NoSuchPersonWithName',
>      'NoSuchProduct',
>      'NoSuchProductSeries',
> +    'NoSuchSourcePackageName',
>      'NoSuchTeamMailingList',
> +    'NotFound',
>      'NotInTeam',
>      'NoUrlForBranch',
> +    'PathTranslationError',
> +    'PermissionDenied',
>      'RequiredParameterMissing',
>      'TeamEmailAddress',
> +    'Unauthorized',
>      'UnexpectedStatusReport',
>      ]
>  
> @@ -502,3 +510,15 @@
>      def __init__(self, email, openid_identifier):
>          LaunchpadFault.__init__(
>              self, email=email, openid_identifier=openid_identifier)
> +
> +
> +# American English spelling to line up with httplib etc.
> +class Unauthorized(LaunchpadFault):
> +    """Permission was denied, but authorisation may help."""
> +
> +    error_code = 410
> +    msg_template = (
> +        "%(message)s")
> +
> +    def __init__(self, message="Authorisation required."):
> +        LaunchpadFault.__init__(self, message=message)
> 
> === 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 17:10:43 +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 17:10:43 +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