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
=== modified file 'database/schema/security.cfg'
--- database/schema/security.cfg 2012-03-01 05:12:30 +0000
+++ database/schema/security.cfg 2012-03-01 16:02:21 +0000
@@ -2160,11 +2160,13 @@
2160public.codeimportevent = SELECT, DELETE2160public.codeimportevent = SELECT, DELETE
2161public.codeimporteventdata = SELECT, DELETE2161public.codeimporteventdata = SELECT, DELETE
2162public.codeimportresult = SELECT, DELETE2162public.codeimportresult = SELECT, DELETE
2163public.distribution = SELECT
2163public.emailaddress = SELECT, UPDATE, DELETE2164public.emailaddress = SELECT, UPDATE, DELETE
2164public.hwsubmission = SELECT, UPDATE2165public.hwsubmission = SELECT, UPDATE
2165public.job = SELECT, INSERT, DELETE2166public.job = SELECT, INSERT, DELETE
2166public.logintoken = SELECT, DELETE2167public.logintoken = SELECT, DELETE
2167public.mailinglistsubscription = SELECT, DELETE2168public.mailinglistsubscription = SELECT, DELETE
2169public.milestone = SELECT
2168public.milestonetag = SELECT2170public.milestonetag = SELECT
2169public.oauthnonce = SELECT, DELETE2171public.oauthnonce = SELECT, DELETE
2170public.openidconsumerassociation = SELECT, DELETE2172public.openidconsumerassociation = SELECT, DELETE
@@ -2172,11 +2174,14 @@
2172public.person = SELECT, DELETE2174public.person = SELECT, DELETE
2173public.potranslation = SELECT, DELETE2175public.potranslation = SELECT, DELETE
2174public.potmsgset = SELECT, DELETE2176public.potmsgset = SELECT, DELETE
2177public.product = SELECT
2175public.revisionauthor = SELECT, UPDATE2178public.revisionauthor = SELECT, UPDATE
2176public.revisioncache = SELECT, DELETE2179public.revisioncache = SELECT, DELETE
2177public.sourcepackagename = SELECT2180public.sourcepackagename = SELECT
2178public.sourcepackagerelease = SELECT2181public.sourcepackagerelease = SELECT
2179public.sourcepackagepublishinghistory = SELECT, UPDATE2182public.sourcepackagepublishinghistory = SELECT, UPDATE
2183public.specification = SELECT, UPDATE
2184public.specificationworkitem = SELECT, INSERT
2180public.suggestivepotemplate = INSERT, DELETE2185public.suggestivepotemplate = INSERT, DELETE
2181public.teamparticipation = SELECT, DELETE2186public.teamparticipation = SELECT, DELETE
2182public.translationmessage = SELECT, DELETE2187public.translationmessage = SELECT, DELETE
21832188
=== added file 'lib/lp/blueprints/tests/test_workitem_migration.py'
--- lib/lp/blueprints/tests/test_workitem_migration.py 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/tests/test_workitem_migration.py 2012-03-01 16:02:21 +0000
@@ -0,0 +1,250 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from textwrap import dedent
7
8from testtools.matchers import (
9 MatchesStructure,
10 )
11
12from lp.blueprints.enums import SpecificationWorkItemStatus
13from lp.blueprints.workitemmigration import (
14 extractWorkItemsFromWhiteboard,
15 WorkitemParser,
16 WorkItemParseError,
17 )
18from lp.testing import (
19 TestCase,
20 TestCaseWithFactory,
21 )
22from lp.testing.layers import DatabaseFunctionalLayer
23
24
25class FakeSpecification(object):
26 assignee = None
27
28
29class TestWorkItemParser(TestCase):
30
31 def test_parse_line_basic(self):
32 parser = WorkitemParser(FakeSpecification())
33 assignee, description, status = parser.parse_blueprint_workitem(
34 "A single work item: TODO")
35 self.assertEqual(
36 [None, "A single work item", SpecificationWorkItemStatus.TODO],
37 [assignee, description, status])
38
39 def test_parse_line_with_assignee(self):
40 parser = WorkitemParser(FakeSpecification())
41 assignee, description, status = parser.parse_blueprint_workitem(
42 "[salgado] A single work item: TODO")
43 self.assertEqual(
44 ["salgado", "A single work item",
45 SpecificationWorkItemStatus.TODO],
46 [assignee, description, status])
47
48 def test_parse_line_with_missing_closing_bracket_for_assignee(self):
49 parser = WorkitemParser(FakeSpecification())
50 self.assertRaises(
51 WorkItemParseError, parser.parse_blueprint_workitem,
52 "[salgado A single work item: TODO")
53
54 def test_parse_line_without_status(self):
55 parser = WorkitemParser(FakeSpecification())
56 assignee, description, status = parser.parse_blueprint_workitem(
57 "A single work item")
58 self.assertEqual(
59 [None, "A single work item", SpecificationWorkItemStatus.TODO],
60 [assignee, description, status])
61
62 def test_parse_line_with_invalid_status(self):
63 parser = WorkitemParser(FakeSpecification())
64 self.assertRaises(
65 WorkItemParseError, parser.parse_blueprint_workitem,
66 "A single work item: FOO")
67
68 def test_parse_line_without_description(self):
69 parser = WorkitemParser(FakeSpecification())
70 self.assertRaises(
71 WorkItemParseError, parser.parse_blueprint_workitem,
72 " : TODO")
73
74 def test_parse_line_with_completed_status(self):
75 parser = WorkitemParser(FakeSpecification())
76 assignee, description, status = parser.parse_blueprint_workitem(
77 "A single work item: Completed")
78 self.assertEqual(
79 [None, "A single work item", SpecificationWorkItemStatus.DONE],
80 [assignee, description, status])
81
82 def test_parse_line_with_inprogress_status(self):
83 parser = WorkitemParser(FakeSpecification())
84 assignee, description, status = parser.parse_blueprint_workitem(
85 "A single work item: INPROGRESS")
86 self.assertEqual(
87 [None, "A single work item",
88 SpecificationWorkItemStatus.INPROGRESS],
89 [assignee, description, status])
90
91 def test_parse_line_with_postpone_status(self):
92 parser = WorkitemParser(FakeSpecification())
93 assignee, description, status = parser.parse_blueprint_workitem(
94 "A single work item: POSTPONE")
95 self.assertEqual(
96 [None, "A single work item",
97 SpecificationWorkItemStatus.POSTPONED],
98 [assignee, description, status])
99
100 def test_parse_line_with_drop_status(self):
101 parser = WorkitemParser(FakeSpecification())
102 assignee, description, status = parser.parse_blueprint_workitem(
103 "A single work item: DROP")
104 self.assertEqual(
105 [None, "A single work item",
106 SpecificationWorkItemStatus.POSTPONED],
107 [assignee, description, status])
108
109 def test_parse_line_with_dropped_status(self):
110 parser = WorkitemParser(FakeSpecification())
111 assignee, description, status = parser.parse_blueprint_workitem(
112 "A single work item: DROPPED")
113 self.assertEqual(
114 [None, "A single work item",
115 SpecificationWorkItemStatus.POSTPONED],
116 [assignee, description, status])
117
118 def test_parse_empty_line(self):
119 parser = WorkitemParser(FakeSpecification())
120 self.assertRaises(
121 AssertionError, parser.parse_blueprint_workitem, "")
122
123
124class TestSpecificationWorkItemExtractionFromWhiteboard(TestCaseWithFactory):
125 layer = DatabaseFunctionalLayer
126
127 def test_None_whiteboard(self):
128 spec = self.factory.makeSpecification(whiteboard=None)
129 work_items = extractWorkItemsFromWhiteboard(spec)
130 self.assertEqual([], work_items)
131
132 def test_empty_whiteboard(self):
133 spec = self.factory.makeSpecification(whiteboard='')
134 work_items = extractWorkItemsFromWhiteboard(spec)
135 self.assertEqual([], work_items)
136
137 def test_single_work_item(self):
138 whiteboard = dedent("""
139 Work items:
140 A single work item: TODO
141 """)
142 spec = self.factory.makeSpecification(whiteboard=whiteboard)
143 work_items = extractWorkItemsFromWhiteboard(spec)
144 self.assertEqual(1, len(work_items))
145 self.assertThat(work_items[0], MatchesStructure.byEquality(
146 assignee=None, title="A single work item", milestone=None,
147 status=SpecificationWorkItemStatus.TODO, specification=spec))
148
149 def test_multiple_work_items(self):
150 whiteboard = dedent("""
151 Work items:
152 A single work item: TODO
153 Another work item: DONE
154 """)
155 spec = self.factory.makeSpecification(whiteboard=whiteboard)
156 work_items = extractWorkItemsFromWhiteboard(spec)
157 self.assertEqual(2, len(work_items))
158 self.assertThat(work_items[0], MatchesStructure.byEquality(
159 assignee=None, title="A single work item", milestone=None,
160 status=SpecificationWorkItemStatus.TODO, specification=spec))
161 self.assertThat(work_items[1], MatchesStructure.byEquality(
162 assignee=None, title="Another work item", milestone=None,
163 status=SpecificationWorkItemStatus.DONE, specification=spec))
164
165 def test_work_item_with_assignee(self):
166 person = self.factory.makePerson()
167 whiteboard = dedent("""
168 Work items:
169 [%s] A single work item: TODO
170 """ % person.name)
171 spec = self.factory.makeSpecification(whiteboard=whiteboard)
172 work_items = extractWorkItemsFromWhiteboard(spec)
173 self.assertEqual(1, len(work_items))
174 self.assertThat(work_items[0], MatchesStructure.byEquality(
175 assignee=person, title="A single work item", milestone=None,
176 status=SpecificationWorkItemStatus.TODO, specification=spec))
177
178 def test_work_item_with_nonexistent_assignee(self):
179 whiteboard = dedent("""
180 Work items:
181 [nonexistentperson] A single work item: TODO""")
182 spec = self.factory.makeSpecification(whiteboard=whiteboard)
183 self.assertRaises(ValueError, extractWorkItemsFromWhiteboard, spec)
184
185 def test_work_item_with_milestone(self):
186 milestone = self.factory.makeMilestone()
187 whiteboard = dedent("""
188 Work items for %s:
189 A single work item: TODO
190 """ % milestone.name)
191 spec = self.factory.makeSpecification(
192 whiteboard=whiteboard, product=milestone.product)
193 work_items = extractWorkItemsFromWhiteboard(spec)
194 self.assertEqual(1, len(work_items))
195 self.assertThat(work_items[0], MatchesStructure.byEquality(
196 assignee=None, title="A single work item", milestone=milestone,
197 status=SpecificationWorkItemStatus.TODO, specification=spec))
198
199 def test_work_item_with_unknown_milestone(self):
200 whiteboard = dedent("""
201 Work items for foo:
202 A single work item: TODO
203 """)
204 spec = self.factory.makeSpecification(whiteboard=whiteboard)
205 self.assertRaises(
206 WorkItemParseError, extractWorkItemsFromWhiteboard, spec)
207
208 def test_blank_line_signals_end_of_work_item_block(self):
209 whiteboard = dedent("""
210 Work items:
211 A single work item: TODO
212
213 Some random notes about this BP.
214 * This is what was discussed during UDS
215 * Oh, yeah, we need to do that too
216 """)
217 spec = self.factory.makeSpecification(whiteboard=whiteboard)
218 work_items = extractWorkItemsFromWhiteboard(spec)
219 self.assertEqual(1, len(work_items))
220 self.assertThat(work_items[0], MatchesStructure.byEquality(
221 assignee=None, title="A single work item",
222 status=SpecificationWorkItemStatus.TODO, specification=spec))
223
224 def test_whiteboard_with_all_possible_sections(self):
225 whiteboard = dedent("""
226 Work items:
227 A single work item: TODO
228
229 Meta:
230 Headline: Foo bar
231 Acceptance: Baz foo
232
233 Complexity:
234 [user1] milestone1: 10
235 """)
236 spec = self.factory.makeSpecification(whiteboard=whiteboard)
237 work_items = extractWorkItemsFromWhiteboard(spec)
238 self.assertEqual(1, len(work_items))
239 self.assertThat(work_items[0], MatchesStructure.byEquality(
240 assignee=None, title="A single work item", milestone=None,
241 status=SpecificationWorkItemStatus.TODO, specification=spec))
242
243 # Now assert that the work items were removed from the whiteboard.
244 self.assertEqual(dedent("""
245 Meta:
246 Headline: Foo bar
247 Acceptance: Baz foo
248
249 Complexity:
250 [user1] milestone1: 10""").strip(), spec.whiteboard.strip())
0251
=== added file 'lib/lp/blueprints/workitemmigration.py'
--- lib/lp/blueprints/workitemmigration.py 1970-01-01 00:00:00 +0000
+++ lib/lp/blueprints/workitemmigration.py 2012-03-01 16:02:21 +0000
@@ -0,0 +1,161 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Helper functions for the migration of work items from whiteboards to the
5SpecificationWorkItem table.
6
7This will be removed once the migration is done.
8"""
9
10__metaclass__ = type
11__all__ = [
12 'extractWorkItemsFromWhiteboard',
13 ]
14
15import re
16
17from zope.component import getUtility
18from zope.security.proxy import removeSecurityProxy
19
20from lp.blueprints.enums import SpecificationWorkItemStatus
21
22from lp.registry.interfaces.person import IPersonSet
23
24
25class WorkItemParseError(Exception):
26 """An error when parsing a work item line from a blueprint's whiteboard."""
27
28
29class WorkitemParser(object):
30 """A parser to extract work items from Blueprint whiteboards."""
31
32 def __init__(self, blueprint):
33 self.blueprint = blueprint
34
35 def _normalize_status(self, status, desc):
36 status = status.strip().lower()
37 if not status:
38 status = SpecificationWorkItemStatus.TODO
39 elif status == u'completed':
40 status = SpecificationWorkItemStatus.DONE
41 elif status in (u'postpone', u'dropped', u'drop'):
42 status = SpecificationWorkItemStatus.POSTPONED
43 else:
44 valid_statuses = SpecificationWorkItemStatus.items
45 if status not in [item.name.lower() for item in valid_statuses]:
46 raise WorkItemParseError('Unknown status: %s' % status)
47 return valid_statuses[status.upper()]
48 return status
49
50 def _parse_line(self, line):
51 try:
52 desc, status = line.rsplit(':', 1)
53 except ValueError:
54 desc = line
55 status = ""
56 assignee_name = None
57 if desc.startswith('['):
58 if ']' in desc:
59 off = desc.index(']')
60 assignee_name = desc[1:off]
61 desc = desc[off + 1:].strip()
62 else:
63 raise WorkItemParseError('Missing closing "]" for assignee')
64 return assignee_name, desc, status
65
66 def parse_blueprint_workitem(self, line):
67 line = line.strip()
68 assert line, "Please don't give us an empty line"
69 assignee_name, desc, status = self._parse_line(line)
70 if not desc:
71 raise WorkItemParseError(
72 'No work item description found on "%s"' % line)
73 status = self._normalize_status(status, desc)
74 return assignee_name, desc, status
75
76
77def milestone_extract(text, valid_milestones):
78 words = text.replace('(', ' ').replace(')', ' ').replace(
79 '[', ' ').replace(']', ' ').replace('<wbr></wbr>', '').split()
80 for milestone in valid_milestones:
81 for word in words:
82 if word == milestone.name:
83 return milestone
84 raise WorkItemParseError("No valid milestones found in %s" % words)
85
86
87def extractWorkItemsFromWhiteboard(spec):
88 work_items = []
89 if not spec.whiteboard:
90 return work_items
91 work_items_re = re.compile('^work items(.*)\s*:\s*$', re.I)
92 meta_re = re.compile('^Meta.*?:$', re.I)
93 complexity_re = re.compile('^Complexity.*?:$', re.I)
94 in_wi_block = False
95 new_whiteboard = []
96
97 target_milestones = list(spec.target.milestones)
98 wi_lines = []
99 # Iterate over all lines in the whiteboard and whenever we find a line
100 # matching work_items_re we 'continue' and store the following lines
101 # until we reach the end of the whiteboard or a line matching meta_re or
102 # complexity_re.
103 for line in spec.whiteboard.splitlines():
104 new_whiteboard.append(line)
105 wi_match = work_items_re.search(line)
106 if wi_match:
107 in_wi_block = True
108 milestone = None
109 milestone_part = wi_match.group(1).strip()
110 if milestone_part:
111 milestone = milestone_extract(
112 milestone_part, target_milestones)
113 new_whiteboard.pop()
114 continue
115 if meta_re.search(line):
116 milestone = None
117 in_wi_block = False
118 continue
119 if complexity_re.search(line):
120 milestone = None
121 in_wi_block = False
122 continue
123
124 if not in_wi_block:
125 # We only care about work-item lines.
126 continue
127
128 if line.strip() == '':
129 # An empty line signals the end of the work-item block:
130 # https://wiki.ubuntu.com/WorkItemsHowto.
131 in_wi_block = False
132 milestone = None
133 continue
134
135 # This is a work-item line, which we don't want in the new
136 # whiteboard because we're migrating them into the
137 # SpecificationWorkItem table.
138 new_whiteboard.pop()
139
140 wi_lines.append((line, milestone))
141
142 # Now parse the work item lines and store them in SpecificationWorkItem.
143 parser = WorkitemParser(spec)
144 sequence = 0
145 for line, milestone in wi_lines:
146 assignee_name, title, status = parser.parse_blueprint_workitem(line)
147 if assignee_name is not None:
148 assignee_name = assignee_name.strip()
149 assignee = getUtility(IPersonSet).getByName(assignee_name)
150 if assignee is None:
151 raise ValueError("Unknown person name: %s" % assignee_name)
152 else:
153 assignee = None
154 workitem = removeSecurityProxy(spec).newWorkItem(
155 status=status, title=title, assignee=assignee,
156 milestone=milestone, sequence=sequence)
157 work_items.append(workitem)
158 sequence += 1
159
160 removeSecurityProxy(spec).whiteboard = "\n".join(new_whiteboard)
161 return work_items
0162
=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py 2012-02-28 04:24:19 +0000
+++ lib/lp/scripts/garbo.py 2012-03-01 16:02:21 +0000
@@ -26,7 +26,6 @@
26import iso860126import iso8601
27from psycopg2 import IntegrityError27from psycopg2 import IntegrityError
28import pytz28import pytz
29from pytz import timezone
30from storm.expr import In29from storm.expr import In
31from storm.locals import (30from storm.locals import (
32 Max,31 Max,
@@ -39,6 +38,8 @@
39from zope.security.proxy import removeSecurityProxy38from zope.security.proxy import removeSecurityProxy
4039
41from lp.answers.model.answercontact import AnswerContact40from lp.answers.model.answercontact import AnswerContact
41from lp.blueprints.model.specification import Specification
42from lp.blueprints.workitemmigration import extractWorkItemsFromWhiteboard
42from lp.bugs.interfaces.bug import IBugSet43from lp.bugs.interfaces.bug import IBugSet
43from lp.bugs.model.bug import Bug44from lp.bugs.model.bug import Bug
44from lp.bugs.model.bugattachment import BugAttachment45from lp.bugs.model.bugattachment import BugAttachment
@@ -63,6 +64,7 @@
63from lp.services.database.lpstorm import IMasterStore64from lp.services.database.lpstorm import IMasterStore
64from lp.services.database.sqlbase import (65from lp.services.database.sqlbase import (
65 cursor,66 cursor,
67 quote_like,
66 session_store,68 session_store,
67 sqlvalues,69 sqlvalues,
68 )70 )
@@ -988,6 +990,80 @@
988 transaction.commit()990 transaction.commit()
989991
990992
993class SpecificationWorkitemMigrator(TunableLoop):
994 """Migrate work-items from Specification.whiteboard to
995 SpecificationWorkItem.
996
997 Migrating work items from the whiteboard is an all-or-nothing thing; if we
998 encounter any errors when parsing the whiteboard of a spec, we abort the
999 transaction and leave its whiteboard unchanged.
1000
1001 On a test with production data, only 100 whiteboards (out of almost 2500)
1002 could not be migrated. On 24 of those the assignee in at least one work
1003 item is not valid, on 33 the status of a work item is not valid and on 42
1004 one or more milestones are not valid.
1005 """
1006
1007 maximum_chunk_size = 500
1008 offset = 0
1009
1010 def __init__(self, log, abort_time=None):
1011 super(SpecificationWorkitemMigrator, self).__init__(
1012 log, abort_time=abort_time)
1013
1014 if not getFeatureFlag('garbo.workitem_migrator.enabled'):
1015 self.log.info(
1016 "Not migrating work items. Change the "
1017 "garbo.workitem_migrator.enabled feature flag if you want "
1018 "to enable this.")
1019 # This will cause isDone() to return True, thus skipping the work
1020 # item migration.
1021 self.total = 0
1022 return
1023
1024 # Get only the specs which contain "work items" in their whiteboard
1025 # and which don't have any SpecificationWorkItems.
1026 query = "whiteboard ilike '%%' || %s || '%%'" % quote_like(
1027 'work items')
1028 query += (" and id not in (select distinct specification from "
1029 "SpecificationWorkItem)")
1030 self.specs = IMasterStore(Specification).find(Specification, query)
1031 self.total = self.specs.count()
1032 self.log.info(
1033 "Migrating work items from the whiteboard of %d specs"
1034 % self.total)
1035
1036 def getNextBatch(self, chunk_size):
1037 end_at = self.offset + int(chunk_size)
1038 return self.specs[self.offset:end_at]
1039
1040 def isDone(self):
1041 """See `TunableLoop`."""
1042 return self.offset >= self.total
1043
1044 def __call__(self, chunk_size):
1045 """See `TunableLoop`."""
1046 for spec in self.getNextBatch(chunk_size):
1047 try:
1048 work_items = extractWorkItemsFromWhiteboard(spec)
1049 except Exception, e:
1050 self.log.info(
1051 "Failed to parse whiteboard of %s: %s" % (
1052 spec, unicode(e)))
1053 transaction.abort()
1054 continue
1055
1056 if len(work_items) > 0:
1057 self.log.info(
1058 "Migrated %d work items from the whiteboard of %s" % (
1059 len(work_items), spec))
1060 transaction.commit()
1061 else:
1062 self.log.info(
1063 "No work items found on the whiteboard of %s" % spec)
1064 self.offset += chunk_size
1065
1066
991class BaseDatabaseGarbageCollector(LaunchpadCronScript):1067class BaseDatabaseGarbageCollector(LaunchpadCronScript):
992 """Abstract base class to run a collection of TunableLoops."""1068 """Abstract base class to run a collection of TunableLoops."""
993 script_name = None # Script name for locking and database user. Override.1069 script_name = None # Script name for locking and database user. Override.
@@ -1238,6 +1314,7 @@
1238 UnusedSessionPruner,1314 UnusedSessionPruner,
1239 DuplicateSessionPruner,1315 DuplicateSessionPruner,
1240 BugHeatUpdater,1316 BugHeatUpdater,
1317 SpecificationWorkitemMigrator,
1241 ]1318 ]
1242 experimental_tunable_loops = []1319 experimental_tunable_loops = []
12431320
12441321
=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py 2012-02-21 07:09:37 +0000
+++ lib/lp/scripts/tests/test_garbo.py 2012-03-01 16:02:21 +0000
@@ -12,6 +12,7 @@
12 )12 )
13import logging13import logging
14from StringIO import StringIO14from StringIO import StringIO
15from textwrap import dedent
15import time16import time
1617
17from pytz import UTC18from pytz import UTC
@@ -29,12 +30,14 @@
29from testtools.matchers import (30from testtools.matchers import (
30 Equals,31 Equals,
31 GreaterThan,32 GreaterThan,
33 MatchesStructure,
32 )34 )
33import transaction35import transaction
34from zope.component import getUtility36from zope.component import getUtility
35from zope.security.proxy import removeSecurityProxy37from zope.security.proxy import removeSecurityProxy
3638
37from lp.answers.model.answercontact import AnswerContact39from lp.answers.model.answercontact import AnswerContact
40from lp.blueprints.enums import SpecificationWorkItemStatus
38from lp.bugs.model.bugnotification import (41from lp.bugs.model.bugnotification import (
39 BugNotification,42 BugNotification,
40 BugNotificationRecipient,43 BugNotificationRecipient,
@@ -72,6 +75,7 @@
72 UTC_NOW,75 UTC_NOW,
73 )76 )
74from lp.services.database.lpstorm import IMasterStore77from lp.services.database.lpstorm import IMasterStore
78from lp.services.features import getFeatureFlag
75from lp.services.features.model import FeatureFlag79from lp.services.features.model import FeatureFlag
76from lp.services.identity.interfaces.account import AccountStatus80from lp.services.identity.interfaces.account import AccountStatus
77from lp.services.identity.interfaces.emailaddress import EmailAddressStatus81from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
@@ -1014,6 +1018,73 @@
1014 self.runHourly()1018 self.runHourly()
1015 self.assertNotEqual(old_update, bug.heat_last_updated)1019 self.assertNotEqual(old_update, bug.heat_last_updated)
10161020
1021 def test_SpecificationWorkitemMigrator_not_enabled_by_default(self):
1022 self.assertFalse(getFeatureFlag('garbo.workitem_migrator.enabled'))
1023 switch_dbuser('testadmin')
1024 whiteboard = dedent("""
1025 Work items:
1026 A single work item: TODO
1027 """)
1028 spec = self.factory.makeSpecification(whiteboard=whiteboard)
1029 transaction.commit()
1030
1031 self.runHourly()
1032
1033 self.assertEqual(whiteboard, spec.whiteboard)
1034 self.assertEqual(0, spec.work_items.count())
1035
1036 def test_SpecificationWorkitemMigrator(self):
1037 # When the migration is successful we remove all work-items from the
1038 # whiteboard.
1039 switch_dbuser('testadmin')
1040 milestone = self.factory.makeMilestone()
1041 person = self.factory.makePerson()
1042 whiteboard = dedent("""
1043 Work items for %s:
1044 [%s] A single work item: TODO
1045
1046 Work items:
1047 Another work item: DONE
1048 """ % (milestone.name, person.name))
1049 spec = self.factory.makeSpecification(
1050 product=milestone.product, whiteboard=whiteboard)
1051 IMasterStore(FeatureFlag).add(FeatureFlag(
1052 u'default', 0, u'garbo.workitem_migrator.enabled', u'True'))
1053 transaction.commit()
1054
1055 self.runHourly()
1056
1057 self.assertEqual('', spec.whiteboard.strip())
1058 self.assertEqual(2, spec.work_items.count())
1059 self.assertThat(spec.work_items[0], MatchesStructure.byEquality(
1060 assignee=person, title="A single work item",
1061 status=SpecificationWorkItemStatus.TODO,
1062 milestone=milestone, specification=spec))
1063 self.assertThat(spec.work_items[1], MatchesStructure.byEquality(
1064 assignee=None, title="Another work item",
1065 status=SpecificationWorkItemStatus.DONE,
1066 milestone=None, specification=spec))
1067
1068 def test_SpecificationWorkitemMigrator_parse_error(self):
1069 # When we fail to parse any work items in the whiteboard we leave it
1070 # untouched and don't create any SpecificationWorkItem entries.
1071 switch_dbuser('testadmin')
1072 whiteboard = dedent("""
1073 Work items:
1074 A work item: TODO
1075 Another work item: UNKNOWNSTATUSWILLFAILTOPARSE
1076 """)
1077 spec = self.factory.makeSpecification(whiteboard=whiteboard)
1078 IMasterStore(FeatureFlag).add(FeatureFlag(
1079 u'default', 0, u'garbo.workitem_migrator.enabled', u'True'))
1080 transaction.commit()
1081
1082 self.runHourly()
1083
1084 self.assertEqual(whiteboard, spec.whiteboard)
1085 self.assertEqual(0, spec.work_items.count())
1086
1087
10171088
1018class TestGarboTasks(TestCaseWithFactory):1089class TestGarboTasks(TestCaseWithFactory):
1019 layer = LaunchpadZopelessLayer1090 layer = LaunchpadZopelessLayer
10201091
=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py 2012-02-21 02:24:04 +0000
+++ lib/lp/services/features/flags.py 2012-03-01 16:02:21 +0000
@@ -280,6 +280,13 @@
280 '',280 '',
281 '',281 '',
282 ''),282 ''),
283 ('garbo.workitem_migrator.enabled',
284 'boolean',
285 ('If true, garbo will try to migrate work items from the whiteboard of '
286 'specifications.'),
287 '',
288 '',
289 ''),
283 ])290 ])
284291
285# The set of all flag names that are documented.292# The set of all flag names that are documented.
286293
=== modified file 'lib/lp/testing/layers.py'
--- lib/lp/testing/layers.py 2012-02-03 06:54:05 +0000
+++ lib/lp/testing/layers.py 2012-03-01 16:02:21 +0000
@@ -269,7 +269,7 @@
269class BaseLayer:269class BaseLayer:
270 """Base layer.270 """Base layer.
271271
272 All out layers should subclass Base, as this is where we will put272 All our layers should subclass Base, as this is where we will put
273 test isolation checks to ensure that tests to not leave global273 test isolation checks to ensure that tests to not leave global
274 resources in a mess.274 resources in a mess.
275275