← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:fork-button into launchpad:master with ~pappacena/launchpad:git-fork-backend as a prerequisite.

Commit message:
Adding UI to allow forking a git repository

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/387157
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:fork-button into launchpad:master.
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index 499a86a..e7943c0 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -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).
 -->
 
@@ -885,6 +885,12 @@
         template="../templates/gitrepository-rescan.pt"/>
     <browser:page
         for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitrepository.GitRepositoryForkView"
+        permission="launchpad.View"
+        name="+fork"
+        template="../templates/gitrepository-fork.pt"/>
+    <browser:page
+        for="lp.code.interfaces.gitrepository.IGitRepository"
         class="lp.code.browser.codeimport.CodeImportEditView"
         permission="launchpad.Edit"
         name="+edit-import"
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index 7dc1c32..e57225d 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -39,6 +39,7 @@ from six.moves.urllib_parse import (
 from zope.component import getUtility
 from zope.event import notify
 from zope.formlib import form
+from zope.formlib.form import FormFields
 from zope.formlib.textwidgets import IntWidget
 from zope.formlib.widget import CustomWidgetFactory
 from zope.interface import (
@@ -99,7 +100,10 @@ from lp.code.errors import (
     )
 from lp.code.interfaces.gitnamespace import get_git_namespace
 from lp.code.interfaces.gitref import IGitRefBatchNavigator
-from lp.code.interfaces.gitrepository import IGitRepository
+from lp.code.interfaces.gitrepository import (
+    IGitRepository,
+    IGitRepositorySet,
+    )
 from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
 from lp.registry.interfaces.person import (
     IPerson,
@@ -454,12 +458,48 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
             return False
         return scan_job.job.status == JobStatus.FAILED
 
-    @property
     def warning_message(self):
         if self.context.status == GitRepositoryStatus.CREATING:
             return "This repository is being created."
         return None
 
+    @property
+    def allow_fork(self):
+        # Users cannot fork repositories which target is a user.
+        if IPerson.providedBy(self.context.target):
+            return False
+        # User cannot fork repositories they already own (not that forking a
+        # repository owned by a team the user is in is still fine).
+        if self.context.owner == self.user:
+            return False
+        return True
+
+
+class GitRepositoryForkView(LaunchpadEditFormView):
+
+    schema = Interface
+
+    field_names = []
+
+    def setUpFields(self):
+        super(GitRepositoryForkView, self).setUpFields()
+        owner_field = Choice(
+            vocabulary='AllUserTeamsParticipationPlusSelf',
+            title=u'Fork to the following owner', required=True,
+            __name__=u'owner')
+        self.form_fields += FormFields(owner_field)
+
+    @action('Fork it', name='fork')
+    def fork(self, action, data):
+        forked = getUtility(IGitRepositorySet).fork(
+            self.context, data['owner'])
+        self.request.response.addNotification("Repository forked.")
+        self.next_url = canonical_url(forked)
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
 
 class GitRepositoryRescanView(LaunchpadEditFormView):
 
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index d323976..e1f0e57 100644
--- a/lib/lp/code/browser/tests/test_gitrepository.py
+++ b/lib/lp/code/browser/tests/test_gitrepository.py
@@ -18,6 +18,10 @@ from textwrap import dedent
 from fixtures import FakeLogger
 import pytz
 import soupmatchers
+from soupmatchers import (
+    Tag,
+    HTMLContains,
+    )
 from storm.store import Store
 from testtools.matchers import (
     AfterPreprocessing,
@@ -28,6 +32,7 @@ from testtools.matchers import (
     MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
+    Not,
     )
 import transaction
 from zope.component import getUtility
@@ -471,6 +476,39 @@ class TestGitRepositoryView(BrowserTestCase):
         result = view.show_rescan_link
         self.assertTrue(result)
 
+    def test_hide_fork_link_for_repos_targeting_person(self):
+        person = self.factory.makePerson()
+        another_person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(target=person)
+        browser = self.getViewBrowser(
+            repository, '+index', user=another_person)
+        fork_link = Tag(
+            "fork link", "a",
+            text="Fork it to your account",
+            attrs={"class": "sprite add subscribe-self",
+                   "href": "+fork"})
+        self.assertThat(browser.contents, Not(HTMLContains(fork_link)))
+
+    def test_show_fork_link_for_the_right_users(self):
+        another_person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository()
+        repo_owner = repository.owner
+
+        fork_link = Tag(
+            "fork link", "a",
+            text="Fork it to your account",
+            attrs={"class": "sprite add subscribe-self",
+                   "href": "+fork"})
+
+        # Do not show the link for the repository owner.
+        browser = self.getViewBrowser(repository, '+index', user=repo_owner)
+        self.assertThat(browser.contents, Not(HTMLContains(fork_link)))
+
+        # Shows for another person.
+        browser = self.getViewBrowser(
+            repository, '+index', user=another_person)
+        self.assertThat(browser.contents, HTMLContains(fork_link))
+
 
 class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase):
     """Tests that Git repositories with private team artifacts can be viewed.
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/model/githosting.py b/lib/lp/code/model/githosting.py
index e2a4462..60dff43 100644
--- a/lib/lp/code/model/githosting.py
+++ b/lib/lp/code/model/githosting.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).
 
 """Communication with the Git hosting service."""
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_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py
index 5fed799..df01b08 100644
--- a/lib/lp/code/model/tests/test_gitnamespace.py
+++ b/lib/lp/code/model/tests/test_gitnamespace.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 `IGitNamespace` implementations."""
@@ -24,6 +24,7 @@ from lp.code.errors import (
     GitRepositoryCreatorNotOwner,
     GitRepositoryExists,
     )
+from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitnamespace import (
     get_git_namespace,
     IGitNamespace,
@@ -45,10 +46,12 @@ from lp.registry.interfaces.accesspolicy import (
     IAccessPolicyGrantFlatSource,
     IAccessPolicySource,
     )
+from lp.services.compat import mock
 from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
     )
+from lp.testing.fixture import ZopeUtilityFixture
 from lp.testing.layers import DatabaseFunctionalLayer
 
 
@@ -100,6 +103,36 @@ class NamespaceMixin:
             GitRepositoryType.HOSTED, registrant, repository_name)
         self.assertEqual([owner], list(repository.subscribers))
 
+    def test_createRepository_creates_on_githosting_sync(self):
+        hosting_client = mock.Mock()
+        self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
+        owner = self.factory.makeTeam()
+        namespace = self.getNamespace(owner)
+        repository_name = self.factory.getUniqueUnicode()
+        registrant = owner.teamowner
+        repository = namespace.createRepository(
+            GitRepositoryType.HOSTED, registrant, repository_name,
+            with_hosting=True)
+        path = repository.getInternalPath()
+        self.assertEqual(
+            [mock.call(path, clone_from=None, async_create=False)],
+            hosting_client.create.call_args_list)
+
+    def test_createRepository_creates_on_githosting_async(self):
+        hosting_client = mock.Mock()
+        self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
+        owner = self.factory.makeTeam()
+        namespace = self.getNamespace(owner)
+        repository_name = self.factory.getUniqueUnicode()
+        registrant = owner.teamowner
+        repository = namespace.createRepository(
+            GitRepositoryType.HOSTED, registrant, repository_name,
+            with_hosting=True, async_hosting=True)
+        path = repository.getInternalPath()
+        self.assertEqual(
+            [mock.call(path, clone_from=None, async_create=True)],
+            hosting_client.create.call_args_list)
+
     def test_getRepositories_no_repositories(self):
         # getRepositories on an IGitNamespace returns a result set of
         # repositories in that namespace.  If there are no repositories, the
diff --git a/lib/lp/code/templates/git-macros.pt b/lib/lp/code/templates/git-macros.pt
index 9407df1..d8f26bc 100644
--- a/lib/lp/code/templates/git-macros.pt
+++ b/lib/lp/code/templates/git-macros.pt
@@ -34,6 +34,10 @@
         </tt>
       </tal:ssh>
     </dd>
+
+    <dt tal:condition="view/allow_fork">
+      <a href="+fork" class="sprite add subscribe-self">Fork it to your account</a>
+    </dt>
   </dl>
 
   <div id="push-directions"
diff --git a/lib/lp/code/templates/gitrepository-fork.pt b/lib/lp/code/templates/gitrepository-fork.pt
new file mode 100644
index 0000000..32e2221
--- /dev/null
+++ b/lib/lp/code/templates/gitrepository-fork.pt
@@ -0,0 +1,23 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+  <body>
+    <div metal:fill-slot="main">
+      <p>
+        This will create a copy of
+        lp:<tal:repository replace="context/name"/> repository in your
+        account.
+      </p>
+      <p>
+        After the fork process, you will be able to git clone the
+        repository, push to it and create Merge Proposals to the original
+        repository.
+      </p>
+      <div metal:use-macro="context/@@launchpad_form/form" />
+    </div>
+  </body>
+</html>
diff --git a/lib/lp/code/templates/gitrepository-index.pt b/lib/lp/code/templates/gitrepository-index.pt
index 89f202f..8e87244 100644
--- a/lib/lp/code/templates/gitrepository-index.pt
+++ b/lib/lp/code/templates/gitrepository-index.pt
@@ -45,7 +45,8 @@
        tal:content="structure context/description/fmt:text-to-html" />
 
   <div class="yui-g first">
-    <div id="repository-management" class="portlet">
+    <div id="repository-management" class="portlet"
+        tal:define="allow_fork view/allow_fork">
       <tal:repository-management
           replace="structure context/@@++repository-management" />
     </div>

References