blob: 64d80eb9d5e7b09e21595842158fd097e215db23 [file] [log] [blame]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import os
import json
import argparse
import sys
from pathlib import Path
from typing import Any, Dict
# Hackery to enable importing of utils from ci/scripts/jenkins
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
sys.path.append(str(REPO_ROOT / "ci" / "scripts" / "jenkins"))
from git_utils import git, GitHubRepo, parse_remote
_commit_query_fields = """
messageHeadline
oid
statusCheckRollup {
contexts(last:100) {
nodes {
... on CheckRun {
conclusion
status
name
checkSuite {
workflowRun {
workflow {
name
}
}
}
}
... on StatusContext {
context
state
}
}
}
}
"""
def commits_query(user: str, repo: str, cursor: str = None):
"""
Create a GraphQL query to find the last N commits along with their statuses
and some metadata (paginated after 'cursor')
"""
after = ""
if cursor is not None:
after = f', after:"{cursor}"'
return f"""
{{
repository(name: "{repo}", owner: "{user}") {{
defaultBranchRef {{
target {{
... on Commit {{
history(first: 15{after}) {{
edges {{ cursor }}
nodes {{
{_commit_query_fields}
}}
}}
}}
}}
}}
}}
}}
"""
EXPECTED_CI_JOBS = [
"cross-isa-minimal/branch",
"gpu/branch",
"arm/branch",
"cortexm/branch",
"cpu/branch",
"docker/branch",
"lint/branch",
"minimal/branch",
"riscv/branch",
"wasm/branch",
]
def commit_passed_ci(commit: Dict[str, Any]) -> bool:
"""
Returns true if all of a commit's statuses are SUCCESS
"""
statuses = commit["statusCheckRollup"]["contexts"]["nodes"]
# GitHub Actions statuses are different from external GitHub statuses, so
# unify them into 1 representation
# https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
unified_statuses = []
for status in statuses:
if "context" in status:
# Parse non-GHA status
unified_statuses.append((status["context"], status["state"] == "SUCCESS"))
else:
# Parse GitHub Actions item
workflow = status["checkSuite"]["workflowRun"]["workflow"]["name"]
name = f"{workflow} / {status['name']}"
unified_statuses.append((name, status["conclusion"] == "SUCCESS"))
print(f"Statuses on {commit['oid']}:", json.dumps(unified_statuses, indent=2))
# Assert that specific jobs are present in the commit statuses (i.e. don't
# approve if CI was broken and didn't schedule a job)
job_names = {name for name, status in unified_statuses}
for job in EXPECTED_CI_JOBS:
if job not in job_names:
# Did not find expected job name
return False
passed_ci = all(status for name, status in unified_statuses)
return passed_ci
def update_branch(user: str, repo: str, sha: str, branch_name: str) -> None:
git(["fetch", "origin", sha])
git(["reset", "--hard", "FETCH_HEAD"])
try:
git(["branch", "-D", branch_name])
except RuntimeError:
# Ignore failures (i.e. the branch did not exist in the first place)
pass
git(["checkout", "-b", branch_name])
# Create and push the branch
git(["push", "origin", "--force", branch_name])
print(f"Pushed branch {branch_name} with commit {sha}")
if __name__ == "__main__":
help = "Push the a branch to the last commit that passed all CI runs"
parser = argparse.ArgumentParser(description=help)
parser.add_argument("--remote", default="origin", help="ssh remote to parse")
parser.add_argument("--dry-run", action="store_true", help="don't submit to GitHub")
parser.add_argument("--branch", default="last-successful", help="branch name")
parser.add_argument(
"--testonly-json", help="(testing) data to use instead of fetching from GitHub"
)
args = parser.parse_args()
remote = git(["config", "--get", f"remote.{args.remote}.url"])
user, repo = parse_remote(remote)
if args.testonly_json:
r = json.loads(args.testonly_json)
else:
github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
q = commits_query(user, repo)
r = github.graphql(q)
commits = r["data"]["repository"]["defaultBranchRef"]["target"]["history"]["nodes"]
# Limit GraphQL pagination
MAX_COMMITS_TO_CHECK = 50
i = 0
while i < MAX_COMMITS_TO_CHECK:
# Check each commit
for commit in commits:
if commit_passed_ci(commit):
print(f"Found last good commit: {commit['oid']}: {commit['messageHeadline']}")
if not args.dry_run:
update_branch(
user=user,
repo=repo,
sha=commit["oid"],
branch_name=args.branch,
)
# Nothing to do after updating the branch, exit early
exit(0)
# No good commit found, proceed to next page of results
edges = r["data"]["repository"]["defaultBranchRef"]["target"]["history"]["edges"]
if len(edges) == 0:
break
else:
q = commits_query(user, repo, cursor=edges[-1]["cursor"])
r = github.graphql(q)
commits = r["data"]["repository"]["defaultBranchRef"]["target"]["history"]["nodes"]
# Backstop to prevent looking through all the past commits
i += len(commits)
print(f"No good commits found in the last {len(commits)} commits")
exit(1)