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}") 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
else: print( f"Checking #{pr['number']} since author is in {author_allowlist}" ) prs_to_check.append(pr) print(f"Summary: Checking {len(prs_to_check)} of {len(prs)} fetched") # Ping reviewers on each PR in the response if necessary for pr in prs_to_check: print("Checking", pr["url"]) reviewers = check_pr(pr, wait_time, now) if reviewers is not None: message = make_ping_message(pr, reviewers) if args.dry_run: print( f"Would have commented on #{pr['number']}:\n{textwrap.indent(message, prefix=' ')}" ) else: r = github.post(f"issues/{pr['number']}/comments", {"body": message}) print(r) edges = r["data"]["repository"]["pullRequests"]["edges"] if len(edges) == 0: # No more results to check break cursor = edges[0]["cursor"] r = github.graphql(prs_query(user, repo, cursor))
class PR: def __init__( self, number: int, owner: str, repo: str, dry_run: bool = False, raw_data: Dict[str, Any] = None, ): self.owner = owner self.number = number self.repo_name = repo self.dry_run = dry_run 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 plus_one(self, comment: Dict[str, Any]): """ React with a thumbs up to a comment """ url = f"issues/comments/{comment['id']}/reactions" data = {"content": "+1"} 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" jobs.append( { "name": item["checkSuite"]["workflowRun"]["workflow"]["name"] + " / " + item["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.github.graphql( query=COLLABORATORS_QUERY, variables={ "owner": self.owner, "name": self.repo_name, "user": user, }, )["data"]["repository"]["collaborators"]["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)@", "\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 self.github.put(url, data=data) 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 merge_if_passed_checks(self) -> 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 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 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 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: 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 elif not all_ci_passed: self.comment(f"Cannot merge, CI did not pass on on {self.head_oid()}") return def rerun_jenkins_ci(self) -> None: url = JENKINS_URL + f"job/tvm/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))
git(["config", "user.name", "tvm-bot"]) git([ "config", "user.email", "*****@*****.**" ]) git(["commit", "-m", message]) git(["push", "--set-upstream", args.remote, BRANCH, "--force"]) logging.info(f"Sending PR to GitHub") github = GitHubRepo(user=user, repo=repo, token=GITHUB_TOKEN) data = { "title": title, "body": body, "head": BRANCH, "base": "main", "maintainer_can_modify": True, } url = "pulls" if args.dry_run: logging.info(f"Dry run, would have sent {data} to {url}") else: try: github.post(url, data=data) except error.HTTPError as e: # Ignore the exception if the PR already exists (which gives a 422). The # existing PR will have been updated in place if e.code == 422: logging.info("PR already exists, ignoring error") logging.exception(e) else: raise e
target_url = os.environ["TARGET_URL"] pr_and_build = get_pr_and_build_numbers(target_url) commit_sha = os.environ["COMMIT_SHA"] docs_url = build_docs_url(args.base_url_docs, pr_and_build["pr_number"], pr_and_build["build_number"]) url = f'issues/{pr_and_build["pr_number"]}/comments' body = f"Built docs for commit [{commit_sha}]({commit_sha}) can be found [here]({docs_url})." if not args.dry_run: github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo) # For now, only comment for PRs open by driazati, gigiblender and areusch. get_pr_url = f'pulls/{pr_and_build["pr_number"]}' pull_request_body = github.get(get_pr_url) author = pull_request_body["user"]["login"] if author not in ["driazati", "gigiblender", "areusch"]: logging.info(f"Skipping this action for user {author}") sys.exit(0) try: github.post(url, {"body": body}) except error.HTTPError as e: logging.exception(f"Failed to add docs comment {docs_url}: {e}") else: logging.info(f"Dry run, would have posted {url} with data {body}.")
] print("PR already had these reviewers requested:", existing_reviewers) existing_reviewers_lower = { existing_reviewer.lower() for existing_reviewer in existing_reviewers } to_add = [] for new_reviewer in new_reviewers: if (new_reviewer.lower() in existing_reviewers_lower or new_reviewer.lower() in existing_review_users): print(f"{new_reviewer} is already review requested, skipping") else: to_add.append(new_reviewer) print(f"After filtering existing reviewers, adding: {to_add}") if not args.dry_run: github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo) # Add reviewers 1 by 1 since GitHub will error out if any of the # requested reviewers aren't members / contributors for reviewer in to_add: try: github.post(f"pulls/{number}/requested_reviewers", {"reviewers": [reviewer]}) except error.HTTPError as e: print(f"Failed to add reviewer {reviewer}: {e}")