Merge lp:~brian-murray/launchpad/595124 into lp:launchpad

Proposed by Brian Murray
Status: Merged
Merged at revision: 11108
Proposed branch: lp:~brian-murray/launchpad/595124
Merge into: lp:launchpad
Diff against target: 443 lines (+164/-41)
12 files modified
lib/lp/bugs/browser/bugtask.py (+7/-4)
lib/lp/bugs/configure.zcml (+2/-1)
lib/lp/bugs/doc/bug.txt (+6/-6)
lib/lp/bugs/doc/bugtask-expiration.txt (+30/-7)
lib/lp/bugs/interfaces/bug.py (+18/-2)
lib/lp/bugs/model/bug.py (+36/-9)
lib/lp/bugs/model/bugtask.py (+1/-1)
lib/lp/bugs/stories/bugs/xx-incomplete-bugs.txt (+16/-6)
lib/lp/bugs/stories/webservice/xx-bug.txt (+42/-0)
lib/lp/bugs/templates/bug-listing-expirable.pt (+3/-3)
lib/lp/bugs/templates/bugtask-index.pt (+1/-1)
lib/lp/bugs/tests/bug.py (+2/-1)
To merge this branch: bzr merge lp:~brian-murray/launchpad/595124
Reviewer Review Type Date Requested Status
Leonard Richardson (community) Approve
Review via email: mp+28543@code.launchpad.net

Commit message

unexport IBug.can_expire which is confusing instead create and export IBug.isExpirable() which can use a custom number of days.

Description of the change

A bug report's can_expire attribute has lead to some confusion as it shows if a bug may expire not whether or not it should expire. To remove any confusion I've modified can_expire to call findExpirableTasks with days_old equal to config.malone.days_before_expiration, (60 by the way), so it will reflect whether or not a bug should expire.

This also changes the results displayed at +expirable-bugs so that only bugs that are expirable will show up not ones that match the criteria and have a days_old of less than 60. Deryck and I discussed this and are in agreement that this makes the most sense. However, individual bug pages will still display "will be marked for expiration in XYZ days" as isExpirable is called with days_old=0.

I've also created IBug.isExpirable() which accepts a quantity of days as an argument we can call findExpirableTasks with a different quantity of days_old - for example 0. This is also exported in the API as it may be useful for a project that wanted a different expiration period than the default or if a package maintainer wanted to be particularly aggressive in expiring bugs.

Tests include:

bin/test -cvvt xx-bug.txt -t bugtask-expiration.txt -t xx-incomplete-bugs.txt

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This all looks good except for one minor point: you are changing the behavior of the web service's can_expire attribute in a backwards-incompatible way. Bugs that used to have can_expire=False will now have can_expire=True without the bugs themselves changing.

I personally don't think the change is big enough to warrant backwards-compatibility, but here's how to do it if Francis disagrees.

Declare two attributes in IBug, can_expire (which uses your current implementation) and can_expire_beta:

    can_expire = exported(Bool(...), ('devel', dict(exported=True),
                                      'beta', dict(exported=False)))

    can_expire_beta = exported(Bool(...), exported_as='can_expire',
                               ('devel', dict(exported=False))

This will make can_expire_beta show up as 'can_expire' in 'beta' and '1.0', but will replace it with can_expire in 'devel'.

I may not have the syntax right; refer to lazr.restful src/lazr/restful/examples/multiversion/resource.py and src/lazr/restful/docs/multiversion.txt for help.

review: Needs Fixing
Revision history for this message
Francis J. Lacoste (flacoste) wrote :

I don't mind the backward incompatibility either. I think Brian is one of the heavy user of that API and if that's fine by him, I wouldn't second guess him here.

I have another concern which isn't introduce by this branch, which is that this means that sending a bugs representation makes an additional query to get the value of the can_expire attribute. I think this is very bad from a performance point of view. Especially when navigating through bug searches.

A better API would be to turn this into a collection exposed on the context and return the expirable bug tasks.

And just remove can_expire from the model. Maybe that's what we should do now? Mark it as removed, export only isExpirable() which can be used to retrieve the information if really needed. Exporting the set of expirable bugs might be done at another time (not sure if the IBugTarget has findExpirableBugs in it already).

Revision history for this message
Brian Murray (brian-murray) wrote :

This made sense to me so I've removed can_expire from being exported in the development version, and kept it in previous versions, of the API and will report a bug about exporting findExirableBugs.

Revision history for this message
Leonard Richardson (leonardr) wrote :

This now looks good. I have one advisory comment: only make this change if it makes sense.

isExpirable() uses the janitor to do a search because it doesn't have access to the user making the request. If you make that method take a user argument, you can have the web service fill in the current user automatically:

from lazr.restful.declarations import call_with, REQUEST_USER
@call_with(who=REQUEST_USER)
def isExpirable(who, days_old=None):
    ...

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugtask.py'
2--- lib/lp/bugs/browser/bugtask.py 2010-06-05 10:41:52 +0000
3+++ lib/lp/bugs/browser/bugtask.py 2010-07-08 13:37:09 +0000
4@@ -1004,7 +1004,7 @@
5 @property
6 def days_to_expiration(self):
7 """Return the number of days before the bug is expired, or None."""
8- if not self.context.bug.can_expire:
9+ if not self.context.bug.isExpirable(days_old=0):
10 return None
11
12 expire_after = timedelta(days=config.malone.days_before_expiration)
13@@ -1023,7 +1023,7 @@
14
15 If the bug is not due to be expired None will be returned.
16 """
17- if not self.context.bug.can_expire:
18+ if not self.context.bug.isExpirable(days_old=0):
19 return None
20
21 days_to_expiration = self.days_to_expiration
22@@ -1943,9 +1943,11 @@
23 The bugtarget may be an `IDistribution`, `IDistroSeries`, `IProduct`,
24 or `IProductSeries`.
25 """
26+ days_old = config.malone.days_before_expiration
27+
28 if target_has_expirable_bugs_listing(self.context):
29 return getUtility(IBugTaskSet).findExpirableBugTasks(
30- 0, user=self.user, target=self.context).count()
31+ days_old, user=self.user, target=self.context).count()
32 else:
33 return None
34
35@@ -3761,9 +3763,10 @@
36 @property
37 def search(self):
38 """Return an `ITableBatchNavigator` for the expirable bugtasks."""
39+ days_old = config.malone.days_before_expiration
40 bugtaskset = getUtility(IBugTaskSet)
41 bugtasks = bugtaskset.findExpirableBugTasks(
42- 0, user=self.user, target=self.context)
43+ days_old, user=self.user, target=self.context)
44 return BugListingBatchNavigator(
45 bugtasks, self.request, columns_to_show=self.columns_to_show,
46 size=config.malone.buglist_batch_size)
47
48=== modified file 'lib/lp/bugs/configure.zcml'
49--- lib/lp/bugs/configure.zcml 2010-06-21 15:46:36 +0000
50+++ lib/lp/bugs/configure.zcml 2010-07-08 13:37:09 +0000
51@@ -661,7 +661,8 @@
52 users_affected_with_dupes
53 bug_messages
54 isUserAffected
55- getHWSubmissions"/>
56+ getHWSubmissions
57+ isExpirable"/>
58 <require
59 permission="launchpad.Edit"
60 attributes="
61
62=== modified file 'lib/lp/bugs/doc/bug.txt'
63--- lib/lp/bugs/doc/bug.txt 2010-04-14 12:55:44 +0000
64+++ lib/lp/bugs/doc/bug.txt 2010-07-08 13:37:09 +0000
65@@ -1067,7 +1067,7 @@
66 --------------
67
68 Incomplete bug reports may expire when they become inactive. Expiration
69-is only available to projects that use Launchpad to track bugs. There
70+is only available to projects that use Launchpad to track bugs. There are
71 two properties related to expiration. IBug.permits_expiration tests
72 that the state of the bug permits expiration, and returns True or False.
73 IBug.can_expire property returns True or False as to whether the bug
74@@ -1102,10 +1102,10 @@
75 False
76
77 Ubuntu has enabled bug expiration. Incomplete, unattended bugs can
78-expired.
79+expire.
80
81 >>> expirable_bugtask = create_old_bug(
82- ... 'bug c', 1, ubuntu, with_message=False)
83+ ... 'bug c', 61, ubuntu, with_message=False)
84 >>> sync_bugtasks(expirable_bugtask)
85
86 >>> expirable_bugtask.status.name
87@@ -1117,9 +1117,9 @@
88 >>> expirable_bugtask.bug.can_expire
89 True
90
91-When the expirable_bugtask assigned, the bugtask is no longer in
92-an expirable state, thus the bug cannot expire even though
93-bug permits expiration.
94+When the expirable_bugtask is assigned, the bugtask is no longer in an
95+expirable state, thus the bug cannot expire even though bug permits
96+expiration.
97
98 >>> expirable_bugtask.transitionToAssignee(sample_person)
99 >>> sync_bugtasks(expirable_bugtask)
100
101=== modified file 'lib/lp/bugs/doc/bugtask-expiration.txt'
102--- lib/lp/bugs/doc/bugtask-expiration.txt 2010-04-17 16:13:48 +0000
103+++ lib/lp/bugs/doc/bugtask-expiration.txt 2010-07-08 13:37:09 +0000
104@@ -188,14 +188,12 @@
105 >>> milestone_bugtask.bug.can_expire
106 False
107
108- # A bugtask can be subject for expiration, even though it is less
109- # than the min_days_old. can_expire indicates whether the bugtask can be
110- # expired in the future, if it doesn't get any further activity.
111+ # Create a bugtask that is not old enough to expire
112 >>> recent_bugtask = create_old_bug('recent', 31, ubuntu)
113 >>> recent_bugtask.bug.permits_expiration
114 True
115 >>> recent_bugtask.bug.can_expire
116- True
117+ False
118
119 # A bugtask that is not expirable; while the product uses Launchpad to
120 # track bugs, enable_bug_expiration is set to False
121@@ -229,9 +227,34 @@
122 duplicate False 61 Incomplete False True False False
123 external False 61 Incomplete False False False False
124 milestone False 61 Incomplete False False True False
125- recent True 31 Incomplete False False False False
126+ recent False 31 Incomplete False False False False
127 no_expire False 61 Incomplete False False False False
128
129+== isExpirable() ==
130+
131+In addition to can_expire bugs have an isExpirable method to which a custom
132+number of days, days_old, can be passed. days_old is then used with
133+findExpirableBugTasks. This allows projects to create their own janitor using
134+a different period for bug expiration.
135+
136+ # Check to ensure that isExpirable() works without days_old, then set the
137+ # bug to Invalid so it doesn't affect the rest of the doctest
138+ >>> from lp.bugs.tests.bug import create_old_bug
139+ >>> very_old_bugtask = create_old_bug('expirable_distro', 351, ubuntu)
140+ >>> very_old_bugtask.bug.isExpirable()
141+ True
142+ >>> very_old_bugtask.transitionToStatus(
143+ ... BugTaskStatus.INVALID, sample_person)
144+
145+ # Pass isExpirable() a days_old parameter, then set the bug to Invalid so it
146+ # doesn't affect the rest of the doctest
147+ >>> from lp.bugs.tests.bug import create_old_bug
148+ >>> not_so_old_bugtask = create_old_bug('expirable_distro', 31, ubuntu)
149+ >>> not_so_old_bugtask.bug.isExpirable(days_old=14)
150+ True
151+ >>> not_so_old_bugtask.transitionToStatus(
152+ ... BugTaskStatus.INVALID, sample_person)
153+
154
155 == findExpirableBugTasks() Part 2 ==
156
157@@ -322,7 +345,7 @@
158 ROLE EXPIRE AGE STATUS ASSIGNED DUP MILE REPLIES
159 ubuntu True 351 Incomplete False False False False
160 hoary True 351 Incomplete False False False False
161- recent True 31 Incomplete False False False False
162+ recent False 31 Incomplete False False False False
163
164 Thunderbird has not enabled bug expiration. Even when the min_days_old
165 is set to 0, no bugtasks are replaced.
166@@ -479,7 +502,7 @@
167 duplicate False 61 Incomplete False True False False
168 external False 61 Incomplete False False False False
169 milestone False 61 Incomplete False False True False
170- recent True 31 Incomplete False False False False
171+ recent False 31 Incomplete False False False False
172 no_expire False 61 Incomplete False False False False
173
174 The bugtasks statusexplanation was updated to explain the change in
175
176=== modified file 'lib/lp/bugs/interfaces/bug.py'
177--- lib/lp/bugs/interfaces/bug.py 2010-06-10 18:55:22 +0000
178+++ lib/lp/bugs/interfaces/bug.py 2010-07-08 13:37:09 +0000
179@@ -277,10 +277,11 @@
180 readonly=True)
181 can_expire = exported(
182 Bool(
183- title=_("Can the Incomplete bug expire if it becomes inactive? "
184+ title=_("Can the Incomplete bug expire? "
185 "Expiration may happen when the bug permits expiration, "
186 "and a bugtask cannot be confirmed."),
187- readonly=True))
188+ readonly=True),
189+ ('devel', dict(exported=False)), exported=True)
190 date_last_message = exported(
191 Datetime(title=_("Date of last bug message"),
192 required=False, readonly=True))
193@@ -809,6 +810,21 @@
194 def updateHeat():
195 """Update the heat for the bug."""
196
197+ @operation_parameters(
198+ days_old=Int(
199+ title=_('Number of days of inactivity for which to check.'),
200+ required=False))
201+ @export_read_operation()
202+ def isExpirable(days_old=None):
203+ """Is this bug eligible for expiration and was it last updated
204+ more than X days ago?
205+
206+ If days_old is None the default number of days without activity
207+ is used.
208+
209+ Returns True or False.
210+ """
211+
212 class InvalidDuplicateValue(Exception):
213 """A bug cannot be set as the duplicate of another."""
214 webservice_error(417)
215
216=== modified file 'lib/lp/bugs/model/bug.py'
217--- lib/lp/bugs/model/bug.py 2010-06-22 16:08:05 +0000
218+++ lib/lp/bugs/model/bug.py 2010-07-08 13:37:09 +0000
219@@ -42,6 +42,7 @@
220 ObjectCreatedEvent, ObjectDeletedEvent, ObjectModifiedEvent)
221 from lazr.lifecycle.snapshot import Snapshot
222
223+from canonical.config import config
224 from canonical.database.constants import UTC_NOW
225 from canonical.database.datetimecol import UtcDateTimeCol
226 from canonical.database.sqlbase import cursor, SQLBase, sqlvalues
227@@ -436,7 +437,7 @@
228 enabled_bug_expiration set to True can be expired. To qualify for
229 expiration, the bug and its bugtasks meet the follow conditions:
230
231- 1. The bug is inactive; the last update of the is older than
232+ 1. The bug is inactive; the last update of the bug is older than
233 Launchpad expiration age.
234 2. The bug is not a duplicate.
235 3. The bug has at least one message (a request for more information).
236@@ -454,14 +455,40 @@
237 if not self.permits_expiration:
238 return False
239
240- # Do the search as the Janitor, to ensure that this bug can be
241- # found, even if it's private. We don't have access to the user
242- # calling this property. If the user has access to view this
243- # property, he has permission to see the bug, so we're not
244- # exposing something we shouldn't. The Janitor has access to
245- # view all bugs.
246- bugtasks = getUtility(IBugTaskSet).findExpirableBugTasks(
247- 0, getUtility(ILaunchpadCelebrities).janitor, bug=self)
248+ days_old = config.malone.days_before_expiration
249+ # Do the search as the Janitor, to ensure that this bug can be
250+ # found, even if it's private. We don't have access to the user
251+ # calling this property. If the user has access to view this
252+ # property, he has permission to see the bug, so we're not
253+ # exposing something we shouldn't. The Janitor has access to
254+ # view all bugs.
255+ bugtasks = getUtility(IBugTaskSet).findExpirableBugTasks(
256+ days_old, getUtility(ILaunchpadCelebrities).janitor, bug=self)
257+ return bugtasks.count() > 0
258+
259+ def isExpirable(self, days_old=None):
260+ """See `IBug`."""
261+
262+ # If days_old is None read it from the Launchpad configuration
263+ # and use that value
264+ if days_old is None:
265+ days_old = config.malone.days_before_expiration
266+
267+ # IBugTaskSet.findExpirableBugTasks() is the authoritative determiner
268+ # if a bug can expire, but it is expensive. We do a general check
269+ # to verify the bug permits expiration before using IBugTaskSet to
270+ # determine if a bugtask can cause expiration.
271+ if not self.permits_expiration:
272+ return False
273+
274+ # Do the search as the Janitor, to ensure that this bug can be
275+ # found, even if it's private. We don't have access to the user
276+ # calling this property. If the user has access to view this
277+ # property, he has permission to see the bug, so we're not
278+ # exposing something we shouldn't. The Janitor has access to
279+ # view all bugs.
280+ bugtasks = getUtility(IBugTaskSet).findExpirableBugTasks(
281+ days_old, getUtility(ILaunchpadCelebrities).janitor, bug=self)
282 return bugtasks.count() > 0
283
284 @property
285
286=== modified file 'lib/lp/bugs/model/bugtask.py'
287--- lib/lp/bugs/model/bugtask.py 2010-06-25 18:48:13 +0000
288+++ lib/lp/bugs/model/bugtask.py 2010-07-08 13:37:09 +0000
289@@ -2265,7 +2265,7 @@
290 transitionToStatus() method. See 'Conjoined Bug Tasks' in
291 c.l.doc/bugtasks.txt.
292
293- Only bugtask the specified user has permission to view are
294+ Only bugtasks the specified user has permission to view are
295 returned. The Janitor celebrity has permission to view all bugs.
296 """
297 if bug is None:
298
299=== modified file 'lib/lp/bugs/stories/bugs/xx-incomplete-bugs.txt'
300--- lib/lp/bugs/stories/bugs/xx-incomplete-bugs.txt 2010-01-23 14:17:49 +0000
301+++ lib/lp/bugs/stories/bugs/xx-incomplete-bugs.txt 2010-07-08 13:37:09 +0000
302@@ -130,11 +130,18 @@
303
304 Users can view a list of expirable bugs via a link on the project's
305 bug page. To see the behaviour of the bug listing, we need another
306-expirable bug. No Privileges Person marks another bug as incomplete.
307+expirable bug. No Privileges Person marks another bug as Incomplete
308+and does so some time ago so it appears as expirable.
309
310 >>> user_browser.open('http://bugs.launchpad.dev/jokosher/+bug/12')
311 >>> user_browser.getControl('Status').value = ['Incomplete']
312 >>> user_browser.getControl('Save Changes', index=0).click()
313+ >>> login('test@canonical.com')
314+ >>> bug_12 = getUtility(IBugSet).get(12)
315+ >>> time_delta = timedelta(days=60)
316+ >>> bug_12.date_last_updated = bug_12.date_last_updated - time_delta
317+ >>> flush_database_updates()
318+ >>> logout()
319
320 The project's bug page reports the number of bugs that will expire if
321 they are not confirmed. No Privileges Person sees that Jokosher has 2
322@@ -180,12 +187,15 @@
323
324 The listing is sorted in order of most inactive to least inactive. The
325 bugs at the top of the list will expire before the ones at the bottom.
326-When No Privileges Person adds a comment to the oldest bug, it is
327-pushed to the bottom of the list.
328+When bug 12's date_last_updated is modified to be older than bug 11's it
329+appears as more inactive than bug 11 and is pushed to the top of the list.
330
331- >>> user_browser.getLink('Make Jokosher use autoaudiosink').click()
332- >>> user_browser.getControl(name='field.comment').value = "bump"
333- >>> user_browser.getControl('Post Comment').click()
334+ >>> login('test@canonical.com')
335+ >>> bug_12 = getUtility(IBugSet).get(12)
336+ >>> time_delta = timedelta(days=2)
337+ >>> bug_12.date_last_updated = bug_12.date_last_updated - time_delta
338+ >>> flush_database_updates()
339+ >>> logout()
340 >>> user_browser.getLink('Bugs').click()
341 >>> user_browser.getLink('Incomplete bugs').click()
342 >>> contents = find_main_content(user_browser.contents)
343
344=== modified file 'lib/lp/bugs/stories/webservice/xx-bug.txt'
345--- lib/lp/bugs/stories/webservice/xx-bug.txt 2010-06-24 17:09:14 +0000
346+++ lib/lp/bugs/stories/webservice/xx-bug.txt 2010-07-08 13:37:09 +0000
347@@ -2002,3 +2002,45 @@
348 ... branch_entry['bug_link']).jsonBody()
349 >>> print bug_link['self_link']
350 http://.../bugs/4
351+
352+Bug expiration
353+--------------
354+
355+In addition to can_expire bugs have an isExpirable method to which a custom time
356+period, days_old, can be passed. This is then used with
357+findExpirableBugTasks. This allows projects to create their own janitor using
358+a different period for bug expiration.
359+
360+Check to ensure that isExpirable() works without days_old.
361+
362+ >>> bug_four = webservice.get("/bugs/4").jsonBody()
363+ >>> print webservice.named_get(bug_four['self_link'],
364+ ... 'isExpirable').jsonBody()
365+ False
366+
367+Pass isExpirable() an integer for days_old.
368+
369+ >>> bug_four = webservice.get("/bugs/4").jsonBody()
370+ >>> print webservice.named_get(bug_four['self_link'], 'isExpirable',
371+ ... days_old='14').jsonBody()
372+ False
373+
374+Pass isExpirable() a string for days_old.
375+
376+ >>> bug_four = webservice.get("/bugs/4").jsonBody()
377+ >>> print webservice.named_get(bug_four['self_link'], 'isExpirable',
378+ ... days_old='sixty')
379+ HTTP/1.1 400 Bad Request
380+ ...
381+ days_old: got 'unicode', expected int: u'sixty'
382+
383+Can expire
384+----------
385+
386+can_expire is not exported in the development version of the API.
387+
388+ >>> bug_four = webservice.get("/bugs/4", api_version='devel').jsonBody()
389+ >>> bug_four[can_expire]
390+ Traceback (most recent call last):
391+ ...
392+ NameError: name 'can_expire' is not defined
393
394=== modified file 'lib/lp/bugs/templates/bug-listing-expirable.pt'
395--- lib/lp/bugs/templates/bug-listing-expirable.pt 2009-09-01 11:06:23 +0000
396+++ lib/lp/bugs/templates/bug-listing-expirable.pt 2010-07-08 13:37:09 +0000
397@@ -25,9 +25,9 @@
398
399 <tal:expirable-bugs condition="view/can_show_expirable_bugs">
400 <p>
401- Incomplete bug reports can expire if they are unattended and they
402- become inactive. These bugs are not confirmed, or in a status
403- that indicates they were confirmed. An Incomplete bug can remain
404+ Incomplete bug reports can expire if they are unattended and are
405+ inactive. These bugs are not confirmed, or in a status that
406+ indicates they were confirmed. An Incomplete bug can remain
407 in this list indefinitely, so long as the bug is regularly updated.
408 See <a href="https://help.launchpad.net/Bugs/Expiry">Bugs/Expiry.
409 </a>
410
411=== modified file 'lib/lp/bugs/templates/bugtask-index.pt'
412--- lib/lp/bugs/templates/bugtask-index.pt 2010-06-24 13:13:01 +0000
413+++ lib/lp/bugs/templates/bugtask-index.pt 2010-07-08 13:37:09 +0000
414@@ -72,7 +72,7 @@
415
416 <p
417 id="can-expire"
418- tal:condition="context/bug/can_expire"
419+ tal:condition="python: context.bug.isExpirable(days_old=0)"
420 >
421 <tal:expiration_message replace="view/expiration_message" />
422 (<a href="https://help.launchpad.net/BugExpiry">find out why</a>)
423
424=== modified file 'lib/lp/bugs/tests/bug.py'
425--- lib/lp/bugs/tests/bug.py 2010-06-04 17:47:34 +0000
426+++ lib/lp/bugs/tests/bug.py 2010-07-08 13:37:09 +0000
427@@ -15,6 +15,7 @@
428 from zope.component import getUtility
429 from zope.security.proxy import removeSecurityProxy
430
431+from canonical.config import config
432 from canonical.launchpad.ftests import sync
433 from canonical.launchpad.testing.pages import (
434 extract_text, find_tag_by_id, find_main_content, find_tags_by_class,
435@@ -220,7 +221,7 @@
436 """Summarize a sequence of bugtasks."""
437 bugtaskset = getUtility(IBugTaskSet)
438 expirable_bugtasks = list(bugtaskset.findExpirableBugTasks(
439- 0, getUtility(ILaunchpadCelebrities).janitor))
440+ config.malone.days_before_expiration, getUtility(ILaunchpadCelebrities).janitor))
441 print 'ROLE EXPIRE AGE STATUS ASSIGNED DUP MILE REPLIES'
442 for bugtask in sorted(set(bugtasks), key=attrgetter('id')):
443 if len(bugtask.bug.bugtasks) == 1: