← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad

 

Guilherme Salgado has proposed merging lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #944119 in Launchpad itself: "Add help popup to the new work-item editor"
  https://bugs.launchpad.net/launchpad/+bug/944119

For more details, see:
https://code.launchpad.net/~salgado/launchpad/workitems-widget-help-popup/+merge/95893


-- 
https://code.launchpad.net/~salgado/launchpad/workitems-widget-help-popup/+merge/95893
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~salgado/launchpad/workitems-widget-help-popup into lp:launchpad.
=== modified file 'lib/lp/blueprints/adapters.py'
--- lib/lp/blueprints/adapters.py	2010-07-30 12:56:27 +0000
+++ lib/lp/blueprints/adapters.py	2012-03-05 13:06:21 +0000
@@ -18,12 +18,14 @@
         summary=None, whiteboard=None, specurl=None, productseries=None,
         distroseries=None, milestone=None, name=None, priority=None,
         definition_status=None, target=None, bugs_linked=None,
-        bugs_unlinked=None, approver=None, assignee=None, drafter=None):
+        bugs_unlinked=None, approver=None, assignee=None, drafter=None,
+        workitems_text=None):
         self.specification = specification
         self.user = user
         self.title = title
         self.summary = summary
         self.whiteboard = whiteboard
+        self.workitems_text = workitems_text
         self.specurl = specurl
         self.productseries = productseries
         self.distroseries = distroseries

=== modified file 'lib/lp/blueprints/browser/configure.zcml'
--- lib/lp/blueprints/browser/configure.zcml	2012-02-17 04:09:06 +0000
+++ lib/lp/blueprints/browser/configure.zcml	2012-03-05 13:06:21 +0000
@@ -373,6 +373,12 @@
             permission="launchpad.AnyPerson"
             template="../templates/specification-edit.pt"/>
         <browser:page
+            name="+workitems"
+            for="lp.blueprints.interfaces.specification.ISpecification"
+            class="lp.blueprints.browser.specification.SpecificationEditWorkItemsView"
+            permission="launchpad.AnyPerson"
+            template="../templates/specification-edit.pt"/>
+        <browser:page
             name="+people"
             for="lp.blueprints.interfaces.specification.ISpecification"
             class="lp.blueprints.browser.specification.SpecificationEditPeopleView"

=== modified file 'lib/lp/blueprints/browser/specification.py'
--- lib/lp/blueprints/browser/specification.py	2012-03-01 20:00:29 +0000
+++ lib/lp/blueprints/browser/specification.py	2012-03-05 13:06:21 +0000
@@ -21,6 +21,7 @@
     'SpecificationEditStatusView',
     'SpecificationEditView',
     'SpecificationEditWhiteboardView',
+    'SpecificationEditWorkItemsView',
     'SpecificationGoalDecideView',
     'SpecificationGoalProposeView',
     'SpecificationLinkBranchView',
@@ -411,7 +412,7 @@
 
     usedfor = ISpecification
     links = ['edit', 'people', 'status', 'priority',
-             'whiteboard', 'proposegoal',
+             'whiteboard', 'proposegoal', 'workitems',
              'milestone', 'requestfeedback', 'givefeedback', 'subscription',
              'addsubscriber',
              'linkbug', 'unlinkbug', 'linkbranch',
@@ -521,6 +522,11 @@
         return Link('+whiteboard', text, icon='edit')
 
     @enabled_with_permission('launchpad.AnyPerson')
+    def workitems(self):
+        text = 'Edit work items'
+        return Link('+workitems', text, icon='edit')
+
+    @enabled_with_permission('launchpad.AnyPerson')
     def linkbranch(self):
         if self.context.linked_branches.count() > 0:
             text = 'Link to another branch'
@@ -648,6 +654,14 @@
             hide_empty=False)
 
     @property
+    def workitems_text_widget(self):
+        """The Work Items text as a widget."""
+        return TextAreaEditorWidget(
+            self.context, ISpecification['workitems_text'], title="Work Items",
+            edit_view='+workitems', edit_title='Edit work items',
+            hide_empty=False)
+
+    @property
     def direction_widget(self):
         return BooleanChoiceWidget(
             self.context, ISpecification['direction_approved'],
@@ -710,6 +724,12 @@
     custom_widget('whiteboard', TextAreaWidget, height=15)
 
 
+class SpecificationEditWorkItemsView(SpecificationEditView):
+    label = 'Edit specification work items'
+    field_names = ['workitems_text']
+    custom_widget('workitems_text', TextAreaWidget, height=15)
+
+
 class SpecificationEditPeopleView(SpecificationEditView):
     label = 'Change the people involved'
     field_names = ['assignee', 'drafter', 'approver', 'whiteboard']

=== modified file 'lib/lp/blueprints/configure.zcml'
--- lib/lp/blueprints/configure.zcml	2012-02-10 17:32:41 +0000
+++ lib/lp/blueprints/configure.zcml	2012-03-05 13:06:21 +0000
@@ -181,7 +181,8 @@
     <require
         permission="launchpad.AnyPerson"
         attributes="linkBug
-                    unlinkBug"/>
+                    unlinkBug
+                    setWorkItems"/>
   </class>
 
   <class class="lp.blueprints.model.specificationbug.SpecificationBug">

=== added file 'lib/lp/blueprints/help/workitems-help.html'
--- lib/lp/blueprints/help/workitems-help.html	1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/help/workitems-help.html	2012-03-05 13:06:21 +0000
@@ -0,0 +1,48 @@
+<html>
+  <head>
+    <title>Blueprint work items</title>
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssreset/reset.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssfonts/fonts.css" />
+    <link rel="stylesheet" type="text/css"
+          href="/+icing/yui/cssbase/base.css" />
+  </head>
+  <body>
+    <h1>Using work items</h1>
+
+    Often, it can take a few separate steps to complete the work described in a
+    blueprint. Launchpad lets you track these steps in the "Work items" text box.
+
+    <h2>Describing work items</h2>
+
+    It's easy to track the steps, or work items, necessary to complete the
+    blueprint. Using the <em>Work items</em> text box, give a short description of each work
+    item along with its status. For example:
+
+    <pre>
+
+      Design the UI: DONE
+      Test the UI: TODO
+      Bootstrap the dev environment: POSTPONED
+    </pre>
+
+    Each work item goes on its own line, followed by a colon and its status.
+
+    <h2>Work item statuses</h2>
+
+    Each work item can have one of four statuses:
+
+    <ul>
+      <li>TODO</li>
+      <li>INPROGRESS</li>
+      <li>DONE</li>
+      <li>POSTPONED</li>
+    </ul>
+
+    <h2>More about work items</h2>
+
+    There's <a href="https://help.launchpad.net/WorkItems"; target="_blank">more
+    about using work items</a> in the Launchpad help wiki.
+  </body>
+</html>

=== modified file 'lib/lp/blueprints/interfaces/specification.py'
--- lib/lp/blueprints/interfaces/specification.py	2012-02-29 13:27:51 +0000
+++ lib/lp/blueprints/interfaces/specification.py	2012-03-05 13:06:21 +0000
@@ -79,6 +79,7 @@
     PublicPersonChoice,
     Summary,
     Title,
+    WorkItemsText,
     )
 from lp.services.webapp import canonical_url
 from lp.services.webapp.menu import structured
@@ -303,6 +304,13 @@
                 "Any notes on the status of this spec you would like to "
                 "make. Your changes will override the current text.")),
         as_of="devel")
+    workitems_text = exported(
+        WorkItemsText(
+            title=_('Work Items'), required=False, readonly=True,
+            description=_(
+                "Work items for this specification input in a text format. "
+                "Your changes will override the current work items.")),
+        as_of="devel")
     direction_approved = exported(
         Bool(title=_('Basic direction approved?'),
              required=True, default=False,
@@ -616,6 +624,16 @@
 
     export_as_webservice_entry(as_of="beta")
 
+    @mutator_for(ISpecificationPublic['workitems_text'])
+    @operation_parameters(new_work_items=WorkItemsText())
+    @export_write_operation()
+    @operation_for_version('devel')
+    def setWorkItems(new_work_items):
+        """Set work items on this specification.
+
+        :param new_work_items: Work items to set.
+        """
+
     @operation_parameters(
         bug=Reference(schema=Interface))  # Really IBug
     @export_write_operation()
@@ -694,6 +712,7 @@
     title = Attribute("The spec title or None.")
     summary = Attribute("The spec summary or None.")
     whiteboard = Attribute("The spec whiteboard or None.")
+    workitems_text = Attribute("The spec work items as text or None.")
     specurl = Attribute("The URL to the spec home page (not in Launchpad).")
     productseries = Attribute("The product series.")
     distroseries = Attribute("The series to which this is targeted.")

=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py	2012-02-29 13:03:19 +0000
+++ lib/lp/blueprints/model/specification.py	2012-03-05 13:06:21 +0000
@@ -224,6 +224,30 @@
             self._subscriptions, key=lambda sub: person_sort_key(sub.person))
 
     @property
+    def workitems_text(self):
+        """See ISpecification."""
+        workitems_lines = []
+        milestone = None
+        for work_item in self.work_items:
+            if work_item.milestone != milestone:
+                milestone = work_item.milestone
+                # Separate milestone blocks, but no blank line if this is the
+                # first work item
+                if work_item.sequence > 0:
+                    workitems_lines.append("")
+                workitems_lines.append("Work items for %s:" % milestone.name)
+            assignee = work_item.assignee
+            if assignee is not None:
+                assignee_part = "[%s] " % assignee.name
+            else:
+                assignee_part = ""
+            # work_items are ordered by sequence
+            workitems_lines.append("%s%s: %s" % (assignee_part,
+                                                 work_item.title,
+                                                 work_item.status.name))
+        return "\n".join(workitems_lines)
+
+    @property
     def target(self):
         """See ISpecification."""
         if self.product:
@@ -249,6 +273,10 @@
             SpecificationWorkItem, specification=self,
             deleted=False).order_by("sequence")
 
+    def setWorkItems(self, new_work_items):
+        field = ISpecification['workitems_text'].bind(self)
+        self.updateWorkItems(field.parseAndValidate(new_work_items))
+
     def _deleteWorkItemsNotMatching(self, titles):
         """Delete all work items whose title does not match the given ones.
 
@@ -570,7 +598,7 @@
                                "distroseries", "milestone"))
         delta.recordNewAndOld(("name", "priority", "definition_status",
                                "target", "approver", "assignee", "drafter",
-                               "whiteboard"))
+                               "whiteboard", "workitems_text"))
         delta.recordListAddedAndRemoved("bugs",
                                         "bugs_linked",
                                         "bugs_unlinked")
@@ -1035,7 +1063,7 @@
 
     def new(self, name, title, specurl, summary, definition_status,
         owner, approver=None, product=None, distribution=None, assignee=None,
-        drafter=None, whiteboard=None,
+        drafter=None, whiteboard=None, workitems_text=None,
         priority=SpecificationPriority.UNDEFINED):
         """See ISpecificationSet."""
         # Adapt the NewSpecificationDefinitionStatus item to a

=== modified file 'lib/lp/blueprints/model/tests/test_specification.py'
--- lib/lp/blueprints/model/tests/test_specification.py	2012-02-28 04:24:19 +0000
+++ lib/lp/blueprints/model/tests/test_specification.py	2012-03-05 13:06:21 +0000
@@ -17,6 +17,7 @@
     SpecificationWorkItemStatus,
     )
 from lp.blueprints.model.specificationworkitem import SpecificationWorkItem
+from lp.registry.model.milestone import Milestone
 from lp.services.webapp import canonical_url
 from lp.testing import (
     ANONYMOUS,
@@ -152,6 +153,22 @@
 
     layer = DatabaseFunctionalLayer
 
+    def assertWorkItemsTextContains(self, spec, items):
+        expected_lines = []
+        for item in items:
+            if isinstance(item, SpecificationWorkItem):
+                line = ''
+                if item.assignee is not None:
+                    line = "[%s] " % item.assignee.name
+                expected_lines.append(u"%s%s: %s" % (line, item.title,
+                                                    item.status.name))
+            else:
+                self.assertIsInstance(item, Milestone)
+                expected_lines.append(u"")
+                expected_lines.append(u"Work items for %s:" % item.name)
+        expected = "\n".join(expected_lines)
+        self.assertEqual(expected, spec.workitems_text)
+
     def test_anonymous_newworkitem_not_allowed(self):
         spec = self.factory.makeSpecification()
         login(ANONYMOUS)
@@ -179,6 +196,80 @@
         self.assertEqual(title, work_item.title)
         self.assertEqual(milestone, work_item.milestone)
 
+    def test_workitems_text_no_workitems(self):
+        spec = self.factory.makeSpecification()
+        self.assertEqual('', spec.workitems_text)
+
+    def test_workitems_text_deleted_workitem(self):
+        work_item = self.factory.makeSpecificationWorkItem(deleted=True)
+        self.assertEqual('', work_item.specification.workitems_text)
+
+    def test_workitems_text_single_workitem(self):
+        work_item = self.factory.makeSpecificationWorkItem()
+        self.assertWorkItemsTextContains(work_item.specification, [work_item])
+
+    def test_workitems_text_multi_workitems_all_statuses(self):
+        spec = self.factory.makeSpecification()
+        work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+            status=SpecificationWorkItemStatus.TODO)
+        work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+            status=SpecificationWorkItemStatus.DONE)
+        work_item3 = self.factory.makeSpecificationWorkItem(specification=spec,
+            status=SpecificationWorkItemStatus.POSTPONED)
+        work_item4 = self.factory.makeSpecificationWorkItem(specification=spec,
+            status=SpecificationWorkItemStatus.INPROGRESS)
+        work_item5 = self.factory.makeSpecificationWorkItem(specification=spec,
+            status=SpecificationWorkItemStatus.BLOCKED)
+        work_items = [work_item1, work_item2, work_item3, work_item4, work_item5]
+        self.assertWorkItemsTextContains(spec, work_items)
+
+    def test_workitems_text_with_milestone(self):
+        spec = self.factory.makeSpecification()
+        milestone = self.factory.makeMilestone(product=spec.product)
+        login_person(spec.owner)
+        work_item = self.factory.makeSpecificationWorkItem(specification=spec,
+            title=u'new-work-item',
+            status=SpecificationWorkItemStatus.TODO,
+            milestone=milestone)
+        items = [milestone, work_item]
+        self.assertWorkItemsTextContains(spec, items)
+
+    def test_workitems_text_with_implicit_and_explicit_milestone(self):
+        spec = self.factory.makeSpecification()
+        milestone = self.factory.makeMilestone(product=spec.product)
+        login_person(spec.owner)
+        work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+            title=u'Work item with default milestone',
+            status=SpecificationWorkItemStatus.TODO,
+            milestone=None)
+        work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+            title=u'Work item with set milestone',
+            status=SpecificationWorkItemStatus.TODO,
+            milestone=milestone)
+        items = [work_item1, milestone, work_item2]
+        self.assertWorkItemsTextContains(spec, items)
+
+    def test_workitems_text_with_different_milestones(self):
+        spec = self.factory.makeSpecification()
+        milestone1 = self.factory.makeMilestone(product=spec.product)
+        milestone2 = self.factory.makeMilestone(product=spec.product)
+        login_person(spec.owner)
+        work_item1 = self.factory.makeSpecificationWorkItem(specification=spec,
+            title=u'Work item with first milestone',
+            status=SpecificationWorkItemStatus.TODO,
+            milestone=milestone1)
+        work_item2 = self.factory.makeSpecificationWorkItem(specification=spec,
+            title=u'Work item with second milestone',
+            status=SpecificationWorkItemStatus.TODO,
+            milestone=milestone2)
+        items = [milestone1, work_item1, milestone2, work_item2]
+        self.assertWorkItemsTextContains(spec, items)
+
+    def test_workitems_text_with_assignee(self):
+        assignee = self.factory.makePerson()
+        work_item = self.factory.makeSpecificationWorkItem(assignee=assignee)
+        self.assertWorkItemsTextContains(work_item.specification, [work_item])
+
     def test_work_items_property(self):
         spec = self.factory.makeSpecification()
         wi1 = self.factory.makeSpecificationWorkItem(

=== modified file 'lib/lp/blueprints/templates/specification-index.pt'
--- lib/lp/blueprints/templates/specification-index.pt	2012-02-01 15:31:32 +0000
+++ lib/lp/blueprints/templates/specification-index.pt	2012-03-05 13:06:21 +0000
@@ -284,6 +284,12 @@
     </div>
 
     <div class="portlet">
+      <a href="/+help-blueprints/workitems-help.html" target="help" class="sprite maybe">&nbsp;
+        <span class="invisible-link">Tag help</span></a>
+      <div class="wide" tal:content="structure view/workitems_text_widget" />
+    </div>
+
+    <div class="portlet">
       <tal:deptree condition="view/has_dep_tree">
         <h2>Dependency tree</h2>
 
@@ -320,7 +326,7 @@
     </div>
 
   <script type="text/javascript">
-    LPJS.use('lp.anim', 'lp.ui', function(Y) {
+    LPJS.use('lp.anim', 'lp.ui', 'node', 'widget', function(Y) {
 
         Y.on('lp:context:implementation_status:changed', function(e) {
             var icon = Y.one('#informational-icon');
@@ -361,6 +367,25 @@
             window.document.title = title;
         });
 
+        // Watch for the whiteboard for edit mode so we can show/hide a
+        // message to the user to make sure not to put work items in there.
+        var whiteboard_node = Y.one('#edit-whiteboard');
+        var whiteboard = Y.Widget.getByNode(whiteboard_node);
+        var notice_node = Y.Node.create('<p/>');
+        notice_node.set('id', 'wimessage');
+        notice_node.addClass('informational message');
+        notice_node.setContent('Please note that work items go in the separate Work Items input field below.');
+        whiteboard.editor.on('visibleChange', function (ev) {
+                var par = whiteboard_node.get('parentNode');
+                // If we're visible, show the message
+                if (ev.newVal) {
+                    par.insertBefore(notice_node, whiteboard_node);
+                } else {
+                    // Otherwise we need to remove the node
+                    par.removeChild(notice_node)
+                }
+        });
+
       });
     </script>
 

=== modified file 'lib/lp/blueprints/tests/test_webservice.py'
--- lib/lp/blueprints/tests/test_webservice.py	2012-01-01 02:58:52 +0000
+++ lib/lp/blueprints/tests/test_webservice.py	2012-03-05 13:06:21 +0000
@@ -143,6 +143,12 @@
         spec_webservice = self.getSpecOnWebservice(spec)
         self.assertEqual(spec.whiteboard, spec_webservice.whiteboard)
 
+    def test_representation_contains_workitems(self):
+        work_item = self.factory.makeSpecificationWorkItem()
+        spec_webservice = self.getSpecOnWebservice(work_item.specification)
+        self.assertEqual(work_item.specification.work_items_text,
+                         spec_webservice.work_items_text)
+
     def test_representation_contains_milestone(self):
         product = self.factory.makeProduct()
         productseries = self.factory.makeProductSeries(product=product)

=== modified file 'lib/lp/services/fields/__init__.py'
--- lib/lp/services/fields/__init__.py	2012-02-16 00:38:53 +0000
+++ lib/lp/services/fields/__init__.py	2012-03-05 13:06:21 +0000
@@ -53,6 +53,7 @@
     'URIField',
     'UniqueField',
     'Whiteboard',
+    'WorkItemsText',
     'is_public_person_or_closed_team',
     'is_public_person',
     ]
@@ -102,12 +103,18 @@
     name_validator,
     valid_name,
     )
+from lp.blueprints.enums import SpecificationWorkItemStatus
 from lp.bugs.errors import InvalidDuplicateValue
 from lp.registry.interfaces.pillar import IPillarNameSet
 from lp.services.webapp.interfaces import ILaunchBag
 
 # Marker object to tell BaseImageUpload to keep the existing image.
 KEEP_SAME_IMAGE = object()
+# Regexp for detecting milestone headers in work items text.
+MILESTONE_RE = re.compile('^work items(.*)\s*:\s*$', re.I)
+# Regexp for work items.
+WORKITEM_RE = re.compile(
+    '^(\[(?P<assignee>.*)\])?\s*(?P<title>.*)\s*:\s*(?P<status>.*)\s*$', re.I)
 
 
 # Field Interfaces
@@ -858,3 +865,95 @@
         else:
             # The vocabulary prevents the revealing of private team names.
             raise PrivateTeamNotAllowed(value)
+
+
+class WorkItemsText(Text):
+
+    def parseLine(self, line):
+        workitem_match = WORKITEM_RE.search(line)
+        if workitem_match:
+            assignee = workitem_match.group('assignee')
+            title = workitem_match.group('title')
+            status = workitem_match.group('status')
+        else:
+            raise LaunchpadValidationError(
+                'Invalid work item format: "%s"' % line)
+        if title == '':
+            raise LaunchpadValidationError(
+                'No work item title found on "%s"' % line)
+        if title.startswith('['):
+            raise LaunchpadValidationError(
+                'Missing closing "]" for assignee on "%s".' % line)
+
+        return {'title': title, 'status': status.strip().upper(),
+                'assignee': assignee}
+
+    def parse(self, text):
+        sequence = 0
+        milestone = None
+        work_items = []
+        for line in text.splitlines():
+            if line.strip() == '':
+                continue
+            milestone_match = MILESTONE_RE.search(line)
+            if milestone_match:
+                milestone_part = milestone_match.group(1).strip()
+                milestone = milestone_part.split()[-1]
+            else:
+                new_work_item = self.parseLine(line)
+                new_work_item['milestone'] = milestone
+                new_work_item['sequence'] = sequence
+                sequence += 1
+                work_items.append(new_work_item)
+        return work_items
+
+    def validate(self, value):
+        self.parseAndValidate(value)
+
+    def parseAndValidate(self, text):
+        work_items = self.parse(text)
+        for work_item in work_items:
+            work_item['status'] = self.getStatus(work_item['status'])
+            work_item['assignee'] = self.getAssignee(work_item['assignee'])
+            work_item['milestone'] = self.getMilestone(work_item['milestone'])
+        return work_items
+
+    def getStatus(self, text):
+        valid_statuses = SpecificationWorkItemStatus.items
+        if text.lower() not in [item.name.lower() for item in valid_statuses]:
+            raise LaunchpadValidationError('Unknown status: %s' % text)
+        return valid_statuses[text.upper()]
+
+    def getAssignee(self, assignee_name):
+        if assignee_name is None:
+            return None
+        from lp.registry.interfaces.person import IPersonSet
+        assignee = getUtility(IPersonSet).getByName(assignee_name)
+        if assignee is None:
+            raise LaunchpadValidationError("Unknown person name: %s" % assignee_name)
+        return assignee
+
+    def getMilestone(self, milestone_name):
+        if milestone_name is None:
+            return None
+
+        target = self.context.target
+
+        milestone = None
+        from lp.registry.interfaces.distribution import IDistribution
+        from lp.registry.interfaces.milestone import IMilestoneSet
+        from lp.registry.interfaces.product import IProduct
+        if IProduct.providedBy(target):
+            milestone = getUtility(IMilestoneSet).getByNameAndProduct(
+                milestone_name, target)
+        elif IDistribution.providedBy(target):
+            milestone = getUtility(IMilestoneSet).getByNameAndDistribution(
+                milestone_name, target)
+        else:
+            raise AssertionError("Unexpected target type.")
+
+        if milestone is None:
+            raise LaunchpadValidationError("The milestone '%s' is not valid "
+                                           "for the target '%s'." % \
+                                               (milestone_name, target.name))
+        return milestone

=== modified file 'lib/lp/services/fields/tests/test_fields.py'
--- lib/lp/services/fields/tests/test_fields.py	2012-01-01 02:58:52 +0000
+++ lib/lp/services/fields/tests/test_fields.py	2012-03-05 13:06:21 +0000
@@ -14,6 +14,7 @@
 from zope.schema.interfaces import TooShort
 
 from lp.app.validators import LaunchpadValidationError
+from lp.blueprints.enums import SpecificationWorkItemStatus
 from lp.registry.interfaces.nameblacklist import INameBlacklistSet
 from lp.registry.interfaces.person import (
     CLOSED_TEAM_POLICY,
@@ -26,6 +27,7 @@
     FormattableDate,
     is_public_person_or_closed_team,
     StrippableText,
+    WorkItemsText,
     )
 from lp.testing import (
     login_person,
@@ -108,6 +110,252 @@
         self.assertEqual(None, field.validate(u'  a  '))
 
 
+class TestWorkItemsTextValidation(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestWorkItemsTextValidation, self).setUp()
+        self.field = WorkItemsText(__name__='test')
+
+    def test_parseandvalidate(self):
+        status = SpecificationWorkItemStatus.TODO
+        assignee = self.factory.makePerson()
+        milestone = self.factory.makeMilestone()
+        title = 'A work item'
+        specification = self.factory.makeSpecification(
+            product=milestone.product)
+        field = self.field.bind(specification)
+        work_items_text = ("Work items for %s:\n"
+                           "[%s]%s: %s" % (milestone.name, assignee.name, title,
+                                           status.name))
+        work_item = field.parseAndValidate(work_items_text)[0]
+        self.assertEqual({'assignee': assignee,
+                          'milestone': milestone,
+                          'sequence': 0,
+                          'status': status,
+                          'title': title}, work_item)
+
+    def test_unknown_assignee_is_rejected(self):
+        person_name = 'test-person'
+        self.assertRaises(
+            LaunchpadValidationError, self.field.getAssignee, person_name)
+
+    def test_validate_valid_assignee(self):
+        assignee = self.factory.makePerson()
+        self.assertEqual(assignee, self.field.getAssignee(assignee.name))
+
+    def test_validate_unset_assignee(self):
+        self.assertIs(None, self.field.getAssignee(None))
+
+    def test_validate_unset_milestone(self):
+        self.assertIs(None, self.field.getMilestone(None))
+
+    def test_validate_unknown_milestone(self):
+        specification = self.factory.makeSpecification()
+        field = self.field.bind(specification)
+        self.assertRaises(
+            LaunchpadValidationError, field.getMilestone, 'does-not-exist')
+
+    def test_validate_valid_product_milestone(self):
+        milestone = self.factory.makeMilestone()
+        specification = self.factory.makeSpecification(
+            product=milestone.product)
+        field = self.field.bind(specification)
+        self.assertEqual(milestone, field.getMilestone(milestone.name))
+
+    def test_validate_valid_distro_milestone(self):
+        distro = self.factory.makeDistribution()
+        milestone = self.factory.makeMilestone(distribution=distro)
+        specification = self.factory.makeSpecification(
+            distribution=milestone.distribution)
+        field = self.field.bind(specification)
+        self.assertEqual(milestone, field.getMilestone(milestone.name))
+
+    def test_validate_invalid_milestone(self):
+        milestone_name = 'test-milestone'
+        self.factory.makeMilestone(name=milestone_name)
+        # Milestone exists but is not a target for this spec.
+        specification = self.factory.makeSpecification(product=None)
+        field = self.field.bind(specification)
+        self.assertRaises(
+            LaunchpadValidationError, field.getMilestone, milestone_name)
+
+    def test_validate_invalid_status(self):
+        self.assertRaises(
+            LaunchpadValidationError, self.field.getStatus,
+            'Invalid status: FOO')
+
+    def test_validate_valid_statuses(self):
+        statuses = [SpecificationWorkItemStatus.TODO,
+                    SpecificationWorkItemStatus.DONE,
+                    SpecificationWorkItemStatus.POSTPONED,
+                    SpecificationWorkItemStatus.INPROGRESS,
+                    SpecificationWorkItemStatus.BLOCKED]
+        for status in statuses:
+            validated_status = self.field.getStatus(status.name)
+            self.assertEqual(validated_status, status)
+
+
+class TestWorkItemsText(TestCase):
+
+    def setUp(self):
+        super(TestWorkItemsText, self).setUp()
+        self.field = WorkItemsText(__name__='test')
+
+    def test_validate_raises_LaunchpadValidationError(self):
+        self.assertRaises(
+            LaunchpadValidationError, self.field.validate,
+            'This is not a valid work item.')
+
+    def test_single_line_parsing(self):
+        work_items_title = 'Test this work item'
+        parsed = self.field.parseLine('%s: TODO' % (work_items_title))
+        self.assertEqual(parsed['title'], work_items_title)
+        self.assertEqual(parsed['status'], 'TODO')
+
+    def test_url_and_colon_in_title(self):
+        work_items_title = 'Test this: which is a url: http://www.linaro.org/'
+        parsed = self.field.parseLine('%s: TODO' % (work_items_title))
+        self.assertEqual(parsed['title'], work_items_title)
+
+    def test_silly_caps_status_parsing(self):
+        parsed_upper = self.field.parseLine('Test this work item: TODO    ')
+        self.assertEqual(parsed_upper['status'], 'TODO')
+        parsed_lower = self.field.parseLine('Test this work item:     todo')
+        self.assertEqual(parsed_lower['status'], 'TODO')
+        parsed_camel = self.field.parseLine('Test this work item: ToDo')
+        self.assertEqual(parsed_camel['status'], 'TODO')
+
+    def test_parseLine_without_status_fails(self):
+        # We should require an explicit status to avoid the problem of work
+        # items with a url but no status.
+        self.assertRaises(
+            LaunchpadValidationError, self.field.parseLine,
+            'Missing status')
+
+    def test_parseLine_without_title_fails(self):
+        self.assertRaises(
+            LaunchpadValidationError, self.field.parseLine,
+            ':TODO')
+
+    def test_parseLine_without_title_with_assignee_fails(self):
+        self.assertRaises(
+            LaunchpadValidationError, self.field.parseLine,
+            '[test-person] :TODO')
+
+    def test_multi_line_parsing(self):
+        title_1 = 'Work item 1'
+        title_2 = 'Work item 2'
+        work_items_text = "%s: TODO\n%s: POSTPONED" % (title_1, title_2)
+        parsed = self.field.parse(work_items_text)
+        self.assertEqual(
+            parsed, [{'title': title_1,
+                      'status': 'TODO',
+                      'assignee': None, 'milestone': None, 'sequence': 0},
+                     {'title': title_2,
+                      'status': 'POSTPONED',
+                      'assignee': None, 'milestone': None, 'sequence': 1}])
+
+    def test_parse_assignee(self):
+        title = 'Work item 1'
+        assignee = 'test-person'
+        work_items_text = "[%s]%s: TODO" % (assignee, title)
+        parsed = self.field.parseLine(work_items_text)
+        self.assertEqual(parsed['assignee'], assignee)
+
+    def test_parse_assignee_with_space(self):
+        title = 'Work item 1'
+        assignee = 'test-person'
+        work_items_text = "[%s] %s: TODO" % (assignee, title)
+        parsed = self.field.parseLine(work_items_text)
+        self.assertEqual(parsed['assignee'], assignee)
+
+    def test_parseLine_with_missing_closing_bracket_for_assignee(self):
+        self.assertRaises(
+            LaunchpadValidationError, self.field.parseLine,
+            "[test-person A single work item: TODO")
+
+    def test_parse_empty_lines_have_no_meaning(self):
+        parsed = self.field.parse("\n\n\n\n\n\n\n\n")
+        self.assertEqual(parsed, [])
+
+    def test_parse_status(self):
+        work_items_text = "A single work item: TODO"
+        parsed = self.field.parse(work_items_text)
+        self.assertEqual(parsed[0]['status'], 'TODO')
+
+    def test_parse_milestone(self):
+        milestone = '2012.02'
+        title = "Work item for a milestone"
+        work_items_text = "Work items for %s:\n%s: TODO" % (milestone, title)
+        parsed = self.field.parse(work_items_text)
+        self.assertEqual(parsed, [{'title': title,
+                      'status': 'TODO',
+                      'assignee': None, 'milestone': milestone, 'sequence': 0}])
+        
+    def test_parse_multi_milestones(self):
+        milestone_1 = '2012.02'
+        milestone_2 = '2012.03'
+        title_1 = "Work item for a milestone"
+        title_2 = "Work item for a later milestone"
+        work_items_text = ("Work items for %s:\n%s: POSTPONED\n\nWork items "
+                           "for %s:\n%s: TODO" % (milestone_1, title_1,
+                                                  milestone_2, title_2))
+        parsed = self.field.parse(work_items_text)
+        self.assertEqual(parsed,
+                         [{'title': title_1,
+                           'status': 'POSTPONED',
+                           'assignee': None, 'milestone': milestone_1,
+                           'sequence': 0},
+                          {'title': title_2,
+                           'status': 'TODO',
+                           'assignee': None, 'milestone': milestone_2,
+                           'sequence': 1}])
+
+    def test_parse_orphaned_work_items(self):
+        # Work items not in a milestone block belong to the latest specified 
+        # milestone.
+        milestone_1 = '2012.02'
+        milestone_2 = '2012.03'
+        title_1 = "Work item for a milestone"
+        title_2 = "Work item for a later milestone"
+        title_3 = "A work item preceeded by a blank line"
+        work_items_text = (
+            "Work items for %s:\n%s: POSTPONED\n\nWork items for %s:\n%s: "
+            "TODO\n\n%s: TODO" % (milestone_1, title_1, milestone_2, title_2,
+                                  title_3))
+        parsed = self.field.parse(work_items_text)
+        self.assertEqual(parsed, 
+                         [{'title': title_1,
+                           'status': 'POSTPONED',
+                           'assignee': None, 'milestone': milestone_1,
+                           'sequence': 0},
+                          {'title': title_2,
+                           'status': 'TODO',
+                           'assignee': None, 'milestone': milestone_2,
+                           'sequence': 1},
+                          {'title': title_3,
+                           'status': 'TODO',
+                           'assignee': None, 'milestone': milestone_2,
+                           'sequence': 2}])
+
+    def test_sequence_single_workitem(self):
+        parsed = self.field.parse("A single work item: TODO")
+        self.assertEqual(0, parsed[0]['sequence'])
+
+    def test_only_workitems_get_sequence(self):
+        parsed = self.field.parse("A single work item: TODO\n"
+                             "A second work item: TODO\n"
+                             "\n"
+                             "Work items for 2012.02:\n"
+                             "Work item for a milestone: TODO\n")
+        self.assertEqual([(wi['title'], wi['sequence']) for wi in parsed], 
+                         [("A single work item", 0), ("A second work item", 1),
+                          ("Work item for a milestone", 2)])
+
+
+
 class TestBlacklistableContentNameField(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer