← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:git-repo-async-privacy into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:git-repo-async-privacy into launchpad:master.

Commit message:
Doing repository privacy change in background, and blocking user from changing it while another change is in progress.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/392776
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:git-repo-async-privacy into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 11aba6f..290e7bc 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -2082,6 +2082,24 @@ public.webhookjob                       = SELECT, INSERT
 public.xref                             = SELECT, INSERT, DELETE
 type=user
 
+[privacy-change-jobs]
+groups=script
+public.accessartifact                   = SELECT, UPDATE, DELETE, INSERT
+public.accessartifactgrant              = SELECT, UPDATE, DELETE, INSERT
+public.accesspolicyartifact             = SELECT, UPDATE, DELETE, INSERT
+public.accesspolicygrant                = SELECT, UPDATE, DELETE
+public.account                          = SELECT
+public.distribution                     = SELECT
+public.gitjob                           = SELECT, UPDATE
+public.gitrepository                    = SELECT, UPDATE
+public.gitsubscription                  = SELECT, UPDATE, DELETE
+public.job                              = SELECT, INSERT, UPDATE
+public.person                           = SELECT
+public.product                          = SELECT
+public.sharingjob                       = SELECT, INSERT, UPDATE
+public.teamparticipation                = SELECT
+type=user
+
 [sharing-jobs]
 groups=script
 public.accessartifactgrant              = SELECT, UPDATE, DELETE
diff --git a/lib/lp/app/widgets/itemswidgets.py b/lib/lp/app/widgets/itemswidgets.py
index 1dbb59f..a644a96 100644
--- a/lib/lp/app/widgets/itemswidgets.py
+++ b/lib/lp/app/widgets/itemswidgets.py
@@ -189,25 +189,29 @@ class LaunchpadRadioWidgetWithDescription(LaunchpadRadioWidget):
         """Render an item of the list."""
         text = html_escape(text)
         id = '%s.%s' % (name, index)
+        extra_attr = {"disabled": "disabled"} if self.context.readonly else {}
         elem = renderElement(u'input',
                              value=value,
                              name=name,
                              id=id,
                              cssClass=cssClass,
-                             type='radio')
+                             type='radio',
+                             **extra_attr)
         return self._renderRow(text, value, id, elem)
 
     def renderSelectedItem(self, index, text, value, name, cssClass):
         """Render a selected item of the list."""
         text = html_escape(text)
         id = '%s.%s' % (name, index)
+        extra_attr = {"disabled": "disabled"} if self.context.readonly else {}
         elem = renderElement(u'input',
                              value=value,
                              name=name,
                              id=id,
                              cssClass=cssClass,
                              checked="checked",
-                             type='radio')
+                             type='radio',
+                             **extra_attr)
         return self._renderRow(text, value, id, elem)
 
     def renderExtraHint(self):
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index 2552ffb..029e7ba 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -483,6 +483,9 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
     def warning_message(self):
         if self.context.status == GitRepositoryStatus.CREATING:
             return "This repository is being created."
+        if (self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            return "This repository's information type is being changed."
         return None
 
     @property
@@ -573,6 +576,9 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
     @cachedproperty
     def schema(self):
         info_types = self.getInformationTypesToShow()
+        read_only_info_type = (
+                self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
 
         class GitRepositoryEditSchema(Interface):
             """Defines the fields for the edit form.
@@ -582,7 +588,8 @@ class GitRepositoryEditFormView(LaunchpadEditFormView):
             """
             use_template(IGitRepository, include=["default_branch"])
             information_type = copy_field(
-                IGitRepository["information_type"], readonly=False,
+                IGitRepository["information_type"],
+                readonly=read_only_info_type,
                 vocabulary=InformationTypeVocabulary(types=info_types))
             name = copy_field(IGitRepository["name"], readonly=False)
             owner = copy_field(IGitRepository["owner"], readonly=False)
@@ -785,6 +792,11 @@ class GitRepositoryEditView(CodeEditOwnerMixin, GitRepositoryEditFormView):
             self.widgets["target"].hint = (
                 "This is the default repository for this target, so it "
                 "cannot be moved to another target.")
+        if (self.context.status ==
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            self.widgets["information_type"].hint = (
+                "Information type is being changed. The operation needs to "
+                "finish before you can changing it again.")
         if self.context.default_branch:
             self.widgets['default_branch'].context.required = True
 
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index 912965d..8a9af9f 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -60,6 +60,9 @@ from lp.code.enums import (
     GitRepositoryType,
     )
 from lp.code.interfaces.gitcollection import IGitCollection
+from lp.code.interfaces.gitjob import (
+    IGitRepositoryTransitionToInformationTypeJobSource,
+    )
 from lp.code.interfaces.gitrepository import IGitRepositorySet
 from lp.code.interfaces.revision import IRevisionSet
 from lp.code.model.gitjob import GitRefScanJob
@@ -161,6 +164,15 @@ class TestGitRepositoryView(BrowserTestCase):
         self.assertTextMatchesExpressionIgnoreWhitespace(
             r"""This repository is being created\..*""", text)
 
+    def test_changing_info_type_warning_message_is_present(self):
+        repository = removeSecurityProxy(self.factory.makeGitRepository())
+        repository.status = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+        text = self.getMainText(repository, "+index", user=repository.owner)
+        self.assertTextMatchesExpressionIgnoreWhitespace(
+            r"""This repository's information type is being changed\..*""",
+            text)
+
     def test_creating_warning_message_is_not_shown(self):
         repository = removeSecurityProxy(self.factory.makeGitRepository())
         repository.status = GitRepositoryStatus.AVAILABLE
@@ -1161,7 +1173,50 @@ class TestGitRepositoryEditView(TestCaseWithFactory):
         browser.getControl("Change Git Repository").click()
         with person_logged_in(person):
             self.assertEqual(
-                InformationType.USERDATA, repository.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(1, len(jobs))
+            job = removeSecurityProxy(jobs[0])
+            self.assertEqual(repository, job.repository)
+            self.assertEqual(InformationType.USERDATA, job.information_type)
+            self.assertEqual(admin, job.user)
+
+    def test_information_type_in_ui_blocked_if_already_changing(self):
+        # The information_type of a repository can't be changed via the UI
+        # if the repository is already pending a info type change.
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(owner=person)
+        removeSecurityProxy(repository).status = (
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION)
+        admin = getUtility(ILaunchpadCelebrities).admin.teamowner
+        browser = self.getUserBrowser(
+            canonical_url(repository) + "/+edit", user=admin)
+        # Make sure the privacy controls are all disabled in the UI.
+        controls = [
+            "Public", "Public Security", "Private Security", "Private",
+            "Proprietary", "Embargoed"]
+        self.assertTrue(
+            all(browser.getControl(i, index=0).disabled for i in controls))
+        expected_msg = (
+            "Information type is being changed. The operation needs to "
+            "finish before you can changing it again.")
+        self.assertIn(expected_msg, extract_text(browser.contents))
+
+        # Trying to change should have no effect in the backend, since the
+        # repository is already changing info type and this field is read-only.
+        browser.getControl("Private", index=1).click()
+        browser.getControl("Change Git Repository").click()
+        with person_logged_in(person):
+            self.assertEqual(
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(0, len(jobs))
 
     def test_edit_view_ajax_render(self):
         # An information type change request is processed as expected when
@@ -1184,7 +1239,17 @@ class TestGitRepositoryEditView(TestCaseWithFactory):
             result = view.render()
             self.assertEqual("", result)
             self.assertEqual(
-                repository.information_type, InformationType.PUBLICSECURITY)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
+            job_util = getUtility(
+                IGitRepositoryTransitionToInformationTypeJobSource)
+            jobs = list(job_util.iterReady())
+            self.assertEqual(1, len(jobs))
+            job = removeSecurityProxy(jobs[0])
+            self.assertEqual(repository, job.repository)
+            self.assertEqual(
+                InformationType.PUBLICSECURITY, job.information_type)
+            self.assertEqual(person, job.user)
 
     def test_change_default_branch(self):
         # An authorised user can change the default branch to one that
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 898e645..fee38a9 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.GitRepositoryTransitionToInformationTypeJob"
+      provides="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJobSource">
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJobSource" />
+  </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.GitRepositoryTransitionToInformationTypeJob">
+    <allow interface="lp.code.interfaces.gitjob.IGitJob" />
+    <allow interface="lp.code.interfaces.gitjob.IGitRepositoryTransitionToInformationTypeJob" />
+  </class>
 
   <lp:help-folder folder="help" name="+help-code" />
 
diff --git a/lib/lp/code/enums.py b/lib/lp/code/enums.py
index d2a9262..2edf401 100644
--- a/lib/lp/code/enums.py
+++ b/lib/lp/code/enums.py
@@ -167,6 +167,12 @@ class GitRepositoryStatus(DBEnumeratedType):
         This repository is available to be used.
         """)
 
+    PENDING_INFORMATION_TYPE_TRANSITION = DBItem(3, """
+        Information type transition pending
+        
+        This repository's privacy setting is being changed.
+    """)
+
 
 class GitObjectType(DBEnumeratedType):
     """Git Object Type
diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py
index 4f31b19..bfe5525 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."""
@@ -11,6 +11,8 @@ __all__ = [
     'IGitRefScanJobSource',
     'IGitRepositoryModifiedMailJob',
     'IGitRepositoryModifiedMailJobSource',
+    'IGitRepositoryTransitionToInformationTypeJob',
+    'IGitRepositoryTransitionToInformationTypeJobSource',
     'IReclaimGitRepositorySpaceJob',
     'IReclaimGitRepositorySpaceJobSource',
     ]
@@ -93,3 +95,20 @@ class IGitRepositoryModifiedMailJobSource(IJobSource):
         :param repository_delta: An `IGitRepositoryDelta` describing the
             changes.
         """
+
+
+class IGitRepositoryTransitionToInformationTypeJob(IRunnableJob):
+    """A Job to change repository's information type."""
+
+
+class IGitRepositoryTransitionToInformationTypeJobSource(IJobSource):
+
+    def create(repository, user, information_type, verify_policy=True):
+        """Create a job to change git repository's information type.
+
+        :param repository: The `IGitRepository` that was modified.
+        :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`.
+        """
diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py
index 1eb87da..ba7b9f2 100644
--- a/lib/lp/code/model/gitjob.py
+++ b/lib/lp/code/model/gitjob.py
@@ -33,10 +33,12 @@ from zope.interface import (
     provider,
     )
 
+from lp.app.enums import InformationType
 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 (
@@ -45,6 +47,8 @@ from lp.code.interfaces.gitjob import (
     IGitRefScanJobSource,
     IGitRepositoryModifiedMailJob,
     IGitRepositoryModifiedMailJobSource,
+    IGitRepositoryTransitionToInformationTypeJob,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IReclaimGitRepositorySpaceJob,
     IReclaimGitRepositorySpaceJobSource,
     )
@@ -100,6 +104,13 @@ class GitJobType(DBEnumeratedType):
         modifications.
         """)
 
+    REPOSITORY_TRANSITION_TO_INFO_TYPE = DBItem(3, """
+        Change repository's information type
+
+        This job runs when a user requests to change privacy settings of a
+        repository.
+    """)
+
 
 @implementer(IGitJob)
 class GitJob(StormBase):
@@ -393,3 +404,50 @@ class GitRepositoryModifiedMailJob(GitJobDerived):
     def run(self):
         """See `IGitRepositoryModifiedMailJob`."""
         self.getMailer().sendAll()
+
+
+@implementer(IGitRepositoryTransitionToInformationTypeJob)
+@provider(IGitRepositoryTransitionToInformationTypeJobSource)
+class GitRepositoryTransitionToInformationTypeJob(GitJobDerived):
+    """A Job to change git repository's information type."""
+
+    class_job_type = GitJobType.REPOSITORY_TRANSITION_TO_INFO_TYPE
+
+    config = config.IGitRepositoryTransitionToInformationTypeJobSource
+
+    @classmethod
+    def create(cls, repository, information_type, user, verify_policy=True):
+        """See `IGitRepositoryTransitionToInformationTypeJobSource`."""
+        metadata = {
+            "user": user.id,
+            "information_type": information_type.value,
+            "verify_policy": verify_policy,
+            }
+        git_job = GitJob(repository, cls.class_job_type, metadata)
+        job = cls(git_job)
+        job.celeryRunOnCommit()
+        return job
+
+    @property
+    def user(self):
+        return getUtility(IPersonSet).get(self.metadata["user"])
+
+    @property
+    def verify_policy(self):
+        return self.metadata["verify_policy"]
+
+    @property
+    def information_type(self):
+        return InformationType.items[self.metadata["information_type"]]
+
+    def run(self):
+        """See `IGitRepositoryTransitionToInformationTypeJob`."""
+        if (self.repository.status !=
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION):
+            raise AttributeError(
+                "The repository %s is not pending information type change." %
+                self.repository)
+
+        self.repository._transitionToInformationType(
+            self.information_type, self.user, self.verify_policy)
+        self.repository.status = GitRepositoryStatus.AVAILABLE
diff --git a/lib/lp/code/model/gitref.py b/lib/lp/code/model/gitref.py
index f7dc142..e2f788d 100644
--- a/lib/lp/code/model/gitref.py
+++ b/lib/lp/code/model/gitref.py
@@ -180,7 +180,7 @@ class GitRefMixin:
 
     def transitionToInformationType(self, information_type, user,
                                     verify_policy=True):
-        return self.repository.transitionToInformationType(
+        return self.repository._transitionToInformationType(
             information_type, user, verify_policy=verify_policy)
 
     @property
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index f7a2a0b..b719e07 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -117,7 +117,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,
+    IGitRepositoryTransitionToInformationTypeJobSource,
+    )
 from lp.code.interfaces.gitlookup import IGitLookup
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
@@ -882,6 +885,24 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin):
     def transitionToInformationType(self, information_type, user,
                                     verify_policy=True):
         """See `IGitRepository`."""
+        if self.status != GitRepositoryStatus.AVAILABLE:
+            raise CannotChangeInformationType(
+                "Cannot change privacy settings while git repository is "
+                "being changed.")
+        self.status = GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION
+        util = getUtility(
+            IGitRepositoryTransitionToInformationTypeJobSource)
+        return util.create(self, information_type, user, verify_policy)
+
+    def _transitionToInformationType(self, information_type, user,
+                                    verify_policy=True):
+        """Synchronously make the change in this repository's information
+        type.
+
+        External callers should use the async, public version of this
+        method, since it deals with the side effects of changing
+        repository's privacy changes.
+        """
         if self.information_type == information_type:
             return
         if (verify_policy and
diff --git a/lib/lp/code/model/tests/test_gitcollection.py b/lib/lp/code/model/tests/test_gitcollection.py
index 447fcb4..372a633 100644
--- a/lib/lp/code/model/tests/test_gitcollection.py
+++ b/lib/lp/code/model/tests/test_gitcollection.py
@@ -599,7 +599,7 @@ class TestBranchMergeProposals(TestCaseWithFactory):
         registrant = self.factory.makePerson()
         mp1 = self.factory.makeBranchMergeProposalForGit(registrant=registrant)
         naked_repository = removeSecurityProxy(mp1.target_git_repository)
-        naked_repository.transitionToInformationType(
+        naked_repository._transitionToInformationType(
             InformationType.USERDATA, registrant, verify_policy=False)
         collection = self.all_repositories.visibleByUser(None)
         proposals = collection.getMergeProposals()
diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py
index 3f606c0..69cb653 100644
--- a/lib/lp/code/model/tests/test_gitjob.py
+++ b/lib/lp/code/model/tests/test_gitjob.py
@@ -25,13 +25,16 @@ from testtools.matchers import (
     MatchesStructure,
     )
 import transaction
+from zope.component import getUtility
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import InformationType
 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,
@@ -39,6 +42,7 @@ from lp.code.interfaces.branchmergeproposal import (
 from lp.code.interfaces.gitjob import (
     IGitJob,
     IGitRefScanJob,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IReclaimGitRepositorySpaceJob,
     )
 from lp.code.model.gitjob import (
@@ -47,9 +51,11 @@ from lp.code.model.gitjob import (
     GitJobDerived,
     GitJobType,
     GitRefScanJob,
+    GitRepositoryTransitionToInformationTypeJob,
     ReclaimGitRepositorySpaceJob,
     )
 from lp.code.tests.helpers import GitHostingFixture
+from lp.registry.errors import CannotChangeInformationType
 from lp.services.config import config
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
@@ -362,6 +368,65 @@ class TestReclaimGitRepositorySpaceJob(TestCaseWithFactory):
         self.assertEqual([(path,)], hosting_fixture.delete.extract_args())
 
 
+class TestGitRepositoryTransitionInformationType(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_block_multiple_requests_to_change_info_type(self):
+        repo = self.factory.makeGitRepository()
+        repo.transitionToInformationType(
+            InformationType.PRIVATESECURITY, repo.owner)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+        expected_msg = (
+            "Cannot change privacy settings while git repository is "
+            "being changed.")
+        self.assertRaisesRegex(
+            CannotChangeInformationType, expected_msg,
+            repo.transitionToInformationType,
+            InformationType.PROPRIETARY, repo.owner)
+
+    def test_avoid_transitioning_while_creating(self):
+        repo = self.factory.makeGitRepository()
+        removeSecurityProxy(repo).status = GitRepositoryStatus.CREATING
+        expected_msg = (
+            "Cannot change privacy settings while git repository is "
+            "being changed.")
+        self.assertRaisesRegex(
+            CannotChangeInformationType, expected_msg,
+            repo.transitionToInformationType,
+            InformationType.PROPRIETARY, repo.owner)
+
+    def test_run_changes_info_type(self):
+        repo = self.factory.makeGitRepository(
+            information_type=InformationType.PUBLIC)
+        # Change to a private info type and with verify_policy, so we hit as
+        # many database tables as possible.
+        repo.transitionToInformationType(
+            InformationType.PRIVATESECURITY, repo.owner, verify_policy=True)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+
+        job_util = getUtility(
+            IGitRepositoryTransitionToInformationTypeJobSource)
+        jobs = list(job_util.iterReady())
+        self.assertEqual(1, len(jobs))
+        with dbuser(GitRepositoryTransitionToInformationTypeJob.config.dbuser):
+            JobRunner(jobs).runAll()
+
+        self.assertEqual(GitRepositoryStatus.AVAILABLE, repo.status)
+        self.assertEqual(
+            InformationType.PRIVATESECURITY, repo.information_type)
+
+        # After the job finished, another change is possible.
+        repo.transitionToInformationType(InformationType.PUBLIC, repo.owner)
+        self.assertEqual(
+            GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+            repo.status)
+
+
 class TestDescribeRepositoryDelta(TestCaseWithFactory):
     """Tests for `describe_repository_delta`."""
 
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 7d7464e..f69d1e3 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -809,7 +809,7 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
 
     def test_private_subscription_does_not_disable_deletion(self):
         # A private repository that has a subscription can be deleted.
-        self.repository.transitionToInformationType(
+        removeSecurityProxy(self.repository)._transitionToInformationType(
             InformationType.USERDATA, self.repository.owner,
             verify_policy=False)
         self.repository.subscribe(
@@ -2055,7 +2055,8 @@ class TestGitRepositoryModerate(TestCaseWithFactory):
             repository.transitionToInformationType(
                 InformationType.PRIVATESECURITY, project.owner)
             self.assertEqual(
-                InformationType.PRIVATESECURITY, repository.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository.status)
 
     def test_attribute_smoketest(self):
         # Users with launchpad.Moderate can set attributes.
@@ -3400,7 +3401,7 @@ class TestGitRepositorySet(TestCaseWithFactory):
             ]
         for repository, modified_date in zip(repositories, modified_dates):
             removeSecurityProxy(repository).date_last_modified = modified_date
-        removeSecurityProxy(repositories[0]).transitionToInformationType(
+        removeSecurityProxy(repositories[0])._transitionToInformationType(
             InformationType.PRIVATESECURITY, repositories[0].registrant)
         self.assertEqual(
             [repositories[3], repositories[4], repositories[1],
@@ -3913,7 +3914,8 @@ class TestGitRepositoryWebservice(TestCaseWithFactory):
         self.assertEqual(209, response.status)
         with person_logged_in(ANONYMOUS):
             self.assertEqual(
-                InformationType.PUBLICSECURITY, repository_db.information_type)
+                GitRepositoryStatus.PENDING_INFORMATION_TYPE_TRANSITION,
+                repository_db.status)
 
     def test_set_information_type_other_person(self):
         # An unrelated user cannot change the information type.
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
index dcbcee6..e884cd2 100644
--- a/lib/lp/code/xmlrpc/tests/test_git.py
+++ b/lib/lp/code/xmlrpc/tests/test_git.py
@@ -899,7 +899,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
         removeSecurityProxy(
-            private_repository).transitionToInformationType(
+            private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -1077,7 +1077,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
         removeSecurityProxy(
-            private_repository).transitionToInformationType(
+            private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -1687,7 +1687,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -2012,7 +2012,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
@@ -2268,7 +2268,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
                 target_rcs_type=TargetRevisionControlSystems.GIT)
             for _ in range(2)]
         private_repository = code_imports[0].git_repository
-        removeSecurityProxy(private_repository).transitionToInformationType(
+        removeSecurityProxy(private_repository)._transitionToInformationType(
             InformationType.PRIVATESECURITY, private_repository.owner)
         with celebrity_logged_in("vcs_imports"):
             jobs = [
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index c16270a..681db58 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -1768,6 +1768,7 @@ job_sources:
     ICommercialExpiredJobSource,
     IExpiringMembershipNotificationJobSource,
     IGitRepositoryModifiedMailJobSource,
+    IGitRepositoryTransitionToInformationTypeJobSource,
     IMembershipNotificationJobSource,
     IOCIRecipeRequestBuildsJobSource,
     IOCIRegistryUploadJobSource,
@@ -1829,6 +1830,11 @@ module: lp.code.interfaces.gitjob
 dbuser: send-branch-mail
 crontab_group: MAIN
 
+[IGitRepositoryTransitionToInformationTypeJobSource]
+module: lp.code.interfaces.gitjob
+dbuser: privacy-change-jobs
+crontab_group: MAIN
+
 [IInitializeDistroSeriesJobSource]
 module: lp.soyuz.interfaces.distributionjob
 dbuser: initializedistroseries
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 57babc5..9d1f53b 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -1815,7 +1815,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             reviewer=reviewer, **optional_repository_args)
         naked_repository = removeSecurityProxy(repository)
         if information_type is not None:
-            naked_repository.transitionToInformationType(
+            naked_repository._transitionToInformationType(
                 information_type, registrant, verify_policy=False)
         return repository