← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/recipe-request-build-partial-success into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/recipe-request-build-partial-success into lp:launchpad.

Requested reviews:
  Launchpad UI Reviewers (launchpad-ui-reviewers): ui
  Launchpad code reviewers (launchpad-reviewers): code
Related bugs:
  #722893 Recipe build request popup behaves oddly on partial success
  https://bugs.launchpad.net/bugs/722893

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/recipe-request-build-partial-success/+merge/50872

Provide better user experience when requesting recipe builds where some work and others fail (partial success).

== Implementation ==

Change the form action so that when there is an error, the json data structure returned to the client contains the successful form rendering and the errors. The form html is used to update the page and the errors are displayed, along with a message about the successful builds. 

== Demo and QA ==

Attempt to request some new recipe builds and select a distro where there isn't a build pending and another distro where there is. The form should display an informational message about the successful builds and an error message about the others.

See screenshot: http://people.canonical.com/~ianb/request-build-partial-success.png

== Tests ==

Added to the existing windmill test for requesting builds

bin/test -vvt test_recipe_request_build

== Lint ==


Linting changed files:
  lib/lp/code/browser/sourcepackagerecipe.py
  lib/lp/code/javascript/requestbuild_overlay.js
  lib/lp/code/templates/sourcepackagerecipe-index.pt
  lib/lp/code/windmill/tests/test_recipe_request_build.py

./lib/lp/code/javascript/requestbuild_overlay.js
     102: Line exceeds 78 characters.
     172: Line exceeds 78 characters.
     296: Line exceeds 78 characters.

-- 
https://code.launchpad.net/~wallyworld/launchpad/recipe-request-build-partial-success/+merge/50872
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/recipe-request-build-partial-success into lp:launchpad.
=== modified file 'lib/canonical/launchpad/icing/icon-sprites'
Binary files lib/canonical/launchpad/icing/icon-sprites	2010-06-11 18:39:35 +0000 and lib/canonical/launchpad/icing/icon-sprites	2011-02-23 05:23:57 +0000 differ
=== modified file 'lib/canonical/launchpad/icing/icon-sprites.positioning'
--- lib/canonical/launchpad/icing/icon-sprites.positioning	2010-10-31 20:18:45 +0000
+++ lib/canonical/launchpad/icing/icon-sprites.positioning	2011-02-23 05:23:57 +0000
@@ -3,7 +3,7 @@
 {
     "../images/arrowLeft.png": [
         0, 
-        -14754
+        -14918
     ], 
     "../images/cancel.png": [
         0, 
@@ -13,9 +13,9 @@
         0, 
         -3440
     ], 
-    "../images/zoom-out.png": [
+    "../images/build-needed.png": [
         0, 
-        -11966
+        -13442
     ], 
     "../images/team.png": [
         0, 
@@ -43,7 +43,7 @@
     ], 
     "../images/arrowTop.png": [
         0, 
-        -14426
+        -14590
     ], 
     "../images/zoom-in.png": [
         0, 
@@ -55,23 +55,23 @@
     ], 
     "../images/blue-bar.png": [
         0, 
-        -14918
+        -15082
     ], 
     "../images/arrowStart.png": [
         0, 
-        -14098
+        -14262
     ], 
     "../images/ppa-icon-inactive.png": [
         0, 
         -12458
     ], 
-    "../images/build-needed.png": [
+    "../images/zoom-out.png": [
         0, 
-        -13278
+        -11966
     ], 
     "../images/purple-bar.png": [
         0, 
-        -15246
+        -15410
     ], 
     "../images/bullet.png": [
         0, 
@@ -79,11 +79,11 @@
     ], 
     "../images/info-large.png": [
         0, 
-        -17340
+        -17504
     ], 
     "../images/trash-logo.png": [
         0, 
-        -20358
+        -20340
     ], 
     "../images/warning.png": [
         0, 
@@ -95,35 +95,35 @@
     ], 
     "../images/build-failure.png": [
         0, 
-        -13442
+        -13606
     ], 
     "../images/branch-large.png": [
         0, 
-        -16066
+        -16230
     ], 
     "../images/download-large.png": [
         0, 
-        -17158
+        -17322
     ], 
     "../images/private-large.png": [
         0, 
-        -18250
+        -18232
     ], 
     "../images/launchpad-large.png": [
         0, 
-        -17522
+        -17686
     ], 
     "../images/translation-file.png": [
         0, 
         -10818
     ], 
-    "../images/read-only.png": [
+    "../images/source-package-recipe.png": [
         0, 
-        -9998
+        -12622
     ], 
     "../images/project-logo.png": [
         0, 
-        -18860
+        -18842
     ], 
     "../images/bug-medium.png": [
         0, 
@@ -135,7 +135,7 @@
     ], 
     "../images/tour-icon": [
         0, 
-        -15902
+        -16066
     ], 
     "../images/trash-icon.png": [
         0, 
@@ -147,7 +147,7 @@
     ], 
     "../images/arrowBottom.png": [
         0, 
-        -14590
+        -14754
     ], 
     "../images/project.png": [
         0, 
@@ -173,17 +173,13 @@
         0, 
         -3768
     ], 
-    "../images/stop.png": [
-        0, 
-        -11310
-    ], 
     "../images/person-logo.png": [
         0, 
-        -19288
+        -19270
     ], 
     "../images/distribution-logo.png": [
         0, 
-        -18646
+        -18628
     ], 
     "../images/retry.png": [
         0, 
@@ -199,7 +195,7 @@
     ], 
     "../images/merge-proposal-icon.png": [
         0, 
-        -12786
+        -12950
     ], 
     "../images/download.png": [
         0, 
@@ -207,7 +203,7 @@
     ], 
     "../images/arrowDown.png": [
         0, 
-        -13934
+        -14098
     ], 
     "../images/package-binary.png": [
         0, 
@@ -219,11 +215,11 @@
     ], 
     "../images/bug-status-expand.png": [
         0, 
-        -12622
+        -12786
     ], 
     "../images/crowd-large.png": [
         0, 
-        -16430
+        -16594
     ], 
     "../images/blueprint.png": [
         0, 
@@ -245,9 +241,13 @@
         0, 
         -6228
     ], 
+    "../images/stop.png": [
+        0, 
+        -11310
+    ], 
     "../images/flame-large.png": [
         0, 
-        -16976
+        -17140
     ], 
     "../images/bug-dupe-icon.png": [
         0, 
@@ -259,11 +259,11 @@
     ], 
     "../images/build-success.png": [
         0, 
-        -13114
+        -13278
     ], 
     "../images/haspatch-icon.png": [
         0, 
-        -15738
+        -15902
     ], 
     "../images/person-inactive-badge.png": [
         0, 
@@ -279,7 +279,7 @@
     ], 
     "../images/team-logo.png": [
         0, 
-        -19716
+        -19698
     ], 
     "../images/arrowRight.png": [
         0, 
@@ -311,7 +311,7 @@
     ], 
     "../images/arrowUp.png": [
         0, 
-        -13770
+        -13934
     ], 
     "../images/distribution.png": [
         0, 
@@ -319,11 +319,11 @@
     ], 
     "../images/error-large.png": [
         0, 
-        -16794
+        -16958
     ], 
     "../images/news.png": [
         0, 
-        -15574
+        -15738
     ], 
     "../images/treeExpanded.png": [
         0, 
@@ -331,7 +331,7 @@
     ], 
     "../images/build-depwait.png": [
         0, 
-        -13606
+        -13770
     ], 
     "../images/blueprint-essential.png": [
         0, 
@@ -351,7 +351,7 @@
     ], 
     "../images/product-logo.png": [
         0, 
-        -19074
+        -19056
     ], 
     "../images/blueprint-medium.png": [
         0, 
@@ -367,11 +367,11 @@
     ], 
     "../images/launchpad-logo.png": [
         0, 
-        -18432
+        -18414
     ], 
     "../images/flame-logo.png": [
         0, 
-        -20144
+        -20126
     ], 
     "../images/translation-template.png": [
         0, 
@@ -383,7 +383,7 @@
     ], 
     "../images/meeting-logo.png": [
         0, 
-        -19930
+        -19912
     ], 
     "../images/treeCollapsed.png": [
         0, 
@@ -391,19 +391,19 @@
     ], 
     "../images/green-bar.png": [
         0, 
-        -15082
+        -15246
     ], 
     "../images/build-superseded.png": [
         0, 
-        -12950
+        -13114
     ], 
     "../images/trash-large.png": [
         0, 
-        -18068
+        -18050
     ], 
     "../images/red-bar.png": [
         0, 
-        -15410
+        -15574
     ], 
     "../images/add.png": [
         0, 
@@ -413,9 +413,13 @@
         0, 
         -328
     ], 
+    "../images/read-only.png": [
+        0, 
+        -9998
+    ], 
     "../images/person-inactive-logo.png": [
         0, 
-        -19502
+        -19484
     ], 
     "../images/edit.png": [
         0, 
@@ -427,11 +431,11 @@
     ], 
     "../images/warning-large.png": [
         0, 
-        -16248
+        -16412
     ], 
     "../images/arrowEnd.png": [
         0, 
-        -14262
+        -14426
     ], 
     "../images/cve.png": [
         0, 
@@ -439,7 +443,7 @@
     ], 
     "../images/merge-proposal-large.png": [
         0, 
-        -17886
+        -17868
     ], 
     "../images/branch.png": [
         0, 
@@ -469,4 +473,4 @@
         0, 
         -1148
     ]
-}
+}
\ No newline at end of file

=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
--- lib/canonical/launchpad/icing/style-3-0.css.in	2011-02-14 07:33:54 +0000
+++ lib/canonical/launchpad/icing/style-3-0.css.in	2011-02-23 05:23:57 +0000
@@ -1471,10 +1471,10 @@
    Colors and fonts
 */
 
-h1, h2, h3, 
-table.listing thead, 
-#homepage-stats strong, 
-#application-footer strong, 
+h1, h2, h3,
+table.listing thead,
+#homepage-stats strong,
+#application-footer strong,
 #application-summary strong {
     color: #5a5a5a;
 }
@@ -2204,7 +2204,10 @@
     background-image: url(/@@/ppa-icon-inactive.png); /* sprite-ref: icon-sprites */
     background-repeat: no-repeat;
     }
-
+.source-package-recipe {
+    background-image: url(/@@/source-package-recipe.png); /* sprite-ref: icon-sprites */
+    background-repeat: no-repeat;
+    }
 .bug-status-expand {
     background-image: url(/@@/bug-status-expand.png); /* sprite-ref: icon-sprites */
     background-repeat: no-repeat;

=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml	2011-02-10 04:16:09 +0000
+++ lib/lp/code/browser/configure.zcml	2011-02-23 05:23:57 +0000
@@ -1239,6 +1239,12 @@
             name="+builds"
             template="../templates/sourcepackagerecipe-builds.pt"
             permission="launchpad.View"/>
+        <browser:page
+            for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
+            layer="lp.code.publisher.CodeLayer"
+            class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestDailyBuildView"
+            name="+request-daily-build"
+            permission="launchpad.Edit"/>
     </facet>
     <facet facet="branches">
         <browser:defaultView

=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
--- lib/lp/code/browser/sourcepackagerecipe.py	2011-02-22 09:07:44 +0000
+++ lib/lp/code/browser/sourcepackagerecipe.py	2011-02-23 05:23:57 +0000
@@ -180,12 +180,27 @@
 
     facet = 'branches'
 
-    links = ('request_builds',)
+    links = ('request_builds', 'request_daily_build',)
 
     def request_builds(self):
         """Provide a link for requesting builds of a recipe."""
         return Link('+request-builds', 'Request build(s)', icon='add')
 
+    def request_daily_build(self):
+        """Provide a link for requesting a daily build of a recipe."""
+        recipe = self.context
+        ppa = recipe.daily_build_archive
+        if (ppa is None or not recipe.build_daily or not recipe.is_stale
+                or not recipe.distroseries):
+            show_request_build = False
+        else:
+            has_upload = ppa.checkArchivePermission(recipe.owner)
+            show_request_build = has_upload
+
+        return Link(
+                '+request-daily-build', 'Build now',
+                enabled=show_request_build)
+
 
 class SourcePackageRecipeView(LaunchpadView):
     """Default view of a SourcePackageRecipe."""
@@ -292,6 +307,17 @@
         return builds
 
 
+def new_builds_notification_text(builds):
+    nr_builds = len(builds)
+    if not nr_builds:
+        builds_text = "All requested recipe builds are already queued."
+    elif nr_builds == 1:
+        builds_text = "1 new recipe build has been queued."
+    else:
+        builds_text = "%d new recipe builds have been queued." % nr_builds
+    return builds_text
+
+
 class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
     """A view for requesting builds of a SourcePackageRecipe."""
 
@@ -341,14 +367,22 @@
         other builds can ne queued and a message be displayed to the caller.
         """
         errors = {}
+        builds = []
         for distroseries in data['distros']:
             try:
-                self.context.requestBuild(
+                build = self.context.requestBuild(
                     data['archive'], self.user, distroseries, manual=True)
+                builds.append(build)
             except BuildAlreadyPending, e:
-                errors['distros'] = ("An identical build is already pending "
-                    "for %s." % e.distroseries)
-        return errors
+                existing_error = errors.get("distros")
+                if existing_error:
+                    new_error = existing_error[:-1] + (
+                                    ", and %s." % e.distroseries)
+                else:
+                    new_error = ("An identical build is "
+                                "already pending for %s." % e.distroseries)
+                errors["distros"] = new_error
+        return builds, errors
 
 
 class SourcePackageRecipeRequestBuildsHtmlView(
@@ -367,12 +401,14 @@
 
     @action('Request builds', name='request')
     def request_action(self, action, data):
-        errors = self.requestBuild(data)
+        builds, errors = self.requestBuild(data)
         if errors:
             [self.setFieldError(field, message)
                 for (field, message) in errors.items()]
             return
         self.next_url = self.cancel_url
+        self.request.response.addNotification(
+                new_builds_notification_text(builds))
 
 
 class SourcePackageRecipeRequestBuildsAjaxView(
@@ -404,7 +440,7 @@
         unexpected exception, that will be handled using the form's standard
         exception processing mechanism (using response code 500).
         """
-        errors = self.requestBuild(data)
+        builds, errors = self.requestBuild(data)
         # If there are errors we return a json data snippet containing the
         # errors instead of rendering the form. These errors are processed
         # by the caller's response handler and displayed to the user.
@@ -416,6 +452,85 @@
         return builds_for_recipe(self.context)
 
 
+class SourcePackageRecipeRequestDailyBuildView(LaunchpadFormView):
+    """Supports requests to perform a daily build for a recipe.
+
+    Renders the recipe builds table so that the recipe index page can be
+    updated with the new build records.
+
+    This view works for both ajax and html form requests.
+    """
+
+    # Attributes for the html version
+    page_title = "Build now"
+
+    class schema(Interface):
+        """Schema for requesting a build."""
+
+    @action('Build now', name='build')
+    def build_action(self, action, data):
+        recipe = self.context
+        builds = recipe.performDailyBuild()
+        if self.request.is_ajax:
+            template = ViewPageTemplateFile(
+                    "../templates/sourcepackagerecipe-builds.pt")
+            return template(self)
+        else:
+            self.next_url = canonical_url(recipe)
+            self.request.response.addNotification(
+                    new_builds_notification_text(builds))
+
+    @property
+    def builds(self):
+        return builds_for_recipe(self.context)
+
+
+class SourcePackageRecipeRequestBuildsAjaxView(
+        SourcePackageRecipeRequestBuildsView):
+    """Supports AJAX form recipe build requests."""
+
+    def _process_error(self, data, errors, reason):
+        """Set up the response and json data to return to the caller."""
+        self.request.response.setStatus(400, reason)
+        self.request.response.setHeader('Content-type', 'application/json')
+        return_data = dict(builds=data, errors=errors)
+        return simplejson.dumps(return_data)
+
+    def failure(self, action, data, errors):
+        """Called by the form if validate() finds any errors.
+
+           We simply convert the errors to json and return that data to the
+           caller for display to the user.
+        """
+        return self._process_error(data, self.widget_errors, "Validation")
+
+    @action('Request builds', name='request', failure=failure)
+    def request_action(self, action, data):
+        """User action for requesting a number of builds.
+
+        The failure handler will handle any validation errors. We still need
+        to handle errors which may occur when invoking the business logic.
+        These "expected" errors are ones which result in a predefined message
+        being displayed to the user. If the business method raises an
+        unexpected exception, that will be handled using the form's standard
+        exception processing mechanism (using response code 500).
+        """
+        builds, errors = self.requestBuild(data)
+        # If there are errors we return a json data snippet containing the
+        # errors as well as the form content. These errors are processed
+        # by the caller's response handler and displayed to the user. The
+        # form content may be rendered as well if required.
+        if errors:
+            builds_html = None
+            if len(builds):
+                builds_html = self.render()
+            return self._process_error(builds_html, errors, "Request Build")
+
+    @property
+    def builds(self):
+        return builds_for_recipe(self.context)
+
+
 class ISourcePackageEditSchema(Interface):
     """Schema for adding or editing a recipe."""
 
@@ -592,7 +707,7 @@
     def initialize(self):
         super(SourcePackageRecipeAddView, self).initialize()
         if getFeatureFlag(RECIPE_BETA_FLAG):
-           self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)
+            self.request.response.addWarningNotification(RECIPE_BETA_MESSAGE)
         widget = self.widgets['use_ppa']
         current_value = widget._getFormValue()
         self.use_ppa_existing = render_radio_widget_part(
@@ -624,8 +739,8 @@
         # Grab the last path element of the branch target path.
         source = getUtility(ISourcePackageRecipeSource)
         for recipe_name in self._recipe_names():
-             if not source.exists(owner, recipe_name):
-                 return recipe_name
+            if not source.exists(owner, recipe_name):
+                return recipe_name
 
     @property
     def initial_values(self):
@@ -633,7 +748,7 @@
         series = [series for series in distroseries if series.status in (
                 SeriesStatus.CURRENT, SeriesStatus.DEVELOPMENT)]
         return {
-            'name' : self._find_unused_name(self.user),
+            'name': self._find_unused_name(self.user),
             'recipe_text': MINIMAL_RECIPE_TEXT % self.context.bzr_identity,
             'owner': self.user,
             'distros': series,

=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py	2011-02-21 20:55:00 +0000
+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py	2011-02-23 05:23:57 +0000
@@ -26,6 +26,7 @@
 from canonical.launchpad.testing.pages import (
     extract_text,
     find_main_content,
+    find_tag_by_id,
     find_tags_by_class,
     get_feedback_messages,
     get_radio_button_text_for_field,
@@ -1227,6 +1228,88 @@
         self.assertEqual(
             [build1, build2, build3, build4, build5], view.builds)
 
+    def test_request_daily_builds_button_stale(self):
+        # Recipes that are stale and are built daily have a build now link
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=self.ppa,
+            is_stale=True, build_daily=True)
+        browser = self.getViewBrowser(recipe)
+        build_button = find_tag_by_id(browser.contents, 'field.actions.build')
+        self.assertIsNot(None, build_button)
+
+    def test_request_daily_builds_button_not_stale(self):
+        # Recipes that are not stale do not have a build now link
+        login(ANONYMOUS)
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=self.ppa,
+            is_stale=False, build_daily=True)
+        browser = self.getViewBrowser(recipe)
+        build_button = find_tag_by_id(browser.contents, 'field.actions.build')
+        self.assertIs(None, build_button)
+
+    def test_request_daily_builds_button_not_daily(self):
+        # Recipes that are not built daily do not have a build now link
+        login(ANONYMOUS)
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=self.ppa,
+            is_stale=True, build_daily=False)
+        browser = self.getViewBrowser(recipe)
+        build_button = find_tag_by_id(browser.contents, 'field.actions.build')
+        self.assertIs(None, build_button)
+
+    def test_request_daily_builds_button_no_daily_ppa(self):
+        # Recipes that have no daily build ppa do not have a build now link
+        login(ANONYMOUS)
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, is_stale=True, build_daily=True)
+        naked_recipe = removeSecurityProxy(recipe)
+        naked_recipe.daily_build_archive = None
+        browser = self.getViewBrowser(recipe)
+        build_button = find_tag_by_id(browser.contents, 'field.actions.build')
+        self.assertIs(None, build_button)
+
+    def test_request_daily_builds_button_ppa_with_no_permissions(self):
+        # Recipes that have a daily build ppa without upload permissions
+        # do not have a build now link
+        login(ANONYMOUS)
+        distroseries = self.factory.makeSourcePackageRecipeDistroseries()
+        person = self.factory.makePerson()
+        daily_build_archive = self.factory.makeArchive(
+                distribution=distroseries.distribution, owner=person)
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=daily_build_archive,
+            is_stale=True, build_daily=True)
+        browser = self.getViewBrowser(recipe)
+        build_button = find_tag_by_id(browser.contents, 'field.actions.build')
+        self.assertIs(None, build_button)
+
+    def test_request_daily_builds_ajax_link_not_rendered(self):
+        # The Build now link should not be rendered without javascript.
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=self.ppa,
+            is_stale=True, build_daily=True)
+        browser = self.getViewBrowser(recipe)
+        build_link = find_tag_by_id(browser.contents, 'request-daily-builds')
+        self.assertIs(None, build_link)
+
+    def test_request_daily_builds_action(self):
+        # Daily builds should be triggered when requested.
+        recipe = self.factory.makeSourcePackageRecipe(
+            owner=self.chef, daily_build_archive=self.ppa,
+            is_stale=True, build_daily=True)
+        browser = self.getViewBrowser(recipe)
+        browser.getControl('Build now').click()
+        login(ANONYMOUS)
+        builds = recipe.getPendingBuilds()
+        build_distros = [
+            build.distroseries.displayname for build in builds]
+        build_distros.sort()
+        # Our recipe has a Warty distroseries
+        self.assertEqual(['Warty'], build_distros)
+        self.assertEqual(
+            set([2505]),
+            set(build.buildqueue_record.lastscore for build in builds))
+
     def test_request_builds_page(self):
         """Ensure the +request-builds page is sane."""
         recipe = self.makeRecipe()

=== modified file 'lib/lp/code/interfaces/sourcepackagerecipe.py'
--- lib/lp/code/interfaces/sourcepackagerecipe.py	2011-02-18 03:39:25 +0000
+++ lib/lp/code/interfaces/sourcepackagerecipe.py	2011-02-23 05:23:57 +0000
@@ -136,6 +136,10 @@
             able to upload to the archive.
         """
 
+    @export_write_operation()
+    def performDailyBuild():
+        """Perform a build into the daily build archive."""
+
 
 class ISourcePackageRecipeEdit(Interface):
     """ISourcePackageRecipe methods that require launchpad.Edit permission."""

=== modified file 'lib/lp/code/javascript/requestbuild_overlay.js'
--- lib/lp/code/javascript/requestbuild_overlay.js	2011-02-18 01:06:19 +0000
+++ lib/lp/code/javascript/requestbuild_overlay.js	2011-02-23 05:23:57 +0000
@@ -12,7 +12,8 @@
 
 var lp_client;
 var request_build_overlay = null;
-var response_handler;
+var request_build_response_handler;
+var request_daily_build_response_handler;
 
 function set_up_lp_client() {
     if (lp_client === undefined) {
@@ -28,7 +29,7 @@
 RequestResponseHandler = function () {};
 RequestResponseHandler.prototype = {
     clearProgressUI: function () {},
-    showError: function (error_msg) {},
+    showError: function (header, error_msg) {},
     getErrorHandler: function (errorCallback) {
         var self = this;
         return function (id, response) {
@@ -36,12 +37,13 @@
             // If it was a timeout...
             if (response.status == 503) {
                 self.showError(
-                    'Timeout error, please try again in a few minutes.');
+                    'Timeout error, please try again in a few minutes.',
+                    null);
             } else {
                 if (errorCallback != null) {
                     errorCallback(self, id, response);
                 } else {
-                    self.showError(response.responseText);
+                    self.showError(response.responseText, null);
                 }
             }
         };
@@ -55,7 +57,7 @@
     }
 };
 
-namespace.connect_requestbuild = function() {
+namespace.connect_requestbuilds = function() {
 
     var request_build_handle = Y.one('#request-builds');
     request_build_handle.addClass('js-action');
@@ -80,23 +82,119 @@
             request_build_overlay.render();
         }
         request_build_overlay.clearError();
-        var temp_spinner = [
+        var loading_spinner = [
             '<div id="temp-spinner">',
             '<img src="/@@/spinner"/>Loading...',
             '</div>'].join('');
-        request_build_overlay.form_node.set("innerHTML", temp_spinner);
+        request_build_overlay.form_node.set("innerHTML", loading_spinner);
         request_build_overlay.loadFormContentAndRender('+builds/++form++');
         request_build_overlay.show();
     });
 
     // Wire up the processing hooks
-    response_handler = new RequestResponseHandler();
-    response_handler.clearProgressUI = function() {
-        destroy_temporary_spinner();
-    };
-    response_handler.showError = function(error) {
-        request_build_overlay.showError(error);
-        Y.log(error);
+    request_build_response_handler = new RequestResponseHandler();
+    request_build_response_handler.clearProgressUI = function() {
+        destroy_temporary_spinner();
+    };
+    request_build_response_handler.showError = function(header, error_msgs) {
+        if (header == null) {
+            if (error_msgs == null)
+                header = "An error occurred, please contact an administrator.";
+            else
+                header = "The following errors occurred:";
+        }
+        var error_html = header;
+        if (error_msgs != null) {
+            error_html += "<ul>";
+            if (typeof(error_msgs) == "string"){
+                error_msgs = [error_msgs];
+            }
+            Y.each(error_msgs, function(error_msg){
+                error_html += "<li>" + error_msg.replace(/<([^>]+)>/g,'') +
+                              "</li>";
+            });
+            error_html += "</ul><p/>";
+        }
+        request_build_overlay.error_node.set('innerHTML', error_html);
+    };
+};
+
+var NO_BUILDS_MESSAGE = "All requested recipe builds are already queued.";
+var ONE_BUILD_MESSAGE = "1 new recipe build has been queued.";
+var MANY_BUILDS_MESSAGE = "{nr_new} new recipe builds have been queued.";
+
+namespace.connect_requestdailybuild = function() {
+
+    var request_daily_build_handle = Y.one('#request-daily-build');
+    request_daily_build_handle.on('click', function(e) {
+        e.preventDefault();
+
+        create_temporary_spinner(
+                "Requesting build...", request_daily_build_handle);
+        request_daily_build_handle.addClass("unseen");
+
+        var base_url = LP.client.cache.context.web_link;
+        var submit_url = base_url+"/+request-daily-build";
+        var current_builds = harvest_current_build_records();
+
+        var qs = LP.client.append_qs('', 'field.actions.build', 'Build now');
+        var y_config = {
+            method: "POST",
+            headers: {'Accept': 'application/xhtml'},
+            on: {
+                failure: request_daily_build_response_handler.getErrorHandler(
+                    function(handler, id, response) {
+                        request_daily_build_handle.removeClass("unseen");
+                        var server_error = 'Server error, ' +
+                                           'please contact an administrator.';
+                        handler.showError(server_error, null);
+                    }),
+                success:
+                    request_daily_build_response_handler.getSuccessHandler(
+                    function(handler, id, response) {
+                        var nr_new = display_build_records(
+                                response.responseText, current_builds);
+                        var new_builds_message;
+                        switch (nr_new) {
+                            case 0:
+                                new_builds_message = NO_BUILDS_MESSAGE;
+                                break;
+                            case 1:
+                                new_builds_message = ONE_BUILD_MESSAGE;
+                                break;
+                            default:
+                                new_builds_message =
+                                        Y.Lang.substitute(
+                                                MANY_BUILDS_MESSAGE,
+                                                {nr_new: nr_new});
+                        }
+                        var build_message_node = Y.Node.create([
+                            '<div id="new-builds-info" class="build-informational">',
+                            new_builds_message,
+                            '</div>'].join(''));
+                        request_daily_build_handle.insert(
+                                build_message_node,
+                                request_daily_build_handle);
+                        Y.later(20000, build_message_node, 'hide', true);
+                    }
+                  )
+            },
+            data: qs
+        };
+        Y.io(submit_url, y_config);
+    });
+
+    // Wire up the processing hooks
+    request_daily_build_response_handler = new RequestResponseHandler();
+    request_daily_build_response_handler.clearProgressUI = function() {
+        destroy_temporary_spinner();
+    };
+    request_daily_build_response_handler.showError = function(header, error) {
+        var error_msg = header;
+        if (error != null)
+            error_msg += " " + error;
+        alert(error_msg);
+        Y.log(error_msg);
     };
 };
 
@@ -118,14 +216,34 @@
 }
 
 /*
+ * Render build records and flash the new ones
+ */
+function display_build_records(build_records_markup, current_builds) {
+    var target = Y.one('#builds-target');
+    target.set('innerHTML', build_records_markup);
+    var new_builds = harvest_current_build_records();
+    var nr_new_builds = 0;
+    Y.Array.each(new_builds, function(row_id) {
+        if( current_builds.indexOf(row_id)>=0 )
+            return;
+        nr_new_builds += 1;
+        var row = Y.one('#'+row_id);
+        var anim = Y.lazr.anim.green_flash({node: row});
+        anim.run();
+    });
+    return nr_new_builds;
+}
+
+/*
  * Perform any client side validation
  * Return: true if data is valid
  */
 function validate(data) {
     var distros = data['field.distros']
     if (Y.Object.size(distros) == 0) {
-        response_handler.showError("You need to specify at least one " +
-                "distro series for which to build.");
+        request_build_response_handler.showError(
+                "You need to specify at least one distro series for " +
+                "which to build.", null);
         return false;
     }
     return true;
@@ -137,7 +255,9 @@
 function do_request_builds(data) {
     if (!validate(data))
         return;
-    create_temporary_spinner();
+    var spinner_location = Y.one('.yui3-lazr-formoverlay-actions');
+    create_temporary_spinner("Requesting builds...", spinner_location);
+
     var base_url = LP.client.cache.context.web_link;
     var submit_url = base_url+"/+builds";
     var current_builds = harvest_current_build_records();
@@ -145,38 +265,47 @@
         method: "POST",
         headers: {'Accept': 'application/json; application/xhtml'},
         on: {
-            failure: response_handler.getErrorHandler(
+            failure: request_build_response_handler.getErrorHandler(
                 function(handler, id, response) {
                     if( response.status >= 500 ) {
                         // There's some error content we need to display.
                         request_build_overlay.set(
                                 'form_content', response.responseText);
                         request_build_overlay.get("form_submit_button")
-                                .setStyle('display', 'none');
+                                .addClass('unseen');
                         request_build_overlay.renderUI();
                         //We want to force the form to be re-created
                         request_build_overlay = null;
                         return;
                     }
-                    var error_info = Y.JSON.parse(response.responseText)
+                    var build_info = Y.JSON.parse(response.responseText);
+                    var build_html = build_info['builds'];
+                    var error_info = build_info['errors'];
                     var errors = [];
+                    var error_header = null;
+                    if (build_html != null) {
+                        var nr_new = display_build_records(
+                                build_html, current_builds);
+                        var new_builds_message = ONE_BUILD_MESSAGE;
+                        if (nr_new > 1) {
+                            new_builds_message =
+                                    Y.Lang.substitute(
+                                            MANY_BUILDS_MESSAGE,
+                                            {nr_new: nr_new});
+                        }
+                        error_header = "<p><div class='popup-build-informational'>"
+                                        +new_builds_message+"</p></div>"+
+                                        "There were also some errors:";
+                    }
                     for (var field_name in error_info)
                         errors.push(error_info[field_name]);
-                    handler.showError(errors);
+                    handler.showError(error_header, errors);
                 }),
-            success: response_handler.getSuccessHandler(
+            success: request_build_response_handler.getSuccessHandler(
                 function(handler, id, response) {
                     request_build_overlay.hide();
-                    var target = Y.one('#builds-target');
-                    target.set('innerHTML', response.responseText);
-                    var new_builds = harvest_current_build_records();
-                    Y.Array.each(new_builds, function(row_id) {
-                        if (current_builds.indexOf(row_id)>=0)
-                            return;
-                        var row = Y.one('#'+row_id);
-                        var anim = Y.lazr.anim.green_flash({node: row});
-                        anim.run();
-                    });
+                    display_build_records(
+                            response.responseText, current_builds)
                 })
         },
         form: {
@@ -190,14 +319,14 @@
 /*
  * Show the temporary "Requesting..." text
  */
-function create_temporary_spinner() {
+function create_temporary_spinner(text, node) {
     // Add the temp "Requesting build..." text
     var temp_spinner = Y.Node.create([
         '<div id="temp-spinner">',
-        '<img src="/@@/spinner"/>Requesting build...',
+        '<img src="/@@/spinner"/>',
+        text,
         '</div>'].join(''));
-    var request_build_handle = Y.one('.yui3-lazr-formoverlay-actions');
-    request_build_handle.insert(temp_spinner, request_build_handle);
+    node.insert(temp_spinner, node);
 }
 
 /*

=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
--- lib/lp/code/model/sourcepackagerecipe.py	2011-02-18 23:51:57 +0000
+++ lib/lp/code/model/sourcepackagerecipe.py	2011-02-23 05:23:57 +0000
@@ -281,6 +281,20 @@
             queue_record.manualScore(queue_record.lastscore + 100)
         return build
 
+    def performDailyBuild(self):
+        """See `ISourcePackageRecipe`."""
+        builds = []
+        self.is_stale = False
+        for distroseries in self.distroseries:
+            try:
+                build = self.requestBuild(
+                    self.daily_build_archive, self.owner,
+                    distroseries, PackagePublishingPocket.RELEASE)
+                builds.append(build)
+            except BuildAlreadyPending:
+                continue
+        return builds
+
     def getBuilds(self):
         """See `ISourcePackageRecipe`."""
         where_clause = BuildFarmJob.status != BuildStatus.NEEDSBUILD

=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
--- lib/lp/code/templates/sourcepackagerecipe-index.pt	2011-02-22 09:07:44 +0000
+++ lib/lp/code/templates/sourcepackagerecipe-index.pt	2011-02-23 05:23:57 +0000
@@ -16,6 +16,25 @@
       font-family: "UbuntuBeta Mono","Ubuntu Mono",monospace;
       margin-top: -15px;
     }
+    .build-informational {
+        background: #d4e8ff url('/+icing/blue-fade-to-grey');
+        border: solid #666;
+        border-width: 1px 2px 2px 1px;
+        color: black;
+        padding: 5px 5px 5px 5px;
+        margin-right: 40px
+    }
+    .build-informational::before {
+        padding-right: 5px;
+        content: url('/@@/info');
+    }
+    .popup-build-informational {
+        color: black;
+    }
+    .popup-build-informational::before {
+        padding-right: 5px;
+        content: url('/@@/info');
+    }
   </style>
 </metal:block>
 
@@ -60,6 +79,23 @@
               </a>
             </dt>
             <dd tal:content="structure view/daily_build_widget"/>
+            <dd
+              tal:define="link context/menu:context/request_daily_build"
+              tal:condition="link/enabled"
+              >
+              <noscript>
+                <form action="+request-daily-build"
+                      method="post"
+                      id="request-daily-build-form">
+                    <input type="submit" name="field.actions.build"
+                        id="field.actions.build" value="Build now" />
+                </form>
+              </noscript>
+              <a id="request-daily-build"
+                 class="sprite source-package-recipe js-action unseen"
+                 tal:attributes="href link/url"
+                 tal:content="link/text"/>
+            </dd>
           </dl>
 
           <dl id="owner">
@@ -116,11 +152,16 @@
         if(Y.UA.ie) {
             return;
         }
-
         Y.on('load', function() {
             var logged_in = LP.client.links['me'] !== undefined;
             if (logged_in) {
-                Y.lp.code.requestbuild_overlay.connect_requestbuild();
+                build_now_link = Y.one('#request-daily-build');
+                if( build_now_link != null ) {
+                  build_now_link.removeClass('unseen');
+                  Y.lp.code.requestbuild_overlay.connect_requestdailybuild();
+                }
+                Y.lp.code.requestbuild_overlay.connect_requestbuilds();
+
             }
         }, window);
       });"

=== modified file 'lib/lp/code/windmill/tests/test_recipe_request_build.py'
--- lib/lp/code/windmill/tests/test_recipe_request_build.py	2011-02-18 01:06:19 +0000
+++ lib/lp/code/windmill/tests/test_recipe_request_build.py	2011-02-23 05:23:57 +0000
@@ -13,13 +13,12 @@
 from lp.testing.windmill.constants import (
     FOR_ELEMENT,
     PAGE_LOAD,
-    SLEEP,
     )
 from lp.testing.windmill.lpuser import login_person
 from lp.app.browser.tales import PPAFormatterAPI
 from lp.code.windmill.testing import CodeWindmillLayer
 from lp.soyuz.model.processor import ProcessorFamily
-from lp.testing import WindmillTestCase
+from lp.testing import WindmillTestCase, quote_jquery_expression
 
 
 class TestRecipeBuild(WindmillTestCase):
@@ -50,7 +49,7 @@
             owner=self.chef, distroseries=self.squirrel, name=u'cake_recipe',
             description=u'This recipe builds a foo for disto bar, with my'
             ' Secret Squirrel changes.', branches=[cake_branch],
-            daily_build_archive=self.ppa)
+            daily_build_archive=self.ppa, build_daily=True, is_stale=True)
         transaction.commit()
         login_person(self.chef, "chef@xxxxxxxxxxx", "test", self.client)
 
@@ -74,23 +73,48 @@
 
         # Ensure it shows up.
         client.waits.forElement(
-            xpath = (u'//tr[contains(@class, "package-build")]/td[4]'
-                     '/a[@href="%s"]') % PPAFormatterAPI(self.ppa).url(),
+            jquery=u"('tr.package-build a[href$=\"%s\"]')"
+            % quote_jquery_expression(PPAFormatterAPI(self.ppa).url()),
             timeout=FOR_ELEMENT)
 
-        # And try the same one again.
+        # And try the same one again plus a new one.
         client.click(id=u'request-builds')
         client.waits.forElement(id=u'field.archive')
+        client.click(id=u'field.distros.1')
         client.click(name=u'field.actions.request')
 
+        # And check that there's an info message.
+        client.waits.forElement(
+            jquery=u"('div.popup-build-informational')",
+            timeout=FOR_ELEMENT)
+
+        client.asserts.assertTextIn(
+            jquery=u"('div.popup-build-informational')[0]",
+            validator=u'1 new recipe build has been queued.')
+
         # And check that there's an error.
         client.waits.forElement(
-            xpath = (
-                u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
-                '/ul/li'), timeout=FOR_ELEMENT)
-        client.asserts.assertText(
-            xpath = (
-                u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
-                '/ul/li'),
+            jquery=u"('div.yui3-lazr-formoverlay-errors ul li')",
+            timeout=FOR_ELEMENT)
+
+        client.asserts.assertTextIn(
+            jquery=u"('div.yui3-lazr-formoverlay-errors ul li')[0]",
             validator=u'An identical build is already pending for %s %s.'
                         % (self.ppa.distribution.name, self.squirrel.name))
+
+    def test_recipe_daily_build_request(self):
+        """Request a recipe build."""
+
+        client = self.client
+        client.open(url=canonical_url(self.recipe))
+        client.waits.forElement(
+            id=u'request-daily-build', timeout=PAGE_LOAD)
+
+        # Request a daily build.
+        client.click(id=u'request-daily-build')
+
+        # Ensure it shows up.
+        client.waits.forElement(
+            jquery=u"('tr.package-build a[href$=\"%s\"]')"
+            % quote_jquery_expression(PPAFormatterAPI(self.ppa).url()),
+            timeout=FOR_ELEMENT)

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2011-02-11 00:25:51 +0000
+++ lib/lp/testing/__init__.py	2011-02-23 05:23:57 +0000
@@ -28,6 +28,7 @@
     'normalize_whitespace',
     'oauth_access_token_for',
     'person_logged_in',
+    'quote_jquery_expression'
     'record_statements',
     'run_with_login',
     'run_with_storm_debug',
@@ -790,7 +791,13 @@
         return client, obj_url
 
 
-class YUIUnitTestCase(WindmillTestCase):
+def quote_jquery_expression(expression):
+    """jquery requires meta chars used in literals escaped with \\"""
+    return re.sub(
+        "([#!$%&()+,./:;?@~|^{}\\[\\]`*\\\'\\\"])", r"\\\\\1", expression)
+
+
+class YUIUnitTestCase(TestCase):
 
     layer = None
     suite_name = ''


Follow ups