← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/launchpad/bug-759467 into lp:launchpad

 

Robert Collins has proposed merging lp:~lifeless/launchpad/bug-759467 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #759467 in Launchpad itself: "incomplete-with-response searches require complex searches"
  https://bugs.launchpad.net/launchpad/+bug/759467

For more details, see:
https://code.launchpad.net/~lifeless/launchpad/bug-759467/+merge/58262

Change from storing INCOMPLETE to storing INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE to permit more efficient future queries.

This required:
 - querying on both the current model and the new
 - extending enumcol to permit using both schemas
 - changing the BugTaskStatusSearch enum values to be intrinsically in the right sort order.
 - adding a migration task to the garbo
 - making BugTask.status a property
 - translating INCOMPLETE into INCOMPLETE_WITH[OUT]_RESPONSE in transitionToStatus
 - changing any INCOMPLETE[_WITHOUT_RESPONSE] into INCOMPLETE_WITH_RESPONSE in Bug.linkMessage.

We don't appear to filter on INCOMPLETE in non-nodowntime services so this can go directly into devel.

As a bonus I found some dead code to delete.
-- 
https://code.launchpad.net/~lifeless/launchpad/bug-759467/+merge/58262
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/launchpad/bug-759467 into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2011-04-19 02:42:28 +0000
+++ database/schema/security.cfg	2011-04-19 10:21:21 +0000
@@ -2239,6 +2239,7 @@
 public.bugnotificationfilter            = SELECT, DELETE
 public.bugnotificationrecipientarchive  = SELECT
 public.bugtag                           = SELECT
+public.bugtask                          = SELECT, UPDATE
 public.bugwatch                         = SELECT, UPDATE
 public.bugwatchactivity                 = SELECT, DELETE
 public.codeimportevent                  = SELECT, DELETE

=== modified file 'lib/canonical/config/schema-lazr.conf'
--- lib/canonical/config/schema-lazr.conf	2011-04-13 05:03:04 +0000
+++ lib/canonical/config/schema-lazr.conf	2011-04-19 10:21:21 +0000
@@ -1489,7 +1489,7 @@
 max_comment_size: 3200
 
 # The number of days of inactivity required before an unassigned
-# bugtask with the status of INCOMPLETE is expired.
+# bugtask with the status of INCOMPLETE_WITHOUT_RESPONSE is expired.
 # datatype: integer
 days_before_expiration: 60
 

=== modified file 'lib/canonical/database/enumcol.py'
--- lib/canonical/database/enumcol.py	2009-06-25 05:30:52 +0000
+++ lib/canonical/database/enumcol.py	2011-04-19 10:21:21 +0000
@@ -16,27 +16,46 @@
     ]
 
 
+def check_enum_type(enum):
+    if not issubclass(enum, DBEnumeratedType):
+        raise TypeError(
+            '%r must be a DBEnumeratedType: %r' % (enum, type(enum)))
+
+
+def check_type(enum):
+    if type(enum) in (list, tuple):
+        map(check_enum_type, enum)
+    else:
+        check_enum_type(enum)
+
+
 class DBEnumVariable(Variable):
     """A Storm variable class representing a DBEnumeratedType."""
     __slots__ = ("_enum",)
 
     def __init__(self, *args, **kwargs):
-        self._enum = kwargs.pop("enum")
-        if not issubclass(self._enum, DBEnumeratedType):
-            raise TypeError(
-                '%r must be a DBEnumeratedType: %r'
-                % (self._enum, type(self._enum)))
+        enum = kwargs.pop("enum")
+        if type(enum) not in (list, tuple):
+            enum = (enum,)
+        self._enum = enum
+        check_type(self._enum)
         super(DBEnumVariable, self).__init__(*args, **kwargs)
 
     def parse_set(self, value, from_db):
         if from_db:
-            return self._enum.items[value]
+            for enum in self._enum:
+                try:
+                    return enum.items[value]
+                except KeyError:
+                    pass
+            raise KeyError('%r not in present in any of %r' % (
+                value, self._enum))
         else:
             if not zope_isinstance(value, DBItem):
                 raise TypeError("Not a DBItem: %r" % (value,))
-            if self._enum != value.enum:
-                raise TypeError("DBItem from wrong type, %r != %r" % (
-                        self._enum.name, value.enum.name))
+            if value.enum not in self._enum:
+                raise TypeError("DBItem from unknown enum, %r not in %r" % (
+                        value.enum.name, self._enum))
             return value
 
     def parse_get(self, value, to_db):
@@ -56,9 +75,7 @@
             enum = kw.pop('enum')
         except KeyError:
             enum = kw.pop('schema')
-        if not issubclass(enum, DBEnumeratedType):
-            raise TypeError(
-                '%r must be a DBEnumeratedType: %r' % (enum, type(enum)))
+        check_type(enum)
         self._kwargs = {
             'enum': enum
             }

=== modified file 'lib/canonical/launchpad/doc/enumcol.txt'
--- lib/canonical/launchpad/doc/enumcol.txt	2009-04-17 10:32:16 +0000
+++ lib/canonical/launchpad/doc/enumcol.txt	2011-04-19 10:21:21 +0000
@@ -25,7 +25,7 @@
 Attempting to use a normal enumerated type for an enumcol will
 result in an error.
 
-    >>> from lazr.enum import EnumeratedType, Item
+    >>> from lazr.enum import EnumeratedType, Item, use_template
     >>> class PlainFooType(EnumeratedType):
     ...     """Enumerated type for the foo column."""
     ...     ONE = Item("One")
@@ -100,7 +100,7 @@
     >>> t.foo = AnotherType.ONE
     Traceback (most recent call last):
     ...
-    TypeError: DBItem from wrong type, 'FooType' != 'AnotherType'
+    TypeError: DBItem from unknown enum, 'AnotherType' not in (<DBEnumeratedType 'FooType'>,)
 
 The type assigned in must be the exact type, not a derived types.
 
@@ -111,9 +111,41 @@
     >>> t.foo = item
     Traceback (most recent call last):
     ...
-    TypeError: DBItem from wrong type, 'FooType' != 'DerivedType'
+    TypeError: DBItem from unknown enum, 'DerivedType' not in (<DBEnumeratedType 'FooType'>,)
 
 A simple way to assign in the correct item is to use the name of the derived
 item to access the correct item from the base type.
 
     >>> t.foo = FooType.items[item.name]
+
+Sometimes its useful to serialise things from two different (but related)
+schemas into one table. This works if you tell the column about both enums:
+
+    >>> class BarType(DBEnumeratedType):
+    ...     use_template(FooType, exclude=('TWO'))
+    ...     THREE = DBItem(3, "Three")
+    
+Redefine the table with awareness of BarType:
+
+    >>> class FooTest(SQLBase):
+    ...     foo = EnumCol(schema=[FooType, BarType], default=DEFAULT)
+
+We can assign items from either schema to the table;
+
+    >>> t = FooTest()
+    >>> t.foo = FooType.ONE
+    >>> t_id = t.id
+    >>> b = FooTest()
+    >>> b.foo = BarType.THREE
+    >>> b_id = b.id
+
+And reading back from the database correctly finds things from the schemas in
+the order given.
+
+    >>> from storm.store import AutoReload
+    >>> b.foo = AutoReload
+    >>> t.foo = AutoReload
+    >>> b.foo == BarType.THREE
+    True
+    >>> t.foo == FooType.ONE
+    True

=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2011-04-13 11:05:45 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-04-19 10:21:21 +0000
@@ -1812,7 +1812,7 @@
             bug_task_set.buildUpstreamClause(upstream_open_bugs))
         open_bugs = bug_task_set.open_bugtask_search
         open_bugs.setTarget(self.context)
-        groups = (BugTask.status, BugTask.importance,
+        groups = (BugTask._status, BugTask.importance,
             Bug.latest_patch_uploaded != None, fixed_upstream_clause)
         counts = bug_task_set.countBugs(open_bugs, groups)
         # Sum the split out aggregates.
@@ -1975,57 +1975,6 @@
     return search_filter_url
 
 
-def getInitialValuesFromSearchParams(search_params, form_schema):
-    """Build a dictionary that can be given as initial values to
-    setUpWidgets, based on the given search params.
-
-    >>> initial = getInitialValuesFromSearchParams(
-    ...     {'status': any(*UNRESOLVED_BUGTASK_STATUSES)}, IBugTaskSearch)
-    >>> for status in initial['status']:
-    ...     print status.name
-    NEW
-    INCOMPLETE
-    CONFIRMED
-    TRIAGED
-    INPROGRESS
-    FIXCOMMITTED
-
-    >>> initial = getInitialValuesFromSearchParams(
-    ...     {'status': BugTaskStatus.INVALID}, IBugTaskSearch)
-    >>> [status.name for status in initial['status']]
-    ['INVALID']
-
-    >>> initial = getInitialValuesFromSearchParams(
-    ...     {'importance': [BugTaskImportance.CRITICAL,
-    ...                   BugTaskImportance.HIGH]}, IBugTaskSearch)
-    >>> [importance.name for importance in initial['importance']]
-    ['CRITICAL', 'HIGH']
-
-    >>> getInitialValuesFromSearchParams(
-    ...     {'assignee': NULL}, IBugTaskSearch)
-    {'assignee': None}
-    """
-    initial = {}
-    for key, value in search_params.items():
-        if IList.providedBy(form_schema[key]):
-            if isinstance(value, any):
-                value = value.query_values
-            elif isinstance(value, (list, tuple)):
-                value = value
-            else:
-                value = [value]
-        elif value == NULL:
-            value = None
-        else:
-            # Should be safe to pass value as it is to setUpWidgets, no need
-            # to worry
-            pass
-
-        initial[key] = value
-
-    return initial
-
-
 class BugTaskListingItem:
     """A decorated bug task.
 

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2011-04-11 12:52:27 +0000
+++ lib/lp/bugs/configure.zcml	2011-04-19 10:21:21 +0000
@@ -221,6 +221,7 @@
                     distribution
                     distroseries
                     milestone
+                    _status
                     status
                     statusexplanation
                     importance

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2011-04-12 12:16:03 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2011-04-19 10:21:21 +0000
@@ -201,6 +201,11 @@
         this product or source package.
         """)
 
+    # INCOMPLETE is never actually stored now: INCOMPLETE_WITH_RESPONSE and
+    # INCOMPLETE_WITHOUT_RESPONSE are mapped to INCOMPLETE on read, and on
+    # write INCOMPLETE is mapped to INCOMPLETE_WITHOUT_RESPONSE. This permits
+    # An index on the INCOMPLETE_WITH*_RESPONSE queries that the webapp
+    # generates.
     INCOMPLETE = DBItem(15, """
         Incomplete
 
@@ -274,10 +279,6 @@
         affected software.
         """)
 
-    # DBItem values 35 and 40 are used by
-    # BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE and
-    # BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
-
     UNKNOWN = DBItem(999, """
         Unknown
 
@@ -292,19 +293,14 @@
     """
     use_template(BugTaskStatus, exclude=('UNKNOWN'))
 
-    sort_order = (
-        'NEW', 'INCOMPLETE_WITH_RESPONSE', 'INCOMPLETE_WITHOUT_RESPONSE',
-        'INCOMPLETE', 'OPINION', 'INVALID', 'WONTFIX', 'EXPIRED',
-        'CONFIRMED', 'TRIAGED', 'INPROGRESS', 'FIXCOMMITTED', 'FIXRELEASED')
-
-    INCOMPLETE_WITH_RESPONSE = DBItem(35, """
+    INCOMPLETE_WITH_RESPONSE = DBItem(13, """
         Incomplete (with response)
 
         This bug has new information since it was last marked
         as requiring a response.
         """)
 
-    INCOMPLETE_WITHOUT_RESPONSE = DBItem(40, """
+    INCOMPLETE_WITHOUT_RESPONSE = DBItem(14, """
         Incomplete (without response)
 
         This bug requires more information, but no additional
@@ -312,6 +308,7 @@
         """)
 
 
+
 class BugTagsSearchCombinator(EnumeratedType):
     """Bug Tags Search Combinator
 
@@ -492,9 +489,13 @@
     # bugwatch; this would be better described in a separate interface,
     # but adding a marker interface during initialization is expensive,
     # and adding it post-initialization is not trivial.
+    # Note that status is a property because the model only exposes INCOMPLETE
+    # but the DB stores INCOMPLETE_WITH_RESPONSE and
+    # INCOMPLETE_WITHOUT_RESPONSE for query efficiency.
     status = exported(
         Choice(title=_('Status'), vocabulary=BugTaskStatus,
                default=BugTaskStatus.NEW, readonly=True))
+    _status = Attribute('The actual status DB column used in queries.')
     importance = exported(
         Choice(title=_('Importance'), vocabulary=BugTaskImportance,
                default=BugTaskImportance.UNDECIDED, readonly=True))

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2011-04-13 20:55:31 +0000
+++ lib/lp/bugs/model/bug.py	2011-04-19 10:21:21 +0000
@@ -154,6 +154,7 @@
 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
 from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
+    BugTaskStatusSearch,
     IBugTask,
     IBugTaskSet,
     UNRESOLVED_BUGTASK_STATUSES,
@@ -272,7 +273,7 @@
         Join(BugTask, BugTask.bugID == BugTag.bugID),
         )
     where_conditions = [
-        BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
+        BugTask._status.is_in(UNRESOLVED_BUGTASK_STATUSES),
         context_condition,
         ]
     if wanted_tags is not None:
@@ -1136,6 +1137,13 @@
             getUtility(IBugWatchSet).fromText(
                 message.text_contents, self, user)
             self.findCvesInText(message.text_contents, user)
+            for bugtask in self.bugtasks:
+                # Check the stored value so we don't write to unaltered tasks.
+                if (bugtask._status == BugTaskStatus.INCOMPLETE or
+                    bugtask._status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE):
+                    # This is not a semantic change, so we don't update date
+                    # records or send email.
+                    bugtask._status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
             # XXX 2008-05-27 jamesh:
             # Ensure that BugMessages get flushed in same order as
             # they are created.

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-04-11 12:52:27 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-04-19 10:21:21 +0000
@@ -507,9 +507,9 @@
         dbName='milestone', foreignKey='Milestone',
         notNull=False, default=None,
         storm_validator=validate_conjoined_attribute)
-    status = EnumCol(
+    _status = EnumCol(
         dbName='status', notNull=True,
-        schema=BugTaskStatus,
+        schema=(BugTaskStatus, BugTaskStatusSearch),
         default=BugTaskStatus.NEW,
         storm_validator=validate_status)
     statusexplanation = StringCol(dbName='statusexplanation', default=None)
@@ -561,6 +561,13 @@
         dbName='targetnamecache', notNull=False, default=None)
 
     @property
+    def status(self):
+        if (self._status == BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE or
+            self._status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE):
+            return BugTaskStatus.INCOMPLETE
+        return self._status
+
+    @property
     def title(self):
         """See `IBugTask`."""
         return 'Bug #%s in %s: "%s"' % (
@@ -843,12 +850,21 @@
                 "Only Bug Supervisors may change status to %s." % (
                     new_status.title,))
 
-        if self.status == new_status:
+        if new_status == BugTaskStatus.INCOMPLETE:
+            # We store INCOMPLETE as INCOMPLETE_WITHOUT_RESPONSE so that it can
+            # be queried on efficiently.
+            if (when is None or self.bug.date_last_message is None or
+                when > self.bug.date_last_message):
+                new_status = BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
+            else:
+                new_status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
+
+        if self._status == new_status:
             # No change in the status, so nothing to do.
             return
 
         old_status = self.status
-        self.status = new_status
+        self._status = new_status
 
         if new_status == BugTaskStatus.UNKNOWN:
             # Ensure that all status-related dates are cleared,
@@ -922,7 +938,8 @@
         # Bugs can jump in and out of 'incomplete' status
         # and for just as long as they're marked incomplete
         # we keep a date_incomplete recorded for them.
-        if new_status == BugTaskStatus.INCOMPLETE:
+        if new_status in (BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+            BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE):
             self.date_incomplete = when
         else:
             self.date_incomplete = None
@@ -1601,29 +1618,45 @@
         elif zope_isinstance(status, not_equals):
             return '(NOT %s)' % self._buildStatusClause(status.value)
         elif zope_isinstance(status, BaseItem):
+            incomplete_response = (
+                status == BugTaskStatus.INCOMPLETE)
             with_response = (
                 status == BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE)
             without_response = (
                 status == BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE)
+            # TODO: bug 759467 tracks the migration of INCOMPLETE in the db to
+            # INCOMPLETE_WITH_RESPONSE and INCOMPLETE_WITHOUT_RESPONSE. When
+            # the migration is complete, we can convert status lookups to a
+            # simple IN clause.
             if with_response or without_response:
-                status_clause = (
-                    '(BugTask.status = %s) ' %
-                    sqlvalues(BugTaskStatus.INCOMPLETE))
                 if with_response:
-                    status_clause += ("""
+                    return """(
+                        BugTask.status = %s OR
+                        (BugTask.status = %s
                         AND (Bug.date_last_message IS NOT NULL
                              AND BugTask.date_incomplete <=
-                                 Bug.date_last_message)
-                        """)
+                                 Bug.date_last_message)))
+                        """ % sqlvalues(
+                            BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+                            BugTaskStatus.INCOMPLETE)
                 elif without_response:
-                    status_clause += ("""
+                    return """(
+                        BugTask.status = %s OR
+                        (BugTask.status = %s
                         AND (Bug.date_last_message IS NULL
                              OR BugTask.date_incomplete >
-                                Bug.date_last_message)
-                        """)
-                else:
-                    assert with_response != without_response
-                return status_clause
+                                Bug.date_last_message)))
+                        """ % sqlvalues(
+                            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+                            BugTaskStatus.INCOMPLETE)
+                assert with_response != without_response
+            elif incomplete_response:
+                # search for any of INCOMPLETE (being migrated from),
+                # INCOMPLETE_WITH_RESPONSE or INCOMPLETE_WITHOUT_RESPONSE
+                return 'BugTask.status %s' % search_value_to_where_condition(
+                    any(BugTaskStatus.INCOMPLETE,
+                        BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+                        BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE))
             else:
                 return '(BugTask.status = %s)' % sqlvalues(status)
         else:
@@ -1660,7 +1693,7 @@
                 And(ConjoinedMaster.bugID == BugTask.bugID,
                     BugTask.distributionID == milestone.distribution.id,
                     ConjoinedMaster.distroseriesID == current_series.id,
-                    Not(ConjoinedMaster.status.is_in(
+                    Not(ConjoinedMaster._status.is_in(
                             BugTask._NON_CONJOINED_STATUSES))))
             join_tables = [(ConjoinedMaster, join)]
         else:
@@ -1680,7 +1713,7 @@
                         And(ConjoinedMaster.bugID == BugTask.bugID,
                             ConjoinedMaster.productseriesID
                                 == Product.development_focusID,
-                            Not(ConjoinedMaster.status.is_in(
+                            Not(ConjoinedMaster._status.is_in(
                                     BugTask._NON_CONJOINED_STATUSES)))),
                     ]
                 # join.right is the table name.
@@ -1693,7 +1726,7 @@
                     And(ConjoinedMaster.bugID == BugTask.bugID,
                         BugTask.productID == milestone.product.id,
                         ConjoinedMaster.productseriesID == dev_focus_id,
-                        Not(ConjoinedMaster.status.is_in(
+                        Not(ConjoinedMaster._status.is_in(
                                 BugTask._NON_CONJOINED_STATUSES))))
                 join_tables = [(ConjoinedMaster, join)]
             else:
@@ -2190,6 +2223,8 @@
             statuses_for_open_tasks = [
                 BugTaskStatus.NEW,
                 BugTaskStatus.INCOMPLETE,
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+                BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
                 BugTaskStatus.CONFIRMED,
                 BugTaskStatus.INPROGRESS,
                 BugTaskStatus.UNKNOWN]
@@ -2601,7 +2636,7 @@
 
         non_target_create_params = dict(
             bug=bug,
-            status=status,
+            _status=status,
             importance=importance,
             assignee=assignee,
             owner=owner,
@@ -2736,14 +2771,15 @@
                 """ + target_clause + """
                 """ + bug_clause + """
                 """ + bug_privacy_filter + """
-                    AND BugTask.status = %s
+                    AND (BugTask.status = %s OR BugTask.status = %s)
                     AND BugTask.assignee IS NULL
                     AND BugTask.milestone IS NULL
                     AND Bug.duplicateof IS NULL
                     AND Bug.date_last_updated < CURRENT_TIMESTAMP
                         AT TIME ZONE 'UTC' - interval '%s days'
                     AND BugWatch.id IS NULL
-            )""" % sqlvalues(BugTaskStatus.INCOMPLETE, min_days_old)
+            )""" % sqlvalues(BugTaskStatus.INCOMPLETE,
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE, min_days_old)
         expirable_bugtasks = BugTask.select(
             query + unconfirmed_bug_condition,
             clauseTables=['Bug'],
@@ -2761,6 +2797,7 @@
         """
         statuses_not_preventing_expiration = [
             BugTaskStatus.INVALID, BugTaskStatus.INCOMPLETE,
+            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
             BugTaskStatus.WONTFIX]
 
         unexpirable_status_list = [
@@ -2906,7 +2943,7 @@
             ]
 
         product_ids = [product.id for product in products]
-        conditions = And(BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
+        conditions = And(BugTask._status.is_in(UNRESOLVED_BUGTASK_STATUSES),
                          Bug.duplicateof == None,
                          BugTask.productID.is_in(product_ids))
 
@@ -2934,7 +2971,7 @@
                 # TODO: sort by their name?
                 "assignee": BugTask.assigneeID,
                 "targetname": BugTask.targetnamecache,
-                "status": BugTask.status,
+                "status": BugTask._status,
                 "title": Bug.title,
                 "milestone": BugTask.milestoneID,
                 "dateassigned": BugTask.date_assigned,

=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
--- lib/lp/registry/model/distributionsourcepackage.py	2011-04-12 06:21:39 +0000
+++ lib/lp/registry/model/distributionsourcepackage.py	2011-04-19 10:21:21 +0000
@@ -219,7 +219,7 @@
             BugTask.distributionID == self.distribution.id,
             BugTask.sourcepackagenameID == self.sourcepackagename.id,
             Bug.duplicateof == None,
-            BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES)).one()
+            BugTask._status.is_in(UNRESOLVED_BUGTASK_STATUSES)).one()
 
         # Aggregate functions return NULL if zero rows match.
         row = list(row)

=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py	2011-04-18 15:40:02 +0000
+++ lib/lp/scripts/garbo.py	2011-04-19 10:21:21 +0000
@@ -54,9 +54,14 @@
     MASTER_FLAVOR,
     )
 from lp.bugs.interfaces.bug import IBugSet
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    BugTaskStatusSearch,
+    )
 from lp.bugs.model.bug import Bug
 from lp.bugs.model.bugattachment import BugAttachment
 from lp.bugs.model.bugnotification import BugNotification
+from lp.bugs.model.bugtask import BugTask
 from lp.bugs.model.bugwatch import BugWatchActivity
 from lp.bugs.scripts.checkwatches.scheduler import (
     BugWatchScheduler,
@@ -730,6 +735,41 @@
         transaction.commit()
 
 
+class BugTaskIncompleteMigrator(TunableLoop):
+    """Migrate BugTaskStatus 'INCOMPLETE' to a concrete WITH/WITHOUT value."""
+
+    maximum_chunk_size = 20000
+    minimum_chunk_size = 100
+
+    def __init__(self, log, abort_time=None, max_heat_age=None):
+        super(BugTaskIncompleteMigrator, self).__init__(log, abort_time)
+        self.transaction = transaction
+        self.total_processed = 0
+        self.is_done = False
+        self.offset = 0
+        self.store = IMasterStore(BugTask)
+        self.query = self.store.find((BugTask, Bug),
+            BugTask._status==BugTaskStatus.INCOMPLETE,
+            BugTask.bugID==Bug.id)
+
+    def isDone(self):
+        """See `ITunableLoop`."""
+        return self.query.is_empty()
+
+    def __call__(self, chunk_size):
+        """See `ITunableLoop`."""
+        transaction.begin()
+        tasks = list(self.query[:chunk_size])
+        for (task, bug) in tasks:
+            if (bug.date_last_message is None or
+                task.date_incomplete > bug.date_last_message):
+                task._status = BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
+            else:
+                task._status = BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
+        self.log.debug("Updated status on %d tasks" % len(tasks))
+        transaction.commit()
+
+
 class BugWatchActivityPruner(BulkPruner):
     """A TunableLoop to prune BugWatchActivity entries."""
     target_table_class = BugWatchActivity
@@ -1050,6 +1090,7 @@
         UnusedSessionPruner,
         DuplicateSessionPruner,
         BugHeatUpdater,
+        BugTaskIncompleteMigrator,
         ]
     experimental_tunable_loops = []
 

=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py	2011-04-15 04:56:44 +0000
+++ lib/lp/scripts/tests/test_garbo.py	2011-04-19 10:21:21 +0000
@@ -52,10 +52,15 @@
     LaunchpadZopelessLayer,
     ZopelessDatabaseLayer,
     )
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    BugTaskStatusSearch,
+    )
 from lp.bugs.model.bugnotification import (
     BugNotification,
     BugNotificationRecipient,
     )
+from lp.bugs.model.bugtask import BugTask
 from lp.code.bzr import (
     BranchFormat,
     RepositoryFormat,
@@ -768,6 +773,38 @@
                 BugNotification.date_emailed < THIRTY_DAYS_AGO).count(),
             0)
 
+    def test_BugTaskIncompleteMigrator(self):
+        # BugTasks with status INCOMPLETE should be either
+        # INCOMPLETE_WITHOUT_RESPONSE or INCOMPLETE_WITH_RESPONSE.
+        # Create a bug with two tasks set to INCOMPLETE and a comment between
+        # them.
+        LaunchpadZopelessLayer.switchDbUser('testadmin')
+        store = IMasterStore(BugTask)
+        bug = self.factory.makeBug()
+        with_response = bug.bugtasks[0]
+        with_response.transitionToStatus(BugTaskStatus.INCOMPLETE, bug.owner)
+        removeSecurityProxy(with_response)._status = BugTaskStatus.INCOMPLETE
+        store.flush()
+        transaction.commit()
+        self.factory.makeBugComment(bug=bug)
+        transaction.commit()
+        without_response = self.factory.makeBugTask(bug=bug)
+        without_response.transitionToStatus(BugTaskStatus.INCOMPLETE, bug.owner)
+        removeSecurityProxy(without_response
+            )._status = BugTaskStatus.INCOMPLETE
+        transaction.commit()
+        self.runHourly()
+        self.assertEqual(1,
+            store.find(BugTask.id,
+                BugTask.id==with_response.id,
+                BugTask._status==BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE
+                ).count())
+        self.assertEqual(1,
+            store.find(BugTask.id,
+                BugTask.id==without_response.id,
+                BugTask._status==
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE).count())
+
     def test_BranchJobPruner(self):
         # Garbo should remove jobs completed over 30 days ago.
         LaunchpadZopelessLayer.switchDbUser('testadmin')


Follow ups