Merge lp:~jcsackett/charmworld/better-latency-refactor into lp:~juju-jitsu/charmworld/trunk

Proposed by j.c.sackett
Status: Merged
Approved by: j.c.sackett
Approved revision: 427
Merged at revision: 422
Proposed branch: lp:~jcsackett/charmworld/better-latency-refactor
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 364 lines (+180/-76)
2 files modified
charmworld/jobs/review.py (+73/-30)
charmworld/jobs/tests/test_review.py (+107/-46)
To merge this branch: bzr merge lp:~jcsackett/charmworld/better-latency-refactor
Reviewer Review Type Date Requested Status
Juju Gui Bot continuous-integration Approve
Aaron Bentley (community) Approve
Review via email: mp+191615@code.launchpad.net

Commit message

Better latency calculation for tasks.

Description of the change

charmworld/jobs/review.py
-------------------------
The criteria for bugtasks being pulled has changed--we want to filter to only
bugs with linked branches; if a bug doesn't have a linked branch, it is managed
solely in launchpad--it only becomes a review queue item once a branch has been
linked.

The latency calculations for both tasks and proposals have been broken out into
their own functions.

`calculate_task_latency` tries to calculate latency based on information about
when the branch was linked. This process can get compilated.
* If there's only one branch linked event, we can safely use its date as the
  start_date.
* If there are multiple events, whether b/c there are multiple branches
  currently linked or because several branches have been linked and unlinked we
  have to find the first linked event that corresponds to a currently linked
  branch, as that is the "review item" that's actually being measured.
  `caclulate_task_latency` filters the events against the list of
  linked_branches to do this.
* There is always the chance filtering results in zero branches; this is
  because a branch could have been linked, and then the name could be changed
  (e.g. the user could have changed his/her display name). Since the branch
  event is only recorded as a string, the string doesn't get updated, and we
  can't match it to the currently linked branch. An example of this is in bug
  #1125869. This should be very rare--there has to have been several link
  events, and the name of the branch has to have been changed. Because it's
  rare, and there's no real way to recover, we abort calculating latency in this
  case.

`calculate_proposal_latency` does the same thing the inlined code used to do,
but as part of the ongoing attempts to make the review code more manageable it
seemed wise to move it to its own function to mirror the task processing.

charmworld/jobs/tests/test_review.py
------------------------------------
Tests for the latency functions have been added.

The Mock* classes have been replaced by functions that return actual mocks,
which make it much easier to add things like `task.bug.activity.entries`.

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

As discussed on IRC, please don't calculate NOW and WINDOW at module import time (e.g. calculate it in update_review_queue and pass it into the other functions.)

I actually prefer fakes[1] such as MockTask, MockBug, MockProposal, because match the real implementation's behaviour better. They will puke if you try to access a member that doesn't exist, or use it the wrong way. For example, if you have a typo in a method name, a MagicMock won't notice, and your code may not work in production. However, I won't block on this basis, because it's a safety / convenience tradeoff, and ultimately a matter of personal preference.

[1] I'm using "fake" in this sense: http://www.martinfowler.com/bliki/TestDouble.html

review: Approve
Revision history for this message
Juju Gui Bot (juju-gui-bot) wrote :

FAILED: Autolanding.
More details in the following jenkins job:
http://162.213.35.27:8080/job/charmworld-autoland-lxc/43/
Executed test runs:

review: Needs Fixing (continuous-integration)
427. By j.c.sackett

Shut up, lint.

Revision history for this message
Juju Gui Bot (juju-gui-bot) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmworld/jobs/review.py'
2--- charmworld/jobs/review.py 2013-10-04 19:01:39 +0000
3+++ charmworld/jobs/review.py 2013-10-17 20:16:11 +0000
4@@ -38,10 +38,20 @@
5 # older than the window that is still open. Right now this is probably the
6 # more performant way to go about it, but that's going to change as the
7 # data outside of our window gets larger.
8+ try:
9+ lp_credentials = get_ini()['lp_credentials_file']
10+ except KeyError:
11+ lp_credentials = None
12 with Timer() as timer:
13- lp = Launchpad.login_anonymously('charm-tools',
14- 'production',
15- version='devel')
16+ if lp_credentials:
17+ lp = Launchpad.login_with('charm-tools',
18+ 'production',
19+ version='devel',
20+ credentials_file=lp_credentials)
21+ else:
22+ lp = Launchpad.login_anonymously('charm-tools',
23+ 'production',
24+ version='devel')
25
26 # Entity roots for items of interests
27 charm = lp.distributions['charms']
28@@ -50,13 +60,13 @@
29
30 log.debug("lp login time %0.2f", timer.duration())
31
32+ branch_filter = "Show only Bugs with linked Branches"
33 with Timer() as timer:
34 bugs = charm.searchTasks(
35 tags=['new-formula', 'new-charm'],
36- tags_combinator="Any")
37- # Best practice dictates that charmers be subscribed to all bugs
38- # and merge proposals for official source package branches.
39- charmers_bugs = charmers.searchTasks()
40+ tags_combinator="Any",
41+ linked_branches=branch_filter)
42+ charmers_bugs = charmers.searchTasks(linked_branches=branch_filter)
43 proposals = charmers.getRequestedReviews()
44 contributor_proposals = charm_contributors.getRequestedReviews()
45
46@@ -69,27 +79,67 @@
47 itertools.chain(proposals, contributor_proposals, doc_proposals))
48
49
50+def calculate_proposal_latency(proposal, now):
51+ if proposal.date_reviewed:
52+ latency = (
53+ proposal.date_reviewed - proposal.date_review_requested)
54+ else:
55+ # `now` has been calculated in UTC, and LP returns UTC TZ
56+ # datetimes. We can safely remove the TZ, letting us get the
57+ latency = (
58+ now - proposal.date_review_requested.replace(tzinfo=None))
59+ return latency.total_seconds()
60+
61+
62+def calculate_task_latency(task, now):
63+ link_entries = [e for e in task.bug.activity.entries if
64+ e['whatchanged'] == 'branch linked']
65+ if len(link_entries) == 1:
66+ start_date = link_entries[0]['datechanged']
67+ start_date = datetime.datetime.strptime(
68+ start_date.split('.')[0], '%Y-%m-%dT%H:%M:%S')
69+ elif len(link_entries) > 1:
70+ #Filter down to links associated with the currently linked branches
71+ linked_branches = task.bug.linked_branches
72+ branch_names = [link.branch.display_name for link in linked_branches]
73+ current_entries = [e for e in link_entries if
74+ e['newvalue'] in branch_names]
75+ if current_entries != []:
76+ start_date = current_entries[0]['datechanged']
77+ start_date = datetime.datetime.strptime(
78+ start_date.split('.')[0], '%Y-%m-%dT%H:%M:%S')
79+ else:
80+ # At some point, the name of the branch changed, so we can't find
81+ # the event when it was added. This happens rarely, so we just
82+ # abandon stats for this task.
83+ return
84+ elif link_entries == []:
85+ # We have no entries, probably b/c the lp step failed to login with
86+ # authentication.
87+ return
88+
89+ if task.date_fix_committed:
90+ end_date = task.date_fix_committed.replace(tzinfo=None)
91+ else:
92+ end_date = now
93+ latency = end_date - start_date
94+ return latency.total_seconds()
95+
96+
97 def update_review_queue(db, tasks, proposals, log):
98 '''Updates the review queue information.'''
99+ now = datetime.datetime.utcnow()
100+ window = now - datetime.timedelta(days=180) # 6 month window for latency
101 bug_statuses = [
102 'New', 'Confirmed', 'Triaged', 'In Progress', 'Fix Committed']
103- proposal_statuses = ['Needs review', 'Needs fixing', 'Needs information']
104- now = datetime.datetime.utcnow()
105- window = now - datetime.timedelta(days=180) # 6 month window for latency
106-
107 r_queue = []
108 latencies = []
109 for task in tasks:
110 if (task.date_created.replace(tzinfo=None) >= window or
111 task.status in bug_statuses):
112- if task.date_confirmed:
113- latency = task.date_confirmed - task.date_created
114- else:
115- # Now has been calculated in UTC, and LP returns UTC TZ
116- # datetimes. We can safely remove the TZ, letting us get the
117- # timedelta.
118- latency = now - task.date_created.replace(tzinfo=None)
119- latencies.append(latency.total_seconds())
120+ latency = calculate_task_latency(task, now)
121+ if latency:
122+ latencies.append(latency)
123
124 if task.status in bug_statuses:
125 entry = {
126@@ -102,19 +152,13 @@
127 }
128 r_queue.append(entry)
129
130+ proposal_statuses = [
131+ 'Needs review', 'Needs fixing', 'Needs information']
132 for proposal in proposals:
133 if (proposal.date_created.replace(tzinfo=None) >= window or
134 proposal.queue_status in proposal_statuses):
135- if proposal.date_reviewed:
136- latency = (
137- proposal.date_reviewed - proposal.date_review_requested)
138- else:
139- # Now has been calculated in UTC, and LP returns UTC TZ
140- # datetimes. We can safely remove the TZ, letting us get the
141- latency = (
142- now - proposal.date_review_requested.replace(tzinfo=None))
143- latencies.append(latency.total_seconds())
144-
145+ latency = calculate_proposal_latency(proposal, now)
146+ latencies.append(latency)
147 if proposal.queue_status in proposal_statuses:
148 parts = proposal.target_branch_link.rsplit('/', 3)
149 if parts[-1] == "trunk":
150@@ -131,7 +175,6 @@
151
152 entry = {
153 'type': 'proposal',
154- 'latency': latency.total_seconds(),
155 'date_modified': last_modified,
156 'date_created': proposal.date_created,
157 'summary': "Merge for %s from %s" % (target, origin),
158
159=== modified file 'charmworld/jobs/tests/test_review.py'
160--- charmworld/jobs/tests/test_review.py 2013-10-15 20:04:46 +0000
161+++ charmworld/jobs/tests/test_review.py 2013-10-17 20:16:11 +0000
162@@ -3,7 +3,13 @@
163 import datetime
164 import logging
165
166-from charmworld.jobs.review import update_review_queue
167+import mock
168+
169+from charmworld.jobs.review import (
170+ calculate_proposal_latency,
171+ calculate_task_latency,
172+ update_review_queue,
173+)
174 from charmworld.testing import MongoTestBase
175
176
177@@ -12,45 +18,45 @@
178 TWO_DAYS = NOW - datetime.timedelta(2)
179
180
181-class MockBug:
182- date_last_updated = NOW
183-
184-
185-class MockTask:
186-
187- def __init__(self, confirmed=False):
188- self.date_created = TWO_DAYS
189- self.title = u'Bug #0 in a collection: "A bug task"'
190- self.web_link = "http://example.com"
191- self.bug = MockBug()
192- if confirmed:
193- self.status = "Confirmed"
194- self.date_confirmed = YESTERDAY
195- else:
196- self.date_confirmed = None
197- self.status = "New"
198-
199-
200-class MockCommentCollection:
201- total_size = 0
202-
203-
204-class MockProposal:
205-
206- def __init__(self, reviewed=False):
207- base = 'https://api.launchpad.net/devel/'
208- self.target_branch_link = base + '~charmers/charms/precise/foo/trunk'
209- self.source_branch_link = base + '~bar/charms/precise/foo/fnord'
210- self.all_comments = MockCommentCollection()
211- self.date_review_requested = TWO_DAYS
212- self.date_created = TWO_DAYS
213- self.web_link = 'http://example.com'
214- if reviewed:
215- self.date_reviewed = YESTERDAY
216- self.queue_status = "Approved"
217- else:
218- self.date_reviewed = None
219- self.queue_status = "Needs review"
220+def get_mock_task(confirmed=False, entries=[], linked_branches=[]):
221+ if confirmed:
222+ status = "Confirmed"
223+ date_fix_committed = YESTERDAY
224+ else:
225+ date_fix_committed = None
226+ status = "New"
227+ return mock.Mock(
228+ status=status,
229+ date_fix_committed=date_fix_committed,
230+ date_created=TWO_DAYS,
231+ title=u'Bug #0 in a collection: "A bug task"',
232+ web_link="http://example.com",
233+ bug=mock.Mock(
234+ date_last_updated=NOW,
235+ linked_branches=linked_branches,
236+ activity=mock.Mock(entries=entries)
237+ ),
238+ )
239+
240+
241+def get_mock_proposal(reviewed=False):
242+ base = 'https://api.launchpad.net/devel/'
243+ if reviewed:
244+ date_reviewed = YESTERDAY
245+ queue_status = "Approved"
246+ else:
247+ date_reviewed = None
248+ queue_status = "Needs review"
249+ return mock.Mock(
250+ target_branch_link=base + '~charmers/charms/precise/foo/trunk',
251+ source_branch_link=base + '~bar/charms/precise/foo/fnord',
252+ all_comments=mock.Mock(total_size=0),
253+ date_review_requested=TWO_DAYS,
254+ date_created=TWO_DAYS,
255+ web_link='http://example.com',
256+ date_reviewed=date_reviewed,
257+ queue_status=queue_status,
258+ )
259
260
261 class ReviewTest(MongoTestBase):
262@@ -63,7 +69,7 @@
263
264 def test_review_handles_bugs(self):
265 log = logging.getLogger('foo')
266- update_review_queue(self.db, [MockTask()], [], log)
267+ update_review_queue(self.db, [get_mock_task()], [], log)
268 self.assertEqual(1, self.db.review_queue.count())
269 item = self.db.review_queue.find_one()
270 self.assertEqual(NOW.date(), item['date_modified'].date())
271@@ -74,7 +80,7 @@
272
273 def test_review_handles_proposals(self):
274 log = logging.getLogger('foo')
275- update_review_queue(self.db, [], [MockProposal()], log)
276+ update_review_queue(self.db, [], [get_mock_proposal()], log)
277 self.assertEqual(1, self.db.review_queue.count())
278 item = self.db.review_queue.find_one()
279 self.assertEqual(TWO_DAYS.date(), item['date_modified'].date())
280@@ -84,10 +90,65 @@
281 self.assertEqual('http://example.com', item['item'])
282 self.assertEqual('Needs review', item['status'])
283
284- def test_review_calculates_latency(self):
285+ def test_calculate_task_latency_with_two_valid_links(self):
286+ linked_branches = [
287+ mock.Mock(branch=mock.Mock(display_name='lp:~foo/bar/bjorn')),
288+ mock.Mock(branch=mock.Mock(display_name='lp:~foo/bar/baz'))
289+ ]
290+ two_days = datetime.datetime.strftime(
291+ TWO_DAYS, '%Y-%m-%dT%H:%M:%S')
292+ yesterday = datetime.datetime.strftime(
293+ YESTERDAY, '%Y-%m-%dT%H:%M:%S')
294+ entries = [
295+ {'whatchanged': 'branch linked',
296+ 'newvalue': 'lp:~foo/bar/baz',
297+ 'datechanged': two_days},
298+ {'whatchanged': 'branch linked',
299+ 'newvalue': 'lp:~foo/bar/bjorn',
300+ 'datechanged': yesterday}]
301+ latency = calculate_task_latency(
302+ get_mock_task(
303+ True, entries=entries, linked_branches=linked_branches), NOW)
304+ self.assertEqual(60 * 60 * 24, int(latency))
305+
306+ def test_calculate_task_latency_bug_with_one_valid_link(self):
307+ linked_branches = [
308+ mock.Mock(branch=mock.Mock(display_name='lp:~foo/bar/bjorn'))
309+ ]
310+ two_days = datetime.datetime.strftime(
311+ TWO_DAYS, '%Y-%m-%dT%H:%M:%S')
312+ yesterday = datetime.datetime.strftime(
313+ YESTERDAY, '%Y-%m-%dT%H:%M:%S')
314+ entries = [
315+ {'whatchanged': 'branch linked',
316+ 'newvalue': 'lp:~foo/bar/baz',
317+ 'datechanged': yesterday},
318+ {'whatchanged': 'branch linked',
319+ 'newvalue': 'lp:~foo/bar/bjorn',
320+ 'datechanged': two_days}]
321+ latency = calculate_task_latency(
322+ get_mock_task(
323+ True, entries=entries, linked_branches=linked_branches), NOW)
324+ self.assertEqual(60 * 60 * 24, int(latency))
325+
326+ def test_calculate_task_latency_bug_with_one_link(self):
327+ date_changed = datetime.datetime.strftime(
328+ TWO_DAYS, '%Y-%m-%dT%H:%M:%S')
329+ entries = [{'whatchanged': 'branch linked',
330+ 'newvalue': 'lp:~foo/bar/baz',
331+ 'datechanged': date_changed}]
332+ latency = calculate_task_latency(
333+ get_mock_task(True, entries=entries), NOW)
334+ self.assertEqual(60 * 60 * 24, int(latency))
335+
336+ def test_calculate_proposal_latency(self):
337+ latency = calculate_proposal_latency(get_mock_proposal(True), NOW)
338+ self.assertEqual(60 * 60 * 24, int(latency))
339+
340+ def test_review_job_saves_latency(self):
341 log = logging.getLogger('foo')
342 self.assertEqual(0, self.db.review_queue_latency.count())
343- update_review_queue(self.db, [], [MockProposal(True)], log)
344+ update_review_queue(self.db, [], [get_mock_proposal(True)], log)
345 self.assertEqual(1, self.db.review_queue_latency.count())
346 item = self.db.review_queue_latency.find_one()
347 self.assertEqual(NOW.date(), item['date'].date())
348@@ -96,14 +157,14 @@
349 self.assertEqual(1, datetime.timedelta(seconds=item['min']).days)
350
351 def test_review_discards_closed_bugs(self):
352- tasks = [MockTask(), MockTask()]
353+ tasks = [get_mock_task(), get_mock_task()]
354 tasks[0].status = 'Fix released'
355 log = logging.getLogger('foo')
356 update_review_queue(self.db, tasks, [], log)
357 self.assertEqual(1, self.db.review_queue.count())
358
359 def test_review_discards_approved_proposals(self):
360- proposals = [MockProposal(), MockProposal()]
361+ proposals = [get_mock_proposal(), get_mock_proposal()]
362 proposals[0].queue_status = 'Approved'
363 log = logging.getLogger('foo')
364 update_review_queue(self.db, [], proposals, log)

Subscribers

People subscribed via source and target branches