Skip to content

Commit cc50e9e

Browse files
andy31415andreilitvin
andauthoredOct 11, 2024
Add logic to stop full CI if some fast and required checks fail (#36000)
* Add job cancel logic and intentional failure. * Give github token permissions on actions * Update logic to have a scheduled job * Restyle * Undo manual change * Remove unnedded files * Undo extra changes * Update date-time sorting * Fix up none possibility on updated at * Be more strict on max age now that we use udpate time * Add support for minute age PRs since we run this so frequently * Make cutoff to not be a rolling target * Use created time if updated time is not available * Make date-time display be reasonable * Null handle fix * Restyle --------- Co-authored-by: Andrei Litvin <andreilitvin@google.com>
1 parent f5bf220 commit cc50e9e

File tree

2 files changed

+126
-34
lines changed

2 files changed

+126
-34
lines changed
 

‎.github/workflows/cancel_workflows_for_pr.yaml

+10-14
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
name: Cancel workflow on PR
15+
name: Cancel workflows on failing CI
1616
on:
1717
workflow_dispatch:
18-
inputs:
19-
pull_request_id:
20-
description: 'PR number to consider'
21-
required: true
22-
type: number
23-
commit_sha:
24-
description: 'Cancel runs for this specific SHA'
25-
required: true
26-
type: string
18+
schedule:
19+
- cron: "*/10 * * * *"
2720

2821
jobs:
2922
cancel_workflow:
30-
name: Report on pull requests
23+
name: Cancel CI on failing pull requests
3124

3225
runs-on: ubuntu-latest
3326

@@ -50,6 +43,9 @@ jobs:
5043
- name: Cancel runs
5144
run: |
5245
scripts/tools/cancel_workflows_for_pr.py \
53-
--pull-request ${{ inputs.pull_request_id }} \
54-
--commit-sha "${{ inputs.commit_sha }}" \
55-
--gh-api-token "${{ secrets.GITHUB_TOKEN }}"
46+
--gh-api-token "${{ secrets.GITHUB_TOKEN }}" \
47+
--require "Restyled" \
48+
--require "Lint Code Base" \
49+
--require "ZAP" \
50+
--require "Run misspell" \
51+
--max-pr-age-minutes 20

‎scripts/tools/cancel_workflows_for_pr.py

+116-20
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@
1515
# limitations under the License.
1616
#
1717

18+
import datetime
1819
import logging
20+
import re
21+
from typing import Optional, Set
1922

2023
import click
2124
import coloredlogs
25+
from dateutil.tz import tzlocal
2226
from github import Github
27+
from github.Commit import Commit
28+
from github.PullRequest import PullRequest
2329

2430
__LOG_LEVELS__ = {
2531
"debug": logging.DEBUG,
@@ -32,28 +38,99 @@
3238

3339

3440
class Canceller:
35-
def __init__(self, token):
41+
def __init__(self, token: str, dry_run: bool):
3642
self.api = Github(token)
3743
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
3868

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)
4269
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
4588
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
4698

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

58135

59136
@click.command()
@@ -63,12 +140,25 @@ def cancel_all_runs(self, pr_number, commit_sha, dry_run):
63140
type=click.Choice(list(__LOG_LEVELS__.keys()), case_sensitive=False),
64141
help="Determines the verbosity of script output.",
65142
)
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")
68143
@click.option("--gh-api-token", help="Github token to use")
69144
@click.option("--token-file", help="Read github token from the given file")
70145
@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+
):
72162
coloredlogs.install(
73163
level=__LOG_LEVELS__[log_level], fmt="%(asctime)s %(levelname)-7s %(message)s"
74164
)
@@ -80,7 +170,13 @@ def main(log_level, pull_request, commit_sha, gh_api_token, token_file, dry_run)
80170
else:
81171
raise Exception("Require a --gh-api-token or --token-file to access github")
82172

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

85181

86182
if __name__ == "__main__":

0 commit comments

Comments
 (0)