44import datetime
55import fnmatch
66import hashlib
7+ import itertools
78import json
89import os
910import re
1415from collections import defaultdict
1516from functools import wraps
1617from pathlib import Path
17- from typing import Any , Callable , Dict , Optional , TypeVar
18+ from typing import Any , Callable , Dict , List , Optional , TypeVar
1819
1920import googleapiclient .discovery
2021import requests
3536from decorators import get_menu_entries , template_renderer
3637from mod_auth .controllers import check_access_rights , login_required
3738from mod_auth .models import Role
39+ from mod_ci import comparison
3840from mod_ci .forms import AddUsersToBlacklist , DeleteUserForm
3941from mod_ci .models import (BlockedUsers , CategoryTestInfo , GcpInstance ,
4042 MaintenanceMode , PendingDeletion , PrCommentInfo ,
41- Status )
43+ ReferenceComparison , Status )
4244from mod_customized .models import CustomizedTest
4345from mod_home .models import CCExtractorVersion , GeneralData
4446from mod_regression .models import (Category , RegressionTest ,
5355GITHUB_API_TIMEOUT = 30 # Timeout for GitHub API calls
5456GCP_API_TIMEOUT = 60 # Timeout for GCP API calls
5557ARTIFACT_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
5662GCP_OPERATION_MAX_WAIT = 1800 # 30 minutes max wait for GCP operations
5763GCP_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
28772981def 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