← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Brad Crittenden has proposed merging lp:~bac/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/~bac/launchpad/bug-759467/+merge/77999

= Summary =

Bugs marked as INCOMPLETE can be broken into two camps and we provide
searching for each:  those with responses and those without
responses.  The original model stored INCOMPLETE requiring complex and
expensive queries to determine whether a response had been given.

Many apologies for the size of the branch, which is significantly over
the 800 line threshold.

== Proposed fix ==

This branch changes status to store INCOMPLETE_WITH_RESPONSE or
INCOMPLETE_WITHOUT_RESPONSE directly into the database as '_status'.  A
property now named 'status' maps the two into INCOMPLETE.

An hourly garbo task is included to perform migration.

== Pre-implementation notes ==

This branch was originally done by Robert but never landed due to test
failures.  I've cleaned up the branch, ferreted out some ugly bugs,
and made it landable.  Robert's original branch was reviewed and
approved here:

https://code.launchpad.net/~lifeless/launchpad/bug-759467/+merge/58262

I could've, perhaps should've, made that branch a pre-requisite branch
but the large number of conflicts with devel (caused by the subsequent
introduction of the BugSummary work) makes that impractical.

== Implementation details ==

As above.

== Tests ==

The impact on the bugs world is so great, all bug tests should be run:

bin/test -vvm lp.bugs 

== Demo and Q/A ==

Create bugs with and without responses and see that they are found
upon searching.

= Launchpad lint =

Lot o' lint.  I cleaned up some but have deferred on the rest to avoid
further inflating the size of this branch.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/registry/model/distributionsourcepackage.py
  lib/canonical/launchpad/doc/enumcol.txt
  lib/lp/bugs/doc/bugtask.txt
  lib/canonical/config/schema-lazr.conf
  lib/lp/bugs/model/tests/test_bugsummary.py
  lib/lp/bugs/model/tests/test_bugtask.py
  lib/lp/bugs/configure.zcml
  lib/lp/bugs/browser/tests/bugs-views.txt
  database/schema/security.cfg
  lib/lp/scripts/garbo.py
  lib/lp/bugs/model/bugtarget.py
  lib/lp/scripts/tests/test_garbo.py
  lib/lp/bugs/model/bug.py
  lib/lp/bugs/interfaces/bugsummary.py
  lib/lp/bugs/browser/tests/test_bugtask.py
  lib/lp/bugs/browser/bugtask.py
  lib/lp/bugs/model/bugsummary.py
  lib/canonical/database/enumcol.py
  lib/lp/bugs/model/tests/test_bugtask_status.py
  lib/lp/bugs/model/bugtask.py
  lib/lp/registry/model/distribution.py
  lib/lp/bugs/interfaces/bugtask.py
  lib/lp/bugs/tests/test_bugtask_search.py
  lib/lp/bugs/scripts/tests/test_bugimport.py

./lib/canonical/launchpad/doc/enumcol.txt
       1: narrative uses a moin header.
     103: want exceeds 78 characters.
     114: want exceeds 78 characters.
     127: narrative has trailing whitespace.
./lib/lp/bugs/doc/bugtask.txt
       1: narrative uses a moin header.
       9: narrative uses a moin header.
      12: narrative uses a moin header.
      91: narrative uses a moin header.
      93: source exceeds 78 characters.
     160: narrative exceeds 78 characters.
     181: narrative uses a moin header.
     208: narrative uses a moin header.
     214: narrative uses a moin header.
     233: narrative uses a moin header.
     263: narrative uses a moin header.
     562: want exceeds 78 characters.
     593: want exceeds 78 characters.
     684: narrative uses a moin header.
     686: narrative exceeds 78 characters.
     687: narrative exceeds 78 characters.
     694: narrative uses a moin header.
     728: narrative exceeds 78 characters.
     771: narrative uses a moin header.
     809: narrative uses a moin header.
     825: narrative uses a moin header.
     838: narrative exceeds 78 characters.
     862: narrative uses a moin header.
     907: narrative uses a moin header.
     931: narrative uses a moin header.
     935: source has bad indentation.
     937: source has bad indentation.
     943: narrative uses a moin header.
     948: narrative exceeds 78 characters.
     987: want exceeds 78 characters.
    1079: narrative uses a moin header.
    1097: narrative uses a moin header.
    1156: narrative uses a moin header.
./lib/canonical/config/schema-lazr.conf
     592: Line exceeds 78 characters.
./lib/lp/bugs/browser/tests/bugs-views.txt
       1: narrative uses a moin header.
      14: narrative uses a moin header.
./lib/lp/bugs/scripts/tests/test_bugimport.py
      93: E303 too many blank lines (2)
     485: E302 expected 2 blank lines, found 1
     735: E301 expected 1 blank line, found 0
     756: E301 expected 1 blank line, found 0
-- 
https://code.launchpad.net/~bac/launchpad/bug-759467/+merge/77999
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~bac/launchpad/bug-759467 into lp:launchpad.
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg	2011-10-03 15:22:48 +0000
+++ database/schema/security.cfg	2011-10-03 20:20:29 +0000
@@ -2122,6 +2122,7 @@
 public.bugsubscriptionfiltertag         = SELECT
 public.bugsummary_rollup_journal(integer) = EXECUTE
 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-10-03 14:39:59 +0000
+++ lib/canonical/config/schema-lazr.conf	2011-10-03 20:20:29 +0000
@@ -1484,7 +1484,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-10-03 20:20:29 +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,16 +75,11 @@
             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
+            'enum': enum,
             }
         super(DBSchemaEnumCol, self).__init__(**kw)
 
 
 EnumCol = DBSchemaEnumCol
-
-
-

=== 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-10-03 20:20:29 +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-10-03 07:49:31 +0000
+++ lib/lp/bugs/browser/bugtask.py	2011-10-03 20:20:29 +0000
@@ -104,7 +104,6 @@
 from zope.schema import Choice
 from zope.schema.interfaces import (
     IContextSourceBinder,
-    IList,
     )
 from zope.schema.vocabulary import (
     getVocabularyRegistry,
@@ -223,6 +222,7 @@
     BugTaskImportance,
     BugTaskSearchParams,
     BugTaskStatus,
+    BugTaskStatusSearch,
     BugTaskStatusSearchDisplay,
     DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY,
     IBugTask,
@@ -281,6 +281,8 @@
     BugTaskStatus.FIXRELEASED: False,
     BugTaskStatus.UNKNOWN: False,
     BugTaskStatus.EXPIRED: False,
+    BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE: True,
+    BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE: True,
     }
 
 
@@ -2080,57 +2082,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/browser/tests/bugs-views.txt'
--- lib/lp/bugs/browser/tests/bugs-views.txt	2011-06-28 15:04:29 +0000
+++ lib/lp/bugs/browser/tests/bugs-views.txt	2011-10-03 20:20:29 +0000
@@ -21,7 +21,7 @@
     >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
     >>> from lp.bugs.model.bugtask import BugTask
     >>> [bugtask.bug.id
-    ...  for bugtask in BugTask.selectBy(status=BugTaskStatus.FIXRELEASED)]
+    ...  for bugtask in BugTask.selectBy(_status=BugTaskStatus.FIXRELEASED)]
     [8]
     >>> for bug in bugs_view.getMostRecentlyFixedBugs():
     ...     print "%s: %s" % (bug.id, bug.title)

=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py	2011-09-26 07:33:00 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py	2011-10-03 20:20:29 +0000
@@ -542,7 +542,6 @@
         foo_bug = self.factory.makeBug(product=product_foo)
         bugtask_set = getUtility(IBugTaskSet)
         bugtask_set.createTask(foo_bug, foo_bug.owner, product_bar)
-
         removeSecurityProxy(product_bar).active = False
 
         request = LaunchpadTestRequest()
@@ -681,7 +680,7 @@
     def test_status_field_bug_task_in_status_expired(self):
         # If a bugtask has the status Expired, this status is included
         # in the options.
-        removeSecurityProxy(self.bug.default_bugtask).status = (
+        removeSecurityProxy(self.bug.default_bugtask)._status = (
             BugTaskStatus.EXPIRED)
         login(NO_PRIVILEGE_EMAIL)
         view = BugTaskEditView(

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2011-09-23 22:38:32 +0000
+++ lib/lp/bugs/configure.zcml	2011-10-03 20:20:29 +0000
@@ -213,6 +213,7 @@
                     distribution
                     distroseries
                     milestone
+                    _status
                     status
                     importance
                     assignee

=== modified file 'lib/lp/bugs/doc/bugtask.txt'
--- lib/lp/bugs/doc/bugtask.txt	2011-08-01 05:25:59 +0000
+++ lib/lp/bugs/doc/bugtask.txt	2011-10-03 20:20:29 +0000
@@ -91,7 +91,6 @@
 == Bug Task Targets ==
 
     >>> from lp.registry.interfaces.distributionsourcepackage import IDistributionSourcePackage
-    >>> from lp.registry.model.distributionsourcepackage import DistributionSourcePackage
 
 The "target" of an IBugTask can be one of the items in the following
 list.
@@ -125,11 +124,8 @@
   * a distribution sourcepackage
 
     >>> def get_expected_target(distro_sp_task):
-    ...      expected_target = DistributionSourcePackage(
-    ...         distro_sp_task.distribution,
-    ...         distro_sp_task.sourcepackagename)
     ...      return distro_sp_task.target
-    ...
+
     >>> debian_ff_task = bugtaskset.get(4)
     >>> IDistributionSourcePackage.providedBy(debian_ff_task.target)
     True
@@ -270,7 +266,7 @@
 in sync with the "generic" bugtask for that distro, because they
 represent the same piece of work. The same is true for product and
 productseries tasks, when the productseries task is targeted to the
-IProduct.developmentfocus. The following attributes are synched:
+IProduct.developmentfocus. The following attributes are synced:
 
     * status
     * assignee

=== modified file 'lib/lp/bugs/interfaces/bugsummary.py'
--- lib/lp/bugs/interfaces/bugsummary.py	2011-06-20 23:36:18 +0000
+++ lib/lp/bugs/interfaces/bugsummary.py	2011-10-03 20:20:29 +0000
@@ -22,7 +22,7 @@
 from canonical.launchpad import _
 from lp.bugs.interfaces.bugtask import (
     BugTaskImportance,
-    BugTaskStatus,
+    BugTaskStatusSearch,
     )
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distroseries import IDistroSeries
@@ -62,7 +62,7 @@
     milestone = Object(IMilestone, readonly=True)
 
     status = Choice(
-        title=_('Status'), vocabulary=BugTaskStatus, readonly=True)
+        title=_('Status'), vocabulary=BugTaskStatusSearch, readonly=True)
     importance = Choice(
         title=_('Importance'), vocabulary=BugTaskImportance, readonly=True)
 
@@ -81,8 +81,8 @@
     def getBugSummaryContextWhereClause():
         """Return a storm clause to filter bugsummaries on this context.
 
-        This method is intentended for in-appserver use only.
-        
+        This method is intended for in-appserver use only.
+
         :return: Either a storm clause to filter bugsummaries, or False if
             there cannot be any matching bug summaries.
         """

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2011-09-08 22:50:59 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2011-10-03 20:20:29 +0000
@@ -17,6 +17,7 @@
     'BugTaskStatus',
     'BugTaskStatusSearch',
     'BugTaskStatusSearchDisplay',
+    'DB_UNRESOLVED_BUGTASK_STATUSES',
     'DEFAULT_SEARCH_BUGTASK_STATUSES_FOR_DISPLAY',
     'IAddBugTaskForm',
     'IAddBugTaskWithProductCreationForm',
@@ -196,6 +197,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
 
@@ -269,10 +275,6 @@
         affected software.
         """)
 
-    # DBItem values 35 and 40 are used by
-    # BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE and
-    # BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE
-
     UNKNOWN = DBItem(999, """
         Unknown
 
@@ -287,19 +289,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
@@ -371,6 +368,12 @@
     BugTaskStatus.INPROGRESS,
     BugTaskStatus.FIXCOMMITTED)
 
+# Actual values stored in the DB:
+DB_UNRESOLVED_BUGTASK_STATUSES = UNRESOLVED_BUGTASK_STATUSES + (
+    BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+    BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+    )
+
 RESOLVED_BUGTASK_STATUSES = (
     BugTaskStatus.FIXRELEASED,
     BugTaskStatus.OPINION,
@@ -481,9 +484,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-09-29 15:38:53 +0000
+++ lib/lp/bugs/model/bug.py	2011-10-03 20:20:29 +0000
@@ -150,6 +150,7 @@
 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
 from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
+    BugTaskStatusSearch,
     IBugTask,
     IBugTaskSet,
     UNRESOLVED_BUGTASK_STATUSES,
@@ -1181,6 +1182,15 @@
             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 in (
+                    BugTaskStatus.INCOMPLETE,
+                    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/bugsummary.py'
--- lib/lp/bugs/model/bugsummary.py	2011-06-21 00:04:12 +0000
+++ lib/lp/bugs/model/bugsummary.py	2011-10-03 20:20:29 +0000
@@ -28,6 +28,7 @@
 from lp.bugs.interfaces.bugtask import (
     BugTaskImportance,
     BugTaskStatus,
+    BugTaskStatusSearch,
     )
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
@@ -66,7 +67,9 @@
     milestone_id = Int(name='milestone')
     milestone = Reference(milestone_id, Milestone.id)
 
-    status = EnumCol(dbName='status', schema=BugTaskStatus)
+    status = EnumCol(
+        dbName='status', schema=(BugTaskStatus, BugTaskStatusSearch))
+
     importance = EnumCol(dbName='importance', schema=BugTaskImportance)
 
     tag = Unicode()
@@ -90,7 +93,8 @@
 
     def __init__(self, *dimensions):
         self.dimensions = map(
-            lambda x:removeSecurityProxy(x.getBugSummaryContextWhereClause()),
+            lambda x:
+            removeSecurityProxy(x.getBugSummaryContextWhereClause()),
             dimensions)
 
     def getBugSummaryContextWhereClause(self):

=== modified file 'lib/lp/bugs/model/bugtarget.py'
--- lib/lp/bugs/model/bugtarget.py	2011-06-20 23:36:18 +0000
+++ lib/lp/bugs/model/bugtarget.py	2011-10-03 20:20:29 +0000
@@ -54,7 +54,6 @@
 from lp.bugs.interfaces.bugtaskfilter import simple_weight_calculator
 from lp.bugs.model.bugtask import (
     BugTaskSet,
-    get_bug_privacy_filter,
     )
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.distributionsourcepackage import (
@@ -123,7 +122,7 @@
 
     def getBugSummaryContextWhereClause(self):
         """Return a storm clause to filter bugsummaries on this context.
-        
+
         :return: Either a storm clause to filter bugsummaries, or False if
             there cannot be any matching bug summaries.
         """
@@ -223,7 +222,8 @@
     # IDistribution, IDistroSeries, IProjectGroup.
     enable_bugfiling_duplicate_search = True
 
-    def getUsedBugTagsWithOpenCounts(self, user, tag_limit=0, include_tags=None):
+    def getUsedBugTagsWithOpenCounts(self, user, tag_limit=0,
+                                     include_tags=None):
         """See IBugTarget."""
         from lp.bugs.model.bug import get_bug_tags_open_count
         return get_bug_tags_open_count(
@@ -367,7 +367,7 @@
         store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
         target_clause = self._getOfficialTagClause()
         return store.find(
-            OfficialBugTag, OfficialBugTag.tag==tag, target_clause).one()
+            OfficialBugTag, OfficialBugTag.tag == tag, target_clause).one()
 
     def addOfficialBugTag(self, tag):
         """See `IOfficialBugTagTarget`."""

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-09-28 03:37:44 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-10-03 20:20:29 +0000
@@ -114,13 +114,13 @@
     BugTaskSearchParams,
     BugTaskStatus,
     BugTaskStatusSearch,
+    DB_UNRESOLVED_BUGTASK_STATUSES,
     IBugTask,
     IBugTaskDelta,
     IBugTaskSet,
     IllegalRelatedBugTasksParams,
     IllegalTarget,
     RESOLVED_BUGTASK_STATUSES,
-    UNRESOLVED_BUGTASK_STATUSES,
     UserCannotEditBugTaskAssignee,
     UserCannotEditBugTaskImportance,
     UserCannotEditBugTaskMilestone,
@@ -343,9 +343,10 @@
     if isinstance(value, PassthroughValue):
         return value.value
 
-    # If this bugtask has no bug yet, then we are probably being
-    # instantiated.
-    if self.bug is None:
+    # Check to see if the object is being instantiated.  This test is specific
+    # to SQLBase.  Checking for specific attributes (like self.bug) is
+    # insufficient and fragile.
+    if self._SO_creating:
         return value
 
     # If this is a conjoined slave then call setattr on the master.
@@ -446,7 +447,7 @@
     _defaultOrder = ['distribution', 'product', 'productseries',
                      'distroseries', 'milestone', 'sourcepackagename']
     _CONJOINED_ATTRIBUTES = (
-        "status", "importance", "assigneeID", "milestoneID",
+        "_status", "importance", "assigneeID", "milestoneID",
         "date_assigned", "date_confirmed", "date_inprogress",
         "date_closed", "date_incomplete", "date_left_new",
         "date_triaged", "date_fix_committed", "date_fix_released",
@@ -475,9 +476,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)
     importance = EnumCol(
@@ -528,6 +529,14 @@
         dbName='targetnamecache', notNull=False, default=None)
 
     @property
+    def status(self):
+        if (self._status in [
+            BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
+            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE]):
+            return BugTaskStatus.INCOMPLETE
+        return self._status
+
+    @property
     def title(self):
         """See `IBugTask`."""
         return 'Bug #%s in %s: "%s"' % (
@@ -584,8 +593,7 @@
     @property
     def age(self):
         """See `IBugTask`."""
-        UTC = pytz.timezone('UTC')
-        now = datetime.datetime.now(UTC)
+        now = datetime.datetime.now(pytz.UTC)
 
         return now - self.datecreated
 
@@ -609,7 +617,7 @@
         Note that this should be kept in sync with the completeness_clause
         above.
         """
-        return self.status in RESOLVED_BUGTASK_STATUSES
+        return self._status in RESOLVED_BUGTASK_STATUSES
 
     def findSimilarBugs(self, user, limit=10):
         """See `IBugTask`."""
@@ -878,12 +886,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,
@@ -901,8 +918,7 @@
             return
 
         if when is None:
-            UTC = pytz.timezone('UTC')
-            when = datetime.datetime.now(UTC)
+            when = datetime.datetime.now(pytz.UTC)
 
         # Record the date of the particular kinds of transitions into
         # certain states.
@@ -957,17 +973,18 @@
         # 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
 
-        if ((old_status in UNRESOLVED_BUGTASK_STATUSES) and
+        if ((old_status in DB_UNRESOLVED_BUGTASK_STATUSES) and
             (new_status in RESOLVED_BUGTASK_STATUSES)):
             self.date_closed = when
 
         if ((old_status in RESOLVED_BUGTASK_STATUSES) and
-            (new_status in UNRESOLVED_BUGTASK_STATUSES)):
+            (new_status in DB_UNRESOLVED_BUGTASK_STATUSES)):
             self.date_left_closed = when
 
         # Ensure that we don't have dates recorded for state
@@ -975,7 +992,7 @@
         # workflow state. We want to ensure that, for example, a
         # bugtask that went New => Confirmed => New
         # has a dateconfirmed value of None.
-        if new_status in UNRESOLVED_BUGTASK_STATUSES:
+        if new_status in DB_UNRESOLVED_BUGTASK_STATUSES:
             self.date_closed = None
 
         if new_status < BugTaskStatus.CONFIRMED:
@@ -1591,7 +1608,7 @@
         """See `IBugTaskSet`."""
         return BugTaskSearchParams(
             user=getUtility(ILaunchBag).user,
-            status=any(*UNRESOLVED_BUGTASK_STATUSES),
+            status=any(*DB_UNRESOLVED_BUGTASK_STATUSES),
             omit_dupes=True)
 
     def get(self, task_id):
@@ -1718,29 +1735,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:
@@ -1777,7 +1810,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:
@@ -1797,7 +1830,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.
@@ -1810,7 +1843,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:
@@ -2302,6 +2335,8 @@
             statuses_for_open_tasks = [
                 BugTaskStatus.NEW,
                 BugTaskStatus.INCOMPLETE,
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
+                BugTaskStatusSearch.INCOMPLETE_WITH_RESPONSE,
                 BugTaskStatus.CONFIRMED,
                 BugTaskStatus.INPROGRESS,
                 BugTaskStatus.UNKNOWN]
@@ -2636,7 +2671,7 @@
         conditions = []
         # Open bug statuses
         conditions.append(
-            BugSummary.status.is_in(UNRESOLVED_BUGTASK_STATUSES))
+            BugSummary.status.is_in(DB_UNRESOLVED_BUGTASK_STATUSES))
         # BugSummary does not include duplicates so no need to exclude.
         context_conditions = []
         for context in contexts:
@@ -2719,7 +2754,6 @@
         validate_new_target(bug, target)
 
         target_key = bug_target_to_key(target)
-
         if not bug.private and bug.security_related:
             product = target_key['product']
             distribution = target_key['distribution']
@@ -2730,7 +2764,7 @@
 
         non_target_create_params = dict(
             bug=bug,
-            status=status,
+            _status=status,
             importance=importance,
             assignee=assignee,
             owner=owner,
@@ -2738,7 +2772,6 @@
         create_params = non_target_create_params.copy()
         create_params.update(target_key)
         bugtask = BugTask(**create_params)
-
         if target_key['distribution']:
             # Create tasks for accepted nominations if this is a source
             # package addition.
@@ -2862,14 +2895,16 @@
                 """ + target_clause + """
                 """ + bug_clause + """
                 """ + bug_privacy_filter + """
-                    AND BugTask.status = %s
+                    AND BugTask.status in (%s, %s, %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_WITH_RESPONSE,
+                BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE, min_days_old)
         expirable_bugtasks = BugTask.select(
             query + unconfirmed_bug_condition,
             clauseTables=['Bug'],
@@ -2887,6 +2922,7 @@
         """
         statuses_not_preventing_expiration = [
             BugTaskStatus.INVALID, BugTaskStatus.INCOMPLETE,
+            BugTaskStatusSearch.INCOMPLETE_WITHOUT_RESPONSE,
             BugTaskStatus.WONTFIX]
 
         unexpirable_status_list = [
@@ -3032,9 +3068,10 @@
             ]
 
         product_ids = [product.id for product in products]
-        conditions = And(BugTask.status.is_in(UNRESOLVED_BUGTASK_STATUSES),
-                         Bug.duplicateof == None,
-                         BugTask.productID.is_in(product_ids))
+        conditions = And(
+            BugTask._status.is_in(DB_UNRESOLVED_BUGTASK_STATUSES),
+            Bug.duplicateof == None,
+            BugTask.productID.is_in(product_ids))
 
         privacy_filter = get_bug_privacy_filter(user)
         if privacy_filter != '':
@@ -3060,7 +3097,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,
@@ -3167,7 +3204,7 @@
 
         open_bugs_cond = (
             'BugTask.status %s' % search_value_to_where_condition(
-                any(*UNRESOLVED_BUGTASK_STATUSES)))
+                any(*DB_UNRESOLVED_BUGTASK_STATUSES)))
 
         sum_template = "SUM(CASE WHEN %s THEN 1 ELSE 0 END) AS %s"
         sums = [

=== modified file 'lib/lp/bugs/model/tests/test_bugsummary.py'
--- lib/lp/bugs/model/tests/test_bugsummary.py	2011-09-22 01:45:12 +0000
+++ lib/lp/bugs/model/tests/test_bugsummary.py	2011-10-03 20:20:29 +0000
@@ -189,7 +189,7 @@
         for count in range(3):
             bug = self.factory.makeBug(product=product)
             bug_task = self.store.find(BugTask, bug=bug).one()
-            bug_task.status = org_status
+            bug_task._status = org_status
 
             self.assertEqual(
                 self.getPublicCount(
@@ -199,8 +199,8 @@
 
         for count in reversed(range(3)):
             bug_task = self.store.find(
-                BugTask, product=product, status=org_status).any()
-            bug_task.status = new_status
+                BugTask, product=product, _status=org_status).any()
+            bug_task._status = new_status
             self.assertEqual(
                 self.getPublicCount(
                     BugSummary.product == product,

=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
--- lib/lp/bugs/model/tests/test_bugtask.py	2011-09-28 03:07:12 +0000
+++ lib/lp/bugs/model/tests/test_bugtask.py	2011-10-03 20:20:29 +0000
@@ -32,6 +32,7 @@
     BugTaskImportance,
     BugTaskSearchParams,
     BugTaskStatus,
+    DB_UNRESOLVED_BUGTASK_STATUSES,
     IBugTaskSet,
     RESOLVED_BUGTASK_STATUSES,
     UNRESOLVED_BUGTASK_STATUSES,
@@ -1349,6 +1350,8 @@
         """
         self.assertNotIn(BugTaskStatus.UNKNOWN, RESOLVED_BUGTASK_STATUSES)
         self.assertNotIn(BugTaskStatus.UNKNOWN, UNRESOLVED_BUGTASK_STATUSES)
+        self.assertNotIn(
+            BugTaskStatus.UNKNOWN, DB_UNRESOLVED_BUGTASK_STATUSES)
 
 
 class TestBugTaskContributor(TestCaseWithFactory):
@@ -1392,26 +1395,35 @@
         self.owner = self.factory.makePerson()
         self.distro = self.factory.makeDistribution(
             name="eggs", owner=self.owner, bug_supervisor=self.owner)
-        distro_release = self.factory.makeDistroSeries(
+        self.distro_release = self.factory.makeDistroSeries(
             distribution=self.distro, registrant=self.owner)
-        source_package = self.factory.makeSourcePackage(
-            sourcepackagename="spam", distroseries=distro_release)
-        bug = self.factory.makeBug(
+        self.source_package = self.factory.makeSourcePackage(
+            sourcepackagename="spam", distroseries=self.distro_release)
+        self.bug = self.factory.makeBug(
             distribution=self.distro,
-            sourcepackagename=source_package.sourcepackagename,
+            sourcepackagename=self.source_package.sourcepackagename,
             owner=self.owner)
         with person_logged_in(self.owner):
-            nomination = bug.addNomination(self.owner, distro_release)
+            nomination = self.bug.addNomination(
+                self.owner, self.distro_release)
             nomination.approve(self.owner)
-            self.generic_task, self.series_task = bug.bugtasks
+            self.generic_task, self.series_task = self.bug.bugtasks
 
     def test_editing_generic_status_reflects_upon_conjoined_master(self):
         # If a change is made to the status of a conjoined slave
         # (generic) task, that change is reflected upon the conjoined
         # master.
         with person_logged_in(self.owner):
+            # Both the generic task and the series task start off with the
+            # status of NEW.
+            self.assertEqual(
+                BugTaskStatus.NEW, self.generic_task.status)
+            self.assertEqual(
+                BugTaskStatus.NEW, self.series_task.status)
+            # Transitioning the generic task to CONFIRMED.
             self.generic_task.transitionToStatus(
                 BugTaskStatus.CONFIRMED, self.owner)
+            # Also transitions the series_task.
             self.assertEqual(
                 BugTaskStatus.CONFIRMED, self.series_task.status)
 
@@ -1448,11 +1460,30 @@
             self.assertEqual(
                 source_package_name, self.series_task.sourcepackagename)
 
+    def test_creating_conjoined_task_gets_synced_attributes(self):
+        bug = self.factory.makeBug(
+            distribution=self.distro,
+            sourcepackagename=self.source_package.sourcepackagename,
+            owner=self.owner)
+        generic_task = bug.bugtasks[0]
+        bugtaskset = getUtility(IBugTaskSet)
+        with person_logged_in(self.owner):
+            generic_task.transitionToStatus(
+                BugTaskStatus.CONFIRMED, self.owner)
+            self.assertEqual(
+                BugTaskStatus.CONFIRMED, generic_task.status)
+            slave_bugtask = bugtaskset.createTask(
+                bug, self.owner, generic_task.target.development_version)
+            self.assertEqual(
+                BugTaskStatus.CONFIRMED, generic_task.status)
+            self.assertEqual(
+                BugTaskStatus.CONFIRMED, slave_bugtask.status)
+
+
 # START TEMPORARY BIT FOR BUGTASK AUTOCONFIRM FEATURE FLAG.
 # When feature flag code is removed, delete these tests (up to "# END
 # TEMPORARY BIT FOR BUGTASK AUTOCONFIRM FEATURE FLAG.")
 
-
 class TestAutoConfirmBugTasksFlagForProduct(TestCaseWithFactory):
     """Tests for auto-confirming bug tasks."""
     # Tests for _checkAutoconfirmFeatureFlag.

=== modified file 'lib/lp/bugs/model/tests/test_bugtask_status.py'
--- lib/lp/bugs/model/tests/test_bugtask_status.py	2011-05-27 21:12:25 +0000
+++ lib/lp/bugs/model/tests/test_bugtask_status.py	2011-10-03 20:20:29 +0000
@@ -67,7 +67,7 @@
     def test_user_cannot_unset_wont_fix_status(self):
         # A regular user should not be able to transition a bug away
         # from Won't Fix.
-        removeSecurityProxy(self.task).status = BugTaskStatus.WONTFIX
+        removeSecurityProxy(self.task)._status = BugTaskStatus.WONTFIX
         with person_logged_in(self.user):
             self.assertRaises(
                 UserCannotEditBugTaskStatus, self.task.transitionToStatus,
@@ -76,7 +76,7 @@
     def test_user_cannot_unset_fix_released_status(self):
         # A regular user should not be able to transition a bug away
         # from Fix Released.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         with person_logged_in(self.user):
             self.assertRaises(
                 UserCannotEditBugTaskStatus, self.task.transitionToStatus,
@@ -132,7 +132,7 @@
     def test_user_canTransitionToStatus_from_wontfix(self):
         # A regular user cannot transition away from Won't Fix,
         # so canTransitionToStatus should return False.
-        removeSecurityProxy(self.task).status = BugTaskStatus.WONTFIX
+        removeSecurityProxy(self.task)._status = BugTaskStatus.WONTFIX
         self.assertEqual(
             self.task.canTransitionToStatus(
                 BugTaskStatus.NEW, self.user),
@@ -141,7 +141,7 @@
     def test_user_canTransitionToStatus_from_fixreleased(self):
         # A regular user cannot transition away from Fix Released,
         # so canTransitionToStatus should return False.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         self.assertEqual(
             self.task.canTransitionToStatus(
                 BugTaskStatus.NEW, self.user),
@@ -160,7 +160,7 @@
 
     def test_reporter_can_unset_fix_released_status(self):
         # The bug reporter can transition away from Fix Released.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         with person_logged_in(self.reporter):
             self.task.transitionToStatus(
                 BugTaskStatus.CONFIRMED, self.reporter)
@@ -169,7 +169,7 @@
     def test_reporter_canTransitionToStatus(self):
         # The bug reporter can transition away from Fix Released, so
         # canTransitionToStatus should always return True.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         self.assertEqual(
             self.task.canTransitionToStatus(
                 BugTaskStatus.CONFIRMED, self.reporter),
@@ -181,7 +181,7 @@
         team = self.factory.makeTeam(members=[self.reporter])
         team_bug = self.factory.makeBug(owner=team)
         naked_task = removeSecurityProxy(team_bug.default_bugtask)
-        naked_task.status = BugTaskStatus.FIXRELEASED
+        naked_task._status = BugTaskStatus.FIXRELEASED
         with person_logged_in(self.reporter):
             team_bug.default_bugtask.transitionToStatus(
                 BugTaskStatus.CONFIRMED, self.reporter)
@@ -242,14 +242,14 @@
 
     def test_privileged_user_can_unset_wont_fix_status(self):
         # Privileged users can transition away from Won't Fix.
-        removeSecurityProxy(self.task).status = BugTaskStatus.WONTFIX
+        removeSecurityProxy(self.task)._status = BugTaskStatus.WONTFIX
         with person_logged_in(self.person):
             self.task.transitionToStatus(BugTaskStatus.CONFIRMED, self.person)
             self.assertEqual(self.task.status, BugTaskStatus.CONFIRMED)
 
     def test_privileged_user_can_unset_fix_released_status(self):
         # Privileged users can transition away from Fix Released.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         with person_logged_in(self.person):
             self.task.transitionToStatus(BugTaskStatus.CONFIRMED, self.person)
             self.assertEqual(self.task.status, BugTaskStatus.CONFIRMED)
@@ -306,7 +306,7 @@
     def test_privileged_user_canTransitionToStatus_from_wontfix(self):
         # A privileged user can transition away from Won't Fix, so
         # canTransitionToStatus should return True.
-        removeSecurityProxy(self.task).status = BugTaskStatus.WONTFIX
+        removeSecurityProxy(self.task)._status = BugTaskStatus.WONTFIX
         self.assertEqual(
             self.task.canTransitionToStatus(
                 BugTaskStatus.NEW, self.person),
@@ -315,7 +315,7 @@
     def test_privileged_user_canTransitionToStatus_from_fixreleased(self):
         # A privileged user can transition away from Fix Released, so
         # canTransitionToStatus should return True.
-        removeSecurityProxy(self.task).status = BugTaskStatus.FIXRELEASED
+        removeSecurityProxy(self.task)._status = BugTaskStatus.FIXRELEASED
         self.assertEqual(
             self.task.canTransitionToStatus(
                 BugTaskStatus.NEW, self.person),

=== modified file 'lib/lp/bugs/scripts/tests/test_bugimport.py'
--- lib/lp/bugs/scripts/tests/test_bugimport.py	2011-08-12 11:19:40 +0000
+++ lib/lp/bugs/scripts/tests/test_bugimport.py	2011-10-03 20:20:29 +0000
@@ -845,7 +845,7 @@
             if bugtask.conjoined_master is not None:
                 continue
             bugtask = removeSecurityProxy(bugtask)
-            bugtask.status = new_malone_status
+            bugtask._status = new_malone_status
         if self.failing:
             cur = cursor()
             cur.execute("""

=== modified file 'lib/lp/bugs/tests/test_bugtask_search.py'
--- lib/lp/bugs/tests/test_bugtask_search.py	2011-09-27 07:53:02 +0000
+++ lib/lp/bugs/tests/test_bugtask_search.py	2011-10-03 20:20:29 +0000
@@ -75,18 +75,19 @@
 
     def test_aggregate_by_target(self):
         # BugTaskSet.search supports returning the counts for each target (as
-        # long only one type of target was selected).
+        # long as only one type of target was selected).
         if self.group_on is None:
             # Not a useful/valid permutation.
             return
         self.getBugTaskSearchParams(user=None, multitarget=True)
         # The test data has 3 bugs for searchtarget and 6 for searchtarget2.
+        user = self.factory.makePerson()
         expected = {(self.targetToGroup(self.searchtarget),): 3,
             (self.targetToGroup(self.searchtarget2),): 6}
-        user = self.factory.makePerson()
-        self.assertEqual(expected, self.bugtask_set.countBugs(user,
-            (self.searchtarget, self.searchtarget2),
-            group_on=self.group_on))
+        actual = self.bugtask_set.countBugs(
+            user, (self.searchtarget, self.searchtarget2),
+            group_on=self.group_on)
+        self.assertEqual(expected, actual)
 
     def test_search_all_bugtasks_for_target(self):
         # BugTaskSet.search() returns all bug tasks for a given bug

=== modified file 'lib/lp/registry/model/distribution.py'
--- lib/lp/registry/model/distribution.py	2011-09-28 23:31:38 +0000
+++ lib/lp/registry/model/distribution.py	2011-10-03 20:20:29 +0000
@@ -98,7 +98,7 @@
 from lp.bugs.interfaces.bugtarget import IHasBugHeat
 from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
-    UNRESOLVED_BUGTASK_STATUSES,
+    DB_UNRESOLVED_BUGTASK_STATUSES,
     )
 from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
 from lp.bugs.model.bug import (
@@ -1557,7 +1557,7 @@
                'triaged': quote(BugTaskStatus.TRIAGED),
                'limit': limit,
                'distro': self.id,
-               'unresolved': quote(UNRESOLVED_BUGTASK_STATUSES),
+               'unresolved': quote(DB_UNRESOLVED_BUGTASK_STATUSES),
                'excluded_packages': quote(exclude_packages),
                 })
         counts = cur.fetchall()

=== modified file 'lib/lp/registry/model/distributionsourcepackage.py'
--- lib/lp/registry/model/distributionsourcepackage.py	2011-09-28 03:28:50 +0000
+++ lib/lp/registry/model/distributionsourcepackage.py	2011-10-03 20:20:29 +0000
@@ -38,7 +38,7 @@
 from canonical.launchpad.interfaces.lpstorm import IStore
 from lp.bugs.interfaces.bugsummary import IBugSummaryDimension
 from lp.bugs.interfaces.bugtarget import IHasBugHeat
-from lp.bugs.interfaces.bugtask import UNRESOLVED_BUGTASK_STATUSES
+from lp.bugs.interfaces.bugtask import DB_UNRESOLVED_BUGTASK_STATUSES
 from lp.bugs.model.bug import (
     Bug,
     BugSet,
@@ -225,7 +225,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(DB_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-10-03 09:38:06 +0000
+++ lib/lp/scripts/garbo.py	2011-10-03 20:20:29 +0000
@@ -59,9 +59,14 @@
     )
 from lp.answers.model.answercontact import AnswerContact
 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,
@@ -808,6 +813,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
@@ -1270,6 +1310,7 @@
         BugHeatUpdater,
         SourcePackagePublishingHistorySPNPopulator,
         BinaryPackagePublishingHistoryBPNPopulator,
+        BugTaskIncompleteMigrator,
         ]
     experimental_tunable_loops = []
 

=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py	2011-10-03 09:38:06 +0000
+++ lib/lp/scripts/tests/test_garbo.py	2011-10-03 20:20:29 +0000
@@ -66,10 +66,15 @@
     ZopelessDatabaseLayer,
     )
 from lp.answers.model.answercontact import AnswerContact
+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,
@@ -858,6 +863,39 @@
         self._test_AnswerContactPruner(
             AccountStatus.SUSPENDED, ONE_DAY_AGO, expected_count=1)
 
+    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')