← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/sync-button-bug-747546 into lp:launchpad/db-devel

 

Gavin Panella has proposed merging lp:~allenap/launchpad/sync-button-bug-747546 into lp:launchpad/db-devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #747546 in Launchpad itself: "Derived Distros: Add a button to sync all packages changed in parent but not changed in child"
  https://bugs.launchpad.net/launchpad/+bug/747546

For more details, see:
https://code.launchpad.net/~allenap/launchpad/sync-button-bug-747546/+merge/60351

This branch adds a button to the +localpackagediffs page that syncs
all updates into a derived series for packages that have not been
changed in the derived series.

It is the completion of jtv's work in lp:~jtv/launchpad/bug-747546.

This branch is quite long, but a lot of it is lint fixes.

-- 
https://code.launchpad.net/~allenap/launchpad/sync-button-bug-747546/+merge/60351
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/sync-button-bug-747546 into lp:launchpad/db-devel.
=== modified file 'lib/lp/registry/browser/distroseries.py'
--- lib/lp/registry/browser/distroseries.py	2011-05-06 10:45:50 +0000
+++ lib/lp/registry/browser/distroseries.py	2011-05-09 09:57:34 +0000
@@ -104,11 +104,20 @@
 from lp.services.worlddata.interfaces.language import ILanguageSet
 from lp.soyuz.browser.archive import PackageCopyingMixin
 from lp.soyuz.browser.packagesearch import PackageSearchViewBase
+from lp.soyuz.interfaces.distributionjob import IPackageCopyJobSource
 from lp.soyuz.interfaces.queue import IPackageUploadSet
+from lp.soyuz.model.queue import PackageUploadQueue
 from lp.translations.browser.distroseries import (
     check_distroseries_translations_viewable,
     )
 
+# DistroSeries statuses that benefit from mass package upgrade support.
+UPGRADABLE_SERIES_STATUSES = [
+    SeriesStatus.FUTURE,
+    SeriesStatus.EXPERIMENTAL,
+    SeriesStatus.DEVELOPMENT,
+    ]
+
 
 class DistroSeriesNavigation(GetitemNavigation, BugTargetTraversalMixin,
     StructuralSubscriptionTargetTraversalMixin):
@@ -848,30 +857,28 @@
     def cached_differences(self):
         """Return a batch navigator of filtered results."""
         if self.specified_package_type == NON_BLACKLISTED:
-            status=(
+            status = (
                 DistroSeriesDifferenceStatus.NEEDS_ATTENTION,)
             child_version_higher = False
         elif self.specified_package_type == BLACKLISTED:
-            status=(
+            status = (
                 DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT)
             child_version_higher = False
         elif self.specified_package_type == HIGHER_VERSION_THAN_PARENT:
-            status=(
+            status = (
                 DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT)
             child_version_higher = True
         elif self.specified_package_type == RESOLVED:
-            status=DistroSeriesDifferenceStatus.RESOLVED
+            status = DistroSeriesDifferenceStatus.RESOLVED
             child_version_higher = False
         else:
             raise AssertionError('specified_package_type unknown')
 
         differences = getUtility(
             IDistroSeriesDifferenceSource).getForDistroSeries(
-                self.context,
-                difference_type = self.differences_type,
+                self.context, difference_type=self.differences_type,
                 source_package_name_filter=self.specified_name_filter,
-                status=status,
-                child_version_higher=child_version_higher)
+                status=status, child_version_higher=child_version_higher)
         return BatchNavigator(differences, self.request)
 
     @cachedproperty
@@ -889,7 +896,7 @@
             differences = getUtility(
                 IDistroSeriesDifferenceSource).getForDistroSeries(
                     self.context,
-                    difference_type = self.differences_type,
+                    difference_type=self.differences_type,
                     status=(
                         DistroSeriesDifferenceStatus.NEEDS_ATTENTION,
                         DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT))
@@ -957,6 +964,56 @@
     def sync_sources(self, action, data):
         self._sync_sources(action, data)
 
+    def getUpgrades(self):
+        """Find straightforward package upgrades.
+
+        These are updates for packages that this distroseries shares
+        with a parent series, for which there have been updates in the
+        parent, and which do not have any changes in this series that
+        might complicate a sync.
+
+        :return: A result set of `DistroSeriesDifference`s.
+        """
+        return getUtility(IDistroSeriesDifferenceSource).getSimpleUpgrades(
+            self.context)
+
+    @action(_("Upgrade Packages"), name="upgrade", condition='canUpgrade')
+    def upgrade(self, action, data):
+        """Request synchronization of straightforward package upgrades."""
+        self.requestUpgrades()
+
+    def requestUpgrades(self):
+        """Request sync of packages that can be easily upgraded."""
+        target_distroseries = self.context
+        target_archive = target_distroseries.main_archive
+        differences_by_archive = (
+            getUtility(IDistroSeriesDifferenceSource)
+                .collateDifferencesByParentArchive(self.getUpgrades()))
+        for source_archive, differences in differences_by_archive.iteritems():
+            source_package_info = [
+                (difference.source_package_name.name,
+                 difference.parent_source_version)
+                for difference in differences]
+            getUtility(IPackageCopyJobSource).create(
+                source_package_info, source_archive, target_archive,
+                target_distroseries, PackagePublishingPocket.UPDATES)
+        self.request.response.addInfoNotification(
+            (u"Upgrades of {context.displayname} packages have been "
+             u"requested. Please give Launchpad some time to complete "
+             u"these.").format(context=self.context))
+
+    def canUpgrade(self, action=None):
+        """Should the form offer a packages upgrade?"""
+        if self.context.status not in UPGRADABLE_SERIES_STATUSES:
+            # A feature freeze precludes blanket updates.
+            return False
+        elif self.getUpgrades().is_empty():
+            # There are no simple updates to perform.
+            return False
+        else:
+            queue = PackageUploadQueue(self.context, None)
+            return check_permission("launchpad.Edit", queue)
+
 
 class DistroSeriesMissingPackagesView(DistroSeriesDifferenceBaseView,
                                       LaunchpadFormView):

=== modified file 'lib/lp/registry/browser/tests/test_distroseries.py'
--- lib/lp/registry/browser/tests/test_distroseries.py	2011-05-08 13:41:34 +0000
+++ lib/lp/registry/browser/tests/test_distroseries.py	2011-05-09 09:57:34 +0000
@@ -20,6 +20,7 @@
 from testtools.content_type import UTF8_TEXT
 from testtools.matchers import (
     EndsWith,
+    Equals,
     LessThan,
     Not,
     )
@@ -32,6 +33,7 @@
 from canonical.launchpad.testing.pages import find_tag_by_id
 from canonical.launchpad.webapp.authorization import check_permission
 from canonical.launchpad.webapp.batching import BatchNavigator
+from canonical.launchpad.webapp.interfaces import BrowserNotificationLevel
 from canonical.launchpad.webapp.publisher import canonical_url
 from canonical.testing.layers import (
     DatabaseFunctionalLayer,
@@ -49,6 +51,7 @@
     DistroSeriesDifferenceType,
     )
 from lp.registry.interfaces.person import IPersonSet
+from lp.registry.interfaces.series import SeriesStatus
 from lp.services.features import (
     get_relevant_feature_controller,
     getFeatureFlag,
@@ -64,12 +67,14 @@
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.distributionjob import (
     IInitialiseDistroSeriesJobSource,
+    IPackageCopyJobSource,
     )
 from lp.soyuz.interfaces.sourcepackageformat import (
     ISourcePackageFormatSelectionSet,
     )
 from lp.soyuz.model.archivepermission import ArchivePermission
 from lp.testing import (
+    anonymous_logged_in,
     celebrity_logged_in,
     feature_flags,
     login_person,
@@ -77,6 +82,7 @@
     set_feature_flag,
     StormStatementRecorder,
     TestCaseWithFactory,
+    with_celebrity_logged_in,
     )
 from lp.testing.matchers import HasQueryCount
 from lp.testing.views import create_initialized_view
@@ -617,7 +623,30 @@
 class TestDistroSeriesLocalDifferencesZopeless(TestCaseWithFactory):
     """Test the distroseries +localpackagediffs view."""
 
-    layer = LaunchpadZopelessLayer
+    layer = LaunchpadFunctionalLayer
+
+    def makePackageUpgrade(self, derived_series=None):
+        """Create a `DistroSeriesDifference` for a package upgrade."""
+        base_version = '1.%d' % self.factory.getUniqueInteger()
+        versions = {
+            'base': base_version,
+            'parent': base_version + '-' + self.factory.getUniqueString(),
+            'derived': base_version,
+        }
+        return self.factory.makeDistroSeriesDifference(
+            derived_series=derived_series, versions=versions,
+            set_base_version=True)
+
+    def makeDerivedSeries(self):
+        """Create a derived `DistroSeries`."""
+        return self.factory.makeDistroSeries(
+            parent_series=self.factory.makeDistroSeries())
+
+    def makeView(self, distroseries=None):
+        """Create a +localpackagediffs view for `distroseries`."""
+        if distroseries is None:
+            distroseries = self.makeDerivedSeries()
+        return create_initialized_view(distroseries, '+localpackagediffs')
 
     def _create_child_and_parent(self):
         parent_series = self.factory.makeDistroSeries(name='lucid')
@@ -633,8 +662,7 @@
 
         self.assertIs(
             None, getFeatureFlag('soyuz.derived-series-ui.enabled'))
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         response = view.request.response
         self.assertEqual(302, response.getStatus())
@@ -645,8 +673,7 @@
         # The view label includes the names of both series.
         derived_series, parent_series = self._create_child_and_parent()
 
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         self.assertEqual(
             "Source package differences between 'Derilucid' and "
@@ -663,8 +690,7 @@
             derived_series=derived_series,
             status=DistroSeriesDifferenceStatus.RESOLVED)
 
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         self.assertContentEqual(
             [current_difference], view.cached_differences.batch)
@@ -679,8 +705,7 @@
             difference_type=(
                 DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES))
 
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         self.assertContentEqual(
             [different_versions_diff], view.cached_differences.batch)
@@ -689,8 +714,7 @@
         # The help link for popup help is included.
         derived_series, parent_series = self._create_child_and_parent()
         set_derived_series_ui_feature_flag(self)
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         soup = BeautifulSoup(view())
         help_links = soup.findAll(
@@ -702,12 +726,12 @@
         derived_series, parent_series = self._create_child_and_parent()
         difference = self.factory.makeDistroSeriesDifference(
             derived_series=derived_series)
-        difference.addComment(difference.owner, "Earlier comment")
-        difference.addComment(difference.owner, "Latest comment")
+        with person_logged_in(derived_series.owner):
+            difference.addComment(difference.owner, "Earlier comment")
+            difference.addComment(difference.owner, "Latest comment")
 
         set_derived_series_ui_feature_flag(self)
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
 
         # Find all the rows within the body of the table
         # listing the differences.
@@ -726,8 +750,7 @@
             derived_series=derived_series)
 
         set_derived_series_ui_feature_flag(self)
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
         soup = BeautifulSoup(view())
         diff_table = soup.find('table', {'class': 'listing'})
         row = diff_table.tbody.findAll('tr')[0]
@@ -763,8 +786,7 @@
             version=new_version)
 
         set_derived_series_ui_feature_flag(self)
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
         soup = BeautifulSoup(view())
         diff_table = soup.find('table', {'class': 'listing'})
         row = diff_table.tbody.tr
@@ -799,14 +821,16 @@
             derived_series=derived_series)
 
         # Delete the publications.
-        difference.source_pub.status = PackagePublishingStatus.DELETED
-        difference.parent_source_pub.status = PackagePublishingStatus.DELETED
+        with celebrity_logged_in("admin"):
+            difference.source_pub.status = (
+                PackagePublishingStatus.DELETED)
+            difference.parent_source_pub.status = (
+                PackagePublishingStatus.DELETED)
         # Flush out the changes and invalidate caches (esp. property caches).
         flush_database_caches()
 
         set_derived_series_ui_feature_flag(self)
-        view = create_initialized_view(
-            derived_series, '+localpackagediffs')
+        view = self.makeView(derived_series)
         soup = BeautifulSoup(view())
         diff_table = soup.find('table', {'class': 'listing'})
         row = diff_table.tbody.tr
@@ -823,6 +847,111 @@
         self.assertEqual(versions['derived'], derived_span[0].string.strip())
         self.assertEqual(versions['parent'], parent_span[0].string.strip())
 
+    def test_getUpgrades_shows_updates_in_parent(self):
+        # The view's getUpgrades methods lists packages that can be
+        # trivially upgraded: changed in the parent, not changed in the
+        # derived series, but present in both.
+        dsd = self.makePackageUpgrade()
+        view = self.makeView(dsd.derived_series)
+        self.assertContentEqual([dsd], view.getUpgrades())
+
+    def test_upgrades_are_offered_if_appropriate(self):
+        # The"Upgrade Packages" button will only be shown to privileged users.
+        dsd = self.makePackageUpgrade()
+        view = self.makeView(dsd.derived_series)
+        with celebrity_logged_in("admin"):
+            self.assertTrue(view.canUpgrade())
+        with person_logged_in(self.factory.makePerson()):
+            self.assertFalse(view.canUpgrade())
+        with anonymous_logged_in():
+            self.assertFalse(view.canUpgrade())
+
+    @with_celebrity_logged_in("admin")
+    def test_upgrades_offered_only_if_available(self):
+        # If there are no upgrades, the "Upgrade Packages" button won't
+        # be shown.
+        view = self.makeView()
+        self.assertFalse(view.canUpgrade())
+        self.makePackageUpgrade()
+        self.assertTrue(view.canUpgrade())
+
+    @with_celebrity_logged_in("admin")
+    def test_upgrades_not_offered_after_feature_freeze(self):
+        # There won't be an "Upgrade Packages" button once feature
+        # freeze has occurred.  Mass updates would not make sense after
+        # that point.
+        upgradeable = {}
+        for status in SeriesStatus.items:
+            dsd = self.makePackageUpgrade()
+            dsd.derived_series.status = status
+            view = self.makeView(dsd.derived_series)
+            upgradeable[status] = view.canUpgrade()
+        expected = {
+            SeriesStatus.FUTURE: True,
+            SeriesStatus.EXPERIMENTAL: True,
+            SeriesStatus.DEVELOPMENT: True,
+            SeriesStatus.FROZEN: False,
+            SeriesStatus.CURRENT: False,
+            SeriesStatus.SUPPORTED: False,
+            SeriesStatus.OBSOLETE: False,
+        }
+        self.assertEqual(expected, upgradeable)
+
+    def test_upgrade_creates_sync_jobs(self):
+        # requestUpgrades generates PackageCopyJobs for the upgrades
+        # that need doing.
+        dsd = self.makePackageUpgrade()
+        series = dsd.derived_series
+        view = self.makeView(series)
+        view.requestUpgrades()
+        job_source = getUtility(IPackageCopyJobSource)
+        jobs = list(
+            job_source.getActiveJobs(series.distribution.main_archive))
+        self.assertEquals(1, len(jobs))
+        job = jobs[0]
+        self.assertEquals(series, job.distroseries)
+        source_package_info = list(job.source_packages)
+        self.assertEquals(1, len(source_package_info))
+        self.assertEqual(
+            (dsd.source_package_name.name, dsd.parent_source_version),
+            source_package_info[0][:2])
+
+    def test_upgrade_gives_feedback(self):
+        # requestUpgrades doesn't instantly perform package upgrades,
+        # but it shows the user a notice that the upgrades have been
+        # requested.
+        dsd = self.makePackageUpgrade()
+        view = self.makeView(dsd.derived_series)
+        view.requestUpgrades()
+        expected = {
+            "level": BrowserNotificationLevel.INFO,
+            "message":
+                ("Upgrades of {0.displayname} packages have been "
+                 "requested. Please give Launchpad some time to "
+                 "complete these.").format(dsd.derived_series),
+            }
+        observed = map(vars, view.request.response.notifications)
+        self.assertEqual([expected], observed)
+
+    def test_requestUpgrade_is_efficient(self):
+        # A single web request may need to schedule large numbers of
+        # package upgrades.  It must do so without issuing large numbers
+        # of database queries.
+        derived_series, parent_series = self._create_child_and_parent()
+        # Take a baseline measure of queries.
+        self.makePackageUpgrade(derived_series=derived_series)
+        flush_database_caches()
+        with StormStatementRecorder() as recorder1:
+            self.makeView(derived_series).requestUpgrades()
+        self.assertThat(recorder1, HasQueryCount(LessThan(10)))
+        # The query count does not increase with more differences.
+        for index in xrange(3):
+            self.makePackageUpgrade(derived_series=derived_series)
+        flush_database_caches()
+        with StormStatementRecorder() as recorder2:
+            self.makeView(derived_series).requestUpgrades()
+        self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
+
 
 class TestDistroSeriesLocalDifferencesFunctional(TestCaseWithFactory):
 

=== modified file 'lib/lp/registry/interfaces/distroseriesdifference.py'
--- lib/lp/registry/interfaces/distroseriesdifference.py	2011-04-27 15:12:08 +0000
+++ lib/lp/registry/interfaces/distroseriesdifference.py	2011-05-09 09:57:34 +0000
@@ -195,7 +195,7 @@
         """
 
     latest_comment = Reference(
-        Interface, # IDistroSeriesDifferenceComment
+        Interface,  # IDistroSeriesDifferenceComment
         title=_("The latest comment"),
         readonly=True)
 
@@ -317,3 +317,24 @@
         :param source_package_name: The name of the package difference.
         :type source_package_name: unicode.
         """
+
+    def getSimpleUpgrades(distro_series):
+        """Find pending upgrades that can be performed mindlessly.
+
+        These are `DistroSeriesDifferences` where the parent has been
+        updated and the child still has the old version, unchanged.
+
+        Blacklisted items are excluded.
+        """
+
+    def collateDifferencesByParentArchive(differences):
+        """Collate the given differences by parent archive.
+
+        The given `IDistroSeriesDifference`s are returned in a `dict`, with
+        the parent `Archive` as keys.
+
+        :param differences: An iterable sequence of `IDistroSeriesDifference`.
+
+        :return: A `dict` of iterable sequences of `IDistroSeriesDifference`
+            keyed by their parent `IArchive`.
+        """

=== modified file 'lib/lp/registry/model/distroseriesdifference.py'
--- lib/lp/registry/model/distroseriesdifference.py	2011-05-06 11:18:35 +0000
+++ lib/lp/registry/model/distroseriesdifference.py	2011-05-09 09:57:34 +0000
@@ -265,7 +265,7 @@
         parent_series=None):
         """See `IDistroSeriesDifferenceSource`."""
         if difference_type is None:
-            difference_type=DistroSeriesDifferenceType.DIFFERENT_VERSIONS
+            difference_type = DistroSeriesDifferenceType.DIFFERENT_VERSIONS
         if status is None:
             status = (
                 DistroSeriesDifferenceStatus.NEEDS_ATTENTION,
@@ -394,6 +394,38 @@
                 SourcePackageName.id),
             SourcePackageName.name == source_package_name).one()
 
+    @staticmethod
+    def getSimpleUpgrades(distro_series):
+        """See `IDistroSeriesDifferenceSource`.
+
+        Eager-load related `ISourcePackageName` records.
+        """
+        differences = IStore(DistroSeriesDifference).find(
+            (DistroSeriesDifference, SourcePackageName),
+            DistroSeriesDifference.derived_series == distro_series,
+            DistroSeriesDifference.difference_type ==
+                DistroSeriesDifferenceType.DIFFERENT_VERSIONS,
+            DistroSeriesDifference.status ==
+                DistroSeriesDifferenceStatus.NEEDS_ATTENTION,
+            DistroSeriesDifference.parent_source_version !=
+                DistroSeriesDifference.base_version,
+            DistroSeriesDifference.source_version ==
+                DistroSeriesDifference.base_version,
+            SourcePackageName.id ==
+                DistroSeriesDifference.source_package_name_id)
+        return DecoratedResultSet(differences, itemgetter(0))
+
+    @staticmethod
+    def collateDifferencesByParentArchive(differences):
+        by_archive = dict()
+        for difference in differences:
+            archive = difference.parent_series.main_archive
+            if archive in by_archive:
+                by_archive[archive].append(difference)
+            else:
+                by_archive[archive] = [difference]
+        return by_archive
+
     @cachedproperty
     def source_pub(self):
         """See `IDistroSeriesDifference`."""

=== modified file 'lib/lp/registry/tests/test_distroseriesdifference.py'
--- lib/lp/registry/tests/test_distroseriesdifference.py	2011-05-06 11:18:35 +0000
+++ lib/lp/registry/tests/test_distroseriesdifference.py	2011-05-09 09:57:34 +0000
@@ -149,7 +149,7 @@
                 'parent': '1.0',
                 'derived': '0.9',
                 })
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -170,7 +170,7 @@
             versions=['1.0', '1.2'])
         parent_changelog = self.factory.makeChangelog(
             versions=['1.0', '1.3'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
         ds_diff = self.factory.makeDistroSeriesDifference(versions={
             'derived': '1.2',
             'parent': '1.3',
@@ -189,12 +189,12 @@
 
         # Resolve the DSD by making the same package version published
         # in parent and derived.
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
             version='1.4')
-        new_parent_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.parent_series,
             status=PackagePublishingStatus.PENDING,
@@ -218,7 +218,7 @@
                 'derived': '1.0',
                 },
             status=DistroSeriesDifferenceStatus.RESOLVED)
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -240,7 +240,7 @@
                 'parent': '1.0',
                 'derived': '0.9',
                 })
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -266,7 +266,7 @@
                 },
             difference_type=(
                 DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES))
-        new_parent_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.parent_series,
             status=PackagePublishingStatus.PENDING,
@@ -290,7 +290,7 @@
             difference_type=(
                 DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES),
             status=DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT)
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -315,7 +315,7 @@
                 'parent': '1.0',
                 },
             status=DistroSeriesDifferenceStatus.BLACKLISTED_ALWAYS)
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -332,7 +332,7 @@
         # The title is a friendly description of the difference.
         parent_series = self.factory.makeDistroSeries(name="lucid")
         derived_series = self.factory.makeDistroSeries(name="derilucid")
-        dsp = self.factory.makeDistroSeriesParent(
+        self.factory.makeDistroSeriesParent(
             derived_series=derived_series, parent_series=parent_series)
         ds_diff = self.factory.makeDistroSeriesDifference(
             source_package_name_str="foonew", derived_series=derived_series,
@@ -395,8 +395,7 @@
         person = self.factory.makePerson()
         with person_logged_in(person):
             self.assertTrue(check_permission('launchpad.Edit', ds_diff))
-            diff_comment = ds_diff.addComment(
-                ds_diff.derived_series.owner, "Boo")
+            ds_diff.addComment(ds_diff.derived_series.owner, "Boo")
 
     def _setupPackageSets(self, ds_diff, distroseries, nb_packagesets):
         # Helper method to create packages sets.
@@ -470,7 +469,7 @@
                 'parent': '1.0',
                 },
             status=DistroSeriesDifferenceStatus.BLACKLISTED_ALWAYS)
-        new_derived_pub = self.factory.makeSourcePackagePublishingHistory(
+        self.factory.makeSourcePackagePublishingHistory(
             sourcepackagename=ds_diff.source_package_name,
             distroseries=ds_diff.derived_series,
             status=PackagePublishingStatus.PENDING,
@@ -527,11 +526,11 @@
     def test_base_version_multiple(self):
         # The latest common base version is set as the base-version.
         dsp = self.factory.makeDistroSeriesParent()
-        source_package_name = self.factory.getOrMakeSourcePackageName('foo')
+        self.factory.getOrMakeSourcePackageName('foo')
         # Create changelogs for both.
         changelog_lfa = self.factory.makeChangelog('foo', ['1.2', '1.1'])
         parent_changelog_lfa = self.factory.makeChangelog('foo', ['1.1'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
 
         ds_diff = self.factory.makeDistroSeriesDifference(
             derived_series=dsp.derived_series, source_package_name_str='foo',
@@ -549,13 +548,13 @@
         # If the maximum base version is invalid, it is discarded and not
         # set as the base version.
         dsp = self.factory.makeDistroSeriesParent()
-        source_package_name = self.factory.getOrMakeSourcePackageName('foo')
+        self.factory.getOrMakeSourcePackageName('foo')
         # Create changelogs for both.
         changelog_lfa = self.factory.makeChangelog(
             'foo', ['1:2.0-1', 'a1:1.8.8-070403-1~priv1', '1:1.7-1'])
         parent_changelog_lfa = self.factory.makeChangelog(
             'foo', ['1:2.0-2', 'a1:1.8.8-070403-1~priv1', '1:1.7-1'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
 
         ds_diff = self.factory.makeDistroSeriesDifference(
             derived_series=dsp.derived_series, source_package_name_str='foo',
@@ -583,7 +582,7 @@
             versions=['1.0', '1.2'])
         parent_changelog = self.factory.makeChangelog(
             versions=['1.0', '1.3'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
 
         ds_diff = self.factory.makeDistroSeriesDifference(versions={
             'derived': '1.2',
@@ -606,7 +605,7 @@
             versions=['1.0', '1.2'])
         parent_changelog = self.factory.makeChangelog(
             versions=['1.0', '1.3'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
 
         ds_diff = self.factory.makeDistroSeriesDifference(versions={
             'derived': '1.2',
@@ -626,7 +625,7 @@
             versions=['1.0', '1.2'])
         parent_changelog = self.factory.makeChangelog(
             versions=['1.0', '1.3'])
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
 
         ds_diff = self.factory.makeDistroSeriesDifference(
             versions={
@@ -656,12 +655,12 @@
         # {derived,parent}_versions must be ordered (e.g. ['1.1',
         # '1.2', '1.3']).
         if status is None:
-            status=DistroSeriesDifferenceStatus.NEEDS_ATTENTION
+            status = DistroSeriesDifferenceStatus.NEEDS_ATTENTION
         derived_changelog = self.factory.makeChangelog(
             versions=derived_versions)
         parent_changelog = self.factory.makeChangelog(
             versions=parent_versions)
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
         ds_diff = self.factory.makeDistroSeriesDifference(
             status=status,
             versions={
@@ -773,7 +772,7 @@
     def createPublication(self, spn, versions, distroseries,
                           status=PackagePublishingStatus.PUBLISHED):
         changelog_lfa = self.factory.makeChangelog(spn.name, versions)
-        transaction.commit() # Yay, librarian.
+        transaction.commit()  # Yay, librarian.
         spr = self.factory.makeSourcePackageRelease(
             sourcepackagename=spn, version=versions[0],
             changelog=changelog_lfa)
@@ -787,7 +786,7 @@
         dsp = self.factory.makeDistroSeriesParent()
         spn = self.factory.getOrMakeSourcePackageName(
             name=self.factory.getUniqueString())
-        parent_spph = self.createPublication(
+        self.createPublication(
             spn, ['1.2-1', '1.0-1'], dsp.parent_series)
         spph = self.createPublication(
             spn, ['1.1-1', '1.0-1'], dsp.derived_series)
@@ -900,6 +899,34 @@
             derived_series=derived_series)
         return dsp.derived_series
 
+    def makeVersionDifference(self, derived_series=None, changed_parent=False,
+                              changed_child=False, status=None):
+        """Create a `DistroSeriesDifference` between package versions.
+
+        The differing package will exist in both the parent series and in the
+        child.
+
+        :param derived_series: Optional `DistroSeries` that the difference is
+            for.  If not given, one will be created.
+        :param changed_parent: Whether the difference should show a change in
+            the parent's version of the package.
+        :param changed_child: Whether the difference should show a change in
+            the child's version of the package.
+        :param status: Optional status for the `DistroSeriesDifference`.  If
+            not given, defaults to `NEEDS_ATTENTION`.
+        """
+        if status is None:
+            status = DistroSeriesDifferenceStatus.NEEDS_ATTENTION
+        base_version = "1.%d" % self.factory.getUniqueInteger()
+        versions = dict.fromkeys(('base', 'parent', 'derived'), base_version)
+        if changed_parent:
+            versions['parent'] += "-%s" % self.factory.getUniqueString()
+        if changed_child:
+            versions['derived'] += "-%s" % self.factory.getUniqueString()
+        return self.factory.makeDistroSeriesDifference(
+            derived_series=derived_series, versions=versions, status=status,
+            set_base_version=True)
+
     def test_getForDistroSeries_default(self):
         # By default all differences needing attention for the given
         # series are returned.
@@ -915,7 +942,7 @@
     def test_getForDistroSeries_filters_by_distroseries(self):
         # Differences for other series are not included.
         derived_series = self.makeDerivedSeries()
-        diffs = self.makeDiffsForDistroSeries(derived_series)
+        self.makeDiffsForDistroSeries(derived_series)
         diff_for_other_series = self.factory.makeDistroSeriesDifference()
 
         result = getUtility(IDistroSeriesDifferenceSource).getForDistroSeries(
@@ -926,9 +953,9 @@
     def test_getForDistroSeries_filters_by_type(self):
         # Only differences for the specified types are returned.
         derived_series = self.makeDerivedSeries()
-        diffs = self.makeDiffsForDistroSeries(derived_series)
+        self.makeDiffsForDistroSeries(derived_series)
 
-        result = getUtility(IDistroSeriesDifferenceSource).getForDistroSeries(
+        getUtility(IDistroSeriesDifferenceSource).getForDistroSeries(
             derived_series,
             DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES)
 
@@ -1011,6 +1038,79 @@
 
         self.assertEqual(ds_diff, result)
 
+    def test_getSimpleUpgrades_finds_simple_update(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        dsd = self.makeVersionDifference(changed_parent=True)
+        self.assertEqual(dsd.base_version, dsd.source_version)
+        self.assertContentEqual(
+            [dsd], dsd_source.getSimpleUpgrades(dsd.derived_series))
+
+    def test_getSimpleUpgrades_ignores_hidden_differences(self):
+        invisible_statuses = [
+            DistroSeriesDifferenceStatus.BLACKLISTED_CURRENT,
+            DistroSeriesDifferenceStatus.BLACKLISTED_ALWAYS,
+            DistroSeriesDifferenceStatus.RESOLVED,
+            ]
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        series = self.makeDerivedSeries()
+        for status in invisible_statuses:
+            self.makeVersionDifference(
+                derived_series=series, changed_parent=True, status=status)
+        self.assertContentEqual([], dsd_source.getSimpleUpgrades(series))
+
+    def test_getSimpleUpgrades_ignores_other_distroseries(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        self.makeVersionDifference(changed_parent=True)
+        self.assertContentEqual(
+            [], dsd_source.getSimpleUpgrades(self.factory.makeDistroSeries()))
+
+    def test_getSimpleUpgrades_ignores_packages_changed_in_child(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        dsd = self.makeVersionDifference(
+            changed_parent=True, changed_child=True)
+        self.assertContentEqual(
+            [], dsd_source.getSimpleUpgrades(dsd.derived_series))
+
+    def test_getSimpleUpgrades_ignores_packages_not_updated_in_parent(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        dsd = self.makeVersionDifference(changed_parent=False)
+        self.assertContentEqual(
+            [], dsd_source.getSimpleUpgrades(dsd.derived_series))
+
+    def test_getSimpleUpgrades_ignores_packages_unique_to_child(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        diff_type = DistroSeriesDifferenceType.UNIQUE_TO_DERIVED_SERIES
+        dsd = self.factory.makeDistroSeriesDifference(
+            difference_type=diff_type)
+        self.assertContentEqual(
+            [], dsd_source.getSimpleUpgrades(dsd.derived_series))
+
+    def test_getSimpleUpgrades_ignores_packages_missing_from_child(self):
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        diff_type = DistroSeriesDifferenceType.MISSING_FROM_DERIVED_SERIES
+        dsd = self.factory.makeDistroSeriesDifference(
+            difference_type=diff_type)
+        self.assertContentEqual(
+            [], dsd_source.getSimpleUpgrades(dsd.derived_series))
+
+    def test_collateDifferencesByParentArchive(self):
+        dsp1 = self.factory.makeDistroSeriesParent()
+        dsp2 = self.factory.makeDistroSeriesParent()
+        differences = [
+            self.factory.makeDistroSeriesDifference(dsp1.derived_series),
+            self.factory.makeDistroSeriesDifference(dsp2.derived_series),
+            self.factory.makeDistroSeriesDifference(dsp1.derived_series),
+            self.factory.makeDistroSeriesDifference(dsp2.derived_series),
+            ]
+        dsd_source = getUtility(IDistroSeriesDifferenceSource)
+        observed = (
+            dsd_source.collateDifferencesByParentArchive(differences))
+        expected = {
+            dsp1.parent_series.main_archive: differences[0::2],
+            dsp2.parent_series.main_archive: differences[1::2],
+            }
+        self.assertEqual(observed, expected)
+
 
 class TestMostRecentComments(TestCaseWithFactory):