15
15
# limitations under the License.
16
16
#
17
17
18
+ import datetime
18
19
import logging
20
+ import re
21
+ from typing import Optional , Set
19
22
20
23
import click
21
24
import coloredlogs
25
+ from dateutil .tz import tzlocal
22
26
from github import Github
27
+ from github .Commit import Commit
28
+ from github .PullRequest import PullRequest
23
29
24
30
__LOG_LEVELS__ = {
25
31
"debug" : logging .DEBUG ,
32
38
33
39
34
40
class Canceller :
35
- def __init__ (self , token ):
41
+ def __init__ (self , token : str , dry_run : bool ):
36
42
self .api = Github (token )
37
43
self .repo = self .api .get_repo (REPOSITORY )
44
+ self .dry_run = dry_run
45
+
46
+ def check_all_pending_prs (self , max_age , required_runs ):
47
+ cutoff = datetime .datetime .now (tzlocal ()) - max_age
48
+ logging .info ("Searching PRs updated after %s" , cutoff )
49
+ for pr in self .repo .get_pulls (state = "open" , sort = "updated" , direction = "desc" ):
50
+ pr_update = pr .updated_at if pr .updated_at else pr .created_at
51
+ pr_update = pr_update .astimezone (tzlocal ())
52
+
53
+ if pr_update < cutoff :
54
+ logging .warning (
55
+ "PR is too old (since %s, cutoff at %s). Skipping the rest..." ,
56
+ pr_update ,
57
+ cutoff ,
58
+ )
59
+ break
60
+ logging .info (
61
+ "Examining PR %d updated at %s: %s" , pr .number , pr_update , pr .title
62
+ )
63
+ self .check_pr (pr , required_runs )
64
+
65
+ def check_pr (self , pr : PullRequest , required_runs ):
66
+
67
+ last_commit : Optional [Commit ] = None
38
68
39
- def cancel_all_runs (self , pr_number , commit_sha , dry_run ):
40
- pr = self .repo .get_pull (pr_number )
41
- logging .info ("Examining PR '%s'" , pr .title )
42
69
for commit in pr .get_commits ():
43
- if commit .sha != commit_sha :
44
- logging .info ("Skipping SHA '%s' as it was not selected" , commit .sha )
70
+ logging .debug (" Found commit %s" , commit .sha )
71
+ if pr .head .sha == commit .sha :
72
+ last_commit = commit
73
+ break
74
+
75
+ if last_commit is None :
76
+ logging .error ("Could not find any commit in the pull request." )
77
+ return
78
+
79
+ logging .info ("Last commit is: %s" , last_commit .sha )
80
+
81
+ in_progress_workflows : Set [int ] = set ()
82
+ failed_check_names : Set [str ] = set ()
83
+
84
+ # Gather all workflows along with failed workflow names
85
+ for check_suite in last_commit .get_check_suites ():
86
+ if check_suite .conclusion == "success" :
87
+ # Finished without errors. Nothing to do here
45
88
continue
89
+ for run in check_suite .get_check_runs ():
90
+ if run .conclusion is not None and run .conclusion != "failure" :
91
+ logging .debug (
92
+ " Run %s is not interesting: (state %s, conclusion %s)" ,
93
+ run .name ,
94
+ run .status ,
95
+ run .conclusion ,
96
+ )
97
+ continue
46
98
47
- for check_suite in commit .get_check_suites ():
48
- for run in check_suite .get_check_runs ():
49
- if run .status in {"in_progress" , "queued" }:
50
- if dry_run :
51
- logging .warning ("DRY RUN: Will not stop run %s" , run .name )
52
- else :
53
- logging .warning ("Stopping run %s" , run .name )
54
- self .repo .get_workflow_run (run .id ).cancel ()
55
- else :
56
- logging .info ("Skip over run %s (%s)" , run .name , run .status )
99
+ # TODO: I am unclear how to really find the workflow id, however the
100
+ # HTML URL is like https://github.com/project-chip/connectedhomeip/actions/runs/11261874698/job/31316278705
101
+ # so need whatever is after run.
102
+ m = re .match (r".*/actions/runs/([\d]+)/job/.*" , run .html_url )
103
+ if not m :
104
+ logging .error (
105
+ "Failed to extract workflow number from %s" , run .html_url
106
+ )
107
+ continue
108
+
109
+ workflow_id = int (m .group (1 ))
110
+ if run .conclusion is None :
111
+ logging .info (
112
+ " Workflow %d (i.e. %s) still pending: %s" ,
113
+ workflow_id ,
114
+ run .name ,
115
+ run .status ,
116
+ )
117
+ in_progress_workflows .add (workflow_id )
118
+ elif run .conclusion == "failure" :
119
+ workflow = self .repo .get_workflow_run (workflow_id )
120
+ logging .warning (" Workflow %s Failed" , workflow .name )
121
+ failed_check_names .add (workflow .name )
122
+
123
+ if not any ([name in failed_check_names for name in required_runs ]):
124
+ logging .info ("No critical failures found" )
125
+ return
126
+
127
+ for id in in_progress_workflows :
128
+ workflow = self .repo .get_workflow_run (id )
129
+ if self .dry_run :
130
+ logging .warning ("DRY RUN: Will not stop %s" , workflow .name )
131
+ else :
132
+ workflow .cancel ()
133
+ logging .warning ("Stopping workflow %s" , workflow .name )
57
134
58
135
59
136
@click .command ()
@@ -63,12 +140,25 @@ def cancel_all_runs(self, pr_number, commit_sha, dry_run):
63
140
type = click .Choice (list (__LOG_LEVELS__ .keys ()), case_sensitive = False ),
64
141
help = "Determines the verbosity of script output." ,
65
142
)
66
- @click .option ("--pull-request" , type = int , help = "Pull request number to consider" )
67
- @click .option ("--commit-sha" , help = "Commit to look at when cancelling pull requests" )
68
143
@click .option ("--gh-api-token" , help = "Github token to use" )
69
144
@click .option ("--token-file" , help = "Read github token from the given file" )
70
145
@click .option ("--dry-run" , default = False , is_flag = True , help = "Actually cancel or not" )
71
- def main (log_level , pull_request , commit_sha , gh_api_token , token_file , dry_run ):
146
+ @click .option (
147
+ "--max-pr-age-days" , default = 0 , type = int , help = "How many days to look at PRs"
148
+ )
149
+ @click .option (
150
+ "--max-pr-age-minutes" , default = 0 , type = int , help = "How many minutes to look at PRs"
151
+ )
152
+ @click .option ("--require" , multiple = True , default = [], help = "Name of required runs" )
153
+ def main (
154
+ log_level ,
155
+ gh_api_token ,
156
+ token_file ,
157
+ dry_run ,
158
+ max_pr_age_days ,
159
+ max_pr_age_minutes ,
160
+ require ,
161
+ ):
72
162
coloredlogs .install (
73
163
level = __LOG_LEVELS__ [log_level ], fmt = "%(asctime)s %(levelname)-7s %(message)s"
74
164
)
@@ -80,7 +170,13 @@ def main(log_level, pull_request, commit_sha, gh_api_token, token_file, dry_run)
80
170
else :
81
171
raise Exception ("Require a --gh-api-token or --token-file to access github" )
82
172
83
- Canceller (gh_token ).cancel_all_runs (pull_request , commit_sha , dry_run )
173
+ max_age = datetime .timedelta (days = max_pr_age_days , minutes = max_pr_age_minutes )
174
+ if max_age == datetime .timedelta ():
175
+ raise Exception (
176
+ "Please specifiy a max age of minutes or days (--max-pr-age-days or --max-pr-age-minutes)"
177
+ )
178
+
179
+ Canceller (gh_token , dry_run ).check_all_pending_prs (max_age , require )
84
180
85
181
86
182
if __name__ == "__main__" :
0 commit comments