← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:comment-editing-revisions-api into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:comment-editing-revisions-api into launchpad:master with ~pappacena/launchpad:comment-editing-api as a prerequisite.

Commit message:
API to get and delete comment's revision history for bug messages, answers and code review comments

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/402285
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:comment-editing-revisions-api into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index a61d39d..4b1ecff 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -149,6 +149,7 @@ from lp.services.messages.interfaces.message import (
     IMessage,
     IUserToUserEmail,
     )
+from lp.services.messages.interfaces.messagerevision import IMessageRevision
 from lp.services.webservice.apihelpers import (
     patch_collection_property,
     patch_collection_return_type,
@@ -612,6 +613,7 @@ patch_reference_property(IIndexedMessage, 'inside', IBugTask)
 
 # IMessage
 patch_reference_property(IMessage, 'owner', IPerson)
+patch_collection_property(IMessage, 'revisions', IMessageRevision)
 
 # IUserToUserEmail
 patch_reference_property(IUserToUserEmail, 'sender', IPerson)
diff --git a/lib/lp/answers/browser/configure.zcml b/lib/lp/answers/browser/configure.zcml
index 631e8b8..87a6b76 100644
--- a/lib/lp/answers/browser/configure.zcml
+++ b/lib/lp/answers/browser/configure.zcml
@@ -282,6 +282,10 @@
     module=".question"
     classes="QuestionNavigation"
     />
+  <browser:navigation
+    module=".question"
+    classes="QuestionMessageNavigation"
+    />
 
   <browser:url
     for="lp.answers.interfaces.questioncollection.IQuestionSet"
diff --git a/lib/lp/answers/browser/question.py b/lib/lp/answers/browser/question.py
index 757da13..18b4c47 100644
--- a/lib/lp/answers/browser/question.py
+++ b/lib/lp/answers/browser/question.py
@@ -71,6 +71,7 @@ from lp.answers.interfaces.question import (
     IQuestionLinkFAQForm,
     )
 from lp.answers.interfaces.questioncollection import IQuestionSet
+from lp.answers.interfaces.questionmessage import IQuestionMessage
 from lp.answers.interfaces.questiontarget import (
     IAnswersFrontPageSearchForm,
     IQuestionTarget,
@@ -269,6 +270,17 @@ class QuestionNavigation(Navigation):
         return self.context.messages[index]
 
 
+class QuestionMessageNavigation(Navigation):
+    """Navigation for the IQuestionMessage."""
+
+    usedfor = IQuestionMessage
+
+    @stepthrough('revisions')
+    def traverse_comments(self, index):
+        index = int(index) - 1
+        return self.context.revisions[index]
+
+
 class QuestionBreadcrumb(Breadcrumb):
     """Builds a breadcrumb for an `IQuestion`."""
 
diff --git a/lib/lp/answers/stories/webservice.txt b/lib/lp/answers/stories/webservice.txt
index eb01af6..9df228f 100644
--- a/lib/lp/answers/stories/webservice.txt
+++ b/lib/lp/answers/stories/webservice.txt
@@ -242,6 +242,7 @@ that indicate how the message changed the question.
     parent_link: None
     question_link: 'http://api.launchpad.test/devel/my-project/+question/...'
     resource_type_link: 'http://api.launchpad.test/devel/#question_message'
+    revisions_collection_link: 'http://...'
     self_link:
         'http://api.launchpad.test/devel/my-project/+question/.../messages/1'
     subject: 'Re: Q 1 great'
diff --git a/lib/lp/bugs/browser/bugcomment.py b/lib/lp/bugs/browser/bugcomment.py
index e40967f..2302d83 100644
--- a/lib/lp/bugs/browser/bugcomment.py
+++ b/lib/lp/bugs/browser/bugcomment.py
@@ -49,6 +49,8 @@ from lp.services.propertycache import (
 from lp.services.webapp import (
     canonical_url,
     LaunchpadView,
+    Navigation,
+    stepthrough,
     )
 from lp.services.webapp.breadcrumb import Breadcrumb
 from lp.services.webapp.interfaces import ILaunchBag
@@ -57,6 +59,16 @@ from lp.services.webapp.interfaces import ILaunchBag
 COMMENT_ACTIVITY_GROUPING_WINDOW = timedelta(minutes=5)
 
 
+class BugCommentNavigation(Navigation):
+    """Navigation for the `IBugComment`."""
+    usedfor = IBugComment
+
+    @stepthrough('revisions')
+    def traverse_comments(self, index):
+        index = int(index) - 1
+        return self.context.revisions[index]
+
+
 def build_comments_from_chunks(
         bugtask, truncate=False, slice_info=None, show_spam_controls=False,
         user=None, hide_first=False):
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index ac80819..d9c36a8 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2010-2014 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2010-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -163,6 +163,10 @@
             path_expression="string:comments/${index}"
             attribute_to_parent="bugtask"
             rootsite="bugs"/>
+          <browser:navigation
+            module=".bugcomment"
+            classes="BugCommentNavigation"
+            />
         <browser:page
             for="lp.bugs.interfaces.bugmessage.IBugComment"
             name="+index"
diff --git a/lib/lp/bugs/stories/webservice/xx-bug.txt b/lib/lp/bugs/stories/webservice/xx-bug.txt
index 0c43c88..17fa5e7 100644
--- a/lib/lp/bugs/stories/webservice/xx-bug.txt
+++ b/lib/lp/bugs/stories/webservice/xx-bug.txt
@@ -239,6 +239,7 @@ Each bug has a collection of messages.
     owner_link: 'http://.../~name12'
     parent_link: None
     resource_type_link: 'http://.../#message'
+    revisions_collection_link: '...'
     self_link: 'http://.../firefox/+bug/5/comments/0'
     subject: 'Firefox install instructions should be complete'
     web_link: 'http://bugs.../firefox/+bug/5/comments/0'
@@ -284,6 +285,7 @@ We can add a new message to a bug by calling the newMessage method.
     content: 'This is a new message added through the webservice API.'
     ...
     resource_type_link: 'http://api.launchpad.test/beta/#message'
+    revisions_collection_link: '...'
     self_link: 'http://api.launchpad.test/beta/firefox/+bug/5/comments/1'
     subject: 'A new message'
     web_link: '...'
@@ -1354,6 +1356,7 @@ attachment. This is where our comment is recorded.
     owner_link: 'http://.../~salgado'
     parent_link: None
     resource_type_link: 'http://.../#message'
+    revisions_collection_link: '...'
     self_link: 'http://.../firefox/+bug/1/comments/2'
     subject: 'Re: Firefox does not support SVG'
     web_link: 'http://bugs.../firefox/+bug/1/comments/2'
diff --git a/lib/lp/code/browser/codereviewcomment.py b/lib/lp/code/browser/codereviewcomment.py
index 0d0496d..c4ca667 100644
--- a/lib/lp/code/browser/codereviewcomment.py
+++ b/lib/lp/code/browser/codereviewcomment.py
@@ -56,10 +56,22 @@ from lp.services.webapp import (
     ContextMenu,
     LaunchpadView,
     Link,
+    Navigation,
+    stepthrough,
     )
 from lp.services.webapp.interfaces import ILaunchBag
 
 
+class CodeReviewCommentNavigation(Navigation):
+    """Navigation for the `ICodeReviewComment`."""
+    usedfor = ICodeReviewComment
+
+    @stepthrough('revisions')
+    def traverse_comments(self, index):
+        index = int(index) - 1
+        return self.context.revisions[index]
+
+
 class ICodeReviewDisplayComment(IComment, ICodeReviewComment):
     """Marker interface for displaying code review comments."""
     message = Object(schema=IMessage, title=_('The message.'))
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index e7943c0..f7bab39 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -556,6 +556,9 @@
         path_expression="string:comments/${id}"
         attribute_to_parent="branch_merge_proposal"
         rootsite="code"/>
+    <browser:navigation
+        module=".codereviewcomment"
+        classes="CodeReviewCommentNavigation" />
     <browser:defaultView
         for="lp.code.interfaces.codereviewcomment.ICodeReviewComment"
         name="+index"/>
diff --git a/lib/lp/security.py b/lib/lp/security.py
index cd88d5c..0ed1a2d 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -187,6 +187,7 @@ from lp.services.identity.interfaces.account import IAccount
 from lp.services.identity.interfaces.emailaddress import IEmailAddress
 from lp.services.librarian.interfaces import ILibraryFileAliasWithParent
 from lp.services.messages.interfaces.message import IMessage
+from lp.services.messages.interfaces.messagerevision import IMessageRevision
 from lp.services.oauth.interfaces import (
     IOAuthAccessToken,
     IOAuthRequestToken,
@@ -3191,6 +3192,16 @@ class EditMessage(AuthorizationBase):
         return user.isOwner(self.obj)
 
 
+class EditMessageRevision(DelegatedAuthorization):
+    """Only the message owner can edit the message revision history."""
+    permission = 'launchpad.Edit'
+    usedfor = IMessageRevision
+
+    def __init__(self, obj):
+        super(EditMessageRevision, self).__init__(
+            obj, obj.message, 'launchpad.Edit')
+
+
 class ViewPublisherConfig(AdminByAdminsTeam):
     usedfor = IPublisherConfig
 
diff --git a/lib/lp/services/messages/browser/configure.zcml b/lib/lp/services/messages/browser/configure.zcml
new file mode 100644
index 0000000..8218040
--- /dev/null
+++ b/lib/lp/services/messages/browser/configure.zcml
@@ -0,0 +1,10 @@
+<configure
+  xmlns="http://namespaces.zope.org/zope";
+  xmlns:browser="http://namespaces.zope.org/browser";
+  xmlns:i18n="http://namespaces.zope.org/i18n";
+  i18n_domain="launchpad">
+  <browser:url
+    for="lp.services.messages.interfaces.messagerevision.IMessageRevision"
+    path_expression="string:revisions/${id}"
+    attribute_to_parent="message_implementation" />
+</configure>
diff --git a/lib/lp/services/messages/browser/message.py b/lib/lp/services/messages/browser/message.py
index 06800e6..1e4a6e7 100644
--- a/lib/lp/services/messages/browser/message.py
+++ b/lib/lp/services/messages/browser/message.py
@@ -7,6 +7,7 @@ __metaclass__ = type
 
 from zope.interface import implementer
 
+from lp.bugs.interfaces.bugmessage import IBugMessage
 from lp.services.messages.interfaces.message import IIndexedMessage
 from lp.services.webapp.interfaces import ICanonicalUrlData
 
@@ -28,6 +29,9 @@ class BugMessageCanonicalUrlData:
 
     def __init__(self, bug, message):
         self.inside = bug.default_bugtask
+        if IBugMessage.providedBy(message):
+            # bug.messages is a list of Message objects, not BugMessage.
+            message = message.message
         self.path = "comments/%d" % list(bug.messages).index(message)
 
 
diff --git a/lib/lp/services/messages/configure.zcml b/lib/lp/services/messages/configure.zcml
index fcaffab..ccf8d12 100644
--- a/lib/lp/services/messages/configure.zcml
+++ b/lib/lp/services/messages/configure.zcml
@@ -75,4 +75,5 @@
     />
 
   <webservice:register module="lp.services.messages.interfaces.webservice" />
+  <include package=".browser"/>
 </configure>
diff --git a/lib/lp/services/messages/interfaces/message.py b/lib/lp/services/messages/interfaces/message.py
index ab31077..7dea2f8 100644
--- a/lib/lp/services/messages/interfaces/message.py
+++ b/lib/lp/services/messages/interfaces/message.py
@@ -88,7 +88,14 @@ class IMessageCommon(Interface):
         Reference(title=_('Person'), schema=Interface,
                   required=False, readonly=True))
 
-    revisions = Attribute(_('Message revision history'))
+    revisions = exported(CollectionField(
+        title=_("Message revision history"),
+        description=_(
+            "Revision history of this message, sorted in descending order."),
+        # Really IMessageRevision, patched in _schema_circular_imports.
+        value_type=Reference(schema=Interface),
+        required=False, readonly=True), as_of="devel")
+
     datecreated = exported(
         Datetime(title=_('Date Created'), required=True, readonly=True),
         exported_as='date_created')
diff --git a/lib/lp/services/messages/interfaces/messagerevision.py b/lib/lp/services/messages/interfaces/messagerevision.py
index e940187..a3316ce 100644
--- a/lib/lp/services/messages/interfaces/messagerevision.py
+++ b/lib/lp/services/messages/interfaces/messagerevision.py
@@ -9,6 +9,12 @@ __all__ = [
     'IMessageRevision'
     ]
 
+from lazr.restful.declarations import (
+    export_write_operation,
+    exported,
+    exported_as_webservice_entry,
+    operation_for_version,
+    )
 from lazr.restful.fields import Reference
 from zope.interface import Interface
 from zope.schema import (
@@ -25,29 +31,37 @@ class IMessageRevisionView(Interface):
     """IMessageRevision readable attributes."""
     id = Int(title=_("ID"), required=True, readonly=True)
 
-    content = Text(
+    content = exported(Text(
         title=_("The message at the given revision"),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
     message = Reference(
         title=_('The current message of this revision.'),
         schema=IMessage, required=True, readonly=True)
 
-    date_created = Datetime(
+    message_implementation = Reference(
+        title=_('The message implementation (BugComment, QuestionMessage or '
+                'CodeReviewComment) related to this revision'),
+        schema=Interface, required=True, readonly=True)
+
+    date_created = exported(Datetime(
         title=_("The time when this message revision was created."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    date_deleted = Datetime(
+    date_deleted = exported(Datetime(
         title=_("The time when this message revision was created."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
 
 class IMessageRevisionEdit(Interface):
     """IMessageRevision editable attributes."""
 
+    @export_write_operation()
+    @operation_for_version("devel")
     def deleteContent():
         """Logically deletes this MessageRevision."""
 
 
+@exported_as_webservice_entry(publish_web_link=False, as_of="devel")
 class IMessageRevision(IMessageRevisionView, IMessageRevisionEdit):
     """A historical revision of a IMessage."""
diff --git a/lib/lp/services/messages/interfaces/webservice.py b/lib/lp/services/messages/interfaces/webservice.py
index 94960a9..1dade00 100644
--- a/lib/lp/services/messages/interfaces/webservice.py
+++ b/lib/lp/services/messages/interfaces/webservice.py
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """All the interfaces that are exposed through the webservice.
@@ -12,10 +12,12 @@ which tells `lazr.restful` that it should look for webservice exports here.
 __metaclass__ = type
 __all__ = [
     'IMessage',
+    'IMessageRevision',
     ]
 
 from lp import _schema_circular_imports
 from lp.services.messages.interfaces.message import IMessage
+from lp.services.messages.interfaces.messagerevision import IMessageRevision
 
 
 _schema_circular_imports
diff --git a/lib/lp/services/messages/model/message.py b/lib/lp/services/messages/model/message.py
index 515710c..4960131 100644
--- a/lib/lp/services/messages/model/message.py
+++ b/lib/lp/services/messages/model/message.py
@@ -178,7 +178,7 @@ class Message(SQLBase):
         return list(Store.of(self).find(
             MessageRevision,
             MessageRevision.message == self
-        ).order_by(Desc(MessageRevision.date_created)))
+        ).order_by(MessageRevision.date_created))
 
     def editContent(self, new_content):
         """See `IMessage`."""
diff --git a/lib/lp/services/messages/model/messagerevision.py b/lib/lp/services/messages/model/messagerevision.py
index df178e4..acbabd7 100644
--- a/lib/lp/services/messages/model/messagerevision.py
+++ b/lib/lp/services/messages/model/messagerevision.py
@@ -19,6 +19,7 @@ from storm.locals import (
     )
 from zope.interface import implementer
 
+from lp.services.database.interfaces import IStore
 from lp.services.database.stormbase import StormBase
 from lp.services.messages.interfaces.messagerevision import IMessageRevision
 from lp.services.utils import utc_now
@@ -35,7 +36,7 @@ class MessageRevision(StormBase):
     message_id = Int(name='message', allow_none=False)
     message = Reference(message_id, 'Message.id')
 
-    content = Unicode(name="content", allow_none=False)
+    content = Unicode(name="content", allow_none=True)
 
     date_created = DateTime(
         name="date_created", tzinfo=pytz.UTC, allow_none=False)
@@ -48,6 +49,27 @@ class MessageRevision(StormBase):
         self.date_created = date_created
         self.date_deleted = date_deleted
 
+    @property
+    def message_implementation(self):
+        from lp.bugs.model.bugmessage import BugMessage
+        from lp.code.model.codereviewcomment import CodeReviewComment
+        from lp.answers.model.questionmessage import QuestionMessage
+
+        store = IStore(self)
+        (identifier, ) = store.execute("""
+            SELECT 'bug' FROM BugMessage WHERE message = %s
+            UNION
+            SELECT 'question' FROM QuestionMessage WHERE message = %s
+            UNION
+            SELECT 'mp' FROM CodeReviewMessage WHERE message = %s;
+        """, params=[self.message_id] * 3).get_one()
+        id_to_class = {
+            "bug": BugMessage,
+            "question": QuestionMessage,
+            "mp": CodeReviewComment}
+        klass = id_to_class[identifier]
+        return store.find(klass, klass.message == self.message_id).one()
+
     def deleteContent(self):
         self.content = None
         self.date_deleted = utc_now()
diff --git a/lib/lp/services/messages/tests/scenarios.py b/lib/lp/services/messages/tests/scenarios.py
new file mode 100644
index 0000000..f366574
--- /dev/null
+++ b/lib/lp/services/messages/tests/scenarios.py
@@ -0,0 +1,41 @@
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from testscenarios import WithScenarios
+from zope.security.proxy import ProxyFactory
+
+from lp.bugs.model.bugmessage import BugMessage
+from lp.services.database.interfaces import IStore
+from lp.testing import (
+    login_person,
+    )
+
+
+class MessageTypeScenariosMixin(WithScenarios):
+
+    scenarios = [
+        ("bug", {"message_type": "bug"}),
+        ("question", {"message_type": "question"}),
+        ("MP comment", {"message_type": "mp"})
+        ]
+
+    def setUp(self):
+        super(MessageTypeScenariosMixin, self).setUp()
+        self.person = self.factory.makePerson()
+        login_person(self.person)
+
+    def makeMessage(self, content=None, **kwargs):
+        owner = kwargs.pop('owner', self.person)
+        if self.message_type == "bug":
+            msg = self.factory.makeBugComment(
+                owner=owner, body=content, **kwargs)
+            return ProxyFactory(IStore(BugMessage).find(
+                BugMessage, BugMessage.message == msg).one())
+        elif self.message_type == "question":
+            question = self.factory.makeQuestion()
+            return question.giveAnswer(owner, content)
+        elif self.message_type == "mp":
+            return self.factory.makeCodeReviewComment(
+                sender=owner, body=content)
diff --git a/lib/lp/services/messages/tests/test_message.py b/lib/lp/services/messages/tests/test_message.py
index c65118c..a08e05a 100644
--- a/lib/lp/services/messages/tests/test_message.py
+++ b/lib/lp/services/messages/tests/test_message.py
@@ -13,7 +13,6 @@ from email.utils import (
     )
 
 import six
-from testscenarios import WithScenarios
 from testtools.matchers import (
     Equals,
     Is,
@@ -21,23 +20,18 @@ from testtools.matchers import (
     )
 import transaction
 from zope.security.interfaces import Unauthorized
-from zope.security.proxy import (
-    ProxyFactory,
-    removeSecurityProxy,
-    )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.bugs.interfaces.bugmessage import IBugMessage
-from lp.bugs.model.bugmessage import BugMessage
 from lp.services.compat import message_as_bytes
-from lp.services.database.interfaces import IStore
 from lp.services.messages.model.message import MessageSet
+from lp.services.messages.tests.scenarios import MessageTypeScenariosMixin
 from lp.services.utils import utc_now
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
     admin_logged_in,
     api_url,
     login,
-    login_person,
     person_logged_in,
     TestCaseWithFactory,
     )
@@ -195,34 +189,6 @@ class TestMessageSet(TestCaseWithFactory):
         self.assertEqual(self.high_characters.decode('latin-1'), result)
 
 
-class MessageTypeScenariosMixin(WithScenarios):
-
-    scenarios = [
-        ("bug", {"message_type": "bug"}),
-        ("question", {"message_type": "question"}),
-        ("MP comment", {"message_type": "mp"})
-        ]
-
-    def setUp(self):
-        super(MessageTypeScenariosMixin, self).setUp()
-        self.person = self.factory.makePerson()
-        login_person(self.person)
-
-    def makeMessage(self, content=None, **kwargs):
-        owner = kwargs.pop('owner', self.person)
-        if self.message_type == "bug":
-            msg = self.factory.makeBugComment(
-                owner=owner, body=content, **kwargs)
-            return ProxyFactory(IStore(BugMessage).find(
-                BugMessage, BugMessage.message == msg).one())
-        elif self.message_type == "question":
-            question = self.factory.makeQuestion()
-            return question.giveAnswer(owner, content)
-        elif self.message_type == "mp":
-            return self.factory.makeCodeReviewComment(
-                sender=owner, body=content)
-
-
 class TestMessageEditing(MessageTypeScenariosMixin, TestCaseWithFactory):
     """Test editing scenarios for Message objects."""
 
@@ -273,10 +239,10 @@ class TestMessageEditing(MessageTypeScenariosMixin, TestCaseWithFactory):
         self.assertEqual("final form", msg.text_contents)
         self.assertEqual(2, len(msg.revisions))
         self.assertIsMessageHistory(
-            msg.revisions[0], msg,
+            msg.revisions[1], msg,
             content="first edit", created_at=first_edit_date)
         self.assertIsMessageHistory(
-            msg.revisions[1], msg,
+            msg.revisions[0], msg,
             content="initial content", created_at=msg.datecreated)
 
     def test_non_owner_cannot_delete_message(self):
diff --git a/lib/lp/services/messages/tests/test_messagerevision.py b/lib/lp/services/messages/tests/test_messagerevision.py
new file mode 100644
index 0000000..18da29c
--- /dev/null
+++ b/lib/lp/services/messages/tests/test_messagerevision.py
@@ -0,0 +1,127 @@
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+from testtools.matchers import (
+    ContainsDict,
+    EndsWith,
+    Equals,
+    Is,
+    MatchesListwise,
+    Not,
+    )
+
+from lp.bugs.interfaces.bugmessage import IBugMessage
+from lp.services.messages.tests.scenarios import MessageTypeScenariosMixin
+from lp.services.webapp.interfaces import OAuthPermission
+from lp.testing import (
+    admin_logged_in,
+    api_url,
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.pages import webservice_for_person
+
+
+class TestMessageHistoryAPI(MessageTypeScenariosMixin, TestCaseWithFactory):
+    """Test editing scenarios for message revisions API."""
+
+    layer = DatabaseFunctionalLayer
+
+    def getWebservice(self, person):
+        return webservice_for_person(
+            person, permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel")
+
+    def getMessageAPIURL(self, msg):
+        with admin_logged_in():
+            if IBugMessage.providedBy(msg):
+                # BugMessage has a special URL mapping that uses the
+                # IMessage object itself.
+                return api_url(msg.message)
+            else:
+                return api_url(msg)
+
+    def test_get_message_revision_list(self):
+        msg = self.makeMessage(content="initial content")
+        msg.editContent("new content 1")
+        msg.editContent("final content")
+        ws = self.getWebservice(self.person)
+        url = self.getMessageAPIURL(msg)
+        ws_message = ws.get(url).jsonBody()
+
+        revisions = ws.get(ws_message['revisions_collection_link']).jsonBody()
+        self.assertThat(revisions, ContainsDict({
+            "start": Equals(0),
+            "total_size": Equals(2)}))
+        self.assertThat(revisions["entries"], MatchesListwise([
+            ContainsDict({
+                "date_created": Not(Is(None)),
+                "date_deleted": Is(None),
+                "content": Equals("initial content"),
+                "self_link": EndsWith("/revisions/1")
+            }),
+            ContainsDict({
+                "date_created": Not(Is(None)),
+                "date_deleted": Is(None),
+                "content": Equals("new content 1"),
+                "self_link": EndsWith("/revisions/2")
+            })]))
+
+    def test_get_single_revision(self):
+        msg = self.makeMessage(content="initial content")
+        msg.editContent("new content 1")
+        ws = self.getWebservice(self.person)
+
+        with person_logged_in(self.person):
+            revision_url = api_url(msg.revisions[0])
+        revision = ws.get(revision_url).jsonBody()
+        self.assertThat(revision, ContainsDict({
+                "date_created": Not(Is(None)),
+                "date_deleted": Is(None),
+                "content": Equals("initial content"),
+                "self_link": EndsWith("/revisions/1")
+            }))
+
+    def test_delete_revision_content(self):
+        msg = self.makeMessage(content="initial content")
+        msg.editContent("new content 1")
+        msg.editContent("final content")
+
+        with person_logged_in(self.person):
+            revision_url = api_url(msg.revisions[0])
+
+        ws = self.getWebservice(self.person)
+        response = ws.named_post(revision_url, "deleteContent")
+        self.assertEqual(200, response.status)
+
+        revision = ws.get(revision_url).jsonBody()
+        self.assertThat(revision, ContainsDict({
+            "date_created": Not(Is(None)),
+            "date_deleted": Not(Is(None)),
+            "content": Is(None),
+            "self_link": EndsWith("/revisions/1")
+        }))
+
+    def test_delete_revision_content_denied_for_non_owners(self):
+        msg = self.makeMessage(content="initial content")
+        msg.editContent("new content 1")
+        msg.editContent("final content")
+        someone_else = self.factory.makePerson()
+
+        with person_logged_in(self.person):
+            revision_url = api_url(msg.revisions[0])
+
+        ws = self.getWebservice(someone_else)
+        response = ws.named_post(revision_url, "deleteContent")
+        self.assertEqual(401, response.status)
+
+        revision = ws.get(revision_url).jsonBody()
+        self.assertThat(revision, ContainsDict({
+            "date_created": Not(Is(None)),
+            "date_deleted": Is(None),
+            "content": Equals("initial content"),
+            "self_link": EndsWith("/revisions/1")
+        }))
diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
index ee5e2e6..d0fbf23 100644
--- a/lib/lp/services/webservice/wadl-to-refhtml.xsl
+++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl
@@ -406,6 +406,12 @@
                 <xsl:text>/comments/</xsl:text>
                 <var>&lt;index&gt;</var>
             </xsl:when>
+            <xsl:when test="@id = 'message_revision'">
+                <xsl:text>/</xsl:text>
+                <var>&lt;message-url&gt;</var>
+                <xsl:text>/revisions/</xsl:text>
+                <var>&lt;index&gt;</var>
+            </xsl:when>
             <xsl:when test="@id = 'milestone'">
                 <xsl:text>/</xsl:text>
                 <var>&lt;target.name&gt;</var>

Follow ups