← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:doctest-rst into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:doctest-rst into launchpad:master.

Commit message:
Rename all doctest files to .rst

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/425052

We shouldn't have as many doctests as we do, but as long as we do, let's make them a little easier to work with.  Naming these files `*.rst` rather than `*.txt` makes various tools work better (for example, vim uses appropriate highlighting for them automatically, and some refactoring tools recognize them as doctests), and it makes it easier to treat them separately from things like text files used as test data.

I considered breaking this up to be a bit smaller, but there's no value in deploying this to production one step at a time since it's all tests, and it was much easier to just do the whole thing in bulk.  This is just about all straightforward substitution, with a few bits of opportunistically fixing up stale references to various doctest file names; the only non-obvious thing here is in `lib/lp/tests/test_opensource.py`, where we should change `.txt` to `.rst` but can't yet until we fix launchpadlib's doctests.
-- 
The attached diff has been truncated due to its size.
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:doctest-rst into launchpad:master.
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d5c58e..90c4157 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -83,8 +83,8 @@ repos:
     -   id: eslint
         args: [--quiet]
 -   repo: https://git.launchpad.net/lp-lint-doctest
-    rev: '0.3'
+    rev: '0.4'
     hooks:
     -   id: lp-lint-doctest
         args: [--allow-option-flag, IGNORE_EXCEPTION_MODULE_IN_PYTHON2]
-        exclude: ^doc/.*|.*/testfiles/.*|bingsearchservice-mapping\.txt
+        exclude: ^doc/.*
diff --git a/configs/README.txt b/configs/README.rst
similarity index 100%
rename from configs/README.txt
rename to configs/README.rst
index 7849168..8d21996 100644
--- a/configs/README.txt
+++ b/configs/README.rst
@@ -89,7 +89,7 @@ The config can be accessed as a dictionary...
 
 You can learn more about lp.services.config in the doctest located at
 
-    lib/lp/services/config/doc/canonical-config.txt
+    lib/lp/services/config/doc/canonical-config.rst
 
 
 Testing with LaunchpadConfig
@@ -173,7 +173,7 @@ so in its local conf file.
 
 You can learn more about lazr.config in the doctest located at
 
-    lib/canonical/lazr/doc/config.txt
+    lazr/config/docs/index.rst
 
 
 schema template and optional sections
diff --git a/cronscripts/process-mail.py b/cronscripts/process-mail.py
index 2fde0d1..7b4335c 100755
--- a/cronscripts/process-mail.py
+++ b/cronscripts/process-mail.py
@@ -31,7 +31,7 @@ class ProcessMail(LaunchpadCronScript):
                 raise
             raise LaunchpadScriptFailure(
                 "No mail box is configured. "
-                "Please see mailbox.txt for info on how to configure one.")
+                "Please see mailbox.rst for info on how to configure one.")
 
 
 if __name__ == '__main__':
diff --git a/doc/README b/doc/README
index bacd86a..a0a1590 100644
--- a/doc/README
+++ b/doc/README
@@ -3,6 +3,6 @@ This directory contains general system-level documentation for Launchpad.
 To build the documentation locally just run ``tox -e docs``.
 
 You can also find general documentation on <https://dev.launchpad.net> and
-documentation on specific parts of the system in doctests (look for *.txt
+documentation on specific parts of the system in doctests (look for *.rst
 files) or in docstrings.
 
diff --git a/doc/reference/tests.rst b/doc/reference/tests.rst
index 164729b..34a3267 100644
--- a/doc/reference/tests.rst
+++ b/doc/reference/tests.rst
@@ -68,8 +68,8 @@ Browser View Tests
 ------------------
 
 View objects are usually documented that way along other system objects in
-files named ``*-pages.txt`` or in
-``lib/lp/<app>/browser/tests/*-views.txt``.
+files named ``*-pages.rst`` or in
+``lib/lp/<app>/browser/tests/*-views.rst``.
 
 The browser tests directory contains both doctest files for documenting the
 use of browser view classes and unit tests (e.g. ``test_*.py``) for
@@ -100,7 +100,7 @@ The basic conventions for testable documentation are:
 
 * The file should have a first-level title element.  An expansion of the
   filename is usually a good start.  For example, the file
-  ``bugcomment.txt`` could have this title:
+  ``bugcomment.rst`` could have this title:
 
 .. code-block:: rst
 
diff --git a/lib/lp/answers/browser/tests/faq-views.txt b/lib/lp/answers/browser/tests/faq-views.rst
similarity index 100%
rename from lib/lp/answers/browser/tests/faq-views.txt
rename to lib/lp/answers/browser/tests/faq-views.rst
diff --git a/lib/lp/answers/browser/tests/question-subscribe_me.txt b/lib/lp/answers/browser/tests/question-subscribe_me.rst
similarity index 100%
rename from lib/lp/answers/browser/tests/question-subscribe_me.txt
rename to lib/lp/answers/browser/tests/question-subscribe_me.rst
diff --git a/lib/lp/answers/browser/tests/test_views.py b/lib/lp/answers/browser/tests/test_views.py
index 91ab009..0fd7031 100644
--- a/lib/lp/answers/browser/tests/test_views.py
+++ b/lib/lp/answers/browser/tests/test_views.py
@@ -49,7 +49,7 @@ def test_suite():
     suite.addTest(loader.loadTestsFromTestCase(TestEmailObfuscated))
     suite.addTest(
         LayeredDocFileSuite(
-            "question-subscribe_me.txt",
+            "question-subscribe_me.rst",
             setUp=setUp,
             tearDown=tearDown,
             layer=DatabaseFunctionalLayer,
@@ -57,7 +57,7 @@ def test_suite():
     )
     suite.addTest(
         LayeredDocFileSuite(
-            "views.txt",
+            "views.rst",
             setUp=setUp,
             tearDown=tearDown,
             layer=DatabaseFunctionalLayer,
@@ -65,7 +65,7 @@ def test_suite():
     )
     suite.addTest(
         LayeredDocFileSuite(
-            "faq-views.txt",
+            "faq-views.rst",
             setUp=setUp,
             tearDown=tearDown,
             layer=DatabaseFunctionalLayer,
diff --git a/lib/lp/answers/browser/tests/views.txt b/lib/lp/answers/browser/tests/views.rst
similarity index 99%
rename from lib/lp/answers/browser/tests/views.txt
rename to lib/lp/answers/browser/tests/views.rst
index 3b5b3ec..831b4c4 100644
--- a/lib/lp/answers/browser/tests/views.txt
+++ b/lib/lp/answers/browser/tests/views.rst
@@ -394,9 +394,9 @@ BugLinkView and BugsUnlinkView
 
 Linking bug (+linkbug) to the question is managed through the
 BugLinkView. Unlinking bugs from the question is managed through the
-BugsUnlinkView. See 'buglinktarget-pages.txt' for their documentation.
+BugsUnlinkView. See 'buglinktarget-pages.rst' for their documentation.
 The notifications sent along linking and unlinking bugs can be found in
-'answer-tracker-notifications.txt'.
+'answer-tracker-notifications.rst'.
 
 
 QuestionRejectView
diff --git a/lib/lp/answers/doc/expiration.txt b/lib/lp/answers/doc/expiration.rst
similarity index 100%
rename from lib/lp/answers/doc/expiration.txt
rename to lib/lp/answers/doc/expiration.rst
diff --git a/lib/lp/answers/doc/faq-vocabulary.txt b/lib/lp/answers/doc/faq-vocabulary.rst
similarity index 100%
rename from lib/lp/answers/doc/faq-vocabulary.txt
rename to lib/lp/answers/doc/faq-vocabulary.rst
diff --git a/lib/lp/answers/doc/faq.txt b/lib/lp/answers/doc/faq.rst
similarity index 97%
rename from lib/lp/answers/doc/faq.txt
rename to lib/lp/answers/doc/faq.rst
index 5399e4a..10be05e 100644
--- a/lib/lp/answers/doc/faq.txt
+++ b/lib/lp/answers/doc/faq.rst
@@ -44,7 +44,7 @@ contacts.)
     ...     sample_person, 'How can I see the Fnords?',
     ...     "Install the Fnords highlighter extension and see the Fnords!")
 
-(The complete description of IFAQTarget is available in faqtarget.txt)
+(The complete description of IFAQTarget is available in faqtarget.rst)
 
 
 IFAQTarget adapters
@@ -114,7 +114,7 @@ for FAQs. It is provided by product, distribution, and projects.
     >>> verifyObject(IFAQCollection, firefox)
     True
 
-(The complete description of IFAQCollection is available in faqcollection.txt)
+(The complete description of IFAQCollection is available in faqcollection.rst)
 
 
 IFAQ
@@ -233,7 +233,7 @@ The searchFAQs() method can be used to find FAQs by keywords or owner.
     How can I play MP3/Divx/DVDs/Quicktime/Realmedia files
         or view Flash/Java web pages (Ubuntu)
 
-(See faqcollection.txt for the full interface description.)
+(See faqcollection.rst for the full interface description.)
 
 
 Linking a FAQ to a question
diff --git a/lib/lp/answers/doc/faqcollection.txt b/lib/lp/answers/doc/faqcollection.rst
similarity index 99%
rename from lib/lp/answers/doc/faqcollection.txt
rename to lib/lp/answers/doc/faqcollection.rst
index 87377c2..57e1e4c 100644
--- a/lib/lp/answers/doc/faqcollection.txt
+++ b/lib/lp/answers/doc/faqcollection.rst
@@ -22,7 +22,7 @@ Population FAQs collection
 --------------------------
 
 The IFAQCollection interface is a read-only interface. The IFAQTarget
-interface is used for creating FAQs (see faqtarget.txt for details).
+interface is used for creating FAQs (see faqtarget.rst for details).
 
 Since not all IFAQCollections are IFAQTarget, we rely on the harness to
 provide us with a newFAQ() function that can be used to add a FAQ to the
diff --git a/lib/lp/answers/doc/faqtarget.txt b/lib/lp/answers/doc/faqtarget.rst
similarity index 98%
rename from lib/lp/answers/doc/faqtarget.txt
rename to lib/lp/answers/doc/faqtarget.rst
index d3302d1..548243a 100644
--- a/lib/lp/answers/doc/faqtarget.txt
+++ b/lib/lp/answers/doc/faqtarget.rst
@@ -157,8 +157,8 @@ sentence describing the issue should be given in parameter. The FAQ's
 title, summary, keywords and content can be the source of the match.
 
 This method uses a "natural language" search algorithm (see
-lib/canonical/doc/textsearching.txt for the details) which ignore common
-words and stop words.
+lib/lp/services/database/doc/textsearching.rst for the details) which ignore
+common words and stop words.
 
     # Create more FAQs.
 
diff --git a/lib/lp/answers/doc/karma.txt b/lib/lp/answers/doc/karma.rst
similarity index 100%
rename from lib/lp/answers/doc/karma.txt
rename to lib/lp/answers/doc/karma.rst
diff --git a/lib/lp/answers/doc/notifications.txt b/lib/lp/answers/doc/notifications.rst
similarity index 100%
rename from lib/lp/answers/doc/notifications.txt
rename to lib/lp/answers/doc/notifications.rst
index ebbf81f..6a9ea22 100644
--- a/lib/lp/answers/doc/notifications.txt
+++ b/lib/lp/answers/doc/notifications.rst
@@ -228,7 +228,7 @@ Linked Bug Status Changed Notification
 
 When a question is linked to a bug, the question's subscribers are
 notified of changes of the bug status. See answer-tracker-notifications-
-linked-bug.txt for more information.
+linked-bug.rst for more information.
 
 
 Workflow Notifications
diff --git a/lib/lp/answers/doc/person.txt b/lib/lp/answers/doc/person.rst
similarity index 100%
rename from lib/lp/answers/doc/person.txt
rename to lib/lp/answers/doc/person.rst
diff --git a/lib/lp/answers/doc/projectgroup.txt b/lib/lp/answers/doc/projectgroup.rst
similarity index 98%
rename from lib/lp/answers/doc/projectgroup.txt
rename to lib/lp/answers/doc/projectgroup.rst
index fc2b578..1099d3d 100644
--- a/lib/lp/answers/doc/projectgroup.txt
+++ b/lib/lp/answers/doc/projectgroup.rst
@@ -48,7 +48,7 @@ In the case where a project group has no projects, there are no results.
     []
 
 Questions can be searched by all the standard searchQuestions() parameters.
-See questiontarget.txt for the full details.
+See questiontarget.rst for the full details.
 
     >>> from lp.answers.enums import (
     ...     QuestionSort, QuestionStatus)
diff --git a/lib/lp/answers/doc/question.txt b/lib/lp/answers/doc/question.rst
similarity index 98%
rename from lib/lp/answers/doc/question.txt
rename to lib/lp/answers/doc/question.rst
index 8aa8ea4..a84f7f1 100644
--- a/lib/lp/answers/doc/question.txt
+++ b/lib/lp/answers/doc/question.rst
@@ -54,7 +54,7 @@ IQuestionTarget attribute.
     >>> firefox_question = firefox.newQuestion(
     ...     sample_person, "Firefox question", "Unable to use Firefox")
 
-The complete IQuestionTarget interface is documented in questiontarget.txt.
+The complete IQuestionTarget interface is documented in questiontarget.rst.
 
 
 Official usage
@@ -326,7 +326,7 @@ method.
     Sample Person
 
 More documentation on the question notifications can be found in
-`answer-tracker-notifications.txt`.
+`answer-tracker-notifications.rst`.
 
 
 Workflow
@@ -336,7 +336,7 @@ A question status should not be manipulated directly but through the
 workflow methods.
 
 The complete question workflow is documented in
-`answer-tracker-workflow.txt`.
+`answer-tracker-workflow.rst`.
 
 
 Unsupported questions
diff --git a/lib/lp/answers/doc/questionsets.txt b/lib/lp/answers/doc/questionsets.rst
similarity index 100%
rename from lib/lp/answers/doc/questionsets.txt
rename to lib/lp/answers/doc/questionsets.rst
diff --git a/lib/lp/answers/doc/questiontarget.txt b/lib/lp/answers/doc/questiontarget.rst
similarity index 100%
rename from lib/lp/answers/doc/questiontarget.txt
rename to lib/lp/answers/doc/questiontarget.rst
index 1184fda..dcfe5de 100644
--- a/lib/lp/answers/doc/questiontarget.txt
+++ b/lib/lp/answers/doc/questiontarget.rst
@@ -161,7 +161,7 @@ Search text
 
 The search_text parameter will select the questions that contain the
 passed in text.  The standard text searching algorithm is used; see
-../../../canonical/launchpad/doct/textsearching.txt.
+lib/lp/services/database/doc/textsearching.rst.
 
     >>> for t in target.searchQuestions(search_text=u'new'):
     ...     print(t.title)
diff --git a/lib/lp/answers/doc/workflow.txt b/lib/lp/answers/doc/workflow.rst
similarity index 100%
rename from lib/lp/answers/doc/workflow.txt
rename to lib/lp/answers/doc/workflow.rst
diff --git a/lib/lp/answers/stories/answer-contact-report.txt b/lib/lp/answers/stories/answer-contact-report.rst
similarity index 100%
rename from lib/lp/answers/stories/answer-contact-report.txt
rename to lib/lp/answers/stories/answer-contact-report.rst
diff --git a/lib/lp/answers/stories/distribution-package-answer-contact.txt b/lib/lp/answers/stories/distribution-package-answer-contact.rst
similarity index 100%
rename from lib/lp/answers/stories/distribution-package-answer-contact.txt
rename to lib/lp/answers/stories/distribution-package-answer-contact.rst
diff --git a/lib/lp/answers/stories/faq-add.txt b/lib/lp/answers/stories/faq-add.rst
similarity index 100%
rename from lib/lp/answers/stories/faq-add.txt
rename to lib/lp/answers/stories/faq-add.rst
diff --git a/lib/lp/answers/stories/faq-browse-and-search.txt b/lib/lp/answers/stories/faq-browse-and-search.rst
similarity index 100%
rename from lib/lp/answers/stories/faq-browse-and-search.txt
rename to lib/lp/answers/stories/faq-browse-and-search.rst
diff --git a/lib/lp/answers/stories/faq-edit.txt b/lib/lp/answers/stories/faq-edit.rst
similarity index 100%
rename from lib/lp/answers/stories/faq-edit.txt
rename to lib/lp/answers/stories/faq-edit.rst
diff --git a/lib/lp/answers/stories/project-add-question.txt b/lib/lp/answers/stories/project-add-question.rst
similarity index 97%
rename from lib/lp/answers/stories/project-add-question.txt
rename to lib/lp/answers/stories/project-add-question.rst
index 737952c..3011d28 100644
--- a/lib/lp/answers/stories/project-add-question.txt
+++ b/lib/lp/answers/stories/project-add-question.rst
@@ -4,7 +4,7 @@ Asking a New Question from a ProjectGroup
 Even though project groups are not QuestionTargets, it is still possible
 to create a question from a project group. There are some form and
 behaviour difference from the regular process for asking a question
-(documented in 35-question-add.txt). Firstly, we do not know the product
+(documented in question-add.rst). Firstly, we do not know the product
 the product the question is about, so we ask the user to select one.
 Secondly, without knowing the product, we cannot show the which of the
 user's preferred languages are supported.
@@ -132,7 +132,7 @@ the problem. They submit the form using the 'Post Question' button.
 
 No Privileged Person is taken to page displaying their question. From this
 point on, the user's interaction with the question follows to regular
-workflow. (see 30-question-workflow.txt for the details).
+workflow. (see question-workflow.rst for the details).
 
     >>> user_browser.url
     '.../thunderbird/+question/...'
@@ -146,7 +146,7 @@ Supported Language behaviour
 
 Following a similar path as demonstrated above with a non-English
 language speaker illustrates a less-than-ideal behaviour for supported
-languages. (See xx-question-add-in-other-languages.txt for the regular
+languages. (See question-add-in-other-languages.rst for the regular
 behaviour).
 
 
diff --git a/lib/lp/answers/stories/question-add-in-other-languages.txt b/lib/lp/answers/stories/question-add-in-other-languages.rst
similarity index 100%
rename from lib/lp/answers/stories/question-add-in-other-languages.txt
rename to lib/lp/answers/stories/question-add-in-other-languages.rst
diff --git a/lib/lp/answers/stories/question-add.txt b/lib/lp/answers/stories/question-add.rst
similarity index 100%
rename from lib/lp/answers/stories/question-add.txt
rename to lib/lp/answers/stories/question-add.rst
diff --git a/lib/lp/answers/stories/question-answer-contact.txt b/lib/lp/answers/stories/question-answer-contact.rst
similarity index 100%
rename from lib/lp/answers/stories/question-answer-contact.txt
rename to lib/lp/answers/stories/question-answer-contact.rst
diff --git a/lib/lp/answers/stories/question-answers-vhost.txt b/lib/lp/answers/stories/question-answers-vhost.rst
similarity index 100%
rename from lib/lp/answers/stories/question-answers-vhost.txt
rename to lib/lp/answers/stories/question-answers-vhost.rst
diff --git a/lib/lp/answers/stories/question-browse-and-search.txt b/lib/lp/answers/stories/question-browse-and-search.rst
similarity index 100%
rename from lib/lp/answers/stories/question-browse-and-search.txt
rename to lib/lp/answers/stories/question-browse-and-search.rst
diff --git a/lib/lp/answers/stories/question-compatibility-urls.txt b/lib/lp/answers/stories/question-compatibility-urls.rst
similarity index 100%
rename from lib/lp/answers/stories/question-compatibility-urls.txt
rename to lib/lp/answers/stories/question-compatibility-urls.rst
diff --git a/lib/lp/answers/stories/question-edit.txt b/lib/lp/answers/stories/question-edit.rst
similarity index 100%
rename from lib/lp/answers/stories/question-edit.txt
rename to lib/lp/answers/stories/question-edit.rst
diff --git a/lib/lp/answers/stories/question-message.txt b/lib/lp/answers/stories/question-message.rst
similarity index 98%
rename from lib/lp/answers/stories/question-message.txt
rename to lib/lp/answers/stories/question-message.rst
index e554384..45cd8f3 100644
--- a/lib/lp/answers/stories/question-message.txt
+++ b/lib/lp/answers/stories/question-message.rst
@@ -4,7 +4,7 @@ Question messages
 Question messages are plain text. They are formatted as HTML for web
 pages. Many messages originate from emails where unwanted or
 unnecessary content is included. Note: This set of tests is generally
-the same rules as xx-bug-comments-truncated.txt; changes here may
+the same rules as xx-bug-comments-truncated.rst; changes here may
 require changes to that test.
 
 Let's have an authenticated user create a message in the style of
diff --git a/lib/lp/answers/stories/question-obfuscation.txt b/lib/lp/answers/stories/question-obfuscation.rst
similarity index 99%
rename from lib/lp/answers/stories/question-obfuscation.txt
rename to lib/lp/answers/stories/question-obfuscation.rst
index 1f082fd..0ba7144 100644
--- a/lib/lp/answers/stories/question-obfuscation.txt
+++ b/lib/lp/answers/stories/question-obfuscation.rst
@@ -4,7 +4,7 @@ Question obfuscation
 Launchpad obfuscates email addresses when pages are viewed by
 anonymous users to prevent address harvesting by spammers. Logged
 in users can see the email address in Question descriptions.
-See question-message.txt for additional documentation.
+See question-message.rst for additional documentation.
 
 
 Logged in users can see email addresses
diff --git a/lib/lp/answers/stories/question-overview.txt b/lib/lp/answers/stories/question-overview.rst
similarity index 100%
rename from lib/lp/answers/stories/question-overview.txt
rename to lib/lp/answers/stories/question-overview.rst
diff --git a/lib/lp/answers/stories/question-reject-and-change-status.txt b/lib/lp/answers/stories/question-reject-and-change-status.rst
similarity index 100%
rename from lib/lp/answers/stories/question-reject-and-change-status.txt
rename to lib/lp/answers/stories/question-reject-and-change-status.rst
diff --git a/lib/lp/answers/stories/question-search-multiple-languages.txt b/lib/lp/answers/stories/question-search-multiple-languages.rst
similarity index 100%
rename from lib/lp/answers/stories/question-search-multiple-languages.txt
rename to lib/lp/answers/stories/question-search-multiple-languages.rst
diff --git a/lib/lp/answers/stories/question-subscriptions.txt b/lib/lp/answers/stories/question-subscriptions.rst
similarity index 100%
rename from lib/lp/answers/stories/question-subscriptions.txt
rename to lib/lp/answers/stories/question-subscriptions.rst
diff --git a/lib/lp/answers/stories/question-workflow.txt b/lib/lp/answers/stories/question-workflow.rst
similarity index 100%
rename from lib/lp/answers/stories/question-workflow.txt
rename to lib/lp/answers/stories/question-workflow.rst
diff --git a/lib/lp/answers/stories/questions-index.txt b/lib/lp/answers/stories/questions-index.rst
similarity index 100%
rename from lib/lp/answers/stories/questions-index.txt
rename to lib/lp/answers/stories/questions-index.rst
diff --git a/lib/lp/answers/stories/this-is-a-faq.txt b/lib/lp/answers/stories/this-is-a-faq.rst
similarity index 99%
rename from lib/lp/answers/stories/this-is-a-faq.txt
rename to lib/lp/answers/stories/this-is-a-faq.rst
index f9ec0cf..acb4ebb 100644
--- a/lib/lp/answers/stories/this-is-a-faq.txt
+++ b/lib/lp/answers/stories/this-is-a-faq.rst
@@ -422,4 +422,4 @@ Or you can just refer to FAQs in comments:
 
 The linkification also happens, incidentally, in bug comments and
 anywhere else the email-to-html formatter is used. See
-doc/displaying-paragraphs-of-text.txt for more details on this.
+doc/displaying-paragraphs-of-text.rst for more details on this.
diff --git a/lib/lp/answers/stories/webservice.txt b/lib/lp/answers/stories/webservice.rst
similarity index 100%
rename from lib/lp/answers/stories/webservice.txt
rename to lib/lp/answers/stories/webservice.rst
diff --git a/lib/lp/answers/tests/emailinterface.txt b/lib/lp/answers/tests/emailinterface.rst
similarity index 99%
rename from lib/lp/answers/tests/emailinterface.txt
rename to lib/lp/answers/tests/emailinterface.rst
index 462df34..1727519 100644
--- a/lib/lp/answers/tests/emailinterface.txt
+++ b/lib/lp/answers/tests/emailinterface.rst
@@ -84,7 +84,7 @@ Incoming Email and Workflow
 ---------------------------
 
 With the way the Answer Tracker workflow is modelled (see
-answer-tracker-workflow.txt for the details), adding a message will
+answer-tracker-workflow.rst for the details), adding a message will
 usually also change the status of the question. But currently, there is
 no way to specify the exact workflow action accomplished by a given
 message. (That will probably change in the near future when we add the
diff --git a/lib/lp/answers/tests/test_doc.py b/lib/lp/answers/tests/test_doc.py
index 1a4ac3d..9660d70 100644
--- a/lib/lp/answers/tests/test_doc.py
+++ b/lib/lp/answers/tests/test_doc.py
@@ -87,31 +87,31 @@ def create_interface_test_suite(test_file, targets):
 
 
 special = {
-    "questiontarget.txt": create_interface_test_suite(
-        "questiontarget.txt",
+    "questiontarget.rst": create_interface_test_suite(
+        "questiontarget.rst",
         [
             ("product", productSetUp),
             ("distribution", distributionSetUp),
             ("distributionsourcepackage", distributionsourcepackageSetUp),
         ],
     ),
-    "faqtarget.txt": create_interface_test_suite(
-        "faqtarget.txt",
+    "faqtarget.rst": create_interface_test_suite(
+        "faqtarget.rst",
         [
             ("product", productSetUp),
             ("distribution", distributionSetUp),
         ],
     ),
-    "faqcollection.txt": create_interface_test_suite(
-        "faqcollection.txt",
+    "faqcollection.rst": create_interface_test_suite(
+        "faqcollection.rst",
         [
             ("product", productSetUp),
             ("distribution", distributionSetUp),
             ("project", projectSetUp),
         ],
     ),
-    "emailinterface.txt": LayeredDocFileSuite(
-        "emailinterface.txt",
+    "emailinterface.rst": LayeredDocFileSuite(
+        "emailinterface.rst",
         setUp=setUp,
         tearDown=tearDown,
         layer=ProcessMailLayer,
diff --git a/lib/lp/answers/tests/test_question_workflow.py b/lib/lp/answers/tests/test_question_workflow.py
index 5979148..d31d8aa 100644
--- a/lib/lp/answers/tests/test_question_workflow.py
+++ b/lib/lp/answers/tests/test_question_workflow.py
@@ -4,7 +4,7 @@
 """Test the question workflow methods.
 
 Comprehensive tests for the question workflow methods. A narrative kind of
-documentation is done in the ../../doc/answer-tracker-workflow.txt Doctest,
+documentation is done in the ../../doc/answer-tracker-workflow.rst Doctest,
 but testing all the possible transitions makes the documentation more heavy
 than necessary. This is tested here.
 """
diff --git a/lib/lp/app/browser/doc/base-layout.txt b/lib/lp/app/browser/doc/base-layout.rst
similarity index 100%
rename from lib/lp/app/browser/doc/base-layout.txt
rename to lib/lp/app/browser/doc/base-layout.rst
diff --git a/lib/lp/app/browser/doc/launchpad-search-pages.txt b/lib/lp/app/browser/doc/launchpad-search-pages.rst
similarity index 100%
rename from lib/lp/app/browser/doc/launchpad-search-pages.txt
rename to lib/lp/app/browser/doc/launchpad-search-pages.rst
diff --git a/lib/lp/app/browser/doc/launchpadform-view.txt b/lib/lp/app/browser/doc/launchpadform-view.rst
similarity index 100%
rename from lib/lp/app/browser/doc/launchpadform-view.txt
rename to lib/lp/app/browser/doc/launchpadform-view.rst
diff --git a/lib/lp/app/browser/doc/menu.txt b/lib/lp/app/browser/doc/menu.rst
similarity index 100%
rename from lib/lp/app/browser/doc/menu.txt
rename to lib/lp/app/browser/doc/menu.rst
diff --git a/lib/lp/app/browser/doc/root-views.txt b/lib/lp/app/browser/doc/root-views.rst
similarity index 100%
rename from lib/lp/app/browser/doc/root-views.txt
rename to lib/lp/app/browser/doc/root-views.rst
diff --git a/lib/lp/app/browser/doc/watermark.txt b/lib/lp/app/browser/doc/watermark.rst
similarity index 100%
rename from lib/lp/app/browser/doc/watermark.txt
rename to lib/lp/app/browser/doc/watermark.rst
diff --git a/lib/lp/app/browser/tests/test_views.py b/lib/lp/app/browser/tests/test_views.py
index 6a3e4b4..f2f5095 100644
--- a/lib/lp/app/browser/tests/test_views.py
+++ b/lib/lp/app/browser/tests/test_views.py
@@ -29,17 +29,17 @@ def tearDown_bing(test):
 # that require something special like the librarian must run on a layer
 # that sets those services up.
 special = {
-    "launchpad-search-pages.txt(Bing)": LayeredDocFileSuite(
-        "../doc/launchpad-search-pages.txt",
-        id_extensions=["launchpad-search-pages.txt(Bing)"],
+    "launchpad-search-pages.rst(Bing)": LayeredDocFileSuite(
+        "../doc/launchpad-search-pages.rst",
+        id_extensions=["launchpad-search-pages.rst(Bing)"],
         setUp=setUp_bing,
         tearDown=tearDown_bing,
         layer=BingLaunchpadFunctionalLayer,
         stdout_logging_level=logging.WARNING,
     ),
     # Run these doctests again with the default search engine.
-    "launchpad-search-pages.txt": LayeredDocFileSuite(
-        "../doc/launchpad-search-pages.txt",
+    "launchpad-search-pages.rst": LayeredDocFileSuite(
+        "../doc/launchpad-search-pages.rst",
         setUp=setUp,
         tearDown=tearDown,
         layer=PageTestLayer,
diff --git a/lib/lp/app/doc/badges.txt b/lib/lp/app/doc/badges.rst
similarity index 100%
rename from lib/lp/app/doc/badges.txt
rename to lib/lp/app/doc/badges.rst
diff --git a/lib/lp/app/doc/batch-navigation.txt b/lib/lp/app/doc/batch-navigation.rst
similarity index 100%
rename from lib/lp/app/doc/batch-navigation.txt
rename to lib/lp/app/doc/batch-navigation.rst
diff --git a/lib/lp/app/doc/celebrities.txt b/lib/lp/app/doc/celebrities.rst
similarity index 100%
rename from lib/lp/app/doc/celebrities.txt
rename to lib/lp/app/doc/celebrities.rst
diff --git a/lib/lp/app/doc/displaying-dates.txt b/lib/lp/app/doc/displaying-dates.rst
similarity index 100%
rename from lib/lp/app/doc/displaying-dates.txt
rename to lib/lp/app/doc/displaying-dates.rst
diff --git a/lib/lp/app/doc/displaying-numbers.txt b/lib/lp/app/doc/displaying-numbers.rst
similarity index 100%
rename from lib/lp/app/doc/displaying-numbers.txt
rename to lib/lp/app/doc/displaying-numbers.rst
diff --git a/lib/lp/app/doc/displaying-paragraphs-of-text.txt b/lib/lp/app/doc/displaying-paragraphs-of-text.rst
similarity index 100%
rename from lib/lp/app/doc/displaying-paragraphs-of-text.txt
rename to lib/lp/app/doc/displaying-paragraphs-of-text.rst
diff --git a/lib/lp/app/doc/hierarchical-menu.txt b/lib/lp/app/doc/hierarchical-menu.rst
similarity index 100%
rename from lib/lp/app/doc/hierarchical-menu.txt
rename to lib/lp/app/doc/hierarchical-menu.rst
diff --git a/lib/lp/app/doc/launchpad-views-cookie.txt b/lib/lp/app/doc/launchpad-views-cookie.rst
similarity index 100%
rename from lib/lp/app/doc/launchpad-views-cookie.txt
rename to lib/lp/app/doc/launchpad-views-cookie.rst
diff --git a/lib/lp/app/doc/launchpadform.txt b/lib/lp/app/doc/launchpadform.rst
similarity index 100%
rename from lib/lp/app/doc/launchpadform.txt
rename to lib/lp/app/doc/launchpadform.rst
diff --git a/lib/lp/app/doc/launchpadformharness.txt b/lib/lp/app/doc/launchpadformharness.rst
similarity index 100%
rename from lib/lp/app/doc/launchpadformharness.txt
rename to lib/lp/app/doc/launchpadformharness.rst
diff --git a/lib/lp/app/doc/launchpadview.txt b/lib/lp/app/doc/launchpadview.rst
similarity index 100%
rename from lib/lp/app/doc/launchpadview.txt
rename to lib/lp/app/doc/launchpadview.rst
diff --git a/lib/lp/app/doc/lazr-js-widgets.txt b/lib/lp/app/doc/lazr-js-widgets.rst
similarity index 100%
rename from lib/lp/app/doc/lazr-js-widgets.txt
rename to lib/lp/app/doc/lazr-js-widgets.rst
diff --git a/lib/lp/app/doc/loginstatus-pages.txt b/lib/lp/app/doc/loginstatus-pages.rst
similarity index 100%
rename from lib/lp/app/doc/loginstatus-pages.txt
rename to lib/lp/app/doc/loginstatus-pages.rst
diff --git a/lib/lp/app/doc/menus.txt b/lib/lp/app/doc/menus.rst
similarity index 100%
rename from lib/lp/app/doc/menus.txt
rename to lib/lp/app/doc/menus.rst
diff --git a/lib/lp/app/doc/multistep.txt b/lib/lp/app/doc/multistep.rst
similarity index 100%
rename from lib/lp/app/doc/multistep.txt
rename to lib/lp/app/doc/multistep.rst
diff --git a/lib/lp/app/doc/object-privacy.txt b/lib/lp/app/doc/object-privacy.rst
similarity index 100%
rename from lib/lp/app/doc/object-privacy.txt
rename to lib/lp/app/doc/object-privacy.rst
diff --git a/lib/lp/app/doc/presenting-lengths-of-time.txt b/lib/lp/app/doc/presenting-lengths-of-time.rst
similarity index 100%
rename from lib/lp/app/doc/presenting-lengths-of-time.txt
rename to lib/lp/app/doc/presenting-lengths-of-time.rst
diff --git a/lib/lp/app/doc/tales-email-formatting.txt b/lib/lp/app/doc/tales-email-formatting.rst
similarity index 99%
rename from lib/lp/app/doc/tales-email-formatting.txt
rename to lib/lp/app/doc/tales-email-formatting.rst
index e4038d9..43204de 100644
--- a/lib/lp/app/doc/tales-email-formatting.txt
+++ b/lib/lp/app/doc/tales-email-formatting.rst
@@ -6,7 +6,7 @@ There is subtle differences in how people quote text that must be
 handled properly. There are also cases were content may look like
 quoted text, but it is not.
 
-See 'The fmt: namespace to get strings (hiding)' in tales.txt for
+See 'The fmt: namespace to get strings (hiding)' in tales.rst for
 the common use cases.
 
 First, let's bring in a small helper function:
diff --git a/lib/lp/app/doc/tales.txt b/lib/lp/app/doc/tales.rst
similarity index 100%
rename from lib/lp/app/doc/tales.txt
rename to lib/lp/app/doc/tales.rst
diff --git a/lib/lp/app/doc/textformatting.txt b/lib/lp/app/doc/textformatting.rst
similarity index 100%
rename from lib/lp/app/doc/textformatting.txt
rename to lib/lp/app/doc/textformatting.rst
diff --git a/lib/lp/app/doc/validation.txt b/lib/lp/app/doc/validation.rst
similarity index 100%
rename from lib/lp/app/doc/validation.txt
rename to lib/lp/app/doc/validation.rst
diff --git a/lib/lp/app/stories/basics/copyright.txt b/lib/lp/app/stories/basics/copyright.rst
similarity index 100%
rename from lib/lp/app/stories/basics/copyright.txt
rename to lib/lp/app/stories/basics/copyright.rst
diff --git a/lib/lp/app/stories/basics/demo-and-lpnet.txt b/lib/lp/app/stories/basics/demo-and-lpnet.rst
similarity index 100%
rename from lib/lp/app/stories/basics/demo-and-lpnet.txt
rename to lib/lp/app/stories/basics/demo-and-lpnet.rst
diff --git a/lib/lp/app/stories/basics/marketing.txt b/lib/lp/app/stories/basics/marketing.rst
similarity index 100%
rename from lib/lp/app/stories/basics/marketing.txt
rename to lib/lp/app/stories/basics/marketing.rst
diff --git a/lib/lp/app/stories/basics/max-batch-size.txt b/lib/lp/app/stories/basics/max-batch-size.rst
similarity index 100%
rename from lib/lp/app/stories/basics/max-batch-size.txt
rename to lib/lp/app/stories/basics/max-batch-size.rst
diff --git a/lib/lp/app/stories/basics/notfound-error.txt b/lib/lp/app/stories/basics/notfound-error.rst
similarity index 100%
rename from lib/lp/app/stories/basics/notfound-error.txt
rename to lib/lp/app/stories/basics/notfound-error.rst
diff --git a/lib/lp/app/stories/basics/notfound-head.txt b/lib/lp/app/stories/basics/notfound-head.rst
similarity index 100%
rename from lib/lp/app/stories/basics/notfound-head.txt
rename to lib/lp/app/stories/basics/notfound-head.rst
diff --git a/lib/lp/app/stories/basics/notfound-traversals.txt b/lib/lp/app/stories/basics/notfound-traversals.rst
similarity index 100%
rename from lib/lp/app/stories/basics/notfound-traversals.txt
rename to lib/lp/app/stories/basics/notfound-traversals.rst
diff --git a/lib/lp/app/stories/basics/page-request-summaries.txt b/lib/lp/app/stories/basics/page-request-summaries.rst
similarity index 100%
rename from lib/lp/app/stories/basics/page-request-summaries.txt
rename to lib/lp/app/stories/basics/page-request-summaries.rst
diff --git a/lib/lp/app/stories/basics/user-requested-oops.txt b/lib/lp/app/stories/basics/user-requested-oops.rst
similarity index 100%
rename from lib/lp/app/stories/basics/user-requested-oops.txt
rename to lib/lp/app/stories/basics/user-requested-oops.rst
diff --git a/lib/lp/app/stories/basics/xx-dbpolicy.txt b/lib/lp/app/stories/basics/xx-dbpolicy.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-dbpolicy.txt
rename to lib/lp/app/stories/basics/xx-dbpolicy.rst
diff --git a/lib/lp/app/stories/basics/xx-developerexceptions.txt b/lib/lp/app/stories/basics/xx-developerexceptions.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-developerexceptions.txt
rename to lib/lp/app/stories/basics/xx-developerexceptions.rst
diff --git a/lib/lp/app/stories/basics/xx-launchpad-statistics.txt b/lib/lp/app/stories/basics/xx-launchpad-statistics.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-launchpad-statistics.txt
rename to lib/lp/app/stories/basics/xx-launchpad-statistics.rst
diff --git a/lib/lp/app/stories/basics/xx-lowercase-redirection.txt b/lib/lp/app/stories/basics/xx-lowercase-redirection.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-lowercase-redirection.txt
rename to lib/lp/app/stories/basics/xx-lowercase-redirection.rst
diff --git a/lib/lp/app/stories/basics/xx-maintenance-message.txt b/lib/lp/app/stories/basics/xx-maintenance-message.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-maintenance-message.txt
rename to lib/lp/app/stories/basics/xx-maintenance-message.rst
diff --git a/lib/lp/app/stories/basics/xx-notifications.txt b/lib/lp/app/stories/basics/xx-notifications.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-notifications.txt
rename to lib/lp/app/stories/basics/xx-notifications.rst
diff --git a/lib/lp/app/stories/basics/xx-offsite-form-post.txt b/lib/lp/app/stories/basics/xx-offsite-form-post.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-offsite-form-post.txt
rename to lib/lp/app/stories/basics/xx-offsite-form-post.rst
diff --git a/lib/lp/app/stories/basics/xx-opstats.txt b/lib/lp/app/stories/basics/xx-opstats.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-opstats.txt
rename to lib/lp/app/stories/basics/xx-opstats.rst
diff --git a/lib/lp/app/stories/basics/xx-preferred-charsets.txt b/lib/lp/app/stories/basics/xx-preferred-charsets.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-preferred-charsets.txt
rename to lib/lp/app/stories/basics/xx-preferred-charsets.rst
diff --git a/lib/lp/app/stories/basics/xx-request-expired.txt b/lib/lp/app/stories/basics/xx-request-expired.rst
similarity index 96%
rename from lib/lp/app/stories/basics/xx-request-expired.txt
rename to lib/lp/app/stories/basics/xx-request-expired.rst
index 6f586bf..014d10f 100644
--- a/lib/lp/app/stories/basics/xx-request-expired.txt
+++ b/lib/lp/app/stories/basics/xx-request-expired.rst
@@ -1,6 +1,6 @@
 
 /+soft-timeout provides a way of testing if hard timeouts work in addition
-to how it works in the xx-soft-timeout.txt test.
+to how it works in the xx-soft-timeout.rst test.
 
 If we set soft_request_timeout to some value, the page will take
 slightly longer then the soft_request_timeout value to generate, thus
diff --git a/lib/lp/app/stories/basics/xx-soft-timeout.txt b/lib/lp/app/stories/basics/xx-soft-timeout.rst
similarity index 100%
rename from lib/lp/app/stories/basics/xx-soft-timeout.txt
rename to lib/lp/app/stories/basics/xx-soft-timeout.rst
diff --git a/lib/lp/app/stories/folder.txt b/lib/lp/app/stories/folder.rst
similarity index 100%
rename from lib/lp/app/stories/folder.txt
rename to lib/lp/app/stories/folder.rst
diff --git a/lib/lp/app/stories/form/xx-form-layout.txt b/lib/lp/app/stories/form/xx-form-layout.rst
similarity index 100%
rename from lib/lp/app/stories/form/xx-form-layout.txt
rename to lib/lp/app/stories/form/xx-form-layout.rst
diff --git a/lib/lp/app/stories/launchpad-root/front-pages.txt b/lib/lp/app/stories/launchpad-root/front-pages.rst
similarity index 100%
rename from lib/lp/app/stories/launchpad-root/front-pages.txt
rename to lib/lp/app/stories/launchpad-root/front-pages.rst
diff --git a/lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt b/lib/lp/app/stories/launchpad-root/xx-featuredprojects.rst
similarity index 100%
rename from lib/lp/app/stories/launchpad-root/xx-featuredprojects.txt
rename to lib/lp/app/stories/launchpad-root/xx-featuredprojects.rst
diff --git a/lib/lp/app/stories/launchpad-search/site-search.txt b/lib/lp/app/stories/launchpad-search/site-search.rst
similarity index 100%
rename from lib/lp/app/stories/launchpad-search/site-search.txt
rename to lib/lp/app/stories/launchpad-search/site-search.rst
diff --git a/lib/lp/app/tests/test_doc.py b/lib/lp/app/tests/test_doc.py
index 655c9b0..84c7a3f 100644
--- a/lib/lp/app/tests/test_doc.py
+++ b/lib/lp/app/tests/test_doc.py
@@ -33,20 +33,20 @@ def tearDown_bing(test):
 
 
 special = {
-    "tales.txt": LayeredDocFileSuite(
-        "../doc/tales.txt",
+    "tales.rst": LayeredDocFileSuite(
+        "../doc/tales.rst",
         setUp=setUp,
         tearDown=tearDown,
         layer=LaunchpadFunctionalLayer,
     ),
-    "menus.txt": LayeredDocFileSuite(
-        "../doc/menus.txt",
+    "menus.rst": LayeredDocFileSuite(
+        "../doc/menus.rst",
         setUp=setGlobs,
         layer=None,
     ),
     "stories/launchpad-search(Bing)": PageTestSuite(
         "../stories/launchpad-search/",
-        id_extensions=["site-search.txt(Bing)"],
+        id_extensions=["site-search.rst(Bing)"],
         setUp=setUp_bing,
         tearDown=tearDown_bing,
     ),
diff --git a/lib/lp/app/validators/tests/test_doc.py b/lib/lp/app/validators/tests/test_doc.py
index ac2504a..dbf7fe9 100644
--- a/lib/lp/app/validators/tests/test_doc.py
+++ b/lib/lp/app/validators/tests/test_doc.py
@@ -12,7 +12,7 @@ from lp.testing.systemdocs import LayeredDocFileSuite, setUp, tearDown
 def test_suite():
     suite = unittest.TestSuite()
     test = LayeredDocFileSuite(
-        "validation.txt",
+        "validation.rst",
         setUp=setUp,
         tearDown=tearDown,
         layer=LaunchpadFunctionalLayer,
diff --git a/lib/lp/app/validators/tests/validation.txt b/lib/lp/app/validators/tests/validation.rst
similarity index 100%
rename from lib/lp/app/validators/tests/validation.txt
rename to lib/lp/app/validators/tests/validation.rst
diff --git a/lib/lp/app/widgets/doc/announcement-date-widget.txt b/lib/lp/app/widgets/doc/announcement-date-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/announcement-date-widget.txt
rename to lib/lp/app/widgets/doc/announcement-date-widget.rst
diff --git a/lib/lp/app/widgets/doc/checkbox-matrix-widget.txt b/lib/lp/app/widgets/doc/checkbox-matrix-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/checkbox-matrix-widget.txt
rename to lib/lp/app/widgets/doc/checkbox-matrix-widget.rst
diff --git a/lib/lp/app/widgets/doc/image-widget.txt b/lib/lp/app/widgets/doc/image-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/image-widget.txt
rename to lib/lp/app/widgets/doc/image-widget.rst
diff --git a/lib/lp/app/widgets/doc/launchpad-radio-widget.txt b/lib/lp/app/widgets/doc/launchpad-radio-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/launchpad-radio-widget.txt
rename to lib/lp/app/widgets/doc/launchpad-radio-widget.rst
diff --git a/lib/lp/app/widgets/doc/lower-case-text-widget.txt b/lib/lp/app/widgets/doc/lower-case-text-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/lower-case-text-widget.txt
rename to lib/lp/app/widgets/doc/lower-case-text-widget.rst
diff --git a/lib/lp/app/widgets/doc/noneable-text-widgets.txt b/lib/lp/app/widgets/doc/noneable-text-widgets.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/noneable-text-widgets.txt
rename to lib/lp/app/widgets/doc/noneable-text-widgets.rst
diff --git a/lib/lp/app/widgets/doc/project-scope-widget.txt b/lib/lp/app/widgets/doc/project-scope-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/project-scope-widget.txt
rename to lib/lp/app/widgets/doc/project-scope-widget.rst
diff --git a/lib/lp/app/widgets/doc/stripped-text-widget.txt b/lib/lp/app/widgets/doc/stripped-text-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/stripped-text-widget.txt
rename to lib/lp/app/widgets/doc/stripped-text-widget.rst
diff --git a/lib/lp/app/widgets/doc/tokens-text-widget.txt b/lib/lp/app/widgets/doc/tokens-text-widget.rst
similarity index 100%
rename from lib/lp/app/widgets/doc/tokens-text-widget.txt
rename to lib/lp/app/widgets/doc/tokens-text-widget.rst
diff --git a/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt b/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.rst
similarity index 98%
rename from lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt
rename to lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.rst
index dea969a..878c916 100644
--- a/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.txt
+++ b/lib/lp/app/widgets/doc/zope3-widgets-use-form-ng.rst
@@ -6,7 +6,7 @@ exceptions (TypeError, AttributeError, ...) when the request contains
 a non-expected number of values.
 
 Launchpad monkey patch the base Zope widgets so that they use
-the IBrowserFormNG interface (see webapp-publication.txt) to obtain
+the IBrowserFormNG interface (see webapp-publication.rst) to obtain
 the form value.
 
 The monkey patch is installed by default:
diff --git a/lib/lp/app/widgets/tests/test_doc.py b/lib/lp/app/widgets/tests/test_doc.py
index 9997790..8fa5582 100644
--- a/lib/lp/app/widgets/tests/test_doc.py
+++ b/lib/lp/app/widgets/tests/test_doc.py
@@ -14,8 +14,8 @@ from lp.testing.systemdocs import LayeredDocFileSuite, setUp, tearDown
 here = os.path.dirname(os.path.realpath(__file__))
 
 special = {
-    "image-widget.txt": LayeredDocFileSuite(
-        "../doc/image-widget.txt",
+    "image-widget.rst": LayeredDocFileSuite(
+        "../doc/image-widget.rst",
         setUp=setUp,
         tearDown=tearDown,
         layer=LaunchpadFunctionalLayer,
diff --git a/lib/lp/archivepublisher/tests/archive-signing.txt b/lib/lp/archivepublisher/tests/archive-signing.rst
similarity index 100%
rename from lib/lp/archivepublisher/tests/archive-signing.txt
rename to lib/lp/archivepublisher/tests/archive-signing.rst
index ced9aa5..c9b2927 100644
--- a/lib/lp/archivepublisher/tests/archive-signing.txt
+++ b/lib/lp/archivepublisher/tests/archive-signing.rst
@@ -290,7 +290,7 @@ key is available in the keyserver.
     >>> retrieved_key.fingerprint == signing_key.fingerprint
     True
 
-As documented in archive.txt, when a named-ppa is created it is
+As documented in archive.rst, when a named-ppa is created it is
 already configured to used the same signing-key created for the
 default PPA. We will create a named-ppa for Celso.
 
diff --git a/lib/lp/archivepublisher/tests/deathrow.txt b/lib/lp/archivepublisher/tests/deathrow.rst
similarity index 100%
rename from lib/lp/archivepublisher/tests/deathrow.txt
rename to lib/lp/archivepublisher/tests/deathrow.rst
diff --git a/lib/lp/archivepublisher/tests/test_processdeathrow.py b/lib/lp/archivepublisher/tests/test_processdeathrow.py
index c1bcbd0..a6f93b2 100644
--- a/lib/lp/archivepublisher/tests/test_processdeathrow.py
+++ b/lib/lp/archivepublisher/tests/test_processdeathrow.py
@@ -3,7 +3,7 @@
 
 """Functional tests for process-death-row.py script.
 
-See lib/canonical/launchpad/doc/deathrow.txt for more detailed tests
+See lib/lp/archivepublisher/tests/deathrow.rst for more detailed tests
 of the module functionality; here we just aim to test that the script
 processes its arguments and handles dry-run correctly.
 """
diff --git a/lib/lp/archivepublisher/tests/test_publisher_documentation.py b/lib/lp/archivepublisher/tests/test_publisher_documentation.py
index 0975e30..31c3671 100644
--- a/lib/lp/archivepublisher/tests/test_publisher_documentation.py
+++ b/lib/lp/archivepublisher/tests/test_publisher_documentation.py
@@ -29,7 +29,7 @@ def test_suite():
     filenames = [
         filename
         for filename in os.listdir(tests_dir)
-        if filename.lower().endswith('.txt')
+        if filename.lower().endswith('.rst')
         ]
 
     for filename in sorted(filenames):
diff --git a/lib/lp/archiveuploader/tests/meta-data-custom-files.txt b/lib/lp/archiveuploader/tests/meta-data-custom-files.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/meta-data-custom-files.txt
rename to lib/lp/archiveuploader/tests/meta-data-custom-files.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload-announcements.txt b/lib/lp/archiveuploader/tests/nascentupload-announcements.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentupload-announcements.txt
rename to lib/lp/archiveuploader/tests/nascentupload-announcements.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload-closing-bugs.txt b/lib/lp/archiveuploader/tests/nascentupload-closing-bugs.rst
similarity index 97%
rename from lib/lp/archiveuploader/tests/nascentupload-closing-bugs.txt
rename to lib/lp/archiveuploader/tests/nascentupload-closing-bugs.rst
index 8ce0f8c..12b97e2 100644
--- a/lib/lp/archiveuploader/tests/nascentupload-closing-bugs.txt
+++ b/lib/lp/archiveuploader/tests/nascentupload-closing-bugs.rst
@@ -3,7 +3,7 @@ Closing Bugs when Publishing Accepted Source
 
 We have implemented 'premature publication of accepted sources' in
 NascentUpload to increase the publishing throughput (see
-launchpad/doc/nascentupload.txt).
+lib/lp/archiveuploader/tests/nascentupload.rst).
 
 Therefore we also use the available infrastructure to close bugs
 mentioned in the source changelog (see close-bugs-from-changelog).
@@ -67,7 +67,7 @@ Testing bug closing
 
 Once the base source is published every posterior version will be
 automatically published in upload time as described in
-nascentupload-publishing-accepted-sources.txt.
+nascentupload-publishing-accepted-sources.rst.
 
     >>> bar2_src = getUploadForSource(
     ...     'suite/bar_1.0-2/bar_1.0-2_source.changes')
diff --git a/lib/lp/archiveuploader/tests/nascentupload-ddebs.txt b/lib/lp/archiveuploader/tests/nascentupload-ddebs.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentupload-ddebs.txt
rename to lib/lp/archiveuploader/tests/nascentupload-ddebs.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt b/lib/lp/archiveuploader/tests/nascentupload-epoch-handling.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt
rename to lib/lp/archiveuploader/tests/nascentupload-epoch-handling.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload-packageset.txt b/lib/lp/archiveuploader/tests/nascentupload-packageset.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentupload-packageset.txt
rename to lib/lp/archiveuploader/tests/nascentupload-packageset.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload-publishing-accepted-sources.txt b/lib/lp/archiveuploader/tests/nascentupload-publishing-accepted-sources.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentupload-publishing-accepted-sources.txt
rename to lib/lp/archiveuploader/tests/nascentupload-publishing-accepted-sources.rst
diff --git a/lib/lp/archiveuploader/tests/nascentupload.txt b/lib/lp/archiveuploader/tests/nascentupload.rst
similarity index 99%
rename from lib/lp/archiveuploader/tests/nascentupload.txt
rename to lib/lp/archiveuploader/tests/nascentupload.rst
index bf902fe..2931a2a 100644
--- a/lib/lp/archiveuploader/tests/nascentupload.txt
+++ b/lib/lp/archiveuploader/tests/nascentupload.rst
@@ -44,10 +44,10 @@ NascentUpload Processing
 
 Processing a NascentUpload consists of building files objects for each
 specified file in the upload, executing all their specific checks and
-collect all errors that may be generated. (see doc/nascentuploadfile.txt)
+collect all errors that may be generated. (see doc/nascentuploadfile.rst)
 
 First, NascentUpload verifies that the changes file specified exist, and
-tries to build a ChangesFile (see doc/nascentuploadfile.txt) object based
+tries to build a ChangesFile (see doc/nascentuploadfile.rst) object based
 on that.
 
     >>> from lp.services.log.logger import DevNullLogger, FakeLogger
@@ -292,7 +292,7 @@ known ORIG file:
     True
 
 The notification message generated is described in more detail in
-doc/nascentupload-announcements.txt.
+doc/nascentupload-announcements.rst.
 
 Roll back everything related with ed_upload:
 
diff --git a/lib/lp/archiveuploader/tests/nascentuploadfile.txt b/lib/lp/archiveuploader/tests/nascentuploadfile.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/nascentuploadfile.txt
rename to lib/lp/archiveuploader/tests/nascentuploadfile.rst
diff --git a/lib/lp/archiveuploader/tests/static-translations.txt b/lib/lp/archiveuploader/tests/static-translations.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/static-translations.txt
rename to lib/lp/archiveuploader/tests/static-translations.rst
diff --git a/lib/lp/archiveuploader/tests/test_nascentupload_documentation.py b/lib/lp/archiveuploader/tests/test_nascentupload_documentation.py
index af05764..0ce3f5d 100644
--- a/lib/lp/archiveuploader/tests/test_nascentupload_documentation.py
+++ b/lib/lp/archiveuploader/tests/test_nascentupload_documentation.py
@@ -112,7 +112,7 @@ def test_suite():
     filenames = [
         filename
         for filename in os.listdir(tests_dir)
-        if filename.lower().endswith('.txt')
+        if filename.lower().endswith('.rst')
         ]
 
     for filename in sorted(filenames):
diff --git a/lib/lp/archiveuploader/tests/upload-karma.txt b/lib/lp/archiveuploader/tests/upload-karma.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/upload-karma.txt
rename to lib/lp/archiveuploader/tests/upload-karma.rst
diff --git a/lib/lp/archiveuploader/tests/upload-path-parsing.txt b/lib/lp/archiveuploader/tests/upload-path-parsing.rst
similarity index 100%
rename from lib/lp/archiveuploader/tests/upload-path-parsing.txt
rename to lib/lp/archiveuploader/tests/upload-path-parsing.rst
diff --git a/lib/lp/blueprints/browser/tests/sprintattendance-views.txt b/lib/lp/blueprints/browser/tests/sprintattendance-views.rst
similarity index 100%
rename from lib/lp/blueprints/browser/tests/sprintattendance-views.txt
rename to lib/lp/blueprints/browser/tests/sprintattendance-views.rst
diff --git a/lib/lp/blueprints/browser/tests/test_specificationdependency.py b/lib/lp/blueprints/browser/tests/test_specificationdependency.py
index fc0360f..3408a64 100644
--- a/lib/lp/blueprints/browser/tests/test_specificationdependency.py
+++ b/lib/lp/blueprints/browser/tests/test_specificationdependency.py
@@ -3,7 +3,7 @@
 
 """Tests for the specification dependency views.
 
-There are also tests in lp/blueprints/stories/blueprints/xx-dependencies.txt.
+There are also tests in lp/blueprints/stories/blueprints/xx-dependencies.rst.
 """
 
 from lp.app.enums import InformationType
diff --git a/lib/lp/blueprints/browser/tests/test_views.py b/lib/lp/blueprints/browser/tests/test_views.py
index 4d993f6..b985bb4 100644
--- a/lib/lp/blueprints/browser/tests/test_views.py
+++ b/lib/lp/blueprints/browser/tests/test_views.py
@@ -102,7 +102,7 @@ def test_suite():
     # Add tests using default setup/teardown
     filenames = [filename
                  for filename in os.listdir(testsdir)
-                 if filename.endswith('.txt')]
+                 if filename.endswith('.rst')]
     # Sort the list to give a predictable order.
     filenames.sort()
     for filename in filenames:
diff --git a/lib/lp/blueprints/doc/specgraph.txt b/lib/lp/blueprints/doc/specgraph.rst
similarity index 100%
rename from lib/lp/blueprints/doc/specgraph.txt
rename to lib/lp/blueprints/doc/specgraph.rst
diff --git a/lib/lp/blueprints/doc/specification-branch.txt b/lib/lp/blueprints/doc/specification-branch.rst
similarity index 100%
rename from lib/lp/blueprints/doc/specification-branch.txt
rename to lib/lp/blueprints/doc/specification-branch.rst
diff --git a/lib/lp/blueprints/doc/specification-notifications.txt b/lib/lp/blueprints/doc/specification-notifications.rst
similarity index 100%
rename from lib/lp/blueprints/doc/specification-notifications.txt
rename to lib/lp/blueprints/doc/specification-notifications.rst
diff --git a/lib/lp/blueprints/doc/specification.txt b/lib/lp/blueprints/doc/specification.rst
similarity index 100%
rename from lib/lp/blueprints/doc/specification.txt
rename to lib/lp/blueprints/doc/specification.rst
diff --git a/lib/lp/blueprints/doc/specificationmessage.txt b/lib/lp/blueprints/doc/specificationmessage.rst
similarity index 100%
rename from lib/lp/blueprints/doc/specificationmessage.txt
rename to lib/lp/blueprints/doc/specificationmessage.rst
diff --git a/lib/lp/blueprints/doc/sprint-agenda.txt b/lib/lp/blueprints/doc/sprint-agenda.rst
similarity index 100%
rename from lib/lp/blueprints/doc/sprint-agenda.txt
rename to lib/lp/blueprints/doc/sprint-agenda.rst
diff --git a/lib/lp/blueprints/doc/sprint-meeting-export.txt b/lib/lp/blueprints/doc/sprint-meeting-export.rst
similarity index 100%
rename from lib/lp/blueprints/doc/sprint-meeting-export.txt
rename to lib/lp/blueprints/doc/sprint-meeting-export.rst
diff --git a/lib/lp/blueprints/doc/sprint.txt b/lib/lp/blueprints/doc/sprint.rst
similarity index 100%
rename from lib/lp/blueprints/doc/sprint.txt
rename to lib/lp/blueprints/doc/sprint.rst
diff --git a/lib/lp/blueprints/doc/sprintattendance.txt b/lib/lp/blueprints/doc/sprintattendance.rst
similarity index 100%
rename from lib/lp/blueprints/doc/sprintattendance.txt
rename to lib/lp/blueprints/doc/sprintattendance.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-buglinks.txt b/lib/lp/blueprints/stories/blueprints/xx-buglinks.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-buglinks.txt
rename to lib/lp/blueprints/stories/blueprints/xx-buglinks.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-creation.txt b/lib/lp/blueprints/stories/blueprints/xx-creation.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-creation.txt
rename to lib/lp/blueprints/stories/blueprints/xx-creation.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-dependencies.txt b/lib/lp/blueprints/stories/blueprints/xx-dependencies.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-dependencies.txt
rename to lib/lp/blueprints/stories/blueprints/xx-dependencies.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-distrorelease.txt b/lib/lp/blueprints/stories/blueprints/xx-distrorelease.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-distrorelease.txt
rename to lib/lp/blueprints/stories/blueprints/xx-distrorelease.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-editing.txt b/lib/lp/blueprints/stories/blueprints/xx-editing.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-editing.txt
rename to lib/lp/blueprints/stories/blueprints/xx-editing.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-milestones.txt b/lib/lp/blueprints/stories/blueprints/xx-milestones.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-milestones.txt
rename to lib/lp/blueprints/stories/blueprints/xx-milestones.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-non-ascii-imagemap.txt b/lib/lp/blueprints/stories/blueprints/xx-non-ascii-imagemap.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-non-ascii-imagemap.txt
rename to lib/lp/blueprints/stories/blueprints/xx-non-ascii-imagemap.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-productseries.txt b/lib/lp/blueprints/stories/blueprints/xx-productseries.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-productseries.txt
rename to lib/lp/blueprints/stories/blueprints/xx-productseries.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-superseding-within-projects.txt b/lib/lp/blueprints/stories/blueprints/xx-superseding-within-projects.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-superseding-within-projects.txt
rename to lib/lp/blueprints/stories/blueprints/xx-superseding-within-projects.rst
diff --git a/lib/lp/blueprints/stories/blueprints/xx-superseding.txt b/lib/lp/blueprints/stories/blueprints/xx-superseding.rst
similarity index 100%
rename from lib/lp/blueprints/stories/blueprints/xx-superseding.txt
rename to lib/lp/blueprints/stories/blueprints/xx-superseding.rst
diff --git a/lib/lp/blueprints/stories/sprints/sprint-settopics.txt b/lib/lp/blueprints/stories/sprints/sprint-settopics.rst
similarity index 100%
rename from lib/lp/blueprints/stories/sprints/sprint-settopics.txt
rename to lib/lp/blueprints/stories/sprints/sprint-settopics.rst
diff --git a/lib/lp/blueprints/stories/sprints/xx-sprint-meeting-export.txt b/lib/lp/blueprints/stories/sprints/xx-sprint-meeting-export.rst
similarity index 100%
rename from lib/lp/blueprints/stories/sprints/xx-sprint-meeting-export.txt
rename to lib/lp/blueprints/stories/sprints/xx-sprint-meeting-export.rst
diff --git a/lib/lp/blueprints/stories/sprints/xx-sprints.txt b/lib/lp/blueprints/stories/sprints/xx-sprints.rst
similarity index 98%
rename from lib/lp/blueprints/stories/sprints/xx-sprints.txt
rename to lib/lp/blueprints/stories/sprints/xx-sprints.rst
index efabff7..70d51f7 100644
--- a/lib/lp/blueprints/stories/sprints/xx-sprints.txt
+++ b/lib/lp/blueprints/stories/sprints/xx-sprints.rst
@@ -306,7 +306,7 @@ It should be possible to register yourself to attend the sprint:
 
 Invalid dates, for instance entering a starting date after the ending
 date, are reported as errors to the users. (See also the tests in
-lib/canonical/launchpad/doc/sprintattendance-pages.txt)
+lib/lp/blueprints/browser/tests/sprintattendance-views.rst)
 
 By default, the form will be pre-filled out with arrival and departure
 dates that correspond to the full length of the conference and imply the
diff --git a/lib/lp/blueprints/stories/standalone/sprint-links.txt b/lib/lp/blueprints/stories/standalone/sprint-links.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/sprint-links.txt
rename to lib/lp/blueprints/stories/standalone/sprint-links.rst
diff --git a/lib/lp/blueprints/stories/standalone/subscribing.txt b/lib/lp/blueprints/stories/standalone/subscribing.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/subscribing.txt
rename to lib/lp/blueprints/stories/standalone/subscribing.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-batching.txt b/lib/lp/blueprints/stories/standalone/xx-batching.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-batching.txt
rename to lib/lp/blueprints/stories/standalone/xx-batching.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-branch-links.txt b/lib/lp/blueprints/stories/standalone/xx-branch-links.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-branch-links.txt
rename to lib/lp/blueprints/stories/standalone/xx-branch-links.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-index.txt b/lib/lp/blueprints/stories/standalone/xx-index.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-index.txt
rename to lib/lp/blueprints/stories/standalone/xx-index.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-informational-blueprints.txt b/lib/lp/blueprints/stories/standalone/xx-informational-blueprints.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-informational-blueprints.txt
rename to lib/lp/blueprints/stories/standalone/xx-informational-blueprints.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-overview.txt b/lib/lp/blueprints/stories/standalone/xx-overview.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-overview.txt
rename to lib/lp/blueprints/stories/standalone/xx-overview.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-personviews.txt b/lib/lp/blueprints/stories/standalone/xx-personviews.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-personviews.txt
rename to lib/lp/blueprints/stories/standalone/xx-personviews.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-retargeting.txt b/lib/lp/blueprints/stories/standalone/xx-retargeting.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-retargeting.txt
rename to lib/lp/blueprints/stories/standalone/xx-retargeting.rst
diff --git a/lib/lp/blueprints/stories/standalone/xx-views.txt b/lib/lp/blueprints/stories/standalone/xx-views.rst
similarity index 100%
rename from lib/lp/blueprints/stories/standalone/xx-views.txt
rename to lib/lp/blueprints/stories/standalone/xx-views.rst
diff --git a/lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py b/lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py
index f6f015a..cb85e85 100644
--- a/lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py
+++ b/lib/lp/blueprints/vocabularies/tests/test_specificationdependency.py
@@ -3,7 +3,7 @@
 
 """Tests for `SpecificationDepCandidatesVocabulary`.
 
-There is also a doctest in specificationdepcandidates.txt.
+There is also a doctest in specificationdepcandidates.rst.
 """
 
 from zope.schema.vocabulary import getVocabularyRegistry
diff --git a/lib/lp/bugs/browser/bug.py b/lib/lp/bugs/browser/bug.py
index 1e9de86..cbc391c 100644
--- a/lib/lp/bugs/browser/bug.py
+++ b/lib/lp/bugs/browser/bug.py
@@ -400,7 +400,7 @@ class MaloneView(LaunchpadFormView):
     schema = IFrontPageBugTaskSearch
     field_names = ['searchtext', 'scope']
 
-    # Test: standalone/xx-slash-malone-slash-bugs.txt
+    # Test: standalone/xx-slash-malone-slash-bugs.rst
     error_message = None
 
     page_title = 'Launchpad Bugs'
diff --git a/lib/lp/bugs/browser/tests/bug-nomination-views.txt b/lib/lp/bugs/browser/tests/bug-nomination-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bug-nomination-views.txt
rename to lib/lp/bugs/browser/tests/bug-nomination-views.rst
diff --git a/lib/lp/bugs/browser/tests/bug-views.txt b/lib/lp/bugs/browser/tests/bug-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bug-views.txt
rename to lib/lp/bugs/browser/tests/bug-views.rst
diff --git a/lib/lp/bugs/browser/tests/buglinktarget-views.txt b/lib/lp/bugs/browser/tests/buglinktarget-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/buglinktarget-views.txt
rename to lib/lp/bugs/browser/tests/buglinktarget-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugs-views.txt b/lib/lp/bugs/browser/tests/bugs-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugs-views.txt
rename to lib/lp/bugs/browser/tests/bugs-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt b/lib/lp/bugs/browser/tests/bugtarget-filebug-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugtarget-filebug-views.txt
rename to lib/lp/bugs/browser/tests/bugtarget-filebug-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugtask-adding-views.txt b/lib/lp/bugs/browser/tests/bugtask-adding-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugtask-adding-views.txt
rename to lib/lp/bugs/browser/tests/bugtask-adding-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugtask-edit-views.txt b/lib/lp/bugs/browser/tests/bugtask-edit-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugtask-edit-views.txt
rename to lib/lp/bugs/browser/tests/bugtask-edit-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugtask-search-views.txt b/lib/lp/bugs/browser/tests/bugtask-search-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugtask-search-views.txt
rename to lib/lp/bugs/browser/tests/bugtask-search-views.rst
diff --git a/lib/lp/bugs/browser/tests/bugwatch-views.txt b/lib/lp/bugs/browser/tests/bugwatch-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/bugwatch-views.txt
rename to lib/lp/bugs/browser/tests/bugwatch-views.rst
diff --git a/lib/lp/bugs/browser/tests/distrosourcepackage-bug-views.txt b/lib/lp/bugs/browser/tests/distrosourcepackage-bug-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/distrosourcepackage-bug-views.txt
rename to lib/lp/bugs/browser/tests/distrosourcepackage-bug-views.rst
diff --git a/lib/lp/bugs/browser/tests/person-bug-views.txt b/lib/lp/bugs/browser/tests/person-bug-views.rst
similarity index 100%
rename from lib/lp/bugs/browser/tests/person-bug-views.txt
rename to lib/lp/bugs/browser/tests/person-bug-views.rst
diff --git a/lib/lp/bugs/browser/tests/test_views.py b/lib/lp/bugs/browser/tests/test_views.py
index f696aa0..2d21755 100644
--- a/lib/lp/bugs/browser/tests/test_views.py
+++ b/lib/lp/bugs/browser/tests/test_views.py
@@ -24,9 +24,9 @@ here = os.path.dirname(os.path.realpath(__file__))
 
 
 special_test_layer = {
-    'bug-views.txt': LaunchpadFunctionalLayer,
-    'bugtarget-filebug-views.txt': LaunchpadFunctionalLayer,
-    'bugtask-target-link-titles.txt': LaunchpadFunctionalLayer,
+    'bug-views.rst': LaunchpadFunctionalLayer,
+    'bugtarget-filebug-views.rst': LaunchpadFunctionalLayer,
+    'bugtask-target-link-titles.rst': LaunchpadFunctionalLayer,
     }
 
 
@@ -37,7 +37,7 @@ def test_suite():
     # Add tests using default setup/teardown
     filenames = [filename
                  for filename in os.listdir(testsdir)
-                 if filename.endswith('.txt')]
+                 if filename.endswith('.rst')]
     # Sort the list to give a predictable order.
     filenames.sort()
     for filename in filenames:
diff --git a/lib/lp/bugs/doc/bug-change.txt b/lib/lp/bugs/doc/bug-change.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-change.txt
rename to lib/lp/bugs/doc/bug-change.rst
diff --git a/lib/lp/bugs/doc/bug-export.txt b/lib/lp/bugs/doc/bug-export.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-export.txt
rename to lib/lp/bugs/doc/bug-export.rst
diff --git a/lib/lp/bugs/doc/bug-heat.txt b/lib/lp/bugs/doc/bug-heat.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-heat.txt
rename to lib/lp/bugs/doc/bug-heat.rst
diff --git a/lib/lp/bugs/doc/bug-reported-acknowledgement.txt b/lib/lp/bugs/doc/bug-reported-acknowledgement.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-reported-acknowledgement.txt
rename to lib/lp/bugs/doc/bug-reported-acknowledgement.rst
diff --git a/lib/lp/bugs/doc/bug-reporting-guidelines.txt b/lib/lp/bugs/doc/bug-reporting-guidelines.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-reporting-guidelines.txt
rename to lib/lp/bugs/doc/bug-reporting-guidelines.rst
diff --git a/lib/lp/bugs/doc/bug-set-status.txt b/lib/lp/bugs/doc/bug-set-status.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-set-status.txt
rename to lib/lp/bugs/doc/bug-set-status.rst
diff --git a/lib/lp/bugs/doc/bug-tags.txt b/lib/lp/bugs/doc/bug-tags.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-tags.txt
rename to lib/lp/bugs/doc/bug-tags.rst
diff --git a/lib/lp/bugs/doc/bug-watch-activity.txt b/lib/lp/bugs/doc/bug-watch-activity.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug-watch-activity.txt
rename to lib/lp/bugs/doc/bug-watch-activity.rst
diff --git a/lib/lp/bugs/doc/bug.txt b/lib/lp/bugs/doc/bug.rst
similarity index 100%
rename from lib/lp/bugs/doc/bug.txt
rename to lib/lp/bugs/doc/bug.rst
index 028e325..0b05ee2 100644
--- a/lib/lp/bugs/doc/bug.txt
+++ b/lib/lp/bugs/doc/bug.rst
@@ -904,7 +904,7 @@ that the state of the bug permits expiration, and returns True or False.
 IBug.can_expire property returns True or False as to whether the bug
 will expire if it becomes inactive because of a bugtask.
 
-`bugtask-expiration.txt` outlines the complete list of constraints that
+`bugtask-expiration.rst` outlines the complete list of constraints that
 govern expiration. In general, a bug that is not valid anywhere,
 that has a single unattended Incomplete bugtask whose pillar has enabled
 bug expiration. Once an bug is recognised to be valid for one bugtask
@@ -965,7 +965,7 @@ than Incomplete, will cause the bug to not permit expiration.
     >>> expirable_bugtask.bug.can_expire
     False
 
-See `bugtask-expiration.txt` for a more comprehensive set of bugs
+See `bugtask-expiration.rst` for a more comprehensive set of bugs
 that can or cannot expire.
 
 
diff --git a/lib/lp/bugs/doc/bugactivity.txt b/lib/lp/bugs/doc/bugactivity.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugactivity.txt
rename to lib/lp/bugs/doc/bugactivity.rst
diff --git a/lib/lp/bugs/doc/bugattachments.txt b/lib/lp/bugs/doc/bugattachments.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugattachments.txt
rename to lib/lp/bugs/doc/bugattachments.rst
diff --git a/lib/lp/bugs/doc/bugcomment.txt b/lib/lp/bugs/doc/bugcomment.rst
similarity index 99%
rename from lib/lp/bugs/doc/bugcomment.txt
rename to lib/lp/bugs/doc/bugcomment.rst
index c96b75f..010c550 100644
--- a/lib/lp/bugs/doc/bugcomment.txt
+++ b/lib/lp/bugs/doc/bugcomment.rst
@@ -333,7 +333,7 @@ Displaying BugComments with activity
 Comments are often made when a user makes a change to a bug, for example
 setting the bug's status. The BugComment class has a property, activity,
 which can hold a list of BugActivityItems associated with a comment (see
-doc/bugactivity.txt for details of the BugActivityItem class).
+doc/bugactivity.rst for details of the BugActivityItem class).
 
     >>> from lp.bugs.browser.bugcomment import BugComment
     >>> from lp.bugs.browser.bugtask import BugActivityItem
diff --git a/lib/lp/bugs/doc/bugmail-headers.txt b/lib/lp/bugs/doc/bugmail-headers.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugmail-headers.txt
rename to lib/lp/bugs/doc/bugmail-headers.rst
diff --git a/lib/lp/bugs/doc/bugmessage-visibility.txt b/lib/lp/bugs/doc/bugmessage-visibility.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugmessage-visibility.txt
rename to lib/lp/bugs/doc/bugmessage-visibility.rst
diff --git a/lib/lp/bugs/doc/bugmessage.txt b/lib/lp/bugs/doc/bugmessage.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugmessage.txt
rename to lib/lp/bugs/doc/bugmessage.rst
diff --git a/lib/lp/bugs/doc/bugnotification-email.txt b/lib/lp/bugs/doc/bugnotification-email.rst
similarity index 99%
rename from lib/lp/bugs/doc/bugnotification-email.txt
rename to lib/lp/bugs/doc/bugnotification-email.rst
index bc835c9..a0fd92b 100644
--- a/lib/lp/bugs/doc/bugnotification-email.txt
+++ b/lib/lp/bugs/doc/bugnotification-email.rst
@@ -4,7 +4,7 @@ Bug Notification Email
 This document describes the internal workings of how bug notification
 emails are generated and how said emails are formatted. It does not
 cover the various rules and semantics surrounding the notifications
-themselves; for that, see bugnotifications.txt.
+themselves; for that, see bugnotifications.rst.
 
 The reference spec associated with this document is available on the
 Launchpad development wiki:
diff --git a/lib/lp/bugs/doc/bugnotification-sending.txt b/lib/lp/bugs/doc/bugnotification-sending.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugnotification-sending.txt
rename to lib/lp/bugs/doc/bugnotification-sending.rst
index bccc3b2..f8d846f 100644
--- a/lib/lp/bugs/doc/bugnotification-sending.txt
+++ b/lib/lp/bugs/doc/bugnotification-sending.rst
@@ -1,7 +1,7 @@
 Sending the Bug Notifications
 =============================
 
-As explained in bugnotifications.txt, a change to a bug causes a bug
+As explained in bugnotifications.rst, a change to a bug causes a bug
 notification to be added. These notifications should be assembled into
 an email notification, and sent to the appropriate people.
 
diff --git a/lib/lp/bugs/doc/bugnotification-threading.txt b/lib/lp/bugs/doc/bugnotification-threading.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugnotification-threading.txt
rename to lib/lp/bugs/doc/bugnotification-threading.rst
diff --git a/lib/lp/bugs/doc/bugnotificationrecipients.txt b/lib/lp/bugs/doc/bugnotificationrecipients.rst
similarity index 99%
rename from lib/lp/bugs/doc/bugnotificationrecipients.txt
rename to lib/lp/bugs/doc/bugnotificationrecipients.rst
index 56866cb..26d975c 100644
--- a/lib/lp/bugs/doc/bugnotificationrecipients.txt
+++ b/lib/lp/bugs/doc/bugnotificationrecipients.rst
@@ -2,7 +2,7 @@
 BugNotificationRecipients instances store email addresses mapped to the
 reason for which they are being notified in a certain bug. It implements
 the INotificationRecipientSet interface for bug notifications. (See
-notification-recipient-set.txt for the details.)
+notification-recipient-set.rst for the details.)
 
 How it's used
 =============
diff --git a/lib/lp/bugs/doc/bugnotifications.txt b/lib/lp/bugs/doc/bugnotifications.rst
similarity index 99%
rename from lib/lp/bugs/doc/bugnotifications.txt
rename to lib/lp/bugs/doc/bugnotifications.rst
index d177713..533b267 100644
--- a/lib/lp/bugs/doc/bugnotifications.txt
+++ b/lib/lp/bugs/doc/bugnotifications.rst
@@ -61,7 +61,7 @@ Reporting a new bug
 Notifications usually have references to a corresponding bug activity.  These
 can get you details on precisely what changed from a more programmatic
 perspective.  You can get details on what the activity provides in
-bugactivity.txt, but for now here is a small demo.
+bugactivity.rst, but for now here is a small demo.
 
     >>> print(latest_notification.activity.whatchanged)
     bug
diff --git a/lib/lp/bugs/doc/bugsubscription.txt b/lib/lp/bugs/doc/bugsubscription.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugsubscription.txt
rename to lib/lp/bugs/doc/bugsubscription.rst
diff --git a/lib/lp/bugs/doc/bugsummary.txt b/lib/lp/bugs/doc/bugsummary.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugsummary.txt
rename to lib/lp/bugs/doc/bugsummary.rst
diff --git a/lib/lp/bugs/doc/bugtarget.txt b/lib/lp/bugs/doc/bugtarget.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtarget.txt
rename to lib/lp/bugs/doc/bugtarget.rst
diff --git a/lib/lp/bugs/doc/bugtask-assignee-widget.txt b/lib/lp/bugs/doc/bugtask-assignee-widget.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-assignee-widget.txt
rename to lib/lp/bugs/doc/bugtask-assignee-widget.rst
diff --git a/lib/lp/bugs/doc/bugtask-bugwatch-widget.txt b/lib/lp/bugs/doc/bugtask-bugwatch-widget.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-bugwatch-widget.txt
rename to lib/lp/bugs/doc/bugtask-bugwatch-widget.rst
diff --git a/lib/lp/bugs/doc/bugtask-display-widgets.txt b/lib/lp/bugs/doc/bugtask-display-widgets.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-display-widgets.txt
rename to lib/lp/bugs/doc/bugtask-display-widgets.rst
diff --git a/lib/lp/bugs/doc/bugtask-expiration.txt b/lib/lp/bugs/doc/bugtask-expiration.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-expiration.txt
rename to lib/lp/bugs/doc/bugtask-expiration.rst
diff --git a/lib/lp/bugs/doc/bugtask-find-similar.txt b/lib/lp/bugs/doc/bugtask-find-similar.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-find-similar.txt
rename to lib/lp/bugs/doc/bugtask-find-similar.rst
diff --git a/lib/lp/bugs/doc/bugtask-package-bugcounts.txt b/lib/lp/bugs/doc/bugtask-package-bugcounts.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-package-bugcounts.txt
rename to lib/lp/bugs/doc/bugtask-package-bugcounts.rst
diff --git a/lib/lp/bugs/doc/bugtask-package-widget.txt b/lib/lp/bugs/doc/bugtask-package-widget.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-package-widget.txt
rename to lib/lp/bugs/doc/bugtask-package-widget.rst
diff --git a/lib/lp/bugs/doc/bugtask-retrieval.txt b/lib/lp/bugs/doc/bugtask-retrieval.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-retrieval.txt
rename to lib/lp/bugs/doc/bugtask-retrieval.rst
diff --git a/lib/lp/bugs/doc/bugtask-search-old-urls.txt b/lib/lp/bugs/doc/bugtask-search-old-urls.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-search-old-urls.txt
rename to lib/lp/bugs/doc/bugtask-search-old-urls.rst
diff --git a/lib/lp/bugs/doc/bugtask-search.txt b/lib/lp/bugs/doc/bugtask-search.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-search.txt
rename to lib/lp/bugs/doc/bugtask-search.rst
diff --git a/lib/lp/bugs/doc/bugtask-status-changes.txt b/lib/lp/bugs/doc/bugtask-status-changes.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-status-changes.txt
rename to lib/lp/bugs/doc/bugtask-status-changes.rst
diff --git a/lib/lp/bugs/doc/bugtask-status-workflow.txt b/lib/lp/bugs/doc/bugtask-status-workflow.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtask-status-workflow.txt
rename to lib/lp/bugs/doc/bugtask-status-workflow.rst
diff --git a/lib/lp/bugs/doc/bugtracker-person.txt b/lib/lp/bugs/doc/bugtracker-person.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtracker-person.txt
rename to lib/lp/bugs/doc/bugtracker-person.rst
diff --git a/lib/lp/bugs/doc/bugtracker-tokens.txt b/lib/lp/bugs/doc/bugtracker-tokens.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugtracker-tokens.txt
rename to lib/lp/bugs/doc/bugtracker-tokens.rst
diff --git a/lib/lp/bugs/doc/bugtracker.txt b/lib/lp/bugs/doc/bugtracker.rst
similarity index 99%
rename from lib/lp/bugs/doc/bugtracker.txt
rename to lib/lp/bugs/doc/bugtracker.rst
index 9d6be71..8f46133 100644
--- a/lib/lp/bugs/doc/bugtracker.txt
+++ b/lib/lp/bugs/doc/bugtracker.rst
@@ -4,7 +4,7 @@ Monitoring External Bug Trackers in Launchpad Bugs
 Malone allows you to monitor bugs in external bug tracking systems. This
 document discusses the API of external bug trackers. To learn more about
 bug watches, the object that represents the link between a Malone bug
-and an external bug, see bugwatch.txt.
+and an external bug, see bugwatch.rst.
 
     >>> import pytz
     >>> from datetime import datetime, timedelta
diff --git a/lib/lp/bugs/doc/bugwatch.txt b/lib/lp/bugs/doc/bugwatch.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugwatch.txt
rename to lib/lp/bugs/doc/bugwatch.rst
diff --git a/lib/lp/bugs/doc/bugwidget.txt b/lib/lp/bugs/doc/bugwidget.rst
similarity index 100%
rename from lib/lp/bugs/doc/bugwidget.txt
rename to lib/lp/bugs/doc/bugwidget.rst
diff --git a/lib/lp/bugs/doc/checkwatches-batching.txt b/lib/lp/bugs/doc/checkwatches-batching.rst
similarity index 100%
rename from lib/lp/bugs/doc/checkwatches-batching.txt
rename to lib/lp/bugs/doc/checkwatches-batching.rst
diff --git a/lib/lp/bugs/doc/checkwatches-cli-switches.txt b/lib/lp/bugs/doc/checkwatches-cli-switches.rst
similarity index 100%
rename from lib/lp/bugs/doc/checkwatches-cli-switches.txt
rename to lib/lp/bugs/doc/checkwatches-cli-switches.rst
diff --git a/lib/lp/bugs/doc/checkwatches.txt b/lib/lp/bugs/doc/checkwatches.rst
similarity index 100%
rename from lib/lp/bugs/doc/checkwatches.txt
rename to lib/lp/bugs/doc/checkwatches.rst
diff --git a/lib/lp/bugs/doc/cve-update.txt b/lib/lp/bugs/doc/cve-update.rst
similarity index 100%
rename from lib/lp/bugs/doc/cve-update.txt
rename to lib/lp/bugs/doc/cve-update.rst
diff --git a/lib/lp/bugs/doc/cve.txt b/lib/lp/bugs/doc/cve.rst
similarity index 100%
rename from lib/lp/bugs/doc/cve.txt
rename to lib/lp/bugs/doc/cve.rst
diff --git a/lib/lp/bugs/doc/displaying-bugs-and-tasks.txt b/lib/lp/bugs/doc/displaying-bugs-and-tasks.rst
similarity index 100%
rename from lib/lp/bugs/doc/displaying-bugs-and-tasks.txt
rename to lib/lp/bugs/doc/displaying-bugs-and-tasks.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-bug-imports.txt b/lib/lp/bugs/doc/externalbugtracker-bug-imports.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-bug-imports.txt
rename to lib/lp/bugs/doc/externalbugtracker-bug-imports.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-bugzilla-api.txt b/lib/lp/bugs/doc/externalbugtracker-bugzilla-api.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-bugzilla-api.txt
rename to lib/lp/bugs/doc/externalbugtracker-bugzilla-api.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt b/lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.rst
similarity index 98%
rename from lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt
rename to lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.rst
index fdf9f29..3cf8ce6 100644
--- a/lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt
+++ b/lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.rst
@@ -243,7 +243,7 @@ The bug data is stored as a list of dicts:
     <BLANKLINE>
 
 BugzillaLPPlugin.initializeRemoteBugDB() uses its _storeBugs() method to
-store bugs. See externalbugtracker-bugzilla-api.txt for details of
+store bugs. See externalbugtracker-bugzilla-api.rst for details of
 _storeBugs().
 
 
@@ -313,14 +313,14 @@ Getting remote statuses
 
 BugzillaLPPlugin doesn't have any special functionality for getting
 remote statuses. See the "Getting remote statuses" section of
-externalbugtracker-bugzilla-api.txt for details of getting remote
+externalbugtracker-bugzilla-api.rst for details of getting remote
 statuses from Bugzilla APIs.
 
 
 Getting the remote product
 --------------------------
 
-See externalbugtracker-bugzilla-api.txt for details of getting remote
+See externalbugtracker-bugzilla-api.rst for details of getting remote
 products from Bugzilla APIs.
 
 
diff --git a/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.rst b/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.rst
new file mode 100644
index 0000000..cdc35cd
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.rst
@@ -0,0 +1,166 @@
+ExternalBugTracker: Issuezilla and other Freaks
+===============================================
+
+Bugzilla has a few variants out there in the wild, and we work hard to
+support them all.
+
+Issuezilla
+----------
+
+We support Issuezilla-style trackers. These trackers are essentially
+modified (ancient) versions of Bugzilla; their XML elements have
+slightly different names, and they lack the severity field. We pretend
+Mozilla's Bugzilla is an Issuezilla instance here:
+
+    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
+
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> from lp.bugs.tests.externalbugtracker import TestIssuezilla
+    >>> from lp.services.log.logger import FakeLogger
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> mozilla_bugzilla = getUtility(IBugTrackerSet).getByName('mozilla.org')
+    >>> issuezilla = TestIssuezilla(mozilla_bugzilla.baseurl)
+    >>> transaction.commit()
+    >>> with issuezilla.responses(post=False):
+    ...     issuezilla._probe_version()
+    (2, 11)
+    >>> for bug_watch in mozilla_bugzilla.watches:
+    ...     print("%s: %s %s" % (bug_watch.remotebug,
+    ...         bug_watch.remotestatus, bug_watch.remote_importance))
+    2000:
+    123543:
+    42: FUBAR BAZBAZ
+    42: FUBAR BAZBAZ
+    >>> transaction.commit()
+    >>> bug_watch_updater = CheckwatchesMaster(txn, logger=FakeLogger())
+    >>> with issuezilla.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         issuezilla, mozilla_bugzilla.watches)
+    INFO Updating 4 watches for 3 bugs on https://bugzilla.mozilla.org
+    INFO Didn't find bug '42' on https://bugzilla.mozilla.org
+    (local bugs: 1, 2).
+
+    >>> for bug_watch in mozilla_bugzilla.watches:
+    ...     print("%s: %s %s" % (bug_watch.remotebug,
+    ...         bug_watch.remotestatus, bug_watch.remote_importance))
+    2000: RESOLVED FIXED LOW
+    123543: ASSIGNED HIGH
+    42: FUBAR BAZBAZ
+    42: FUBAR BAZBAZ
+
+
+Bugzilla prior to 2.11
+----------------------
+
+Old-style Bugzillas are quite similar to Issuezilla. Again we pretend
+Mozilla.org's using this old version. Here's the scoop:
+
+    >>> from lp.bugs.tests.externalbugtracker import TestOldBugzilla
+    >>> old_bugzilla = TestOldBugzilla(mozilla_bugzilla.baseurl)
+
+  a) The version is way old:
+
+    >>> transaction.commit()
+    >>> with old_bugzilla.responses(post=False):
+    ...     old_bugzilla._probe_version()
+    (2, 10)
+
+  b) The tags are not prefixed with the bz: namespace:
+
+    >>> bug_item_file = old_bugzilla._readBugItemFile()
+    >>> "<bug_status>" in bug_item_file
+    True
+    >>> "<bug_id>" in bug_item_file
+    True
+
+We support them just fine:
+
+    >>> remote_bugs = ['42', '123543']
+    >>> with old_bugzilla.responses():
+    ...     old_bugzilla.initializeRemoteBugDB(remote_bugs)
+    >>> for remote_bug in remote_bugs:
+    ...     print("%s: %s %s" % (
+    ...         remote_bug,
+    ...         old_bugzilla.getRemoteStatus(remote_bug),
+    ...         old_bugzilla.getRemoteImportance(remote_bug)))
+    42: RESOLVED FIXED LOW BLOCKER
+    123543: ASSIGNED HIGH BLOCKER
+
+
+Bugzilla oddities
+-----------------
+
+Some Bugzillas have some weird properties that we need to cater for:
+
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestWeirdBugzilla)
+    >>> weird_bugzilla = TestWeirdBugzilla(mozilla_bugzilla.baseurl)
+    >>> transaction.commit()
+    >>> with weird_bugzilla.responses(post=False):
+    ...     weird_bugzilla._probe_version()
+    (2, 20)
+
+  a) The bug status tag is <bz:status> and not <bz:bug_status>
+
+    >>> bug_item_file = weird_bugzilla._readBugItemFile()
+    >>> print(bug_item_file)
+    <li>...<bz:status>...
+
+  b) The content is non-ascii:
+
+    >>> six.ensure_text(bug_item_file).encode("ascii")
+    Traceback (most recent call last):
+    ...
+    UnicodeEncodeError: 'ascii' codec can't encode character...
+
+Yet everything still works as expected:
+
+    >>> remote_bugs = ['2000', '123543']
+    >>> with weird_bugzilla.responses():
+    ...     weird_bugzilla.initializeRemoteBugDB(remote_bugs)
+    >>> for remote_bug in remote_bugs:
+    ...     print("%s: %s %s" % (
+    ...         remote_bug,
+    ...         weird_bugzilla.getRemoteStatus(remote_bug),
+    ...         weird_bugzilla.getRemoteImportance(remote_bug)))
+    2000: ASSIGNED HIGH BLOCKER
+    123543: RESOLVED FIXED HIGH BLOCKER
+
+
+Broken Bugzillas
+----------------
+
+What does /not/ work as expected is parsing Bugzillas which produce
+invalid XML:
+
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestBrokenBugzilla)
+    >>> broken_bugzilla = TestBrokenBugzilla(mozilla_bugzilla.baseurl)
+    >>> transaction.commit()
+    >>> with broken_bugzilla.responses(post=False):
+    ...     broken_bugzilla._probe_version()
+    (2, 20)
+    >>> "</foobar>" in broken_bugzilla._readBugItemFile()
+    True
+
+    >>> remote_bugs = ['42', '2000']
+    >>> with broken_bugzilla.responses():
+    ...     broken_bugzilla.initializeRemoteBugDB(remote_bugs)
+    Traceback (most recent call last):
+    ...
+    lp.bugs.externalbugtracker.base.UnparsableBugData:
+    Failed to parse XML description...
+
+However, embedded control characters do not generate errors.
+
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     AnotherBrokenBugzilla)
+    >>> broken_bugzilla = AnotherBrokenBugzilla(mozilla_bugzilla.baseurl)
+    >>> r"NOT\x01USED" in repr(broken_bugzilla._readBugItemFile())
+    True
+
+    >>> remote_bugs = ['42', '2000']
+    >>> transaction.commit()
+    >>> with broken_bugzilla.responses():
+    ...     broken_bugzilla.initializeRemoteBugDB(remote_bugs) # no exception
diff --git a/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt b/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt
deleted file mode 100644
index cdc35cd..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt
+++ /dev/null
@@ -1,166 +0,0 @@
-ExternalBugTracker: Issuezilla and other Freaks
-===============================================
-
-Bugzilla has a few variants out there in the wild, and we work hard to
-support them all.
-
-Issuezilla
-----------
-
-We support Issuezilla-style trackers. These trackers are essentially
-modified (ancient) versions of Bugzilla; their XML elements have
-slightly different names, and they lack the severity field. We pretend
-Mozilla's Bugzilla is an Issuezilla instance here:
-
-    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
-
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> from lp.bugs.tests.externalbugtracker import TestIssuezilla
-    >>> from lp.services.log.logger import FakeLogger
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> mozilla_bugzilla = getUtility(IBugTrackerSet).getByName('mozilla.org')
-    >>> issuezilla = TestIssuezilla(mozilla_bugzilla.baseurl)
-    >>> transaction.commit()
-    >>> with issuezilla.responses(post=False):
-    ...     issuezilla._probe_version()
-    (2, 11)
-    >>> for bug_watch in mozilla_bugzilla.watches:
-    ...     print("%s: %s %s" % (bug_watch.remotebug,
-    ...         bug_watch.remotestatus, bug_watch.remote_importance))
-    2000:
-    123543:
-    42: FUBAR BAZBAZ
-    42: FUBAR BAZBAZ
-    >>> transaction.commit()
-    >>> bug_watch_updater = CheckwatchesMaster(txn, logger=FakeLogger())
-    >>> with issuezilla.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         issuezilla, mozilla_bugzilla.watches)
-    INFO Updating 4 watches for 3 bugs on https://bugzilla.mozilla.org
-    INFO Didn't find bug '42' on https://bugzilla.mozilla.org
-    (local bugs: 1, 2).
-
-    >>> for bug_watch in mozilla_bugzilla.watches:
-    ...     print("%s: %s %s" % (bug_watch.remotebug,
-    ...         bug_watch.remotestatus, bug_watch.remote_importance))
-    2000: RESOLVED FIXED LOW
-    123543: ASSIGNED HIGH
-    42: FUBAR BAZBAZ
-    42: FUBAR BAZBAZ
-
-
-Bugzilla prior to 2.11
-----------------------
-
-Old-style Bugzillas are quite similar to Issuezilla. Again we pretend
-Mozilla.org's using this old version. Here's the scoop:
-
-    >>> from lp.bugs.tests.externalbugtracker import TestOldBugzilla
-    >>> old_bugzilla = TestOldBugzilla(mozilla_bugzilla.baseurl)
-
-  a) The version is way old:
-
-    >>> transaction.commit()
-    >>> with old_bugzilla.responses(post=False):
-    ...     old_bugzilla._probe_version()
-    (2, 10)
-
-  b) The tags are not prefixed with the bz: namespace:
-
-    >>> bug_item_file = old_bugzilla._readBugItemFile()
-    >>> "<bug_status>" in bug_item_file
-    True
-    >>> "<bug_id>" in bug_item_file
-    True
-
-We support them just fine:
-
-    >>> remote_bugs = ['42', '123543']
-    >>> with old_bugzilla.responses():
-    ...     old_bugzilla.initializeRemoteBugDB(remote_bugs)
-    >>> for remote_bug in remote_bugs:
-    ...     print("%s: %s %s" % (
-    ...         remote_bug,
-    ...         old_bugzilla.getRemoteStatus(remote_bug),
-    ...         old_bugzilla.getRemoteImportance(remote_bug)))
-    42: RESOLVED FIXED LOW BLOCKER
-    123543: ASSIGNED HIGH BLOCKER
-
-
-Bugzilla oddities
------------------
-
-Some Bugzillas have some weird properties that we need to cater for:
-
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestWeirdBugzilla)
-    >>> weird_bugzilla = TestWeirdBugzilla(mozilla_bugzilla.baseurl)
-    >>> transaction.commit()
-    >>> with weird_bugzilla.responses(post=False):
-    ...     weird_bugzilla._probe_version()
-    (2, 20)
-
-  a) The bug status tag is <bz:status> and not <bz:bug_status>
-
-    >>> bug_item_file = weird_bugzilla._readBugItemFile()
-    >>> print(bug_item_file)
-    <li>...<bz:status>...
-
-  b) The content is non-ascii:
-
-    >>> six.ensure_text(bug_item_file).encode("ascii")
-    Traceback (most recent call last):
-    ...
-    UnicodeEncodeError: 'ascii' codec can't encode character...
-
-Yet everything still works as expected:
-
-    >>> remote_bugs = ['2000', '123543']
-    >>> with weird_bugzilla.responses():
-    ...     weird_bugzilla.initializeRemoteBugDB(remote_bugs)
-    >>> for remote_bug in remote_bugs:
-    ...     print("%s: %s %s" % (
-    ...         remote_bug,
-    ...         weird_bugzilla.getRemoteStatus(remote_bug),
-    ...         weird_bugzilla.getRemoteImportance(remote_bug)))
-    2000: ASSIGNED HIGH BLOCKER
-    123543: RESOLVED FIXED HIGH BLOCKER
-
-
-Broken Bugzillas
-----------------
-
-What does /not/ work as expected is parsing Bugzillas which produce
-invalid XML:
-
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestBrokenBugzilla)
-    >>> broken_bugzilla = TestBrokenBugzilla(mozilla_bugzilla.baseurl)
-    >>> transaction.commit()
-    >>> with broken_bugzilla.responses(post=False):
-    ...     broken_bugzilla._probe_version()
-    (2, 20)
-    >>> "</foobar>" in broken_bugzilla._readBugItemFile()
-    True
-
-    >>> remote_bugs = ['42', '2000']
-    >>> with broken_bugzilla.responses():
-    ...     broken_bugzilla.initializeRemoteBugDB(remote_bugs)
-    Traceback (most recent call last):
-    ...
-    lp.bugs.externalbugtracker.base.UnparsableBugData:
-    Failed to parse XML description...
-
-However, embedded control characters do not generate errors.
-
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     AnotherBrokenBugzilla)
-    >>> broken_bugzilla = AnotherBrokenBugzilla(mozilla_bugzilla.baseurl)
-    >>> r"NOT\x01USED" in repr(broken_bugzilla._readBugItemFile())
-    True
-
-    >>> remote_bugs = ['42', '2000']
-    >>> transaction.commit()
-    >>> with broken_bugzilla.responses():
-    ...     broken_bugzilla.initializeRemoteBugDB(remote_bugs) # no exception
diff --git a/lib/lp/bugs/doc/externalbugtracker-bugzilla.txt b/lib/lp/bugs/doc/externalbugtracker-bugzilla.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-bugzilla.txt
rename to lib/lp/bugs/doc/externalbugtracker-bugzilla.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-checkwatches.txt b/lib/lp/bugs/doc/externalbugtracker-checkwatches.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-checkwatches.txt
rename to lib/lp/bugs/doc/externalbugtracker-checkwatches.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-comment-imports.txt b/lib/lp/bugs/doc/externalbugtracker-comment-imports.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-comment-imports.txt
rename to lib/lp/bugs/doc/externalbugtracker-comment-imports.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-comment-pushing.txt b/lib/lp/bugs/doc/externalbugtracker-comment-pushing.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-comment-pushing.txt
rename to lib/lp/bugs/doc/externalbugtracker-comment-pushing.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-debbugs.txt b/lib/lp/bugs/doc/externalbugtracker-debbugs.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-debbugs.txt
rename to lib/lp/bugs/doc/externalbugtracker-debbugs.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-emailaddress.rst b/lib/lp/bugs/doc/externalbugtracker-emailaddress.rst
new file mode 100644
index 0000000..b0b5c8c
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-emailaddress.rst
@@ -0,0 +1,134 @@
+Email addresses as bug trackers
+===============================
+
+The EMAILADDRESS BugTrackerType
+-------------------------------
+
+Launchpad allows users to register an email address as an external bug
+tracker. This means that bugs for that bug tracker can be forwarded to
+the specified email address.
+
+The BugTrackerType enumeration defines an EMAILADDRESS bug tracker
+type.
+
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> BugTrackerType.EMAILADDRESS.title
+    'Email Address'
+
+Since email addresses are not external bug trackers in the classic
+sense there is no ExternalBugTracker descendant for them. Trying to
+create a new ExternalBugTracker for an email address will fail.
+
+    >>> from lp.bugs.externalbugtracker import (
+    ...     get_external_bugtracker)
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> bug_tracker = get_external_bugtracker(
+    ...     new_bugtracker(BugTrackerType.EMAILADDRESS))
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownBugTrackerTypeError: EMAILADDRESS
+
+A bug tracker of type EMAILADDRESS can be created in the same way as
+any other bug tracker, but with a baseurl in the form
+mailto:<email-address>.
+
+    >>> from zope.component import getUtility
+    >>> from lp.testing import verifyObject
+    >>> from lp.bugs.interfaces.bugtracker import (
+    ...     IBugTracker,
+    ...     IBugTrackerSet,
+    ...     )
+    >>> from lp.registry.interfaces.person import IPersonSet
+
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+    >>> email_tracker = getUtility(IBugTrackerSet).ensureBugTracker(
+    ...     baseurl='mailto:somebugaddress@xxxxxxxxxxx', owner=sample_person,
+    ...     bugtrackertype=BugTrackerType.EMAILADDRESS,
+    ...     title="Sample email address tracker", summary="Nothing",
+    ...     contactdetails="None", name='email-tracker')
+    >>> verifyObject(IBugTracker, email_tracker)
+    True
+
+Passing no name parameter to ensureBugTracker() will create a new bug
+tracker with a name in the form auto-<local_name>, where local name is
+the local part of an email address (e.g. <local_name>@foobar.com).
+
+    >>> other_tracker = getUtility(IBugTrackerSet).ensureBugTracker(
+    ...     baseurl='mailto:another.bugtracker@xxxxxxxxxxx',
+    ...     owner=sample_person, bugtrackertype=BugTrackerType.EMAILADDRESS,
+    ...     title="Sample email address tracker", summary="Nothing",
+    ...     contactdetails="None")
+    >>> verifyObject(IBugTracker, other_tracker)
+    True
+
+    >>> print(other_tracker.name)
+    auto-another.bugtracker
+
+
+Adding a BugWatch to an upstream email address
+----------------------------------------------
+
+We can add a bug watch to an upstream email address in the normal
+fashion. For email addresses, we record the message ID of the mail sent
+to the remote email address, if it is known. The presence of a bug watch
+means that a message has been sent to the upstream tracker.
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> example_bug = getUtility(IBugSet).get(15)
+
+If the message ID of the email sent to the remote tracker is not known,
+we record a remotebug value of '' for the bugwatch (we can't use None
+because BugWatch.remotebug is a NOT NULL field).
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> bug_watch = example_bug.addWatch(bugtracker=email_tracker,
+    ...     remotebug='', owner=getUtility(ILaunchpadCelebrities).janitor)
+    >>> print(bug_watch.bugtracker.name)
+    email-tracker
+    >>> print(bug_watch.remotebug)
+    <BLANKLINE>
+    >>> bug_watch.bug.id
+    15
+
+By contrast, if the message ID is known it is recorded in the remotebug
+field.
+
+    >>> from email.utils import make_msgid
+    >>> message_id = make_msgid('launchpad')
+    >>> bug_watch = example_bug.addWatch(bugtracker=email_tracker,
+    ...     remotebug=message_id,
+    ...     owner=getUtility(ILaunchpadCelebrities).janitor)
+    >>> bug_watch.remotebug == message_id
+    True
+
+Normally, BugTracker.getBugsWatching() returns a shortlist of the bugs
+watching a given remote bug for that bug tracker. However, since with
+an email address bug tracker Launchpad can never know a remote bug ID,
+calling getBugsWatching() on an email address bug tracker will always
+return an empty list.
+
+    >>> from operator import attrgetter
+
+    >>> for watch in sorted(
+    ...         email_tracker.latestwatches, key=attrgetter('remotebug')):
+    ...     print(watch.remotebug)
+    <BLANKLINE>
+    ...launchpad@...
+
+    >>> email_tracker.getBugsWatching('')
+    []
+
+    >>> email_tracker.getBugsWatching(message_id)
+    []
+
+Similarly, Bug.getBugWatch() will always return None for email address
+bug trackers.
+
+    >>> print(example_bug.getBugWatch(email_tracker, ''))
+    None
+
+    >>> print(example_bug.getBugWatch(email_tracker, message_id))
+    None
+
diff --git a/lib/lp/bugs/doc/externalbugtracker-emailaddress.txt b/lib/lp/bugs/doc/externalbugtracker-emailaddress.txt
deleted file mode 100644
index b0b5c8c..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-emailaddress.txt
+++ /dev/null
@@ -1,134 +0,0 @@
-Email addresses as bug trackers
-===============================
-
-The EMAILADDRESS BugTrackerType
--------------------------------
-
-Launchpad allows users to register an email address as an external bug
-tracker. This means that bugs for that bug tracker can be forwarded to
-the specified email address.
-
-The BugTrackerType enumeration defines an EMAILADDRESS bug tracker
-type.
-
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> BugTrackerType.EMAILADDRESS.title
-    'Email Address'
-
-Since email addresses are not external bug trackers in the classic
-sense there is no ExternalBugTracker descendant for them. Trying to
-create a new ExternalBugTracker for an email address will fail.
-
-    >>> from lp.bugs.externalbugtracker import (
-    ...     get_external_bugtracker)
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> bug_tracker = get_external_bugtracker(
-    ...     new_bugtracker(BugTrackerType.EMAILADDRESS))
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownBugTrackerTypeError: EMAILADDRESS
-
-A bug tracker of type EMAILADDRESS can be created in the same way as
-any other bug tracker, but with a baseurl in the form
-mailto:<email-address>.
-
-    >>> from zope.component import getUtility
-    >>> from lp.testing import verifyObject
-    >>> from lp.bugs.interfaces.bugtracker import (
-    ...     IBugTracker,
-    ...     IBugTrackerSet,
-    ...     )
-    >>> from lp.registry.interfaces.person import IPersonSet
-
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-    >>> email_tracker = getUtility(IBugTrackerSet).ensureBugTracker(
-    ...     baseurl='mailto:somebugaddress@xxxxxxxxxxx', owner=sample_person,
-    ...     bugtrackertype=BugTrackerType.EMAILADDRESS,
-    ...     title="Sample email address tracker", summary="Nothing",
-    ...     contactdetails="None", name='email-tracker')
-    >>> verifyObject(IBugTracker, email_tracker)
-    True
-
-Passing no name parameter to ensureBugTracker() will create a new bug
-tracker with a name in the form auto-<local_name>, where local name is
-the local part of an email address (e.g. <local_name>@foobar.com).
-
-    >>> other_tracker = getUtility(IBugTrackerSet).ensureBugTracker(
-    ...     baseurl='mailto:another.bugtracker@xxxxxxxxxxx',
-    ...     owner=sample_person, bugtrackertype=BugTrackerType.EMAILADDRESS,
-    ...     title="Sample email address tracker", summary="Nothing",
-    ...     contactdetails="None")
-    >>> verifyObject(IBugTracker, other_tracker)
-    True
-
-    >>> print(other_tracker.name)
-    auto-another.bugtracker
-
-
-Adding a BugWatch to an upstream email address
-----------------------------------------------
-
-We can add a bug watch to an upstream email address in the normal
-fashion. For email addresses, we record the message ID of the mail sent
-to the remote email address, if it is known. The presence of a bug watch
-means that a message has been sent to the upstream tracker.
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> example_bug = getUtility(IBugSet).get(15)
-
-If the message ID of the email sent to the remote tracker is not known,
-we record a remotebug value of '' for the bugwatch (we can't use None
-because BugWatch.remotebug is a NOT NULL field).
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> bug_watch = example_bug.addWatch(bugtracker=email_tracker,
-    ...     remotebug='', owner=getUtility(ILaunchpadCelebrities).janitor)
-    >>> print(bug_watch.bugtracker.name)
-    email-tracker
-    >>> print(bug_watch.remotebug)
-    <BLANKLINE>
-    >>> bug_watch.bug.id
-    15
-
-By contrast, if the message ID is known it is recorded in the remotebug
-field.
-
-    >>> from email.utils import make_msgid
-    >>> message_id = make_msgid('launchpad')
-    >>> bug_watch = example_bug.addWatch(bugtracker=email_tracker,
-    ...     remotebug=message_id,
-    ...     owner=getUtility(ILaunchpadCelebrities).janitor)
-    >>> bug_watch.remotebug == message_id
-    True
-
-Normally, BugTracker.getBugsWatching() returns a shortlist of the bugs
-watching a given remote bug for that bug tracker. However, since with
-an email address bug tracker Launchpad can never know a remote bug ID,
-calling getBugsWatching() on an email address bug tracker will always
-return an empty list.
-
-    >>> from operator import attrgetter
-
-    >>> for watch in sorted(
-    ...         email_tracker.latestwatches, key=attrgetter('remotebug')):
-    ...     print(watch.remotebug)
-    <BLANKLINE>
-    ...launchpad@...
-
-    >>> email_tracker.getBugsWatching('')
-    []
-
-    >>> email_tracker.getBugsWatching(message_id)
-    []
-
-Similarly, Bug.getBugWatch() will always return None for email address
-bug trackers.
-
-    >>> print(example_bug.getBugWatch(email_tracker, ''))
-    None
-
-    >>> print(example_bug.getBugWatch(email_tracker, message_id))
-    None
-
diff --git a/lib/lp/bugs/doc/externalbugtracker-linking-back.rst b/lib/lp/bugs/doc/externalbugtracker-linking-back.rst
new file mode 100644
index 0000000..cdd3783
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-linking-back.rst
@@ -0,0 +1,142 @@
+Remote bugs linking back to Launchpad
+=====================================
+
+Some bug trackers support linking back to bugs in Launchpad. This way we
+can tell external bug trackers that we're watching the bug, and make it
+easier to users of the external bug tracker to get more information
+about the bug.
+
+    >>> from zope.interface import implementer
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestExternalBugTracker)
+    >>> from lp.bugs.interfaces.externalbugtracker import (
+    ...     ISupportsBackLinking)
+
+    >>> @implementer(ISupportsBackLinking)
+    ... class BackLinkingExternalBugTracker(TestExternalBugTracker):
+    ...
+    ...     def __init__(self, baseurl):
+    ...         super().__init__(baseurl)
+    ...         self.last_launchpad_bug_id = None
+    ...
+    ...     def getLaunchpadBugId(self, remote_bug):
+    ...         print("Getting Launchpad id for bug %s" % remote_bug)
+    ...         return self.last_launchpad_bug_id
+    ...
+    ...     def setLaunchpadBugId(self, remote_bug, launchpad_bug_id,
+    ...                           launchpad_bug_url):
+    ...         self.last_launchpad_bug_id = launchpad_bug_id
+    ...         print("Setting Launchpad id for bug %s" % remote_bug)
+
+The methods are called by the CheckwatchesMaster class:
+
+    >>> from lp.testing.dbuser import switch_dbuser
+    >>> txn = transaction
+
+    >>> switch_dbuser('launchpad')
+
+    >>> bug_watch = factory.makeBugWatch('42')
+    >>> bug_watch.bug.default_bugtask.bugwatch = bug_watch
+    >>> bug_watch_2 = factory.makeBugWatch('42', bug_watch.bugtracker)
+    >>> bug_watch_2.bug.default_bugtask.bugwatch = bug_watch_2
+    >>> bug_watch_without_bugtask = (
+    ...     factory.makeBugWatch('42', bug_watch.bugtracker))
+
+    >>> unlinked_bug = factory.makeBug()
+
+    >>> txn.commit()
+    >>> switch_dbuser('checkwatches')
+
+    >>> from lp.services.log.logger import DevNullLogger
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> checkwatches_master = CheckwatchesMaster(
+    ...     txn, logger=DevNullLogger())
+    >>> txn.commit()
+
+    >>> external_bugtracker = BackLinkingExternalBugTracker(
+    ...     'http://example.com/')
+
+    >>> checkwatches_master.updateBugWatches(external_bugtracker, [bug_watch])
+    Getting Launchpad id for bug 42
+    Setting Launchpad id for bug 42
+
+    >>> external_bugtracker.last_launchpad_bug_id == bug_watch.bug.id
+    True
+
+For comment syncing and back-linking to be attempted, bug watches must
+be related to a bug task, not just a bug.
+
+    >>> checkwatches_master.updateBugWatches(
+    ...     external_bugtracker, [bug_watch_without_bugtask])
+
+
+BugWatchUpdater.linkLaunchpadBug()
+----------------------------------
+
+The BugWatchUpdater method that does the work of setting the Launchpad
+bug link is linkLaunchpadBug(). This method first retrieves the
+current Launchpad bug ID for the remote bug. If the remote bug is
+already linked to a Launchpad bug other than the one that we're trying
+to link it to, the BugWatchUpdater will check that the bug that is
+already linked has a valid watch on the remote bug in question. If it
+does, the link will remain unchanged. Otherwise it will be updated.
+
+Bug 42 on the remote bug tracker is linked to using bug_watch.
+
+    >>> external_bugtracker.last_launchpad_bug_id == bug_watch.bug.id
+    True
+
+Trying to link another bug to it will have no effect since the bug that
+is currently linked has a valid bug watch. Only getLaunchpadBugId() will
+be called on our BackLinkingExternalBugTracker.
+
+    >>> bug_watch.bug.id == bug_watch_2.bug.id
+    False
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.bugs.scripts.checkwatches.tests.test_bugwatchupdater import (
+    ...     make_bug_watch_updater)
+
+    >>> bug_watch_updater = make_bug_watch_updater(
+    ...     CheckwatchesMaster(transaction, logger=FakeLogger()),
+    ...     bug_watch, external_bugtracker)
+    >>> bug_watch_updater.linkLaunchpadBug()
+    Getting Launchpad id for bug 42
+
+However, if we set the current Launchpad bug ID on our
+BackLinkingExternalBugTracker to a Launchpad bug that doesn't link to
+the remote bug, BugWatchUpdater.linkLaunchpadBug() will call
+getLaunchpadBugId() and then, when it discovers that the current
+Launchpad bug ID isn't valid, setLaunchpadBugId() to correct the error.
+
+unlinked_bug doesn't link to bug 42 on the remote bug tracker.
+
+    >>> print(unlinked_bug.getBugWatch(
+    ...     bug_watch.bugtracker, bug_watch.remotebug))
+    None
+
+However, the remote bug currently thinks that unlinked_bug does in
+fact link to it.
+
+    >>> external_bugtracker.last_launchpad_bug_id = unlinked_bug.id
+
+Calling linkLaunchpadBug() with a bug watch that actually does link to
+bug 42 will correct the error.
+
+    >>> transaction.commit()
+
+    >>> bug_watch_updater.linkLaunchpadBug()
+    Getting Launchpad id for bug 42
+    Setting Launchpad id for bug 42
+
+linkLaunchpadBug() will also handle cases where the current Launchpad
+bug ID for a remote bug references a Launchpad bug that doesn't exist.
+The remote bug's Launchpad bug ID will be overwritten to correct the
+error.
+
+    >>> external_bugtracker.last_launchpad_bug_id = 0
+    >>> bug_watch_updater.linkLaunchpadBug()
+    Getting Launchpad id for bug 42
+    Setting Launchpad id for bug 42
diff --git a/lib/lp/bugs/doc/externalbugtracker-linking-back.txt b/lib/lp/bugs/doc/externalbugtracker-linking-back.txt
deleted file mode 100644
index cdd3783..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-linking-back.txt
+++ /dev/null
@@ -1,142 +0,0 @@
-Remote bugs linking back to Launchpad
-=====================================
-
-Some bug trackers support linking back to bugs in Launchpad. This way we
-can tell external bug trackers that we're watching the bug, and make it
-easier to users of the external bug tracker to get more information
-about the bug.
-
-    >>> from zope.interface import implementer
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestExternalBugTracker)
-    >>> from lp.bugs.interfaces.externalbugtracker import (
-    ...     ISupportsBackLinking)
-
-    >>> @implementer(ISupportsBackLinking)
-    ... class BackLinkingExternalBugTracker(TestExternalBugTracker):
-    ...
-    ...     def __init__(self, baseurl):
-    ...         super().__init__(baseurl)
-    ...         self.last_launchpad_bug_id = None
-    ...
-    ...     def getLaunchpadBugId(self, remote_bug):
-    ...         print("Getting Launchpad id for bug %s" % remote_bug)
-    ...         return self.last_launchpad_bug_id
-    ...
-    ...     def setLaunchpadBugId(self, remote_bug, launchpad_bug_id,
-    ...                           launchpad_bug_url):
-    ...         self.last_launchpad_bug_id = launchpad_bug_id
-    ...         print("Setting Launchpad id for bug %s" % remote_bug)
-
-The methods are called by the CheckwatchesMaster class:
-
-    >>> from lp.testing.dbuser import switch_dbuser
-    >>> txn = transaction
-
-    >>> switch_dbuser('launchpad')
-
-    >>> bug_watch = factory.makeBugWatch('42')
-    >>> bug_watch.bug.default_bugtask.bugwatch = bug_watch
-    >>> bug_watch_2 = factory.makeBugWatch('42', bug_watch.bugtracker)
-    >>> bug_watch_2.bug.default_bugtask.bugwatch = bug_watch_2
-    >>> bug_watch_without_bugtask = (
-    ...     factory.makeBugWatch('42', bug_watch.bugtracker))
-
-    >>> unlinked_bug = factory.makeBug()
-
-    >>> txn.commit()
-    >>> switch_dbuser('checkwatches')
-
-    >>> from lp.services.log.logger import DevNullLogger
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> checkwatches_master = CheckwatchesMaster(
-    ...     txn, logger=DevNullLogger())
-    >>> txn.commit()
-
-    >>> external_bugtracker = BackLinkingExternalBugTracker(
-    ...     'http://example.com/')
-
-    >>> checkwatches_master.updateBugWatches(external_bugtracker, [bug_watch])
-    Getting Launchpad id for bug 42
-    Setting Launchpad id for bug 42
-
-    >>> external_bugtracker.last_launchpad_bug_id == bug_watch.bug.id
-    True
-
-For comment syncing and back-linking to be attempted, bug watches must
-be related to a bug task, not just a bug.
-
-    >>> checkwatches_master.updateBugWatches(
-    ...     external_bugtracker, [bug_watch_without_bugtask])
-
-
-BugWatchUpdater.linkLaunchpadBug()
-----------------------------------
-
-The BugWatchUpdater method that does the work of setting the Launchpad
-bug link is linkLaunchpadBug(). This method first retrieves the
-current Launchpad bug ID for the remote bug. If the remote bug is
-already linked to a Launchpad bug other than the one that we're trying
-to link it to, the BugWatchUpdater will check that the bug that is
-already linked has a valid watch on the remote bug in question. If it
-does, the link will remain unchanged. Otherwise it will be updated.
-
-Bug 42 on the remote bug tracker is linked to using bug_watch.
-
-    >>> external_bugtracker.last_launchpad_bug_id == bug_watch.bug.id
-    True
-
-Trying to link another bug to it will have no effect since the bug that
-is currently linked has a valid bug watch. Only getLaunchpadBugId() will
-be called on our BackLinkingExternalBugTracker.
-
-    >>> bug_watch.bug.id == bug_watch_2.bug.id
-    False
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.bugs.scripts.checkwatches.tests.test_bugwatchupdater import (
-    ...     make_bug_watch_updater)
-
-    >>> bug_watch_updater = make_bug_watch_updater(
-    ...     CheckwatchesMaster(transaction, logger=FakeLogger()),
-    ...     bug_watch, external_bugtracker)
-    >>> bug_watch_updater.linkLaunchpadBug()
-    Getting Launchpad id for bug 42
-
-However, if we set the current Launchpad bug ID on our
-BackLinkingExternalBugTracker to a Launchpad bug that doesn't link to
-the remote bug, BugWatchUpdater.linkLaunchpadBug() will call
-getLaunchpadBugId() and then, when it discovers that the current
-Launchpad bug ID isn't valid, setLaunchpadBugId() to correct the error.
-
-unlinked_bug doesn't link to bug 42 on the remote bug tracker.
-
-    >>> print(unlinked_bug.getBugWatch(
-    ...     bug_watch.bugtracker, bug_watch.remotebug))
-    None
-
-However, the remote bug currently thinks that unlinked_bug does in
-fact link to it.
-
-    >>> external_bugtracker.last_launchpad_bug_id = unlinked_bug.id
-
-Calling linkLaunchpadBug() with a bug watch that actually does link to
-bug 42 will correct the error.
-
-    >>> transaction.commit()
-
-    >>> bug_watch_updater.linkLaunchpadBug()
-    Getting Launchpad id for bug 42
-    Setting Launchpad id for bug 42
-
-linkLaunchpadBug() will also handle cases where the current Launchpad
-bug ID for a remote bug references a Launchpad bug that doesn't exist.
-The remote bug's Launchpad bug ID will be overwritten to correct the
-error.
-
-    >>> external_bugtracker.last_launchpad_bug_id = 0
-    >>> bug_watch_updater.linkLaunchpadBug()
-    Getting Launchpad id for bug 42
-    Setting Launchpad id for bug 42
diff --git a/lib/lp/bugs/doc/externalbugtracker-mantis-csv.rst b/lib/lp/bugs/doc/externalbugtracker-mantis-csv.rst
new file mode 100644
index 0000000..c47051d
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-mantis-csv.rst
@@ -0,0 +1,193 @@
+ExternalBugTracker: Mantis
+==========================
+
+This covers the implementation of the Mantis bug watch updater when
+used in the "CSV export" mode. The default mode is to page-scrape
+individual bug reports, but this mode downloads all bug information in
+a CSV format. This is not currently enabled because not all Mantis
+installations work with it (at least two prominent installations
+return empty exports).
+
+
+Basics
+------
+
+The class that implements ExternalBugTracker for Mantis is called,
+surprisingly, Mantis! It doesn't do any version probing and simply
+stores a base URL which it will use to construct URLs to pull a CSV
+export from.
+
+    >>> from lp.bugs.externalbugtracker import Mantis
+    >>> alsa_mantis = Mantis('http://example.com')
+
+As with all ExternalBugTrackers, Mantis contains a function for converting one
+of its own status to a Malone status. Mantis' function takes a string
+in the form "status: resolution" as follows:
+
+    >>> alsa_mantis.convertRemoteStatus('assigned: open').title
+    'In Progress'
+    >>> alsa_mantis.convertRemoteStatus("resolved: won't fix").title
+    "Won't Fix"
+    >>> alsa_mantis.convertRemoteStatus('confirmed: open').title
+    'Confirmed'
+    >>> alsa_mantis.convertRemoteStatus('closed: suspended').title
+    'Invalid'
+    >>> alsa_mantis.convertRemoteStatus('closed: fixed').title
+    'Fix Released'
+
+If the status can't be converted an UnknownRemoteStatusError is raised.
+
+    >>> alsa_mantis.convertRemoteStatus(('foo: bar')).title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: foo: bar
+
+
+Updating Bug Watches
+--------------------
+
+Let's set up a BugTracker and some watches for the Example.com Bug
+Tracker:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestMantis)
+
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.MANTIS)
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '3224',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+We use a specially hacked Mantis instance that doesn't do network
+calls to verify here. We set its batch_query_threshold to 0 so as to
+force it to use the CSV import code:
+
+    >>> mantis = TestMantis(example_bug_tracker.baseurl)
+    >>> mantis.batch_query_threshold = 0
+
+Collect the Example.com watches:
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    3224: None
+
+And have our special Mantis instance process them:
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     transaction, logger=FakeLogger())
+    >>> with mantis.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         mantis, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    3224: assigned: open
+
+Let's add a few more watches:
+
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> expected_remote_statuses = dict(
+    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
+    ...     for bug_watch in example_bug_tracker.watches)
+
+    >>> print(pretty(expected_remote_statuses))
+    {3224: 'assigned: open'}
+
+    >>> remote_bugs = [
+    ...     (7346, dict(status='assigned', resolution='open')),
+    ...     (6685, dict(status='new', resolution='open')),
+    ...     (8104, dict(status='assigned', resolution='open')),
+    ...     (6919, dict(status='assigned', resolution='open')),
+    ...     (8006, dict(status='resolved', resolution='no change required')),
+    ... ]
+
+    >>> for remote_bug_id, remote_bug in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+    ...     mantis.bugs[remote_bug_id] = remote_bug
+    ...     expected_remote_statuses[remote_bug_id] = (
+    ...         "%s: %s" % (remote_bug['status'], remote_bug['resolution']))
+
+Instead of issuing one request per bug watch, like was done before,
+updateBugWatches() issues only one request to update all watches:
+
+    >>> from lp.services.propertycache import get_property_cache
+    >>> del get_property_cache(mantis).csv_data
+
+    >>> with mantis.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         mantis, example_bug_tracker.watches)
+    INFO Updating 6 watches for 6 bugs on http://bugs.some.where
+    POST http://bugs.some.where/view_all_set.php?f=3
+    GET http://bugs.some.where/csv_export.php
+
+    >>> remote_statuses = dict(
+    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
+    ...     for bug_watch in example_bug_tracker.watches)
+
+    >>> remote_bug_ids = set(remote_statuses).union(expected_remote_statuses)
+    >>> for remote_bug_id in sorted(remote_bug_ids):
+    ...     remote_status = remote_statuses[remote_bug_id]
+    ...     expected_remote_status = expected_remote_statuses[remote_bug_id]
+    ...     print('Remote bug %d' % (remote_bug_id,))
+    ...     print(' * Expected << %s >>' % (expected_remote_status,))
+    ...     print(' *      Got << %s >>' % (remote_status,))
+    Remote bug 3224
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+    Remote bug 6685
+     * Expected << new: open >>
+     *      Got << new: open >>
+    Remote bug 6919
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+    Remote bug 7346
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+    Remote bug 8006
+     * Expected << resolved: no change required >>
+     *      Got << resolved: no change required >>
+    Remote bug 8104
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+
+updateBugWatches() updates the lastchecked attribute on the watches, so
+now no bug watches are in need of updating:
+
+    >>> from lp.services.database.sqlbase import flush_database_updates
+    >>> flush_database_updates()
+    >>> example_bug_tracker.watches_needing_update.count()
+    0
+
+If the status isn't different, the lastchanged attribute doesn't get
+updated:
+
+    >>> import pytz
+    >>> from datetime import datetime, timedelta
+    >>> bug_watch = example_bug_tracker.watches[0]
+    >>> now = datetime.now(pytz.timezone('UTC'))
+    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
+    >>> old_last_changed = bug_watch.lastchanged
+    >>> bug_watch_updater.updateBugWatches(mantis, [bug_watch])
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> bug_watch.lastchanged == old_last_changed
+    True
diff --git a/lib/lp/bugs/doc/externalbugtracker-mantis-csv.txt b/lib/lp/bugs/doc/externalbugtracker-mantis-csv.txt
deleted file mode 100644
index c47051d..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-mantis-csv.txt
+++ /dev/null
@@ -1,193 +0,0 @@
-ExternalBugTracker: Mantis
-==========================
-
-This covers the implementation of the Mantis bug watch updater when
-used in the "CSV export" mode. The default mode is to page-scrape
-individual bug reports, but this mode downloads all bug information in
-a CSV format. This is not currently enabled because not all Mantis
-installations work with it (at least two prominent installations
-return empty exports).
-
-
-Basics
-------
-
-The class that implements ExternalBugTracker for Mantis is called,
-surprisingly, Mantis! It doesn't do any version probing and simply
-stores a base URL which it will use to construct URLs to pull a CSV
-export from.
-
-    >>> from lp.bugs.externalbugtracker import Mantis
-    >>> alsa_mantis = Mantis('http://example.com')
-
-As with all ExternalBugTrackers, Mantis contains a function for converting one
-of its own status to a Malone status. Mantis' function takes a string
-in the form "status: resolution" as follows:
-
-    >>> alsa_mantis.convertRemoteStatus('assigned: open').title
-    'In Progress'
-    >>> alsa_mantis.convertRemoteStatus("resolved: won't fix").title
-    "Won't Fix"
-    >>> alsa_mantis.convertRemoteStatus('confirmed: open').title
-    'Confirmed'
-    >>> alsa_mantis.convertRemoteStatus('closed: suspended').title
-    'Invalid'
-    >>> alsa_mantis.convertRemoteStatus('closed: fixed').title
-    'Fix Released'
-
-If the status can't be converted an UnknownRemoteStatusError is raised.
-
-    >>> alsa_mantis.convertRemoteStatus(('foo: bar')).title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: foo: bar
-
-
-Updating Bug Watches
---------------------
-
-Let's set up a BugTracker and some watches for the Example.com Bug
-Tracker:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestMantis)
-
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.MANTIS)
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '3224',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-We use a specially hacked Mantis instance that doesn't do network
-calls to verify here. We set its batch_query_threshold to 0 so as to
-force it to use the CSV import code:
-
-    >>> mantis = TestMantis(example_bug_tracker.baseurl)
-    >>> mantis.batch_query_threshold = 0
-
-Collect the Example.com watches:
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    3224: None
-
-And have our special Mantis instance process them:
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     transaction, logger=FakeLogger())
-    >>> with mantis.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         mantis, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    3224: assigned: open
-
-Let's add a few more watches:
-
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> expected_remote_statuses = dict(
-    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
-    ...     for bug_watch in example_bug_tracker.watches)
-
-    >>> print(pretty(expected_remote_statuses))
-    {3224: 'assigned: open'}
-
-    >>> remote_bugs = [
-    ...     (7346, dict(status='assigned', resolution='open')),
-    ...     (6685, dict(status='new', resolution='open')),
-    ...     (8104, dict(status='assigned', resolution='open')),
-    ...     (6919, dict(status='assigned', resolution='open')),
-    ...     (8006, dict(status='resolved', resolution='no change required')),
-    ... ]
-
-    >>> for remote_bug_id, remote_bug in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-    ...     mantis.bugs[remote_bug_id] = remote_bug
-    ...     expected_remote_statuses[remote_bug_id] = (
-    ...         "%s: %s" % (remote_bug['status'], remote_bug['resolution']))
-
-Instead of issuing one request per bug watch, like was done before,
-updateBugWatches() issues only one request to update all watches:
-
-    >>> from lp.services.propertycache import get_property_cache
-    >>> del get_property_cache(mantis).csv_data
-
-    >>> with mantis.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         mantis, example_bug_tracker.watches)
-    INFO Updating 6 watches for 6 bugs on http://bugs.some.where
-    POST http://bugs.some.where/view_all_set.php?f=3
-    GET http://bugs.some.where/csv_export.php
-
-    >>> remote_statuses = dict(
-    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
-    ...     for bug_watch in example_bug_tracker.watches)
-
-    >>> remote_bug_ids = set(remote_statuses).union(expected_remote_statuses)
-    >>> for remote_bug_id in sorted(remote_bug_ids):
-    ...     remote_status = remote_statuses[remote_bug_id]
-    ...     expected_remote_status = expected_remote_statuses[remote_bug_id]
-    ...     print('Remote bug %d' % (remote_bug_id,))
-    ...     print(' * Expected << %s >>' % (expected_remote_status,))
-    ...     print(' *      Got << %s >>' % (remote_status,))
-    Remote bug 3224
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-    Remote bug 6685
-     * Expected << new: open >>
-     *      Got << new: open >>
-    Remote bug 6919
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-    Remote bug 7346
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-    Remote bug 8006
-     * Expected << resolved: no change required >>
-     *      Got << resolved: no change required >>
-    Remote bug 8104
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-
-updateBugWatches() updates the lastchecked attribute on the watches, so
-now no bug watches are in need of updating:
-
-    >>> from lp.services.database.sqlbase import flush_database_updates
-    >>> flush_database_updates()
-    >>> example_bug_tracker.watches_needing_update.count()
-    0
-
-If the status isn't different, the lastchanged attribute doesn't get
-updated:
-
-    >>> import pytz
-    >>> from datetime import datetime, timedelta
-    >>> bug_watch = example_bug_tracker.watches[0]
-    >>> now = datetime.now(pytz.timezone('UTC'))
-    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
-    >>> old_last_changed = bug_watch.lastchanged
-    >>> bug_watch_updater.updateBugWatches(mantis, [bug_watch])
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> bug_watch.lastchanged == old_last_changed
-    True
diff --git a/lib/lp/bugs/doc/externalbugtracker-mantis-logging-in.txt b/lib/lp/bugs/doc/externalbugtracker-mantis-logging-in.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-mantis-logging-in.txt
rename to lib/lp/bugs/doc/externalbugtracker-mantis-logging-in.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-mantis.rst b/lib/lp/bugs/doc/externalbugtracker-mantis.rst
new file mode 100644
index 0000000..9ccf856
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-mantis.rst
@@ -0,0 +1,208 @@
+ExternalBugTracker: Mantis
+==========================
+
+This covers the implementation of the Mantis bug watch updater.
+
+
+Basics
+------
+
+The class that implements ExternalBugTracker for Mantis is called,
+surprisingly, Mantis! It doesn't do any version probing and simply
+stores a base URL which it will use to construct URLs to pull a CSV
+export from.
+
+    >>> from lp.bugs.externalbugtracker import Mantis
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> from lp.testing import verifyObject
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+    >>> alsa_mantis = Mantis('http://example.com/')
+
+    >>> verifyObject(IExternalBugTracker, alsa_mantis)
+    True
+
+As with all ExternalBugTrackers, Mantis contains a function for converting one
+of its own status to a Malone status. Mantis' function takes a string
+in the form "status: resolution" as follows:
+
+    >>> alsa_mantis.convertRemoteStatus('assigned: open').title
+    'In Progress'
+    >>> alsa_mantis.convertRemoteStatus("resolved: won't fix").title
+    "Won't Fix"
+    >>> alsa_mantis.convertRemoteStatus('confirmed: open').title
+    'Confirmed'
+    >>> alsa_mantis.convertRemoteStatus('closed: suspended').title
+    'Invalid'
+    >>> alsa_mantis.convertRemoteStatus('closed: fixed').title
+    'Fix Released'
+
+If the status can't be converted an UnknownRemoteStatusError is raised.
+
+    >>> alsa_mantis.convertRemoteStatus(('foo: bar')).title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: foo: bar
+
+
+Updating Bug Watches
+--------------------
+
+Let's set up a BugTracker and some watches for the Example.com Bug
+Tracker:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestMantis)
+
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.MANTIS)
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '1550',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+
+We use a specially hacked Mantis instance that doesn't do network
+calls to verify here:
+
+    >>> mantis = TestMantis(example_bug_tracker.baseurl)
+
+Collect the Example.com watches:
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    1550: None
+
+And have our special Mantis instance process them:
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     txn, logger=FakeLogger())
+    >>> with mantis.responses(post=False):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         mantis, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    1550: assigned: open
+
+Let's add a few more watches:
+
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> expected_remote_statuses = dict(
+    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
+    ...     for bug_watch in example_bug_tracker.watches)
+
+    >>> for remotebug, remotestatus in expected_remote_statuses.items():
+    ...     print('%d: %s' % (remotebug, remotestatus))
+    1550: assigned: open
+
+    >>> remote_bugs = [
+    ...     (1550, dict(status='assigned', resolution='open')),
+    ...     (1679, dict(status='closed', resolution='unable to reproduce')),
+    ...     (1730, dict(status='assigned', resolution='open')),
+    ...     (1738, dict(status='feedback', resolution='open')),
+    ...     (1748, dict(status='resolved', resolution='fixed')),
+    ...     (1798, None), # Remote bug doesn't exist.
+    ... ]
+
+    >>> for remote_bug_id, remote_bug in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+    ...     if remote_bug is None:
+    ...         expected_remote_statuses[remote_bug_id] = None
+    ...     else:
+    ...         expected_remote_statuses[remote_bug_id] = (
+    ...             "%s: %s" % (remote_bug['status'],
+    ...                         remote_bug['resolution']))
+
+Instead of issuing one request per bug watch, like was done before,
+updateBugWatches() issues only one request to update all watches:
+
+    >>> from operator import attrgetter
+    >>> getid = attrgetter('id')
+
+    >>> with mantis.responses(trace_calls=True, post=False):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         mantis, sorted(example_bug_tracker.watches, key=getid))
+    INFO Updating 7 watches for 6 bugs on http://bugs.some.where
+    INFO Didn't find bug '1798' on http://bugs.some.where
+    (local bugs: 10).
+    GET http://bugs.some.where/view.php?id=1550
+    GET http://bugs.some.where/view.php?id=1679
+    GET http://bugs.some.where/view.php?id=1730
+    GET http://bugs.some.where/view.php?id=1738
+    GET http://bugs.some.where/view.php?id=1748
+    GET http://bugs.some.where/view.php?id=1798
+
+    >>> remote_statuses = dict(
+    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
+    ...     for bug_watch in example_bug_tracker.watches)
+
+    >>> for remote_bug_id in sorted(set(remote_statuses).union(
+    ...     expected_remote_statuses)):
+    ...     remote_status = remote_statuses[remote_bug_id]
+    ...     expected_remote_status = expected_remote_statuses[remote_bug_id]
+    ...     print('Remote bug %d' % (remote_bug_id,))
+    ...     print(' * Expected << %s >>' % (expected_remote_status,))
+    ...     print(' *      Got << %s >>' % (remote_status,))
+    Remote bug 1550
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+    Remote bug 1679
+     * Expected << closed: unable to reproduce >>
+     *      Got << closed: unable to reproduce >>
+    Remote bug 1730
+     * Expected << assigned: open >>
+     *      Got << assigned: open >>
+    Remote bug 1738
+     * Expected << feedback: open >>
+     *      Got << feedback: open >>
+    Remote bug 1748
+     * Expected << resolved: fixed >>
+     *      Got << resolved: fixed >>
+    Remote bug 1798
+     * Expected << None >>
+     *      Got << None >>
+
+updateBugWatches() updates the lastchecked attribute on the watches, so
+now no bug watches are in need of updating:
+
+    >>> from lp.services.database.sqlbase import flush_database_updates
+    >>> flush_database_updates()
+    >>> example_bug_tracker.watches_needing_update.count()
+    0
+
+If the status isn't different, the lastchanged attribute doesn't get
+updated:
+
+    >>> import pytz
+    >>> from datetime import datetime, timedelta
+    >>> bug_watch = sorted(example_bug_tracker.watches, key=getid)[0]
+    >>> now = datetime.now(pytz.timezone('UTC'))
+    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
+    >>> bug_watch.lastchecked = bug_watch.lastchanged
+    >>> old_last_changed = bug_watch.lastchanged
+    >>> with mantis.responses(post=False):
+    ...     bug_watch_updater.updateBugWatches(mantis, [bug_watch])
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> bug_watch.lastchanged == old_last_changed
+    True
diff --git a/lib/lp/bugs/doc/externalbugtracker-mantis.txt b/lib/lp/bugs/doc/externalbugtracker-mantis.txt
deleted file mode 100644
index 9ccf856..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-mantis.txt
+++ /dev/null
@@ -1,208 +0,0 @@
-ExternalBugTracker: Mantis
-==========================
-
-This covers the implementation of the Mantis bug watch updater.
-
-
-Basics
-------
-
-The class that implements ExternalBugTracker for Mantis is called,
-surprisingly, Mantis! It doesn't do any version probing and simply
-stores a base URL which it will use to construct URLs to pull a CSV
-export from.
-
-    >>> from lp.bugs.externalbugtracker import Mantis
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> from lp.testing import verifyObject
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
-    >>> alsa_mantis = Mantis('http://example.com/')
-
-    >>> verifyObject(IExternalBugTracker, alsa_mantis)
-    True
-
-As with all ExternalBugTrackers, Mantis contains a function for converting one
-of its own status to a Malone status. Mantis' function takes a string
-in the form "status: resolution" as follows:
-
-    >>> alsa_mantis.convertRemoteStatus('assigned: open').title
-    'In Progress'
-    >>> alsa_mantis.convertRemoteStatus("resolved: won't fix").title
-    "Won't Fix"
-    >>> alsa_mantis.convertRemoteStatus('confirmed: open').title
-    'Confirmed'
-    >>> alsa_mantis.convertRemoteStatus('closed: suspended').title
-    'Invalid'
-    >>> alsa_mantis.convertRemoteStatus('closed: fixed').title
-    'Fix Released'
-
-If the status can't be converted an UnknownRemoteStatusError is raised.
-
-    >>> alsa_mantis.convertRemoteStatus(('foo: bar')).title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: foo: bar
-
-
-Updating Bug Watches
---------------------
-
-Let's set up a BugTracker and some watches for the Example.com Bug
-Tracker:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestMantis)
-
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.MANTIS)
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '1550',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-
-We use a specially hacked Mantis instance that doesn't do network
-calls to verify here:
-
-    >>> mantis = TestMantis(example_bug_tracker.baseurl)
-
-Collect the Example.com watches:
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    1550: None
-
-And have our special Mantis instance process them:
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     txn, logger=FakeLogger())
-    >>> with mantis.responses(post=False):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         mantis, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    1550: assigned: open
-
-Let's add a few more watches:
-
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> expected_remote_statuses = dict(
-    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
-    ...     for bug_watch in example_bug_tracker.watches)
-
-    >>> for remotebug, remotestatus in expected_remote_statuses.items():
-    ...     print('%d: %s' % (remotebug, remotestatus))
-    1550: assigned: open
-
-    >>> remote_bugs = [
-    ...     (1550, dict(status='assigned', resolution='open')),
-    ...     (1679, dict(status='closed', resolution='unable to reproduce')),
-    ...     (1730, dict(status='assigned', resolution='open')),
-    ...     (1738, dict(status='feedback', resolution='open')),
-    ...     (1748, dict(status='resolved', resolution='fixed')),
-    ...     (1798, None), # Remote bug doesn't exist.
-    ... ]
-
-    >>> for remote_bug_id, remote_bug in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-    ...     if remote_bug is None:
-    ...         expected_remote_statuses[remote_bug_id] = None
-    ...     else:
-    ...         expected_remote_statuses[remote_bug_id] = (
-    ...             "%s: %s" % (remote_bug['status'],
-    ...                         remote_bug['resolution']))
-
-Instead of issuing one request per bug watch, like was done before,
-updateBugWatches() issues only one request to update all watches:
-
-    >>> from operator import attrgetter
-    >>> getid = attrgetter('id')
-
-    >>> with mantis.responses(trace_calls=True, post=False):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         mantis, sorted(example_bug_tracker.watches, key=getid))
-    INFO Updating 7 watches for 6 bugs on http://bugs.some.where
-    INFO Didn't find bug '1798' on http://bugs.some.where
-    (local bugs: 10).
-    GET http://bugs.some.where/view.php?id=1550
-    GET http://bugs.some.where/view.php?id=1679
-    GET http://bugs.some.where/view.php?id=1730
-    GET http://bugs.some.where/view.php?id=1738
-    GET http://bugs.some.where/view.php?id=1748
-    GET http://bugs.some.where/view.php?id=1798
-
-    >>> remote_statuses = dict(
-    ...     (int(bug_watch.remotebug), bug_watch.remotestatus)
-    ...     for bug_watch in example_bug_tracker.watches)
-
-    >>> for remote_bug_id in sorted(set(remote_statuses).union(
-    ...     expected_remote_statuses)):
-    ...     remote_status = remote_statuses[remote_bug_id]
-    ...     expected_remote_status = expected_remote_statuses[remote_bug_id]
-    ...     print('Remote bug %d' % (remote_bug_id,))
-    ...     print(' * Expected << %s >>' % (expected_remote_status,))
-    ...     print(' *      Got << %s >>' % (remote_status,))
-    Remote bug 1550
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-    Remote bug 1679
-     * Expected << closed: unable to reproduce >>
-     *      Got << closed: unable to reproduce >>
-    Remote bug 1730
-     * Expected << assigned: open >>
-     *      Got << assigned: open >>
-    Remote bug 1738
-     * Expected << feedback: open >>
-     *      Got << feedback: open >>
-    Remote bug 1748
-     * Expected << resolved: fixed >>
-     *      Got << resolved: fixed >>
-    Remote bug 1798
-     * Expected << None >>
-     *      Got << None >>
-
-updateBugWatches() updates the lastchecked attribute on the watches, so
-now no bug watches are in need of updating:
-
-    >>> from lp.services.database.sqlbase import flush_database_updates
-    >>> flush_database_updates()
-    >>> example_bug_tracker.watches_needing_update.count()
-    0
-
-If the status isn't different, the lastchanged attribute doesn't get
-updated:
-
-    >>> import pytz
-    >>> from datetime import datetime, timedelta
-    >>> bug_watch = sorted(example_bug_tracker.watches, key=getid)[0]
-    >>> now = datetime.now(pytz.timezone('UTC'))
-    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
-    >>> bug_watch.lastchecked = bug_watch.lastchanged
-    >>> old_last_changed = bug_watch.lastchanged
-    >>> with mantis.responses(post=False):
-    ...     bug_watch_updater.updateBugWatches(mantis, [bug_watch])
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> bug_watch.lastchanged == old_last_changed
-    True
diff --git a/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.rst b/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.rst
new file mode 100644
index 0000000..4d789f3
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.rst
@@ -0,0 +1,82 @@
+ExternalBugTracker: Python
+==========================
+
+This covers the implementation of the ExternalBugTracker class for
+Python bugwatches.
+
+The Python bug tracker is a slight modification of the Roundup
+bugtracker and the functionality for importing bugs from it is housed
+within the Roundup ExternalBugTracker. As such, we only test the ways in
+which it differs from standard Roundup status imports. For the tests
+common to Roundup and Python instances, see
+externalbugtracker-roundup.rst
+
+
+Status Conversion
+-----------------
+
+The basic Python bug statuses map to Launchpad bug statuses.
+Roundup.convertRemoteStatus() handles the conversion.
+
+Because Python bugtracker statuses are entirely numeric, we use the
+convert_python_status() helper function, which accepts parameters for
+status and resolution, to make the test more readable.
+
+    >>> from lp.bugs.externalbugtracker import (
+    ...     Roundup)
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     convert_python_status)
+    >>> python_bugs = Roundup('http://bugs.python.org')
+
+    >>> python_bugs_example_statuses = [
+    ...     ('open', 'None'),
+    ...     ('open', 'accepted'),
+    ...     ('open', 'duplicate'),
+    ...     ('open', 'fixed'),
+    ...     ('open', 'invalid'),
+    ...     ('open', 'later'),
+    ...     ('open', 'out-of-date'),
+    ...     ('open', 'postponed'),
+    ...     ('open', 'rejected'),
+    ...     ('open', 'remind'),
+    ...     ('open', 'wontfix'),
+    ...     ('open', 'worksforme'),
+    ...     ('closed', 'None'),
+    ...     ('closed', 'accepted'),
+    ...     ('closed', 'fixed'),
+    ...     ('closed', 'postponed'),
+    ...     ('pending', 'None'),
+    ...     ('pending', 'postponed'),
+    ...     ]
+
+    >>> for status, resolution in python_bugs_example_statuses:
+    ...     status_string = convert_python_status(status, resolution)
+    ...     status_converted = python_bugs.convertRemoteStatus(status_string)
+    ...     print('(%s, %s) --> %s --> %s' % (
+    ...         status, resolution, status_string, status_converted))
+    (open, None) --> 1:None --> New
+    (open, accepted) --> 1:1 --> Confirmed
+    (open, duplicate) --> 1:2 --> Confirmed
+    (open, fixed) --> 1:3 --> Fix Committed
+    (open, invalid) --> 1:4 --> Invalid
+    (open, later) --> 1:5 --> Confirmed
+    (open, out-of-date) --> 1:6 --> Invalid
+    (open, postponed) --> 1:7 --> Confirmed
+    (open, rejected) --> 1:8 --> Won't Fix
+    (open, remind) --> 1:9 --> Confirmed
+    (open, wontfix) --> 1:10 --> Won't Fix
+    (open, worksforme) --> 1:11 --> Invalid
+    (closed, None) --> 2:None --> Won't Fix
+    (closed, accepted) --> 2:1 --> Fix Committed
+    (closed, fixed) --> 2:3 --> Fix Released
+    (closed, postponed) --> 2:7 --> Won't Fix
+    (pending, None) --> 3:None --> Incomplete
+    (pending, postponed) --> 3:7 --> Won't Fix
+
+If the status isn't something that our Python_Bugs ExternalBugTracker can
+understand an UnknownRemoteStatusError will be raised.
+
+    >>> python_bugs.convertRemoteStatus('7:13').title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: 7:13
diff --git a/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.txt b/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.txt
deleted file mode 100644
index 7225c7f..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-roundup-python-bugs.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-ExternalBugTracker: Python
-==========================
-
-This covers the implementation of the ExternalBugTracker class for
-Python bugwatches.
-
-The Python bug tracker is a slight modification of the Roundup
-bugtracker and the functionality for importing bugs from it is housed
-within the Roundup ExternalBugTracker. As such, we only test the ways in
-which it differs from standard Roundup status imports. For the tests
-common to Roundup and Python instances, see
-externalbugtracker-roundup.txt
-
-
-Status Conversion
------------------
-
-The basic Python bug statuses map to Launchpad bug statuses.
-Roundup.convertRemoteStatus() handles the conversion.
-
-Because Python bugtracker statuses are entirely numeric, we use the
-convert_python_status() helper function, which accepts parameters for
-status and resolution, to make the test more readable.
-
-    >>> from lp.bugs.externalbugtracker import (
-    ...     Roundup)
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     convert_python_status)
-    >>> python_bugs = Roundup('http://bugs.python.org')
-
-    >>> python_bugs_example_statuses = [
-    ...     ('open', 'None'),
-    ...     ('open', 'accepted'),
-    ...     ('open', 'duplicate'),
-    ...     ('open', 'fixed'),
-    ...     ('open', 'invalid'),
-    ...     ('open', 'later'),
-    ...     ('open', 'out-of-date'),
-    ...     ('open', 'postponed'),
-    ...     ('open', 'rejected'),
-    ...     ('open', 'remind'),
-    ...     ('open', 'wontfix'),
-    ...     ('open', 'worksforme'),
-    ...     ('closed', 'None'),
-    ...     ('closed', 'accepted'),
-    ...     ('closed', 'fixed'),
-    ...     ('closed', 'postponed'),
-    ...     ('pending', 'None'),
-    ...     ('pending', 'postponed'),
-    ...     ]
-
-    >>> for status, resolution in python_bugs_example_statuses:
-    ...     status_string = convert_python_status(status, resolution)
-    ...     status_converted = python_bugs.convertRemoteStatus(status_string)
-    ...     print('(%s, %s) --> %s --> %s' % (
-    ...         status, resolution, status_string, status_converted))
-    (open, None) --> 1:None --> New
-    (open, accepted) --> 1:1 --> Confirmed
-    (open, duplicate) --> 1:2 --> Confirmed
-    (open, fixed) --> 1:3 --> Fix Committed
-    (open, invalid) --> 1:4 --> Invalid
-    (open, later) --> 1:5 --> Confirmed
-    (open, out-of-date) --> 1:6 --> Invalid
-    (open, postponed) --> 1:7 --> Confirmed
-    (open, rejected) --> 1:8 --> Won't Fix
-    (open, remind) --> 1:9 --> Confirmed
-    (open, wontfix) --> 1:10 --> Won't Fix
-    (open, worksforme) --> 1:11 --> Invalid
-    (closed, None) --> 2:None --> Won't Fix
-    (closed, accepted) --> 2:1 --> Fix Committed
-    (closed, fixed) --> 2:3 --> Fix Released
-    (closed, postponed) --> 2:7 --> Won't Fix
-    (pending, None) --> 3:None --> Incomplete
-    (pending, postponed) --> 3:7 --> Won't Fix
-
-If the status isn't something that our Python_Bugs ExternalBugTracker can
-understand an UnknownRemoteStatusError will be raised.
-
-    >>> python_bugs.convertRemoteStatus('7:13').title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: 7:13
diff --git a/lib/lp/bugs/doc/externalbugtracker-roundup.rst b/lib/lp/bugs/doc/externalbugtracker-roundup.rst
new file mode 100644
index 0000000..f7d7949
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-roundup.rst
@@ -0,0 +1,211 @@
+ExternalBugTracker: Roundup
+===========================
+
+This covers the implementation of the ExternalBugTracker class for Roundup
+bugwatches.
+
+
+Basics
+------
+
+The ExternalBugTracker descendant class which implements methods for updating
+bug watches on Roundup bug trackers is externalbugtracker.Roundup, which
+implements IExternalBugTracker.
+
+    >>> from lp.bugs.externalbugtracker import Roundup
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> from lp.testing import verifyObject
+    >>> verifyObject(
+    ...     IExternalBugTracker,
+    ...     Roundup('http://example.com'))
+    True
+
+
+Status Conversion
+-----------------
+
+The basic Roundup bug statuses (i.e. those available by default in new
+Roundup instances) map to Launchpad bug statuses.
+
+Roundup.convertRemoteStatus() handles the conversion.
+
+    >>> roundup = Roundup('http://example.com/')
+    >>> roundup.convertRemoteStatus('1').title
+    'New'
+    >>> roundup.convertRemoteStatus('2').title
+    'Confirmed'
+    >>> roundup.convertRemoteStatus('3').title
+    'Incomplete'
+    >>> roundup.convertRemoteStatus('4').title
+    'Incomplete'
+    >>> roundup.convertRemoteStatus('5').title
+    'In Progress'
+    >>> roundup.convertRemoteStatus('6').title
+    'In Progress'
+    >>> roundup.convertRemoteStatus('7').title
+    'Fix Committed'
+    >>> roundup.convertRemoteStatus('8').title
+    'Fix Released'
+
+Some Roundup trackers are set up to use multiple fields (columns in
+Roundup terminology) to represent bug statuses. We store multiple
+values by joining them with colons. The Roundup class knows how many
+fields are expected for a particular remote host (for those that we
+support), and will generate an error when we have more or less field
+values compared to the expected number of fields.
+
+    >>> roundup.convertRemoteStatus('1:2')
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError:
+    1 field(s) expected, got 2: 1:2
+
+If the status isn't something that our Roundup ExternalBugTracker can
+understand an UnknownRemoteStatusError will be raised.
+
+    >>> roundup.convertRemoteStatus('eggs').title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError:
+    Unrecognized value for field 1 (status): eggs
+
+
+Initialization
+--------------
+
+Calling initializeRemoteBugDB() on our Roundup instance and passing it a set
+of remote bug IDs will fetch those bug IDs from the server and file them in a
+local variable for later use.
+
+We use a test-oriented implementation for the purposes of these tests, which
+avoids relying on a working network connection.
+
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestRoundup, print_bugwatches)
+    >>> roundup = TestRoundup(u'http://test.roundup/')
+    >>> with roundup.responses():
+    ...     roundup.initializeRemoteBugDB([1])
+    >>> sorted(roundup.bugs.keys())
+    [1]
+
+
+Export Methods
+--------------
+
+There are two means by which we can export Roundup bug statuses: on a
+bug-by-bug basis and as a batch. When the number of bugs that need updating is
+less than a given bug tracker's batch_query_threshold the bugs will be
+fetched one-at-a-time:
+
+    >>> roundup.batch_query_threshold
+    10
+
+    >>> with roundup.responses(trace_calls=True):
+    ...     roundup.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    GET http://test.roundup/issue?...&id=6
+    GET http://test.roundup/issue?...&id=7
+    GET http://test.roundup/issue?...&id=8
+    GET http://test.roundup/issue?...&id=9
+    GET http://test.roundup/issue?...&id=10
+
+If there are more than batch_query_threshold bugs to update then they are
+fetched as a batch:
+
+    >>> roundup.batch_query_threshold = 4
+    >>> with roundup.responses(trace_calls=True):
+    ...     roundup.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    GET http://test.roundup/issue?...@startwith=0
+
+
+Updating Bug Watches
+--------------------
+
+First, we create some bug watches to test with:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.ROUNDUP)
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '1',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+
+Collect the Example.com watches:
+
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1: None
+
+And have a Roundup instance process them:
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     txn, logger=FakeLogger())
+    >>> roundup = TestRoundup(example_bug_tracker.baseurl)
+    >>> with roundup.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         roundup, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1: 1
+
+We'll add some more watches now.
+
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     roundup.convertRemoteStatus)
+    Remote bug 1: New
+
+    >>> remote_bugs = [
+    ...     (2, 'Confirmed'),
+    ...     (3, 'Incomplete'),
+    ...     (4, 'Incomplete'),
+    ...     (5, 'In Progress'),
+    ...     (9, 'In Progress'),
+    ...     (10, 'Fix Committed'),
+    ...     (11, 'Fix Released'),
+    ...     (12, 'Incomplete'),
+    ...     (13, 'Incomplete'),
+    ...     (14, 'In Progress')
+    ... ]
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> for remote_bug_id, remote_status in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+
+    >>> with roundup.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         roundup, example_bug_tracker.watches)
+    INFO Updating 11 watches for 11 bugs on http://bugs.some.where
+    GET http://.../issue?...@startwith=0
+
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     roundup.convertRemoteStatus)
+    Remote bug 1: New
+    Remote bug 2: Confirmed
+    Remote bug 3: Incomplete
+    Remote bug 4: Incomplete
+    Remote bug 5: In Progress
+    Remote bug 9: In Progress
+    Remote bug 10: Fix Committed
+    Remote bug 11: Fix Released
+    Remote bug 12: Incomplete
+    Remote bug 13: Incomplete
+    Remote bug 14: In Progress
+
diff --git a/lib/lp/bugs/doc/externalbugtracker-roundup.txt b/lib/lp/bugs/doc/externalbugtracker-roundup.txt
deleted file mode 100644
index f7d7949..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-roundup.txt
+++ /dev/null
@@ -1,211 +0,0 @@
-ExternalBugTracker: Roundup
-===========================
-
-This covers the implementation of the ExternalBugTracker class for Roundup
-bugwatches.
-
-
-Basics
-------
-
-The ExternalBugTracker descendant class which implements methods for updating
-bug watches on Roundup bug trackers is externalbugtracker.Roundup, which
-implements IExternalBugTracker.
-
-    >>> from lp.bugs.externalbugtracker import Roundup
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> from lp.testing import verifyObject
-    >>> verifyObject(
-    ...     IExternalBugTracker,
-    ...     Roundup('http://example.com'))
-    True
-
-
-Status Conversion
------------------
-
-The basic Roundup bug statuses (i.e. those available by default in new
-Roundup instances) map to Launchpad bug statuses.
-
-Roundup.convertRemoteStatus() handles the conversion.
-
-    >>> roundup = Roundup('http://example.com/')
-    >>> roundup.convertRemoteStatus('1').title
-    'New'
-    >>> roundup.convertRemoteStatus('2').title
-    'Confirmed'
-    >>> roundup.convertRemoteStatus('3').title
-    'Incomplete'
-    >>> roundup.convertRemoteStatus('4').title
-    'Incomplete'
-    >>> roundup.convertRemoteStatus('5').title
-    'In Progress'
-    >>> roundup.convertRemoteStatus('6').title
-    'In Progress'
-    >>> roundup.convertRemoteStatus('7').title
-    'Fix Committed'
-    >>> roundup.convertRemoteStatus('8').title
-    'Fix Released'
-
-Some Roundup trackers are set up to use multiple fields (columns in
-Roundup terminology) to represent bug statuses. We store multiple
-values by joining them with colons. The Roundup class knows how many
-fields are expected for a particular remote host (for those that we
-support), and will generate an error when we have more or less field
-values compared to the expected number of fields.
-
-    >>> roundup.convertRemoteStatus('1:2')
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError:
-    1 field(s) expected, got 2: 1:2
-
-If the status isn't something that our Roundup ExternalBugTracker can
-understand an UnknownRemoteStatusError will be raised.
-
-    >>> roundup.convertRemoteStatus('eggs').title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError:
-    Unrecognized value for field 1 (status): eggs
-
-
-Initialization
---------------
-
-Calling initializeRemoteBugDB() on our Roundup instance and passing it a set
-of remote bug IDs will fetch those bug IDs from the server and file them in a
-local variable for later use.
-
-We use a test-oriented implementation for the purposes of these tests, which
-avoids relying on a working network connection.
-
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestRoundup, print_bugwatches)
-    >>> roundup = TestRoundup(u'http://test.roundup/')
-    >>> with roundup.responses():
-    ...     roundup.initializeRemoteBugDB([1])
-    >>> sorted(roundup.bugs.keys())
-    [1]
-
-
-Export Methods
---------------
-
-There are two means by which we can export Roundup bug statuses: on a
-bug-by-bug basis and as a batch. When the number of bugs that need updating is
-less than a given bug tracker's batch_query_threshold the bugs will be
-fetched one-at-a-time:
-
-    >>> roundup.batch_query_threshold
-    10
-
-    >>> with roundup.responses(trace_calls=True):
-    ...     roundup.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    GET http://test.roundup/issue?...&id=6
-    GET http://test.roundup/issue?...&id=7
-    GET http://test.roundup/issue?...&id=8
-    GET http://test.roundup/issue?...&id=9
-    GET http://test.roundup/issue?...&id=10
-
-If there are more than batch_query_threshold bugs to update then they are
-fetched as a batch:
-
-    >>> roundup.batch_query_threshold = 4
-    >>> with roundup.responses(trace_calls=True):
-    ...     roundup.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    GET http://test.roundup/issue?...@startwith=0
-
-
-Updating Bug Watches
---------------------
-
-First, we create some bug watches to test with:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.ROUNDUP)
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '1',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-
-Collect the Example.com watches:
-
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1: None
-
-And have a Roundup instance process them:
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     txn, logger=FakeLogger())
-    >>> roundup = TestRoundup(example_bug_tracker.baseurl)
-    >>> with roundup.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         roundup, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1: 1
-
-We'll add some more watches now.
-
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     roundup.convertRemoteStatus)
-    Remote bug 1: New
-
-    >>> remote_bugs = [
-    ...     (2, 'Confirmed'),
-    ...     (3, 'Incomplete'),
-    ...     (4, 'Incomplete'),
-    ...     (5, 'In Progress'),
-    ...     (9, 'In Progress'),
-    ...     (10, 'Fix Committed'),
-    ...     (11, 'Fix Released'),
-    ...     (12, 'Incomplete'),
-    ...     (13, 'Incomplete'),
-    ...     (14, 'In Progress')
-    ... ]
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> for remote_bug_id, remote_status in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-
-    >>> with roundup.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         roundup, example_bug_tracker.watches)
-    INFO Updating 11 watches for 11 bugs on http://bugs.some.where
-    GET http://.../issue?...@startwith=0
-
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     roundup.convertRemoteStatus)
-    Remote bug 1: New
-    Remote bug 2: Confirmed
-    Remote bug 3: Incomplete
-    Remote bug 4: Incomplete
-    Remote bug 5: In Progress
-    Remote bug 9: In Progress
-    Remote bug 10: Fix Committed
-    Remote bug 11: Fix Released
-    Remote bug 12: Incomplete
-    Remote bug 13: Incomplete
-    Remote bug 14: In Progress
-
diff --git a/lib/lp/bugs/doc/externalbugtracker-rt.rst b/lib/lp/bugs/doc/externalbugtracker-rt.rst
new file mode 100644
index 0000000..7a1b9bd
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-rt.rst
@@ -0,0 +1,285 @@
+ExternalBugTracker: RT
+======================
+
+This covers the implementation of an ExternalBugTracker class for RT
+instances.
+
+
+Basics
+------
+
+When importing bugs from remote RT instances, we use an RT-specific
+implementation of ExternalBugTracker, RequestTracker.
+
+    >>> from lp.bugs.externalbugtracker import (
+    ...     RequestTracker)
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> from lp.testing import verifyObject
+    >>> verifyObject(
+    ...     IExternalBugTracker,
+    ...     RequestTracker('http://example.com/'))
+    True
+
+
+Authentication Credentials
+--------------------------
+
+RT instances require that we log in to be able to export statuses for
+their tickets. The RequestTracker ExternalBugTracker class has a
+credentials property which returns a dict of credentials based on the
+hostname of the current remote RT instance.
+
+The default username and password for RT instances are 'guest' and
+'guest'. The credentials property for an RT instance that we don't have
+specific credentials for will return the default credentials.
+
+    >>> rt_one = RequestTracker('http://foobar.com')
+    >>> print(pretty(rt_one.credentials))
+    {'pass': 'guest', 'user': 'guest'}
+
+However, if the RT instance is one for which we have a username and
+password, those credentials will be retrieved from the Launchpad
+configuration files. rt.example.com is known to Launchpad.
+
+    >>> rt_two = RequestTracker('http://rt.example.com')
+    >>> print(pretty(rt_two.credentials))
+    {'pass': 'pangalacticgargleblaster', 'user': 'zaphod'}
+
+Status Conversion
+-----------------
+
+The RequestTracker class can convert the default RT ticket statuses into
+Launchpad statuses:
+
+    >>> rt = RequestTracker('http://example.com/')
+    >>> rt.convertRemoteStatus('new').title
+    'New'
+    >>> rt.convertRemoteStatus('open').title
+    'Confirmed'
+    >>> rt.convertRemoteStatus('stalled').title
+    'Confirmed'
+    >>> rt.convertRemoteStatus('rejected').title
+    'Invalid'
+    >>> rt.convertRemoteStatus('resolved').title
+    'Fix Released'
+
+Passing a status which the RequestTracker instance can't understand will
+result in an UnknownRemoteStatusError being raised.
+
+    >>> rt.convertRemoteStatus('spam').title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: spam
+
+
+Importance Conversion
+---------------------
+
+There is no obvious mapping from ticket priorities to importances. They
+are all imported as Unknown. No exception is raised, because they are
+all unknown.
+
+    >>> rt.convertRemoteImportance('foo').title
+    'Unknown'
+
+
+Initialization
+--------------
+
+Calling initializeRemoteBugDB() on our RequestTracker instance and
+passing it a set of remote bug IDs will fetch those bug IDs from the
+server and file them in a local variable for later use.
+
+We use a test-oriented implementation of RequestTracker for the purposes
+of these tests, which allows us to not rely on a working network
+connection.
+
+    >>> from lp.bugs.tests.externalbugtracker import TestRequestTracker
+    >>> rt = TestRequestTracker('http://example.com/')
+    >>> with rt.responses(trace_calls=True):
+    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
+    GET http://example.com/?...
+    GET http://example.com/REST/1.0/search/ticket/?...
+    >>> sorted(rt.bugs.keys())
+    [1585, 1586, 1587, 1588, 1589]
+
+The first request logs into RT and saves the resulting cookie.
+
+    >>> def print_cookie_jar(jar):
+    ...     for name, value in sorted(jar.items()):
+    ...         print('%s=%s' % (name, value))
+
+    >>> print_cookie_jar(rt._cookie_jar)
+    rt_credentials=guest:guest
+
+Subsequent requests use this.
+
+    >>> with rt.responses(trace_calls=True) as requests_mock:
+    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
+    ...     print(requests_mock.calls[0].request.headers['Cookie'])
+    rt_credentials=guest:guest
+    GET http://example.com/REST/1.0/search/ticket/?...
+
+
+Export Methods
+--------------
+
+There are two means by which we can export RT bug statuses: on a
+bug-by-bug basis and as a batch. When the number of bugs that need
+updating is less than a given bug RT instances's batch_query_threshold
+the bugs will be fetched one-at-a-time:
+
+    >>> rt.batch_query_threshold
+    1
+
+    >>> with rt.responses(trace_calls=True):
+    ...     rt.initializeRemoteBugDB([1585])
+    GET http://example.com/REST/1.0/ticket/1585/show
+
+    >>> list(rt.bugs)
+    [1585]
+
+If there are more than batch_query_threshold bugs to update then they are
+fetched as a batch:
+
+    >>> with rt.responses(trace_calls=True):
+    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
+    GET http://example.com/REST/1.0/search/ticket/?...
+
+    >>> sorted(rt.bugs.keys())
+    [1585, 1586, 1587, 1588, 1589]
+
+If something goes wrong when we request a bug from the remote server a
+BugTrackerConnectError will be raised. We can demonstrate this by making
+our test RT instance simulate such a situation.
+
+    >>> with rt.responses(bad=True):
+    ...     rt.initializeRemoteBugDB([1585])
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.BugTrackerConnectError: ...
+
+This can also be demonstrated for importing bugs as a batch:
+
+    >>> with rt.responses(bad=True):
+    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.BugTrackerConnectError: ...
+
+Updating Bug Watches
+--------------------
+
+First, we create some bug watches to test with. Example.com hosts an RT
+instance which has several bugs that we wish to watch:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     print_bugwatches)
+
+Launchpad.dev bug #10 is the same bug as reported in example.com bug
+#1585, so we add a watch against the remote bug.
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.RT)
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '1585',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1585: None
+
+Our RequestTracker ExternalBugTracker can now process, and retrieve a
+remote status for, the bug watch that we have created.
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     txn, logger=FakeLogger())
+    >>> rt = TestRequestTracker(example_bug_tracker.baseurl)
+    >>> with rt.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         rt, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1585: new
+
+We now add some more watches against remote bugs in the example.com bug
+tracker with a variety of statuses.
+
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     rt.convertRemoteStatus)
+    Remote bug 1585: New
+
+    >>> remote_bugs = [
+    ...     1586,
+    ...     1587,
+    ...     1588,
+    ...     1589,
+    ... ]
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> for remote_bug_id in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+
+    >>> with rt.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         rt, example_bug_tracker.watches)
+    INFO Updating 5 watches for 5 bugs on http://bugs.some.where
+    GET http://bugs.some.where/REST/1.0/search/ticket/?...
+
+The bug statuses have now been imported from the Example.com bug
+tracker, so the bug watches should now have valid Launchpad bug
+statuses:
+
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     rt.convertRemoteStatus)
+    Remote bug 1585: New
+    Remote bug 1586: Confirmed
+    Remote bug 1587: Confirmed
+    Remote bug 1588: Fix Released
+    Remote bug 1589: Invalid
+
+
+Getting the remote product for a bug
+------------------------------------
+
+It's possible to get the remote product for a remote RT bug using
+getRemoteProduct(). In the case of RT, what we refer to in Launchpad as
+a "remote product" is in fact the name of an RT ticket Queue. RT has no
+concept of products, only queues, so though there'e a terminology
+mismatch the meaning is essentially the same.
+
+    >>> print(rt.getRemoteProduct(1585))
+    OpenSSL-Bugs
+
+If you try to get the remote product of a bug that doesn't exist you'll
+get a BugNotFound error.
+
+    >>> print(rt.getRemoteProduct('this-doesnt-exist'))
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.BugNotFound: this-doesnt-exist
+
+If for some reason the RT instance doesn't return a Queue name for a
+bug, getRemoteProduct() will return None.
+
+    >>> del rt.bugs[1589]['queue']
+    >>> print(rt.getRemoteProduct(1589))
+    None
diff --git a/lib/lp/bugs/doc/externalbugtracker-rt.txt b/lib/lp/bugs/doc/externalbugtracker-rt.txt
deleted file mode 100644
index 7a1b9bd..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-rt.txt
+++ /dev/null
@@ -1,285 +0,0 @@
-ExternalBugTracker: RT
-======================
-
-This covers the implementation of an ExternalBugTracker class for RT
-instances.
-
-
-Basics
-------
-
-When importing bugs from remote RT instances, we use an RT-specific
-implementation of ExternalBugTracker, RequestTracker.
-
-    >>> from lp.bugs.externalbugtracker import (
-    ...     RequestTracker)
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> from lp.testing import verifyObject
-    >>> verifyObject(
-    ...     IExternalBugTracker,
-    ...     RequestTracker('http://example.com/'))
-    True
-
-
-Authentication Credentials
---------------------------
-
-RT instances require that we log in to be able to export statuses for
-their tickets. The RequestTracker ExternalBugTracker class has a
-credentials property which returns a dict of credentials based on the
-hostname of the current remote RT instance.
-
-The default username and password for RT instances are 'guest' and
-'guest'. The credentials property for an RT instance that we don't have
-specific credentials for will return the default credentials.
-
-    >>> rt_one = RequestTracker('http://foobar.com')
-    >>> print(pretty(rt_one.credentials))
-    {'pass': 'guest', 'user': 'guest'}
-
-However, if the RT instance is one for which we have a username and
-password, those credentials will be retrieved from the Launchpad
-configuration files. rt.example.com is known to Launchpad.
-
-    >>> rt_two = RequestTracker('http://rt.example.com')
-    >>> print(pretty(rt_two.credentials))
-    {'pass': 'pangalacticgargleblaster', 'user': 'zaphod'}
-
-Status Conversion
------------------
-
-The RequestTracker class can convert the default RT ticket statuses into
-Launchpad statuses:
-
-    >>> rt = RequestTracker('http://example.com/')
-    >>> rt.convertRemoteStatus('new').title
-    'New'
-    >>> rt.convertRemoteStatus('open').title
-    'Confirmed'
-    >>> rt.convertRemoteStatus('stalled').title
-    'Confirmed'
-    >>> rt.convertRemoteStatus('rejected').title
-    'Invalid'
-    >>> rt.convertRemoteStatus('resolved').title
-    'Fix Released'
-
-Passing a status which the RequestTracker instance can't understand will
-result in an UnknownRemoteStatusError being raised.
-
-    >>> rt.convertRemoteStatus('spam').title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: spam
-
-
-Importance Conversion
----------------------
-
-There is no obvious mapping from ticket priorities to importances. They
-are all imported as Unknown. No exception is raised, because they are
-all unknown.
-
-    >>> rt.convertRemoteImportance('foo').title
-    'Unknown'
-
-
-Initialization
---------------
-
-Calling initializeRemoteBugDB() on our RequestTracker instance and
-passing it a set of remote bug IDs will fetch those bug IDs from the
-server and file them in a local variable for later use.
-
-We use a test-oriented implementation of RequestTracker for the purposes
-of these tests, which allows us to not rely on a working network
-connection.
-
-    >>> from lp.bugs.tests.externalbugtracker import TestRequestTracker
-    >>> rt = TestRequestTracker('http://example.com/')
-    >>> with rt.responses(trace_calls=True):
-    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
-    GET http://example.com/?...
-    GET http://example.com/REST/1.0/search/ticket/?...
-    >>> sorted(rt.bugs.keys())
-    [1585, 1586, 1587, 1588, 1589]
-
-The first request logs into RT and saves the resulting cookie.
-
-    >>> def print_cookie_jar(jar):
-    ...     for name, value in sorted(jar.items()):
-    ...         print('%s=%s' % (name, value))
-
-    >>> print_cookie_jar(rt._cookie_jar)
-    rt_credentials=guest:guest
-
-Subsequent requests use this.
-
-    >>> with rt.responses(trace_calls=True) as requests_mock:
-    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
-    ...     print(requests_mock.calls[0].request.headers['Cookie'])
-    rt_credentials=guest:guest
-    GET http://example.com/REST/1.0/search/ticket/?...
-
-
-Export Methods
---------------
-
-There are two means by which we can export RT bug statuses: on a
-bug-by-bug basis and as a batch. When the number of bugs that need
-updating is less than a given bug RT instances's batch_query_threshold
-the bugs will be fetched one-at-a-time:
-
-    >>> rt.batch_query_threshold
-    1
-
-    >>> with rt.responses(trace_calls=True):
-    ...     rt.initializeRemoteBugDB([1585])
-    GET http://example.com/REST/1.0/ticket/1585/show
-
-    >>> list(rt.bugs)
-    [1585]
-
-If there are more than batch_query_threshold bugs to update then they are
-fetched as a batch:
-
-    >>> with rt.responses(trace_calls=True):
-    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
-    GET http://example.com/REST/1.0/search/ticket/?...
-
-    >>> sorted(rt.bugs.keys())
-    [1585, 1586, 1587, 1588, 1589]
-
-If something goes wrong when we request a bug from the remote server a
-BugTrackerConnectError will be raised. We can demonstrate this by making
-our test RT instance simulate such a situation.
-
-    >>> with rt.responses(bad=True):
-    ...     rt.initializeRemoteBugDB([1585])
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.BugTrackerConnectError: ...
-
-This can also be demonstrated for importing bugs as a batch:
-
-    >>> with rt.responses(bad=True):
-    ...     rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.BugTrackerConnectError: ...
-
-Updating Bug Watches
---------------------
-
-First, we create some bug watches to test with. Example.com hosts an RT
-instance which has several bugs that we wish to watch:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     print_bugwatches)
-
-Launchpad.dev bug #10 is the same bug as reported in example.com bug
-#1585, so we add a watch against the remote bug.
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.RT)
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '1585',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1585: None
-
-Our RequestTracker ExternalBugTracker can now process, and retrieve a
-remote status for, the bug watch that we have created.
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     txn, logger=FakeLogger())
-    >>> rt = TestRequestTracker(example_bug_tracker.baseurl)
-    >>> with rt.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         rt, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1585: new
-
-We now add some more watches against remote bugs in the example.com bug
-tracker with a variety of statuses.
-
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     rt.convertRemoteStatus)
-    Remote bug 1585: New
-
-    >>> remote_bugs = [
-    ...     1586,
-    ...     1587,
-    ...     1588,
-    ...     1589,
-    ... ]
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> for remote_bug_id in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-
-    >>> with rt.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         rt, example_bug_tracker.watches)
-    INFO Updating 5 watches for 5 bugs on http://bugs.some.where
-    GET http://bugs.some.where/REST/1.0/search/ticket/?...
-
-The bug statuses have now been imported from the Example.com bug
-tracker, so the bug watches should now have valid Launchpad bug
-statuses:
-
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     rt.convertRemoteStatus)
-    Remote bug 1585: New
-    Remote bug 1586: Confirmed
-    Remote bug 1587: Confirmed
-    Remote bug 1588: Fix Released
-    Remote bug 1589: Invalid
-
-
-Getting the remote product for a bug
-------------------------------------
-
-It's possible to get the remote product for a remote RT bug using
-getRemoteProduct(). In the case of RT, what we refer to in Launchpad as
-a "remote product" is in fact the name of an RT ticket Queue. RT has no
-concept of products, only queues, so though there'e a terminology
-mismatch the meaning is essentially the same.
-
-    >>> print(rt.getRemoteProduct(1585))
-    OpenSSL-Bugs
-
-If you try to get the remote product of a bug that doesn't exist you'll
-get a BugNotFound error.
-
-    >>> print(rt.getRemoteProduct('this-doesnt-exist'))
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.BugNotFound: this-doesnt-exist
-
-If for some reason the RT instance doesn't return a Queue name for a
-bug, getRemoteProduct() will return None.
-
-    >>> del rt.bugs[1589]['queue']
-    >>> print(rt.getRemoteProduct(1589))
-    None
diff --git a/lib/lp/bugs/doc/externalbugtracker-sourceforge.rst b/lib/lp/bugs/doc/externalbugtracker-sourceforge.rst
new file mode 100644
index 0000000..b409da8
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-sourceforge.rst
@@ -0,0 +1,298 @@
+ExternalBugTracker: SourceForge
+===============================
+
+This covers the implementation of the ExternalBugTracker class for
+SourceForge bugwatches.
+
+
+Basics
+------
+
+The ExternalBugTracker descendant class which implements methods for
+updating bug watches on SourceForge bug trackers is
+externalbugtracker.SourceForge, which implements IExternalBugTracker.
+
+    >>> from lp.bugs.externalbugtracker import (
+    ...     SourceForge)
+    >>> from lp.testing import verifyObject
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+    >>> verifyObject(IExternalBugTracker,
+    ...     SourceForge('http://example.com'))
+    True
+
+
+Status Conversion
+-----------------
+
+The SourceForge bug status/resolution combinations map to Launchpad bug
+statuses. SourceForge.convertRemoteStatus() handles the conversion.
+
+    >>> sourceforge = SourceForge('http://example.com')
+    >>> sourceforge.convertRemoteStatus('Open').title
+    'New'
+    >>> sourceforge.convertRemoteStatus('Closed').title
+    'Fix Released'
+    >>> sourceforge.convertRemoteStatus('Pending').title
+    'Incomplete'
+    >>> sourceforge.convertRemoteStatus('Open:Accepted').title
+    'Confirmed'
+    >>> sourceforge.convertRemoteStatus('Open:Duplicate').title
+    'Confirmed'
+    >>> sourceforge.convertRemoteStatus('Open:Fixed').title
+    'Fix Committed'
+    >>> sourceforge.convertRemoteStatus('Open:Invalid').title
+    'Invalid'
+    >>> sourceforge.convertRemoteStatus('Open:Later').title
+    'Confirmed'
+    >>> sourceforge.convertRemoteStatus('Open:Out of Date').title
+    'Invalid'
+    >>> sourceforge.convertRemoteStatus('Open:Postponed').title
+    'Confirmed'
+    >>> sourceforge.convertRemoteStatus('Open:Rejected').title
+    "Won't Fix"
+    >>> sourceforge.convertRemoteStatus('Open:Remind').title
+    'Confirmed'
+    >>> sourceforge.convertRemoteStatus("Open:Won't Fix").title
+    "Won't Fix"
+    >>> sourceforge.convertRemoteStatus('Open:Works For Me').title
+    'Invalid'
+    >>> sourceforge.convertRemoteStatus('Closed:Accepted').title
+    'Fix Committed'
+    >>> sourceforge.convertRemoteStatus('Closed:Fixed').title
+    'Fix Released'
+    >>> sourceforge.convertRemoteStatus('Closed:Postponed').title
+    "Won't Fix"
+    >>> sourceforge.convertRemoteStatus('Pending:Postponed').title
+    "Won't Fix"
+
+If the status isn't something that our SourceForge ExternalBugTracker can
+understand an UnknownRemoteStatusError will be raised.
+
+    >>> sourceforge.convertRemoteStatus('eggs').title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: eggs
+
+
+Initialization
+--------------
+
+Calling initializeRemoteBugDB() on our SourceForge instance and passing
+it a set of remote bug IDs will fetch those bug IDs from the server and
+file them in a local variable for later use.
+
+We use a test-oriented implementation for the purposes of these tests, which
+avoids relying on a working network connection.
+
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     TestSourceForge, print_bugwatches)
+    >>> sourceforge = TestSourceForge('http://example.com/')
+    >>> with sourceforge.responses():
+    ...     sourceforge.initializeRemoteBugDB([1722250])
+    >>> sorted(sourceforge.bugs.keys())
+    [1722250]
+
+If a remote bug doesn't define the requisite data, an error will be
+raised. We use a special sample bug, bug 0, which defines no status or
+resolution, to demonstrate this:
+
+    >>> with sourceforge.responses():
+    ...     sourceforge.initializeRemoteBugDB([0])
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnparsableBugData:
+    Remote bug 0 does not define a status.
+
+Some SourceForge bugs are marked private. Although we can't import a
+status from them, we don't raise an error when trying to initialize the
+remote bug database. Sample bug 99 is private.
+
+    >>> with sourceforge.responses():
+    ...     sourceforge.initializeRemoteBugDB([99])
+    >>> sorted(sourceforge.bugs.keys())
+    [99]
+
+If we look at the bug, however, we can see that its private field has
+been set to True:
+
+    >>> sourceforge.bugs[99]['private']
+    True
+
+The SourceForge ExternalBugTracker class has an _extractErrorMessage()
+method which can be used to find error messages.
+
+    >>> page_data = open(
+    ...     'lib/lp/bugs/tests/testfiles/'
+    ...     'sourceforge-sample-bug-99.html')
+    >>> print(sourceforge._extractErrorMessage(page_data))
+    Artifact: This Artifact Has Been Made Private. Only Group Members
+    Can View Private ArtifactTypes.
+
+Trying to access the remote status of a private bug, however, will raise
+a PrivateRemoteBug error.
+
+    >>> sourceforge.getRemoteStatus(99)
+    Traceback (most recent call last):
+     ...
+    lp.bugs.externalbugtracker.base.PrivateRemoteBug:
+    Bug 99 on http://example.com is private.
+
+
+Updating Bug Watches
+--------------------
+
+First, we create some bug watches to test with. Example.com hosts a
+SourceForge instance which has several bugs that we wish to watch:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+
+Launchpad.dev bug #10 is the same bug as reported in example.com bug
+#1722250, so we add a watch against the remote bug.
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.SOURCEFORGE)
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '1722250',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1722250: None
+
+Our SourceForge ExternalBugTracker can now process, and retrieve a
+remote status for, the bug watch that we have created.
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     txn, logger=FakeLogger())
+    >>> sourceforge = TestSourceForge(example_bug_tracker.baseurl)
+    >>> with sourceforge.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         sourceforge, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+    >>> print_bugwatches(example_bug_tracker.watches)
+    Remote bug 1722250: Open:None
+
+We now add some more watches against remote bugs in the example.com bug
+tracker with a variety of statuses.
+
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     sourceforge.convertRemoteStatus)
+    Remote bug 1722250: New
+
+    >>> remote_bugs = [
+    ...     1722251,
+    ...     1722252,
+    ...     1722253,
+    ...     1722254,
+    ...     1722255,
+    ...     1722256,
+    ...     1722257,
+    ...     1722258,
+    ...     1722259,
+    ... ]
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> for remote_bug_id in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+
+By default, SourceForge ExternalBugTrackers will only import one bug at
+a time so as to avoid tripping SourceForge's rate limiting filters. So
+even if we pass it 10 bug watches to update only one will actually be
+updated. In this case it will be the first bug watch that hasn't yet
+been checked.
+
+    >>> transaction.commit()
+
+    >>> from operator import attrgetter
+    >>> with sourceforge.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         sourceforge,
+    ...         sorted(example_bug_tracker.watches, key=attrgetter('id')))
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+    GET http://bugs.some.where/support/tracker.php?aid=1722251
+
+For the sake of this test we can set the bug tracker's batch_size to
+None so that it will process all the updates at once:
+
+    >>> sourceforge.batch_size = None
+    >>> with sourceforge.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         sourceforge, example_bug_tracker.watches)
+    INFO Updating 10 watches for 10 bugs on http://bugs.some.where
+    GET http://bugs.some.where/support/tracker.php?aid=1722250
+    GET http://bugs.some.where/support/tracker.php?aid=1722251
+    GET http://bugs.some.where/support/tracker.php?aid=1722252
+    GET http://bugs.some.where/support/tracker.php?aid=1722253
+    GET http://bugs.some.where/support/tracker.php?aid=1722254
+    GET http://bugs.some.where/support/tracker.php?aid=1722255
+    GET http://bugs.some.where/support/tracker.php?aid=1722256
+    GET http://bugs.some.where/support/tracker.php?aid=1722257
+    GET http://bugs.some.where/support/tracker.php?aid=1722258
+    GET http://bugs.some.where/support/tracker.php?aid=1722259
+
+The bug statuses have now been imported from the Example.com bug
+tracker, so the bug watches should now have valid Launchpad bug
+statuses:
+
+    >>> print_bugwatches(example_bug_tracker.watches,
+    ...     sourceforge.convertRemoteStatus)
+    Remote bug 1722250: New
+    Remote bug 1722251: Won't Fix
+    Remote bug 1722252: Incomplete
+    Remote bug 1722253: Won't Fix
+    Remote bug 1722254: Invalid
+    Remote bug 1722255: Confirmed
+    Remote bug 1722256: Won't Fix
+    Remote bug 1722257: Fix Committed
+    Remote bug 1722258: Invalid
+    Remote bug 1722259: Won't Fix
+
+
+Getting the remote product for a bug
+------------------------------------
+
+It's possible to get the remote product for a bug by calling
+SourceForge.getRemoteProduct(). In SourceForge's case, what we refer to
+in Launchpad as a 'remote product' is a combination of (group_id, atid),
+where 'group_id' is the ID of the 'artifact group' on SourceForge to
+which a bug belongs and 'atid' is the 'artifact tracker ID'. This allows
+us to pinpoint the specific SourceForge tracker to which the bug
+belongs. The remote product is returned by getRemoteProduct() as an
+ampersand-separated string.
+
+    >>> print(sourceforge.getRemoteProduct('1722250'))
+    155120&794532
+
+If you try to get the remote product of a bug that doesn't exist you'll
+get a BugNotFound error.
+
+    >>> print(sourceforge.getRemoteProduct(999999999))
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.BugNotFound: 999999999
+
+If SourceForge can't find the group_id and atid for the bug (for example
+if the bug is private), getRemoteProduct() will return None.
+
+    >>> transaction.commit()
+
+    >>> with sourceforge.responses():
+    ...     sourceforge.initializeRemoteBugDB([99])
+    >>> print(sourceforge.getRemoteProduct(99))
+    None
diff --git a/lib/lp/bugs/doc/externalbugtracker-sourceforge.txt b/lib/lp/bugs/doc/externalbugtracker-sourceforge.txt
deleted file mode 100644
index b409da8..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-sourceforge.txt
+++ /dev/null
@@ -1,298 +0,0 @@
-ExternalBugTracker: SourceForge
-===============================
-
-This covers the implementation of the ExternalBugTracker class for
-SourceForge bugwatches.
-
-
-Basics
-------
-
-The ExternalBugTracker descendant class which implements methods for
-updating bug watches on SourceForge bug trackers is
-externalbugtracker.SourceForge, which implements IExternalBugTracker.
-
-    >>> from lp.bugs.externalbugtracker import (
-    ...     SourceForge)
-    >>> from lp.testing import verifyObject
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
-    >>> verifyObject(IExternalBugTracker,
-    ...     SourceForge('http://example.com'))
-    True
-
-
-Status Conversion
------------------
-
-The SourceForge bug status/resolution combinations map to Launchpad bug
-statuses. SourceForge.convertRemoteStatus() handles the conversion.
-
-    >>> sourceforge = SourceForge('http://example.com')
-    >>> sourceforge.convertRemoteStatus('Open').title
-    'New'
-    >>> sourceforge.convertRemoteStatus('Closed').title
-    'Fix Released'
-    >>> sourceforge.convertRemoteStatus('Pending').title
-    'Incomplete'
-    >>> sourceforge.convertRemoteStatus('Open:Accepted').title
-    'Confirmed'
-    >>> sourceforge.convertRemoteStatus('Open:Duplicate').title
-    'Confirmed'
-    >>> sourceforge.convertRemoteStatus('Open:Fixed').title
-    'Fix Committed'
-    >>> sourceforge.convertRemoteStatus('Open:Invalid').title
-    'Invalid'
-    >>> sourceforge.convertRemoteStatus('Open:Later').title
-    'Confirmed'
-    >>> sourceforge.convertRemoteStatus('Open:Out of Date').title
-    'Invalid'
-    >>> sourceforge.convertRemoteStatus('Open:Postponed').title
-    'Confirmed'
-    >>> sourceforge.convertRemoteStatus('Open:Rejected').title
-    "Won't Fix"
-    >>> sourceforge.convertRemoteStatus('Open:Remind').title
-    'Confirmed'
-    >>> sourceforge.convertRemoteStatus("Open:Won't Fix").title
-    "Won't Fix"
-    >>> sourceforge.convertRemoteStatus('Open:Works For Me').title
-    'Invalid'
-    >>> sourceforge.convertRemoteStatus('Closed:Accepted').title
-    'Fix Committed'
-    >>> sourceforge.convertRemoteStatus('Closed:Fixed').title
-    'Fix Released'
-    >>> sourceforge.convertRemoteStatus('Closed:Postponed').title
-    "Won't Fix"
-    >>> sourceforge.convertRemoteStatus('Pending:Postponed').title
-    "Won't Fix"
-
-If the status isn't something that our SourceForge ExternalBugTracker can
-understand an UnknownRemoteStatusError will be raised.
-
-    >>> sourceforge.convertRemoteStatus('eggs').title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: eggs
-
-
-Initialization
---------------
-
-Calling initializeRemoteBugDB() on our SourceForge instance and passing
-it a set of remote bug IDs will fetch those bug IDs from the server and
-file them in a local variable for later use.
-
-We use a test-oriented implementation for the purposes of these tests, which
-avoids relying on a working network connection.
-
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     TestSourceForge, print_bugwatches)
-    >>> sourceforge = TestSourceForge('http://example.com/')
-    >>> with sourceforge.responses():
-    ...     sourceforge.initializeRemoteBugDB([1722250])
-    >>> sorted(sourceforge.bugs.keys())
-    [1722250]
-
-If a remote bug doesn't define the requisite data, an error will be
-raised. We use a special sample bug, bug 0, which defines no status or
-resolution, to demonstrate this:
-
-    >>> with sourceforge.responses():
-    ...     sourceforge.initializeRemoteBugDB([0])
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnparsableBugData:
-    Remote bug 0 does not define a status.
-
-Some SourceForge bugs are marked private. Although we can't import a
-status from them, we don't raise an error when trying to initialize the
-remote bug database. Sample bug 99 is private.
-
-    >>> with sourceforge.responses():
-    ...     sourceforge.initializeRemoteBugDB([99])
-    >>> sorted(sourceforge.bugs.keys())
-    [99]
-
-If we look at the bug, however, we can see that its private field has
-been set to True:
-
-    >>> sourceforge.bugs[99]['private']
-    True
-
-The SourceForge ExternalBugTracker class has an _extractErrorMessage()
-method which can be used to find error messages.
-
-    >>> page_data = open(
-    ...     'lib/lp/bugs/tests/testfiles/'
-    ...     'sourceforge-sample-bug-99.html')
-    >>> print(sourceforge._extractErrorMessage(page_data))
-    Artifact: This Artifact Has Been Made Private. Only Group Members
-    Can View Private ArtifactTypes.
-
-Trying to access the remote status of a private bug, however, will raise
-a PrivateRemoteBug error.
-
-    >>> sourceforge.getRemoteStatus(99)
-    Traceback (most recent call last):
-     ...
-    lp.bugs.externalbugtracker.base.PrivateRemoteBug:
-    Bug 99 on http://example.com is private.
-
-
-Updating Bug Watches
---------------------
-
-First, we create some bug watches to test with. Example.com hosts a
-SourceForge instance which has several bugs that we wish to watch:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-
-Launchpad.dev bug #10 is the same bug as reported in example.com bug
-#1722250, so we add a watch against the remote bug.
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.SOURCEFORGE)
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '1722250',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1722250: None
-
-Our SourceForge ExternalBugTracker can now process, and retrieve a
-remote status for, the bug watch that we have created.
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     txn, logger=FakeLogger())
-    >>> sourceforge = TestSourceForge(example_bug_tracker.baseurl)
-    >>> with sourceforge.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         sourceforge, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-    >>> print_bugwatches(example_bug_tracker.watches)
-    Remote bug 1722250: Open:None
-
-We now add some more watches against remote bugs in the example.com bug
-tracker with a variety of statuses.
-
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     sourceforge.convertRemoteStatus)
-    Remote bug 1722250: New
-
-    >>> remote_bugs = [
-    ...     1722251,
-    ...     1722252,
-    ...     1722253,
-    ...     1722254,
-    ...     1722255,
-    ...     1722256,
-    ...     1722257,
-    ...     1722258,
-    ...     1722259,
-    ... ]
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> for remote_bug_id in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-
-By default, SourceForge ExternalBugTrackers will only import one bug at
-a time so as to avoid tripping SourceForge's rate limiting filters. So
-even if we pass it 10 bug watches to update only one will actually be
-updated. In this case it will be the first bug watch that hasn't yet
-been checked.
-
-    >>> transaction.commit()
-
-    >>> from operator import attrgetter
-    >>> with sourceforge.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         sourceforge,
-    ...         sorted(example_bug_tracker.watches, key=attrgetter('id')))
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-    GET http://bugs.some.where/support/tracker.php?aid=1722251
-
-For the sake of this test we can set the bug tracker's batch_size to
-None so that it will process all the updates at once:
-
-    >>> sourceforge.batch_size = None
-    >>> with sourceforge.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         sourceforge, example_bug_tracker.watches)
-    INFO Updating 10 watches for 10 bugs on http://bugs.some.where
-    GET http://bugs.some.where/support/tracker.php?aid=1722250
-    GET http://bugs.some.where/support/tracker.php?aid=1722251
-    GET http://bugs.some.where/support/tracker.php?aid=1722252
-    GET http://bugs.some.where/support/tracker.php?aid=1722253
-    GET http://bugs.some.where/support/tracker.php?aid=1722254
-    GET http://bugs.some.where/support/tracker.php?aid=1722255
-    GET http://bugs.some.where/support/tracker.php?aid=1722256
-    GET http://bugs.some.where/support/tracker.php?aid=1722257
-    GET http://bugs.some.where/support/tracker.php?aid=1722258
-    GET http://bugs.some.where/support/tracker.php?aid=1722259
-
-The bug statuses have now been imported from the Example.com bug
-tracker, so the bug watches should now have valid Launchpad bug
-statuses:
-
-    >>> print_bugwatches(example_bug_tracker.watches,
-    ...     sourceforge.convertRemoteStatus)
-    Remote bug 1722250: New
-    Remote bug 1722251: Won't Fix
-    Remote bug 1722252: Incomplete
-    Remote bug 1722253: Won't Fix
-    Remote bug 1722254: Invalid
-    Remote bug 1722255: Confirmed
-    Remote bug 1722256: Won't Fix
-    Remote bug 1722257: Fix Committed
-    Remote bug 1722258: Invalid
-    Remote bug 1722259: Won't Fix
-
-
-Getting the remote product for a bug
-------------------------------------
-
-It's possible to get the remote product for a bug by calling
-SourceForge.getRemoteProduct(). In SourceForge's case, what we refer to
-in Launchpad as a 'remote product' is a combination of (group_id, atid),
-where 'group_id' is the ID of the 'artifact group' on SourceForge to
-which a bug belongs and 'atid' is the 'artifact tracker ID'. This allows
-us to pinpoint the specific SourceForge tracker to which the bug
-belongs. The remote product is returned by getRemoteProduct() as an
-ampersand-separated string.
-
-    >>> print(sourceforge.getRemoteProduct('1722250'))
-    155120&794532
-
-If you try to get the remote product of a bug that doesn't exist you'll
-get a BugNotFound error.
-
-    >>> print(sourceforge.getRemoteProduct(999999999))
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.BugNotFound: 999999999
-
-If SourceForge can't find the group_id and atid for the bug (for example
-if the bug is private), getRemoteProduct() will return None.
-
-    >>> transaction.commit()
-
-    >>> with sourceforge.responses():
-    ...     sourceforge.initializeRemoteBugDB([99])
-    >>> print(sourceforge.getRemoteProduct(99))
-    None
diff --git a/lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt b/lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.rst
similarity index 100%
rename from lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt
rename to lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.rst
diff --git a/lib/lp/bugs/doc/externalbugtracker-trac.rst b/lib/lp/bugs/doc/externalbugtracker-trac.rst
new file mode 100644
index 0000000..58c71c6
--- /dev/null
+++ b/lib/lp/bugs/doc/externalbugtracker-trac.rst
@@ -0,0 +1,415 @@
+ExternalBugTracker: Trac
+========================
+
+This covers the implementation of the ExternalBugTracker class for Trac
+bugwatches.
+
+
+Basics
+------
+
+The ExternalBugTracker descendant class which implements methods for updating
+bug watches on Trac bug trackers is externalbugtracker.Trac, which implements
+IExternalBugTracker.
+
+    >>> from lp.bugs.externalbugtracker import Trac
+    >>> from lp.bugs.tests.externalbugtracker import (
+    ...     new_bugtracker)
+    >>> from lp.testing import verifyObject
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
+    >>> trac = Trac('http://example.com/')
+    >>> verifyObject(IExternalBugTracker, trac)
+    True
+
+
+LP plugin
+---------
+
+Some Trac instances have a plugin installed to make it easier for us to
+communicate with them. getExternalBugTrackerToUse() probes the bug
+tracker for the special authentication mechanism the plugin uses, and
+returns a TracLPPlugin if it's found.
+
+If the LP plugin is installed, the URL will return 401, since it fails
+to validate the token.
+
+    >>> import responses
+    >>> from lp.bugs.externalbugtracker.trac import TracLPPlugin
+
+    >>> trac = Trac('http://example.com/')
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add(
+    ...         'GET', 'http://example.com/launchpad-auth/check', status=401)
+    ...     chosen_trac = trac.getExternalBugTrackerToUse()
+    >>> isinstance(chosen_trac, TracLPPlugin)
+    True
+    >>> print(chosen_trac.baseurl)
+    http://example.com
+
+Some Trac instances in the wild return HTTP 200 when the resource is
+not found (HTTP 404 would be more appropriate). A distinguishing
+difference between a 200 response from a broken Trac and a response
+from a Trac with the plugin installed (when we've accidentally passed
+a valid token, which is very unlikely), is that the broken Trac will
+not include a "trac_auth" cookie.
+
+The plain, non-plugin, external bug tracker is selected for broken
+Trac installations:
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add(
+    ...         'GET', 'http://example.com/launchpad-auth/check')
+    ...     chosen_trac = trac.getExternalBugTrackerToUse()
+    >>> isinstance(chosen_trac, TracLPPlugin)
+    False
+    >>> print(chosen_trac.baseurl)
+    http://example.com
+
+In the event that our deliberately bogus token is considered valid,
+the external bug tracker that groks the plugin is selected:
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add(
+    ...         'GET', 'http://example.com/launchpad-auth/check',
+    ...         headers={'Set-Cookie': 'trac_auth=1234'})
+    ...     chosen_trac = trac.getExternalBugTrackerToUse()
+    >>> isinstance(chosen_trac, TracLPPlugin)
+    True
+    >>> print(chosen_trac.baseurl)
+    http://example.com
+
+If a 404 is returned, the normal Trac instance is returned.
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add(
+    ...         'GET', 'http://example.com/launchpad-auth/check', status=404)
+    ...     chosen_trac = trac.getExternalBugTrackerToUse()
+    >>> chosen_trac is trac
+    True
+
+In the event that a connection error is returned, we return a normal Trac
+instance. It will deal with the connection error later, if the situation
+persists.
+
+    >>> from requests.exceptions import ConnectTimeout
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add(
+    ...         'GET', 'http://example.com/launchpad-auth/check',
+    ...         body=ConnectTimeout())
+    ...     chosen_trac = trac.getExternalBugTrackerToUse()
+    >>> chosen_trac is trac
+    True
+
+
+Status Conversion
+-----------------
+
+The basic Trac ticket statuses map to Launchpad bug statuses.
+Trac.convertRemoteStatus() handles the conversion.
+
+    >>> trac = Trac('http://example.com/')
+    >>> trac.convertRemoteStatus('open').title
+    'New'
+    >>> trac.convertRemoteStatus('new').title
+    'New'
+    >>> trac.convertRemoteStatus('reopened').title
+    'New'
+    >>> trac.convertRemoteStatus('accepted').title
+    'Confirmed'
+    >>> trac.convertRemoteStatus('assigned').title
+    'Confirmed'
+    >>> trac.convertRemoteStatus('fixed').title
+    'Fix Released'
+    >>> trac.convertRemoteStatus('closed').title
+    'Fix Released'
+    >>> trac.convertRemoteStatus('invalid').title
+    'Invalid'
+    >>> trac.convertRemoteStatus('wontfix').title
+    "Won't Fix"
+    >>> trac.convertRemoteStatus('duplicate').title
+    'Confirmed'
+    >>> trac.convertRemoteStatus('worksforme').title
+    'Invalid'
+    >>> trac.convertRemoteStatus('fixverified').title
+    'Fix Released'
+
+If the status isn't one that our Trac ExternalBugTracker can understand
+an UnknownRemoteStatusError will be raised.
+
+    >>> trac.convertRemoteStatus('eggs').title
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: eggs
+
+
+Initialization
+--------------
+
+Calling initializeRemoteBugDB() on our Trac instance and passing it a set of
+remote bug IDs will fetch those bug IDs from the server and file them in a
+local variable for later use.
+
+We use a test-oriented implementation for the purposes of these tests, which
+avoids relying on a network connection.
+
+    >>> from lp.bugs.tests.externalbugtracker import TestTrac
+    >>> trac = TestTrac(u'http://test.trac/')
+    >>> with trac.responses():
+    ...     trac.initializeRemoteBugDB([1])
+    >>> sorted(trac.bugs.keys())
+    [1]
+
+If we initialize with a different set of keys we overwrite the first set:
+
+    >>> with trac.responses():
+    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10, 11, 12])
+    >>> sorted(trac.bugs.keys())
+    [6, 7, 8, 9, 10, 11, 12]
+
+
+Export Methods
+--------------
+
+There are two means by which we can export Trac bug statuses: on a bug-by-bug
+basis and as a batch. When the number of bugs that need updating is less than
+a given bug tracker's batch_query_threshold the bugs will be fetched
+one-at-a-time:
+
+    >>> trac.batch_query_threshold
+    10
+
+    >>> with trac.responses(trace_calls=True):
+    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    GET http://test.trac/ticket/6
+    GET http://test.trac/ticket/6?format=csv
+    GET http://test.trac/ticket/6?format=csv
+    GET http://test.trac/ticket/7?format=csv
+    GET http://test.trac/ticket/8?format=csv
+    GET http://test.trac/ticket/9?format=csv
+    GET http://test.trac/ticket/10?format=csv
+
+If there are more than batch_query_threshold bugs to update then they are
+fetched as a batch:
+
+    >>> trac.batch_query_threshold = 4
+    >>> with trac.responses(trace_calls=True):
+    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    GET http://test.trac/query?id=6&id=7...&format=csv
+
+The batch updating method will also be used in cases where the Trac instance
+doesn't support CSV exports of individual tickets:
+
+    >>> trac.batch_query_threshold = 10
+    >>> with trac.responses(trace_calls=True, supports_single_exports=False):
+    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    GET http://test.trac/ticket/6
+    GET http://test.trac/ticket/6?format=csv
+    GET http://test.trac/query?id=6&id=7...&format=csv
+
+If, when using the batch export method, the Trac instance comes across
+invalid data, it will raise an UnparsableBugData exception. We will
+force our trac instance to use invalid data for the purposes of this
+test.
+
+    >>> with trac.responses(broken=True):
+    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
+    http://test.trac does not define all the necessary fields for bug status
+    imports (Defined field names: ['<html>']).
+
+This is also true of the single bug export mode.
+
+    >>> with trac.responses(broken=True):
+    ...     trac.initializeRemoteBugDB([6])
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
+    http://test.trac does not define all the necessary fields for bug status
+    imports (Defined field names: ['<html>']).
+
+Trying to get the remote status of the bug will raise a BugNotFound
+error since the bug was never imported.
+
+    >>> trac.getRemoteStatus(6)
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.BugNotFound: 6
+
+Both the single and batch ticket import modes use the _fetchBugData()
+method to retrieve the CSV data from the remote Trac instance. This
+method accepts a URL from which to retrieve the data as a parameter.
+
+    >>> query_url = 'http://test.trac/query?id=%s&format=csv'
+    >>> query_string = '&id='.join(['1', '2', '3', '4', '5'])
+    >>> query_url = query_url % query_string
+
+    >>> with trac.responses(trace_calls=True, supports_single_exports=False):
+    ...     remote_bugs = trac._fetchBugData(query_url)
+    GET http://test.trac/query?id=1&id=2...&format=csv
+
+However, _fetchBugData() doesn't actually check the results it returns
+except for checking that they are valid Trac CSV exports. in this case,
+the IDs returned are nothing like the ones we asked for:
+
+    >>> bug_ids = sorted(int(bug['id']) for bug in remote_bugs)
+    >>> print(bug_ids)
+    [1, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153]
+
+If _fetchBugData() receives a response that isn't a valid Trac CSV
+export, it will raise an UnparsableBugData error.
+
+    >>> with trac.responses(broken=True):
+    ...     trac._fetchBugData(query_url)
+    Traceback (most recent call last):
+      ...
+    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
+    http://test.trac does not define all the necessary fields for bug status
+    imports (Defined field names: ['<html>']).
+
+
+Updating Bug Watches
+--------------------
+
+First, we create some bug watches to test with:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+
+    >>> sample_person = getUtility(IPersonSet).getByEmail(
+    ...     'test@xxxxxxxxxxxxx')
+
+    >>> example_bug_tracker = new_bugtracker(BugTrackerType.TRAC)
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> example_bug = getUtility(IBugSet).get(10)
+    >>> example_bugwatch = example_bug.addWatch(
+    ...     example_bug_tracker, '1',
+    ...     getUtility(ILaunchpadCelebrities).janitor)
+
+
+Collect the Example.com watches:
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    1: None
+
+And have a Trac instance process them:
+
+    >>> transaction.commit()
+
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
+    >>> txn = LaunchpadZopelessLayer.txn
+    >>> bug_watch_updater = CheckwatchesMaster(
+    ...     txn, FakeLogger())
+    >>> trac = TestTrac(example_bug_tracker.baseurl)
+    >>> with trac.responses():
+    ...     bug_watch_updater.updateBugWatches(
+    ...         trac, example_bug_tracker.watches)
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> for bug_watch in example_bug_tracker.watches:
+    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
+    1: fixed
+
+We'll add some more watches now.
+
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+
+    >>> bug_watch_set = getUtility(IBugWatchSet)
+    >>> bug_watches = dict(
+    ...     (int(bug_watch.remotebug), bug_watch.id)
+    ...     for bug_watch in example_bug_tracker.watches)
+
+    >>> for remote_bug, bug_watch_id in bug_watches.items():
+    ...     bug_watch = getUtility(IBugWatchSet).get(bug_watch_id)
+    ...     print("%s: %s" % (remote_bug, bug_watch.remotestatus))
+    1: fixed
+
+    >>> remote_bugs = [
+    ...     (143, 'fixed'),
+    ...     (144, 'assigned'),
+    ...     (145, 'duplicate'),
+    ...     (146, 'invalid'),
+    ...     (147, 'worksforme'),
+    ...     (148, 'wontfix'),
+    ...     (149, 'reopened'),
+    ...     (150, 'new'),
+    ...     (151, 'new'),
+    ...     (152, 'new'),
+    ...     (153, 'new'),
+    ... ]
+
+    >>> for remote_bug_id, remote_status in remote_bugs:
+    ...     bug_watch = bug_watch_set.createBugWatch(
+    ...         bug=example_bug, owner=sample_person,
+    ...         bugtracker=example_bug_tracker,
+    ...         remotebug=str(remote_bug_id))
+    ...     bug_watches[remote_bug_id] = bug_watch.id
+
+    >>> with trac.responses(trace_calls=True):
+    ...     bug_watch_updater.updateBugWatches(
+    ...         trac, example_bug_tracker.watches)
+    INFO Updating 12 watches for 12 bugs on http://bugs.some.where
+    GET http://bugs.some.where/query?id=...
+
+    >>> for remote_bug_id in sorted(bug_watches.keys()):
+    ...     bug_watch = getUtility(IBugWatchSet).get(
+    ...         bug_watches[remote_bug_id])
+    ...     remote_status = bug_watch.remotestatus
+    ...     print('Remote bug %d: %s' % (remote_bug_id, remote_status))
+    Remote bug 1: fixed
+    Remote bug 143: fixed
+    Remote bug 144: assigned
+    Remote bug 145: duplicate
+    Remote bug 146: invalid
+    Remote bug 147: worksforme
+    Remote bug 148: wontfix
+    Remote bug 149: reopened
+    Remote bug 150: new
+    Remote bug 151: new
+    Remote bug 152: new
+    Remote bug 153: new
+
+updateBugWatches() updates the lastchecked attribute on the watches, so
+now no bug watches are in need of updating:
+
+    >>> flush_database_updates()
+    >>> example_bug_tracker.watches_needing_update.count()
+    0
+
+If the status isn't different, the lastchanged attribute doesn't get
+updated. If we set a bug watch's lastchanged timestamp manually and call
+update, lastchanged shouldn't be affected because the remote status of the bug
+watch hasn't altered:
+
+    >>> import pytz
+    >>> from datetime import datetime, timedelta
+    >>> from operator import attrgetter
+    >>> sorted_bug_watches = sorted(
+    ...     (bug_watch for bug_watch in example_bug_tracker.watches),
+    ...     key=attrgetter('remotebug'))
+    >>> bug_watch = sorted_bug_watches[-1]
+    >>> now = datetime.now(pytz.timezone('UTC'))
+    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
+    >>> old_last_changed = bug_watch.lastchanged
+    >>> print(bug_watch.remotebug)
+    153
+    >>> print(bug_watch.remotestatus)
+    new
+
+    >>> trac.batch_query_threshold = 0
+    >>> with trac.responses():
+    ...     bug_watch_updater.updateBugWatches(trac, [bug_watch])
+    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
+
+    >>> bug_watch.lastchanged == old_last_changed
+    True
+    >>> print(bug_watch.remotebug)
+    153
+    >>> print(bug_watch.remotestatus)
+    new
diff --git a/lib/lp/bugs/doc/externalbugtracker-trac.txt b/lib/lp/bugs/doc/externalbugtracker-trac.txt
deleted file mode 100644
index 58c71c6..0000000
--- a/lib/lp/bugs/doc/externalbugtracker-trac.txt
+++ /dev/null
@@ -1,415 +0,0 @@
-ExternalBugTracker: Trac
-========================
-
-This covers the implementation of the ExternalBugTracker class for Trac
-bugwatches.
-
-
-Basics
-------
-
-The ExternalBugTracker descendant class which implements methods for updating
-bug watches on Trac bug trackers is externalbugtracker.Trac, which implements
-IExternalBugTracker.
-
-    >>> from lp.bugs.externalbugtracker import Trac
-    >>> from lp.bugs.tests.externalbugtracker import (
-    ...     new_bugtracker)
-    >>> from lp.testing import verifyObject
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.bugs.interfaces.externalbugtracker import IExternalBugTracker
-    >>> trac = Trac('http://example.com/')
-    >>> verifyObject(IExternalBugTracker, trac)
-    True
-
-
-LP plugin
----------
-
-Some Trac instances have a plugin installed to make it easier for us to
-communicate with them. getExternalBugTrackerToUse() probes the bug
-tracker for the special authentication mechanism the plugin uses, and
-returns a TracLPPlugin if it's found.
-
-If the LP plugin is installed, the URL will return 401, since it fails
-to validate the token.
-
-    >>> import responses
-    >>> from lp.bugs.externalbugtracker.trac import TracLPPlugin
-
-    >>> trac = Trac('http://example.com/')
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add(
-    ...         'GET', 'http://example.com/launchpad-auth/check', status=401)
-    ...     chosen_trac = trac.getExternalBugTrackerToUse()
-    >>> isinstance(chosen_trac, TracLPPlugin)
-    True
-    >>> print(chosen_trac.baseurl)
-    http://example.com
-
-Some Trac instances in the wild return HTTP 200 when the resource is
-not found (HTTP 404 would be more appropriate). A distinguishing
-difference between a 200 response from a broken Trac and a response
-from a Trac with the plugin installed (when we've accidentally passed
-a valid token, which is very unlikely), is that the broken Trac will
-not include a "trac_auth" cookie.
-
-The plain, non-plugin, external bug tracker is selected for broken
-Trac installations:
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add(
-    ...         'GET', 'http://example.com/launchpad-auth/check')
-    ...     chosen_trac = trac.getExternalBugTrackerToUse()
-    >>> isinstance(chosen_trac, TracLPPlugin)
-    False
-    >>> print(chosen_trac.baseurl)
-    http://example.com
-
-In the event that our deliberately bogus token is considered valid,
-the external bug tracker that groks the plugin is selected:
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add(
-    ...         'GET', 'http://example.com/launchpad-auth/check',
-    ...         headers={'Set-Cookie': 'trac_auth=1234'})
-    ...     chosen_trac = trac.getExternalBugTrackerToUse()
-    >>> isinstance(chosen_trac, TracLPPlugin)
-    True
-    >>> print(chosen_trac.baseurl)
-    http://example.com
-
-If a 404 is returned, the normal Trac instance is returned.
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add(
-    ...         'GET', 'http://example.com/launchpad-auth/check', status=404)
-    ...     chosen_trac = trac.getExternalBugTrackerToUse()
-    >>> chosen_trac is trac
-    True
-
-In the event that a connection error is returned, we return a normal Trac
-instance. It will deal with the connection error later, if the situation
-persists.
-
-    >>> from requests.exceptions import ConnectTimeout
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add(
-    ...         'GET', 'http://example.com/launchpad-auth/check',
-    ...         body=ConnectTimeout())
-    ...     chosen_trac = trac.getExternalBugTrackerToUse()
-    >>> chosen_trac is trac
-    True
-
-
-Status Conversion
------------------
-
-The basic Trac ticket statuses map to Launchpad bug statuses.
-Trac.convertRemoteStatus() handles the conversion.
-
-    >>> trac = Trac('http://example.com/')
-    >>> trac.convertRemoteStatus('open').title
-    'New'
-    >>> trac.convertRemoteStatus('new').title
-    'New'
-    >>> trac.convertRemoteStatus('reopened').title
-    'New'
-    >>> trac.convertRemoteStatus('accepted').title
-    'Confirmed'
-    >>> trac.convertRemoteStatus('assigned').title
-    'Confirmed'
-    >>> trac.convertRemoteStatus('fixed').title
-    'Fix Released'
-    >>> trac.convertRemoteStatus('closed').title
-    'Fix Released'
-    >>> trac.convertRemoteStatus('invalid').title
-    'Invalid'
-    >>> trac.convertRemoteStatus('wontfix').title
-    "Won't Fix"
-    >>> trac.convertRemoteStatus('duplicate').title
-    'Confirmed'
-    >>> trac.convertRemoteStatus('worksforme').title
-    'Invalid'
-    >>> trac.convertRemoteStatus('fixverified').title
-    'Fix Released'
-
-If the status isn't one that our Trac ExternalBugTracker can understand
-an UnknownRemoteStatusError will be raised.
-
-    >>> trac.convertRemoteStatus('eggs').title
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnknownRemoteStatusError: eggs
-
-
-Initialization
---------------
-
-Calling initializeRemoteBugDB() on our Trac instance and passing it a set of
-remote bug IDs will fetch those bug IDs from the server and file them in a
-local variable for later use.
-
-We use a test-oriented implementation for the purposes of these tests, which
-avoids relying on a network connection.
-
-    >>> from lp.bugs.tests.externalbugtracker import TestTrac
-    >>> trac = TestTrac(u'http://test.trac/')
-    >>> with trac.responses():
-    ...     trac.initializeRemoteBugDB([1])
-    >>> sorted(trac.bugs.keys())
-    [1]
-
-If we initialize with a different set of keys we overwrite the first set:
-
-    >>> with trac.responses():
-    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10, 11, 12])
-    >>> sorted(trac.bugs.keys())
-    [6, 7, 8, 9, 10, 11, 12]
-
-
-Export Methods
---------------
-
-There are two means by which we can export Trac bug statuses: on a bug-by-bug
-basis and as a batch. When the number of bugs that need updating is less than
-a given bug tracker's batch_query_threshold the bugs will be fetched
-one-at-a-time:
-
-    >>> trac.batch_query_threshold
-    10
-
-    >>> with trac.responses(trace_calls=True):
-    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    GET http://test.trac/ticket/6
-    GET http://test.trac/ticket/6?format=csv
-    GET http://test.trac/ticket/6?format=csv
-    GET http://test.trac/ticket/7?format=csv
-    GET http://test.trac/ticket/8?format=csv
-    GET http://test.trac/ticket/9?format=csv
-    GET http://test.trac/ticket/10?format=csv
-
-If there are more than batch_query_threshold bugs to update then they are
-fetched as a batch:
-
-    >>> trac.batch_query_threshold = 4
-    >>> with trac.responses(trace_calls=True):
-    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    GET http://test.trac/query?id=6&id=7...&format=csv
-
-The batch updating method will also be used in cases where the Trac instance
-doesn't support CSV exports of individual tickets:
-
-    >>> trac.batch_query_threshold = 10
-    >>> with trac.responses(trace_calls=True, supports_single_exports=False):
-    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    GET http://test.trac/ticket/6
-    GET http://test.trac/ticket/6?format=csv
-    GET http://test.trac/query?id=6&id=7...&format=csv
-
-If, when using the batch export method, the Trac instance comes across
-invalid data, it will raise an UnparsableBugData exception. We will
-force our trac instance to use invalid data for the purposes of this
-test.
-
-    >>> with trac.responses(broken=True):
-    ...     trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
-    http://test.trac does not define all the necessary fields for bug status
-    imports (Defined field names: ['<html>']).
-
-This is also true of the single bug export mode.
-
-    >>> with trac.responses(broken=True):
-    ...     trac.initializeRemoteBugDB([6])
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
-    http://test.trac does not define all the necessary fields for bug status
-    imports (Defined field names: ['<html>']).
-
-Trying to get the remote status of the bug will raise a BugNotFound
-error since the bug was never imported.
-
-    >>> trac.getRemoteStatus(6)
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.BugNotFound: 6
-
-Both the single and batch ticket import modes use the _fetchBugData()
-method to retrieve the CSV data from the remote Trac instance. This
-method accepts a URL from which to retrieve the data as a parameter.
-
-    >>> query_url = 'http://test.trac/query?id=%s&format=csv'
-    >>> query_string = '&id='.join(['1', '2', '3', '4', '5'])
-    >>> query_url = query_url % query_string
-
-    >>> with trac.responses(trace_calls=True, supports_single_exports=False):
-    ...     remote_bugs = trac._fetchBugData(query_url)
-    GET http://test.trac/query?id=1&id=2...&format=csv
-
-However, _fetchBugData() doesn't actually check the results it returns
-except for checking that they are valid Trac CSV exports. in this case,
-the IDs returned are nothing like the ones we asked for:
-
-    >>> bug_ids = sorted(int(bug['id']) for bug in remote_bugs)
-    >>> print(bug_ids)
-    [1, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153]
-
-If _fetchBugData() receives a response that isn't a valid Trac CSV
-export, it will raise an UnparsableBugData error.
-
-    >>> with trac.responses(broken=True):
-    ...     trac._fetchBugData(query_url)
-    Traceback (most recent call last):
-      ...
-    lp.bugs.externalbugtracker.base.UnparsableBugData: External bugtracker
-    http://test.trac does not define all the necessary fields for bug status
-    imports (Defined field names: ['<html>']).
-
-
-Updating Bug Watches
---------------------
-
-First, we create some bug watches to test with:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-
-    >>> sample_person = getUtility(IPersonSet).getByEmail(
-    ...     'test@xxxxxxxxxxxxx')
-
-    >>> example_bug_tracker = new_bugtracker(BugTrackerType.TRAC)
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> example_bug = getUtility(IBugSet).get(10)
-    >>> example_bugwatch = example_bug.addWatch(
-    ...     example_bug_tracker, '1',
-    ...     getUtility(ILaunchpadCelebrities).janitor)
-
-
-Collect the Example.com watches:
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    1: None
-
-And have a Trac instance process them:
-
-    >>> transaction.commit()
-
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> from lp.bugs.scripts.checkwatches import CheckwatchesMaster
-    >>> txn = LaunchpadZopelessLayer.txn
-    >>> bug_watch_updater = CheckwatchesMaster(
-    ...     txn, FakeLogger())
-    >>> trac = TestTrac(example_bug_tracker.baseurl)
-    >>> with trac.responses():
-    ...     bug_watch_updater.updateBugWatches(
-    ...         trac, example_bug_tracker.watches)
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> for bug_watch in example_bug_tracker.watches:
-    ...     print("%s: %s" % (bug_watch.remotebug, bug_watch.remotestatus))
-    1: fixed
-
-We'll add some more watches now.
-
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-
-    >>> bug_watch_set = getUtility(IBugWatchSet)
-    >>> bug_watches = dict(
-    ...     (int(bug_watch.remotebug), bug_watch.id)
-    ...     for bug_watch in example_bug_tracker.watches)
-
-    >>> for remote_bug, bug_watch_id in bug_watches.items():
-    ...     bug_watch = getUtility(IBugWatchSet).get(bug_watch_id)
-    ...     print("%s: %s" % (remote_bug, bug_watch.remotestatus))
-    1: fixed
-
-    >>> remote_bugs = [
-    ...     (143, 'fixed'),
-    ...     (144, 'assigned'),
-    ...     (145, 'duplicate'),
-    ...     (146, 'invalid'),
-    ...     (147, 'worksforme'),
-    ...     (148, 'wontfix'),
-    ...     (149, 'reopened'),
-    ...     (150, 'new'),
-    ...     (151, 'new'),
-    ...     (152, 'new'),
-    ...     (153, 'new'),
-    ... ]
-
-    >>> for remote_bug_id, remote_status in remote_bugs:
-    ...     bug_watch = bug_watch_set.createBugWatch(
-    ...         bug=example_bug, owner=sample_person,
-    ...         bugtracker=example_bug_tracker,
-    ...         remotebug=str(remote_bug_id))
-    ...     bug_watches[remote_bug_id] = bug_watch.id
-
-    >>> with trac.responses(trace_calls=True):
-    ...     bug_watch_updater.updateBugWatches(
-    ...         trac, example_bug_tracker.watches)
-    INFO Updating 12 watches for 12 bugs on http://bugs.some.where
-    GET http://bugs.some.where/query?id=...
-
-    >>> for remote_bug_id in sorted(bug_watches.keys()):
-    ...     bug_watch = getUtility(IBugWatchSet).get(
-    ...         bug_watches[remote_bug_id])
-    ...     remote_status = bug_watch.remotestatus
-    ...     print('Remote bug %d: %s' % (remote_bug_id, remote_status))
-    Remote bug 1: fixed
-    Remote bug 143: fixed
-    Remote bug 144: assigned
-    Remote bug 145: duplicate
-    Remote bug 146: invalid
-    Remote bug 147: worksforme
-    Remote bug 148: wontfix
-    Remote bug 149: reopened
-    Remote bug 150: new
-    Remote bug 151: new
-    Remote bug 152: new
-    Remote bug 153: new
-
-updateBugWatches() updates the lastchecked attribute on the watches, so
-now no bug watches are in need of updating:
-
-    >>> flush_database_updates()
-    >>> example_bug_tracker.watches_needing_update.count()
-    0
-
-If the status isn't different, the lastchanged attribute doesn't get
-updated. If we set a bug watch's lastchanged timestamp manually and call
-update, lastchanged shouldn't be affected because the remote status of the bug
-watch hasn't altered:
-
-    >>> import pytz
-    >>> from datetime import datetime, timedelta
-    >>> from operator import attrgetter
-    >>> sorted_bug_watches = sorted(
-    ...     (bug_watch for bug_watch in example_bug_tracker.watches),
-    ...     key=attrgetter('remotebug'))
-    >>> bug_watch = sorted_bug_watches[-1]
-    >>> now = datetime.now(pytz.timezone('UTC'))
-    >>> bug_watch.lastchanged = now - timedelta(weeks=2)
-    >>> old_last_changed = bug_watch.lastchanged
-    >>> print(bug_watch.remotebug)
-    153
-    >>> print(bug_watch.remotestatus)
-    new
-
-    >>> trac.batch_query_threshold = 0
-    >>> with trac.responses():
-    ...     bug_watch_updater.updateBugWatches(trac, [bug_watch])
-    INFO Updating 1 watches for 1 bugs on http://bugs.some.where
-
-    >>> bug_watch.lastchanged == old_last_changed
-    True
-    >>> print(bug_watch.remotebug)
-    153
-    >>> print(bug_watch.remotestatus)
-    new
diff --git a/lib/lp/bugs/doc/externalbugtracker.txt b/lib/lp/bugs/doc/externalbugtracker.rst
similarity index 99%
rename from lib/lp/bugs/doc/externalbugtracker.txt
rename to lib/lp/bugs/doc/externalbugtracker.rst
index d867059..4ea1e31 100644
--- a/lib/lp/bugs/doc/externalbugtracker.txt
+++ b/lib/lp/bugs/doc/externalbugtracker.rst
@@ -420,7 +420,7 @@ Limiting which bug watches to update
 ....................................
 
 XXX: GavinPanella 2010-01-13 bug=507205: Move this section to
-checkwatches-batching.txt.
+checkwatches-batching.rst.
 
 In order to reduce the amount of data we have to transfer over the
 network, each IExternalBugTracker has the ability to filter out bugs
@@ -888,7 +888,7 @@ Prioritisation of watches
 _getRemoteIdsToCheck() prioritizes the IDs it returns. Bug watches which have
 comments to push or which have never been checked will always be returned in
 the remote_ids_to_check list, limited only by the batch_size of the bug
-tracker (see "Batched BugWatch Updating" in doc/checkwatches.txt).
+tracker (see "Batched BugWatch Updating" in doc/checkwatches.rst).
 
 We'll create some example unchecked watches as well as some watches with
 comments to push in order to demonstrate this.
diff --git a/lib/lp/bugs/doc/initial-bug-contacts.rst b/lib/lp/bugs/doc/initial-bug-contacts.rst
new file mode 100644
index 0000000..7161f17
--- /dev/null
+++ b/lib/lp/bugs/doc/initial-bug-contacts.rst
@@ -0,0 +1,354 @@
+Bug Subscriptions
+=================
+
+Package bug subscriptions allow zero, one, or more people or teams that
+get explicitly Cc'd to all public bugs filed on a package.
+
+The package bug subscriptions are obtained from looking at the
+StructuralSubscription table.
+
+The list of package bug subscriptions are accessed through the
+IDistributionSourcePackage.bug_subscriptions attribute. When there are
+no subscriptions associated with a package, an empty list is returned:
+
+    >>> from zope.component import getUtility
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+
+    >>> debian = getUtility(IDistributionSet).getByName("debian")
+    >>> debian_firefox = debian.getSourcePackage("mozilla-firefox")
+
+    >>> list(debian_firefox.bug_subscriptions)
+    []
+
+Adding a package subscription is done with the
+IDistributionSourcePackage.addBugSubscription method. You have to be
+logged in to call this method:
+
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> personset = getUtility(IPersonSet)
+    >>> sample_person = personset.getByName("name12")
+
+    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
+    Traceback (most recent call last):
+      ...
+    zope.security.interfaces.Unauthorized: ...
+
+Let's login then to add a subscription:
+
+    >>> from lp.testing import login
+    >>> login("foo.bar@xxxxxxxxxxxxx")
+
+    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
+    <...StructuralSubscription object at ...>
+
+    >>> for pbc in debian_firefox.bug_subscriptions:
+    ...     print(pbc.subscriber.name)
+    name12
+
+Trying to add a subscription to a package when that person or team is
+already subscribe to that package will return the existing subscription.
+
+    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
+    <...StructuralSubscription object at ...>
+
+Let's add an ITeam as one of the subscribers:
+
+    >>> ubuntu_team = personset.getByName("ubuntu-team")
+    >>> debian_firefox.addBugSubscription(ubuntu_team, ubuntu_team)
+    <...StructuralSubscription object at ...>
+
+    >>> from operator import attrgetter
+
+    >>> for sub in sorted(
+    ...         debian_firefox.bug_subscriptions,
+    ...         key=attrgetter('subscriber.name')):
+    ...     print(sub.subscriber.name)
+    name12
+    ubuntu-team
+
+To remove a subscription, use
+IStructuralSubscriptionTarget.removeBugSubscription:
+
+    >>> debian_firefox.removeBugSubscription(sample_person, sample_person)
+    >>> sorted([
+    ...     sub.subscriber.id for sub in debian_firefox.bug_subscriptions])
+    [17]
+
+Trying to remove a subscription that doesn't exist on a source package
+raises a DeleteSubscriptionError.
+
+    >>> foobar = personset.getByName("name16")
+    >>> debian_firefox.removeBugSubscription(foobar, foobar)
+    Traceback (most recent call last):
+      ...
+    lp.registry.errors.DeleteSubscriptionError: ...
+
+
+Package Subscriptions and Bug Tasks
+-----------------------------------
+
+Often a bug gets reported on package foo, when it should have been
+reported on bar. When a user, likely a bug triager or developer, changes
+the source package, the subscribers for the new package get subscribed.
+The subscribers of the previous package also remain subscribed.
+
+To demonstrate, let's change the source package for bug #1 in mozilla-
+firefox in Ubuntu to be pmount in Ubuntu, and see how the subscribers
+list changes.
+
+    >>> from lp.bugs.interfaces.bugtask import IBugTaskSet
+
+    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
+
+    >>> ubuntu_firefox = ubuntu.getSourcePackage("mozilla-firefox")
+    >>> ubuntu_pmount = ubuntu.getSourcePackage("pmount")
+
+    >>> bug_one_in_ubuntu_firefox = getUtility(IBugTaskSet).get(17)
+
+Foo Bar, a package subscriber to ubuntu mozilla-firefox and ubuntu
+pmount is currently not subscribed to bug 1.
+
+    >>> from itertools import chain
+    >>> def subscriber_names(bug):
+    ...     subscribers = chain(
+    ...         bug.getDirectSubscribers(),
+    ...         bug.getIndirectSubscribers())
+    ...     return sorted(
+    ...         subscriber.displayname for subscriber in subscribers)
+
+    >>> names = subscriber_names(bug_one_in_ubuntu_firefox.bug)
+    >>> for name in names:
+    ...     print(name)
+    Foo Bar
+    Mark Shuttleworth
+    Sample Person
+    Steve Alexander
+    Ubuntu Team
+
+Changing the package for bug_one_in_ubuntu_firefox to pmount will
+implicitly subscribe the new package's subscribers to the bug. In
+demonstrating this, we'll also make Sample Person a subscriber to ubuntu
+pmount, to show that the subscription changes behave correctly when a
+subscriber to the new package is already subscribed to the bug:
+
+    >>> import transaction
+    >>> from lp.services.mail import stub
+    >>> from lp.services.webapp.snapshot import notify_modified
+
+    >>> daf = personset.getByName("daf")
+    >>> ubuntu_pmount.addBugSubscription(daf, daf)
+    <...StructuralSubscription object at ...>
+
+    >>> ubuntu_pmount.addBugSubscription(sample_person, sample_person)
+    <...StructuralSubscription object at ...>
+
+    >>> with notify_modified(
+    ...         bug_one_in_ubuntu_firefox,
+    ...         ["id", "title", "sourcepackagename"]):
+    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu_pmount, daf)
+    >>> transaction.commit()
+
+With the source package changed, we can see that daf is now subscribed:
+
+    >>> for name in subscriber_names(bug_one_in_ubuntu_firefox.bug):
+    ...     print(name)
+    Dafydd Harries
+    Foo Bar
+    Mark Shuttleworth
+    Sample Person
+    Steve Alexander
+    Ubuntu Team
+
+daf is sent an email giving him complete information about the bug that
+has just been retargeted, including the title, description, status,
+importance, etc. The References header of the email contains the msgid
+of the initial bug report (as if daf was a original recipient of the bug
+notification). The email has the X-Launchpad-Message-Rationale header to
+track why daf received the email. The rational is repeated in the footer
+of the email with the bug title and URL.
+
+    >>> import email
+    >>> from operator import itemgetter
+
+    >>> test_emails = list(stub.test_emails)
+    >>> test_emails.sort(key=itemgetter(1))
+
+    >>> len(test_emails)
+    1
+
+    >>> from_addr, to_addr, raw_message = test_emails.pop()
+    >>> print(from_addr)
+    bounces@xxxxxxxxxxxxx
+
+    >>> print(to_addr)
+    ['daf@xxxxxxxxxxxxx']
+
+    >>> msg = email.message_from_bytes(raw_message)
+    >>> msg['References'] == (
+    ...        bug_one_in_ubuntu_firefox.bug.initial_message.rfc822msgid)
+    True
+
+    >>> msg['X-Launchpad-Message-Rationale']
+    'Subscriber (pmount in Ubuntu)'
+    >>> msg['X-Launchpad-Message-For']
+    'daf'
+
+    >>> msg['Subject']
+    '[Bug 1] [NEW] Firefox does not support SVG'
+
+    >>> print(six.ensure_text(msg.get_payload(decode=True)))
+    You have been subscribed to a public bug:
+    <BLANKLINE>
+    Firefox needs to support embedded SVG images, now that the standard has
+    been finalised.
+    <BLANKLINE>
+    The SVG standard 1.0 is complete, and draft implementations for Firefox
+    exist. One of these implementations needs to be integrated with the base
+    install of Firefox. Ideally, the implementation needs to include support
+    for the manipulation of SVG objects from JavaScript to enable
+    interactive and dynamic SVG drawings.
+    <BLANKLINE>
+    ** Affects: firefox
+         Importance: Low
+           Assignee: Mark Shuttleworth (mark)
+             Status: New
+    <BLANKLINE>
+    ** Affects: pmount (Ubuntu)
+         Importance: Medium
+             Status: New
+    <BLANKLINE>
+    ** Affects: mozilla-firefox (Debian)
+         Importance: Low
+             Status: Confirmed
+    <BLANKLINE>
+    --
+    Firefox does not support SVG
+    http://bugs.launchpad.test/bugs/1
+    You received this bug notification because you
+    are subscribed to pmount in Ubuntu.
+
+Since the reporter didn't do anything to trigger this change, the bug
+address is used as the From address.
+
+    >>> print(msg['From'])
+    Launchpad Bug Tracker <1@xxxxxxxxxxxxxxxxxx>
+
+    >>> stub.test_emails = []
+
+Let's see that nothing unexpected happens when we set the source package
+to None.
+
+    >>> with notify_modified(
+    ...         bug_one_in_ubuntu_firefox, ["sourcepackagename"]):
+    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu, daf)
+    >>> transaction.commit()
+    >>> stub.test_emails = []
+
+The package subscribers, Daf and Foo Bar, are implicitly unsubscribed:
+
+    >>> names = subscriber_names(bug_one_in_ubuntu_firefox.bug)
+    >>> for name in names:
+    ...     print(name)
+    Mark Shuttleworth
+    Sample Person
+    Steve Alexander
+    Ubuntu Team
+
+Subscriptions are not limited to persons; teams are also allowed to
+subscribe. Teams are a bit different, since they might not have a
+contact address. Let's add such a team as a subscriber.
+
+    >>> ubuntu_gnome = personset.getByName("name18")
+    >>> ubuntu_gnome.preferredemail is None
+    True
+
+    >>> ubuntu_pmount.addBugSubscription(ubuntu_gnome, ubuntu_gnome)
+    <...StructuralSubscription object at ...>
+
+    >>> with notify_modified(
+    ...         bug_one_in_ubuntu_firefox, ["sourcepackagename"]):
+    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu_pmount, daf)
+    >>> transaction.commit()
+
+The Ubuntu Gnome team was subscribed to the bug:
+
+    >>> stub.test_emails = []
+    >>> for name in subscriber_names(bug_one_in_ubuntu_firefox.bug):
+    ...     print(name)
+    Dafydd Harries
+    Foo Bar
+    Mark Shuttleworth
+    Sample Person
+    Steve Alexander
+    Ubuntu Gnome Team
+    Ubuntu Team
+
+
+Product Bug Supervisors and Bug Tasks
+-------------------------------------
+
+Like reassigning a bug task to another package, reassigning a bug task
+to another product will subscribe any new product bug supervisors to the
+bug that aren't already subscribed.
+
+    >>> from lp.registry.interfaces.product import IProductSet
+
+    >>> mozilla_firefox = getUtility(IProductSet).get(4)
+
+Then we'll reassign bug #2 in Ubuntu to be in Firefox:
+
+    >>> bug_two_in_ubuntu = getUtility(IBugTaskSet).get(3)
+    >>> print(bug_two_in_ubuntu.bug.id)
+    2
+
+    >>> print(bug_two_in_ubuntu.product.name)
+    tomcat
+
+    >>> for subscription in sorted(
+    ...         bug_two_in_ubuntu.bug.subscriptions,
+    ...         key=attrgetter('person.displayname')):
+    ...     print(subscription.person.displayname)
+    Steve Alexander
+
+    >>> with notify_modified(bug_two_in_ubuntu, ["id", "title", "product"]):
+    ...     bug_two_in_ubuntu.transitionToTarget(mozilla_firefox, daf)
+    >>> transaction.commit()
+
+
+Teams as bug supervisors
+------------------------
+
+The list of teams that a user may add to a package as a bug supervisor
+will only contain those teams of which the user is an administrator.
+
+    >>> from zope.component import getMultiAdapter
+    >>> from lp.services.webapp.servers import LaunchpadTestRequest
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+
+    >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
+    >>> package = ubuntu.getSourcePackage('mozilla-firefox')
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> request = LaunchpadTestRequest()
+    >>> view = getMultiAdapter((package, request), name='+subscribe')
+
+Sample Person is a member of four teams:
+
+    >>> sample_person = view.user
+    >>> for membership in sample_person.team_memberships:
+    ...     print('%s: %s' % (
+    ...         membership.team.displayname, membership.status.name))
+    HWDB Team: APPROVED
+    Landscape Developers: ADMIN
+    Launchpad Users: ADMIN
+    Warty Security Team: APPROVED
+
+But is only an administrator of Landscape Developers, so that is the
+only team that will be listed when the user is changing a package bug
+supervisor:
+
+    >>> for team in view.user.getAdministratedTeams():
+    ...        print(team.displayname)
+    Landscape Developers
+    Launchpad Users
+
diff --git a/lib/lp/bugs/doc/initial-bug-contacts.txt b/lib/lp/bugs/doc/initial-bug-contacts.txt
deleted file mode 100644
index 7161f17..0000000
--- a/lib/lp/bugs/doc/initial-bug-contacts.txt
+++ /dev/null
@@ -1,354 +0,0 @@
-Bug Subscriptions
-=================
-
-Package bug subscriptions allow zero, one, or more people or teams that
-get explicitly Cc'd to all public bugs filed on a package.
-
-The package bug subscriptions are obtained from looking at the
-StructuralSubscription table.
-
-The list of package bug subscriptions are accessed through the
-IDistributionSourcePackage.bug_subscriptions attribute. When there are
-no subscriptions associated with a package, an empty list is returned:
-
-    >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-
-    >>> debian = getUtility(IDistributionSet).getByName("debian")
-    >>> debian_firefox = debian.getSourcePackage("mozilla-firefox")
-
-    >>> list(debian_firefox.bug_subscriptions)
-    []
-
-Adding a package subscription is done with the
-IDistributionSourcePackage.addBugSubscription method. You have to be
-logged in to call this method:
-
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> personset = getUtility(IPersonSet)
-    >>> sample_person = personset.getByName("name12")
-
-    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
-    Traceback (most recent call last):
-      ...
-    zope.security.interfaces.Unauthorized: ...
-
-Let's login then to add a subscription:
-
-    >>> from lp.testing import login
-    >>> login("foo.bar@xxxxxxxxxxxxx")
-
-    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
-    <...StructuralSubscription object at ...>
-
-    >>> for pbc in debian_firefox.bug_subscriptions:
-    ...     print(pbc.subscriber.name)
-    name12
-
-Trying to add a subscription to a package when that person or team is
-already subscribe to that package will return the existing subscription.
-
-    >>> debian_firefox.addBugSubscription(sample_person, sample_person)
-    <...StructuralSubscription object at ...>
-
-Let's add an ITeam as one of the subscribers:
-
-    >>> ubuntu_team = personset.getByName("ubuntu-team")
-    >>> debian_firefox.addBugSubscription(ubuntu_team, ubuntu_team)
-    <...StructuralSubscription object at ...>
-
-    >>> from operator import attrgetter
-
-    >>> for sub in sorted(
-    ...         debian_firefox.bug_subscriptions,
-    ...         key=attrgetter('subscriber.name')):
-    ...     print(sub.subscriber.name)
-    name12
-    ubuntu-team
-
-To remove a subscription, use
-IStructuralSubscriptionTarget.removeBugSubscription:
-
-    >>> debian_firefox.removeBugSubscription(sample_person, sample_person)
-    >>> sorted([
-    ...     sub.subscriber.id for sub in debian_firefox.bug_subscriptions])
-    [17]
-
-Trying to remove a subscription that doesn't exist on a source package
-raises a DeleteSubscriptionError.
-
-    >>> foobar = personset.getByName("name16")
-    >>> debian_firefox.removeBugSubscription(foobar, foobar)
-    Traceback (most recent call last):
-      ...
-    lp.registry.errors.DeleteSubscriptionError: ...
-
-
-Package Subscriptions and Bug Tasks
------------------------------------
-
-Often a bug gets reported on package foo, when it should have been
-reported on bar. When a user, likely a bug triager or developer, changes
-the source package, the subscribers for the new package get subscribed.
-The subscribers of the previous package also remain subscribed.
-
-To demonstrate, let's change the source package for bug #1 in mozilla-
-firefox in Ubuntu to be pmount in Ubuntu, and see how the subscribers
-list changes.
-
-    >>> from lp.bugs.interfaces.bugtask import IBugTaskSet
-
-    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
-
-    >>> ubuntu_firefox = ubuntu.getSourcePackage("mozilla-firefox")
-    >>> ubuntu_pmount = ubuntu.getSourcePackage("pmount")
-
-    >>> bug_one_in_ubuntu_firefox = getUtility(IBugTaskSet).get(17)
-
-Foo Bar, a package subscriber to ubuntu mozilla-firefox and ubuntu
-pmount is currently not subscribed to bug 1.
-
-    >>> from itertools import chain
-    >>> def subscriber_names(bug):
-    ...     subscribers = chain(
-    ...         bug.getDirectSubscribers(),
-    ...         bug.getIndirectSubscribers())
-    ...     return sorted(
-    ...         subscriber.displayname for subscriber in subscribers)
-
-    >>> names = subscriber_names(bug_one_in_ubuntu_firefox.bug)
-    >>> for name in names:
-    ...     print(name)
-    Foo Bar
-    Mark Shuttleworth
-    Sample Person
-    Steve Alexander
-    Ubuntu Team
-
-Changing the package for bug_one_in_ubuntu_firefox to pmount will
-implicitly subscribe the new package's subscribers to the bug. In
-demonstrating this, we'll also make Sample Person a subscriber to ubuntu
-pmount, to show that the subscription changes behave correctly when a
-subscriber to the new package is already subscribed to the bug:
-
-    >>> import transaction
-    >>> from lp.services.mail import stub
-    >>> from lp.services.webapp.snapshot import notify_modified
-
-    >>> daf = personset.getByName("daf")
-    >>> ubuntu_pmount.addBugSubscription(daf, daf)
-    <...StructuralSubscription object at ...>
-
-    >>> ubuntu_pmount.addBugSubscription(sample_person, sample_person)
-    <...StructuralSubscription object at ...>
-
-    >>> with notify_modified(
-    ...         bug_one_in_ubuntu_firefox,
-    ...         ["id", "title", "sourcepackagename"]):
-    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu_pmount, daf)
-    >>> transaction.commit()
-
-With the source package changed, we can see that daf is now subscribed:
-
-    >>> for name in subscriber_names(bug_one_in_ubuntu_firefox.bug):
-    ...     print(name)
-    Dafydd Harries
-    Foo Bar
-    Mark Shuttleworth
-    Sample Person
-    Steve Alexander
-    Ubuntu Team
-
-daf is sent an email giving him complete information about the bug that
-has just been retargeted, including the title, description, status,
-importance, etc. The References header of the email contains the msgid
-of the initial bug report (as if daf was a original recipient of the bug
-notification). The email has the X-Launchpad-Message-Rationale header to
-track why daf received the email. The rational is repeated in the footer
-of the email with the bug title and URL.
-
-    >>> import email
-    >>> from operator import itemgetter
-
-    >>> test_emails = list(stub.test_emails)
-    >>> test_emails.sort(key=itemgetter(1))
-
-    >>> len(test_emails)
-    1
-
-    >>> from_addr, to_addr, raw_message = test_emails.pop()
-    >>> print(from_addr)
-    bounces@xxxxxxxxxxxxx
-
-    >>> print(to_addr)
-    ['daf@xxxxxxxxxxxxx']
-
-    >>> msg = email.message_from_bytes(raw_message)
-    >>> msg['References'] == (
-    ...        bug_one_in_ubuntu_firefox.bug.initial_message.rfc822msgid)
-    True
-
-    >>> msg['X-Launchpad-Message-Rationale']
-    'Subscriber (pmount in Ubuntu)'
-    >>> msg['X-Launchpad-Message-For']
-    'daf'
-
-    >>> msg['Subject']
-    '[Bug 1] [NEW] Firefox does not support SVG'
-
-    >>> print(six.ensure_text(msg.get_payload(decode=True)))
-    You have been subscribed to a public bug:
-    <BLANKLINE>
-    Firefox needs to support embedded SVG images, now that the standard has
-    been finalised.
-    <BLANKLINE>
-    The SVG standard 1.0 is complete, and draft implementations for Firefox
-    exist. One of these implementations needs to be integrated with the base
-    install of Firefox. Ideally, the implementation needs to include support
-    for the manipulation of SVG objects from JavaScript to enable
-    interactive and dynamic SVG drawings.
-    <BLANKLINE>
-    ** Affects: firefox
-         Importance: Low
-           Assignee: Mark Shuttleworth (mark)
-             Status: New
-    <BLANKLINE>
-    ** Affects: pmount (Ubuntu)
-         Importance: Medium
-             Status: New
-    <BLANKLINE>
-    ** Affects: mozilla-firefox (Debian)
-         Importance: Low
-             Status: Confirmed
-    <BLANKLINE>
-    --
-    Firefox does not support SVG
-    http://bugs.launchpad.test/bugs/1
-    You received this bug notification because you
-    are subscribed to pmount in Ubuntu.
-
-Since the reporter didn't do anything to trigger this change, the bug
-address is used as the From address.
-
-    >>> print(msg['From'])
-    Launchpad Bug Tracker <1@xxxxxxxxxxxxxxxxxx>
-
-    >>> stub.test_emails = []
-
-Let's see that nothing unexpected happens when we set the source package
-to None.
-
-    >>> with notify_modified(
-    ...         bug_one_in_ubuntu_firefox, ["sourcepackagename"]):
-    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu, daf)
-    >>> transaction.commit()
-    >>> stub.test_emails = []
-
-The package subscribers, Daf and Foo Bar, are implicitly unsubscribed:
-
-    >>> names = subscriber_names(bug_one_in_ubuntu_firefox.bug)
-    >>> for name in names:
-    ...     print(name)
-    Mark Shuttleworth
-    Sample Person
-    Steve Alexander
-    Ubuntu Team
-
-Subscriptions are not limited to persons; teams are also allowed to
-subscribe. Teams are a bit different, since they might not have a
-contact address. Let's add such a team as a subscriber.
-
-    >>> ubuntu_gnome = personset.getByName("name18")
-    >>> ubuntu_gnome.preferredemail is None
-    True
-
-    >>> ubuntu_pmount.addBugSubscription(ubuntu_gnome, ubuntu_gnome)
-    <...StructuralSubscription object at ...>
-
-    >>> with notify_modified(
-    ...         bug_one_in_ubuntu_firefox, ["sourcepackagename"]):
-    ...     bug_one_in_ubuntu_firefox.transitionToTarget(ubuntu_pmount, daf)
-    >>> transaction.commit()
-
-The Ubuntu Gnome team was subscribed to the bug:
-
-    >>> stub.test_emails = []
-    >>> for name in subscriber_names(bug_one_in_ubuntu_firefox.bug):
-    ...     print(name)
-    Dafydd Harries
-    Foo Bar
-    Mark Shuttleworth
-    Sample Person
-    Steve Alexander
-    Ubuntu Gnome Team
-    Ubuntu Team
-
-
-Product Bug Supervisors and Bug Tasks
--------------------------------------
-
-Like reassigning a bug task to another package, reassigning a bug task
-to another product will subscribe any new product bug supervisors to the
-bug that aren't already subscribed.
-
-    >>> from lp.registry.interfaces.product import IProductSet
-
-    >>> mozilla_firefox = getUtility(IProductSet).get(4)
-
-Then we'll reassign bug #2 in Ubuntu to be in Firefox:
-
-    >>> bug_two_in_ubuntu = getUtility(IBugTaskSet).get(3)
-    >>> print(bug_two_in_ubuntu.bug.id)
-    2
-
-    >>> print(bug_two_in_ubuntu.product.name)
-    tomcat
-
-    >>> for subscription in sorted(
-    ...         bug_two_in_ubuntu.bug.subscriptions,
-    ...         key=attrgetter('person.displayname')):
-    ...     print(subscription.person.displayname)
-    Steve Alexander
-
-    >>> with notify_modified(bug_two_in_ubuntu, ["id", "title", "product"]):
-    ...     bug_two_in_ubuntu.transitionToTarget(mozilla_firefox, daf)
-    >>> transaction.commit()
-
-
-Teams as bug supervisors
-------------------------
-
-The list of teams that a user may add to a package as a bug supervisor
-will only contain those teams of which the user is an administrator.
-
-    >>> from zope.component import getMultiAdapter
-    >>> from lp.services.webapp.servers import LaunchpadTestRequest
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-
-    >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
-    >>> package = ubuntu.getSourcePackage('mozilla-firefox')
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> request = LaunchpadTestRequest()
-    >>> view = getMultiAdapter((package, request), name='+subscribe')
-
-Sample Person is a member of four teams:
-
-    >>> sample_person = view.user
-    >>> for membership in sample_person.team_memberships:
-    ...     print('%s: %s' % (
-    ...         membership.team.displayname, membership.status.name))
-    HWDB Team: APPROVED
-    Landscape Developers: ADMIN
-    Launchpad Users: ADMIN
-    Warty Security Team: APPROVED
-
-But is only an administrator of Landscape Developers, so that is the
-only team that will be listed when the user is changing a package bug
-supervisor:
-
-    >>> for team in view.user.getAdministratedTeams():
-    ...        print(team.displayname)
-    Landscape Developers
-    Launchpad Users
-
diff --git a/lib/lp/bugs/doc/malone-karma.rst b/lib/lp/bugs/doc/malone-karma.rst
new file mode 100644
index 0000000..06d7529
--- /dev/null
+++ b/lib/lp/bugs/doc/malone-karma.rst
@@ -0,0 +1,248 @@
+This file lists all the karma events that Malone produces. First let's
+import some stuff and define a function to help us make the
+documentation cleaner:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> from lp.registry.interfaces.karma import IKarmaActionSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@xxxxxxxxxxxxx')
+
+Setup an event listener to help ensure karma is assigned when it should.
+
+    >>> from zope.event import notify
+    >>> from lp.testing.karma import KarmaAssignedEventListener
+    >>> karma_helper = KarmaAssignedEventListener()
+    >>> karma_helper.register_listener()
+
+Foo Bar is the one that will get all the karma:
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+
+
+Karma Actions
+-------------
+
+Create a bug:
+
+    >>> from lazr.lifecycle.event import ObjectCreatedEvent
+    >>> from lp.bugs.interfaces.bug import CreateBugParams
+    >>> debian = getUtility(IDistributionSet).getByName('debian')
+    >>> params = CreateBugParams(
+    ...     comment=u"Give me some karma!", title=u"New Bug", owner=foo_bar)
+    >>> bug = debian.createBug(params)
+    Karma added: action=bugcreated, distribution=debian
+
+Change the title of a bug:
+
+    >>> from lp.services.webapp.snapshot import notify_modified
+    >>> with notify_modified(bug, ['title']):
+    ...     bug.title = "Better Title"
+    Karma added: action=bugtitlechanged, distribution=debian
+
+Change the description of a bug:
+
+    >>> with notify_modified(bug, ['description']):
+    ...     bug.description = "Description of bug"
+    Karma added: action=bugdescriptionchanged, distribution=debian
+
+Add a CVE reference to a bug:
+
+    >>> from lp.bugs.interfaces.cve import CveStatus, ICveSet
+    >>> cve = getUtility(ICveSet).new('2003-1234', description="Blah blah",
+    ...     status=CveStatus.CANDIDATE)
+    >>> cve.linkBug(bug)
+    Karma added: action=bugcverefadded, distribution=debian
+    True
+
+Link a merge proposal to a bug:
+
+    >>> dsp = factory.makeDistributionSourcePackage(distribution=debian)
+    >>> merge_proposal = factory.makeBranchMergeProposalForGit(target=dsp)
+    Karma added: action=branchmergeproposed, distribution=debian
+    >>> bug.linkMergeProposal(merge_proposal, getUtility(ILaunchBag).user)
+    Karma added: action=bugbranchcreated, distribution=debian
+
+Add watch for external bug to the bug:
+
+    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
+    >>> from lp.bugs.model.bugwatch import BugWatch
+    >>> debbugs = getUtility(IBugTrackerSet)['debbugs']
+    >>> bugwatch = BugWatch(
+    ...     bug=bug,bugtracker=debbugs, remotebug=u'42', owner=foo_bar)
+    >>> notify(ObjectCreatedEvent(bugwatch))
+    Karma added: action=bugwatchadded, distribution=debian
+
+Mark a bug task as fixed:
+
+    >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
+    >>> bugtask = bug.bugtasks[0]
+    >>> with notify_modified(bugtask, ['status']):
+    ...     bugtask.transitionToStatus(
+    ...         BugTaskStatus.FIXRELEASED, getUtility(ILaunchBag).user)
+    Karma added: action=bugfixed, distribution=debian
+
+Mark a bug task as fixed when it is assigned awards the karma to the assignee:
+
+    >>> ufo_product = factory.makeProduct(name='ufo')
+    >>> assignee = factory.makePerson(name='assignee')
+    >>> assigned_bugtask = factory.makeBugTask(bug=bug, target=ufo_product)
+    >>> assigned_bugtask.transitionToAssignee(assignee)
+    >>> with notify_modified(assigned_bugtask, ['status']):
+    ...     assigned_bugtask.transitionToStatus(
+    ...         BugTaskStatus.FIXRELEASED, getUtility(ILaunchBag).user)
+    Karma added: action=bugfixed, product=ufo
+
+    >>> for karma in assignee.latestKarma():
+    ...     print(karma.action.name)
+    bugfixed
+
+Reject a bug task:
+
+    >>> with notify_modified(bugtask, ['status']):
+    ...     bugtask.transitionToStatus(
+    ...         BugTaskStatus.INVALID, bugtask.target.owner)
+    Karma added: action=bugrejected, distribution=debian
+
+User accept a bug task:
+
+    >>> with notify_modified(bugtask, ['status']):
+    ...     bugtask.transitionToStatus(
+    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
+    Karma added: action=bugaccepted, distribution=debian
+
+Driver accept a bug task:
+
+    >>> ignored = login_person(bugtask.target.owner)
+    >>> with notify_modified(bugtask, ['status']):
+    ...     bugtask.transitionToStatus(
+    ...         BugTaskStatus.TRIAGED, getUtility(ILaunchBag).user)
+    Karma added: action=bugaccepted, distribution=debian
+
+    >>> login('admin@xxxxxxxxxxxxx')
+
+Change a bug task's importance:
+
+    >>> from lp.bugs.interfaces.bugtask import BugTaskImportance
+    >>> bugtask.transitionToImportance(
+    ...     BugTaskImportance.HIGH, getUtility(ILaunchBag).user)
+    >>> for importance in BugTaskImportance.items:
+    ...     with notify_modified(bugtask, ['importance']):
+    ...         bugtask.transitionToImportance(
+    ...             importance, getUtility(ILaunchBag).user)
+    ...         print(importance.name)
+    UNKNOWN
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    UNDECIDED
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    CRITICAL
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    HIGH
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    MEDIUM
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    LOW
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+    WISHLIST
+    Karma added: action=bugtaskimportancechanged, distribution=debian
+
+Create a new bug task on a product:
+
+    >>> from lp.bugs.interfaces.bugtask import IBugTaskSet
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> evolution = getUtility(IProductSet)['evolution']
+    >>> evolution_task = getUtility(IBugTaskSet).createTask(
+    ...     bug, foo_bar, evolution)
+    >>> notify(ObjectCreatedEvent(evolution_task))
+    Karma added: action=bugtaskcreated, product=evolution
+
+Create a new bug task on a product series:
+
+    >>> evolution_trunk = evolution.getSeries('trunk')
+    >>> evolution_trunk_task = getUtility(IBugTaskSet).createTask(
+    ...     bug, foo_bar, evolution_trunk)
+    >>> notify(ObjectCreatedEvent(evolution_trunk_task))
+    Karma added: action=bugtaskcreated, product=evolution
+
+Create a new bug task on a distroseries:
+
+    >>> debian_woody = debian.getSeries("woody")
+    >>> debian_woody_task = getUtility(IBugTaskSet).createTask(
+    ...     bug, foo_bar, debian_woody)
+    >>> notify(ObjectCreatedEvent(debian_woody_task))
+    Karma added: action=bugtaskcreated, distribution=debian
+
+Accept a distro series task.
+
+    >>> debian_woody_task.transitionToStatus(
+    ...     BugTaskStatus.NEW, getUtility(ILaunchBag).user)
+    >>> with notify_modified(debian_woody_task, ['status']):
+    ...     debian_woody_task.transitionToStatus(
+    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
+    Karma added: action=bugaccepted, distribution=debian
+
+Accept a productseries task.
+
+    >>> evolution_trunk_task.transitionToStatus(
+    ...     BugTaskStatus.NEW, getUtility(ILaunchBag).user)
+    >>> with notify_modified(evolution_trunk_task, ['status']):
+    ...     evolution_trunk_task.transitionToStatus(
+    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
+    Karma added: action=bugaccepted, product=evolution
+
+Mark a bug as a duplicate:
+
+(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
+be actual contributions to all bugtasks of that bug.)
+
+    >>> bug_one = getUtility(IBugSet).get(1)
+    >>> with notify_modified(bug, ['duplicateof']):
+    ...     bug.markAsDuplicate(bug_one)
+    Karma added: action=bugmarkedasduplicate, product=evolution
+    Karma added: action=bugmarkedasduplicate, product=evolution
+    Karma added: action=bugmarkedasduplicate, product=ufo
+    Karma added: action=bugmarkedasduplicate, distribution=debian
+    Karma added: action=bugmarkedasduplicate, distribution=debian
+
+Adding a comment generates a karma event, but gives no points:
+
+    >>> from lp.bugs.interfaces.bugmessage import IBugMessageSet
+    >>> comment = getUtility(IBugMessageSet).createMessage(
+    ...     subject="foo", bug=bug, owner=foo_bar, content="bar")
+    >>> notify(ObjectCreatedEvent(comment))
+    Karma added: action=bugcommentadded, product=evolution
+    Karma added: action=bugcommentadded, product=evolution
+    Karma added: action=bugcommentadded, product=ufo
+    Karma added: action=bugcommentadded, distribution=debian
+    Karma added: action=bugcommentadded, distribution=debian
+
+Now, let's check that we've covered all of Launchpad's bug-related karma
+actions, except for updating the obsolete "summary", "priority", and "Web
+links":
+
+    >>> from lp.registry.model.karma import KarmaCategory
+    >>> bugs_category = KarmaCategory.byName('bugs')
+    >>> bugs_karma_actions = bugs_category.karmaactions
+    >>> summary_change = getUtility(
+    ...     IKarmaActionSet).getByName('bugsummarychanged')
+    >>> karma_helper.added_karma_actions.add(summary_change)
+    >>> priority_change = getUtility(
+    ...     IKarmaActionSet).getByName('bugtaskprioritychanged')
+    >>> karma_helper.added_karma_actions.add(priority_change)
+    >>> link_change = getUtility(
+    ...     IKarmaActionSet).getByName('bugextrefadded')
+    >>> karma_helper.added_karma_actions.add(link_change)
+    >>> karma_helper.added_karma_actions.issuperset(bugs_karma_actions)
+    True
+
+Unregister the event listener to make sure we won't interfere in other tests.
+
+    >>> karma_helper.unregister_listener()
+
+XXX Matthew Paul Thomas 2006-03-22: On 2007-03-23, a year after bug summaries
+were removed, all the karma gained from updating bug summaries will have
+expired. Then the 'bugsummarychanged' row should be removed from the database,
+and summary_change can be removed from this test. The same applies to the
+'bugtaskprioritychanged' row after about 2007-05-15, and the 'bugextrefadded'
+row after about 2008-09-25.
diff --git a/lib/lp/bugs/doc/malone-karma.txt b/lib/lp/bugs/doc/malone-karma.txt
deleted file mode 100644
index 06d7529..0000000
--- a/lib/lp/bugs/doc/malone-karma.txt
+++ /dev/null
@@ -1,248 +0,0 @@
-This file lists all the karma events that Malone produces. First let's
-import some stuff and define a function to help us make the
-documentation cleaner:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> from lp.registry.interfaces.karma import IKarmaActionSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@xxxxxxxxxxxxx')
-
-Setup an event listener to help ensure karma is assigned when it should.
-
-    >>> from zope.event import notify
-    >>> from lp.testing.karma import KarmaAssignedEventListener
-    >>> karma_helper = KarmaAssignedEventListener()
-    >>> karma_helper.register_listener()
-
-Foo Bar is the one that will get all the karma:
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-
-
-Karma Actions
--------------
-
-Create a bug:
-
-    >>> from lazr.lifecycle.event import ObjectCreatedEvent
-    >>> from lp.bugs.interfaces.bug import CreateBugParams
-    >>> debian = getUtility(IDistributionSet).getByName('debian')
-    >>> params = CreateBugParams(
-    ...     comment=u"Give me some karma!", title=u"New Bug", owner=foo_bar)
-    >>> bug = debian.createBug(params)
-    Karma added: action=bugcreated, distribution=debian
-
-Change the title of a bug:
-
-    >>> from lp.services.webapp.snapshot import notify_modified
-    >>> with notify_modified(bug, ['title']):
-    ...     bug.title = "Better Title"
-    Karma added: action=bugtitlechanged, distribution=debian
-
-Change the description of a bug:
-
-    >>> with notify_modified(bug, ['description']):
-    ...     bug.description = "Description of bug"
-    Karma added: action=bugdescriptionchanged, distribution=debian
-
-Add a CVE reference to a bug:
-
-    >>> from lp.bugs.interfaces.cve import CveStatus, ICveSet
-    >>> cve = getUtility(ICveSet).new('2003-1234', description="Blah blah",
-    ...     status=CveStatus.CANDIDATE)
-    >>> cve.linkBug(bug)
-    Karma added: action=bugcverefadded, distribution=debian
-    True
-
-Link a merge proposal to a bug:
-
-    >>> dsp = factory.makeDistributionSourcePackage(distribution=debian)
-    >>> merge_proposal = factory.makeBranchMergeProposalForGit(target=dsp)
-    Karma added: action=branchmergeproposed, distribution=debian
-    >>> bug.linkMergeProposal(merge_proposal, getUtility(ILaunchBag).user)
-    Karma added: action=bugbranchcreated, distribution=debian
-
-Add watch for external bug to the bug:
-
-    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
-    >>> from lp.bugs.model.bugwatch import BugWatch
-    >>> debbugs = getUtility(IBugTrackerSet)['debbugs']
-    >>> bugwatch = BugWatch(
-    ...     bug=bug,bugtracker=debbugs, remotebug=u'42', owner=foo_bar)
-    >>> notify(ObjectCreatedEvent(bugwatch))
-    Karma added: action=bugwatchadded, distribution=debian
-
-Mark a bug task as fixed:
-
-    >>> from lp.bugs.interfaces.bugtask import BugTaskStatus
-    >>> bugtask = bug.bugtasks[0]
-    >>> with notify_modified(bugtask, ['status']):
-    ...     bugtask.transitionToStatus(
-    ...         BugTaskStatus.FIXRELEASED, getUtility(ILaunchBag).user)
-    Karma added: action=bugfixed, distribution=debian
-
-Mark a bug task as fixed when it is assigned awards the karma to the assignee:
-
-    >>> ufo_product = factory.makeProduct(name='ufo')
-    >>> assignee = factory.makePerson(name='assignee')
-    >>> assigned_bugtask = factory.makeBugTask(bug=bug, target=ufo_product)
-    >>> assigned_bugtask.transitionToAssignee(assignee)
-    >>> with notify_modified(assigned_bugtask, ['status']):
-    ...     assigned_bugtask.transitionToStatus(
-    ...         BugTaskStatus.FIXRELEASED, getUtility(ILaunchBag).user)
-    Karma added: action=bugfixed, product=ufo
-
-    >>> for karma in assignee.latestKarma():
-    ...     print(karma.action.name)
-    bugfixed
-
-Reject a bug task:
-
-    >>> with notify_modified(bugtask, ['status']):
-    ...     bugtask.transitionToStatus(
-    ...         BugTaskStatus.INVALID, bugtask.target.owner)
-    Karma added: action=bugrejected, distribution=debian
-
-User accept a bug task:
-
-    >>> with notify_modified(bugtask, ['status']):
-    ...     bugtask.transitionToStatus(
-    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
-    Karma added: action=bugaccepted, distribution=debian
-
-Driver accept a bug task:
-
-    >>> ignored = login_person(bugtask.target.owner)
-    >>> with notify_modified(bugtask, ['status']):
-    ...     bugtask.transitionToStatus(
-    ...         BugTaskStatus.TRIAGED, getUtility(ILaunchBag).user)
-    Karma added: action=bugaccepted, distribution=debian
-
-    >>> login('admin@xxxxxxxxxxxxx')
-
-Change a bug task's importance:
-
-    >>> from lp.bugs.interfaces.bugtask import BugTaskImportance
-    >>> bugtask.transitionToImportance(
-    ...     BugTaskImportance.HIGH, getUtility(ILaunchBag).user)
-    >>> for importance in BugTaskImportance.items:
-    ...     with notify_modified(bugtask, ['importance']):
-    ...         bugtask.transitionToImportance(
-    ...             importance, getUtility(ILaunchBag).user)
-    ...         print(importance.name)
-    UNKNOWN
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    UNDECIDED
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    CRITICAL
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    HIGH
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    MEDIUM
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    LOW
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-    WISHLIST
-    Karma added: action=bugtaskimportancechanged, distribution=debian
-
-Create a new bug task on a product:
-
-    >>> from lp.bugs.interfaces.bugtask import IBugTaskSet
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> evolution = getUtility(IProductSet)['evolution']
-    >>> evolution_task = getUtility(IBugTaskSet).createTask(
-    ...     bug, foo_bar, evolution)
-    >>> notify(ObjectCreatedEvent(evolution_task))
-    Karma added: action=bugtaskcreated, product=evolution
-
-Create a new bug task on a product series:
-
-    >>> evolution_trunk = evolution.getSeries('trunk')
-    >>> evolution_trunk_task = getUtility(IBugTaskSet).createTask(
-    ...     bug, foo_bar, evolution_trunk)
-    >>> notify(ObjectCreatedEvent(evolution_trunk_task))
-    Karma added: action=bugtaskcreated, product=evolution
-
-Create a new bug task on a distroseries:
-
-    >>> debian_woody = debian.getSeries("woody")
-    >>> debian_woody_task = getUtility(IBugTaskSet).createTask(
-    ...     bug, foo_bar, debian_woody)
-    >>> notify(ObjectCreatedEvent(debian_woody_task))
-    Karma added: action=bugtaskcreated, distribution=debian
-
-Accept a distro series task.
-
-    >>> debian_woody_task.transitionToStatus(
-    ...     BugTaskStatus.NEW, getUtility(ILaunchBag).user)
-    >>> with notify_modified(debian_woody_task, ['status']):
-    ...     debian_woody_task.transitionToStatus(
-    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
-    Karma added: action=bugaccepted, distribution=debian
-
-Accept a productseries task.
-
-    >>> evolution_trunk_task.transitionToStatus(
-    ...     BugTaskStatus.NEW, getUtility(ILaunchBag).user)
-    >>> with notify_modified(evolution_trunk_task, ['status']):
-    ...     evolution_trunk_task.transitionToStatus(
-    ...         BugTaskStatus.CONFIRMED, getUtility(ILaunchBag).user)
-    Karma added: action=bugaccepted, product=evolution
-
-Mark a bug as a duplicate:
-
-(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
-be actual contributions to all bugtasks of that bug.)
-
-    >>> bug_one = getUtility(IBugSet).get(1)
-    >>> with notify_modified(bug, ['duplicateof']):
-    ...     bug.markAsDuplicate(bug_one)
-    Karma added: action=bugmarkedasduplicate, product=evolution
-    Karma added: action=bugmarkedasduplicate, product=evolution
-    Karma added: action=bugmarkedasduplicate, product=ufo
-    Karma added: action=bugmarkedasduplicate, distribution=debian
-    Karma added: action=bugmarkedasduplicate, distribution=debian
-
-Adding a comment generates a karma event, but gives no points:
-
-    >>> from lp.bugs.interfaces.bugmessage import IBugMessageSet
-    >>> comment = getUtility(IBugMessageSet).createMessage(
-    ...     subject="foo", bug=bug, owner=foo_bar, content="bar")
-    >>> notify(ObjectCreatedEvent(comment))
-    Karma added: action=bugcommentadded, product=evolution
-    Karma added: action=bugcommentadded, product=evolution
-    Karma added: action=bugcommentadded, product=ufo
-    Karma added: action=bugcommentadded, distribution=debian
-    Karma added: action=bugcommentadded, distribution=debian
-
-Now, let's check that we've covered all of Launchpad's bug-related karma
-actions, except for updating the obsolete "summary", "priority", and "Web
-links":
-
-    >>> from lp.registry.model.karma import KarmaCategory
-    >>> bugs_category = KarmaCategory.byName('bugs')
-    >>> bugs_karma_actions = bugs_category.karmaactions
-    >>> summary_change = getUtility(
-    ...     IKarmaActionSet).getByName('bugsummarychanged')
-    >>> karma_helper.added_karma_actions.add(summary_change)
-    >>> priority_change = getUtility(
-    ...     IKarmaActionSet).getByName('bugtaskprioritychanged')
-    >>> karma_helper.added_karma_actions.add(priority_change)
-    >>> link_change = getUtility(
-    ...     IKarmaActionSet).getByName('bugextrefadded')
-    >>> karma_helper.added_karma_actions.add(link_change)
-    >>> karma_helper.added_karma_actions.issuperset(bugs_karma_actions)
-    True
-
-Unregister the event listener to make sure we won't interfere in other tests.
-
-    >>> karma_helper.unregister_listener()
-
-XXX Matthew Paul Thomas 2006-03-22: On 2007-03-23, a year after bug summaries
-were removed, all the karma gained from updating bug summaries will have
-expired. Then the 'bugsummarychanged' row should be removed from the database,
-and summary_change can be removed from this test. The same applies to the
-'bugtaskprioritychanged' row after about 2007-05-15, and the 'bugextrefadded'
-row after about 2008-09-25.
diff --git a/lib/lp/bugs/doc/malone-xmlrpc.rst b/lib/lp/bugs/doc/malone-xmlrpc.rst
new file mode 100644
index 0000000..4b5ee05
--- /dev/null
+++ b/lib/lp/bugs/doc/malone-xmlrpc.rst
@@ -0,0 +1,291 @@
+XML-RPC Integration with Malone
+===============================
+
+Malone provides an XML-RPC interface for filing bugs.
+
+    >>> import xmlrpc.client
+    >>> from lp.testing.xmlrpc import XMLRPCTestTransport
+    >>> filebug_api = xmlrpc.client.ServerProxy(
+    ...     'http://test@xxxxxxxxxxxxx:test@xxxxxxxxxxxxxxxxxxxxx/bugs/',
+    ...     transport=XMLRPCTestTransport())
+
+
+The filebug API
+---------------
+
+The filebug API is:
+
+    filebug_api.filebug(params)
+
+params is a dict, with the following keys:
+
+REQUIRED ARGS: summary: A string
+               comment: A string
+
+OPTIONAL ARGS: product: The product name, as a string. Default None.
+               distro: The distro name, as a string. Default None.
+               package: A string, allowed only if distro is specified.
+                        Default None.
+               security_related: Is this a security vulnerability?
+                                 Default False.
+               subscribers: A list of email addresses. Default None.
+
+Either product or distro must be provided.
+
+The bug owner is the currently authenticated user, taken from the
+request.
+
+The return value is the bug URL, in short form, e.g.:
+
+    http://launchpad.net/bugs/42
+
+Support for attachments will be added in the near future.
+
+
+Examples
+--------
+
+First, let's define a simple event listener to show that the
+IObjectCreatedEvent is being published when a bug is reported through
+the XML-RPC interface.
+
+    >>> from lazr.lifecycle.interfaces import IObjectCreatedEvent
+    >>> from lp.bugs.interfaces.bug import IBug
+    >>> from lp.testing.fixture import ZopeEventHandlerFixture
+
+    >>> def on_created_event(obj, event):
+    ...     print("ObjectCreatedEvent: %r" % obj)
+
+    >>> on_created_listener = ZopeEventHandlerFixture(
+    ...     on_created_event, (IBug, IObjectCreatedEvent))
+    >>> on_created_listener.setUp()
+
+Reporting a product bug.
+
+(We'll define a simple function to extract the bug ID from the URL
+return value.)
+
+    >>> def get_bug_id_from_url(url):
+    ...     return int(url.split("/")[-1])
+
+    >>> params = dict(
+    ...     product='firefox', summary='the summary', comment='the comment')
+    >>> bug_url = filebug_api.filebug(params)
+    ObjectCreatedEvent: <Bug ...>
+    >>> print(bug_url)
+    http://bugs.launchpad.test/bugs/...
+
+    >>> from zope.component import getUtility
+    >>> from lp.bugs.interfaces.bug import IBugSet
+
+    >>> bugset = getUtility(IBugSet)
+    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
+
+    >>> print(bug.title)
+    the summary
+    >>> print(bug.description)
+    the comment
+    >>> print(bug.owner.name)
+    name12
+
+    >>> firefox_bug = bug.bugtasks[0]
+
+    >>> print(firefox_bug.product.name)
+    firefox
+
+Reporting a distro bug.
+
+    >>> params = dict(
+    ...     distro='ubuntu', summary='another bug', comment='another comment')
+    >>> bug_url = filebug_api.filebug(params)
+    ObjectCreatedEvent: <Bug ...>
+    >>> print(bug_url)
+    http://bugs.launchpad.test/bugs/...
+
+    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
+
+    >>> print(bug.title)
+    another bug
+    >>> print(bug.description)
+    another comment
+    >>> print(bug.owner.name)
+    name12
+
+    >>> ubuntu_bug = bug.bugtasks[0]
+
+    >>> print(ubuntu_bug.distribution.name)
+    ubuntu
+    >>> ubuntu_bug.sourcepackagename is None
+    True
+
+Reporting a package bug.
+
+    >>> params = dict(
+    ...     distro='ubuntu', package='evolution', summary='email is cool',
+    ...     comment='email is nice', security_related=True,
+    ...     subscribers=["no-priv@xxxxxxxxxxxxx"])
+    >>> bug_url = filebug_api.filebug(params)
+    ObjectCreatedEvent: <Bug ...>
+    >>> print(bug_url)
+    http://bugs.launchpad.test/bugs/...
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
+
+    >>> print(bug.title)
+    email is cool
+    >>> print(bug.description)
+    email is nice
+    >>> bug.security_related
+    True
+    >>> bug.private
+    True
+    >>> for name in sorted(p.name for p in bug.getDirectSubscribers()):
+    ...     print(name)
+    name12
+    no-priv
+    >>> bug.getIndirectSubscribers()
+    []
+
+    >>> evolution_bug = bug.bugtasks[0]
+
+    >>> print(evolution_bug.distribution.name)
+    ubuntu
+    >>> print(evolution_bug.sourcepackagename.name)
+    evolution
+
+
+Error Handling
+--------------
+
+Malone's xmlrpc interface provides extensive error handling. The various
+error conditions it recognizes are:
+
+Failing to specify a product or distribution.
+
+    >>> params = dict()
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 60: 'Required arguments missing. You must
+    specify either a product or distribution in which the bug exists.'>
+
+Specifying *both* a product and distribution.
+
+    >>> params = dict(product='firefox', distro='ubuntu')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 70: 'Too many arguments. You may specify
+    either a product or a distribution, but not both.'>
+
+Specifying a non-existent product.
+
+    >>> params = dict(product='nosuchproduct')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 10: 'No such project: nosuchproduct'>
+
+Specifying a non-existent distribution.
+
+    >>> params = dict(distro='nosuchdistro')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 80: 'No such distribution: nosuchdistro'>
+
+Specifying a non-existent package.
+
+    >>> params = dict(distro='ubuntu', package='nosuchpackage')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 90: 'No such package: nosuchpackage'>
+
+Missing summary.
+
+    >>> params = dict(product='firefox')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 100: 'Required parameter missing: summary'>
+
+Missing comment.
+
+    >>> params = dict(product='firefox', summary='the summary')
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 100: 'Required parameter missing: comment'>
+
+Invalid subscriber.
+
+    >>> params = dict(
+    ...     product='firefox', summary='summary', comment='comment',
+    ...     subscribers=["foo.bar@xxxxxxxxxxxxx", "nosuch@xxxxxxxxxxxxxx"])
+    >>> filebug_api.filebug(params)
+    Traceback (most recent call last):
+    ...
+    xmlrpc.client.Fault: <Fault 20: 'Invalid subscriber: No user with the
+    email address "nosuch@xxxxxxxxxxxxxx" was found'>
+
+    >>> on_created_listener.cleanUp()
+
+
+Generating bugtracker authentication tokens
+-------------------------------------------
+
+Launchpad Bugs also provides an XML-RPC API for generating login tokens
+for authentication with external bug trackers.
+
+    >>> from zope.component import getUtility
+    >>> from lp.xmlrpc.interfaces import IPrivateApplication
+    >>> from lp.bugs.interfaces.malone import IPrivateMaloneApplication
+    >>> from lp.testing import verifyObject
+
+    >>> private_root = getUtility(IPrivateApplication)
+    >>> verifyObject(IPrivateMaloneApplication,
+    ...     private_root.bugs)
+    True
+
+The API provides a single method, newBugTrackerToken(), which returns
+the ID of the new LoginToken.
+
+    >>> from lp.services.verification.interfaces.logintoken import (
+    ...     ILoginTokenSet)
+    >>> from lp.services.webapp.servers import LaunchpadTestRequest
+    >>> from lp.bugs.interfaces.externalbugtracker import (
+    ...     IExternalBugTrackerTokenAPI)
+    >>> from lp.bugs.xmlrpc.bug import (
+    ...     ExternalBugTrackerTokenAPI)
+
+    >>> bugtracker_token_api = ExternalBugTrackerTokenAPI(
+    ...     private_root.bugs, LaunchpadTestRequest())
+
+    >>> verifyObject(IExternalBugTrackerTokenAPI, bugtracker_token_api)
+    True
+
+    >>> token_string = bugtracker_token_api.newBugTrackerToken()
+    >>> token = getUtility(ILoginTokenSet)[token_string]
+    >>> token
+    <LoginToken at ...>
+
+The LoginToken generated will be of the LoginTokenType BUGTRACKER.
+
+    >>> print(token.tokentype.title)
+    Launchpad is authenticating itself with a remote bug tracker.
+
+These requests are all handled by the private xml-rpc server.
+
+    >>> bugtracker_api = xmlrpc.client.ServerProxy(
+    ...     'http://xmlrpc-private.launchpad.test:8087/bugs',
+    ...     transport=XMLRPCTestTransport())
+
+    >>> token_string = bugtracker_api.newBugTrackerToken()
+    >>> token = getUtility(ILoginTokenSet)[token_string]
+    >>> token
+    <LoginToken at ...>
+
+    >>> print(token.tokentype.title)
+    Launchpad is authenticating itself with a remote bug tracker.
diff --git a/lib/lp/bugs/doc/malone-xmlrpc.txt b/lib/lp/bugs/doc/malone-xmlrpc.txt
deleted file mode 100644
index 4b5ee05..0000000
--- a/lib/lp/bugs/doc/malone-xmlrpc.txt
+++ /dev/null
@@ -1,291 +0,0 @@
-XML-RPC Integration with Malone
-===============================
-
-Malone provides an XML-RPC interface for filing bugs.
-
-    >>> import xmlrpc.client
-    >>> from lp.testing.xmlrpc import XMLRPCTestTransport
-    >>> filebug_api = xmlrpc.client.ServerProxy(
-    ...     'http://test@xxxxxxxxxxxxx:test@xxxxxxxxxxxxxxxxxxxxx/bugs/',
-    ...     transport=XMLRPCTestTransport())
-
-
-The filebug API
----------------
-
-The filebug API is:
-
-    filebug_api.filebug(params)
-
-params is a dict, with the following keys:
-
-REQUIRED ARGS: summary: A string
-               comment: A string
-
-OPTIONAL ARGS: product: The product name, as a string. Default None.
-               distro: The distro name, as a string. Default None.
-               package: A string, allowed only if distro is specified.
-                        Default None.
-               security_related: Is this a security vulnerability?
-                                 Default False.
-               subscribers: A list of email addresses. Default None.
-
-Either product or distro must be provided.
-
-The bug owner is the currently authenticated user, taken from the
-request.
-
-The return value is the bug URL, in short form, e.g.:
-
-    http://launchpad.net/bugs/42
-
-Support for attachments will be added in the near future.
-
-
-Examples
---------
-
-First, let's define a simple event listener to show that the
-IObjectCreatedEvent is being published when a bug is reported through
-the XML-RPC interface.
-
-    >>> from lazr.lifecycle.interfaces import IObjectCreatedEvent
-    >>> from lp.bugs.interfaces.bug import IBug
-    >>> from lp.testing.fixture import ZopeEventHandlerFixture
-
-    >>> def on_created_event(obj, event):
-    ...     print("ObjectCreatedEvent: %r" % obj)
-
-    >>> on_created_listener = ZopeEventHandlerFixture(
-    ...     on_created_event, (IBug, IObjectCreatedEvent))
-    >>> on_created_listener.setUp()
-
-Reporting a product bug.
-
-(We'll define a simple function to extract the bug ID from the URL
-return value.)
-
-    >>> def get_bug_id_from_url(url):
-    ...     return int(url.split("/")[-1])
-
-    >>> params = dict(
-    ...     product='firefox', summary='the summary', comment='the comment')
-    >>> bug_url = filebug_api.filebug(params)
-    ObjectCreatedEvent: <Bug ...>
-    >>> print(bug_url)
-    http://bugs.launchpad.test/bugs/...
-
-    >>> from zope.component import getUtility
-    >>> from lp.bugs.interfaces.bug import IBugSet
-
-    >>> bugset = getUtility(IBugSet)
-    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
-
-    >>> print(bug.title)
-    the summary
-    >>> print(bug.description)
-    the comment
-    >>> print(bug.owner.name)
-    name12
-
-    >>> firefox_bug = bug.bugtasks[0]
-
-    >>> print(firefox_bug.product.name)
-    firefox
-
-Reporting a distro bug.
-
-    >>> params = dict(
-    ...     distro='ubuntu', summary='another bug', comment='another comment')
-    >>> bug_url = filebug_api.filebug(params)
-    ObjectCreatedEvent: <Bug ...>
-    >>> print(bug_url)
-    http://bugs.launchpad.test/bugs/...
-
-    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
-
-    >>> print(bug.title)
-    another bug
-    >>> print(bug.description)
-    another comment
-    >>> print(bug.owner.name)
-    name12
-
-    >>> ubuntu_bug = bug.bugtasks[0]
-
-    >>> print(ubuntu_bug.distribution.name)
-    ubuntu
-    >>> ubuntu_bug.sourcepackagename is None
-    True
-
-Reporting a package bug.
-
-    >>> params = dict(
-    ...     distro='ubuntu', package='evolution', summary='email is cool',
-    ...     comment='email is nice', security_related=True,
-    ...     subscribers=["no-priv@xxxxxxxxxxxxx"])
-    >>> bug_url = filebug_api.filebug(params)
-    ObjectCreatedEvent: <Bug ...>
-    >>> print(bug_url)
-    http://bugs.launchpad.test/bugs/...
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> bug = bugset.get(get_bug_id_from_url(bug_url))
-
-    >>> print(bug.title)
-    email is cool
-    >>> print(bug.description)
-    email is nice
-    >>> bug.security_related
-    True
-    >>> bug.private
-    True
-    >>> for name in sorted(p.name for p in bug.getDirectSubscribers()):
-    ...     print(name)
-    name12
-    no-priv
-    >>> bug.getIndirectSubscribers()
-    []
-
-    >>> evolution_bug = bug.bugtasks[0]
-
-    >>> print(evolution_bug.distribution.name)
-    ubuntu
-    >>> print(evolution_bug.sourcepackagename.name)
-    evolution
-
-
-Error Handling
---------------
-
-Malone's xmlrpc interface provides extensive error handling. The various
-error conditions it recognizes are:
-
-Failing to specify a product or distribution.
-
-    >>> params = dict()
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 60: 'Required arguments missing. You must
-    specify either a product or distribution in which the bug exists.'>
-
-Specifying *both* a product and distribution.
-
-    >>> params = dict(product='firefox', distro='ubuntu')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 70: 'Too many arguments. You may specify
-    either a product or a distribution, but not both.'>
-
-Specifying a non-existent product.
-
-    >>> params = dict(product='nosuchproduct')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 10: 'No such project: nosuchproduct'>
-
-Specifying a non-existent distribution.
-
-    >>> params = dict(distro='nosuchdistro')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 80: 'No such distribution: nosuchdistro'>
-
-Specifying a non-existent package.
-
-    >>> params = dict(distro='ubuntu', package='nosuchpackage')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 90: 'No such package: nosuchpackage'>
-
-Missing summary.
-
-    >>> params = dict(product='firefox')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 100: 'Required parameter missing: summary'>
-
-Missing comment.
-
-    >>> params = dict(product='firefox', summary='the summary')
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 100: 'Required parameter missing: comment'>
-
-Invalid subscriber.
-
-    >>> params = dict(
-    ...     product='firefox', summary='summary', comment='comment',
-    ...     subscribers=["foo.bar@xxxxxxxxxxxxx", "nosuch@xxxxxxxxxxxxxx"])
-    >>> filebug_api.filebug(params)
-    Traceback (most recent call last):
-    ...
-    xmlrpc.client.Fault: <Fault 20: 'Invalid subscriber: No user with the
-    email address "nosuch@xxxxxxxxxxxxxx" was found'>
-
-    >>> on_created_listener.cleanUp()
-
-
-Generating bugtracker authentication tokens
--------------------------------------------
-
-Launchpad Bugs also provides an XML-RPC API for generating login tokens
-for authentication with external bug trackers.
-
-    >>> from zope.component import getUtility
-    >>> from lp.xmlrpc.interfaces import IPrivateApplication
-    >>> from lp.bugs.interfaces.malone import IPrivateMaloneApplication
-    >>> from lp.testing import verifyObject
-
-    >>> private_root = getUtility(IPrivateApplication)
-    >>> verifyObject(IPrivateMaloneApplication,
-    ...     private_root.bugs)
-    True
-
-The API provides a single method, newBugTrackerToken(), which returns
-the ID of the new LoginToken.
-
-    >>> from lp.services.verification.interfaces.logintoken import (
-    ...     ILoginTokenSet)
-    >>> from lp.services.webapp.servers import LaunchpadTestRequest
-    >>> from lp.bugs.interfaces.externalbugtracker import (
-    ...     IExternalBugTrackerTokenAPI)
-    >>> from lp.bugs.xmlrpc.bug import (
-    ...     ExternalBugTrackerTokenAPI)
-
-    >>> bugtracker_token_api = ExternalBugTrackerTokenAPI(
-    ...     private_root.bugs, LaunchpadTestRequest())
-
-    >>> verifyObject(IExternalBugTrackerTokenAPI, bugtracker_token_api)
-    True
-
-    >>> token_string = bugtracker_token_api.newBugTrackerToken()
-    >>> token = getUtility(ILoginTokenSet)[token_string]
-    >>> token
-    <LoginToken at ...>
-
-The LoginToken generated will be of the LoginTokenType BUGTRACKER.
-
-    >>> print(token.tokentype.title)
-    Launchpad is authenticating itself with a remote bug tracker.
-
-These requests are all handled by the private xml-rpc server.
-
-    >>> bugtracker_api = xmlrpc.client.ServerProxy(
-    ...     'http://xmlrpc-private.launchpad.test:8087/bugs',
-    ...     transport=XMLRPCTestTransport())
-
-    >>> token_string = bugtracker_api.newBugTrackerToken()
-    >>> token = getUtility(ILoginTokenSet)[token_string]
-    >>> token
-    <LoginToken at ...>
-
-    >>> print(token.tokentype.title)
-    Launchpad is authenticating itself with a remote bug tracker.
diff --git a/lib/lp/bugs/doc/new-line-to-spaces-widget.txt b/lib/lp/bugs/doc/new-line-to-spaces-widget.rst
similarity index 100%
rename from lib/lp/bugs/doc/new-line-to-spaces-widget.txt
rename to lib/lp/bugs/doc/new-line-to-spaces-widget.rst
diff --git a/lib/lp/bugs/doc/official-bug-tags.rst b/lib/lp/bugs/doc/official-bug-tags.rst
new file mode 100644
index 0000000..047a768
--- /dev/null
+++ b/lib/lp/bugs/doc/official-bug-tags.rst
@@ -0,0 +1,246 @@
+Official Bug Tags
+=================
+
+Distributions and products can define official bug tags.
+
+    >>> from zope.component import getUtility
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> from lp.services.database.interfaces import IStore
+    >>> from lp.bugs.model.bugtarget import OfficialBugTag
+    >>> store = IStore(OfficialBugTag)
+
+    >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
+    >>> distro_tag = OfficialBugTag()
+    >>> distro_tag.tag = u'PCI'
+    >>> distro_tag.target = ubuntu
+    >>> store.add(distro_tag)
+    <lp.bugs.model.bugtarget.OfficialBugTag object at...
+
+    >>> firefox = getUtility(IProductSet).getByName('firefox')
+    >>> product_tag = OfficialBugTag()
+    >>> product_tag.tag = u'bar'
+    >>> product_tag.target = firefox
+    >>> store.add(product_tag)
+    <lp.bugs.model.bugtarget.OfficialBugTag object at...
+
+We can add the same bug tag for different products and distributions.
+
+    >>> distro_tag2 = OfficialBugTag()
+    >>> distro_tag2.tag = u'foo'
+    >>> distro_tag2.distribution = ubuntu
+    >>> store.add(distro_tag2)
+    <lp.bugs.model.bugtarget.OfficialBugTag object at...
+    >>> store.flush()
+
+But bug tags must be unique for each product and distribution.
+
+    >>> distro_tag3 = OfficialBugTag()
+    >>> distro_tag3.tag = u'PCI'
+    >>> distro_tag3.distribution = ubuntu
+    >>> store.add(distro_tag3)
+    <lp.bugs.model.bugtarget.OfficialBugTag object at...
+    >>> store.flush()
+    Traceback (most recent call last):
+    storm.database.UniqueViolation: ...
+
+    >>> import transaction
+    >>> transaction.abort()
+
+
+Targets of official bug tags
+----------------------------
+
+Distribution owners and other persons with the permission launchpad.Edit
+can add and remove offical bug tags by calling addOfficialBugTag()
+or removeOfficialBugTag(), respectively.
+
+    >>> login('colin.watson@xxxxxxxxxxxxxxx')
+    >>> ubuntu.addOfficialBugTag(u'foo')
+    >>> ubuntu.addOfficialBugTag(u'bar')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
+    >>> result_set = result_set.order_by(OfficialBugTag.tag)
+    >>> for tag in result_set:
+    ...     print(tag.tag)
+    bar
+    foo
+
+    >>> ubuntu.removeOfficialBugTag(u'foo')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
+    >>> for tag in result_set:
+    ...     print(tag.tag)
+    bar
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> firefox.addOfficialBugTag(u'foo')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.product==firefox)
+    >>> for tag in result_set:
+    ...     print(tag.tag)
+    foo
+
+    >>> firefox.removeOfficialBugTag(u'foo')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.product==firefox)
+    >>> print(result_set.count())
+    0
+
+    >>> transaction.commit()
+
+The attempt to add an existing tag a second time succeeds but does not
+change the data.
+
+    >>> login('colin.watson@xxxxxxxxxxxxxxx')
+    >>> ubuntu.addOfficialBugTag(u'bar')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
+    >>> result_set = result_set.order_by(OfficialBugTag.tag)
+    >>> for tag in result_set:
+    ...     print(tag.tag)
+    bar
+
+Similary, deleting an not-existent tag does not lead to an error, but
+does not change the data either.
+
+    >>> ubuntu.removeOfficialBugTag(u'foo')
+    >>> result_set = store.find(
+    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
+    >>> for tag in result_set:
+    ...     print(tag.tag)
+    bar
+
+Ordinary users cannot add and remove official bug tags.
+
+    >>> login('no-priv@xxxxxxxxxxxxx')
+    >>> ubuntu.addOfficialBugTag(u'foo')
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized:
+    (<Distribution 'Ubuntu' (ubuntu)>, 'addOfficialBugTag', 'launchpad.Edit')
+
+    >>> ubuntu.removeOfficialBugTag(u'foo')
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized:
+    (<Distribution 'Ubuntu' (ubuntu)>, 'removeOfficialBugTag',
+     'launchpad.Edit')
+
+    >>> firefox.addOfficialBugTag(u'foo')
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized:
+    (<Product at ...>, 'addOfficialBugTag', 'launchpad.Edit')
+
+    >>> firefox.removeOfficialBugTag(u'foo')
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized:
+    (<Product at ...>, 'removeOfficialBugTag', 'launchpad.Edit')
+
+Official tags are accessible as a list property of official tag targets.
+
+    >>> for tag in ubuntu.official_bug_tags:
+    ...     print(tag)
+    bar
+
+To set the list, the user must have edit permissions for the target.
+
+    >>> login('colin.watson@xxxxxxxxxxxxxxx')
+
+Setting the list creates any new tags appearing in the list.
+
+    >>> ubuntu.official_bug_tags = [u'foo', u'bar']
+    >>> for tag in ubuntu.official_bug_tags:
+    ...     print(tag)
+    bar
+    foo
+
+Any existing tags missing from the list are removed.
+
+    >>> ubuntu.official_bug_tags = [u'foo']
+    >>> for tag in ubuntu.official_bug_tags:
+    ...     print(tag)
+    foo
+
+The list is publicly readable.
+
+    >>> login(ANONYMOUS)
+    >>> for tag in ubuntu.official_bug_tags:
+    ...     print(tag)
+    foo
+
+But only writable for users with edit permissions.
+
+    >>> login('no-priv@xxxxxxxxxxxxx')
+    >>> ubuntu.official_bug_tags = [u'foo', u'bar']
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized:
+    (<Distribution 'Ubuntu' (ubuntu)>, 'official_bug_tags',
+     'launchpad.BugSupervisor')
+
+The same is available for products.
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> firefox.official_bug_tags = [u'foo', u'bar']
+    >>> login(ANONYMOUS)
+    >>> for tag in firefox.official_bug_tags:
+    ...     print(tag)
+    bar
+    foo
+
+
+Official tags for additional bug targets
+----------------------------------------
+
+All IHasBugs implementations provide an official_bug_tags property. They are
+taken from the relevant distribution or product.
+
+Distribution series and distribution source package get the official tags of
+their parent distribution.
+
+    >>> for tag in ubuntu.getSeries('hoary').official_bug_tags:
+    ...     print(tag)
+    foo
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> for tag in ubuntu.getSeries(
+    ...         'hoary').getSourcePackage('alsa-utils').official_bug_tags:
+    ...     print(tag)
+    foo
+    >>> login(ANONYMOUS)
+
+    >>> for tag in ubuntu.getSourcePackage('alsa-utils').official_bug_tags:
+    ...     print(tag)
+    foo
+
+Product series gets the tags of the parent product.
+
+    >>> for tag in firefox.getSeries('1.0').official_bug_tags:
+    ...     print(tag)
+    bar
+    foo
+
+Project group gets the union of all the tags available for its products.
+
+    >>> login('test@xxxxxxxxxxxxx')
+    >>> from lp.registry.interfaces.projectgroup import IProjectGroupSet
+    >>> thunderbird = getUtility(IProductSet).getByName('thunderbird')
+    >>> thunderbird.official_bug_tags = [u'baz']
+    >>> login('no-priv@xxxxxxxxxxxxx')
+    >>> mozilla = getUtility(IProjectGroupSet).getByName('mozilla')
+    >>> for tag in mozilla.official_bug_tags:
+    ...     print(tag)
+    bar
+    baz
+    foo
+    >>> login(ANONYMOUS)
+
+Milestone gets the tags of the relevant product.
+
+    >>> for tag in firefox.getMilestone('1.0').official_bug_tags:
+    ...     print(tag)
+    bar
+    foo
diff --git a/lib/lp/bugs/doc/official-bug-tags.txt b/lib/lp/bugs/doc/official-bug-tags.txt
deleted file mode 100644
index 047a768..0000000
--- a/lib/lp/bugs/doc/official-bug-tags.txt
+++ /dev/null
@@ -1,246 +0,0 @@
-Official Bug Tags
-=================
-
-Distributions and products can define official bug tags.
-
-    >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> from lp.services.database.interfaces import IStore
-    >>> from lp.bugs.model.bugtarget import OfficialBugTag
-    >>> store = IStore(OfficialBugTag)
-
-    >>> ubuntu = getUtility(IDistributionSet).getByName('ubuntu')
-    >>> distro_tag = OfficialBugTag()
-    >>> distro_tag.tag = u'PCI'
-    >>> distro_tag.target = ubuntu
-    >>> store.add(distro_tag)
-    <lp.bugs.model.bugtarget.OfficialBugTag object at...
-
-    >>> firefox = getUtility(IProductSet).getByName('firefox')
-    >>> product_tag = OfficialBugTag()
-    >>> product_tag.tag = u'bar'
-    >>> product_tag.target = firefox
-    >>> store.add(product_tag)
-    <lp.bugs.model.bugtarget.OfficialBugTag object at...
-
-We can add the same bug tag for different products and distributions.
-
-    >>> distro_tag2 = OfficialBugTag()
-    >>> distro_tag2.tag = u'foo'
-    >>> distro_tag2.distribution = ubuntu
-    >>> store.add(distro_tag2)
-    <lp.bugs.model.bugtarget.OfficialBugTag object at...
-    >>> store.flush()
-
-But bug tags must be unique for each product and distribution.
-
-    >>> distro_tag3 = OfficialBugTag()
-    >>> distro_tag3.tag = u'PCI'
-    >>> distro_tag3.distribution = ubuntu
-    >>> store.add(distro_tag3)
-    <lp.bugs.model.bugtarget.OfficialBugTag object at...
-    >>> store.flush()
-    Traceback (most recent call last):
-    storm.database.UniqueViolation: ...
-
-    >>> import transaction
-    >>> transaction.abort()
-
-
-Targets of official bug tags
-----------------------------
-
-Distribution owners and other persons with the permission launchpad.Edit
-can add and remove offical bug tags by calling addOfficialBugTag()
-or removeOfficialBugTag(), respectively.
-
-    >>> login('colin.watson@xxxxxxxxxxxxxxx')
-    >>> ubuntu.addOfficialBugTag(u'foo')
-    >>> ubuntu.addOfficialBugTag(u'bar')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
-    >>> result_set = result_set.order_by(OfficialBugTag.tag)
-    >>> for tag in result_set:
-    ...     print(tag.tag)
-    bar
-    foo
-
-    >>> ubuntu.removeOfficialBugTag(u'foo')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
-    >>> for tag in result_set:
-    ...     print(tag.tag)
-    bar
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> firefox.addOfficialBugTag(u'foo')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.product==firefox)
-    >>> for tag in result_set:
-    ...     print(tag.tag)
-    foo
-
-    >>> firefox.removeOfficialBugTag(u'foo')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.product==firefox)
-    >>> print(result_set.count())
-    0
-
-    >>> transaction.commit()
-
-The attempt to add an existing tag a second time succeeds but does not
-change the data.
-
-    >>> login('colin.watson@xxxxxxxxxxxxxxx')
-    >>> ubuntu.addOfficialBugTag(u'bar')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
-    >>> result_set = result_set.order_by(OfficialBugTag.tag)
-    >>> for tag in result_set:
-    ...     print(tag.tag)
-    bar
-
-Similary, deleting an not-existent tag does not lead to an error, but
-does not change the data either.
-
-    >>> ubuntu.removeOfficialBugTag(u'foo')
-    >>> result_set = store.find(
-    ...     OfficialBugTag, OfficialBugTag.distribution==ubuntu)
-    >>> for tag in result_set:
-    ...     print(tag.tag)
-    bar
-
-Ordinary users cannot add and remove official bug tags.
-
-    >>> login('no-priv@xxxxxxxxxxxxx')
-    >>> ubuntu.addOfficialBugTag(u'foo')
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized:
-    (<Distribution 'Ubuntu' (ubuntu)>, 'addOfficialBugTag', 'launchpad.Edit')
-
-    >>> ubuntu.removeOfficialBugTag(u'foo')
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized:
-    (<Distribution 'Ubuntu' (ubuntu)>, 'removeOfficialBugTag',
-     'launchpad.Edit')
-
-    >>> firefox.addOfficialBugTag(u'foo')
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized:
-    (<Product at ...>, 'addOfficialBugTag', 'launchpad.Edit')
-
-    >>> firefox.removeOfficialBugTag(u'foo')
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized:
-    (<Product at ...>, 'removeOfficialBugTag', 'launchpad.Edit')
-
-Official tags are accessible as a list property of official tag targets.
-
-    >>> for tag in ubuntu.official_bug_tags:
-    ...     print(tag)
-    bar
-
-To set the list, the user must have edit permissions for the target.
-
-    >>> login('colin.watson@xxxxxxxxxxxxxxx')
-
-Setting the list creates any new tags appearing in the list.
-
-    >>> ubuntu.official_bug_tags = [u'foo', u'bar']
-    >>> for tag in ubuntu.official_bug_tags:
-    ...     print(tag)
-    bar
-    foo
-
-Any existing tags missing from the list are removed.
-
-    >>> ubuntu.official_bug_tags = [u'foo']
-    >>> for tag in ubuntu.official_bug_tags:
-    ...     print(tag)
-    foo
-
-The list is publicly readable.
-
-    >>> login(ANONYMOUS)
-    >>> for tag in ubuntu.official_bug_tags:
-    ...     print(tag)
-    foo
-
-But only writable for users with edit permissions.
-
-    >>> login('no-priv@xxxxxxxxxxxxx')
-    >>> ubuntu.official_bug_tags = [u'foo', u'bar']
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized:
-    (<Distribution 'Ubuntu' (ubuntu)>, 'official_bug_tags',
-     'launchpad.BugSupervisor')
-
-The same is available for products.
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> firefox.official_bug_tags = [u'foo', u'bar']
-    >>> login(ANONYMOUS)
-    >>> for tag in firefox.official_bug_tags:
-    ...     print(tag)
-    bar
-    foo
-
-
-Official tags for additional bug targets
-----------------------------------------
-
-All IHasBugs implementations provide an official_bug_tags property. They are
-taken from the relevant distribution or product.
-
-Distribution series and distribution source package get the official tags of
-their parent distribution.
-
-    >>> for tag in ubuntu.getSeries('hoary').official_bug_tags:
-    ...     print(tag)
-    foo
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> for tag in ubuntu.getSeries(
-    ...         'hoary').getSourcePackage('alsa-utils').official_bug_tags:
-    ...     print(tag)
-    foo
-    >>> login(ANONYMOUS)
-
-    >>> for tag in ubuntu.getSourcePackage('alsa-utils').official_bug_tags:
-    ...     print(tag)
-    foo
-
-Product series gets the tags of the parent product.
-
-    >>> for tag in firefox.getSeries('1.0').official_bug_tags:
-    ...     print(tag)
-    bar
-    foo
-
-Project group gets the union of all the tags available for its products.
-
-    >>> login('test@xxxxxxxxxxxxx')
-    >>> from lp.registry.interfaces.projectgroup import IProjectGroupSet
-    >>> thunderbird = getUtility(IProductSet).getByName('thunderbird')
-    >>> thunderbird.official_bug_tags = [u'baz']
-    >>> login('no-priv@xxxxxxxxxxxxx')
-    >>> mozilla = getUtility(IProjectGroupSet).getByName('mozilla')
-    >>> for tag in mozilla.official_bug_tags:
-    ...     print(tag)
-    bar
-    baz
-    foo
-    >>> login(ANONYMOUS)
-
-Milestone gets the tags of the relevant product.
-
-    >>> for tag in firefox.getMilestone('1.0').official_bug_tags:
-    ...     print(tag)
-    bar
-    foo
diff --git a/lib/lp/bugs/doc/product-update-remote-product-script.txt b/lib/lp/bugs/doc/product-update-remote-product-script.rst
similarity index 100%
rename from lib/lp/bugs/doc/product-update-remote-product-script.txt
rename to lib/lp/bugs/doc/product-update-remote-product-script.rst
diff --git a/lib/lp/bugs/doc/product-update-remote-product.rst b/lib/lp/bugs/doc/product-update-remote-product.rst
new file mode 100644
index 0000000..7372652
--- /dev/null
+++ b/lib/lp/bugs/doc/product-update-remote-product.rst
@@ -0,0 +1,267 @@
+Updating Product.remote_product
+===============================
+
+The remote_product attribute of a Product is used to present links for
+filing and searching bugs in the Product's bug tracker, in case it's not
+using Launchpad to track its bugs. We don't expect users to set the
+remote_product themselves, so we have a script that tries to set this
+automatically.
+
+    >>> from lp.registry.model.product import Product
+    >>> from lp.services.database.interfaces import IStore
+    >>> store = IStore(Product)
+    >>> store.execute("UPDATE Product SET remote_product = 'not-None'")
+    <storm...>
+
+    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
+    >>> from lp.services.log.logger import FakeLogger, BufferLogger
+    >>> from lp.bugs.scripts.updateremoteproduct import (
+    ...     RemoteProductUpdater)
+    >>> from lp.testing.faketransaction import FakeTransaction
+    >>> updater = RemoteProductUpdater(FakeTransaction(), BufferLogger())
+
+
+Testing
+-------
+
+To help testing, there is a method, _getExternalBugTracker(), that
+creates the ExternalBugTracker for the given BugTracker.
+
+    >>> rt = factory.makeBugTracker(
+    ...     bugtrackertype=BugTrackerType.RT,
+    ...     base_url=u'http://rt.example.com/')
+    >>> rt_external = updater._getExternalBugTracker(rt)
+    >>> rt_external.__class__.__name__
+    'RequestTracker'
+    >>> print(rt_external.baseurl)
+    http://rt.example.com
+
+For testing, _getExternalBugTracker() can be overridden to return an
+ExternalBugTracker that doesn't require network access.
+
+    >>> class FakeExternalBugTracker:
+    ...
+    ...     def initializeRemoteBugDB(self, bug_ids):
+    ...         print("Initializing DB for bugs: [%s]." %
+    ...             ", ".join("'%s'" % bug_id for bug_id in bug_ids))
+    ...
+    ...     def getRemoteProduct(self, remote_bug):
+    ...         return 'product-for-bug-%s' % remote_bug
+
+
+    >>> class NoNetworkRemoteProductUpdater(RemoteProductUpdater):
+    ...
+    ...     external_bugtracker_to_return = FakeExternalBugTracker
+    ...
+    ...     def _getExternalBugTracker(self, bug_tracker):
+    ...         return self.external_bugtracker_to_return()
+
+
+update()
+--------
+
+The update method simply loops over all the bug tracker types that can
+track more than one product, and calls updateByBugTrackerType(). Any bug
+tracker type that isn't specified as being for a single product is being
+looped over. The EMAILADDRESS one is special, though. It could be used
+for more than one product, but we have no way of interacting with it, so
+it's skipped as well.
+
+    >>> class TrackerTypeCollectingUpdater(RemoteProductUpdater):
+    ...     def __init__(self):
+    ...         self.logger = BufferLogger()
+    ...         self.looped_over_bug_tracker_types = set()
+    ...     def updateByBugTrackerType(self, bugtracker_type):
+    ...         self.looped_over_bug_tracker_types.add(bugtracker_type)
+
+    >>> from lp.bugs.interfaces.bugtracker import (
+    ...     SINGLE_PRODUCT_BUGTRACKERTYPES)
+    >>> multi_product_trackers = set(
+    ...     bugtracker_type for bugtracker_type in BugTrackerType.items
+    ...     if bugtracker_type not in SINGLE_PRODUCT_BUGTRACKERTYPES)
+    >>> multi_product_trackers.remove(BugTrackerType.EMAILADDRESS)
+
+    >>> updater = TrackerTypeCollectingUpdater()
+    >>> updater.update()
+    >>> for item in multi_product_trackers.symmetric_difference(
+    ...         updater.looped_over_bug_tracker_types):
+    ...     print(item)
+
+
+updateByBugTrackerType()
+------------------------
+
+The updateByBugTrackerType() method looks at the bug watches that are
+linked to the product, to decide what remote_product should be set to.
+It accepts a single parameter, the type of the bug tracker that should
+be updated.
+
+
+No bug watches
+..............
+
+If there are no bug watches, nothing will be done.
+
+    >>> bugzilla_product = factory.makeProduct(
+    ...     name=u'bugzilla-product', official_malone=False)
+    >>> bugzilla = factory.makeBugTracker(
+    ...     bugtrackertype=BugTrackerType.BUGZILLA)
+    >>> bugzilla_product.bugtracker = bugzilla
+    >>> rt_product = factory.makeProduct(
+    ...     name=u'rt-product', official_malone=False)
+    >>> rt = factory.makeBugTracker(
+    ...     bugtrackertype=BugTrackerType.RT)
+    >>> rt_product.bugtracker = rt
+
+    >>> list(bugzilla_product.getLinkedBugWatches())
+    []
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    >>> print(bugzilla_product.remote_product)
+    None
+    >>> print(rt_product.remote_product)
+    None
+
+
+Linked bug watches
+..................
+
+If there are bug watches for a product having a None remote_product, an
+arbitrary bug watch will be retrieved, and queried for its remote
+product. Products having a bug tracker of a different type than the
+given one are ignored.
+
+    >>> from lp.testing.dbuser import lp_dbuser
+
+    >>> updater = NoNetworkRemoteProductUpdater(
+    ...     FakeTransaction(), BufferLogger())
+
+    >>> with lp_dbuser():
+    ...     bugzilla_bugtask = factory.makeBugTask(target=bugzilla_product)
+    ...     bugzilla_bugwatch = factory.makeBugWatch(
+    ...         '42', bugtracker=bugzilla, bug=bugzilla_bugtask.bug)
+    ...     bugzilla_bugtask.bugwatch = bugzilla_bugwatch
+    ...     rt_bugtask = factory.makeBugTask(target=rt_product)
+    ...     rt_bugwatch = factory.makeBugWatch(
+    ...         '84', bugtracker=rt, bug=rt_bugtask.bug)
+    ...     rt_bugtask.bugwatch = rt_bugwatch
+
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    Initializing DB for bugs: ['84'].
+
+    >>> print(rt_product.remote_product)
+    product-for-bug-84
+
+    >>> print(bugzilla_product.remote_product)
+    None
+
+
+remote_product already set
+..........................
+
+If a product already has remote_product set, it will not be updated.
+
+    >>> with lp_dbuser():
+    ...     rt_product = factory.makeProduct(official_malone=False)
+    ...     rt = factory.makeBugTracker(
+    ...         bugtrackertype=BugTrackerType.RT)
+    ...     rt_product.bugtracker = rt
+    ...     rt_bugtask = factory.makeBugTask(target=rt_product)
+    ...     rt_bugwatch = factory.makeBugWatch(
+    ...         '84', bugtracker=rt, bug=rt_bugtask.bug)
+    ...     rt_bugtask.bugwatch = rt_bugwatch
+
+    >>> rt_product.remote_product = u'already-set'
+    >>> updater = NoNetworkRemoteProductUpdater(
+    ...     FakeTransaction(), BufferLogger())
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    >>> print(rt_product.remote_product)
+    already-set
+
+
+Transaction handling
+....................
+
+To avoid long-running write transactions, the transaction is committed
+after each product's remote_product has been updated.
+
+    >>> with lp_dbuser():
+    ...     for index in range(3):
+    ...         rt_product = factory.makeProduct(official_malone=False)
+    ...         rt = factory.makeBugTracker(
+    ...             bugtrackertype=BugTrackerType.RT)
+    ...         rt_product.bugtracker = rt
+    ...         rt_bugtask = factory.makeBugTask(target=rt_product)
+    ...         rt_bugwatch = factory.makeBugWatch(
+    ...             '84', bugtracker=rt, bug=rt_bugtask.bug)
+    ...         rt_bugtask.bugwatch = rt_bugwatch
+
+    >>> updater = NoNetworkRemoteProductUpdater(
+    ...     FakeTransaction(log_calls=True), BufferLogger())
+    >>> updater.print_method_calls = False
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    Initializing DB for bugs: ['84'].
+    COMMIT
+    Initializing DB for bugs: ['84'].
+    COMMIT
+    Initializing DB for bugs: ['84'].
+    COMMIT
+
+
+Error handling
+..............
+
+If the ExternalBugTracker raises any BugWatchUpdateErrors,
+updateByBugTrackerType() will simply log the error and then continue.
+This is a simplistic approach but it means that problems with one bug
+tracker don't break the run for all bug trackers.
+
+    >>> with lp_dbuser():
+    ...     new_rt_product = factory.makeProduct(
+    ...         name='fooix', official_malone=False)
+    ...     new_rt_product.bugtracker = rt
+    ...     new_rt_bugtask = factory.makeBugTask(target=new_rt_product)
+    ...     new_rt_bugwatch = factory.makeBugWatch(
+    ...         '42', bugtracker=rt, bug=new_rt_bugtask.bug)
+    ...     new_rt_bugtask.bugwatch = new_rt_bugwatch
+
+    >>> from lp.bugs.externalbugtracker.base import (
+    ...     BugNotFound, BugWatchUpdateError)
+    >>> class BrokenOnInitExternalBugTracker(
+    ...         FakeExternalBugTracker):
+    ...     def initializeRemoteBugDB(self, bug_ids):
+    ...         raise BugWatchUpdateError("This here is an error")
+
+    >>> updater.logger = FakeLogger()
+    >>> updater.external_bugtracker_to_return = (
+    ...     BrokenOnInitExternalBugTracker)
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    INFO  1 projects using RT needing updating.
+    DEBUG Trying to update fooix
+    ERROR Unable to set remote_product for 'fooix': This here is an error
+
+    >>> class BrokenOnGetRemoteProductExternalBugTracker(
+    ...         FakeExternalBugTracker):
+    ...     def getRemoteProduct(self, remote_bug):
+    ...         raise BugNotFound("Didn't find bug %s." % remote_bug)
+
+    >>> updater.external_bugtracker_to_return = (
+    ...     BrokenOnGetRemoteProductExternalBugTracker)
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    INFO  1 projects using RT needing updating.
+    DEBUG Trying to update fooix
+    Initializing DB for bugs: ['42'].
+    ERROR Unable to set remote_product for 'fooix': Didn't find bug 42.
+
+AssertionErrors are also handled.
+
+    >>> class RaisesAssertionErrorExternalBugTracker(FakeExternalBugTracker):
+    ...     def initializeRemoteBugDB(self, bug_ids):
+    ...         assert True == False, "True isn't False!"
+
+    >>> updater.external_bugtracker_to_return = (
+    ...     RaisesAssertionErrorExternalBugTracker)
+    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
+    INFO  1 projects using RT needing updating.
+    DEBUG Trying to update fooix
+    ERROR Unable to set remote_product for 'fooix': True isn't False!
+
diff --git a/lib/lp/bugs/doc/product-update-remote-product.txt b/lib/lp/bugs/doc/product-update-remote-product.txt
deleted file mode 100644
index 7372652..0000000
--- a/lib/lp/bugs/doc/product-update-remote-product.txt
+++ /dev/null
@@ -1,267 +0,0 @@
-Updating Product.remote_product
-===============================
-
-The remote_product attribute of a Product is used to present links for
-filing and searching bugs in the Product's bug tracker, in case it's not
-using Launchpad to track its bugs. We don't expect users to set the
-remote_product themselves, so we have a script that tries to set this
-automatically.
-
-    >>> from lp.registry.model.product import Product
-    >>> from lp.services.database.interfaces import IStore
-    >>> store = IStore(Product)
-    >>> store.execute("UPDATE Product SET remote_product = 'not-None'")
-    <storm...>
-
-    >>> from lp.bugs.interfaces.bugtracker import BugTrackerType
-    >>> from lp.services.log.logger import FakeLogger, BufferLogger
-    >>> from lp.bugs.scripts.updateremoteproduct import (
-    ...     RemoteProductUpdater)
-    >>> from lp.testing.faketransaction import FakeTransaction
-    >>> updater = RemoteProductUpdater(FakeTransaction(), BufferLogger())
-
-
-Testing
--------
-
-To help testing, there is a method, _getExternalBugTracker(), that
-creates the ExternalBugTracker for the given BugTracker.
-
-    >>> rt = factory.makeBugTracker(
-    ...     bugtrackertype=BugTrackerType.RT,
-    ...     base_url=u'http://rt.example.com/')
-    >>> rt_external = updater._getExternalBugTracker(rt)
-    >>> rt_external.__class__.__name__
-    'RequestTracker'
-    >>> print(rt_external.baseurl)
-    http://rt.example.com
-
-For testing, _getExternalBugTracker() can be overridden to return an
-ExternalBugTracker that doesn't require network access.
-
-    >>> class FakeExternalBugTracker:
-    ...
-    ...     def initializeRemoteBugDB(self, bug_ids):
-    ...         print("Initializing DB for bugs: [%s]." %
-    ...             ", ".join("'%s'" % bug_id for bug_id in bug_ids))
-    ...
-    ...     def getRemoteProduct(self, remote_bug):
-    ...         return 'product-for-bug-%s' % remote_bug
-
-
-    >>> class NoNetworkRemoteProductUpdater(RemoteProductUpdater):
-    ...
-    ...     external_bugtracker_to_return = FakeExternalBugTracker
-    ...
-    ...     def _getExternalBugTracker(self, bug_tracker):
-    ...         return self.external_bugtracker_to_return()
-
-
-update()
---------
-
-The update method simply loops over all the bug tracker types that can
-track more than one product, and calls updateByBugTrackerType(). Any bug
-tracker type that isn't specified as being for a single product is being
-looped over. The EMAILADDRESS one is special, though. It could be used
-for more than one product, but we have no way of interacting with it, so
-it's skipped as well.
-
-    >>> class TrackerTypeCollectingUpdater(RemoteProductUpdater):
-    ...     def __init__(self):
-    ...         self.logger = BufferLogger()
-    ...         self.looped_over_bug_tracker_types = set()
-    ...     def updateByBugTrackerType(self, bugtracker_type):
-    ...         self.looped_over_bug_tracker_types.add(bugtracker_type)
-
-    >>> from lp.bugs.interfaces.bugtracker import (
-    ...     SINGLE_PRODUCT_BUGTRACKERTYPES)
-    >>> multi_product_trackers = set(
-    ...     bugtracker_type for bugtracker_type in BugTrackerType.items
-    ...     if bugtracker_type not in SINGLE_PRODUCT_BUGTRACKERTYPES)
-    >>> multi_product_trackers.remove(BugTrackerType.EMAILADDRESS)
-
-    >>> updater = TrackerTypeCollectingUpdater()
-    >>> updater.update()
-    >>> for item in multi_product_trackers.symmetric_difference(
-    ...         updater.looped_over_bug_tracker_types):
-    ...     print(item)
-
-
-updateByBugTrackerType()
-------------------------
-
-The updateByBugTrackerType() method looks at the bug watches that are
-linked to the product, to decide what remote_product should be set to.
-It accepts a single parameter, the type of the bug tracker that should
-be updated.
-
-
-No bug watches
-..............
-
-If there are no bug watches, nothing will be done.
-
-    >>> bugzilla_product = factory.makeProduct(
-    ...     name=u'bugzilla-product', official_malone=False)
-    >>> bugzilla = factory.makeBugTracker(
-    ...     bugtrackertype=BugTrackerType.BUGZILLA)
-    >>> bugzilla_product.bugtracker = bugzilla
-    >>> rt_product = factory.makeProduct(
-    ...     name=u'rt-product', official_malone=False)
-    >>> rt = factory.makeBugTracker(
-    ...     bugtrackertype=BugTrackerType.RT)
-    >>> rt_product.bugtracker = rt
-
-    >>> list(bugzilla_product.getLinkedBugWatches())
-    []
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    >>> print(bugzilla_product.remote_product)
-    None
-    >>> print(rt_product.remote_product)
-    None
-
-
-Linked bug watches
-..................
-
-If there are bug watches for a product having a None remote_product, an
-arbitrary bug watch will be retrieved, and queried for its remote
-product. Products having a bug tracker of a different type than the
-given one are ignored.
-
-    >>> from lp.testing.dbuser import lp_dbuser
-
-    >>> updater = NoNetworkRemoteProductUpdater(
-    ...     FakeTransaction(), BufferLogger())
-
-    >>> with lp_dbuser():
-    ...     bugzilla_bugtask = factory.makeBugTask(target=bugzilla_product)
-    ...     bugzilla_bugwatch = factory.makeBugWatch(
-    ...         '42', bugtracker=bugzilla, bug=bugzilla_bugtask.bug)
-    ...     bugzilla_bugtask.bugwatch = bugzilla_bugwatch
-    ...     rt_bugtask = factory.makeBugTask(target=rt_product)
-    ...     rt_bugwatch = factory.makeBugWatch(
-    ...         '84', bugtracker=rt, bug=rt_bugtask.bug)
-    ...     rt_bugtask.bugwatch = rt_bugwatch
-
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    Initializing DB for bugs: ['84'].
-
-    >>> print(rt_product.remote_product)
-    product-for-bug-84
-
-    >>> print(bugzilla_product.remote_product)
-    None
-
-
-remote_product already set
-..........................
-
-If a product already has remote_product set, it will not be updated.
-
-    >>> with lp_dbuser():
-    ...     rt_product = factory.makeProduct(official_malone=False)
-    ...     rt = factory.makeBugTracker(
-    ...         bugtrackertype=BugTrackerType.RT)
-    ...     rt_product.bugtracker = rt
-    ...     rt_bugtask = factory.makeBugTask(target=rt_product)
-    ...     rt_bugwatch = factory.makeBugWatch(
-    ...         '84', bugtracker=rt, bug=rt_bugtask.bug)
-    ...     rt_bugtask.bugwatch = rt_bugwatch
-
-    >>> rt_product.remote_product = u'already-set'
-    >>> updater = NoNetworkRemoteProductUpdater(
-    ...     FakeTransaction(), BufferLogger())
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    >>> print(rt_product.remote_product)
-    already-set
-
-
-Transaction handling
-....................
-
-To avoid long-running write transactions, the transaction is committed
-after each product's remote_product has been updated.
-
-    >>> with lp_dbuser():
-    ...     for index in range(3):
-    ...         rt_product = factory.makeProduct(official_malone=False)
-    ...         rt = factory.makeBugTracker(
-    ...             bugtrackertype=BugTrackerType.RT)
-    ...         rt_product.bugtracker = rt
-    ...         rt_bugtask = factory.makeBugTask(target=rt_product)
-    ...         rt_bugwatch = factory.makeBugWatch(
-    ...             '84', bugtracker=rt, bug=rt_bugtask.bug)
-    ...         rt_bugtask.bugwatch = rt_bugwatch
-
-    >>> updater = NoNetworkRemoteProductUpdater(
-    ...     FakeTransaction(log_calls=True), BufferLogger())
-    >>> updater.print_method_calls = False
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    Initializing DB for bugs: ['84'].
-    COMMIT
-    Initializing DB for bugs: ['84'].
-    COMMIT
-    Initializing DB for bugs: ['84'].
-    COMMIT
-
-
-Error handling
-..............
-
-If the ExternalBugTracker raises any BugWatchUpdateErrors,
-updateByBugTrackerType() will simply log the error and then continue.
-This is a simplistic approach but it means that problems with one bug
-tracker don't break the run for all bug trackers.
-
-    >>> with lp_dbuser():
-    ...     new_rt_product = factory.makeProduct(
-    ...         name='fooix', official_malone=False)
-    ...     new_rt_product.bugtracker = rt
-    ...     new_rt_bugtask = factory.makeBugTask(target=new_rt_product)
-    ...     new_rt_bugwatch = factory.makeBugWatch(
-    ...         '42', bugtracker=rt, bug=new_rt_bugtask.bug)
-    ...     new_rt_bugtask.bugwatch = new_rt_bugwatch
-
-    >>> from lp.bugs.externalbugtracker.base import (
-    ...     BugNotFound, BugWatchUpdateError)
-    >>> class BrokenOnInitExternalBugTracker(
-    ...         FakeExternalBugTracker):
-    ...     def initializeRemoteBugDB(self, bug_ids):
-    ...         raise BugWatchUpdateError("This here is an error")
-
-    >>> updater.logger = FakeLogger()
-    >>> updater.external_bugtracker_to_return = (
-    ...     BrokenOnInitExternalBugTracker)
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    INFO  1 projects using RT needing updating.
-    DEBUG Trying to update fooix
-    ERROR Unable to set remote_product for 'fooix': This here is an error
-
-    >>> class BrokenOnGetRemoteProductExternalBugTracker(
-    ...         FakeExternalBugTracker):
-    ...     def getRemoteProduct(self, remote_bug):
-    ...         raise BugNotFound("Didn't find bug %s." % remote_bug)
-
-    >>> updater.external_bugtracker_to_return = (
-    ...     BrokenOnGetRemoteProductExternalBugTracker)
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    INFO  1 projects using RT needing updating.
-    DEBUG Trying to update fooix
-    Initializing DB for bugs: ['42'].
-    ERROR Unable to set remote_product for 'fooix': Didn't find bug 42.
-
-AssertionErrors are also handled.
-
-    >>> class RaisesAssertionErrorExternalBugTracker(FakeExternalBugTracker):
-    ...     def initializeRemoteBugDB(self, bug_ids):
-    ...         assert True == False, "True isn't False!"
-
-    >>> updater.external_bugtracker_to_return = (
-    ...     RaisesAssertionErrorExternalBugTracker)
-    >>> updater.updateByBugTrackerType(BugTrackerType.RT)
-    INFO  1 projects using RT needing updating.
-    DEBUG Trying to update fooix
-    ERROR Unable to set remote_product for 'fooix': True isn't False!
-
diff --git a/lib/lp/bugs/doc/products-with-no-remote-product.txt b/lib/lp/bugs/doc/products-with-no-remote-product.rst
similarity index 100%
rename from lib/lp/bugs/doc/products-with-no-remote-product.txt
rename to lib/lp/bugs/doc/products-with-no-remote-product.rst
diff --git a/lib/lp/bugs/doc/sourceforge-remote-products.rst b/lib/lp/bugs/doc/sourceforge-remote-products.rst
new file mode 100644
index 0000000..befa82b
--- /dev/null
+++ b/lib/lp/bugs/doc/sourceforge-remote-products.rst
@@ -0,0 +1,169 @@
+Getting remote products from SourceForge projects
+=================================================
+
+Launchpad Products can be linked to SourceForge projects by setting
+their 'sourceforgeproject' attribute.
+
+It's possible to get a list of the Products that are linked to a
+SourceForge project but which have no remote_product set by calling
+IProductSet.getSFLinkedProductsWithNoneRemoteProduct().
+
+There are currently no Products in the database linked to a SourceForge
+project without a remote_product set.
+
+    >>> from zope.component import getUtility
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> products = getUtility(
+    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
+
+    >>> print(products.count())
+    0
+
+If we add a Product and link it to a SourceForge project,
+getSFLinkedProductsWithNoneRemoteProduct() will return it.
+
+    >>> from lp.testing.factory import LaunchpadObjectFactory
+    >>> from transaction import commit
+    >>> factory = LaunchpadObjectFactory()
+
+    >>> product_1 = factory.makeProduct(name='my-first-product')
+    >>> product_1.sourceforgeproject = 'fronobulator'
+    >>> commit()
+
+    >>> products = getUtility(
+    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
+
+    >>> for product in products:
+    ...     print(product.name, product.sourceforgeproject)
+    my-first-product fronobulator
+
+Define some request mocks so that we don't try to access SourceForge.
+
+    >>> import os.path
+    >>> import re
+    >>> from urllib.parse import urlsplit
+    >>> import responses
+
+    >>> def project_callback(request):
+    ...     url = urlsplit(request.url)
+    ...     project = re.match(r'.*/projects/([a-z]+)', url.path).group(1)
+    ...     file_path = os.path.join(
+    ...         os.path.dirname(__file__), os.pardir, 'tests', 'testfiles',
+    ...         'sourceforge-project-%s.html' % project)
+    ...     with open(file_path) as test_file:
+    ...         return (200, {}, test_file.read())
+    >>> def add_project_response(requests_mock):
+    ...     requests_mock.add_callback(
+    ...         'GET', re.compile(r'.*/projects/[a-z]+'),
+    ...         callback=project_callback)
+
+    >>> def tracker_callback(request):
+    ...     url = urlsplit(request.url)
+    ...     group_id = re.match(r'group_id=([0-9]+)', url.query).group(1)
+    ...     file_path = os.path.join(
+    ...         os.path.dirname(__file__), os.pardir, 'tests', 'testfiles',
+    ...         'sourceforge-tracker-%s.html' % group_id)
+    ...     with open(file_path) as test_file:
+    ...         return (200, {}, test_file.read())
+    >>> def add_tracker_response(requests_mock):
+    ...     requests_mock.add_callback(
+    ...         'GET', re.compile(r'.*/tracker/\?group_id=[0-9]+'),
+    ...         match_querystring=True, callback=tracker_callback)
+
+    >>> def print_calls(calls):
+    ...     for call in calls:
+    ...         url = urlsplit(call.request.url)
+    ...         print('Got page %s%s' % (
+    ...             url.path, '?%s' % url.query if url.query else ''))
+
+    >>> from lp.bugs.scripts.sfremoteproductfinder import (
+    ...     SourceForgeRemoteProductFinder,
+    ...     )
+    >>> from lp.services.log.logger import FakeLogger
+    >>> from lp.testing.layers import LaunchpadZopelessLayer
+    >>> finder = SourceForgeRemoteProductFinder(
+    ...     txn=LaunchpadZopelessLayer.txn, logger=FakeLogger())
+
+SourceForgeRemoteProductFinder has a method,
+getRemoteProductFromSourceForge(), which does all the heavy lifting of finding
+the bug tracker for a given SourceForge project. It does this by fetching the
+SourceForge project page about each of them. It then finds the link to the
+project's 'Tracker' index within that page and follows it. Finally, it
+extracts the URL of the project's bug tracker and returns the group_id and
+atid therein as an ampersand-separated string.
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     add_project_response(requests_mock)
+    ...     add_tracker_response(requests_mock)
+    ...     remote_product = finder.getRemoteProductFromSourceForge(
+    ...         'fronobulator')
+    ...     print_calls(requests_mock.calls)
+    Got page /projects/fronobulator
+    Got page /tracker/?group_id=5570
+
+    >>> print(remote_product)
+    5570&105570
+
+If an error is raised when trying to fetch the project pages from the
+remote server, it will be logged.
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     requests_mock.add('GET', re.compile(r'.*'), status=500)
+    ...     finder.getRemoteProductFromSourceForge('fronobulator')
+    ERROR...Error fetching project...: 500 Server Error: Internal Server Error
+
+SourceForgeRemoteProductFinder.setRemoteProductsFromSourceForge()
+iterates over the list of products returned by
+getSFLinkedProductsWithNoneRemoteProduct() and then calls
+getRemoteProductFromSourceForge() to fetch their remote products.
+
+    >>> with responses.RequestsMock() as requests_mock:
+    ...     add_project_response(requests_mock)
+    ...     add_tracker_response(requests_mock)
+    ...     finder.setRemoteProductsFromSourceForge()
+    ...     print_calls(requests_mock.calls)
+    INFO...Updating 1 Products using SourceForge project data
+    DEBUG...Updating remote_product for Product 'my-first-product'
+    Got page /projects/fronobulator
+    Got page /tracker/?group_id=5570
+
+The product that was linked to SourceForge without a remote_product now has
+its remote_product set.
+
+    >>> product_1 = getUtility(IProductSet).getByName('my-first-product')
+    >>> print(product_1.remote_product)
+    5570&105570
+
+There are no other SourceForge-linked products that have no remote product.
+
+    >>> products = getUtility(
+    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
+
+    >>> print(products.count())
+    0
+
+
+update-sourceforge-remote-products.py
+-------------------------------------
+
+There is a cronscript, update-sourceforge-remote-products.py, which will use
+the SourceForgeRemoteProductFinder to periodically update Products'
+remote_product fields.
+
+    >>> import subprocess
+    >>> process = subprocess.Popen(
+    ...     ['cronscripts/update-sourceforge-remote-products.py', '-v'],
+    ...     stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+    ...     stderr=subprocess.PIPE, universal_newlines=True)
+    >>> (out, err) = process.communicate()
+    >>> print(out)
+    <BLANKLINE>
+    >>> process.returncode
+    0
+
+    >>> print(err)
+    INFO    ...
+    INFO    No Products to update.
+    INFO    Time for this run: ... seconds.
+    DEBUG   updateremoteproduct ran in ...s (excl. load & lock)
+    DEBUG   Removing lock file:...
diff --git a/lib/lp/bugs/doc/sourceforge-remote-products.txt b/lib/lp/bugs/doc/sourceforge-remote-products.txt
deleted file mode 100644
index befa82b..0000000
--- a/lib/lp/bugs/doc/sourceforge-remote-products.txt
+++ /dev/null
@@ -1,169 +0,0 @@
-Getting remote products from SourceForge projects
-=================================================
-
-Launchpad Products can be linked to SourceForge projects by setting
-their 'sourceforgeproject' attribute.
-
-It's possible to get a list of the Products that are linked to a
-SourceForge project but which have no remote_product set by calling
-IProductSet.getSFLinkedProductsWithNoneRemoteProduct().
-
-There are currently no Products in the database linked to a SourceForge
-project without a remote_product set.
-
-    >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> products = getUtility(
-    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
-
-    >>> print(products.count())
-    0
-
-If we add a Product and link it to a SourceForge project,
-getSFLinkedProductsWithNoneRemoteProduct() will return it.
-
-    >>> from lp.testing.factory import LaunchpadObjectFactory
-    >>> from transaction import commit
-    >>> factory = LaunchpadObjectFactory()
-
-    >>> product_1 = factory.makeProduct(name='my-first-product')
-    >>> product_1.sourceforgeproject = 'fronobulator'
-    >>> commit()
-
-    >>> products = getUtility(
-    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
-
-    >>> for product in products:
-    ...     print(product.name, product.sourceforgeproject)
-    my-first-product fronobulator
-
-Define some request mocks so that we don't try to access SourceForge.
-
-    >>> import os.path
-    >>> import re
-    >>> from urllib.parse import urlsplit
-    >>> import responses
-
-    >>> def project_callback(request):
-    ...     url = urlsplit(request.url)
-    ...     project = re.match(r'.*/projects/([a-z]+)', url.path).group(1)
-    ...     file_path = os.path.join(
-    ...         os.path.dirname(__file__), os.pardir, 'tests', 'testfiles',
-    ...         'sourceforge-project-%s.html' % project)
-    ...     with open(file_path) as test_file:
-    ...         return (200, {}, test_file.read())
-    >>> def add_project_response(requests_mock):
-    ...     requests_mock.add_callback(
-    ...         'GET', re.compile(r'.*/projects/[a-z]+'),
-    ...         callback=project_callback)
-
-    >>> def tracker_callback(request):
-    ...     url = urlsplit(request.url)
-    ...     group_id = re.match(r'group_id=([0-9]+)', url.query).group(1)
-    ...     file_path = os.path.join(
-    ...         os.path.dirname(__file__), os.pardir, 'tests', 'testfiles',
-    ...         'sourceforge-tracker-%s.html' % group_id)
-    ...     with open(file_path) as test_file:
-    ...         return (200, {}, test_file.read())
-    >>> def add_tracker_response(requests_mock):
-    ...     requests_mock.add_callback(
-    ...         'GET', re.compile(r'.*/tracker/\?group_id=[0-9]+'),
-    ...         match_querystring=True, callback=tracker_callback)
-
-    >>> def print_calls(calls):
-    ...     for call in calls:
-    ...         url = urlsplit(call.request.url)
-    ...         print('Got page %s%s' % (
-    ...             url.path, '?%s' % url.query if url.query else ''))
-
-    >>> from lp.bugs.scripts.sfremoteproductfinder import (
-    ...     SourceForgeRemoteProductFinder,
-    ...     )
-    >>> from lp.services.log.logger import FakeLogger
-    >>> from lp.testing.layers import LaunchpadZopelessLayer
-    >>> finder = SourceForgeRemoteProductFinder(
-    ...     txn=LaunchpadZopelessLayer.txn, logger=FakeLogger())
-
-SourceForgeRemoteProductFinder has a method,
-getRemoteProductFromSourceForge(), which does all the heavy lifting of finding
-the bug tracker for a given SourceForge project. It does this by fetching the
-SourceForge project page about each of them. It then finds the link to the
-project's 'Tracker' index within that page and follows it. Finally, it
-extracts the URL of the project's bug tracker and returns the group_id and
-atid therein as an ampersand-separated string.
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     add_project_response(requests_mock)
-    ...     add_tracker_response(requests_mock)
-    ...     remote_product = finder.getRemoteProductFromSourceForge(
-    ...         'fronobulator')
-    ...     print_calls(requests_mock.calls)
-    Got page /projects/fronobulator
-    Got page /tracker/?group_id=5570
-
-    >>> print(remote_product)
-    5570&105570
-
-If an error is raised when trying to fetch the project pages from the
-remote server, it will be logged.
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     requests_mock.add('GET', re.compile(r'.*'), status=500)
-    ...     finder.getRemoteProductFromSourceForge('fronobulator')
-    ERROR...Error fetching project...: 500 Server Error: Internal Server Error
-
-SourceForgeRemoteProductFinder.setRemoteProductsFromSourceForge()
-iterates over the list of products returned by
-getSFLinkedProductsWithNoneRemoteProduct() and then calls
-getRemoteProductFromSourceForge() to fetch their remote products.
-
-    >>> with responses.RequestsMock() as requests_mock:
-    ...     add_project_response(requests_mock)
-    ...     add_tracker_response(requests_mock)
-    ...     finder.setRemoteProductsFromSourceForge()
-    ...     print_calls(requests_mock.calls)
-    INFO...Updating 1 Products using SourceForge project data
-    DEBUG...Updating remote_product for Product 'my-first-product'
-    Got page /projects/fronobulator
-    Got page /tracker/?group_id=5570
-
-The product that was linked to SourceForge without a remote_product now has
-its remote_product set.
-
-    >>> product_1 = getUtility(IProductSet).getByName('my-first-product')
-    >>> print(product_1.remote_product)
-    5570&105570
-
-There are no other SourceForge-linked products that have no remote product.
-
-    >>> products = getUtility(
-    ...     IProductSet).getSFLinkedProductsWithNoneRemoteProduct()
-
-    >>> print(products.count())
-    0
-
-
-update-sourceforge-remote-products.py
--------------------------------------
-
-There is a cronscript, update-sourceforge-remote-products.py, which will use
-the SourceForgeRemoteProductFinder to periodically update Products'
-remote_product fields.
-
-    >>> import subprocess
-    >>> process = subprocess.Popen(
-    ...     ['cronscripts/update-sourceforge-remote-products.py', '-v'],
-    ...     stdin=subprocess.PIPE, stdout=subprocess.PIPE,
-    ...     stderr=subprocess.PIPE, universal_newlines=True)
-    >>> (out, err) = process.communicate()
-    >>> print(out)
-    <BLANKLINE>
-    >>> process.returncode
-    0
-
-    >>> print(err)
-    INFO    ...
-    INFO    No Products to update.
-    INFO    Time for this run: ... seconds.
-    DEBUG   updateremoteproduct ran in ...s (excl. load & lock)
-    DEBUG   Removing lock file:...
diff --git a/lib/lp/bugs/doc/structural-subscriptions.rst b/lib/lp/bugs/doc/structural-subscriptions.rst
new file mode 100644
index 0000000..a6be049
--- /dev/null
+++ b/lib/lp/bugs/doc/structural-subscriptions.rst
@@ -0,0 +1,96 @@
+Structural Subscriptions
+------------------------
+
+Structural subscriptions allow a user to subscribe to a launchpad
+structure like a product, project, productseries, distribution,
+distroseries, milestone or a combination of sourcepackagename and
+distribution.
+
+    >>> from lp.testing import person_logged_in
+    >>> from zope.component import getUtility
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.registry.interfaces.product import IProductSet
+
+    >>> person_set = getUtility(IPersonSet)
+    >>> foobar = person_set.getByEmail('foo.bar@xxxxxxxxxxxxx')
+    >>> sampleperson = person_set.getByEmail('test@xxxxxxxxxxxxx')
+    >>> firefox = getUtility(IProductSet).getByName("firefox")
+    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
+
+    >>> with person_logged_in(foobar):
+    ...     ff_sub = firefox.addBugSubscription(
+    ...         subscriber=sampleperson, subscribed_by=foobar)
+    >>> ff_sub.target
+    <Product at ...>
+
+    >>> with person_logged_in(foobar):
+    ...     ubuntu_sub = ubuntu.addBugSubscription(
+    ...         subscriber=sampleperson, subscribed_by=foobar)
+    >>> ubuntu_sub.target
+    <Distribution 'Ubuntu' (ubuntu)>
+
+    >>> evolution = ubuntu.getSourcePackage('evolution')
+    >>> with person_logged_in(foobar):
+    ...     evolution_sub = evolution.addBugSubscription(
+    ...         subscriber=sampleperson, subscribed_by=foobar)
+    >>> evolution_sub.target
+    <...DistributionSourcePackage object at ...>
+
+    >>> sampleperson.structural_subscriptions.count()
+    3
+
+
+Parent subscription targets
+===========================
+
+Some subscription targets relate to other targets hierarchically. An
+IDistribution, for example, can be said to be a parent of all
+IDistributionSourcePackages for that distribution.
+
+    >>> evolution_package = evolution_sub.target
+
+A target's parent can be retrieved using the
+`parent_subscription_target` property.
+
+    >>> print(evolution_package.parent_subscription_target.displayname)
+    Ubuntu
+    >>> print(ubuntu.parent_subscription_target)
+    None
+    >>> print(firefox.parent_subscription_target.displayname)
+    The Mozilla Project
+
+    >>> ff_milestone = firefox.getMilestone('1.0')
+    >>> ff_milestone.parent_subscription_target == firefox
+    True
+    >>> print(ff_milestone.parent_subscription_target.displayname)
+    Mozilla Firefox
+
+    >>> ff_trunk = firefox.getSeries('trunk')
+    >>> ff_trunk.parent_subscription_target == firefox
+    True
+    >>> print(ff_trunk.parent_subscription_target.displayname)
+    Mozilla Firefox
+
+    >>> warty = ubuntu.getSeries('warty')
+    >>> warty.parent_subscription_target == ubuntu
+    True
+    >>> print(warty.parent_subscription_target.displayname)
+    Ubuntu
+
+When notifying subscribers of bug activity, both subscribers to the
+target and to the target's parent are notified.
+
+
+Target type display
+===================
+
+Structural subscription targets have a `target_type_display` attribute, which
+can be used to refer to them in display.
+
+    >>> print(firefox.target_type_display)
+    project
+    >>> print(evolution_package.target_type_display)
+    package
+    >>> print(ff_milestone.target_type_display)
+    milestone
diff --git a/lib/lp/bugs/doc/structural-subscriptions.txt b/lib/lp/bugs/doc/structural-subscriptions.txt
deleted file mode 100644
index a6be049..0000000
--- a/lib/lp/bugs/doc/structural-subscriptions.txt
+++ /dev/null
@@ -1,96 +0,0 @@
-Structural Subscriptions
-------------------------
-
-Structural subscriptions allow a user to subscribe to a launchpad
-structure like a product, project, productseries, distribution,
-distroseries, milestone or a combination of sourcepackagename and
-distribution.
-
-    >>> from lp.testing import person_logged_in
-    >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.registry.interfaces.product import IProductSet
-
-    >>> person_set = getUtility(IPersonSet)
-    >>> foobar = person_set.getByEmail('foo.bar@xxxxxxxxxxxxx')
-    >>> sampleperson = person_set.getByEmail('test@xxxxxxxxxxxxx')
-    >>> firefox = getUtility(IProductSet).getByName("firefox")
-    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
-
-    >>> with person_logged_in(foobar):
-    ...     ff_sub = firefox.addBugSubscription(
-    ...         subscriber=sampleperson, subscribed_by=foobar)
-    >>> ff_sub.target
-    <Product at ...>
-
-    >>> with person_logged_in(foobar):
-    ...     ubuntu_sub = ubuntu.addBugSubscription(
-    ...         subscriber=sampleperson, subscribed_by=foobar)
-    >>> ubuntu_sub.target
-    <Distribution 'Ubuntu' (ubuntu)>
-
-    >>> evolution = ubuntu.getSourcePackage('evolution')
-    >>> with person_logged_in(foobar):
-    ...     evolution_sub = evolution.addBugSubscription(
-    ...         subscriber=sampleperson, subscribed_by=foobar)
-    >>> evolution_sub.target
-    <...DistributionSourcePackage object at ...>
-
-    >>> sampleperson.structural_subscriptions.count()
-    3
-
-
-Parent subscription targets
-===========================
-
-Some subscription targets relate to other targets hierarchically. An
-IDistribution, for example, can be said to be a parent of all
-IDistributionSourcePackages for that distribution.
-
-    >>> evolution_package = evolution_sub.target
-
-A target's parent can be retrieved using the
-`parent_subscription_target` property.
-
-    >>> print(evolution_package.parent_subscription_target.displayname)
-    Ubuntu
-    >>> print(ubuntu.parent_subscription_target)
-    None
-    >>> print(firefox.parent_subscription_target.displayname)
-    The Mozilla Project
-
-    >>> ff_milestone = firefox.getMilestone('1.0')
-    >>> ff_milestone.parent_subscription_target == firefox
-    True
-    >>> print(ff_milestone.parent_subscription_target.displayname)
-    Mozilla Firefox
-
-    >>> ff_trunk = firefox.getSeries('trunk')
-    >>> ff_trunk.parent_subscription_target == firefox
-    True
-    >>> print(ff_trunk.parent_subscription_target.displayname)
-    Mozilla Firefox
-
-    >>> warty = ubuntu.getSeries('warty')
-    >>> warty.parent_subscription_target == ubuntu
-    True
-    >>> print(warty.parent_subscription_target.displayname)
-    Ubuntu
-
-When notifying subscribers of bug activity, both subscribers to the
-target and to the target's parent are notified.
-
-
-Target type display
-===================
-
-Structural subscription targets have a `target_type_display` attribute, which
-can be used to refer to them in display.
-
-    >>> print(firefox.target_type_display)
-    project
-    >>> print(evolution_package.target_type_display)
-    package
-    >>> print(ff_milestone.target_type_display)
-    milestone
diff --git a/lib/lp/bugs/doc/treelookup.rst b/lib/lp/bugs/doc/treelookup.rst
new file mode 100644
index 0000000..ecf3009
--- /dev/null
+++ b/lib/lp/bugs/doc/treelookup.rst
@@ -0,0 +1,211 @@
+Doing lookups in a tree
+=======================
+
+    >>> from lp.bugs.adapters.treelookup import (
+    ...     LookupBranch, LookupTree)
+
+`LookupTree` encapsulates a simple tree structure that can be used to
+do lookups using one or more keys.
+
+A tree contains multiple branches. To find something in a tree, one or
+more keys are passed in. The second and subsequent keys are used if a
+branch off the tree leads to another tree... which breaks the analogy
+somewhat, but you get the picture :)
+
+For a given key, each branch in the tree is checked, in order, to see
+if it contains that key. If it does, the branch result is looked
+at.
+
+If the result is a tree, it is searched in the same way, but using the
+next key that was originally passed in.
+
+If the result is any other object, it is returned as the result of the
+search.
+
+Two things arise from this:
+
+ * There can be more than one path through the tree to the same
+   result.
+
+ * A search of the tree may return a result without consuming all of
+   the given keys.
+
+It is also possible to specify a default branch. This is done by
+creating a branch with no keys. This must be the last branch in the
+tree, because it would not make sense for it to appear in any other
+position.
+
+
+Creation
+--------
+
+    >>> tree = LookupTree(
+    ...     ('Snack', LookupTree(
+    ...             ('Mars Bar', 'Snickers', 'Bad'),
+    ...             ('Apple', 'Banana', 'Good'))),
+    ...     LookupBranch('Lunch', 'Dinner', LookupTree(
+    ...             ('Fish and chips', "Penne all'arrabbiata", 'Nice'),
+    ...             ('Raw liver', 'Not so nice'))),
+    ...     ('Make up your mind!',),
+    ...     )
+
+Behind the scenes, `LookupTree` promotes plain tuples (or any
+iterable) into `LookupBranch` instances. This means that the last
+member of the tuple is the result of the branch. All the other members
+are keys.
+
+Tuples/branches without keys are default choices. They must come
+last. It doesn't make sense for a default to appear in any other
+position, because it would completely obscure the subsequent branches
+in the tree. Hence, attempting to specify a default branch before the
+last position is treated as an error.
+
+    >>> broken_tree = LookupTree(
+    ...     ('Free agents',),
+    ...     ('Alice', 'Bob', 'Allies of Schneier'))
+    Traceback (most recent call last):
+    ...
+    TypeError: Default branch must be last.
+
+To help when constructing more complex trees, an existing `LookupTree`
+instance can be passed in when constructing a new one. Its branches
+are copied into the new `LookupTree` at that point.
+
+    >>> breakfast_tree = LookupTree(
+    ...     ('Breakfast', 'Corn flakes'),
+    ...     tree,
+    ...     )
+
+    >>> len(tree.branches)
+    3
+    >>> len(breakfast_tree.branches)
+    4
+
+Although it should not happen in regular operation (because
+`LookupTree.__init__` ensures all arguments are `LookupBranch`
+instances), `LookupTree._verify` also checks that every branch is a
+`LookupBranch`.
+
+    >>> invalid_tree = LookupTree(tree)
+    >>> invalid_tree.branches = invalid_tree.branches + ('Greenland',)
+    >>> invalid_tree._verify()
+    Traceback (most recent call last):
+    ...
+    TypeError: Not a LookupBranch: ...'Greenland'
+
+
+Searching
+---------
+
+Just call `tree.find`.
+
+    >>> print(tree.find('Snack', 'Banana'))
+    Good
+
+If you specify more keys than you need to reach a leaf, you still get
+the result.
+
+    >>> print(tree.find('Snack', 'Banana', 'Big', 'Yellow', 'Taxi'))
+    Good
+
+But an exception is raised if it does not reach a leaf.
+
+    >>> tree.find('Snack')
+    Traceback (most recent call last):
+    ...
+    KeyError: ...'Snack'
+
+
+Development
+-----------
+
+`LookupTree` makes development easy, because `describe` gives a
+complete description of the tree you've created.
+
+    >>> print(tree.describe())
+    tree(
+        branch(Snack => tree(
+            branch('Mars Bar', Snickers => 'Bad')
+            branch(Apple, Banana => 'Good')
+            ))
+        branch(Lunch, Dinner => tree(
+            branch('Fish and chips', "Penne all'arrabbiata" => 'Nice')
+            branch('Raw liver' => 'Not so nice')
+            ))
+        branch(* => 'Make up your mind!')
+        )
+
+We can also see that the result of constructing a new lookup using an
+existing one is the same as if we had constructed it independently.
+
+    >>> print(breakfast_tree.describe())
+    tree(
+        branch(Breakfast => 'Corn flakes')
+        branch(Snack => tree(
+            branch('Mars Bar', Snickers => 'Bad')
+            branch(Apple, Banana => 'Good')
+            ))
+        branch(Lunch, Dinner => tree(
+            branch('Fish and chips', "Penne all'arrabbiata" => 'Nice')
+            branch('Raw liver' => 'Not so nice')
+            ))
+        branch(* => 'Make up your mind!')
+        )
+
+Simple keys are shown without quotes, to aid readability, and default
+branches are shown with '*' as the key.
+
+
+Pruning
+-------
+
+During tree creation, branches which have keys that already appear in
+earlier branches are cloned and have those already seen keys
+pruned. If all keys are removed from a branch it is discarded.
+
+The third branch in the following tree is discarded because 'Snack'
+already appears as a key in the first branch. The fourth branch is
+cloned then modified to remove the 'Lunch' key which already appeared
+in the second branch. The default branch is left unchanged; only
+branches with keys are candidates for being discarded.
+
+    >>> pruned_tree = LookupTree(
+    ...     ('Snack', 'Crisps'),
+    ...     ('Lunch', 'Bread'),
+    ...     ('Snack', 'Mars Bar'),
+    ...     ('Lunch', 'Dinner', 'Soup'),
+    ...     ('Eat more fruit and veg',),
+    ...     )
+    >>> print(pruned_tree.describe())
+    tree(
+        branch(Snack => 'Crisps')
+        branch(Lunch => 'Bread')
+        branch(Dinner => 'Soup')
+        branch(* => 'Eat more fruit and veg')
+        )
+
+
+Documentation
+-------------
+
+You can discover the minimum and maximum depth of a tree.
+
+    >>> tree.min_depth
+    1
+    >>> tree.max_depth
+    2
+
+`LookupTree` has a `flatten` method that may be useful when generating
+documentation. It yields tuples of keys that represent paths to
+leaves.
+
+    >>> for elems in tree.flatten():
+    ...     path, result = elems[:-1], elems[-1]
+    ...     print(' => '.join(
+    ...         [pretty(node.keys) for node in path] + [pretty(result)]))
+    ('Snack',) => ('Mars Bar', 'Snickers') => 'Bad'
+    ('Snack',) => ('Apple', 'Banana') => 'Good'
+    ('Lunch', 'Dinner') => ('Fish and chips', "Penne all'arrabbiata")
+      => 'Nice'
+    ('Lunch', 'Dinner') => ('Raw liver',) => 'Not so nice'
+    () => 'Make up your mind!'
diff --git a/lib/lp/bugs/doc/treelookup.txt b/lib/lp/bugs/doc/treelookup.txt
deleted file mode 100644
index ecf3009..0000000
--- a/lib/lp/bugs/doc/treelookup.txt
+++ /dev/null
@@ -1,211 +0,0 @@
-Doing lookups in a tree
-=======================
-
-    >>> from lp.bugs.adapters.treelookup import (
-    ...     LookupBranch, LookupTree)
-
-`LookupTree` encapsulates a simple tree structure that can be used to
-do lookups using one or more keys.
-
-A tree contains multiple branches. To find something in a tree, one or
-more keys are passed in. The second and subsequent keys are used if a
-branch off the tree leads to another tree... which breaks the analogy
-somewhat, but you get the picture :)
-
-For a given key, each branch in the tree is checked, in order, to see
-if it contains that key. If it does, the branch result is looked
-at.
-
-If the result is a tree, it is searched in the same way, but using the
-next key that was originally passed in.
-
-If the result is any other object, it is returned as the result of the
-search.
-
-Two things arise from this:
-
- * There can be more than one path through the tree to the same
-   result.
-
- * A search of the tree may return a result without consuming all of
-   the given keys.
-
-It is also possible to specify a default branch. This is done by
-creating a branch with no keys. This must be the last branch in the
-tree, because it would not make sense for it to appear in any other
-position.
-
-
-Creation
---------
-
-    >>> tree = LookupTree(
-    ...     ('Snack', LookupTree(
-    ...             ('Mars Bar', 'Snickers', 'Bad'),
-    ...             ('Apple', 'Banana', 'Good'))),
-    ...     LookupBranch('Lunch', 'Dinner', LookupTree(
-    ...             ('Fish and chips', "Penne all'arrabbiata", 'Nice'),
-    ...             ('Raw liver', 'Not so nice'))),
-    ...     ('Make up your mind!',),
-    ...     )
-
-Behind the scenes, `LookupTree` promotes plain tuples (or any
-iterable) into `LookupBranch` instances. This means that the last
-member of the tuple is the result of the branch. All the other members
-are keys.
-
-Tuples/branches without keys are default choices. They must come
-last. It doesn't make sense for a default to appear in any other
-position, because it would completely obscure the subsequent branches
-in the tree. Hence, attempting to specify a default branch before the
-last position is treated as an error.
-
-    >>> broken_tree = LookupTree(
-    ...     ('Free agents',),
-    ...     ('Alice', 'Bob', 'Allies of Schneier'))
-    Traceback (most recent call last):
-    ...
-    TypeError: Default branch must be last.
-
-To help when constructing more complex trees, an existing `LookupTree`
-instance can be passed in when constructing a new one. Its branches
-are copied into the new `LookupTree` at that point.
-
-    >>> breakfast_tree = LookupTree(
-    ...     ('Breakfast', 'Corn flakes'),
-    ...     tree,
-    ...     )
-
-    >>> len(tree.branches)
-    3
-    >>> len(breakfast_tree.branches)
-    4
-
-Although it should not happen in regular operation (because
-`LookupTree.__init__` ensures all arguments are `LookupBranch`
-instances), `LookupTree._verify` also checks that every branch is a
-`LookupBranch`.
-
-    >>> invalid_tree = LookupTree(tree)
-    >>> invalid_tree.branches = invalid_tree.branches + ('Greenland',)
-    >>> invalid_tree._verify()
-    Traceback (most recent call last):
-    ...
-    TypeError: Not a LookupBranch: ...'Greenland'
-
-
-Searching
----------
-
-Just call `tree.find`.
-
-    >>> print(tree.find('Snack', 'Banana'))
-    Good
-
-If you specify more keys than you need to reach a leaf, you still get
-the result.
-
-    >>> print(tree.find('Snack', 'Banana', 'Big', 'Yellow', 'Taxi'))
-    Good
-
-But an exception is raised if it does not reach a leaf.
-
-    >>> tree.find('Snack')
-    Traceback (most recent call last):
-    ...
-    KeyError: ...'Snack'
-
-
-Development
------------
-
-`LookupTree` makes development easy, because `describe` gives a
-complete description of the tree you've created.
-
-    >>> print(tree.describe())
-    tree(
-        branch(Snack => tree(
-            branch('Mars Bar', Snickers => 'Bad')
-            branch(Apple, Banana => 'Good')
-            ))
-        branch(Lunch, Dinner => tree(
-            branch('Fish and chips', "Penne all'arrabbiata" => 'Nice')
-            branch('Raw liver' => 'Not so nice')
-            ))
-        branch(* => 'Make up your mind!')
-        )
-
-We can also see that the result of constructing a new lookup using an
-existing one is the same as if we had constructed it independently.
-
-    >>> print(breakfast_tree.describe())
-    tree(
-        branch(Breakfast => 'Corn flakes')
-        branch(Snack => tree(
-            branch('Mars Bar', Snickers => 'Bad')
-            branch(Apple, Banana => 'Good')
-            ))
-        branch(Lunch, Dinner => tree(
-            branch('Fish and chips', "Penne all'arrabbiata" => 'Nice')
-            branch('Raw liver' => 'Not so nice')
-            ))
-        branch(* => 'Make up your mind!')
-        )
-
-Simple keys are shown without quotes, to aid readability, and default
-branches are shown with '*' as the key.
-
-
-Pruning
--------
-
-During tree creation, branches which have keys that already appear in
-earlier branches are cloned and have those already seen keys
-pruned. If all keys are removed from a branch it is discarded.
-
-The third branch in the following tree is discarded because 'Snack'
-already appears as a key in the first branch. The fourth branch is
-cloned then modified to remove the 'Lunch' key which already appeared
-in the second branch. The default branch is left unchanged; only
-branches with keys are candidates for being discarded.
-
-    >>> pruned_tree = LookupTree(
-    ...     ('Snack', 'Crisps'),
-    ...     ('Lunch', 'Bread'),
-    ...     ('Snack', 'Mars Bar'),
-    ...     ('Lunch', 'Dinner', 'Soup'),
-    ...     ('Eat more fruit and veg',),
-    ...     )
-    >>> print(pruned_tree.describe())
-    tree(
-        branch(Snack => 'Crisps')
-        branch(Lunch => 'Bread')
-        branch(Dinner => 'Soup')
-        branch(* => 'Eat more fruit and veg')
-        )
-
-
-Documentation
--------------
-
-You can discover the minimum and maximum depth of a tree.
-
-    >>> tree.min_depth
-    1
-    >>> tree.max_depth
-    2
-
-`LookupTree` has a `flatten` method that may be useful when generating
-documentation. It yields tuples of keys that represent paths to
-leaves.
-
-    >>> for elems in tree.flatten():
-    ...     path, result = elems[:-1], elems[-1]
-    ...     print(' => '.join(
-    ...         [pretty(node.keys) for node in path] + [pretty(result)]))
-    ('Snack',) => ('Mars Bar', 'Snickers') => 'Bad'
-    ('Snack',) => ('Apple', 'Banana') => 'Good'
-    ('Lunch', 'Dinner') => ('Fish and chips', "Penne all'arrabbiata")
-      => 'Nice'
-    ('Lunch', 'Dinner') => ('Raw liver',) => 'Not so nice'
-    () => 'Make up your mind!'
diff --git a/lib/lp/bugs/doc/vocabularies.rst b/lib/lp/bugs/doc/vocabularies.rst
new file mode 100644
index 0000000..a7916bc
--- /dev/null
+++ b/lib/lp/bugs/doc/vocabularies.rst
@@ -0,0 +1,334 @@
+Vocabularies
+============
+
+Introduction
+------------
+
+Vocabularies are lists of terms. In Launchpad's Component Architecture
+(CA), a vocabulary is a list of terms that a widget (normally a selection
+style widget) "speaks", i.e., its allowed values.
+
+    >>> from zope.component import getUtility
+    >>> from lp.testing import ANONYMOUS, login
+    >>> from lp.services.webapp.interfaces import IOpenLaunchBag
+    >>> from lp.registry.interfaces.person import IPersonSet
+    >>> from lp.registry.interfaces.product import IProductSet
+    >>> from lp.registry.interfaces.projectgroup import IProjectGroupSet
+    >>> person_set = getUtility(IPersonSet)
+    >>> product_set = getUtility(IProductSet)
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> launchbag = getUtility(IOpenLaunchBag)
+    >>> launchbag.clear()
+
+
+Values, Tokens, and Titles
+..........................
+
+In Launchpad, we generally use "tokenized vocabularies." Each term in
+a vocabulary has a value, token and title. A term is rendered in a
+select widget like this:
+
+<option value="$token">$title</option>
+
+The $token is probably the data you would store in your DB. The Token is
+used to uniquely identify a Term, and the Title is the thing you display
+to the user.
+
+
+Launchpad Vocabularies
+----------------------
+
+There are two kinds of vocabularies in Launchpad: enumerable and
+non-enumerable. Enumerable vocabularies are short enough to render in a
+select widget. Non-enumerable vocabularies require a query interface to make
+it easy to choose just one or a couple of options from several hundred,
+several thousand, or more.
+
+Vocabularies should not be imported - they can be retrieved from the
+vocabulary registry.
+
+    >>> from zope.schema.vocabulary import getVocabularyRegistry
+    >>> from zope.security.proxy import removeSecurityProxy
+    >>> vocabulary_registry = getVocabularyRegistry()
+    >>> def get_naked_vocab(context, name):
+    ...     return removeSecurityProxy(
+    ...         vocabulary_registry.get(context, name))
+    >>> product_vocabulary = vocabulary_registry.get(None, "Product")
+    >>> product_vocabulary.displayname
+    'Select a project'
+
+
+Enumerable Vocabularies
+-----------------------
+
+
+DistributionUsingMaloneVocabulary
+.................................
+
+All the distributions that use Malone as their main bug tracker.
+
+    >>> using_malone_vocabulary = get_naked_vocab(
+    ...     None, 'DistributionUsingMalone')
+    >>> len(using_malone_vocabulary)
+    2
+    >>> for term in using_malone_vocabulary:
+    ...     print(term.token, term.value.displayname, term.title)
+    gentoo Gentoo Gentoo
+    ubuntu Ubuntu Ubuntu
+
+    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+    >>> ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+    >>> ubuntu in using_malone_vocabulary
+    True
+    >>> debian = getUtility(ILaunchpadCelebrities).debian
+    >>> debian in using_malone_vocabulary
+    False
+
+    >>> term = using_malone_vocabulary.getTerm(ubuntu)
+    >>> print(term.token, term.value.displayname, term.title)
+    ubuntu Ubuntu Ubuntu
+
+    >>> term = using_malone_vocabulary.getTerm(debian)
+    Traceback (most recent call last):
+    ...
+    LookupError:...
+
+    >>> term = using_malone_vocabulary.getTermByToken('ubuntu')
+    >>> print(term.token, term.value.displayname, term.title)
+    ubuntu Ubuntu Ubuntu
+
+    >>> term = using_malone_vocabulary.getTermByToken('debian')
+    Traceback (most recent call last):
+    ...
+    LookupError:...
+
+
+BugNominatableSeriesVocabulary
+..............................
+
+All the series that can be nominated for fixing.
+
+This vocabulary needs either a product or distribution in the launchbag
+to get the available series. It also needs a bug, since it list only
+series that haven't already been nominated.
+
+Let's start with putting a product in the launchbag.
+
+    >>> firefox = product_set.getByName('firefox')
+    >>> getUtility(IOpenLaunchBag).clear()
+    >>> getUtility(IOpenLaunchBag).add(firefox)
+
+Firefox has the following series:
+
+    >>> for series in firefox.series:
+    ...     print(series.name)
+    1.0
+    trunk
+
+Now, if we look at bug one, we can see that it hasn't been targeted
+for any Firefox series yet:
+
+    >>> from lp.bugs.interfaces.bug import IBugSet
+
+    >>> bug_one = getUtility(IBugSet).get(1)
+    >>> for bugtask in bug_one.bugtasks:
+    ...     print(bugtask.bugtargetdisplayname)
+    Mozilla Firefox
+    mozilla-firefox (Ubuntu)
+    mozilla-firefox (Debian)
+
+It has however been nominated for 1.0:
+
+    >>> for nomination in bug_one.getNominations(firefox):
+    ...     print(nomination.target.name)
+    1.0
+
+This means that if we iterate through the vocabulary with bug one, only
+the trunk will be nominatable:
+
+    >>> firefox_bug_one = bug_one.bugtasks[0]
+    >>> print(firefox_bug_one.target.name)
+    firefox
+    >>> series_vocabulary = vocabulary_registry.get(
+    ...     firefox_bug_one, 'BugNominatableSeries')
+    >>> for term in series_vocabulary:
+    ...     print("%s: %s" % (term.token, term.title))
+    trunk: Trunk
+
+No series is targeted or nominated on bug 4:
+
+    >>> bug_four = getUtility(IBugSet).get(4)
+    >>> for bugtask in bug_four.bugtasks:
+    ...     print(bugtask.bugtargetdisplayname)
+    Mozilla Firefox
+
+    >>> for nomination in bug_four.getNominations(firefox):
+    ...     print(nomination.target.name)
+
+So if we give bug four to the vocabulary, all series will be returned:
+
+    >>> firefox_bug_four = bug_four.bugtasks[0]
+    >>> print(firefox_bug_four.target.name)
+    firefox
+    >>> series_vocabulary = vocabulary_registry.get(
+    ...     firefox_bug_four, 'BugNominatableSeries')
+    >>> for term in series_vocabulary:
+    ...     print("%s: %s" % (term.token, term.title))
+    1.0: 1.0
+    trunk: Trunk
+
+The same works for distributions:
+
+    >>> getUtility(IOpenLaunchBag).clear()
+    >>> getUtility(IOpenLaunchBag).add(ubuntu)
+
+Bug one is nominated for Ubuntu Hoary:
+
+    >>> bug_one = getUtility(IBugSet).get(1)
+    >>> for bugtask in bug_one.bugtasks:
+    ...     print(bugtask.bugtargetdisplayname)
+    Mozilla Firefox
+    mozilla-firefox (Ubuntu)
+    mozilla-firefox (Debian)
+
+    >>> for nomination in bug_one.getNominations(ubuntu):
+    ...     print(nomination.target.name)
+    hoary
+
+So Hoary isn't included in the vocabulary:
+
+    >>> ubuntu_bug_one = bug_one.bugtasks[1]
+    >>> print(ubuntu_bug_one.distribution.name)
+    ubuntu
+    >>> series_vocabulary = vocabulary_registry.get(
+    ...     ubuntu_bug_one, 'BugNominatableSeries')
+    >>> for term in series_vocabulary:
+    ...     print("%s: %s" % (term.token, term.title))
+    breezy-autotest: Breezy-autotest
+    grumpy: Grumpy
+    warty: Warty
+
+The same is true for bug two, where the bug is targeted to Hoary.
+
+    >>> bug_two = getUtility(IBugSet).get(2)
+    >>> for bugtask in bug_two.bugtasks:
+    ...     print(bugtask.bugtargetdisplayname)
+    Tomcat
+    Ubuntu
+    Ubuntu Hoary
+    mozilla-firefox (Debian)
+    mozilla-firefox (Debian Woody)
+
+    >>> for nomination in bug_two.getNominations(ubuntu):
+    ...     print(nomination.target.name)
+    hoary
+
+    >>> ubuntu_bug_two = bug_two.bugtasks[1]
+    >>> print(ubuntu_bug_two.distribution.name)
+    ubuntu
+    >>> series_vocabulary = vocabulary_registry.get(
+    ...     ubuntu_bug_two, 'BugNominatableSeries')
+    >>> for term in series_vocabulary:
+    ...     print("%s: %s" % (term.token, term.title))
+    breezy-autotest: Breezy-autotest
+    grumpy: Grumpy
+    warty: Warty
+
+We can get a specific term by using the release name:
+
+    >>> term = series_vocabulary.getTermByToken('warty')
+    >>> term.value == ubuntu.getSeries('warty')
+    True
+
+Trying to get a non-existent release will result in a
+NoSuchDistroSeries error.
+
+    >>> series_vocabulary.getTermByToken('non-such-release')
+    Traceback (most recent call last):
+    ...
+    lp.registry.errors.NoSuchDistroSeries: ...
+
+
+ProjectProductsVocabularyUsingMalone
+....................................
+
+All the products in a project using Malone.
+
+
+    >>> mozilla_project = getUtility(IProjectGroupSet).getByName('mozilla')
+    >>> for product in mozilla_project.products:
+    ...     print("%s: %s" % (product.name, product.bug_tracking_usage.name))
+    firefox: LAUNCHPAD
+    thunderbird: UNKNOWN
+
+    >>> mozilla_products_vocabulary = vocabulary_registry.get(
+    ...     mozilla_project,'ProjectProductsUsingMalone')
+    >>> for term in mozilla_products_vocabulary:
+    ...     print("%s: %s" %(term.token, term.title))
+    firefox: Mozilla Firefox
+
+
+Non-Enumerable Vocabularies
+---------------------------
+
+Iterating over non-enumerable vocabularies, while possible, will
+probably kill the database. Instead, these vocabularies are
+search-driven.
+
+
+BugWatchVocabulary
+..................
+
+All bug watches associated with a bugtask's bug.
+
+    >>> bug_one = getUtility(IBugSet).get(1)
+    >>> bugtask = bug_one.bugtasks[0]
+    >>> vocab = vocabulary_registry.get(bugtask, "BugWatch")
+    >>> for term in vocab:
+    ...     print(term.title)
+    The Mozilla.org Bug Tracker <a...>#123543</a>
+    The Mozilla.org Bug Tracker <a...>#2000</a>
+    The Mozilla.org Bug Tracker <a...>#42</a>
+    Debian Bug tracker <a...>#304014</a>
+
+Bug watches with an email address URL (i.e. starts with "mailto:";) are
+treated differently.
+
+    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
+    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
+
+    >>> bug_twelve = getUtility(IBugSet).get(12)
+    >>> email_bugtracker = getUtility(IBugTrackerSet).getByName('email')
+    >>> email_bugwatch = getUtility(IBugWatchSet).createBugWatch(
+    ...     bug_twelve, launchbag.user, email_bugtracker, '')
+    >>> print(email_bugwatch.url)
+    mailto:bugs@xxxxxxxxxxx
+
+The title is rendered differently compared to other bug watches.
+
+    >>> bugtask = bug_twelve.bugtasks[0]
+    >>> vocab = vocabulary_registry.get(bugtask, "BugWatch")
+    >>> for term in vocab:
+    ...     print(term.title)
+    Email bugtracker &lt;<a...>bugs@xxxxxxxxxxx</a>&gt;
+
+Additionally, if the bug tracker's title contains the bug tracker's
+URL, then the title is linkified instead.
+
+    >>> email_bugtracker.title = (
+    ...     'Lionel Richtea (%s)' % (
+    ...         email_bugtracker.baseurl,))
+
+    >>> for term in vocab:
+    ...     print(term.title)
+    Lionel Richtea (<a...>mailto:bugs@xxxxxxxxxxx</a>)
+
+When there is no logged-in user, the title is much different. The
+email address is hidden, and there is no hyperlink.
+
+    >>> current_user = launchbag.user
+    >>> login(ANONYMOUS)
+
+    >>> for term in vocab:
+    ...     print(term.title)
+    Lionel Richtea (mailto:&lt;email address hidden&gt;)
diff --git a/lib/lp/bugs/doc/vocabularies.txt b/lib/lp/bugs/doc/vocabularies.txt
deleted file mode 100644
index a7916bc..0000000
--- a/lib/lp/bugs/doc/vocabularies.txt
+++ /dev/null
@@ -1,334 +0,0 @@
-Vocabularies
-============
-
-Introduction
-------------
-
-Vocabularies are lists of terms. In Launchpad's Component Architecture
-(CA), a vocabulary is a list of terms that a widget (normally a selection
-style widget) "speaks", i.e., its allowed values.
-
-    >>> from zope.component import getUtility
-    >>> from lp.testing import ANONYMOUS, login
-    >>> from lp.services.webapp.interfaces import IOpenLaunchBag
-    >>> from lp.registry.interfaces.person import IPersonSet
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> from lp.registry.interfaces.projectgroup import IProjectGroupSet
-    >>> person_set = getUtility(IPersonSet)
-    >>> product_set = getUtility(IProductSet)
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> launchbag = getUtility(IOpenLaunchBag)
-    >>> launchbag.clear()
-
-
-Values, Tokens, and Titles
-..........................
-
-In Launchpad, we generally use "tokenized vocabularies." Each term in
-a vocabulary has a value, token and title. A term is rendered in a
-select widget like this:
-
-<option value="$token">$title</option>
-
-The $token is probably the data you would store in your DB. The Token is
-used to uniquely identify a Term, and the Title is the thing you display
-to the user.
-
-
-Launchpad Vocabularies
-----------------------
-
-There are two kinds of vocabularies in Launchpad: enumerable and
-non-enumerable. Enumerable vocabularies are short enough to render in a
-select widget. Non-enumerable vocabularies require a query interface to make
-it easy to choose just one or a couple of options from several hundred,
-several thousand, or more.
-
-Vocabularies should not be imported - they can be retrieved from the
-vocabulary registry.
-
-    >>> from zope.schema.vocabulary import getVocabularyRegistry
-    >>> from zope.security.proxy import removeSecurityProxy
-    >>> vocabulary_registry = getVocabularyRegistry()
-    >>> def get_naked_vocab(context, name):
-    ...     return removeSecurityProxy(
-    ...         vocabulary_registry.get(context, name))
-    >>> product_vocabulary = vocabulary_registry.get(None, "Product")
-    >>> product_vocabulary.displayname
-    'Select a project'
-
-
-Enumerable Vocabularies
------------------------
-
-
-DistributionUsingMaloneVocabulary
-.................................
-
-All the distributions that use Malone as their main bug tracker.
-
-    >>> using_malone_vocabulary = get_naked_vocab(
-    ...     None, 'DistributionUsingMalone')
-    >>> len(using_malone_vocabulary)
-    2
-    >>> for term in using_malone_vocabulary:
-    ...     print(term.token, term.value.displayname, term.title)
-    gentoo Gentoo Gentoo
-    ubuntu Ubuntu Ubuntu
-
-    >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-    >>> ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
-    >>> ubuntu in using_malone_vocabulary
-    True
-    >>> debian = getUtility(ILaunchpadCelebrities).debian
-    >>> debian in using_malone_vocabulary
-    False
-
-    >>> term = using_malone_vocabulary.getTerm(ubuntu)
-    >>> print(term.token, term.value.displayname, term.title)
-    ubuntu Ubuntu Ubuntu
-
-    >>> term = using_malone_vocabulary.getTerm(debian)
-    Traceback (most recent call last):
-    ...
-    LookupError:...
-
-    >>> term = using_malone_vocabulary.getTermByToken('ubuntu')
-    >>> print(term.token, term.value.displayname, term.title)
-    ubuntu Ubuntu Ubuntu
-
-    >>> term = using_malone_vocabulary.getTermByToken('debian')
-    Traceback (most recent call last):
-    ...
-    LookupError:...
-
-
-BugNominatableSeriesVocabulary
-..............................
-
-All the series that can be nominated for fixing.
-
-This vocabulary needs either a product or distribution in the launchbag
-to get the available series. It also needs a bug, since it list only
-series that haven't already been nominated.
-
-Let's start with putting a product in the launchbag.
-
-    >>> firefox = product_set.getByName('firefox')
-    >>> getUtility(IOpenLaunchBag).clear()
-    >>> getUtility(IOpenLaunchBag).add(firefox)
-
-Firefox has the following series:
-
-    >>> for series in firefox.series:
-    ...     print(series.name)
-    1.0
-    trunk
-
-Now, if we look at bug one, we can see that it hasn't been targeted
-for any Firefox series yet:
-
-    >>> from lp.bugs.interfaces.bug import IBugSet
-
-    >>> bug_one = getUtility(IBugSet).get(1)
-    >>> for bugtask in bug_one.bugtasks:
-    ...     print(bugtask.bugtargetdisplayname)
-    Mozilla Firefox
-    mozilla-firefox (Ubuntu)
-    mozilla-firefox (Debian)
-
-It has however been nominated for 1.0:
-
-    >>> for nomination in bug_one.getNominations(firefox):
-    ...     print(nomination.target.name)
-    1.0
-
-This means that if we iterate through the vocabulary with bug one, only
-the trunk will be nominatable:
-
-    >>> firefox_bug_one = bug_one.bugtasks[0]
-    >>> print(firefox_bug_one.target.name)
-    firefox
-    >>> series_vocabulary = vocabulary_registry.get(
-    ...     firefox_bug_one, 'BugNominatableSeries')
-    >>> for term in series_vocabulary:
-    ...     print("%s: %s" % (term.token, term.title))
-    trunk: Trunk
-
-No series is targeted or nominated on bug 4:
-
-    >>> bug_four = getUtility(IBugSet).get(4)
-    >>> for bugtask in bug_four.bugtasks:
-    ...     print(bugtask.bugtargetdisplayname)
-    Mozilla Firefox
-
-    >>> for nomination in bug_four.getNominations(firefox):
-    ...     print(nomination.target.name)
-
-So if we give bug four to the vocabulary, all series will be returned:
-
-    >>> firefox_bug_four = bug_four.bugtasks[0]
-    >>> print(firefox_bug_four.target.name)
-    firefox
-    >>> series_vocabulary = vocabulary_registry.get(
-    ...     firefox_bug_four, 'BugNominatableSeries')
-    >>> for term in series_vocabulary:
-    ...     print("%s: %s" % (term.token, term.title))
-    1.0: 1.0
-    trunk: Trunk
-
-The same works for distributions:
-
-    >>> getUtility(IOpenLaunchBag).clear()
-    >>> getUtility(IOpenLaunchBag).add(ubuntu)
-
-Bug one is nominated for Ubuntu Hoary:
-
-    >>> bug_one = getUtility(IBugSet).get(1)
-    >>> for bugtask in bug_one.bugtasks:
-    ...     print(bugtask.bugtargetdisplayname)
-    Mozilla Firefox
-    mozilla-firefox (Ubuntu)
-    mozilla-firefox (Debian)
-
-    >>> for nomination in bug_one.getNominations(ubuntu):
-    ...     print(nomination.target.name)
-    hoary
-
-So Hoary isn't included in the vocabulary:
-
-    >>> ubuntu_bug_one = bug_one.bugtasks[1]
-    >>> print(ubuntu_bug_one.distribution.name)
-    ubuntu
-    >>> series_vocabulary = vocabulary_registry.get(
-    ...     ubuntu_bug_one, 'BugNominatableSeries')
-    >>> for term in series_vocabulary:
-    ...     print("%s: %s" % (term.token, term.title))
-    breezy-autotest: Breezy-autotest
-    grumpy: Grumpy
-    warty: Warty
-
-The same is true for bug two, where the bug is targeted to Hoary.
-
-    >>> bug_two = getUtility(IBugSet).get(2)
-    >>> for bugtask in bug_two.bugtasks:
-    ...     print(bugtask.bugtargetdisplayname)
-    Tomcat
-    Ubuntu
-    Ubuntu Hoary
-    mozilla-firefox (Debian)
-    mozilla-firefox (Debian Woody)
-
-    >>> for nomination in bug_two.getNominations(ubuntu):
-    ...     print(nomination.target.name)
-    hoary
-
-    >>> ubuntu_bug_two = bug_two.bugtasks[1]
-    >>> print(ubuntu_bug_two.distribution.name)
-    ubuntu
-    >>> series_vocabulary = vocabulary_registry.get(
-    ...     ubuntu_bug_two, 'BugNominatableSeries')
-    >>> for term in series_vocabulary:
-    ...     print("%s: %s" % (term.token, term.title))
-    breezy-autotest: Breezy-autotest
-    grumpy: Grumpy
-    warty: Warty
-
-We can get a specific term by using the release name:
-
-    >>> term = series_vocabulary.getTermByToken('warty')
-    >>> term.value == ubuntu.getSeries('warty')
-    True
-
-Trying to get a non-existent release will result in a
-NoSuchDistroSeries error.
-
-    >>> series_vocabulary.getTermByToken('non-such-release')
-    Traceback (most recent call last):
-    ...
-    lp.registry.errors.NoSuchDistroSeries: ...
-
-
-ProjectProductsVocabularyUsingMalone
-....................................
-
-All the products in a project using Malone.
-
-
-    >>> mozilla_project = getUtility(IProjectGroupSet).getByName('mozilla')
-    >>> for product in mozilla_project.products:
-    ...     print("%s: %s" % (product.name, product.bug_tracking_usage.name))
-    firefox: LAUNCHPAD
-    thunderbird: UNKNOWN
-
-    >>> mozilla_products_vocabulary = vocabulary_registry.get(
-    ...     mozilla_project,'ProjectProductsUsingMalone')
-    >>> for term in mozilla_products_vocabulary:
-    ...     print("%s: %s" %(term.token, term.title))
-    firefox: Mozilla Firefox
-
-
-Non-Enumerable Vocabularies
----------------------------
-
-Iterating over non-enumerable vocabularies, while possible, will
-probably kill the database. Instead, these vocabularies are
-search-driven.
-
-
-BugWatchVocabulary
-..................
-
-All bug watches associated with a bugtask's bug.
-
-    >>> bug_one = getUtility(IBugSet).get(1)
-    >>> bugtask = bug_one.bugtasks[0]
-    >>> vocab = vocabulary_registry.get(bugtask, "BugWatch")
-    >>> for term in vocab:
-    ...     print(term.title)
-    The Mozilla.org Bug Tracker <a...>#123543</a>
-    The Mozilla.org Bug Tracker <a...>#2000</a>
-    The Mozilla.org Bug Tracker <a...>#42</a>
-    Debian Bug tracker <a...>#304014</a>
-
-Bug watches with an email address URL (i.e. starts with "mailto:";) are
-treated differently.
-
-    >>> from lp.bugs.interfaces.bugtracker import IBugTrackerSet
-    >>> from lp.bugs.interfaces.bugwatch import IBugWatchSet
-
-    >>> bug_twelve = getUtility(IBugSet).get(12)
-    >>> email_bugtracker = getUtility(IBugTrackerSet).getByName('email')
-    >>> email_bugwatch = getUtility(IBugWatchSet).createBugWatch(
-    ...     bug_twelve, launchbag.user, email_bugtracker, '')
-    >>> print(email_bugwatch.url)
-    mailto:bugs@xxxxxxxxxxx
-
-The title is rendered differently compared to other bug watches.
-
-    >>> bugtask = bug_twelve.bugtasks[0]
-    >>> vocab = vocabulary_registry.get(bugtask, "BugWatch")
-    >>> for term in vocab:
-    ...     print(term.title)
-    Email bugtracker &lt;<a...>bugs@xxxxxxxxxxx</a>&gt;
-
-Additionally, if the bug tracker's title contains the bug tracker's
-URL, then the title is linkified instead.
-
-    >>> email_bugtracker.title = (
-    ...     'Lionel Richtea (%s)' % (
-    ...         email_bugtracker.baseurl,))
-
-    >>> for term in vocab:
-    ...     print(term.title)
-    Lionel Richtea (<a...>mailto:bugs@xxxxxxxxxxx</a>)
-
-When there is no logged-in user, the title is much different. The
-email address is hidden, and there is no hyperlink.
-
-    >>> current_user = launchbag.user
-    >>> login(ANONYMOUS)
-
-    >>> for term in vocab:
-    ...     print(term.title)
-    Lionel Richtea (mailto:&lt;email address hidden&gt;)
diff --git a/lib/lp/bugs/interfaces/bugtask.py b/lib/lp/bugs/interfaces/bugtask.py
index d228e16..d68c28b 100644
--- a/lib/lp/bugs/interfaces/bugtask.py
+++ b/lib/lp/bugs/interfaces/bugtask.py
@@ -799,7 +799,7 @@ class IBugTask(IHasBug, IBugTaskDelete):
 
           product=firefox; status=New; importance=Critical; assignee=None;
 
-        See doc/bugmail-headers.txt for a complete explanation and more
+        See doc/bugmail-headers.rst for a complete explanation and more
         examples.
         """
 
diff --git a/lib/lp/bugs/interfaces/bugtasksearch.py b/lib/lp/bugs/interfaces/bugtasksearch.py
index fd7f88c..c89c736 100644
--- a/lib/lp/bugs/interfaces/bugtasksearch.py
+++ b/lib/lp/bugs/interfaces/bugtasksearch.py
@@ -145,7 +145,7 @@ class BugTaskSearchParams:
 
     For a more thorough treatment, check out:
 
-        lib/lp/bugs/doc/bugtask-search.txt
+        lib/lp/bugs/doc/bugtask-search.rst
     """
 
     product = None
diff --git a/lib/lp/bugs/model/bugnotification.py b/lib/lp/bugs/model/bugnotification.py
index 3a30136..dc9794f 100644
--- a/lib/lp/bugs/model/bugnotification.py
+++ b/lib/lp/bugs/model/bugnotification.py
@@ -194,7 +194,7 @@ class BugNotificationSet:
             status=status)
         store = Store.of(bug_notification)
         # XXX jamesh 2008-05-21: these flushes are to fix ordering
-        # problems in the bugnotification-sending.txt tests.
+        # problems in the bugnotification-sending.rst tests.
         store.flush()
 
         bulk.create(
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-distribution-default-values.txt b/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-distribution-default-values.rst
similarity index 100%
rename from lib/lp/bugs/stories/bug-also-affects/xx-also-affects-distribution-default-values.txt
rename to lib/lp/bugs/stories/bug-also-affects/xx-also-affects-distribution-default-values.rst
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.rst b/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.rst
new file mode 100644
index 0000000..876f24e
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.rst
@@ -0,0 +1,116 @@
+Registering an upstream affected by a given bug
+===============================================
+
+The test browser does not support javascript
+    >>> user_browser.open(
+    ...     'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
+    >>> find_link = user_browser.getLink('Find')
+    >>> find_link.url
+    'http://launchpad.test/firefox/+bug...'
+
+    >>> user_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/+bug/1/+affects-new-product')
+    >>> user_browser.getControl('Bug URL').value = (
+    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=42')
+    >>> user_browser.getControl('Project name').value = 'The Foo Project'
+    >>> user_browser.getControl('Project ID').value = 'foo'
+    >>> user_browser.getControl('Project summary').value = 'The Foo Project'
+    >>> user_browser.getControl('Continue').click()
+
+We're now redirected to the newly created bugtask page.
+
+    >>> user_browser.title
+    'Bug #1 ... : Bugs : The Foo Project'
+
+When creating a new upstream through this page we'll check if there's any
+upstream already registered in Launchpad which uses the same bugtracker as
+the one specified by the user. If there are any we present them as options
+for the user to use as the affected upstream.
+
+    >>> from lp.testing.pages import strip_label
+
+    >>> user_browser.open(
+    ...     'http://bugs.launchpad.test/tomcat/+bug/2/+affects-new-product')
+    >>> print(user_browser.title)
+    Register project affected by...
+    >>> user_browser.getControl('Bug URL').value = (
+    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=421')
+    >>> user_browser.getControl('Project name').value = 'The Bar Project'
+    >>> user_browser.getControl('Project ID').value = 'bar'
+    >>> user_browser.getControl('Project summary').value = 'The Bar Project'
+    >>> user_browser.getControl('Continue').click()
+
+    >>> print(user_browser.title)
+    Register project affected by...
+
+    >>> print_feedback_messages(user_browser.contents)
+    There are some projects using the bug tracker you specified. One of
+    these may be the one you were trying to register.
+    >>> control = user_browser.getControl(name='field.existing_product')
+    >>> [strip_label(label) for label in control.displayValue]
+    ['The Foo Project']
+
+Now we can either choose to report the bug as affecting our existing Foo
+Project or create the new Bar Project.
+
+    >>> user_browser.getControl('Use Existing Project')
+    <SubmitControl name='field.actions.use_existing_product' type='submit'>
+    >>> user_browser.getControl('Continue')
+    <SubmitControl name='field.actions.continue' type='submit'>
+
+First, let's use the existing project.
+
+    >>> user_browser.getControl('Use Existing Project').click()
+    >>> user_browser.title
+    'Bug #2 (blackhole) ... : Bugs : The Foo Project'
+
+    >>> from lp.bugs.tests.bug import print_remote_bugtasks
+    >>> print_remote_bugtasks(user_browser.contents)
+    The Foo Project ...    auto-bugs.foo.org #421
+
+If we try using that same existing project again, we'll get an error
+explaining we can't because it's already known that it's affected by
+this bug.
+
+    >>> user_browser.goBack()
+    >>> user_browser.getControl('Use Existing Project').click()
+    >>> print(user_browser.title)
+    Register project affected by...
+    >>> print_feedback_messages(user_browser.contents)
+    There is 1 error.
+    ...
+    A fix for this bug has already been requested for The Foo Project
+
+Now we'll tell Launchpad to not use the existing upstream as we want to report
+the bug as affecting another (unregistered) upstream.
+
+    >>> user_browser.goBack()
+    >>> user_browser.getControl('Bug URL').value = (
+    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=123')
+    >>> user_browser.getControl('Continue').click()
+    >>> user_browser.title
+    'Bug #2 (blackhole) ... : Bugs : The Bar Project'
+    >>> print_remote_bugtasks(user_browser.contents)
+    The Bar Project ...   auto-bugs.foo.org #123
+    The Bar Project ...   auto-bugs.foo.org #421
+
+Error handling
+--------------
+
+If the URL of the remote bug is not recognized by Launchpad, we'll tell the
+user and ask them to check if it's correct.
+
+    >>> user_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/+bug/1/+affects-new-product')
+    >>> user_browser.getControl('Bug URL').value = (
+    ...     'http://foo.org/notabug.cgi?id=42')
+    >>> user_browser.getControl('Project name').value = 'Foo Project'
+    >>> user_browser.getControl('Project ID').value = 'bazfoo'
+    >>> user_browser.getControl('Project summary').value = 'The Foo Project'
+    >>> user_browser.getControl('Continue').click()
+    >>> print(user_browser.title)
+    Register project affected by...
+    >>> print_feedback_messages(user_browser.contents)
+    There is 1 error.
+    Launchpad does not recognize the bug tracker at this URL.
+
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.txt b/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.txt
deleted file mode 100644
index 876f24e..0000000
--- a/lib/lp/bugs/stories/bug-also-affects/xx-also-affects-new-upstream.txt
+++ /dev/null
@@ -1,116 +0,0 @@
-Registering an upstream affected by a given bug
-===============================================
-
-The test browser does not support javascript
-    >>> user_browser.open(
-    ...     'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
-    >>> find_link = user_browser.getLink('Find')
-    >>> find_link.url
-    'http://launchpad.test/firefox/+bug...'
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/+bug/1/+affects-new-product')
-    >>> user_browser.getControl('Bug URL').value = (
-    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=42')
-    >>> user_browser.getControl('Project name').value = 'The Foo Project'
-    >>> user_browser.getControl('Project ID').value = 'foo'
-    >>> user_browser.getControl('Project summary').value = 'The Foo Project'
-    >>> user_browser.getControl('Continue').click()
-
-We're now redirected to the newly created bugtask page.
-
-    >>> user_browser.title
-    'Bug #1 ... : Bugs : The Foo Project'
-
-When creating a new upstream through this page we'll check if there's any
-upstream already registered in Launchpad which uses the same bugtracker as
-the one specified by the user. If there are any we present them as options
-for the user to use as the affected upstream.
-
-    >>> from lp.testing.pages import strip_label
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.test/tomcat/+bug/2/+affects-new-product')
-    >>> print(user_browser.title)
-    Register project affected by...
-    >>> user_browser.getControl('Bug URL').value = (
-    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=421')
-    >>> user_browser.getControl('Project name').value = 'The Bar Project'
-    >>> user_browser.getControl('Project ID').value = 'bar'
-    >>> user_browser.getControl('Project summary').value = 'The Bar Project'
-    >>> user_browser.getControl('Continue').click()
-
-    >>> print(user_browser.title)
-    Register project affected by...
-
-    >>> print_feedback_messages(user_browser.contents)
-    There are some projects using the bug tracker you specified. One of
-    these may be the one you were trying to register.
-    >>> control = user_browser.getControl(name='field.existing_product')
-    >>> [strip_label(label) for label in control.displayValue]
-    ['The Foo Project']
-
-Now we can either choose to report the bug as affecting our existing Foo
-Project or create the new Bar Project.
-
-    >>> user_browser.getControl('Use Existing Project')
-    <SubmitControl name='field.actions.use_existing_product' type='submit'>
-    >>> user_browser.getControl('Continue')
-    <SubmitControl name='field.actions.continue' type='submit'>
-
-First, let's use the existing project.
-
-    >>> user_browser.getControl('Use Existing Project').click()
-    >>> user_browser.title
-    'Bug #2 (blackhole) ... : Bugs : The Foo Project'
-
-    >>> from lp.bugs.tests.bug import print_remote_bugtasks
-    >>> print_remote_bugtasks(user_browser.contents)
-    The Foo Project ...    auto-bugs.foo.org #421
-
-If we try using that same existing project again, we'll get an error
-explaining we can't because it's already known that it's affected by
-this bug.
-
-    >>> user_browser.goBack()
-    >>> user_browser.getControl('Use Existing Project').click()
-    >>> print(user_browser.title)
-    Register project affected by...
-    >>> print_feedback_messages(user_browser.contents)
-    There is 1 error.
-    ...
-    A fix for this bug has already been requested for The Foo Project
-
-Now we'll tell Launchpad to not use the existing upstream as we want to report
-the bug as affecting another (unregistered) upstream.
-
-    >>> user_browser.goBack()
-    >>> user_browser.getControl('Bug URL').value = (
-    ...     'http://bugs.foo.org/bugs/show_bug.cgi?id=123')
-    >>> user_browser.getControl('Continue').click()
-    >>> user_browser.title
-    'Bug #2 (blackhole) ... : Bugs : The Bar Project'
-    >>> print_remote_bugtasks(user_browser.contents)
-    The Bar Project ...   auto-bugs.foo.org #123
-    The Bar Project ...   auto-bugs.foo.org #421
-
-Error handling
---------------
-
-If the URL of the remote bug is not recognized by Launchpad, we'll tell the
-user and ask them to check if it's correct.
-
-    >>> user_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/+bug/1/+affects-new-product')
-    >>> user_browser.getControl('Bug URL').value = (
-    ...     'http://foo.org/notabug.cgi?id=42')
-    >>> user_browser.getControl('Project name').value = 'Foo Project'
-    >>> user_browser.getControl('Project ID').value = 'bazfoo'
-    >>> user_browser.getControl('Project summary').value = 'The Foo Project'
-    >>> user_browser.getControl('Continue').click()
-    >>> print(user_browser.title)
-    Register project affected by...
-    >>> print_feedback_messages(user_browser.contents)
-    There is 1 error.
-    Launchpad does not recognize the bug tracker at this URL.
-
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-bug-also-affects.txt b/lib/lp/bugs/stories/bug-also-affects/xx-bug-also-affects.rst
similarity index 100%
rename from lib/lp/bugs/stories/bug-also-affects/xx-bug-also-affects.txt
rename to lib/lp/bugs/stories/bug-also-affects/xx-bug-also-affects.rst
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.rst b/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.rst
new file mode 100644
index 0000000..a238e64
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.rst
@@ -0,0 +1,57 @@
+Bug tracker information
+=======================
+
+If a product doesn't use Launchpad to track its bugs, there's
+information about the product's bug tracker when adding an upstream
+task.
+
+    >>> user_browser.open(
+    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
+    >>> user_browser.getControl('Project').value = 'gnome-terminal'
+    >>> user_browser.getControl('Continue').click()
+    >>> print(user_browser.contents)
+    <...GNOME Terminal uses
+    <a href="http://bugzilla.gnome.org/bugs";>GnomeGBug GTracker</a>
+    to track its bugs...
+
+    >>> from lp.bugs.tests.bug import print_upstream_linking_form
+    >>> print_upstream_linking_form(user_browser)
+    (*) I have the URL for the upstream bug:
+        [          ]
+    ( ) I have already emailed an upstream bug contact:
+        [          ]
+    ( ) I want to add this upstream project to the bug report, but someone
+        must find or report this bug in the upstream bug tracker.
+
+If a product doesn't use Launchpad, and doesn't have a bug tracker
+specified, it will simply say that it doesn't use Launchpad to track
+its bugs and prompt for a URL or an email address.
+
+    >>> user_browser.open(
+    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
+    >>> user_browser.getControl('Project').value = 'thunderbird'
+    >>> user_browser.getControl('Continue').click()
+    >>> print(user_browser.contents)
+    <...Mozilla Thunderbird doesn't use Launchpad to track its bugs...
+
+    >>> print_upstream_linking_form(user_browser)
+    (*) I have the URL for the upstream bug:
+        [          ]
+    ( ) I have already emailed an upstream bug contact:
+        [          ]
+    ( ) I want to add this upstream project to the bug report, but someone
+        must find or report this bug in the upstream bug tracker.
+
+For products using Launchpad, the linking upstream widgets won't even
+appear.
+
+    >>> user_browser.open(
+    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
+    >>> user_browser.getControl('Project').value = 'evolution'
+    >>> user_browser.getControl('Continue').click()
+
+    >>> print_upstream_linking_form(user_browser)
+    Traceback (most recent call last):
+    ...
+    LookupError: name 'field.link_upstream_how'
+    ...
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.txt b/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.txt
deleted file mode 100644
index a238e64..0000000
--- a/lib/lp/bugs/stories/bug-also-affects/xx-bugtracker-information.txt
+++ /dev/null
@@ -1,57 +0,0 @@
-Bug tracker information
-=======================
-
-If a product doesn't use Launchpad to track its bugs, there's
-information about the product's bug tracker when adding an upstream
-task.
-
-    >>> user_browser.open(
-    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
-    >>> user_browser.getControl('Project').value = 'gnome-terminal'
-    >>> user_browser.getControl('Continue').click()
-    >>> print(user_browser.contents)
-    <...GNOME Terminal uses
-    <a href="http://bugzilla.gnome.org/bugs";>GnomeGBug GTracker</a>
-    to track its bugs...
-
-    >>> from lp.bugs.tests.bug import print_upstream_linking_form
-    >>> print_upstream_linking_form(user_browser)
-    (*) I have the URL for the upstream bug:
-        [          ]
-    ( ) I have already emailed an upstream bug contact:
-        [          ]
-    ( ) I want to add this upstream project to the bug report, but someone
-        must find or report this bug in the upstream bug tracker.
-
-If a product doesn't use Launchpad, and doesn't have a bug tracker
-specified, it will simply say that it doesn't use Launchpad to track
-its bugs and prompt for a URL or an email address.
-
-    >>> user_browser.open(
-    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
-    >>> user_browser.getControl('Project').value = 'thunderbird'
-    >>> user_browser.getControl('Continue').click()
-    >>> print(user_browser.contents)
-    <...Mozilla Thunderbird doesn't use Launchpad to track its bugs...
-
-    >>> print_upstream_linking_form(user_browser)
-    (*) I have the URL for the upstream bug:
-        [          ]
-    ( ) I have already emailed an upstream bug contact:
-        [          ]
-    ( ) I want to add this upstream project to the bug report, but someone
-        must find or report this bug in the upstream bug tracker.
-
-For products using Launchpad, the linking upstream widgets won't even
-appear.
-
-    >>> user_browser.open(
-    ...    'http://launchpad.test/firefox/+bug/1/+choose-affected-product')
-    >>> user_browser.getControl('Project').value = 'evolution'
-    >>> user_browser.getControl('Continue').click()
-
-    >>> print_upstream_linking_form(user_browser)
-    Traceback (most recent call last):
-    ...
-    LookupError: name 'field.link_upstream_how'
-    ...
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.rst b/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.rst
new file mode 100644
index 0000000..53c99f1
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.rst
@@ -0,0 +1,91 @@
+Duplicate bug watches
+=====================
+
+Adding the same bug watch twice to a bug
+----------------------------------------
+
+When adding bug watches, existing bug watches are re-used if there
+already is one pointing to the same remote bug. For example, let's start
+with adding a Debian bug watch to bug #8.
+
+    >>> debian_bug = 'http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=42'
+    >>> user_browser.open(
+    ...     'http://launchpad.test/debian/+source/mozilla-firefox/'
+    ...     '+bug/8/')
+    >>> user_browser.getLink(url='+distrotask').click()
+    >>> user_browser.getControl('Distribution').value = ['debian']
+    >>> user_browser.getControl('Source Package Name').value = 'alsa-utils'
+    >>> user_browser.getControl('URL').value = debian_bug
+    >>> user_browser.getControl('Continue').click()
+
+Now we can see the added bug watch in the bug watch portlet.
+
+    >>> bugwatch_portlet = find_portlet(
+    ...     user_browser.contents, 'Remote bug watches')
+    >>> for li_tag in bugwatch_portlet.find_all('li'):
+    ...     print(li_tag.find_all('a')[0].decode_contents())
+    debbugs #42
+
+If we add another bug watch, pointing to the same URL, the previous one
+will be used; i.e., another one won't be added.
+
+    >>> from zope.component import getUtility
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> factory.makeSourcePackage(
+    ...     distroseries=getUtility(IDistributionSet)['debian']['sid'],
+    ...     sourcepackagename='pmount',
+    ...     publish=True)
+    <SourcePackage ...>
+    >>> logout()
+    >>> user_browser.getLink(url='+distrotask').click()
+    >>> user_browser.getControl('Distribution').value = ['debian']
+    >>> user_browser.getControl('Source Package Name').value = 'pmount'
+    >>> user_browser.getControl('URL').value = debian_bug
+    >>> user_browser.getControl('Continue').click()
+    >>> for tag in find_tags_by_class(user_browser.contents, 'message'):
+    ...   print(tag)
+
+    >>> bugwatch_portlet = find_portlet(
+    ...     user_browser.contents, 'Remote bug watches')
+    >>> for li_tag in bugwatch_portlet.find_all('li'):
+    ...     print(li_tag.find_all('a')[0].string)
+    debbugs #42
+
+Both the thunderbird and gnome-terminal Debian tasks are pointing to the
+same bug watch.
+
+    >>> user_browser.open(
+    ...     'http://launchpad.test/debian/+source/mozilla-firefox/'
+    ...     '+bug/8/')
+
+    >>> from lp.bugs.tests.bug import print_bug_affects_table
+    >>> print_bug_affects_table(user_browser.contents)
+    alsa-utils (Debian) ... Unknown  Unknown  debbugs #42
+    ...
+    pmount (Debian)     ... Unknown  Unknown  debbugs #42
+
+
+Adding the same bug watch to two different bugs
+-----------------------------------------------
+
+If a bug watch which is already added to another bug is added, a
+notification is added linking to the bug. This is useful for detecting
+duplicates.
+
+    >>> user_browser.open('http://launchpad.test/bugs/5')
+    >>> user_browser.getLink(url='+distrotask').click()
+    >>> user_browser.getControl('Distribution').value = ['debian']
+    >>> user_browser.getControl('Source Package Name').value = (
+    ...     'mozilla-firefox')
+    >>> user_browser.getControl('URL').value = debian_bug
+    >>> user_browser.getControl('Continue').click()
+    >>> print_feedback_messages(user_browser.contents)
+    Bug #8 also links to the added bug watch (debbugs #42).
+
+The notification links to the bug in question.
+
+    >>> user_browser.getLink('Bug #8').click()
+    >>> user_browser.url
+    'http://.../+bug/8'
+
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.txt b/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.txt
deleted file mode 100644
index 53c99f1..0000000
--- a/lib/lp/bugs/stories/bug-also-affects/xx-duplicate-bugwatches.txt
+++ /dev/null
@@ -1,91 +0,0 @@
-Duplicate bug watches
-=====================
-
-Adding the same bug watch twice to a bug
-----------------------------------------
-
-When adding bug watches, existing bug watches are re-used if there
-already is one pointing to the same remote bug. For example, let's start
-with adding a Debian bug watch to bug #8.
-
-    >>> debian_bug = 'http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=42'
-    >>> user_browser.open(
-    ...     'http://launchpad.test/debian/+source/mozilla-firefox/'
-    ...     '+bug/8/')
-    >>> user_browser.getLink(url='+distrotask').click()
-    >>> user_browser.getControl('Distribution').value = ['debian']
-    >>> user_browser.getControl('Source Package Name').value = 'alsa-utils'
-    >>> user_browser.getControl('URL').value = debian_bug
-    >>> user_browser.getControl('Continue').click()
-
-Now we can see the added bug watch in the bug watch portlet.
-
-    >>> bugwatch_portlet = find_portlet(
-    ...     user_browser.contents, 'Remote bug watches')
-    >>> for li_tag in bugwatch_portlet.find_all('li'):
-    ...     print(li_tag.find_all('a')[0].decode_contents())
-    debbugs #42
-
-If we add another bug watch, pointing to the same URL, the previous one
-will be used; i.e., another one won't be added.
-
-    >>> from zope.component import getUtility
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> factory.makeSourcePackage(
-    ...     distroseries=getUtility(IDistributionSet)['debian']['sid'],
-    ...     sourcepackagename='pmount',
-    ...     publish=True)
-    <SourcePackage ...>
-    >>> logout()
-    >>> user_browser.getLink(url='+distrotask').click()
-    >>> user_browser.getControl('Distribution').value = ['debian']
-    >>> user_browser.getControl('Source Package Name').value = 'pmount'
-    >>> user_browser.getControl('URL').value = debian_bug
-    >>> user_browser.getControl('Continue').click()
-    >>> for tag in find_tags_by_class(user_browser.contents, 'message'):
-    ...   print(tag)
-
-    >>> bugwatch_portlet = find_portlet(
-    ...     user_browser.contents, 'Remote bug watches')
-    >>> for li_tag in bugwatch_portlet.find_all('li'):
-    ...     print(li_tag.find_all('a')[0].string)
-    debbugs #42
-
-Both the thunderbird and gnome-terminal Debian tasks are pointing to the
-same bug watch.
-
-    >>> user_browser.open(
-    ...     'http://launchpad.test/debian/+source/mozilla-firefox/'
-    ...     '+bug/8/')
-
-    >>> from lp.bugs.tests.bug import print_bug_affects_table
-    >>> print_bug_affects_table(user_browser.contents)
-    alsa-utils (Debian) ... Unknown  Unknown  debbugs #42
-    ...
-    pmount (Debian)     ... Unknown  Unknown  debbugs #42
-
-
-Adding the same bug watch to two different bugs
------------------------------------------------
-
-If a bug watch which is already added to another bug is added, a
-notification is added linking to the bug. This is useful for detecting
-duplicates.
-
-    >>> user_browser.open('http://launchpad.test/bugs/5')
-    >>> user_browser.getLink(url='+distrotask').click()
-    >>> user_browser.getControl('Distribution').value = ['debian']
-    >>> user_browser.getControl('Source Package Name').value = (
-    ...     'mozilla-firefox')
-    >>> user_browser.getControl('URL').value = debian_bug
-    >>> user_browser.getControl('Continue').click()
-    >>> print_feedback_messages(user_browser.contents)
-    Bug #8 also links to the added bug watch (debbugs #42).
-
-The notification links to the bug in question.
-
-    >>> user_browser.getLink('Bug #8').click()
-    >>> user_browser.url
-    'http://.../+bug/8'
-
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.rst b/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.rst
new file mode 100644
index 0000000..072ba0a
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.rst
@@ -0,0 +1,49 @@
+Requesting a fix for a distribution with no current release
+===========================================================
+
+Sometimes a distribution might not have any releases, thus it won't have
+a current release either. In this case it will still be possible to
+request a fix for these releases.
+
+A distribution using Launchpad
+------------------------------
+
+Gentoo is currently using Launchpad.
+
+    >>> admin_browser.open('http://launchpad.test/gentoo/+edit')
+    >>> admin_browser.getControl(
+    ...     'Bugs in this project are tracked in Launchpad').selected
+    True
+
+Any user can request a fix for it.
+
+    >>> user_browser.open('http://launchpad.test/bugs/4')
+    >>> user_browser.getLink('Also affects distribution/package').click()
+    >>> user_browser.getControl('Distribution').value = ['gentoo']
+    >>> user_browser.getControl('Continue').click()
+    >>> user_browser.url
+    'http://bugs.launchpad.test/gentoo/+bug/4'
+
+A distribution not using Launchpad
+----------------------------------
+
+If we change Gentoo not to use Launchpad, any user can still add a task and
+link to a external bug for it.
+
+    >>> admin_browser.getControl(
+    ...     'Bugs in this project are tracked in Launchpad').selected = False
+    >>> admin_browser.getControl('Change', index=3).click()
+    >>> admin_browser.open('http://launchpad.test/gentoo/+edit')
+    >>> admin_browser.getControl(
+    ...     'Bugs in this project are tracked in Launchpad').selected
+    False
+
+    >>> user_browser.open('http://launchpad.test/bugs/7')
+    >>> user_browser.getLink('Also affects distribution/package').click()
+    >>> user_browser.getControl('Distribution').value = ['gentoo']
+    >>> user_browser.getControl('Source Package').value = ''
+    >>> user_browser.getControl('URL').value = (
+    ...     'http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1234')
+    >>> user_browser.getControl('Continue').click()
+    >>> user_browser.url
+    'http://bugs.launchpad.test/gentoo/+bug/7'
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.txt b/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.txt
deleted file mode 100644
index 072ba0a..0000000
--- a/lib/lp/bugs/stories/bug-also-affects/xx-request-distribution-no-release-fix.txt
+++ /dev/null
@@ -1,49 +0,0 @@
-Requesting a fix for a distribution with no current release
-===========================================================
-
-Sometimes a distribution might not have any releases, thus it won't have
-a current release either. In this case it will still be possible to
-request a fix for these releases.
-
-A distribution using Launchpad
-------------------------------
-
-Gentoo is currently using Launchpad.
-
-    >>> admin_browser.open('http://launchpad.test/gentoo/+edit')
-    >>> admin_browser.getControl(
-    ...     'Bugs in this project are tracked in Launchpad').selected
-    True
-
-Any user can request a fix for it.
-
-    >>> user_browser.open('http://launchpad.test/bugs/4')
-    >>> user_browser.getLink('Also affects distribution/package').click()
-    >>> user_browser.getControl('Distribution').value = ['gentoo']
-    >>> user_browser.getControl('Continue').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.test/gentoo/+bug/4'
-
-A distribution not using Launchpad
-----------------------------------
-
-If we change Gentoo not to use Launchpad, any user can still add a task and
-link to a external bug for it.
-
-    >>> admin_browser.getControl(
-    ...     'Bugs in this project are tracked in Launchpad').selected = False
-    >>> admin_browser.getControl('Change', index=3).click()
-    >>> admin_browser.open('http://launchpad.test/gentoo/+edit')
-    >>> admin_browser.getControl(
-    ...     'Bugs in this project are tracked in Launchpad').selected
-    False
-
-    >>> user_browser.open('http://launchpad.test/bugs/7')
-    >>> user_browser.getLink('Also affects distribution/package').click()
-    >>> user_browser.getControl('Distribution').value = ['gentoo']
-    >>> user_browser.getControl('Source Package').value = ''
-    >>> user_browser.getControl('URL').value = (
-    ...     'http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1234')
-    >>> user_browser.getControl('Continue').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.test/gentoo/+bug/7'
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.rst b/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.rst
new file mode 100644
index 0000000..d3656ca
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.rst
@@ -0,0 +1,134 @@
+Links to upstream bug trackers
+==============================
+
+Sometimes people will want to mark a bug as being upstream but will
+either not know what the bug's upstream URL is or will know that the bug
+is not yet filed upstream.
+
+If a  project is linked to an upstream bug tracker and there is a record
+of the remote product it uses on that bug tracker, links will be shown
+on the +choose-affected-product form to that bug tracker's bug filing
+and search forms.
+
+    >>> user_browser.open('http://launchpad.test/bugs/13/')
+    >>> user_browser.getLink(url='+choose-affected-product').click()
+    >>> user_browser.getControl('Project').value = 'thunderbird'
+    >>> user_browser.getControl('Continue').click()
+
+Thunderbird isn't linked to an upstream tracker, so the text is more
+general.
+
+    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
+    >>> print(extract_text(text))
+    Mozilla Thunderbird
+    doesn't use Launchpad to track its bugs. If you know this bug
+    has been reported in another bug tracker, you can link to it;
+    Launchpad will keep track of its status for you.
+    I have the URL for the upstream bug...
+
+If we link Thunderbird to an upstream bug tracker, the text will change
+to reflect this.
+
+    >>> admin_browser.open(
+    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
+    >>> admin_browser.getControl(
+    ...     name='field.bugtracker').value = ['external']
+    >>> admin_browser.getControl(
+    ...     name='field.bugtracker.bugtracker').value = 'mozilla.org'
+    >>> admin_browser.getControl('Change').click()
+
+    >>> user_browser.open('http://launchpad.test/bugs/13/')
+    >>> user_browser.getLink(url='+choose-affected-product').click()
+    >>> user_browser.getControl('Project').value = 'thunderbird'
+    >>> user_browser.getControl('Continue').click()
+
+    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
+    >>> print(extract_text(text))
+    Mozilla Thunderbird
+    uses The Mozilla.org Bug Tracker to
+    track its bugs. If you know this bug has been reported there,
+    you can link to it; Launchpad will keep track of its status
+    for you.
+    I have the URL for the upstream bug...
+
+If the project that the user links to is one that has its remote_product
+set, links to the upstream bug tracker's bug filing and search forms
+will be displayed.
+
+    >>> user_browser.open('http://launchpad.test/bugs/13/')
+    >>> user_browser.getLink(url='+choose-affected-product').click()
+    >>> user_browser.getControl('Project').value = 'gnome-terminal'
+    >>> user_browser.getControl('Continue').click()
+
+    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
+    >>> print(extract_text(text))
+    GNOME Terminal uses GnomeGBug GTracker to track its bugs.
+    If you know this bug has been reported there, you can link to it;
+    Launchpad will keep track of its status for you.
+    If you want to report this bug on GnomeGBug GTracker before adding
+    the link to Launchpad you can use the GnomeGBug GTracker bug filing
+    form. If you want to search for this bug on GnomeGBug GTracker
+    before adding the link to Launchpad you can use the GnomeGBug
+    GTracker search form...
+
+The description given in the link to the bug filing form contains a
+link back to bug 13, the place where it was originally filed.
+
+    >>> from urllib.parse import (
+    ...     parse_qs,
+    ...     urlparse,
+    ...     )
+
+    >>> url = user_browser.getLink(text=u'bug filing form').url
+    >>> scheme, netloc, path, params, query, fragment = urlparse(url)
+    >>> [long_desc] = parse_qs(query)['long_desc']
+
+    >>> print(long_desc)
+    Originally reported at:
+      http://bugs.launchpad.test/bugs/13
+    <BLANKLINE>
+    The messages placed on this bug are for eyeball viewing of JS and
+    CSS behaviour.
+
+If the remote bug tracker is one for which Launchpad doesn't offer a bug
+filing link, such as Debbugs, only a search link will be displayed.
+
+    >>> admin_browser.open(
+    ...     'http://launchpad.test/gnome-terminal/+configure-bugtracker')
+    >>> admin_browser.getControl(
+    ...     'In a registered bug tracker:').selected = True
+    >>> admin_browser.getControl(
+    ...     name='field.bugtracker.bugtracker').value = 'debbugs'
+    >>> admin_browser.getControl('Change').click()
+
+    >>> user_browser.open('http://launchpad.test/bugs/13/')
+    >>> user_browser.getLink(url='+choose-affected-product').click()
+    >>> user_browser.getControl('Project').value = 'gnome-terminal'
+    >>> user_browser.getControl('Continue').click()
+
+    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
+    >>> print(extract_text(text))
+    GNOME Terminal uses Debian Bug tracker to track its bugs.
+    If you know this bug has been reported there, you can link to it;
+    Launchpad will keep track of its status for you.
+    If you want to search for this bug on Debian Bug tracker
+    before adding the link to Launchpad you can use the Debian Bug
+    tracker search form...
+
+
+Setting the remote project
+==========================
+
+The remote_product field, which stores a Product's ID on the remote bug
+tracker, can be set from the +configure-bugtracker page, too.
+
+    >>> admin_browser.open(
+    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
+    >>> admin_browser.getControl(name='field.remote_product').value = (
+    ...     'Thunderbird')
+    >>> admin_browser.getControl('Change').click()
+
+    >>> admin_browser.open(
+    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
+    >>> print(admin_browser.getControl(name='field.remote_product').value)
+    Thunderbird
diff --git a/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.txt b/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.txt
deleted file mode 100644
index d3656ca..0000000
--- a/lib/lp/bugs/stories/bug-also-affects/xx-upstream-bugtracker-links.txt
+++ /dev/null
@@ -1,134 +0,0 @@
-Links to upstream bug trackers
-==============================
-
-Sometimes people will want to mark a bug as being upstream but will
-either not know what the bug's upstream URL is or will know that the bug
-is not yet filed upstream.
-
-If a  project is linked to an upstream bug tracker and there is a record
-of the remote product it uses on that bug tracker, links will be shown
-on the +choose-affected-product form to that bug tracker's bug filing
-and search forms.
-
-    >>> user_browser.open('http://launchpad.test/bugs/13/')
-    >>> user_browser.getLink(url='+choose-affected-product').click()
-    >>> user_browser.getControl('Project').value = 'thunderbird'
-    >>> user_browser.getControl('Continue').click()
-
-Thunderbird isn't linked to an upstream tracker, so the text is more
-general.
-
-    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
-    >>> print(extract_text(text))
-    Mozilla Thunderbird
-    doesn't use Launchpad to track its bugs. If you know this bug
-    has been reported in another bug tracker, you can link to it;
-    Launchpad will keep track of its status for you.
-    I have the URL for the upstream bug...
-
-If we link Thunderbird to an upstream bug tracker, the text will change
-to reflect this.
-
-    >>> admin_browser.open(
-    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
-    >>> admin_browser.getControl(
-    ...     name='field.bugtracker').value = ['external']
-    >>> admin_browser.getControl(
-    ...     name='field.bugtracker.bugtracker').value = 'mozilla.org'
-    >>> admin_browser.getControl('Change').click()
-
-    >>> user_browser.open('http://launchpad.test/bugs/13/')
-    >>> user_browser.getLink(url='+choose-affected-product').click()
-    >>> user_browser.getControl('Project').value = 'thunderbird'
-    >>> user_browser.getControl('Continue').click()
-
-    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
-    >>> print(extract_text(text))
-    Mozilla Thunderbird
-    uses The Mozilla.org Bug Tracker to
-    track its bugs. If you know this bug has been reported there,
-    you can link to it; Launchpad will keep track of its status
-    for you.
-    I have the URL for the upstream bug...
-
-If the project that the user links to is one that has its remote_product
-set, links to the upstream bug tracker's bug filing and search forms
-will be displayed.
-
-    >>> user_browser.open('http://launchpad.test/bugs/13/')
-    >>> user_browser.getLink(url='+choose-affected-product').click()
-    >>> user_browser.getControl('Project').value = 'gnome-terminal'
-    >>> user_browser.getControl('Continue').click()
-
-    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
-    >>> print(extract_text(text))
-    GNOME Terminal uses GnomeGBug GTracker to track its bugs.
-    If you know this bug has been reported there, you can link to it;
-    Launchpad will keep track of its status for you.
-    If you want to report this bug on GnomeGBug GTracker before adding
-    the link to Launchpad you can use the GnomeGBug GTracker bug filing
-    form. If you want to search for this bug on GnomeGBug GTracker
-    before adding the link to Launchpad you can use the GnomeGBug
-    GTracker search form...
-
-The description given in the link to the bug filing form contains a
-link back to bug 13, the place where it was originally filed.
-
-    >>> from urllib.parse import (
-    ...     parse_qs,
-    ...     urlparse,
-    ...     )
-
-    >>> url = user_browser.getLink(text=u'bug filing form').url
-    >>> scheme, netloc, path, params, query, fragment = urlparse(url)
-    >>> [long_desc] = parse_qs(query)['long_desc']
-
-    >>> print(long_desc)
-    Originally reported at:
-      http://bugs.launchpad.test/bugs/13
-    <BLANKLINE>
-    The messages placed on this bug are for eyeball viewing of JS and
-    CSS behaviour.
-
-If the remote bug tracker is one for which Launchpad doesn't offer a bug
-filing link, such as Debbugs, only a search link will be displayed.
-
-    >>> admin_browser.open(
-    ...     'http://launchpad.test/gnome-terminal/+configure-bugtracker')
-    >>> admin_browser.getControl(
-    ...     'In a registered bug tracker:').selected = True
-    >>> admin_browser.getControl(
-    ...     name='field.bugtracker.bugtracker').value = 'debbugs'
-    >>> admin_browser.getControl('Change').click()
-
-    >>> user_browser.open('http://launchpad.test/bugs/13/')
-    >>> user_browser.getLink(url='+choose-affected-product').click()
-    >>> user_browser.getControl('Project').value = 'gnome-terminal'
-    >>> user_browser.getControl('Continue').click()
-
-    >>> text = find_tag_by_id(user_browser.contents, 'upstream-text')
-    >>> print(extract_text(text))
-    GNOME Terminal uses Debian Bug tracker to track its bugs.
-    If you know this bug has been reported there, you can link to it;
-    Launchpad will keep track of its status for you.
-    If you want to search for this bug on Debian Bug tracker
-    before adding the link to Launchpad you can use the Debian Bug
-    tracker search form...
-
-
-Setting the remote project
-==========================
-
-The remote_product field, which stores a Product's ID on the remote bug
-tracker, can be set from the +configure-bugtracker page, too.
-
-    >>> admin_browser.open(
-    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
-    >>> admin_browser.getControl(name='field.remote_product').value = (
-    ...     'Thunderbird')
-    >>> admin_browser.getControl('Change').click()
-
-    >>> admin_browser.open(
-    ...     'http://launchpad.test/thunderbird/+configure-bugtracker')
-    >>> print(admin_browser.getControl(name='field.remote_product').value)
-    Thunderbird
diff --git a/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.rst b/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.rst
new file mode 100644
index 0000000..f6c5c45
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.rst
@@ -0,0 +1,144 @@
+Foo Bar, an LP admin, is about to make bug #2 private.
+
+    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
+    >>> browser.open(
+    ...     "http://bugs.launchpad.test/debian/+source/mozilla-firefox/";
+    ...     "+bug/2/+secrecy")
+
+Foo Bar is not Cc'd on this bug, but is able to set the bug private
+anyway, because they are an admin.
+
+    >>> browser.getControl("Private", index=1).selected = True
+    >>> browser.getControl("Change").click()
+    >>> print(browser.url)
+    http://bugs.launchpad.test/debian/+source/mozilla-firefox/+bug/2
+
+When we go back to the secrecy form, the previously set value is pre-selected.
+
+    >>> browser.open(
+    ...     "http://bugs.launchpad.test/debian/+source/mozilla-firefox/";
+    ...     "+bug/2/+secrecy")
+    >>> browser.getControl("Private", index=1).selected
+    True
+
+Foo Bar files a security (private) bug on Ubuntu. They get redirected to the
+bug page.
+
+    >>> browser = setupBrowser("Basic foo.bar@xxxxxxxxxxxxx:test")
+    >>> browser.open("http://launchpad.test/ubuntu/+filebug";)
+
+The Ubuntu maintainer, Ubuntu Team, will be subscribed.
+
+    >>> browser.getControl(name="field.title", index=0).value = (
+    ...     "a private bug")
+    >>> browser.getControl('Continue').click()
+
+    >>> browser.getControl(name="packagename_option").value = ["choose"]
+    >>> browser.getControl(name="field.packagename").value = "evolution"
+    >>> browser.getControl(name="field.comment").value = "secret info"
+    >>> browser.getControl("Private Security").selected = True
+    >>> browser.getControl("Submit Bug Report").click()
+
+    >>> bug_id = browser.url.split("/")[-1]
+    >>> print(browser.url.replace(bug_id, "BUG-ID"))
+    http://bugs.launchpad.test/ubuntu/+source/evolution/+bug/BUG-ID
+
+    >>> print(browser.contents)
+    <!DOCTYPE...
+    ...Security-related bugs are by default private...
+
+Foo Bar sees the private bug they filed.
+
+    >>> browser.open("http://launchpad.test/ubuntu/+bugs";)
+    >>> print(browser.contents.replace(bug_id, "BUG-ID"))
+    <!DOCTYPE...
+    ...
+    ...Ubuntu...
+    ...<a...>...BUG-ID...</a>...
+
+Foo Bar is subscribed to the bug.
+
+    >>> from operator import attrgetter
+    >>> from zope.component import getUtility
+    >>> from lp.testing import login, logout
+    >>> from lp.bugs.interfaces.bug import IBugSet
+
+    >>> login("foo.bar@xxxxxxxxxxxxx")
+
+    >>> bug = getUtility(IBugSet).get(bug_id)
+
+    >>> for subscriber in sorted(
+    ...         bug.getDirectSubscribers(), key=attrgetter('name')):
+    ...     print(subscriber.name)
+    name16
+
+    >>> logout()
+
+
+Anonymous users cannot see private bugs filed on distros, of course!
+
+Not directly.
+
+    >>> anon_browser.open("http://launchpad.test/bugs/14";)
+    Traceback (most recent call last):
+      ...
+    zope.publisher.interfaces.NotFound: ...
+
+And not in bug listings.
+
+    >>> anon_browser.open("http://launchpad.test/ubuntu/+bugs";)
+    >>> "a private bug" not in anon_browser.contents
+    True
+
+A user not subscribed to a private bug will not be able to see the bug.
+
+Neither directly.
+
+    >>> browser = setupBrowser("Basic no-privs@xxxxxxxxxxxxx:test")
+    >>> browser.open("http://launchpad.test/bugs/14";)
+    Traceback (most recent call last):
+      ...
+    zope.publisher.interfaces.NotFound: ...
+
+Nor in a search listing.
+
+    >>> browser.open("http://launchpad.test/ubuntu/+bugs";)
+    >>> "a private bug" not in browser.contents
+    True
+
+First, some setup. Find out what the latest [private] bug reported on
+Ubuntu evolution is, so we can avoid hardcoding its ID here:
+
+    >>> from zope.component import getUtility
+    >>> from lp.services.webapp.interfaces import ILaunchBag
+    >>> from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
+    >>> from lp.registry.interfaces.distribution import IDistributionSet
+    >>> from lp.registry.interfaces.sourcepackagename import (
+    ...     ISourcePackageNameSet,
+    ...     )
+    >>> from lp.testing import login, logout
+
+    >>> login("foo.bar@xxxxxxxxxxxxx")
+    >>> launchbag = getUtility(ILaunchBag)
+    >>> evo = getUtility(ISourcePackageNameSet).queryByName("evolution")
+    >>> params = BugTaskSearchParams(user=launchbag.user,
+    ...     sourcepackagename=evo, orderby="-id")
+
+    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
+    >>> latest_evo_task = ubuntu.searchTasks(params)[0]
+    >>> latest_evo_bug = latest_evo_task.bug.id
+    >>> logout()
+
+Unsubscribing from a private bug redirects you to the bug listing (see
+further down for an exception to this rule.) Let's demonstrate by having
+Foo Bar, an admin, subscribe Sample Person to a private bug.
+
+    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
+    >>> add_subscriber_url = (
+    ...     "http://launchpad.test/ubuntu/+source/evolution/+bug/%s";
+    ...     "/+addsubscriber" % latest_evo_bug)
+    >>> browser.open(add_subscriber_url)
+    >>> browser.getControl("Person").value = "name12"
+    >>> browser.getControl("Subscribe user").click()
+    >>> browser.url
+    'http://bugs.launchpad.test/ubuntu/+source/evolution/+bug/...'
diff --git a/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.txt b/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.txt
deleted file mode 100644
index f6c5c45..0000000
--- a/lib/lp/bugs/stories/bug-privacy/xx-bug-privacy.txt
+++ /dev/null
@@ -1,144 +0,0 @@
-Foo Bar, an LP admin, is about to make bug #2 private.
-
-    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
-    >>> browser.open(
-    ...     "http://bugs.launchpad.test/debian/+source/mozilla-firefox/";
-    ...     "+bug/2/+secrecy")
-
-Foo Bar is not Cc'd on this bug, but is able to set the bug private
-anyway, because they are an admin.
-
-    >>> browser.getControl("Private", index=1).selected = True
-    >>> browser.getControl("Change").click()
-    >>> print(browser.url)
-    http://bugs.launchpad.test/debian/+source/mozilla-firefox/+bug/2
-
-When we go back to the secrecy form, the previously set value is pre-selected.
-
-    >>> browser.open(
-    ...     "http://bugs.launchpad.test/debian/+source/mozilla-firefox/";
-    ...     "+bug/2/+secrecy")
-    >>> browser.getControl("Private", index=1).selected
-    True
-
-Foo Bar files a security (private) bug on Ubuntu. They get redirected to the
-bug page.
-
-    >>> browser = setupBrowser("Basic foo.bar@xxxxxxxxxxxxx:test")
-    >>> browser.open("http://launchpad.test/ubuntu/+filebug";)
-
-The Ubuntu maintainer, Ubuntu Team, will be subscribed.
-
-    >>> browser.getControl(name="field.title", index=0).value = (
-    ...     "a private bug")
-    >>> browser.getControl('Continue').click()
-
-    >>> browser.getControl(name="packagename_option").value = ["choose"]
-    >>> browser.getControl(name="field.packagename").value = "evolution"
-    >>> browser.getControl(name="field.comment").value = "secret info"
-    >>> browser.getControl("Private Security").selected = True
-    >>> browser.getControl("Submit Bug Report").click()
-
-    >>> bug_id = browser.url.split("/")[-1]
-    >>> print(browser.url.replace(bug_id, "BUG-ID"))
-    http://bugs.launchpad.test/ubuntu/+source/evolution/+bug/BUG-ID
-
-    >>> print(browser.contents)
-    <!DOCTYPE...
-    ...Security-related bugs are by default private...
-
-Foo Bar sees the private bug they filed.
-
-    >>> browser.open("http://launchpad.test/ubuntu/+bugs";)
-    >>> print(browser.contents.replace(bug_id, "BUG-ID"))
-    <!DOCTYPE...
-    ...
-    ...Ubuntu...
-    ...<a...>...BUG-ID...</a>...
-
-Foo Bar is subscribed to the bug.
-
-    >>> from operator import attrgetter
-    >>> from zope.component import getUtility
-    >>> from lp.testing import login, logout
-    >>> from lp.bugs.interfaces.bug import IBugSet
-
-    >>> login("foo.bar@xxxxxxxxxxxxx")
-
-    >>> bug = getUtility(IBugSet).get(bug_id)
-
-    >>> for subscriber in sorted(
-    ...         bug.getDirectSubscribers(), key=attrgetter('name')):
-    ...     print(subscriber.name)
-    name16
-
-    >>> logout()
-
-
-Anonymous users cannot see private bugs filed on distros, of course!
-
-Not directly.
-
-    >>> anon_browser.open("http://launchpad.test/bugs/14";)
-    Traceback (most recent call last):
-      ...
-    zope.publisher.interfaces.NotFound: ...
-
-And not in bug listings.
-
-    >>> anon_browser.open("http://launchpad.test/ubuntu/+bugs";)
-    >>> "a private bug" not in anon_browser.contents
-    True
-
-A user not subscribed to a private bug will not be able to see the bug.
-
-Neither directly.
-
-    >>> browser = setupBrowser("Basic no-privs@xxxxxxxxxxxxx:test")
-    >>> browser.open("http://launchpad.test/bugs/14";)
-    Traceback (most recent call last):
-      ...
-    zope.publisher.interfaces.NotFound: ...
-
-Nor in a search listing.
-
-    >>> browser.open("http://launchpad.test/ubuntu/+bugs";)
-    >>> "a private bug" not in browser.contents
-    True
-
-First, some setup. Find out what the latest [private] bug reported on
-Ubuntu evolution is, so we can avoid hardcoding its ID here:
-
-    >>> from zope.component import getUtility
-    >>> from lp.services.webapp.interfaces import ILaunchBag
-    >>> from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
-    >>> from lp.registry.interfaces.distribution import IDistributionSet
-    >>> from lp.registry.interfaces.sourcepackagename import (
-    ...     ISourcePackageNameSet,
-    ...     )
-    >>> from lp.testing import login, logout
-
-    >>> login("foo.bar@xxxxxxxxxxxxx")
-    >>> launchbag = getUtility(ILaunchBag)
-    >>> evo = getUtility(ISourcePackageNameSet).queryByName("evolution")
-    >>> params = BugTaskSearchParams(user=launchbag.user,
-    ...     sourcepackagename=evo, orderby="-id")
-
-    >>> ubuntu = getUtility(IDistributionSet).getByName("ubuntu")
-    >>> latest_evo_task = ubuntu.searchTasks(params)[0]
-    >>> latest_evo_bug = latest_evo_task.bug.id
-    >>> logout()
-
-Unsubscribing from a private bug redirects you to the bug listing (see
-further down for an exception to this rule.) Let's demonstrate by having
-Foo Bar, an admin, subscribe Sample Person to a private bug.
-
-    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
-    >>> add_subscriber_url = (
-    ...     "http://launchpad.test/ubuntu/+source/evolution/+bug/%s";
-    ...     "/+addsubscriber" % latest_evo_bug)
-    >>> browser.open(add_subscriber_url)
-    >>> browser.getControl("Person").value = "name12"
-    >>> browser.getControl("Subscribe user").click()
-    >>> browser.url
-    'http://bugs.launchpad.test/ubuntu/+source/evolution/+bug/...'
diff --git a/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.rst b/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.rst
new file mode 100644
index 0000000..f56deb7
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.rst
@@ -0,0 +1,72 @@
+Presenting private bug reports
+==============================
+
+When a bug report is public, it says so.
+
+    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
+    >>> browser.open("http://launchpad.test/bugs/4";)
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
+    This report contains Public information...
+
+But when marked private, it gains the standard Launchpad presentation
+for private things.
+
+    >>> browser.open("http://bugs.launchpad.test/firefox/+bug/4/+secrecy";)
+    >>> browser.getControl("Private", index=1).selected = True
+    >>> browser.getControl("Change").click()
+    >>> print(browser.url)
+    http://bugs.launchpad.test/firefox/+bug/4
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
+    This report contains Private information...
+
+Bugs created before we started recording the date and time and who
+marked the bug private show only a simple message:
+
+    >>> browser.open("http://launchpad.test/bugs/14";)
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
+    This report contains Private Security information...
+
+But newer bugs that are filed private at creation time (like security
+bugs or where the product requests that bugs are private by default)
+have the full message:
+
+    >>> browser.open("http://bugs.launchpad.test/firefox/+filebug";)
+    >>> browser.getControl('Summary', index=0).value = (
+    ...     'Firefox crashes when I change the default route')
+    >>> browser.getControl('Continue').click()
+
+    >>> browser.getControl("Further information").value = "foo"
+    >>> browser.getControl("Private Security").selected = True
+    >>> browser.getControl("Submit Bug Report").click()
+
+    >>> print(browser.url)
+    http://bugs.launchpad.test/firefox/+bug/...
+    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
+    This report contains Private Security information...
+
+XXX 20080708 mpt: Bug 246671 again.
+
+If you visit the private bugs page through its shortcut URL, we don't
+redirect you unless you are actually able to see the bug. The reason for
+this is that redirecting you already discloses what product or distro
+the private bug is in.
+
+Of course, Foo Bar gets redirected:
+
+    >>> browser.open('http://bugs.launchpad.test/bugs/4')
+    >>> browser.url
+    'http://bugs.launchpad.test/firefox/+bug/4'
+
+But poor old no privs does not, and neither do anonymous users:
+
+    >>> browser = setupBrowser(auth="Basic no-priv@xxxxxxxxxxxxx:test")
+    >>> browser.open('http://bugs.launchpad.test/bugs/4')
+    Traceback (most recent call last):
+    ...
+    zope.publisher.interfaces.NotFound: ...
+
+    >>> anon_browser.open('http://bugs.launchpad.test/bugs/4')
+    Traceback (most recent call last):
+    ...
+    zope.publisher.interfaces.NotFound: ...
+
diff --git a/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.txt b/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.txt
deleted file mode 100644
index f56deb7..0000000
--- a/lib/lp/bugs/stories/bug-privacy/xx-presenting-private-bugs.txt
+++ /dev/null
@@ -1,72 +0,0 @@
-Presenting private bug reports
-==============================
-
-When a bug report is public, it says so.
-
-    >>> browser = setupBrowser(auth="Basic foo.bar@xxxxxxxxxxxxx:test")
-    >>> browser.open("http://launchpad.test/bugs/4";)
-    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
-    This report contains Public information...
-
-But when marked private, it gains the standard Launchpad presentation
-for private things.
-
-    >>> browser.open("http://bugs.launchpad.test/firefox/+bug/4/+secrecy";)
-    >>> browser.getControl("Private", index=1).selected = True
-    >>> browser.getControl("Change").click()
-    >>> print(browser.url)
-    http://bugs.launchpad.test/firefox/+bug/4
-    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
-    This report contains Private information...
-
-Bugs created before we started recording the date and time and who
-marked the bug private show only a simple message:
-
-    >>> browser.open("http://launchpad.test/bugs/14";)
-    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
-    This report contains Private Security information...
-
-But newer bugs that are filed private at creation time (like security
-bugs or where the product requests that bugs are private by default)
-have the full message:
-
-    >>> browser.open("http://bugs.launchpad.test/firefox/+filebug";)
-    >>> browser.getControl('Summary', index=0).value = (
-    ...     'Firefox crashes when I change the default route')
-    >>> browser.getControl('Continue').click()
-
-    >>> browser.getControl("Further information").value = "foo"
-    >>> browser.getControl("Private Security").selected = True
-    >>> browser.getControl("Submit Bug Report").click()
-
-    >>> print(browser.url)
-    http://bugs.launchpad.test/firefox/+bug/...
-    >>> print(extract_text(find_tag_by_id(browser.contents, 'privacy')))
-    This report contains Private Security information...
-
-XXX 20080708 mpt: Bug 246671 again.
-
-If you visit the private bugs page through its shortcut URL, we don't
-redirect you unless you are actually able to see the bug. The reason for
-this is that redirecting you already discloses what product or distro
-the private bug is in.
-
-Of course, Foo Bar gets redirected:
-
-    >>> browser.open('http://bugs.launchpad.test/bugs/4')
-    >>> browser.url
-    'http://bugs.launchpad.test/firefox/+bug/4'
-
-But poor old no privs does not, and neither do anonymous users:
-
-    >>> browser = setupBrowser(auth="Basic no-priv@xxxxxxxxxxxxx:test")
-    >>> browser.open('http://bugs.launchpad.test/bugs/4')
-    Traceback (most recent call last):
-    ...
-    zope.publisher.interfaces.NotFound: ...
-
-    >>> anon_browser.open('http://bugs.launchpad.test/bugs/4')
-    Traceback (most recent call last):
-    ...
-    zope.publisher.interfaces.NotFound: ...
-
diff --git a/lib/lp/bugs/stories/bug-release-management/nomination-navigation.txt b/lib/lp/bugs/stories/bug-release-management/nomination-navigation.rst
similarity index 100%
rename from lib/lp/bugs/stories/bug-release-management/nomination-navigation.txt
rename to lib/lp/bugs/stories/bug-release-management/nomination-navigation.rst
diff --git a/lib/lp/bugs/stories/bug-release-management/xx-anonymous-bug-nomination.txt b/lib/lp/bugs/stories/bug-release-management/xx-anonymous-bug-nomination.rst
similarity index 100%
rename from lib/lp/bugs/stories/bug-release-management/xx-anonymous-bug-nomination.txt
rename to lib/lp/bugs/stories/bug-release-management/xx-anonymous-bug-nomination.rst
diff --git a/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.rst b/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.rst
new file mode 100644
index 0000000..b8b6462
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.rst
@@ -0,0 +1,382 @@
+Privileged users can approve and decline bug nominations from the bug
+page.
+
+The approve/decline buttons and buttons aren't visible to unprivileged users.
+
+    >>> no_priv_browser = setupBrowser(
+    ...     auth="Basic no-priv@xxxxxxxxxxxxx:test")
+    >>> no_priv_browser.open("http://launchpad.test/bugs/1";)
+
+    >>> no_priv_browser.getControl("Approve")
+    Traceback (most recent call last):
+      ...
+    LookupError: ...
+
+    >>> no_priv_browser.getControl("Decline")
+    Traceback (most recent call last):
+      ...
+    LookupError: ...
+
+But an admin can see them.
+
+    >>> admin_browser.open("http://bugs.launchpad.test/bugs/1";)
+    >>> approve_button = admin_browser.getControl("Approve", index=0)
+    >>> decline_button = admin_browser.getControl("Decline", index=0)
+
+Approving a nomination displays a feedback message.
+
+    >>> approve_button.click()
+
+    >>> feedback_msg = find_tags_by_class(
+    ...     admin_browser.contents, "informational message")[0]
+    >>> print(feedback_msg.decode_contents())
+    Approved nomination for Mozilla Firefox 1.0
+
+After a productseries task has been created, it's editable.
+
+    >>> user_browser.open('http://launchpad.test/firefox/+bug/1')
+    >>> user_browser.getLink(url='firefox/1.0/+bug/1/+editstatus').click()
+    >>> user_browser.url
+    'http://bugs.launchpad.test/firefox/1.0/+bug/1/+editstatus'
+
+    >>> user_browser.getControl('Status').value
+    ['New']
+    >>> user_browser.getControl('Status').value = ['Confirmed']
+    >>> user_browser.getControl('Save Changes').click()
+    >>> user_browser.url
+    'http://bugs.launchpad.test/firefox/1.0/+bug/1'
+
+Privileged users can decline bug nominations.
+
+    >>> admin_browser.open("http://bugs.launchpad.test/bugs/1";)
+    >>> approve_button = admin_browser.getControl("Approve", index=0)
+    >>> decline_button = admin_browser.getControl("Decline", index=0)
+
+Declining a nomination displays a feedback message.
+
+    >>> decline_button.click()
+
+    >>> feedback_msg = find_tags_by_class(
+    ...     admin_browser.contents, "informational message")[0]
+    >>> print(feedback_msg.decode_contents())
+    Declined nomination for Ubuntu Hoary
+
+Nominate a bug to a distribution release
+========================================
+
+A bug can be nominated for a distribution release.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> nominater = factory.makePerson(name='denominater')
+    >>> poseidon = factory.makeDistribution(name='poseidon',
+    ...     bug_supervisor=nominater)
+    >>> dsp = factory.makeDistributionSourcePackage(distribution=poseidon)
+    >>> series = factory.makeDistroSeries(distribution=poseidon,
+    ...     name='aqua')
+    >>> ignore = factory.makeSourcePackagePublishingHistory(
+    ...     distroseries=series, sourcepackagename=dsp.sourcepackagename)
+    >>> series = factory.makeDistroSeries(distribution=poseidon,
+    ...     name='hydro')
+    >>> ignore = factory.makeSourcePackagePublishingHistory(
+    ...     distroseries=series, sourcepackagename=dsp.sourcepackagename)
+    >>> bug_task = factory.makeBugTask(target=dsp)
+    >>> nominater_browser = setupBrowser(
+    ...     auth='Basic %s:test' % nominater.preferredemail.email)
+    >>> logout()
+    >>> nominater_browser.open(
+    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
+    ...     (dsp.name, bug_task.bug.id))
+
+Before we continue, we'll set up a second browser instance, to simulate
+the nominater accessing the site from another window. Working with the same
+form in different browser windows or tabs can sometimes trigger edge case
+errors, and we'll give an example of one shortly.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> nominater_other_browser = setupBrowser(
+    ...     auth='Basic %s:test' % nominater.preferredemail.email)
+    >>> logout()
+    >>> nominater_other_browser.open(
+    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
+    ...     (dsp.name, bug_task.bug.id))
+    >>> nominater_browser.getControl("Aqua").selected = True
+    >>> nominater_browser.getControl("Nominate").click()
+    >>> for tag in find_tags_by_class(nominater_browser.contents, 'message'):
+    ...     print(tag)
+    <div...Added nominations for: Poseidon Aqua...
+
+Now, if the nominater, having the form open in another browser window,
+accidentally nominates the bug for Aqua a second time, an error is
+raised.
+
+    >>> nominater_other_browser.getControl("Aqua").selected = True
+    >>> nominater_other_browser.getControl("Nominate").click()
+
+    >>> for tag in find_tags_by_class(nominater_other_browser.contents,
+    ...     'message'):
+    ...     print(tag.decode_contents())
+    There is 1 error.
+    This bug has already been nominated for these series: Aqua
+
+When a nomination is submitted by a privileged user, it is immediately
+approved and targeted to the release.
+
+    >>> admin_browser.open(
+    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
+    ...     (dsp.name, bug_task.bug.id))
+
+    >>> admin_browser.getControl("Hydro").selected = True
+    >>> admin_browser.getControl("Target").click()
+
+    >>> for tag in find_tags_by_class(admin_browser.contents, 'message'):
+    ...     print(tag)
+    <div...Targeted bug to: Poseidon Hydro...
+
+Nominating a bug for a product series
+=====================================
+
+A bug can be nominated for a product series.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> nominater = factory.makePerson(name='nominater')
+    >>> widget = factory.makeProduct(name='widget',
+    ...     official_malone = True,
+    ...     bug_supervisor=nominater)
+    >>> series = factory.makeProductSeries(product=widget,
+    ...     name='beta')
+    >>> bug = factory.makeBug(target=widget)
+    >>> nominater_browser = setupBrowser(
+    ...     auth='Basic %s:test' % nominater.preferredemail.email)
+    >>> logout()
+    >>> nominater_browser.open(
+    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
+
+Before we continue, we'll set up a second browser instance, to simulate
+the nominater accessing the site from another window. Working with the same
+form in different browser windows or tabs can sometimes trigger edge case
+errors, and we'll give an example of one shortly.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> nominater_other_browser = setupBrowser(
+    ...     auth='Basic %s:test' % nominater.preferredemail.email)
+    >>> logout()
+    >>> nominater_other_browser.open(
+    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
+
+    >>> nominater_browser.getControl("Beta").selected = True
+    >>> nominater_other_browser.getControl("Beta").selected = True
+    >>> nominater_browser.getControl("Nominate").click()
+
+    >>> for tag in find_tags_by_class(nominater_browser.contents, 'message'):
+    ...     print(tag)
+    <div...Added nominations for: Widget beta...
+
+Now, if the nominater, having the form open in another browser window,
+accidentally nominates the bug for Beta a second time, an error is raised.
+
+    >>> nominater_other_browser.getControl("Nominate").click()
+
+    >>> for tag in find_tags_by_class(nominater_other_browser.contents,
+    ...     'message'):
+    ...     print(tag.decode_contents())
+    There is 1 error.
+    This bug has already been nominated for these series: Beta
+
+When a nomination is submitted by a privileged user, it is immediately
+approved and targeted to the release.
+
+    >>> admin_browser.open(
+    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
+
+    >>> admin_browser.getControl("Trunk").selected = True
+    >>> admin_browser.getControl("Target").click()
+
+    >>> for tag in find_tags_by_class(admin_browser.contents, 'message'):
+    ...     print(tag)
+    <div...Targeted bug to: Widget trunk...
+
+When a bug is targeted to the current development release, the general
+distribution task is no longer editable. Instead the status is tracked
+in the release task.
+
+    >>> user_browser.open('http://bugs.launchpad.test/ubuntu/+bug/2')
+    >>> ubuntu_edit_url = (
+    ...     'http://bugs.launchpad.test/ubuntu/+bug/2/+editstatus')
+    >>> user_browser.getLink(url=ubuntu_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> ubuntu_hoary_edit_url = (
+    ...     'http://bugs.launchpad.test/ubuntu/hoary/+bug/2/+editstatus')
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
+    True
+
+The use of the Won't Fix status is restricted. We need to use it to
+illustrate conjoined bugtasks, so we'll make 'no-priv' the bug supervisor
+for Ubuntu:
+
+    >>> admin_browser.open('http://bugs.launchpad.test/ubuntu/+bugsupervisor')
+    >>> admin_browser.getControl('Bug Supervisor').value = 'no-priv'
+    >>> admin_browser.getControl('Change').click()
+
+    >>> print(extract_text(
+    ...     find_tag_by_id(admin_browser.contents, 'bug-supervisor')))
+    Bug supervisor:
+    No Privileges Person
+
+    >>> user_browser.reload()
+
+However, if we reject the Hoary task, it means that the bug is deferred
+to the next release. In that case, the general Ubuntu task will keep
+open, while the release task is invalid.
+
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue = ["Won't Fix"]
+    >>> user_browser.getControl('Save Changes').click()
+
+Now both the general and release tasks are editable.
+
+    >>> user_browser.getLink(url=ubuntu_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue
+    ['New']
+    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue
+    ["Won't Fix"]
+
+If the release task gets reopened, the tasks will be synced again, and
+the distribution task won't be editable.
+
+    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=ubuntu_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
+    True
+
+It's worth noting that only a rejection causes the conjoined bugtasks
+from being separated, if the task gets changed to Fix Released, it
+general distribution task will remain uneditable.
+
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue = ['Fix Released']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=ubuntu_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
+    True
+
+When a bug is targeted to the current development series, the general
+product task is no longer editable. Instead the status is tracked
+in the series task.
+
+    >>> admin_browser.open(
+    ...     "http://launchpad.test/products/firefox/+bug/4/+nominate";)
+    >>> admin_browser.getControl("Trunk").selected = True
+    >>> admin_browser.getControl("Target").click()
+
+    >>> user_browser.open('http://bugs.launchpad.test/firefox/+bug/4')
+    >>> firefox_edit_url = (
+    ...     'http://bugs.launchpad.test/firefox/+bug/4/+editstatus')
+    >>> user_browser.getLink(url=firefox_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> firefox_trunk_edit_url = (
+    ...     'http://bugs.launchpad.test/firefox/trunk/+bug/4/+editstatus')
+    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
+    True
+
+The use of the Won't Fix status is restricted. We need to use it to
+illustrate conjoined bugtasks, so we'll make 'no-priv' the bug supervisor
+for Firefox:
+
+    >>> admin_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/+bugsupervisor')
+    >>> admin_browser.getControl('Bug Supervisor').value = 'no-priv'
+    >>> admin_browser.getControl('Change').click()
+
+    >>> print(extract_text(find_tag_by_id(admin_browser.contents,
+    ...     'bug-supervisor')))
+    Bug supervisor:
+    No Privileges Person
+
+    >>> user_browser.reload()
+
+However, if we reject the Trunk task, it means that the bug is deferred
+to the next release. In that case, the general Firefox task will stay
+open, while the series task is invalid.
+
+    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue = ["Won't Fix"]
+    >>> user_browser.getControl('Save Changes').click()
+    >>> user_browser.url
+    'http://bugs.launchpad.test/firefox/trunk/+bug/4'
+
+Now both the general and series tasks are editable.
+
+    >>> user_browser.getLink(url=firefox_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue
+    ['New']
+    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue
+    ["Won't Fix"]
+
+If the series task gets reopened, the tasks will be synced again, and
+the distribution task won't be editable.
+
+    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=firefox_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
+    True
+
+It's worth noting that only a rejection causes the conjoined bugtasks
+from being separated, if the task gets changed to Fix Released, the
+general distribution task will remain uneditable.
+
+    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
+    >>> user_browser.getControl('Status').displayValue = ['Fix Released']
+    >>> user_browser.getControl('Save Changes').click()
+
+    >>> user_browser.getLink(url=firefox_edit_url)
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
+    True
+
+Now that we've targeted a few bugs towards Firefox 1.0, we can go to
+the productseries' bug page, in order to see a list of all bugs
+targeted to it.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/firefox/1.0/+bugs')
+
+    >>> from lp.bugs.tests.bug import print_bugtasks
+    >>> print_bugtasks(anon_browser.contents)
+    5 Firefox install instructions should be complete
+      Mozilla Firefox 1.0 Undecided New
+    1 Firefox does not support SVG
+      Mozilla Firefox 1.0 Undecided Confirmed
diff --git a/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.txt b/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.txt
deleted file mode 100644
index b8b6462..0000000
--- a/lib/lp/bugs/stories/bug-release-management/xx-bug-release-management.txt
+++ /dev/null
@@ -1,382 +0,0 @@
-Privileged users can approve and decline bug nominations from the bug
-page.
-
-The approve/decline buttons and buttons aren't visible to unprivileged users.
-
-    >>> no_priv_browser = setupBrowser(
-    ...     auth="Basic no-priv@xxxxxxxxxxxxx:test")
-    >>> no_priv_browser.open("http://launchpad.test/bugs/1";)
-
-    >>> no_priv_browser.getControl("Approve")
-    Traceback (most recent call last):
-      ...
-    LookupError: ...
-
-    >>> no_priv_browser.getControl("Decline")
-    Traceback (most recent call last):
-      ...
-    LookupError: ...
-
-But an admin can see them.
-
-    >>> admin_browser.open("http://bugs.launchpad.test/bugs/1";)
-    >>> approve_button = admin_browser.getControl("Approve", index=0)
-    >>> decline_button = admin_browser.getControl("Decline", index=0)
-
-Approving a nomination displays a feedback message.
-
-    >>> approve_button.click()
-
-    >>> feedback_msg = find_tags_by_class(
-    ...     admin_browser.contents, "informational message")[0]
-    >>> print(feedback_msg.decode_contents())
-    Approved nomination for Mozilla Firefox 1.0
-
-After a productseries task has been created, it's editable.
-
-    >>> user_browser.open('http://launchpad.test/firefox/+bug/1')
-    >>> user_browser.getLink(url='firefox/1.0/+bug/1/+editstatus').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.test/firefox/1.0/+bug/1/+editstatus'
-
-    >>> user_browser.getControl('Status').value
-    ['New']
-    >>> user_browser.getControl('Status').value = ['Confirmed']
-    >>> user_browser.getControl('Save Changes').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.test/firefox/1.0/+bug/1'
-
-Privileged users can decline bug nominations.
-
-    >>> admin_browser.open("http://bugs.launchpad.test/bugs/1";)
-    >>> approve_button = admin_browser.getControl("Approve", index=0)
-    >>> decline_button = admin_browser.getControl("Decline", index=0)
-
-Declining a nomination displays a feedback message.
-
-    >>> decline_button.click()
-
-    >>> feedback_msg = find_tags_by_class(
-    ...     admin_browser.contents, "informational message")[0]
-    >>> print(feedback_msg.decode_contents())
-    Declined nomination for Ubuntu Hoary
-
-Nominate a bug to a distribution release
-========================================
-
-A bug can be nominated for a distribution release.
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> nominater = factory.makePerson(name='denominater')
-    >>> poseidon = factory.makeDistribution(name='poseidon',
-    ...     bug_supervisor=nominater)
-    >>> dsp = factory.makeDistributionSourcePackage(distribution=poseidon)
-    >>> series = factory.makeDistroSeries(distribution=poseidon,
-    ...     name='aqua')
-    >>> ignore = factory.makeSourcePackagePublishingHistory(
-    ...     distroseries=series, sourcepackagename=dsp.sourcepackagename)
-    >>> series = factory.makeDistroSeries(distribution=poseidon,
-    ...     name='hydro')
-    >>> ignore = factory.makeSourcePackagePublishingHistory(
-    ...     distroseries=series, sourcepackagename=dsp.sourcepackagename)
-    >>> bug_task = factory.makeBugTask(target=dsp)
-    >>> nominater_browser = setupBrowser(
-    ...     auth='Basic %s:test' % nominater.preferredemail.email)
-    >>> logout()
-    >>> nominater_browser.open(
-    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
-    ...     (dsp.name, bug_task.bug.id))
-
-Before we continue, we'll set up a second browser instance, to simulate
-the nominater accessing the site from another window. Working with the same
-form in different browser windows or tabs can sometimes trigger edge case
-errors, and we'll give an example of one shortly.
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> nominater_other_browser = setupBrowser(
-    ...     auth='Basic %s:test' % nominater.preferredemail.email)
-    >>> logout()
-    >>> nominater_other_browser.open(
-    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
-    ...     (dsp.name, bug_task.bug.id))
-    >>> nominater_browser.getControl("Aqua").selected = True
-    >>> nominater_browser.getControl("Nominate").click()
-    >>> for tag in find_tags_by_class(nominater_browser.contents, 'message'):
-    ...     print(tag)
-    <div...Added nominations for: Poseidon Aqua...
-
-Now, if the nominater, having the form open in another browser window,
-accidentally nominates the bug for Aqua a second time, an error is
-raised.
-
-    >>> nominater_other_browser.getControl("Aqua").selected = True
-    >>> nominater_other_browser.getControl("Nominate").click()
-
-    >>> for tag in find_tags_by_class(nominater_other_browser.contents,
-    ...     'message'):
-    ...     print(tag.decode_contents())
-    There is 1 error.
-    This bug has already been nominated for these series: Aqua
-
-When a nomination is submitted by a privileged user, it is immediately
-approved and targeted to the release.
-
-    >>> admin_browser.open(
-    ...     "http://launchpad.test/poseidon/+source/%s/+bug/%s/+nominate"; %
-    ...     (dsp.name, bug_task.bug.id))
-
-    >>> admin_browser.getControl("Hydro").selected = True
-    >>> admin_browser.getControl("Target").click()
-
-    >>> for tag in find_tags_by_class(admin_browser.contents, 'message'):
-    ...     print(tag)
-    <div...Targeted bug to: Poseidon Hydro...
-
-Nominating a bug for a product series
-=====================================
-
-A bug can be nominated for a product series.
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> nominater = factory.makePerson(name='nominater')
-    >>> widget = factory.makeProduct(name='widget',
-    ...     official_malone = True,
-    ...     bug_supervisor=nominater)
-    >>> series = factory.makeProductSeries(product=widget,
-    ...     name='beta')
-    >>> bug = factory.makeBug(target=widget)
-    >>> nominater_browser = setupBrowser(
-    ...     auth='Basic %s:test' % nominater.preferredemail.email)
-    >>> logout()
-    >>> nominater_browser.open(
-    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
-
-Before we continue, we'll set up a second browser instance, to simulate
-the nominater accessing the site from another window. Working with the same
-form in different browser windows or tabs can sometimes trigger edge case
-errors, and we'll give an example of one shortly.
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> nominater_other_browser = setupBrowser(
-    ...     auth='Basic %s:test' % nominater.preferredemail.email)
-    >>> logout()
-    >>> nominater_other_browser.open(
-    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
-
-    >>> nominater_browser.getControl("Beta").selected = True
-    >>> nominater_other_browser.getControl("Beta").selected = True
-    >>> nominater_browser.getControl("Nominate").click()
-
-    >>> for tag in find_tags_by_class(nominater_browser.contents, 'message'):
-    ...     print(tag)
-    <div...Added nominations for: Widget beta...
-
-Now, if the nominater, having the form open in another browser window,
-accidentally nominates the bug for Beta a second time, an error is raised.
-
-    >>> nominater_other_browser.getControl("Nominate").click()
-
-    >>> for tag in find_tags_by_class(nominater_other_browser.contents,
-    ...     'message'):
-    ...     print(tag.decode_contents())
-    There is 1 error.
-    This bug has already been nominated for these series: Beta
-
-When a nomination is submitted by a privileged user, it is immediately
-approved and targeted to the release.
-
-    >>> admin_browser.open(
-    ...     "http://launchpad.test/widget/+bug/%s/+nominate"; % bug.id)
-
-    >>> admin_browser.getControl("Trunk").selected = True
-    >>> admin_browser.getControl("Target").click()
-
-    >>> for tag in find_tags_by_class(admin_browser.contents, 'message'):
-    ...     print(tag)
-    <div...Targeted bug to: Widget trunk...
-
-When a bug is targeted to the current development release, the general
-distribution task is no longer editable. Instead the status is tracked
-in the release task.
-
-    >>> user_browser.open('http://bugs.launchpad.test/ubuntu/+bug/2')
-    >>> ubuntu_edit_url = (
-    ...     'http://bugs.launchpad.test/ubuntu/+bug/2/+editstatus')
-    >>> user_browser.getLink(url=ubuntu_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> ubuntu_hoary_edit_url = (
-    ...     'http://bugs.launchpad.test/ubuntu/hoary/+bug/2/+editstatus')
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
-    True
-
-The use of the Won't Fix status is restricted. We need to use it to
-illustrate conjoined bugtasks, so we'll make 'no-priv' the bug supervisor
-for Ubuntu:
-
-    >>> admin_browser.open('http://bugs.launchpad.test/ubuntu/+bugsupervisor')
-    >>> admin_browser.getControl('Bug Supervisor').value = 'no-priv'
-    >>> admin_browser.getControl('Change').click()
-
-    >>> print(extract_text(
-    ...     find_tag_by_id(admin_browser.contents, 'bug-supervisor')))
-    Bug supervisor:
-    No Privileges Person
-
-    >>> user_browser.reload()
-
-However, if we reject the Hoary task, it means that the bug is deferred
-to the next release. In that case, the general Ubuntu task will keep
-open, while the release task is invalid.
-
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue = ["Won't Fix"]
-    >>> user_browser.getControl('Save Changes').click()
-
-Now both the general and release tasks are editable.
-
-    >>> user_browser.getLink(url=ubuntu_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue
-    ['New']
-    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue
-    ["Won't Fix"]
-
-If the release task gets reopened, the tasks will be synced again, and
-the distribution task won't be editable.
-
-    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=ubuntu_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
-    True
-
-It's worth noting that only a rejection causes the conjoined bugtasks
-from being separated, if the task gets changed to Fix Released, it
-general distribution task will remain uneditable.
-
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue = ['Fix Released']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=ubuntu_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> user_browser.getLink(url=ubuntu_hoary_edit_url) is not None
-    True
-
-When a bug is targeted to the current development series, the general
-product task is no longer editable. Instead the status is tracked
-in the series task.
-
-    >>> admin_browser.open(
-    ...     "http://launchpad.test/products/firefox/+bug/4/+nominate";)
-    >>> admin_browser.getControl("Trunk").selected = True
-    >>> admin_browser.getControl("Target").click()
-
-    >>> user_browser.open('http://bugs.launchpad.test/firefox/+bug/4')
-    >>> firefox_edit_url = (
-    ...     'http://bugs.launchpad.test/firefox/+bug/4/+editstatus')
-    >>> user_browser.getLink(url=firefox_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> firefox_trunk_edit_url = (
-    ...     'http://bugs.launchpad.test/firefox/trunk/+bug/4/+editstatus')
-    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
-    True
-
-The use of the Won't Fix status is restricted. We need to use it to
-illustrate conjoined bugtasks, so we'll make 'no-priv' the bug supervisor
-for Firefox:
-
-    >>> admin_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/+bugsupervisor')
-    >>> admin_browser.getControl('Bug Supervisor').value = 'no-priv'
-    >>> admin_browser.getControl('Change').click()
-
-    >>> print(extract_text(find_tag_by_id(admin_browser.contents,
-    ...     'bug-supervisor')))
-    Bug supervisor:
-    No Privileges Person
-
-    >>> user_browser.reload()
-
-However, if we reject the Trunk task, it means that the bug is deferred
-to the next release. In that case, the general Firefox task will stay
-open, while the series task is invalid.
-
-    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue = ["Won't Fix"]
-    >>> user_browser.getControl('Save Changes').click()
-    >>> user_browser.url
-    'http://bugs.launchpad.test/firefox/trunk/+bug/4'
-
-Now both the general and series tasks are editable.
-
-    >>> user_browser.getLink(url=firefox_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue
-    ['New']
-    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue
-    ["Won't Fix"]
-
-If the series task gets reopened, the tasks will be synced again, and
-the distribution task won't be editable.
-
-    >>> user_browser.getControl('Status').displayValue = ['Confirmed']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=firefox_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
-    True
-
-It's worth noting that only a rejection causes the conjoined bugtasks
-from being separated, if the task gets changed to Fix Released, the
-general distribution task will remain uneditable.
-
-    >>> user_browser.getLink(url=firefox_trunk_edit_url).click()
-    >>> user_browser.getControl('Status').displayValue = ['Fix Released']
-    >>> user_browser.getControl('Save Changes').click()
-
-    >>> user_browser.getLink(url=firefox_edit_url)
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> user_browser.getLink(url=firefox_trunk_edit_url) is not None
-    True
-
-Now that we've targeted a few bugs towards Firefox 1.0, we can go to
-the productseries' bug page, in order to see a list of all bugs
-targeted to it.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/firefox/1.0/+bugs')
-
-    >>> from lp.bugs.tests.bug import print_bugtasks
-    >>> print_bugtasks(anon_browser.contents)
-    5 Firefox install instructions should be complete
-      Mozilla Firefox 1.0 Undecided New
-    1 Firefox does not support SVG
-      Mozilla Firefox 1.0 Undecided Confirmed
diff --git a/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.rst b/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.rst
new file mode 100644
index 0000000..4fa1eb4
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.rst
@@ -0,0 +1,138 @@
+fficial Bug Tags
+=================
+
+Project admins can manage the official bug tags by following a link
+from the main bug page.
+
+    >>> admin_browser.open(
+    ...     'http://bugs.launchpad.test/firefox')
+    >>> admin_browser.getLink('Edit official tags').click()
+    >>> print(admin_browser.url)
+    http://bugs.launchpad.test/firefox/+manage-official-tags
+
+Tags are entered into a textarea as a list of white-spaces separated
+words.
+
+    >>> admin_browser.getControl('Official Bug Tags').value = 'foo bar'
+    >>> admin_browser.getControl('Save').click()
+    >>> print(admin_browser.url)
+    http://bugs.launchpad.test/firefox
+    >>> admin_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/+manage-official-tags')
+    >>> print(admin_browser.getControl('Official Bug Tags').value)
+    bar foo
+
+The link as well as the edit form is only available for products and
+distributions but not for other bug targets.
+
+    >>> admin_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/1.0')
+    >>> print(admin_browser.getLink('Edit official tags'))
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> admin_browser.open(
+    ...     'http://bugs.launchpad.test/firefox/1.0/+manage-official-tags')
+    Traceback (most recent call last):
+    ...
+    zope.publisher.interfaces.NotFound: ...
+
+The link as well as the edit form is only available for project
+administrators but not for ordinary users.
+
+    >>> browser.open('http://bugs.launchpad.test/firefox')
+    >>> print(browser.getLink('Edit official tags'))
+    Traceback (most recent call last):
+    ...
+    zope.testbrowser.browser.LinkNotFoundError
+
+    >>> browser.open(
+    ...     'http://bugs.launchpad.test/firefox/+manage-official-tags')
+    Traceback (most recent call last):
+    ...
+    zope.security.interfaces.Unauthorized: ...
+
+The link is also available for the bug supervisor.
+
+    >>> from lp.testing.sampledata import ADMIN_EMAIL
+    >>> from lp.testing import login, logout
+    >>> login(ADMIN_EMAIL)
+    >>> supervisor = factory.makePerson()
+    >>> youbuntu = factory.makeProduct(name='youbuntu',
+    ...     bug_supervisor=supervisor,
+    ...     official_malone=True)
+    >>> bug_super_browser = setupBrowser(
+    ...     auth='Basic %s:test' % supervisor.preferredemail.email)
+    >>> logout()
+    >>> bug_super_browser.open(
+    ...     'http://bugs.launchpad.test/youbuntu')
+    >>> bug_super_browser.getLink('Edit official tags').click()
+    >>> print(bug_super_browser.url)
+    http://bugs.launchpad.test/youbuntu/+manage-official-tags
+
+The bug supervisor can also set the tags for the product.
+
+    >>> bug_super_browser.getControl('Official Bug Tags').value = 'foo bar'
+    >>> bug_super_browser.getControl('Save').click()
+    >>> print(bug_super_browser.url)
+    http://bugs.launchpad.test/youbuntu
+    >>> bug_super_browser.open(
+    ...     'http://bugs.launchpad.test/youbuntu/+manage-official-tags')
+    >>> print(bug_super_browser.getControl('Official Bug Tags').value)
+    bar foo
+
+Official Tags on Bug Pages
+--------------------------
+
+Official tags are displayed using a different style from unofficial ones.
+They are grouped together at the beginning of the list.
+
+    >>> from lp.services.webapp import canonical_url
+    >>> from lp.bugs.tests.bug import print_bug_tag_anchors
+    >>> import transaction
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> gfoobar = factory.makeProduct(name='gfoobar')
+    >>> gfoobar.official_bug_tags = [u'alpha', u'charlie']
+    >>> gfoobar_bug = factory.makeBug(target=gfoobar)
+    >>> gfoobar_bug.tags = [u'alpha', u'bravo', u'charlie', u'delta']
+    >>> gfoobar_bug_url = canonical_url(gfoobar_bug)
+    >>> transaction.commit()
+    >>> logout()
+
+    >>> browser.open(gfoobar_bug_url)
+    >>> tags_div = find_tag_by_id(browser.contents, 'bug-tags')
+    >>> print_bug_tag_anchors(tags_div.find_all('a'))
+    official-tag alpha
+    official-tag charlie
+    unofficial-tag bravo
+    unofficial-tag delta
+
+
+Entering Official Tags
+----------------------
+
+Available Official Tags in Javascript
+.....................................
+
+The list of available official tags is present on the page as a Javascript
+variable. This list is used to initialize the tag entry widget. The list
+comprises of the official tags of all targets for which the bug has a task.
+
+    >>> login('foo.bar@xxxxxxxxxxxxx')
+    >>> product1 = factory.makeProduct()
+    >>> product2 = factory.makeProduct()
+    >>> product1.official_bug_tags = [u'eenie', u'meenie']
+    >>> product2.official_bug_tags = [u'miney', u'moe']
+    >>> bug = factory.makeBug(target=product1)
+    >>> bug.addTask(target=product2, owner=factory.makePerson())
+    <BugTask ...>
+    >>> bug_url = canonical_url(bug)
+    >>> transaction.commit()
+    >>> logout()
+
+    >>> browser.open(bug_url)
+    >>> js = find_tag_by_id(browser.contents, 'available-official-tags-js')
+    >>> print(js)
+    <script...>var available_official_tags =
+    ["eenie", "meenie", "miney", "moe"];</script>
diff --git a/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.txt b/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.txt
deleted file mode 100644
index 4fa1eb4..0000000
--- a/lib/lp/bugs/stories/bug-tags/xx-official-bug-tags.txt
+++ /dev/null
@@ -1,138 +0,0 @@
-fficial Bug Tags
-=================
-
-Project admins can manage the official bug tags by following a link
-from the main bug page.
-
-    >>> admin_browser.open(
-    ...     'http://bugs.launchpad.test/firefox')
-    >>> admin_browser.getLink('Edit official tags').click()
-    >>> print(admin_browser.url)
-    http://bugs.launchpad.test/firefox/+manage-official-tags
-
-Tags are entered into a textarea as a list of white-spaces separated
-words.
-
-    >>> admin_browser.getControl('Official Bug Tags').value = 'foo bar'
-    >>> admin_browser.getControl('Save').click()
-    >>> print(admin_browser.url)
-    http://bugs.launchpad.test/firefox
-    >>> admin_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/+manage-official-tags')
-    >>> print(admin_browser.getControl('Official Bug Tags').value)
-    bar foo
-
-The link as well as the edit form is only available for products and
-distributions but not for other bug targets.
-
-    >>> admin_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/1.0')
-    >>> print(admin_browser.getLink('Edit official tags'))
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> admin_browser.open(
-    ...     'http://bugs.launchpad.test/firefox/1.0/+manage-official-tags')
-    Traceback (most recent call last):
-    ...
-    zope.publisher.interfaces.NotFound: ...
-
-The link as well as the edit form is only available for project
-administrators but not for ordinary users.
-
-    >>> browser.open('http://bugs.launchpad.test/firefox')
-    >>> print(browser.getLink('Edit official tags'))
-    Traceback (most recent call last):
-    ...
-    zope.testbrowser.browser.LinkNotFoundError
-
-    >>> browser.open(
-    ...     'http://bugs.launchpad.test/firefox/+manage-official-tags')
-    Traceback (most recent call last):
-    ...
-    zope.security.interfaces.Unauthorized: ...
-
-The link is also available for the bug supervisor.
-
-    >>> from lp.testing.sampledata import ADMIN_EMAIL
-    >>> from lp.testing import login, logout
-    >>> login(ADMIN_EMAIL)
-    >>> supervisor = factory.makePerson()
-    >>> youbuntu = factory.makeProduct(name='youbuntu',
-    ...     bug_supervisor=supervisor,
-    ...     official_malone=True)
-    >>> bug_super_browser = setupBrowser(
-    ...     auth='Basic %s:test' % supervisor.preferredemail.email)
-    >>> logout()
-    >>> bug_super_browser.open(
-    ...     'http://bugs.launchpad.test/youbuntu')
-    >>> bug_super_browser.getLink('Edit official tags').click()
-    >>> print(bug_super_browser.url)
-    http://bugs.launchpad.test/youbuntu/+manage-official-tags
-
-The bug supervisor can also set the tags for the product.
-
-    >>> bug_super_browser.getControl('Official Bug Tags').value = 'foo bar'
-    >>> bug_super_browser.getControl('Save').click()
-    >>> print(bug_super_browser.url)
-    http://bugs.launchpad.test/youbuntu
-    >>> bug_super_browser.open(
-    ...     'http://bugs.launchpad.test/youbuntu/+manage-official-tags')
-    >>> print(bug_super_browser.getControl('Official Bug Tags').value)
-    bar foo
-
-Official Tags on Bug Pages
---------------------------
-
-Official tags are displayed using a different style from unofficial ones.
-They are grouped together at the beginning of the list.
-
-    >>> from lp.services.webapp import canonical_url
-    >>> from lp.bugs.tests.bug import print_bug_tag_anchors
-    >>> import transaction
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> gfoobar = factory.makeProduct(name='gfoobar')
-    >>> gfoobar.official_bug_tags = [u'alpha', u'charlie']
-    >>> gfoobar_bug = factory.makeBug(target=gfoobar)
-    >>> gfoobar_bug.tags = [u'alpha', u'bravo', u'charlie', u'delta']
-    >>> gfoobar_bug_url = canonical_url(gfoobar_bug)
-    >>> transaction.commit()
-    >>> logout()
-
-    >>> browser.open(gfoobar_bug_url)
-    >>> tags_div = find_tag_by_id(browser.contents, 'bug-tags')
-    >>> print_bug_tag_anchors(tags_div.find_all('a'))
-    official-tag alpha
-    official-tag charlie
-    unofficial-tag bravo
-    unofficial-tag delta
-
-
-Entering Official Tags
-----------------------
-
-Available Official Tags in Javascript
-.....................................
-
-The list of available official tags is present on the page as a Javascript
-variable. This list is used to initialize the tag entry widget. The list
-comprises of the official tags of all targets for which the bug has a task.
-
-    >>> login('foo.bar@xxxxxxxxxxxxx')
-    >>> product1 = factory.makeProduct()
-    >>> product2 = factory.makeProduct()
-    >>> product1.official_bug_tags = [u'eenie', u'meenie']
-    >>> product2.official_bug_tags = [u'miney', u'moe']
-    >>> bug = factory.makeBug(target=product1)
-    >>> bug.addTask(target=product2, owner=factory.makePerson())
-    <BugTask ...>
-    >>> bug_url = canonical_url(bug)
-    >>> transaction.commit()
-    >>> logout()
-
-    >>> browser.open(bug_url)
-    >>> js = find_tag_by_id(browser.contents, 'available-official-tags-js')
-    >>> print(js)
-    <script...>var available_official_tags =
-    ["eenie", "meenie", "miney", "moe"];</script>
diff --git a/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.rst b/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.rst
new file mode 100644
index 0000000..4c45b07
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.rst
@@ -0,0 +1,79 @@
+Searching for bug tags
+======================
+
+On the advanced search page it's possible to search for a specific tag.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
+    >>> anon_browser.getControl('Tags').value = 'crash'
+    >>> anon_browser.getControl('Search', index=0).click()
+
+    >>> from lp.bugs.tests.bug import print_bugtasks
+    >>> print_bugtasks(anon_browser.contents)
+    9 Thunderbird crashes thunderbird (Ubuntu) Medium Confirmed
+    10 another test bug linux-source-2.6.15 (Ubuntu) Medium New
+
+If more than one tag is entered, bugs with any those tags will be
+shown.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
+    >>> anon_browser.getControl('Tags').value = 'crash dataloss'
+    >>> anon_browser.getControl('Search', index=0).click()
+    >>> print_bugtasks(anon_browser.contents)
+    9 Thunderbird crashes thunderbird (Ubuntu) Medium Confirmed
+    10 another test bug linux-source-2.6.15 (Ubuntu) Medium New
+    2 Blackhole Trash folder Ubuntu Medium New
+
+If an invalid tag name is entered, an error message will be displayed.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
+    >>> anon_browser.getControl('Tags').value = '!!invalid!!'
+    >>> anon_browser.getControl('Search', index=0).click()
+
+    >>> for tag in find_tags_by_class(anon_browser.contents, 'message'):
+    ...     print(tag.decode_contents())
+    '!!invalid!!' isn't a valid tag name. Tags must start with a letter
+    or number and be lowercase. The characters "+", "-" and "." are also
+    allowed after the first character.
+
+
+Cross-Site Scripting, or XSS
+----------------------------
+
+The tags field and its related messages are properly escaped in order
+to prevent XSS.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
+    >>> anon_browser.getControl('Tags').value = (
+    ...     '<script>alert("cheezburger");</script>')
+    >>> anon_browser.getControl('Search', index=0).click()
+
+The value can be obtained correctly, which indicates that the markup
+is parse-able:
+
+    >>> anon_browser.getControl('Tags').value
+    '<script>alert("cheezburger");</script>'
+
+Indeed, the markup is valid and correctly escaped:
+
+    >>> print(find_tag_by_id(anon_browser.contents, 'field.tag').prettify())
+    <input class="textType" id="field.tag"
+           name="field.tag" size="20" type="text"
+           value='&lt;script&gt;alert("cheezburger");&lt;/script&gt;'/>
+
+The error message is also valid and correctly escaped:
+
+    >>> for tag in find_tags_by_class(anon_browser.contents, 'message'):
+    ...     print(tag.prettify())
+    <div class="message">
+    '&lt;script&gt;alert("cheezburger");&lt;/script&gt;' isn't ...
+    </div>
+
+The script we tried to inject is not present, unescaped, anywhere in
+the page:
+
+    >>> '<script>alert("cheezburger");</script>' in anon_browser.contents
+    False
diff --git a/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.txt b/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.txt
deleted file mode 100644
index 4c45b07..0000000
--- a/lib/lp/bugs/stories/bug-tags/xx-searching-for-tags.txt
+++ /dev/null
@@ -1,79 +0,0 @@
-Searching for bug tags
-======================
-
-On the advanced search page it's possible to search for a specific tag.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
-    >>> anon_browser.getControl('Tags').value = 'crash'
-    >>> anon_browser.getControl('Search', index=0).click()
-
-    >>> from lp.bugs.tests.bug import print_bugtasks
-    >>> print_bugtasks(anon_browser.contents)
-    9 Thunderbird crashes thunderbird (Ubuntu) Medium Confirmed
-    10 another test bug linux-source-2.6.15 (Ubuntu) Medium New
-
-If more than one tag is entered, bugs with any those tags will be
-shown.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
-    >>> anon_browser.getControl('Tags').value = 'crash dataloss'
-    >>> anon_browser.getControl('Search', index=0).click()
-    >>> print_bugtasks(anon_browser.contents)
-    9 Thunderbird crashes thunderbird (Ubuntu) Medium Confirmed
-    10 another test bug linux-source-2.6.15 (Ubuntu) Medium New
-    2 Blackhole Trash folder Ubuntu Medium New
-
-If an invalid tag name is entered, an error message will be displayed.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
-    >>> anon_browser.getControl('Tags').value = '!!invalid!!'
-    >>> anon_browser.getControl('Search', index=0).click()
-
-    >>> for tag in find_tags_by_class(anon_browser.contents, 'message'):
-    ...     print(tag.decode_contents())
-    '!!invalid!!' isn't a valid tag name. Tags must start with a letter
-    or number and be lowercase. The characters "+", "-" and "." are also
-    allowed after the first character.
-
-
-Cross-Site Scripting, or XSS
-----------------------------
-
-The tags field and its related messages are properly escaped in order
-to prevent XSS.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/ubuntu/+bugs?advanced=1')
-    >>> anon_browser.getControl('Tags').value = (
-    ...     '<script>alert("cheezburger");</script>')
-    >>> anon_browser.getControl('Search', index=0).click()
-
-The value can be obtained correctly, which indicates that the markup
-is parse-able:
-
-    >>> anon_browser.getControl('Tags').value
-    '<script>alert("cheezburger");</script>'
-
-Indeed, the markup is valid and correctly escaped:
-
-    >>> print(find_tag_by_id(anon_browser.contents, 'field.tag').prettify())
-    <input class="textType" id="field.tag"
-           name="field.tag" size="20" type="text"
-           value='&lt;script&gt;alert("cheezburger");&lt;/script&gt;'/>
-
-The error message is also valid and correctly escaped:
-
-    >>> for tag in find_tags_by_class(anon_browser.contents, 'message'):
-    ...     print(tag.prettify())
-    <div class="message">
-    '&lt;script&gt;alert("cheezburger");&lt;/script&gt;' isn't ...
-    </div>
-
-The script we tried to inject is not present, unescaped, anywhere in
-the page:
-
-    >>> '<script>alert("cheezburger");</script>' in anon_browser.contents
-    False
diff --git a/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.rst b/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.rst
new file mode 100644
index 0000000..0ca1f7c
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.rst
@@ -0,0 +1,53 @@
+On the bug listings page there is a portlet which shows all tags that
+have been used for bugs in this context. The content of this portlet is loaded
+using Javascript after page load.
+
+    >>> anon_browser.open('http://launchpad.test/ubuntu/+bugs')
+    >>> anon_browser.getLink(id='tags-content-link').click()
+    >>> print(anon_browser.url)
+    http://launchpad.test/ubuntu/+bugtarget-portlet-tags-content
+    >>> tags_portlet = find_tags_by_class(
+    ...     anon_browser.contents, 'data-list')[0]
+    >>> for a_tag in tags_portlet('a'):
+    ...     print(a_tag.decode_contents())
+    crash
+    dataloss
+    pebcak
+
+If we click on a tag, only bugs with that tag are displayed:
+
+    >>> anon_browser.getLink(url='crash').click()
+    >>> anon_browser.url
+    'http://launchpad.test/ubuntu/+bugs?field.tag=crash'
+
+    >>> from lp.bugs.tests.bug import print_bugtasks
+    >>> print_bugtasks(anon_browser.contents)
+    9 Thunderbird crashes
+      thunderbird (Ubuntu) Medium Confirmed
+    10 another test bug
+       linux-source-2.6.15 (Ubuntu) Medium New
+
+Clicking on a tags shows only bugs that have that specific tag, so if
+we click on another tag, the bugs that were shown previously won't be
+shown.
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/ubuntu/+bugtarget-portlet-tags-content')
+    >>> anon_browser.getLink('dataloss').click()
+    >>> anon_browser.url
+    'http://launchpad.test/ubuntu/+bugs?field.tag=dataloss'
+    >>> print_bugtasks(anon_browser.contents)
+    2 Blackhole Trash folder
+      Ubuntu Medium New
+
+We update bug #2's status to Invalid to demonstrate that the portlet body is
+not available when no tags are relevant:
+
+    >>> admin_browser.open('http://bugs.launchpad.test/tomcat/+bug/2')
+    >>> admin_browser.getControl("Status", index=0).value = ["Invalid"]
+    >>> admin_browser.getControl("Save Changes", index=0).click()
+
+    >>> anon_browser.open(
+    ...     'http://launchpad.test/tomcat/+bugtarget-portlet-tags-content')
+    >>> print(extract_text(anon_browser.contents))
+    Tags
diff --git a/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.txt b/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.txt
deleted file mode 100644
index 0ca1f7c..0000000
--- a/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-listings-page.txt
+++ /dev/null
@@ -1,53 +0,0 @@
-On the bug listings page there is a portlet which shows all tags that
-have been used for bugs in this context. The content of this portlet is loaded
-using Javascript after page load.
-
-    >>> anon_browser.open('http://launchpad.test/ubuntu/+bugs')
-    >>> anon_browser.getLink(id='tags-content-link').click()
-    >>> print(anon_browser.url)
-    http://launchpad.test/ubuntu/+bugtarget-portlet-tags-content
-    >>> tags_portlet = find_tags_by_class(
-    ...     anon_browser.contents, 'data-list')[0]
-    >>> for a_tag in tags_portlet('a'):
-    ...     print(a_tag.decode_contents())
-    crash
-    dataloss
-    pebcak
-
-If we click on a tag, only bugs with that tag are displayed:
-
-    >>> anon_browser.getLink(url='crash').click()
-    >>> anon_browser.url
-    'http://launchpad.test/ubuntu/+bugs?field.tag=crash'
-
-    >>> from lp.bugs.tests.bug import print_bugtasks
-    >>> print_bugtasks(anon_browser.contents)
-    9 Thunderbird crashes
-      thunderbird (Ubuntu) Medium Confirmed
-    10 another test bug
-       linux-source-2.6.15 (Ubuntu) Medium New
-
-Clicking on a tags shows only bugs that have that specific tag, so if
-we click on another tag, the bugs that were shown previously won't be
-shown.
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/ubuntu/+bugtarget-portlet-tags-content')
-    >>> anon_browser.getLink('dataloss').click()
-    >>> anon_browser.url
-    'http://launchpad.test/ubuntu/+bugs?field.tag=dataloss'
-    >>> print_bugtasks(anon_browser.contents)
-    2 Blackhole Trash folder
-      Ubuntu Medium New
-
-We update bug #2's status to Invalid to demonstrate that the portlet body is
-not available when no tags are relevant:
-
-    >>> admin_browser.open('http://bugs.launchpad.test/tomcat/+bug/2')
-    >>> admin_browser.getControl("Status", index=0).value = ["Invalid"]
-    >>> admin_browser.getControl("Save Changes", index=0).click()
-
-    >>> anon_browser.open(
-    ...     'http://launchpad.test/tomcat/+bugtarget-portlet-tags-content')
-    >>> print(extract_text(anon_browser.contents))
-    Tags
diff --git a/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-page.rst b/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-page.rst
new file mode 100644
index 0000000..e4b2e8d
--- /dev/null
+++ b/lib/lp/bugs/stories/bug-tags/xx-tags-on-bug-page.rst
@@ -0,0 +1,80 @@
+Users can see bugs tags on the bug page.
+
+    >>> user_browser.open('http://bugs.launchpad.test/firefox/+bug/1')
+    >>> user_browser.url
+    'http://bugs.launchpad.test/firefox/+bug/1'
+    >>> print(extract_text(find_tag_by_id(user_browser.contents, 'bug-tags')))
+    Add tags  Tag help
+
+Let's specify some tags:
+
+    >>> user_browser.getLink('Add tags').click()
+    >>> tags = user_browser.getControl('Tags')
+    >>> tags.value
+    ''
+
+If we enter an invalid tag name, we'll get an error.
+
+    >>> tags.value = "!!invalid!! foo"
+    >>> user_browser.getControl('Change').click()
+
+    >>> for tag in find_tags_by_class(user_browser.contents, 'message'):
+    ...     print(tag.decode_contents())
+    There is 1 error.
+    '!!invalid!!' isn't a valid tag name. Tags must start with a letter
+    or number and be lowercase. The characters "+", "-" and "." are also
+    allowed after the first character.
+
+Let's specify two valid tags.
+
+    >>> tags = user_browser.getControl('Tags')
+    >>> tags.value = "bar foo"
+    >>> user_browser.getControl('Change').click()
+
+
+Now the tags will be displayed on the bug page:
+
+    >>> 'Tags:' in user_browser.contents
+    True
+    >>> 'foo' in user_browser.contents
+    True
+    >>> 'bar' in user_browser.contents
+    True
+
+Simply changing the ordering of the bug tags won't cause anything to
+happe

Follow ups