← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~jtv/launchpad/bug-711077 into lp:launchpad

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/launchpad/bug-711077 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #711077 Product:+code-index timeouts
  https://bugs.launchpad.net/bugs/711077

For more details, see:
https://code.launchpad.net/~jtv/launchpad/bug-711077/+merge/49242

= Summary =

Some +code-index indexes were timing out.

There were several main culprits:
 * Code to figure out whether a branch is attached to bugs was too roundabout.
 * Following references between objects can be costly even from cache.
 * Authors' names and icons were retrieved one icon at a time.
 * Complex tooltips with last-revision author info were constructed in TAL.
 * Counting and querying merge proposals is complex, costly, and duplicative.
 * ValidPersonCache is now a view, and hard to cache.


== Proposed fix ==

I addressed most of the issues I found, just not the last two ones: branch merge proposals and ValidPersonCache.  It might help if we could get rid of the ValidPersonCache view in the schema at some point.

Fixes:
 * A new getBranchesWithVisibleBugs queries exactly what's needed, no more.
 * Many references can be bypassed using foreign keys' numeric values.
 * A new fetchAuthorsForDisplay queries persons, along with their icon info.
 * A new link formatter renders RevisionAuthor links directly in python.


== Pre-implementation notes ==

Robert felt that GIL issues might be behind the problem, so I had a profile taken.  I was unable to make much of the results though, and so focused on conventional optimization.


== Implementation details ==

Tests for getBranchesWithVisibleBugs are direct descendants of existing ones for getBugBranchesForBranches, which I gradually reworked since they weren't being used anywhere else.  The new code passed the old tests, but in the end I broke them down into smaller units with more explicit test goals.


== Tests ==

I ran all non-Windmill code tests plus test_bugbranch, and have a full EC2 run underway, but for a quick peek at the most you can run:

{{{
./bin/test -vvc lp.bugs.tests.test_bugbranch -t getBranchesWithVisibleBugs
./bin/test -vvc lp.code.browser.tests.test_revisionauthor -t RevisionAuthorFormatter
./bin/test -vvc lp.code.model.tests.test_revision -t fetchAuthorsForDisplay
}}}

== Demo and Q/A ==

This is an optimization branch, so don't expect any changes in functionality.  But have a look at: https://code.qastaging.launchpad.net/openobject-addons/+code-index

That page should render fast enough (once qastaging's cache is warmed up to a realistic level) and not break.  In fact a cowboyed version of the branch is running on qastaging as I write this.


= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/code/templates/branch-listing.pt
  lib/lp/bugs/tests/test_bugbranch.py
  lib/lp/code/interfaces/revision.py
  BRANCH.TODO
  lib/lp/app/browser/configure.zcml
  lib/lp/app/browser/tales.py
  lib/lp/bugs/interfaces/bugbranch.py
  lib/lp/code/model/tests/test_revision.py
  lib/lp/bugs/model/bugbranch.py
  lib/lp/code/model/revision.py
  lib/lp/code/browser/branchlisting.py
  lib/lp/code/browser/tests/test_revisionauthor.py
-- 
https://code.launchpad.net/~jtv/launchpad/bug-711077/+merge/49242
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/launchpad/bug-711077 into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2011-01-20 20:35:32 +0000
+++ lib/lp/app/browser/configure.zcml	2011-02-10 16:13:03 +0000
@@ -499,6 +499,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.code.interfaces.revision.IRevisionAuthor"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.RevisionAuthorFormatterAPI"
+      name="fmt"
+      />
+  <adapter
       for="lp.translations.interfaces.translationgroup.ITranslationGroup"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.TranslationGroupFormatterAPI"

=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2011-01-19 22:11:23 +0000
+++ lib/lp/app/browser/tales.py	2011-02-10 16:13:03 +0000
@@ -2253,6 +2253,25 @@
             return u''
 
 
+class RevisionAuthorFormatterAPI(ObjectFormatterAPI):
+    """Adapter for `IRevisionAuthor` links."""
+
+    traversable_names = {'link': 'link'}
+
+    def link(self, view_name=None, rootsite='mainsite'):
+        """See `ObjectFormatterAPI`."""
+        context = self._context
+        if context.person is not None:
+            return PersonFormatterAPI(self._context.person).link(
+                view_name, rootsite)
+        elif context.name_without_email:
+            return cgi.escape(context.name_without_email)
+        elif getUtility(ILaunchBag).user is not None:
+            return cgi.escape(context.email)
+        else:
+            return "&lt;email address hidden&gt;"
+
+
 def clean_path_segments(request):
     """Returns list of path segments, excluding system-related segments."""
     proto_host_port = request.getApplicationURL()

=== modified file 'lib/lp/bugs/interfaces/bugbranch.py'
--- lib/lp/bugs/interfaces/bugbranch.py	2010-11-09 13:32:50 +0000
+++ lib/lp/bugs/interfaces/bugbranch.py	2011-02-10 16:13:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0211,E0213
@@ -48,6 +48,7 @@
         BugField(
             title=_("Bug #"),
             required=True, readonly=True))
+    branch_id = Int(title=_("Branch ID"), required=True, readonly=True)
     branch = exported(
         ReferenceChoice(
             title=_("Branch"), schema=IBranch,
@@ -75,11 +76,14 @@
         Return None if there is no such link.
         """
 
-    def getBugBranchesForBranches(branches, user):
-        """Return a sequence of IBugBranch instances associated with
-        the given branches.
+    def getBranchesWithVisibleBugs(branches, user):
+        """Find which of `branches` are for bugs that `user` can see.
 
-        Only return instances that are visible to the user.
+        :param branches: A sequence of `Branch`es to limit the search
+            to.
+        :return: A result set of `Branch` ids: a subset of the ids
+            found in `branches`, but limited to branches that are
+            visible to `user`.
         """
 
     def getBugBranchesForBugTasks(tasks):

=== modified file 'lib/lp/bugs/model/bugbranch.py'
--- lib/lp/bugs/model/bugbranch.py	2010-10-03 15:30:06 +0000
+++ lib/lp/bugs/model/bugbranch.py	2011-02-10 16:13:03 +0000
@@ -13,24 +13,29 @@
 from sqlobject import (
     ForeignKey,
     IN,
+    IntCol,
     StringCol,
     )
+from storm.expr import (
+    And,
+    Or,
+    Select,
+    )
 from zope.component import getUtility
 from zope.interface import implements
 
 from canonical.database.constants import UTC_NOW
 from canonical.database.datetimecol import UtcDateTimeCol
-from canonical.database.sqlbase import (
-    SQLBase,
-    sqlvalues,
-    )
+from canonical.database.sqlbase import SQLBase
 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
+from canonical.launchpad.interfaces.lpstorm import IStore
 from lp.bugs.interfaces.bugbranch import (
     IBugBranch,
     IBugBranchSet,
     )
 from lp.code.interfaces.branchtarget import IHasBranchTarget
 from lp.registry.interfaces.person import validate_public_person
+from lp.registry.model.teammembership import TeamParticipation
 
 
 class BugBranch(SQLBase):
@@ -39,6 +44,7 @@
 
     datecreated = UtcDateTimeCol(notNull=True, default=UTC_NOW)
     bug = ForeignKey(dbName="bug", foreignKey="Bug", notNull=True)
+    branch_id = IntCol(dbName="branch", notNull=True)
     branch = ForeignKey(dbName="branch", foreignKey="Branch", notNull=True)
     revision_hint = StringCol(default=None)
 
@@ -69,41 +75,45 @@
         "See `IBugBranchSet`."
         return BugBranch.selectOneBy(bugID=bug.id, branchID=branch.id)
 
-    def getBugBranchesForBranches(self, branches, user):
-        "See IBugBranchSet."
+    def getBranchesWithVisibleBugs(self, branches, user):
+        """See `IBugBranchSet`."""
+        # Avoid circular imports.
+        from lp.bugs.model.bug import Bug
+        from lp.bugs.model.bugsubscription import BugSubscription
+
         branch_ids = [branch.id for branch in branches]
-        if not branch_ids:
+        if branch_ids == []:
             return []
-        where_clauses = []
-
-        # Select only bug branch links for the branches specified,
-        # and join with the Bug table.
-        where_clauses.append("""
-            BugBranch.branch in %s AND
-            BugBranch.bug = Bug.id""" % sqlvalues(branch_ids))
 
         admins = getUtility(ILaunchpadCelebrities).admin
-        if user:
-            if not user.inTeam(admins):
-                # Enforce privacy-awareness for logged-in, non-admin users,
-                # so that they can only see the private bugs that they're
-                # allowed to see.
-                where_clauses.append("""
-                    (Bug.private = FALSE OR
-                     Bug.id in (
-                         SELECT Bug.id
-                         FROM Bug, BugSubscription, TeamParticipation
-                         WHERE Bug.id = BugSubscription.bug AND
-                             TeamParticipation.person = %(personid)s AND
-                             BugSubscription.person = TeamParticipation.team))
-                             """ % sqlvalues(personid=user.id))
+        if user is None:
+            # Anonymous visitors only get to know about public bugs.
+            visible = And(
+                Bug.id == BugBranch.bugID,
+                Bug.private == False)
+        elif user.inTeam(admins):
+            # Administrators know about all bugs.
+            visible = True
         else:
-            # Anonymous user; filter to include only public bugs in
-            # the search results.
-            where_clauses.append("Bug.private = FALSE")
-
-        return BugBranch.select(
-            ' AND '.join(where_clauses), clauseTables=['Bug'])
+            # Anyone else can know about public bugs plus any private
+            # ones they may be directly or indirectly subscribed to.
+            subscribed = And(
+                TeamParticipation.teamID == BugSubscription.person_id,
+                TeamParticipation.personID == user.id)
+
+            visible = And(
+                Bug.id == BugBranch.bugID,
+                Or(
+                    Bug.private == False,
+                    Bug.id.is_in(Select(
+                        columns=[BugSubscription.bug_id],
+                        tables=[BugSubscription, TeamParticipation],
+                        where=subscribed))))
+
+        return IStore(BugBranch).find(
+            BugBranch.branchID,
+            BugBranch.branch_id.is_in(branch_ids),
+            visible).config(distinct=True)
 
     def getBugBranchesForBugTasks(self, tasks):
         "See IBugBranchSet."

=== modified file 'lib/lp/bugs/tests/test_bugbranch.py'
--- lib/lp/bugs/tests/test_bugbranch.py	2010-10-26 15:47:24 +0000
+++ lib/lp/bugs/tests/test_bugbranch.py	2011-02-10 16:13:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for bug-branch linking from the bugs side."""
@@ -32,46 +32,103 @@
         # BugBranchSet objects provide IBugBranchSet.
         self.assertProvides(BugBranchSet(), IBugBranchSet)
 
-    def test_getBugBranchesForBranches_no_branches(self):
+    def test_getBranchesWithVisibleBugs_no_branches(self):
         bug_branches = getUtility(IBugBranchSet)
-        links = bug_branches.getBugBranchesForBranches(
+        links = bug_branches.getBranchesWithVisibleBugs(
             [], self.factory.makePerson())
         self.assertEqual([], list(links))
 
-    def test_getBugBranchesForBranches(self):
-        # IBugBranchSet.getBugBranchesForBranches returns all of the BugBranch
-        # objects associated with the given branches.
+    def test_getBranchesWithVisibleBugs_finds_branches_with_public_bugs(self):
+        # IBugBranchSet.getBranchesWithVisibleBugs returns all of the
+        # Branch ids associated with the given branches that have bugs
+        # visible to the current user.  Those trivially include ones
+        # for non-private bugs.
         branch_1 = self.factory.makeBranch()
         branch_2 = self.factory.makeBranch()
         bug_a = self.factory.makeBug()
         bug_b = self.factory.makeBug()
         self.factory.loginAsAnyone()
-        link_1 = bug_a.linkBranch(branch_1, self.factory.makePerson())
-        link_2 = bug_a.linkBranch(branch_2, self.factory.makePerson())
-        link_3 = bug_b.linkBranch(branch_2, self.factory.makePerson())
-        self.assertEqual(
-            set([link_1, link_2, link_3]),
-            set(getUtility(IBugBranchSet).getBugBranchesForBranches(
-                [branch_1, branch_2], self.factory.makePerson())))
-
-    def test_getBugBranchesForBranches_respects_bug_privacy(self):
-        # IBugBranchSet.getBugBranchesForBranches returns only the BugBranch
-        # objects that are visible by the user who is asking for them.
-        branch = self.factory.makeBranch()
-        user = self.factory.makePerson()
-        public_bug = self.factory.makeBug()
-        private_visible_bug = self.factory.makeBug(private=True)
-        private_invisible_bug = self.factory.makeBug(private=True)
-        with celebrity_logged_in('admin'):
-            public_bug.linkBranch(branch, user)
-            private_visible_bug.subscribe(user, user)
-            private_visible_bug.linkBranch(branch, user)
-            private_invisible_bug.linkBranch(branch, user)
-        bug_branches = getUtility(IBugBranchSet).getBugBranchesForBranches(
-            [branch], user)
-        self.assertEqual(
-            set([public_bug, private_visible_bug]),
-            set([link.bug for link in bug_branches]))
+        bug_a.linkBranch(branch_1, self.factory.makePerson())
+        bug_a.linkBranch(branch_2, self.factory.makePerson())
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [branch_1.id, branch_2.id],
+            utility.getBranchesWithVisibleBugs(
+                [branch_1, branch_2], self.factory.makePerson()))
+
+    def test_getBranchesWithVisibleBugs_shows_public_bugs_to_anon(self):
+        # getBranchesWithVisibleBugs shows public bugs to anyone,
+        # including anonymous users.
+        branch = self.factory.makeBranch()
+        bug = self.factory.makeBug(private=False)
+        with celebrity_logged_in('admin'):
+            bug.linkBranch(branch, self.factory.makePerson())
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [branch.id], utility.getBranchesWithVisibleBugs([branch], None))
+
+    def test_getBranchesWithVisibleBugs_ignores_duplicate_bugbranches(self):
+        # getBranchesWithVisibleBugs reports a branch only once even if
+        # it's linked to the same bug multiple times.
+        branch = self.factory.makeBranch()
+        user = self.factory.makePerson()
+        bug = self.factory.makeBug()
+        self.factory.loginAsAnyone()
+        bug.linkBranch(branch, user)
+        bug.linkBranch(branch, user)
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [branch.id], utility.getBranchesWithVisibleBugs([branch], user))
+
+    def test_getBranchesWithVisibleBugs_ignores_extra_bugs(self):
+        # getBranchesWithVisibleBugs reports a branch only once even if
+        # it's liked to multiple bugs.
+        branch = self.factory.makeBranch()
+        user = self.factory.makePerson()
+        with celebrity_logged_in('admin'):
+            self.factory.makeBug().linkBranch(branch, user)
+            self.factory.makeBug().linkBranch(branch, user)
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [branch.id], utility.getBranchesWithVisibleBugs([branch], user))
+
+    def test_getBranchesWithVisibleBugs_hides_private_bugs_from_anon(self):
+        # getBranchesWithVisibleBugs does not show private bugs to users
+        # who aren't logged in.
+        branch = self.factory.makeBranch()
+        bug = self.factory.makeBug(private=True)
+        with celebrity_logged_in('admin'):
+            bug.linkBranch(branch, self.factory.makePerson())
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [], utility.getBranchesWithVisibleBugs([branch], None))
+
+    def test_getBranchesWithVisibleBugs_hides_private_bugs_from_joe(self):
+        # getBranchesWithVisibleBugs does not show private bugs to
+        # arbitrary logged-in users (such as Average Joe, or J. Random
+        # Hacker).
+        branch = self.factory.makeBranch()
+        bug = self.factory.makeBug(private=True)
+        with celebrity_logged_in('admin'):
+            bug.linkBranch(branch, self.factory.makePerson())
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [],
+            utility.getBranchesWithVisibleBugs(
+                [branch], self.factory.makePerson()))
+
+    def test_getBranchesWithVisibleBugs_shows_private_bugs_to_sub(self):
+        # getBranchesWithVisibleBugs will show private bugs to their
+        # subscribers.
+        branch = self.factory.makeBranch()
+        bug = self.factory.makeBug(private=True)
+        user = self.factory.makePerson()
+        with celebrity_logged_in('admin'):
+            bug.subscribe(user, self.factory.makePerson())
+            bug.linkBranch(branch, self.factory.makePerson())
+        utility = getUtility(IBugBranchSet)
+        self.assertContentEqual(
+            [branch.id], utility.getBranchesWithVisibleBugs([branch], user))
 
     def test_getBugBranchesForBugTasks(self):
         # IBugBranchSet.getBugBranchesForBugTasks returns all of the BugBranch

=== modified file 'lib/lp/code/browser/branchlisting.py'
--- lib/lp/code/browser/branchlisting.py	2011-02-02 15:43:31 +0000
+++ lib/lp/code/browser/branchlisting.py	2011-02-10 16:13:03 +0000
@@ -439,9 +439,8 @@
     @cachedproperty
     def branch_ids_with_bug_links(self):
         """Return a set of branch ids that should show bug badges."""
-        bug_branches = getUtility(IBugBranchSet).getBugBranchesForBranches(
-            self.visible_branches_for_view, self.view_user)
-        return set(bug_branch.branch.id for bug_branch in bug_branches)
+        return set(getUtility(IBugBranchSet).getBranchesWithVisibleBugs(
+            self.visible_branches_for_view, self.view_user))
 
     @cachedproperty
     def branch_ids_with_spec_links(self):
@@ -466,17 +465,25 @@
     @cachedproperty
     def tip_revisions(self):
         """Return a set of branch ids that should show blueprint badges."""
-        revisions = getUtility(IRevisionSet).getTipRevisionsForBranches(
+        revisionset = getUtility(IRevisionSet)
+        revisions = revisionset.getTipRevisionsForBranches(
             self.visible_branches_for_view)
         if revisions is None:
-            revision_map = {}
-        else:
-            # Key the revisions by revision id.
-            revision_map = dict((revision.revision_id, revision)
-                                for revision in revisions)
+            revisions = []
+
+        # Key the revisions by revision id.
+        revision_map = dict(
+            (revision.revision_id, revision) for revision in revisions)
+
+        # Cache display information for authors of branches' respective
+        # last revisions.
+        revisionset.fetchAuthorsForDisplay(
+            [revision.revision_author for revision in revisions])
+
         # Return a dict keyed on branch id.
-        return dict((branch.id, revision_map.get(branch.last_scanned_id))
-                     for branch in self.visible_branches_for_view)
+        return dict(
+            (branch.id, revision_map.get(branch.last_scanned_id))
+            for branch in self.visible_branches_for_view)
 
     def _createItem(self, branch):
         last_commit = self.tip_revisions[branch.id]

=== added file 'lib/lp/code/browser/tests/test_revisionauthor.py'
--- lib/lp/code/browser/tests/test_revisionauthor.py	1970-01-01 00:00:00 +0000
+++ lib/lp/code/browser/tests/test_revisionauthor.py	2011-02-10 16:13:03 +0000
@@ -0,0 +1,82 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests related to `RevisionAuthor`."""
+
+__metaclass__ = type
+
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.app.browser.tales import (
+    PersonFormatterAPI,
+    RevisionAuthorFormatterAPI,
+    )
+from lp.testing import (
+    login,
+    TestCaseWithFactory,
+    )
+from lp.testing.sampledata import USER_EMAIL
+
+
+class TestRevisionAuthorFormatterAPI(TestCaseWithFactory):
+    """Test `RevisionAuthor` link formatter."""
+
+    layer = DatabaseFunctionalLayer
+
+    def _formatAuthorLink(self, revision):
+        """Format a link to `revision`'s author."""
+        return RevisionAuthorFormatterAPI(revision.revision_author).link()
+
+    def test_link_links_to_person_if_known(self):
+        # When a RevisionAuthor is coupled to a Person, the link
+        # formatter simply links to the Person.
+        author = self.factory.makePerson()
+        revision = self.factory.makeRevision(author=author)
+        self.assertEqual(
+            PersonFormatterAPI(author).link(None),
+            self._formatAuthorLink(revision))
+
+    def test_link_shows_name(self):
+        # When a RevisionAuthor is not coupled to a Person but does have
+        # a name attached to it, the link formatter shows the name.
+        revision = self.factory.makeRevision(author="J.R. Hacker")
+        self.assertEqual("J.R. Hacker", self._formatAuthorLink(revision))
+
+    def test_link_shows_email_if_necessary(self):
+        # If nothing else is available, the author link will show the
+        # author's email address.
+        login(USER_EMAIL)
+        email = "%s@xxxxxxxxxxx" % self.factory.getUniqueString()
+        revision = self.factory.makeRevision(author=email)
+        self.assertEqual(email, self._formatAuthorLink(revision))
+
+    def test_link_name_trumps_email(self):
+        # If both a name and an email address are available, the email
+        # address is not shown.
+        name = self.factory.getUniqueString()
+        email = "%s@xxxxxxxxxxx" % self.factory.getUniqueString()
+        full_email = "%s <%s>" % (name, email)
+        revision = self.factory.makeRevision(author=full_email)
+        self.assertEqual(name, self._formatAuthorLink(revision))
+
+    def test_email_is_never_shown_to_anonymous_users(self):
+        # Even if only an email address is available, it will not be
+        # shown to anonymous users.
+        account = self.factory.getUniqueString()
+        email = "%s@xxxxxxxxxxx" % account
+        revision = self.factory.makeRevision(author=email)
+        self.assertNotIn(account, self._formatAuthorLink(revision))
+        self.assertNotIn('@', self._formatAuthorLink(revision))
+
+    def test_name_is_escaped(self):
+        # The author's name is HTML-escaped.
+        revision = self.factory.makeRevision(author="apples & pears")
+        self.assertEqual(
+            "apples &amp; pears", self._formatAuthorLink(revision))
+
+    def test_email_is_escaped(self):
+        # The author's email address is HTML-escaped.
+        login(USER_EMAIL)
+        raw_email = "a&b@xxxxxxxxxxx"
+        escaped_email = "a&amp;b@xxxxxxxxxxx"
+        revision = self.factory.makeRevision(author=raw_email)
+        self.assertEqual(escaped_email, self._formatAuthorLink(revision))

=== modified file 'lib/lp/code/interfaces/revision.py'
--- lib/lp/code/interfaces/revision.py	2010-10-12 05:34:49 +0000
+++ lib/lp/code/interfaces/revision.py	2011-02-10 16:13:03 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0211,E0213
@@ -34,7 +34,10 @@
     date_created = Datetime(
         title=_("Date Created"), required=True, readonly=True)
     log_body = Attribute("The revision log message.")
+
+    revision_author_id = Attribute("Revision author identifier id.")
     revision_author = Attribute("The revision author identifier.")
+
     gpgkey = Attribute("The OpenPGP key used to sign the revision.")
     revision_id = Attribute("The globally unique revision identifier.")
     revision_date = Datetime(
@@ -224,3 +227,13 @@
 
         :param limit: Remove at most `limit` rows at once.
         """
+
+    def fetchAuthorsForDisplay(revision_authors):
+        """Retrieve `Person`s associated with a bunch of `RevisionAuthor`s.
+
+        Also prefetches information that will be needed in order to show
+        the authors in the user interface.
+
+        :param revision_authors: A sequence of `IRevisionAuthor`s.
+        :return: A list of `Person`s.
+        """

=== modified file 'lib/lp/code/model/revision.py'
--- lib/lp/code/model/revision.py	2010-10-12 05:34:49 +0000
+++ lib/lp/code/model/revision.py	2011-02-10 16:13:03 +0000
@@ -33,6 +33,7 @@
     Asc,
     Desc,
     Join,
+    LeftJoin,
     Not,
     Or,
     Select,
@@ -54,6 +55,10 @@
     UTC_NOW,
     )
 from canonical.database.datetimecol import UtcDateTimeCol
+from canonical.launchpad.database.librarian import (
+    LibraryFileAlias,
+    LibraryFileContent,
+    )
 from canonical.database.sqlbase import (
     quote,
     SQLBase,
@@ -81,6 +86,10 @@
 from lp.registry.interfaces.person import validate_public_person
 from lp.registry.interfaces.product import IProduct
 from lp.registry.interfaces.projectgroup import IProjectGroup
+from lp.registry.model.person import (
+    Person,
+    ValidPersonCache,
+    )
 
 
 class Revision(SQLBase):
@@ -92,8 +101,9 @@
     log_body = StringCol(notNull=True)
     gpgkey = ForeignKey(dbName='gpgkey', foreignKey='GPGKey', default=None)
 
-    revision_author = ForeignKey(
-        dbName='revision_author', foreignKey='RevisionAuthor', notNull=True)
+    revision_author_id = Int(name='revision_author', allow_none=False)
+    revision_author = Reference(revision_author_id, 'RevisionAuthor.id')
+
     revision_id = StringCol(notNull=True, alternateID=True,
                             alternateMethodName='byRevisionID')
     revision_date = UtcDateTimeCol(notNull=False)
@@ -430,7 +440,6 @@
         # Here to stop circular imports.
         from lp.code.model.branch import Branch
         from lp.code.model.branchrevision import BranchRevision
-        from lp.registry.model.person import ValidPersonCache
 
         store = IStore(Revision)
         results_with_dupes = store.find(
@@ -591,6 +600,31 @@
             limit=limit)
         store.find(RevisionCache, RevisionCache.id.is_in(subquery)).remove()
 
+    @staticmethod
+    def fetchAuthorsForDisplay(revision_authors):
+        """See `IRevisionSet`."""
+        person_ids = set([
+            revision_author.personID
+            for revision_author in revision_authors])
+        person_ids.discard(None)
+        if len(person_ids) == 0:
+            return []
+        store = IStore(RevisionAuthor)
+        source = store.using(
+            Person,
+            LeftJoin(LibraryFileAlias, LibraryFileAlias.id == Person.iconID),
+            LeftJoin(
+                LibraryFileContent,
+                LibraryFileContent.id == LibraryFileAlias.contentID))
+        columns = (
+            Person,
+            LibraryFileAlias,
+            LibraryFileContent,
+            )
+        prejoined_authors = source.find(
+            columns, Person.id.is_in(person_ids))
+        return [person for person, alias, content in prejoined_authors]
+
 
 def revision_time_limit(day_limit):
     """The storm fragment to limit the revision_date field of the Revision."""

=== modified file 'lib/lp/code/model/tests/test_revision.py'
--- lib/lp/code/model/tests/test_revision.py	2010-12-20 21:46:45 +0000
+++ lib/lp/code/model/tests/test_revision.py	2011-02-10 16:13:03 +0000
@@ -262,6 +262,34 @@
         found = self.revision_set.getByRevisionId('nonexistent')
         self.assertIs(None, found)
 
+    def test_fetchAuthorsForDisplay_finds_nothing_for_empty_list(self):
+        self.assertContentEqual(
+            [], self.revision_set.fetchAuthorsForDisplay([]))
+
+    def test_fetchAuthorsForDisplay_finds_revision_author(self):
+        author = self.factory.makePerson()
+        result = self.revision_set.fetchAuthorsForDisplay(
+            [self.factory.makeRevision(author=author).revision_author])
+        self.assertEqual([author], result)
+
+    def test_fetchAuthorsForDisplay_does_not_repeat_authors(self):
+        author = self.factory.makePerson()
+        revision_authors = [
+            self.factory.makeRevision(author=author).revision_author
+            for counter in xrange(3)]
+        result = self.revision_set.fetchAuthorsForDisplay(revision_authors)
+        self.assertEqual([author], result)
+
+    def test_fetchAuthorsForDisplay_skips_None(self):
+        revision = self.factory.makeRevision(author="A. Nonnimers")
+        self.assertIs(None, revision.revision_author.person)
+        result = self.revision_set.fetchAuthorsForDisplay(
+            [revision.revision_author])
+        # A revision_author record doesn't have to have a Person
+        # attached.  If it doesn't, fetchAuthorsForDisplay effectively
+        # ignores it.
+        self.assertEqual([], result)
+
 
 class TestRevisionGetBranch(TestCaseWithFactory):
     """Test the `getBranch` method of the revision."""
@@ -445,7 +473,7 @@
         rev2 = self._makeRevision(
             revision_date=(now - timedelta(days=2)))
         self._addRevisionsToBranch(self._makeBranch(), rev1, rev2)
-        self.assertEqual([rev2],  self._getRevisions(day_limit))
+        self.assertEqual([rev2], self._getRevisions(day_limit))
 
 
 class TestGetPublicRevisionsForPerson(GetPublicRevisionsTestCase,

=== modified file 'lib/lp/code/templates/branch-listing.pt'
--- lib/lp/code/templates/branch-listing.pt	2010-09-28 20:46:26 +0000
+++ lib/lp/code/templates/branch-listing.pt	2011-02-10 16:13:03 +0000
@@ -160,36 +160,19 @@
               <tal:revision-log replace="branch/revision_log/fmt:shorten/40"/>
             </div>
             <div class="popupTitle"
-                 tal:attributes="id string:branch-log-${branch/id};
-                                 onmouseover string:hide_commit(${branch/id});">
-              <p><strong>Author:</strong>
-              <tal:known-person condition="branch/revision_author/person">
-                <tal:person-link
-                    replace="structure branch/revision_author/person/fmt:link"/>
-              </tal:known-person>
-              <tal:unknown-person
-                  condition="not: branch/revision_author/person">
-                <tal:person-text
-                    tal:replace="branch/revision_author/name_without_email">
-                  John Doe
-                </tal:person-text>
-                <tal:person-only-email
-                    condition="not: branch/revision_author/name_without_email">
-                  <tal:not-anonymous condition="context/view/user">
-                    <tal:person-text
-                        tal:replace="branch/revision_author/email">
-                      doe.john@xxxxxxxxxxx
-                    </tal:person-text>
-                  </tal:not-anonymous>
-                  <tal:anonymous condition="not:context/view/user">
-                    &lt;email address hidden&gt;
-                  </tal:anonymous>
-                </tal:person-only-email>
-              </tal:unknown-person>
-              <br/>
-              <strong>Revision Date:</strong>
-              <tal:revision-date replace="branch/revision_date/fmt:datetime"/></p>
-              <tal:commit-msg replace="structure branch/revision_log/fmt:text-to-html"/>
+              tal:attributes="id string:branch-log-${branch/id};
+                              onmouseover string:hide_commit(${branch/id});">
+              <p>
+                <strong>Author:</strong>
+                <tal:author
+                  replace="structure branch/revision_author/fmt:link" />
+                <br/>
+                <strong>Revision Date:</strong>
+                <tal:revision-date
+                  replace="branch/revision_date/fmt:datetime"/>
+              </p>
+              <tal:commit-msg
+                replace="structure branch/revision_log/fmt:text-to-html"/>
             </div>
           </td>
 


Follow ups