Merge ~ilasc/launchpad:git-push-url-hint into launchpad:master

Proposed by Ioana Lasc
Status: Merged
Approved by: Ioana Lasc
Approved revision: 6909a8bdba9f2113c45199545d7e628b34cd5dde
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ilasc/launchpad:git-push-url-hint
Merge into: launchpad:master
Diff against target: 555 lines (+321/-28)
9 files modified
lib/lp/code/browser/gitref.py (+22/-1)
lib/lp/code/browser/gitrepository.py (+20/-1)
lib/lp/code/browser/tests/test_gitlisting.py (+1/-1)
lib/lp/code/browser/tests/test_gitref.py (+115/-0)
lib/lp/code/browser/tests/test_gitrepository.py (+118/-16)
lib/lp/code/interfaces/gitnamespace.py (+3/-0)
lib/lp/code/interfaces/gitrepository.py (+20/-1)
lib/lp/code/model/gitnamespace.py (+4/-0)
lib/lp/code/templates/git-macros.pt (+18/-8)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Thiago F. Pappacena (community) Approve
Review via email: mp+386567@code.launchpad.net

Commit message

Add git push URL hinting

Description of the change

Add git push URL hinting to LP UI on both repository and branch pages when the user doesn’t have permission to push to the repository.

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Looks like a good job. I just have a question about the splitting of the URL itself.

review: Approve
Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
~ilasc/launchpad:git-push-url-hint updated
17b83c2... by Ioana Lasc

Subclass GitIdentityMixin for contributor repository path

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

Thanks, this is an improvement. I'd like to see some more polishing of the contributor-identity code.

review: Needs Fixing
~ilasc/launchpad:git-push-url-hint updated
a83386a... by Ioana Lasc

Address code review comments

Revision history for this message
Ioana Lasc (ilasc) wrote :

Thank you Colin!
I've addressed everything and in relation to your comment on the 2 code paths in git_ssh_url_non_owner: I have 2 code paths because otherwise it breaks like this when self.context.target_default is true:

   Module lp.code.interfaces.gitrepository, line 1201, in getRepositoryIdentities
    for default in self.getRepositoryDefaults()]
    Module lp.code.interfaces.gitrepository, line 1192, in getRepositoryDefaults
    "Only projects, packages, or OCI projects can have "

The branch is now ready for another look.

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

git_ssh_url_non_owner works just fine with a single code path in my tests; you're passing target_default=False when constructing ContributorGitIdentity, so the breakage you mention doesn't happen. It does require some test changes to account for the resulting URLs being different, but IMO the changed URLs are an improvement. See this patch:

  https://paste.ubuntu.com/p/gS5YWSnDYB/

There's this new import policy warning from the test suite, which should be resolved one way or another:

There were 2 imports of names not appearing in the __all__.
You should not import ContributorGitIdentity from lp.code.interfaces.gitrepository:
    lp.code.browser.gitref
    lp.code.browser.gitrepository

There's also one further case that may have been overlooked here. Personal repositories (those handled by lp.code.model.gitnamespace.PersonalGitNamespace, of the form /~OWNER/+git/NAME) don't support merge proposals between different repositories, because there's no obvious link between them except possibly for the name. This isn't ideal, and it might be worth us revisiting it in the future, but for the meantime this is how it is. As such, it risks misleading users if, for example, I visit /~ilasc/+git/random-scripts and am told that I can push code to /~cjwatson/+git/random-scripts and propose changes from there. We should probably stick with the existing "You cannot push to this branch/repository" language in that case and not try to provide a push URL hint, even though it's unfortunate that that complicates this branch some more.

Despite my repeated "Needs fixing" votes, this is definitely improving, and I think is nearly there! Hopefully it should just be this one more round.

review: Needs Fixing
~ilasc/launchpad:git-push-url-hint updated
281c8a9... by Ioana Lasc

Address code review comments

Revision history for this message
Ioana Lasc (ilasc) wrote :

Colin thank you for the helpful comments.
MP is now ready for another review.

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

I'd like a few more bits of tidying up, explained in detail below, but this looks pretty close now. Thanks for your work.

review: Approve
~ilasc/launchpad:git-push-url-hint updated
a9e7a78... by Ioana Lasc

Merge branch 'master' into git-push-url-hint

5dd6be6... by Ioana Lasc

Address code review comments

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
~ilasc/launchpad:git-push-url-hint updated
6909a8b... by Ioana Lasc

Fix unit tests

Revision history for this message
Ioana Lasc (ilasc) wrote :

Thanks Colin! Agreed with all comments & suggestions.
Had to tweak a few tests I didn't write for the new code, possibly worth another look before I land this ?

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
2index a945fa0..f17207e 100644
3--- a/lib/lp/code/browser/gitref.py
4+++ b/lib/lp/code/browser/gitref.py
5@@ -13,6 +13,7 @@ __all__ = [
6
7 import json
8
9+from breezy import urlutils
10 from lazr.restful.interface import copy_field
11 from six.moves.urllib_parse import (
12 quote_plus,
13@@ -46,8 +47,12 @@ from lp.code.errors import InvalidBranchMergeProposal
14 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
15 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
16 from lp.code.interfaces.gitref import IGitRef
17-from lp.code.interfaces.gitrepository import IGitRepositorySet
18+from lp.code.interfaces.gitrepository import (
19+ ContributorGitIdentity,
20+ IGitRepositorySet,
21+ )
22 from lp.registry.interfaces.person import IPerson
23+from lp.services.config import config
24 from lp.services.helpers import english_list
25 from lp.services.propertycache import cachedproperty
26 from lp.services.scripts import log
27@@ -120,6 +125,22 @@ class GitRefView(LaunchpadView, HasSnapsViewMixin):
28 return urlunsplit(url)
29
30 @property
31+ def git_ssh_url_non_owner(self):
32+ """The git+ssh:// URL for this repository, adjusted for this user.
33+
34+ The user is not the owner of the repository.
35+ """
36+ contributor = ContributorGitIdentity(
37+ owner=self.user,
38+ target=self.context.repository.target,
39+ repository=self.context.repository)
40+ base_url = urlutils.join(
41+ config.codehosting.git_ssh_root, contributor.shortened_path)
42+ url = list(urlsplit(base_url))
43+ url[1] = "{}@{}".format(self.user.name, url[1])
44+ return urlunsplit(url)
45+
46+ @property
47 def user_can_push(self):
48 """Whether the user can push to this branch."""
49 return (
50diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
51index 7dc1c32..76b9f7d 100644
52--- a/lib/lp/code/browser/gitrepository.py
53+++ b/lib/lp/code/browser/gitrepository.py
54@@ -25,6 +25,7 @@ __all__ = [
55 import base64
56 from collections import defaultdict
57
58+from breezy import urlutils
59 from lazr.lifecycle.event import ObjectModifiedEvent
60 from lazr.lifecycle.snapshot import Snapshot
61 from lazr.restful.interface import (
62@@ -99,7 +100,10 @@ from lp.code.errors import (
63 )
64 from lp.code.interfaces.gitnamespace import get_git_namespace
65 from lp.code.interfaces.gitref import IGitRefBatchNavigator
66-from lp.code.interfaces.gitrepository import IGitRepository
67+from lp.code.interfaces.gitrepository import (
68+ ContributorGitIdentity,
69+ IGitRepository,
70+ )
71 from lp.code.vocabularies.gitrule import GitPermissionsVocabulary
72 from lp.registry.interfaces.person import (
73 IPerson,
74@@ -385,6 +389,21 @@ class GitRepositoryView(InformationTypePortletMixin, LaunchpadView,
75 return urlunsplit(url)
76
77 @property
78+ def git_ssh_url_non_owner(self):
79+ """The git+ssh:// URL for this repository, adjusted for this user.
80+ The user is not the owner of the repository."""
81+
82+ contributor = ContributorGitIdentity(
83+ owner=self.user,
84+ target=self.context.target,
85+ repository=self.context)
86+ base_url = urlutils.join(
87+ config.codehosting.git_ssh_root, contributor.shortened_path)
88+ url = list(urlsplit(base_url))
89+ url[1] = "{}@{}".format(self.user.name, url[1])
90+ return urlunsplit(url)
91+
92+ @property
93 def user_can_push(self):
94 """Whether the user can push to this branch."""
95 return (
96diff --git a/lib/lp/code/browser/tests/test_gitlisting.py b/lib/lp/code/browser/tests/test_gitlisting.py
97index 72954ad..e7682fc 100644
98--- a/lib/lp/code/browser/tests/test_gitlisting.py
99+++ b/lib/lp/code/browser/tests/test_gitlisting.py
100@@ -125,7 +125,7 @@ class TestTargetGitListingView:
101 repository=other_repo, user=other_repo.owner)
102
103 self.assertThat(
104- self.target, BrowsesWithQueryLimit(36, self.owner, '+git'))
105+ self.target, BrowsesWithQueryLimit(38, self.owner, '+git'))
106
107 def test_copes_with_no_default(self):
108 self.factory.makeGitRepository(
109diff --git a/lib/lp/code/browser/tests/test_gitref.py b/lib/lp/code/browser/tests/test_gitref.py
110index 0268ca4..0a36550 100644
111--- a/lib/lp/code/browser/tests/test_gitref.py
112+++ b/lib/lp/code/browser/tests/test_gitref.py
113@@ -33,6 +33,8 @@ from lp.services.webapp.publisher import canonical_url
114 from lp.testing import (
115 admin_logged_in,
116 BrowserTestCase,
117+ login_person,
118+ person_logged_in,
119 StormStatementRecorder,
120 TestCaseWithFactory,
121 )
122@@ -155,6 +157,119 @@ class TestGitRefView(BrowserTestCase):
123 git clone -b branch git\+ssh://{username}@git.launchpad.test/.*
124 """.format(username=username), text)
125
126+ def test_push_directions_logged_in_cannot_push_individual(self):
127+ repo = self.factory.makeGitRepository()
128+ [ref] = self.factory.makeGitRefs(repository=repo,
129+ paths=["refs/heads/branch"])
130+ login_person(self.user)
131+ view = create_initialized_view(ref, "+index", principal=self.user)
132+ git_push_url_text_match = soupmatchers.HTMLContains(
133+ soupmatchers.Tag(
134+ 'Push url text', 'dt',
135+ text='To fork this repository and propose '
136+ 'fixes from there, push to this repository:'))
137+
138+ git_push_url_hint_match = soupmatchers.HTMLContains(
139+ soupmatchers.Tag(
140+ 'Push url hint', 'span',
141+ text=('git+ssh://%s@git.launchpad.test/'
142+ '~%s/%s') % (
143+ self.user.name, self.user.name, repo.target.name)))
144+ with person_logged_in(self.user):
145+ rendered_view = view.render()
146+ self.assertThat(rendered_view, git_push_url_text_match)
147+ self.assertThat(rendered_view, git_push_url_hint_match)
148+
149+ def test_push_directions_logged_in_cannot_push_individual_project(self):
150+ # Repository is the default for a project
151+ eric = self.factory.makePerson(name="eric")
152+ fooix = self.factory.makeProduct(name="fooix", owner=eric)
153+ repository = self.factory.makeGitRepository(
154+ owner=eric, target=fooix, name="fooix-repo")
155+ [ref] = self.factory.makeGitRefs(repository=repository,
156+ paths=["refs/heads/branch"])
157+ self.repository_set = getUtility(IGitRepositorySet)
158+ with person_logged_in(fooix.owner) as user:
159+ self.repository_set.setDefaultRepositoryForOwner(
160+ repository.owner, fooix, repository, user)
161+ self.repository_set.setDefaultRepository(fooix, repository)
162+ login_person(self.user)
163+ view = create_initialized_view(ref, "+index", principal=self.user)
164+ git_push_url_text_match = soupmatchers.HTMLContains(
165+ soupmatchers.Tag(
166+ 'Push url text', 'dt',
167+ text='To fork this repository and propose '
168+ 'fixes from there, push to this repository:'))
169+ git_push_url_hint_match = soupmatchers.HTMLContains(
170+ soupmatchers.Tag(
171+ 'Push url hint', 'span',
172+ text='git+ssh://%s@git.launchpad.test/~%s/%s' %
173+ (self.user.name,
174+ self.user.name,
175+ repository.target.name)))
176+
177+ with person_logged_in(self.user):
178+ rendered_view = view.render()
179+ self.assertThat(rendered_view, git_push_url_text_match)
180+ self.assertThat(rendered_view, git_push_url_hint_match)
181+
182+ def test_push_directions_logged_in_cannot_push_individual_package(self):
183+ # Repository is the default for a package
184+ mint = self.factory.makeDistribution(name="mint")
185+ eric = self.factory.makePerson(name="eric")
186+ mint_choc = self.factory.makeDistributionSourcePackage(
187+ distribution=mint, sourcepackagename="choc")
188+ repository = self.factory.makeGitRepository(
189+ owner=eric, target=mint_choc, name="choc-repo")
190+ [ref] = self.factory.makeGitRefs(repository=repository,
191+ paths=["refs/heads/branch"])
192+ dsp = repository.target
193+ self.repository_set = getUtility(IGitRepositorySet)
194+ with admin_logged_in():
195+ self.repository_set.setDefaultRepositoryForOwner(
196+ repository.owner, dsp, repository, repository.owner)
197+ self.repository_set.setDefaultRepository(dsp, repository)
198+ login_person(self.user)
199+ view = create_initialized_view(ref, "+index", principal=self.user)
200+ git_push_url_text_match = soupmatchers.HTMLContains(
201+ soupmatchers.Tag(
202+ 'Push url text', 'dt',
203+ text='To fork this repository and propose '
204+ 'fixes from there, push to this repository:'))
205+ git_push_url_hint_match = soupmatchers.HTMLContains(
206+ soupmatchers.Tag(
207+ 'Push url hint', 'span',
208+ text='git+ssh://%s@git.launchpad.test/~%s/%s/+source/%s' %
209+ (self.user.name,
210+ self.user.name,
211+ mint.name,
212+ mint_choc.name)))
213+
214+ with person_logged_in(self.user):
215+ rendered_view = view.render()
216+ self.assertThat(rendered_view, git_push_url_text_match)
217+ self.assertThat(rendered_view, git_push_url_hint_match)
218+
219+ def test_push_directions_logged_in_cannot_push_personal_project(self):
220+ repository = self.factory.makeGitRepository(
221+ owner=self.user, target=self.user)
222+ [ref] = self.factory.makeGitRefs(repository=repository,
223+ paths=["refs/heads/branch"])
224+ other_user = self.factory.makePerson()
225+ login_person(other_user)
226+ view = create_initialized_view(ref, "+index", principal=self.user)
227+ git_push_url_text_match = soupmatchers.Tag(
228+ 'Push url text', 'a',
229+ text=self.user.displayname)
230+ with person_logged_in(other_user):
231+ rendered_view = view.render()
232+ div = soupmatchers.Tag("Push directions", "div",
233+ attrs={"id": "push-directions"})
234+ self.assertThat(rendered_view, soupmatchers.HTMLContains(
235+ soupmatchers.Within(
236+ div,
237+ git_push_url_text_match)))
238+
239 def makeCommitLog(self):
240 authors = [self.factory.makePerson() for _ in range(5)]
241 with admin_logged_in():
242diff --git a/lib/lp/code/browser/tests/test_gitrepository.py b/lib/lp/code/browser/tests/test_gitrepository.py
243index d323976..d3211ba 100644
244--- a/lib/lp/code/browser/tests/test_gitrepository.py
245+++ b/lib/lp/code/browser/tests/test_gitrepository.py
246@@ -50,6 +50,7 @@ from lp.code.enums import (
247 GitRepositoryStatus,
248 GitRepositoryType,
249 )
250+from lp.code.interfaces.gitrepository import IGitRepositorySet
251 from lp.code.interfaces.revision import IRevisionSet
252 from lp.code.model.gitjob import GitRefScanJob
253 from lp.code.tests.helpers import GitHostingFixture
254@@ -225,6 +226,7 @@ class TestGitRepositoryView(BrowserTestCase):
255 Update this repository:
256 git push
257 git+ssh://{username}@git.launchpad.test/{repository.shortened_path}
258+ BRANCHNAME
259 """).format(username=username, repository=repository),
260 flags=doctest.NORMALIZE_WHITESPACE))
261
262@@ -246,30 +248,130 @@ class TestGitRepositoryView(BrowserTestCase):
263 # If the user is logged in but cannot push to a repository owned by
264 # a person, we explain who can push.
265 repository = self.factory.makeGitRepository()
266- browser = self.getViewBrowser(repository)
267- directions = find_tag_by_id(browser.contents, "push-directions")
268 login_person(self.user)
269- self.assertThat(directions.renderContents(), DocTestMatches(dedent("""
270- You cannot push to this repository. Only <a
271- href="http://launchpad.test/~{owner.name}">{owner.display_name}</a>
272- can push to this repository.
273- """).format(owner=repository.owner),
274- flags=doctest.NORMALIZE_WHITESPACE))
275+ view = create_initialized_view(
276+ repository, '+index', principal=self.user)
277+ git_push_url_text_match = soupmatchers.HTMLContains(
278+ soupmatchers.Tag(
279+ 'Push url text', 'dt',
280+ text='To fork this repository and propose '
281+ 'fixes from there, push to this repository:'))
282+ git_push_url_hint_match = soupmatchers.HTMLContains(
283+ soupmatchers.Tag(
284+ 'Push url hint', 'span',
285+ text='git+ssh://%s@git.launchpad.test/~%s/%s' %
286+ (self.user.name, self.user.name, repository.target.name)))
287+ with person_logged_in(self.user):
288+ rendered_view = view.render()
289+ self.assertThat(rendered_view, git_push_url_text_match)
290+ self.assertThat(rendered_view, git_push_url_hint_match)
291+
292+ def test_push_directions_logged_in_cannot_push_individual_project(self):
293+ # Repository is the default for a project
294+ eric = self.factory.makePerson(name="eric")
295+ fooix = self.factory.makeProduct(name="fooix", owner=eric)
296+ repository = self.factory.makeGitRepository(
297+ owner=eric, target=fooix, name="fooix-repo")
298+ self.repository_set = getUtility(IGitRepositorySet)
299+ with person_logged_in(fooix.owner) as user:
300+ self.repository_set.setDefaultRepositoryForOwner(
301+ repository.owner, fooix, repository, user)
302+ self.repository_set.setDefaultRepository(fooix, repository)
303+ login_person(self.user)
304+ view = create_initialized_view(
305+ repository, '+index', principal=self.user)
306+ git_push_url_text_match = soupmatchers.HTMLContains(
307+ soupmatchers.Tag(
308+ 'Push url text', 'dt',
309+ text='To fork this repository and propose '
310+ 'fixes from there, push to this repository:'))
311+ git_push_url_hint_match = soupmatchers.HTMLContains(
312+ soupmatchers.Tag(
313+ 'Push url hint', 'span',
314+ text='git+ssh://%s@git.launchpad.test/~%s/%s' %
315+ (self.user.name, self.user.name,
316+ repository.target.name)))
317+ with person_logged_in(self.user):
318+ rendered_view = view.render()
319+ self.assertThat(rendered_view, git_push_url_text_match)
320+ self.assertThat(rendered_view, git_push_url_hint_match)
321+
322+ def test_push_directions_logged_in_cannot_push_individual_package(self):
323+ # Repository is the default for a package
324+ mint = self.factory.makeDistribution(name="mint")
325+ eric = self.factory.makePerson(name="eric")
326+ mint_choc = self.factory.makeDistributionSourcePackage(
327+ distribution=mint, sourcepackagename="choc")
328+ repository = self.factory.makeGitRepository(
329+ owner=eric, target=mint_choc, name="choc-repo")
330+ dsp = repository.target
331+ self.repository_set = getUtility(IGitRepositorySet)
332+ with admin_logged_in():
333+ self.repository_set.setDefaultRepositoryForOwner(
334+ repository.owner, dsp, repository, repository.owner)
335+ self.repository_set.setDefaultRepository(dsp, repository)
336+ login_person(self.user)
337+ view = create_initialized_view(
338+ repository, '+index', principal=self.user)
339+ git_push_url_text_match = soupmatchers.HTMLContains(
340+ soupmatchers.Tag(
341+ 'Push url text', 'dt',
342+ text='To fork this repository and propose '
343+ 'fixes from there, push to this repository:'))
344+ git_push_url_hint_match = soupmatchers.HTMLContains(
345+ soupmatchers.Tag(
346+ 'Push url hint', 'span',
347+ text='git+ssh://%s@git.launchpad.test/~%s/%s/+source/%s' %
348+ (self.user.name, self.user.name,
349+ mint.name, mint_choc.name)))
350+ with person_logged_in(self.user):
351+ rendered_view = view.render()
352+ self.assertThat(rendered_view, git_push_url_text_match)
353+ self.assertThat(rendered_view, git_push_url_hint_match)
354+
355+ def test_push_directions_logged_in_cannot_push_personal_project(self):
356+ # If the user is logged in but cannot push to a repository owned by
357+ # a person, we explain who can push.
358+ repository = self.factory.makeGitRepository(
359+ owner=self.user, target=self.user)
360+ other_user = self.factory.makePerson()
361+ login_person(other_user)
362+ view = create_initialized_view(
363+ repository, '+index', principal=other_user)
364+ git_push_url_text_match = soupmatchers.Tag(
365+ 'Push url text', 'a',
366+ text=self.user.displayname)
367+ with person_logged_in(other_user):
368+ rendered_view = view.render()
369+ div = soupmatchers.Tag("Push directions", "div",
370+ attrs={"id": "push-directions"})
371+ self.assertThat(rendered_view, soupmatchers.HTMLContains(
372+ soupmatchers.Within(
373+ div,
374+ git_push_url_text_match)))
375
376 def test_push_directions_logged_in_cannot_push_team(self):
377 # If the user is logged in but cannot push to a repository owned by
378 # a team, we explain who can push.
379 team = self.factory.makeTeam()
380 repository = self.factory.makeGitRepository(owner=team)
381- browser = self.getViewBrowser(repository)
382- directions = find_tag_by_id(browser.contents, "push-directions")
383 login_person(self.user)
384- self.assertThat(directions.renderContents(), DocTestMatches(dedent("""
385- You cannot push to this repository. Members of <a
386- href="http://launchpad.test/~{owner.name}">{owner.display_name}</a>
387- can push to this repository.
388- """).format(owner=repository.owner),
389- flags=doctest.NORMALIZE_WHITESPACE))
390+ view = create_initialized_view(
391+ repository, '+index', principal=self.user)
392+ git_push_url_text_match = soupmatchers.HTMLContains(
393+ soupmatchers.Tag(
394+ 'Push url text', 'dt',
395+ text='To fork this repository and propose '
396+ 'fixes from there, push to this repository:'))
397+ git_push_url_hint_match = soupmatchers.HTMLContains(
398+ soupmatchers.Tag(
399+ 'Push url hint', 'span',
400+ text='git+ssh://%s@git.launchpad.test/~%s/%s' %
401+ (self.user.name, self.user.name, repository.target.name)))
402+ with person_logged_in(self.user):
403+ rendered_view = view.render()
404+ self.assertThat(rendered_view, git_push_url_text_match)
405+ self.assertThat(rendered_view, git_push_url_hint_match)
406
407 def test_no_push_directions_for_imported_repository(self):
408 # Imported repositories never show push directions.
409diff --git a/lib/lp/code/interfaces/gitnamespace.py b/lib/lp/code/interfaces/gitnamespace.py
410index fd7e3e7..9c6886b 100644
411--- a/lib/lp/code/interfaces/gitnamespace.py
412+++ b/lib/lp/code/interfaces/gitnamespace.py
413@@ -99,6 +99,9 @@ class IGitNamespacePolicy(Interface):
414 "True iff this namespace permits automatically setting a default "
415 "repository on push.")
416
417+ show_push_url_hints = Attribute(
418+ "True if this namespace permits display of the push URL hint.")
419+
420 supports_merge_proposals = Attribute(
421 "Does this namespace support merge proposals at all?")
422
423diff --git a/lib/lp/code/interfaces/gitrepository.py b/lib/lp/code/interfaces/gitrepository.py
424index 189b426..344bc43 100644
425--- a/lib/lp/code/interfaces/gitrepository.py
426+++ b/lib/lp/code/interfaces/gitrepository.py
427@@ -6,6 +6,7 @@
428 __metaclass__ = type
429
430 __all__ = [
431+ 'ContributorGitIdentity',
432 'GitIdentityMixin',
433 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
434 'git_repository_name_validator',
435@@ -1033,7 +1034,8 @@ class IGitRepositorySet(Interface):
436 "checked."),
437 schema=IPerson),
438 repository_names=List(value_type=Text(),
439- title=_('List of repository unique names'), required=True),
440+ title=_('List of repository unique names'),
441+ required=True),
442 )
443 @export_read_operation()
444 @operation_for_version("devel")
445@@ -1228,3 +1230,20 @@ def user_has_special_git_repository_access(user, repository=None):
446 if code_import is None:
447 return False
448 return roles.in_vcs_imports
449+
450+
451+class ContributorGitIdentity(GitIdentityMixin):
452+
453+ def __init__(self, owner,
454+ target, repository):
455+ self.target_default = False
456+ self.owner_default = True
457+ self.owner = owner
458+ self.target = target
459+ self.repository = repository
460+
461+ def getRepositoryIdentities(self):
462+ identities = [
463+ (default.path, default.context)
464+ for default in self.getRepositoryDefaults()]
465+ return identities
466diff --git a/lib/lp/code/model/gitnamespace.py b/lib/lp/code/model/gitnamespace.py
467index 8a08a97..f9ac2f5 100644
468--- a/lib/lp/code/model/gitnamespace.py
469+++ b/lib/lp/code/model/gitnamespace.py
470@@ -273,6 +273,7 @@ class PersonalGitNamespace(_BaseGitNamespace):
471 supports_merge_proposals = True
472 supports_code_imports = False
473 allow_recipe_name_from_target = False
474+ show_push_url_hints = False
475
476 def __init__(self, person):
477 self.owner = person
478@@ -356,6 +357,7 @@ class ProjectGitNamespace(_BaseGitNamespace):
479 supports_merge_proposals = True
480 supports_code_imports = True
481 allow_recipe_name_from_target = True
482+ show_push_url_hints = True
483
484 def __init__(self, person, project):
485 self.owner = person
486@@ -447,6 +449,7 @@ class PackageGitNamespace(_BaseGitNamespace):
487 supports_merge_proposals = True
488 supports_code_imports = True
489 allow_recipe_name_from_target = True
490+ show_push_url_hints = True
491
492 def __init__(self, person, distro_source_package):
493 self.owner = person
494@@ -538,6 +541,7 @@ class OCIProjectGitNamespace(_BaseGitNamespace):
495 supports_merge_proposals = True
496 supports_code_imports = True
497 allow_recipe_name_from_target = True
498+ show_push_url_hints = False
499
500 def __init__(self, person, oci_project):
501 self.owner = person
502diff --git a/lib/lp/code/templates/git-macros.pt b/lib/lp/code/templates/git-macros.pt
503index 9407df1..928bf83 100644
504--- a/lib/lp/code/templates/git-macros.pt
505+++ b/lib/lp/code/templates/git-macros.pt
506@@ -64,31 +64,41 @@
507 <tt class="command">
508 git push
509 <span class="ssh-url" tal:content="view/git_ssh_url" />
510- <tal:branch condition="branch_name|nothing" replace="branch_name" />
511+ <em>BRANCHNAME</em>
512 </tt>
513 </dd>
514 </dl>
515- <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions">
516- To authenticate with the Launchpad Git hosting service, you need to
517- <a tal:attributes="href string:${view/user/fmt:url}/+editsshkeys">
518- register an SSH key</a>.
519- </p>
520 </tal:can-push>
521
522 <tal:cannot-push condition="not:view/user_can_push">
523 <tal:individual condition="not:context/owner/is_team">
524- You cannot push to this <tal:kind replace="kind" />. Only
525+ You cannot push directly to this <tal:kind replace="kind" />. Only
526 <a tal:attributes="href context/owner/fmt:url"
527 tal:content="context/owner/displayname">Person</a>
528 can push to this <tal:kind replace="kind" />.
529 </tal:individual>
530 <tal:team condition="context/owner/is_team">
531- You cannot push to this <tal:kind replace="kind" />. Members of
532+ You cannot push directly to this <tal:kind replace="kind" />. Members of
533 <a tal:attributes="href context/owner/fmt:url"
534 tal:content="context/owner/displayname">Team</a>
535 can push to this <tal:kind replace="kind" />.
536 </tal:team>
537+ <dl tal:condition="context/namespace/show_push_url_hints" id="push-url">
538+ <dt>To fork this repository and propose fixes from there, push to this repository:</dt>
539+ <dd>
540+ <tt class="command">
541+ git push
542+ <span class="ssh-url" tal:content="view/git_ssh_url_non_owner" />
543+ <em>BRANCHNAME</em>
544+ </tt>
545+ </dd>
546+ </dl>
547 </tal:cannot-push>
548+ <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions">
549+ To authenticate with the Launchpad Git hosting service, you need to
550+ <a tal:attributes="href string:${view/user/fmt:url}/+editsshkeys">
551+ register an SSH key</a>.
552+ </p>
553 </tal:logged-in>
554
555 </div>

Subscribers

People subscribed via source and target branches

to status/vote changes: