chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# isort: skip_file
|
||||
# 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.
|
||||
"""Package to enable testing of GitHub scripts."""
|
||||
Executable
+799
@@ -0,0 +1,799 @@
|
||||
#!/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.
|
||||
# ruff: noqa: E402, E501, F841, RUF012
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 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 cmd_utils import init_log
|
||||
from git_utils import GitHubRepo, git, parse_remote, post
|
||||
|
||||
Review = dict[str, Any]
|
||||
CIJob = dict[str, Any]
|
||||
Comment = dict[str, Any]
|
||||
CommentChecker = Callable[[Comment], bool]
|
||||
|
||||
EXPECTED_JOBS = ["tvm-ci/pr-head"]
|
||||
TVM_BOT_JENKINS_TOKEN = os.environ["TVM_BOT_JENKINS_TOKEN"]
|
||||
GH_ACTIONS_TOKEN = os.environ["GH_ACTIONS_TOKEN"]
|
||||
JENKINS_URL = "https://ci.tlcpack.ai/"
|
||||
THANKS_MESSAGE = r"(\s*)Thanks for contributing to TVM! Please refer to guideline https://tvm.apache.org/docs/contribute/ for useful information and tips. After the pull request is submitted, please request code reviews from \[Reviewers\]\(https://github.com/apache/incubator-tvm/blob/master/CONTRIBUTORS.md#reviewers\) by them in the pull request thread.(\s*)"
|
||||
|
||||
|
||||
def to_json_str(obj: Any) -> str:
|
||||
return json.dumps(obj, indent=2)
|
||||
|
||||
|
||||
COLLABORATORS_QUERY = """
|
||||
query ($owner: String!, $name: String!, $user: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
collaborators(query: $user, first: 100) {
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
MENTIONABLE_QUERY = """
|
||||
query ($owner: String!, $name: String!, $user: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
mentionableUsers(query: $user, first: 100) {
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
PR_QUERY = """
|
||||
query ($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
title
|
||||
body
|
||||
state
|
||||
author {
|
||||
login
|
||||
}
|
||||
comments(last: 100) {
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
}
|
||||
nodes {
|
||||
authorAssociation
|
||||
author {
|
||||
login
|
||||
}
|
||||
id
|
||||
updatedAt
|
||||
body
|
||||
}
|
||||
}
|
||||
authorCommits:commits(last:100) {
|
||||
nodes {
|
||||
commit {
|
||||
authors(first:100) {
|
||||
nodes {
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
statusCheckRollup {
|
||||
contexts(first: 100) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
nodes {
|
||||
... on CheckRun {
|
||||
name
|
||||
databaseId
|
||||
checkSuite {
|
||||
workflowRun {
|
||||
databaseId
|
||||
workflow {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
conclusion
|
||||
url
|
||||
}
|
||||
... on StatusContext {
|
||||
state
|
||||
context
|
||||
targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviewDecision
|
||||
reviews(last: 100) {
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
}
|
||||
nodes {
|
||||
body
|
||||
updatedAt
|
||||
url
|
||||
id
|
||||
authorCanPushToRepository
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
author {
|
||||
login
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def walk(obj, visitor, parent_key=None):
|
||||
"""
|
||||
Recursively call 'visitor' on all the children of a dictionary
|
||||
"""
|
||||
visitor(obj, parent_key)
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
walk(v, visitor, parent_key=k)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
walk(v, visitor)
|
||||
|
||||
|
||||
class PR:
|
||||
def __init__(
|
||||
self,
|
||||
number: int,
|
||||
owner: str,
|
||||
repo: str,
|
||||
dry_run: bool = False,
|
||||
raw_data: dict[str, Any] | None = None,
|
||||
):
|
||||
self.owner = owner
|
||||
self.number = number
|
||||
self.repo_name = repo
|
||||
self.dry_run = dry_run
|
||||
self.has_error = False
|
||||
|
||||
if dry_run and raw_data:
|
||||
# In test mode there is no need to fetch anything
|
||||
self.raw = raw_data
|
||||
self.github = None
|
||||
else:
|
||||
self.github = GitHubRepo(user=owner, repo=repo, token=os.environ["GITHUB_TOKEN"])
|
||||
if os.getenv("DEBUG", "0") == "1":
|
||||
# For local runs fill in the requested data but cache it for
|
||||
# later use
|
||||
cached_path = Path("pr.json")
|
||||
if not cached_path.exists():
|
||||
self.raw = self.fetch_data()
|
||||
with open(cached_path, "w") as f:
|
||||
json.dump(self.raw, f, indent=2)
|
||||
else:
|
||||
with open(cached_path) as f:
|
||||
self.raw = json.load(f)
|
||||
else:
|
||||
# Usual path, fetch the PR's data based on the number from
|
||||
# GitHub
|
||||
self.raw = self.fetch_data()
|
||||
|
||||
def checker(obj, parent_key):
|
||||
"""
|
||||
Verify that any paged results don't have extra data (if so the bot
|
||||
may still work since most relevant comments will be more recent)
|
||||
"""
|
||||
if parent_key == "pageInfo":
|
||||
if obj.get("hasPreviousPage", False):
|
||||
warnings.warn(f"Found {obj} with a previous page, bot may be missing data")
|
||||
if obj.get("hasNextPage", False):
|
||||
warnings.warn(f"Found {obj} with a next page, bot may be missing data")
|
||||
|
||||
walk(self.raw, checker)
|
||||
|
||||
logging.info(f"Verified data, running with PR {to_json_str(self.raw)}")
|
||||
|
||||
def __repr__(self):
|
||||
return json.dumps(self.raw, indent=2)
|
||||
|
||||
def react(self, comment: dict[str, Any], content: str):
|
||||
"""
|
||||
React with a thumbs up to a comment
|
||||
"""
|
||||
url = f"issues/comments/{comment['id']}/reactions"
|
||||
data = {"content": content}
|
||||
if self.dry_run:
|
||||
logging.info(f"Dry run, would have +1'ed to {url} with {data}")
|
||||
else:
|
||||
self.github.post(url, data=data)
|
||||
|
||||
def head_commit(self):
|
||||
return self.raw["commits"]["nodes"][0]["commit"]
|
||||
|
||||
def co_authors(self) -> list[str]:
|
||||
authors = []
|
||||
for commit in self.raw["authorCommits"]["nodes"]:
|
||||
# Co-authors always come after the main author according to the
|
||||
# GitHub docs, so ignore the first item
|
||||
for author in commit["commit"]["authors"]["nodes"][1:]:
|
||||
name = author["name"]
|
||||
email = author["email"]
|
||||
authors.append(f"{name} <{email}>")
|
||||
|
||||
return list(set(authors))
|
||||
|
||||
def head_oid(self):
|
||||
return self.head_commit()["oid"]
|
||||
|
||||
def ci_jobs(self) -> list[CIJob]:
|
||||
"""
|
||||
Get a list of all CI jobs (GitHub Actions and other) in a unified format
|
||||
"""
|
||||
jobs = []
|
||||
for item in self.head_commit()["statusCheckRollup"]["contexts"]["nodes"]:
|
||||
if "checkSuite" in item:
|
||||
# GitHub Actions job, parse separately
|
||||
status = item["conclusion"]
|
||||
if status is None:
|
||||
# If the 'conclusion' isn't filled out the job hasn't
|
||||
# finished yet
|
||||
status = "PENDING"
|
||||
workflow_name = item["checkSuite"]["workflowRun"]["workflow"]["name"]
|
||||
if workflow_name != "CI":
|
||||
# Ignore all jobs that aren't in the main.yml workflow (these are mostly
|
||||
# automation jobs that run on PRs for tagging / reviews)
|
||||
continue
|
||||
check_name = item["name"]
|
||||
jobs.append(
|
||||
{
|
||||
"name": f"{workflow_name} / {check_name}",
|
||||
"url": item["url"],
|
||||
"status": status.upper(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# GitHub Status (e.g. from Jenkins)
|
||||
jobs.append(
|
||||
{
|
||||
"name": item["context"],
|
||||
"url": item["targetUrl"],
|
||||
"status": item["state"].upper(),
|
||||
}
|
||||
)
|
||||
|
||||
logging.info(f"Found CI jobs for {self.head_commit()['oid']} {to_json_str(jobs)}")
|
||||
return jobs
|
||||
|
||||
def reviews(self) -> list[Review]:
|
||||
return self.raw["reviews"]["nodes"]
|
||||
|
||||
def head_commit_reviews(self) -> list[Review]:
|
||||
"""
|
||||
Find reviews associated with the head commit
|
||||
"""
|
||||
commits_to_review_status: dict[str, list[Review]] = {}
|
||||
|
||||
for review in self.reviews():
|
||||
if not review["authorCanPushToRepository"]:
|
||||
# ignore reviews from non-committers
|
||||
continue
|
||||
|
||||
oid = review["commit"]["oid"]
|
||||
if oid in commits_to_review_status:
|
||||
commits_to_review_status[oid].append(review)
|
||||
else:
|
||||
commits_to_review_status[oid] = [review]
|
||||
|
||||
# Only use the data for the head commit of the PR
|
||||
head_reviews = commits_to_review_status.get(self.head_oid(), [])
|
||||
return head_reviews
|
||||
|
||||
def fetch_data(self):
|
||||
"""
|
||||
Fetch the data for this PR from GitHub
|
||||
"""
|
||||
return self.github.graphql(
|
||||
query=PR_QUERY,
|
||||
variables={
|
||||
"owner": self.owner,
|
||||
"name": self.repo_name,
|
||||
"number": self.number,
|
||||
},
|
||||
)["data"]["repository"]["pullRequest"]
|
||||
|
||||
def search_collaborator(self, user: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query GitHub for collaborators matching 'user'
|
||||
"""
|
||||
return self.search_users(user, COLLABORATORS_QUERY)["collaborators"]["nodes"]
|
||||
|
||||
def search_users(self, user: str, query: str) -> list[dict[str, Any]]:
|
||||
return self.github.graphql(
|
||||
query=query,
|
||||
variables={
|
||||
"owner": self.owner,
|
||||
"name": self.repo_name,
|
||||
"user": user,
|
||||
},
|
||||
)["data"]["repository"]
|
||||
|
||||
def search_mentionable_users(self, user: str) -> list[dict[str, Any]]:
|
||||
return self.search_users(user, MENTIONABLE_QUERY)["mentionableUsers"]["nodes"]
|
||||
|
||||
def comment(self, text: str) -> None:
|
||||
"""
|
||||
Leave the comment 'text' on this PR
|
||||
"""
|
||||
logging.info(f"Commenting:\n{text}")
|
||||
# TODO: Update latest comment in-place if there has been no activity
|
||||
data = {"body": text}
|
||||
url = f"issues/{self.number}/comments"
|
||||
if self.dry_run:
|
||||
logging.info(
|
||||
f"Dry run, would have commented on url={url} commenting with data={to_json_str(data)}"
|
||||
)
|
||||
return
|
||||
|
||||
self.github.post(url, data=data)
|
||||
|
||||
def state(self) -> str:
|
||||
"""
|
||||
PR state (OPEN, CLOSED, MERGED, etc)
|
||||
"""
|
||||
return self.raw["state"]
|
||||
|
||||
def processed_body(self) -> str:
|
||||
body = self.raw["body"].strip().replace("\r", "")
|
||||
# Remove any @-mentions of people
|
||||
body = re.sub(r"(\s)@", r"\g<1>", body)
|
||||
|
||||
# Remove the auto-inserted text since it's not useful to have in the commit log
|
||||
body = re.sub(THANKS_MESSAGE, "\n\n", body)
|
||||
return body.strip()
|
||||
|
||||
def body_with_co_authors(self) -> str:
|
||||
"""
|
||||
Add 'Co-authored-by' strings to the PR body based on the prior commits
|
||||
in the PR
|
||||
"""
|
||||
body = self.processed_body()
|
||||
author_lines = self.co_authors()
|
||||
logging.info(f"Found co-authors: author_lines={author_lines}")
|
||||
full_author_lines = [f"Co-authored-by: {author_line}" for author_line in author_lines]
|
||||
|
||||
authors_to_add = []
|
||||
for author_line in author_lines:
|
||||
if author_line not in body:
|
||||
authors_to_add.append(f"Co-authored-by: {author_line}")
|
||||
|
||||
if len(authors_to_add) > 0:
|
||||
# If the line isn't already in the PR body (it could have been
|
||||
# added manually), put it in
|
||||
full_author_text = "\n".join(authors_to_add)
|
||||
body = f"{body}\n\n{full_author_text}"
|
||||
|
||||
return body
|
||||
|
||||
def merge(self) -> None:
|
||||
"""
|
||||
Request a merge of this PR via the GitHub API
|
||||
"""
|
||||
url = f"pulls/{self.number}/merge"
|
||||
|
||||
title = self.raw["title"] + f" (#{self.number})"
|
||||
body = self.body_with_co_authors()
|
||||
logging.info(f"Full commit:\n{title}\n\n{body}")
|
||||
|
||||
data = {
|
||||
"commit_title": title,
|
||||
"commit_message": body,
|
||||
# The SHA is necessary in case there was an update right when this
|
||||
# script ran, GitHub will sort out who won
|
||||
"sha": self.head_oid(),
|
||||
"merge_method": "squash",
|
||||
}
|
||||
if self.dry_run:
|
||||
logging.info(f"Dry run, would have merged with url={url} and data={to_json_str(data)}")
|
||||
return
|
||||
|
||||
r = self.github.put(url, data=data)
|
||||
logging.info(f"GitHub merge response: {r}")
|
||||
return r
|
||||
|
||||
def author(self) -> str:
|
||||
return self.raw["author"]["login"]
|
||||
|
||||
def find_failed_ci_jobs(self) -> list[CIJob]:
|
||||
# NEUTRAL is GitHub Action's way of saying cancelled
|
||||
return [
|
||||
job
|
||||
for job in self.ci_jobs()
|
||||
if job["status"] not in {"SUCCESS", "SUCCESSFUL", "SKIPPED"}
|
||||
]
|
||||
|
||||
def find_missing_expected_jobs(self) -> list[str]:
|
||||
# Map of job name: has seen in completed jobs
|
||||
seen_expected_jobs = {name: False for name in EXPECTED_JOBS}
|
||||
logging.info(f"Expected to see jobs: {seen_expected_jobs}")
|
||||
|
||||
missing_expected_jobs = []
|
||||
for job in self.ci_jobs():
|
||||
seen_expected_jobs[job["name"]] = True
|
||||
|
||||
for name, seen in seen_expected_jobs.items():
|
||||
if not seen:
|
||||
missing_expected_jobs.append(name)
|
||||
|
||||
return missing_expected_jobs
|
||||
|
||||
def trigger_gha_ci(self, sha: str) -> None:
|
||||
logging.info("POST-ing a workflow_dispatch event to main.yml")
|
||||
actions_github = GitHubRepo(
|
||||
user=self.github.user, repo=self.github.repo, token=GH_ACTIONS_TOKEN
|
||||
)
|
||||
r = actions_github.post(
|
||||
url="actions/workflows/main.yml/dispatches",
|
||||
data={
|
||||
"ref": "main",
|
||||
},
|
||||
)
|
||||
logging.info(f"Successful workflow_dispatch: {r}")
|
||||
|
||||
def merge_if_passed_checks(self) -> dict[str, Any] | None:
|
||||
failed_ci_jobs = self.find_failed_ci_jobs()
|
||||
all_ci_passed = len(failed_ci_jobs) == 0
|
||||
has_one_approval = False
|
||||
|
||||
if not all_ci_passed:
|
||||
failed_jobs_msg = "\n".join(
|
||||
[f" * [{job['name']} (`{job['status']}`)]({job['url']})" for job in failed_ci_jobs]
|
||||
)
|
||||
self.comment(
|
||||
f"Cannot merge, these CI jobs are not successful on {self.head_oid()}:\n{failed_jobs_msg}"
|
||||
)
|
||||
return None
|
||||
|
||||
missing_expected_jobs = self.find_missing_expected_jobs()
|
||||
|
||||
if len(missing_expected_jobs) > 0:
|
||||
missing_jobs_msg = "\n".join([f" * `{name}`" for name in missing_expected_jobs])
|
||||
self.comment(f"Cannot merge, missing expected jobs:\n{missing_jobs_msg}")
|
||||
return None
|
||||
|
||||
head_commit_reviews = self.head_commit_reviews()
|
||||
for review in head_commit_reviews:
|
||||
if review["state"] == "CHANGES_REQUESTED":
|
||||
self.comment(
|
||||
f"Cannot merge, found [this review]({review['url']}) on {self.head_oid()} with changes requested"
|
||||
)
|
||||
return None
|
||||
|
||||
if review["state"] == "APPROVED":
|
||||
has_one_approval = True
|
||||
logging.info(f"Found approving review: {to_json_str(review)}")
|
||||
|
||||
if has_one_approval and all_ci_passed:
|
||||
return self.merge()
|
||||
elif not has_one_approval:
|
||||
self.comment(
|
||||
f"Cannot merge, did not find any approving reviews from users with write access on {self.head_oid()}"
|
||||
)
|
||||
return None
|
||||
elif not all_ci_passed:
|
||||
self.comment(f"Cannot merge, CI did not pass on on {self.head_oid()}")
|
||||
return None
|
||||
|
||||
def rerun_jenkins_ci(self) -> None:
|
||||
job_names = [
|
||||
"tvm-arm",
|
||||
"tvm-cpu",
|
||||
"tvm-docker",
|
||||
"tvm-gpu",
|
||||
"tvm-wasm",
|
||||
]
|
||||
for name in job_names:
|
||||
url = JENKINS_URL + f"job/{name}/job/PR-{self.number}/buildWithParameters"
|
||||
logging.info(f"Rerunning ci with URL={url}")
|
||||
if self.dry_run:
|
||||
logging.info("Dry run, not sending POST")
|
||||
else:
|
||||
post(url, auth=("tvm-bot", TVM_BOT_JENKINS_TOKEN))
|
||||
|
||||
def rerun_github_actions(self) -> None:
|
||||
workflow_ids = []
|
||||
for item in self.head_commit()["statusCheckRollup"]["contexts"]["nodes"]:
|
||||
if "checkSuite" in item and item["conclusion"] == "FAILURE":
|
||||
workflow_id = item["checkSuite"]["workflowRun"]["databaseId"]
|
||||
workflow_ids.append(workflow_id)
|
||||
|
||||
workflow_ids = list(set(workflow_ids))
|
||||
logging.info(f"Rerunning GitHub Actions workflows with IDs: {workflow_ids}")
|
||||
if self.dry_run:
|
||||
actions_github = None
|
||||
else:
|
||||
actions_github = GitHubRepo(
|
||||
user=self.github.user, repo=self.github.repo, token=GH_ACTIONS_TOKEN
|
||||
)
|
||||
for workflow_id in workflow_ids:
|
||||
if self.dry_run:
|
||||
logging.info(f"Dry run, not restarting workflow {workflow_id}")
|
||||
else:
|
||||
try:
|
||||
actions_github.post(f"actions/runs/{workflow_id}/rerun-failed-jobs", data={})
|
||||
except RuntimeError as e:
|
||||
logging.exception(e)
|
||||
# Ignore errors about jobs that are part of the same workflow to avoid
|
||||
# having to figure out which jobs are in which workflows ahead of time
|
||||
if "The workflow run containing this job is already running" in str(e):
|
||||
pass
|
||||
else:
|
||||
raise e
|
||||
|
||||
def comment_failure(self, msg: str, exceptions: Exception | list[Exception]):
|
||||
if not isinstance(exceptions, list):
|
||||
exceptions = [exceptions]
|
||||
|
||||
logging.info(f"Failed, commenting {exceptions}")
|
||||
|
||||
# Extract all the traceback strings
|
||||
for item in exceptions:
|
||||
try:
|
||||
raise item
|
||||
except Exception:
|
||||
item.exception_msg = traceback.format_exc()
|
||||
|
||||
comment = f"{msg} in {args.run_url}\n\n"
|
||||
for exception in exceptions:
|
||||
comment += f"<details>\n\n```\n{exception.exception_msg}\n```\n\n"
|
||||
if hasattr(exception, "read"):
|
||||
comment += f"with response\n\n```\n{exception.read().decode()}\n```\n\n"
|
||||
comment += "</details>"
|
||||
|
||||
pr.comment(comment)
|
||||
pr.has_error = True
|
||||
return exception
|
||||
|
||||
|
||||
def check_author(pr, triggering_comment, args):
|
||||
comment_author = triggering_comment["user"]["login"]
|
||||
if pr.author() == comment_author:
|
||||
logging.info("Comment user is PR author, continuing")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def search_users(name, triggering_comment, testing_json, search_fn):
|
||||
logging.info(f"Checking {name}")
|
||||
commment_author = triggering_comment["user"]["login"]
|
||||
if testing_json:
|
||||
matching_users = json.loads(testing_json)
|
||||
else:
|
||||
matching_users = search_fn(commment_author)
|
||||
logging.info(f"Found {name}: {matching_users}")
|
||||
user_names = {user["login"] for user in matching_users}
|
||||
|
||||
return len(matching_users) > 0 and commment_author in user_names
|
||||
|
||||
|
||||
def check_collaborator(pr, triggering_comment, args):
|
||||
return search_users(
|
||||
name="collaborators",
|
||||
triggering_comment=triggering_comment,
|
||||
search_fn=pr.search_collaborator,
|
||||
testing_json=args.testing_collaborators_json,
|
||||
)
|
||||
|
||||
|
||||
def check_mentionable_users(pr, triggering_comment, args):
|
||||
return search_users(
|
||||
name="mentionable users",
|
||||
triggering_comment=triggering_comment,
|
||||
search_fn=pr.search_mentionable_users,
|
||||
testing_json=args.testing_mentionable_users_json,
|
||||
)
|
||||
|
||||
|
||||
AUTH_CHECKS = {
|
||||
"mentionable_users": check_mentionable_users,
|
||||
"collaborators": check_collaborator,
|
||||
"author": check_author,
|
||||
}
|
||||
# Stash the keys so they're accessible from the values
|
||||
AUTH_CHECKS = {k: (k, v) for k, v in AUTH_CHECKS.items()}
|
||||
|
||||
|
||||
class Merge:
|
||||
triggers = [
|
||||
"merge",
|
||||
"merge this",
|
||||
"merge this pr",
|
||||
]
|
||||
|
||||
auth = [AUTH_CHECKS["collaborators"], AUTH_CHECKS["author"]]
|
||||
|
||||
@staticmethod
|
||||
def run(pr: PR):
|
||||
info = None
|
||||
try:
|
||||
info = pr.merge_if_passed_checks()
|
||||
except Exception as e:
|
||||
pr.comment_failure("Failed to process merge request", e)
|
||||
raise e
|
||||
|
||||
if info is not None:
|
||||
try:
|
||||
pr.trigger_gha_ci(sha=info["sha"])
|
||||
except Exception as e:
|
||||
pr.comment_failure("Failed to trigger GitHub Actions", e)
|
||||
raise e
|
||||
|
||||
|
||||
class Rerun:
|
||||
triggers = [
|
||||
"rerun",
|
||||
"rerun ci",
|
||||
"re-run",
|
||||
"re-run ci",
|
||||
"run",
|
||||
"run ci",
|
||||
]
|
||||
|
||||
auth = [AUTH_CHECKS["mentionable_users"]]
|
||||
|
||||
@staticmethod
|
||||
def run(pr: PR):
|
||||
errors = []
|
||||
try:
|
||||
pr.rerun_jenkins_ci()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
errors.append(e)
|
||||
|
||||
try:
|
||||
pr.rerun_github_actions()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
errors.append(e)
|
||||
|
||||
if len(errors) > 0:
|
||||
pr.comment_failure("Failed to re-run CI", errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
help = "Check if a PR has comments trying to merge it, and do so based on reviews/CI status"
|
||||
parser = argparse.ArgumentParser(description=help)
|
||||
parser.add_argument("--remote", default="origin", help="ssh remote to parse")
|
||||
parser.add_argument("--pr", required=True, help="pr number to check")
|
||||
parser.add_argument("--run-url", required=True, help="workflow run URL")
|
||||
parser.add_argument(
|
||||
"--trigger-comment-json", required=True, help="json of the comment that triggered this run"
|
||||
)
|
||||
parser.add_argument("--testing-pr-json", help="(testing only) manual data for testing")
|
||||
parser.add_argument(
|
||||
"--testing-collaborators-json", help="(testing only) manual data for testing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--testing-mentionable-users-json", help="(testing only) manual data for testing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="run but don't send any request to GitHub",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
init_log()
|
||||
comment = json.loads(args.trigger_comment_json)
|
||||
body = comment["body"].strip()
|
||||
|
||||
# Check that the comment was addressed to tvm-bot
|
||||
if not body.startswith("@tvm-bot "):
|
||||
logging.info(f"Not a bot comment, '{body}' does not start with '@tvm-bot'")
|
||||
exit(0)
|
||||
|
||||
# Find the code to run for the command from the user
|
||||
user_command = body.lstrip("@tvm-bot").strip()
|
||||
command_to_run = None
|
||||
for command in [Merge, Rerun]:
|
||||
if user_command in command.triggers:
|
||||
command_to_run = command
|
||||
break
|
||||
|
||||
if command_to_run is None:
|
||||
logging.info(f"Command '{user_command}' did not match anything")
|
||||
exit(0)
|
||||
|
||||
# Find the remote for querying more data about the PR
|
||||
remote = git(["config", "--get", f"remote.{args.remote}.url"])
|
||||
logging.info(f"Using remote remote={remote}")
|
||||
owner, repo = parse_remote(remote)
|
||||
|
||||
if args.pr.strip() == "":
|
||||
logging.info("No PR number passed")
|
||||
exit(0)
|
||||
|
||||
logging.info(f"Checking owner={owner} repo={repo}")
|
||||
if args.testing_pr_json:
|
||||
pr = PR(
|
||||
number=int(args.pr),
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
dry_run=args.dry_run,
|
||||
raw_data=json.loads(args.testing_pr_json),
|
||||
)
|
||||
else:
|
||||
pr = PR(number=int(args.pr), owner=owner, repo=repo, dry_run=args.dry_run)
|
||||
|
||||
for name, check in command_to_run.auth:
|
||||
if check(pr, comment, args):
|
||||
logging.info(f"Passed auth check '{name}', continuing")
|
||||
# Only one authorization check needs to pass (e.g. just mentionable
|
||||
# or PR author), not all of them so quit
|
||||
break
|
||||
else:
|
||||
logging.info(f"Failed auth check '{name}', quitting")
|
||||
# Add a sad face
|
||||
pr.react(comment, "confused")
|
||||
exit(0)
|
||||
|
||||
# Acknowledge the comment with a react
|
||||
pr.react(comment, "+1")
|
||||
|
||||
state = pr.state()
|
||||
|
||||
if state != "OPEN":
|
||||
logging.info(f"Ignoring event on PR, state was not OPEN, instead was state={state}")
|
||||
exit(0)
|
||||
|
||||
# Run the command
|
||||
command_to_run.run(pr)
|
||||
|
||||
if pr.has_error:
|
||||
raise RuntimeError("PR commented a failure")
|
||||
Reference in New Issue
Block a user