Merge lp:~canonical-platform-qa/qakit/landings-reporting into lp:qakit
- landings-reporting
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Sergio Cazzolato |
Approved revision: | 50 |
Merged at revision: | 20 |
Proposed branch: | lp:~canonical-platform-qa/qakit/landings-reporting |
Merge into: | lp:qakit |
Diff against target: |
1119 lines (+1053/-4) 5 files modified
qakit/metrics/landings.py (+391/-0) qakit/metrics/tests/test_landings.py (+342/-0) qakit/metrics/trello.py (+209/-0) qakit/metrics/util.py (+81/-0) qakit/trello.py (+30/-4) |
To merge this branch: | bzr merge lp:~canonical-platform-qa/qakit/landings-reporting |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Sergio Cazzolato | Approve | ||
Christopher Lee (community) | Approve | ||
Review via email: mp+273633@code.launchpad.net |
Commit message
Data gathering from Trello for our silo board.
Description of the change
Data gathering from Trello for our silo board.
Gather cards and actions, then compute silos with timings.
NOTE that the reporting half will follow, also note that you'll need pymongo to test.
Allan LeSage (allanlesage) wrote : | # |
- 21. By Allan LeSage
-
Remove an outdated script.
Christopher Lee (veebers) wrote : | # |
Hi Allan,
I had an initial look over the code making some comments. I'll come back and do a more indepth review once the questions posed have been answered and when I have my head fully around what the code is doing.
Allan LeSage (allanlesage) wrote : | # |
Thanks for the detailed review Chris! Replied inline.
- 22. By Allan LeSage
-
Specify what's being created or moved in function name.
- 23. By Allan LeSage
-
Result instead of passed_or_failed.
- 24. By Allan LeSage
-
Explanatory comment for 'actions'.
- 25. By Allan LeSage
-
Explanatory comment for TypeError on None result action.
- 26. By Allan LeSage
-
Collapse duration exceptions, preserving comments.
- 27. By Allan LeSage
-
Awkward language but less abstract for compute_
silo_timings. - 28. By Allan LeSage
-
Amend a comment on mongodb array upsert.
- 29. By Allan LeSage
-
Fix flake8 error on long line string.
- 30. By Allan LeSage
-
More specific exception on first run finding no actions.
- 31. By Allan LeSage
-
Add docstring for since.
- 32. By Allan LeSage
-
Standardize on line-ending parens.
- 33. By Allan LeSage
-
Docstring clarification on return for get_cards_
and_actions. - 34. By Allan LeSage
-
Remove unused function queue_to_list.
- 35. By Allan LeSage
-
Amend a get_cards_actions Trello docstring, correct a flake8 error.
- 36. By Allan LeSage
-
Add Trello API reference.
Christopher Lee (veebers) wrote : | # |
Looking good, just a couple of comments inline.
Allan LeSage (allanlesage) wrote : | # |
Thanks again for the review, comments below, pushing now.
- 37. By Allan LeSage
-
Indicate 'card' as subject of relevant function names.
- 38. By Allan LeSage
-
Adjust import to avoid config collision.
- 39. By Allan LeSage
-
Move 'since' arg validation to own function.
- 40. By Allan LeSage
-
Encapsulate logging setup.
Sergio Cazzolato (sergio-j-cazzolato) wrote : | # |
Some minor comments inline
- 41. By Allan LeSage
-
Expand exception logging around mongo errors.
- 42. By Allan LeSage
-
De-Noneing sinces.
- 43. By Allan LeSage
-
Geek out on exceptions instead of Nones.
Allan LeSage (allanlesage) wrote : | # |
Shifted to raising exceptions--what do you guys think? Actually auditioning this for your opinion. IMO it's maybe better to return None a level below, would make the big assembly function more succinct. Meanwhile Sergio I think I've served your other requests, see comments inline.
Christopher Lee (veebers) wrote : | # |
Comment about the exception handling.
- 44. By Allan LeSage
-
Backed off exception-raising, preferring returning Nones as the client level, added tests.
Allan LeSage (allanlesage) wrote : | # |
Add some unit testing :) , also add logging and prefer to return Nones (to raising exceptions) at least at client-visible level.
- 45. By Allan LeSage
-
Unit tests not executable.
- 46. By Allan LeSage
-
Log for no result datetime per veebers' req.
- 47. By Allan LeSage
-
Comment clean-up.
- 48. By Allan LeSage
-
Some updates and test-fixes for oversights.
Christopher Lee (veebers) wrote : | # |
Some very minor comments, but otherwise looks good.
The testing comes very close in some places to just testing that the mock is correct but otherwise tests the functions logic.
- 49. By Allan LeSage
-
Collapse TRELLO config refs per veebers' suggestion.
- 50. By Allan LeSage
-
Remove parse_date_str ftn.
Allan LeSage (allanlesage) wrote : | # |
Thanks Chris, made your suggested changes--I'd like to use *sections* for the config stuff which will make all clearer, but because this affects brendand I'd like to defer for a future MP.
Christopher Lee (veebers) wrote : | # |
Makes sense. LGTM
Sergio Cazzolato (sergio-j-cazzolato) wrote : | # |
Looks good.
Preview Diff
1 | === added directory 'qakit/metrics' |
2 | === added file 'qakit/metrics/landings.py' |
3 | --- qakit/metrics/landings.py 1970-01-01 00:00:00 +0000 |
4 | +++ qakit/metrics/landings.py 2015-10-14 17:47:11 +0000 |
5 | @@ -0,0 +1,391 @@ |
6 | +#!/usr/bin/python3 |
7 | +# UESQA Metrics |
8 | +# Copyright (C) 2015 Canonical |
9 | +# |
10 | +# This program is free software: you can redistribute it and/or modify |
11 | +# it under the terms of the GNU General Public License as published by |
12 | +# the Free Software Foundation, either version 3 of the License, or |
13 | +# (at your option) any later version. |
14 | +# |
15 | +# This program is distributed in the hope that it will be useful, |
16 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | +# GNU General Public License for more details. |
19 | +# |
20 | +# You should have received a copy of the GNU General Public License |
21 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
22 | + |
23 | +import argparse |
24 | +import configparser |
25 | +import datetime |
26 | +import dateutil.parser |
27 | +import logging |
28 | +import sys |
29 | + |
30 | +import pymongo |
31 | + |
32 | +import qakit.config as qakit_config |
33 | +import qakit.metrics.trello as trello |
34 | +import qakit.trello as trelloapi |
35 | +import qakit.metrics.util as util |
36 | + |
37 | +logger = logging.getLogger(__name__) |
38 | + |
39 | + |
40 | +def upsert_silo(trello_db, silo): |
41 | + """Insert a silo into our db. |
42 | + |
43 | + :param trello_db: pymongo.MongoClient db for silos |
44 | + :param silo: computed silo |
45 | + |
46 | + """ |
47 | + try: |
48 | + return trello_db.silos.update( |
49 | + {'id': silo['id']}, |
50 | + silo, |
51 | + upsert=True) |
52 | + except pymongo.errors.PyMongoError as e: |
53 | + logger.error(e) |
54 | + |
55 | + |
56 | +def get_card_created_action(trello_db, card_id): |
57 | + """Return the 'action' by which the given card was created. |
58 | + |
59 | + 'Actions' come from the Trello API where they're represented in |
60 | + JSON; we store them in our db unmodified. Actions have types, |
61 | + users, data, etc. to describe all the actions possible by a user |
62 | + in the Trello UI. Here we return the card's action of type |
63 | + 'createCard'--worth noting that Trello only returns modifications |
64 | + and comments by default. |
65 | + |
66 | + :param trello_db: pymongo.MongoClient db for Trello items |
67 | + :param card_id: Trello card id string |
68 | + :raises ValueError: if card creation action not found--this can |
69 | + happen if our database begins after this card was created, e.g. |
70 | + |
71 | + """ |
72 | + created_action = trello_db.actions.find_one( |
73 | + {'data.card.id': card_id, |
74 | + 'type': 'createCard'}) |
75 | + if not created_action: |
76 | + # this could happen if our db has an incomplete set of |
77 | + # actions, e.g. starting after this card was created |
78 | + raise ValueError( |
79 | + "Create action for card {} not found.".format(card_id)) |
80 | + return created_action |
81 | + |
82 | + |
83 | +def get_card_created_datetime(trello_db, card_id): |
84 | + """Return the datetime of a card's 'createCard' action. |
85 | + |
86 | + :param trello_db: pymongo.MongoClient db for Trello items |
87 | + :param card_id: Trello card id string |
88 | + |
89 | + """ |
90 | + try: |
91 | + created_action = get_card_created_action(trello_db, card_id) |
92 | + except ValueError: |
93 | + # this could happen if our db has an incomplete set of |
94 | + # actions, e.g. starting after this card was created |
95 | + logger.info( |
96 | + "Create action for card {} not found.".format(card_id)) |
97 | + return None |
98 | + return dateutil.parser.parse(created_action['date']) |
99 | + |
100 | + |
101 | +def get_card_result_action(trello_db, card_id): |
102 | + """Return the 'action' of moving to 'Passed' or 'Failed' column. |
103 | + |
104 | + 'Actions' come from the Trello API where they're represented in |
105 | + JSON; we store them in our db unmodified. Actions have types, |
106 | + users, data, etc. to describe all the actions possible by a user |
107 | + in the Trello UI. We're most interested in the card-moving |
108 | + actions which result in a card being moved from one list ("under |
109 | + testing") to another ("Passed" or "Failed"). |
110 | + |
111 | + Silo cards should have only one of these results but we can't |
112 | + prevent QEs from making a mistake by moving the card from one lane |
113 | + to another, e.g., so just use the last reported result. |
114 | + |
115 | + :param trello_db: pymongo.MongoClient db for Trello items |
116 | + :param card_id: Trello card id string |
117 | + :raises ValueError: if card result not found, e.g. still under |
118 | + testing |
119 | + |
120 | + """ |
121 | + result_action = trello_db.actions.find_one( |
122 | + {'data.card.id': card_id, |
123 | + 'data.listAfter.name': {'$in': ['Passed', 'Failed']}}, |
124 | + sort=[('date', pymongo.DESCENDING)]) |
125 | + if not result_action: |
126 | + raise ValueError( |
127 | + "Result action for card {} not found".format(card_id)) |
128 | + return result_action |
129 | + |
130 | + |
131 | +def get_card_under_testing_action(trello_db, card_id): |
132 | + """Return the 'action' of moving to "Under Testing" column. |
133 | + |
134 | + 'Actions' come from the Trello API where they're represented in |
135 | + JSON; we store them in our db unmodified. Actions have types, |
136 | + users, data, etc. to describe all the actions possible by a user |
137 | + in the Trello UI. We're most interested in the card-moving |
138 | + actions which result in a card being moved from one list ("Ready |
139 | + for Testing") to another ("Under Testing"). |
140 | + |
141 | + Silo cards should have only one of these results but we can't |
142 | + prevent QEs from making a mistake by moving the card from one lane |
143 | + to another, e.g., so just use the last reported result. |
144 | + |
145 | + :param trello_db: pymongo.MongoClient db for Trello items |
146 | + :param card_id: Trello card id string |
147 | + :raises ValueError: if card 'Under Testing' action not found, e.g. |
148 | + not yet under testing |
149 | + |
150 | + """ |
151 | + under_testing_action = trello_db.actions.find_one( |
152 | + {'data.card.id': card_id, |
153 | + 'data.listAfter.name': "Under Testing"}, |
154 | + sort=[('date', pymongo.DESCENDING)]) |
155 | + if not under_testing_action: |
156 | + raise ValueError( |
157 | + "'Under Testing' action for card {} not found".format(card_id)) |
158 | + return under_testing_action |
159 | + |
160 | + |
161 | +def get_card_under_testing_datetime(trello_db, card_id): |
162 | + """Return the datetime that the given card came under testing. |
163 | + |
164 | + :param trello_db: pymongo.MongoClient db for Trello items |
165 | + :param card_id: Trello card id string |
166 | + |
167 | + """ |
168 | + try: |
169 | + action = get_card_under_testing_action(trello_db, card_id) |
170 | + except ValueError: |
171 | + # no result yet |
172 | + logger.debug( |
173 | + "'Under Testing' action for card {} not found.".format(card_id)) |
174 | + return None |
175 | + return dateutil.parser.parse(action['date']) |
176 | + |
177 | + |
178 | +def get_card_result(trello_db, card_id): |
179 | + """Return passed or failed status string for a silo card. |
180 | + |
181 | + :param trello_db: pymongo.MongoClient db for Trello items |
182 | + :param card_id: Trello card id string |
183 | + |
184 | + """ |
185 | + try: |
186 | + action = get_card_result_action(trello_db, card_id) |
187 | + except ValueError: |
188 | + # no result yet |
189 | + logger.debug( |
190 | + "No result found for card {}.".format(card_id)) |
191 | + return None |
192 | + return action['data']['listAfter']['name'] |
193 | + |
194 | + |
195 | +def get_card_result_datetime(trello_db, card_id): |
196 | + """Return the datetime that the given card passed or failed. |
197 | + |
198 | + :param trello_db: pymongo.MongoClient db for Trello items |
199 | + :param card_id: Trello card id string |
200 | + |
201 | + """ |
202 | + try: |
203 | + action = get_card_result_action(trello_db, card_id) |
204 | + except ValueError: |
205 | + # no result yet |
206 | + logger.debug( |
207 | + "No result found for card {}.".format(card_id)) |
208 | + return None |
209 | + return dateutil.parser.parse(action['date']) |
210 | + |
211 | + |
212 | +def get_card_week_number(trello_db, card_id): |
213 | + """Return the week number for the card's result. |
214 | + |
215 | + Cards belong to week for which a passed or failed result was |
216 | + reported. |
217 | + |
218 | + :param trello_db: pymongo.MongoClient db for Trello items |
219 | + :param card_id: Trello card id string |
220 | + |
221 | + """ |
222 | + result_datetime = get_card_result_datetime(trello_db, card_id) |
223 | + if not result_datetime: |
224 | + # no result yet |
225 | + logger.debug( |
226 | + "No result found for card {}.".format(card_id)) |
227 | + return None |
228 | + return int(result_datetime.strftime("%W")) |
229 | + |
230 | + |
231 | +def get_card_year(trello_db, card_id): |
232 | + """Return the year for the card's result. |
233 | + |
234 | + :param trello_db: pymongo.MongoClient db for Trello items |
235 | + :param card_id: Trello card id string |
236 | + |
237 | + """ |
238 | + result_datetime = get_card_result_datetime(trello_db, card_id) |
239 | + if not result_datetime: |
240 | + # no result yet |
241 | + logger.debug( |
242 | + "No result found for card {}.".format(card_id)) |
243 | + return None |
244 | + return int(result_datetime.strftime("%Y")) |
245 | + |
246 | + |
247 | +def get_card_testing_duration(trello_db, card_id): |
248 | + """Return a silo's testing duration in seconds. |
249 | + |
250 | + :param trello_db: pymongo.MongoClient db for Trello items |
251 | + :param card_id: Trello card id string |
252 | + |
253 | + """ |
254 | + under_testing_datetime = get_card_under_testing_datetime( |
255 | + trello_db, card_id) |
256 | + result_datetime = get_card_result_datetime(trello_db, card_id) |
257 | + if not under_testing_datetime or not result_datetime: |
258 | + logger.debug("Card {} has no testing duration.".format(card_id)) |
259 | + return None |
260 | + duration = result_datetime - under_testing_datetime |
261 | + return duration.total_seconds() |
262 | + |
263 | + |
264 | +def get_card_total_duration(trello_db, card_id): |
265 | + """Get a silo's time from creation to passed or failed in seconds. |
266 | + |
267 | + :param trello_db: pymongo.MongoClient db for Trello items |
268 | + :param card_id: Trello card id string |
269 | + |
270 | + """ |
271 | + created_datetime = get_card_created_datetime(trello_db, card_id) |
272 | + result_datetime = get_card_result_datetime( |
273 | + trello_db, |
274 | + card_id) |
275 | + if not created_datetime or not result_datetime: |
276 | + logger.debug("Card {} has no total duration.".format(card_id)) |
277 | + return None |
278 | + total_duration = result_datetime - created_datetime |
279 | + return total_duration.total_seconds() |
280 | + |
281 | + |
282 | +def compute_silo_timing(trello_db, card): |
283 | + """Return a silo dict with timings, looking up actions in trello_db. |
284 | + |
285 | + :param trello_db: pymongo.MongoClient db for Trello items |
286 | + :param card: Trello dict of silo card |
287 | + |
288 | + """ |
289 | + logger.info("Computing silo timings for card {}.".format(card['id'])) |
290 | + silo = {'id': card['id'], |
291 | + 'name': card['name'], |
292 | + 'url': card['url']} |
293 | + silo['created_datetime'] = get_card_created_datetime(trello_db, card['id']) |
294 | + silo['result_datetime'] = get_card_result_datetime(trello_db, card['id']) |
295 | + silo['week_number'] = get_card_week_number(trello_db, card['id']) |
296 | + silo['year'] = get_card_year(trello_db, card['id']) |
297 | + silo['total_duration'] = get_card_total_duration( |
298 | + trello_db, card['id']) |
299 | + silo['testing_duration'] = get_card_testing_duration( |
300 | + trello_db, card['id']) |
301 | + silo['result'] = get_card_result(trello_db, card['id']) |
302 | + return silo |
303 | + |
304 | + |
305 | +def compute_silos_timing(trello_db, cards=None): |
306 | + """Compute silo timings for the given cards and store in a db. |
307 | + |
308 | + :param trello_db: pymongo.MongoClient db in which to store silos |
309 | + :param cards: list of card dicts as returned by Trello |
310 | + |
311 | + """ |
312 | + if cards is None: |
313 | + cards = trello_db.cards.find() |
314 | + for card in cards: |
315 | + silo = compute_silo_timing(trello_db, card) |
316 | + upsert_silo(trello_db, silo) |
317 | + |
318 | + |
319 | +def ensure_unique_indexes(trello_db): |
320 | + """Ensure that our card, action, and silo indexes exist. |
321 | + |
322 | + We're being paranoid--this should've been taken care of in setup |
323 | + and we're using upserts everywhere--but we don't want to insert |
324 | + duplicates. NOTE: if this errors it means a duplicate exists, |
325 | + needs to be fixed in the db. |
326 | + |
327 | + :param trello_db: pymongo.MongoClient db for Trello items |
328 | + |
329 | + """ |
330 | + trello_db.cards.create_index('id', unique=True) |
331 | + trello_db.actions.create_index('id', unique=True) |
332 | + trello_db.silos.create_index('id', unique=True) |
333 | + |
334 | + |
335 | +def _read_config(config_filepath): |
336 | + """Parse the config at the given filepath, returning a config dict.""" |
337 | + config_file = configparser.ConfigParser() |
338 | + config_file.read(config_filepath) |
339 | + config = {} |
340 | + for var_name in ('TRELLO_APP_KEY', 'TRELLO_TOKEN', 'TRELLO_BOARD_ID'): |
341 | + new_var_name = var_name.lower().replace('trello_', '') |
342 | + config[new_var_name] = config_file['DEFAULT'][var_name] |
343 | + return config |
344 | + |
345 | + |
346 | +def _parse_arguments(): |
347 | + parser = argparse.ArgumentParser( |
348 | + "Retrieve silos from Ops Trello board, storing in a Mongo db.") |
349 | + parser.add_argument( |
350 | + '--since', |
351 | + required=False, |
352 | + help=("retrieve actions and cards modified since the given date; " |
353 | + "defaults to last known action in database."), |
354 | + default=None, |
355 | + type=str) |
356 | + return parser.parse_args() |
357 | + |
358 | + |
359 | +def _validate_since_arg(args): |
360 | + """Validate format of 'since' arg or set to 2014 as necessary. |
361 | + |
362 | + :param args: argparse namespace |
363 | + |
364 | + """ |
365 | + if not args.since: |
366 | + args.since = datetime.datetime(2014, 1, 1) |
367 | + else: |
368 | + try: |
369 | + args.since = datetime.datetime.strptime(args.since, "%Y-%m-%d") |
370 | + except ValueError: |
371 | + raise argparse.ArgumentError( |
372 | + None, |
373 | + "'since' must be in YYYY-MM-DD format") |
374 | + |
375 | + |
376 | +def main(): |
377 | + util.setup_logging() |
378 | + config_dict = _read_config(qakit_config.get_config_file_location()) |
379 | + args = _parse_arguments() |
380 | + _validate_since_arg(args) |
381 | + conn = pymongo.MongoClient() |
382 | + trello_db = conn.trello |
383 | + ensure_unique_indexes(trello_db) |
384 | + trello_session = trelloapi.TrelloApi( |
385 | + app_key=config_dict['app_key'], |
386 | + token=config_dict['token']) |
387 | + cards = trello.retrieve_cards_and_actions( |
388 | + trello_db, |
389 | + trello_session, |
390 | + config_dict['board_id'], |
391 | + since=args.since) |
392 | + compute_silos_timing(trello_db, cards) |
393 | + |
394 | + |
395 | +if __name__ == '__main__': |
396 | + sys.exit(main()) |
397 | |
398 | === added directory 'qakit/metrics/tests' |
399 | === added file 'qakit/metrics/tests/test_landings.py' |
400 | --- qakit/metrics/tests/test_landings.py 1970-01-01 00:00:00 +0000 |
401 | +++ qakit/metrics/tests/test_landings.py 2015-10-14 17:47:11 +0000 |
402 | @@ -0,0 +1,342 @@ |
403 | +# UESQA Metrics |
404 | +# Copyright (C) 2015 Canonical |
405 | +# |
406 | +# This program is free software: you can redistribute it and/or modify |
407 | +# it under the terms of the GNU General Public License as published by |
408 | +# the Free Software Foundation, either version 3 of the License, or |
409 | +# (at your option) any later version. |
410 | +# |
411 | +# This program is distributed in the hope that it will be useful, |
412 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
413 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
414 | +# GNU General Public License for more details. |
415 | +# |
416 | +# You should have received a copy of the GNU General Public License |
417 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
418 | + |
419 | +import datetime |
420 | +import unittest |
421 | +from unittest import mock |
422 | + |
423 | +import dateutil |
424 | +from bson import ObjectId |
425 | + |
426 | +import qakit.metrics.landings as landings |
427 | + |
428 | + |
429 | +CREATED = { |
430 | + "_id": ObjectId("559d3b9a6f9289b4a62909fd"), |
431 | + "id": "559c033efbfb3ecbc9f725b6", |
432 | + "type": "createCard", |
433 | + "memberCreator": { |
434 | + "id": "53bfc3936a7d7c888e46f4cd", |
435 | + "fullName": "Jean-Baptiste Lallement", |
436 | + "username": "jeanbaptistelallement", |
437 | + "initials": "JBL", |
438 | + "avatarHash": None |
439 | + }, |
440 | + "date": "2015-07-07T16:50:06.409Z", |
441 | + "data": { |
442 | + "board": { |
443 | + "id": "53fc6641728df958a48bfbe1", |
444 | + "shortLink": "AE3swczu", |
445 | + "name": ("QA Testing Requests - for questions ping EU: " |
446 | + "jibel, US: jfunk - or ubuntu-qa on #ubuntu-ci-eng") |
447 | + }, |
448 | + "card": { |
449 | + "name": "ubuntu/landing-026 - messaging-app : boiko", |
450 | + "id": "559c033efbfb3ecbc9f725b5", |
451 | + "shortLink": "7cOV5cYP", |
452 | + "idShort": 1865 |
453 | + }, |
454 | + "list": { |
455 | + "id": "53fc6641728df958a48bfbe2", |
456 | + "name": "Need QA Sign-off" |
457 | + } |
458 | + }, |
459 | + "idMemberCreator": "53bfc3936a7d7c888e46f4cd" |
460 | +} |
461 | + |
462 | +UNDER_TESTING = { |
463 | + "_id": ObjectId("559d3b9a6f9289b4a62909f4"), |
464 | + "id": "559c50bfb233bddda9939772", |
465 | + "type": "updateCard", |
466 | + "memberCreator": { |
467 | + "id": "510c44e279f3442c56000417", |
468 | + "fullName": "Allan LeSage", |
469 | + "username": "allanlesage", |
470 | + "initials": "AL", |
471 | + "avatarHash": "5c61e74f355449f6c7cfc35880e6a49f" |
472 | + }, |
473 | + "date": "2015-07-07T22:20:47.469Z", |
474 | + "data": { |
475 | + "board": { |
476 | + "id": "53fc6641728df958a48bfbe1", |
477 | + "shortLink": "AE3swczu", |
478 | + "name": ("QA Testing Requests - for questions ping EU: " |
479 | + "jibel, US: jfunk - or ubuntu-qa on #ubuntu-ci-eng") |
480 | + }, |
481 | + "card": { |
482 | + "name": "ubuntu/landing-026 - messaging-app : boiko", |
483 | + "id": "559c033efbfb3ecbc9f725b5", |
484 | + "shortLink": "7cOV5cYP", |
485 | + "idList": "53fc6641728df958a48bfbe3", |
486 | + "idShort": 1865 |
487 | + }, |
488 | + "listBefore": { |
489 | + "id": "552bcb7e2bc13035850abeae", |
490 | + "name": "Ready for Testing" |
491 | + }, |
492 | + "listAfter": { |
493 | + "id": "53fc6641728df958a48bfbe3", |
494 | + "name": "Under Testing" |
495 | + }, |
496 | + "old": { |
497 | + "idList": "552bcb7e2bc13035850abeae" |
498 | + } |
499 | + }, |
500 | + "idMemberCreator": "510c44e279f3442c56000417" |
501 | +} |
502 | + |
503 | +PASSED = { |
504 | + "_id": ObjectId("559d3b9a6f9289b4a62909ed"), |
505 | + "id": "559d24feed5efa44c31650c0", |
506 | + "type": "updateCard", |
507 | + "memberCreator": { |
508 | + "id": "53bfc49275645e39d37a1046", |
509 | + "fullName": "VÃctor R. Ruiz", |
510 | + "username": "victorrruiz", |
511 | + "initials": "VR", |
512 | + "avatarHash": "c15c5a5db02d89903f2233d88ad778d5" |
513 | + }, |
514 | + "date": "2015-07-08T13:26:22.006Z", |
515 | + "data": { |
516 | + "board": { |
517 | + "id": "53fc6641728df958a48bfbe1", |
518 | + "shortLink": "AE3swczu", |
519 | + "name": ("QA Testing Requests - for questions ping EU: " |
520 | + "jibel, US: jfunk - or ubuntu-qa on #ubuntu-ci-eng") |
521 | + }, |
522 | + "card": { |
523 | + "name": "ubuntu/landing-026 - messaging-app : boiko", |
524 | + "id": "559c033efbfb3ecbc9f725b5", |
525 | + "shortLink": "7cOV5cYP", |
526 | + "idList": "53fc6641728df958a48bfbe4", |
527 | + "idShort": 1865 |
528 | + }, |
529 | + "listBefore": { |
530 | + "id": "53fc6641728df958a48bfbe3", |
531 | + "name": "Under Testing" |
532 | + }, |
533 | + "listAfter": { |
534 | + "id": "53fc6641728df958a48bfbe4", |
535 | + "name": "Passed" |
536 | + }, |
537 | + "old": { |
538 | + "idList": "53fc6641728df958a48bfbe3" |
539 | + } |
540 | + }, |
541 | + "idMemberCreator": "53bfc49275645e39d37a1046" |
542 | +} |
543 | + |
544 | + |
545 | +class GetCardCreatedActionTestCase(unittest.TestCase): |
546 | + |
547 | + def test_vanilla(self): |
548 | + db = mock.Mock( |
549 | + actions=mock.Mock( |
550 | + find_one=mock.Mock(return_value=CREATED))) |
551 | + self.assertEqual( |
552 | + CREATED, |
553 | + landings.get_card_created_action(db, 'fake-card-id')) |
554 | + |
555 | + def test_no_created_action_raises(self): |
556 | + db = mock.Mock( |
557 | + actions=mock.Mock( |
558 | + find_one=mock.Mock(return_value=None))) |
559 | + with self.assertRaises(ValueError): |
560 | + landings.get_card_created_action(db, 'fake-card-id') |
561 | + |
562 | + |
563 | +@mock.patch('qakit.metrics.landings.get_card_created_action') |
564 | +class GetCardCreatedDatetimeTestCase(unittest.TestCase): |
565 | + |
566 | + def test_vanilla(self, mock_get_card_created_action): |
567 | + mock_get_card_created_action.return_value = CREATED |
568 | + self.assertEqual( |
569 | + datetime.datetime( |
570 | + 2015, 7, 7, 16, 50, 6, 409000, tzinfo=dateutil.tz.tzutc()), |
571 | + landings.get_card_created_datetime('fake-db', 'fake-card-id')) |
572 | + |
573 | + def test_no_created_datetime_raises(self, mock_get_card_created_action): |
574 | + mock_get_card_created_action.side_effect = ValueError |
575 | + self.assertEqual( |
576 | + None, |
577 | + landings.get_card_created_datetime('fake-db', 'fake-card-id')) |
578 | + |
579 | + |
580 | +class GetCardResultActionTestCase(unittest.TestCase): |
581 | + |
582 | + def test_vanilla(self): |
583 | + db = mock.Mock( |
584 | + actions=mock.Mock( |
585 | + find_one=mock.Mock(return_value=PASSED))) |
586 | + action = landings.get_card_result_action(db, 'fake-card-id') |
587 | + self.assertEqual(PASSED, action) |
588 | + |
589 | + def test_raise_if_action_not_found(self): |
590 | + db = mock.Mock( |
591 | + actions=mock.Mock( |
592 | + find_one=mock.Mock(return_value=None))) |
593 | + with self.assertRaises(ValueError): |
594 | + landings.get_card_result_action(db, 'fake-card-id') |
595 | + |
596 | + |
597 | +@mock.patch('qakit.metrics.landings.get_card_result_action') |
598 | +class GetCardResultDatetimeTestCase(unittest.TestCase): |
599 | + |
600 | + def test_vanilla(self, mock_get_card_result_action): |
601 | + mock_get_card_result_action.return_value = PASSED |
602 | + self.assertEqual( |
603 | + datetime.datetime( |
604 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()), |
605 | + landings.get_card_result_datetime('fake-db', 'fake-card-id')) |
606 | + |
607 | + def test_no_result_datetime_if_no_result(self, mock_get_card_result_action): |
608 | + mock_get_card_result_action.side_effect = ValueError |
609 | + self.assertEqual( |
610 | + None, |
611 | + landings.get_card_result_datetime('fake-db', 'fake-card-id')) |
612 | + |
613 | + |
614 | +class GetCardUnderTestingActionTestCase(unittest.TestCase): |
615 | + |
616 | + def test_vanilla(self): |
617 | + db = mock.Mock( |
618 | + actions=mock.Mock( |
619 | + find_one=mock.Mock(return_value=UNDER_TESTING))) |
620 | + action = landings.get_card_under_testing_action(db, 'fake-card-id') |
621 | + self.assertEqual(UNDER_TESTING, action) |
622 | + |
623 | + def test_raise_if_action_not_found(self): |
624 | + db = mock.Mock( |
625 | + actions=mock.Mock( |
626 | + find_one=mock.Mock(return_value=None))) |
627 | + with self.assertRaises(ValueError): |
628 | + landings.get_card_under_testing_action(db, 'fake-card-id') |
629 | + |
630 | + |
631 | +@mock.patch('qakit.metrics.landings.get_card_under_testing_action') |
632 | +class GetCardUnderTestingDatetimeTestCase(unittest.TestCase): |
633 | + |
634 | + def test_vanilla(self, mock_get_card_under_testing_action): |
635 | + mock_get_card_under_testing_action.return_value = PASSED |
636 | + self.assertEqual( |
637 | + datetime.datetime( |
638 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()), |
639 | + landings.get_card_under_testing_datetime('fake-db', 'fake-card-id')) |
640 | + |
641 | + def test_no_under_testing_datetime_if_never_under_testing( |
642 | + self, mock_get_card_under_testing_action): |
643 | + mock_get_card_under_testing_action.side_effect = ValueError |
644 | + self.assertEqual( |
645 | + None, |
646 | + landings.get_card_under_testing_datetime('fake-db', 'fake-card-id')) |
647 | + |
648 | + |
649 | +@mock.patch('qakit.metrics.landings.get_card_result_action') |
650 | +class GetCardResultTestCase(unittest.TestCase): |
651 | + |
652 | + def test_vanilla(self, mock_get_card_result_action): |
653 | + mock_get_card_result_action.return_value = PASSED |
654 | + self.assertEqual("Passed", landings.get_card_result( |
655 | + 'fake-db', 'fake-card-id')) |
656 | + |
657 | + def test_no_result_if_no_result(self, mock_get_card_result_action): |
658 | + mock_get_card_result_action.side_effect = ValueError |
659 | + self.assertEqual(None, landings.get_card_result( |
660 | + 'fake-db', 'fake-card-id')) |
661 | + |
662 | + |
663 | +@mock.patch('qakit.metrics.landings.get_card_result_datetime') |
664 | +class GetCardWeekNumberTestCase(unittest.TestCase): |
665 | + |
666 | + def test_vanilla(self, mock_get_card_result_datetime): |
667 | + mock_get_card_result_datetime.return_value = datetime.datetime( |
668 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
669 | + self.assertEqual( |
670 | + 27, |
671 | + landings.get_card_week_number('fake-db', 'fake-card-id')) |
672 | + |
673 | + def test_no_week_number_if_no_result(self, mock_get_card_result_datetime): |
674 | + mock_get_card_result_datetime.return_value = None |
675 | + self.assertEqual(None, landings.get_card_week_number( |
676 | + 'fake-db', 'fake-card-id')) |
677 | + |
678 | + |
679 | +@mock.patch('qakit.metrics.landings.get_card_result_datetime') |
680 | +class GetCardYearTestCase(unittest.TestCase): |
681 | + |
682 | + def test_vanilla(self, mock_get_card_result_datetime): |
683 | + mock_get_card_result_datetime.return_value = datetime.datetime( |
684 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
685 | + self.assertEqual( |
686 | + 2015, |
687 | + landings.get_card_year('fake-db', 'fake-card-id')) |
688 | + |
689 | + def test_no_year_if_no_result(self, mock_get_card_result_datetime): |
690 | + mock_get_card_result_datetime.return_value = None |
691 | + self.assertEqual(None, landings.get_card_year( |
692 | + 'fake-db', 'fake-card-id')) |
693 | + |
694 | + |
695 | +@mock.patch('qakit.metrics.landings.get_card_result_datetime') |
696 | +@mock.patch('qakit.metrics.landings.get_card_under_testing_datetime') |
697 | +class GetCardTestingDurationTestCase(unittest.TestCase): |
698 | + |
699 | + def test_vanilla( |
700 | + self, |
701 | + mock_get_card_under_testing_datetime, |
702 | + mock_get_card_result_datetime): |
703 | + mock_get_card_under_testing_datetime.return_value = datetime.datetime( |
704 | + 2015, 7, 8, 13, 25, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
705 | + mock_get_card_result_datetime.return_value = datetime.datetime( |
706 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
707 | + self.assertEqual(60.0, landings.get_card_testing_duration( |
708 | + 'fake-db', 'fake-card-id')) |
709 | + |
710 | + def test_no_under_testing_datetime( |
711 | + self, |
712 | + mock_get_card_under_testing_datetime, |
713 | + mock_get_card_result_datetime): |
714 | + mock_get_card_under_testing_datetime.return_value = datetime.datetime( |
715 | + 2015, 7, 8, 13, 25, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
716 | + mock_get_card_result_datetime.return_value = None |
717 | + self.assertEqual(None, landings.get_card_testing_duration( |
718 | + 'fake-db', 'fake-card-id')) |
719 | + |
720 | + |
721 | +@mock.patch('qakit.metrics.landings.get_card_result_datetime') |
722 | +@mock.patch('qakit.metrics.landings.get_card_created_datetime') |
723 | +class GetCardTotalDurationTestCase(unittest.TestCase): |
724 | + |
725 | + def test_vanilla( |
726 | + self, |
727 | + mock_get_card_created_datetime, |
728 | + mock_get_card_result_datetime): |
729 | + mock_get_card_created_datetime.return_value = datetime.datetime( |
730 | + 2015, 7, 8, 13, 25, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
731 | + mock_get_card_result_datetime.return_value = datetime.datetime( |
732 | + 2015, 7, 8, 13, 26, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
733 | + self.assertEqual(60.0, landings.get_card_total_duration( |
734 | + 'fake-db', 'fake-card-id')) |
735 | + |
736 | + def test_no_under_testing_datetime( |
737 | + self, |
738 | + mock_get_card_created_datetime, |
739 | + mock_get_card_result_datetime): |
740 | + mock_get_card_created_datetime.return_value = datetime.datetime( |
741 | + 2015, 7, 8, 13, 25, 22, 6000, tzinfo=dateutil.tz.tzutc()) |
742 | + mock_get_card_result_datetime.return_value = None |
743 | + self.assertEqual(None, landings.get_card_total_duration( |
744 | + 'fake-db', 'fake-card-id')) |
745 | |
746 | === added file 'qakit/metrics/trello.py' |
747 | --- qakit/metrics/trello.py 1970-01-01 00:00:00 +0000 |
748 | +++ qakit/metrics/trello.py 2015-10-14 17:47:11 +0000 |
749 | @@ -0,0 +1,209 @@ |
750 | +# UESQA Metrics |
751 | +# Copyright (C) 2015 Canonical |
752 | +# |
753 | +# This program is free software: you can redistribute it and/or modify |
754 | +# it under the terms of the GNU General Public License as published by |
755 | +# the Free Software Foundation, either version 3 of the License, or |
756 | +# (at your option) any later version. |
757 | +# |
758 | +# This program is distributed in the hope that it will be useful, |
759 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
760 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
761 | +# GNU General Public License for more details. |
762 | +# |
763 | +# You should have received a copy of the GNU General Public License |
764 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
765 | + |
766 | + |
767 | +import logging |
768 | + |
769 | +import pymongo |
770 | + |
771 | +logger = logging.getLogger(__name__) |
772 | + |
773 | + |
774 | +def upsert_card(trello_db, card): |
775 | + """Insert a Trello card into our db. |
776 | + |
777 | + :param trello_db: pymongo.MongoClient db for Trello items |
778 | + :param action: Trello-reported action to upsert |
779 | + |
780 | + """ |
781 | + try: |
782 | + trello_db.cards.update( |
783 | + {'id': card['id']}, |
784 | + card, |
785 | + upsert=True) |
786 | + except pymongo.errors.PyMongoError as e: |
787 | + logger.error(e) |
788 | + |
789 | + |
790 | +def upsert_action(trello_db, action): |
791 | + """Insert a Trello action into our db. |
792 | + |
793 | + :param trello_db: pymongo.MongoClient db for Trello items |
794 | + :param action: Trello-reported action to upsert |
795 | + |
796 | + """ |
797 | + try: |
798 | + return trello_db.actions.update( |
799 | + {'id': action['id']}, |
800 | + action, |
801 | + upsert=True) |
802 | + except pymongo.errors.PyMongoError as e: |
803 | + logger.error(e) |
804 | + |
805 | + |
806 | +def upsert_actions(trello_db, actions): |
807 | + """Insert a list of Trello silo actions into our trello_db. |
808 | + |
809 | + :param trello_db: pymongo.MongoClient db for Trello items |
810 | + :param actions: Trello-reported actions to upsert |
811 | + |
812 | + """ |
813 | + # TODO: does mongo make it possible to upsert the array whole? |
814 | + for action in actions: |
815 | + try: |
816 | + upsert_action(trello_db, action) |
817 | + except pymongo.errors.PyMongoError as e: |
818 | + logger.error(e) |
819 | + |
820 | + |
821 | +def get_last_action_datetime(trello_db): |
822 | + """Return the datetime of the last known action in our db. |
823 | + |
824 | + :param trello_db: pymongo.MongoClient db for Trello items |
825 | + |
826 | + """ |
827 | + last_action = trello_db.actions.find_one( |
828 | + {}, |
829 | + sort=[('date', pymongo.DESCENDING)]) |
830 | + try: |
831 | + return last_action['date'] |
832 | + except TypeError: |
833 | + # first run we have no actions, e.g. |
834 | + return None |
835 | + |
836 | + |
837 | +def get_boards_actions_count(trello_session, board_id, since=None): |
838 | + """Return number of actions available for given board_id. |
839 | + |
840 | + NOTE that 1000 is Trello's max, likely means that there are more. |
841 | + |
842 | + :param trello_session: TrelloSession to query |
843 | + :param board_id: Trello board id string |
844 | + :param since: datetime to query, defaults to None |
845 | + |
846 | + """ |
847 | + return trello_session.get_boards_action( |
848 | + board_id, |
849 | + limit=1000, |
850 | + trello_format='count', |
851 | + since=since)['_value'] |
852 | + |
853 | + |
854 | +def get_board_actions_by_card(trello_db, |
855 | + trello_session, |
856 | + board_id, |
857 | + since=None): |
858 | + """Crawl a board's cards, retrieving actions and storing in db. |
859 | + |
860 | + Trello API limits us to 1K actions when querying by board--use |
861 | + this when spooling up a db from scratch, e.g., or when auditing. |
862 | + |
863 | + :param trello_db: pymongo.MongoClient db for Trello items |
864 | + :param trello_session: TrelloSession to query |
865 | + :param board_id: Trello board id string |
866 | + :param since: datetime from which to query, defaults to None |
867 | + |
868 | + """ |
869 | + cards = trello_session.get_boards_card( |
870 | + board_id, |
871 | + filter='all', |
872 | + since=since) |
873 | + for card in cards: |
874 | + actions = trello_session.get_cards_actions( |
875 | + card['id'], |
876 | + filter=("addMemberToCard,removeMemberFromCard," |
877 | + "commentCard,updateCard,createCard"), |
878 | + since=since) |
879 | + logger.debug('Card "{}" has {} actions.'.format( |
880 | + card['name'], len(actions))) |
881 | + upsert_actions(trello_db, actions) |
882 | + |
883 | + |
884 | +def retrieve_board_cards(trello_db, trello_session, board_id, since=None): |
885 | + """Retrieve cards associated with a board and store in trello_db. |
886 | + |
887 | + :param trello_db: pymongo.MongoClient db for Trello items |
888 | + :param trello_session: TrelloSession to query |
889 | + :param board_id: Trello board id string |
890 | + :param since: datetime from which to query, defaults to None |
891 | + |
892 | + """ |
893 | + if not since: |
894 | + since = get_last_action_datetime(trello_db) |
895 | + cards = trello_session.get_boards_card( |
896 | + board_id, |
897 | + filter='all', |
898 | + since=since) |
899 | + logger.info("Board {} contains {} cards.".format(board_id, len(cards))) |
900 | + for card in cards: |
901 | + upsert_card(trello_db, card) |
902 | + return cards |
903 | + |
904 | + |
905 | +def retrieve_board_actions(trello_db, trello_session, board_id, since=None): |
906 | + """Get actions associated with a board from Trello and store in trello_db. |
907 | + |
908 | + :param trello_db: pymongo.MongoClient db for Trello items |
909 | + :param trello_session: TrelloSession to query |
910 | + :param board_id: Trello board id string |
911 | + :param since: datetime to query, defaults to None |
912 | + |
913 | + """ |
914 | + if not since: |
915 | + since = get_last_action_datetime(trello_db) |
916 | + logger.debug("Retr{}.".format(since)) |
917 | + if get_boards_actions_count( |
918 | + trello_session, |
919 | + board_id, |
920 | + since=since) == 1000: |
921 | + logger.info("More than 1000 actions found; crawling cards " |
922 | + "individually to get a complete set of actions.") |
923 | + get_board_actions_by_card(trello_db, trello_session, board_id, since) |
924 | + else: |
925 | + actions = trello_session.get_boards_action( |
926 | + board_id, |
927 | + since=since) |
928 | + logger.info("Found {} actions since {}.".format( |
929 | + len(actions), since)) |
930 | + for action in actions: |
931 | + upsert_action(trello_db, action) |
932 | + |
933 | + |
934 | +def retrieve_cards_and_actions(trello_db, |
935 | + trello_session, |
936 | + board_id, |
937 | + since=None): |
938 | + """Get a board's cards and actions, store in trello_db, and return cards. |
939 | + |
940 | + :param trello_db: pymongo.MongoClient db for Trello items |
941 | + :param trello_session: TrelloSession to query |
942 | + :param board_id: Trello board id string |
943 | + :param since: datetime to query, defaults to None; if None get all actions |
944 | + and cards since the last known action in our db |
945 | + |
946 | + """ |
947 | + if not since: |
948 | + since = get_last_action_datetime(trello_db) |
949 | + logger.debug("Last action occurred {}.".format(since)) |
950 | + logger.debug("Retrieving cards and actions for board {} since {}.".format( |
951 | + board_id, since)) |
952 | + cards = retrieve_board_cards( |
953 | + trello_db, |
954 | + trello_session, |
955 | + board_id, |
956 | + since=since) |
957 | + retrieve_board_actions(trello_db, trello_session, board_id, since=since) |
958 | + return cards |
959 | |
960 | === added file 'qakit/metrics/util.py' |
961 | --- qakit/metrics/util.py 1970-01-01 00:00:00 +0000 |
962 | +++ qakit/metrics/util.py 2015-10-14 17:47:11 +0000 |
963 | @@ -0,0 +1,81 @@ |
964 | + |
965 | +# UEQA KPIs |
966 | +# Copyright (C) 2015 Canonical |
967 | +# |
968 | +# This program is free software: you can redistribute it and/or modify |
969 | +# it under the terms of the GNU General Public License as published by |
970 | +# the Free Software Foundation, either version 3 of the License, or |
971 | +# (at your option) any later version. |
972 | +# |
973 | +# This program is distributed in the hope that it will be useful, |
974 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
975 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
976 | +# GNU General Public License for more details. |
977 | +# |
978 | +# You should have received a copy of the GNU General Public License |
979 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
980 | + |
981 | +import functools |
982 | +import sys |
983 | +import time |
984 | + |
985 | +import logging |
986 | + |
987 | + |
988 | +LOG_FILEPATH = '/var/log/qakit.log' |
989 | + |
990 | + |
991 | +def setup_logging(log_filepath=LOG_FILEPATH): |
992 | + """Set up qakit logging. |
993 | + |
994 | + :param log_filepath: filepath for system log, suggest /var/log/qakit.log |
995 | + |
996 | + """ |
997 | + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') |
998 | + file_handler = logging.FileHandler(log_filepath) |
999 | + file_handler.setLevel(logging.DEBUG) |
1000 | + file_handler.setFormatter(formatter) |
1001 | + stdout_handler = logging.StreamHandler(sys.stdout) |
1002 | + stdout_handler.setLevel(logging.DEBUG) |
1003 | + stdout_handler.setFormatter(formatter) |
1004 | + logger = logging.getLogger() |
1005 | + logger.setLevel(logging.DEBUG) |
1006 | + logger.addHandler(stdout_handler) |
1007 | + logger.addHandler(file_handler) |
1008 | + |
1009 | + |
1010 | +def retry(ExceptionToCheck, tries=4, delay=2, backoff=2, logger=None): |
1011 | + """Retry calling the decorated function using an exponential backoff. |
1012 | + |
1013 | + http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/ |
1014 | + Original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry |
1015 | + |
1016 | + :param ExceptionToCheck: the exception to check. may be a tuple of |
1017 | + exceptions to check |
1018 | + :param tries: number of times to try (not retry) before giving up |
1019 | + :param delay: initial delay between retries in seconds |
1020 | + :param backoff: backoff multiplier e.g. value of 2 will double the delay |
1021 | + each retry |
1022 | + :param logger: logger.Logger to use. |
1023 | + |
1024 | + """ |
1025 | + def deco_retry(f): |
1026 | + @functools.wraps(f) |
1027 | + def f_retry(*args, **kwargs): |
1028 | + for i in range(tries): |
1029 | + try: |
1030 | + return f(*args, **kwargs) |
1031 | + except ExceptionToCheck as e: |
1032 | + d = delay * backoff ** i |
1033 | + if logger: |
1034 | + logger.debug( |
1035 | + "%s, retrying in %d seconds. . . ." % (e, d)) |
1036 | + time.sleep(d) |
1037 | + return f(*args, **kwargs) |
1038 | + return f_retry # true decorator |
1039 | + return deco_retry |
1040 | + |
1041 | + |
1042 | +def get_week_number(datetime_): |
1043 | + """Return the week of the year from a datetime.""" |
1044 | + return int(datetime_.strftime('%W')) |
1045 | |
1046 | === modified file 'qakit/trello.py' |
1047 | --- qakit/trello.py 2015-06-24 15:47:30 +0000 |
1048 | +++ qakit/trello.py 2015-10-14 17:47:11 +0000 |
1049 | @@ -21,7 +21,6 @@ |
1050 | |
1051 | from __future__ import absolute_import |
1052 | |
1053 | -import json |
1054 | import requests |
1055 | |
1056 | |
1057 | @@ -57,7 +56,8 @@ |
1058 | checkItemStates=None, |
1059 | checklists=None, |
1060 | filter=None, |
1061 | - fields=None): |
1062 | + fields=None, |
1063 | + since=None): |
1064 | resp = requests.get( |
1065 | "https://trello.com/1/boards/{}/cards".format(board_id), |
1066 | params=dict(key=self._app_key, |
1067 | @@ -68,7 +68,8 @@ |
1068 | checkItemStates=checkItemStates, |
1069 | checklists=checklists, |
1070 | filter=filter, |
1071 | - fields=fields), |
1072 | + fields=fields, |
1073 | + since=since), |
1074 | data=None) |
1075 | resp.raise_for_status() |
1076 | return resp.json() |
1077 | @@ -77,7 +78,9 @@ |
1078 | board_id, |
1079 | filter=None, |
1080 | fields=None, |
1081 | - limit=None, |
1082 | + limit=1000, |
1083 | + trello_format=None, |
1084 | + since=None, |
1085 | page=None, |
1086 | idModels=None): |
1087 | resp = requests.get( |
1088 | @@ -87,8 +90,31 @@ |
1089 | filter=filter, |
1090 | fields=fields, |
1091 | limit=limit, |
1092 | + format=trello_format, |
1093 | + since=since, |
1094 | page=page, |
1095 | idModels=idModels), |
1096 | data=None) |
1097 | resp.raise_for_status() |
1098 | return resp.json() |
1099 | + |
1100 | + def get_cards_actions(self, card_id, filter=None, since=None): |
1101 | + """Get actions associated with a card from Trello. |
1102 | + |
1103 | + See https://developers.trello.com/advanced-reference/card#get-1-cards-card-id-or-shortlink-actions # NOQA |
1104 | + |
1105 | + :param card_id: Trello card ID string |
1106 | + :param filter: list of action type strings to retrieve, defaults (in |
1107 | + Trello) to commentCard, updateCard |
1108 | + :param since: datetime from which to query |
1109 | + |
1110 | + """ |
1111 | + resp = requests.get( |
1112 | + "https://api.trello.com/1/cards/{}/actions".format(card_id), |
1113 | + params=dict( |
1114 | + key=self._app_key, |
1115 | + token=self._token, |
1116 | + filter=filter), |
1117 | + data=None) |
1118 | + resp.raise_for_status() |
1119 | + return resp.json() |
O one more thing: we're going to do away with the "week" data item for silos, as it's proving unreliable under international pressures--instead we'll do this on the reporting side, providing queries for arbitrary start-and-end times. (In a subsequent MP.)