Merge ~pappacena/launchpad:fork-button into launchpad:master
- Git
- lp:~pappacena/launchpad
- fork-button
- Merge into master
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) |
Related bugs: |
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
Description of the change
Thiago F. Pappacena (pappacena) wrote : | # |
- d87633a... by Thiago F. Pappacena
-
Merge branch 'git-fork-backend' into fork-button
- 4e5bb5e... by Thiago F. Pappacena
-
Fixing IGitRepositoryS
et.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
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.
- e657c03... by Thiago F. Pappacena
-
Merge branch 'git-fork-backend' into fork-button
Thiago F. Pappacena (pappacena) wrote : | # |
Some screenshots, while I work on the diff comments:
The fork button itself: https:/
New repository's owner selection: https:/
Owners selector open: https:/
- 52c0f04... by Thiago F. Pappacena
-
Refactoring
- bc92b17... by Thiago F. Pappacena
-
Fixing tests
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.)
- 52ffd87... by Thiago F. Pappacena
-
Rearrange fork link position
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:/
- When user cannot push directly (and there is the push URL hint): https:/
I would like some opinion on the text before landing this.
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.
- 68e062a... by Thiago F. Pappacena
-
Linting the code and changing fork message
Thiago F. Pappacena (pappacena) wrote : | # |
Pushed the changes
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
1 | diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml | |||
2 | index 499a86a..e7943c0 100644 | |||
3 | --- a/lib/lp/code/browser/configure.zcml | |||
4 | +++ b/lib/lp/code/browser/configure.zcml | |||
5 | @@ -1,4 +1,4 @@ | |||
7 | 1 | <!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the | 1 | <!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
8 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | 2 | GNU Affero General Public License version 3 (see the file LICENSE). |
9 | 3 | --> | 3 | --> |
10 | 4 | 4 | ||
11 | @@ -885,6 +885,12 @@ | |||
12 | 885 | template="../templates/gitrepository-rescan.pt"/> | 885 | template="../templates/gitrepository-rescan.pt"/> |
13 | 886 | <browser:page | 886 | <browser:page |
14 | 887 | for="lp.code.interfaces.gitrepository.IGitRepository" | 887 | for="lp.code.interfaces.gitrepository.IGitRepository" |
15 | 888 | class="lp.code.browser.gitrepository.GitRepositoryForkView" | ||
16 | 889 | permission="launchpad.View" | ||
17 | 890 | name="+fork" | ||
18 | 891 | template="../templates/gitrepository-fork.pt"/> | ||
19 | 892 | <browser:page | ||
20 | 893 | for="lp.code.interfaces.gitrepository.IGitRepository" | ||
21 | 888 | class="lp.code.browser.codeimport.CodeImportEditView" | 894 | class="lp.code.browser.codeimport.CodeImportEditView" |
22 | 889 | permission="launchpad.Edit" | 895 | permission="launchpad.Edit" |
23 | 890 | name="+edit-import" | 896 | name="+edit-import" |
24 | diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py | |||
25 | index 76b9f7d..2552ffb 100644 | |||
26 | --- a/lib/lp/code/browser/gitrepository.py | |||
27 | +++ b/lib/lp/code/browser/gitrepository.py | |||
28 | @@ -40,6 +40,7 @@ from six.moves.urllib_parse import ( | |||
29 | 40 | from zope.component import getUtility | 40 | from zope.component import getUtility |
30 | 41 | from zope.event import notify | 41 | from zope.event import notify |
31 | 42 | from zope.formlib import form | 42 | from zope.formlib import form |
32 | 43 | from zope.formlib.form import FormFields | ||
33 | 43 | from zope.formlib.textwidgets import IntWidget | 44 | from zope.formlib.textwidgets import IntWidget |
34 | 44 | from zope.formlib.widget import CustomWidgetFactory | 45 | from zope.formlib.widget import CustomWidgetFactory |
35 | 45 | from zope.interface import ( | 46 | from zope.interface import ( |
36 | @@ -58,6 +59,7 @@ from zope.schema.vocabulary import ( | |||
37 | 58 | SimpleTerm, | 59 | SimpleTerm, |
38 | 59 | SimpleVocabulary, | 60 | SimpleVocabulary, |
39 | 60 | ) | 61 | ) |
40 | 62 | from zope.security.interfaces import Unauthorized | ||
41 | 61 | 63 | ||
42 | 62 | from lp import _ | 64 | from lp import _ |
43 | 63 | from lp.app.browser.informationtype import InformationTypePortletMixin | 65 | from lp.app.browser.informationtype import InformationTypePortletMixin |
44 | @@ -103,6 +105,7 @@ from lp.code.interfaces.gitref import IGitRefBatchNavigator | |||
45 | 103 | from lp.code.interfaces.gitrepository import ( | 105 | from lp.code.interfaces.gitrepository import ( |
46 | 104 | ContributorGitIdentity, | 106 | ContributorGitIdentity, |
47 | 105 | IGitRepository, | 107 | IGitRepository, |
48 | 108 | IGitRepositorySet, | ||
49 | 106 | ) | 109 | ) |
50 | 107 | from lp.code.vocabularies.gitrule import GitPermissionsVocabulary | 110 | from lp.code.vocabularies.gitrule import GitPermissionsVocabulary |
51 | 108 | from lp.registry.interfaces.person import ( | 111 | from lp.registry.interfaces.person import ( |
52 | @@ -141,6 +144,9 @@ from lp.services.webhooks.browser import WebhookTargetNavigationMixin | |||
53 | 141 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin | 144 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
54 | 142 | 145 | ||
55 | 143 | 146 | ||
56 | 147 | GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled' | ||
57 | 148 | |||
58 | 149 | |||
59 | 144 | @implementer(ICanonicalUrlData) | 150 | @implementer(ICanonicalUrlData) |
60 | 145 | class GitRepositoryURL: | 151 | class GitRepositoryURL: |
61 | 146 | """Git repository URL creation rules.""" | 152 | """Git repository URL creation rules.""" |
62 | @@ -479,6 +485,62 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView, | |||
63 | 479 | return "This repository is being created." | 485 | return "This repository is being created." |
64 | 480 | return None | 486 | return None |
65 | 481 | 487 | ||
66 | 488 | @property | ||
67 | 489 | def allow_fork(self): | ||
68 | 490 | if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED): | ||
69 | 491 | return False | ||
70 | 492 | # User cannot fork repositories they already own (note that forking a | ||
71 | 493 | # repository owned by a team the user is in is still fine). | ||
72 | 494 | if self.context.owner == self.user: | ||
73 | 495 | return False | ||
74 | 496 | return self.context.namespace.supports_repository_forking | ||
75 | 497 | |||
76 | 498 | @property | ||
77 | 499 | def fork_url(self): | ||
78 | 500 | return canonical_url(self.context, view_name='+fork') | ||
79 | 501 | |||
80 | 502 | |||
81 | 503 | class GitRepositoryForkView(LaunchpadEditFormView): | ||
82 | 504 | |||
83 | 505 | schema = Interface | ||
84 | 506 | |||
85 | 507 | field_names = [] | ||
86 | 508 | |||
87 | 509 | def initialize(self): | ||
88 | 510 | if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED): | ||
89 | 511 | raise Unauthorized() | ||
90 | 512 | super(GitRepositoryForkView, self).initialize() | ||
91 | 513 | |||
92 | 514 | def setUpFields(self): | ||
93 | 515 | super(GitRepositoryForkView, self).setUpFields() | ||
94 | 516 | owner_field = Choice( | ||
95 | 517 | vocabulary='AllUserTeamsParticipationPlusSelf', | ||
96 | 518 | title=u'Fork to the following owner', required=True, | ||
97 | 519 | __name__=u'owner') | ||
98 | 520 | self.form_fields += FormFields(owner_field) | ||
99 | 521 | |||
100 | 522 | @property | ||
101 | 523 | def initial_values(self): | ||
102 | 524 | return {'owner': self.user} | ||
103 | 525 | |||
104 | 526 | def validate(self, data): | ||
105 | 527 | new_owner = data.get("owner") | ||
106 | 528 | if not new_owner or not self.user.inTeam(new_owner): | ||
107 | 529 | self.setFieldError( | ||
108 | 530 | "owner", | ||
109 | 531 | "You should select a valid user to fork the repository.") | ||
110 | 532 | |||
111 | 533 | @action('Fork it', name='fork') | ||
112 | 534 | def fork(self, action, data): | ||
113 | 535 | forked = getUtility(IGitRepositorySet).fork( | ||
114 | 536 | self.context, self.user, data.get("owner")) | ||
115 | 537 | self.request.response.addNotification("Repository forked.") | ||
116 | 538 | self.next_url = canonical_url(forked) | ||
117 | 539 | |||
118 | 540 | @property | ||
119 | 541 | def cancel_url(self): | ||
120 | 542 | return canonical_url(self.context) | ||
121 | 543 | |||
122 | 482 | 544 | ||
123 | 483 | class GitRepositoryRescanView(LaunchpadEditFormView): | 545 | class GitRepositoryRescanView(LaunchpadEditFormView): |
124 | 484 | 546 | ||
125 | diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py | |||
126 | index d3211ba..912965d 100644 | |||
127 | --- a/lib/lp/code/browser/tests/test_gitrepository.py | |||
128 | +++ b/lib/lp/code/browser/tests/test_gitrepository.py | |||
129 | @@ -18,6 +18,10 @@ from textwrap import dedent | |||
130 | 18 | from fixtures import FakeLogger | 18 | from fixtures import FakeLogger |
131 | 19 | import pytz | 19 | import pytz |
132 | 20 | import soupmatchers | 20 | import soupmatchers |
133 | 21 | from soupmatchers import ( | ||
134 | 22 | Tag, | ||
135 | 23 | HTMLContains, | ||
136 | 24 | ) | ||
137 | 21 | from storm.store import Store | 25 | from storm.store import Store |
138 | 22 | from testtools.matchers import ( | 26 | from testtools.matchers import ( |
139 | 23 | AfterPreprocessing, | 27 | AfterPreprocessing, |
140 | @@ -28,6 +32,7 @@ from testtools.matchers import ( | |||
141 | 28 | MatchesListwise, | 32 | MatchesListwise, |
142 | 29 | MatchesSetwise, | 33 | MatchesSetwise, |
143 | 30 | MatchesStructure, | 34 | MatchesStructure, |
144 | 35 | Not, | ||
145 | 31 | ) | 36 | ) |
146 | 32 | import transaction | 37 | import transaction |
147 | 33 | from zope.component import getUtility | 38 | from zope.component import getUtility |
148 | @@ -40,16 +45,21 @@ from lp.app.enums import InformationType | |||
149 | 40 | from lp.app.errors import UnexpectedFormData | 45 | from lp.app.errors import UnexpectedFormData |
150 | 41 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities | 46 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
151 | 42 | from lp.app.interfaces.services import IService | 47 | from lp.app.interfaces.services import IService |
153 | 43 | from lp.code.browser.gitrepository import encode_form_field_id | 48 | from lp.code.browser.gitrepository import ( |
154 | 49 | encode_form_field_id, | ||
155 | 50 | GIT_REPOSITORY_FORK_ENABLED, | ||
156 | 51 | ) | ||
157 | 44 | from lp.code.enums import ( | 52 | from lp.code.enums import ( |
158 | 45 | BranchMergeProposalStatus, | 53 | BranchMergeProposalStatus, |
159 | 46 | CodeReviewVote, | 54 | CodeReviewVote, |
160 | 47 | GitActivityType, | 55 | GitActivityType, |
161 | 48 | GitGranteeType, | 56 | GitGranteeType, |
162 | 57 | GitListingSort, | ||
163 | 49 | GitPermissionType, | 58 | GitPermissionType, |
164 | 50 | GitRepositoryStatus, | 59 | GitRepositoryStatus, |
165 | 51 | GitRepositoryType, | 60 | GitRepositoryType, |
166 | 52 | ) | 61 | ) |
167 | 62 | from lp.code.interfaces.gitcollection import IGitCollection | ||
168 | 53 | from lp.code.interfaces.gitrepository import IGitRepositorySet | 63 | from lp.code.interfaces.gitrepository import IGitRepositorySet |
169 | 54 | from lp.code.interfaces.revision import IRevisionSet | 64 | from lp.code.interfaces.revision import IRevisionSet |
170 | 55 | from lp.code.model.gitjob import GitRefScanJob | 65 | from lp.code.model.gitjob import GitRefScanJob |
171 | @@ -131,6 +141,10 @@ class TestGitRepositoryView(BrowserTestCase): | |||
172 | 131 | 141 | ||
173 | 132 | layer = LaunchpadFunctionalLayer | 142 | layer = LaunchpadFunctionalLayer |
174 | 133 | 143 | ||
175 | 144 | def setUp(self): | ||
176 | 145 | super(TestGitRepositoryView, self).setUp() | ||
177 | 146 | self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'})) | ||
178 | 147 | |||
179 | 134 | def test_clone_instructions(self): | 148 | def test_clone_instructions(self): |
180 | 135 | repository = self.factory.makeGitRepository() | 149 | repository = self.factory.makeGitRepository() |
181 | 136 | username = repository.owner.name | 150 | username = repository.owner.name |
182 | @@ -538,7 +552,7 @@ class TestGitRepositoryView(BrowserTestCase): | |||
183 | 538 | repository, "+repository-portlet-subscriber-content") | 552 | repository, "+repository-portlet-subscriber-content") |
184 | 539 | with StormStatementRecorder() as recorder: | 553 | with StormStatementRecorder() as recorder: |
185 | 540 | view.render() | 554 | view.render() |
187 | 541 | self.assertThat(recorder, HasQueryCount(Equals(6))) | 555 | self.assertThat(recorder, HasQueryCount(Equals(7))) |
188 | 542 | 556 | ||
189 | 543 | def test_show_rescan_link(self): | 557 | def test_show_rescan_link(self): |
190 | 544 | repository = self.factory.makeGitRepository() | 558 | repository = self.factory.makeGitRepository() |
191 | @@ -573,6 +587,65 @@ class TestGitRepositoryView(BrowserTestCase): | |||
192 | 573 | result = view.show_rescan_link | 587 | result = view.show_rescan_link |
193 | 574 | self.assertTrue(result) | 588 | self.assertTrue(result) |
194 | 575 | 589 | ||
195 | 590 | def assertContainsForkLink(self, browser, repository, text): | ||
196 | 591 | """Asserts that browser contains the fork link with the given text""" | ||
197 | 592 | with admin_logged_in(): | ||
198 | 593 | url = canonical_url(repository, view_name='+fork') | ||
199 | 594 | fork_link = Tag( | ||
200 | 595 | "fork link", "a", | ||
201 | 596 | text=re.compile(re.escape(text)), | ||
202 | 597 | attrs={"class": "sprite add", "href": url}) | ||
203 | 598 | self.assertThat(browser.contents, HTMLContains(fork_link)) | ||
204 | 599 | |||
205 | 600 | def assertDoesntContainForkLink(self, browser, repository, texts): | ||
206 | 601 | """Asserts that there is no fork button with any of the given texts.""" | ||
207 | 602 | with admin_logged_in(): | ||
208 | 603 | url = canonical_url(repository, view_name='+fork') | ||
209 | 604 | for text in texts: | ||
210 | 605 | fork_link = Tag( | ||
211 | 606 | "fork link", "a", | ||
212 | 607 | text=re.compile(re.escape(text)), | ||
213 | 608 | attrs={"class": "sprite add", "href": url}) | ||
214 | 609 | self.assertThat(browser.contents, Not(HTMLContains(fork_link))) | ||
215 | 610 | |||
216 | 611 | def test_hide_fork_link_for_repos_targeting_person(self): | ||
217 | 612 | person = self.factory.makePerson() | ||
218 | 613 | another_person = self.factory.makePerson() | ||
219 | 614 | repository = self.factory.makeGitRepository(target=person) | ||
220 | 615 | browser = self.getViewBrowser( | ||
221 | 616 | repository, '+index', user=another_person) | ||
222 | 617 | self.assertDoesntContainForkLink(browser, repository, [ | ||
223 | 618 | "Fork it to your account", | ||
224 | 619 | "Or click here to fork it to your account.", | ||
225 | 620 | ]) | ||
226 | 621 | |||
227 | 622 | def test_show_fork_link_for_the_right_users(self): | ||
228 | 623 | another_person = self.factory.makePerson() | ||
229 | 624 | repository = self.factory.makeGitRepository() | ||
230 | 625 | repo_owner = repository.owner | ||
231 | 626 | |||
232 | 627 | # Do not show the link for the repository owner. | ||
233 | 628 | browser = self.getViewBrowser(repository, '+index', user=repo_owner) | ||
234 | 629 | self.assertDoesntContainForkLink(browser, repository, [ | ||
235 | 630 | "Fork it to your account", | ||
236 | 631 | "fork it directly to your account", | ||
237 | 632 | ]) | ||
238 | 633 | |||
239 | 634 | # Shows for another person. | ||
240 | 635 | browser = self.getViewBrowser( | ||
241 | 636 | repository, '+index', user=another_person) | ||
242 | 637 | self.assertContainsForkLink( | ||
243 | 638 | browser, repository, "fork it directly to your account") | ||
244 | 639 | |||
245 | 640 | # Even for another person, do not show it if the feature flag is off. | ||
246 | 641 | self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''})) | ||
247 | 642 | browser = self.getViewBrowser( | ||
248 | 643 | repository, '+index', user=another_person) | ||
249 | 644 | self.assertDoesntContainForkLink(browser, repository, [ | ||
250 | 645 | "Fork it to your account", | ||
251 | 646 | "fork it directly to your account", | ||
252 | 647 | ]) | ||
253 | 648 | |||
254 | 576 | 649 | ||
255 | 577 | class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase): | 650 | class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase): |
256 | 578 | """Tests that Git repositories with private team artifacts can be viewed. | 651 | """Tests that Git repositories with private team artifacts can be viewed. |
257 | @@ -2071,3 +2144,126 @@ class TestGitRepositoryActivityView(BrowserTestCase): | |||
258 | 2071 | create_activity, | 2144 | create_activity, |
259 | 2072 | 2) | 2145 | 2) |
260 | 2073 | self.assertThat(recorder2, HasQueryCount.byEquality(recorder1)) | 2146 | self.assertThat(recorder2, HasQueryCount.byEquality(recorder1)) |
261 | 2147 | |||
262 | 2148 | |||
263 | 2149 | class TestGitRepositoryForkView(BrowserTestCase): | ||
264 | 2150 | |||
265 | 2151 | layer = DatabaseFunctionalLayer | ||
266 | 2152 | |||
267 | 2153 | def setUp(self): | ||
268 | 2154 | super(TestGitRepositoryForkView, self).setUp() | ||
269 | 2155 | self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'})) | ||
270 | 2156 | |||
271 | 2157 | def getReposOwnedBy(self, user): | ||
272 | 2158 | return IGitCollection(user).getRepositories( | ||
273 | 2159 | sort_by=GitListingSort.NEWEST_FIRST) | ||
274 | 2160 | |||
275 | 2161 | def test_fork_page_redirects_with_disabled_feature(self): | ||
276 | 2162 | self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''})) | ||
277 | 2163 | with admin_logged_in(): | ||
278 | 2164 | repository = self.factory.makeGitRepository() | ||
279 | 2165 | owner = repository.owner | ||
280 | 2166 | self.assertRaises( | ||
281 | 2167 | Unauthorized, self.getViewBrowser, | ||
282 | 2168 | repository, "+fork", rootsite="code", user=owner) | ||
283 | 2169 | |||
284 | 2170 | def test_fork_page_shows_input(self): | ||
285 | 2171 | with admin_logged_in(): | ||
286 | 2172 | repository = self.factory.makeGitRepository() | ||
287 | 2173 | owner = removeSecurityProxy(repository.owner) | ||
288 | 2174 | |||
289 | 2175 | team = self.factory.makeTeam(members=[owner]) | ||
290 | 2176 | another_person = self.factory.makePerson() | ||
291 | 2177 | |||
292 | 2178 | select_owner = Tag( | ||
293 | 2179 | "owner selector", "select", | ||
294 | 2180 | attrs={"id": "field.owner", "name": "field.owner"}) | ||
295 | 2181 | |||
296 | 2182 | def person_option_tag(person): | ||
297 | 2183 | return Tag( | ||
298 | 2184 | "option for %s" % person, "option", | ||
299 | 2185 | text="%s (%s)" % (person.displayname, person.name), | ||
300 | 2186 | attrs=dict(value=person.name)) | ||
301 | 2187 | |||
302 | 2188 | option_owner = person_option_tag(owner) | ||
303 | 2189 | option_team = person_option_tag(team) | ||
304 | 2190 | option_another_person = person_option_tag(another_person) | ||
305 | 2191 | |||
306 | 2192 | browser = self.getViewBrowser( | ||
307 | 2193 | repository, "+fork", rootsite="code", user=owner) | ||
308 | 2194 | |||
309 | 2195 | html = browser.contents | ||
310 | 2196 | self.assertThat(html, HTMLContains(select_owner)) | ||
311 | 2197 | self.assertThat(html, HTMLContains(option_owner)) | ||
312 | 2198 | self.assertThat(html, HTMLContains(option_team)) | ||
313 | 2199 | self.assertThat(html, Not(HTMLContains(option_another_person))) | ||
314 | 2200 | |||
315 | 2201 | def test_fork_page_submit_to_self(self): | ||
316 | 2202 | self.useFixture(GitHostingFixture()) | ||
317 | 2203 | repository = self.factory.makeGitRepository() | ||
318 | 2204 | another_person = self.factory.makePerson() | ||
319 | 2205 | |||
320 | 2206 | with person_logged_in(another_person): | ||
321 | 2207 | view = create_initialized_view(repository, name="+fork", form={ | ||
322 | 2208 | "field.owner": another_person.name, | ||
323 | 2209 | "field.actions.fork": "Fork it"}) | ||
324 | 2210 | |||
325 | 2211 | forked = self.getReposOwnedBy(another_person)[0] | ||
326 | 2212 | self.assertNotEqual(forked, repository) | ||
327 | 2213 | self.assertEqual(another_person, forked.owner) | ||
328 | 2214 | |||
329 | 2215 | notifications = view.request.response.notifications | ||
330 | 2216 | self.assertEqual(1, len(notifications)) | ||
331 | 2217 | self.assertEqual("Repository forked.", notifications[0].message) | ||
332 | 2218 | |||
333 | 2219 | self.assertEqual(canonical_url(forked), view.next_url) | ||
334 | 2220 | |||
335 | 2221 | def test_fork_page_submit_to_team(self): | ||
336 | 2222 | self.useFixture(GitHostingFixture()) | ||
337 | 2223 | repository = self.factory.makeGitRepository() | ||
338 | 2224 | another_person = self.factory.makePerson() | ||
339 | 2225 | team = self.factory.makeTeam(members=[another_person]) | ||
340 | 2226 | |||
341 | 2227 | with person_logged_in(another_person): | ||
342 | 2228 | view = create_initialized_view(repository, name="+fork", form={ | ||
343 | 2229 | "field.owner": team.name, | ||
344 | 2230 | "field.actions.fork": "Fork it"}) | ||
345 | 2231 | |||
346 | 2232 | forked = self.getReposOwnedBy(team)[0] | ||
347 | 2233 | self.assertNotEqual(forked, repository) | ||
348 | 2234 | self.assertEqual(team, forked.owner) | ||
349 | 2235 | |||
350 | 2236 | notifications = view.request.response.notifications | ||
351 | 2237 | self.assertEqual(1, len(notifications)) | ||
352 | 2238 | self.assertEqual("Repository forked.", notifications[0].message) | ||
353 | 2239 | |||
354 | 2240 | self.assertEqual(canonical_url(forked), view.next_url) | ||
355 | 2241 | |||
356 | 2242 | def test_fork_page_submit_missing_user(self): | ||
357 | 2243 | self.useFixture(GitHostingFixture()) | ||
358 | 2244 | repository = self.factory.makeGitRepository() | ||
359 | 2245 | another_person = self.factory.makePerson() | ||
360 | 2246 | |||
361 | 2247 | with person_logged_in(another_person): | ||
362 | 2248 | view = create_initialized_view(repository, name="+fork", form={ | ||
363 | 2249 | "field.actions.fork": "Fork it"}) | ||
364 | 2250 | |||
365 | 2251 | # No repository should have been created. | ||
366 | 2252 | self.assertEqual(0, self.getReposOwnedBy(another_person).count()) | ||
367 | 2253 | self.assertEqual( | ||
368 | 2254 | ['You should select a valid user to fork the repository.'], | ||
369 | 2255 | view.errors) | ||
370 | 2256 | |||
371 | 2257 | self.assertEqual(None, view.next_url) | ||
372 | 2258 | |||
373 | 2259 | def test_fork_page_submit_invalid_user(self): | ||
374 | 2260 | self.useFixture(GitHostingFixture()) | ||
375 | 2261 | repository = self.factory.makeGitRepository() | ||
376 | 2262 | another_person = self.factory.makePerson() | ||
377 | 2263 | invalid_person = self.factory.makePerson() | ||
378 | 2264 | |||
379 | 2265 | with person_logged_in(another_person): | ||
380 | 2266 | create_initialized_view(repository, name="+fork", form={ | ||
381 | 2267 | "field.owner": invalid_person.name, | ||
382 | 2268 | "field.actions.fork": "Fork it"}) | ||
383 | 2269 | self.assertEqual(0, self.getReposOwnedBy(invalid_person).count()) | ||
384 | diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py | |||
385 | index 3a6e600..f4ff75a 100644 | |||
386 | --- a/lib/lp/code/interfaces/gitnamespace.py | |||
387 | +++ b/lib/lp/code/interfaces/gitnamespace.py | |||
388 | @@ -107,8 +107,8 @@ class IGitNamespacePolicy(Interface): | |||
389 | 107 | "True iff this namespace permits automatically setting a default " | 107 | "True iff this namespace permits automatically setting a default " |
390 | 108 | "repository on push.") | 108 | "repository on push.") |
391 | 109 | 109 | ||
394 | 110 | show_push_url_hints = Attribute( | 110 | supports_repository_forking = Attribute( |
395 | 111 | "True if this namespace permits display of the push URL hint.") | 111 | "Does this namespace support repository forking at all?") |
396 | 112 | 112 | ||
397 | 113 | supports_merge_proposals = Attribute( | 113 | supports_merge_proposals = Attribute( |
398 | 114 | "Does this namespace support merge proposals at all?") | 114 | "Does this namespace support merge proposals at all?") |
399 | diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py | |||
400 | index 22e9d8f..9d72ee5 100644 | |||
401 | --- a/lib/lp/code/model/gitnamespace.py | |||
402 | +++ b/lib/lp/code/model/gitnamespace.py | |||
403 | @@ -285,7 +285,7 @@ class PersonalGitNamespace(_BaseGitNamespace): | |||
404 | 285 | supports_merge_proposals = True | 285 | supports_merge_proposals = True |
405 | 286 | supports_code_imports = False | 286 | supports_code_imports = False |
406 | 287 | allow_recipe_name_from_target = False | 287 | allow_recipe_name_from_target = False |
408 | 288 | show_push_url_hints = False | 288 | supports_repository_forking = False |
409 | 289 | 289 | ||
410 | 290 | def __init__(self, person): | 290 | def __init__(self, person): |
411 | 291 | self.owner = person | 291 | self.owner = person |
412 | @@ -369,7 +369,7 @@ class ProjectGitNamespace(_BaseGitNamespace): | |||
413 | 369 | supports_merge_proposals = True | 369 | supports_merge_proposals = True |
414 | 370 | supports_code_imports = True | 370 | supports_code_imports = True |
415 | 371 | allow_recipe_name_from_target = True | 371 | allow_recipe_name_from_target = True |
417 | 372 | show_push_url_hints = True | 372 | supports_repository_forking = True |
418 | 373 | 373 | ||
419 | 374 | def __init__(self, person, project): | 374 | def __init__(self, person, project): |
420 | 375 | self.owner = person | 375 | self.owner = person |
421 | @@ -461,7 +461,7 @@ class PackageGitNamespace(_BaseGitNamespace): | |||
422 | 461 | supports_merge_proposals = True | 461 | supports_merge_proposals = True |
423 | 462 | supports_code_imports = True | 462 | supports_code_imports = True |
424 | 463 | allow_recipe_name_from_target = True | 463 | allow_recipe_name_from_target = True |
426 | 464 | show_push_url_hints = True | 464 | supports_repository_forking = True |
427 | 465 | 465 | ||
428 | 466 | def __init__(self, person, distro_source_package): | 466 | def __init__(self, person, distro_source_package): |
429 | 467 | self.owner = person | 467 | self.owner = person |
430 | @@ -553,7 +553,7 @@ class OCIProjectGitNamespace(_BaseGitNamespace): | |||
431 | 553 | supports_merge_proposals = True | 553 | supports_merge_proposals = True |
432 | 554 | supports_code_imports = True | 554 | supports_code_imports = True |
433 | 555 | allow_recipe_name_from_target = True | 555 | allow_recipe_name_from_target = True |
435 | 556 | show_push_url_hints = False | 556 | supports_repository_forking = False |
436 | 557 | 557 | ||
437 | 558 | def __init__(self, person, oci_project): | 558 | def __init__(self, person, oci_project): |
438 | 559 | self.owner = person | 559 | self.owner = person |
439 | diff --git a/lib/lp/code/model/tests/test_gitnamespace.py b/lib/lp/code/model/tests/test_gitnamespace.py | |||
440 | index f873782..3755bbd 100644 | |||
441 | --- a/lib/lp/code/model/tests/test_gitnamespace.py | |||
442 | +++ b/lib/lp/code/model/tests/test_gitnamespace.py | |||
443 | @@ -24,6 +24,7 @@ from lp.code.errors import ( | |||
444 | 24 | GitRepositoryCreatorNotOwner, | 24 | GitRepositoryCreatorNotOwner, |
445 | 25 | GitRepositoryExists, | 25 | GitRepositoryExists, |
446 | 26 | ) | 26 | ) |
447 | 27 | from lp.code.interfaces.githosting import IGitHostingClient | ||
448 | 27 | from lp.code.interfaces.gitnamespace import ( | 28 | from lp.code.interfaces.gitnamespace import ( |
449 | 28 | get_git_namespace, | 29 | get_git_namespace, |
450 | 29 | IGitNamespace, | 30 | IGitNamespace, |
451 | @@ -45,10 +46,12 @@ from lp.registry.interfaces.accesspolicy import ( | |||
452 | 45 | IAccessPolicyGrantFlatSource, | 46 | IAccessPolicyGrantFlatSource, |
453 | 46 | IAccessPolicySource, | 47 | IAccessPolicySource, |
454 | 47 | ) | 48 | ) |
455 | 49 | from lp.services.compat import mock | ||
456 | 48 | from lp.testing import ( | 50 | from lp.testing import ( |
457 | 49 | person_logged_in, | 51 | person_logged_in, |
458 | 50 | TestCaseWithFactory, | 52 | TestCaseWithFactory, |
459 | 51 | ) | 53 | ) |
460 | 54 | from lp.testing.fixture import ZopeUtilityFixture | ||
461 | 52 | from lp.testing.layers import DatabaseFunctionalLayer | 55 | from lp.testing.layers import DatabaseFunctionalLayer |
462 | 53 | 56 | ||
463 | 54 | 57 | ||
464 | @@ -100,6 +103,36 @@ class NamespaceMixin: | |||
465 | 100 | GitRepositoryType.HOSTED, registrant, repository_name) | 103 | GitRepositoryType.HOSTED, registrant, repository_name) |
466 | 101 | self.assertEqual([owner], list(repository.subscribers)) | 104 | self.assertEqual([owner], list(repository.subscribers)) |
467 | 102 | 105 | ||
468 | 106 | def test_createRepository_creates_on_githosting_sync(self): | ||
469 | 107 | hosting_client = mock.Mock() | ||
470 | 108 | self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient)) | ||
471 | 109 | owner = self.factory.makeTeam() | ||
472 | 110 | namespace = self.getNamespace(owner) | ||
473 | 111 | repository_name = self.factory.getUniqueUnicode() | ||
474 | 112 | registrant = owner.teamowner | ||
475 | 113 | repository = namespace.createRepository( | ||
476 | 114 | GitRepositoryType.HOSTED, registrant, repository_name, | ||
477 | 115 | with_hosting=True) | ||
478 | 116 | path = repository.getInternalPath() | ||
479 | 117 | self.assertEqual( | ||
480 | 118 | [mock.call(path, clone_from=None, async_create=False)], | ||
481 | 119 | hosting_client.create.call_args_list) | ||
482 | 120 | |||
483 | 121 | def test_createRepository_creates_on_githosting_async(self): | ||
484 | 122 | hosting_client = mock.Mock() | ||
485 | 123 | self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient)) | ||
486 | 124 | owner = self.factory.makeTeam() | ||
487 | 125 | namespace = self.getNamespace(owner) | ||
488 | 126 | repository_name = self.factory.getUniqueUnicode() | ||
489 | 127 | registrant = owner.teamowner | ||
490 | 128 | repository = namespace.createRepository( | ||
491 | 129 | GitRepositoryType.HOSTED, registrant, repository_name, | ||
492 | 130 | with_hosting=True, async_hosting=True) | ||
493 | 131 | path = repository.getInternalPath() | ||
494 | 132 | self.assertEqual( | ||
495 | 133 | [mock.call(path, clone_from=None, async_create=True)], | ||
496 | 134 | hosting_client.create.call_args_list) | ||
497 | 135 | |||
498 | 103 | def test_getRepositories_no_repositories(self): | 136 | def test_getRepositories_no_repositories(self): |
499 | 104 | # getRepositories on an IGitNamespace returns a result set of | 137 | # getRepositories on an IGitNamespace returns a result set of |
500 | 105 | # repositories in that namespace. If there are no repositories, the | 138 | # repositories in that namespace. If there are no repositories, the |
501 | diff --git a/lib/lp/code/templates/git-macros.pt b/lib/lp/code/templates/git-macros.pt | |||
502 | index 928bf83..156fd51 100644 | |||
503 | --- a/lib/lp/code/templates/git-macros.pt | |||
504 | +++ b/lib/lp/code/templates/git-macros.pt | |||
505 | @@ -68,6 +68,11 @@ | |||
506 | 68 | </tt> | 68 | </tt> |
507 | 69 | </dd> | 69 | </dd> |
508 | 70 | </dl> | 70 | </dl> |
509 | 71 | <dt tal:condition="python: getattr(view, 'allow_fork', False)"> | ||
510 | 72 | <a tal:attributes="href view/fork_url" class="sprite add"> | ||
511 | 73 | Fork it to your account | ||
512 | 74 | </a> | ||
513 | 75 | </dt> | ||
514 | 71 | </tal:can-push> | 76 | </tal:can-push> |
515 | 72 | 77 | ||
516 | 73 | <tal:cannot-push condition="not:view/user_can_push"> | 78 | <tal:cannot-push condition="not:view/user_can_push"> |
517 | @@ -83,7 +88,7 @@ | |||
518 | 83 | tal:content="context/owner/displayname">Team</a> | 88 | tal:content="context/owner/displayname">Team</a> |
519 | 84 | can push to this <tal:kind replace="kind" />. | 89 | can push to this <tal:kind replace="kind" />. |
520 | 85 | </tal:team> | 90 | </tal:team> |
522 | 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"> |
523 | 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> |
524 | 88 | <dd> | 93 | <dd> |
525 | 89 | <tt class="command"> | 94 | <tt class="command"> |
526 | @@ -92,6 +97,12 @@ | |||
527 | 92 | <em>BRANCHNAME</em> | 97 | <em>BRANCHNAME</em> |
528 | 93 | </tt> | 98 | </tt> |
529 | 94 | </dd> | 99 | </dd> |
530 | 100 | <dd tal:condition="python: getattr(view, 'allow_fork', False)"> | ||
531 | 101 | or | ||
532 | 102 | <a tal:attributes="href view/fork_url" class="sprite add"> | ||
533 | 103 | fork it directly to your account | ||
534 | 104 | </a>. | ||
535 | 105 | </dd> | ||
536 | 95 | </dl> | 106 | </dl> |
537 | 96 | </tal:cannot-push> | 107 | </tal:cannot-push> |
538 | 97 | <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions"> | 108 | <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions"> |
539 | diff --git a/lib/lp/code/templates/gitrepository-fork.pt b/lib/lp/code/templates/gitrepository-fork.pt | |||
540 | 98 | new file mode 100644 | 109 | new file mode 100644 |
541 | index 0000000..e656dcb | |||
542 | --- /dev/null | |||
543 | +++ b/lib/lp/code/templates/gitrepository-fork.pt | |||
544 | @@ -0,0 +1,22 @@ | |||
545 | 1 | <html | ||
546 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
547 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
548 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
549 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
550 | 6 | metal:use-macro="view/macro:page/main_only" | ||
551 | 7 | i18n:domain="launchpad"> | ||
552 | 8 | <body> | ||
553 | 9 | <div metal:fill-slot="main"> | ||
554 | 10 | <p> | ||
555 | 11 | This will create a copy of | ||
556 | 12 | <a tal:replace="structure context/fmt:link" /> in your account. | ||
557 | 13 | </p> | ||
558 | 14 | <p> | ||
559 | 15 | After the fork process, you will be able to git clone the | ||
560 | 16 | repository, push to it and create merge proposals to the original | ||
561 | 17 | repository. | ||
562 | 18 | </p> | ||
563 | 19 | <div metal:use-macro="context/@@launchpad_form/form" /> | ||
564 | 20 | </div> | ||
565 | 21 | </body> | ||
566 | 22 | </html> |
This branch depends on https:/ /code.launchpad .net/~pappacena /launchpad/ +git/launchpad/ +merge/ 387146