Merge lp:~canonical-platform-qa/qakit/landings-reporting into lp:qakit

Proposed by Allan LeSage
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
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.

To post a comment you must log in.
Revision history for this message
Allan LeSage (allanlesage) wrote :

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.)

21. By Allan LeSage

Remove an outdated script.

Revision history for this message
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.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
Christopher Lee (veebers) wrote :

Looking good, just a couple of comments inline.

review: Needs Fixing
Revision history for this message
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.

Revision history for this message
Sergio Cazzolato (sergio-j-cazzolato) wrote :

Some minor comments inline

review: Needs Fixing
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.

Revision history for this message
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.

Revision history for this message
Christopher Lee (veebers) wrote :

Comment about the exception handling.

review: Needs Fixing
44. By Allan LeSage

Backed off exception-raising, preferring returning Nones as the client level, added tests.

Revision history for this message
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.

Revision history for this message
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.

review: Needs Fixing
49. By Allan LeSage

Collapse TRELLO config refs per veebers' suggestion.

50. By Allan LeSage

Remove parse_date_str ftn.

Revision history for this message
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.

Revision history for this message
Christopher Lee (veebers) wrote :

Makes sense. LGTM

review: Approve
Revision history for this message
Sergio Cazzolato (sergio-j-cazzolato) wrote :

Looks good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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()

Subscribers

People subscribed via source and target branches

to all changes: