← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:git-fork-backend into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:git-fork-backend into launchpad:master.

Commit message:
Fork method on GitRepository to allow users to asynchronously create a copy of a repository

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/387146
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:git-fork-backend into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 93dc9f8..6ee31c0 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -739,6 +739,29 @@ public.webhookjob                         = SELECT, INSERT
 public.xref                               = SELECT, INSERT, DELETE
 type=user
 
+[gitrepo-creator]
+groups=script
+public.branchmergeproposal                      = SELECT, DELETE
+public.codeimport                               = SELECT, DELETE
+public.gitactivity                              = SELECT, DELETE
+public.gitjob                                   = SELECT, INSERT, DELETE
+public.gitref                                   = SELECT, DELETE
+public.gitrepository                            = SELECT, UPDATE, DELETE
+public.gitrule                                  = SELECT, DELETE
+public.gitrulegrant                             = SELECT, DELETE
+public.gitsubscription                          = SELECT, DELETE
+public.job                                      = SELECT, UPDATE, INSERT, DELETE
+public.ocirecipe                                = SELECT, DELETE
+public.person                                   = SELECT
+public.product                                  = SELECT
+public.snap                                     = SELECT, DELETE
+public.sourcepackagerecipe                      = SELECT, DELETE
+public.sourcepackagerecipedata                  = SELECT, DELETE
+public.sourcepackagerecipedatainstruction       = SELECT, DELETE
+public.webhook                                  = SELECT, DELETE
+public.webhookjob                               = SELECT, DELETE
+type=user
+
 [targetnamecacheupdater]
 groups=script
 public.binarypackagename                        = SELECT
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..d660366 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1111,6 +1111,11 @@
       provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource">
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" />
   </securedutility>
+  <securedutility
+      component="lp.code.model.gitjob.GitRepositoryConfirmCreationJob"
+      provides="lp.code.interfaces.gitjob.IGitRepositoryConfirmCreationJobSource">
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryConfirmCreationJobSource" />
+  </securedutility>
   <class class="lp.code.model.gitjob.GitRefScanJob">
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" />
@@ -1123,6 +1128,10 @@
     <allow interface="lp.code.interfaces.gitjob.IGitJob" />
     <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" />
   </class>
+    <class class="lp.code.model.gitjob.GitRepositoryConfirmCreationJob">
+    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryConfirmCreationJob" />
+  </class>
 
   <lp:help-folder folder="help" name="+help-code" />
 
diff --git a/lib/lp/code/interfaces/githosting.py b/lib/lp/code/interfaces/githosting.py
index 378930a..d854174 100644
--- a/lib/lp/code/interfaces/githosting.py
+++ b/lib/lp/code/interfaces/githosting.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interface for communication with the Git hosting service."""
@@ -14,13 +14,15 @@ from zope.interface import Interface
 class IGitHostingClient(Interface):
     """Interface for the internal API provided by the Git hosting service."""
 
-    def create(path, clone_from=None):
+    def create(path, clone_from=None, async_create=False):
         """Create a Git repository.
 
         :param path: Physical path of the new repository on the hosting
             service.
         :param clone_from: If not None, clone the new repository from this
             other physical path.
+        :param async_create: Do not block the call until the repository is
+            actually created.
         """
 
     def getProperties(path):
diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py
index 4f31b19..e143515 100644
--- a/lib/lp/code/interfaces/gitjob.py
+++ b/lib/lp/code/interfaces/gitjob.py
@@ -1,4 +1,4 @@
-# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """GitJob interfaces."""
@@ -9,6 +9,8 @@ __all__ = [
     'IGitJob',
     'IGitRefScanJob',
     'IGitRefScanJobSource',
+    'IGitRepositoryConfirmCreationJob',
+    'IGitRepositoryConfirmCreationJobSource',
     'IGitRepositoryModifiedMailJob',
     'IGitRepositoryModifiedMailJobSource',
     'IReclaimGitRepositorySpaceJob',
@@ -93,3 +95,18 @@ class IGitRepositoryModifiedMailJobSource(IJobSource):
         :param repository_delta: An `IGitRepositoryDelta` describing the
             changes.
         """
+
+
+class IGitRepositoryConfirmCreationJob(IRunnableJob):
+    """"A Job to confirm the async creation of a GitRepository on code
+    hosting service."""
+
+
+class IGitRepositoryConfirmCreationJobSource(IJobSource):
+
+    def create(repository):
+        """Confirms or abort a repository creation by checking it on the
+        code hosting service.
+
+        :param repository: The `IGitRepository` in process of creation.
+        """
diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
index fd7e3e7..c933b67 100644
--- a/lib/lp/code/interfaces/gitnamespace.py
+++ b/lib/lp/code/interfaces/gitnamespace.py
@@ -40,8 +40,16 @@ class IGitNamespace(Interface):
     def createRepository(repository_type, registrant, name,
                          information_type=None, date_created=None,
                          target_default=False, owner_default=False,
-                         with_hosting=False, status=None):
-        """Create and return an `IGitRepository` in this namespace."""
+                         with_hosting=False, async_hosting=False, status=None):
+        """Create and return an `IGitRepository` in this namespace.
+
+        :param with_hosting: If True, also creates the repository on git
+            hosting service.
+        :param async_hosting: If with_hosting is True, this controls if the
+            call to create repository on hosting service will be done
+            asynchronously, or it will block until the service creates the
+            repository.
+        """
 
     def isNameUsed(name):
         """Is 'name' already used in this namespace?"""
diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
index 189b426..d0d1dfe 100644
--- a/lib/lp/code/interfaces/gitrepository.py
+++ b/lib/lp/code/interfaces/gitrepository.py
@@ -967,6 +967,13 @@ class IGitRepositorySet(Interface):
         :param with_hosting: Create the repository on the hosting service.
         """
 
+    def fork(origin, user):
+        """Fork a repository to the given user's account.
+
+        :param origin: The original GitRepository.
+        :param user: The user forking the repository.
+        :return: The newly created GitRepository."""
+
     # Marker for references to Git URL layouts: ##GITNAMESPACE##
     @call_with(user=REQUEST_USER)
     @operation_parameters(
diff --git a/lib/lp/code/model/githosting.py b/lib/lp/code/model/githosting.py
index 94d6538..e2a4462 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.py
@@ -90,13 +90,20 @@ class GitHostingClient:
     def _delete(self, path, **kwargs):
         return self._request("delete", path, **kwargs)
 
-    def create(self, path, clone_from=None):
+    def create(self, path, clone_from=None, async_create=False):
         """See `IGitHostingClient`."""
         try:
             if clone_from:
                 request = {"repo_path": path, "clone_from": clone_from}
             else:
                 request = {"repo_path": path}
+            if async_create:
+                # XXX pappacena 2020-07-02: async forces to clone_refs
+                # because it's only used in situations where this is
+                # desirable for now. We might need to add "clone_refs" as
+                # parameter in the future.
+                request['async'] = True
+                request['clone_refs'] = clone_from is not None
             self._post("/repo", json=request)
         except requests.RequestException as e:
             raise GitRepositoryCreationFault(
diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
index 3c041da..4fde969 100644
--- a/lib/lp/code/model/gitjob.py
+++ b/lib/lp/code/model/gitjob.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -12,6 +12,8 @@ __all__ = [
     'ReclaimGitRepositorySpaceJob',
     ]
 
+from datetime import timedelta
+
 from lazr.delegates import delegate_to
 from lazr.enum import (
     DBEnumeratedType,
@@ -37,12 +39,15 @@ from lp.app.errors import NotFoundError
 from lp.code.enums import (
     GitActivityType,
     GitPermissionType,
+    GitRepositoryStatus,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitjob import (
     IGitJob,
     IGitRefScanJob,
     IGitRefScanJobSource,
+    IGitRepositoryConfirmCreationJob,
+    IGitRepositoryConfirmCreationJobSource,
     IGitRepositoryModifiedMailJob,
     IGitRepositoryModifiedMailJobSource,
     IReclaimGitRepositorySpaceJob,
@@ -100,6 +105,13 @@ class GitJobType(DBEnumeratedType):
         modifications.
         """)
 
+    REPOSITORY_CONFIRM_ASYNC_CREATION = DBItem(3, """
+        Repository confirm async creation
+
+        This job runs to pool code hosting to confirm or abort an async
+        repository creation request.
+        """)
+
 
 @implementer(IGitJob)
 class GitJob(StormBase):
@@ -404,3 +416,65 @@ class GitRepositoryModifiedMailJob(GitJobDerived):
     def run(self):
         """See `IGitRepositoryModifiedMailJob`."""
         self.getMailer().sendAll()
+
+
+@implementer(IGitRepositoryConfirmCreationJob)
+@provider(IGitRepositoryConfirmCreationJobSource)
+class GitRepositoryConfirmCreationJob(GitJobDerived):
+    """A job that checks if an async repository creation has finished
+    already."""
+
+    class RepositoryNotReady(Exception):
+        pass
+
+    class_job_type = GitJobType.REPOSITORY_CONFIRM_ASYNC_CREATION
+
+    config = config.IGitRepositoryModifiedMailJobSource
+
+    retry_error_types = (RepositoryNotReady, )
+    retry_delay = timedelta(seconds=5)
+    max_retries = 600  # 600 retires * 5 seconds = 50 minutes
+
+    @classmethod
+    def create(cls, repository):
+        """See ``"""
+        metadata = {}
+        git_job = GitJob(repository, cls.class_job_type, metadata)
+
+        job = cls(git_job)
+        job.celeryRunOnCommit()
+        return job
+
+    def run(self):
+        """See `GitRepositoryConfirmCreationJob`."""
+        log.debug(
+            "Trying to confirm availability of git repository %s",
+            self.repository)
+        hosting_path = self.repository.getInternalPath()
+        props = getUtility(IGitHostingClient).getProperties(
+            hosting_path)
+        log.debug(
+            "Git repository %s properties on code hosting: %s",
+            self.repository, props)
+        if props.get("is_available"):
+            naked_repo = removeSecurityProxy(self.repository)
+            naked_repo.status = GitRepositoryStatus.AVAILABLE
+            IStore(naked_repo).flush()
+            log.info(
+                "Git repository %s availability confirmed.", self.repository)
+            return
+
+        # If we didn't reach the max retries, raise something to retry in
+        # some seconds.
+        if self.attempt_count < self.max_retries:
+            log.info(
+                "Git repository %s is not available on code hosting yet. "
+                "Retrying later.",
+                self.repository)
+            raise GitRepositoryConfirmCreationJob.RepositoryNotReady()
+
+        # We have tried enough. We should abort this repository creation.
+        log.error(
+            "Git repository %s availability could not be confirmed. Removing.",
+            self.repository)
+        self.repository.destroySelf(break_references=True)
diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
index 2a358f6..099b595 100644
--- a/lib/lp/code/model/gitnamespace.py
+++ b/lib/lp/code/model/gitnamespace.py
@@ -33,6 +33,7 @@ from lp.code.enums import (
     BranchSubscriptionDiffSize,
     BranchSubscriptionNotificationLevel,
     CodeReviewNotificationLevel,
+    GitRepositoryStatus,
     )
 from lp.code.errors import (
     GitDefaultConflict,
@@ -75,7 +76,8 @@ class _BaseGitNamespace:
                          reviewer=None, information_type=None,
                          date_created=DEFAULT, description=None,
                          target_default=False, owner_default=False,
-                         with_hosting=False, status=None):
+                         with_hosting=False, async_hosting=False,
+                         status=GitRepositoryStatus.AVAILABLE):
         """See `IGitNamespace`."""
         repository_set = getUtility(IGitRepositorySet)
 
@@ -123,7 +125,8 @@ class _BaseGitNamespace:
 
             clone_from_repository = repository.getClonedFrom()
             repository._createOnHostingService(
-                clone_from_repository=clone_from_repository)
+                clone_from_repository=clone_from_repository,
+                async_create=async_hosting)
 
         return repository
 
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 77a4bbd..6a3fdab 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -116,7 +116,10 @@ from lp.code.interfaces.gitcollection import (
     IGitCollection,
     )
 from lp.code.interfaces.githosting import IGitHostingClient
-from lp.code.interfaces.gitjob import IGitRefScanJobSource
+from lp.code.interfaces.gitjob import (
+    IGitRefScanJobSource,
+    IGitRepositoryConfirmCreationJobSource,
+    )
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
@@ -359,7 +362,8 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         self.owner_default = False
         self.target_default = False
 
-    def _createOnHostingService(self, clone_from_repository=None):
+    def _createOnHostingService(
+            self, clone_from_repository=None, async_create=False):
         """Create this repository on the hosting service."""
         hosting_path = self.getInternalPath()
         if clone_from_repository is not None:
@@ -367,7 +371,8 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
         else:
             clone_from_path = None
         getUtility(IGitHostingClient).create(
-            hosting_path, clone_from=clone_from_path)
+            hosting_path, clone_from=clone_from_path,
+            async_create=async_create)
 
     def getClonedFrom(self):
         """See `IGitRepository`"""
@@ -1728,13 +1733,28 @@ class GitRepositorySet:
 
     def new(self, repository_type, registrant, owner, target, name,
             information_type=None, date_created=DEFAULT, description=None,
-            with_hosting=False):
+            with_hosting=False, async_hosting=False,
+            status=GitRepositoryStatus.AVAILABLE):
         """See `IGitRepositorySet`."""
         namespace = get_git_namespace(target, owner)
         return namespace.createRepository(
             repository_type, registrant, name,
             information_type=information_type, date_created=date_created,
-            description=description, with_hosting=with_hosting)
+            description=description, with_hosting=with_hosting,
+            async_hosting=async_hosting, status=status)
+
+    def fork(self, origin, user):
+        repository = self.new(
+            repository_type=GitRepositoryType.HOSTED,
+            registrant=user, owner=user, target=origin.target,
+            name=origin.name,
+            information_type=origin.information_type,
+            date_created=UTC_NOW, description=origin.description,
+            with_hosting=True, async_hosting=True,
+            status=GitRepositoryStatus.CREATING)
+        # Start pooling job to check when the repository will be ready.
+        getUtility(IGitRepositoryConfirmCreationJobSource).create(repository)
+        return repository
 
     def getByPath(self, user, path):
         """See `IGitRepositorySet`."""
diff --git a/lib/lp/code/model/tests/test_githosting.py b/lib/lp/code/model/tests/test_githosting.py
index d966dbe..16717c4 100644
--- a/lib/lp/code/model/tests/test_githosting.py
+++ b/lib/lp/code/model/tests/test_githosting.py
@@ -2,7 +2,7 @@
 # NOTE: The first line above must stay first; do not move the copyright
 # notice to the top.  See http://www.python.org/dev/peps/pep-0263/.
 #
-# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Unit tests for `GitHostingClient`.
@@ -23,7 +23,6 @@ from lazr.restful.utils import get_current_browser_request
 import responses
 from six.moves.urllib.parse import (
     parse_qsl,
-    urljoin,
     urlsplit,
     )
 from testtools.matchers import (
@@ -130,6 +129,13 @@ class TestGitHostingClient(TestCase):
             "repo", method="POST",
             json_data={"repo_path": "123", "clone_from": "122"})
 
+    def test_create_async(self):
+        with self.mockRequests("POST"):
+            self.client.create("123", clone_from="122", async_create=True)
+        self.assertRequest(
+            "repo", method="POST",
+            json_data={"repo_path": "123", "clone_from": "122", "async": True})
+
     def test_create_failure(self):
         with self.mockRequests("POST", status=400):
             self.assertRaisesWithContent(
diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
index 5964944..617eb44 100644
--- a/lib/lp/code/model/tests/test_gitjob.py
+++ b/lib/lp/code/model/tests/test_gitjob.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for `GitJob`s."""
@@ -24,6 +24,7 @@ from testtools.matchers import (
     MatchesStructure,
     )
 import transaction
+from zope.component import getUtility
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
@@ -31,6 +32,7 @@ from lp.code.adapters.gitrepository import GitRepositoryDelta
 from lp.code.enums import (
     GitGranteeType,
     GitObjectType,
+    GitRepositoryStatus,
     )
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_WEBHOOKS_FEATURE_FLAG,
@@ -40,17 +42,21 @@ from lp.code.interfaces.gitjob import (
     IGitRefScanJob,
     IReclaimGitRepositorySpaceJob,
     )
+from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.model.gitjob import (
     describe_repository_delta,
     GitJob,
     GitJobDerived,
     GitJobType,
     GitRefScanJob,
+    GitRepositoryConfirmCreationJob,
     ReclaimGitRepositorySpaceJob,
     )
+from lp.code.model.gitrepository import GitRepository
 from lp.code.tests.helpers import GitHostingFixture
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
+from lp.services.database.interfaces import IStore
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.runner import JobRunner
 from lp.services.utils import seconds_since_epoch
@@ -484,5 +490,70 @@ class TestDescribeRepositoryDelta(TestCaseWithFactory):
             snapshot, repository)
 
 
+class TestGitRepositoryConfirmCreationJob(TestCaseWithFactory):
+    """Tests for `GitRepositoryConfirmCreationJob`."""
+
+    layer = ZopelessDatabaseLayer
+
+    def test_confirms_repository_creation(self):
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        project = self.factory.makeProduct()
+        repo = self.factory.makeGitRepository(target=project)
+
+        another_user = self.factory.makePerson()
+        forked = getUtility(IGitRepositorySet).fork(repo, another_user)
+        self.assertEqual(GitRepositoryStatus.CREATING, forked.status)
+
+        # Run the job that checks if repository was confirmed
+        [job] = GitRepositoryConfirmCreationJob.iterReady()
+        self.assertEqual(forked, job.repository)
+        with dbuser("gitrepo-creator"):
+            JobRunner([job]).runAll()
+
+        self.assertEqual(
+            [((forked.getInternalPath(), ), {})],
+            hosting_fixture.getProperties.calls)
+        self.assertEqual(GitRepositoryStatus.AVAILABLE, forked.status)
+
+    def test_aborts_repository_creation(self):
+        # Makes sure that after max_retries, the job gives up and deletes
+        # the repository being created.
+        hosting_fixture = self.useFixture(GitHostingFixture())
+        hosting_fixture.getProperties.result["is_available"] = False
+        project = self.factory.makeProduct()
+        repo = self.factory.makeGitRepository(target=project)
+
+        another_user = self.factory.makePerson()
+        forked = getUtility(IGitRepositorySet).fork(repo, another_user)
+        self.assertEqual(GitRepositoryStatus.CREATING, forked.status)
+
+        # Run the job that checks if repository was confirmed
+        [job] = GitRepositoryConfirmCreationJob.iterReady()
+
+        # Asserts that the first run raises an error that should be retried.
+        self.assertRaises(
+            GitRepositoryConfirmCreationJob.RepositoryNotReady, job.run)
+        self.assertIn(
+            GitRepositoryConfirmCreationJob.RepositoryNotReady,
+            GitRepositoryConfirmCreationJob.retry_error_types)
+
+        # Pretends that this is the last retry.
+        job.max_retries = 2
+        job.attempt_count = 2
+        self.assertEqual(forked, job.repository)
+        with dbuser("gitrepo-creator"):
+            JobRunner([job]).runAll()
+
+        self.assertEqual(
+            0, len(list(GitRepositoryConfirmCreationJob.iterReady())))
+        # Asserts it called twice the git hosting service to get properties.
+        self.assertEqual(
+            [((forked.getInternalPath(), ), {})] * 2,
+            hosting_fixture.getProperties.calls)
+        store = IStore(forked)
+        resultset = store.find(GitRepository, GitRepository.id == forked.id)
+        self.assertEqual(0, resultset.count())
+
+
 # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too,
 # but that isn't feasible until we have a proper turnip fixture.
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index a8b9c35..7780f22 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -117,6 +117,7 @@ from lp.code.model.gitjob import (
     GitJob,
     GitJobType,
     GitRefScanJob,
+    GitRepositoryConfirmCreationJob,
     ReclaimGitRepositorySpaceJob,
     )
 from lp.code.model.gitrepository import (
@@ -3271,7 +3272,8 @@ class TestGitRepositorySet(TestCaseWithFactory):
         self.assertThat(repository, MatchesStructure.byEquality(
             registrant=owner, owner=owner, target=target, name=name))
         self.assertEqual(
-            [((repository.getInternalPath(),), {"clone_from": None})],
+            [((repository.getInternalPath(),),
+              {'async_create': False, "clone_from": None})],
             hosting_fixture.create.calls)
 
     def test_provides_IGitRepositorySet(self):
@@ -3559,6 +3561,26 @@ class TestGitRepositorySet(TestCaseWithFactory):
             self.assertFalse(repo1.target_default)
             self.assertTrue(repo2.target_default)
 
+    def test_fork_git_repository_creates_status_check_job(self):
+        self.useFixture(GitHostingFixture())
+        project = self.factory.makeProduct()
+        repo = self.factory.makeGitRepository(target=project)
+
+        another_user = self.factory.makePerson()
+        forked = getUtility(IGitRepositorySet).fork(repo, another_user)
+
+        self.assertThat(forked, MatchesStructure(
+            status=Equals(GitRepositoryStatus.CREATING),
+            repository_type=Equals(GitRepositoryType.HOSTED),
+            target=Equals(project),
+            information_type=Equals(repo.information_type),
+            description=Equals(repo.description),
+            registrant=Equals(another_user),
+            owner=Equals(another_user)))
+
+        [job] = GitRepositoryConfirmCreationJob.iterReady()
+        self.assertEqual(forked, job.repository)
+
 
 class TestGitRepositorySetDefaultsMixin:
 
diff --git a/lib/lp/code/tests/helpers.py b/lib/lp/code/tests/helpers.py
index 46f12ed..a141d26 100644
--- a/lib/lp/code/tests/helpers.py
+++ b/lib/lp/code/tests/helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Helper functions for code testing live here."""
@@ -49,8 +49,8 @@ from lp.code.model.seriessourcepackagebranch import (
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.database.sqlbase import cursor
-from lp.services.propertycache import get_property_cache
 from lp.services.memcache.testing import MemcacheFixture
+from lp.services.propertycache import get_property_cache
 from lp.testing import (
     run_with_login,
     time_counter,
@@ -345,7 +345,7 @@ class GitHostingFixture(fixtures.Fixture):
                  merges=None, blob=None, disable_memcache=True):
         self.create = FakeMethod()
         self.getProperties = FakeMethod(
-            result={"default_branch": default_branch})
+            result={"default_branch": default_branch, "is_available": True})
         self.setProperties = FakeMethod()
         self.getRefs = FakeMethod(result=({} if refs is None else refs))
         self.getCommits = FakeMethod(
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index ffd76d7..d2933b3 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1872,6 +1872,10 @@ module: lp.registry.interfaces.persontransferjob
 dbuser: person-transfer-job
 crontab_group: MAIN
 
+[IGitRepositoryConfirmCreationJobSource]
+module: lp.code.interfaces.gitjob
+dbuser: gitrepo-creator
+
 [IGitRefScanJobSource]
 module: lp.code.interfaces.gitjob
 dbuser: branchscanner

References