← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad

 

Abel Deuring has proposed merging lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~adeuring/launchpad/bug-739052-5/+merge/71548

This branch is another sequel to get StormRangeFactory running. A first test in a batch view revealed that timestamps used as memo values were not properly processed: I simply forgot before that we use timestamps with a timezone in our database.

So, instead of timestamps like "2011-08-16T12:13:14" we have values like "2011-08-16T12:13:14+00:00"

This branch fixes the method StormRangeFactory.parseMemo() to properly process a time zone offset.

Since the memo value comes "in real life" from a URL query string, i.e., we have no real control over how users might tweak them, I still allow timestamps without a time zone offset: In this case, it is assumed the a UTC time is "meant".

Another issue I discovered during the batch view test: lazr.batchnavigator calls len(slice), where slice is returned by StormRangeFactory.getSlice(). Since Storm ResultSet instances do not implement __len__(), getSlice() now returns lists.

test: ./bin/test canonical -vvt test_batching

no lint

-- 
https://code.launchpad.net/~adeuring/launchpad/bug-739052-5/+merge/71548
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~adeuring/launchpad/bug-739052-5 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/scripts/ftests/test_oops_prune.py'
=== modified file 'lib/canonical/launchpad/scripts/oops.py'
--- lib/canonical/launchpad/scripts/oops.py	2011-08-12 20:41:50 +0000
+++ lib/canonical/launchpad/scripts/oops.py	2011-08-15 11:58:31 +0000
@@ -20,7 +20,11 @@
 import os
 import re
 
+<<<<<<< TREE
 from oops import uniquefileallocator
+=======
+from ...oops import uniquefileallocator
+>>>>>>> MERGE-SOURCE
 from pytz import utc
 
 from canonical.database.sqlbase import cursor

=== modified file 'lib/canonical/launchpad/webapp/batching.py'
--- lib/canonical/launchpad/webapp/batching.py	2011-08-08 11:48:18 +0000
+++ lib/canonical/launchpad/webapp/batching.py	2011-08-15 11:58:31 +0000
@@ -4,9 +4,11 @@
 __metaclass__ = type
 
 from datetime import datetime
+import re
 
 import lazr.batchnavigator
 from lazr.batchnavigator.interfaces import IRangeFactory
+import pytz
 import simplejson
 from storm import Undef
 from storm.expr import (
@@ -161,6 +163,15 @@
         return simplejson.JSONEncoder.default(self, obj)
 
 
+# An ISO timestamp has the format yyyy-mm-ddThh:mm:ss.ffffff+hh:mm
+# The fractions of a second and the time zone offset are optional.
+timestamp_regex = re.compile(
+    r'^(?P<year>\d\d\d\d)-(?P<month>\d\d)-(?P<day>\d\d)'
+    'T(?P<hour>\d\d):(?P<minute>\d\d):(?P<second>\d\d)'
+    '(\.(?P<sec_fraction>\d\d\d\d\d\d))?'
+    '((?P<tzsign>[+-])(?P<tzhour>\d\d):(?P<tzminute>\d\d))?$')
+
+
 class StormRangeFactory:
     """A range factory for Storm result sets.
 
@@ -302,21 +313,36 @@
                 # value.
                 if (str(error).startswith('Expected datetime') and
                     isinstance(value, str)):
+                    mo = timestamp_regex.search(value)
+                    if mo is None:
+                        self.reportError(
+                            'Invalid datetime value: %r' % value)
+                        return None
+                    sec_fraction = mo.group('sec_fraction')
+                    if sec_fraction is None:
+                        sec_fraction = 0
+                    else:
+                        sec_fraction = int(sec_fraction)
+                    tzsign = mo.group('tzsign')
+                    if tzsign is None:
+                        tzinfo = pytz.UTC
+                    else:
+                        tzhour = int(mo.group('tzhour'))
+                        tzminute = int(mo.group('tzminute'))
+                        tzoffset = 60 * tzhour + tzminute
+                        if tzsign == '-':
+                            tzoffset = -tzoffset
+                        tzinfo = pytz.FixedOffset(tzoffset)
                     try:
-                        value = datetime.strptime(
-                            value, '%Y-%m-%dT%H:%M:%S.%f')
+                        value = datetime(
+                            int(mo.group('year')), int(mo.group('month')),
+                            int(mo.group('day')), int(mo.group('hour')),
+                            int(mo.group('minute')), int(mo.group('second')),
+                            sec_fraction, tzinfo)
                     except ValueError:
-                        # One more attempt: If the fractions of a second
-                        # are zero, datetime.isoformat() omits the
-                        # entire part '.000000', so we need a different
-                        # format for strptime().
-                        try:
-                            value = datetime.strptime(
-                                value, '%Y-%m-%dT%H:%M:%S')
-                        except ValueError:
-                            self.reportError(
-                                'Invalid datetime value: %r' % value)
-                            return None
+                        self.reportError(
+                            'Invalid datetime value: %r' % value)
+                        return None
                 else:
                     self.reportError(
                         'Invalid parameter: %r' % value)
@@ -473,7 +499,9 @@
         if not forwards:
             self.resultset.order_by(*self.reverseSortOrder())
         parsed_memo = self.parseMemo(endpoint_memo)
+        # Note that lazr.batchnavigator calls len(slice), so we can't
+        # return the plain result set.
         if parsed_memo is None:
-            return self.resultset.config(limit=size)
+            return list(self.resultset.config(limit=size))
         else:
-            return self.getSliceFromMemo(size, parsed_memo)
+            return list(self.getSliceFromMemo(size, parsed_memo))

=== modified file 'lib/canonical/launchpad/webapp/errorlog.py'
=== modified file 'lib/canonical/launchpad/webapp/tests/test_batching.py'
--- lib/canonical/launchpad/webapp/tests/test_batching.py	2011-08-12 11:37:08 +0000
+++ lib/canonical/launchpad/webapp/tests/test_batching.py	2011-08-15 11:58:31 +0000
@@ -22,6 +22,7 @@
     BatchNavigator,
     DateTimeJSONEncoder,
     StormRangeFactory,
+    timestamp_regex,
     )
 from canonical.launchpad.webapp.interfaces import StormRangeFactoryError
 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
@@ -276,34 +277,61 @@
         resultset = self.makeStormResultSet()
         resultset.order_by(Person.datecreated, Person.name, Person.id)
         range_factory = StormRangeFactory(resultset, self.logError)
-        valid_memo = [datetime(2011, 7, 25, 11, 30, 30, 45), 'foo', 1]
+        valid_memo = [
+            datetime(2011, 7, 25, 11, 30, 30, 45, tzinfo=pytz.UTC), 'foo', 1]
         json_data = simplejson.dumps(valid_memo, cls=DateTimeJSONEncoder)
         self.assertEqual(valid_memo, range_factory.parseMemo(json_data))
         self.assertEqual(0, len(self.error_messages))
 
     def test_parseMemo__short_iso_timestamp(self):
         # An ISO timestamp without fractions of a second
-        # (YYYY-MM-DDThh:mm:ss) is a valid value for colums which
+        # (YYYY-MM-DDThh:mm:ss) is a valid value for columns which
         # store datetime values.
         resultset = self.makeStormResultSet()
         resultset.order_by(Person.datecreated)
         range_factory = StormRangeFactory(resultset, self.logError)
         valid_short_timestamp_json = '["2011-07-25T11:30:30"]'
         self.assertEqual(
-            [datetime(2011, 7, 25, 11, 30, 30)],
+            [datetime(2011, 7, 25, 11, 30, 30, tzinfo=pytz.UTC)],
             range_factory.parseMemo(valid_short_timestamp_json))
         self.assertEqual(0, len(self.error_messages))
 
     def test_parseMemo__long_iso_timestamp(self):
         # An ISO timestamp with fractions of a second
-        # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for colums
+        # (YYYY-MM-DDThh:mm:ss.ffffff) is a valid value for columns
         # which store datetime values.
         resultset = self.makeStormResultSet()
         resultset.order_by(Person.datecreated)
         range_factory = StormRangeFactory(resultset, self.logError)
         valid_long_timestamp_json = '["2011-07-25T11:30:30.123456"]'
         self.assertEqual(
-            [datetime(2011, 7, 25, 11, 30, 30, 123456)],
+            [datetime(2011, 7, 25, 11, 30, 30, 123456, tzinfo=pytz.UTC)],
+            range_factory.parseMemo(valid_long_timestamp_json))
+        self.assertEqual(0, len(self.error_messages))
+
+    def test_parseMemo__short_iso_timestamp_with_tzoffset(self):
+        # An ISO timestamp with fractions of a second and a time zone
+        # offset (YYYY-MM-DDThh:mm:ss.ffffff+hh:mm) is a valid value
+        # for columns which store datetime values.
+        resultset = self.makeStormResultSet()
+        resultset.order_by(Person.datecreated)
+        range_factory = StormRangeFactory(resultset, self.logError)
+        valid_long_timestamp_json = '["2011-07-25T11:30:30-01:00"]'
+        self.assertEqual(
+            [datetime(2011, 7, 25, 12, 30, 30, tzinfo=pytz.UTC)],
+            range_factory.parseMemo(valid_long_timestamp_json))
+        self.assertEqual(0, len(self.error_messages))
+
+    def test_parseMemo__long_iso_timestamp_with_tzoffset(self):
+        # An ISO timestamp with fractions of a second and a time zone
+        # offset (YYYY-MM-DDThh:mm:ss.ffffff+hh:mm) is a valid value
+        # for columns which store datetime values.
+        resultset = self.makeStormResultSet()
+        resultset.order_by(Person.datecreated)
+        range_factory = StormRangeFactory(resultset, self.logError)
+        valid_long_timestamp_json = '["2011-07-25T11:30:30.123456+01:00"]'
+        self.assertEqual(
+            [datetime(2011, 7, 25, 10, 30, 30, 123456, tzinfo=pytz.UTC)],
             range_factory.parseMemo(valid_long_timestamp_json))
         self.assertEqual(0, len(self.error_messages))
 
@@ -320,7 +348,7 @@
             ["Invalid datetime value: '2011-05-35T11:30:30'"],
             self.error_messages)
 
-    def test_parseMemo__nonsencial_iso_timestamp_value(self):
+    def test_parseMemo__nonsensical_iso_timestamp_value(self):
         # A memo string is rejected when an ISO timespamp is expected
         # but a nonsensical string is provided.
         resultset = self.makeStormResultSet()
@@ -333,6 +361,66 @@
             ["Invalid datetime value: 'bar'"],
             self.error_messages)
 
+    def test_timestamp_regex__without_second_fraction_without_tzinfo(self):
+        # An ISO time string without fractions of a second and without
+        # time zone data is accepted by timestamp_regex.
+        mo = timestamp_regex.search('2011-01-02T12:03:04')
+        self.assertEqual('2011', mo.group('year'))
+        self.assertEqual('01', mo.group('month'))
+        self.assertEqual('02', mo.group('day'))
+        self.assertEqual('12', mo.group('hour'))
+        self.assertEqual('03', mo.group('minute'))
+        self.assertEqual('04', mo.group('second'))
+        self.assertIs(None, mo.group('sec_fraction'))
+        self.assertIs(None, mo.group('tzsign'))
+        self.assertIs(None, mo.group('tzhour'))
+        self.assertIs(None, mo.group('tzminute'))
+
+    def test_timestamp_regex__with_second_fraction_without_tzinfo(self):
+        # An ISO time string without fractions of a second and without
+        # time zone data is accepted by timestamp_regex.
+        mo = timestamp_regex.search('2011-01-02T12:03:04.123456')
+        self.assertEqual('2011', mo.group('year'))
+        self.assertEqual('01', mo.group('month'))
+        self.assertEqual('02', mo.group('day'))
+        self.assertEqual('12', mo.group('hour'))
+        self.assertEqual('03', mo.group('minute'))
+        self.assertEqual('04', mo.group('second'))
+        self.assertEqual('123456', mo.group('sec_fraction'))
+        self.assertIs(None, mo.group('tzsign'))
+        self.assertIs(None, mo.group('tzhour'))
+        self.assertIs(None, mo.group('tzminute'))
+
+    def test_timestamp_regex__without_second_fraction_with_tzinfo(self):
+        # An ISO time string without fractions of a second and without
+        # time zone data is accepted by timestamp_regex.
+        mo = timestamp_regex.search('2011-01-02T12:03:04+05:00')
+        self.assertEqual('2011', mo.group('year'))
+        self.assertEqual('01', mo.group('month'))
+        self.assertEqual('02', mo.group('day'))
+        self.assertEqual('12', mo.group('hour'))
+        self.assertEqual('03', mo.group('minute'))
+        self.assertEqual('04', mo.group('second'))
+        self.assertIs(None, mo.group('sec_fraction'))
+        self.assertEqual('+', mo.group('tzsign'))
+        self.assertEqual('05', mo.group('tzhour'))
+        self.assertEqual('00', mo.group('tzminute'))
+
+    def test_timestamp_regex__with_second_fraction_with_tzinfo(self):
+        # An ISO time string without fractions of a second and without
+        # time zone data is accepted by timestamp_regex.
+        mo = timestamp_regex.search('2011-01-02T12:03:04.123456-06:07')
+        self.assertEqual('2011', mo.group('year'))
+        self.assertEqual('01', mo.group('month'))
+        self.assertEqual('02', mo.group('day'))
+        self.assertEqual('12', mo.group('hour'))
+        self.assertEqual('03', mo.group('minute'))
+        self.assertEqual('04', mo.group('second'))
+        self.assertEqual('123456', mo.group('sec_fraction'))
+        self.assertEqual('-', mo.group('tzsign'))
+        self.assertEqual('06', mo.group('tzhour'))
+        self.assertEqual('07', mo.group('tzminute'))
+
     def test_parseMemo__descending_sort_order(self):
         # Validation of a memo string against a descending sort order works.
         resultset = self.makeStormResultSet()
@@ -491,7 +579,7 @@
         all_results = list(resultset)
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3)
-        self.assertEqual(all_results[:3], list(sliced_result))
+        self.assertEqual(all_results[:3], sliced_result)
 
     def test_getSlice__forward_with_memo(self):
         resultset = self.makeStormResultSet()
@@ -500,7 +588,7 @@
         memo = simplejson.dumps([all_results[0].name, all_results[0].id])
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3, memo)
-        self.assertEqual(all_results[1:4], list(sliced_result))
+        self.assertEqual(all_results[1:4], sliced_result)
 
     def test_getSlice__backward_without_memo(self):
         resultset = self.makeStormResultSet()
@@ -510,7 +598,7 @@
         expected.reverse()
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3, forwards=False)
-        self.assertEqual(expected, list(sliced_result))
+        self.assertEqual(expected, sliced_result)
 
     def test_getSlice_backward_with_memo(self):
         resultset = self.makeStormResultSet()
@@ -521,7 +609,7 @@
         memo = simplejson.dumps([all_results[4].name, all_results[4].id])
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3, memo, forwards=False)
-        self.assertEqual(expected, list(sliced_result))
+        self.assertEqual(expected, sliced_result)
 
     def makeResultSetWithPartiallyIdenticalSortData(self):
         # Create a result set, where each value of
@@ -558,7 +646,7 @@
             [memo_lfa.mimetype, memo_lfa.filename, memo_lfa.id])
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3, memo)
-        self.assertEqual(all_results[3:6], list(sliced_result))
+        self.assertEqual(all_results[3:6], sliced_result)
 
     def test_getSlice__decorated_resultset(self):
         resultset = self.makeDecoratedStormResultSet()
@@ -567,4 +655,24 @@
         memo = simplejson.dumps([resultset.get_plain_result_set()[0][1].id])
         range_factory = StormRangeFactory(resultset)
         sliced_result = range_factory.getSlice(3, memo)
+<<<<<<< TREE
         self.assertEqual(all_results[1:4], list(sliced_result))
+=======
+        self.assertEqual(all_results[1:4], sliced_result)
+
+    def test_getSlice__returns_list(self):
+        # getSlice() returns lists.
+        resultset = self.makeStormResultSet()
+        resultset.order_by(Person.id)
+        all_results = list(resultset)
+        range_factory = StormRangeFactory(resultset)
+        sliced_result = range_factory.getSlice(3)
+        self.assertIsInstance(sliced_result, list)
+        memo = simplejson.dumps([all_results[0].name])
+        sliced_result = range_factory.getSlice(3, memo)
+        self.assertIsInstance(sliced_result, list)
+
+
+def test_suite():
+    return TestLoader().loadTestsFromName(__name__)
+>>>>>>> MERGE-SOURCE

=== modified file 'lib/canonical/launchpad/webapp/tests/test_errorlog.py'
=== modified file 'lib/lp/app/javascript/formwidgets/formwidgets.js'
--- lib/lp/app/javascript/formwidgets/formwidgets.js	2011-08-10 10:23:57 +0000
+++ lib/lp/app/javascript/formwidgets/formwidgets.js	2011-08-15 11:58:31 +0000
@@ -334,11 +334,16 @@
             .setAttribute(
                 "for", item.one("input").generateID())
             .setStyle("font-weight", "normal")
+<<<<<<< TREE
             .set("text", choice.text);
         item.setData(choice.data)
             .setStyle("overflow", "hidden")
             .setStyle("text-overflow", "ellipsis")
             .setStyle("white-space", "nowrap");
+=======
+            .set("text", choice.text);
+        item.setData(choice.data);
+>>>>>>> MERGE-SOURCE
         return item;
     },
 
@@ -365,6 +370,7 @@
         // Create a mapping of value -> choice for the given choices.
         this._convertChoices(choices).forEach(
             function(choice) {
+<<<<<<< TREE
                 choicemap[choice.value] = true;
             }
         );
@@ -379,6 +385,22 @@
     },
 
     /**
+=======
+                choicemap[choice.value] = true;
+            }
+        );
+        // Filter out the choices mentioned.
+        choices = this.get("choices").filter(function(choice) {
+            return !owns(choicemap, choice.value);
+        });
+        // Set back again.
+        this.set("choices", choices);
+        // Tell everyone!
+        Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+    },
+
+    /**
+>>>>>>> MERGE-SOURCE
      * Add new choices (if they are not already present).
      *
      * @method add_choices
@@ -391,6 +413,7 @@
                 choicemap[choice.value] = choice;
             }
         );
+<<<<<<< TREE
         // Allow existing choices to be redefined.
         choices = this.get("choices").map(function(choice) {
             if (owns(choicemap, choice.value)) {
@@ -405,6 +428,22 @@
         this.set("choices", choices);
         // Tell everyone!
         Y.lp.anim.green_flash({node: this.fieldNode}).run();
+=======
+        // Allow existing choices to be redefined.
+        choices = this.get("choices").map(function(choice) {
+            if (owns(choicemap, choice.value)) {
+                choice = choicemap[choice.value];
+                delete choicemap[choice.value];
+            }
+            return choice;
+        });
+        // Add the new choices (i.e. what remains in choicemap).
+        choices = choices.concat(values(choicemap));
+        // Set back again.
+        this.set("choices", choices);
+        // Tell everyone!
+        Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+>>>>>>> MERGE-SOURCE
     }
 
 });

=== modified file 'lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js'
--- lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js	2011-08-09 16:31:48 +0000
+++ lib/lp/app/javascript/formwidgets/tests/test_formwidgets.js	2011-08-15 11:58:31 +0000
@@ -498,6 +498,209 @@
         testFormRowWidget, testChoiceListWidget);
     suite.add(new Y.Test.Case(testChoiceListWidget));
 
+<<<<<<< TREE
+=======
+    var testSelectWidget = {
+        name: 'TestSelectWidget',
+
+        choices: [
+            {value: "a", text: "A", data: 123},
+            {value: "b", text: "B", data: 456},
+            {value: "c", text: "C", data: 789}
+        ],
+
+        setUp: function() {
+            this.container = Y.Node.create("<div />");
+            this.widget = new widgets.SelectWidget();
+        },
+
+        tearDown: function() {
+            this.container.remove(true);
+        },
+
+        testNameChange: function() {
+            this.widget
+                .set("name", "foo")
+                .set("choices", this.choices);
+            var select = this.widget.fieldNode.one("select");
+            Assert.areSame("foo", select.get("name"));
+            this.widget
+                .set("name", "bar");
+            Assert.areSame("bar", select.get("name"));
+        },
+
+        testChoices: function() {
+            this.widget.set("choices", this.choices);
+            var choices_observed = this.widget.get("choices");
+            /* We have to compare bit by bit ourselves because
+               Javascript is a language born in hell. */
+            ArrayAssert.itemsAreSame(
+                attrselect("value")(this.choices),
+                attrselect("value")(choices_observed));
+            ArrayAssert.itemsAreSame(
+                attrselect("text")(this.choices),
+                attrselect("text")(choices_observed));
+            ArrayAssert.itemsAreSame(
+                attrselect("data")(this.choices),
+                attrselect("data")(choices_observed));
+        },
+
+        testRenderChoices: function() {
+            this.widget.set("choices", this.choices);
+            this.widget.render(this.container);
+            ArrayAssert.itemsAreSame(
+                ["a", "b", "c"],
+                this.container.all("select > option").get("value"));
+            ArrayAssert.itemsAreSame(
+                ["A", "B", "C"],
+                this.container.all("select > option").get("text"));
+        },
+
+        testRenderEmptyChoices: function() {
+            this.widget.fieldNode.append("something");
+            this.widget.set("choices", []);
+            this.widget.render(this.container);
+            Assert.isNull(this.container.one("select"));
+            Assert.isFalse(this.widget.fieldNode.hasChildNodes());
+        },
+
+        testRenderChoicesChange: function() {
+            var choices1 = [
+                {value: "a", text: "A", data: 123}
+            ];
+            this.widget.set("choices", choices1);
+            this.widget.render(this.container);
+            var choices2 = [
+                {value: "b", text: "B", data: 456},
+                {value: "c", text: "C", data: 789}
+            ];
+            this.widget.set("choices", choices2);
+            ArrayAssert.itemsAreSame(
+                ["b", "c"],
+                this.container.all("select > option").get("value"));
+            ArrayAssert.itemsAreSame(
+                ["B", "C"],
+                this.container.all("select > option").get("text"));
+        },
+
+        testChoice: function() {
+            this.widget
+                .set("choices", this.choices)
+                .set("multiple", true);
+            /* It would be better to deselect all options by default,
+               but this appears impossible; the browser seems to
+               select the first option when rendering. */
+            this.widget.fieldNode.all("option").set("selected", false);
+            ArrayAssert.itemsAreSame(
+                [], this.widget.get("choice"));
+            this.widget.fieldNode.one("option[value=a]")
+                .set("selected", true);
+            ArrayAssert.itemsAreSame(
+                ["a"], this.widget.get("choice"));
+            this.widget.fieldNode.one("option[value=c]")
+                .set("selected", true);
+            ArrayAssert.itemsAreSame(
+                ["a", "c"], this.widget.get("choice"));
+        },
+
+        testSetChoice: function() {
+            this.widget
+                .set("multiple", true)
+                .set("choices", this.choices)
+                .set("choice", "a");
+            ArrayAssert.itemsAreSame(
+                ["a"], this.widget.get("choice"));
+            this.widget.set("choice", ["a"]);
+            ArrayAssert.itemsAreSame(
+                ["a"], this.widget.get("choice"));
+            this.widget.set("choice", ["a", "b"]);
+            ArrayAssert.itemsAreSame(
+                ["a", "b"], this.widget.get("choice"));
+            this.widget.set("choice", ["b", "z"]);
+            ArrayAssert.itemsAreSame(
+                ["b"], this.widget.get("choice"));
+        },
+
+        testSize: function() {
+            Assert.areSame(1, this.widget.get("size"));
+        },
+
+        testRenderSize: function() {
+            this.widget
+                .set("choices", this.choices)
+                .set("size", 7)
+                .render(this.container);
+            Assert.areSame(
+                7, this.widget.fieldNode.one("select").get("size"));
+        },
+
+        testRenderSizeChange: function() {
+            this.widget
+                .set("choices", this.choices)
+                .set("size", 3)
+                .render(this.container)
+                .set("size", 5);
+            Assert.areSame(
+                5, this.widget.fieldNode.one("select").get("size"));
+        },
+
+        testAutoSize: function() {
+            this.widget.set("choices", this.choices);
+            /* Without argument, autoSize() sets the size to the same
+               as the number of choices. */
+            this.widget.autoSize();
+            Assert.areSame(3, this.widget.get("size"));
+        },
+
+        testAutoSizeMoreChoicesThanMaxiumum: function() {
+            this.widget.set("choices", this.choices);
+            /* autoSize() sets the size to the same as the number of
+               choices unless there are more than the specified
+               maximum. */
+            this.widget.autoSize(2);
+            Assert.areSame(2, this.widget.get("size"));
+        },
+
+        testAutoSizeFewerChoicesThanMaxiumum: function() {
+            this.widget.set("choices", this.choices);
+            /* autoSize() sets the size to the same as the number of
+               choices. */
+            this.widget.autoSize(5);
+            Assert.areSame(3, this.widget.get("size"));
+        },
+
+        testMultiple: function() {
+            Assert.areSame(false, this.widget.get("multiple"));
+        },
+
+        testRenderMultiple: function() {
+            this.widget
+                .set("choices", this.choices)
+                .set("multiple", true)
+                .render(this.container);
+            Assert.isTrue(
+                this.widget.fieldNode.one("select")
+                    .hasAttribute("multiple"));
+        },
+
+        testRenderMultipleChange: function() {
+            this.widget
+                .set("choices", this.choices)
+                .set("multiple", true)
+                .render(this.container)
+                .set("multiple", false);
+            Assert.isFalse(
+                this.widget.fieldNode.one("select")
+                    .hasAttribute("multiple"));
+        }
+
+    };
+
+    testSelectWidget = Y.merge(
+        testFormRowWidget, testSelectWidget);
+    suite.add(new Y.Test.Case(testSelectWidget));
+
+>>>>>>> MERGE-SOURCE
     var testFormActionsWidget = {
         name: 'TestFormActionsWidget',
 

=== modified file 'lib/lp/registry/javascript/distroseries/tests/test_widgets.js'
=== modified file 'lib/lp/registry/javascript/distroseries/widgets.js'
--- lib/lp/registry/javascript/distroseries/widgets.js	2011-08-09 16:19:19 +0000
+++ lib/lp/registry/javascript/distroseries/widgets.js	2011-08-15 11:58:31 +0000
@@ -483,7 +483,37 @@
             var content = "<div>[No package sets to select from yet!]</div>";
             this.fieldNode.empty().append(content);
         }
-    },
+<<<<<<< TREE
+    },
+=======
+    }
+});
+
+
+Y.extend(PackagesetPickerWidget, formwidgets.ChoiceListWidget, {
+
+    /**
+     * Return whether this widget is displaying any choices.
+     *
+     * @method _hasChoices
+     * @return {Boolean}
+     */
+    _hasChoices: function() {
+        return this.fieldNode.one("li > input") !== null;
+    },
+
+    /**
+     * Display a message when no parent series are selected.
+     *
+     * @method _cleanDisplay
+     */
+    _cleanDisplay: function() {
+        if (!this._hasChoices()) {
+            var content = "<div>[No package sets to select from yet!]</div>";
+            this.fieldNode.empty().append(content);
+        }
+    },
+>>>>>>> MERGE-SOURCE
 
     /**
      * Add a distroseries: add its packagesets to the packageset picker.
@@ -564,6 +594,7 @@
         this.error_handler = new Y.lp.client.ErrorHandler();
         this.error_handler.clearProgressUI = Y.bind(this.hideSpinner, this);
         this.error_handler.showError = Y.bind(this.showError, this);
+<<<<<<< TREE
         /* Display a message if there are no choices. */
         this.after("choicesChange", Y.bind(this._cleanDisplay, this));
         this._cleanDisplay();
@@ -576,6 +607,20 @@
            distroseries is added as a parent and used when a
            distroseries is removed to get all the corresponding
            packagesets to be removed from the widget. */
+=======
+        /* Display a message if there are no choices. */
+        this.after("choicesChange", Y.bind(this._cleanDisplay, this));
+        this._cleanDisplay();
+        /* Green-flash when the set of choices is changed. */
+        this.after("choicesChange", function(e) {
+            Y.lazr.anim.green_flash({node: this.fieldNode}).run();
+        });
+        /* _packagesets maps each distroseries' id to a collection of
+           ids of its packagesets. It's populated each time a new
+           distroseries is added as a parent and used when a
+           distroseries is removed to get all the corresponding
+           packagesets to be removed from the widget. */
+>>>>>>> MERGE-SOURCE
         this._packagesets = {};
     }
 
@@ -586,5 +631,10 @@
 
 }, "0.1", {"requires": [
                "node", "dom", "io", "widget", "lp.client",
+<<<<<<< TREE
                "lp.app.formwidgets", "lp.anim", "array-extras",
                "substitute", "transition"]});
+=======
+               "lp.app.formwidgets", "lazr.anim", "array-extras",
+               "substitute", "transition"]});
+>>>>>>> MERGE-SOURCE

=== modified file 'lib/lp/registry/javascript/distroseriesdifferences_details.js'
--- lib/lp/registry/javascript/distroseriesdifferences_details.js	2011-08-11 13:58:54 +0000
+++ lib/lp/registry/javascript/distroseriesdifferences_details.js	2011-08-15 11:58:31 +0000
@@ -14,6 +14,7 @@
 /**
  * Create one Launchpad client that will be used with multiple requests.
  */
+<<<<<<< TREE
 namespace.lp_client = new Y.lp.client.Launchpad();
 
 namespace.io = Y.io;
@@ -48,7 +49,31 @@
     },
 
     expander_handler: function(e) {
+=======
+var lp_client = new Y.lp.client.Launchpad();
+
+/*
+ * XXX: rvb 2011-08-01 bug=796669: At present this module it is
+ * function-passing spaghetti. The duct-tape is getting frayed.
+ * It ought to be recomposed as widgets or something a bit more objecty so
+ * it can be unit tested without having to set-up the world each time.
+ */
+
+/*
+ * Setup the expandable rows for each difference.
+ *
+ * @method setup_expandable_rows
+ */
+namespace.setup_expandable_rows = function() {
+
+    var blacklist_handler = function(e, dsd_link, source_name,
+                                     latest_comment_container,
+                                     add_comment_placeholder) {
+        // We only want to select the new radio if the update is
+        // successful.
+>>>>>>> MERGE-SOURCE
         e.preventDefault();
+<<<<<<< TREE
         var parsed = this.parse_row_data();
         this._toggle.toggleClass('treeCollapsed').toggleClass('treeExpanded');
 
@@ -119,6 +144,322 @@
         // Set-up diffs and the means to request them.
         namespace.setup_packages_diff_states(container, api_uri);
     },
+=======
+        var blacklist_options_container = this.ancestor('div');
+        blacklist_comment_overlay(
+            e, dsd_link, source_name, latest_comment_container,
+            add_comment_placeholder, blacklist_options_container);
+    };
+
+    var blacklist_comment_overlay = function(e, dsd_link, source_name,
+                                             latest_comment_container,
+                                             add_comment_placeholder,
+                                             blacklist_options_container) {
+        var comment_form = Y.Node.create("<form />")
+            .appendChild(Y.Node.create("<textarea />")
+                .set("name", "comment")
+                .set("rows", "3")
+                .set("cols", "60"));
+
+        /* Buttons */
+        var submit_button = Y.Node.create(
+            '<button type="submit" class="lazr-pos lazr-btn" />')
+               .set("text", "OK");
+        var cancel_button = Y.Node.create(
+            '<button type="button" class="lazr-neg lazr-btn" />')
+               .set("text", "Cancel");
+
+        var submit_callback = function(data) {
+            overlay.hide();
+            var comment = "";
+            if (data.comment !== undefined) {
+                comment = data.comment[0];
+            }
+            blacklist_submit_handler(
+                e, dsd_link, source_name, comment, latest_comment_container,
+                add_comment_placeholder, blacklist_options_container);
+
+        };
+        var origin = blacklist_options_container.one('.blacklist-options');
+        var overlay = new Y.lazr.FormOverlay({
+            align: {
+                 /* Align the centre of the overlay with the centre of the
+                    node containing the blacklist options. */
+                 node: origin,
+                 points: [
+                     Y.WidgetPositionAlign.CC,
+                     Y.WidgetPositionAlign.CC
+                 ]
+             },
+             headerContent: "<h2>Add an optional comment</h2>",
+             form_content: comment_form,
+             form_submit_button: submit_button,
+             form_cancel_button: cancel_button,
+             form_submit_callback: submit_callback,
+             visible: true
+        });
+        overlay.render();
+    };
+
+    var blacklist_submit_handler = function(e, dsd_link, source_name,
+                                            comment, latest_comment_container,
+                                            add_comment_placeholder,
+                                            blacklist_options_container) {
+        // Disable all the inputs.
+        blacklist_options_container.all('input').set('disabled', 'disabled');
+        e.target.prepend('<img src="/@@/spinner" />');
+
+        var method_name = (e.target.get('value') === 'NONE') ?
+            'unblacklist' : 'blacklist';
+        var blacklist_all = (e.target.get('value') === 'BLACKLISTED_ALWAYS');
+
+        var diff_rows = Y.all('tr.' + source_name);
+
+        var config = {
+            on: {
+                success: function(updated_entry, args) {
+                    // Let the user know this item is now blacklisted.
+                    blacklist_options_container.one('img').remove();
+                    blacklist_options_container.all(
+                        'input').set('disabled', false);
+                    e.target.set('checked', true);
+                    Y.each(diff_rows, function(diff_row) {
+                        var fade_to_gray = new Y.Anim({
+                            node: diff_row,
+                            from: { backgroundColor: '#FFFFFF'},
+                            to: { backgroundColor: '#EEEEEE'}
+                            });
+                        if (method_name === 'unblacklist') {
+                            fade_to_gray.set('reverse', true);
+                            }
+                        fade_to_gray.run();
+                    });
+                    add_comment(
+                        updated_entry, add_comment_placeholder,
+                        latest_comment_container);
+                },
+                failure: function(id, response) {
+                    blacklist_options_container.one('img').remove();
+                    blacklist_options_container.all(
+                        'input').set('disabled', false);
+                }
+            },
+            parameters: {
+                all: blacklist_all,
+                comment: comment
+            }
+        };
+
+        lp_client.named_post(dsd_link, method_name, config);
+
+    };
+
+    /**
+     * Link the click event for these blacklist options to the correct
+     * api uri.
+     *
+     * @param blacklist_options {Node} The node containing the blacklist
+     *     options.
+     * @param source_name {string} The name of the source to update.
+     * @param dsd_link {string} The uri for the distroseriesdifference object.
+     * @param latest_comment_container {Node} The node containing the last
+     *     comment.
+     * @param add_comment_placeholder {Node} The node containing the "add
+     *     comment" slot.
+     */
+    var setup_blacklist_options = function(
+        blacklist_options, source_name, dsd_link, latest_comment_container,
+        add_comment_placeholder) {
+        Y.on('click', blacklist_handler, blacklist_options.all('input'),
+             blacklist_options, dsd_link, source_name,
+             latest_comment_container, add_comment_placeholder);
+    };
+
+    /**
+     * Update the latest comment on the difference row.
+     *
+     * @param comment_entry {lp.client.Entry} An object representing
+     *     a DistroSeriesDifferenceComment.
+     * @param placeholder {Node}
+     *     The node in which to put the latest comment HTML fragment. The
+     *     contents of this node will be replaced.
+     */
+    var update_latest_comment = function(comment_entry, placeholder) {
+        var comment_latest_fragment_url =
+            comment_entry.get("web_link") + "/+latest-comment-fragment";
+        var config = {
+            on: {
+                success: function(comment_latest_fragment_html) {
+                    placeholder.set(
+                        "innerHTML", comment_latest_fragment_html);
+                    Y.lazr.anim.green_flash({node: placeholder}).run();
+                },
+                failure: function(id, response) {
+                    Y.lazr.anim.red_flash({node: placeholder}).run();
+                }
+            },
+            accept: Y.lp.client.XHTML
+        };
+        lp_client.get(comment_latest_fragment_url, config);
+    };
+
+    /**
+     * This method adds a comment in the UI. It appends a comment to the
+     * list of comments and updates the latest comments slot.
+     *
+     * @param comment_entry {Comment} A comment as returns by the api.
+     * @param add_comment_placeholder {Node} The node that contains the
+     *     relevant comment fields.
+     * @param latest_comment_placeholder {Node} The node that contains the
+     *     latest comment.
+     */
+    var add_comment = function(comment_entry, add_comment_placeholder,
+                               latest_comment_placeholder) {
+        // Grab the XHTML representation of the comment
+        // and prepend it to the list of comments.
+        var config = {
+            on: {
+                success: function(comment_html) {
+                    var comment_node = Y.Node.create(comment_html);
+                    add_comment_placeholder.insert(comment_node, 'before');
+                    var reveal = Y.lazr.effects.slide_out(comment_node);
+                    reveal.on("end", function() {
+                        Y.lazr.anim.green_flash(
+                            {node: comment_node}).run();
+                    });
+                    reveal.run();
+                }
+            },
+            accept: Y.lp.client.XHTML
+        };
+        lp_client.get(comment_entry.get('self_link'), config);
+        update_latest_comment(comment_entry, latest_comment_placeholder);
+    };
+
+    /**
+     * Handle the add comment event triggered by the 'add comment' form.
+     *
+     * This method adds a comment via the API and update the UI.
+     *
+     * @param comment_form {Node} The node that contains the relevant comment
+     *     fields.
+     * @param latest_comment_placeholder {Node} The node that contains the
+     *     latest comment.
+     * @param api_uri {string} The uri for the distroseriesdifference to which
+     *     the comment is to be added.
+     * @param cb_success {function} Called when a comment has successfully
+     *     been added. (Deferreds would be awesome right about now.)
+     */
+    var add_comment_handler = function(
+            comment_form, latest_comment_placeholder, api_uri, cb_success) {
+
+        var comment_area = comment_form.one('textarea');
+        var comment_text = comment_area.get('value');
+
+        // Treat empty comments as mistakes.
+        if (Y.Lang.trim(comment_text).length === 0) {
+            Y.lazr.anim.red_flash({node: comment_area}).run();
+            return;
+        }
+
+        var success_handler = function(comment_entry) {
+            add_comment(
+                comment_entry, comment_form, latest_comment_placeholder);
+            comment_form.one('textarea').set('value', '');
+            cb_success();
+        };
+        var failure_handler = function(id, response) {
+            // Re-enable field with red flash.
+            Y.lazr.anim.red_flash({node: comment_form}).run();
+        };
+
+        var config = {
+            on: {
+                success: success_handler,
+                failure: failure_handler,
+                start: function() {
+                    // Show a spinner.
+                    comment_form.one('div.widget-bd')
+                        .append('<img src="/@@/spinner" />');
+                    // Disable the textarea and button.
+                    comment_form.all('textarea,button')
+                        .setAttribute('disabled', 'disabled');
+                },
+                end: function() {
+                    // Remove the spinner.
+                    comment_form.all('img').remove();
+                    // Enable the form.
+                    comment_form.all('textarea,button')
+                        .removeAttribute('disabled');
+                }
+            },
+            parameters: {
+                comment: comment_text
+            }
+        };
+        lp_client.named_post(api_uri, 'addComment', config);
+    };
+
+    /**
+     * Add the comment fields ready for sliding out.
+     *
+     * This method adds the markup for a slide-out comment and sets
+     * the event handlers.
+     *
+     * @param placeholder {Node} The node that is to contain the comment
+     *     fields.
+     * @param api_uri {string} The uri for the distroseriesdifference to which
+     *     the comment is to be added.
+     */
+    var setup_add_comment = function(
+            placeholder, latest_comment_placeholder, api_uri) {
+        placeholder.insert([
+            '<a class="widget-hd js-action sprite add" href="">',
+            '  Add comment</a>',
+            '<div class="widget-bd lazr-closed" ',
+            '     style="height:0;overflow:hidden">',
+            '  <textarea></textarea><button>Save comment</button>',
+            '</div>'
+            ].join(''), 'replace');
+
+        // The comment area should slide in when the 'Add comment'
+        // action is clicked.
+        var slide_anim = null;
+        var slide = function(direction) {
+            // Slide out if direction is true, slide in if direction
+            // is false, otherwise do the opposite of what's being
+            // animated right now.
+            if (slide_anim === null) {
+                slide_anim = Y.lazr.effects.slide_out(
+                    placeholder.one('div.widget-bd'));
+                if (Y.Lang.isBoolean(direction)) {
+                    slide_anim.set("reverse", !direction);
+                }
+            }
+            else {
+                if (Y.Lang.isBoolean(direction)) {
+                    slide_anim.set("reverse", !direction);
+                }
+                else {
+                    slide_anim.set('reverse', !slide_anim.get('reverse'));
+                }
+                slide_anim.stop();
+            }
+            slide_anim.run();
+        };
+        var slide_in = function() { slide(false); };
+
+        placeholder.one('a.widget-hd').on(
+            'click', function(e) { e.preventDefault(); slide(); });
+
+        placeholder.one('button').on('click', function(e) {
+            e.preventDefault();
+            add_comment_handler(
+                placeholder, latest_comment_placeholder,
+                api_uri, slide_in);
+        });
+    };
+>>>>>>> MERGE-SOURCE
 
     /**
      * Get the extra information for this diff to display.
@@ -146,9 +487,53 @@
         container.one('div.diff-extra-container').insert(
             in_progress_message, 'replace');
         var success_cb = function(transaction_id, response, args) {
+<<<<<<< TREE
             this.setup_extra_diff_info(
                 master_container, container, source_name, parent_distro_name,
                 parent_series_name, response);
+=======
+            args.container.one('div.diff-extra-container').insert(
+                response.responseText, 'replace');
+            var api_uri = [
+                LP.cache.context.self_link,
+                '+source',
+                source_name,
+                '+difference',
+                parent_distro_name,
+                parent_series_name
+                ].join('/');
+            var latest_comment_container =
+                args.master_container.one('td.latest-comment-fragment');
+            // The add comment slot is only available when the user has the
+            // right to add comments.
+            var add_comment_placeholder =
+                args.container.one('div.add-comment-placeholder');
+            if (add_comment_placeholder !== null) {
+                setup_add_comment(
+                    add_comment_placeholder,
+                    latest_comment_container,
+                    api_uri);
+            }
+            // The blacklist slot with a class 'blacklist-options' is only
+            // available when the user has the right to blacklist.
+            var blacklist_slot = args.container.one('div.blacklist-options');
+            if (blacklist_slot !== null) {
+                setup_blacklist_options(
+                    blacklist_slot, source_name, api_uri,
+                    latest_comment_container,
+                    add_comment_placeholder);
+            }
+            // If the user has not the right to blacklist, we disable
+            // the blacklist slot.
+            var disabled_blacklist_slot = args.container.one(
+                'div.blacklist-options-disabled');
+            if (disabled_blacklist_slot !== null) {
+                disabled_blacklist_slot
+                    .all('input').set('disabled', 'disabled');
+            }
+            // Set-up diffs and the means to request them.
+            namespace.setup_packages_diff_states(args.container, api_uri);
+>>>>>>> MERGE-SOURCE
         };
 
         var failure_cb = function(transaction_id, response, args) {

=== modified file 'lib/lp/registry/javascript/tests/test_distroseriesdifferences_details.js'
=== modified file 'lib/lp/soyuz/scripts/tests/test_initialize_distroseries.py'
=== modified file 'versions.cfg'
--- versions.cfg	2011-08-15 02:51:13 +0000
+++ versions.cfg	2011-08-15 11:58:31 +0000
@@ -49,7 +49,11 @@
 mocker = 0.10.1
 mozrunner = 1.3.4
 oauth = 1.0
+<<<<<<< TREE
 oops = 0.0.3
+=======
+oops = 0.0.1
+>>>>>>> MERGE-SOURCE
 paramiko = 1.7.4
 Paste = 1.7.2
 PasteDeploy = 1.3.3