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 @@ |
6 | -<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
7 | +<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
8 | GNU Affero General Public License version 3 (see the file LICENSE). |
9 | --> |
10 | |
11 | @@ -885,6 +885,12 @@ |
12 | template="../templates/gitrepository-rescan.pt"/> |
13 | <browser:page |
14 | for="lp.code.interfaces.gitrepository.IGitRepository" |
15 | + class="lp.code.browser.gitrepository.GitRepositoryForkView" |
16 | + permission="launchpad.View" |
17 | + name="+fork" |
18 | + template="../templates/gitrepository-fork.pt"/> |
19 | + <browser:page |
20 | + for="lp.code.interfaces.gitrepository.IGitRepository" |
21 | class="lp.code.browser.codeimport.CodeImportEditView" |
22 | permission="launchpad.Edit" |
23 | 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 | from zope.component import getUtility |
30 | from zope.event import notify |
31 | from zope.formlib import form |
32 | +from zope.formlib.form import FormFields |
33 | from zope.formlib.textwidgets import IntWidget |
34 | from zope.formlib.widget import CustomWidgetFactory |
35 | from zope.interface import ( |
36 | @@ -58,6 +59,7 @@ from zope.schema.vocabulary import ( |
37 | SimpleTerm, |
38 | SimpleVocabulary, |
39 | ) |
40 | +from zope.security.interfaces import Unauthorized |
41 | |
42 | from lp import _ |
43 | from lp.app.browser.informationtype import InformationTypePortletMixin |
44 | @@ -103,6 +105,7 @@ from lp.code.interfaces.gitref import IGitRefBatchNavigator |
45 | from lp.code.interfaces.gitrepository import ( |
46 | ContributorGitIdentity, |
47 | IGitRepository, |
48 | + IGitRepositorySet, |
49 | ) |
50 | from lp.code.vocabularies.gitrule import GitPermissionsVocabulary |
51 | from lp.registry.interfaces.person import ( |
52 | @@ -141,6 +144,9 @@ from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
53 | from lp.snappy.browser.hassnaps import HasSnapsViewMixin |
54 | |
55 | |
56 | +GIT_REPOSITORY_FORK_ENABLED = 'gitrepository.fork.enabled' |
57 | + |
58 | + |
59 | @implementer(ICanonicalUrlData) |
60 | class GitRepositoryURL: |
61 | """Git repository URL creation rules.""" |
62 | @@ -479,6 +485,62 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView, |
63 | return "This repository is being created." |
64 | return None |
65 | |
66 | + @property |
67 | + def allow_fork(self): |
68 | + if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED): |
69 | + return False |
70 | + # User cannot fork repositories they already own (note that forking a |
71 | + # repository owned by a team the user is in is still fine). |
72 | + if self.context.owner == self.user: |
73 | + return False |
74 | + return self.context.namespace.supports_repository_forking |
75 | + |
76 | + @property |
77 | + def fork_url(self): |
78 | + return canonical_url(self.context, view_name='+fork') |
79 | + |
80 | + |
81 | +class GitRepositoryForkView(LaunchpadEditFormView): |
82 | + |
83 | + schema = Interface |
84 | + |
85 | + field_names = [] |
86 | + |
87 | + def initialize(self): |
88 | + if not getFeatureFlag(GIT_REPOSITORY_FORK_ENABLED): |
89 | + raise Unauthorized() |
90 | + super(GitRepositoryForkView, self).initialize() |
91 | + |
92 | + def setUpFields(self): |
93 | + super(GitRepositoryForkView, self).setUpFields() |
94 | + owner_field = Choice( |
95 | + vocabulary='AllUserTeamsParticipationPlusSelf', |
96 | + title=u'Fork to the following owner', required=True, |
97 | + __name__=u'owner') |
98 | + self.form_fields += FormFields(owner_field) |
99 | + |
100 | + @property |
101 | + def initial_values(self): |
102 | + return {'owner': self.user} |
103 | + |
104 | + def validate(self, data): |
105 | + new_owner = data.get("owner") |
106 | + if not new_owner or not self.user.inTeam(new_owner): |
107 | + self.setFieldError( |
108 | + "owner", |
109 | + "You should select a valid user to fork the repository.") |
110 | + |
111 | + @action('Fork it', name='fork') |
112 | + def fork(self, action, data): |
113 | + forked = getUtility(IGitRepositorySet).fork( |
114 | + self.context, self.user, data.get("owner")) |
115 | + self.request.response.addNotification("Repository forked.") |
116 | + self.next_url = canonical_url(forked) |
117 | + |
118 | + @property |
119 | + def cancel_url(self): |
120 | + return canonical_url(self.context) |
121 | + |
122 | |
123 | class GitRepositoryRescanView(LaunchpadEditFormView): |
124 | |
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 | from fixtures import FakeLogger |
131 | import pytz |
132 | import soupmatchers |
133 | +from soupmatchers import ( |
134 | + Tag, |
135 | + HTMLContains, |
136 | + ) |
137 | from storm.store import Store |
138 | from testtools.matchers import ( |
139 | AfterPreprocessing, |
140 | @@ -28,6 +32,7 @@ from testtools.matchers import ( |
141 | MatchesListwise, |
142 | MatchesSetwise, |
143 | MatchesStructure, |
144 | + Not, |
145 | ) |
146 | import transaction |
147 | from zope.component import getUtility |
148 | @@ -40,16 +45,21 @@ from lp.app.enums import InformationType |
149 | from lp.app.errors import UnexpectedFormData |
150 | from lp.app.interfaces.launchpad import ILaunchpadCelebrities |
151 | from lp.app.interfaces.services import IService |
152 | -from lp.code.browser.gitrepository import encode_form_field_id |
153 | +from lp.code.browser.gitrepository import ( |
154 | + encode_form_field_id, |
155 | + GIT_REPOSITORY_FORK_ENABLED, |
156 | + ) |
157 | from lp.code.enums import ( |
158 | BranchMergeProposalStatus, |
159 | CodeReviewVote, |
160 | GitActivityType, |
161 | GitGranteeType, |
162 | + GitListingSort, |
163 | GitPermissionType, |
164 | GitRepositoryStatus, |
165 | GitRepositoryType, |
166 | ) |
167 | +from lp.code.interfaces.gitcollection import IGitCollection |
168 | from lp.code.interfaces.gitrepository import IGitRepositorySet |
169 | from lp.code.interfaces.revision import IRevisionSet |
170 | from lp.code.model.gitjob import GitRefScanJob |
171 | @@ -131,6 +141,10 @@ class TestGitRepositoryView(BrowserTestCase): |
172 | |
173 | layer = LaunchpadFunctionalLayer |
174 | |
175 | + def setUp(self): |
176 | + super(TestGitRepositoryView, self).setUp() |
177 | + self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'})) |
178 | + |
179 | def test_clone_instructions(self): |
180 | repository = self.factory.makeGitRepository() |
181 | username = repository.owner.name |
182 | @@ -538,7 +552,7 @@ class TestGitRepositoryView(BrowserTestCase): |
183 | repository, "+repository-portlet-subscriber-content") |
184 | with StormStatementRecorder() as recorder: |
185 | view.render() |
186 | - self.assertThat(recorder, HasQueryCount(Equals(6))) |
187 | + self.assertThat(recorder, HasQueryCount(Equals(7))) |
188 | |
189 | def test_show_rescan_link(self): |
190 | repository = self.factory.makeGitRepository() |
191 | @@ -573,6 +587,65 @@ class TestGitRepositoryView(BrowserTestCase): |
192 | result = view.show_rescan_link |
193 | self.assertTrue(result) |
194 | |
195 | + def assertContainsForkLink(self, browser, repository, text): |
196 | + """Asserts that browser contains the fork link with the given text""" |
197 | + with admin_logged_in(): |
198 | + url = canonical_url(repository, view_name='+fork') |
199 | + fork_link = Tag( |
200 | + "fork link", "a", |
201 | + text=re.compile(re.escape(text)), |
202 | + attrs={"class": "sprite add", "href": url}) |
203 | + self.assertThat(browser.contents, HTMLContains(fork_link)) |
204 | + |
205 | + def assertDoesntContainForkLink(self, browser, repository, texts): |
206 | + """Asserts that there is no fork button with any of the given texts.""" |
207 | + with admin_logged_in(): |
208 | + url = canonical_url(repository, view_name='+fork') |
209 | + for text in texts: |
210 | + fork_link = Tag( |
211 | + "fork link", "a", |
212 | + text=re.compile(re.escape(text)), |
213 | + attrs={"class": "sprite add", "href": url}) |
214 | + self.assertThat(browser.contents, Not(HTMLContains(fork_link))) |
215 | + |
216 | + def test_hide_fork_link_for_repos_targeting_person(self): |
217 | + person = self.factory.makePerson() |
218 | + another_person = self.factory.makePerson() |
219 | + repository = self.factory.makeGitRepository(target=person) |
220 | + browser = self.getViewBrowser( |
221 | + repository, '+index', user=another_person) |
222 | + self.assertDoesntContainForkLink(browser, repository, [ |
223 | + "Fork it to your account", |
224 | + "Or click here to fork it to your account.", |
225 | + ]) |
226 | + |
227 | + def test_show_fork_link_for_the_right_users(self): |
228 | + another_person = self.factory.makePerson() |
229 | + repository = self.factory.makeGitRepository() |
230 | + repo_owner = repository.owner |
231 | + |
232 | + # Do not show the link for the repository owner. |
233 | + browser = self.getViewBrowser(repository, '+index', user=repo_owner) |
234 | + self.assertDoesntContainForkLink(browser, repository, [ |
235 | + "Fork it to your account", |
236 | + "fork it directly to your account", |
237 | + ]) |
238 | + |
239 | + # Shows for another person. |
240 | + browser = self.getViewBrowser( |
241 | + repository, '+index', user=another_person) |
242 | + self.assertContainsForkLink( |
243 | + browser, repository, "fork it directly to your account") |
244 | + |
245 | + # Even for another person, do not show it if the feature flag is off. |
246 | + self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''})) |
247 | + browser = self.getViewBrowser( |
248 | + repository, '+index', user=another_person) |
249 | + self.assertDoesntContainForkLink(browser, repository, [ |
250 | + "Fork it to your account", |
251 | + "fork it directly to your account", |
252 | + ]) |
253 | + |
254 | |
255 | class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase): |
256 | """Tests that Git repositories with private team artifacts can be viewed. |
257 | @@ -2071,3 +2144,126 @@ class TestGitRepositoryActivityView(BrowserTestCase): |
258 | create_activity, |
259 | 2) |
260 | self.assertThat(recorder2, HasQueryCount.byEquality(recorder1)) |
261 | + |
262 | + |
263 | +class TestGitRepositoryForkView(BrowserTestCase): |
264 | + |
265 | + layer = DatabaseFunctionalLayer |
266 | + |
267 | + def setUp(self): |
268 | + super(TestGitRepositoryForkView, self).setUp() |
269 | + self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: 'on'})) |
270 | + |
271 | + def getReposOwnedBy(self, user): |
272 | + return IGitCollection(user).getRepositories( |
273 | + sort_by=GitListingSort.NEWEST_FIRST) |
274 | + |
275 | + def test_fork_page_redirects_with_disabled_feature(self): |
276 | + self.useFixture(FeatureFixture({GIT_REPOSITORY_FORK_ENABLED: ''})) |
277 | + with admin_logged_in(): |
278 | + repository = self.factory.makeGitRepository() |
279 | + owner = repository.owner |
280 | + self.assertRaises( |
281 | + Unauthorized, self.getViewBrowser, |
282 | + repository, "+fork", rootsite="code", user=owner) |
283 | + |
284 | + def test_fork_page_shows_input(self): |
285 | + with admin_logged_in(): |
286 | + repository = self.factory.makeGitRepository() |
287 | + owner = removeSecurityProxy(repository.owner) |
288 | + |
289 | + team = self.factory.makeTeam(members=[owner]) |
290 | + another_person = self.factory.makePerson() |
291 | + |
292 | + select_owner = Tag( |
293 | + "owner selector", "select", |
294 | + attrs={"id": "field.owner", "name": "field.owner"}) |
295 | + |
296 | + def person_option_tag(person): |
297 | + return Tag( |
298 | + "option for %s" % person, "option", |
299 | + text="%s (%s)" % (person.displayname, person.name), |
300 | + attrs=dict(value=person.name)) |
301 | + |
302 | + option_owner = person_option_tag(owner) |
303 | + option_team = person_option_tag(team) |
304 | + option_another_person = person_option_tag(another_person) |
305 | + |
306 | + browser = self.getViewBrowser( |
307 | + repository, "+fork", rootsite="code", user=owner) |
308 | + |
309 | + html = browser.contents |
310 | + self.assertThat(html, HTMLContains(select_owner)) |
311 | + self.assertThat(html, HTMLContains(option_owner)) |
312 | + self.assertThat(html, HTMLContains(option_team)) |
313 | + self.assertThat(html, Not(HTMLContains(option_another_person))) |
314 | + |
315 | + def test_fork_page_submit_to_self(self): |
316 | + self.useFixture(GitHostingFixture()) |
317 | + repository = self.factory.makeGitRepository() |
318 | + another_person = self.factory.makePerson() |
319 | + |
320 | + with person_logged_in(another_person): |
321 | + view = create_initialized_view(repository, name="+fork", form={ |
322 | + "field.owner": another_person.name, |
323 | + "field.actions.fork": "Fork it"}) |
324 | + |
325 | + forked = self.getReposOwnedBy(another_person)[0] |
326 | + self.assertNotEqual(forked, repository) |
327 | + self.assertEqual(another_person, forked.owner) |
328 | + |
329 | + notifications = view.request.response.notifications |
330 | + self.assertEqual(1, len(notifications)) |
331 | + self.assertEqual("Repository forked.", notifications[0].message) |
332 | + |
333 | + self.assertEqual(canonical_url(forked), view.next_url) |
334 | + |
335 | + def test_fork_page_submit_to_team(self): |
336 | + self.useFixture(GitHostingFixture()) |
337 | + repository = self.factory.makeGitRepository() |
338 | + another_person = self.factory.makePerson() |
339 | + team = self.factory.makeTeam(members=[another_person]) |
340 | + |
341 | + with person_logged_in(another_person): |
342 | + view = create_initialized_view(repository, name="+fork", form={ |
343 | + "field.owner": team.name, |
344 | + "field.actions.fork": "Fork it"}) |
345 | + |
346 | + forked = self.getReposOwnedBy(team)[0] |
347 | + self.assertNotEqual(forked, repository) |
348 | + self.assertEqual(team, forked.owner) |
349 | + |
350 | + notifications = view.request.response.notifications |
351 | + self.assertEqual(1, len(notifications)) |
352 | + self.assertEqual("Repository forked.", notifications[0].message) |
353 | + |
354 | + self.assertEqual(canonical_url(forked), view.next_url) |
355 | + |
356 | + def test_fork_page_submit_missing_user(self): |
357 | + self.useFixture(GitHostingFixture()) |
358 | + repository = self.factory.makeGitRepository() |
359 | + another_person = self.factory.makePerson() |
360 | + |
361 | + with person_logged_in(another_person): |
362 | + view = create_initialized_view(repository, name="+fork", form={ |
363 | + "field.actions.fork": "Fork it"}) |
364 | + |
365 | + # No repository should have been created. |
366 | + self.assertEqual(0, self.getReposOwnedBy(another_person).count()) |
367 | + self.assertEqual( |
368 | + ['You should select a valid user to fork the repository.'], |
369 | + view.errors) |
370 | + |
371 | + self.assertEqual(None, view.next_url) |
372 | + |
373 | + def test_fork_page_submit_invalid_user(self): |
374 | + self.useFixture(GitHostingFixture()) |
375 | + repository = self.factory.makeGitRepository() |
376 | + another_person = self.factory.makePerson() |
377 | + invalid_person = self.factory.makePerson() |
378 | + |
379 | + with person_logged_in(another_person): |
380 | + create_initialized_view(repository, name="+fork", form={ |
381 | + "field.owner": invalid_person.name, |
382 | + "field.actions.fork": "Fork it"}) |
383 | + 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 | "True iff this namespace permits automatically setting a default " |
390 | "repository on push.") |
391 | |
392 | - show_push_url_hints = Attribute( |
393 | - "True if this namespace permits display of the push URL hint.") |
394 | + supports_repository_forking = Attribute( |
395 | + "Does this namespace support repository forking at all?") |
396 | |
397 | supports_merge_proposals = Attribute( |
398 | "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 | supports_merge_proposals = True |
405 | supports_code_imports = False |
406 | allow_recipe_name_from_target = False |
407 | - show_push_url_hints = False |
408 | + supports_repository_forking = False |
409 | |
410 | def __init__(self, person): |
411 | self.owner = person |
412 | @@ -369,7 +369,7 @@ class ProjectGitNamespace(_BaseGitNamespace): |
413 | supports_merge_proposals = True |
414 | supports_code_imports = True |
415 | allow_recipe_name_from_target = True |
416 | - show_push_url_hints = True |
417 | + supports_repository_forking = True |
418 | |
419 | def __init__(self, person, project): |
420 | self.owner = person |
421 | @@ -461,7 +461,7 @@ class PackageGitNamespace(_BaseGitNamespace): |
422 | supports_merge_proposals = True |
423 | supports_code_imports = True |
424 | allow_recipe_name_from_target = True |
425 | - show_push_url_hints = True |
426 | + supports_repository_forking = True |
427 | |
428 | def __init__(self, person, distro_source_package): |
429 | self.owner = person |
430 | @@ -553,7 +553,7 @@ class OCIProjectGitNamespace(_BaseGitNamespace): |
431 | supports_merge_proposals = True |
432 | supports_code_imports = True |
433 | allow_recipe_name_from_target = True |
434 | - show_push_url_hints = False |
435 | + supports_repository_forking = False |
436 | |
437 | def __init__(self, person, oci_project): |
438 | 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 | GitRepositoryCreatorNotOwner, |
445 | GitRepositoryExists, |
446 | ) |
447 | +from lp.code.interfaces.githosting import IGitHostingClient |
448 | from lp.code.interfaces.gitnamespace import ( |
449 | get_git_namespace, |
450 | IGitNamespace, |
451 | @@ -45,10 +46,12 @@ from lp.registry.interfaces.accesspolicy import ( |
452 | IAccessPolicyGrantFlatSource, |
453 | IAccessPolicySource, |
454 | ) |
455 | +from lp.services.compat import mock |
456 | from lp.testing import ( |
457 | person_logged_in, |
458 | TestCaseWithFactory, |
459 | ) |
460 | +from lp.testing.fixture import ZopeUtilityFixture |
461 | from lp.testing.layers import DatabaseFunctionalLayer |
462 | |
463 | |
464 | @@ -100,6 +103,36 @@ class NamespaceMixin: |
465 | GitRepositoryType.HOSTED, registrant, repository_name) |
466 | self.assertEqual([owner], list(repository.subscribers)) |
467 | |
468 | + def test_createRepository_creates_on_githosting_sync(self): |
469 | + hosting_client = mock.Mock() |
470 | + self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient)) |
471 | + owner = self.factory.makeTeam() |
472 | + namespace = self.getNamespace(owner) |
473 | + repository_name = self.factory.getUniqueUnicode() |
474 | + registrant = owner.teamowner |
475 | + repository = namespace.createRepository( |
476 | + GitRepositoryType.HOSTED, registrant, repository_name, |
477 | + with_hosting=True) |
478 | + path = repository.getInternalPath() |
479 | + self.assertEqual( |
480 | + [mock.call(path, clone_from=None, async_create=False)], |
481 | + hosting_client.create.call_args_list) |
482 | + |
483 | + def test_createRepository_creates_on_githosting_async(self): |
484 | + hosting_client = mock.Mock() |
485 | + self.useFixture(ZopeUtilityFixture(hosting_client, IGitHostingClient)) |
486 | + owner = self.factory.makeTeam() |
487 | + namespace = self.getNamespace(owner) |
488 | + repository_name = self.factory.getUniqueUnicode() |
489 | + registrant = owner.teamowner |
490 | + repository = namespace.createRepository( |
491 | + GitRepositoryType.HOSTED, registrant, repository_name, |
492 | + with_hosting=True, async_hosting=True) |
493 | + path = repository.getInternalPath() |
494 | + self.assertEqual( |
495 | + [mock.call(path, clone_from=None, async_create=True)], |
496 | + hosting_client.create.call_args_list) |
497 | + |
498 | def test_getRepositories_no_repositories(self): |
499 | # getRepositories on an IGitNamespace returns a result set of |
500 | # 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 | </tt> |
507 | </dd> |
508 | </dl> |
509 | + <dt tal:condition="python: getattr(view, 'allow_fork', False)"> |
510 | + <a tal:attributes="href view/fork_url" class="sprite add"> |
511 | + Fork it to your account |
512 | + </a> |
513 | + </dt> |
514 | </tal:can-push> |
515 | |
516 | <tal:cannot-push condition="not:view/user_can_push"> |
517 | @@ -83,7 +88,7 @@ |
518 | tal:content="context/owner/displayname">Team</a> |
519 | can push to this <tal:kind replace="kind" />. |
520 | </tal:team> |
521 | - <dl tal:condition="context/namespace/show_push_url_hints" id="push-url"> |
522 | + <dl tal:condition="context/namespace/supports_repository_forking" id="push-url"> |
523 | <dt>To fork this repository and propose fixes from there, push to this repository:</dt> |
524 | <dd> |
525 | <tt class="command"> |
526 | @@ -92,6 +97,12 @@ |
527 | <em>BRANCHNAME</em> |
528 | </tt> |
529 | </dd> |
530 | + <dd tal:condition="python: getattr(view, 'allow_fork', False)"> |
531 | + or |
532 | + <a tal:attributes="href view/fork_url" class="sprite add"> |
533 | + fork it directly to your account |
534 | + </a>. |
535 | + </dd> |
536 | </dl> |
537 | </tal:cannot-push> |
538 | <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 | 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 | +<html |
546 | + xmlns="http://www.w3.org/1999/xhtml" |
547 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
548 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
549 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
550 | + metal:use-macro="view/macro:page/main_only" |
551 | + i18n:domain="launchpad"> |
552 | + <body> |
553 | + <div metal:fill-slot="main"> |
554 | + <p> |
555 | + This will create a copy of |
556 | + <a tal:replace="structure context/fmt:link" /> in your account. |
557 | + </p> |
558 | + <p> |
559 | + After the fork process, you will be able to git clone the |
560 | + repository, push to it and create merge proposals to the original |
561 | + repository. |
562 | + </p> |
563 | + <div metal:use-macro="context/@@launchpad_form/form" /> |
564 | + </div> |
565 | + </body> |
566 | +</html> |
This branch depends on https:/ /code.launchpad .net/~pappacena /launchpad/ +git/launchpad/ +merge/ 387146