launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #17878
[Merge] lp:~cjwatson/launchpad/git-sharing into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-sharing into lp:launchpad.
Commit message:
Add sharing service support for Git repositories.
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-sharing/+merge/249815
In https://code.launchpad.net/~cjwatson/launchpad/git-namespace/+merge/248995 I said that the next branch in this series would bring up test infrastructure. That turns out to have been a slight lie, because I remembered that creating test objects tends to require the ability to set their information type as well, so the sharing infrastructure needs to be in place. This branch sets that up. It's largely straightforward by analogy with branch handling, although I also did a bit of canonicalisation of argument ordering and ensuring that all calls to the methods in question use keyword arguments (oh for Python 3 and keyword-only arguments!).
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-sharing 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-16 13:40:19 +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
=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py 2014-06-19 02:12:50 +0000
+++ lib/lp/blueprints/model/specification.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 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).
__metaclass__ = type
@@ -758,7 +758,7 @@
# Grant the subscriber access if they can't see the
# specification.
service = getUtility(IService, 'sharing')
- ignored, ignored, shared_specs = service.getVisibleArtifacts(
+ _, _, _, shared_specs = service.getVisibleArtifacts(
person, specifications=[self], ignore_permissions=True)
if not shared_specs:
service.ensureAccessGrants(
=== modified file 'lib/lp/blueprints/tests/test_specification.py'
--- lib/lp/blueprints/tests/test_specification.py 2015-01-06 04:52:44 +0000
+++ lib/lp/blueprints/tests/test_specification.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 Canonical Ltd. This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Unit tests for Specification."""
@@ -490,7 +490,7 @@
product=product, information_type=InformationType.PROPRIETARY)
spec.subscribe(user, subscribed_by=owner)
service = getUtility(IService, 'sharing')
- ignored, ignored, shared_specs = service.getVisibleArtifacts(
+ _, _, _, shared_specs = service.getVisibleArtifacts(
user, specifications=[spec])
self.assertEqual([spec], shared_specs)
# The spec is also returned by getSharedSpecifications(),
@@ -507,7 +507,7 @@
service.sharePillarInformation(
product, user_2, owner, permissions)
spec.subscribe(user_2, subscribed_by=owner)
- ignored, ignored, shared_specs = service.getVisibleArtifacts(
+ _, _, _, shared_specs = service.getVisibleArtifacts(
user_2, specifications=[spec])
self.assertEqual([spec], shared_specs)
self.assertEqual(
@@ -527,7 +527,7 @@
spec.subscribe(user, subscribed_by=owner)
spec.unsubscribe(user, unsubscribed_by=owner)
service = getUtility(IService, 'sharing')
- ignored, ignored, shared_specs = service.getVisibleArtifacts(
+ _, _, _, shared_specs = service.getVisibleArtifacts(
user, specifications=[spec])
self.assertEqual([], shared_specs)
=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py 2014-11-14 22:10:03 +0000
+++ lib/lp/bugs/model/bug.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 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).
"""Launchpad bug-related database table classes."""
@@ -836,7 +836,7 @@
# there is at least one bugtask for which access can be checked.
if self.default_bugtask:
service = getUtility(IService, 'sharing')
- bugs, ignored, ignored = service.getVisibleArtifacts(
+ bugs, _, _, _ = service.getVisibleArtifacts(
person, bugs=[self], ignore_permissions=True)
if not bugs:
service.ensureAccessGrants(
@@ -1774,7 +1774,7 @@
if information_type in PRIVATE_INFORMATION_TYPES:
service = getUtility(IService, 'sharing')
for person in (who, self.owner):
- bugs, ignored, ignored = service.getVisibleArtifacts(
+ bugs, _, _, _ = service.getVisibleArtifacts(
person, bugs=[self], ignore_permissions=True)
if not bugs:
# subscribe() isn't sufficient if a subscription
=== modified file 'lib/lp/code/browser/branchsubscription.py'
--- lib/lp/code/browser/branchsubscription.py 2014-11-28 22:07:05 +0000
+++ lib/lp/code/browser/branchsubscription.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 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).
__metaclass__ = type
@@ -200,7 +200,7 @@
page_title = label = "Subscribe to branch"
def validate(self, data):
- if data.has_key('person'):
+ if 'person' in data:
person = data['person']
subscription = self.context.getSubscription(person)
if subscription is None and not self.context.userCanBeSubscribed(
@@ -279,7 +279,7 @@
url = canonical_url(self.branch)
# If the subscriber can no longer see the branch, redirect them away.
service = getUtility(IService, 'sharing')
- ignored, branches, ignored = service.getVisibleArtifacts(
+ _, branches, _, _ = service.getVisibleArtifacts(
self.person, branches=[self.branch], ignore_permissions=True)
if not branches:
url = canonical_url(self.branch.target)
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml 2015-02-09 11:38:30 +0000
+++ lib/lp/code/configure.zcml 2015-02-16 13:40:19 +0000
@@ -807,6 +807,57 @@
<adapter factory="lp.code.model.linkedbranch.PackageLinkedBranch" />
<adapter factory="lp.code.model.linkedbranch.DistributionPackageLinkedBranch" />
+ <!-- GitRepository -->
+
+ <class class="lp.code.model.gitrepository.GitRepository">
+ <require
+ permission="launchpad.View"
+ interface="lp.app.interfaces.launchpad.IPrivacy
+ lp.code.interfaces.gitrepository.IGitRepositoryView
+ lp.code.interfaces.gitrepository.IGitRepositoryModerateAttributes" />
+ <require
+ permission="launchpad.Moderate"
+ interface="lp.code.interfaces.gitrepository.IGitRepositoryModerate"
+ set_schema="lp.code.interfaces.gitrepository.IGitRepositoryModerateAttributes" />
+ <require
+ permission="launchpad.Edit"
+ interface="lp.code.interfaces.gitrepository.IGitRepositoryEdit" />
+ </class>
+ <subscriber
+ for="lp.code.interfaces.gitrepository.IGitRepository zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+ handler="lp.code.model.gitrepository.git_repository_modified"/>
+
+ <!-- GitRepositorySet -->
+
+ <class class="lp.code.model.gitrepository.GitRepositorySet">
+ <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />
+ </class>
+ <securedutility
+ class="lp.code.model.gitrepository.GitRepositorySet"
+ provides="lp.code.interfaces.gitrepository.IGitRepositorySet">
+ <allow interface="lp.code.interfaces.gitrepository.IGitRepositorySet" />
+ </securedutility>
+
+ <!-- GitNamespace -->
+
+ <class class="lp.code.model.gitnamespace.PackageGitNamespace">
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
+ </class>
+ <class class="lp.code.model.gitnamespace.PersonalGitNamespace">
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
+ </class>
+ <class class="lp.code.model.gitnamespace.ProjectGitNamespace">
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespace" />
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespacePolicy" />
+ </class>
+ <securedutility
+ class="lp.code.model.gitnamespace.GitNamespaceSet"
+ provides="lp.code.interfaces.gitnamespace.IGitNamespaceSet">
+ <allow interface="lp.code.interfaces.gitnamespace.IGitNamespaceSet" />
+ </securedutility>
+
<lp:help-folder folder="help" name="+help-code" />
<!-- Diffs -->
=== modified file 'lib/lp/code/errors.py'
--- lib/lp/code/errors.py 2013-12-20 05:38:18 +0000
+++ lib/lp/code/errors.py 2015-02-16 13:40:19 +0000
@@ -28,11 +28,20 @@
'CodeImportNotInReviewedState',
'ClaimReviewFailed',
'DiffNotFound',
+ 'GitDefaultConflict',
+ 'GitRepositoryCreationException',
+ 'GitRepositoryCreationFault',
+ 'GitRepositoryCreationForbidden',
+ 'GitRepositoryCreatorNotMemberOfOwnerTeam',
+ 'GitRepositoryCreatorNotOwner',
+ 'GitRepositoryExists',
+ 'GitTargetError',
'InvalidBranchMergeProposal',
'InvalidMergeQueueConfig',
'InvalidNamespace',
'NoLinkedBranch',
'NoSuchBranch',
+ 'NoSuchGitRepository',
'PrivateBranchRecipe',
'ReviewNotPending',
'StaleLastMirrored',
@@ -312,6 +321,91 @@
"""Raised when the user specifies an unrecognized branch type."""
+class GitRepositoryCreationException(Exception):
+ """Base class for Git repository creation exceptions."""
+
+
+@error_status(httplib.CONFLICT)
+class GitRepositoryExists(GitRepositoryCreationException):
+ """Raised when creating a Git repository that already exists."""
+
+ def __init__(self, existing_repository):
+ params = {
+ "name": existing_repository.name,
+ "context": existing_repository.namespace.name,
+ }
+ message = (
+ 'A Git repository with the name "%(name)s" already exists for '
+ '%(context)s.' % params)
+ self.existing_repository = existing_repository
+ GitRepositoryCreationException.__init__(self, message)
+
+
+class GitRepositoryCreationForbidden(GitRepositoryCreationException):
+ """A visibility policy forbids Git repository creation.
+
+ The exception is raised if the policy for the project does not allow the
+ creator of the repository to create a repository for that project.
+ """
+
+
+@error_status(httplib.BAD_REQUEST)
+class GitRepositoryCreatorNotMemberOfOwnerTeam(GitRepositoryCreationException):
+ """Git repository creator is not a member of the owner team.
+
+ Raised when a user is attempting to create a repository and set the
+ owner of the repository to a team that they are not a member of.
+ """
+
+
+@error_status(httplib.BAD_REQUEST)
+class GitRepositoryCreatorNotOwner(GitRepositoryCreationException):
+ """A user cannot create a Git repository belonging to another user.
+
+ Raised when a user is attempting to create a repository and set the
+ owner of the repository to another user.
+ """
+
+
+class GitRepositoryCreationFault(GitRepositoryCreationException):
+ """Raised when there is a hosting fault creating a Git repository."""
+
+
+class GitTargetError(Exception):
+ """Raised when there is an error determining a Git repository target."""
+
+
+class NoSuchGitRepository(NameLookupFailed):
+ """Raised when we try to load a Git repository that does not exist."""
+
+ _message_prefix = "No such Git repository"
+
+
+@error_status(httplib.CONFLICT)
+class GitDefaultConflict(Exception):
+ """Raised when trying to set a Git repository as the default for
+ something that already has a default."""
+
+ def __init__(self, existing_repository, target, owner=None):
+ params = {
+ "unique_name": existing_repository.unique_name,
+ "target": target.displayname,
+ "owner": owner.displayname,
+ }
+ if owner is None:
+ message = (
+ "The default repository for '%(target)s' is already set to "
+ "%(unique_name)s." % params)
+ else:
+ message = (
+ "%(owner)'s default repository for '%(target)s' is already "
+ "set to %(unique_name)s." % params)
+ self.existing_repository = existing_repository
+ self.target = target
+ self.owner = owner
+ Exception.__init__(self, message)
+
+
@error_status(httplib.BAD_REQUEST)
class CodeImportNotInReviewedState(Exception):
"""Raised when the user requests an import of a non-automatic import."""
=== added file 'lib/lp/code/interfaces/gitnamespace.py'
--- lib/lp/code/interfaces/gitnamespace.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitnamespace.py 2015-02-16 13:40:19 +0000
@@ -0,0 +1,249 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface for a Git repository namespace."""
+
+__metaclass__ = type
+__all__ = [
+ 'get_git_namespace',
+ 'IGitNamespace',
+ 'IGitNamespacePolicy',
+ 'IGitNamespaceSet',
+ 'split_git_unique_name',
+ ]
+
+from zope.component import getUtility
+from zope.interface import (
+ Attribute,
+ Interface,
+ )
+
+from lp.code.errors import InvalidNamespace
+from lp.registry.interfaces.distributionsourcepackage import (
+ IDistributionSourcePackage,
+ )
+from lp.registry.interfaces.product import IProduct
+
+
+class IGitNamespace(Interface):
+ """A namespace that a Git repository lives in."""
+
+ name = Attribute(
+ "The name of the namespace. This is prepended to the repository name.")
+
+ target = Attribute("The `IHasGitRepositories` for this namespace.")
+
+ def createRepository(registrant, name, information_type=None,
+ date_created=None):
+ """Create and return an `IGitRepository` in this namespace."""
+
+ def isNameUsed(name):
+ """Is 'name' already used in this namespace?"""
+
+ def findUnusedName(prefix):
+ """Find an unused repository name starting with 'prefix'.
+
+ Note that there is no guarantee that the name returned by this method
+ will remain unused for very long.
+ """
+
+ def moveRepository(repository, mover, new_name=None,
+ rename_if_necessary=False):
+ """Move the repository into this namespace.
+
+ :param repository: The `IGitRepository` to move.
+ :param mover: The `IPerson` doing the moving.
+ :param new_name: A new name for the repository.
+ :param rename_if_necessary: Rename the repository if the repository
+ name already exists in this namespace.
+ :raises GitRepositoryCreatorNotMemberOfOwnerTeam: if the namespace
+ owner is a team and 'mover' is not in that team.
+ :raises GitRepositoryCreatorNotOwner: if the namespace owner is an
+ individual and 'mover' is not the owner.
+ :raises GitRepositoryCreationForbidden: if 'mover' is not allowed to
+ create a repository in this namespace due to privacy rules.
+ :raises GitRepositoryExists: if a repository with the new name
+ already exists in the namespace, and 'rename_if_necessary' is
+ False.
+ """
+
+ def getRepositories():
+ """Return the repositories in this namespace."""
+
+ def getByName(repository_name, default=None):
+ """Find the repository in this namespace called 'repository_name'.
+
+ :return: `IGitRepository` if found, otherwise 'default'.
+ """
+
+ def __eq__(other):
+ """Is this namespace the same as another namespace?"""
+
+ def __ne__(other):
+ """Is this namespace not the same as another namespace?"""
+
+
+class IGitNamespacePolicy(Interface):
+ """Methods relating to Git repository creation and validation."""
+
+ def getAllowedInformationTypes(who):
+ """Get the information types that a repository in this namespace can
+ have.
+
+ :param who: The user making the request.
+ :return: A sequence of `InformationType`s.
+ """
+
+ def getDefaultInformationType(who):
+ """Get the default information type for repositories in this namespace.
+
+ :param who: The user to return the information type for.
+ :return: An `InformationType`.
+ """
+
+ def validateRegistrant(registrant):
+ """Check that the registrant can create a repository in this namespace.
+
+ :param registrant: An `IPerson`.
+ :raises GitRepositoryCreatorNotMemberOfOwnerTeam: if the namespace
+ owner is a team and the registrant is not in that team.
+ :raises GitRepositoryCreatorNotOwner: if the namespace owner is an
+ individual and the registrant is not the owner.
+ :raises GitRepositoryCreationForbidden: if the registrant is not
+ allowed to create a repository in this namespace due to privacy
+ rules.
+ """
+
+ def validateRepositoryName(name):
+ """Check the repository `name`.
+
+ :param name: A branch name, either string or unicode.
+ :raises GitRepositoryExists: if a branch with the `name` already
+ exists in the namespace.
+ :raises LaunchpadValidationError: if the name doesn't match the
+ validation constraints on IGitRepository.name.
+ """
+
+ def validateMove(repository, mover, name=None):
+ """Check that 'mover' can move 'repository' into this namespace.
+
+ :param repository: An `IGitRepository` that might be moved.
+ :param mover: The `IPerson` who would move it.
+ :param name: A new name for the repository. If None, the repository
+ name is used.
+ :raises GitRepositoryCreatorNotMemberOfOwnerTeam: if the namespace
+ owner is a team and 'mover' is not in that team.
+ :raises GitRepositoryCreatorNotOwner: if the namespace owner is an
+ individual and 'mover' is not the owner.
+ :raises GitRepositoryCreationForbidden: if 'mover' is not allowed to
+ create a repository in this namespace due to privacy rules.
+ :raises GitRepositoryExists: if a repository with the new name
+ already exists in the namespace.
+ """
+
+
+class IGitNamespaceSet(Interface):
+ """Interface for getting Git repository namespaces."""
+
+ def get(person, project=None, distribution=None, sourcepackagename=None):
+ """Return the appropriate `IGitNamespace` for the given objects."""
+
+ def interpret(person, project, distribution, sourcepackagename):
+ """Like `get`, but takes names of objects.
+
+ :raise NoSuchPerson: If the person referred to cannot be found.
+ :raise NoSuchProduct: If the project referred to cannot be found.
+ :raise NoSuchDistribution: If the distribution referred to cannot be
+ found.
+ :raise NoSuchSourcePackageName: If the sourcepackagename referred to
+ cannot be found.
+ :return: An `IGitNamespace`.
+ """
+
+ def parse(namespace_name):
+ """Parse 'namespace_name' into its components.
+
+ The name of a namespace is actually a path containing many elements,
+ each of which maps to a particular kind of object in Launchpad.
+ Elements that can appear in a namespace name are: 'person',
+ 'project', 'distribution', and 'sourcepackagename'.
+
+ `parse` returns a dict which maps the names of these elements (e.g.
+ 'person', 'project') to the values of these elements (e.g. 'mark',
+ 'firefox'). If the given path doesn't include a particular kind of
+ element, the dict maps that element name to None.
+
+ For example::
+ parse('~foo/bar') => {
+ 'person': 'foo', 'project': 'bar', 'distribution': None,
+ 'sourcepackagename': None,
+ }
+
+ If the given 'namespace_name' cannot be parsed, then we raise an
+ `InvalidNamespace` error.
+
+ :raise InvalidNamespace: If the name is too long, too short, or
+ malformed.
+ :return: A dict with keys matching each component in
+ 'namespace_name'.
+ """
+
+ def lookup(namespace_name):
+ """Return the `IGitNamespace` for 'namespace_name'.
+
+ :raise InvalidNamespace: if namespace_name cannot be parsed.
+ :raise NoSuchPerson: if the person referred to cannot be found.
+ :raise NoSuchProduct: if the project referred to cannot be found.
+ :raise NoSuchDistribution: if the distribution referred to cannot be
+ found.
+ :raise NoSuchSourcePackageName: if the sourcepackagename referred to
+ cannot be found.
+ :return: An `IGitNamespace`.
+ """
+
+ def traverse(segments):
+ """Look up the Git repository at the path given by 'segments'.
+
+ The iterable 'segments' will be consumed until a repository is
+ found. As soon as a repository is found, the repository will be
+ returned and the consumption of segments will stop. Thus, there
+ will often be unconsumed segments that can be used for further
+ traversal.
+
+ :param segments: An iterable of URL segments, a prefix of which
+ identifies a Git repository. The first segment is the username,
+ *not* preceded by a '~`.
+ :raise InvalidNamespace: if there are not enough segments to define a
+ repository.
+ :raise NoSuchPerson: if the person referred to cannot be found.
+ :raise NoSuchProduct: if the product or distro referred to cannot be
+ found.
+ :raise NoSuchDistribution: if the distribution referred to cannot be
+ found.
+ :raise NoSuchSourcePackageName: if the sourcepackagename referred to
+ cannot be found.
+ :return: `IGitRepository`.
+ """
+
+
+def get_git_namespace(target, owner):
+ if IProduct.providedBy(target):
+ return getUtility(IGitNamespaceSet).get(owner, project=target)
+ elif IDistributionSourcePackage.providedBy(target):
+ return getUtility(IGitNamespaceSet).get(
+ owner, distribution=target.distribution,
+ sourcepackagename=target.sourcepackagename)
+ else:
+ return getUtility(IGitNamespaceSet).get(owner)
+
+
+# Marker for references to Git URL layouts: ##GITNAMESPACE##
+def split_git_unique_name(unique_name):
+ """Return the namespace and repository names of a unique name."""
+ try:
+ namespace_name, literal, repository_name = unique_name.rsplit("/", 2)
+ except ValueError:
+ raise InvalidNamespace(unique_name)
+ if literal != "+git":
+ raise InvalidNamespace(unique_name)
+ return namespace_name, repository_name
=== 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-16 13:40:19 +0000
@@ -0,0 +1,375 @@
+# 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__ = [
+ 'GitIdentityMixin',
+ 'git_repository_name_validator',
+ 'IGitRepository',
+ 'IGitRepositorySet',
+ 'user_has_special_git_repository_access',
+ ]
+
+import re
+
+from lazr.restful.fields import Reference
+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.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=True)
+
+ 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."))
+
+ target = Reference(
+ title=_("Target"), required=True, readonly=True,
+ schema=IHasGitRepositories,
+ description=_("The target of the repository."))
+
+ namespace = Attribute(
+ "The namespace of this repository, as an `IGitNamespace`.")
+
+ information_type = Choice(
+ title=_("Information Type"), vocabulary=InformationType,
+ required=True, readonly=True, default=InformationType.PUBLIC,
+ description=_(
+ "The type of information contained in this repository."))
+
+ owner_default = Bool(
+ title=_("Owner default"), required=True, readonly=True,
+ description=_(
+ "Whether this repository is the default for its owner and "
+ "target."))
+
+ target_default = Bool(
+ title=_("Target default"), required=True, readonly=True,
+ description=_(
+ "Whether this repository is the default for its target."))
+
+ 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 setOwnerDefault(value):
+ """Set whether this repository is the default for its owner-target.
+
+ This is for internal use; the caller should ensure permission to edit
+ the owner, should arrange to remove any existing owner-target default
+ (including any target default with the same owner), and should check
+ that this repository is attached to the desired target.
+
+ :raises Unauthorized: if lacking permission to edit the owner.
+ :param value: True if this repository should be the owner-target
+ default, otherwise False.
+ """
+
+ def setTargetDefault(value):
+ """Set whether this repository is the default for its target.
+
+ This is for internal use; the caller should ensure permission to edit
+ the target, should arrange to remove any existing target default, and
+ should check that this repository is attached to the desired target.
+
+ :raises Unauthorized: if lacking permission to edit the target.
+ :param value: True if this repository should be the target default,
+ otherwise False.
+ """
+
+ def getCodebrowseUrl():
+ """Construct a browsing URL for this Git repository."""
+
+ 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 getRepositoryDefaults():
+ """Return a sorted list of `ICanHasDefaultGitRepository` objects.
+
+ There is one result for each related object for which this
+ repository is the default. For example, in the case where a
+ repository is the default for a project and is also its owner's
+ default repository for that project, the objects for both the
+ project and the person-project are returned.
+
+ More important related objects 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 default repositories, the context object is the
+ appropriate default object.
+
+ Where a repository is the default for 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 the default for the 'fooix'
+ project and which is also its owner's default repository for that
+ project is accessible using:
+ fooix - the context object is the project fooix
+ ~fooix-owner/fooix - the context object is the person-project
+ ~fooix-owner and fooix
+ ~fooix-owner/fooix/+git/fooix - the unique name of the repository
+ where the context 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(target, user):
+ """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)
+ """
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ 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/+git/NAME
+ ~OWNER/DISTRO/+source/SOURCE/+git/NAME
+ ~OWNER/+git/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 getDefaultRepository(target, owner=None):
+ """Get the default repository for a target or owner-target.
+
+ :param target: An `IHasGitRepositories`.
+ :param owner: An `IPerson`, in which case search for that person's
+ default repository for this target; or None, in which case
+ search for the overall default repository for this target.
+
+ :raises GitTargetError: if `target` is an `IPerson`.
+ :return: An `IGitRepository`, or None.
+ """
+
+ def getRepositories():
+ """Return an empty collection of repositories.
+
+ This only exists to keep lazr.restful happy.
+ """
+
+
+class GitIdentityMixin:
+ """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
+ context objects 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 getRepositoryDefaults(self):
+ """See `IGitRepository`."""
+ # XXX cjwatson 2015-02-06: This will return shortcut defaults once
+ # they're implemented.
+ return []
+
+ def getRepositoryIdentities(self):
+ """See `IGitRepository`."""
+ identities = [
+ (default.path, default.context)
+ for default in self.getRepositoryDefaults()]
+ 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-16 13:40:19 +0000
@@ -0,0 +1,40 @@
+# 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',
+ ]
+
+from zope.interface import Interface
+
+
+class IHasGitRepositories(Interface):
+ """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.
+ """
+
+ 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.
+ """
+
+ 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)
+ """
=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py 2014-01-15 00:59:48 +0000
+++ lib/lp/code/model/branch.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 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).
__metaclass__ = type
@@ -208,7 +208,6 @@
mirror_status_message = StringCol(default=None)
information_type = EnumCol(
enum=InformationType, default=InformationType.PUBLIC)
- access_policy = IntCol()
@property
def private(self):
@@ -915,7 +914,7 @@
subscription.review_level = code_review_level
# Grant the subscriber access if they can't see the branch.
service = getUtility(IService, 'sharing')
- ignored, branches, ignored = service.getVisibleArtifacts(
+ _, branches, _, _ = service.getVisibleArtifacts(
person, branches=[self], ignore_permissions=True)
if not branches:
service.ensureAccessGrants(
@@ -929,7 +928,7 @@
# currently accessible to the person but which the subscribed_by user
# has edit permissions for.
service = getUtility(IService, 'sharing')
- ignored, invisible_stacked_branches = service.getInvisibleArtifacts(
+ _, invisible_stacked_branches, _ = service.getInvisibleArtifacts(
person, branches=self.getStackedOnBranches())
editable_stacked_on_branches = [
branch for branch in invisible_stacked_branches
@@ -1661,7 +1660,7 @@
policy_grant_query = Coalesce(
ArrayIntersects(
- Array(branch_class.access_policy),
+ Array(SQL('%s.access_policy' % branch_class.__storm_table__)),
Select(
ArrayAgg(AccessPolicyGrant.policy_id),
tables=(AccessPolicyGrant,
=== modified file 'lib/lp/code/model/branchnamespace.py'
--- lib/lp/code/model/branchnamespace.py 2015-02-09 11:38:30 +0000
+++ lib/lp/code/model/branchnamespace.py 2015-02-16 13:40:19 +0000
@@ -7,6 +7,8 @@
__all__ = [
'BranchNamespaceSet',
'BRANCH_POLICY_ALLOWED_TYPES',
+ 'BRANCH_POLICY_DEFAULT_TYPES',
+ 'BRANCH_POLICY_REQUIRED_GRANTS',
'PackageBranchNamespace',
'PersonalBranchNamespace',
'ProjectBranchNamespace',
=== added file 'lib/lp/code/model/gitnamespace.py'
--- lib/lp/code/model/gitnamespace.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/model/gitnamespace.py 2015-02-16 13:40:19 +0000
@@ -0,0 +1,538 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Implementations of `IGitNamespace`."""
+
+__metaclass__ = type
+__all__ = [
+ 'GitNamespaceSet',
+ 'PackageGitNamespace',
+ 'PersonalGitNamespace',
+ 'ProjectGitNamespace',
+ ]
+
+from lazr.lifecycle.event import ObjectCreatedEvent
+from storm.locals import And
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implements
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import (
+ FREE_INFORMATION_TYPES,
+ InformationType,
+ NON_EMBARGOED_INFORMATION_TYPES,
+ PUBLIC_INFORMATION_TYPES,
+ )
+from lp.app.interfaces.services import IService
+from lp.code.errors import (
+ GitRepositoryCreationForbidden,
+ GitRepositoryCreatorNotMemberOfOwnerTeam,
+ GitRepositoryCreatorNotOwner,
+ GitRepositoryExists,
+ InvalidNamespace,
+ NoSuchGitRepository,
+ )
+from lp.code.interfaces.gitnamespace import (
+ IGitNamespace,
+ IGitNamespacePolicy,
+ IGitNamespaceSet,
+ )
+from lp.code.interfaces.gitrepository import (
+ IGitRepository,
+ user_has_special_git_repository_access,
+ )
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
+from lp.code.model.branchnamespace import (
+ BRANCH_POLICY_ALLOWED_TYPES,
+ BRANCH_POLICY_DEFAULT_TYPES,
+ BRANCH_POLICY_REQUIRED_GRANTS,
+ )
+from lp.code.model.gitrepository import GitRepository
+from lp.registry.enums import PersonVisibility
+from lp.registry.errors import NoSuchSourcePackageName
+from lp.registry.interfaces.distribution import (
+ IDistribution,
+ IDistributionSet,
+ NoSuchDistribution,
+ )
+from lp.registry.interfaces.distributionsourcepackage import (
+ IDistributionSourcePackage,
+ )
+from lp.registry.interfaces.person import (
+ IPersonSet,
+ NoSuchPerson,
+ )
+from lp.registry.interfaces.pillar import IPillarNameSet
+from lp.registry.interfaces.product import (
+ IProduct,
+ IProductSet,
+ NoSuchProduct,
+ )
+from lp.registry.interfaces.projectgroup import IProjectGroup
+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.services.database.constants import DEFAULT
+from lp.services.database.interfaces import IStore
+from lp.services.propertycache import get_property_cache
+
+
+class _BaseGitNamespace:
+ """Common code for Git repository namespaces."""
+
+ def createRepository(self, registrant, name, information_type=None,
+ date_created=DEFAULT):
+ """See `IGitNamespace`."""
+
+ self.validateRegistrant(registrant)
+ self.validateRepositoryName(name)
+
+ if information_type is None:
+ information_type = self.getDefaultInformationType(registrant)
+ if information_type is None:
+ raise GitRepositoryCreationForbidden()
+
+ repository = GitRepository(
+ registrant, self.owner, self.target, name, information_type,
+ date_created)
+ repository._reconcileAccess()
+
+ notify(ObjectCreatedEvent(repository))
+
+ return repository
+
+ def isNameUsed(self, repository_name):
+ """See `IGitNamespace`."""
+ return self.getByName(repository_name) is not None
+
+ def findUnusedName(self, prefix):
+ """See `IGitNamespace`."""
+ name = prefix
+ count = 0
+ while self.isNameUsed(name):
+ count += 1
+ name = "%s-%s" % (prefix, count)
+ return name
+
+ def validateRegistrant(self, registrant):
+ """See `IGitNamespace`."""
+ if user_has_special_git_repository_access(registrant):
+ return
+ owner = self.owner
+ if not registrant.inTeam(owner):
+ if owner.is_team:
+ raise GitRepositoryCreatorNotMemberOfOwnerTeam(
+ "%s is not a member of %s"
+ % (registrant.displayname, owner.displayname))
+ else:
+ raise GitRepositoryCreatorNotOwner(
+ "%s cannot create Git repositories owned by %s"
+ % (registrant.displayname, owner.displayname))
+
+ if not self.getAllowedInformationTypes(registrant):
+ raise GitRepositoryCreationForbidden(
+ 'You cannot create Git repositories in "%s"' % self.name)
+
+ def validateRepositoryName(self, name):
+ """See `IGitNamespace`."""
+ existing_repository = self.getByName(name)
+ if existing_repository is not None:
+ raise GitRepositoryExists(existing_repository)
+
+ # Not all code paths that lead to Git repository creation go via a
+ # schema-validated form, so we validate the repository name here to
+ # give a nicer error message than 'ERROR: new row for relation
+ # "gitrepository" violates check constraint "valid_name"...'.
+ IGitRepository['name'].validate(unicode(name))
+
+ def validateMove(self, repository, mover, name=None):
+ """See `IGitNamespace`."""
+ if name is None:
+ name = repository.name
+ self.validateRepositoryName(name)
+ self.validateRegistrant(mover)
+
+ def moveRepository(self, repository, mover, new_name=None,
+ rename_if_necessary=False):
+ """See `IGitNamespace`."""
+ # Check to see if the repository is already in this namespace.
+ old_namespace = repository.namespace
+ if self.name == old_namespace.name:
+ return
+ if new_name is None:
+ new_name = repository.name
+ if rename_if_necessary:
+ new_name = self.findUnusedName(new_name)
+ self.validateMove(repository, mover, new_name)
+ # Remove the security proxy of the repository as the owner and
+ # target attributes are read-only through the interface.
+ naked_repository = removeSecurityProxy(repository)
+ naked_repository.owner = self.owner
+ self._retargetRepository(naked_repository)
+ del get_property_cache(naked_repository).target
+ naked_repository.name = new_name
+
+ def getRepositories(self):
+ """See `IGitNamespace`."""
+ return IStore(GitRepository).find(
+ GitRepository, self._getRepositoriesClause())
+
+ def getByName(self, repository_name, default=None):
+ """See `IGitNamespace`."""
+ match = IStore(GitRepository).find(
+ GitRepository, self._getRepositoriesClause(),
+ GitRepository.name == repository_name).one()
+ if match is None:
+ match = default
+ return match
+
+ def getAllowedInformationTypes(self, who=None):
+ """See `IGitNamespace`."""
+ raise NotImplementedError
+
+ def getDefaultInformationType(self, who=None):
+ """See `IGitNamespace`."""
+ raise NotImplementedError
+
+ def __eq__(self, other):
+ """See `IGitNamespace`."""
+ return self.target == other.target
+
+ def __ne__(self, other):
+ """See `IGitNamespace`."""
+ return not self == other
+
+
+class PersonalGitNamespace(_BaseGitNamespace):
+ """A namespace for personal repositories.
+
+ Repositories in this namespace have names like "~foo/+git/bar".
+ """
+
+ implements(IGitNamespace, IGitNamespacePolicy)
+
+ def __init__(self, person):
+ self.owner = person
+
+ def _getRepositoriesClause(self):
+ return And(
+ GitRepository.owner == self.owner,
+ GitRepository.project == None,
+ GitRepository.distribution == None,
+ GitRepository.sourcepackagename == None)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ @property
+ def name(self):
+ """See `IGitNamespace`."""
+ return "~%s" % self.owner.name
+
+ @property
+ def target(self):
+ """See `IGitNamespace`."""
+ return IHasGitRepositories(self.owner)
+
+ def _retargetRepository(self, repository):
+ repository.project = None
+ repository.distribution = None
+ repository.sourcepackagename = None
+
+ @property
+ def _is_private_team(self):
+ return (
+ self.owner.is_team
+ and self.owner.visibility == PersonVisibility.PRIVATE)
+
+ def getAllowedInformationTypes(self, who=None):
+ """See `IGitNamespace`."""
+ # Private teams get private branches, everyone else gets public ones.
+ if self._is_private_team:
+ return NON_EMBARGOED_INFORMATION_TYPES
+ else:
+ return FREE_INFORMATION_TYPES
+
+ def getDefaultInformationType(self, who=None):
+ """See `IGitNamespace`."""
+ if self._is_private_team:
+ return InformationType.PROPRIETARY
+ else:
+ return InformationType.PUBLIC
+
+
+class ProjectGitNamespace(_BaseGitNamespace):
+ """A namespace for project repositories.
+
+ This namespace is for all the repositories owned by a particular person
+ in a particular project.
+ """
+
+ implements(IGitNamespace, IGitNamespacePolicy)
+
+ def __init__(self, person, project):
+ self.owner = person
+ self.project = project
+
+ def _getRepositoriesClause(self):
+ return And(
+ GitRepository.owner == self.owner,
+ GitRepository.project == self.project)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ @property
+ def name(self):
+ """See `IGitNamespace`."""
+ return '~%s/%s' % (self.owner.name, self.project.name)
+
+ @property
+ def target(self):
+ """See `IGitNamespace`."""
+ return IHasGitRepositories(self.project)
+
+ def _retargetRepository(self, repository):
+ repository.project = self.project
+ repository.distribution = None
+ repository.sourcepackagename = None
+
+ def getAllowedInformationTypes(self, who=None):
+ """See `IGitNamespace`."""
+ # Some policies require that the repository owner or current user
+ # have full access to an information type. If it's required and the
+ # user doesn't hold it, no information types are legal.
+ required_grant = BRANCH_POLICY_REQUIRED_GRANTS[
+ self.project.branch_sharing_policy]
+ if (required_grant is not None
+ and not getUtility(IService, 'sharing').checkPillarAccess(
+ [self.project], required_grant, self.owner)
+ and (who is None
+ or not getUtility(IService, 'sharing').checkPillarAccess(
+ [self.project], required_grant, who))):
+ return []
+
+ return BRANCH_POLICY_ALLOWED_TYPES[self.project.branch_sharing_policy]
+
+ def getDefaultInformationType(self, who=None):
+ """See `IGitNamespace`."""
+ default_type = BRANCH_POLICY_DEFAULT_TYPES[
+ self.project.branch_sharing_policy]
+ if default_type not in self.getAllowedInformationTypes(who):
+ return None
+ return default_type
+
+
+class PackageGitNamespace(_BaseGitNamespace):
+ """A namespace for distribution source package repositories.
+
+ This namespace is for all the repositories owned by a particular person
+ in a particular source package in a particular distribution.
+ """
+
+ implements(IGitNamespace, IGitNamespacePolicy)
+
+ def __init__(self, person, distro_source_package):
+ self.owner = person
+ self.distro_source_package = distro_source_package
+
+ def _getRepositoriesClause(self):
+ dsp = self.distro_source_package
+ return And(
+ GitRepository.owner == self.owner,
+ GitRepository.distribution == dsp.distribution,
+ GitRepository.sourcepackagename == dsp.sourcepackagename)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ @property
+ def name(self):
+ """See `IGitNamespace`."""
+ dsp = self.distro_source_package
+ return '~%s/%s/+source/%s' % (
+ self.owner.name, dsp.distribution.name, dsp.sourcepackagename.name)
+
+ @property
+ def target(self):
+ """See `IGitNamespace`."""
+ return IHasGitRepositories(self.distro_source_package)
+
+ def _retargetRepository(self, repository):
+ dsp = self.distro_source_package
+ repository.project = None
+ repository.distribution = dsp.distribution
+ repository.sourcepackagename = dsp.sourcepackagename
+
+ def getAllowedInformationTypes(self, who=None):
+ """See `IGitNamespace`."""
+ return PUBLIC_INFORMATION_TYPES
+
+ def getDefaultInformationType(self, who=None):
+ """See `IGitNamespace`."""
+ return InformationType.PUBLIC
+
+ def __eq__(self, other):
+ """See `IGitNamespace`."""
+ # We may have different DSP objects that are functionally the same.
+ self_dsp = self.distro_source_package
+ other_dsp = IDistributionSourcePackage(other.target)
+ return (
+ self_dsp.distribution == other_dsp.distribution and
+ self_dsp.sourcepackagename == other_dsp.sourcepackagename)
+
+
+class GitNamespaceSet:
+ """Only implementation of `IGitNamespaceSet`."""
+
+ implements(IGitNamespaceSet)
+
+ def get(self, person, project=None, distribution=None,
+ sourcepackagename=None):
+ """See `IGitNamespaceSet`."""
+ if project is not None:
+ assert distribution is None and sourcepackagename is None, (
+ "project implies no distribution or sourcepackagename. "
+ "Got %r, %r, %r."
+ % (project, distribution, sourcepackagename))
+ return ProjectGitNamespace(person, project)
+ elif distribution is not None:
+ assert sourcepackagename is not None, (
+ "distribution implies sourcepackagename. Got %r, %r"
+ % (distribution, sourcepackagename))
+ return PackageGitNamespace(
+ person, distribution.getSourcePackage(sourcepackagename))
+ else:
+ return PersonalGitNamespace(person)
+
+ def _findOrRaise(self, error, name, finder, *args):
+ if name is None:
+ return None
+ args = list(args)
+ args.append(name)
+ result = finder(*args)
+ if result is None:
+ raise error(name)
+ return result
+
+ def _findPerson(self, person_name):
+ return self._findOrRaise(
+ NoSuchPerson, person_name, getUtility(IPersonSet).getByName)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ def _findPillar(self, pillar_name):
+ """Find and return the pillar with the given name.
+
+ If the given name is '+git' (indicating a personal repository) or
+ None, return None.
+
+ :raise NoSuchProduct if there's no pillar with the given name or it
+ is a project group.
+ """
+ if pillar_name == "+git":
+ return None
+ pillar = self._findOrRaise(
+ NoSuchProduct, pillar_name, getUtility(IPillarNameSet).getByName)
+ if IProjectGroup.providedBy(pillar):
+ raise NoSuchProduct(pillar_name)
+ return pillar
+
+ def _findProject(self, project_name):
+ return self._findOrRaise(
+ NoSuchProduct, project_name, getUtility(IProductSet).getByName)
+
+ def _findDistribution(self, distribution_name):
+ return self._findOrRaise(
+ NoSuchDistribution, distribution_name,
+ getUtility(IDistributionSet).getByName)
+
+ def _findSourcePackageName(self, sourcepackagename_name):
+ return self._findOrRaise(
+ NoSuchSourcePackageName, sourcepackagename_name,
+ getUtility(ISourcePackageNameSet).queryByName)
+
+ def _realize(self, names):
+ """Turn a dict of object names into a dict of objects.
+
+ Takes the results of `IGitNamespaceSet.parse` and turns them into a
+ dict where the values are Launchpad objects.
+ """
+ data = {}
+ data["person"] = self._findPerson(names["person"])
+ data["project"] = self._findProject(names["project"])
+ data["distribution"] = self._findDistribution(names["distribution"])
+ data["sourcepackagename"] = self._findSourcePackageName(
+ names["sourcepackagename"])
+ return data
+
+ def interpret(self, person, project, distribution, sourcepackagename):
+ names = dict(
+ person=person, project=project, distribution=distribution,
+ sourcepackagename=sourcepackagename)
+ data = self._realize(names)
+ return self.get(**data)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ def parse(self, namespace_name):
+ """See `IGitNamespaceSet`."""
+ data = dict(
+ person=None, project=None, distribution=None,
+ sourcepackagename=None)
+ tokens = namespace_name.split("/")
+ if len(tokens) == 1:
+ data["person"] = tokens[0]
+ elif len(tokens) == 2:
+ data["person"] = tokens[0]
+ data["project"] = tokens[1]
+ elif len(tokens) == 4 and tokens[2] == "+source":
+ data["person"] = tokens[0]
+ data["distribution"] = tokens[1]
+ data["sourcepackagename"] = tokens[3]
+ else:
+ raise InvalidNamespace(namespace_name)
+ if not data["person"].startswith("~"):
+ raise InvalidNamespace(namespace_name)
+ data["person"] = data["person"][1:]
+ return data
+
+ def lookup(self, namespace_name):
+ """See `IGitNamespaceSet`."""
+ names = self.parse(namespace_name)
+ return self.interpret(**names)
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ def traverse(self, segments):
+ """See `IGitNamespaceSet`."""
+ traversed_segments = []
+
+ def get_next_segment():
+ try:
+ result = segments.next()
+ except StopIteration:
+ raise InvalidNamespace("/".join(traversed_segments))
+ if result is None:
+ raise AssertionError("None segment passed to traverse()")
+ if not isinstance(result, unicode):
+ result = result.decode("US-ASCII")
+ traversed_segments.append(result)
+ return result
+
+ person_name = get_next_segment()
+ person = self._findPerson(person_name)
+ pillar_name = get_next_segment()
+ pillar = self._findPillar(pillar_name)
+ if pillar is None:
+ namespace = self.get(person)
+ git_literal = pillar_name
+ elif IProduct.providedBy(pillar):
+ namespace = self.get(person, project=pillar)
+ git_literal = get_next_segment()
+ else:
+ source_literal = get_next_segment()
+ if source_literal != "+source":
+ raise InvalidNamespace("/".join(traversed_segments))
+ sourcepackagename_name = get_next_segment()
+ sourcepackagename = self._findSourcePackageName(
+ sourcepackagename_name)
+ namespace = self.get(
+ person, distribution=IDistribution(pillar),
+ sourcepackagename=sourcepackagename)
+ git_literal = get_next_segment()
+ if git_literal != "+git":
+ raise InvalidNamespace("/".join(traversed_segments))
+ repository_name = get_next_segment()
+ return self._findOrRaise(
+ NoSuchGitRepository, repository_name, namespace.getByName)
=== 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-16 13:40:19 +0000
@@ -0,0 +1,428 @@
+# 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 (
+ Bool,
+ DateTime,
+ Int,
+ Reference,
+ Unicode,
+ )
+from zope.component import getUtility
+from zope.interface import implements
+from zope.security.interfaces import Unauthorized
+
+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.errors import (
+ GitDefaultConflict,
+ GitTargetError,
+ )
+from lp.code.interfaces.gitnamespace import (
+ get_git_namespace,
+ IGitNamespacePolicy,
+ )
+from lp.code.interfaces.gitrepository import (
+ GitIdentityMixin,
+ IGitRepository,
+ IGitRepositorySet,
+ user_has_special_git_repository_access,
+ )
+from lp.registry.enums import PersonVisibility
+from lp.registry.errors import CannotChangeInformationType
+from lp.registry.interfaces.accesspolicy import (
+ IAccessArtifactSource,
+ IAccessPolicySource,
+ )
+from lp.registry.interfaces.distributionsourcepackage import (
+ IDistributionSourcePackage,
+ )
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.product import IProduct
+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.interfaces import IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.database.stormexpr import (
+ Array,
+ ArrayAgg,
+ ArrayIntersects,
+ )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp.authorization import check_permission
+
+
+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, GitIdentityMixin):
+ """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)
+ owner_default = Bool(name='owner_default', allow_none=False)
+ target_default = Bool(name='target_default', allow_none=False)
+
+ def __init__(self, registrant, owner, target, name, information_type,
+ date_created):
+ 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 = None
+ self.distribution = None
+ self.sourcepackagename = None
+ if IProduct.providedBy(target):
+ self.project = target
+ elif IDistributionSourcePackage.providedBy(target):
+ self.distribution = target.distribution
+ self.sourcepackagename = target.sourcepackagename
+ self.owner_default = False
+ self.target_default = False
+
+ # Marker for references to Git URL layouts: ##GITNAMESPACE##
+ @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 += "/+git/%(repository)s"
+ return fmt % names
+
+ def __repr__(self):
+ return "<GitRepository %r (%d)>" % (self.unique_name, self.id)
+
+ @cachedproperty
+ def target(self):
+ """See `IGitRepository`."""
+ if self.project is None:
+ if self.distribution is None:
+ return self.owner
+ else:
+ return self.distribution.getSourcePackage(
+ self.sourcepackagename)
+ else:
+ return self.project
+
+ def setTarget(self, target, user):
+ """See `IGitRepository`."""
+ if IPerson.providedBy(target):
+ owner = IPerson(target)
+ if (self.information_type in PRIVATE_INFORMATION_TYPES and
+ (not owner.is_team or
+ owner.visibility != PersonVisibility.PRIVATE)):
+ raise GitTargetError(
+ "Only private teams may have personal private "
+ "repositories.")
+ namespace = get_git_namespace(target, self.owner)
+ if (self.information_type not in
+ namespace.getAllowedInformationTypes(user)):
+ raise GitTargetError(
+ "%s repositories are not allowed for target %s." % (
+ self.information_type.title, target.displayname))
+ namespace.moveRepository(self, user, rename_if_necessary=True)
+ self._reconcileAccess()
+
+ @property
+ def namespace(self):
+ """See `IGitRepository`."""
+ return get_git_namespace(self.target, self.owner)
+
+ def setOwnerDefault(self, value):
+ """See `IGitRepository`."""
+ if not check_permission("launchpad.Edit", self.owner):
+ raise Unauthorized(
+ "You don't have permission to change the default repository "
+ "for %s on '%s'." %
+ (self.owner.displayname, self.target.displayname))
+ if value:
+ # Check for an existing owner-target default.
+ existing = getUtility(IGitRepositorySet).getDefaultRepository(
+ self.target, owner=self.owner)
+ if existing is not None:
+ raise GitDefaultConflict(
+ existing, self.target, owner=self.owner)
+ self.owner_default = value
+
+ def setTargetDefault(self, value):
+ """See `IGitRepository`."""
+ if not check_permission("launchpad.Edit", self.target):
+ raise Unauthorized(
+ "You don't have permission to change the default repository "
+ "for '%s'." % self.target.displayname)
+ if value:
+ # Any target default must also be an owner-target default.
+ self.setOwnerDefault(True)
+ # Check for an existing target default.
+ existing = getUtility(IGitRepositorySet).getDefaultRepository(
+ self.target)
+ if existing is not None:
+ raise GitDefaultConflict(existing, self.target)
+ self.target_default = value
+
+ @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 getCodebrowseUrl(self):
+ """See `IGitRepository`."""
+ return urlutils.join(
+ config.codehosting.git_browse_root, self.unique_name)
+
+ @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)
+
+ @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.
+ policy = IGitNamespacePolicy(self.namespace)
+ types = set(policy.getAllowedInformationTypes(user))
+ 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`."""
+ new_namespace = get_git_namespace(self.target, new_owner)
+ new_namespace.moveRepository(self, user, rename_if_necessary=True)
+
+ 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`."""
+ namespace = get_git_namespace(target, owner)
+ return namespace.createRepository(
+ registrant, name, information_type=information_type,
+ date_created=date_created)
+
+ def getByPath(self, user, path):
+ """See `IGitRepositorySet`."""
+ # XXX cjwatson 2015-02-06: Fill this in once IGitLookup is in place.
+ raise NotImplementedError
+
+ def getDefaultRepository(self, target, owner=None):
+ """See `IGitRepositorySet`."""
+ clauses = []
+ if IProduct.providedBy(target):
+ clauses.append(GitRepository.project == target)
+ elif IDistributionSourcePackage.providedBy(target):
+ clauses.append(GitRepository.distribution == target.distribution)
+ clauses.append(
+ GitRepository.sourcepackagename == target.sourcepackagename)
+ else:
+ raise GitTargetError(
+ "Personal repositories cannot be defaults for any target.")
+ if owner is not None:
+ clauses.append(GitRepository.owner == owner)
+ clauses.append(GitRepository.owner_default == True)
+ else:
+ clauses.append(GitRepository.target_default == True)
+ return IStore(GitRepository).find(GitRepository, *clauses).one()
+
+ def getRepositories(self):
+ """See `IGitRepositorySet`."""
+ return []
+
+
+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(SQL("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-16 13:40:19 +0000
@@ -0,0 +1,28 @@
+# 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',
+ ]
+
+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
=== modified file 'lib/lp/code/model/tests/test_branchsubscription.py'
--- lib/lp/code/model/tests/test_branchsubscription.py 2012-09-19 13:22:42 +0000
+++ lib/lp/code/model/tests/test_branchsubscription.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2012 Canonical Ltd. This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Tests for the BranchSubscrptions model object.."""
@@ -133,7 +133,7 @@
None, CodeReviewNotificationLevel.NOEMAIL, owner)
# The stacked on branch should be visible.
service = getUtility(IService, 'sharing')
- ignored, visible_branches, ignored = service.getVisibleArtifacts(
+ _, visible_branches, _, _ = service.getVisibleArtifacts(
grantee, branches=[private_stacked_on_branch])
self.assertContentEqual(
[private_stacked_on_branch], visible_branches)
@@ -161,7 +161,7 @@
grantee, BranchSubscriptionNotificationLevel.NOEMAIL,
None, CodeReviewNotificationLevel.NOEMAIL, owner)
# The stacked on branch should not be visible.
- ignored, visible_branches, ignored = service.getVisibleArtifacts(
+ _, visible_branches, _, _ = service.getVisibleArtifacts(
grantee, branches=[private_stacked_on_branch])
self.assertContentEqual([], visible_branches)
self.assertIn(
=== 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-16 13:40:19 +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/browser/pillar.py'
--- lib/lp/registry/browser/pillar.py 2014-11-24 01:20:26 +0000
+++ lib/lp/registry/browser/pillar.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2013 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).
"""Common views for objects that implement `IPillar`."""
@@ -444,12 +444,14 @@
def _loadSharedArtifacts(self):
# As a concrete can by linked via more than one policy, we use sets to
# filter out dupes.
- self.bugtasks, self.branches, self.specifications = (
+ (self.bugtasks, self.branches, self.gitrepositories,
+ self.specifications) = (
self.sharing_service.getSharedArtifacts(
self.pillar, self.person, self.user))
bug_ids = set([bugtask.bug.id for bugtask in self.bugtasks])
self.shared_bugs_count = len(bug_ids)
self.shared_branches_count = len(self.branches)
+ self.shared_gitrepositories_count = len(self.gitrepositories)
self.shared_specifications_count = len(self.specifications)
def _build_specification_template_data(self, specs, request):
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml 2015-02-09 17:42:48 +0000
+++ lib/lp/registry/configure.zcml 2015-02-16 13:40:19 +0000
@@ -556,6 +556,11 @@
bug_reporting_guidelines
enable_bugfiling_duplicate_search
"/>
+
+ <!-- IHasGitRepositories -->
+
+ <allow
+ interface="lp.code.interfaces.hasgitrepositories.IHasGitRepositories" />
</class>
<adapter
provides="lp.registry.interfaces.distribution.IDistribution"
=== modified file 'lib/lp/registry/interfaces/accesspolicy.py'
--- lib/lp/registry/interfaces/accesspolicy.py 2012-09-21 11:41:56 +0000
+++ lib/lp/registry/interfaces/accesspolicy.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011-2012 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 pillar and artifact access policies."""
@@ -35,6 +35,7 @@
concrete_artifact = Attribute("Concrete artifact")
bug_id = Attribute("bug_id")
branch_id = Attribute("branch_id")
+ gitrepository_id = Attribute("gitrepository_id")
specification_id = Attribute("specification_id")
=== 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-16 13:40:19 +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-16 13:40:19 +0000
@@ -111,6 +111,7 @@
IHasMergeProposals,
IHasRequestedReviews,
)
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
from lp.code.interfaces.hasrecipes import IHasRecipes
from lp.registry.enums import (
EXCLUSIVE_TEAM_POLICY,
@@ -688,7 +689,7 @@
IHasMergeProposals, IHasMugshot,
IHasLocation, IHasRequestedReviews, IObjectWithLocation,
IHasBugs, IHasRecipes, IHasTranslationImports,
- IPersonSettings, IQuestionsPerson):
+ IPersonSettings, IQuestionsPerson, IHasGitRepositories):
"""IPerson attributes that require launchpad.View permission."""
account = Object(schema=IAccount)
accountID = Int(title=_('Account ID'), required=True, readonly=True)
=== 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-16 13:40:19 +0000
@@ -102,6 +102,7 @@
IHasCodeImports,
IHasMergeProposals,
)
+from lp.code.interfaces.hasgitrepositories import IHasGitRepositories
from lp.code.interfaces.hasrecipes import IHasRecipes
from lp.registry.enums import (
BranchSharingPolicy,
@@ -475,7 +476,7 @@
IHasMugshot, IHasSprints, IHasTranslationImports,
ITranslationPolicy, IKarmaContext, IMakesAnnouncements,
IOfficialBugTagTargetPublic, IHasOOPSReferences,
- IHasRecipes, IHasCodeImports, IServiceUsage):
+ IHasRecipes, IHasCodeImports, IServiceUsage, IHasGitRepositories):
"""Public IProduct properties."""
registrant = exported(
=== modified file 'lib/lp/registry/interfaces/sharingservice.py'
--- lib/lp/registry/interfaces/sharingservice.py 2015-02-06 15:17:07 +0000
+++ lib/lp/registry/interfaces/sharingservice.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2012-2013 Canonical Ltd. This software is licensed under the
+# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Interfaces for sharing service."""
@@ -108,7 +108,7 @@
:param user: the user making the request. Only artifacts visible to the
user will be included in the result.
- :return: a (bugtasks, branches, specifications) tuple
+ :return: a (bugtasks, branches, gitrepositories, specifications) tuple
"""
def checkPillarArtifactAccess(pillar, user):
@@ -148,6 +148,14 @@
:return: a collection of branches
"""
+ def getSharedGitRepositories(pillar, person, user):
+ """Return the Git repositories shared between the pillar and person.
+
+ :param user: the user making the request. Only Git repositories
+ visible to the user will be included in the result.
+ :return: a collection of Git repositories.
+ """
+
@export_read_operation()
@call_with(user=REQUEST_USER)
@operation_parameters(
@@ -163,19 +171,25 @@
:return: a collection of specifications.
"""
- def getVisibleArtifacts(person, branches=None, bugs=None):
+ def getVisibleArtifacts(person, bugs=None, branches=None,
+ gitrepositories=None, specifications=None):
"""Return the artifacts shared with person.
Given lists of artifacts, return those a person has access to either
via a policy grant or artifact grant.
:param person: the person whose access is being checked.
+ :param bugs: the bugs to check for which a person has access.
:param branches: the branches to check for which a person has access.
- :param bugs: the bugs to check for which a person has access.
+ :param gitrepositories: the Git repositories to check for which a
+ person has access.
+ :param specifications: the specifications to check for which a
+ person has access.
:return: a collection of artifacts the person can see.
"""
- def getInvisibleArtifacts(person, branches=None, bugs=None):
+ def getInvisibleArtifacts(person, bugs=None, branches=None,
+ gitrepositories=None):
"""Return the artifacts which are not shared with person.
Given lists of artifacts, return those a person does not have access to
@@ -184,8 +198,10 @@
access to private information. Internal use only. *
:param person: the person whose access is being checked.
+ :param bugs: the bugs to check for which a person has access.
:param branches: the branches to check for which a person has access.
- :param bugs: the bugs to check for which a person has access.
+ :param gitrepositories: the Git repositories to check for which a
+ person has access.
:return: a collection of artifacts the person can not see.
"""
@@ -304,10 +320,11 @@
branches=List(
Reference(schema=IBranch), title=_('Branches'), required=False),
specifications=List(
- Reference(schema=ISpecification), title=_('Specifications'), required=False))
+ Reference(schema=ISpecification), title=_('Specifications'),
+ required=False))
@operation_for_version('devel')
- def revokeAccessGrants(pillar, grantee, user, branches=None, bugs=None,
- specifications=None):
+ def revokeAccessGrants(pillar, grantee, user, bugs=None, branches=None,
+ gitrepositories=None, specifications=None):
"""Remove a grantee's access to the specified artifacts.
:param pillar: the pillar from which to remove access
@@ -315,6 +332,7 @@
:param user: the user making the request
:param bugs: the bugs for which to revoke access
:param branches: the branches for which to revoke access
+ :param gitrepositories: the Git repositories for which to revoke access
:param specifications: the specifications for which to revoke access
"""
@@ -328,14 +346,15 @@
branches=List(
Reference(schema=IBranch), title=_('Branches'), required=False))
@operation_for_version('devel')
- def ensureAccessGrants(grantees, user, branches=None, bugs=None,
- specifications=None):
+ def ensureAccessGrants(grantees, user, bugs=None, branches=None,
+ gitrepositories=None, specifications=None):
"""Ensure a grantee has an access grant to the specified artifacts.
:param grantees: the people or teams for whom to grant access
:param user: the user making the request
:param bugs: the bugs for which to grant access
:param branches: the branches for which to grant access
+ :param gitrepositories: the Git repositories for which to grant access
:param specifications: the specifications for which to grant access
"""
=== modified file 'lib/lp/registry/model/accesspolicy.py'
--- lib/lp/registry/model/accesspolicy.py 2013-06-20 05:50:00 +0000
+++ lib/lp/registry/model/accesspolicy.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011-2012 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).
"""Model classes for pillar and artifact access policies."""
@@ -98,12 +98,16 @@
bug = Reference(bug_id, 'Bug.id')
branch_id = Int(name='branch')
branch = Reference(branch_id, 'Branch.id')
+ gitrepository_id = Int(name='gitrepository')
+ gitrepository = Reference(gitrepository_id, 'GitRepository.id')
specification_id = Int(name='specification')
specification = Reference(specification_id, 'Specification.id')
@property
def concrete_artifact(self):
- artifact = self.bug or self.branch or self.specification
+ artifact = (
+ self.bug or self.branch or self.gitrepository or
+ self.specification)
return artifact
@classmethod
@@ -111,10 +115,13 @@
from lp.blueprints.interfaces.specification import ISpecification
from lp.bugs.interfaces.bug import IBug
from lp.code.interfaces.branch import IBranch
+ from lp.code.interfaces.gitrepository import IGitRepository
if IBug.providedBy(concrete_artifact):
col = cls.bug
elif IBranch.providedBy(concrete_artifact):
col = cls.branch
+ elif IGitRepository.providedBy(concrete_artifact):
+ col = cls.gitrepository
elif ISpecification.providedBy(concrete_artifact):
col = cls.specification
else:
@@ -137,6 +144,7 @@
from lp.blueprints.interfaces.specification import ISpecification
from lp.bugs.interfaces.bug import IBug
from lp.code.interfaces.branch import IBranch
+ from lp.code.interfaces.gitrepository import IGitRepository
existing = list(cls.find(concrete_artifacts))
if len(existing) == len(concrete_artifacts):
@@ -150,15 +158,17 @@
insert_values = []
for concrete in needed:
if IBug.providedBy(concrete):
- insert_values.append((concrete, None, None))
+ insert_values.append((concrete, None, None, None))
elif IBranch.providedBy(concrete):
- insert_values.append((None, concrete, None))
+ insert_values.append((None, concrete, None, None))
+ elif IGitRepository.providedBy(concrete):
+ insert_values.append((None, None, concrete, None))
elif ISpecification.providedBy(concrete):
- insert_values.append((None, None, concrete))
+ insert_values.append((None, None, None, concrete))
else:
raise ValueError("%r is not a supported artifact" % concrete)
new = create(
- (cls.bug, cls.branch, cls.specification),
+ (cls.bug, cls.branch, cls.gitrepository, cls.specification),
insert_values, get_objects=True)
return list(existing) + new
=== 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-16 13:40:19 +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-16 13:40:19 +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-16 13:40:19 +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/model/sharingjob.py'
--- lib/lp/registry/model/sharingjob.py 2013-07-04 08:32:03 +0000
+++ lib/lp/registry/model/sharingjob.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2012-2013 Canonical Ltd. This software is licensed under the
+# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Job classes related to the sharing feature are in here."""
@@ -10,7 +10,6 @@
'RemoveArtifactSubscriptionsJob',
]
-import contextlib
import logging
from lazr.delegates import delegates
@@ -58,11 +57,13 @@
from lp.bugs.model.bugtasksearch import get_bug_privacy_filter_terms
from lp.code.interfaces.branch import IBranch
from lp.code.interfaces.branchlookup import IBranchLookup
+from lp.code.interfaces.gitrepository import IGitRepository
from lp.code.model.branch import (
Branch,
get_branch_privacy_filter,
)
from lp.code.model.branchsubscription import BranchSubscription
+from lp.code.model.gitrepository import GitRepository
from lp.registry.interfaces.person import IPersonSet
from lp.registry.interfaces.product import IProduct
from lp.registry.interfaces.sharingjob import (
@@ -85,7 +86,6 @@
)
from lp.services.job.runner import BaseRunnableJob
from lp.services.mail.sendmail import format_address_for_person
-from lp.services.webapp import errorlog
class SharingJobType(DBEnumeratedType):
@@ -265,6 +265,7 @@
bug_ids = []
branch_ids = []
+ gitrepository_ids = []
specification_ids = []
if artifacts:
for artifact in artifacts:
@@ -272,6 +273,8 @@
bug_ids.append(artifact.id)
elif IBranch.providedBy(artifact):
branch_ids.append(artifact.id)
+ elif IGitRepository.providedBy(artifact):
+ gitrepository_ids.append(artifact.id)
elif ISpecification.providedBy(artifact):
specification_ids.append(artifact.id)
else:
@@ -283,6 +286,7 @@
metadata = {
'bug_ids': bug_ids,
'branch_ids': branch_ids,
+ 'gitrepository_ids': gitrepository_ids,
'specification_ids': specification_ids,
'information_types': information_types,
'requestor.id': requestor.id
@@ -315,6 +319,10 @@
return [getUtility(IBranchLookup).get(id) for id in self.branch_ids]
@property
+ def gitrepository_ids(self):
+ return self.metadata.get('gitrepository_ids', [])
+
+ @property
def specification_ids(self):
return self.metadata.get('specification_ids', [])
@@ -343,6 +351,7 @@
'requestor': self.requestor.name,
'bug_ids': self.bug_ids,
'branch_ids': self.branch_ids,
+ 'gitrepository_ids': self.gitrepository_ids,
'specification_ids': self.specification_ids,
'pillar': getattr(self.pillar, 'name', None),
'grantee': getattr(self.grantee, 'name', None)
@@ -358,10 +367,14 @@
bug_filters = []
branch_filters = []
+ gitrepository_filters = []
specification_filters = []
if self.branch_ids:
branch_filters.append(Branch.id.is_in(self.branch_ids))
+ if self.gitrepository_ids:
+ gitrepository_filters.append(GitRepository.id.is_in(
+ self.gitrepository_ids))
if self.specification_ids:
specification_filters.append(Specification.id.is_in(
self.specification_ids))
@@ -374,6 +387,9 @@
self.information_types))
branch_filters.append(
Branch.information_type.is_in(self.information_types))
+ gitrepository_filters.append(
+ GitRepository.information_type.is_in(
+ self.information_types))
specification_filters.append(
Specification.information_type.is_in(
self.information_types))
@@ -381,12 +397,16 @@
bug_filters.append(
BugTaskFlat.product == self.product)
branch_filters.append(Branch.product == self.product)
+ gitrepository_filters.append(
+ GitRepository.project == self.product)
specification_filters.append(
Specification.product == self.product)
if self.distro:
bug_filters.append(
BugTaskFlat.distribution == self.distro)
branch_filters.append(Branch.distribution == self.distro)
+ gitrepository_filters.append(
+ GitRepository.distribution == self.distro)
specification_filters.append(
Specification.distribution == self.distro)
@@ -401,6 +421,8 @@
Select(
TeamParticipation.personID,
where=TeamParticipation.team == self.grantee)))
+ # XXX cjwatson 2015-02-05: Fill this in once we have
+ # GitRepositorySubscription.
specification_filters.append(
In(SpecificationSubscription.personID,
Select(
@@ -430,6 +452,8 @@
for sub in branch_subscriptions:
sub.branch.unsubscribe(
sub.person, self.requestor, ignore_permissions=True)
+ # XXX cjwatson 2015-02-05: Fill this in once we have
+ # GitRepositorySubscription.
if specification_filters:
specification_filters.append(Not(*get_specification_privacy_filter(
SpecificationSubscription.personID)))
=== modified file 'lib/lp/registry/services/sharingservice.py'
--- lib/lp/registry/services/sharingservice.py 2013-06-20 05:50:00 +0000
+++ lib/lp/registry/services/sharingservice.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2012-2013 Canonical Ltd. This software is licensed under the
+# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Classes for pillar and artifact sharing service."""
@@ -194,10 +194,12 @@
@available_with_permission('launchpad.Driver', 'pillar')
def getSharedArtifacts(self, pillar, person, user, include_bugs=True,
- include_branches=True, include_specifications=True):
+ include_branches=True, include_gitrepositories=True,
+ include_specifications=True):
"""See `ISharingService`."""
bug_ids = set()
branch_ids = set()
+ gitrepository_ids = set()
specification_ids = set()
for artifact in self.getArtifactGrantsForPersonOnPillar(
pillar, person):
@@ -205,6 +207,8 @@
bug_ids.add(artifact.bug_id)
elif artifact.branch_id and include_branches:
branch_ids.add(artifact.branch_id)
+ elif artifact.gitrepository_id and include_gitrepositories:
+ gitrepository_ids.add(artifact.gitrepository_id)
elif artifact.specification_id and include_specifications:
specification_ids.add(artifact.specification_id)
@@ -221,11 +225,14 @@
wanted_branches = all_branches.visibleByUser(user).withIds(
*branch_ids)
branches = list(wanted_branches.getBranches())
+ # Load the Git repositories.
+ gitrepositories = []
+ # XXX cjwatson 2015-02-16: Fill in once IGitCollection is in place.
specifications = []
if specification_ids:
specifications = load(Specification, specification_ids)
- return bugtasks, branches, specifications
+ return bugtasks, branches, gitrepositories, specifications
def checkPillarArtifactAccess(self, pillar, user):
"""See `ISharingService`."""
@@ -245,25 +252,33 @@
@available_with_permission('launchpad.Driver', 'pillar')
def getSharedBugs(self, pillar, person, user):
"""See `ISharingService`."""
- bugtasks, ignore, ignore = self.getSharedArtifacts(
+ bugtasks, _, _, _ = self.getSharedArtifacts(
pillar, person, user, include_branches=False,
- include_specifications=False)
+ include_gitrepositories=False, include_specifications=False)
return bugtasks
@available_with_permission('launchpad.Driver', 'pillar')
def getSharedBranches(self, pillar, person, user):
"""See `ISharingService`."""
- ignore, branches, ignore = self.getSharedArtifacts(
+ _, branches, _, _ = self.getSharedArtifacts(
pillar, person, user, include_bugs=False,
- include_specifications=False)
+ include_gitrepositories=False, include_specifications=False)
return branches
@available_with_permission('launchpad.Driver', 'pillar')
+ def getSharedGitRepositories(self, pillar, person, user):
+ """See `ISharingService`."""
+ _, _, gitrepositories, _ = self.getSharedArtifacts(
+ pillar, person, user, include_bugs=False, include_branches=False,
+ include_specifications=False)
+ return gitrepositories
+
+ @available_with_permission('launchpad.Driver', 'pillar')
def getSharedSpecifications(self, pillar, person, user):
"""See `ISharingService`."""
- ignore, ignore, specifications = self.getSharedArtifacts(
- pillar, person, user, include_bugs=False,
- include_branches=False)
+ _, _, _, specifications = self.getSharedArtifacts(
+ pillar, person, user, include_bugs=False, include_branches=False,
+ include_gitrepositories=False)
return specifications
def _getVisiblePrivateSpecificationIDs(self, person, specifications):
@@ -300,11 +315,13 @@
TeamParticipation.personID == person.id,
In(Specification.id, spec_ids)))
- def getVisibleArtifacts(self, person, branches=None, bugs=None,
- specifications=None, ignore_permissions=False):
+ def getVisibleArtifacts(self, person, bugs=None, branches=None,
+ gitrepositories=None, specifications=None,
+ ignore_permissions=False):
"""See `ISharingService`."""
bugs_by_id = {}
branches_by_id = {}
+ gitrepositories_by_id = {}
for bug in bugs or []:
if (not ignore_permissions
and not check_permission('launchpad.View', bug)):
@@ -315,6 +332,11 @@
and not check_permission('launchpad.View', branch)):
raise Unauthorized
branches_by_id[branch.id] = branch
+ for gitrepository in gitrepositories or []:
+ if (not ignore_permissions
+ and not check_permission('launchpad.View', gitrepository)):
+ raise Unauthorized
+ gitrepositories_by_id[gitrepository.id] = gitrepository
for spec in specifications or []:
if (not ignore_permissions
and not check_permission('launchpad.View', spec)):
@@ -336,6 +358,11 @@
*branches_by_id.keys())
visible_branches = list(wanted_branches.getBranches())
+ # Load the Git repositories.
+ visible_gitrepositories = []
+ # XXX cjwatson 2015-02-16: Fill in once IGitCollection is in place.
+
+ # Load the specifications.
visible_specs = []
if specifications:
visible_private_spec_ids = self._getVisiblePrivateSpecificationIDs(
@@ -344,16 +371,22 @@
spec for spec in specifications
if spec.id in visible_private_spec_ids or not spec.private]
- return visible_bugs, visible_branches, visible_specs
+ return (
+ visible_bugs, visible_branches, visible_gitrepositories,
+ visible_specs)
- def getInvisibleArtifacts(self, person, branches=None, bugs=None):
+ def getInvisibleArtifacts(self, person, bugs=None, branches=None,
+ gitrepositories=None):
"""See `ISharingService`."""
bugs_by_id = {}
branches_by_id = {}
+ gitrepositories_by_id = {}
for bug in bugs or []:
bugs_by_id[bug.id] = bug
for branch in branches or []:
branches_by_id[branch.id] = branch
+ for gitrepository in gitrepositories or []:
+ gitrepositories_by_id[gitrepository.id] = gitrepository
# Load the bugs.
visible_bug_ids = set()
@@ -376,7 +409,11 @@
branches_by_id[branch_id]
for branch_id in invisible_branch_ids]
- return invisible_bugs, invisible_branches
+ # Load the Git repositories.
+ invisible_gitrepositories = []
+ # XXX cjwatson 2015-02-16: Fill in once IGitCollection is in place.
+
+ return invisible_bugs, invisible_branches, invisible_gitrepositories
def getPeopleWithoutAccess(self, concrete_artifact, people):
"""See `ISharingService`."""
@@ -722,42 +759,51 @@
return invisible_types
@available_with_permission('launchpad.Edit', 'pillar')
- def revokeAccessGrants(self, pillar, grantee, user, branches=None,
- bugs=None, specifications=None):
+ def revokeAccessGrants(self, pillar, grantee, user, bugs=None,
+ branches=None, gitrepositories=None,
+ specifications=None):
"""See `ISharingService`."""
- if not branches and not bugs and not specifications:
+ if (not bugs and not branches and not gitrepositories and
+ not specifications):
raise ValueError(
- "Either bugs, branches or specifications must be specified")
+ "Either bugs, branches, gitrepositories, or specifications "
+ "must be specified")
artifacts = []
+ if bugs:
+ artifacts.extend(bugs)
if branches:
artifacts.extend(branches)
- if bugs:
- artifacts.extend(bugs)
+ if gitrepositories:
+ artifacts.extend(gitrepositories)
if specifications:
artifacts.extend(specifications)
- # Find the access artifacts associated with the bugs and branches.
+ # Find the access artifacts associated with the bugs, branches, Git
+ # repositories, and specifications.
accessartifact_source = getUtility(IAccessArtifactSource)
artifacts_to_delete = accessartifact_source.find(artifacts)
- # Revoke access to bugs/branches for the specified grantee.
+ # Revoke access to artifacts for the specified grantee.
getUtility(IAccessArtifactGrantSource).revokeByArtifact(
artifacts_to_delete, [grantee])
# Create a job to remove subscriptions for artifacts the grantee can no
# longer see.
- getUtility(IRemoveArtifactSubscriptionsJobSource).create(
+ return getUtility(IRemoveArtifactSubscriptionsJobSource).create(
user, artifacts, grantee=grantee, pillar=pillar)
- def ensureAccessGrants(self, grantees, user, branches=None, bugs=None,
- specifications=None, ignore_permissions=False):
+ def ensureAccessGrants(self, grantees, user, bugs=None, branches=None,
+ gitrepositories=None, specifications=None,
+ ignore_permissions=False):
"""See `ISharingService`."""
artifacts = []
+ if bugs:
+ artifacts.extend(bugs)
if branches:
artifacts.extend(branches)
- if bugs:
- artifacts.extend(bugs)
+ if gitrepositories:
+ artifacts.extend(gitrepositories)
if specifications:
artifacts.extend(specifications)
if not ignore_permissions:
@@ -767,15 +813,15 @@
if not check_permission('launchpad.Edit', artifact):
raise Unauthorized
- # Ensure there are access artifacts associated with the bugs and
- # branches.
+ # Ensure there are access artifacts associated with the bugs,
+ # branches, Git repositories, and specifications.
artifacts = getUtility(IAccessArtifactSource).ensure(artifacts)
aagsource = getUtility(IAccessArtifactGrantSource)
artifacts_with_grants = [
artifact_grant.abstract_artifact
for artifact_grant in
aagsource.find(product(artifacts, grantees))]
- # Create access to bugs/branches for the specified grantee for which a
+ # Create access to artifacts for the specified grantee for which a
# grant does not already exist.
missing_artifacts = set(artifacts) - set(artifacts_with_grants)
getUtility(IAccessArtifactGrantSource).grant(
=== modified file 'lib/lp/registry/services/tests/test_sharingservice.py'
--- lib/lp/registry/services/tests/test_sharingservice.py 2015-02-06 15:17:07 +0000
+++ lib/lp/registry/services/tests/test_sharingservice.py 2015-02-16 13:40:19 +0000
@@ -1,4 +1,4 @@
-# Copyright 2012-2013 Canonical Ltd. This software is licensed under the
+# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
@@ -1075,9 +1075,10 @@
# Check that grantees have expected access grants and subscriptions.
for person in [team_grantee, person_grantee]:
- visible_bugs, visible_branches, visible_specs = (
+ visible_bugs, visible_branches, _, visible_specs = (
self.service.getVisibleArtifacts(
- person, branches, bugs, specifications))
+ person, bugs=bugs, branches=branches,
+ specifications=specifications))
self.assertContentEqual(bugs or [], visible_bugs)
self.assertContentEqual(branches or [], visible_branches)
self.assertContentEqual(specifications or [], visible_specs)
@@ -1102,8 +1103,9 @@
for person in [team_grantee, person_grantee]:
for bug in bugs or []:
self.assertNotIn(person, bug.getDirectSubscribers())
- visible_bugs, visible_branches, visible_specs = (
- self.service.getVisibleArtifacts(person, branches, bugs))
+ visible_bugs, visible_branches, _, visible_specs = (
+ self.service.getVisibleArtifacts(
+ person, bugs=bugs, branches=branches))
self.assertContentEqual([], visible_bugs)
self.assertContentEqual([], visible_branches)
self.assertContentEqual([], visible_specs)
@@ -1386,7 +1388,7 @@
product, grantee, user)
# Check the results.
- shared_bugtasks, shared_branches, shared_specs = (
+ shared_bugtasks, shared_branches, _, shared_specs = (
self.service.getSharedArtifacts(product, grantee, user))
self.assertContentEqual(bug_tasks[:9], shared_bugtasks)
self.assertContentEqual(branches[:9], shared_branches)
@@ -1673,8 +1675,9 @@
# Test the getVisibleArtifacts method.
grantee, ignore, branches, bugs, specs = self._make_Artifacts()
# Check the results.
- shared_bugs, shared_branches, shared_specs = (
- self.service.getVisibleArtifacts(grantee, branches, bugs, specs))
+ shared_bugs, shared_branches, _, shared_specs = (
+ self.service.getVisibleArtifacts(
+ grantee, bugs=bugs, branches=branches, specifications=specs))
self.assertContentEqual(bugs[:5], shared_bugs)
self.assertContentEqual(branches[:5], shared_branches)
self.assertContentEqual(specs[:5], shared_specs)
@@ -1683,8 +1686,9 @@
# getVisibleArtifacts() returns private specifications if
# user has a policy grant for the pillar of the specification.
ignore, owner, branches, bugs, specs = self._make_Artifacts()
- shared_bugs, shared_branches, shared_specs = (
- self.service.getVisibleArtifacts(owner, branches, bugs, specs))
+ shared_bugs, shared_branches, _, shared_specs = (
+ self.service.getVisibleArtifacts(
+ owner, bugs=bugs, branches=branches, specifications=specs))
self.assertContentEqual(bugs, shared_bugs)
self.assertContentEqual(branches, shared_branches)
self.assertContentEqual(specs, shared_specs)
@@ -1693,8 +1697,9 @@
# Test the getInvisibleArtifacts method.
grantee, ignore, branches, bugs, specs = self._make_Artifacts()
# Check the results.
- not_shared_bugs, not_shared_branches = (
- self.service.getInvisibleArtifacts(grantee, branches, bugs))
+ not_shared_bugs, not_shared_branches, _ = (
+ self.service.getInvisibleArtifacts(
+ grantee, bugs=bugs, branches=branches))
self.assertContentEqual(bugs[5:], not_shared_bugs)
self.assertContentEqual(branches[5:], not_shared_branches)
@@ -1718,7 +1723,7 @@
information_type=InformationType.USERDATA)
bugs.append(bug)
- shared_bugs, shared_branches, shared_specs = (
+ shared_bugs, shared_branches, _, shared_specs = (
self.service.getVisibleArtifacts(grantee, bugs=bugs))
self.assertContentEqual(bugs, shared_bugs)
@@ -1726,7 +1731,7 @@
for x in range(0, 5):
change_callback(bugs[x], owner)
# Check the results.
- shared_bugs, shared_branches, shared_specs = (
+ shared_bugs, shared_branches, _, shared_specs = (
self.service.getVisibleArtifacts(grantee, bugs=bugs))
self.assertContentEqual(bugs[5:], shared_bugs)
=== 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-16 13:40:19 +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-16 13:40:19 +0000
@@ -83,6 +83,10 @@
)
from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
from lp.code.interfaces.diff import IPreviewDiff
+from lp.code.interfaces.gitrepository import (
+ IGitRepository,
+ user_has_special_git_repository_access,
+ )
from lp.code.interfaces.sourcepackagerecipe import ISourcePackageRecipe
from lp.code.interfaces.sourcepackagerecipebuild import (
ISourcePackageRecipeBuild,
@@ -1151,14 +1155,37 @@
class EditDistributionSourcePackage(AuthorizationBase):
- """DistributionSourcePackage is not editable.
-
- But EditStructuralSubscription needs launchpad.Edit defined on all
- targets.
- """
permission = 'launchpad.Edit'
usedfor = IDistributionSourcePackage
+ def _checkUpload(self, user, archive, distroseries):
+ # 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 distroseries is None:
+ return False
+ reason = archive.verifyUpload(
+ user.person, sourcepackagename=self.obj.sourcepackagename,
+ component=None, distroseries=distroseries, strict_component=False)
+ return reason is None
+
+ 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
+
+ return self._checkUpload(
+ user, distribution.main_archive, distribution.currentseries)
+
class BugTargetOwnerOrBugSupervisorOrAdmins(AuthorizationBase):
"""Product's owner and bug supervisor can set official bug tags."""
@@ -2176,6 +2203,57 @@
return user.in_admin
+class ViewGitRepository(AuthorizationBase):
+ """Controls visibility of Git repositories.
+
+ A person can see the repository if the repository is public, they are
+ the owner of the repository, they are in the team that owns the
+ repository, they have an access grant to the repository, or they are a
+ Launchpad administrator.
+ """
+ permission = 'launchpad.View'
+ usedfor = IGitRepository
+
+ def checkAuthenticated(self, user):
+ return self.obj.visibleByUser(user.person)
+
+ def checkUnauthenticated(self):
+ return self.obj.visibleByUser(None)
+
+
+class EditGitRepository(AuthorizationBase):
+ """The owner or admins can edit Git repositories."""
+ permission = 'launchpad.Edit'
+ usedfor = IGitRepository
+
+ def checkAuthenticated(self, user):
+ # XXX cjwatson 2015-01-23: People who can upload source packages to
+ # a distribution should be able to push to the corresponding
+ # "official" repositories, once those are defined.
+ return (
+ user.inTeam(self.obj.owner) or
+ user_has_special_git_repository_access(user.person))
+
+
+class ModerateGitRepository(EditGitRepository):
+ """The owners, project owners, and admins can moderate Git repositories."""
+ permission = 'launchpad.Moderate'
+
+ def checkAuthenticated(self, user):
+ if super(ModerateGitRepository, self).checkAuthenticated(user):
+ return True
+ target = self.obj.target
+ if (target is not None and IProduct.providedBy(target) and
+ user.inTeam(target.owner)):
+ return True
+ return user.in_commercial_admin
+
+
+class AdminGitRepository(AdminByAdminsTeam):
+ """The admins can administer Git repositories."""
+ usedfor = IGitRepository
+
+
class AdminDistroSeriesTranslations(AuthorizationBase):
permission = 'launchpad.TranslationsAdmin'
usedfor = IDistroSeries
@@ -2858,8 +2936,7 @@
usedfor = IPublisherConfig
-class EditSourcePackage(AuthorizationBase):
- permission = 'launchpad.Edit'
+class EditSourcePackage(EditDistributionSourcePackage):
usedfor = ISourcePackage
def checkAuthenticated(self, user):
@@ -2871,15 +2948,8 @@
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.
- reason = distribution.main_archive.verifyUpload(
- user.person, distroseries=self.obj.distroseries,
- sourcepackagename=self.obj.sourcepackagename,
- component=None, strict_component=False)
- return reason is None
+ return self._checkUpload(
+ user, distribution.main_archive, self.obj.distroseries)
class ViewLiveFS(DelegatedAuthorization):
=== 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-16 13:40:19 +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: none
+
+# 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: none
+
+# 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: none
+
[codeimport]
# Where the Bazaar imports are stored.