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

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 68e062a06a9b1ac5064ce682c22b905b6b2d14af
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:fork-button
Merge into: launchpad:master
Prerequisite: ~pappacena/launchpad:git-fork-backend
Diff against target: 566 lines (+340/-10)
8 files modified
lib/lp/code/browser/configure.zcml (+7/-1)
lib/lp/code/browser/gitrepository.py (+62/-0)
lib/lp/code/browser/tests/test_gitrepository.py (+198/-2)
lib/lp/code/interfaces/gitnamespace.py (+2/-2)
lib/lp/code/model/gitnamespace.py (+4/-4)
lib/lp/code/model/tests/test_gitnamespace.py (+33/-0)
lib/lp/code/templates/git-macros.pt (+12/-1)
lib/lp/code/templates/gitrepository-fork.pt (+22/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+387157@code.launchpad.net

Commit message

Adding UI to allow forking a git repository

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :
~pappacena/launchpad:fork-button updated
d87633a... by Thiago F. Pappacena

Merge branch 'git-fork-backend' into fork-button

4e5bb5e... by Thiago F. Pappacena

Fixing IGitRepositorySet.fork call

b90fd8f... by Thiago F. Pappacena

Merge branch 'git-fork-backend' into fork-button

cf5ebb0... by Thiago F. Pappacena

Small fix on fork URL for projects

4d594ef... by Thiago F. Pappacena

Merge branch 'git-fork-backend' into fork-button

18f4666... by Thiago F. Pappacena

Show fork button only for views that explicitly declare it

62081da... by Thiago F. Pappacena

Merge branch 'git-fork-backend' into fork-button

188ef86... by Thiago F. Pappacena

Adding feature flag to enable / disable fork button

Revision history for this message
Colin Watson (cjwatson) wrote :

This looks essentially OK aside from a few corrections described below, but I'd like to see at least a screenshot or two of what this looks like in practice as well, please.

review: Needs Information
~pappacena/launchpad:fork-button updated
e657c03... by Thiago F. Pappacena

Merge branch 'git-fork-backend' into fork-button

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Some screenshots, while I work on the diff comments:

The fork button itself: https://monosnap.com/file/CaNNw6uDQH5crkfzy9qQ2rcZ12Side
New repository's owner selection: https://monosnap.com/file/VKIAxNQ5Cv01FVgmrrI5WS2QOYKuan
Owners selector open: https://monosnap.com/file/fVSk2vMeCyYkwf10nM27M6zqNHyZ7S

~pappacena/launchpad:fork-button updated
52c0f04... by Thiago F. Pappacena

Refactoring

bc92b17... by Thiago F. Pappacena

Fixing tests

Revision history for this message
Colin Watson (cjwatson) wrote :

OK, in that case I think all the comments I have are covered by my previous set of inline comments, thanks. (Ultimately we'll probably want the Fork link to be a JS link that pops up an owner picker and then lets you start the fork process without loading a new page, but I'm not expecting that in this round.)

review: Needs Fixing
~pappacena/launchpad:fork-button updated
52ffd87... by Thiago F. Pappacena

Rearrange fork link position

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the requested changes.

I have also changed slightly the button text, to make it fit better in the text when user can/cannot push to the original repository:

- When user can push directly (and there is no push URL hint): https://monosnap.com/file/LK24hWnrv9tIiBe6sqx5Me1mb9okq3
- When user cannot push directly (and there is the push URL hint): https://monosnap.com/file/3r4y9L7wnHKFFLVAqYTZvOsZVNPDjg

I would like some opinion on the text before landing this.

Revision history for this message
Colin Watson (cjwatson) wrote :

This looks pretty good, thanks; just some small suggestions. Also note that the supporting turnip changes need to be on production (not just master and/or qastaging) before landing this.

review: Approve
~pappacena/launchpad:fork-button updated
68e062a... by Thiago F. Pappacena

Linting the code and changing fork message

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Pushed the changes

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

The corresponding changes on Turnip reached production already.

Worst case scenario, we still have the feature flag to hit the fork button.

Landing this MP.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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 @@
1<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -885,6 +885,12 @@
885 template="../templates/gitrepository-rescan.pt"/>885 template="../templates/gitrepository-rescan.pt"/>
886 <browser:page886 <browser:page
887 for="lp.code.interfaces.gitrepository.IGitRepository"887 for="lp.code.interfaces.gitrepository.IGitRepository"
888 class="lp.code.browser.gitrepository.GitRepositoryForkView"
889 permission="launchpad.View"
890 name="+fork"
891 template="../templates/gitrepository-fork.pt"/>
892 <browser:page
893 for="lp.code.interfaces.gitrepository.IGitRepository"
888 class="lp.code.browser.codeimport.CodeImportEditView"894 class="lp.code.browser.codeimport.CodeImportEditView"
889 permission="launchpad.Edit"895 permission="launchpad.Edit"
890 name="+edit-import"896 name="+edit-import"
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index 76b9f7d..2552ffb 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -40,6 +40,7 @@ from six.moves.urllib_parse import (
40from zope.component import getUtility40from zope.component import getUtility
41from zope.event import notify41from zope.event import notify
42from zope.formlib import form42from zope.formlib import form
43from zope.formlib.form import FormFields
43from zope.formlib.textwidgets import IntWidget44from zope.formlib.textwidgets import IntWidget
44from zope.formlib.widget import CustomWidgetFactory45from zope.formlib.widget import CustomWidgetFactory
45from zope.interface import (46from zope.interface import (
@@ -58,6 +59,7 @@ from zope.schema.vocabulary import (
58 SimpleTerm,59 SimpleTerm,
59 SimpleVocabulary,60 SimpleVocabulary,
60 )61 )
62from zope.security.interfaces import Unauthorized
6163
62from lp import _64from lp import _
63from lp.app.browser.informationtype import InformationTypePortletMixin65from lp.app.browser.informationtype import InformationTypePortletMixin
@@ -103,6 +105,7 @@ from lp.code.interfaces.gitref import IGitRefBatchNavigator
103from lp.code.interfaces.gitrepository import (105from lp.code.interfaces.gitrepository import (
104 ContributorGitIdentity,106 ContributorGitIdentity,
105 IGitRepository,107 IGitRepository,
108 IGitRepositorySet,
106 )109 )
107from lp.code.vocabularies.gitrule import GitPermissionsVocabulary110from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
108from lp.registry.interfaces.person import (111from lp.registry.interfaces.person import (
@@ -141,6 +144,9 @@ from lp.services.webhooks.browser import WebhookTargetNavigationMixin
141from lp.snappy.browser.hassnaps import HasSnapsViewMixin144from lp.snappy.browser.hassnaps import HasSnapsViewMixin
142145
143146
147GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled'
148
149
144@implementer(ICanonicalUrlData)150@implementer(ICanonicalUrlData)
145class GitRepositoryURL:151class GitRepositoryURL:
146 """Git repository URL creation rules."""152 """Git repository URL creation rules."""
@@ -479,6 +485,62 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
479 return "This repository is being created."485 return "This repository is being created."
480 return None486 return None
481487
488 @property
489 def allow_fork(self):
490 if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED):
491 return False
492 # User cannot fork repositories they already own (note that forking a
493 # repository owned by a team the user is in is still fine).
494 if self.context.owner == self.user:
495 return False
496 return self.context.namespace.supports_repository_forking
497
498 @property
499 def fork_url(self):
500 return canonical_url(self.context, view_name='+fork')
501
502
503class GitRepositoryForkView(LaunchpadEditFormView):
504
505 schema = Interface
506
507 field_names = []
508
509 def initialize(self):
510 if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED):
511 raise Unauthorized()
512 super(GitRepositoryForkView, self).initialize()
513
514 def setUpFields(self):
515 super(GitRepositoryForkView, self).setUpFields()
516 owner_field = Choice(
517 vocabulary='AllUserTeamsParticipationPlusSelf',
518 title=u'Fork to the following owner', required=True,
519 __name__=u'owner')
520 self.form_fields += FormFields(owner_field)
521
522 @property
523 def initial_values(self):
524 return {'owner': self.user}
525
526 def validate(self, data):
527 new_owner = data.get("owner")
528 if not new_owner or not self.user.inTeam(new_owner):
529 self.setFieldError(
530 "owner",
531 "You should select a valid user to fork the repository.")
532
533 @action('Fork it', name='fork')
534 def fork(self, action, data):
535 forked = getUtility(IGitRepositorySet).fork(
536 self.context, self.user, data.get("owner"))
537 self.request.response.addNotification("Repository forked.")
538 self.next_url = canonical_url(forked)
539
540 @property
541 def cancel_url(self):
542 return canonical_url(self.context)
543
482544
483class GitRepositoryRescanView(LaunchpadEditFormView):545class GitRepositoryRescanView(LaunchpadEditFormView):
484546
diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
index d3211ba..912965d 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
18from fixtures import FakeLogger18from fixtures import FakeLogger
19import pytz19import pytz
20import soupmatchers20import soupmatchers
21from soupmatchers import (
22 Tag,
23 HTMLContains,
24 )
21from storm.store import Store25from storm.store import Store
22from testtools.matchers import (26from testtools.matchers import (
23 AfterPreprocessing,27 AfterPreprocessing,
@@ -28,6 +32,7 @@ from testtools.matchers import (
28 MatchesListwise,32 MatchesListwise,
29 MatchesSetwise,33 MatchesSetwise,
30 MatchesStructure,34 MatchesStructure,
35 Not,
31 )36 )
32import transaction37import transaction
33from zope.component import getUtility38from zope.component import getUtility
@@ -40,16 +45,21 @@ from lp.app.enums import InformationType
40from lp.app.errors import UnexpectedFormData45from lp.app.errors import UnexpectedFormData
41from lp.app.interfaces.launchpad import ILaunchpadCelebrities46from lp.app.interfaces.launchpad import ILaunchpadCelebrities
42from lp.app.interfaces.services import IService47from lp.app.interfaces.services import IService
43from lp.code.browser.gitrepository import encode_form_field_id48from lp.code.browser.gitrepository import (
49 encode_form_field_id,
50 GIT_REPOSITORY_FORK_ENABLED,
51 )
44from lp.code.enums import (52from lp.code.enums import (
45 BranchMergeProposalStatus,53 BranchMergeProposalStatus,
46 CodeReviewVote,54 CodeReviewVote,
47 GitActivityType,55 GitActivityType,
48 GitGranteeType,56 GitGranteeType,
57 GitListingSort,
49 GitPermissionType,58 GitPermissionType,
50 GitRepositoryStatus,59 GitRepositoryStatus,
51 GitRepositoryType,60 GitRepositoryType,
52 )61 )
62from lp.code.interfaces.gitcollection import IGitCollection
53from lp.code.interfaces.gitrepository import IGitRepositorySet63from lp.code.interfaces.gitrepository import IGitRepositorySet
54from lp.code.interfaces.revision import IRevisionSet64from lp.code.interfaces.revision import IRevisionSet
55from lp.code.model.gitjob import GitRefScanJob65from lp.code.model.gitjob import GitRefScanJob
@@ -131,6 +141,10 @@ class TestGitRepositoryView(BrowserTestCase):
131141
132 layer = LaunchpadFunctionalLayer142 layer = LaunchpadFunctionalLayer
133143
144 def setUp(self):
145 super(TestGitRepositoryView, self).setUp()
146 self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'}))
147
134 def test_clone_instructions(self):148 def test_clone_instructions(self):
135 repository = self.factory.makeGitRepository()149 repository = self.factory.makeGitRepository()
136 username = repository.owner.name150 username = repository.owner.name
@@ -538,7 +552,7 @@ class TestGitRepositoryView(BrowserTestCase):
538 repository, "+repository-portlet-subscriber-content")552 repository, "+repository-portlet-subscriber-content")
539 with StormStatementRecorder() as recorder:553 with StormStatementRecorder() as recorder:
540 view.render()554 view.render()
541 self.assertThat(recorder, HasQueryCount(Equals(6)))555 self.assertThat(recorder, HasQueryCount(Equals(7)))
542556
543 def test_show_rescan_link(self):557 def test_show_rescan_link(self):
544 repository = self.factory.makeGitRepository()558 repository = self.factory.makeGitRepository()
@@ -573,6 +587,65 @@ class TestGitRepositoryView(BrowserTestCase):
573 result = view.show_rescan_link587 result = view.show_rescan_link
574 self.assertTrue(result)588 self.assertTrue(result)
575589
590 def assertContainsForkLink(self, browser, repository, text):
591 """Asserts that browser contains the fork link with the given text"""
592 with admin_logged_in():
593 url = canonical_url(repository, view_name='+fork')
594 fork_link = Tag(
595 "fork link", "a",
596 text=re.compile(re.escape(text)),
597 attrs={"class": "sprite add", "href": url})
598 self.assertThat(browser.contents, HTMLContains(fork_link))
599
600 def assertDoesntContainForkLink(self, browser, repository, texts):
601 """Asserts that there is no fork button with any of the given texts."""
602 with admin_logged_in():
603 url = canonical_url(repository, view_name='+fork')
604 for text in texts:
605 fork_link = Tag(
606 "fork link", "a",
607 text=re.compile(re.escape(text)),
608 attrs={"class": "sprite add", "href": url})
609 self.assertThat(browser.contents, Not(HTMLContains(fork_link)))
610
611 def test_hide_fork_link_for_repos_targeting_person(self):
612 person = self.factory.makePerson()
613 another_person = self.factory.makePerson()
614 repository = self.factory.makeGitRepository(target=person)
615 browser = self.getViewBrowser(
616 repository, '+index', user=another_person)
617 self.assertDoesntContainForkLink(browser, repository, [
618 "Fork it to your account",
619 "Or click here to fork it to your account.",
620 ])
621
622 def test_show_fork_link_for_the_right_users(self):
623 another_person = self.factory.makePerson()
624 repository = self.factory.makeGitRepository()
625 repo_owner = repository.owner
626
627 # Do not show the link for the repository owner.
628 browser = self.getViewBrowser(repository, '+index', user=repo_owner)
629 self.assertDoesntContainForkLink(browser, repository, [
630 "Fork it to your account",
631 "fork it directly to your account",
632 ])
633
634 # Shows for another person.
635 browser = self.getViewBrowser(
636 repository, '+index', user=another_person)
637 self.assertContainsForkLink(
638 browser, repository, "fork it directly to your account")
639
640 # Even for another person, do not show it if the feature flag is off.
641 self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''}))
642 browser = self.getViewBrowser(
643 repository, '+index', user=another_person)
644 self.assertDoesntContainForkLink(browser, repository, [
645 "Fork it to your account",
646 "fork it directly to your account",
647 ])
648
576649
577class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase):650class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase):
578 """Tests that Git repositories with private team artifacts can be viewed.651 """Tests that Git repositories with private team artifacts can be viewed.
@@ -2071,3 +2144,126 @@ class TestGitRepositoryActivityView(BrowserTestCase):
2071 create_activity,2144 create_activity,
2072 2)2145 2)
2073 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))2146 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
2147
2148
2149class TestGitRepositoryForkView(BrowserTestCase):
2150
2151 layer = DatabaseFunctionalLayer
2152
2153 def setUp(self):
2154 super(TestGitRepositoryForkView, self).setUp()
2155 self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'}))
2156
2157 def getReposOwnedBy(self, user):
2158 return IGitCollection(user).getRepositories(
2159 sort_by=GitListingSort.NEWEST_FIRST)
2160
2161 def test_fork_page_redirects_with_disabled_feature(self):
2162 self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''}))
2163 with admin_logged_in():
2164 repository = self.factory.makeGitRepository()
2165 owner = repository.owner
2166 self.assertRaises(
2167 Unauthorized, self.getViewBrowser,
2168 repository, "+fork", rootsite="code", user=owner)
2169
2170 def test_fork_page_shows_input(self):
2171 with admin_logged_in():
2172 repository = self.factory.makeGitRepository()
2173 owner = removeSecurityProxy(repository.owner)
2174
2175 team = self.factory.makeTeam(members=[owner])
2176 another_person = self.factory.makePerson()
2177
2178 select_owner = Tag(
2179 "owner selector", "select",
2180 attrs={"id": "field.owner", "name": "field.owner"})
2181
2182 def person_option_tag(person):
2183 return Tag(
2184 "option for %s" % person, "option",
2185 text="%s (%s)" % (person.displayname, person.name),
2186 attrs=dict(value=person.name))
2187
2188 option_owner = person_option_tag(owner)
2189 option_team = person_option_tag(team)
2190 option_another_person = person_option_tag(another_person)
2191
2192 browser = self.getViewBrowser(
2193 repository, "+fork", rootsite="code", user=owner)
2194
2195 html = browser.contents
2196 self.assertThat(html, HTMLContains(select_owner))
2197 self.assertThat(html, HTMLContains(option_owner))
2198 self.assertThat(html, HTMLContains(option_team))
2199 self.assertThat(html, Not(HTMLContains(option_another_person)))
2200
2201 def test_fork_page_submit_to_self(self):
2202 self.useFixture(GitHostingFixture())
2203 repository = self.factory.makeGitRepository()
2204 another_person = self.factory.makePerson()
2205
2206 with person_logged_in(another_person):
2207 view = create_initialized_view(repository, name="+fork", form={
2208 "field.owner": another_person.name,
2209 "field.actions.fork": "Fork it"})
2210
2211 forked = self.getReposOwnedBy(another_person)[0]
2212 self.assertNotEqual(forked, repository)
2213 self.assertEqual(another_person, forked.owner)
2214
2215 notifications = view.request.response.notifications
2216 self.assertEqual(1, len(notifications))
2217 self.assertEqual("Repository forked.", notifications[0].message)
2218
2219 self.assertEqual(canonical_url(forked), view.next_url)
2220
2221 def test_fork_page_submit_to_team(self):
2222 self.useFixture(GitHostingFixture())
2223 repository = self.factory.makeGitRepository()
2224 another_person = self.factory.makePerson()
2225 team = self.factory.makeTeam(members=[another_person])
2226
2227 with person_logged_in(another_person):
2228 view = create_initialized_view(repository, name="+fork", form={
2229 "field.owner": team.name,
2230 "field.actions.fork": "Fork it"})
2231
2232 forked = self.getReposOwnedBy(team)[0]
2233 self.assertNotEqual(forked, repository)
2234 self.assertEqual(team, forked.owner)
2235
2236 notifications = view.request.response.notifications
2237 self.assertEqual(1, len(notifications))
2238 self.assertEqual("Repository forked.", notifications[0].message)
2239
2240 self.assertEqual(canonical_url(forked), view.next_url)
2241
2242 def test_fork_page_submit_missing_user(self):
2243 self.useFixture(GitHostingFixture())
2244 repository = self.factory.makeGitRepository()
2245 another_person = self.factory.makePerson()
2246
2247 with person_logged_in(another_person):
2248 view = create_initialized_view(repository, name="+fork", form={
2249 "field.actions.fork": "Fork it"})
2250
2251 # No repository should have been created.
2252 self.assertEqual(0, self.getReposOwnedBy(another_person).count())
2253 self.assertEqual(
2254 ['You should select a valid user to fork the repository.'],
2255 view.errors)
2256
2257 self.assertEqual(None, view.next_url)
2258
2259 def test_fork_page_submit_invalid_user(self):
2260 self.useFixture(GitHostingFixture())
2261 repository = self.factory.makeGitRepository()
2262 another_person = self.factory.makePerson()
2263 invalid_person = self.factory.makePerson()
2264
2265 with person_logged_in(another_person):
2266 create_initialized_view(repository, name="+fork", form={
2267 "field.owner": invalid_person.name,
2268 "field.actions.fork": "Fork it"})
2269 self.assertEqual(0, self.getReposOwnedBy(invalid_person).count())
diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
index 3a6e600..f4ff75a 100644
--- a/lib/lp/code/interfaces/gitnamespace.py
+++ b/lib/lp/code/interfaces/gitnamespace.py
@@ -107,8 +107,8 @@ class IGitNamespacePolicy(Interface):
107 "True iff this namespace permits automatically setting a default "107 "True iff this namespace permits automatically setting a default "
108 "repository on push.")108 "repository on push.")
109109
110 show_push_url_hints = Attribute(110 supports_repository_forking = Attribute(
111 "True if this namespace permits display of the push URL hint.")111 "Does this namespace support repository forking at all?")
112112
113 supports_merge_proposals = Attribute(113 supports_merge_proposals = Attribute(
114 "Does this namespace support merge proposals at all?")114 "Does this namespace support merge proposals at all?")
diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
index 22e9d8f..9d72ee5 100644
--- a/lib/lp/code/model/gitnamespace.py
+++ b/lib/lp/code/model/gitnamespace.py
@@ -285,7 +285,7 @@ class PersonalGitNamespace(_BaseGitNamespace):
285 supports_merge_proposals = True285 supports_merge_proposals = True
286 supports_code_imports = False286 supports_code_imports = False
287 allow_recipe_name_from_target = False287 allow_recipe_name_from_target = False
288 show_push_url_hints = False288 supports_repository_forking = False
289289
290 def __init__(self, person):290 def __init__(self, person):
291 self.owner = person291 self.owner = person
@@ -369,7 +369,7 @@ class ProjectGitNamespace(_BaseGitNamespace):
369 supports_merge_proposals = True369 supports_merge_proposals = True
370 supports_code_imports = True370 supports_code_imports = True
371 allow_recipe_name_from_target = True371 allow_recipe_name_from_target = True
372 show_push_url_hints = True372 supports_repository_forking = True
373373
374 def __init__(self, person, project):374 def __init__(self, person, project):
375 self.owner = person375 self.owner = person
@@ -461,7 +461,7 @@ class PackageGitNamespace(_BaseGitNamespace):
461 supports_merge_proposals = True461 supports_merge_proposals = True
462 supports_code_imports = True462 supports_code_imports = True
463 allow_recipe_name_from_target = True463 allow_recipe_name_from_target = True
464 show_push_url_hints = True464 supports_repository_forking = True
465465
466 def __init__(self, person, distro_source_package):466 def __init__(self, person, distro_source_package):
467 self.owner = person467 self.owner = person
@@ -553,7 +553,7 @@ class OCIProjectGitNamespace(_BaseGitNamespace):
553 supports_merge_proposals = True553 supports_merge_proposals = True
554 supports_code_imports = True554 supports_code_imports = True
555 allow_recipe_name_from_target = True555 allow_recipe_name_from_target = True
556 show_push_url_hints = False556 supports_repository_forking = False
557557
558 def __init__(self, person, oci_project):558 def __init__(self, person, oci_project):
559 self.owner = person559 self.owner = person
diff --git a/lib/lp/code/model/tests/test_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py
index f873782..3755bbd 100644
--- a/lib/lp/code/model/tests/test_gitnamespace.py
+++ b/lib/lp/code/model/tests/test_gitnamespace.py
@@ -24,6 +24,7 @@ from lp.code.errors import (
24 GitRepositoryCreatorNotOwner,24 GitRepositoryCreatorNotOwner,
25 GitRepositoryExists,25 GitRepositoryExists,
26 )26 )
27from lp.code.interfaces.githosting import IGitHostingClient
27from lp.code.interfaces.gitnamespace import (28from lp.code.interfaces.gitnamespace import (
28 get_git_namespace,29 get_git_namespace,
29 IGitNamespace,30 IGitNamespace,
@@ -45,10 +46,12 @@ from lp.registry.interfaces.accesspolicy import (
45 IAccessPolicyGrantFlatSource,46 IAccessPolicyGrantFlatSource,
46 IAccessPolicySource,47 IAccessPolicySource,
47 )48 )
49from lp.services.compat import mock
48from lp.testing import (50from lp.testing import (
49 person_logged_in,51 person_logged_in,
50 TestCaseWithFactory,52 TestCaseWithFactory,
51 )53 )
54from lp.testing.fixture import ZopeUtilityFixture
52from lp.testing.layers import DatabaseFunctionalLayer55from lp.testing.layers import DatabaseFunctionalLayer
5356
5457
@@ -100,6 +103,36 @@ class NamespaceMixin:
100 GitRepositoryType.HOSTED, registrant, repository_name)103 GitRepositoryType.HOSTED, registrant, repository_name)
101 self.assertEqual([owner], list(repository.subscribers))104 self.assertEqual([owner], list(repository.subscribers))
102105
106 def test_createRepository_creates_on_githosting_sync(self):
107 hosting_client = mock.Mock()
108 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
109 owner = self.factory.makeTeam()
110 namespace = self.getNamespace(owner)
111 repository_name = self.factory.getUniqueUnicode()
112 registrant = owner.teamowner
113 repository = namespace.createRepository(
114 GitRepositoryType.HOSTED, registrant, repository_name,
115 with_hosting=True)
116 path = repository.getInternalPath()
117 self.assertEqual(
118 [mock.call(path, clone_from=None, async_create=False)],
119 hosting_client.create.call_args_list)
120
121 def test_createRepository_creates_on_githosting_async(self):
122 hosting_client = mock.Mock()
123 self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient))
124 owner = self.factory.makeTeam()
125 namespace = self.getNamespace(owner)
126 repository_name = self.factory.getUniqueUnicode()
127 registrant = owner.teamowner
128 repository = namespace.createRepository(
129 GitRepositoryType.HOSTED, registrant, repository_name,
130 with_hosting=True, async_hosting=True)
131 path = repository.getInternalPath()
132 self.assertEqual(
133 [mock.call(path, clone_from=None, async_create=True)],
134 hosting_client.create.call_args_list)
135
103 def test_getRepositories_no_repositories(self):136 def test_getRepositories_no_repositories(self):
104 # getRepositories on an IGitNamespace returns a result set of137 # getRepositories on an IGitNamespace returns a result set of
105 # repositories in that namespace. If there are no repositories, the138 # 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 928bf83..156fd51 100644
--- a/lib/lp/code/templates/git-macros.pt
+++ b/lib/lp/code/templates/git-macros.pt
@@ -68,6 +68,11 @@
68 </tt>68 </tt>
69 </dd>69 </dd>
70 </dl>70 </dl>
71 <dt tal:condition="python: getattr(view, 'allow_fork', False)">
72 <a tal:attributes="href view/fork_url" class="sprite add">
73 Fork it to your account
74 </a>
75 </dt>
71 </tal:can-push>76 </tal:can-push>
7277
73 <tal:cannot-push condition="not:view/user_can_push">78 <tal:cannot-push condition="not:view/user_can_push">
@@ -83,7 +88,7 @@
83 tal:content="context/owner/displayname">Team</a>88 tal:content="context/owner/displayname">Team</a>
84 can push to this <tal:kind replace="kind" />.89 can push to this <tal:kind replace="kind" />.
85 </tal:team>90 </tal:team>
86 <dl tal:condition="context/namespace/show_push_url_hints" id="push-url">91 <dl tal:condition="context/namespace/supports_repository_forking" id="push-url">
87 <dt>To fork this repository and propose fixes from there, push to this repository:</dt>92 <dt>To fork this repository and propose fixes from there, push to this repository:</dt>
88 <dd>93 <dd>
89 <tt class="command">94 <tt class="command">
@@ -92,6 +97,12 @@
92 <em>BRANCHNAME</em>97 <em>BRANCHNAME</em>
93 </tt>98 </tt>
94 </dd>99 </dd>
100 <dd tal:condition="python: getattr(view, 'allow_fork', False)">
101 or
102 <a tal:attributes="href view/fork_url" class="sprite add">
103 fork it directly to your account
104 </a>.
105 </dd>
95 </dl>106 </dl>
96 </tal:cannot-push>107 </tal:cannot-push>
97 <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions">108 <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions">
diff --git a/lib/lp/code/templates/gitrepository-fork.pt b/lib/lp/code/templates/gitrepository-fork.pt
98new file mode 100644109new file mode 100644
index 0000000..e656dcb
--- /dev/null
+++ b/lib/lp/code/templates/gitrepository-fork.pt
@@ -0,0 +1,22 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad">
8 <body>
9 <div metal:fill-slot="main">
10 <p>
11 This will create a copy of
12 <a tal:replace="structure context/fmt:link" /> in your account.
13 </p>
14 <p>
15 After the fork process, you will be able to git clone the
16 repository, push to it and create merge proposals to the original
17 repository.
18 </p>
19 <div metal:use-macro="context/@@launchpad_form/form" />
20 </div>
21 </body>
22</html>