← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~deryck/launchpad/update-storm-17-add-back-dupe-changes into lp:launchpad/devel

 

Deryck Hodge has proposed merging lp:~deryck/launchpad/update-storm-17-add-back-dupe-changes into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


My previous approved (and landed) work to converge on markAsDuplicate
rather than setting duplicateof directly was reverted due to a test
failure from a Storm bug.  This branch includes my already approved
changes with a change to versions.cfg to get us on the Storm version
that was just released, 0.17.

I have already committed the new Storm to download-cache.  I have run
tests.  All that needs approval here is the change in Storm version.

Everything in the diff other than this change to versions.cfg is
approved in the MPs here:

https://code.edge.launchpad.net/~deryck/launchpad/do-the-right-thing-dupe-move-78596/+merge/27144
https://code.edge.launchpad.net/~deryck/launchpad/better-dupe-handling-ui/+merge/30309

And I have approval from Robert to change the version of Storm here:

https://code.edge.launchpad.net/~deryck/launchpad/update-storm-r365/+merge/31881

However, I didn't get this landed yesterday before the new Storm
today, so I thought I would do a versions.cfg update alongside
restoring my reverted duplicate handling work.

Cheers,
deryck
-- 
https://code.launchpad.net/~deryck/launchpad/update-storm-17-add-back-dupe-changes/+merge/31983
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~deryck/launchpad/update-storm-17-add-back-dupe-changes into lp:launchpad/devel.
=== modified file 'lib/canonical/launchpad/fields/__init__.py'
--- lib/canonical/launchpad/fields/__init__.py	2010-07-22 12:17:41 +0000
+++ lib/canonical/launchpad/fields/__init__.py	2010-08-06 18:42:48 +0000
@@ -302,12 +302,8 @@
         bug isn't a duplicate of itself, otherwise
         return False.
         """
-        from lp.bugs.interfaces.bug import IBugSet
-        bugset = getUtility(IBugSet)
         current_bug = self.context
         dup_target = value
-        current_bug_has_dup_refs = bool(bugset.searchAsUser(
-            user=getUtility(ILaunchBag).user, duplicateof=current_bug))
         if current_bug == dup_target:
             raise LaunchpadValidationError(_(dedent("""
                 You can't mark a bug as a duplicate of itself.""")))
@@ -318,13 +314,6 @@
                 isn't a duplicate itself.
                 """), mapping={'dup': dup_target.id,
                                'orig': dup_target.duplicateof.id}))
-        elif current_bug_has_dup_refs:
-            raise LaunchpadValidationError(_(dedent("""
-                There are other bugs already marked as duplicates of
-                Bug ${current}.  These bugs should be changed to be
-                duplicates of another bug if you are certain you would
-                like to perform this change."""),
-                mapping={'current': current_bug.id}))
         else:
             return True
 

=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
--- lib/canonical/launchpad/icing/style-3-0.css.in	2010-07-26 21:00:39 +0000
+++ lib/canonical/launchpad/icing/style-3-0.css.in	2010-08-06 18:42:48 +0000
@@ -1828,6 +1828,12 @@
     */
     display: inline;
     }
+body.tab-bugs #duplicate-actions .sprite {
+    /* Override sprite style for edit icon on "Mark as duplicate"
+       to make the text not appear on a second line.
+    */
+    display:inline;
+    }
 .yui-picker-results li.sprite {
     /* XXX: EdwinGrubbs 2009-07-27 bug=405476
      * Override the .yui-picker-results style, so that the next icon

=== modified file 'lib/canonical/launchpad/mail/commands.py'
--- lib/canonical/launchpad/mail/commands.py	2010-08-02 02:13:52 +0000
+++ lib/canonical/launchpad/mail/commands.py	2010-08-06 18:42:48 +0000
@@ -382,30 +382,40 @@
         return {'title': self.string_args[0]}
 
 
-class DuplicateEmailCommand(EditEmailCommand):
+class DuplicateEmailCommand(EmailCommand):
     """Marks a bug as a duplicate of another bug."""
 
     implements(IBugEditEmailCommand)
     _numberOfArguments = 1
 
-    def convertArguments(self, context):
-        """See EmailCommand."""
+    def execute(self, context, current_event):
+        """See IEmailCommand."""
+        self._ensureNumberOfArguments()
         [bug_id] = self.string_args
-        if bug_id == 'no':
+
+        if bug_id != 'no':
+            try:
+                bug = getUtility(IBugSet).getByNameOrID(bug_id)
+            except NotFoundError:
+                raise EmailProcessingError(
+                    get_error_message('no-such-bug.txt', bug_id=bug_id))
+        else:
             # 'no' is a special value for unmarking a bug as a duplicate.
-            return {'duplicateof': None}
-        try:
-            bug = getUtility(IBugSet).getByNameOrID(bug_id)
-        except NotFoundError:
-            raise EmailProcessingError(
-                get_error_message('no-such-bug.txt', bug_id=bug_id))
+            bug = None
+
         duplicate_field = IBug['duplicateof'].bind(context)
         try:
             duplicate_field.validate(bug)
         except ValidationError, error:
             raise EmailProcessingError(error.doc())
 
-        return {'duplicateof': bug}
+        context_snapshot = Snapshot(
+            context, providing=providedBy(context))
+        context.markAsDuplicate(bug)
+        current_event = ObjectModifiedEvent(
+            context, context_snapshot, 'duplicateof')
+        notify(current_event)
+        return bug, current_event
 
 
 class CVEEmailCommand(EmailCommand):

=== modified file 'lib/lp/bugs/browser/bug.py'
--- lib/lp/bugs/browser/bug.py	2010-08-02 02:13:52 +0000
+++ lib/lp/bugs/browser/bug.py	2010-08-06 18:42:48 +0000
@@ -49,8 +49,9 @@
 from canonical.cachedproperty import cachedproperty
 
 from canonical.launchpad import _
+from canonical.launchpad.fields import DuplicateBug
+from canonical.launchpad.webapp.interfaces import ILaunchBag
 from lp.app.errors import NotFoundError
-from canonical.launchpad.webapp.interfaces import ILaunchBag
 from lp.bugs.interfaces.bug import IBug, IBugSet
 from lp.bugs.interfaces.bugattachment import BugAttachmentType
 from lp.bugs.interfaces.bugtask import (
@@ -659,9 +660,35 @@
     field_names = ['duplicateof']
     label = "Mark bug report as a duplicate"
 
+    def setUpFields(self):
+        """Make the readonly version of duplicateof available."""
+        super(BugMarkAsDuplicateView, self).setUpFields()
+
+        duplicateof_field = DuplicateBug(
+            __name__='duplicateof', title=_('Duplicate Of'), required=False)
+
+        self.form_fields = self.form_fields.omit('duplicateof')
+        self.form_fields = formlib.form.Fields(duplicateof_field)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView.`"""
+        return {'duplicateof': self.context.bug.duplicateof}
+
     @action('Change', name='change')
     def change_action(self, action, data):
         """Update the bug."""
+        data = dict(data)
+        # We handle duplicate changes by hand instead of leaving it to
+        # the usual machinery because we must use bug.markAsDuplicate().
+        bug = self.context.bug
+        bug_before_modification = Snapshot(
+            bug, providing=providedBy(bug))
+        duplicateof = data.pop('duplicateof')
+        bug.markAsDuplicate(duplicateof)
+        notify(
+            ObjectModifiedEvent(bug, bug_before_modification, 'duplicateof'))
+        # Apply other changes.
         self.updateBugFromData(data)
 
 

=== modified file 'lib/lp/bugs/browser/tests/bug-subscription-views.txt'
--- lib/lp/bugs/browser/tests/bug-subscription-views.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/browser/tests/bug-subscription-views.txt	2010-08-06 18:42:48 +0000
@@ -30,7 +30,7 @@
     {"name12": "subscriber-12"}
 
     >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> bug_15.duplicateof = bug_13
+    >>> bug_15.markAsDuplicate(bug_13)
     >>> bug_13 = getUtility(IBugSet).get(13)
 
     >>> subscriber_ids_view = create_initialized_view(

=== modified file 'lib/lp/bugs/browser/tests/bug-views.txt'
--- lib/lp/bugs/browser/tests/bug-views.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/browser/tests/bug-views.txt	2010-08-06 18:42:48 +0000
@@ -249,7 +249,8 @@
 and see how the returned link changes.
 
     >>> bug_two = bugset.get(2)
-    >>> bug_two.duplicateof = 5
+    >>> bug_five = bugset.get(5)
+    >>> bug_two.markAsDuplicate(bug_five)
 
     >>> bug_page_view = getMultiAdapter(
     ...     (bug_five_in_firefox.bug, request), name="+portlet-duplicates")
@@ -380,7 +381,7 @@
 
     >>> from canonical.launchpad.ftests import syncUpdate
 
-    >>> bug_two.duplicateof = bug_three
+    >>> bug_two.markAsDuplicate(bug_three)
     >>> syncUpdate(bug_two)
 
     >>> bug_menu.subscription().text
@@ -391,7 +392,7 @@
     the current user is a member. So, for Foo Bar, bug #3 has a simple
     Subscribe link initially.
 
-    >>> bug_two.duplicateof = None
+    >>> bug_two.markAsDuplicate(None)
     >>> syncUpdate(bug_two)
 
     >>> login("foo.bar@xxxxxxxxxxxxx")
@@ -406,7 +407,7 @@
     >>> bug_two.subscribe(ubuntu_team, ubuntu_team)
         <BugSubscription...>
 
-    >>> bug_two.duplicateof = bug_three
+    >>> bug_two.markAsDuplicate(bug_three)
     >>> syncUpdate(bug_two)
 
     >>> bug_menu.subscription().text

=== modified file 'lib/lp/bugs/browser/tests/special/bugs-fixed-elsewhere.txt'
--- lib/lp/bugs/browser/tests/special/bugs-fixed-elsewhere.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/browser/tests/special/bugs-fixed-elsewhere.txt	2010-08-06 18:42:48 +0000
@@ -125,7 +125,7 @@
 
 Bugs that are duplicate of other bugs aren't included in the count.
 
-    >>> another_bug.duplicateof = bug
+    >>> another_bug.markAsDuplicate(bug)
     >>> syncUpdate(another_bug)
 
     >>> view.bugs_fixed_elsewhere_count

=== modified file 'lib/lp/bugs/browser/tests/special/bugtarget-recently-touched-bugs.txt'
--- lib/lp/bugs/browser/tests/special/bugtarget-recently-touched-bugs.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/browser/tests/special/bugtarget-recently-touched-bugs.txt	2010-08-06 18:42:48 +0000
@@ -80,7 +80,7 @@
 Bugs that are duplicates of other bugs will be omitted from the list as
 well.
 
-    >>> bug_b.duplicateof = bug_c
+    >>> bug_b.markAsDuplicate(bug_c)
     >>> syncUpdate(bug_b)
 
     >>> for bugtask in portlet_view.getMostRecentlyUpdatedBugTasks()[:2]:

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2010-07-29 19:48:15 +0000
+++ lib/lp/bugs/configure.zcml	2010-08-06 18:42:48 +0000
@@ -652,7 +652,6 @@
                     getBugTasksByPackageName
                     users_affected_count
                     users_unaffected_count
-                    readonly_duplicateof
                     users_affected
                     users_unaffected
                     users_affected_count_with_dupes
@@ -703,7 +702,6 @@
                     date_last_updated
                     date_made_private
                     datecreated
-                    duplicateof
                     hits
                     hitstimestamp
                     name

=== modified file 'lib/lp/bugs/doc/bug.txt'
--- lib/lp/bugs/doc/bug.txt	2010-07-29 19:12:10 +0000
+++ lib/lp/bugs/doc/bug.txt	2010-08-06 18:42:48 +0000
@@ -184,18 +184,22 @@
 
     >>> login("foo.bar@xxxxxxxxxxxxx")
 
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> productset = getUtility(IProductSet)
+    >>> firefox = productset.get(4)
     >>> firefox_test_bug = bugset.get(3)
-    >>> firefox_test_bug.duplicateof = firefox_crashes.id
+    >>> firefox_master_bug = factory.makeBug(product=firefox)
+    >>> firefox_test_bug.markAsDuplicate(firefox_master_bug)
     >>> flush_database_updates()
 
     >>> dups_of_bug_six = bugset.searchAsUser(
-    ...     duplicateof=firefox_crashes, user=current_user())
+    ...     duplicateof=firefox_master_bug, user=current_user())
     >>> print dups_of_bug_six.count()
     1
     >>> dups_of_bug_six[0].id
     3
 
-    >>> firefox_test_bug.duplicateof = None
+    >>> firefox_test_bug.markAsDuplicate(None)
     >>> flush_database_updates()
     >>> dups_of_bug_six = bugset.searchAsUser(
     ...     duplicateof=firefox_crashes, user=current_user())
@@ -426,11 +430,6 @@
 
 When a public bug is filed:
 
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> productset = getUtility(IProductSet)
-    >>> bugset = getUtility(IBugSet)
-
-    >>> firefox = productset.get(4)
     >>> params = CreateBugParams(
     ...     title="test firefox bug", comment="blah blah blah", owner=foobar)
     >>> params.setBugTarget(product=firefox)
@@ -765,7 +764,7 @@
 
     >>> print firefox_bug.duplicateof
     None
-    >>> firefox_bug.duplicateof = firefox_crashes
+    >>> firefox_bug.markAsDuplicate(firefox_master_bug)
 
     >>> bug_duplicateof_changed = ObjectModifiedEvent(
     ...     firefox_bug, bug_before_modification, ["duplicateof"])
@@ -1241,7 +1240,7 @@
 
     >>> dupe_affected_user = factory.makePerson(name='sheila-shakespeare')
     >>> dupe_one = factory.makeBug(owner=dupe_affected_user)
-    >>> dupe_one.duplicateof = test_bug
+    >>> dupe_one.markAsDuplicate(test_bug)
     >>> test_bug.users_affected_count_with_dupes
     3
 
@@ -1275,7 +1274,7 @@
 
     >>> dupe_affected_other_user = factory.makePerson(name='napoleon-bonaparte')
     >>> dupe_three = factory.makeBug(owner=dupe_affected_other_user)
-    >>> dupe_three.duplicateof = test_bug
+    >>> dupe_three.markAsDuplicate(test_bug)
     >>> test_bug.users_affected_count_with_dupes
     4
 
@@ -1284,7 +1283,7 @@
 user_affected_count still only increments by 1 for that user.
 
     >>> dupe_two = factory.makeBug(owner=dupe_affected_user)
-    >>> dupe_two.duplicateof = test_bug
+    >>> dupe_two.markAsDuplicate(test_bug)
     >>> test_bug.users_affected_count_with_dupes
     4
 
@@ -1438,7 +1437,7 @@
 
     >>> new_bug_3 = bugs[3]
     >>> new_bug_4 = bugs[4]
-    >>> new_bug_3.duplicateof = new_bug_4
+    >>> new_bug_3.markAsDuplicate(new_bug_4)
     >>> matching_bugs = getUtility(IBugSet).getDistinctBugsForBugTasks(
     ...     bug_tasks, user=no_priv)
 
@@ -1566,7 +1565,7 @@
     >>> dupe = factory.makeBug()
     >>> subscription = dupe.subscribe(person, person)
 
-    >>> dupe.duplicateof = bug
+    >>> dupe.markAsDuplicate(bug)
 
     # Re-fetch the bug so that the fact that it's a duplicate definitely
     # registers.

=== modified file 'lib/lp/bugs/doc/bugactivity.txt'
--- lib/lp/bugs/doc/bugactivity.txt	2010-07-22 16:27:12 +0000
+++ lib/lp/bugs/doc/bugactivity.txt	2010-08-06 18:42:48 +0000
@@ -123,7 +123,8 @@
     ...     "id", "title", "description", "name",
     ...     "private", "duplicateof", "security_related"]
     >>> old_bug = Snapshot(bug, providing=IBug)
-    >>> bug.duplicateof = 1
+    >>> latest_bug = factory.makeBug()
+    >>> bug.markAsDuplicate(latest_bug)
     >>> bug_edited = ObjectModifiedEvent(bug, old_bug, edit_fields)
     >>> notify(bug_edited)
     >>> latest_activity = bug.activity[-1]
@@ -131,25 +132,26 @@
     u'marked as duplicate'
     >>> latest_activity.oldvalue is None
     True
-    >>> latest_activity.newvalue == u'1'
+    >>> latest_activity.newvalue == unicode(latest_bug.id)
     True
 
 
-== Bug report has itss duplicate marker changed to another bug report ==
+== Bug report has its duplicate marker changed to another bug report ==
 
     >>> edit_fields = [
     ...     "id", "title", "description", "name", "private", "duplicateof",
     ...     "security_related"]
     >>> old_bug = Snapshot(bug, providing=IBug)
-    >>> bug.duplicateof = 2
+    >>> another_bug = factory.makeBug()
+    >>> bug.markAsDuplicate(another_bug)
     >>> bug_edited = ObjectModifiedEvent(bug, old_bug, edit_fields)
     >>> notify(bug_edited)
     >>> latest_activity = bug.activity[-1]
     >>> latest_activity.whatchanged
     u'changed duplicate marker'
-    >>> latest_activity.oldvalue == u'1'
+    >>> latest_activity.oldvalue == unicode(latest_bug.id)
     True
-    >>> latest_activity.newvalue == u'2'
+    >>> latest_activity.newvalue == unicode(another_bug.id)
     True
 
 
@@ -159,18 +161,58 @@
     ...     "id", "title", "description", "name", "private", "duplicateof",
     ...     "security_related"]
     >>> old_bug = Snapshot(bug, providing=IBug)
-    >>> bug.duplicateof = None
+    >>> bug.markAsDuplicate(None)
     >>> bug_edited = ObjectModifiedEvent(bug, old_bug, edit_fields)
     >>> notify(bug_edited)
     >>> latest_activity = bug.activity[-1]
     >>> latest_activity.whatchanged
     u'removed duplicate marker'
-    >>> latest_activity.oldvalue == u'2'
+    >>> latest_activity.oldvalue == unicode(another_bug.id)
     True
     >>> latest_activity.newvalue is None
     True
 
 
+==  A bug with multiple duplicates ==
+
+When a bug has multiple duplicates and is itself marked a duplicate,
+the duplicates are automatically duped to the same master bug.  These changes
+are then reflected in the activity log for each bug itself.
+
+    >>> edit_fields = [
+    ...     "id", "title", "description", "name", "private", "duplicateof",
+    ...     "security_related"]
+    >>> initial_bug = factory.makeBug()
+    >>> dupe_one = factory.makeBug()
+    >>> dupe_two = factory.makeBug()
+    >>> dupe_one.markAsDuplicate(initial_bug)
+    >>> dupe_two.markAsDuplicate(initial_bug)
+
+After creating a few bugs to work with, we create a final bug and duplicate
+the initial bug against it.
+
+    >>> final_bug = factory.makeBug()
+    >>> initial_bug.markAsDuplicate(final_bug)
+
+Now, we confirm the activity log for the other bugs correctly list the
+final_bug as their master bug.
+
+    >>> latest_activity = dupe_one.activity[-1]
+    >>> print latest_activity.whatchanged
+    changed duplicate marker
+    >>> latest_activity.oldvalue == unicode(initial_bug.id)
+    True
+    >>> latest_activity.newvalue == unicode(final_bug.id)
+    True
+    >>> latest_activity = dupe_two.activity[-1]
+    >>> print latest_activity.whatchanged
+    changed duplicate marker
+    >>> latest_activity.oldvalue == unicode(initial_bug.id)
+    True
+    >>> latest_activity.newvalue == unicode(final_bug.id)
+    True
+
+
 == BugActivityItem ==
 
 BugActivityItem implements the stuff that BugActivity doesn't need to

=== modified file 'lib/lp/bugs/doc/bugnotification-sending.txt'
--- lib/lp/bugs/doc/bugnotification-sending.txt	2010-07-22 20:30:26 +0000
+++ lib/lp/bugs/doc/bugnotification-sending.txt	2010-08-06 18:42:48 +0000
@@ -760,13 +760,14 @@
     >>> switch_db_to_launchpad()
     >>> new_bug = ubuntu.createBug(params)
     >>> switch_db_to_bugnotification()
-
     >>> flush_notifications()
 
 If a bug is a duplicate of another bug, a marker gets inserted at the
 top of the email:
 
-    >>> new_bug.duplicateof = bug_one
+    >>> switch_db_to_launchpad()
+    >>> new_bug.markAsDuplicate(bug_one)
+    >>> switch_db_to_bugnotification()
     >>> comment = getUtility(IMessageSet).fromText(
     ...     'subject', 'a comment.', sample_person,
     ...     datecreated=ten_minutes_ago)

=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
--- lib/lp/bugs/doc/bugsubscription.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/bugsubscription.txt	2010-08-06 18:42:48 +0000
@@ -341,7 +341,7 @@
     Stuart Bishop
     Ubuntu Team
 
-    >>> linux_source_bug_dupe.duplicateof = linux_source_bug
+    >>> linux_source_bug_dupe.markAsDuplicate(linux_source_bug)
     >>> linux_source_bug_dupe.syncUpdate()
 
     >>> print_displayname(linux_source_bug.getIndirectSubscribers())
@@ -868,7 +868,7 @@
     ...     comment="one more dupe test description",
     ...     owner=keybuk)
     >>> dupe_ff_bug = firefox.createBug(params)
-    >>> dupe_ff_bug.duplicateof = ff_bug
+    >>> dupe_ff_bug.markAsDuplicate(ff_bug)
     >>> dupe_ff_bug.syncUpdate()
     >>> dupe_ff_bug.subscribe(foobar, lifeless)
     <BugSubscription at ...>

=== modified file 'lib/lp/bugs/doc/bugtask-package-bugcounts.txt'
--- lib/lp/bugs/doc/bugtask-package-bugcounts.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/bugtask-package-bugcounts.txt	2010-08-06 18:42:48 +0000
@@ -170,7 +170,7 @@
 Duplicates bugs are omitted from the counts.
 
     >>> from canonical.launchpad.interfaces import IBugSet
-    >>> bug.duplicateof = getUtility(IBugSet).get(1)
+    >>> bug.markAsDuplicate(getUtility(IBugSet).get(1))
     >>> syncUpdate(bug)
     >>> package_counts = getUtility(IBugTaskSet).getBugCountsForPackages(
     ...     user=foo_bar, packages=packages)

=== modified file 'lib/lp/bugs/doc/bugtask-search.txt'
--- lib/lp/bugs/doc/bugtask-search.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/bugtask-search.txt	2010-08-06 18:42:48 +0000
@@ -1037,7 +1037,7 @@
 
     >>> bug_nine = getUtility(IBugSet).get(9)
     >>> bug_ten = getUtility(IBugSet).get(10)
-    >>> bug_ten.duplicateof = bug_nine
+    >>> bug_ten.markAsDuplicate(bug_nine)
     >>> flush_database_updates()
 
 Searching again reveals bug #9 at the top of the list, since it now has a duplicate.

=== modified file 'lib/lp/bugs/doc/bugzilla-import.txt'
--- lib/lp/bugs/doc/bugzilla-import.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/bugzilla-import.txt	2010-08-06 18:42:48 +0000
@@ -87,8 +87,7 @@
 
   >>> duplicates = [
   ...     (1, 2),
-  ...     (2, 3),
-  ...     (2, 4),
+  ...     (3, 4),
   ...     ]
 
   >>> class FakeBackend:
@@ -536,9 +535,9 @@
   None
   >>> bug2.duplicateof == bug1
   True
-  >>> bug3.duplicateof == bug2
+  >>> bug3.duplicateof == None
   True
-  >>> bug4.duplicateof == bug2
+  >>> bug4.duplicateof == bug3
   True
 
 

=== modified file 'lib/lp/bugs/doc/checkwatches.txt'
--- lib/lp/bugs/doc/checkwatches.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/checkwatches.txt	2010-08-06 18:42:48 +0000
@@ -490,8 +490,8 @@
 another bug, comments won't be synced and the bug won't be linked back
 to the remote bug.
 
+    >>> LaunchpadZopelessLayer.switchDbUser('launchpad')
     >>> bug_15 = getUtility(IBugSet).get(15)
-    >>> bug_watch.bug.duplicateof = bug_15
-    >>> transaction.commit()
+    >>> bug_watch.bug.markAsDuplicate(bug_15)
 
     >>> updater.updateBugWatches(remote_system, [bug_watch], now=nowish)

=== modified file 'lib/lp/bugs/doc/malone-karma.txt'
--- lib/lp/bugs/doc/malone-karma.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/doc/malone-karma.txt	2010-08-06 18:42:48 +0000
@@ -204,7 +204,7 @@
 
     >>> bug_one = getUtility(IBugSet).get(1)
     >>> old_bug = Snapshot(bug, providing=IBug)
-    >>> bug.duplicateof = bug_one
+    >>> bug.markAsDuplicate(bug_one)
 
     (Notice how changing a bug with multiple bugtasks will assign karma to you
     once for each bugtask. This is so because we consider changes in a bug to

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2010-08-02 02:13:52 +0000
+++ lib/lp/bugs/interfaces/bug.py	2010-08-06 18:42:48 +0000
@@ -184,8 +184,7 @@
     ownerID = Int(title=_('Owner'), required=True, readonly=True)
     owner = exported(
         Reference(IPerson, title=_("The owner's IPerson"), readonly=True))
-    duplicateof = DuplicateBug(title=_('Duplicate Of'), required=False)
-    readonly_duplicateof = exported(
+    duplicateof = exported(
         DuplicateBug(title=_('Duplicate Of'), required=False, readonly=True),
         exported_as='duplicate_of')
     # This is redefined from IPrivacy.private because the attribute is
@@ -755,8 +754,8 @@
     def markUserAffected(user, affected=True):
         """Mark :user: as affected by this bug."""
 
-    @mutator_for(readonly_duplicateof)
-    @operation_parameters(duplicate_of=copy_field(readonly_duplicateof))
+    @mutator_for(duplicateof)
+    @operation_parameters(duplicate_of=copy_field(duplicateof))
     @export_write_operation()
     def markAsDuplicate(duplicate_of):
         """Mark this bug as a duplicate of another."""

=== modified file 'lib/lp/bugs/javascript/bugtask_index.js'
--- lib/lp/bugs/javascript/bugtask_index.js	2010-07-22 16:27:12 +0000
+++ lib/lp/bugs/javascript/bugtask_index.js	2010-08-06 18:42:48 +0000
@@ -165,10 +165,23 @@
             update_dupe_url = update_dupe_link.get('href');
             var mark_dupe_form_url = update_dupe_url + '/++form++';
 
+            var form_header = '<p>Marking this bug as a duplicate will, by default, ' +
+                              'hide it from search results listings.</p>';
+
+            var has_dupes = Y.one('#portlet-duplicates');
+            if (has_dupes !== null) {
+                form_header = form_header +
+                    '<p style="padding:2px 2px 0 36px;" ' +
+                    'class="large-warning"><strong>Note:</strong> ' +
+                    'This bug has duplicates of its own. ' +
+                    'If you go ahead, they too will become duplicates of ' +
+                    'the bug you specify here.  This cannot be undone.' +
+                    '</p></div>';
+            }
+
             duplicate_form_overlay = new Y.lazr.FormOverlay({
                 headerContent: '<h2>Mark bug report as duplicate</h2>',
-                form_header: 'Marking the bug as a duplicate will, by default, ' +
-                             'hide it from search results listings.',
+                form_header: form_header,
                 form_submit_button: Y.Node.create(submit_button_html),
                 form_cancel_button: Y.Node.create(cancel_button_html),
                 centered: true,
@@ -187,6 +200,7 @@
                 if (duplicate_form_overlay){
                     e.preventDefault();
                     duplicate_form_overlay.show();
+                    Y.DOM.byId('field.duplicateof').focus();
                 }
             });
             // Add a class denoting them as js-action links.
@@ -366,8 +380,15 @@
     // Hide the formoverlay:
     duplicate_form_overlay.hide();
 
+    // Hide the dupe edit icon if it exists.
+    var dupe_edit_icon = Y.one('#change_duplicate_bug');
+    if (dupe_edit_icon !== null) {
+        dupe_edit_icon.setStyle('display', 'none');
+    }
+
     // Add the spinner...
     var dupe_span = Y.one('#mark-duplicate-text');
+    dupe_span.removeClass('sprite bug-dupe');
     dupe_span.addClass('update-in-progress-message');
 
     // Set the new duplicate link on the bug entry.
@@ -390,28 +411,35 @@
 
                 if (new_dup_url !== null) {
                     dupe_span.set('innerHTML', [
-                        'Duplicate of <a>bug #</a> ',
-                        '<a class="menu-link-mark-dupe js-action sprite edit">',
-                        '<span class="invisible-link">edit</span></a>'].join(""));
+                        '<a id="change_duplicate_bug" ',
+                        'title="Edit or remove linked duplicate bug" ',
+                        'class="sprite edit"></a>',
+                        'Duplicate of <a>bug #</a>'].join(""));
                     dupe_span.all('a').item(0)
+                        .set('href', update_dupe_url);
+                    dupe_span.all('a').item(1)
                         .set('href', '/bugs/' + new_dup_id)
                         .appendChild(document.createTextNode(new_dup_id));
-                    dupe_span.all('a').item(1)
-                        .set('href', update_dupe_url);
+                    var has_dupes = Y.one('#portlet-duplicates');
+                    if (has_dupes !== null) {
+                        has_dupes.get('parentNode').removeChild(has_dupes);
+                    }
                     show_comment_on_duplicate_warning();
                 } else {
+                    dupe_span.addClass('sprite bug-dupe');
                     dupe_span.set('innerHTML', [
-                        '<a class="menu-link-mark-dupe js-action ',
-                        'sprite bug-dupe">Mark as duplicate</a>'].join(""));
+                        '<a class="menu-link-mark-dupe js-action">',
+                        'Mark as duplicate</a>'].join(""));
                     dupe_span.one('a').set('href', update_dupe_url);
                     hide_comment_on_duplicate_warning();
                 }
                 Y.lazr.anim.green_flash({node: dupe_span}).run();
                 // ensure the new link is hooked up correctly:
-                dupe_span.one('a.menu-link-mark-dupe').on(
+                dupe_span.one('a').on(
                     'click', function(e){
                         e.preventDefault();
                         duplicate_form_overlay.show();
+                        Y.DOM.byId('field.duplicateof').focus();
                     });
             },
             failure: function(id, request) {

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2010-08-02 02:13:52 +0000
+++ lib/lp/bugs/model/bug.py	2010-08-06 18:42:48 +0000
@@ -1493,11 +1493,6 @@
 
         self.updateHeat()
 
-    @property
-    def readonly_duplicateof(self):
-        """See `IBug`."""
-        return self.duplicateof
-
     def markAsDuplicate(self, duplicate_of):
         """See `IBug`."""
         field = DuplicateBug()
@@ -1506,6 +1501,16 @@
         try:
             if duplicate_of is not None:
                 field._validate(duplicate_of)
+            if self.duplicates:
+                for duplicate in self.duplicates:
+                    # Fire a notify event in model code since moving
+                    # duplicates of a duplicate does not normally fire an
+                    # event.
+                    dupe_before = Snapshot(
+                        duplicate, providing=providedBy(duplicate))
+                    duplicate.markAsDuplicate(duplicate_of)
+                    notify(ObjectModifiedEvent(
+                            duplicate, dupe_before, 'duplicateof'))
             self.duplicateof = duplicate_of
         except LaunchpadValidationError, validation_error:
             raise InvalidDuplicateValue(validation_error)

=== modified file 'lib/lp/bugs/scripts/bugimport.py'
--- lib/lp/bugs/scripts/bugimport.py	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/scripts/bugimport.py	2010-08-06 18:42:48 +0000
@@ -466,7 +466,7 @@
                 self.logger.info(
                     'Marking bug %d as duplicate of bug %d',
                     other_bug.id, bug.id)
-                other_bug.duplicateof = bug
+                other_bug.markAsDuplicate(bug)
             del self.pending_duplicates[bug_id]
         # Process this bug as a duplicate
         if duplicateof is not None:
@@ -478,7 +478,7 @@
                 self.logger.info(
                     'Marking bug %d as duplicate of bug %d',
                     bug.id, other_bug.id)
-                bug.duplicateof = other_bug
+                bug.markAsDuplicate(other_bug)
             else:
                 self.pending_duplicates.setdefault(
                     duplicateof, []).append(bug.id)

=== modified file 'lib/lp/bugs/scripts/bugzilla.py'
--- lib/lp/bugs/scripts/bugzilla.py	2010-08-02 02:13:52 +0000
+++ lib/lp/bugs/scripts/bugzilla.py	2010-08-06 18:42:48 +0000
@@ -638,7 +638,7 @@
                 lpdupe.duplicateof is None):
                 logger.info('Marking %d as a duplicate of %d',
                             lpdupe.id, lpdupe_of.id)
-                lpdupe.duplicateof = lpdupe_of
+                lpdupe.markAsDuplicate(lpdupe_of)
             trans.commit()
 
     def importBugs(self, trans, product=None, component=None, status=None):

=== removed directory 'lib/lp/bugs/stories/duplicate-bug-handling'
=== removed file 'lib/lp/bugs/stories/duplicate-bug-handling/10-mark-bug-as-duplicate.txt'
--- lib/lp/bugs/stories/duplicate-bug-handling/10-mark-bug-as-duplicate.txt	2010-07-22 19:59:00 +0000
+++ lib/lp/bugs/stories/duplicate-bug-handling/10-mark-bug-as-duplicate.txt	1970-01-01 00:00:00 +0000
@@ -1,48 +0,0 @@
-First, visit the +duplicate page on the bug you're looking at:
-
-  >>> print http(r"""
-  ... GET /debian/+source/mozilla-firefox/+bug/2/+duplicate HTTP/1.1
-  ... Accept-Language: en-ca,en-us;q=0.8,en;q=0.5,fr-ca;q=0.3
-  ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-  ... """)
-  HTTP/1.1 200 Ok
-  ...
-
-Now, let's go ahead and mark bug 2 as a duplicate of bug 1.
-
-  >>> print http(r"""
-  ... POST /debian/+source/mozilla-firefox/+bug/2/+duplicate HTTP/1.1
-  ... Authorization: Basic Zm9vLmJhckBjYW5vbmljYWwuY29tOnRlc3Q=
-  ... Content-Length: 309
-  ... Content-Type: multipart/form-data; boundary=---------------------------17976867087135254391306687757
-  ...
-  ... -----------------------------17976867087135254391306687757
-  ... Content-Disposition: form-data; name="field.duplicateof"
-  ...
-  ... 1
-  ... -----------------------------17976867087135254391306687757
-  ... Content-Disposition: form-data; name="field.actions.change"
-  ...
-  ... Change
-  ... -----------------------------17976867087135254391306687757--
-  ... """)
-  HTTP/1.1 303 See Other
-  ...
-  Content-Length: 0
-  ...
-  Location: http://.../debian/+source/mozilla-firefox/+bug/2
-  ...
-
-When the bug is marked as a duplicate, a notification was generated, to
-tell bug 2's subscribers that their bug is a dupe of bug 1:
-
-    >>> from canonical.launchpad.database import BugNotification
-    >>> BugNotification.select().count()
-    1
-
-    >>> notification = BugNotification.select(orderBy='-id')[0]
-    >>> notification.bug.id
-    2
-    >>> print notification.message.text_contents
-    ** This bug has been marked a duplicate of bug 1
-    ...

=== removed file 'lib/lp/bugs/stories/duplicate-bug-handling/20-show-bug-is-duplicate.txt'
--- lib/lp/bugs/stories/duplicate-bug-handling/20-show-bug-is-duplicate.txt	2010-07-22 19:59:00 +0000
+++ lib/lp/bugs/stories/duplicate-bug-handling/20-show-bug-is-duplicate.txt	1970-01-01 00:00:00 +0000
@@ -1,41 +0,0 @@
-When a bug is marked as a duplicate of another bug, the duplicate bug's
-page shows this prominently, and the comment field is accompanied by an extra
-warning that it's a duplicate.
-
-    >>> user_browser.open(
-    ... 'http://launchpad.dev/debian/+source/mozilla-firefox/+bug/2')
-    >>> print find_tag_by_id(
-    ...     user_browser.contents, 'duplicate-of').renderContents()
-    bug #1...
-    >>> for message in find_tags_by_class(user_browser.contents, 'message'):
-    ...     print extract_text(message)
-    Remember, this bug report is a duplicate of bug #1. Comment here only if...
-
-The "Affects" lines are also not expandable, preventing people from changing
-the bug's status to no effect.
-
-    >>> affects_table = find_tags_by_class(user_browser.contents, 'listing')[0]
-    >>> "toggleFormVisibility('task" in affects_table
-    False
-
-If the bug is no longer marked as a duplicate,
-
-    >>> user_browser.getLink(id="change_duplicate_bug").click()
-    >>> user_browser.url
-    'http://bugs.launchpad.dev/debian/+source/mozilla-firefox/+bug/2/+duplicate'
-    >>> user_browser.getControl(name='field.duplicateof').value = ''
-    >>> user_browser.getControl('Change').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.dev/debian/+source/mozilla-firefox/+bug/2'
-
-the duplicate notifications disappear,
-
-    >>> find_tags_by_class(user_browser.contents, 'message')
-    []
-
-and the "Affects" lines become expandable once more.
-
-    >>> affects_table = find_tags_by_class(user_browser.contents, 'listing')[0]
-    >>> print affects_table
-    <...
-    ...toggleFormVisibility('task...

=== removed file 'lib/lp/bugs/stories/duplicate-bug-handling/xx-mark-duplicate-validation.txt'
--- lib/lp/bugs/stories/duplicate-bug-handling/xx-mark-duplicate-validation.txt	2010-07-22 19:59:00 +0000
+++ lib/lp/bugs/stories/duplicate-bug-handling/xx-mark-duplicate-validation.txt	1970-01-01 00:00:00 +0000
@@ -1,76 +0,0 @@
-=================================
-Mark as duplicate form validation
-=================================
-
-    >>> bg3 = 'http://bugs.launchpad.dev/debian/+source/mozilla-firefox/+bug/3'
-    >>> bug_3_dup_url = '%s/+duplicate' % bg3
-    >>> user_browser.open(bug_3_dup_url)
-
-Tests the marking of a bug that is a duplicate of a duplicate.
-
-    >>> user_browser.getControl('Duplicate Of').value = '6'
-    >>> user_browser.getControl('Change').click()
-    >>> print user_browser.contents
-    <...
-    <p class="error message">There is 1 error.</p>
-    ...
-    ...already a duplicate...
-
-Tests the marking of a bug that is a duplicate of itself.
-
-    >>> user_browser.getControl('Duplicate Of').value = '3'
-    >>> user_browser.getControl('Change').click()
-    >>> print user_browser.contents
-    <...
-    <p class="error message">There is 1 error.</p>
-    ...
-    ...can't mark a bug as a duplicate of itself...
-    ...
-
-Tests the marking of a bug that is already marked as duplicate with
-the same value. In this case, nothing should happen, since nothing
-changed.
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.dev/firefox/+bug/6/+duplicate')
-    >>> user_browser.getControl('Duplicate Of').value
-    '5'
-    >>> user_browser.getControl('Change').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.dev/firefox/+bug/6'
-
-Tests the input of data that is not a valid value.
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.dev/firefox/+bug/6/+duplicate')
-    >>> user_browser.getControl('Duplicate Of').value = 'xajskd'
-    >>> user_browser.getControl('Change').click()
-    >>> print user_browser.contents
-    <...
-    ...Not a valid bug number or nickname...
-    ...
-
-Tests using a bug nickname as the duplicate value
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.dev/evolution/+bug/7/+duplicate')
-    >>> user_browser.getControl('Duplicate Of').value = 'blackhole'
-    >>> user_browser.getControl('Change').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.dev/evolution/+bug/7'
-
-Marking a bugX as a duplicate of bugY is not allowed if bugX has dupes.
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.dev/firefox/+bug/5/+duplicate')
-    >>> user_browser.getControl('Duplicate Of').value = '4'
-    >>> user_browser.getControl('Change').click()
-    >>> print user_browser.contents
-    <...
-    ...There are other bugs already...
-    ...
-
-    >>> from canonical.launchpad.database import Bug
-    >>> Bug.get(5).duplicateof is None
-    True
-

=== modified file 'lib/lp/bugs/templates/bug-portlet-actions.pt'
--- lib/lp/bugs/templates/bug-portlet-actions.pt	2010-07-22 16:27:12 +0000
+++ lib/lp/bugs/templates/bug-portlet-actions.pt	2010-08-06 18:42:48 +0000
@@ -11,10 +11,11 @@
         tal:define="link context_menu/markduplicate"
         tal:condition="python: link.enabled and not
                       context.duplicateof"
+        class="sprite bug-dupe"
     >
     <a
       tal:attributes="href link/path"
-      class="menu-link-mark-dupe sprite bug-dupe">Mark as duplicate</a>
+      class="menu-link-mark-dupe">Mark as duplicate</a>
     </span>
     <tal:block
       tal:condition="context/duplicates"
@@ -31,8 +32,7 @@
         id="change_duplicate_bug"
         title="Edit or remove linked duplicate bug"
         class="sprite edit"
-        tal:attributes="href link/url"></a>
-      <span id="mark-duplicate-text">Duplicate of
+        tal:attributes="href link/url"></a><span id="mark-duplicate-text">Duplicate of
       <a
         tal:condition="duplicateof/required:launchpad.View"
         tal:attributes="href duplicateof/fmt:url; title

=== modified file 'lib/lp/bugs/tests/bug.py'
--- lib/lp/bugs/tests/bug.py	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/tests/bug.py	2010-08-06 18:42:48 +0000
@@ -196,7 +196,8 @@
     params = CreateBugParams(
         owner=no_priv, title=title, comment='Something is broken.')
     bug = target.createBug(params)
-    bug.duplicateof = duplicateof
+    if duplicateof is not None:
+        bug.markAsDuplicate(duplicateof)
     sample_person = getUtility(IPersonSet).getByEmail('test@xxxxxxxxxxxxx')
     if with_message is True:
         bug.newMessage(

=== modified file 'lib/lp/bugs/tests/bugs-emailinterface.txt'
--- lib/lp/bugs/tests/bugs-emailinterface.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/tests/bugs-emailinterface.txt	2010-08-06 18:42:48 +0000
@@ -926,32 +926,6 @@
     duplicate itself.
     ...
 
-If the specified bug has other bugs marked as a duplicate of it, an
-error message is sent telling you this.
-
-    >>> bug_four = getUtility(IBugSet).get(4)
-    >>> bug_four.duplicateof.id
-    2
-
-    >>> from canonical.launchpad.ftests import syncUpdate
-    >>> syncUpdate(bug_four)
-    >>> submit_commands(bug_four.duplicateof, 'duplicate 1')
-    >>> bug_four.duplicateof.id
-    2
-
-    >>> print_latest_email()
-    Subject: Submit Request Failure
-    To: test@xxxxxxxxxxxxx
-    <BLANKLINE>
-    ...
-    Failing command:
-        duplicate 1
-    ...
-    There are other bugs already marked as duplicates of Bug 2.
-    These bugs should be changed to be duplicates of another bug
-    if you are certain you would like to perform this change.
-    ...
-
 
 === cve $cve ===
 
@@ -1916,7 +1890,7 @@
     ... Subject: A bug with no affects
     ...
     ...  private yes
-    ...  duplicate 1
+    ...  unsubscribe
     ...  cve 1999-8979
     ... """
 

=== modified file 'lib/lp/bugs/tests/bugtarget-bugcount.txt'
--- lib/lp/bugs/tests/bugtarget-bugcount.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/tests/bugtarget-bugcount.txt	2010-08-06 18:42:48 +0000
@@ -129,7 +129,7 @@
     ...     bugtarget, "Another Test Bug", status=BugTaskStatus.NEW)
     >>> Store.of(another_bug).flush()
     >>> old_counts = bugtarget.getBugCounts(None)
-    >>> another_bug.duplicateof = bug
+    >>> another_bug.markAsDuplicate(bug)
     >>> syncUpdate(another_bug)
     >>> new_counts = bugtarget.getBugCounts(None)
     >>> print_count_difference(

=== modified file 'lib/lp/bugs/tests/test_bugchanges.py'
--- lib/lp/bugs/tests/test_bugchanges.py	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/tests/test_bugchanges.py	2010-08-06 18:42:48 +0000
@@ -90,7 +90,10 @@
         :return: The value of `attribute` before modification.
         """
         obj_before_modification = Snapshot(obj, providing=providedBy(obj))
-        setattr(obj, attribute, new_value)
+        if attribute == 'duplicateof':
+            obj.markAsDuplicate(new_value)
+        else:
+            setattr(obj, attribute, new_value)
         notify(ObjectModifiedEvent(
             obj, obj_before_modification, [attribute], self.user))
 
@@ -1304,7 +1307,7 @@
         duplicate_bug = self.factory.makeBug()
         duplicate_bug_recipients = duplicate_bug.getBugNotificationRecipients(
             level=BugNotificationLevel.METADATA).getRecipients()
-        duplicate_bug.duplicateof = self.bug
+        duplicate_bug.markAsDuplicate(self.bug)
         self.saveOldChanges(duplicate_bug)
         self.changeAttribute(duplicate_bug, 'duplicateof', None)
 
@@ -1335,7 +1338,7 @@
         bug_two = self.factory.makeBug()
         bug_recipients = self.bug.getBugNotificationRecipients(
             level=BugNotificationLevel.METADATA).getRecipients()
-        self.bug.duplicateof = bug_one
+        self.bug.markAsDuplicate(bug_one)
         self.saveOldChanges()
         self.changeAttribute(self.bug, 'duplicateof', bug_two)
 

=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
--- lib/lp/bugs/tests/test_bugnotification.py	2010-07-22 12:17:41 +0000
+++ lib/lp/bugs/tests/test_bugnotification.py	2010-08-06 18:42:48 +0000
@@ -155,7 +155,7 @@
             user='test@xxxxxxxxxxxxx')
         self.bug = self.factory.makeBug()
         self.dupe_bug = self.factory.makeBug()
-        self.dupe_bug.duplicateof = self.bug
+        self.dupe_bug.markAsDuplicate(self.bug)
         self.dupe_subscribers = set(
             self.dupe_bug.getDirectSubscribers() +
             self.dupe_bug.getIndirectSubscribers())

=== added file 'lib/lp/bugs/tests/test_duplicate_handling.py'
--- lib/lp/bugs/tests/test_duplicate_handling.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/tests/test_duplicate_handling.py	2010-08-06 18:42:48 +0000
@@ -0,0 +1,102 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for bug duplicate validation."""
+
+from textwrap import dedent
+import unittest
+
+from zope.security.interfaces import ForbiddenAttribute
+
+from canonical.testing import DatabaseFunctionalLayer
+
+from lp.bugs.interfaces.bug import InvalidDuplicateValue
+from lp.testing import TestCaseWithFactory
+
+
+class TestDuplicateAttributes(TestCaseWithFactory):
+    """Test bug attributes related to duplicate handling."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestDuplicateAttributes, self).setUp(user='test@xxxxxxxxxxxxx')
+
+    def setDuplicateofDirectly(self, bug, duplicateof):
+        """Helper method to set duplicateof directly."""
+        bug.duplicateof = duplicateof
+
+    def test_duplicateof_readonly(self):
+        # Test that no one can set duplicateof directly.
+        bug = self.factory.makeBug()
+        dupe_bug = self.factory.makeBug()
+        self.assertRaises(
+            ForbiddenAttribute, self.setDuplicateofDirectly, bug, dupe_bug)
+
+
+class TestMarkDuplicateValidation(TestCaseWithFactory):
+    """Test for validation around marking bug duplicates."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestMarkDuplicateValidation, self).setUp(
+            user='test@xxxxxxxxxxxxx')
+        self.bug = self.factory.makeBug()
+        self.dupe_bug = self.factory.makeBug()
+        self.dupe_bug.markAsDuplicate(self.bug)
+        self.possible_dupe = self.factory.makeBug()
+
+    def assertDuplicateError(self, bug, duplicateof, msg):
+        try:
+            bug.markAsDuplicate(duplicateof)
+        except InvalidDuplicateValue, err:
+            self.assertEqual(str(err), msg)
+
+    def test_error_on_duplicate_to_duplicate(self):
+        # Test that a bug cannot be marked a duplicate of
+        # a bug that is already itself a duplicate.
+        msg = dedent(u"""
+            Bug %s is already a duplicate of bug %s. You
+            can only mark a bug report as duplicate of one that
+            isn't a duplicate itself.
+            """ % (
+                self.dupe_bug.id, self.dupe_bug.duplicateof.id))
+        self.assertDuplicateError(
+            self.possible_dupe, self.dupe_bug, msg)
+
+    def test_error_duplicate_to_itself(self):
+        # Test that a bug cannot be marked its own duplicate
+        msg = dedent(u"""
+            You can't mark a bug as a duplicate of itself.""")
+        self.assertDuplicateError(self.bug, self.bug, msg)
+
+
+class TestMoveDuplicates(TestCaseWithFactory):
+    """Test duplicates are moved when master bug is marked a duplicate."""
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestMoveDuplicates, self).setUp(user='test@xxxxxxxxxxxxx')
+
+    def test_duplicates_are_moved(self):
+        # Confirm that a bug with two duplicates can be marked
+        # a duplicate of a new bug and that the duplicates will
+        # be re-marked as duplicates of the new bug, too.
+        bug = self.factory.makeBug()
+        dupe_one = self.factory.makeBug()
+        dupe_two = self.factory.makeBug()
+        dupe_one.markAsDuplicate(bug)
+        dupe_two.markAsDuplicate(bug)
+        self.assertEqual(dupe_one.duplicateof, bug)
+        self.assertEqual(dupe_two.duplicateof, bug)
+        new_bug = self.factory.makeBug()
+        bug.markAsDuplicate(new_bug)
+        self.assertEqual(bug.duplicateof, new_bug)
+        self.assertEqual(dupe_one.duplicateof, new_bug)
+        self.assertEqual(dupe_two.duplicateof, new_bug)
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/bugs/windmill/tests/test_mark_duplicate.py'
--- lib/lp/bugs/windmill/tests/test_mark_duplicate.py	2010-07-26 16:00:01 +0000
+++ lib/lp/bugs/windmill/tests/test_mark_duplicate.py	2010-08-06 18:42:48 +0000
@@ -44,10 +44,13 @@
         client.waits.forElement(
             xpath=MAIN_FORM_ELEMENT, timeout=constants.FOR_ELEMENT)
 
-        # Initially the form overlay is hidden
+        # Initially the form overlay is hidden...
         client.asserts.assertElemJS(
             xpath=MAIN_FORM_ELEMENT, js=FORM_NOT_VISIBLE)
 
+        # ...and there is an expandable form for editing bug status, etc.
+        client.asserts.assertNode(classname='bug-status-expand')
+
         # Clicking on the mark duplicate link brings up the formoverlay.
         # Entering 1 as the duplicate ID changes the duplicate text.
         client.click(classname=u'menu-link-mark-dupe')
@@ -66,7 +69,7 @@
             id='warning-comment-on-duplicate', timeout=constants.FOR_ELEMENT)
 
         # The duplicate can be cleared:
-        client.click(classname=u'menu-link-mark-dupe')
+        client.click(id=u'mark-duplicate-text')
         client.type(text=u'', id=u'field.duplicateof')
         client.click(xpath=CHANGE_BUTTON)
         client.waits.forElement(
@@ -77,7 +80,7 @@
         client.asserts.assertNotNode(id='warning-comment-on-duplicate')
 
         # Entering a false bug number results in input validation errors
-        client.click(classname=u'menu-link-mark-dupe')
+        client.click(id=u'mark-duplicate-text')
         client.type(text=u'123', id=u'field.duplicateof')
         client.click(xpath=CHANGE_BUTTON)
         error_xpath = (
@@ -105,6 +108,14 @@
             xpath=u"//h1[@id='bug-title']/span[1]",
             validator=u'Firefox does not support SVG')
 
+        # If someone wants to set the master to dupe another bug, there
+        # is a warning in the dupe widget about this bug having its own
+        # duplicates.
+        client.click(classname='menu-link-mark-dupe')
+        client.asserts.assertTextIn(
+            classname='large-warning', validator=u'This bug has duplicates',
+            timeout=constants.FOR_ELEMENT)
+
         # When we go back to the page for the duplicate bug...
         client.open(url=u'http://bugs.launchpad.dev:8085/bugs/15')
         client.waits.forPageLoad(timeout=constants.PAGE_LOAD)
@@ -115,6 +126,9 @@
         # as the one we saw before.
         client.asserts.assertNode(id='warning-comment-on-duplicate')
 
+        # Duplicate pages also do not have the expandable form on them.
+        client.asserts.assertNotNode(classname='bug-status-expand')
+
         # Once we remove the duplicate mark...
         client.click(id=u'change_duplicate_bug')
         client.type(text=u'', id=u'field.duplicateof')

=== modified file 'lib/lp/registry/browser/tests/person-views.txt'
--- lib/lp/registry/browser/tests/person-views.txt	2010-07-22 12:17:41 +0000
+++ lib/lp/registry/browser/tests/person-views.txt	2010-08-06 18:42:48 +0000
@@ -546,7 +546,7 @@
 
 But duplicate bugs are never displayed.
 
-    >>> another_bug.duplicateof = bug
+    >>> another_bug.markAsDuplicate(bug)
 
     # Create a new view because we're testing some cached properties.
 

=== modified file 'versions.cfg'
--- versions.cfg	2010-08-05 09:57:43 +0000
+++ versions.cfg	2010-08-06 18:42:48 +0000
@@ -64,9 +64,7 @@
 simplesettings = 0.4
 SimpleTal = 4.1
 sourcecodegen = 0.6.9
-# This is Storm 0.15 with r342 cherry-picked which fixes a memory leak
-# important for message sharing migration script.
-storm = 0.15danilo-storm-launchpad-r342
+storm = 0.17
 # Has the LessThan matcher.
 testtools = 0.9.6dev91
 transaction = 1.0.0