Transition to libbe.LOG for logging
[be.git] / libbe / bug.py
index 5f2cf541207230f05fb2af3efd5faf5568e04c0a..bb7a37da963a8db0bead90bf4f3ab1dd47a8eca9 100644 (file)
@@ -1,29 +1,34 @@
-# Copyright (C) 2008-2009 Gianluca Montecchi <gian@grys.it>
+# Copyright (C) 2008-2012 Chris Ball <cjb@laptop.org>
+#                         Gianluca Montecchi <gian@grys.it>
+#                         Niall Douglas (s_sourceforge@nedprod.com) <spam@spamtrap.com>
+#                         Robert Lehmann <mail@robertlehmann.de>
 #                         Thomas Habets <thomas@habets.pp.se>
-#                         W. Trevor King <wking@drexel.edu>
+#                         Valtteri Kokkoniemi <rvk@iki.fi>
+#                         W. Trevor King <wking@tremily.us>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# This file is part of Bugs Everywhere.
 #
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
+# Bugs Everywhere is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 2 of the License, or (at your option) any
+# later version.
 #
-# You should have received a copy of the GNU General Public License along
-# with this program; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+# Bugs Everywhere is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
 
-"""
-Define the Bug class for representing bugs.
+"""Define :py:class:`Bug` for representing bugs.
 """
 
 import copy
 import os
 import os.path
 import errno
+import sys
 import time
 import types
 try: # import core module, Python >= 2.5
@@ -31,22 +36,20 @@ try: # import core module, Python >= 2.5
 except ImportError: # look for non-core module
     from elementtree import ElementTree
 import xml.sax.saxutils
-import doctest
 
-from beuuid import uuid_gen
-from properties import Property, doc_property, local_property, \
-    defaulting_property, checked_property, cached_property, \
+import libbe
+import libbe.util.id
+from libbe.storage.util.properties import Property, doc_property, \
+    local_property, defaulting_property, checked_property, cached_property, \
     primed_property, change_hook_property, settings_property
-import settings_object
-import mapfile
-import comment
-import utility
+import libbe.storage.util.settings_object as settings_object
+import libbe.storage.util.mapfile as mapfile
+import libbe.comment as comment
+import libbe.util.utility as utility
 
+if libbe.TESTING == True:
+    import doctest
 
-class DiskAccessRequired (Exception):
-    def __init__(self, goal):
-        msg = "Cannot %s without accessing the disk" % goal
-        Exception.__init__(self, msg)
 
 ### Define and describe valid bug categories
 # Use a tuple of (category, description) tuples since we don't have
@@ -54,6 +57,7 @@ class DiskAccessRequired (Exception):
 
 # in order of increasing severity.  (name, description) pairs
 severity_def = (
+  ("target", "The issue is a target or milestone, not a bug."),
   ("wishlist","A feature that could improve usefulness, but not a bug."),
   ("minor","The standard bug level."),
   ("serious","A bug that requires workarounds."),
@@ -116,8 +120,12 @@ def load_status(active_status_def, inactive_status_def):
 load_status(active_status_def, inactive_status_def)
 
 
-class Bug(settings_object.SavedSettingsObject):
-    """
+class Bug (settings_object.SavedSettingsObject):
+    """A bug (or issue) is a place to store attributes and attach
+    :py:class:`~libbe.comment.Comment`\s.  In mailing-list terms, a bug is
+    analogous to a thread.  Bugs are normally stored in
+    :py:class:`~libbe.bugdir.BugDir`\s.
+
     >>> b = Bug()
     >>> print b.status
     open
@@ -127,6 +135,7 @@ class Bug(settings_object.SavedSettingsObject):
     There are two formats for time, int and string.  Setting either
     one will adjust the other appropriately.  The string form is the
     one stored in the bug's settings file on disk.
+
     >>> print type(b.time)
     <type 'int'>
     >>> print type(b.time_string)
@@ -166,15 +175,11 @@ class Bug(settings_object.SavedSettingsObject):
                          check_fn=lambda s: s in status_values,
                          require_save=True)
     def status(): return {}
-    
+
     @property
     def active(self):
         return self.status in active_status_values
 
-    @_versioned_property(name="target",
-                         doc="The deadline for fixing this bug")
-    def target(): return {}
-
     @_versioned_property(name="creator",
                          doc="The user who entered the bug into the system")
     def creator(): return {}
@@ -193,10 +198,19 @@ class Bug(settings_object.SavedSettingsObject):
 
     def _get_time(self):
         if self.time_string == None:
+            self._cached_time_string = None
+            self._cached_time = None
             return None
-        return utility.str_to_time(self.time_string)
+        if (not hasattr(self, '_cached_time_string')
+            or self.time_string != self._cached_time_string):
+            self._cached_time_string = self.time_string
+            self._cached_time = utility.str_to_time(self.time_string)
+        return self._cached_time
     def _set_time(self, value):
-        self.time_string = utility.time_to_str(value)
+        if not hasattr(self, '_cached_time') or value != self._cached_time:
+            self.time_string = utility.time_to_str(value)
+            self._cached_time_string = self.time_string
+            self._cached_time = value
     time = property(fget=_get_time,
                     fset=_set_time,
                     doc="An integer version of .time_string")
@@ -220,42 +234,35 @@ class Bug(settings_object.SavedSettingsObject):
     def summary(): return {}
 
     def _get_comment_root(self, load_full=False):
-        if self.sync_with_disk:
-            return comment.loadComments(self, load_full=load_full)
+        if self.storage != None and self.storage.is_readable():
+            return comment.load_comments(self, load_full=load_full)
         else:
             return comment.Comment(self, uuid=comment.INVALID_UUID)
 
     @Property
     @cached_property(generator=_get_comment_root)
     @local_property("comment_root")
-    @doc_property(doc="The trunk of the comment tree")
+    @doc_property(doc="The trunk of the comment tree.  We use a dummy root comment by default, because there can be several comment threads rooted on the same parent bug.  To simplify comment interaction, we condense these threads into a single thread with a Comment dummy root.")
     def comment_root(): return {}
 
-    def _get_vcs(self):
-        if hasattr(self.bugdir, "vcs"):
-            return self.bugdir.vcs
-
-    @Property
-    @cached_property(generator=_get_vcs)
-    @local_property("vcs")
-    @doc_property(doc="A revision control system instance.")
-    def vcs(): return {}
-
-    def __init__(self, bugdir=None, uuid=None, from_disk=False,
+    def __init__(self, bugdir=None, uuid=None, from_storage=False,
                  load_comments=False, summary=None):
         settings_object.SavedSettingsObject.__init__(self)
         self.bugdir = bugdir
+        self.storage = None
         self.uuid = uuid
-        if from_disk == True:
-            self.sync_with_disk = True
-        else:
-            self.sync_with_disk = False
+        self.id = libbe.util.id.ID(self, 'bug')
+        if from_storage == False:
             if uuid == None:
-                self.uuid = uuid_gen()
+                self.uuid = libbe.util.id.uuid_gen()
             self.time = int(time.time()) # only save to second precision
-            if self.vcs != None:
-                self.creator = self.vcs.get_user_id()
             self.summary = summary
+            dummy = self.comment_root
+        if self.bugdir != None:
+            self.storage = self.bugdir.storage
+        if from_storage == False:
+            if self.storage != None and self.storage.is_writeable():
+                self.save()
 
     def __repr__(self):
         return "Bug(uuid=%r)" % self.uuid
@@ -276,24 +283,142 @@ class Bug(settings_object.SavedSettingsObject):
             return str(value)
         return value
 
-    def xml(self, indent=0, shortname=None, show_comments=False):
-        if shortname == None:
-            if self.bugdir == None:
-                shortname = self.uuid
+    def string(self, shortlist=False, show_comments=False):
+        if shortlist == False:
+            if self.time == None:
+                timestring = ""
             else:
-                shortname = self.bugdir.bug_shortname(self)
+                htime = utility.handy_time(self.time)
+                timestring = "%s (%s)" % (htime, self.time_string)
+            info = [("ID", self.uuid),
+                    ("Short name", self.id.user()),
+                    ("Severity", self.severity),
+                    ("Status", self.status),
+                    ("Assigned", self._setting_attr_string("assigned")),
+                    ("Reporter", self._setting_attr_string("reporter")),
+                    ("Creator", self._setting_attr_string("creator")),
+                    ("Created", timestring)]
+            for estr in self.extra_strings:
+                info.append(('Extra string', estr))
+            longest_key_len = max([len(k) for k,v in info])
+            infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
+            bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
+        else:
+            statuschar = self.status[0]
+            severitychar = self.severity[0]
+            chars = "%c%c" % (statuschar, severitychar)
+            bugout = "%s:%s: %s" % (self.id.user(),chars,self.summary.rstrip('\n'))
+
+        if show_comments == True:
+            self.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
+            comout = self.comment_root.string_thread(flatten=False)
+            output = bugout + '\n' + comout.rstrip('\n')
+        else :
+            output = bugout
+        return output
 
+    def xml(self, indent=0, show_comments=False):
+        """
+        >>> bugA = Bug(uuid='0123', summary='Need to test Bug.xml()')
+        >>> bugA.uuid = 'bugA'
+        >>> bugA.time_string = 'Thu, 01 Jan 1970 00:00:00 +0000'
+        >>> bugA.creator = u'Frank'
+        >>> bugA.extra_strings += ['TAG: very helpful']
+        >>> commA = bugA.comment_root.new_reply(body='comment A')
+        >>> commA.uuid = 'commA'
+        >>> commA.date = 'Thu, 01 Jan 1970 00:01:00 +0000'
+        >>> commB = commA.new_reply(body='comment B')
+        >>> commB.uuid = 'commB'
+        >>> commB.date = 'Thu, 01 Jan 1970 00:02:00 +0000'
+        >>> commC = commB.new_reply(body='comment C')
+        >>> commC.uuid = 'commC'
+        >>> commC.date = 'Thu, 01 Jan 1970 00:03:00 +0000'
+        >>> print(bugA.xml(show_comments=True))  # doctest: +REPORT_UDIFF
+        <bug>
+          <uuid>bugA</uuid>
+          <short-name>/bug</short-name>
+          <severity>minor</severity>
+          <status>open</status>
+          <creator>Frank</creator>
+          <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+          <summary>Need to test Bug.xml()</summary>
+          <extra-string>TAG: very helpful</extra-string>
+          <comment>
+            <uuid>commA</uuid>
+            <short-name>/bug/commA</short-name>
+            <author></author>
+            <date>Thu, 01 Jan 1970 00:01:00 +0000</date>
+            <content-type>text/plain</content-type>
+            <body>comment A</body>
+          </comment>
+          <comment>
+            <uuid>commB</uuid>
+            <short-name>/bug/commB</short-name>
+            <in-reply-to>commA</in-reply-to>
+            <author></author>
+            <date>Thu, 01 Jan 1970 00:02:00 +0000</date>
+            <content-type>text/plain</content-type>
+            <body>comment B</body>
+          </comment>
+          <comment>
+            <uuid>commC</uuid>
+            <short-name>/bug/commC</short-name>
+            <in-reply-to>commB</in-reply-to>
+            <author></author>
+            <date>Thu, 01 Jan 1970 00:03:00 +0000</date>
+            <content-type>text/plain</content-type>
+            <body>comment C</body>
+          </comment>
+        </bug>
+        >>> print(bugA.xml(show_comments=True, indent=2))
+        ... # doctest: +REPORT_UDIFF
+          <bug>
+            <uuid>bugA</uuid>
+            <short-name>/bug</short-name>
+            <severity>minor</severity>
+            <status>open</status>
+            <creator>Frank</creator>
+            <created>Thu, 01 Jan 1970 00:00:00 +0000</created>
+            <summary>Need to test Bug.xml()</summary>
+            <extra-string>TAG: very helpful</extra-string>
+            <comment>
+              <uuid>commA</uuid>
+              <short-name>/bug/commA</short-name>
+              <author></author>
+              <date>Thu, 01 Jan 1970 00:01:00 +0000</date>
+              <content-type>text/plain</content-type>
+              <body>comment A</body>
+            </comment>
+            <comment>
+              <uuid>commB</uuid>
+              <short-name>/bug/commB</short-name>
+              <in-reply-to>commA</in-reply-to>
+              <author></author>
+              <date>Thu, 01 Jan 1970 00:02:00 +0000</date>
+              <content-type>text/plain</content-type>
+              <body>comment B</body>
+            </comment>
+            <comment>
+              <uuid>commC</uuid>
+              <short-name>/bug/commC</short-name>
+              <in-reply-to>commB</in-reply-to>
+              <author></author>
+              <date>Thu, 01 Jan 1970 00:03:00 +0000</date>
+              <content-type>text/plain</content-type>
+              <body>comment C</body>
+            </comment>
+          </bug>
+        """
         if self.time == None:
             timestring = ""
         else:
             timestring = utility.time_to_str(self.time)
 
         info = [('uuid', self.uuid),
-                ('short-name', shortname),
+                ('short-name', self.id.user()),
                 ('severity', self.severity),
                 ('status', self.status),
                 ('assigned', self.assigned),
-                ('target', self.target),
                 ('reporter', self.reporter),
                 ('creator', self.creator),
                 ('created', timestring),
@@ -305,18 +430,17 @@ class Bug(settings_object.SavedSettingsObject):
         for estr in self.extra_strings:
             lines.append('  <extra-string>%s</extra-string>' % estr)
         if show_comments == True:
-            comout = self.comment_root.xml_thread(indent=indent+2,
-                                                  auto_name_map=True,
-                                                  bug_shortname=shortname)
-            if len(comout) > 0:
+            comout = self.comment_root.xml_thread(indent=indent+2)
+            if comout:
+                comout = comout[indent:]  # strip leading indent spaces
                 lines.append(comout)
         lines.append('</bug>')
         istring = ' '*indent
         sep = '\n' + istring
         return istring + sep.join(lines).rstrip('\n')
 
-    def from_xml(self, xml_string, verbose=True):
-        """
+    def from_xml(self, xml_string, preserve_uuids=False):
+        u"""
         Note: If a bug uuid is given, set .alt_id to it's value.
         >>> bugA = Bug(uuid="0123", summary="Need to test Bug.from_xml()")
         >>> bugA.date = "Thu, 01 Jan 1970 00:00:00 +0000"
@@ -325,21 +449,25 @@ class Bug(settings_object.SavedSettingsObject):
         >>> commA = bugA.comment_root.new_reply(body='comment A')
         >>> commB = bugA.comment_root.new_reply(body='comment B')
         >>> commC = commA.new_reply(body='comment C')
-        >>> xml = bugA.xml(shortname="bug-1", show_comments=True)
+        >>> xml = bugA.xml(show_comments=True)
         >>> bugB = Bug()
-        >>> bugB.from_xml(xml, verbose=True)
-        >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
+        >>> bugB.from_xml(xml)
+        >>> bugB.xml(show_comments=True) == xml
         False
         >>> bugB.uuid = bugB.alt_id
         >>> for comm in bugB.comments():
         ...     comm.uuid = comm.alt_id
         ...     comm.alt_id = None
-        >>> bugB.xml(shortname="bug-1", show_comments=True) == xml
+        >>> bugB.xml(show_comments=True) == xml
         True
         >>> bugB.explicit_attrs  # doctest: +NORMALIZE_WHITESPACE
-        ['severity', 'status', 'creator', 'created', 'summary']
+        ['severity', 'status', 'creator', 'time', 'summary']
         >>> len(list(bugB.comments()))
         3
+        >>> bugC = Bug()
+        >>> bugC.from_xml(xml, preserve_uuids=True)
+        >>> bugC.uuid == bugA.uuid
+        True
         """
         if type(xml_string) == types.UnicodeType:
             xml_string = xml_string.strip().encode('unicode_escape')
@@ -349,48 +477,59 @@ class Bug(settings_object.SavedSettingsObject):
             bug = ElementTree.XML(xml_string)
         if bug.tag != 'bug':
             raise utility.InvalidXML( \
-                'bug', bug, 'root element must be <comment>')
-        tags=['uuid','short-name','severity','status','assigned','target',
+                'bug', bug, 'root element must be <bug>')
+        tags=['uuid','short-name','severity','status','assigned',
               'reporter', 'creator','created','summary','extra-string']
         self.explicit_attrs = []
         uuid = None
         estrs = []
+        comments = []
         for child in bug.getchildren():
             if child.tag == 'short-name':
                 pass
             elif child.tag == 'comment':
                 comm = comment.Comment(bug=self)
-                comm.from_xml(child)
-                self.add_comment(comm)
+                comm.from_xml(child, preserve_uuids=preserve_uuids)
+                comments.append(comm)
                 continue
             elif child.tag in tags:
                 if child.text == None or len(child.text) == 0:
                     text = settings_object.EMPTY
                 else:
                     text = xml.sax.saxutils.unescape(child.text)
-                    text = text.decode('unicode_escape').strip()
-                if child.tag == 'uuid':
+                    if not isinstance(text, unicode):
+                        text = text.decode('unicode_escape')
+                    text = text.strip()
+                if child.tag == 'uuid' and not preserve_uuids:
                     uuid = text
                     continue # don't set the bug's uuid tag.
+                elif child.tag == 'created':
+                    if text is not settings_object.EMPTY:
+                        self.time = utility.str_to_time(text)
+                        self.explicit_attrs.append('time')
+                    continue
                 elif child.tag == 'extra-string':
                     estrs.append(text)
                     continue # don't set the bug's extra_string yet.
                 attr_name = child.tag.replace('-','_')
                 self.explicit_attrs.append(attr_name)
                 setattr(self, attr_name, text)
-            elif verbose == True:
-                print >> sys.stderr, "Ignoring unknown tag %s in %s" \
-                    % (child.tag, comment.tag)
+            else:
+                libbe.LOG.warning(
+                    'ignoring unknown tag {0} in {1}'.format(
+                        child.tag, comment.tag))
         if uuid != self.uuid:
             if not hasattr(self, 'alt_id') or self.alt_id == None:
                 self.alt_id = uuid
         self.extra_strings = estrs
+        self.add_comments(comments, ignore_missing_references=True)
 
-    def add_comment(self, new_comment):
+    def add_comment(self, comment, *args, **kwargs):
         """
         Add a comment too the current bug, under the parent specified
         by comment.in_reply_to.
         Note: If a bug uuid is given, set .alt_id to it's value.
+
         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.add_comment()')
         >>> bugA.creator = 'Jack'
         >>> commA = bugA.comment_root.new_reply(body='comment A')
@@ -402,10 +541,10 @@ class Bug(settings_object.SavedSettingsObject):
         >>> commC.uuid = 'commC'
         >>> commC.in_reply_to = commA.uuid
         >>> bugA.add_comment(commC)
-        >>> print bugA.xml(shortname="bug-1", show_comments=True)  # doctest: +ELLIPSIS
+        >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
         <bug>
           <uuid>0123</uuid>
-          <short-name>bug-1</short-name>
+          <short-name>/012</short-name>
           <severity>minor</severity>
           <status>open</status>
           <creator>Jack</creator>
@@ -413,7 +552,7 @@ class Bug(settings_object.SavedSettingsObject):
           <summary>Need to test Bug.add_comment()</summary>
           <comment>
             <uuid>commA</uuid>
-            <short-name>bug-1:1</short-name>
+            <short-name>/012/commA</short-name>
             <author></author>
             <date>...</date>
             <content-type>text/plain</content-type>
@@ -421,7 +560,7 @@ class Bug(settings_object.SavedSettingsObject):
           </comment>
           <comment>
             <uuid>commC</uuid>
-            <short-name>bug-1:2</short-name>
+            <short-name>/012/commC</short-name>
             <in-reply-to>commA</in-reply-to>
             <author></author>
             <date>...</date>
@@ -430,7 +569,7 @@ class Bug(settings_object.SavedSettingsObject):
           </comment>
           <comment>
             <uuid>commB</uuid>
-            <short-name>bug-1:3</short-name>
+            <short-name>/012/commB</short-name>
             <author></author>
             <date>...</date>
             <content-type>text/plain</content-type>
@@ -438,27 +577,57 @@ class Bug(settings_object.SavedSettingsObject):
           </comment>
         </bug>
         """
+        self.add_comments([comment], **kwargs)
+
+    def add_comments(self, comments, default_parent=None,
+                     ignore_missing_references=False):
+        """
+        Convert a raw list of comments to single root comment.  If a
+        comment does not specify a parent with .in_reply_to, the
+        parent defaults to .comment_root, but you can specify another
+        default parent via default_parent.
+        """
         uuid_map = {}
-        for c in self.comments():
+        if default_parent == None:
+            default_parent = self.comment_root
+        for c in list(self.comments()) + comments:
+            assert c.uuid != None
+            assert c.uuid not in uuid_map
             uuid_map[c.uuid] = c
             if c.alt_id != None:
                 uuid_map[c.alt_id] = c
-        assert new_comment.uuid not in uuid_map
-        if new_comment.alt_id != None:
-            assert new_comment.alt_id not in uuid_map
-        if new_comment.in_reply_to == comment.INVALID_UUID:
-            new_comment.in_reply_to = None
-        if new_comment.in_reply_to == None:
-            parent = self.comment_root
-        else:
-            parent = uuid_map[new_comment.in_reply_to]
-        new_comment.bug = self
-        parent.append(new_comment)
+        uuid_map[None] = self.comment_root
+        uuid_map[comment.INVALID_UUID] = self.comment_root
+        if default_parent != self.comment_root:
+            assert default_parent.uuid in uuid_map, default_parent.uuid
+        for c in comments:
+            if c.in_reply_to == None \
+                    and default_parent.uuid != comment.INVALID_UUID:
+                c.in_reply_to = default_parent.uuid
+            elif c.in_reply_to == comment.INVALID_UUID:
+                c.in_reply_to = None
+            try:
+                parent = uuid_map[c.in_reply_to]
+            except KeyError:
+                if ignore_missing_references == True:
+                    libbe.LOG.warning(
+                        'ignoring missing reference to {0}'.format(
+                            c.in_reply_to))
+                    parent = default_parent
+                    if parent.uuid != comment.INVALID_UUID:
+                        c.in_reply_to = parent.uuid
+                else:
+                    raise comment.MissingReference(c)
+            c.bug = self
+            parent.append(c)
 
-    def merge(self, other, allow_changes=True, allow_new_comments=True):
+    def merge(self, other, accept_changes=True,
+              accept_extra_strings=True, accept_comments=True,
+              change_exception=False):
         """
         Merge info from other into this bug.  Overrides any attributes
         in self that are listed in other.explicit_attrs.
+
         >>> bugA = Bug(uuid='0123', summary='Need to test Bug.merge()')
         >>> bugA.date = 'Thu, 01 Jan 1970 00:00:00 +0000'
         >>> bugA.creator = 'Frank'
@@ -474,19 +643,39 @@ class Bug(settings_object.SavedSettingsObject):
         >>> bugB.extra_strings += ['TAG: useful']
         >>> commB = bugB.comment_root.new_reply(body='comment B')
         >>> commB.uuid = 'uuid-commB'
-        >>> bugA.merge(bugB, allow_changes=False)
+        >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
+        ...            accept_comments=False, change_exception=False)
+        >>> print bugA.creator
+        Frank
+        >>> bugA.merge(bugB, accept_changes=False, accept_extra_strings=False,
+        ...            accept_comments=False, change_exception=True)
         Traceback (most recent call last):
           ...
         ValueError: Merge would change creator "Frank"->"John" for bug 0123
-        >>> bugA.merge(bugB, allow_new_comments=False)
+        >>> print bugA.creator
+        Frank
+        >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=False,
+        ...            accept_comments=False, change_exception=True)
+        Traceback (most recent call last):
+          ...
+        ValueError: Merge would add extra string "TAG: useful" for bug 0123
+        >>> print bugA.creator
+        John
+        >>> print bugA.extra_strings
+        ['TAG: favorite', 'TAG: very helpful']
+        >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
+        ...            accept_comments=False, change_exception=True)
         Traceback (most recent call last):
           ...
         ValueError: Merge would add comment uuid-commB (alt: None) to bug 0123
-        >>> bugA.merge(bugB)
+        >>> print bugA.extra_strings
+        ['TAG: favorite', 'TAG: useful', 'TAG: very helpful']
+        >>> bugA.merge(bugB, accept_changes=True, accept_extra_strings=True,
+        ...            accept_comments=True, change_exception=True)
         >>> print bugA.xml(show_comments=True)  # doctest: +ELLIPSIS
         <bug>
           <uuid>0123</uuid>
-          <short-name>0123</short-name>
+          <short-name>/012</short-name>
           <severity>minor</severity>
           <status>open</status>
           <creator>John</creator>
@@ -497,7 +686,7 @@ class Bug(settings_object.SavedSettingsObject):
           <extra-string>TAG: very helpful</extra-string>
           <comment>
             <uuid>uuid-commA</uuid>
-            <short-name>0123:1</short-name>
+            <short-name>/012/uuid-commA</short-name>
             <author></author>
             <date>...</date>
             <content-type>text/plain</content-type>
@@ -505,7 +694,7 @@ class Bug(settings_object.SavedSettingsObject):
           </comment>
           <comment>
             <uuid>uuid-commB</uuid>
-            <short-name>0123:2</short-name>
+            <short-name>/012/uuid-commB</short-name>
             <author></author>
             <date>...</date>
             <content-type>text/plain</content-type>
@@ -513,23 +702,25 @@ class Bug(settings_object.SavedSettingsObject):
           </comment>
         </bug>
         """
-        for attr in other.explicit_attrs:
-            old = getattr(self, attr)
-            new = getattr(other, attr)
-            if old != new:
-                if allow_changes == True:
-                    setattr(self, attr, new)
-                else:
-                    raise ValueError, \
-                        'Merge would change %s "%s"->"%s" for bug %s' \
-                        % (attr, old, new, self.uuid)
-        if allow_changes == False and len(other.extra_strings) > 0:
-            raise ValueError, \
-                'Merge would change extra_strings for bug %s' % self.uuid
+        if hasattr(other, 'explicit_attrs'):
+            for attr in other.explicit_attrs:
+                old = getattr(self, attr)
+                new = getattr(other, attr)
+                if old != new:
+                    if accept_changes:
+                        setattr(self, attr, new)
+                    elif change_exception:
+                        raise ValueError(
+                            ('Merge would change {} "{}"->"{}" for bug {}'
+                             ).format(attr, old, new, self.uuid))
         for estr in other.extra_strings:
             if not estr in self.extra_strings:
-                self.extra_strings.append(estr)
-        import sys
+                if accept_extra_strings == True:
+                    self.extra_strings += [estr]
+                elif change_exception == True:
+                    raise ValueError, \
+                        'Merge would add extra string "%s" for bug %s' \
+                        % (estr, self.uuid)
         for o_comm in other.comments():
             try:
                 s_comm = self.comment_root.comment_from_uuid(o_comm.uuid)
@@ -539,129 +730,82 @@ class Bug(settings_object.SavedSettingsObject):
                 except KeyError, e:
                     s_comm = None
             if s_comm == None:
-                if allow_new_comments == False:
+                if accept_comments == True:
+                    o_comm_copy = copy.copy(o_comm)
+                    o_comm_copy.bug = self
+                    o_comm_copy.id = libbe.util.id.ID(o_comm_copy, 'comment')
+                    self.comment_root.add_reply(o_comm_copy)
+                elif change_exception == True:
                     raise ValueError, \
                         'Merge would add comment %s (alt: %s) to bug %s' \
                         % (o_comm.uuid, o_comm.alt_id, self.uuid)
-                o_comm_copy = copy.copy(o_comm)
-                o_comm_copy.bug = self
-                print >> sys.stderr, "add comment %s" % o_comm.uuid
-                self.comment_root.add_reply(o_comm_copy)
             else:
-                print >> sys.stderr, "merge comment %s into %s" % (o_comm.uuid, s_comm.uuid)
-                s_comm.merge(o_comm, allow_changes=allow_changes)
-
-    def string(self, shortlist=False, show_comments=False):
-        if self.bugdir == None:
-            shortname = self.uuid
-        else:
-            shortname = self.bugdir.bug_shortname(self)
-        if shortlist == False:
-            if self.time == None:
-                timestring = ""
-            else:
-                htime = utility.handy_time(self.time)
-                timestring = "%s (%s)" % (htime, self.time_string)
-            info = [("ID", self.uuid),
-                    ("Short name", shortname),
-                    ("Severity", self.severity),
-                    ("Status", self.status),
-                    ("Assigned", self._setting_attr_string("assigned")),
-                    ("Target", self._setting_attr_string("target")),
-                    ("Reporter", self._setting_attr_string("reporter")),
-                    ("Creator", self._setting_attr_string("creator")),
-                    ("Created", timestring)]
-            longest_key_len = max([len(k) for k,v in info])
-            infolines = ["  %*s : %s\n" %(longest_key_len,k,v) for k,v in info]
-            bugout = "".join(infolines) + "%s" % self.summary.rstrip('\n')
-        else:
-            statuschar = self.status[0]
-            severitychar = self.severity[0]
-            chars = "%c%c" % (statuschar, severitychar)
-            bugout = "%s:%s: %s" % (shortname,chars,self.summary.rstrip('\n'))
-        
-        if show_comments == True:
-            # take advantage of the string_thread(auto_name_map=True)
-            # SIDE-EFFECT of sorting by comment time.
-            comout = self.comment_root.string_thread(flatten=False,
-                                                     auto_name_map=True,
-                                                     bug_shortname=shortname)
-            output = bugout + '\n' + comout.rstrip('\n')
-        else :
-            output = bugout
-        return output
+                s_comm.merge(o_comm, accept_changes=accept_changes,
+                             accept_extra_strings=accept_extra_strings,
+                             change_exception=change_exception)
 
     # methods for saving/loading/acessing settings and properties.
 
-    def get_path(self, *args):
-        dir = os.path.join(self.bugdir.get_path("bugs"), self.uuid)
-        if len(args) == 0:
-            return dir
-        assert args[0] in ["values", "comments"], str(args)
-        return os.path.join(dir, *args)
-
-    def set_sync_with_disk(self, value):
-        self.sync_with_disk = value
-        for comment in self.comments():
-            comment.set_sync_with_disk(value)
-
-    def load_settings(self):
-        if self.sync_with_disk == False:
-            raise DiskAccessRequired("load settings")
-        self.settings = mapfile.map_load(self.vcs, self.get_path("values"))
-        self._setup_saved_settings()
+    def load_settings(self, settings_mapfile=None):
+        if settings_mapfile == None:
+            settings_mapfile = self.storage.get(
+                self.id.storage('values'), '{}\n')
+        try:
+            settings = mapfile.parse(settings_mapfile)
+        except mapfile.InvalidMapfileContents, e:
+            raise Exception('Invalid settings file for bug %s\n'
+                            '(BE version missmatch?)' % self.id.user())
+        self._setup_saved_settings(settings)
 
     def save_settings(self):
-        if self.sync_with_disk == False:
-            raise DiskAccessRequired("save settings")
-        assert self.summary != None, "Can't save blank bug"
-        self.vcs.mkdir(self.get_path())
-        path = self.get_path("values")
-        mapfile.map_save(self.vcs, path, self._get_saved_settings())
+        mf = mapfile.generate(self._get_saved_settings())
+        self.storage.set(self.id.storage('values'), mf)
 
     def save(self):
         """
-        Save any loaded contents to disk.  Because of lazy loading of
-        comments, this is actually not too inefficient.
-        
-        However, if self.sync_with_disk = True, then any changes are
-        automatically written to disk as soon as they happen, so
-        calling this method will just waste time (unless something
-        else has been messing with your on-disk files).
+        Save any loaded contents to storage.  Because of lazy loading
+        of comments, this is actually not too inefficient.
+
+        However, if self.storage.is_writeable() == True, then any
+        changes are automatically written to storage as soon as they
+        happen, so calling this method will just waste time (unless
+        something else has been messing with your stored files).
         """
-        sync_with_disk = self.sync_with_disk
-        if sync_with_disk == False:
-            self.set_sync_with_disk(True)
+        assert self.storage != None, "Can't save without storage"
+        if self.bugdir != None:
+            parent = self.bugdir.id.storage()
+        else:
+            parent = None
+        self.storage.add(self.id.storage(), parent=parent, directory=True)
+        self.storage.add(self.id.storage('values'), parent=self.id.storage(),
+                         directory=False)
         self.save_settings()
         if len(self.comment_root) > 0:
-            comment.saveComments(self)
-        if sync_with_disk == False:
-            self.set_sync_with_disk(False)
+            comment.save_comments(self)
 
     def load_comments(self, load_full=True):
-        if self.sync_with_disk == False:
-            raise DiskAccessRequired("load comments")
         if load_full == True:
             # Force a complete load of the whole comment tree
             self.comment_root = self._get_comment_root(load_full=True)
         else:
             # Setup for fresh lazy-loading.  Clear _comment_root, so
-            # _get_comment_root returns a fresh version.  Turn of
-            # syncing temporarily so we don't write our blank comment
+            # next _get_comment_root returns a fresh version.  Turn of
+            # writing temporarily so we don't write our blank comment
             # tree to disk.
-            self.sync_with_disk = False
+            w = self.storage.writeable
+            self.storage.writeable = False
             self.comment_root = None
-            self.sync_with_disk = True
+            self.storage.writeable = w
 
     def remove(self):
-        if self.sync_with_disk == False:
-            raise DiskAccessRequired("remove")
-        self.comment_root.remove()
-        path = self.get_path()
-        self.vcs.recursive_remove(path)
-    
+        self.storage.recursive_remove(self.id.storage())
+
     # methods for managing comments
 
+    def uuids(self):
+        for comment in self.comments():
+            yield comment.uuid
+
     def comments(self):
         for comment in self.comment_root.traverse():
             yield comment
@@ -670,20 +814,15 @@ class Bug(settings_object.SavedSettingsObject):
         comm = self.comment_root.new_reply(body=body)
         return comm
 
-    def comment_from_shortname(self, shortname, *args, **kwargs):
-        return self.comment_root.comment_from_shortname(shortname,
-                                                        *args, **kwargs)
-
     def comment_from_uuid(self, uuid, *args, **kwargs):
         return self.comment_root.comment_from_uuid(uuid, *args, **kwargs)
 
-    def comment_shortnames(self, shortname=None):
-        """
-        SIDE-EFFECT : Comment.comment_shortnames will sort the comment
-        tree by comment.time
-        """
-        for id, comment in self.comment_root.comment_shortnames(shortname):
-            yield (id, comment)
+    # methods for id generation
+
+    def sibling_uuids(self):
+        if self.bugdir != None:
+            return self.bugdir.uuids()
+        return []
 
 
 # The general rule for bug sorting is that "more important" bugs are
@@ -696,6 +835,7 @@ def cmp_severity(bug_1, bug_2):
     """
     Compare the severity levels of two bugs, with more severe bugs
     comparing as less.
+
     >>> bugA = Bug()
     >>> bugB = Bug()
     >>> bugA.severity = bugB.severity = "wishlist"
@@ -714,8 +854,9 @@ def cmp_severity(bug_1, bug_2):
 
 def cmp_status(bug_1, bug_2):
     """
-    Compare the status levels of two bugs, with more 'open' bugs
+    Compare the status levels of two bugs, with more "open" bugs
     comparing as less.
+
     >>> bugA = Bug()
     >>> bugB = Bug()
     >>> bugA.status = bugB.status = "open"
@@ -735,9 +876,10 @@ def cmp_status(bug_1, bug_2):
 
 def cmp_attr(bug_1, bug_2, attr, invert=False):
     """
-    Compare a general attribute between two bugs using the conventional
-    comparison rule for that attribute type.  If invert == True, sort
-    *against* that convention.
+    Compare a general attribute between two bugs using the
+    conventional comparison rule for that attribute type.  If
+    ``invert==True``, sort *against* that convention.
+
     >>> attr="severity"
     >>> bugA = Bug()
     >>> bugB = Bug()
@@ -757,7 +899,7 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
     val_2 = getattr(bug_2, attr)
     if val_1 == None: val_1 = None
     if val_2 == None: val_2 = None
-    
+
     if invert == True :
         return -cmp(val_1, val_2)
     else :
@@ -767,13 +909,18 @@ def cmp_attr(bug_1, bug_2, attr, invert=False):
 cmp_uuid = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "uuid")
 cmp_creator = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "creator")
 cmp_assigned = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "assigned")
-cmp_target = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "target")
 cmp_reporter = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "reporter")
 cmp_summary = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "summary")
 cmp_extra_strings = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "extra_strings")
 # chronological rankings (newer < older)
 cmp_time = lambda bug_1, bug_2 : cmp_attr(bug_1, bug_2, "time", invert=True)
 
+def cmp_mine(bug_1, bug_2):
+    user_id = libbe.ui.util.user.get_user_id(bug_1.storage)
+    mine_1 = bug_1.assigned != user_id
+    mine_2 = bug_2.assigned != user_id
+    return cmp(mine_1, mine_2)
+
 def cmp_comments(bug_1, bug_2):
     """
     Compare two bugs' comments lists.  Doesn't load any new comments,
@@ -793,8 +940,7 @@ def cmp_comments(bug_1, bug_2):
 
 DEFAULT_CMP_FULL_CMP_LIST = \
     (cmp_status, cmp_severity, cmp_assigned, cmp_time, cmp_creator,
-     cmp_reporter, cmp_target, cmp_comments, cmp_summary, cmp_uuid,
-     cmp_extra_strings)
+     cmp_reporter, cmp_comments, cmp_summary, cmp_uuid, cmp_extra_strings)
 
 class BugCompoundComparator (object):
     def __init__(self, cmp_list=DEFAULT_CMP_FULL_CMP_LIST):
@@ -805,7 +951,7 @@ class BugCompoundComparator (object):
             if val != 0 :
                 return val
         return 0
-        
+
 cmp_full = BugCompoundComparator()
 
 
@@ -826,4 +972,5 @@ def cmp_last_modified(bug_1, bug_2):
     return -cmp(val_1, val_2)
 
 
-suite = doctest.DocTestSuite()
+if libbe.TESTING == True:
+    suite = doctest.DocTestSuite()