1
= Tracking changes to a bug =
3
The base class for BugChanges doesn't actually implement anything.
6
>>> from canonical.launchpad.webapp.testing import verifyObject
7
>>> from datetime import datetime
8
>>> from lp.bugs.adapters.bugchange import BugChangeBase
9
>>> from lp.bugs.interfaces.bugchange import IBugChange
11
>>> from lp.testing.factory import LaunchpadObjectFactory
12
>>> factory = LaunchpadObjectFactory()
13
>>> login("test@canonical.com")
14
>>> example_person = factory.makePerson(
15
... name="ford-prefect", displayname="Ford Prefect")
17
>>> nowish = datetime(2009, 3, 13, 10, 9, tzinfo=pytz.timezone('UTC'))
18
>>> base_instance = BugChangeBase(when=nowish, person=example_person)
19
>>> verifyObject(IBugChange, base_instance)
22
>>> base_instance.getBugNotification()
23
Traceback (most recent call last):
25
NotImplementedError...
27
>>> base_instance.getBugActivity()
28
Traceback (most recent call last):
30
NotImplementedError...
32
But the basic attributes are still available.
34
>>> print base_instance.when
35
2009-03-13 10:09:00+00:00
37
>>> print base_instance.person.displayname
40
Because the base class is abstract, you can't pass it to
43
>>> example_product = factory.makeProduct(
44
... owner=example_person, name="heart-of-gold",
45
... displayname="Heart of Gold")
46
>>> example_bug = factory.makeBug(
47
... product=example_product, owner=example_person,
48
... title="Reality is on the blink again",
49
... description="I'm tired of thinking up funny strings for tests")
50
>>> example_bug.addChange(base_instance)
51
Traceback (most recent call last):
53
NotImplementedError...
55
We'll create a test class that actually implements the methods we need.
57
>>> from lp.bugs.mail.bugnotificationrecipients import (
58
... BugNotificationRecipients)
60
>>> example_message = factory.makeMessage(content="Hello, world")
61
>>> example_person_2 = factory.makePerson(
62
... displayname="Zaphod Beeblebrox")
64
>>> recipients = BugNotificationRecipients()
65
>>> recipients.addDirectSubscriber(example_person_2)
67
>>> class TestBugChange(BugChangeBase):
69
... bug_activity_data = {
70
... 'whatchanged': 'Nothing',
71
... 'oldvalue': 'OldValue',
72
... 'newvalue': 'NewValue',
75
... bug_notification_data = {
76
... 'text': 'Some message text',
79
... def getBugActivity(self):
80
... return self.bug_activity_data
82
... def getBugNotification(self):
83
... return self.bug_notification_data
85
>>> activity_to_ignore = set()
86
>>> def print_bug_activity(activity):
87
... for activity in activity:
88
... if activity not in activity_to_ignore:
89
... print "%s: %s %s => %s (%s)" % (
90
... activity.datechanged, activity.whatchanged,
91
... activity.oldvalue, activity.newvalue,
92
... activity.person.displayname)
94
Creating bugs generates activity records, indirectly, using the
95
addChange() API, but we want to ignore them for now.
97
>>> activity_to_ignore.update(example_bug.activity)
99
BugActivity entries are added when addChange() is called.
101
>>> example_bug.addChange(
102
... TestBugChange(when=nowish, person=example_person),
103
... recipients=recipients)
104
>>> print_bug_activity(example_bug.activity)
105
2009-03-13...: Nothing OldValue => NewValue (Ford Prefect)
107
As are BugNotifications.
109
>>> from lp.bugs.model.bugnotification import BugNotification
110
>>> latest_notification = BugNotification.selectFirst(orderBy='-id')
111
>>> print latest_notification.message.text_contents
114
The notification's recipients are taken from the recipients parameter
115
passed to addChange().
117
>>> for recipient in latest_notification.recipients:
118
... print recipient.person.displayname
121
But if getBugActivity() returns None, no activity entries will be added.
123
>>> class NoActionBugChange(TestBugChange):
124
... bug_activity_data = None
125
... bug_notification_data = None
127
>>> example_bug.addChange(
128
... NoActionBugChange(when=nowish, person=example_person))
129
>>> print_bug_activity(example_bug.activity)
130
2009-03-13...: Nothing OldValue => NewValue (Ford Prefect)
132
And if getBugNotification() returns None, no notification will be added.
134
>>> new_latest_notification = BugNotification.selectFirst(orderBy='-id')
135
>>> new_latest_notification.id == latest_notification.id
138
If no recipients are passed to addChange() the default recipient list
139
for the Bug will be used. This includes people subscribed to the
140
bug's target for Meta data changes, but not for lifecycle changes.
143
>>> from lp.testing import person_logged_in
144
>>> from lp.bugs.enum import BugNotificationLevel
145
>>> lifecycle_subscriber = factory.makePerson(
146
... displayname='Lifecycle subscriber')
147
>>> metadata_subscriber = factory.makePerson(
148
... displayname='Meta-data subscriber')
149
>>> subscription = example_bug.bugtasks[0].target.addBugSubscription(
150
... lifecycle_subscriber, lifecycle_subscriber)
151
>>> with person_logged_in(lifecycle_subscriber):
152
... filter = subscription.bug_filters.one()
153
... filter.bug_notification_level = BugNotificationLevel.LIFECYCLE
154
>>> subscription = example_bug.bugtasks[0].target.addBugSubscription(
155
... metadata_subscriber, metadata_subscriber)
156
>>> with person_logged_in(metadata_subscriber):
157
... filter = subscription.bug_filters.one()
158
... filter.bug_notification_level = BugNotificationLevel.METADATA
159
>>> example_bug.addChange(
160
... TestBugChange(when=nowish, person=example_person))
161
>>> latest_notification = BugNotification.selectFirst(orderBy='-id')
162
>>> print latest_notification.message.text_contents
166
... recipient.person.displayname
167
... for recipient in latest_notification.recipients]
168
>>> for name in sorted(recipients):
173
If you try to send a notification without adding a text body for the
174
notification you'll get an error.
176
>>> class NoNotificationTextBugChange(TestBugChange):
178
... bug_notification_data = {
182
>>> example_bug.addChange(
183
... NoNotificationTextBugChange(when=nowish, person=example_person))
184
Traceback (most recent call last):
186
AssertionError: notification_data must include a `text` value.
189
== BugChange subclasses ==
191
=== Getting the right bug change class ===
193
Given that we know what's changing and the name of the field that is
194
being changed, we can find a suitable IBugChange implementation to
195
help us describe the change.
197
>>> from lp.bugs.adapters.bugchange import (
198
... get_bug_change_class)
200
If get_bug_change_class() is asked for a BugChange for an object or
201
field that it doesn't know about, it will raise a NoBugChangeFoundError.
203
>>> get_bug_change_class(object(), 'fooix')
204
Traceback (most recent call last):
206
NoBugChangeFoundError: Unable to find a suitable BugChange for field
207
'fooix' on object <object object at ...>
209
For fields it knows about, it will return a more suitable class.
211
>>> get_bug_change_class(example_bug, 'title')
212
<class '...BugTitleChange'>
214
get_bug_change_class will also work for BugTasks.
216
>>> get_bug_change_class(example_bug.bugtasks[0], 'importance')
217
<class '...BugTaskImportanceChange'>
219
See component/ftests/test_bugchange.py for some sanity checks.
222
=== AttributeChange ===
224
The AttributeChange class offers basic functionality for dealing with
225
bug attribute changes.
227
>>> from lp.bugs.adapters.bugchange import (
230
>>> simple_change = AttributeChange(
231
... when=nowish, person=example_person, what_changed='title',
232
... old_value=example_bug.title, new_value='Spam')
234
In its getBugActivity() method AttributeChange merely returns the
235
field name, old value and new value as passed to its __init__()
238
>>> activity_data = simple_change.getBugActivity()
239
>>> print pretty(activity_data)
241
'oldvalue': u'Reality is on the blink again',
242
'whatchanged': 'title'}
245
=== BugDescriptionChange ===
247
This describes a change to the description of a
248
bug. getBugNotification() returns a formatted description of the
251
>>> from lp.bugs.adapters.bugchange import (
252
... BugDescriptionChange)
254
>>> bug_desc_change = BugDescriptionChange(
255
... when=nowish, person=example_person,
256
... what_changed='description', old_value=example_bug.description,
257
... new_value='Well, maybe not')
258
>>> print bug_desc_change.getBugNotification()['text']
259
** Description changed:
261
- I'm tired of thinking up funny strings for tests
265
=== BugTitleChange ===
267
This, surprisingly, describes a title change for a bug. Again,
268
getBugNotification() returns a specially formatted description of
271
>>> from lp.bugs.adapters.bugchange import (
274
>>> bug_title_change = BugTitleChange(
275
... when=nowish, person=example_person,
276
... what_changed='title', old_value=example_bug.title,
277
... new_value='Spam')
278
>>> print bug_title_change.getBugNotification()['text']
281
- Reality is on the blink again
284
BugTitleChange mutates the `what_changed` field and will return
285
'summary' rather than 'title'. This is to maintain naming consistency
288
>>> print bug_title_change.getBugActivity()['whatchanged']
292
=== BugDuplicateChange ===
294
This describes a change to the duplicate marker for a bug.
296
>>> from lp.bugs.adapters.bugchange import (
297
... BugDuplicateChange)
299
>>> duplicate_bug = factory.makeBug(title="Fish can't walk")
301
>>> bug_duplicate_change = BugDuplicateChange(
302
... when=nowish, person=example_person,
303
... what_changed='duplicateof', old_value=None,
304
... new_value=duplicate_bug)
305
>>> print bug_duplicate_change.getBugNotification()['text']
306
** This bug has been marked a duplicate of bug ...
309
BugDuplicateChange overrides getBugActivity() to customize all the
312
>>> print pretty(bug_duplicate_change.getBugActivity())
314
'whatchanged': 'marked as duplicate'}
317
=== BugVisibilityChange ===
319
BugVisibilityChange is used to represent a change in a Bug's `private`
322
>>> from lp.bugs.adapters.bugchange import (
323
... BugVisibilityChange)
325
>>> bug_visibility_change = BugVisibilityChange(
326
... when=nowish, person=example_person,
327
... what_changed='private', old_value=example_bug.private,
330
IBug.private is a boolean but to make it more readable we express it in
331
activity and notification records as a string, where True = 'Private'
332
and False = 'Public'. We also refer to it as "visibility" rather than
335
>>> print pretty(bug_visibility_change.getBugActivity())
336
{'newvalue': 'private',
337
'oldvalue': 'public',
338
'whatchanged': 'visibility'}
340
We also use the 'Private', 'Public' and 'Visibility' terms in the
343
>>> print bug_visibility_change.getBugNotification()['text']
344
** Visibility changed to: Private
346
If we reverse the changes we'll see the opposite values in the
347
notification and activity entries.
349
>>> bug_visibility_change = BugVisibilityChange(
350
... when=nowish, person=example_person,
351
... what_changed='private', old_value=True, new_value=False)
352
>>> print pretty(bug_visibility_change.getBugActivity())
353
{'newvalue': 'public',
354
'oldvalue': 'private',
355
'whatchanged': 'visibility'}
357
>>> print bug_visibility_change.getBugNotification()['text']
358
** Visibility changed to: Public
363
BugTagsChange is used to represent a change in a Bug's tag list.
365
>>> from lp.bugs.adapters.bugchange import (
368
>>> tags_change = BugTagsChange(
369
... when=nowish, person=example_person,
370
... what_changed='tags',
371
... old_value=[u'first-tag', u'second-tag', u'third-tag'],
372
... new_value=[u'second-tag', u'third-tag', u'zillionth-tag'])
374
This change is expressed in the activity entry in the same way as any
375
other attribute change. The list of tags is converted to a
376
space-separated string for display.
378
>>> print pretty(tags_change.getBugActivity())
379
{'newvalue': u'second-tag third-tag zillionth-tag',
380
'oldvalue': u'first-tag second-tag third-tag',
381
'whatchanged': 'tags'}
383
Addtions and removals are expressed separately in the notification.
385
>>> print tags_change.getBugNotification()['text']
386
** Tags added: zillionth-tag
387
** Tags removed: first-tag
390
=== BugSecurityChange ===
392
BugSecurityChange is used to represent a change in a Bug's
393
`security_related` attribute.
395
>>> from lp.bugs.adapters.bugchange import (
396
... BugSecurityChange)
398
>>> bug_security_change = BugSecurityChange(
399
... when=nowish, person=example_person,
400
... what_changed='security_related',
401
... old_value=False, new_value=True)
403
IBug.security_related is a boolean but to make it more readable we
404
express it in activity and notification records as a short phrase.
406
Marking a bug as security related causes one set of terms/phrases to
409
>>> print pretty(bug_security_change.getBugActivity())
412
'whatchanged': 'security vulnerability'}
414
>>> print bug_security_change.getBugNotification()['text']
415
** This bug has been flagged as a security vulnerability
417
Going the other way the phrases are similar.
419
>>> bug_security_change = BugSecurityChange(
420
... when=nowish, person=example_person,
421
... what_changed='security_related',
422
... old_value=True, new_value=False)
424
>>> print pretty(bug_security_change.getBugActivity())
427
'whatchanged': 'security vulnerability'}
429
>>> print bug_security_change.getBugNotification()['text']
430
** This bug is no longer flagged as a security vulnerability
433
=== CveLinkedToBug / CveUnlinkedFromBug ===
435
These describe the linking or unlinking of a CVE to a bug.
437
>>> from lp.bugs.interfaces.cve import ICveSet
438
>>> cve = getUtility(ICveSet)['1999-8979']
440
getBugNotification() returns a formatted description of the change
441
when a CVE is linked to a bug.
443
>>> from lp.bugs.adapters.bugchange import (
444
... CveLinkedToBug, CveUnlinkedFromBug)
446
>>> bug_cve_linked = CveLinkedToBug(
447
... when=nowish, person=example_person, cve=cve)
449
>>> print pretty(bug_cve_linked.getBugActivity())
450
{'newvalue': u'1999-8979',
451
'whatchanged': 'cve linked'}
453
>>> print bug_cve_linked.getBugNotification()['text']
454
** CVE added: http://www.cve.mitre.org/cgi-bin/cvename.cgi?name=1999-8979
456
And when a CVE is unlinked from a bug.
458
>>> bug_cve_unlinked = CveUnlinkedFromBug(
459
... when=nowish, person=example_person, cve=cve)
461
>>> print pretty(bug_cve_unlinked.getBugActivity())
462
{'oldvalue': u'1999-8979',
463
'whatchanged': 'cve unlinked'}
465
>>> print bug_cve_unlinked.getBugNotification()['text']
466
** CVE removed: http://www.cve.mitre.org/cgi-bin/cvename.cgi?name=1999-8979
469
== BugAttachmentChange ==
471
BugAttachmentChange is used to handle the addition and removal of
472
attachments from a bug.
474
>>> from lp.bugs.adapters.bugchange import (
475
... BugAttachmentChange)
477
You can add an attachment...
479
>>> attachment = factory.makeBugAttachment(
480
... description='sample-attachment')
481
>>> attachment_change = BugAttachmentChange(
482
... when=nowish, person=example_person,
483
... what_changed='security_related',
484
... old_value=None, new_value=attachment)
486
>>> print pretty(attachment_change.getBugActivity())
488
u'sample-attachment http://bugs.launchpad.dev/bugs/...+files/...',
490
'whatchanged': 'attachment added'}
492
>>> print attachment_change.getBugNotification()['text']
493
** Attachment added: "sample-attachment"
494
http://bugs.launchpad.dev/bugs/.../+attachment/1/+files/...
498
>>> attachment_change = BugAttachmentChange(
499
... when=nowish, person=example_person,
500
... what_changed='security_related',
501
... old_value=attachment, new_value=None)
503
>>> print pretty(attachment_change.getBugActivity())
506
u'sample-attachment http://bugs.launchpad.dev/bugs/...+files/...',
507
'whatchanged': 'attachment removed'}
509
>>> print attachment_change.getBugNotification()['text']
510
** Attachment removed: "sample-attachment"
511
http://bugs.launchpad.dev/bugs/.../+attachment/1/+files/...
514
== BugTaskAttributeChange ==
516
BugTaskAttributeChange is a generic BugChange that can be used to
517
represent a change in the attributes of one of a Bug's BugTasks. It is
518
intended to be subclassed.
520
>>> from lp.bugs.interfaces.bugtask import (
521
... BugTaskStatus, BugTaskImportance)
522
>>> from lp.bugs.adapters.bugchange import (
523
... BugTaskAttributeChange)
525
BugTaskAttributeChange takes an instance of BugTask. It uses this to
526
work out how to describe to the user which BugTask's attributes have
529
Subclasses must at least define `display_attribute`.
531
>>> class ExampleBugTaskAttributeChange(BugTaskAttributeChange):
532
... display_attribute = 'title'
534
>>> example_bug_task = example_bug.bugtasks[0]
535
>>> task_attribute_change = ExampleBugTaskAttributeChange(
536
... when=nowish, person=example_person,
537
... what_changed='status',
538
... old_value=BugTaskStatus.NEW,
539
... new_value=BugTaskStatus.FIXRELEASED,
540
... bug_task=example_bug_task)
542
>>> print task_attribute_change.display_activity_label
544
>>> print task_attribute_change.display_notification_label
546
>>> print task_attribute_change.display_old_value
548
>>> print task_attribute_change.display_new_value
551
Several types of attribute change can be handled by
552
BugTaskAttributeChange.
555
=== Status changes ===
557
Status changes use a BugTaskStatus's `title` attribute to describe to
558
the user what has changed.
560
>>> from lp.bugs.adapters.bugchange import (
561
... BugTaskStatusChange)
563
>>> status_change = BugTaskStatusChange(
564
... bug_task=example_bug_task, when=nowish, person=example_person,
565
... what_changed='status', old_value=BugTaskStatus.NEW,
566
... new_value=BugTaskStatus.FIXRELEASED)
567
>>> print pretty(status_change.getBugActivity())
568
{'newvalue': 'Fix Released',
570
'whatchanged': u'heart-of-gold: status'}
572
>>> notification_text = status_change.getBugNotification()['text']
573
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
574
** Changed in: heart-of-gold
575
Status: New => Fix Released
578
=== Importance changes ===
580
Importance changes use a BugTaskImportance's `title` attribute to
581
describe to the user what has changed.
583
>>> from lp.bugs.adapters.bugchange import (
584
... BugTaskImportanceChange)
586
>>> importance_change = BugTaskImportanceChange(
587
... bug_task=example_bug_task, when=nowish, person=example_person,
588
... what_changed='importance',
589
... old_value=BugTaskImportance.UNDECIDED,
590
... new_value=BugTaskImportance.CRITICAL)
591
>>> print pretty(importance_change.getBugActivity())
592
{'newvalue': 'Critical',
593
'oldvalue': 'Undecided',
594
'whatchanged': u'heart-of-gold: importance'}
596
>>> notification_text = importance_change.getBugNotification()['text']
597
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
598
** Changed in: heart-of-gold
599
Importance: Undecided => Critical
602
=== Milestone changes ===
604
Milestone changes use a Milestone's `name` attribute to describe to
605
the user what has changed.
607
>>> from lp.bugs.adapters.bugchange import (
608
... BugTaskMilestoneChange)
610
>>> milestone = factory.makeMilestone(
611
... product=example_bug_task.product,
612
... name="example-milestone")
614
>>> milestone_change = BugTaskMilestoneChange(
615
... bug_task=example_bug_task, when=nowish,
616
... person=example_person, what_changed='milestone',
617
... old_value=None, new_value=milestone)
618
>>> print pretty(milestone_change.getBugActivity())
619
{'newvalue': u'example-milestone',
621
'whatchanged': u'heart-of-gold: milestone'}
623
>>> notification_text = milestone_change.getBugNotification()['text']
624
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
625
** Changed in: heart-of-gold
626
Milestone: None => example-milestone
629
=== Bugwatch changes ===
631
Bugwatch changes use a Bugwatch's `title` attribute to describe to the
632
user what has changed.
634
>>> from lp.bugs.adapters.bugchange import (
635
... BugTaskBugWatchChange)
637
>>> bug_tracker = factory.makeBugTracker(
638
... base_url="http://bugs.example.com/")
639
>>> bug_watch = factory.makeBugWatch(
640
... bug=example_bug_task.bug, bugtracker=bug_tracker,
641
... remote_bug="1245")
643
>>> bug_watch_change = BugTaskBugWatchChange(
644
... bug_task=example_bug_task, when=nowish,
645
... person=example_person, what_changed='bugwatch',
646
... old_value=None, new_value=bug_watch)
647
>>> print pretty(bug_watch_change.getBugActivity())
648
{'newvalue': u'bugs.example.com/ #1245',
650
'whatchanged': u'heart-of-gold: remote watch'}
652
>>> notification_text = bug_watch_change.getBugNotification()['text']
653
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
654
** Changed in: heart-of-gold
655
Remote watch: None => bugs.example.com/ #1245
658
=== Assignee changes ===
660
Assignee changes use the assignee's `unique_displayname` attribute to
661
describe to the user what has changed.
663
>>> from lp.bugs.adapters.bugchange import (
664
... BugTaskAssigneeChange)
666
>>> assignee_change = BugTaskAssigneeChange(
667
... bug_task=example_bug_task, when=nowish,
668
... person=example_person, what_changed='assignee',
669
... old_value=None, new_value=example_person)
670
>>> print pretty(assignee_change.getBugActivity())
671
{'newvalue': u'Ford Prefect (ford-prefect)',
673
'whatchanged': u'heart-of-gold: assignee'}
675
>>> notification_text = assignee_change.getBugNotification()['text']
676
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
677
** Changed in: heart-of-gold
678
Assignee: (unassigned) => Ford Prefect (ford-prefect)
681
=== Target (Affects) changes ===
683
Changes to the bug task target (aka affects) use the BugTaskTargetChange
684
class to describe the change. It inspects the `bugtargetname`
685
attribute for the values to use in the activity log.
687
>>> from lp.bugs.adapters.bugchange import (
688
... BugTaskTargetChange)
690
>>> new_target = factory.makeProduct(name="magrathea")
692
>>> target_change = BugTaskTargetChange(
693
... bug_task=example_bug_task, when=nowish, person=example_person,
694
... what_changed='target',
695
... old_value=example_bug_task.target,
696
... new_value=new_target)
697
>>> print pretty(target_change.getBugActivity())
698
{'newvalue': u'magrathea',
699
'oldvalue': u'heart-of-gold',
700
'whatchanged': 'affects'}
702
>>> notification_text = target_change.getBugNotification()['text']
703
>>> print notification_text #doctest: -NORMALIZE_WHITESPACE
704
** Project changed: heart-of-gold => magrathea