示例#1
0
    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
示例#2
0
            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))
示例#3
0
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))
示例#4
0
        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
示例#5
0
    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}.")
示例#6
0
    ]
    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}")