Merge lp:~linaro-infrastructure/launchpad/workitems-migration-script into lp:launchpad
- workitems-migration-script
- Merge into devel
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 | ||||
Related bugs: |
|
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,
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.
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.
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/
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?
Steve Kowalik (stevenk) wrote : | # |
If you want to QA this on staging, and not have it running on production then add the loop to HourlyDatabaseG
* 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(
* SpecificationWo
* 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.
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.
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
> HourlyDatabaseG
> 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:
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(
> '').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-
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.
> SpecificationWo
> 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.
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.
Guilherme Salgado (salgado) wrote : | # |
Ok, I've added the feature flag and guarded the new garbo job with it.
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.
Steve Kowalik (stevenk) wrote : | # |
Thank you for bearing with me through my review, I am happy for this to land.
Preview Diff
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 |
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.