← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Review: Approve code



Diff comments:

> === modified file 'lib/lp/code/configure.zcml'
> --- lib/lp/code/configure.zcml	2015-03-06 16:31:30 +0000
> +++ lib/lp/code/configure.zcml	2015-03-13 13:41:37 +0000
> @@ -859,6 +859,14 @@
>      <allow interface="lp.code.interfaces.gitnamespace.IGitNamespaceSet" />
>    </securedutility>
>  
> +  <!-- GitRef -->
> +
> +  <class class="lp.code.model.gitref.GitRef">
> +    <require
> +        permission="launchpad.View"
> +        interface="lp.code.interfaces.gitref.IGitRef" />
> +  </class>
> +
>    <!-- GitCollection -->
>  
>    <class class="lp.code.model.gitcollection.GenericGitCollection">
> @@ -933,6 +941,20 @@
>    <adapter factory="lp.code.model.gitlookup.DistributionGitTraversable" />
>    <adapter factory="lp.code.model.gitlookup.DistributionSourcePackageGitTraversable" />
>  
> +  <!-- Git-related jobs -->
> +  <class class="lp.code.model.gitjob.GitJob">
> +    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
> +  </class>
> +  <securedutility
> +      component="lp.code.model.gitjob.GitRefScanJob"
> +      provides="lp.code.interfaces.gitjob.IGitRefScanJobSource">
> +    <allow interface="lp.code.interfaces.gitjob.IGitRefScanJobSource" />
> +  </securedutility>
> +  <class class="lp.code.model.gitjob.GitRefScanJob">
> +    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
> +    <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
> +  </class>
> +
>    <lp:help-folder folder="help" name="+help-code" />
>  
>    <!-- Diffs -->
> 
> === modified file 'lib/lp/code/enums.py'
> --- lib/lp/code/enums.py	2014-02-24 07:19:52 +0000
> +++ lib/lp/code/enums.py	2015-03-13 13:41:37 +0000
> @@ -1,4 +1,4 @@
> -# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
> +# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
>  # GNU Affero General Public License version 3 (see the file LICENSE).
>  
>  """Enumerations used in the lp/code modules."""
> @@ -20,6 +20,7 @@
>      'CodeImportReviewStatus',
>      'CodeReviewNotificationLevel',
>      'CodeReviewVote',
> +    'GitObjectType',
>      'NON_CVS_RCS_TYPES',
>      'RevisionControlSystems',
>      'UICreatableBranchType',
> @@ -115,6 +116,37 @@
>      use_template(BranchType, exclude='IMPORTED')
>  
>  
> +class GitObjectType(DBEnumeratedType):
> +    """Git Object Type
> +
> +    Keep these in sync with the concrete GIT_OBJ_* enum values in libgit2.
> +    """
> +
> +    COMMIT = DBItem(1, """
> +        Commit
> +
> +        A commit object.
> +        """)
> +
> +    TREE = DBItem(2, """
> +        Tree
> +
> +        A tree (directory listing) object.
> +        """)
> +
> +    BLOB = DBItem(3, """
> +        Blob
> +
> +        A file revision object.
> +        """)
> +
> +    TAG = DBItem(4, """
> +        Tag
> +
> +        An annotated tag object.
> +        """)
> +
> +
>  class BranchLifecycleStatusFilter(EnumeratedType):
>      """Branch Lifecycle Status Filter
>  
> 
> === modified file 'lib/lp/code/errors.py'
> --- lib/lp/code/errors.py	2015-03-04 18:27:40 +0000
> +++ lib/lp/code/errors.py	2015-03-13 13:41:37 +0000
> @@ -36,6 +36,7 @@
>      'GitRepositoryCreatorNotMemberOfOwnerTeam',
>      'GitRepositoryCreatorNotOwner',
>      'GitRepositoryExists',
> +    'GitRepositoryRefScanFault',
>      'GitTargetError',
>      'InvalidBranchMergeProposal',
>      'InvalidMergeQueueConfig',
> @@ -381,6 +382,10 @@
>      """Raised when there is a hosting fault creating a Git repository."""
>  
>  
> +class GitRepositoryRefScanFault(Exception):
> +    """Raised when there is a fault getting the refs for a repository."""
> +
> +
>  class GitTargetError(Exception):
>      """Raised when there is an error determining a Git repository target."""
>  
> 
> === modified file 'lib/lp/code/githosting.py'
> --- lib/lp/code/githosting.py	2015-03-03 14:51:10 +0000
> +++ lib/lp/code/githosting.py	2015-03-13 13:41:37 +0000
> @@ -9,11 +9,14 @@
>      ]
>  
>  import json
> -from urlparse import urljoin
>  
> +from bzrlib import urlutils
>  import requests
>  
> -from lp.code.errors import GitRepositoryCreationFault
> +from lp.code.errors import (
> +    GitRepositoryCreationFault,
> +    GitRepositoryRefScanFault,
> +    )
>  
>  
>  class GitHostingClient:
> @@ -40,7 +43,7 @@
>              # should just use post(json=) and drop the explicit Content-Type
>              # header.
>              response = self._makeSession().post(
> -                urljoin(self.endpoint, "repo"),
> +                urlutils.join(self.endpoint, "repo"),
>                  headers={"Content-Type": "application/json"},
>                  data=json.dumps({"repo_path": path, "bare_repo": True}),
>                  timeout=self.timeout)
> @@ -50,3 +53,20 @@
>          if response.status_code != 200:
>              raise GitRepositoryCreationFault(
>                  "Failed to create Git repository: %s" % response.text)
> +
> +    def get_refs(self, path):
> +        try:
> +            response = self._makeSession().get(
> +                urlutils.join(self.endpoint, "repo", path, "refs"),
> +                timeout=self.timeout)
> +        except Exception as e:
> +            raise GitRepositoryRefScanFault(
> +                "Failed to get refs from Git repository: %s" % unicode(e))
> +        if response.status_code != 200:
> +            raise GitRepositoryRefScanFault(
> +                "Failed to get refs from Git repository: %s" % response.text)
> +        try:
> +            return response.json()
> +        except ValueError as e:
> +            raise GitRepositoryRefScanFault(
> +                "Failed to decode ref-scan response: %s" % unicode(e))
> 
> === modified file 'lib/lp/code/interfaces/gitapi.py'
> --- lib/lp/code/interfaces/gitapi.py	2015-03-04 11:12:06 +0000
> +++ lib/lp/code/interfaces/gitapi.py	2015-03-13 13:41:37 +0000
> @@ -49,3 +49,11 @@
>                  "writable", whose value is True if the requester can push to
>                  this repository, otherwise False.
>          """
> +
> +    def notify(translated_path):
> +        """Notify of a change to the repository at 'translated_path'.
> +
> +        :param translated_path: The translated path to the repository.  (We
> +            use translated paths here in order to avoid problems with
> +            repository names etc. being changed during a push.)
> +        """
> 
> === added file 'lib/lp/code/interfaces/gitjob.py'
> --- lib/lp/code/interfaces/gitjob.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/interfaces/gitjob.py	2015-03-13 13:41:37 +0000
> @@ -0,0 +1,53 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""GitJob interfaces."""
> +
> +__metaclass__ = type
> +
> +__all__ = [
> +    'IGitJob',
> +    'IGitRefScanJob',
> +    'IGitRefScanJobSource',
> +    ]
> +
> +from lazr.restful.fields import Reference
> +from zope.interface import (
> +    Attribute,
> +    Interface,
> +    )
> +
> +from lp import _
> +from lp.code.interfaces.gitrepository import IGitRepository
> +from lp.services.job.interfaces.job import (
> +    IJob,
> +    IJobSource,
> +    IRunnableJob,
> +    )
> +
> +
> +class IGitJob(Interface):
> +    """A job related to a Git repository."""
> +
> +    job = Reference(
> +        title=_("The common Job attributes."), schema=IJob,
> +        required=True, readonly=True)
> +
> +    repository = Reference(
> +        title=_("The Git repository to use for this job."),
> +        schema=IGitRepository, required=True, readonly=True)
> +
> +    metadata = Attribute(_("A dict of data about the job."))
> +
> +
> +class IGitRefScanJob(IRunnableJob):
> +    """A Job that scans a Git repository for its current list of references."""
> +
> +
> +class IGitRefScanJobSource(IJobSource):
> +
> +    def create(repository):
> +        """Scan a repository for refs.
> +
> +        :param repository: The database repository to scan.
> +        """
> 
> === modified file 'lib/lp/code/interfaces/gitlookup.py'
> --- lib/lp/code/interfaces/gitlookup.py	2015-03-05 11:39:06 +0000
> +++ lib/lp/code/interfaces/gitlookup.py	2015-03-13 13:41:37 +0000
> @@ -90,6 +90,12 @@
>          Return the default value if there is no such repository.
>          """
>  
> +    def getByHostingPath(path):
> +        """Get information about a given path on the hosting backend.
> +
> +        :return: An `IGitRepository`, or None.
> +        """
> +
>      def getByUniqueName(unique_name):
>          """Find a repository by its unique name.
>  
> 
> === added file 'lib/lp/code/interfaces/gitref.py'
> --- lib/lp/code/interfaces/gitref.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/interfaces/gitref.py	2015-03-13 13:41:37 +0000
> @@ -0,0 +1,43 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Git reference ("ref") interfaces."""
> +
> +__metaclass__ = type
> +
> +__all__ = [
> +    'IGitRef',
> +    ]
> +
> +from zope.interface import (
> +    Attribute,
> +    Interface,
> +    )
> +from zope.schema import (
> +    Choice,
> +    TextLine,
> +    )
> +
> +from lp import _
> +from lp.code.enums import GitObjectType
> +
> +
> +class IGitRef(Interface):
> +    """A reference in a Git repository."""
> +
> +    repository = Attribute("The Git repository containing this reference.")
> +
> +    path = TextLine(
> +        title=_("Path"), required=True, readonly=True,
> +        description=_(
> +            "The full path of this reference, e.g. refs/heads/master."))
> +
> +    commit_sha1 = TextLine(
> +        title=_("Commit SHA-1"), required=True, readonly=True,
> +        description=_(
> +            "The full SHA-1 object name of the commit object referenced by "
> +            "this reference."))
> +
> +    object_type = Choice(
> +        title=_("Object type"), required=True, readonly=True,
> +        vocabulary=GitObjectType)
> 
> === modified file 'lib/lp/code/interfaces/gitrepository.py'
> --- lib/lp/code/interfaces/gitrepository.py	2015-03-06 16:31:30 +0000
> +++ lib/lp/code/interfaces/gitrepository.py	2015-03-13 13:41:37 +0000
> @@ -186,6 +186,50 @@
>              "'lp:' plus a shortcut version of the path via that target.  "
>              "Otherwise it is simply 'lp:' plus the unique name.")))
>  
> +    refs = Attribute("The references present in this repository.")
> +
> +    def getRefByPath(path):
> +        """Look up a single reference in this repository by path.
> +
> +        :param path: A string to look up as a path.
> +
> +        :return: An `IGitRef`, or None.
> +        """
> +
> +    def convertRefInfo(info):
> +        """Validate and canonicalise ref info from the hosting service.
> +
> +        :param info: A dict of {"object":
> +            {"sha1": sha1, "type": "commit"/"tree"/"blob"/"tag"}}.
> +
> +        :raises ValueError: if the dict is malformed.
> +        :return: A dict of {"sha1": sha1, "type": `GitObjectType`}.
> +        """
> +
> +    def createRefs(refs_info, get_objects=False):
> +        """Create a set of references in this repository.
> +
> +        :param refs_info: A dict mapping ref paths to
> +            {"sha1": sha1, "type": `GitObjectType`}.
> +        :param get_objects: Return the created references.
> +
> +        :return: A list of the created references if get_objects, otherwise
> +            None.
> +        """
> +
> +    def removeRefs(paths):
> +        """Remove a set of references in this repository.
> +
> +        :params paths: An iterable of paths.
> +        """
> +
> +    def updateRef(ref, info):
> +        """Update a single reference in this repository.
> +
> +        :param ref: An `IGitRef`.
> +        :param info: {"sha1": sha1, "type": `GitObjectType`}
> +        """

There's no real reason this can't be a bulk method, and it could even be merged into createRefs.

> +
>      def setOwnerDefault(value):
>          """Set whether this repository is the default for its owner-target.
>  
> 
> === added file 'lib/lp/code/model/gitjob.py'
> --- lib/lp/code/model/gitjob.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/model/gitjob.py	2015-03-13 13:41:37 +0000
> @@ -0,0 +1,200 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +__metaclass__ = type
> +
> +__all__ = [
> +    'GitJob',
> +    'GitRefScanJob',
> +    ]
> +
> +from lazr.delegates import delegates
> +from lazr.enum import (
> +    DBEnumeratedType,
> +    DBItem,
> +    )
> +from storm.exceptions import LostObjectError
> +from storm.locals import (
> +    Int,
> +    JSON,
> +    Reference,
> +    )
> +from zope.interface import (
> +    classProvides,
> +    implements,
> +    )
> +
> +from lp.app.errors import NotFoundError
> +from lp.code.githosting import GitHostingClient
> +from lp.code.interfaces.gitjob import (
> +    IGitJob,
> +    IGitRefScanJob,
> +    IGitRefScanJobSource,
> +    )
> +from lp.services.config import config
> +from lp.services.database.enumcol import EnumCol
> +from lp.services.database.interfaces import (
> +    IMasterStore,
> +    IStore,
> +    )
> +from lp.services.database.stormbase import StormBase
> +from lp.services.job.model.job import (
> +    EnumeratedSubclass,
> +    Job,
> +    )
> +from lp.services.job.runner import BaseRunnableJob
> +from lp.services.mail.sendmail import format_address_for_person
> +from lp.services.scripts import log
> +
> +
> +class GitJobType(DBEnumeratedType):
> +    """Values that `IGitJob.job_type` can take."""
> +
> +    REF_SCAN = DBItem(0, """
> +        Ref scan
> +
> +        This job scans a repository for its current list of references.
> +        """)
> +
> +
> +class GitJob(StormBase):
> +    """See `IGitJob`."""
> +
> +    __storm_table__ = 'GitJob'
> +
> +    implements(IGitJob)
> +
> +    job_id = Int(name='job', primary=True, allow_none=False)
> +    job = Reference(job_id, 'Job.id')
> +
> +    repository_id = Int(name='repository', allow_none=False)
> +    repository = Reference(repository_id, 'GitRepository.id')
> +
> +    job_type = EnumCol(enum=GitJobType, notNull=True)
> +
> +    metadata = JSON('json_data')
> +
> +    def __init__(self, repository, job_type, metadata, **job_args):
> +        """Constructor.
> +
> +        Extra keyword arguments are used to construct the underlying Job
> +        object.
> +
> +        :param repository: The database repository this job relates to.
> +        :param job_type: The `GitJobType` of this job.
> +        :param metadata: The type-specific variables, as a JSON-compatible
> +            dict.
> +        """
> +        super(GitJob, self).__init__()
> +        self.job = Job(**job_args)
> +        self.repository = repository
> +        self.job_type = job_type
> +        self.metadata = metadata
> +
> +    def makeDerived(self):
> +        return GitJobDerived.makeSubclass(self)
> +
> +
> +class GitJobDerived(BaseRunnableJob):
> +
> +    __metaclass__ = EnumeratedSubclass
> +
> +    delegates(IGitJob)
> +
> +    def __init__(self, git_job):
> +        self.context = git_job
> +
> +    @classmethod
> +    def get(cls, job_id):
> +        """Get a job by id.
> +
> +        :return: The `GitJob` with the specified id, as the current
> +            `GitJobDerived` subclass.
> +        :raises: `NotFoundError` if there is no job with the specified id,
> +            or its `job_type` does not match the desired subclass.
> +        """
> +        git_job = IStore(GitJob).get(GitJob, job_id)
> +        if git_job.job_type != cls.class_job_type:
> +            raise NotFoundError(
> +                "No object found with id %d and type %s" %
> +                (job_id, cls.class_job_type.title))
> +        return cls(git_job)
> +
> +    @classmethod
> +    def iterReady(cls):
> +        """See `IJobSource`."""
> +        jobs = IMasterStore(GitJob).find(
> +            GitJob,
> +            GitJob.job_type == cls.class_job_type,
> +            GitJob.job == Job.id,
> +            Job.id.is_in(Job.ready_jobs))
> +        return (cls(job) for job in jobs)
> +
> +    def getOopsVars(self):
> +        """See `IRunnableJob`."""
> +        oops_vars = super(GitJobDerived, self).getOopsVars()
> +        oops_vars.extend([
> +            ('git_job_id', self.context.job.id),
> +            ('git_job_type', self.context.job_type.title),
> +            ('git_repository_name', self.context.repository.unique_name)])

I'd also include the repository ID, as the name is mutable.

> +        return oops_vars
> +
> +    def getErrorRecipients(self):
> +        if self.requester is None:
> +            return []
> +        return [format_address_for_person(self.requester)]
> +
> +
> +class GitRefScanJob(GitJobDerived):
> +    """A Job that scans a Git repository for its current list of references."""
> +
> +    implements(IGitRefScanJob)
> +
> +    classProvides(IGitRefScanJobSource)
> +    class_job_type = GitJobType.REF_SCAN
> +
> +    config = config.IGitRefScanJobSource
> +
> +    @classmethod
> +    def create(cls, repository):
> +        """See `IGitRefScanJobSource`."""
> +        git_job = GitJob(
> +            repository, cls.class_job_type,
> +            {"repository_name": repository.unique_name})
> +        job = cls(git_job)
> +        job.celeryRunOnCommit()
> +        return job
> +
> +    def __init__(self, git_job):
> +        super(GitRefScanJob, self).__init__(git_job)
> +        self._cached_repository_name = self.metadata["repository_name"]
> +        self._hosting_client = GitHostingClient(
> +            config.codehosting.internal_git_api_endpoint)
> +
> +    def run(self):
> +        """See `IGitRefScanJob`."""
> +        try:
> +            hosting_path = self.repository.getInternalPath()
> +        except LostObjectError:
> +            log.warning(
> +                "Skipping repository %s because it has been deleted." %
> +                self._cached_repository_name)
> +            return
> +        new_refs = {}
> +        for path, info in self._hosting_client.get_refs(hosting_path).items():
> +            try:
> +                new_refs[path] = self.repository.convertRefInfo(info)
> +            except ValueError:
> +                pass
> +        current_refs = dict((ref.path, ref) for ref in self.repository.refs)
> +        refs_to_insert = dict(
> +            (path, info) for path, info in new_refs.items()
> +            if path not in current_refs)
> +        self.repository.createRefs(refs_to_insert)
> +        self.repository.removeRefs(set(current_refs) - set(new_refs))
> +        for path in set(new_refs) & set(current_refs):
> +            new_ref = new_refs[path]
> +            current_ref = current_refs[path]
> +            if (new_ref["sha1"] != current_ref.commit_sha1 or
> +                new_ref["type"] != current_ref.object_type):
> +                self.repository.updateRef(current_ref, new_ref)

This would be much nicer to test if there was a function somewhere that said "update this repository with these refs", or just "update this repository", rather than having to run a whole job.

> 
> === modified file 'lib/lp/code/model/gitlookup.py'
> --- lib/lp/code/model/gitlookup.py	2015-03-05 11:39:06 +0000
> +++ lib/lp/code/model/gitlookup.py	2015-03-13 13:41:37 +0000
> @@ -297,6 +297,16 @@
>              return default
>          return repository
>  
> +    def getByHostingPath(self, path):
> +        """See `IGitLookup`."""
> +        # This may need to change later to improve support for sharding.
> +        # See also `IGitRepository.getInternalPath`.
> +        try:
> +            repository_id = int(path)
> +        except ValueError:
> +            return None
> +        return self.get(repository_id)
> +
>      @staticmethod
>      def uriToPath(uri):
>          """See `IGitLookup`."""
> 
> === added file 'lib/lp/code/model/gitref.py'
> --- lib/lp/code/model/gitref.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/model/gitref.py	2015-03-13 13:41:37 +0000
> @@ -0,0 +1,37 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +__metaclass__ = type
> +__all__ = [
> +    'GitRef',
> +    ]
> +
> +from storm.locals import (
> +    Int,
> +    Reference,
> +    Unicode,
> +    )
> +from zope.interface import implements
> +
> +from lp.code.enums import GitObjectType
> +from lp.code.interfaces.gitref import IGitRef
> +from lp.services.database.enumcol import EnumCol
> +from lp.services.database.stormbase import StormBase
> +
> +
> +class GitRef(StormBase):
> +    """See `IGitRef`."""
> +
> +    __storm_table__ = 'GitRef'
> +    __storm_primary__ = ('repository_id', 'path')
> +
> +    implements(IGitRef)
> +
> +    repository_id = Int(name='repository', allow_none=False)
> +    repository = Reference(repository_id, 'GitRepository.id')
> +
> +    path = Unicode(name='path', allow_none=False)
> +
> +    commit_sha1 = Unicode(name='commit_sha1', allow_none=False)
> +
> +    object_type = EnumCol(enum=GitObjectType, notNull=True)
> 
> === modified file 'lib/lp/code/model/gitrepository.py'
> --- lib/lp/code/model/gitrepository.py	2015-03-05 14:13:16 +0000
> +++ lib/lp/code/model/gitrepository.py	2015-03-13 13:41:37 +0000
> @@ -24,6 +24,7 @@
>      Reference,
>      Unicode,
>      )
> +from storm.store import Store
>  from zope.component import getUtility
>  from zope.interface import implements
>  from zope.security.proxy import removeSecurityProxy
> @@ -36,6 +37,7 @@
>  from lp.app.interfaces.informationtype import IInformationType
>  from lp.app.interfaces.launchpad import IPrivacy
>  from lp.app.interfaces.services import IService
> +from lp.code.enums import GitObjectType
>  from lp.code.errors import (
>      GitDefaultConflict,
>      GitFeatureDisabled,
> @@ -57,6 +59,7 @@
>      IGitRepositorySet,
>      user_has_special_git_repository_access,
>      )
> +from lp.code.model.gitref import GitRef
>  from lp.registry.enums import PersonVisibility
>  from lp.registry.errors import CannotChangeInformationType
>  from lp.registry.interfaces.accesspolicy import (
> @@ -78,6 +81,7 @@
>      )
>  from lp.registry.model.teammembership import TeamParticipation
>  from lp.services.config import config
> +from lp.services.database import bulk
>  from lp.services.database.constants import (
>      DEFAULT,
>      UTC_NOW,
> @@ -91,10 +95,21 @@
>      ArrayIntersects,
>      )
>  from lp.services.features import getFeatureFlag
> -from lp.services.propertycache import cachedproperty
> +from lp.services.propertycache import (
> +    cachedproperty,
> +    get_property_cache,
> +    )
>  from lp.services.webapp.authorization import available_with_permission
>  
>  
> +object_type_map = {
> +    "commit": GitObjectType.COMMIT,
> +    "tree": GitObjectType.TREE,
> +    "blob": GitObjectType.BLOB,
> +    "tag": GitObjectType.TAG,
> +    }
> +
> +
>  def git_repository_modified(repository, event):
>      """Update the date_last_modified property when a GitRepository is modified.
>  
> @@ -243,6 +258,7 @@
>      def getInternalPath(self):
>          """See `IGitRepository`."""
>          # This may need to change later to improve support for sharding.
> +        # See also `IGitLookup.getByHostingPath`.
>          return str(self.id)
>  
>      def getCodebrowseUrl(self):
> @@ -279,6 +295,61 @@
>              self, self.information_type, pillars, wanted_links)
>  
>      @cachedproperty
> +    def refs(self):
> +        """See `IGitRepository`."""
> +        return list(Store.of(self).find(
> +            GitRef, GitRef.repository_id == self.id).order_by(GitRef.path))
> +
> +    def getRefByPath(self, path):
> +        return Store.of(self).find(
> +            GitRef,
> +            GitRef.repository_id == self.id,
> +            GitRef.path == path).one()
> +
> +    @staticmethod
> +    def convertRefInfo(info):
> +        """See `IGitRepository`."""
> +        if "object" not in info:
> +            raise ValueError('ref info does not contain "object" key')
> +        obj = info["object"]
> +        if "sha1" not in obj:
> +            raise ValueError('ref info object does not contain "sha1" key')
> +        if "type" not in obj:
> +            raise ValueError('ref info object does not contain "type" key')
> +        if not isinstance(obj["sha1"], basestring) or len(obj["sha1"]) != 40:
> +            raise ValueError('ref info sha1 is not a 40-character string')
> +        if obj["type"] not in object_type_map:
> +            raise ValueError('ref info type is not a recognised object type')
> +        sha1 = obj["sha1"]
> +        if isinstance(sha1, bytes):

Unless you're deliberately trying to be Python 3-ish, this should be str.

> +            sha1 = sha1.decode("US-ASCII")
> +        return {"sha1": sha1, "type": object_type_map[obj["type"]]}
> +
> +    def createRefs(self, refs_info, get_objects=False):
> +        """See `IGitRepository`."""
> +        refs = bulk.create(
> +            (GitRef.repository, GitRef.path, GitRef.commit_sha1,
> +             GitRef.object_type),
> +            [(self, path, info["sha1"], info["type"])
> +             for path, info in refs_info.items()],
> +            get_objects=get_objects)
> +        del get_property_cache(self).refs
> +        return refs
> +
> +    def removeRefs(self, paths):
> +        """See `IGitRepository`."""
> +        Store.of(self).find(
> +            GitRef,
> +            GitRef.repository == self, GitRef.path.is_in(paths)).remove()
> +        del get_property_cache(self).refs
> +
> +    def updateRef(self, ref, info):
> +        """See `IGitRepository`."""
> +        naked_ref = removeSecurityProxy(ref)
> +        naked_ref.commit_sha1 = info["sha1"]
> +        naked_ref.object_type = info["type"]
> +
> +    @cachedproperty
>      def _known_viewers(self):
>          """A set of known persons able to view this repository.
>  
> 
> === added file 'lib/lp/code/model/tests/test_gitjob.py'
> --- lib/lp/code/model/tests/test_gitjob.py	1970-01-01 00:00:00 +0000
> +++ lib/lp/code/model/tests/test_gitjob.py	2015-03-13 13:41:37 +0000
> @@ -0,0 +1,113 @@
> +# Copyright 2015 Canonical Ltd.  This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Tests for `GitJob`s."""
> +
> +__metaclass__ = type
> +
> +import hashlib
> +
> +from testtools.matchers import (
> +    MatchesSetwise,
> +    MatchesStructure,
> +    )
> +
> +from lp.code.enums import GitObjectType
> +from lp.code.interfaces.gitjob import (
> +    IGitJob,
> +    IGitRefScanJob,
> +    )
> +from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
> +from lp.code.model.gitjob import (
> +    GitJob,
> +    GitJobDerived,
> +    GitJobType,
> +    GitRefScanJob,
> +    )
> +from lp.services.features.testing import FeatureFixture
> +from lp.testing import TestCaseWithFactory
> +from lp.testing.dbuser import dbuser
> +from lp.testing.fakemethod import FakeMethod
> +from lp.testing.layers import (
> +    DatabaseFunctionalLayer,
> +    LaunchpadZopelessLayer,
> +    )
> +
> +
> +class TestGitJob(TestCaseWithFactory):
> +    """Tests for `GitJob`."""
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def test_provides_interface(self):
> +        # `GitJob` objects provide `IGitJob`.
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
> +        repository = self.factory.makeGitRepository()
> +        self.assertProvides(
> +            GitJob(repository, GitJobType.REF_SCAN, {}), IGitJob)
> +
> +
> +class TestGitJobDerived(TestCaseWithFactory):
> +    """Tests for `GitJobDerived`."""
> +
> +    layer = LaunchpadZopelessLayer
> +
> +    def test_getOopsMailController(self):
> +        """By default, no mail is sent about failed BranchJobs."""
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
> +        repository = self.factory.makeGitRepository()
> +        job = GitJob(repository, GitJobType.REF_SCAN, {})
> +        derived = GitJobDerived(job)
> +        self.assertIsNone(derived.getOopsMailController("x"))
> +
> +
> +class TestGitRefScanJobMixin:
> +
> +    @staticmethod
> +    def makeFakeRefs(paths):
> +        return dict(
> +            (path, {"object": {
> +                "sha1": hashlib.sha1(path).hexdigest(),
> +                "type": "commit",
> +                }})
> +            for path in paths)
> +
> +    def assertRefsMatch(self, refs, repository, paths):
> +        matchers = [
> +            MatchesStructure.byEquality(
> +                repository=repository,
> +                path=path,
> +                commit_sha1=unicode(hashlib.sha1(path).hexdigest()),
> +                object_type=GitObjectType.COMMIT)
> +            for path in paths]
> +        self.assertThat(refs, MatchesSetwise(*matchers))
> +
> +
> +class TestGitRefScanJob(TestGitRefScanJobMixin, TestCaseWithFactory):
> +    """Tests for `GitRefScanJob`."""
> +
> +    layer = LaunchpadZopelessLayer
> +
> +    def setUp(self):
> +        super(TestGitRefScanJob, self).setUp()
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
> +
> +    def test_provides_interface(self):
> +        # `GitRefScanJob` objects provide `IGitRefScanJob`.
> +        repository = self.factory.makeGitRepository()
> +        self.assertProvides(GitRefScanJob.create(repository), IGitRefScanJob)
> +
> +    def test_run(self):
> +        # Ensure the job scans the repository.
> +        repository = self.factory.makeGitRepository()
> +        job = GitRefScanJob.create(repository)
> +        paths = (u"refs/heads/master", u"refs/tags/1.0")
> +        job._hosting_client.get_refs = FakeMethod(
> +            result=self.makeFakeRefs(paths))
> +        with dbuser("branchscanner"):
> +            job.run()
> +        self.assertRefsMatch(repository.refs, repository, paths)
> +
> +
> +# XXX cjwatson 2015-03-12: We should test that the job works via Celery too,
> +# but that isn't feasible until we have a proper turnip fixture.
> 
> === modified file 'lib/lp/code/model/tests/test_gitlookup.py'
> --- lib/lp/code/model/tests/test_gitlookup.py	2015-03-05 14:13:16 +0000
> +++ lib/lp/code/model/tests/test_gitlookup.py	2015-03-13 13:41:37 +0000
> @@ -35,6 +35,26 @@
>  from lp.testing.layers import DatabaseFunctionalLayer
>  
>  
> +class TestGetByHostingPath(TestCaseWithFactory):
> +    """Test `IGitLookup.getByHostingPath`."""
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestGetByHostingPath, self).setUp()
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
> +        self.lookup = getUtility(IGitLookup)
> +
> +    def test_exists(self):
> +        repository = self.factory.makeGitRepository()
> +        self.assertEqual(
> +            repository,
> +            self.lookup.getByHostingPath(repository.getInternalPath()))
> +
> +    def test_missing(self):
> +        self.assertIsNone(self.lookup.getByHostingPath("nonexistent"))
> +
> +
>  class TestGetByUniqueName(TestCaseWithFactory):
>      """Tests for `IGitLookup.getByUniqueName`."""
>  
> 
> === modified file 'lib/lp/code/model/tests/test_gitrepository.py'
> --- lib/lp/code/model/tests/test_gitrepository.py	2015-03-06 16:31:30 +0000
> +++ lib/lp/code/model/tests/test_gitrepository.py	2015-03-13 13:41:37 +0000
> @@ -7,10 +7,15 @@
>  
>  from datetime import datetime
>  from functools import partial
> +import hashlib
>  import json
>  
>  from lazr.lifecycle.event import ObjectModifiedEvent
>  import pytz
> +from testtools.matchers import (
> +    MatchesSetwise,
> +    MatchesStructure,
> +    )
>  from zope.component import getUtility
>  from zope.event import notify
>  from zope.security.proxy import removeSecurityProxy
> @@ -21,6 +26,7 @@
>      PUBLIC_INFORMATION_TYPES,
>      )
>  from lp.app.interfaces.launchpad import ILaunchpadCelebrities
> +from lp.code.enums import GitObjectType
>  from lp.code.errors import (
>      GitFeatureDisabled,
>      GitRepositoryCreatorNotMemberOfOwnerTeam,
> @@ -37,6 +43,7 @@
>      IGitRepository,
>      IGitRepositorySet,
>      )
> +from lp.code.model.gitrepository import GitRepository
>  from lp.registry.enums import (
>      BranchSharingPolicy,
>      PersonVisibility,
> @@ -410,6 +417,107 @@
>              get_policies_for_artifact(repository))
>  
>  
> +class TestGitRepositoryRefs(TestCaseWithFactory):
> +    """Tests for ref handling."""
> +
> +    layer = DatabaseFunctionalLayer
> +
> +    def setUp(self):
> +        super(TestGitRepositoryRefs, self).setUp()
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
> +
> +    def test_convertRefInfo(self):
> +        # convertRefInfo converts a valid info dictionary.
> +        sha1 = unicode(hashlib.sha1("").hexdigest())
> +        info = {"object": {"sha1": sha1, "type": u"commit"}}
> +        expected_info = {"sha1": sha1, "type": GitObjectType.COMMIT}
> +        self.assertEqual(expected_info, GitRepository.convertRefInfo(info))
> +
> +    def test_convertRefInfo_requires_object(self):
> +        self.assertRaisesWithContent(
> +            ValueError, 'ref info does not contain "object" key',
> +            GitRepository.convertRefInfo, {})
> +
> +    def test_convertRefInfo_requires_object_sha1(self):
> +        self.assertRaisesWithContent(
> +            ValueError, 'ref info object does not contain "sha1" key',
> +            GitRepository.convertRefInfo, {"object": {}})
> +
> +    def test_convertRefInfo_requires_object_type(self):
> +        info = {
> +            "object": {"sha1": u"0000000000000000000000000000000000000000"},
> +            }
> +        self.assertRaisesWithContent(
> +            ValueError, 'ref info object does not contain "type" key',
> +            GitRepository.convertRefInfo, info)
> +
> +    def test_convertRefInfo_bad_sha1(self):
> +        info = {"object": {"sha1": "x", "type": "commit"}}
> +        self.assertRaisesWithContent(
> +            ValueError, 'ref info sha1 is not a 40-character string',
> +            GitRepository.convertRefInfo, info)
> +
> +    def test_convertRefInfo_bad_type(self):
> +        info = {
> +            "object": {
> +                "sha1": u"0000000000000000000000000000000000000000",
> +                "type": u"nonsense",
> +                },
> +            }
> +        self.assertRaisesWithContent(
> +            ValueError, 'ref info type is not a recognised object type',
> +            GitRepository.convertRefInfo, info)
> +
> +    def assertRefsMatch(self, refs, repository, paths):
> +        matchers = [
> +            MatchesStructure.byEquality(
> +                repository=repository,
> +                path=path,
> +                commit_sha1=unicode(hashlib.sha1(path).hexdigest()),
> +                object_type=GitObjectType.COMMIT)
> +            for path in paths]
> +        self.assertThat(refs, MatchesSetwise(*matchers))
> +
> +    def test_createRefs(self):
> +        repository = self.factory.makeGitRepository()
> +        self.assertEqual([], repository.refs)
> +        paths = (u"refs/heads/master", u"refs/tags/1.0")
> +        self.factory.makeGitRefs(repository=repository, paths=paths)
> +        self.assertRefsMatch(repository.refs, repository, paths)
> +
> +    def test_removeRefs(self):
> +        repository = self.factory.makeGitRepository()
> +        paths = (u"refs/heads/master", u"refs/heads/branch", u"refs/tags/1.0")
> +        self.factory.makeGitRefs(repository=repository, paths=paths)
> +        self.assertRefsMatch(repository.refs, repository, paths)
> +        repository.removeRefs([u"refs/heads/branch", u"refs/tags/1.0"])
> +        self.assertRefsMatch(
> +            repository.refs, repository, [u"refs/heads/master"])
> +
> +    def test_updateRef(self):
> +        repository = self.factory.makeGitRepository()
> +        paths = (u"refs/heads/master", u"refs/tags/1.0")
> +        self.factory.makeGitRefs(repository=repository, paths=paths)
> +        self.assertRefsMatch(repository.refs, repository, paths)
> +        tag_ref = repository.getRefByPath(u"refs/tags/1.0")
> +        self.assertIsNotNone(tag_ref)
> +        new_info = {
> +            "sha1": u"0000000000000000000000000000000000000000",
> +            "type": GitObjectType.BLOB,
> +            }
> +        repository.updateRef(tag_ref, new_info)
> +        self.assertRefsMatch(
> +            [ref for ref in repository.refs if ref.path != u"refs/tags/1.0"],
> +            repository, [u"refs/heads/master"])
> +        self.assertThat(
> +            tag_ref, MatchesStructure.byEquality(
> +                repository=repository,
> +                path=u"refs/tags/1.0",
> +                commit_sha1=u"0000000000000000000000000000000000000000",
> +                object_type=GitObjectType.BLOB,
> +                ))
> +
> +
>  class TestGitRepositoryGetAllowedInformationTypes(TestCaseWithFactory):
>      """Test `IGitRepository.getAllowedInformationTypes`."""
>  
> 
> === modified file 'lib/lp/code/xmlrpc/git.py'
> --- lib/lp/code/xmlrpc/git.py	2015-03-03 17:09:33 +0000
> +++ lib/lp/code/xmlrpc/git.py	2015-03-13 13:41:37 +0000
> @@ -38,6 +38,7 @@
>      split_git_unique_name,
>      )
>  from lp.code.interfaces.gitrepository import IGitRepositorySet
> +from lp.code.interfaces.gitjob import IGitRefScanJobSource
>  from lp.code.xmlrpc.codehosting import run_with_login
>  from lp.registry.errors import (
>      InvalidName,
> @@ -232,3 +233,9 @@
>          return run_with_login(
>              requester_id, self._translatePath,
>              path.strip("/"), permission, can_authenticate)
> +
> +    def notify(self, translated_path):
> +        """See `IGitAPI`."""
> +        repository = getUtility(IGitLookup).getByHostingPath(translated_path)
> +        job = getUtility(IGitRefScanJobSource).create(repository)
> +        job.celeryRunOnCommit()

getByHostingPath can plausibly return None, and the None will propagate down unnecessarily far. Probably best to fail early.

> 
> === modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
> --- lib/lp/code/xmlrpc/tests/test_git.py	2015-03-04 18:27:40 +0000
> +++ lib/lp/code/xmlrpc/tests/test_git.py	2015-03-13 13:41:37 +0000
> @@ -15,6 +15,7 @@
>      LAUNCHPAD_SERVICES,
>      )
>  from lp.code.interfaces.gitcollection import IAllGitRepositories
> +from lp.code.interfaces.gitjob import IGitRefScanJobSource
>  from lp.code.interfaces.gitrepository import (
>      GIT_FEATURE_FLAG,
>      GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
> @@ -621,6 +622,14 @@
>              "GitRepositoryCreationFault: nothing here",
>              self.oopses[0]["tb_text"])
>  
> +    def test_notify(self):
> +        # The notify call creates a GitRefScanJob.
> +        repository = self.factory.makeGitRepository()
> +        self.assertIsNone(self.git_api.notify(repository.getInternalPath()))
> +        job_source = getUtility(IGitRefScanJobSource)
> +        [job] = list(job_source.iterReady())
> +        self.assertEqual(repository, job.repository)
> +
>  
>  class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
>      """Slow tests for `IGitAPI`.
> 
> === modified file 'lib/lp/scripts/garbo.py'
> --- lib/lp/scripts/garbo.py	2014-11-06 02:22:57 +0000
> +++ lib/lp/scripts/garbo.py	2015-03-13 13:41:37 +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).
>  
>  """Database garbage collection."""
> @@ -1092,6 +1092,23 @@
>          """
>  
>  
> +class GitJobPruner(BulkPruner):
> +    """Prune `GitJob`s that are in a final state and more than a month old.
> +
> +    When a GitJob is completed, it gets set to a final state.  These jobs
> +    should be pruned from the database after a month.
> +    """
> +    target_table_class = Job
> +    ids_to_prune_query = """
> +        SELECT DISTINCT Job.id
> +        FROM Job, GitJob
> +        WHERE
> +            Job.id = GitJob.job
> +            AND Job.date_finished < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
> +                - CAST('30 days' AS interval)
> +        """
> +
> +
>  class BugHeatUpdater(TunableLoop):
>      """A `TunableLoop` for bug heat calculations."""
>  
> @@ -1645,6 +1662,7 @@
>          BugWatchActivityPruner,
>          CodeImportEventPruner,
>          CodeImportResultPruner,
> +        GitJobPruner,
>          HWSubmissionEmailLinker,
>          LiveFSFilePruner,
>          LoginTokenPruner,
> 
> === modified file 'lib/lp/scripts/tests/test_garbo.py'
> --- lib/lp/scripts/tests/test_garbo.py	2015-01-07 00:35:41 +0000
> +++ lib/lp/scripts/tests/test_garbo.py	2015-03-13 13:41:37 +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).
>  
>  """Test the database garbage collector."""
> @@ -49,6 +49,7 @@
>      )
>  from lp.code.enums import CodeImportResultStatus
>  from lp.code.interfaces.codeimportevent import ICodeImportEventSet
> +from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
>  from lp.code.model.branchjob import (
>      BranchJob,
>      BranchUpgradeJob,
> @@ -56,6 +57,10 @@
>  from lp.code.model.codeimportevent import CodeImportEvent
>  from lp.code.model.codeimportresult import CodeImportResult
>  from lp.code.model.diff import Diff
> +from lp.code.model.gitjob import (
> +    GitJob,
> +    GitRefScanJob,
> +    )
>  from lp.registry.enums import (
>      BranchSharingPolicy,
>      BugSharingPolicy,
> @@ -930,6 +935,48 @@
>          switch_dbuser('testadmin')
>          self.assertEqual(store.find(BranchJob).count(), 1)
>  
> +    def test_GitJobPruner(self):
> +        # Garbo should remove jobs completed over 30 days ago.
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u'on'}))
> +        switch_dbuser('testadmin')
> +        store = IMasterStore(Job)
> +
> +        db_repository = self.factory.makeGitRepository()
> +        Store.of(db_repository).flush()
> +        git_job = GitRefScanJob.create(db_repository)
> +        git_job.job.date_finished = THIRTY_DAYS_AGO
> +
> +        self.assertEqual(
> +            1,
> +            store.find(GitJob, GitJob.repository == db_repository.id).count())
> +
> +        self.runDaily()
> +
> +        switch_dbuser('testadmin')
> +        self.assertEqual(
> +            0,
> +            store.find(GitJob, GitJob.repository == db_repository.id).count())
> +
> +    def test_GitJobPruner_doesnt_prune_recent_jobs(self):
> +        # Check to make sure the garbo doesn't remove jobs that aren't more
> +        # than thirty days old.
> +        self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u'on'}))
> +        switch_dbuser('testadmin')
> +        store = IMasterStore(Job)
> +
> +        db_repository = self.factory.makeGitRepository()
> +
> +        git_job = GitRefScanJob.create(db_repository)
> +        git_job.job.date_finished = THIRTY_DAYS_AGO
> +
> +        db_repository2 = self.factory.makeGitRepository()
> +        GitRefScanJob.create(db_repository2)
> +
> +        self.runDaily()
> +
> +        switch_dbuser('testadmin')
> +        self.assertEqual(1, store.find(GitJob).count())
> +
>      def test_ObsoleteBugAttachmentPruner(self):
>          # Bug attachments without a LibraryFileContent record are removed.
>  
> 
> === modified file 'lib/lp/security.py'
> --- lib/lp/security.py	2015-03-06 10:22:08 +0000
> +++ lib/lp/security.py	2015-03-13 13:41:37 +0000
> @@ -84,6 +84,7 @@
>  from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
>  from lp.code.interfaces.diff import IPreviewDiff
>  from lp.code.interfaces.gitcollection import IGitCollection
> +from lp.code.interfaces.gitref import IGitRef
>  from lp.code.interfaces.gitrepository import (
>      IGitRepository,
>      user_has_special_git_repository_access,
> @@ -2260,6 +2261,15 @@
>      usedfor = IGitRepository
>  
>  
> +class ViewGitRef(DelegatedAuthorization):
> +    """Anyone who can see a Git repository can see references within it."""
> +    permission = 'launchpad.View'
> +    usedfor = IGitRef
> +
> +    def __init__(self, obj):
> +        super(ViewGitRef, self).__init__(obj, obj.repository)
> +
> +
>  class AdminDistroSeriesTranslations(AuthorizationBase):
>      permission = 'launchpad.TranslationsAdmin'
>      usedfor = IDistroSeries
> 
> === modified file 'lib/lp/services/config/schema-lazr.conf'
> --- lib/lp/services/config/schema-lazr.conf	2015-02-19 17:33:35 +0000
> +++ lib/lp/services/config/schema-lazr.conf	2015-03-13 13:41:37 +0000
> @@ -1750,6 +1750,10 @@
>  module: lp.soyuz.interfaces.distributionjob
>  dbuser: distroseriesdifferencejob
>  
> +[IGitRefScanJobSource]
> +module: lp.code.interfaces.gitjob
> +dbuser: branchscanner
> +
>  [IInitializeDistroSeriesJobSource]
>  module: lp.soyuz.interfaces.distributionjob
>  dbuser: initializedistroseries
> 
> === modified file 'lib/lp/testing/factory.py'
> --- lib/lp/testing/factory.py	2015-02-20 00:56:57 +0000
> +++ lib/lp/testing/factory.py	2015-03-13 13:41:37 +0000
> @@ -108,6 +108,7 @@
>      CodeImportResultStatus,
>      CodeImportReviewStatus,
>      CodeReviewNotificationLevel,
> +    GitObjectType,
>      RevisionControlSystems,
>      )
>  from lp.code.errors import UnknownBranchTypeError
> @@ -1700,6 +1701,20 @@
>                  information_type, registrant, verify_policy=False)
>          return repository
>  
> +    def makeGitRefs(self, repository=None, paths=None):
> +        """Create and return a list of new, arbitrary GitRefs."""
> +        if repository is None:
> +            repository = self.makeGitRepository()
> +        if paths is None:
> +            paths = [self.getUniqueString('refs/heads/path').decode('utf-8')]
> +        refs_info = dict(
> +            (path, {
> +                "sha1": unicode(hashlib.sha1(path).hexdigest()),
> +                "type": GitObjectType.COMMIT,
> +                })
> +            for path in paths)
> +        return repository.createRefs(refs_info, get_objects=True)
> +
>      def makeBug(self, target=None, owner=None, bug_watch_url=None,
>                  information_type=None, date_closed=None, title=None,
>                  date_created=None, description=None, comment=None,
> 


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


References