← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-basic-model into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-basic-model into lp:launchpad with lp:~cjwatson/launchpad/git-personmerge-whitelist as a prerequisite.

Commit message:
Very basic preliminary model for GitRepository.  Make Product, DistributionSourcePackage, and Person implement IHasGitRepositories.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1032731 in Launchpad itself: "Support for Launchpad-hosted Git repositories"
  https://bugs.launchpad.net/launchpad/+bug/1032731

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-basic-model/+merge/248976

Very basic preliminary model for GitRepository.  Make Product, DistributionSourcePackage, and Person implement IHasGitRepositories.

This doesn't really do anything useful by itself.  In particular, in the cause of getting this chunk of changes down to a reasonable size, I left out the GitNamespace stuff that makes it possible to actually create GitRepository objects (the general architecture is fairly similar to Branch*), which also means that most of the tests are deferred to a later branch when we have enough infrastructure to support creating test objects.

This branch implements one of the candidate proposals for Git URL layouts in Launchpad.  I'm not assuming that this will be the final layout; it's relatively easy to change later.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-basic-model into lp:launchpad.
=== modified file 'configs/development/launchpad-lazr.conf'
--- configs/development/launchpad-lazr.conf	2014-02-27 08:39:44 +0000
+++ configs/development/launchpad-lazr.conf	2015-02-06 22:26:57 +0000
@@ -48,6 +48,10 @@
 access_log: /var/tmp/bazaar.launchpad.dev/codehosting-access.log
 blacklisted_hostnames:
 use_forking_daemon: True
+internal_git_endpoint: http://git.launchpad.dev:19417/
+git_browse_root: https://git.launchpad.dev/
+git_anon_root: git://git.launchpad.dev/
+git_ssh_root: git+ssh://git.launchpad.dev/
 
 [codeimport]
 bazaar_branch_store: file:///tmp/bazaar-branches

=== added file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitrepository.py	2015-02-06 22:26:57 +0000
@@ -0,0 +1,384 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository interfaces."""
+
+__metaclass__ = type
+
+__all__ = [
+    'GitPathMixin',
+    'git_repository_name_validator',
+    'IGitRepository',
+    'IGitRepositorySet',
+    'user_has_special_git_repository_access',
+    ]
+
+import re
+
+from lazr.restful.fields import (
+    Reference,
+    ReferenceChoice,
+    )
+from zope.interface import (
+    Attribute,
+    Interface,
+    )
+from zope.schema import (
+    Bool,
+    Choice,
+    Datetime,
+    Int,
+    Text,
+    TextLine,
+    )
+
+from lp import _
+from lp.app.enums import InformationType
+from lp.app.validators import LaunchpadValidationError
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+    )
+from lp.registry.interfaces.product import IProduct
+from lp.registry.interfaces.role import IPersonRoles
+from lp.services.fields import (
+    PersonChoice,
+    PublicPersonChoice,
+    )
+
+
+GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE = _(
+    "Git repository names must start with a number or letter.  The characters "
+    "+, -, _, . and @ are also allowed after the first character.  Repository "
+    "names must not end with \".git\".")
+
+
+# This is a copy of the pattern in database/schema/patch-2209-61-0.sql.
+# Don't change it without changing that.
+valid_git_repository_name_pattern = re.compile(
+    r"^(?i)[a-z0-9][a-z0-9+\.\-@_]*\Z")
+
+
+def valid_git_repository_name(name):
+    """Return True iff the name is valid as a Git repository name.
+
+    The rules for what is a valid Git repository name are described in
+    GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE.
+    """
+    if (not name.endswith(".git") and
+        valid_git_repository_name_pattern.match(name)):
+        return True
+    return False
+
+
+def git_repository_name_validator(name):
+    """Return True if the name is valid, or raise a LaunchpadValidationError.
+    """
+    if not valid_git_repository_name(name):
+        raise LaunchpadValidationError(
+            _("Invalid Git repository name '${name}'. ${message}",
+              mapping={
+                  "name": name,
+                  "message": GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
+                  }))
+    return True
+
+
+class IGitRepositoryView(Interface):
+    """IGitRepository attributes that require launchpad.View permission."""
+
+    id = Int(title=_("ID"), readonly=True, required=True)
+
+    date_created = Datetime(
+        title=_("Date created"), required=True, readonly=True)
+
+    date_last_modified = Datetime(
+        title=_("Date last modified"), required=True, readonly=False)
+
+    registrant = PublicPersonChoice(
+        title=_("Registrant"), required=True, readonly=True,
+        vocabulary="ValidPersonOrTeam",
+        description=_("The person who registered this Git repository."))
+
+    owner = PersonChoice(
+        title=_("Owner"), required=True, readonly=False,
+        vocabulary="AllUserTeamsParticipationPlusSelf",
+        description=_(
+            "The owner of this Git repository. This controls who can modify "
+            "the repository."))
+
+    project = ReferenceChoice(
+        title=_("Project"), required=False, readonly=True,
+        vocabulary="Product", schema=IProduct,
+        description=_(
+            "The project that this Git repository belongs to. None if it "
+            "belongs to a distribution source package instead."))
+
+    # The distribution and sourcepackagename attributes are exported
+    # together as distro_source_package.
+    distribution = Choice(
+        title=_("Distribution"), required=False,
+        vocabulary="Distribution",
+        description=_(
+            "The distribution that this Git repository belongs to. None if it "
+            "belongs to a project instead."))
+
+    sourcepackagename = Choice(
+        title=_("Source Package Name"), required=False,
+        vocabulary="SourcePackageName",
+        description=_(
+            "The source package that this Git repository belongs to. None if "
+            "it belongs to a project instead. Source package repositories "
+            "always belong to a distribution."))
+
+    distro_source_package = Reference(
+        title=_(
+            "The IDistributionSourcePackage that this Git repository belongs "
+            "to. None if it belongs to a project instead."),
+        schema=IDistributionSourcePackage, required=False, readonly=True)
+
+    target = Reference(
+        title=_("Target"), required=True, readonly=True,
+        schema=IHasGitRepositories,
+        description=_("The target of the repository."))
+
+    information_type = Choice(
+        title=_("Information Type"), vocabulary=InformationType,
+        required=True, readonly=True, default=InformationType.PUBLIC,
+        description=_(
+            "The type of information contained in this repository."))
+
+    unique_name = Text(
+        title=_("Unique name"), readonly=True,
+        description=_(
+            "Unique name of the repository, including the owner and project "
+            "names."))
+
+    displayname = Text(
+        title=_("Display name"), readonly=True,
+        description=_("Display name of the repository."))
+
+    shortened_path = Attribute(
+        "The shortest reasonable version of the path to this repository.")
+
+    git_identity = Text(
+        title=_("Git identity"), readonly=True,
+        description=_(
+            "If this is the default repository for some target, then this is "
+            "'lp:' plus a shortcut version of the path via that target.  "
+            "Otherwise it is simply 'lp:' plus the unique name."))
+
+    def codebrowse_url():
+        """Construct a browsing URL for this branch."""
+
+    def addToLaunchBag(launchbag):
+        """Add information about this branch to `launchbag'.
+
+        Use this when traversing to this branch in the web UI.
+
+        In particular, add information about the branch's target to the
+        launchbag. If the branch has a product, add that; if it has a source
+        package, add lots of information about that.
+
+        :param launchbag: `ILaunchBag`.
+        """
+
+    def visibleByUser(user):
+        """Can the specified user see this repository?"""
+
+    def getAllowedInformationTypes(user):
+        """Get a list of acceptable `InformationType`s for this repository.
+
+        If the user is a Launchpad admin, any type is acceptable.
+        """
+
+    def getInternalPath():
+        """Get the internal path to this repository.
+
+        This is used on the storage backend.
+        """
+
+    def getRepositoryLinks():
+        """Return a sorted list of `ICanHasLinkedGitRepository` objects.
+
+        There is one result for each related object that the repository is
+        linked to.  For example, in the case where a branch is linked to a
+        project and is also its owner's preferred branch for that project,
+        the link objects for both the project and the person-project are
+        returned.
+
+        The sorting uses the defined order of the linked objects where the
+        more important links are sorted first.
+        """
+
+    def getRepositoryIdentities():
+        """A list of aliases for a repository.
+
+        Returns a list of tuples of path and context object.  There is at
+        least one alias for any repository, and that is the repository
+        itself.  For linked repositories, the context object is the
+        appropriate linked object.
+
+        Where a repository is linked to a product or a distribution source
+        package, the repository is available through a number of different
+        URLs.  These URLs are the aliases for the repository.
+
+        For example, a repository which is linked to the 'fooix' project and
+        which is also its owner's preferred repository for that project is
+        accessible using:
+          fooix - the linked object is the project fooix
+          ~fooix-owner/fooix - the linked object is the person-project
+              ~fooix-owner and fooix
+          ~fooix-owner/fooix/g/fooix - the unique name of the repository
+              where the linked object is the repository itself.
+        """
+
+
+class IGitRepositoryModerateAttributes(Interface):
+    """IGitRepository attributes that can be edited by more than one community.
+    """
+
+    # XXX cjwatson 2015-01-29: Add some advice about default repository
+    # naming.
+    name = TextLine(
+        title=_("Name"), required=True,
+        constraint=git_repository_name_validator,
+        description=_(
+            "The repository name. Keep very short, unique, and descriptive, "
+            "because it will be used in URLs."))
+
+
+class IGitRepositoryModerate(Interface):
+    """IGitRepository methods that can be called by more than one community."""
+
+    def transitionToInformationType(information_type, user,
+                                    verify_policy=True):
+        """Set the information type for this repository.
+
+        :param information_type: The `InformationType` to transition to.
+        :param user: The `IPerson` who is making the change.
+        :param verify_policy: Check if the new information type complies
+            with the `IGitNamespacePolicy`.
+        """
+
+
+class IGitRepositoryEdit(Interface):
+    """IGitRepository methods that require launchpad.Edit permission."""
+
+    def setOwner(new_owner, user):
+        """Set the owner of the repository to be `new_owner`."""
+
+    def setTarget(user, target):
+        """Set the target of the repository."""
+
+    def destroySelf():
+        """Delete the specified repository."""
+
+
+class IGitRepository(IGitRepositoryView, IGitRepositoryModerateAttributes,
+                     IGitRepositoryModerate, IGitRepositoryEdit):
+    """A Git repository."""
+
+    private = Bool(
+        title=_("Repository is confidential"), required=False, readonly=True,
+        description=_("This repository is visible only to its subscribers."))
+
+
+class IGitRepositorySet(Interface):
+    """Interface representing the set of Git repositories."""
+
+    def new(registrant, owner, target, name, information_type=None,
+            date_created=None):
+        """Create a Git repository and return it.
+
+        :param registrant: The `IPerson` who registered the new repository.
+        :param owner: The `IPerson` who owns the new repository.
+        :param target: The `IProduct`, `IDistributionSourcePackage`, or
+            `IPerson` that the new repository is associated with.
+        :param name: The repository name.
+        :param information_type: Set the repository's information type to
+            one different from the target's default.  The type must conform
+            to the target's code sharing policy.  (optional)
+        """
+
+    def getByPath(user, path):
+        """Find a repository by its path.
+
+        Any of these forms may be used, with or without a leading slash:
+            Unique names:
+                ~OWNER/PROJECT/g/NAME
+                ~OWNER/DISTRO/+source/SOURCE/g/NAME
+                ~OWNER/g/NAME
+            Owner-target default aliases:
+                ~OWNER/PROJECT
+                ~OWNER/DISTRO/+source/SOURCE
+            Official aliases:
+                PROJECT
+                DISTRO/+source/SOURCE
+
+        Return None if no match was found.
+        """
+
+    def getPersonalRepository(person, repository_name):
+        """Find a personal repository."""
+
+    def getProjectRepository(person, project, repository_name=None):
+        """Find a project repository."""
+
+    def getPackageRepository(person, distribution, sourcepackagename,
+                             repository_name=None):
+        """Find a package repository."""
+
+    def getRepositories(limit=50, eager_load=True):
+        """Return a collection of repositories.
+
+        :param eager_load: If True (the default because this is used in the
+            web service and it needs the related objects to create links)
+            eager load related objects (projects, etc.).
+        """
+
+
+class GitPathMixin:
+    """This mixin class determines Git repository paths.
+
+    Used by both the model GitRepository class and the browser repository
+    listing item.  This allows the browser code to cache the associated
+    links which reduces query counts.
+    """
+
+    @property
+    def shortened_path(self):
+        """See `IGitRepository`."""
+        path, context = self.getRepositoryIdentities()[0]
+        return path
+
+    @property
+    def git_identity(self):
+        """See `IGitRepository`."""
+        return "lp:" + self.shortened_path
+
+    def getRepositoryLinks(self):
+        """See `IGitRepository`."""
+        # XXX cjwatson 2015-02-06: This will return shortcut links once
+        # they're implemented.
+        return []
+
+    def getRepositoryIdentities(self):
+        """See `IGitRepository`."""
+        identities = [
+            (link.path, link.context) for link in self.getRepositoryLinks()]
+        identities.append((self.unique_name, self))
+        return identities
+
+
+def user_has_special_git_repository_access(user):
+    """Admins have special access.
+
+    :param user: An `IPerson` or None.
+    """
+    if user is None:
+        return False
+    roles = IPersonRoles(user)
+    if roles.in_admin:
+        return True
+    return False

=== added file 'lib/lp/code/interfaces/hasgitrepositories.py'
--- lib/lp/code/interfaces/hasgitrepositories.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/hasgitrepositories.py	2015-02-06 22:26:57 +0000
@@ -0,0 +1,50 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces relating to targets of Git repositories."""
+
+__metaclass__ = type
+
+__all__ = [
+    'IHasGitRepositories',
+    'IHasGitRepositoriesEdit',
+    'IHasGitRepositoriesView',
+    ]
+
+from zope.interface import Interface
+
+
+class IHasGitRepositoriesView(Interface):
+    """Viewing an object that has related Git repositories."""
+
+    def getGitRepositories(visible_by_user=None, eager_load=False):
+        """Returns all Git repositories related to this object.
+
+        :param visible_by_user: Normally the user who is asking.
+        :param eager_load: If True, load related objects for the whole
+            collection.
+        :returns: A list of `IGitRepository` objects.
+        """
+
+
+class IHasGitRepositoriesEdit(Interface):
+    """Editing an object that has related Git repositories."""
+
+    def createGitRepository(registrant, owner, name, information_type=None):
+        """Create a Git repository for this target and return it.
+
+        :param registrant: The `IPerson` who registered the new repository.
+        :param owner: The `IPerson` who owns the new repository.
+        :param name: The repository name.
+        :param information_type: Set the repository's information type to
+            one different from the target's default.  The type must conform
+            to the target's code sharing policy.  (optional)
+        """
+
+
+class IHasGitRepositories(IHasGitRepositoriesView, IHasGitRepositoriesEdit):
+    """An object that has related Git repositories.
+
+    A project contains Git repositories, a source package on a distribution
+    contains branches, and a person contains "personal" branches.
+    """

=== added file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitrepository.py	2015-02-06 22:26:57 +0000
@@ -0,0 +1,366 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'get_git_repository_privacy_filter',
+    'GitRepository',
+    'GitRepositorySet',
+    ]
+
+from bzrlib import urlutils
+import pytz
+from storm.expr import (
+    Coalesce,
+    Join,
+    Or,
+    Select,
+    SQL,
+    )
+from storm.locals import (
+    DateTime,
+    Int,
+    Reference,
+    Unicode,
+    )
+from zope.component import getUtility
+from zope.interface import implements
+
+from lp.app.enums import (
+    InformationType,
+    PRIVATE_INFORMATION_TYPES,
+    PUBLIC_INFORMATION_TYPES,
+    )
+from lp.app.interfaces.informationtype import IInformationType
+from lp.app.interfaces.launchpad import IPrivacy
+from lp.app.interfaces.services import IService
+from lp.code.interfaces.gitrepository import (
+    GitPathMixin,
+    IGitRepository,
+    IGitRepositorySet,
+    user_has_special_git_repository_access,
+    )
+from lp.registry.errors import CannotChangeInformationType
+from lp.registry.interfaces.accesspolicy import (
+    IAccessArtifactSource,
+    IAccessPolicySource,
+    )
+from lp.registry.interfaces.role import IHasOwner
+from lp.registry.interfaces.sharingjob import (
+    IRemoveArtifactSubscriptionsJobSource,
+    )
+from lp.registry.model.accesspolicy import (
+    AccessPolicyGrant,
+    reconcile_access_for_artifact,
+    )
+from lp.registry.model.teammembership import TeamParticipation
+from lp.services.config import config
+from lp.services.database.constants import (
+    DEFAULT,
+    UTC_NOW,
+    )
+from lp.services.database.enumcol import EnumCol
+from lp.services.database.stormbase import StormBase
+from lp.services.database.stormexpr import (
+    Array,
+    ArrayAgg,
+    ArrayIntersects,
+    )
+from lp.services.propertycache import cachedproperty
+
+
+def git_repository_modified(repository, event):
+    """Update the date_last_modified property when a GitRepository is modified.
+
+    This method is registered as a subscriber to `IObjectModifiedEvent`
+    events on Git repositories.
+    """
+    repository.date_last_modified = UTC_NOW
+
+
+class GitRepository(StormBase, GitPathMixin):
+    """See `IGitRepository`."""
+
+    __storm_table__ = 'GitRepository'
+
+    implements(IGitRepository, IHasOwner, IPrivacy, IInformationType)
+
+    id = Int(primary=True)
+
+    date_created = DateTime(
+        name='date_created', tzinfo=pytz.UTC, allow_none=False)
+    date_last_modified = DateTime(
+        name='date_last_modified', tzinfo=pytz.UTC, allow_none=False)
+
+    registrant_id = Int(name='registrant', allow_none=False)
+    registrant = Reference(registrant_id, 'Person.id')
+
+    owner_id = Int(name='owner', allow_none=False)
+    owner = Reference(owner_id, 'Person.id')
+
+    project_id = Int(name='project', allow_none=True)
+    project = Reference(project_id, 'Product.id')
+
+    distribution_id = Int(name='distribution', allow_none=True)
+    distribution = Reference(distribution_id, 'Distribution.id')
+
+    sourcepackagename_id = Int(name='sourcepackagename', allow_none=True)
+    sourcepackagename = Reference(sourcepackagename_id, 'SourcePackageName.id')
+
+    name = Unicode(name='name', allow_none=False)
+
+    information_type = EnumCol(enum=InformationType, notNull=True)
+    access_policy = Int(name='access_policy')
+
+    def __init__(self, registrant, owner, name, information_type, date_created,
+                 project=None, distribution=None, sourcepackagename=None):
+        super(GitRepository, self).__init__()
+        self.registrant = registrant
+        self.owner = owner
+        self.name = name
+        self.information_type = information_type
+        self.date_created = date_created
+        self.date_last_modified = date_created
+        self.project = project
+        self.distribution = distribution
+        self.sourcepackagename = sourcepackagename
+
+    @property
+    def unique_name(self):
+        names = {"owner": self.owner.name, "repository": self.name}
+        if self.project is not None:
+            fmt = "~%(owner)s/%(project)s"
+            names["project"] = self.project.name
+        elif self.distribution is not None:
+            fmt = "~%(owner)s/%(distribution)s/+source/%(source)s"
+            names["distribution"] = self.distribution.name
+            names["source"] = self.sourcepackagename.name
+        else:
+            fmt = "~%(owner)s"
+        fmt += "/g/%(repository)s"
+        return fmt % names
+
+    def __repr__(self):
+        return "<GitRepository %r (%d)>" % (self.unique_name, self.id)
+
+    @property
+    def target(self):
+        """See `IGitRepository`."""
+        if self.project is None:
+            if self.distribution is None:
+                return self.owner
+            else:
+                return self.distro_source_package
+        else:
+            return self.project
+
+    def setTarget(self, user, target):
+        """See `IGitRepository`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    @property
+    def displayname(self):
+        return self.git_identity
+
+    def getInternalPath(self):
+        """See `IGitRepository`."""
+        # This may need to change later to improve support for sharding.
+        return str(self.id)
+
+    def codebrowse_url(self):
+        """See `IGitRepository`."""
+        return urlutils.join(
+            config.codehosting.git_browse_root, self.unique_name)
+
+    @cachedproperty
+    def distro_source_package(self):
+        """See `IGitRepository`."""
+        if self.distribution is not None:
+            return self.distribution.getSourcePackage(self.sourcepackagename)
+        else:
+            return None
+
+    @property
+    def private(self):
+        return self.information_type in PRIVATE_INFORMATION_TYPES
+
+    def _reconcileAccess(self):
+        """Reconcile the repository's sharing information.
+
+        Takes the information_type and target and makes the related
+        AccessArtifact and AccessPolicyArtifacts match.
+        """
+        wanted_links = None
+        pillars = []
+        # For private personal repositories, we calculate the wanted grants.
+        if (not self.project and not self.distribution and
+            not self.information_type in PUBLIC_INFORMATION_TYPES):
+            aasource = getUtility(IAccessArtifactSource)
+            [abstract_artifact] = aasource.ensure([self])
+            wanted_links = set(
+                (abstract_artifact, policy) for policy in
+                getUtility(IAccessPolicySource).findByTeam([self.owner]))
+        else:
+            # We haven't yet quite worked out how distribution privacy
+            # works, so only work for projects for now.
+            if self.project is not None:
+                pillars = [self.project]
+        reconcile_access_for_artifact(
+            self, self.information_type, pillars, wanted_links)
+
+    def addToLaunchBag(self, launchbag):
+        """See `IGitRepository`."""
+        launchbag.add(self.project)
+        launchbag.add(self.distribution)
+
+    @cachedproperty
+    def _known_viewers(self):
+        """A set of known persons able to view this repository.
+
+        This method must return an empty set or repository searches will
+        trigger late evaluation.  Any 'should be set on load' properties
+        must be done by the repository search.
+
+        If you are tempted to change this method, don't. Instead see
+        visibleByUser which defines the just-in-time policy for repository
+        visibility, and IGitCollection which honours visibility rules.
+        """
+        return set()
+
+    def visibleByUser(self, user):
+        """See `IGitRepository`."""
+        if self.information_type in PUBLIC_INFORMATION_TYPES:
+            return True
+        elif user is None:
+            return False
+        elif user.id in self._known_viewers:
+            return True
+        else:
+            # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is
+            # in place.
+            return False
+
+    def getAllowedInformationTypes(self, user):
+        """See `IGitRepository`."""
+        if user_has_special_git_repository_access(user):
+            # Admins can set any type.
+            types = set(PUBLIC_INFORMATION_TYPES + PRIVATE_INFORMATION_TYPES)
+        else:
+            # Otherwise the permitted types are defined by the namespace.
+            # XXX cjwatson 2015-01-19: Define permitted types properly.  For
+            # now, non-admins only get public repository access.
+            types = set(PUBLIC_INFORMATION_TYPES)
+        return types
+
+    def transitionToInformationType(self, information_type, user,
+                                    verify_policy=True):
+        """See `IGitRepository`."""
+        if self.information_type == information_type:
+            return
+        if (verify_policy and
+            information_type not in self.getAllowedInformationTypes(user)):
+            raise CannotChangeInformationType("Forbidden by project policy.")
+        self.information_type = information_type
+        self._reconcileAccess()
+        # XXX cjwatson 2015-02-05: Once we have repository subscribers, we
+        # need to grant them access if necessary.  For now, treat the owner
+        # as always subscribed, which is just about enough to make the
+        # GitCollection tests pass.
+        if information_type in PRIVATE_INFORMATION_TYPES:
+            # Grant the subscriber access if they can't see the repository.
+            service = getUtility(IService, "sharing")
+            blind_subscribers = service.getPeopleWithoutAccess(
+                self, [self.owner])
+            if len(blind_subscribers):
+                service.ensureAccessGrants(
+                    blind_subscribers, user, gitrepositories=[self],
+                    ignore_permissions=True)
+        # As a result of the transition, some subscribers may no longer have
+        # access to the repository.  We need to run a job to remove any such
+        # subscriptions.
+        getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self])
+
+    def setOwner(self, new_owner, user):
+        """See `IGitRepository`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    def destroySelf(self):
+        raise NotImplementedError
+
+
+class GitRepositorySet:
+    """See `IGitRepositorySet`."""
+
+    implements(IGitRepositorySet)
+
+    def new(self, registrant, owner, target, name, information_type=None,
+            date_created=DEFAULT):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    def getPersonalRepository(self, person, repository_name):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    def getProjectRepository(self, person, project, repository_name=None):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    def getPackageRepository(self, person, distribution, sourcepackagename,
+                              repository_name=None):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitNamespace is in
+        # place.
+        raise NotImplementedError
+
+    def getByPath(self, user, path):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitLookup is in place.
+        raise NotImplementedError
+
+    def getRepositories(self, limit=50, eager_load=True):
+        """See `IGitRepositorySet`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is in
+        # place.
+        raise NotImplementedError
+
+
+def get_git_repository_privacy_filter(user):
+    public_filter = GitRepository.information_type.is_in(
+        PUBLIC_INFORMATION_TYPES)
+
+    if user is None:
+        return [public_filter]
+
+    artifact_grant_query = Coalesce(
+        ArrayIntersects(
+            SQL("GitRepository.access_grants"),
+            Select(
+                ArrayAgg(TeamParticipation.teamID),
+                tables=TeamParticipation,
+                where=(TeamParticipation.person == user)
+            )), False)
+
+    policy_grant_query = Coalesce(
+        ArrayIntersects(
+            Array(GitRepository.access_policy),
+            Select(
+                ArrayAgg(AccessPolicyGrant.policy_id),
+                tables=(AccessPolicyGrant,
+                        Join(TeamParticipation,
+                            TeamParticipation.teamID ==
+                            AccessPolicyGrant.grantee_id)),
+                where=(TeamParticipation.person == user)
+            )), False)
+
+    return [Or(public_filter, artifact_grant_query, policy_grant_query)]

=== added file 'lib/lp/code/model/hasgitrepositories.py'
--- lib/lp/code/model/hasgitrepositories.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/hasgitrepositories.py	2015-02-06 22:26:57 +0000
@@ -0,0 +1,29 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+__all__ = [
+    'HasGitRepositoriesMixin',
+    'HasGitShortcutsMixin',
+    ]
+
+from zope.component import getUtility
+
+from lp.code.interfaces.gitrepository import IGitRepositorySet
+
+
+class HasGitRepositoriesMixin:
+    """A mixin implementation for `IHasGitRepositories`."""
+
+    def createGitRepository(self, registrant, owner, name,
+                            information_type=None):
+        """See `IHasGitRepositories`."""
+        return getUtility(IGitRepositorySet).new(
+            registrant, owner, self, name,
+            information_type=information_type)
+
+    def getGitRepositories(self, visible_by_user=None, eager_load=False):
+        """See `IHasGitRepositories`."""
+        # XXX cjwatson 2015-02-06: Fill this in once IGitCollection is in
+        # place.
+        raise NotImplementedError

=== added file 'lib/lp/code/model/tests/test_hasgitrepositories.py'
--- lib/lp/code/model/tests/test_hasgitrepositories.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/tests/test_hasgitrepositories.py	2015-02-06 22:26:57 +0000
@@ -0,0 +1,34 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for classes that implement IHasGitRepositories."""
+
+__metaclass__ = type
+
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestIHasGitRepositories(TestCaseWithFactory):
+    """Test that the correct objects implement the interface."""
+
+    layer = DatabaseFunctionalLayer
+
+    def test_project_implements_hasgitrepositories(self):
+        # Projects should implement IHasGitRepositories.
+        project = self.factory.makeProduct()
+        verifyObject(IHasGitRepositories, project)
+
+    def test_dsp_implements_hasgitrepositories(self):
+        # DistributionSourcePackages should implement IHasGitRepositories.
+        dsp = self.factory.makeDistributionSourcePackage()
+        verifyObject(IHasGitRepositories, dsp)
+
+    def test_person_implements_hasgitrepositories(self):
+        # People should implement IHasGitRepositories.
+        person = self.factory.makePerson()
+        verifyObject(IHasGitRepositories, person)

=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml	2015-01-29 16:28:30 +0000
+++ lib/lp/registry/configure.zcml	2015-02-06 22:26:57 +0000
@@ -556,6 +556,14 @@
                 bug_reporting_guidelines
                 enable_bugfiling_duplicate_search
                 "/>
+
+        <!-- IHasGitRepositories -->
+
+        <allow
+            interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositoriesView" />
+        <require
+            permission="launchpad.Edit"
+            interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositoriesEdit" />
     </class>
     <adapter
         provides="lp.registry.interfaces.distribution.IDistribution"

=== modified file 'lib/lp/registry/interfaces/distributionsourcepackage.py'
--- lib/lp/registry/interfaces/distributionsourcepackage.py	2014-11-28 22:28:40 +0000
+++ lib/lp/registry/interfaces/distributionsourcepackage.py	2015-02-06 22:26:57 +0000
@@ -34,6 +34,7 @@
     IHasBranches,
     IHasMergeProposals,
     )
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.role import IHasDrivers
 from lp.soyuz.enums import ArchivePurpose
@@ -42,7 +43,8 @@
 class IDistributionSourcePackage(IHeadingContext, IBugTarget, IHasBranches,
                                  IHasMergeProposals, IHasOfficialBugTags,
                                  IStructuralSubscriptionTarget,
-                                 IQuestionTarget, IHasDrivers):
+                                 IQuestionTarget, IHasDrivers,
+                                 IHasGitRepositories):
     """Represents a source package in a distribution.
 
     Create IDistributionSourcePackages by invoking

=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py	2015-01-30 18:24:07 +0000
+++ lib/lp/registry/interfaces/person.py	2015-02-06 22:26:57 +0000
@@ -111,6 +111,11 @@
     IHasMergeProposals,
     IHasRequestedReviews,
     )
+from lp.code.interfaces.hasgitrepositories import (
+    IHasGitRepositories,
+    IHasGitRepositoriesEdit,
+    IHasGitRepositoriesView,
+    )
 from lp.code.interfaces.hasrecipes import IHasRecipes
 from lp.registry.enums import (
     EXCLUSIVE_TEAM_POLICY,
@@ -688,7 +693,8 @@
                     IHasMergeProposals, IHasMugshot,
                     IHasLocation, IHasRequestedReviews, IObjectWithLocation,
                     IHasBugs, IHasRecipes, IHasTranslationImports,
-                    IPersonSettings, IQuestionsPerson):
+                    IPersonSettings, IQuestionsPerson,
+                    IHasGitRepositoriesView):
     """IPerson attributes that require launchpad.View permission."""
     account = Object(schema=IAccount)
     accountID = Int(title=_('Account ID'), required=True, readonly=True)
@@ -1581,7 +1587,7 @@
         """
 
 
-class IPersonEditRestricted(Interface):
+class IPersonEditRestricted(IHasGitRepositoriesEdit):
     """IPerson attributes that require launchpad.Edit permission."""
 
     @call_with(requester=REQUEST_USER)
@@ -1870,7 +1876,7 @@
 class IPerson(IPersonPublic, IPersonLimitedView, IPersonViewRestricted,
               IPersonEditRestricted, IPersonModerateRestricted,
               IPersonSpecialRestricted, IHasStanding, ISetLocation,
-              IHeadingContext):
+              IHeadingContext, IHasGitRepositories):
     """A Person."""
     export_as_webservice_entry(plural_name='people')
 

=== modified file 'lib/lp/registry/interfaces/product.py'
--- lib/lp/registry/interfaces/product.py	2015-01-30 18:24:07 +0000
+++ lib/lp/registry/interfaces/product.py	2015-02-06 22:26:57 +0000
@@ -102,6 +102,11 @@
     IHasCodeImports,
     IHasMergeProposals,
     )
+from lp.code.interfaces.hasgitrepositories import (
+    IHasGitRepositories,
+    IHasGitRepositoriesEdit,
+    IHasGitRepositoriesView,
+    )
 from lp.code.interfaces.hasrecipes import IHasRecipes
 from lp.registry.enums import (
     BranchSharingPolicy,
@@ -475,7 +480,8 @@
     IHasMugshot, IHasSprints, IHasTranslationImports,
     ITranslationPolicy, IKarmaContext, IMakesAnnouncements,
     IOfficialBugTagTargetPublic, IHasOOPSReferences,
-    IHasRecipes, IHasCodeImports, IServiceUsage):
+    IHasRecipes, IHasCodeImports, IServiceUsage,
+    IHasGitRepositoriesView):
     """Public IProduct properties."""
 
     registrant = exported(
@@ -837,7 +843,8 @@
         """
 
 
-class IProductEditRestricted(IOfficialBugTagTargetRestricted):
+class IProductEditRestricted(IOfficialBugTagTargetRestricted,
+                             IHasGitRepositoriesEdit):
     """`IProduct` properties which require launchpad.Edit permission."""
 
     @mutator_for(IProductView['bug_sharing_policy'])
@@ -889,7 +896,7 @@
     IProductModerateRestricted, IProductDriverRestricted, IProductView,
     IProductLimitedView, IProductPublic, IQuestionTarget,
     ISpecificationTarget, IStructuralSubscriptionTarget, IInformationType,
-    IPillar):
+    IPillar, IHasGitRepositories):
     """A Product.
 
     The Launchpad Registry describes the open source world as ProjectGroups

=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
--- lib/lp/registry/model/distributionsourcepackage.py	2014-11-27 20:52:37 +0000
+++ lib/lp/registry/model/distributionsourcepackage.py	2015-02-06 22:26:57 +0000
@@ -43,6 +43,7 @@
     HasBranchesMixin,
     HasMergeProposalsMixin,
     )
+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
@@ -119,7 +120,8 @@
                                 HasBranchesMixin,
                                 HasCustomLanguageCodesMixin,
                                 HasMergeProposalsMixin,
-                                HasDriversMixin):
+                                HasDriversMixin,
+                                HasGitRepositoriesMixin):
     """This is a "Magic Distribution Source Package". It is not an
     SQLObject, but instead it represents a source package with a particular
     name in a particular distribution. You can then ask it all sorts of

=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py	2015-01-28 16:10:51 +0000
+++ lib/lp/registry/model/person.py	2015-02-06 22:26:57 +0000
@@ -146,6 +146,7 @@
     HasMergeProposalsMixin,
     HasRequestedReviewsMixin,
     )
+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
 from lp.registry.enums import (
     EXCLUSIVE_TEAM_POLICY,
     INCLUSIVE_TEAM_POLICY,
@@ -476,7 +477,7 @@
 class Person(
     SQLBase, HasBugsBase, HasSpecificationsMixin, HasTranslationImportsMixin,
     HasBranchesMixin, HasMergeProposalsMixin, HasRequestedReviewsMixin,
-    QuestionsPersonMixin):
+    QuestionsPersonMixin, HasGitRepositoriesMixin):
     """A Person."""
 
     implements(IPerson, IHasIcon, IHasLogo, IHasMugshot)

=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py	2015-01-29 16:28:30 +0000
+++ lib/lp/registry/model/product.py	2015-02-06 22:26:57 +0000
@@ -124,6 +124,7 @@
     HasCodeImportsMixin,
     HasMergeProposalsMixin,
     )
+from lp.code.model.hasgitrepositories import HasGitRepositoriesMixin
 from lp.code.model.sourcepackagerecipe import SourcePackageRecipe
 from lp.code.model.sourcepackagerecipedata import SourcePackageRecipeData
 from lp.registry.enums import (
@@ -361,7 +362,8 @@
               OfficialBugTagTargetMixin, HasBranchesMixin,
               HasCustomLanguageCodesMixin, HasMergeProposalsMixin,
               HasCodeImportsMixin, InformationTypeMixin,
-              TranslationPolicyMixin):
+              TranslationPolicyMixin,
+              HasGitRepositoriesMixin):
     """A Product."""
 
     implements(

=== modified file 'lib/lp/registry/tests/test_product.py'
--- lib/lp/registry/tests/test_product.py	2015-01-29 16:28:30 +0000
+++ lib/lp/registry/tests/test_product.py	2015-02-06 22:26:57 +0000
@@ -858,10 +858,10 @@
             'getCustomLanguageCode', 'getDefaultBugInformationType',
             'getDefaultSpecificationInformationType',
             'getEffectiveTranslationPermission', 'getExternalBugTracker',
-            'getFAQ', 'getFirstEntryToImport', 'getLinkedBugWatches',
-            'getMergeProposals', 'getMilestone', 'getMilestonesAndReleases',
-            'getQuestion', 'getQuestionLanguages', 'getPackage', 'getRelease',
-            'getSeries', 'getSubscription',
+            'getFAQ', 'getFirstEntryToImport', 'getGitRepositories',
+            'getLinkedBugWatches', 'getMergeProposals', 'getMilestone',
+            'getMilestonesAndReleases', 'getQuestion', 'getQuestionLanguages',
+            'getPackage', 'getRelease', 'getSeries', 'getSubscription',
             'getSubscriptions', 'getSupportedLanguages', 'getTimeline',
             'getTopContributors', 'getTopContributorsGroupedByCategory',
             'getTranslationGroups', 'getTranslationImportQueueEntries',
@@ -902,7 +902,8 @@
         'launchpad.Edit': set((
             'addOfficialBugTag', 'removeOfficialBugTag',
             'setBranchSharingPolicy', 'setBugSharingPolicy',
-            'setSpecificationSharingPolicy', 'checkInformationType')),
+            'setSpecificationSharingPolicy', 'checkInformationType',
+            'createGitRepository')),
         'launchpad.Moderate': set((
             'is_permitted', 'license_approved', 'project_reviewed',
             'reviewer_whiteboard', 'setAliases')),

=== modified file 'lib/lp/security.py'
--- lib/lp/security.py	2015-01-06 04:52:44 +0000
+++ lib/lp/security.py	2015-02-06 22:26:57 +0000
@@ -1151,14 +1151,34 @@
 
 
 class EditDistributionSourcePackage(AuthorizationBase):
-    """DistributionSourcePackage is not editable.
-
-    But EditStructuralSubscription needs launchpad.Edit defined on all
-    targets.
-    """
     permission = 'launchpad.Edit'
     usedfor = IDistributionSourcePackage
 
+    def checkAuthenticated(self, user):
+        """Anyone who can upload a package can edit it.
+
+        Checking upload permission requires a distroseries; a reasonable
+        approximation is to check whether the user can upload the package to
+        the current series.
+        """
+        if user.in_admin:
+            return True
+
+        distribution = self.obj.distribution
+        if user.inTeam(distribution.owner):
+            return True
+
+        # We use verifyUpload() instead of checkUpload() because we don't
+        # have a pocket.  It returns the reason the user can't upload or
+        # None if they are allowed.
+        if distribution.currentseries is None:
+            return False
+        reason = distribution.main_archive.verifyUpload(
+            user.person, sourcepackagename=self.obj.sourcepackagename,
+            component=None, distroseries=distribution.currentseries,
+            strict_component=False)
+        return reason is None
+
 
 class BugTargetOwnerOrBugSupervisorOrAdmins(AuthorizationBase):
     """Product's owner and bug supervisor can set official bug tags."""

=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf	2014-08-05 08:58:14 +0000
+++ lib/lp/services/config/schema-lazr.conf	2015-02-06 22:26:57 +0000
@@ -335,6 +335,27 @@
 # of shutting down and so should not receive any more connections.
 web_status_port = tcp:8022
 
+# The URL of the internal Git hosting API endpoint.
+internal_git_endpoint: none
+
+# The URL prefix for links to the Git code browser.  Links are formed by
+# appending the repository's path to the root URL.
+#
+# datatype: urlbase
+git_browse_root: https://git.launchpad.net/
+
+# The URL prefix for anonymous Git protocol fetches.  Links are formed by
+# appending the repository's path to the root URL.
+#
+# datatype: urlbase
+git_anon_root: git://git.launchpad.net/
+
+# The URL prefix for Git-over-SSH.  Links are formed by appending the
+# repository's path to the root URL.
+#
+# datatype: urlbase
+git_ssh_root: git+ssh://git.launchpad.net/
+
 
 [codeimport]
 # Where the Bazaar imports are stored.


References