Coverage for CIResults/metrics.py: 70%

786 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-23 13:11 +0000

1from django.db.models import Sum, Max, Min, Avg 

2from django.utils import timezone 

3from django.utils.functional import cached_property 

4 

5from .models import Issue, BugComment, BugTracker, KnownFailure, Machine 

6from .models import IssueFilterAssociated, TestsuiteRun, UnknownFailure 

7from .models import TestResult, RunConfig, TextStatus, Test 

8 

9from collections import namedtuple, OrderedDict, defaultdict 

10from dateutil.relativedelta import relativedelta, MO 

11 

12from datetime import timedelta 

13 

14from copy import deepcopy 

15 

16import statistics 

17import dateutil 

18import hashlib 

19import copy 

20import json 

21import csv 

22import io 

23 

24 

25class Period: 

26 def __init__(self, start, end, label_format='%Y-%m-%d %H:%M:%S'): 

27 self.start = start 

28 self.end = end 

29 

30 self.start_label = start.strftime(label_format) 

31 self.end_label = end.strftime(label_format) 

32 

33 def __repr__(self): 

34 return "{}->{}".format(self.start_label, self.end_label) 

35 

36 def __str__(self): 

37 return repr(self) 

38 

39 def __eq__(self, other): 

40 return self.start == other.start and self.end == other.end 

41 

42 

43class Periodizer: 

44 @classmethod 

45 def from_json(cls, json_string): 

46 params = json.loads(json_string) 

47 

48 count = int(params.get('count', 30)) 

49 

50 end_date_str = params.get('end') 

51 if end_date_str is not None: 

52 end_date = timezone.make_aware(dateutil.parser.parse(end_date_str)) 

53 else: 

54 end_date = timezone.now() 

55 

56 period_str = params.get('period', 'week') 

57 if period_str == 'month': 

58 period_offset = relativedelta(months=1, day=1, hour=0, minute=0, 

59 second=0, microsecond=0) 

60 period = relativedelta(months=1) 

61 real_end = end_date + period_offset 

62 description = 'last {} months'.format(count, ) 

63 if end_date_str is not None: 

64 description += " before {}".format(real_end.strftime("%B %Y")) 

65 label_format = "%b %Y" 

66 elif period_str == 'day': 

67 period_offset = relativedelta(days=1, hour=0, minute=0, 

68 second=0, microsecond=0) 

69 period = relativedelta(days=1) 

70 real_end = end_date + period_offset 

71 

72 description = 'last {} days'.format(count) 

73 if end_date_str is not None: 

74 description += " before {}".format(real_end.date().isoformat()) 

75 label_format = "%Y-%m-%d" 

76 else: # fall-through: default to week 

77 period_offset = relativedelta(days=1, hour=0, minute=0, 

78 second=0, microsecond=0, 

79 weekday=MO(1)) 

80 period = relativedelta(weeks=1) 

81 real_end = end_date + period_offset 

82 description = 'last {} weeks'.format(count) 

83 if end_date_str is not None: 

84 description += " before {} / {}".format(real_end.date().isoformat(), 

85 real_end.strftime("WW-%Y.%W")) 

86 label_format = "WW-%Y-%W" 

87 

88 return cls(period_offset=period_offset, period=period, period_count=count, 

89 end_date=end_date, description=description, label_format=label_format) 

90 

91 def __init__(self, period_offset=relativedelta(days=1, hour=0, minute=0, 

92 second=0, microsecond=0, 

93 weekday=MO(1)), 

94 period=relativedelta(weeks=1), period_count=30, 

95 end_date=timezone.now(), 

96 description="last 30 weeks", 

97 label_format="WW-%Y-%W"): 

98 self.period_offset = period_offset 

99 self.period = period 

100 self.period_count = period_count 

101 self.end_date = end_date 

102 self.description = description 

103 self.label_format = label_format 

104 

105 self.end_cur_period = end_date + period_offset 

106 

107 def __iter__(self): 

108 # Reset the current position 

109 self.cur_period = self.period_count 

110 return self 

111 

112 def __next__(self): 

113 if self.cur_period == 0: 

114 raise StopIteration 

115 

116 self.cur_period -= 1 

117 cur_time = self.end_cur_period - self.cur_period * self.period 

118 return Period(cur_time - self.period, cur_time, self.label_format) 

119 

120 

121PeriodOpenItem = namedtuple("PeriodOpenItem", ('period', 'label', 'active', 'new', 'closed')) 

122 

123 

124PeriodCommentItem = namedtuple("PeriodCommentItem", ('period', 'label', 'dev_comments', 'user_comments', 'accounts')) 

125 

126 

127class ItemCountTrend: 

128 def __init__(self, items, fields=[], periodizer=None): 

129 self.items = items 

130 self.periodizer = periodizer 

131 

132 self.fields = defaultdict(list) 

133 for i in items: 

134 for field in fields: 

135 values = getattr(i, field) 

136 if not isinstance(values, str): 

137 values = len(values) 

138 self.fields[field].append(values) 

139 

140 @property 

141 def stats(self): 

142 r = {} 

143 if self.periodizer is not None: 

144 r["period_desc"] = self.periodizer.description 

145 for field, values in self.fields.items(): 

146 r[field] = values 

147 return r 

148 

149 

150class OpenCloseCountTrend(ItemCountTrend): 

151 def __init__(self, *args, **kwargs): 

152 super().__init__(*args, fields=['label', 'active', 'new', 'closed'], **kwargs) 

153 

154 

155class BugCommentCountTrend(ItemCountTrend): 

156 def __init__(self, *args, **kwargs): 

157 super().__init__(*args, fields=['label', 'dev_comments', 'user_comments'], **kwargs) 

158 

159 

160def bugs_followed_since(): 

161 bt = BugTracker.objects.exclude(components_followed_since=None).order_by('-components_followed_since').first() 

162 if bt is not None: 

163 return bt.components_followed_since 

164 else: 

165 return None 

166 

167 

168week_period = Periodizer.from_json('{"period": "week", "count":30}') 

169 

170 

171# TODO: Add tests for all these functions 

172 

173def metrics_issues_over_time(user_query, periodizer=None): 

174 if periodizer is None: 

175 periodizer = copy.copy(week_period) 

176 

177 # Get a list of periods, from oldest to newest 

178 issues = [] 

179 for period in periodizer: 

180 issues.append(PeriodOpenItem(period, period.start_label, [], [], [])) 

181 

182 if len(issues) == 0: 

183 return OpenCloseCountTrend(issues, periodizer=periodizer) 

184 

185 query = user_query.objects.exclude(archived_on__lt=issues[0].period.start) 

186 filtered_issues = query.exclude(added_on__gt=issues[-1].period.end) 

187 

188 for i in filtered_issues: 

189 for ip in issues: 

190 if i.added_on >= ip.period.start and i.added_on < ip.period.end: 

191 ip.new.append(i) 

192 elif i.archived_on is not None and i.archived_on >= ip.period.start and i.archived_on < ip.period.end: 

193 ip.closed.append(i) 

194 elif i.added_on < ip.period.start and (i.archived_on is None or i.archived_on >= ip.period.end): 

195 ip.active.append(i) 

196 

197 return OpenCloseCountTrend(issues, periodizer=periodizer) 

198 

199 

200def metrics_bugs_over_time(user_query, periodizer=None): 

201 if periodizer is None: 

202 periodizer = copy.copy(week_period) 

203 

204 # Find out what is the earliest components_followed_since and make sure we don't go that far 

205 earliest_followed_since = bugs_followed_since() 

206 if earliest_followed_since is None: 

207 return (OpenCloseCountTrend([], periodizer=periodizer), BugCommentCountTrend([], periodizer=periodizer)) 

208 

209 # Get a list of periods, from oldest to newest 

210 bug_periods = [] 

211 comment_periods = [] 

212 for period in periodizer: 

213 # Ignore all the periods not fully covered 

214 if period.start < earliest_followed_since: 

215 continue 

216 bug_periods.append(PeriodOpenItem(period, period.start_label, [], [], [])) 

217 comment_periods.append(PeriodCommentItem(period, period.start_label, [], [], None)) 

218 

219 followed_bug_ids = set() 

220 filtered_bugs = user_query.objects.exclude(closed__lt=earliest_followed_since) 

221 for bug in filtered_bugs.prefetch_related("tracker"): 

222 # ignore bugs that we are not following 

223 if bug.tracker.tracker.has_components and bug.component not in bug.tracker.components_followed_list: 

224 continue 

225 

226 # ignore bugs that do not have a created / closed value yet 

227 if not bug.created or not bug.closed: 

228 continue 

229 

230 for bp in bug_periods: 

231 if bug.created >= bp.period.start and bug.created < bp.period.end: 

232 bp.new.append(bug) 

233 elif bug.closed is not None and bug.closed >= bp.period.start and bug.closed < bp.period.end: 

234 bp.closed.append(bug) 

235 elif bug.created < bp.period.start and (bug.closed is None or bug.closed >= bp.period.end): 

236 bp.active.append(bug) 

237 

238 # Keep track of all the bugs we used for the open/close count 

239 followed_bug_ids.add(bug.id) 

240 

241 # Fetch all comments made on the followed bugs and order them in chronological 

242 # order, before associating them to their corresponding bug period 

243 idx = 0 

244 followed_comments = BugComment.objects.filter(bug_id__in=followed_bug_ids, 

245 created_on__gte=comment_periods[0].period.start, 

246 created_on__lte=periodizer.end_cur_period).order_by('created_on') 

247 for comment in followed_comments.prefetch_related('account'): 

248 cp = comment_periods[idx] 

249 

250 # go to the next period if the comment has been made after the end of the current period 

251 while comment.created_on > cp.period.end: 

252 idx += 1 

253 cp = comment_periods[idx] 

254 

255 if comment.account.is_developer: 

256 cp.dev_comments.append(comment) 

257 else: 

258 cp.user_comments.append(comment) 

259 return (OpenCloseCountTrend(bug_periods, periodizer=periodizer), 

260 BugCommentCountTrend(comment_periods, periodizer=periodizer)) 

261 

262 

263def metrics_comments_over_time(user_query, periodizer=None): 

264 if periodizer is None: 

265 periodizer = Periodizer.from_json('{"period": "week", "count": 8}') 

266 

267 earliest_followed_since = bugs_followed_since() 

268 if earliest_followed_since is None: 

269 return BugCommentCountTrend([], periodizer=periodizer), {} 

270 

271 # Get a list of periods, from oldest to newest 

272 comment_periods = [] 

273 for period in periodizer: 

274 # Ignore all the periods not fully covered 

275 if period.start < earliest_followed_since: 

276 continue 

277 period = PeriodCommentItem(period, period.start_label, [], [], defaultdict(list)) 

278 comment_periods.append(period) 

279 

280 if len(comment_periods) == 0: 

281 return BugCommentCountTrend([], periodizer=periodizer), {} 

282 

283 # Get the list of comments wanted by the user, excluding comments made before the moment we started polling 

284 query = user_query.objects.exclude(created_on__lt=comment_periods[0].period.start) 

285 filtered_comments = query.exclude(created_on__gt=comment_periods[-1].period.end) 

286 

287 idx = 0 

288 cp = comment_periods[idx] 

289 accounts_found = set() 

290 for comment in filtered_comments.prefetch_related('bug__tracker', 'account__person').order_by('created_on'): 

291 # ignore comments on bugs that we are not following 

292 if comment.bug.tracker.tracker.has_components: 

293 if comment.bug.component not in comment.bug.tracker.components_followed_list: 

294 continue 

295 

296 # go to the next period if the comment has been made after the end of the current period 

297 while comment.created_on > cp.period.end: 

298 idx += 1 

299 cp = comment_periods[idx] 

300 

301 # Add the comment to the global counter 

302 if comment.account.is_developer: 

303 cp.dev_comments.append(comment) 

304 else: 

305 cp.user_comments.append(comment) 

306 

307 # Add the comment to the per-account list 

308 accounts_found.add(comment.account) 

309 cp.accounts[comment.account].append(comment) 

310 

311 # Create an dictionnary that keeps track of comments per account 

312 sorted_account = sorted(accounts_found, key=lambda a: str(a)) 

313 per_account_periods = OrderedDict() 

314 for account in sorted_account: 

315 per_account_periods[account] = [] 

316 for cp in comment_periods: 

317 per_account_periods[account].append(cp.accounts[account]) 

318 

319 return BugCommentCountTrend(comment_periods, periodizer=periodizer), per_account_periods 

320 

321 

322class Bin: 

323 def __init__(self, upper_limit, label): 

324 self.items = set() 

325 self.upper_limit = upper_limit 

326 self.label = label 

327 

328 

329class TimeBinizer: 

330 def __init__(self, items, bins=[Bin(timedelta(hours=1), "under an hour"), 

331 Bin(timedelta(hours=6), "under 6 hours"), 

332 Bin(timedelta(days=1), "under a day"), 

333 Bin(timedelta(days=7), "under a week"), 

334 Bin(timedelta(days=30), "under a month"), 

335 Bin(timedelta(days=90), "under 3 months"), 

336 Bin(timedelta(days=365), "under a year"), 

337 Bin(timedelta.max, "over a year")]): 

338 self._bins = deepcopy(bins) 

339 for item, time in items: 

340 for bin in self._bins: 

341 if time < bin.upper_limit: 

342 bin.items.add(item) 

343 break 

344 

345 @property 

346 def bins(self): 

347 return self._bins 

348 

349 @property 

350 def stats(self): 

351 return {"items_count": [len(b.items) for b in self.bins], 

352 "label": [b.label for b in self.bins]} 

353 

354 

355def metrics_issues_ttr(date=timezone.now(), period=timedelta(days=30)): 

356 request = Issue.objects.filter(archived_on__lt=date, archived_on__gt=date-period).exclude(archived_on__isnull=True) 

357 return TimeBinizer([(item, item.archived_on - item.added_on) 

358 for item in request if item.archived_on and item.added_on]) 

359 

360 

361def metrics_open_issues_age(date=timezone.now()): 

362 request = Issue.objects.filter(archived_on=None) 

363 now = timezone.now() 

364 return TimeBinizer([(item, now - item.added_on) for item in request if item.added_on]) 

365 

366 

367def metrics_failure_filing_delay(date=timezone.now(), period=timedelta(days=30)): 

368 request = KnownFailure.objects.filter(manually_associated_on__gt=date-period) 

369 request = request.filter(result__ts_run__runconfig__temporary=False) 

370 bins = [Bin(timedelta(hours=1), "under an hour"), 

371 Bin(timedelta(hours=8), "under 8 hours"), 

372 Bin(timedelta(days=1), "under a day"), 

373 Bin(timedelta(days=3), "under three days"), 

374 Bin(timedelta(days=7), "under a week"), 

375 Bin(timedelta(days=30), "under a month"), 

376 Bin(timedelta.max, "over a month")] 

377 return TimeBinizer([(item, item.filing_delay) for item in request], bins=bins) 

378 

379 

380def metrics_bugs_ttr(user_query, date=timezone.now(), period=timedelta(days=30)): 

381 request = user_query.objects.filter(closed__lt=date, closed__gt=date-period) 

382 request = request.exclude(closed__isnull=True).prefetch_related("tracker") 

383 bugs = set() 

384 for bug in request: 

385 if bug.tracker.tracker.has_components: 

386 if bug.component in bug.tracker.components_followed_list: 

387 bugs.add(bug) 

388 else: 

389 bugs.add(bug) 

390 return TimeBinizer([(item, item.closed - item.created) for item in bugs if item.closed and item.created]) 

391 

392 

393def metrics_open_bugs_age(user_query, date=timezone.now()): 

394 request = user_query.objects.filter(closed=None).prefetch_related("tracker") 

395 bugs = set() 

396 for bug in request: 

397 if bug.tracker.tracker.has_components: 

398 if bug.component in bug.tracker.components_followed_list: 

399 bugs.add(bug) 

400 else: 

401 bugs.add(bug) 

402 now = timezone.now() 

403 return TimeBinizer([(item, now - item.created) for item in bugs if item.created]) 

404 

405 

406class PieChartData: 

407 # results needs to be a dictionary mapping a label (string) to a number 

408 def __init__(self, results, colors=dict()): 

409 self._results = OrderedDict() 

410 self._colors = colors 

411 

412 for label, value in sorted(results.items(), key=lambda kv: kv[1], reverse=True): 

413 self._results[label] = value 

414 

415 def label_to_color(self, label): 

416 color = self._colors.get(label) 

417 

418 if color is not None: 

419 return color 

420 else: 

421 blake2 = hashlib.blake2b() 

422 blake2.update(label.encode()) 

423 return "#" + blake2.hexdigest()[-7:-1] 

424 

425 @property 

426 def colors(self): 

427 return [self.label_to_color(label) for label in self._results.keys()] 

428 

429 def stats(self): 

430 return {'results': list(self._results.values()), 

431 'labels': list(self._results.keys()), 

432 'colors': list(self.colors)} 

433 

434 

435class ColouredObjectPieChartData(PieChartData): 

436 def __init__(self, objects): 

437 results = defaultdict(int) 

438 for obj in objects: 

439 results[str(obj)] += 1 

440 

441 colors = {} 

442 for obj in set(objects): 

443 colors[str(obj)] = obj.color 

444 

445 super().__init__(results, colors) 

446 

447 

448def metrics_testresult_statuses_stats(results): 

449 return ColouredObjectPieChartData([r.status for r in results]) 

450 

451 

452def metrics_knownfailure_statuses_stats(failures): 

453 return ColouredObjectPieChartData([f.result.status for f in failures]) 

454 

455 

456def metrics_testresult_machines_stats(results): 

457 return ColouredObjectPieChartData([r.ts_run.machine for r in results]) 

458 

459 

460def metrics_knownfailure_machines_stats(failures): 

461 return ColouredObjectPieChartData([f.result.ts_run.machine for f in failures]) 

462 

463 

464def metrics_testresult_tests_stats(results): 

465 tests = {} 

466 for result in results: 

467 label = str(result.test) 

468 tests[label] = tests.get(label, 0) + 1 

469 return PieChartData(tests) 

470 

471 

472def metrics_knownfailure_tests_stats(failures): 

473 return metrics_testresult_tests_stats([f.result for f in failures]) 

474 

475 

476def metrics_knownfailure_issues_stats(failures): 

477 # Since the query has likely already been made, we can't prefetch the 

478 # issues anymore... so let's hand roll it! 

479 matched_ifas = set() 

480 for failure in failures: 

481 matched_ifas.add(failure.matched_ifa_id) 

482 ifa_to_issues = dict() 

483 ifas = IssueFilterAssociated.objects.filter(id__in=matched_ifas) 

484 for e in ifas.prefetch_related('issue', 'issue__bugs', 'issue__bugs__tracker'): 

485 ifa_to_issues[e.id] = e.issue 

486 

487 issues = {} 

488 for failure in failures: 

489 label = str(ifa_to_issues.get(failure.matched_ifa_id, None)) 

490 issues[label] = issues.get(label, 0) + 1 

491 return PieChartData(issues) 

492 

493 

494def metrics_testresult_issues_stats(failures): 

495 total = [] 

496 for failure in failures: 

497 total.extend(failure.known_failures.all()) 

498 return metrics_knownfailure_issues_stats(total) 

499 

500 

501class Rate: 

502 def __init__(self, count, total): 

503 self.count = count 

504 self.total = total 

505 

506 @property 

507 def percent(self): 

508 if self.total > 0: 

509 return self.count / self.total * 100.0 

510 else: 

511 return 0.0 

512 

513 def __repr__(self): 

514 return "Rate({}, {})".format(self.count, self.total) 

515 

516 def __str__(self): 

517 return "{:.2f}% ({} / {})".format(self.percent, self.count, self.total) 

518 

519 

520class Statistics: 

521 def __init__(self, unit, samples=None): 

522 self.unit = unit 

523 

524 if samples is None: 

525 self.samples = [] 

526 else: 

527 self.samples = samples 

528 

529 def add(self, sample): 

530 self.samples.append(sample) 

531 

532 def __iadd__(self, sample): 

533 self.add(sample) 

534 return self 

535 

536 @property 

537 def min(self): 

538 return min(self.samples) 

539 

540 @property 

541 def max(self): 

542 return max(self.samples) 

543 

544 @property 

545 def mean(self): 

546 return statistics.mean(self.samples) 

547 

548 @property 

549 def median(self): 

550 return statistics.median(self.samples) 

551 

552 @property 

553 def stdev(self): 

554 return statistics.stdev(self.samples) if len(self.samples) > 1 else 0 

555 

556 def __str__(self): 

557 mean = self.mean 

558 if mean > 0: 

559 stdev = self.stdev 

560 return "{:.3f} {} ±{:.1f}%".format(mean, self.unit, stdev / mean * 100.0) 

561 else: 

562 return "0 {}".format(self.unit) 

563 

564 

565class LineChartData: 

566 # results needs to be a dictionary mapping a label (string) to a number 

567 def __init__(self, results, x_labels, line_label_colors={}): 

568 self._results = OrderedDict() 

569 self.x_labels = x_labels 

570 self.line_label_colors = line_label_colors 

571 

572 for label, value in results.items(): 

573 self._results[label] = value 

574 

575 def label_to_color(self, label): 

576 blake2 = hashlib.blake2b() 

577 blake2.update(label.encode()) 

578 default = "#" + blake2.hexdigest()[-7:-1] 

579 

580 return self.line_label_colors.get(label, default) 

581 

582 def stats(self): 

583 dataset = [] 

584 for line_label, result in self._results.items(): 

585 color = self.label_to_color(line_label) 

586 entry = {'label': line_label, 

587 'borderColor': color, 

588 'backgroundColor': color, 

589 'data': result} 

590 dataset.append(entry) 

591 

592 return {'dataset': dataset, 'labels': self.x_labels} 

593 

594 

595class MetricPassRatePerRunconfig: 

596 filtering_model = TestResult 

597 

598 def _queryset_to_dict(self, Model, ids, *prefetch_related): 

599 return dict((o.pk, o) for o in Model.objects.filter(id__in=ids).prefetch_related(*prefetch_related)) 

600 

601 def __init__(self, user_query): 

602 self.query = user_query 

603 db_results = self.query.objects.values_list('id', 'status_id', 'ts_run__runconfig_id') 

604 

605 # Collect all the failures 

606 statuses = self._queryset_to_dict(TextStatus, [r[1] for r in db_results], 'testsuite') 

607 failure_status_ids = set([s.id for s in statuses.values() if s.is_failure]) 

608 failures = set() 

609 for result_id, status_id, runconfig_id in db_results: 

610 if status_id in failure_status_ids: 

611 failures.add(result_id) 

612 del failure_status_ids 

613 

614 # Find the related issues 

615 known_failures = KnownFailure.objects.filter(result__in=failures).values_list('result_id', 

616 'matched_ifa__issue_id', 

617 'matched_ifa__issue__expected') 

618 issues = self._queryset_to_dict(Issue, [r[1] for r in known_failures], 

619 'bugs__tracker', 'bugs__assignee__person') 

620 self._issue_hit_count = defaultdict(int) # [issue] = int 

621 expected_failures = set() # [failure_ids](issues) 

622 for result_id, issue_id, issue_expected in known_failures: 

623 issue = issues[issue_id] 

624 if issue_expected: 

625 expected_failures.add(result_id) 

626 else: 

627 self._issue_hit_count[issue] += 1 

628 self.total_result_count = len(db_results) 

629 self.kept_result_count = self.total_result_count - len(expected_failures) 

630 del known_failures 

631 

632 # Count the results per runconfig and statuses 

633 runconfigs = self._queryset_to_dict(RunConfig, [r[2] for r in db_results]) 

634 runconfigs_tmp = defaultdict(lambda: defaultdict(int)) 

635 statuses_tmp = set() 

636 for result_id, status_id, runconfig_id in db_results: 

637 if result_id not in expected_failures: 

638 status = statuses[status_id] 

639 runconfigs_tmp[runconfigs[runconfig_id]][status] += 1 

640 statuses_tmp.add(status) 

641 del expected_failures 

642 

643 # Order the statuses and runconfigs 

644 runconfigs_ordered = sorted(runconfigs_tmp.keys(), key=lambda r: r.added_on, reverse=True) 

645 statuses_ordered = sorted(statuses_tmp, key=lambda r: str(r)) 

646 

647 # Create the final result structures 

648 self.runconfigs = OrderedDict() # [runconfig][status] = Rate() 

649 self.statuses = OrderedDict() # [status][runconfig] = Rate() 

650 self.results_count = dict() # [runconfig] = int 

651 for status in statuses_ordered: 

652 self.statuses[status] = OrderedDict() 

653 for runconfig in runconfigs_ordered: 

654 # Compute the total for the run 

655 total = 0 

656 for status in statuses_ordered: 

657 total += runconfigs_tmp[runconfig][status] 

658 self.results_count[runconfig] = total 

659 

660 # Add the passrate to both the runconfig-major and status-major table 

661 self.runconfigs[runconfig] = OrderedDict() 

662 for status in statuses_ordered: 

663 passrate = Rate(runconfigs_tmp[runconfig][status], total) 

664 self.runconfigs[runconfig][status] = self.statuses[status][runconfig] = passrate 

665 

666 @property 

667 def discarded_rate(self): 

668 return Rate(self.total_result_count - self.kept_result_count, self.total_result_count) 

669 

670 @cached_property 

671 def chart(self): 

672 # The runconfigs are ordered from newest to oldest, reverse that 

673 runconfigs = list(reversed(self.runconfigs.keys())) 

674 

675 chart_data = defaultdict(list) 

676 for runconfig in runconfigs: 

677 for status in self.runconfigs[runconfig]: 

678 passrate = self.runconfigs[runconfig][status] 

679 chart_data[status.name].append(format((passrate.count / passrate.total) * 100, '.3f')) 

680 

681 status_colors = dict() 

682 for status in self.statuses: 

683 status_colors[status.name] = status.color 

684 

685 return LineChartData(chart_data, [r.name for r in runconfigs], status_colors) 

686 

687 @cached_property 

688 def to_csv(self): 

689 f = io.StringIO() 

690 writer = csv.writer(f, quoting=csv.QUOTE_NONNUMERIC) 

691 

692 writer.writerow(['Run Config', 'Results count'] + [str(s) for s in self.statuses]) 

693 for runconfig, statuses in self.runconfigs.items(): 

694 writer.writerow([runconfig, self.results_count[runconfig]] + [p.count for status, p in statuses.items()]) 

695 

696 return f.getvalue() 

697 

698 @cached_property 

699 def most_hit_issues(self): 

700 ordered_issues = OrderedDict() 

701 for issue in sorted(self._issue_hit_count, key=lambda i: self._issue_hit_count[i], reverse=True): 

702 occ_count = self._issue_hit_count[issue] 

703 ordered_issues[issue] = Rate(occ_count, self.kept_result_count) 

704 return ordered_issues 

705 

706 

707class MetricPassRatePerTest: 

708 filtering_model = TestResult 

709 

710 discarded_label = 'discarded (expected)' 

711 discarded_color = '#cc99ff' 

712 

713 class AggregatedTestResults: 

714 def __init__(self, test): 

715 self.test = test 

716 

717 self.results_count = 0 

718 self.statuses = defaultdict(int) 

719 self.duration = Statistics('s') 

720 self.issues = defaultdict(int) 

721 self.overall_result = self.test.testsuite.notrun_status 

722 self.worst_failures = [] 

723 

724 self.discarded_results_count = 0 

725 self.discarded_count_per_issue = defaultdict(int) # [issue] = count 

726 

727 def add_result(self, result_id, status, duration, issues=None): 

728 self.results_count += 1 

729 self.statuses[status] += 1 

730 self.duration.add(duration.total_seconds()) 

731 if issues is not None: 

732 for issue in issues: 

733 self.issues[issue] += 1 

734 

735 if self.overall_result is None or status.actual_severity > self.overall_result.actual_severity: 

736 self.overall_result = status 

737 self.worst_failures = [] 

738 

739 if status == self.overall_result: 

740 self.worst_failures.append(result_id) 

741 

742 def add_discarded_result(self, result_id, duration, issues=None): 

743 self.discarded_results_count += 1 

744 self.duration.add(duration.total_seconds()) 

745 for issue in issues: 

746 self.discarded_count_per_issue[issue] += 1 

747 

748 def status_occurence_rate(self, status): 

749 return Rate(self.statuses.get(status, 0), self.results_count) 

750 

751 def issue_occurence_rate(self, issue): 

752 return Rate(self.issues.get(issue, 0), self.results_count) 

753 

754 @property 

755 def rate_of_worst_failure(self): 

756 return self.status_occurence_rate(self.overall_result) 

757 

758 @property 

759 def is_fully_discarded(self): 

760 return self.results_count == 0 and self.discarded_results_count > 0 

761 

762 @property 

763 def issue_occurence_rates(self): 

764 rates = dict() 

765 if self.is_fully_discarded: 

766 for issue in self.discarded_count_per_issue: 

767 rates[issue] = Rate(self.discarded_count_per_issue.get(issue, 0), 

768 self.results_count + self.discarded_results_count) 

769 else: 

770 for issue in self.issues: 

771 rates[issue] = self.issue_occurence_rate(issue) 

772 return rates 

773 

774 @cached_property 

775 def is_pass(self): 

776 # Notruns are not pass 

777 if self.overall_result.is_notrun: 

778 return False 

779 

780 return not self.overall_result.is_failure 

781 

782 PassRateStatistics = namedtuple('PassRateStatistics', 'passrate runrate discarded_rate notrun_rate') 

783 

784 def _queryset_to_dict(self, Model, ids, *prefetch_related): 

785 return dict((o.pk, o) for o in Model.objects.filter(id__in=ids).prefetch_related(*prefetch_related)) 

786 

787 def __init__(self, user_query): 

788 self.query = user_query 

789 

790 # Create the final result structures 

791 self.tests = OrderedDict() # [test] = AggregatedTestResults() 

792 self.statuses = OrderedDict() # [status] = Rate() 

793 

794 # Fetch the user-filtered from the database 

795 db_results = self.query.objects.values_list('id', 'test_id', 'status_id', 'ts_run_id', 'duration') 

796 

797 # This query will lazily be evaluated, so it can be set early 

798 ts_runs = TestsuiteRun.objects.filter(id__in=[r[3] for r in db_results]).values_list('machine_id', 

799 'runconfig_id') 

800 

801 # Collect all the failures and ts_run_ids that got some notruns 

802 statuses = self._queryset_to_dict(TextStatus, [r[2] for r in db_results], 'testsuite') 

803 failure_status_ids = set([s.id for s in statuses.values() if s.is_failure]) 

804 notrun_status_ids = set([s.id for s in statuses.values() if s.is_notrun]) 

805 interrupted_tsruns_notruns_count = defaultdict(int) 

806 notrun_count = 0 

807 failure_ids = set() 

808 for result_id, test_id, status_id, ts_run_id, duration in db_results: 

809 if status_id in failure_status_ids: 

810 failure_ids.add(result_id) 

811 elif status_id in notrun_status_ids: 

812 interrupted_tsruns_notruns_count[ts_run_id] += 1 

813 notrun_count += 1 

814 del failure_status_ids 

815 

816 # Find the last test of every interrupted ts_run 

817 q = TestResult.objects.filter(ts_run__in=interrupted_tsruns_notruns_count.keys()) 

818 q = q.exclude(status_id__in=notrun_status_ids).values_list('ts_run__id').annotate(last_test_run_id=Max('id')) 

819 last_result_of_tsr = set([v[1] for v in q]) 

820 q2 = KnownFailure.objects.filter(result_id__in=last_result_of_tsr).values_list('result__ts_run_id', 

821 'matched_ifa__issue_id') 

822 run_to_issue = dict(q2) 

823 

824 id_to_issue = dict([(i.id, i) for i in Issue.objects.filter(id__in=run_to_issue.values())]) 

825 issue_not_run_impact = defaultdict(int) 

826 for run_id, issue_id in run_to_issue.items(): 

827 issue = id_to_issue[issue_id] 

828 notruns_count = interrupted_tsruns_notruns_count.pop(run_id) 

829 issue_not_run_impact[issue] += notruns_count 

830 

831 undocumented_failure_not_run_count = 0 

832 q3 = UnknownFailure.objects.filter(result_id__in=last_result_of_tsr).values_list('result__ts_run_id', flat=True) 

833 for undocumented_interrupted_ts_run_id in set(q3): 

834 notruns_count = interrupted_tsruns_notruns_count.pop(undocumented_interrupted_ts_run_id) 

835 undocumented_failure_not_run_count += notruns_count 

836 

837 unexplained_notruns_count = sum(interrupted_tsruns_notruns_count.values()) 

838 del run_to_issue 

839 del id_to_issue 

840 del interrupted_tsruns_notruns_count 

841 

842 # Find the related issues 

843 known_failures = KnownFailure.objects.filter(result__in=failure_ids).values_list('result_id', 

844 'matched_ifa__issue_id', 

845 'matched_ifa__issue__expected') 

846 issues = self._queryset_to_dict(Issue, [r[1] for r in known_failures], 

847 'bugs__tracker', 'bugs__assignee__person') 

848 issue_hit_count = defaultdict(int) # [issue] = Rate 

849 failures = defaultdict(set) # [result_id] = set(issues) 

850 expected_failures = defaultdict(set) # [failure_ids](issues) 

851 explained_failure_ids = set() 

852 for result_id, issue_id, issue_expected in known_failures: 

853 issue = issues[issue_id] 

854 if issue_expected: 

855 expected_failures[result_id].add(issue) 

856 else: 

857 failures[result_id].add(issue) 

858 issue_hit_count[issue] += 1 

859 explained_failure_ids.add(result_id) 

860 self.total_result_count = len(db_results) 

861 self.kept_result_count = self.total_result_count - len(expected_failures) 

862 self.uncovered_failure_rate = Rate(len(failure_ids - explained_failure_ids), len(failure_ids)) 

863 self.notrun_rate = Rate(notrun_count, self.total_result_count) 

864 del known_failures 

865 del failure_ids 

866 del explained_failure_ids 

867 

868 # Create all the aggregated results for each test, in the alphabetical order 

869 tests = self._queryset_to_dict(Test, [r[1] for r in db_results], 'testsuite') 

870 for test in sorted(tests.values(), key=lambda t: str(t)): 

871 self.tests[test] = self.AggregatedTestResults(test) 

872 

873 # Move all the results to their right category 

874 status_result_count = defaultdict(int) 

875 for result_id, test_id, status_id, ts_run_id, duration in db_results: 

876 test = tests[test_id] 

877 

878 if result_id not in expected_failures: 

879 status = statuses[status_id] 

880 status_result_count[status] += 1 

881 self.tests[test].add_result(result_id, status, duration, failures.get(result_id)) 

882 else: 

883 self.tests[test].add_discarded_result(result_id, duration, expected_failures.get(result_id)) 

884 

885 # We do not need these results anymore, so free them 

886 del tests 

887 del failures 

888 del expected_failures 

889 del issues 

890 del db_results 

891 

892 # Get the list of machines and runconfigs 

893 self.machines = Machine.objects.filter(id__in=[r[0] for r in ts_runs]) 

894 self.runconfigs = RunConfig.objects.filter(id__in=[r[1] for r in ts_runs]) 

895 

896 # Compute the results-level stats 

897 self.most_hit_issues = OrderedDict() 

898 for issue in sorted(issue_hit_count.keys(), key=lambda i: issue_hit_count[i], reverse=True): # noqa 

899 self.most_hit_issues[issue] = Rate(issue_hit_count[issue], self.kept_result_count) 

900 del issue_hit_count 

901 

902 self.most_interrupting_issues = OrderedDict() 

903 self.explained_interruption_rate = Rate(0, self.notrun_rate.count) 

904 for issue in sorted(issue_not_run_impact.keys(), key=lambda i: issue_not_run_impact[i], reverse=True): # noqa 

905 rate = Rate(issue_not_run_impact[issue], self.notrun_rate.count) 

906 self.most_interrupting_issues[issue] = rate 

907 self.explained_interruption_rate.count += rate.count 

908 self.unknown_failure_interruption_rate = Rate(undocumented_failure_not_run_count, self.notrun_rate.count) 

909 self.unexplained_interruption_rate = Rate(unexplained_notruns_count, self.notrun_rate.count) 

910 

911 # Create all the status rates in alphabetical order and compute their occurence rate 

912 statuses_ordered = sorted(status_result_count.keys(), key=lambda r: str(r), 

913 reverse=True) # only the statuses we kept 

914 for status in statuses_ordered: 

915 self.statuses[status] = Rate(status_result_count[status], self.kept_result_count) 

916 

917 @cached_property 

918 def result_statuses_chart(self): 

919 statuses = {} 

920 colors = {} 

921 for status, rate in self.statuses.items(): 

922 label = str(status) 

923 statuses[label] = rate.count 

924 colors[label] = status.color 

925 

926 discarded_count = self.total_result_count - self.kept_result_count 

927 if discarded_count > 0: 

928 statuses[self.discarded_label] = discarded_count 

929 colors[self.discarded_label] = self.discarded_color 

930 

931 return PieChartData(statuses, colors) 

932 

933 @cached_property 

934 def uncovered_failure_rate_chart(self): 

935 statuses = { 

936 'filed / documented': self.uncovered_failure_rate.total - self.uncovered_failure_rate.count, 

937 'need filing': self.uncovered_failure_rate.count, 

938 } 

939 

940 colors = { 

941 'filed / documented': '#33cc33', 

942 'need filing': '#ff0000', 

943 } 

944 

945 return PieChartData(statuses, colors) 

946 

947 @cached_property 

948 def aggregated_statuses_chart(self): 

949 statuses = defaultdict(int) 

950 colors = {} 

951 

952 for test, agg_result in self.tests.items(): 

953 if agg_result.is_fully_discarded: 

954 label = self.discarded_label 

955 else: 

956 label = str(agg_result.overall_result) 

957 statuses[label] += 1 

958 

959 colors[self.discarded_label] = self.discarded_color 

960 for status in self.statuses: 

961 colors[str(status)] = status.color 

962 

963 return PieChartData(statuses, colors) 

964 

965 @cached_property 

966 def passrate_chart(self): 

967 stats = self.statistics 

968 

969 statuses = { 

970 'pass rate': stats.passrate.count, 

971 'fail rate': stats.runrate.count - stats.passrate.count, 

972 'discarded rate': stats.discarded_rate.count, 

973 'notrun rate': stats.notrun_rate.count, 

974 } 

975 

976 colors = { 

977 'pass rate': '#33cc33', 

978 'fail rate': '#ff0000', 

979 'discarded rate': self.discarded_color, 

980 'notrun rate': '#000000', 

981 } 

982 

983 return PieChartData(statuses, colors) 

984 

985 @cached_property 

986 def raw_statistics(self): 

987 passrate = Rate(0, self.total_result_count) 

988 runrate = Rate(0, self.total_result_count) 

989 discarded_rate = Rate(0, self.total_result_count) 

990 notrun_rate = Rate(0, self.total_result_count) 

991 

992 self.kept_result_count = self.total_result_count 

993 

994 for test, results in self.tests.items(): 

995 if results.is_fully_discarded: 

996 discarded_rate.count += 1 

997 elif not results.overall_result.is_notrun: 

998 runrate.count += 1 

999 if not results.overall_result.is_failure: 

1000 passrate.count += 1 

1001 else: 

1002 notrun_rate.count += 1 

1003 

1004 return self.PassRateStatistics(passrate, runrate, discarded_rate, notrun_rate) 

1005 

1006 @cached_property 

1007 def statistics(self): 

1008 passrate = Rate(0, len(self.tests)) 

1009 runrate = Rate(0, len(self.tests)) 

1010 discarded_rate = Rate(0, len(self.tests)) 

1011 notrun_rate = Rate(0, len(self.tests)) 

1012 

1013 for test, results in self.tests.items(): 

1014 if results.is_fully_discarded: 

1015 discarded_rate.count += 1 

1016 elif not results.overall_result.is_notrun: 

1017 runrate.count += 1 

1018 if not results.overall_result.is_failure: 

1019 passrate.count += 1 

1020 else: 

1021 notrun_rate.count += 1 

1022 

1023 return self.PassRateStatistics(passrate, runrate, discarded_rate, notrun_rate) 

1024 

1025 @cached_property 

1026 def total_execution_time(self): 

1027 total = 0 

1028 for results in self.tests.values(): 

1029 total += results.duration.mean 

1030 return timedelta(seconds=int(total)) 

1031 

1032 

1033class MetricRuntimeHistory: 

1034 filtering_model = TestsuiteRun 

1035 

1036 def _queryset_to_dict(self, Model, ids, *prefetch_related): 

1037 return dict((o.pk, o) for o in Model.objects.filter(id__in=ids).prefetch_related(*prefetch_related)) 

1038 

1039 def __init__(self, user_query, average_per_machine=False): 

1040 self.query = user_query 

1041 self.average_per_machine = average_per_machine 

1042 

1043 # Fetch the user-filtered from the database 

1044 filtered_results = self.query.objects 

1045 filtered_results = filtered_results.filter(duration__gt=timedelta(seconds=0)) # Ignore negative exec times 

1046 db_results = filtered_results.values('id', 'runconfig_id', 

1047 'machine_id').annotate(total=Sum("duration")) 

1048 

1049 # Get the runconfigs and machines 

1050 runconfigs = self._queryset_to_dict(RunConfig, [r['runconfig_id'] for r in db_results]) 

1051 machines = self._queryset_to_dict(Machine, [r['machine_id'] for r in db_results], "aliases") 

1052 self._tsr_ids = [r['id'] for r in db_results] 

1053 

1054 # Add the results to the history, then compute the totals 

1055 self.participating_machines = defaultdict(lambda: defaultdict(set)) 

1056 runconfigs_tmp = defaultdict(lambda: defaultdict(timedelta)) 

1057 machines_tmp = set() 

1058 for r in db_results: 

1059 # de-duplicate shard machines by replacing a machine by its shard 

1060 machine = machines[r['machine_id']] 

1061 if machine.aliases is not None: 

1062 machine = machine.aliases 

1063 

1064 runconfigs_tmp[runconfigs[r['runconfig_id']]][machine] += r['total'] 

1065 self.participating_machines[runconfigs[r['runconfig_id']]][machine].add(r['machine_id']) 

1066 machines_tmp.add(machine) 

1067 

1068 # Order the machines and runconfigs 

1069 runconfigs_ordered = sorted(runconfigs_tmp.keys(), key=lambda r: r.added_on, reverse=True) 

1070 machines_ordered = sorted(machines_tmp, key=lambda m: str(m)) 

1071 

1072 # Create the final result structures 

1073 self.runconfigs = OrderedDict() # [runconfig][machine] = Rate() 

1074 self.machines = OrderedDict() # [machine][runconfig] = Rate() 

1075 for machine in machines_ordered: 

1076 self.machines[machine] = OrderedDict() 

1077 for runconfig in runconfigs_ordered: 

1078 # Add the runtime to both the runconfig-major and machine-major table 

1079 self.runconfigs[runconfig] = OrderedDict() 

1080 for machine in machines_ordered: 

1081 total_time = runconfigs_tmp[runconfig][machine] 

1082 machine_count = len(self.participating_machines[runconfig][machine]) 

1083 if average_per_machine: 

1084 if machine_count > 0: 

1085 actual_time = total_time / machine_count 

1086 else: 

1087 actual_time = timedelta() 

1088 else: 

1089 actual_time = total_time 

1090 self.runconfigs[runconfig][machine] = self.machines[machine][runconfig] = actual_time 

1091 

1092 def _machine_to_name(self, machine): 

1093 counts = list() 

1094 for runconfig in self.runconfigs: 

1095 counts.append(len(self.participating_machines[runconfig][machine])) 

1096 

1097 c_min = min(counts) 

1098 c_max = max(counts) 

1099 

1100 if c_max == 1: 

1101 return str(machine) 

1102 elif c_min == c_max: 

1103 return "{} ({})".format(str(machine), c_min) 

1104 else: 

1105 return "{} ({}-{})".format(str(machine), c_min, c_max) 

1106 

1107 @cached_property 

1108 def chart(self): 

1109 # The runconfigs are ordered from newest to oldest, reverse that 

1110 runconfigs = list(reversed(self.runconfigs.keys())) 

1111 

1112 chart_data = defaultdict(list) 

1113 for runconfig in runconfigs: 

1114 for machine in self.runconfigs[runconfig]: 

1115 time = self.runconfigs[runconfig][machine] 

1116 if time != timedelta(): 

1117 minutes = format(time.total_seconds() / 60, '.2f') 

1118 else: 

1119 minutes = "null" 

1120 chart_data[self._machine_to_name(machine)].append(minutes) 

1121 

1122 machines_colors = dict() 

1123 for machine in self.machines: 

1124 machines_colors[self._machine_to_name(machine)] = machine.color 

1125 

1126 return LineChartData(chart_data, [r.name for r in runconfigs], machines_colors) 

1127 

1128 @cached_property 

1129 def longest_tests(self): 

1130 TestRunTime = namedtuple("TestRunTime", ('test', 'machine', 'min', 'avg', 'max')) 

1131 longest_tests = [] 

1132 

1133 db_results = TestResult.objects.filter(ts_run_id__in=self._tsr_ids) 

1134 db_results = db_results.values("test_id", "ts_run__machine_id") 

1135 db_results = db_results.annotate(d_avg=Avg("duration"), 

1136 d_min=Min("duration"), 

1137 d_max=Max("duration")).order_by("-d_max") 

1138 db_results = db_results[:1000] 

1139 

1140 tests = self._queryset_to_dict(Test, [r['test_id'] for r in db_results], "testsuite") 

1141 machines = self._queryset_to_dict(Machine, [r['ts_run__machine_id'] for r in db_results], "aliases") 

1142 

1143 # Create all the TestRunTime objects 

1144 aliased_machines = defaultdict(list) 

1145 for r in db_results: 

1146 test = tests[r['test_id']] 

1147 machine = machines[r['ts_run__machine_id']] 

1148 runtime = TestRunTime(test, machine, r['d_min'], r['d_avg'], r['d_max']) 

1149 

1150 if machine.aliases is None: 

1151 longest_tests.append(runtime) 

1152 else: 

1153 aliased_machines[(test, machine.aliases)].append(runtime) 

1154 

1155 # Deduplicate all the data for aliased machines 

1156 for key, results in aliased_machines.items(): 

1157 test, machine = key 

1158 

1159 # Aggregate all the values (Avg won't be super accurate, but good-enough) 

1160 d_min = results[0].min 

1161 avg_sum = timedelta() 

1162 d_max = results[0].max 

1163 for result in results: 

1164 d_min = min(d_min, result.min) 

1165 d_max = max(d_max, result.max) 

1166 avg_sum += result.avg 

1167 

1168 longest_tests.append(TestRunTime(results[0].test, machine, d_min, avg_sum / len(results), d_max)) 

1169 

1170 return sorted(longest_tests, key=lambda r: r.max, reverse=True)[:100]