← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/lifecycle-subscription into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/lifecycle-subscription into lp:launchpad with lp:~danilo/launchpad/remove-bugtaskadded as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~danilo/launchpad/lifecycle-subscription/+merge/48185

= Implement BugNotificationLevel.LIFECYCLE for direct subscriptions =

With bug subscription work, one of the "levels" at which one can subscribe to a bug is "LIFECYCLE": only when bug is closed and/or reopened should one get an email about it.

== Proposed fix ==


== Pre-implementation notes ==

== Implementation details ==

== Tests ==

== Demo and Q/A ==


= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/adapters/tests/__init__.py
  lib/lp/bugs/adapters/bugchange.py
  lib/lp/bugs/adapters/tests/test_bugchange.py
  lib/lp/bugs/interfaces/bugchange.py
  lib/lp/bugs/interfaces/bugtask.py
-- 
https://code.launchpad.net/~danilo/launchpad/lifecycle-subscription/+merge/48185
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/lifecycle-subscription into lp:launchpad.
=== modified file 'lib/lp/bugs/adapters/bugchange.py'
--- lib/lp/bugs/adapters/bugchange.py	2010-09-02 10:29:37 +0000
+++ lib/lp/bugs/adapters/bugchange.py	2011-02-01 16:05:39 +0000
@@ -38,8 +38,14 @@
 from canonical.launchpad.browser.librarian import ProxiedLibraryFileAlias
 from canonical.launchpad.webapp.publisher import canonical_url
 from lp.bugs.interfaces.bugchange import IBugChange
-from lp.bugs.interfaces.bugtask import IBugTask
+from lp.bugs.interfaces.bugtask import (
+    BugTaskStatus,
+    IBugTask,
+    RESOLVED_BUGTASK_STATUSES,
+    UNRESOLVED_BUGTASK_STATUSES,
+    )
 from lp.registry.interfaces.product import IProduct
+from lp.registry.enum import BugNotificationLevel
 
 
 class NoBugChangeFoundError(Exception):
@@ -110,6 +116,9 @@
 
     implements(IBugChange)
 
+    # Most changes will be at METADATA level.
+    change_level = BugNotificationLevel.METADATA
+
     def __init__(self, when, person):
         self.person = person
         self.when = when
@@ -341,9 +350,50 @@
         return {'text': notification_text}
 
 
+def _is_status_change_lifecycle_change(old_status, new_status):
+    """Is a status change a lifecycle change."""
+    # Bug is moving from one of unresolved bug statuses (like
+    # 'in progress') to one of resolved ('fix released').
+    bug_is_closed = (old_status in UNRESOLVED_BUGTASK_STATUSES and
+                     new_status in RESOLVED_BUGTASK_STATUSES)
+
+    # Bug is moving back from one of resolved bug statuses (reopening).
+    bug_is_reopened = (old_status in RESOLVED_BUGTASK_STATUSES and
+                       new_status in UNRESOLVED_BUGTASK_STATUSES)
+    return bug_is_closed or bug_is_reopened
+
+
 class BugDuplicateChange(AttributeChange):
     """Describes a change to a bug's duplicate marker."""
 
+    @property
+    def change_level(self):
+        lifecycle = False
+        old_bug = self.old_value
+        new_bug = self.new_value
+        if old_bug is not None and new_bug is not None:
+            # Bug was already a duplicate of one bug,
+            # and we are changing it to be a duplicate of another bug.
+            lifecycle = _is_status_change_lifecycle_change(
+                old_bug.default_bugtask.status,
+                new_bug.default_bugtask.status)
+        elif new_bug is not None:
+            # old_bug is None here, so we are just adding a duplicate marker.
+            lifecycle = (new_bug.default_bugtask.status in
+                         RESOLVED_BUGTASK_STATUSES)
+        elif old_bug is not None:
+            # Unmarking a bug as duplicate.  This is lifecycle change
+            # only if bug has been reopened as a result.
+            lifecycle = (old_bug.default_bugtask.status in
+                         RESOLVED_BUGTASK_STATUSES)
+        else:
+            pass
+
+        if lifecycle:
+            return BugNotificationLevel.LIFECYCLE
+        else:
+            return BugNotificationLevel.METADATA
+
     def getBugActivity(self):
         if self.old_value is not None and self.new_value is not None:
             return {
@@ -722,6 +772,24 @@
     # Use `status.title` in activity records and notifications.
     display_attribute = 'title'
 
+    @property
+    def change_level(self):
+        """See `IBugChange`."""
+        # Is bug being closed or reopened.
+        lifecycle_change = _is_status_change_lifecycle_change(
+            self.old_value, self.new_value)
+
+        # When bug task is put into INCOMPLETE status, and subscriber
+        # is the person who originally reported the bug, we still notify
+        # them because bug now depends on their input.
+        subscriber_is_asked_for_info = (
+            self.new_value == BugTaskStatus.INCOMPLETE and
+            self.bug_task.bug.owner == self.person)
+
+        if (lifecycle_change or subscriber_is_asked_for_info):
+            return BugNotificationLevel.LIFECYCLE
+        return BugNotificationLevel.METADATA
+
 
 class BugTaskMilestoneChange(BugTaskAttributeChange):
     """Represents a change in BugTask.milestone."""
@@ -752,6 +820,7 @@
 
     def getBugActivity(self):
         """See `IBugChange`."""
+
         def assignee_for_display(assignee):
             if assignee is None:
                 return None
@@ -766,6 +835,7 @@
 
     def getBugNotification(self):
         """See `IBugChange`."""
+
         def assignee_for_display(assignee):
             if assignee is None:
                 return "(unassigned)"

=== added file 'lib/lp/bugs/adapters/tests/__init__.py'
=== modified file 'lib/lp/bugs/adapters/tests/test_bugchange.py'
--- lib/lp/bugs/adapters/tests/test_bugchange.py	2010-10-04 19:50:45 +0000
+++ lib/lp/bugs/adapters/tests/test_bugchange.py	2011-02-01 16:05:39 +0000
@@ -5,31 +5,27 @@
 
 __metaclass__ = type
 
-import unittest
-
-from canonical.launchpad.ftests import (
-    ANONYMOUS,
-    login,
-    logout,
-    )
-from canonical.testing.layers import LaunchpadFunctionalLayer
+from canonical.launchpad.webapp.publisher import canonical_url
+from canonical.testing.layers import ZopelessDatabaseLayer
 from lp.bugs.adapters.bugchange import (
     BUG_CHANGE_LOOKUP,
+    BugDescriptionChange,
     get_bug_change_class,
+    get_bug_changes,
     )
-from lp.testing.factory import LaunchpadObjectFactory
-
-
-class BugChangeTestCase(unittest.TestCase):
-
-    layer = LaunchpadFunctionalLayer
+from lp.bugs.adapters.bugdelta import BugDelta
+from lp.bugs.interfaces.bugtask import BugTaskStatus
+from lp.bugs.model.bugtask import BugTaskDelta
+from lp.registry.enum import BugNotificationLevel
+from lp.testing import TestCaseWithFactory
+
+
+class BugChangeTestCase(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
 
     def setUp(self):
-        login(ANONYMOUS)
-        self.factory = LaunchpadObjectFactory()
-
-    def tearDown(self):
-        logout()
+        super(BugChangeTestCase, self).setUp()
 
     def test_get_bug_change_class(self):
         # get_bug_change_class() should return whatever is contained
@@ -45,5 +41,182 @@
                 "Got %s." % (expected, field_name, received))
 
 
-def test_suite():
-    return unittest.TestLoader().loadTestsFromName(__name__)
+class BugChangeLevelTestCase(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(BugChangeLevelTestCase, self).setUp()
+        self.bug = self.factory.makeBug()
+        self.bugtask = self.bug.default_bugtask
+        self.user = self.factory.makePerson()
+
+    def createDelta(self, user=None, **kwargs):
+        if user is None:
+            user = self.user
+        return BugDelta(
+            bug=self.bug,
+            bugurl=canonical_url(self.bug),
+            user=user,
+            **kwargs)
+
+    def test_change_level_metadata_description(self):
+        # Changing a bug description is considered to have change_level
+        # of BugNotificationLevel.METADATA.
+        bug_delta = self.createDelta(
+            description={'new': 'new description',
+                         'old': self.bug.description})
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertTrue(isinstance(change, BugDescriptionChange))
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_lifecycle_status_closing(self):
+        # Changing a bug task status from NEW to FIXRELEASED makes this
+        # change a BugNotificationLevel.LIFECYCLE change.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.NEW,
+                    'new': BugTaskStatus.FIXRELEASED})
+        bug_delta = self.createDelta(
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.LIFECYCLE,
+                          change.change_level)
+
+    def test_change_level_lifecycle_status_reopening(self):
+        # Changing a bug task status from FIXRELEASED to TRIAGED makes this
+        # change a BugNotificationLevel.LIFECYCLE change.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.FIXRELEASED,
+                    'new': BugTaskStatus.TRIAGED})
+        bug_delta = self.createDelta(
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.LIFECYCLE,
+                          change.change_level)
+
+    def test_change_level_metadata_status_worked_on(self):
+        # Changing a bug task status from TRIAGED to FIXCOMMITTED makes this
+        # change a BugNotificationLevel.METADATA change.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.TRIAGED,
+                    'new': BugTaskStatus.FIXCOMMITTED})
+        bug_delta = self.createDelta(
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_metadata_status_stays_closed(self):
+        # Changing a bug task status from OPINION to WONTFIX makes this
+        # change a BugNotificationLevel.METADATA change.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.OPINION,
+                    'new': BugTaskStatus.WONTFIX})
+        bug_delta = self.createDelta(
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_metadata_status_incomplete_for_someone_else(self):
+        # Changing a bug task status from NEW to INCOMPLETE makes this
+        # change a BugNotificationLevel.METADATA change if subscriber is
+        # not the original reporter of the bug as well.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.NEW,
+                    'new': BugTaskStatus.INCOMPLETE})
+        bug_delta = self.createDelta(
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_lifecycle_status_incomplete_for_subscriber(self):
+        # Changing a bug task status from NEW to INCOMPLETE makes this
+        # change a BugNotificationLevel.LIFECYCLE change if subscriber is
+        # the original reporter of the bug as well: this ensures that
+        # bug reporter gets a notification about their input needed even
+        # if they are only subscribed on the LIFECYCLE level.
+        bugtask_delta = BugTaskDelta(
+            bugtask=self.bugtask,
+            status={'old': BugTaskStatus.NEW,
+                    'new': BugTaskStatus.INCOMPLETE})
+        bug_delta = self.createDelta(
+            user=self.bug.owner,
+            bugtask_deltas=bugtask_delta)
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.LIFECYCLE,
+                          change.change_level)
+
+    def test_change_level_metadata_duplicate_of_unresolved(self):
+        # Marking a bug as a duplicate of an unresolved bug is a
+        # simple BugNotificationLevel.METADATA change.
+        duplicate_of = self.factory.makeBug()
+        duplicate_of.default_bugtask.transitionToStatus(
+            BugTaskStatus.NEW, self.user)
+        bug_delta = self.createDelta(
+            user=self.bug.owner,
+            duplicateof={'old': None,
+                         'new': duplicate_of})
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_lifecycle_duplicate_of_resolved(self):
+        # Marking a bug as a duplicate of a resolved bug is
+        # a BugNotificationLevel.LIFECYCLE change.
+        duplicate_of = self.factory.makeBug()
+        duplicate_of.default_bugtask.transitionToStatus(
+            BugTaskStatus.FIXRELEASED, self.user)
+        bug_delta = self.createDelta(
+            user=self.bug.owner,
+            duplicateof={'old': None,
+                         'new': duplicate_of})
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.LIFECYCLE,
+                          change.change_level)
+
+    def test_change_level_metadata_not_duplicate_of_unresolved(self):
+        # Un-marking a bug as a duplicate of an unresolved bug is a
+        # simple BugNotificationLevel.METADATA change.
+        duplicate_of = self.factory.makeBug()
+        duplicate_of.default_bugtask.transitionToStatus(
+            BugTaskStatus.NEW, self.user)
+        bug_delta = self.createDelta(
+            user=self.bug.owner,
+            duplicateof={'new': None,
+                         'old': duplicate_of})
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.METADATA,
+                          change.change_level)
+
+    def test_change_level_lifecycle_not_duplicate_of_resolved(self):
+        # Un-marking a bug as a duplicate of a resolved bug is
+        # a BugNotificationLevel.LIFECYCLE change.
+        duplicate_of = self.factory.makeBug()
+        duplicate_of.default_bugtask.transitionToStatus(
+            BugTaskStatus.FIXRELEASED, self.user)
+        bug_delta = self.createDelta(
+            user=self.bug.owner,
+            duplicateof={'new': None,
+                         'old': duplicate_of})
+
+        change = list(get_bug_changes(bug_delta))[0]
+        self.assertEquals(BugNotificationLevel.LIFECYCLE,
+                          change.change_level)

=== modified file 'lib/lp/bugs/interfaces/bugchange.py'
--- lib/lp/bugs/interfaces/bugchange.py	2010-08-20 20:31:18 +0000
+++ lib/lp/bugs/interfaces/bugchange.py	2011-02-01 16:05:39 +0000
@@ -21,6 +21,8 @@
 
     when = Attribute("The timestamp for the BugChange.")
     person = Attribute("The Person who made the change.")
+    change_level = Attribute(
+        "Type of the change to a bug expressed as `BugNotificationLevel`.")
 
     def getBugActivity():
         """Return the `BugActivity` data for this change as a dict."""

=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
--- lib/lp/bugs/interfaces/bugtask.py	2010-12-17 04:26:11 +0000
+++ lib/lp/bugs/interfaces/bugtask.py	2011-02-01 16:05:39 +0000
@@ -350,13 +350,6 @@
     BUGS_WITHOUT_BRANCHES = Item("Show only Bugs without linked Branches")
 
 
-# XXX: Brad Bollenbach 2005-12-02 bugs=5320:
-# In theory, INCOMPLETE belongs in UNRESOLVED_BUGTASK_STATUSES, but the
-# semantics of our current reports would break if it were added to the
-# list below.
-
-# XXX: matsubara 2006-02-02 bug=4201:
-# I added the INCOMPLETE as a short-term solution.
 UNRESOLVED_BUGTASK_STATUSES = (
     BugTaskStatus.NEW,
     BugTaskStatus.INCOMPLETE,


Follow ups