← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-basic-browser into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-basic-browser into lp:launchpad.

Commit message:
Add basic navigation and a bare minimum of browser rendering for Git repositories.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1032731 in Launchpad itself: "Support for Launchpad-hosted Git repositories"
  https://bugs.launchpad.net/launchpad/+bug/1032731

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-basic-browser/+merge/251779

Add basic navigation and a bare minimum of browser rendering for Git repositories.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-basic-browser into lp:launchpad.
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2014-12-06 10:52:39 +0000
+++ lib/lp/code/browser/configure.zcml	2015-03-04 16:58:30 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -760,6 +760,36 @@
         name="+count-summary"
         template="../templates/branch-count-summary.pt"/>
 
+    <browser:defaultView
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        name="+index"/>
+    <browser:url
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        urldata="lp.code.browser.gitrepository.GitRepositoryURL"/>
+    <browser:menus
+        module="lp.code.browser.gitrepository"
+        classes="
+            GitRepositoryContextMenu"/>
+    <browser:pages
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        class="lp.code.browser.gitrepository.GitRepositoryView"
+        permission="launchpad.View">
+        <browser:page
+            name="+index"
+            template="../templates/gitrepository-index.pt"/>
+        <browser:page
+            name="++repository-information"
+            template="../templates/gitrepository-information.pt"/>
+        <browser:page
+            name="++repository-management"
+            template="../templates/gitrepository-management.pt"/>
+    </browser:pages>
+    <adapter
+        provides="lp.services.webapp.interfaces.IBreadcrumb"
+        for="lp.code.interfaces.gitrepository.IGitRepository"
+        factory="lp.code.browser.gitrepository.GitRepositoryBreadcrumb"
+        permission="zope.Public"/>
+
     <browser:menus
         classes="ProductBranchesMenu"
         module="lp.code.browser.branchlisting"/>

=== added file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/gitrepository.py	2015-03-04 16:58:30 +0000
@@ -0,0 +1,108 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Git repository views."""
+
+__metaclass__ = type
+
+__all__ = [
+    'GitRepositoryBreadcrumb',
+    'GitRepositoryContextMenu',
+    'GitRepositoryURL',
+    'GitRepositoryView',
+    ]
+
+from bzrlib import urlutils
+from zope.interface import implements
+
+from lp.app.browser.informationtype import InformationTypePortletMixin
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.config import config
+from lp.services.webapp import (
+    ContextMenu,
+    LaunchpadView,
+    Link,
+    )
+from lp.services.webapp.authorization import (
+    check_permission,
+    precache_permission_for_objects,
+    )
+from lp.services.webapp.breadcrumb import NameBreadcrumb
+from lp.services.webapp.interfaces import ICanonicalUrlData
+
+
+class GitRepositoryURL:
+    """Git repository URL creation rules."""
+
+    implements(ICanonicalUrlData)
+
+    rootsite = "code"
+    inside = None
+
+    def __init__(self, repository):
+        self.repository = repository
+
+    @property
+    def path(self):
+        return self.repository.unique_name
+
+
+class GitRepositoryBreadcrumb(NameBreadcrumb):
+
+    @property
+    def inside(self):
+        return self.context.unique_name.split("/")[-1]
+
+
+class GitRepositoryContextMenu(ContextMenu):
+    """Context menu for `IGitRepository`."""
+
+    usedfor = IGitRepository
+    facet = "branches"
+    links = ["source"]
+
+    def source(self):
+        """Return a link to the branch's browsing interface."""
+        text = "Browse the code"
+        url = self.context.getCodebrowseUrl()
+        return Link(url, text, icon="info")
+
+
+class GitRepositoryView(InformationTypePortletMixin, LaunchpadView):
+
+    @property
+    def page_title(self):
+        return self.context.display_name
+
+    label = page_title
+
+    def initialize(self):
+        super(GitRepositoryView, self).initialize()
+        # Cache permission so that the private team owner can be rendered.  The
+        # security adapter will do the job also but we don't want or need the
+        # expense of running several complex SQL queries.
+        authorised_people = [self.context.owner]
+        if self.user is not None:
+            precache_permission_for_objects(
+                self.request, "launchpad.LimitedView", authorised_people)
+
+    @property
+    def anon_url(self):
+        if self.context.visibleByUser(None):
+            return urlutils.join(
+                config.codehosting.git_anon_root, self.context.shortened_path)
+        else:
+            return None
+
+    @property
+    def ssh_url(self):
+        if self.user is not None:
+            return urlutils.join(
+                config.codehosting.git_ssh_root, self.context.shortened_path)
+        else:
+            return None
+
+    @property
+    def user_can_push(self):
+        """Whether the user can push to this branch."""
+        return check_permission("launchpad.Edit", self.context)

=== added file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py	2015-03-04 16:58:30 +0000
@@ -0,0 +1,169 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Unit tests for GitRepositoryView."""
+
+__metaclass__ = type
+
+from BeautifulSoup import BeautifulSoup
+from bzrlib import urlutils
+from fixtures import FakeLogger
+from zope.component import getUtility
+from zope.publisher.interfaces import NotFound
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.services import IService
+from lp.registry.interfaces.person import PersonVisibility
+from lp.services.config import config
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import (
+    admin_logged_in,
+    BrowserTestCase,
+    login_person,
+    logout,
+    person_logged_in,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import (
+    setupBrowser,
+    setupBrowserForUser,
+    )
+from lp.testing.views import create_initialized_view
+
+
+class TestGitRepositoryView(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_anon_url_for_public(self):
+        # Public repositories have an anonymous URL, visible to anyone.
+        repository = self.factory.makeGitRepository()
+        view = create_initialized_view(repository, "+index")
+        expected_url = urlutils.join(
+            config.codehosting.git_anon_root, repository.shortened_path)
+        self.assertEqual(expected_url, view.anon_url)
+
+    def test_anon_url_not_for_private(self):
+        # Private repositories do not have an anonymous URL.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=owner, information_type=InformationType.USERDATA)
+        with person_logged_in(owner):
+            view = create_initialized_view(repository, "+index")
+            self.assertIsNone(view.anon_url)
+
+    def test_ssh_url_for_public_logged_in(self):
+        # Public repositories have an SSH URL, visible if logged in.
+        repository = self.factory.makeGitRepository()
+        with person_logged_in(repository.owner):
+            view = create_initialized_view(repository, "+index")
+            expected_url = urlutils.join(
+                config.codehosting.git_ssh_root, repository.shortened_path)
+            self.assertEqual(expected_url, view.ssh_url)
+
+    def test_ssh_url_for_public_not_anonymous(self):
+        # Public repositories do not have an SSH URL if not logged in.
+        repository = self.factory.makeGitRepository()
+        view = create_initialized_view(repository, "+index")
+        self.assertIsNone(view.ssh_url)
+
+    def test_ssh_url_for_private(self):
+        # Private repositories have an SSH URL.
+        owner = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=owner, information_type=InformationType.USERDATA)
+        with person_logged_in(owner):
+            view = create_initialized_view(repository, "+index")
+            expected_url = urlutils.join(
+                config.codehosting.git_ssh_root, repository.shortened_path)
+            self.assertEqual(expected_url, view.ssh_url)
+
+    def test_user_can_push(self):
+        # A user can push if they have edit permissions.
+        repository = self.factory.makeGitRepository()
+        with person_logged_in(repository.owner):
+            view = create_initialized_view(repository, "+index")
+            self.assertTrue(view.user_can_push)
+
+    def test_user_can_push_admins_can(self):
+        # Admins can push to any repository.
+        repository = self.factory.makeGitRepository()
+        with admin_logged_in():
+            view = create_initialized_view(repository, "+index")
+            self.assertTrue(view.user_can_push)
+
+    def test_user_can_push_non_owner(self):
+        # Someone not associated with the repository cannot upload.
+        repository = self.factory.makeGitRepository()
+        with person_logged_in(self.factory.makePerson()):
+            view = create_initialized_view(repository, "+index")
+            self.assertFalse(view.user_can_push)
+
+    def test_view_for_user_with_artifact_grant(self):
+        # Users with an artifact grant for a repository related to a private
+        # project can view the main repository page.
+        owner = self.factory.makePerson()
+        user = self.factory.makePerson()
+        project = self.factory.makeProduct(
+            owner=owner, information_type=InformationType.PROPRIETARY)
+        with person_logged_in(owner):
+            project_name = project.name
+            repository = self.factory.makeGitRepository(
+                owner=owner, target=project,
+                information_type=InformationType.PROPRIETARY)
+            getUtility(IService, "sharing").ensureAccessGrants(
+                [user], owner, gitrepositories=[repository])
+        with person_logged_in(user):
+            url = canonical_url(repository)
+        # The main check: No Unauthorized error should be raised.
+        browser = self.getUserBrowser(url, user=user)
+        self.assertIn(project_name, browser.contents)
+
+
+class TestGitRepositoryViewPrivateArtifacts(BrowserTestCase):
+    """ Tests that Git repositories with private team artifacts can be viewed.
+
+    A repository may be associated with a private team as follows:
+    - the owner is a private team
+
+    A logged in user who is not authorised to see the private team(s) still
+    needs to be able to view the repository.  The private team will be
+    rendered in the normal way, displaying the team name and Launchpad URL.
+    """
+
+    layer = DatabaseFunctionalLayer
+
+    def _getBrowser(self, user=None):
+        if user is None:
+            browser = setupBrowser()
+            logout()
+            return browser
+        else:
+            login_person(user)
+            return setupBrowserForUser(user=user)
+
+    def test_view_repository_with_private_owner(self):
+        # A repository with a private owner is rendered.
+        private_owner = self.factory.makeTeam(
+            displayname="PrivateTeam", visibility=PersonVisibility.PRIVATE)
+        with person_logged_in(private_owner):
+            repository = self.factory.makeGitRepository(owner=private_owner)
+        # Ensure the repository owner is rendered.
+        url = canonical_url(repository, rootsite="code")
+        user = self.factory.makePerson()
+        browser = self._getBrowser(user)
+        browser.open(url)
+        soup = BeautifulSoup(browser.contents)
+        self.assertIsNotNone(soup.find('a', text="PrivateTeam"))
+
+    def test_anonymous_view_repository_with_private_owner(self):
+        # A repository with a private owner is not rendered for anon users.
+        self.useFixture(FakeLogger())
+        private_owner = self.factory.makeTeam(
+            visibility=PersonVisibility.PRIVATE)
+        with person_logged_in(private_owner):
+            repository = self.factory.makeGitRepository(owner=private_owner)
+        # Viewing the branch results in an error.
+        url = canonical_url(repository, rootsite="code")
+        browser = self._getBrowser()
+        self.assertRaises(NotFound, browser.open, url)

=== modified file 'lib/lp/code/model/tests/test_gitcollection.py'
--- lib/lp/code/model/tests/test_gitcollection.py	2015-02-26 14:46:35 +0000
+++ lib/lp/code/model/tests/test_gitcollection.py	2015-03-04 16:58:30 +0000
@@ -27,6 +27,7 @@
     )
 from lp.registry.model.personproduct import PersonProduct
 from lp.services.database.interfaces import IStore
+from lp.services.webapp.publisher import canonical_url
 from lp.testing import (
     person_logged_in,
     StormStatementRecorder,
@@ -423,8 +424,8 @@
             [self.public_repository], list(repositories.getRepositories()))
 
     def test_owner_sees_own_repositories(self):
-        # Users can always see the repositories that they own, as well as public
-        # repositories.
+        # Users can always see the repositories that they own, as well as
+        # public repositories.
         owner = removeSecurityProxy(self.private_repository).owner
         repositories = self.all_repositories.visibleByUser(owner)
         self.assertEqual(
@@ -520,6 +521,11 @@
         search_results = self.collection.search(lp_name)
         self.assertEqual([repository], list(search_results))
 
+    def test_exact_match_full_url(self):
+        repository = self.factory.makeGitRepository()
+        url = canonical_url(repository)
+        self.assertEqual([repository], list(self.collection.search(url)))
+
     def test_exact_match_bad_url(self):
         search_results = self.collection.search('http:hahafail')
         self.assertEqual([], list(search_results))
@@ -653,7 +659,8 @@
         team1 = self.factory.makeTeam(owner=person)
         repository = self.factory.makeGitRepository(owner=team1)
         # Make another team that person is in that owns a repository in a
-        # different namespace to the namespace of the repository owned by team1.
+        # different namespace to the namespace of the repository owned by
+        # team1.
         team2 = self.factory.makeTeam(owner=person)
         self.factory.makeGitRepository(owner=team2)
         collection = self.all_repositories.inProject(repository.target)

=== added file 'lib/lp/code/templates/gitrepository-index.pt'
--- lib/lp/code/templates/gitrepository-index.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-index.pt	2015-03-04 16:58:30 +0000
@@ -0,0 +1,53 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_side"
+  i18n:domain="launchpad"
+>
+
+<metal:block fill-slot="head_epilogue">
+  <style type="text/css">
+    #clone-url dt {
+      font-weight: strong;
+    }
+  </style>
+</metal:block>
+
+<body>
+
+<metal:side fill-slot="side">
+  <div tal:replace="structure context/@@+global-actions" />
+</metal:side>
+
+<tal:registering metal:fill-slot="registering">
+  Created by
+    <tal:registrant replace="structure context/registrant/fmt:link" />
+  on
+    <tal:created-on replace="structure context/date_created/fmt:date" />
+  and last modified on
+    <tal:last-modified replace="structure context/date_last_modified/fmt:date" />
+</tal:registering>
+
+<div metal:fill-slot="main">
+
+  <div class="yui-g first">
+    <div id="repository-management" class="portlet">
+      <tal:repository-management
+          replace="structure context/@@++repository-management" />
+    </div>
+  </div>
+
+  <div class="yui-g">
+    <div id="repository-info" class="portlet">
+      <h2>Repository information</h2>
+      <tal:repository-info
+          replace="structure context/@@++repository-information" />
+    </div>
+  </div>
+
+</div>
+
+</body>
+</html>

=== added file 'lib/lp/code/templates/gitrepository-information.pt'
--- lib/lp/code/templates/gitrepository-information.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-information.pt	2015-03-04 16:58:30 +0000
@@ -0,0 +1,18 @@
+<div
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";>
+
+  <div class="two-column-list">
+    <dl id="owner">
+      <dt>Owner:</dt>
+      <dd tal:content="structure context/owner/fmt:link" />
+    </dl>
+
+    <dl id="partof" tal:condition="context/target">
+      <dt>Target:</dt>
+      <dd tal:content="structure context/target/fmt:link" />
+    </dl>
+  </div>
+
+</div>

=== added file 'lib/lp/code/templates/gitrepository-management.pt'
--- lib/lp/code/templates/gitrepository-management.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-management.pt	2015-03-04 16:58:30 +0000
@@ -0,0 +1,82 @@
+<div
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  tal:define="context_menu view/context/menu:context">
+
+  <dl id="clone-url">
+    <dt>Get this repository:</dt>
+    <dd>
+      <tal:anonymous condition="view/anon_url">
+        <tt class="command">
+          git clone <span class="anon-url" tal:content="view/anon_url" />
+        </tt>
+        <br />
+      </tal:anonymous>
+      <tal:ssh condition="view/ssh_url">
+        <tt class="command">
+          git clone <span class="ssh-url" tal:content="view/ssh_url" />
+        </tt>
+      </tal:ssh>
+    </dd>
+  </dl>
+
+  <div id="upload-directions">
+    <tal:not-logged-in condition="not:view/user">
+      <tal:individual condition="not:context/owner/is_team">
+          Only
+          <a tal:attributes="href context/owner/fmt:url"
+             tal:content="context/owner/displayname">Person</a>
+          can upload to this repository. If you are
+          <tal:branch-owner replace="context/owner/displayname"/>
+          please <a href="+login">log in</a> for upload directions.
+      </tal:individual>
+      <tal:team tal:condition="context/owner/is_team">
+          Members of
+          <a tal:attributes="href context/owner/fmt:url"
+             tal:content="context/owner/displayname">Team</a>
+          can upload to this repository. <a href="+login">Log in</a> for
+          directions.
+      </tal:team>
+    </tal:not-logged-in>
+
+    <tal:logged-in condition="view/user">
+      <tal:can-push tal:condition="view/user_can_push">
+        <dl id="push-url">
+          <dt>Update this repository:</dt>
+          <dd>
+            <tt class="command">
+            git push
+            </tt>
+          </dd>
+        </dl>
+        <p tal:condition="not:view/user/sshkeys" id="ssh-key-directions">
+          To authenticate with the Launchpad Git hosting service, you need to
+          <a tal:attributes="href string:${view/user/fmt:url}/+editsshkeys">
+            register a SSH key</a>.
+        </p>
+      </tal:can-push>
+
+      <tal:cannot-push condition="not:view/user_can_push">
+        <div id="push-directions" tal:condition="not:context/owner/is_team">
+          You cannot push to this repository. Only
+          <a tal:attributes="href context/owner/fmt:url"
+             tal:content="context/owner/displayname">Person</a>
+          can push to this repository.
+        </div>
+        <div id="push-directions" tal:condition="context/owner/is_team">
+          You cannot push to this repository. Members of
+          <a tal:attributes="href context/owner/fmt:url"
+             tal:content="context/owner/displayname">Team</a>
+          can push to this repository.
+        </div>
+      </tal:cannot-push>
+    </tal:logged-in>
+
+  </div>
+
+  <div style="margin-top: 1.5em" tal:define="link context_menu/source">
+    <a tal:replace="structure link/fmt:link" />
+  </div>
+
+</div>

=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py	2015-02-27 01:11:06 +0000
+++ lib/lp/registry/browser/person.py	2015-03-04 16:58:30 +0000
@@ -143,6 +143,7 @@
 from lp.code.browser.sourcepackagerecipelisting import HasRecipesMenuMixin
 from lp.code.errors import InvalidNamespace
 from lp.code.interfaces.branchnamespace import IBranchNamespaceSet
+from lp.code.interfaces.gitlookup import IGitTraverser
 from lp.registry.browser import BaseRdfView
 from lp.registry.browser.branding import BrandingChangeView
 from lp.registry.browser.menu import (
@@ -155,6 +156,9 @@
 from lp.registry.errors import VoucherAlreadyRedeemed
 from lp.registry.interfaces.codeofconduct import ISignedCodeOfConductSet
 from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+    )
 from lp.registry.interfaces.gpg import IGPGKeySet
 from lp.registry.interfaces.irc import IIrcIDSet
 from lp.registry.interfaces.jabber import (
@@ -361,6 +365,40 @@
         raise NotFoundError
 
     def traverse(self, pillar_name):
+        try:
+            # Look for a Git repository.  We must be careful not to consume
+            # the traversal stack immediately, as if we fail to find a Git
+            # repository we will need to look for a Bazaar branch instead.
+            segments = (
+                ["~%s" % self.context.name, pillar_name] +
+                list(reversed(self.request.getTraversalStack())))
+            num_segments = len(segments)
+            iter_segments = iter(segments)
+            traverser = getUtility(IGitTraverser)
+            _, target, repository = traverser.traverse(iter_segments)
+            if repository is None:
+                raise NotFoundError
+            for i in range(num_segments - len(list(iter_segments))):
+                self.request.stepstogo.consume()
+
+            if IProduct.providedBy(target):
+                if target.name != pillar_name:
+                    # This repository was accessed through one of its
+                    # project's aliases, so we must redirect to its
+                    # canonical URL.
+                    return self.redirectSubTree(canonical_url(repository))
+
+            if IDistributionSourcePackage.providedBy(target):
+                if target.distribution.name != pillar_name:
+                    # This branch or repository was accessed through one of its
+                    # distribution's aliases, so we must redirect to its
+                    # canonical URL.
+                    return self.redirectSubTree(canonical_url(repository))
+
+            return repository
+        except (NotFoundError, InvalidNamespace):
+            pass
+
         # If the pillar is a product, then return the PersonProduct; if it
         # is a distribution and further segments provide a source package,
         # then return the PersonDistributionSourcePackage.
@@ -409,13 +447,13 @@
 
         if branch.product is not None:
             if branch.product.name != pillar_name:
-                # This branch was accessed through one of its product's
+                # This branch was accessed through one of its project's
                 # aliases, so we must redirect to its canonical URL.
                 return self.redirectSubTree(canonical_url(branch))
 
         if branch.distribution is not None:
             if branch.distribution.name != pillar_name:
-                # This branch was accessed through one of its product's
+                # This branch was accessed through one of its distribution's
                 # aliases, so we must redirect to its canonical URL.
                 return self.redirectSubTree(canonical_url(branch))
 

=== modified file 'lib/lp/registry/browser/tests/test_person.py'
--- lib/lp/registry/browser/tests/test_person.py	2015-02-26 21:09:49 +0000
+++ lib/lp/registry/browser/tests/test_person.py	2015-03-04 16:58:30 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -144,6 +144,28 @@
         self.assertRedirect('/api/devel' + in_suf, '/api/devel' + out_suf)
         self.assertRedirect('/api/1.0' + in_suf, '/api/1.0' + out_suf)
 
+    def test_traverse_git_repository_project(self):
+        project = self.factory.makeProduct()
+        repository = self.factory.makeGitRepository(target=project)
+        url = "/~%s/%s/+git/%s" % (
+            repository.owner.name, project.name, repository.name)
+        self.assertEqual(repository, test_traverse(url)[0])
+
+    def test_traverse_git_repository_package(self):
+        dsp = self.factory.makeDistributionSourcePackage()
+        repository = self.factory.makeGitRepository(target=dsp)
+        url = "/~%s/%s/+source/%s/+git/%s" % (
+            repository.owner.name, dsp.distribution.name,
+            dsp.sourcepackagename.name, repository.name)
+        self.assertEqual(repository, test_traverse(url)[0])
+
+    def test_traverse_git_repository_personal(self):
+        person = self.factory.makePerson()
+        repository = self.factory.makeGitRepository(
+            owner=person, target=person)
+        url = "/~%s/+git/%s" % (person.name, repository.name)
+        self.assertEqual(repository, test_traverse(url)[0])
+
 
 class PersonViewOpenidIdentityUrlTestCase(TestCaseWithFactory):
     """Tests for the public OpenID identifier shown on the profile page."""
@@ -274,7 +296,7 @@
             implementation_status=SpecificationImplementationStatus.STARTED,
             information_type=InformationType.PUBLIC)
         private_name = 'super-private'
-        private_spec = self.factory.makeSpecification(
+        self.factory.makeSpecification(
             name=private_name, assignee=person,
             implementation_status=SpecificationImplementationStatus.STARTED,
             information_type=InformationType.PROPRIETARY)


Follow ups