launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18017
Re: [Merge] lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad
Review: Approve code
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.
The code no longer unescape()s, so just this docstring needs fixing now.
> + :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))
I think this will still catch eg. GitRepositoryCreationFaults caused by turnip rejecting the create request?
> +
> + 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