Skip to content

Commit 2e7ddbb

Browse files
committed
feat(ci): report each failure against master and against the branch point
A verdict says a test does not match the approved output. It does not say who made that true, and for a reviewer that is the only interesting part. The comment answered it by asking whether the test last passed in the newest master run, which charges a branch for everything master did while the branch was open -- a three-line change to file_functions.c was reported as breaking 45 tests it never touched. Each failure is now described against two references as well as the approved output: the tip of master, and the newest completed run for the closest ancestor commit the branch actually descends from. Where both sides fail, the recorded hashes separate "fails identically" -- unchanged behaviour measured against a baseline that has gone stale -- from "fails differently", where something moved even though the verdict did not. Pass and fail are untouched and still decided against the approved output alone. A comparison explains a failure; it never excuses one, because a baseline that stopped describing reality is a thing to fix rather than a thing to pass. A reference we have no run for is reported as such rather than as agreement. The verdict per test is taken from get_test_results, which already accounts for exit codes, absent outputs, and the alternative hashes an output may legitimately produce. Deciding that again here would have created a second definition of "passed", free to drift from the first.
1 parent 3be3be7 commit 2e7ddbb

6 files changed

Lines changed: 639 additions & 75 deletions

File tree

mod_ci/comparison.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Compare one test run's regression results against another run's.
2+
3+
Pass and fail are decided elsewhere, and against one thing only: the approved
4+
output. That answers "is this correct?", which is the question a baseline is for
5+
-- but it is not the question a reviewer is asking. A reviewer wants to know
6+
what *this change* did, and a test that has been failing since last month tells
7+
them nothing while burying the one that started failing today.
8+
9+
So the verdict stays absolute and the report is relative. The same failure is
10+
described against several references at once: the approved output, the tip of
11+
master, and the closest ancestor commit we still hold results for. A test
12+
failing identically on all of them is drift someone needs to approve; a test
13+
failing only here is the change under review.
14+
15+
The functions below take plain values rather than models so the classification
16+
can be tested without a database, a GitHub client, or a CI run.
17+
"""
18+
19+
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple
20+
21+
#: The test matched the approved output on both sides.
22+
UNCHANGED_PASS = 'unchanged_pass'
23+
#: Fails here, matched the approved output in the reference run.
24+
BROKEN_HERE = 'broken_here'
25+
#: Matches the approved output here, failed in the reference run.
26+
FIXED_HERE = 'fixed_here'
27+
#: Fails on both sides and produces the *same* bytes -- unchanged behaviour
28+
#: measured against a baseline that no longer describes it.
29+
FAILING_IDENTICALLY = 'failing_identically'
30+
#: Fails on both sides but the output differs, so something moved even though
31+
#: the verdict did not.
32+
FAILING_DIFFERENTLY = 'failing_differently'
33+
#: The reference run holds no record of this test, so nothing can be said.
34+
NO_REFERENCE = 'no_reference'
35+
36+
#: Every verdict, in the order a reader should be shown them: what this change
37+
#: broke first, what it fixed next, then the pre-existing noise.
38+
VERDICTS = (BROKEN_HERE, FIXED_HERE, FAILING_DIFFERENTLY, FAILING_IDENTICALLY,
39+
UNCHANGED_PASS, NO_REFERENCE)
40+
41+
42+
class TestState(NamedTuple):
43+
"""How one regression test behaved in one run.
44+
45+
``signature`` identifies *how* a failing test differed, so two runs failing
46+
the same test can be told apart by whether they produced the same bytes.
47+
"""
48+
49+
#: True when the exit code matched and every output file matched the approved one.
50+
passed: bool
51+
#: (output_id, produced hash) for each output that did not match, sorted.
52+
signature: Tuple[Tuple[int, Optional[str]], ...]
53+
54+
55+
def build_state(test_results: Iterable[Any]) -> Dict[int, TestState]:
56+
"""
57+
Summarise a run as one state per regression test.
58+
59+
Whether a test passed is taken from ``get_test_results``, which is the
60+
platform's own verdict and already accounts for exit codes, outputs that are
61+
absent when they should not be, and the alternative hashes an output may
62+
legitimately produce. Re-deriving any of that here would mean two
63+
definitions of "passed" that could drift apart.
64+
65+
The signature is built from the recorded hashes, which is the part
66+
``get_test_results`` does not express: it lets two runs failing the same
67+
test be told apart by whether they produced the same bytes.
68+
69+
:param test_results: The structure returned by ``get_test_results``.
70+
:type test_results: Iterable[Any]
71+
:return: Regression test id mapped to how that test behaved.
72+
:rtype: Dict[int, TestState]
73+
"""
74+
states: Dict[int, TestState] = {}
75+
for category in test_results:
76+
for entry in category['tests']:
77+
# A caller that reports no files leaves the failure unexplained rather
78+
# than unnoticed: the verdict still counts, only the signature is empty.
79+
failed_outputs: List[Tuple[int, Optional[str]]] = sorted(
80+
(result_file.regression_test_output_id, result_file.got)
81+
for result_file in (entry.get('files') or ()) if result_file.got is not None)
82+
states[entry['test'].id] = TestState(passed=not entry['error'],
83+
signature=tuple(failed_outputs))
84+
return states
85+
86+
87+
def classify(current: TestState, reference: Optional[TestState]) -> str:
88+
"""
89+
Describe one test's behaviour here relative to a reference run.
90+
91+
:param current: How the test behaved in the run being reported on.
92+
:type current: TestState
93+
:param reference: How it behaved in the reference run, if that run has a record.
94+
:type reference: Optional[TestState]
95+
:return: One of the module's verdict constants.
96+
:rtype: str
97+
"""
98+
if reference is None:
99+
return NO_REFERENCE
100+
if current.passed and reference.passed:
101+
return UNCHANGED_PASS
102+
if current.passed:
103+
return FIXED_HERE
104+
if reference.passed:
105+
return BROKEN_HERE
106+
if current.signature == reference.signature:
107+
return FAILING_IDENTICALLY
108+
return FAILING_DIFFERENTLY
109+
110+
111+
def compare(current: Dict[int, TestState],
112+
reference: Optional[Dict[int, TestState]]) -> Dict[str, List[int]]:
113+
"""
114+
Bucket every regression test in a run by how it compares to a reference run.
115+
116+
A missing reference run is not the same as a reference run that passed
117+
everything: it is reported as ``no_reference`` so the comment can say "we
118+
have no records to compare against" instead of implying good news.
119+
120+
:param current: States for the run being reported on.
121+
:type current: Dict[int, TestState]
122+
:param reference: States for the reference run, or None when there is no such run.
123+
:type reference: Optional[Dict[int, TestState]]
124+
:return: Verdict mapped to the regression test ids in it.
125+
:rtype: Dict[str, List[int]]
126+
"""
127+
buckets: Dict[str, List[int]] = {verdict: [] for verdict in VERDICTS}
128+
for rt_id in sorted(current):
129+
reference_state = None if reference is None else reference.get(rt_id)
130+
buckets[classify(current[rt_id], reference_state)].append(rt_id)
131+
return buckets
132+
133+
134+
def summarise(buckets: Dict[str, List[int]]) -> Dict[str, int]:
135+
"""
136+
Count each verdict, for a table that has to stay short.
137+
138+
:param buckets: Output of :func:`compare`.
139+
:type buckets: Dict[str, List[int]]
140+
:return: Verdict mapped to how many tests fell in it.
141+
:rtype: Dict[str, int]
142+
"""
143+
return {verdict: len(ids) for verdict, ids in buckets.items()}

mod_ci/controllers.py

Lines changed: 152 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import datetime
55
import fnmatch
66
import hashlib
7+
import itertools
78
import json
89
import os
910
import re
@@ -14,7 +15,7 @@
1415
from collections import defaultdict
1516
from functools import wraps
1617
from pathlib import Path
17-
from typing import Any, Callable, Dict, Optional, TypeVar
18+
from typing import Any, Callable, Dict, List, Optional, TypeVar
1819

1920
import googleapiclient.discovery
2021
import requests
@@ -35,10 +36,11 @@
3536
from decorators import get_menu_entries, template_renderer
3637
from mod_auth.controllers import check_access_rights, login_required
3738
from mod_auth.models import Role
39+
from mod_ci import comparison
3840
from mod_ci.forms import AddUsersToBlacklist, DeleteUserForm
3941
from mod_ci.models import (BlockedUsers, CategoryTestInfo, GcpInstance,
4042
MaintenanceMode, PendingDeletion, PrCommentInfo,
41-
Status)
43+
ReferenceComparison, Status)
4244
from mod_customized.models import CustomizedTest
4345
from mod_home.models import CCExtractorVersion, GeneralData
4446
from mod_regression.models import (Category, RegressionTest,
@@ -53,6 +55,10 @@
5355
GITHUB_API_TIMEOUT = 30 # Timeout for GitHub API calls
5456
GCP_API_TIMEOUT = 60 # Timeout for GCP API calls
5557
ARTIFACT_DOWNLOAD_TIMEOUT = 300 # 5 minutes for artifact downloads
58+
59+
#: How far back to walk a branch's history looking for a run to compare against.
60+
#: Deep enough to clear a stale branch, short enough to stay one API page.
61+
ANCESTOR_SEARCH_DEPTH = 50
5662
GCP_OPERATION_MAX_WAIT = 1800 # 30 minutes max wait for GCP operations
5763
GCP_VM_CREATE_VERIFY_TIMEOUT = 60 # 60 seconds to verify VM creation started
5864

@@ -2835,43 +2841,141 @@ def set_avg_time(platform, process_type: str, time_taken: int) -> None:
28352841
safe_db_commit(g.db, f"updating average {process_type} time for {platform.value}")
28362842

28372843

2838-
def get_info_for_pr_comment(test: Test) -> PrCommentInfo:
2844+
def find_ancestor_run(repository, test: Test) -> Optional[Test]:
28392845
"""
2840-
Return info about the given test for use in a PR comment.
2846+
Find the newest completed run for a commit this one descends from.
28412847
2842-
:param test: The test whose report will be returned
2848+
The tip of master is not always what a branch was cut from, so a comparison
2849+
against it charges the branch for whatever master did in between. Walking
2850+
back from the branch's own base answers the narrower question a reviewer is
2851+
asking: what changed *here*.
2852+
2853+
Any GitHub failure resolves to None rather than raising -- a comment missing
2854+
one of its comparisons is worth more than no comment at all.
2855+
2856+
:param repository: GitHub repository handle used to walk the commit history.
2857+
:type repository: Repository.Repository
2858+
:param test: The run whose ancestry should be searched.
28432859
:type test: Test
2860+
:return: The closest ancestor's completed run on the same platform, if any.
2861+
:rtype: Optional[Test]
28442862
"""
2845-
last_test_master = g.db.query(Test).filter(Test.branch == "master", Test.test_type == TestType.commit,
2846-
Test.platform == test.platform).join(
2863+
from run import log
2864+
2865+
if repository is None:
2866+
return None
2867+
try:
2868+
if test.pr_nr:
2869+
start = repository.get_pull(number=test.pr_nr).base.sha
2870+
else:
2871+
parents = repository.get_commit(test.commit).parents
2872+
if not parents:
2873+
return None
2874+
start = parents[0].sha
2875+
ancestry = [commit.sha for commit in
2876+
itertools.islice(repository.get_commits(sha=start), ANCESTOR_SEARCH_DEPTH)]
2877+
except Exception as error:
2878+
log.warning(f"Could not resolve ancestry for test {test.id}: {type(error).__name__}: {error}")
2879+
return None
2880+
2881+
if not ancestry:
2882+
return None
2883+
2884+
runs = g.db.query(Test).filter(and_(Test.commit.in_(ancestry),
2885+
Test.platform == test.platform,
2886+
Test.id != test.id)).join(
28472887
TestProgress, Test.id == TestProgress.test_id).filter(
2848-
TestProgress.status == TestStatus.completed).order_by(TestProgress.id.desc()).first()
2888+
TestProgress.status == TestStatus.completed).order_by(TestProgress.id.desc()).all()
2889+
2890+
newest_per_commit: Dict[str, Test] = {}
2891+
for run in runs:
2892+
newest_per_commit.setdefault(run.commit, run)
2893+
# Nearest ancestor first: ancestry is already in walk order.
2894+
for sha in ancestry:
2895+
if sha in newest_per_commit:
2896+
return newest_per_commit[sha]
2897+
return None
28492898

2850-
extra_failed_tests = []
2851-
common_failed_tests = []
2852-
fixed_tests = []
2853-
category_stats = []
28542899

2900+
def _compare_against(label: str, reference: Optional[Test], current: Dict[int, comparison.TestState],
2901+
regression_tests: Dict[int, RegressionTest],
2902+
already_used: Dict[Any, str]) -> ReferenceComparison:
2903+
"""
2904+
Describe this run's results against one reference run.
2905+
2906+
:param label: How the reference should be named to a reader.
2907+
:type label: str
2908+
:param reference: The run to compare against, or None when there is none.
2909+
:type reference: Optional[Test]
2910+
:param current: States for the run being reported on.
2911+
:type current: Dict[int, comparison.TestState]
2912+
:param regression_tests: Regression tests by id, for rendering the buckets.
2913+
:type regression_tests: Dict[int, RegressionTest]
2914+
:param already_used: Run ids already compared against, mapped to their label.
2915+
:type already_used: Dict[Any, str]
2916+
:return: The comparison, empty when there was nothing to compare against.
2917+
:rtype: ReferenceComparison
2918+
"""
2919+
if reference is None:
2920+
empty: Dict[str, List[RegressionTest]] = {verdict: [] for verdict in comparison.VERDICTS}
2921+
return ReferenceComparison(label, None, empty, {verdict: 0 for verdict in comparison.VERDICTS})
2922+
2923+
duplicate_of = already_used.get(reference.id)
2924+
if duplicate_of is None:
2925+
already_used[reference.id] = label
2926+
2927+
buckets = comparison.compare(current, comparison.build_state(get_test_results(reference)))
2928+
tests = {verdict: [regression_tests[rt_id] for rt_id in ids if rt_id in regression_tests]
2929+
for verdict, ids in buckets.items()}
2930+
return ReferenceComparison(label, reference, tests, comparison.summarise(buckets), duplicate_of)
2931+
2932+
2933+
def get_info_for_pr_comment(test: Test, repository=None) -> PrCommentInfo:
2934+
"""
2935+
Return info about the given test for use in a PR comment.
2936+
2937+
Pass and fail are decided against the approved output and nothing else. The
2938+
comparisons that follow do not change any verdict; they say what each
2939+
failure means relative to master and to the commit the branch was cut from,
2940+
which is what separates "this change broke it" from "it has been failing for
2941+
a month".
2942+
2943+
:param test: The test whose report will be returned
2944+
:type test: Test
2945+
:param repository: GitHub repository handle, needed to resolve the ancestor.
2946+
:type repository: Optional[Repository.Repository]
2947+
"""
28552948
test_results = get_test_results(test)
2856-
platform_column = f"last_passed_on_{test.platform.value}"
2949+
current = comparison.build_state(test_results)
2950+
2951+
category_stats = []
2952+
failed_tests = []
2953+
regression_tests: Dict[int, RegressionTest] = {}
28572954
for category_results in test_results:
2858-
category_name = category_results['category'].name
2859-
2860-
category_test_pass_count = 0
2861-
for test in category_results['tests']:
2862-
if not test['error']:
2863-
category_test_pass_count += 1
2864-
if last_test_master and getattr(test['test'], platform_column) != last_test_master.id:
2865-
fixed_tests.append(test['test'])
2955+
passed_in_category = 0
2956+
for entry in category_results['tests']:
2957+
regression_tests[entry['test'].id] = entry['test']
2958+
if entry['error']:
2959+
failed_tests.append(entry['test'])
28662960
else:
2867-
if last_test_master and getattr(test['test'], platform_column) != last_test_master.id:
2868-
common_failed_tests.append(test['test'])
2869-
else:
2870-
extra_failed_tests.append(test['test'])
2961+
passed_in_category += 1
2962+
category_stats.append(CategoryTestInfo(category_results['category'].name,
2963+
len(category_results['tests']), passed_in_category))
2964+
2965+
last_test_master = g.db.query(Test).filter(Test.branch == "master", Test.test_type == TestType.commit,
2966+
Test.platform == test.platform).join(
2967+
TestProgress, Test.id == TestProgress.test_id).filter(
2968+
TestProgress.status == TestStatus.completed).order_by(TestProgress.id.desc()).first()
28712969

2872-
category_stats.append(CategoryTestInfo(category_name, len(category_results['tests']), category_test_pass_count))
2970+
already_used: Dict[Any, str] = {}
2971+
comparisons = [
2972+
_compare_against('the tip of master', last_test_master, current, regression_tests, already_used),
2973+
_compare_against('the commit this branch was cut from', find_ancestor_run(repository, test),
2974+
current, regression_tests, already_used),
2975+
]
28732976

2874-
return PrCommentInfo(category_stats, extra_failed_tests, fixed_tests, common_failed_tests, last_test_master)
2977+
return PrCommentInfo(category_stats, failed_tests, len(current) - len(failed_tests),
2978+
len(current), comparisons, last_test_master)
28752979

28762980

28772981
def comment_pr(test: Test) -> str:
@@ -2885,16 +2989,28 @@ def comment_pr(test: Test) -> str:
28852989

28862990
test_id = test.id
28872991
platform = test.platform.name
2888-
comment_info = get_info_for_pr_comment(test)
2889-
template = app.jinja_env.get_or_select_template('ci/pr_comment.txt')
2890-
message = template.render(comment_info=comment_info, test_id=test_id, platform=platform)
2891-
log.debug(f"GitHub PR Comment Message Created for Test_id: {test_id}")
28922992
if not g.github['bot_token']:
28932993
log.error(f"GitHub token not configured, cannot post PR comment for Test_id: {test_id}")
28942994
return Status.FAILURE
2995+
2996+
# Resolved before the report is built, because working out which commit this
2997+
# branch was cut from needs the repository. A failure here costs that one
2998+
# comparison; the comment is still worth posting without it.
2999+
gh = None
3000+
repository = None
28953001
try:
28963002
gh = Github(auth=Auth.Token(g.github['bot_token']))
28973003
repository = gh.get_repo(f"{g.github['repository_owner']}/{g.github['repository']}")
3004+
except Exception as e:
3005+
log.error(f"Could not reach GitHub for Test_id: {test_id} with Exception {e}")
3006+
3007+
comment_info = get_info_for_pr_comment(test, repository)
3008+
template = app.jinja_env.get_or_select_template('ci/pr_comment.txt')
3009+
message = template.render(comment_info=comment_info, test_id=test_id, platform=platform)
3010+
log.debug(f"GitHub PR Comment Message Created for Test_id: {test_id}")
3011+
try:
3012+
if repository is None or gh is None:
3013+
raise RuntimeError('no GitHub repository handle')
28983014
# Pull requests are just issues with code, so GitHub considers PR comments in issues
28993015
pull_request = repository.get_pull(number=test.pr_nr)
29003016
comments = pull_request.get_issue_comments()
@@ -2907,7 +3023,11 @@ def comment_pr(test: Test) -> str:
29073023
log.debug(f"GitHub PR Comment ID {comment.id} Uploaded for Test_id: {test_id}")
29083024
except Exception as e:
29093025
log.error(f"GitHub PR Comment Failed for Test_id: {test_id} with Exception {e}")
2910-
return Status.SUCCESS if len(comment_info.extra_failed_tests) == 0 else Status.FAILURE
3026+
# The verdict is whether the output matched what was approved, and nothing
3027+
# else. The comparisons in the comment explain a failure; they never excuse
3028+
# one, because a baseline that no longer matches reality is a thing to fix
3029+
# rather than a thing to pass.
3030+
return Status.SUCCESS if len(comment_info.failed_tests) == 0 else Status.FAILURE
29113031

29123032

29133033
@mod_ci.route('/show_maintenance')

0 commit comments

Comments
 (0)