Merge lp:~linaro-infrastructure/launchpad/workitems-migration-script into lp:launchpad

Proposed by Guilherme Salgado
Status: Merged
Approved by: Steve Kowalik
Approved revision: no longer in the source branch.
Merged at revision: 14893
Proposed branch: lp:~linaro-infrastructure/launchpad/workitems-migration-script
Merge into: lp:launchpad
Diff against target: 710 lines (+573/-2)
7 files modified
database/schema/security.cfg (+5/-0)
lib/lp/blueprints/tests/test_workitem_migration.py (+250/-0)
lib/lp/blueprints/workitemmigration.py (+161/-0)
lib/lp/scripts/garbo.py (+78/-1)
lib/lp/scripts/tests/test_garbo.py (+71/-0)
lib/lp/services/features/flags.py (+7/-0)
lib/lp/testing/layers.py (+1/-1)
To merge this branch: bzr merge lp:~linaro-infrastructure/launchpad/workitems-migration-script
Reviewer Review Type Date Requested Status
Steve Kowalik (community) code Approve
j.c.sackett (community) Approve
Review via email: mp+93883@code.launchpad.net

Commit message

[r=jcsackett,stevenk][bug=940449] Add a garbo job to migrate work items from specs' whiteboards

Description of the change

This is the script to migrate work items from the whiteboard to the new table. We're not going to run it until we have the new UI to edit work items separately from the whiteboard, though.

To post a comment you must log in.
Revision history for this message
Steve Kowalik (stevenk) wrote :

I'm going to mark this as Needs Fixing. This requires discussion, and certainly doesn't require a one-shot script -- the usual modus operandi for migrations like this is either a garbo job or an API script.

review: Needs Fixing (code)
Revision history for this message
Guilherme Salgado (salgado) wrote :

On 22/02/12 00:48, Steve Kowalik wrote:
> Review: Needs Fixing code
>
> I'm going to mark this as Needs Fixing. This requires discussion, and certainly doesn't require a one-shot script -- the usual modus operandi for migrations like this is either a garbo job or an API script.

Can you let me know what is it that needs discussing? The
implementation, the migration itself or something else?

Also, the idea is to run this as a garbo script; I've implemented it
using a TunableLoop for that reason. IIUC we'd just need to add it to
the list of tasks executed by garbo, which I haven't done because the UI
hasn't landed yet.

From testing it against staging data, though, it seems to parse and
migrate ~90% of the whiteboards with work items, so my gut feeling is
that we won't need to run it for long.

Revision history for this message
Steve Kowalik (stevenk) wrote :

The implementation does. If the idea is to run it as a garbo job, then do that -- add it the loop in lib/lp/scripts/garbo.py, and test it as part of lib/lp/scripts/tests/test_garbo.py, not this separate script.

Revision history for this message
Guilherme Salgado (salgado) wrote :

The separate script is just to make it easy to test this on staging. Also, we don't want to hook the new TunableLoop into garbo yet as we don't want it running on production until the UI has landed. And I've put it all in a separate file to make it easier to remove it after all whiteboards have been migrated. If you feel strong about it, I can move the code/tests together with the other garbo stuff, but can you please also review the code itself as shuffling things around shouldn't change much of the actual code?

Revision history for this message
Steve Kowalik (stevenk) wrote :

If you want to QA this on staging, and not have it running on production then add the loop to HourlyDatabaseGarbageCollector.experimental_tunable_loops. And your removal point is moot, since we can just rollback the revision that included this garbo job. I do feel very strongly about this, and will not approve it as-is.

* Your imports do not meet our standards. There are three groups: Standard Python modules, contributed code, then internal modules, all of which must be in alphabetical order.
* Some of your assertions look to add gratuitous vertical whitespace, why?
* I'd prefer more descriptive names rather than say 'nonono' for a IPerson that doesn't exist.
* + words = text.replace('(', ' ').replace(')', ' ').replace('[', ' ').replace(']', ' ').replace('<wbr></wbr>', '').split() Really?! Surely this could be done better without having to chain six function calls together?
* SpecificationWorkitemMigratorProcess is also not needed.
* The script is not needed due to the above comment.

I was holding off on reviewing it until it moved into the garbo infrastructure, since it may change the code a fair bit.

Revision history for this message
Steve Kowalik (stevenk) wrote :

Another suggestion is to use a feature flag to guard this code. Then the garbo job will run on both production and staging, but will not do any work until the feature flag is turned on.

Revision history for this message
Guilherme Salgado (salgado) wrote :

On 28/02/12 19:25, Steve Kowalik wrote:
> If you want to QA this on staging, and not have it running on
> production then add the loop to
> HourlyDatabaseGarbageCollector.experimental_tunable_loops. And your
> removal point is moot, since we can just rollback the revision that
> included this garbo job. I do feel very strongly about this, and will
> not approve it as-is.
>
> * Your imports do not meet our standards. There are three groups:
> Standard Python modules, contributed code, then internal modules, all

Should be fixed now

> of which must be in alphabetical order. * Some of your assertions
> look to add gratuitous vertical whitespace, why? * I'd prefer more

Do you mean things like:

        self.assertThat(work_items[0], MatchesStructure.byEquality(
            assignee=None, title="A single work item",
            status=SpecificationWorkItemStatus.TODO,
            milestone=None,
            specification=spec))

I've rearranged the arguments on those so that they fit in two lines.

If you mean something else, I'd appreciate if you could be more specific.

> descriptive names rather than say 'nonono' for a IPerson that doesn't

Changed

> exist. * + words = text.replace('(', ' ').replace(')', '
> ').replace('[', ' ').replace(']', ' ').replace('<wbr></wbr>',
> '').split() Really?! Surely this could be done better without having
> to chain six function calls together? *

It certainly could, but this code is known to correctly parse all
variants of work-item headers (it was taken from
launchpad-work-items-tracker) and given that, as I said, we'll throw
this away once the migration is done, I'd rather not rewrite it to avoid
the regressions I might introduce when doing so. I know I could write
tests for all variants to ensure no regressions are introduced and I'd
be happy to do so if we were planning to keep this code around for some
reason, but that's not the case here.

> SpecificationWorkitemMigratorProcess is also not needed. * The script
> is not needed due to the above comment.

Right, removed.

>
> I was holding off on reviewing it until it moved into the garbo
> infrastructure, since it may change the code a fair bit.

It didn't change much; I only had to move stuff around and write a test
using the test infrastructure for garbo, which is probably nicer than
running the script as a subprocess.

Revision history for this message
Steve Kowalik (stevenk) wrote :

So I think I'm going to change my mind, sorry. I am extremly happy with this as it stands, but you will have an easier time QAing and deploying this if you change the garbo job to only run if a certain feature flag is set. I'd suggest lp.scripts.workitems_migrator or something like that.

Revision history for this message
Guilherme Salgado (salgado) wrote :

Ok, I've added the feature flag and guarded the new garbo job with it.

Revision history for this message
j.c.sackett (jcsackett) wrote :

The feature flag stuff looks good to me. I'll be speaking with Steve in a few hours and if he's cool with everything we can send it off to land. I don't want to step on his toes and he clearly has a fuller understanding of how this fits into LP.

review: Approve
Revision history for this message
Steve Kowalik (stevenk) wrote :

Thank you for bearing with me through my review, I am happy for this to land.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2012-03-01 05:12:30 +0000
3+++ database/schema/security.cfg 2012-03-01 16:02:21 +0000
4@@ -2160,11 +2160,13 @@
5 public.codeimportevent = SELECT, DELETE
6 public.codeimporteventdata = SELECT, DELETE
7 public.codeimportresult = SELECT, DELETE
8+public.distribution = SELECT
9 public.emailaddress = SELECT, UPDATE, DELETE
10 public.hwsubmission = SELECT, UPDATE
11 public.job = SELECT, INSERT, DELETE
12 public.logintoken = SELECT, DELETE
13 public.mailinglistsubscription = SELECT, DELETE
14+public.milestone = SELECT
15 public.milestonetag = SELECT
16 public.oauthnonce = SELECT, DELETE
17 public.openidconsumerassociation = SELECT, DELETE
18@@ -2172,11 +2174,14 @@
19 public.person = SELECT, DELETE
20 public.potranslation = SELECT, DELETE
21 public.potmsgset = SELECT, DELETE
22+public.product = SELECT
23 public.revisionauthor = SELECT, UPDATE
24 public.revisioncache = SELECT, DELETE
25 public.sourcepackagename = SELECT
26 public.sourcepackagerelease = SELECT
27 public.sourcepackagepublishinghistory = SELECT, UPDATE
28+public.specification = SELECT, UPDATE
29+public.specificationworkitem = SELECT, INSERT
30 public.suggestivepotemplate = INSERT, DELETE
31 public.teamparticipation = SELECT, DELETE
32 public.translationmessage = SELECT, DELETE
33
34=== added file 'lib/lp/blueprints/tests/test_workitem_migration.py'
35--- lib/lp/blueprints/tests/test_workitem_migration.py 1970-01-01 00:00:00 +0000
36+++ lib/lp/blueprints/tests/test_workitem_migration.py 2012-03-01 16:02:21 +0000
37@@ -0,0 +1,250 @@
38+# Copyright 2012 Canonical Ltd. This software is licensed under the
39+# GNU Affero General Public License version 3 (see the file LICENSE).
40+
41+__metaclass__ = type
42+
43+from textwrap import dedent
44+
45+from testtools.matchers import (
46+ MatchesStructure,
47+ )
48+
49+from lp.blueprints.enums import SpecificationWorkItemStatus
50+from lp.blueprints.workitemmigration import (
51+ extractWorkItemsFromWhiteboard,
52+ WorkitemParser,
53+ WorkItemParseError,
54+ )
55+from lp.testing import (
56+ TestCase,
57+ TestCaseWithFactory,
58+ )
59+from lp.testing.layers import DatabaseFunctionalLayer
60+
61+
62+class FakeSpecification(object):
63+ assignee = None
64+
65+
66+class TestWorkItemParser(TestCase):
67+
68+ def test_parse_line_basic(self):
69+ parser = WorkitemParser(FakeSpecification())
70+ assignee, description, status = parser.parse_blueprint_workitem(
71+ "A single work item: TODO")
72+ self.assertEqual(
73+ [None, "A single work item", SpecificationWorkItemStatus.TODO],
74+ [assignee, description, status])
75+
76+ def test_parse_line_with_assignee(self):
77+ parser = WorkitemParser(FakeSpecification())
78+ assignee, description, status = parser.parse_blueprint_workitem(
79+ "[salgado] A single work item: TODO")
80+ self.assertEqual(
81+ ["salgado", "A single work item",
82+ SpecificationWorkItemStatus.TODO],
83+ [assignee, description, status])
84+
85+ def test_parse_line_with_missing_closing_bracket_for_assignee(self):
86+ parser = WorkitemParser(FakeSpecification())
87+ self.assertRaises(
88+ WorkItemParseError, parser.parse_blueprint_workitem,
89+ "[salgado A single work item: TODO")
90+
91+ def test_parse_line_without_status(self):
92+ parser = WorkitemParser(FakeSpecification())
93+ assignee, description, status = parser.parse_blueprint_workitem(
94+ "A single work item")
95+ self.assertEqual(
96+ [None, "A single work item", SpecificationWorkItemStatus.TODO],
97+ [assignee, description, status])
98+
99+ def test_parse_line_with_invalid_status(self):
100+ parser = WorkitemParser(FakeSpecification())
101+ self.assertRaises(
102+ WorkItemParseError, parser.parse_blueprint_workitem,
103+ "A single work item: FOO")
104+
105+ def test_parse_line_without_description(self):
106+ parser = WorkitemParser(FakeSpecification())
107+ self.assertRaises(
108+ WorkItemParseError, parser.parse_blueprint_workitem,
109+ " : TODO")
110+
111+ def test_parse_line_with_completed_status(self):
112+ parser = WorkitemParser(FakeSpecification())
113+ assignee, description, status = parser.parse_blueprint_workitem(
114+ "A single work item: Completed")
115+ self.assertEqual(
116+ [None, "A single work item", SpecificationWorkItemStatus.DONE],
117+ [assignee, description, status])
118+
119+ def test_parse_line_with_inprogress_status(self):
120+ parser = WorkitemParser(FakeSpecification())
121+ assignee, description, status = parser.parse_blueprint_workitem(
122+ "A single work item: INPROGRESS")
123+ self.assertEqual(
124+ [None, "A single work item",
125+ SpecificationWorkItemStatus.INPROGRESS],
126+ [assignee, description, status])
127+
128+ def test_parse_line_with_postpone_status(self):
129+ parser = WorkitemParser(FakeSpecification())
130+ assignee, description, status = parser.parse_blueprint_workitem(
131+ "A single work item: POSTPONE")
132+ self.assertEqual(
133+ [None, "A single work item",
134+ SpecificationWorkItemStatus.POSTPONED],
135+ [assignee, description, status])
136+
137+ def test_parse_line_with_drop_status(self):
138+ parser = WorkitemParser(FakeSpecification())
139+ assignee, description, status = parser.parse_blueprint_workitem(
140+ "A single work item: DROP")
141+ self.assertEqual(
142+ [None, "A single work item",
143+ SpecificationWorkItemStatus.POSTPONED],
144+ [assignee, description, status])
145+
146+ def test_parse_line_with_dropped_status(self):
147+ parser = WorkitemParser(FakeSpecification())
148+ assignee, description, status = parser.parse_blueprint_workitem(
149+ "A single work item: DROPPED")
150+ self.assertEqual(
151+ [None, "A single work item",
152+ SpecificationWorkItemStatus.POSTPONED],
153+ [assignee, description, status])
154+
155+ def test_parse_empty_line(self):
156+ parser = WorkitemParser(FakeSpecification())
157+ self.assertRaises(
158+ AssertionError, parser.parse_blueprint_workitem, "")
159+
160+
161+class TestSpecificationWorkItemExtractionFromWhiteboard(TestCaseWithFactory):
162+ layer = DatabaseFunctionalLayer
163+
164+ def test_None_whiteboard(self):
165+ spec = self.factory.makeSpecification(whiteboard=None)
166+ work_items = extractWorkItemsFromWhiteboard(spec)
167+ self.assertEqual([], work_items)
168+
169+ def test_empty_whiteboard(self):
170+ spec = self.factory.makeSpecification(whiteboard='')
171+ work_items = extractWorkItemsFromWhiteboard(spec)
172+ self.assertEqual([], work_items)
173+
174+ def test_single_work_item(self):
175+ whiteboard = dedent("""
176+ Work items:
177+ A single work item: TODO
178+ """)
179+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
180+ work_items = extractWorkItemsFromWhiteboard(spec)
181+ self.assertEqual(1, len(work_items))
182+ self.assertThat(work_items[0], MatchesStructure.byEquality(
183+ assignee=None, title="A single work item", milestone=None,
184+ status=SpecificationWorkItemStatus.TODO, specification=spec))
185+
186+ def test_multiple_work_items(self):
187+ whiteboard = dedent("""
188+ Work items:
189+ A single work item: TODO
190+ Another work item: DONE
191+ """)
192+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
193+ work_items = extractWorkItemsFromWhiteboard(spec)
194+ self.assertEqual(2, len(work_items))
195+ self.assertThat(work_items[0], MatchesStructure.byEquality(
196+ assignee=None, title="A single work item", milestone=None,
197+ status=SpecificationWorkItemStatus.TODO, specification=spec))
198+ self.assertThat(work_items[1], MatchesStructure.byEquality(
199+ assignee=None, title="Another work item", milestone=None,
200+ status=SpecificationWorkItemStatus.DONE, specification=spec))
201+
202+ def test_work_item_with_assignee(self):
203+ person = self.factory.makePerson()
204+ whiteboard = dedent("""
205+ Work items:
206+ [%s] A single work item: TODO
207+ """ % person.name)
208+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
209+ work_items = extractWorkItemsFromWhiteboard(spec)
210+ self.assertEqual(1, len(work_items))
211+ self.assertThat(work_items[0], MatchesStructure.byEquality(
212+ assignee=person, title="A single work item", milestone=None,
213+ status=SpecificationWorkItemStatus.TODO, specification=spec))
214+
215+ def test_work_item_with_nonexistent_assignee(self):
216+ whiteboard = dedent("""
217+ Work items:
218+ [nonexistentperson] A single work item: TODO""")
219+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
220+ self.assertRaises(ValueError, extractWorkItemsFromWhiteboard, spec)
221+
222+ def test_work_item_with_milestone(self):
223+ milestone = self.factory.makeMilestone()
224+ whiteboard = dedent("""
225+ Work items for %s:
226+ A single work item: TODO
227+ """ % milestone.name)
228+ spec = self.factory.makeSpecification(
229+ whiteboard=whiteboard, product=milestone.product)
230+ work_items = extractWorkItemsFromWhiteboard(spec)
231+ self.assertEqual(1, len(work_items))
232+ self.assertThat(work_items[0], MatchesStructure.byEquality(
233+ assignee=None, title="A single work item", milestone=milestone,
234+ status=SpecificationWorkItemStatus.TODO, specification=spec))
235+
236+ def test_work_item_with_unknown_milestone(self):
237+ whiteboard = dedent("""
238+ Work items for foo:
239+ A single work item: TODO
240+ """)
241+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
242+ self.assertRaises(
243+ WorkItemParseError, extractWorkItemsFromWhiteboard, spec)
244+
245+ def test_blank_line_signals_end_of_work_item_block(self):
246+ whiteboard = dedent("""
247+ Work items:
248+ A single work item: TODO
249+
250+ Some random notes about this BP.
251+ * This is what was discussed during UDS
252+ * Oh, yeah, we need to do that too
253+ """)
254+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
255+ work_items = extractWorkItemsFromWhiteboard(spec)
256+ self.assertEqual(1, len(work_items))
257+ self.assertThat(work_items[0], MatchesStructure.byEquality(
258+ assignee=None, title="A single work item",
259+ status=SpecificationWorkItemStatus.TODO, specification=spec))
260+
261+ def test_whiteboard_with_all_possible_sections(self):
262+ whiteboard = dedent("""
263+ Work items:
264+ A single work item: TODO
265+
266+ Meta:
267+ Headline: Foo bar
268+ Acceptance: Baz foo
269+
270+ Complexity:
271+ [user1] milestone1: 10
272+ """)
273+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
274+ work_items = extractWorkItemsFromWhiteboard(spec)
275+ self.assertEqual(1, len(work_items))
276+ self.assertThat(work_items[0], MatchesStructure.byEquality(
277+ assignee=None, title="A single work item", milestone=None,
278+ status=SpecificationWorkItemStatus.TODO, specification=spec))
279+
280+ # Now assert that the work items were removed from the whiteboard.
281+ self.assertEqual(dedent("""
282+ Meta:
283+ Headline: Foo bar
284+ Acceptance: Baz foo
285+
286+ Complexity:
287+ [user1] milestone1: 10""").strip(), spec.whiteboard.strip())
288
289=== added file 'lib/lp/blueprints/workitemmigration.py'
290--- lib/lp/blueprints/workitemmigration.py 1970-01-01 00:00:00 +0000
291+++ lib/lp/blueprints/workitemmigration.py 2012-03-01 16:02:21 +0000
292@@ -0,0 +1,161 @@
293+# Copyright 2012 Canonical Ltd. This software is licensed under the
294+# GNU Affero General Public License version 3 (see the file LICENSE).
295+
296+"""Helper functions for the migration of work items from whiteboards to the
297+SpecificationWorkItem table.
298+
299+This will be removed once the migration is done.
300+"""
301+
302+__metaclass__ = type
303+__all__ = [
304+ 'extractWorkItemsFromWhiteboard',
305+ ]
306+
307+import re
308+
309+from zope.component import getUtility
310+from zope.security.proxy import removeSecurityProxy
311+
312+from lp.blueprints.enums import SpecificationWorkItemStatus
313+
314+from lp.registry.interfaces.person import IPersonSet
315+
316+
317+class WorkItemParseError(Exception):
318+ """An error when parsing a work item line from a blueprint's whiteboard."""
319+
320+
321+class WorkitemParser(object):
322+ """A parser to extract work items from Blueprint whiteboards."""
323+
324+ def __init__(self, blueprint):
325+ self.blueprint = blueprint
326+
327+ def _normalize_status(self, status, desc):
328+ status = status.strip().lower()
329+ if not status:
330+ status = SpecificationWorkItemStatus.TODO
331+ elif status == u'completed':
332+ status = SpecificationWorkItemStatus.DONE
333+ elif status in (u'postpone', u'dropped', u'drop'):
334+ status = SpecificationWorkItemStatus.POSTPONED
335+ else:
336+ valid_statuses = SpecificationWorkItemStatus.items
337+ if status not in [item.name.lower() for item in valid_statuses]:
338+ raise WorkItemParseError('Unknown status: %s' % status)
339+ return valid_statuses[status.upper()]
340+ return status
341+
342+ def _parse_line(self, line):
343+ try:
344+ desc, status = line.rsplit(':', 1)
345+ except ValueError:
346+ desc = line
347+ status = ""
348+ assignee_name = None
349+ if desc.startswith('['):
350+ if ']' in desc:
351+ off = desc.index(']')
352+ assignee_name = desc[1:off]
353+ desc = desc[off + 1:].strip()
354+ else:
355+ raise WorkItemParseError('Missing closing "]" for assignee')
356+ return assignee_name, desc, status
357+
358+ def parse_blueprint_workitem(self, line):
359+ line = line.strip()
360+ assert line, "Please don't give us an empty line"
361+ assignee_name, desc, status = self._parse_line(line)
362+ if not desc:
363+ raise WorkItemParseError(
364+ 'No work item description found on "%s"' % line)
365+ status = self._normalize_status(status, desc)
366+ return assignee_name, desc, status
367+
368+
369+def milestone_extract(text, valid_milestones):
370+ words = text.replace('(', ' ').replace(')', ' ').replace(
371+ '[', ' ').replace(']', ' ').replace('<wbr></wbr>', '').split()
372+ for milestone in valid_milestones:
373+ for word in words:
374+ if word == milestone.name:
375+ return milestone
376+ raise WorkItemParseError("No valid milestones found in %s" % words)
377+
378+
379+def extractWorkItemsFromWhiteboard(spec):
380+ work_items = []
381+ if not spec.whiteboard:
382+ return work_items
383+ work_items_re = re.compile('^work items(.*)\s*:\s*$', re.I)
384+ meta_re = re.compile('^Meta.*?:$', re.I)
385+ complexity_re = re.compile('^Complexity.*?:$', re.I)
386+ in_wi_block = False
387+ new_whiteboard = []
388+
389+ target_milestones = list(spec.target.milestones)
390+ wi_lines = []
391+ # Iterate over all lines in the whiteboard and whenever we find a line
392+ # matching work_items_re we 'continue' and store the following lines
393+ # until we reach the end of the whiteboard or a line matching meta_re or
394+ # complexity_re.
395+ for line in spec.whiteboard.splitlines():
396+ new_whiteboard.append(line)
397+ wi_match = work_items_re.search(line)
398+ if wi_match:
399+ in_wi_block = True
400+ milestone = None
401+ milestone_part = wi_match.group(1).strip()
402+ if milestone_part:
403+ milestone = milestone_extract(
404+ milestone_part, target_milestones)
405+ new_whiteboard.pop()
406+ continue
407+ if meta_re.search(line):
408+ milestone = None
409+ in_wi_block = False
410+ continue
411+ if complexity_re.search(line):
412+ milestone = None
413+ in_wi_block = False
414+ continue
415+
416+ if not in_wi_block:
417+ # We only care about work-item lines.
418+ continue
419+
420+ if line.strip() == '':
421+ # An empty line signals the end of the work-item block:
422+ # https://wiki.ubuntu.com/WorkItemsHowto.
423+ in_wi_block = False
424+ milestone = None
425+ continue
426+
427+ # This is a work-item line, which we don't want in the new
428+ # whiteboard because we're migrating them into the
429+ # SpecificationWorkItem table.
430+ new_whiteboard.pop()
431+
432+ wi_lines.append((line, milestone))
433+
434+ # Now parse the work item lines and store them in SpecificationWorkItem.
435+ parser = WorkitemParser(spec)
436+ sequence = 0
437+ for line, milestone in wi_lines:
438+ assignee_name, title, status = parser.parse_blueprint_workitem(line)
439+ if assignee_name is not None:
440+ assignee_name = assignee_name.strip()
441+ assignee = getUtility(IPersonSet).getByName(assignee_name)
442+ if assignee is None:
443+ raise ValueError("Unknown person name: %s" % assignee_name)
444+ else:
445+ assignee = None
446+ workitem = removeSecurityProxy(spec).newWorkItem(
447+ status=status, title=title, assignee=assignee,
448+ milestone=milestone, sequence=sequence)
449+ work_items.append(workitem)
450+ sequence += 1
451+
452+ removeSecurityProxy(spec).whiteboard = "\n".join(new_whiteboard)
453+ return work_items
454
455=== modified file 'lib/lp/scripts/garbo.py'
456--- lib/lp/scripts/garbo.py 2012-02-28 04:24:19 +0000
457+++ lib/lp/scripts/garbo.py 2012-03-01 16:02:21 +0000
458@@ -26,7 +26,6 @@
459 import iso8601
460 from psycopg2 import IntegrityError
461 import pytz
462-from pytz import timezone
463 from storm.expr import In
464 from storm.locals import (
465 Max,
466@@ -39,6 +38,8 @@
467 from zope.security.proxy import removeSecurityProxy
468
469 from lp.answers.model.answercontact import AnswerContact
470+from lp.blueprints.model.specification import Specification
471+from lp.blueprints.workitemmigration import extractWorkItemsFromWhiteboard
472 from lp.bugs.interfaces.bug import IBugSet
473 from lp.bugs.model.bug import Bug
474 from lp.bugs.model.bugattachment import BugAttachment
475@@ -63,6 +64,7 @@
476 from lp.services.database.lpstorm import IMasterStore
477 from lp.services.database.sqlbase import (
478 cursor,
479+ quote_like,
480 session_store,
481 sqlvalues,
482 )
483@@ -988,6 +990,80 @@
484 transaction.commit()
485
486
487+class SpecificationWorkitemMigrator(TunableLoop):
488+ """Migrate work-items from Specification.whiteboard to
489+ SpecificationWorkItem.
490+
491+ Migrating work items from the whiteboard is an all-or-nothing thing; if we
492+ encounter any errors when parsing the whiteboard of a spec, we abort the
493+ transaction and leave its whiteboard unchanged.
494+
495+ On a test with production data, only 100 whiteboards (out of almost 2500)
496+ could not be migrated. On 24 of those the assignee in at least one work
497+ item is not valid, on 33 the status of a work item is not valid and on 42
498+ one or more milestones are not valid.
499+ """
500+
501+ maximum_chunk_size = 500
502+ offset = 0
503+
504+ def __init__(self, log, abort_time=None):
505+ super(SpecificationWorkitemMigrator, self).__init__(
506+ log, abort_time=abort_time)
507+
508+ if not getFeatureFlag('garbo.workitem_migrator.enabled'):
509+ self.log.info(
510+ "Not migrating work items. Change the "
511+ "garbo.workitem_migrator.enabled feature flag if you want "
512+ "to enable this.")
513+ # This will cause isDone() to return True, thus skipping the work
514+ # item migration.
515+ self.total = 0
516+ return
517+
518+ # Get only the specs which contain "work items" in their whiteboard
519+ # and which don't have any SpecificationWorkItems.
520+ query = "whiteboard ilike '%%' || %s || '%%'" % quote_like(
521+ 'work items')
522+ query += (" and id not in (select distinct specification from "
523+ "SpecificationWorkItem)")
524+ self.specs = IMasterStore(Specification).find(Specification, query)
525+ self.total = self.specs.count()
526+ self.log.info(
527+ "Migrating work items from the whiteboard of %d specs"
528+ % self.total)
529+
530+ def getNextBatch(self, chunk_size):
531+ end_at = self.offset + int(chunk_size)
532+ return self.specs[self.offset:end_at]
533+
534+ def isDone(self):
535+ """See `TunableLoop`."""
536+ return self.offset >= self.total
537+
538+ def __call__(self, chunk_size):
539+ """See `TunableLoop`."""
540+ for spec in self.getNextBatch(chunk_size):
541+ try:
542+ work_items = extractWorkItemsFromWhiteboard(spec)
543+ except Exception, e:
544+ self.log.info(
545+ "Failed to parse whiteboard of %s: %s" % (
546+ spec, unicode(e)))
547+ transaction.abort()
548+ continue
549+
550+ if len(work_items) > 0:
551+ self.log.info(
552+ "Migrated %d work items from the whiteboard of %s" % (
553+ len(work_items), spec))
554+ transaction.commit()
555+ else:
556+ self.log.info(
557+ "No work items found on the whiteboard of %s" % spec)
558+ self.offset += chunk_size
559+
560+
561 class BaseDatabaseGarbageCollector(LaunchpadCronScript):
562 """Abstract base class to run a collection of TunableLoops."""
563 script_name = None # Script name for locking and database user. Override.
564@@ -1238,6 +1314,7 @@
565 UnusedSessionPruner,
566 DuplicateSessionPruner,
567 BugHeatUpdater,
568+ SpecificationWorkitemMigrator,
569 ]
570 experimental_tunable_loops = []
571
572
573=== modified file 'lib/lp/scripts/tests/test_garbo.py'
574--- lib/lp/scripts/tests/test_garbo.py 2012-02-21 07:09:37 +0000
575+++ lib/lp/scripts/tests/test_garbo.py 2012-03-01 16:02:21 +0000
576@@ -12,6 +12,7 @@
577 )
578 import logging
579 from StringIO import StringIO
580+from textwrap import dedent
581 import time
582
583 from pytz import UTC
584@@ -29,12 +30,14 @@
585 from testtools.matchers import (
586 Equals,
587 GreaterThan,
588+ MatchesStructure,
589 )
590 import transaction
591 from zope.component import getUtility
592 from zope.security.proxy import removeSecurityProxy
593
594 from lp.answers.model.answercontact import AnswerContact
595+from lp.blueprints.enums import SpecificationWorkItemStatus
596 from lp.bugs.model.bugnotification import (
597 BugNotification,
598 BugNotificationRecipient,
599@@ -72,6 +75,7 @@
600 UTC_NOW,
601 )
602 from lp.services.database.lpstorm import IMasterStore
603+from lp.services.features import getFeatureFlag
604 from lp.services.features.model import FeatureFlag
605 from lp.services.identity.interfaces.account import AccountStatus
606 from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
607@@ -1014,6 +1018,73 @@
608 self.runHourly()
609 self.assertNotEqual(old_update, bug.heat_last_updated)
610
611+ def test_SpecificationWorkitemMigrator_not_enabled_by_default(self):
612+ self.assertFalse(getFeatureFlag('garbo.workitem_migrator.enabled'))
613+ switch_dbuser('testadmin')
614+ whiteboard = dedent("""
615+ Work items:
616+ A single work item: TODO
617+ """)
618+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
619+ transaction.commit()
620+
621+ self.runHourly()
622+
623+ self.assertEqual(whiteboard, spec.whiteboard)
624+ self.assertEqual(0, spec.work_items.count())
625+
626+ def test_SpecificationWorkitemMigrator(self):
627+ # When the migration is successful we remove all work-items from the
628+ # whiteboard.
629+ switch_dbuser('testadmin')
630+ milestone = self.factory.makeMilestone()
631+ person = self.factory.makePerson()
632+ whiteboard = dedent("""
633+ Work items for %s:
634+ [%s] A single work item: TODO
635+
636+ Work items:
637+ Another work item: DONE
638+ """ % (milestone.name, person.name))
639+ spec = self.factory.makeSpecification(
640+ product=milestone.product, whiteboard=whiteboard)
641+ IMasterStore(FeatureFlag).add(FeatureFlag(
642+ u'default', 0, u'garbo.workitem_migrator.enabled', u'True'))
643+ transaction.commit()
644+
645+ self.runHourly()
646+
647+ self.assertEqual('', spec.whiteboard.strip())
648+ self.assertEqual(2, spec.work_items.count())
649+ self.assertThat(spec.work_items[0], MatchesStructure.byEquality(
650+ assignee=person, title="A single work item",
651+ status=SpecificationWorkItemStatus.TODO,
652+ milestone=milestone, specification=spec))
653+ self.assertThat(spec.work_items[1], MatchesStructure.byEquality(
654+ assignee=None, title="Another work item",
655+ status=SpecificationWorkItemStatus.DONE,
656+ milestone=None, specification=spec))
657+
658+ def test_SpecificationWorkitemMigrator_parse_error(self):
659+ # When we fail to parse any work items in the whiteboard we leave it
660+ # untouched and don't create any SpecificationWorkItem entries.
661+ switch_dbuser('testadmin')
662+ whiteboard = dedent("""
663+ Work items:
664+ A work item: TODO
665+ Another work item: UNKNOWNSTATUSWILLFAILTOPARSE
666+ """)
667+ spec = self.factory.makeSpecification(whiteboard=whiteboard)
668+ IMasterStore(FeatureFlag).add(FeatureFlag(
669+ u'default', 0, u'garbo.workitem_migrator.enabled', u'True'))
670+ transaction.commit()
671+
672+ self.runHourly()
673+
674+ self.assertEqual(whiteboard, spec.whiteboard)
675+ self.assertEqual(0, spec.work_items.count())
676+
677+
678
679 class TestGarboTasks(TestCaseWithFactory):
680 layer = LaunchpadZopelessLayer
681
682=== modified file 'lib/lp/services/features/flags.py'
683--- lib/lp/services/features/flags.py 2012-02-21 02:24:04 +0000
684+++ lib/lp/services/features/flags.py 2012-03-01 16:02:21 +0000
685@@ -280,6 +280,13 @@
686 '',
687 '',
688 ''),
689+ ('garbo.workitem_migrator.enabled',
690+ 'boolean',
691+ ('If true, garbo will try to migrate work items from the whiteboard of '
692+ 'specifications.'),
693+ '',
694+ '',
695+ ''),
696 ])
697
698 # The set of all flag names that are documented.
699
700=== modified file 'lib/lp/testing/layers.py'
701--- lib/lp/testing/layers.py 2012-02-03 06:54:05 +0000
702+++ lib/lp/testing/layers.py 2012-03-01 16:02:21 +0000
703@@ -269,7 +269,7 @@
704 class BaseLayer:
705 """Base layer.
706
707- All out layers should subclass Base, as this is where we will put
708+ All our layers should subclass Base, as this is where we will put
709 test isolation checks to ensure that tests to not leave global
710 resources in a mess.
711