Esempio n. 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
Esempio n. 2
0
    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)}")
Esempio n. 3
0
    def check_pr_title():
        remote = git(["config", "--get", f"remote.{args.remote}.url"])
        user, repo = parse_remote(remote)

        if args.pr_title:
            title = args.pr_title
        else:
            github = GitHubRepo(token=os.environ["TOKEN"],
                                user=user,
                                repo=repo)
            pr = github.get(f"pulls/{args.pr}")
            title = pr["title"]
        print("pr title:", title)
        return title.startswith("[skip ci]")
Esempio n. 4
0
    def check_pr_title():
        remote = git(["config", "--get", f"remote.{args.remote}.url"])
        user, repo = parse_remote(remote)

        if args.pr_title:
            title = args.pr_title
        else:
            github = GitHubRepo(token=os.environ["TOKEN"],
                                user=user,
                                repo=repo)
            pr = github.get(f"pulls/{args.pr}")
            title = pr["title"]
        logging.info(f"pr title: {title}")
        tags = tags_from_title(title)
        logging.info(f"Found title tags: {tags}")
        return "skip ci" in tags
Esempio n. 5
0
def fetch_pr_data(args, cache):
    github = GitHubRepo(user=user, repo=repo, token=GITHUB_TOKEN)

    if args.from_commit is None or args.to_commit is None:
        print(
            "--from-commit and --to-commit must be specified if --skip-query is not used"
        )
        exit(1)

    i = 0
    page_size = 80
    cursor = f"{args.from_commit} {i}"

    while True:
        r = github.graphql(
            query=PRS_QUERY,
            variables={
                "owner": user,
                "name": repo,
                "after": cursor,
                "pageSize": page_size,
            },
        )
        data = r["data"]["repository"]["defaultBranchRef"]["target"]["history"]
        if not data["pageInfo"]["hasNextPage"]:
            break
        cursor = data["pageInfo"]["endCursor"]
        results = data["nodes"]

        to_add = []
        stop = False
        for r in results:
            if r["oid"] == args.to_commit:
                print(f"Found {r['oid']}, stopping")
                stop = True
                break
            else:
                to_add.append(r)

        oids = [r["oid"] for r in to_add]
        print(oids)
        append_and_save(to_add, cache)
        if stop:
            break
        print(i)
        i += page_size
Esempio n. 6
0
def fetch_issue(github: GitHubRepo, issue_number: int):
    query = """query($owner: String!, $name: String!, $number: Int!){
    repository(owner: $owner, name: $name) {
        issue(number: $number) {
        body
        comments(first:100) {
            nodes {
            body
            }
        }
        }
    }
    }"""
    r = github.graphql(
        query,
        variables={
            "owner": github.user,
            "name": github.repo,
            "number": issue_number,
        },
    )
    return r
Esempio n. 7
0
        f"  time cutoff: {wait_time}\n"
        f"  number cutoff: {cutoff_pr_number}\n"
        f"  dry run: {args.dry_run}\n"
        f"  user/repo: {user}/{repo}\n",
        end="",
    )

    # [slow rollout]
    # This code is here to gate this feature to a limited set of people before
    # deploying it for everyone to avoid spamming in the case of bugs or
    # ongoing development.
    if args.allowlist:
        author_allowlist = args.allowlist.split(",")
    else:
        github = GitHubRepo(token=os.environ["GITHUB_TOKEN"],
                            user=user,
                            repo=repo)
        allowlist_issue = github.get("issues/9983")
        author_allowlist = set(find_reviewers(allowlist_issue["body"]))

    if args.pr_json:
        r = json.loads(args.pr_json)
    else:
        q = prs_query(user, repo)
        r = github.graphql(q)

    now = datetime.datetime.utcnow()
    if args.now:
        now = datetime.datetime.strptime(args.now, GIT_DATE_FORMAT)

    # Loop until all PRs have been checked
Esempio n. 8
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))
Esempio n. 9
0
    if args.dry_run:
        logging.info("Dry run, would have committed Jenkinsfile")
    else:
        logging.info(f"Creating git commit")
        git(["checkout", "-B", BRANCH])
        git(["add", str(JENKINSFILE.relative_to(REPO_ROOT))])
        git(["add", str(GENERATED_JENKINSFILE.relative_to(REPO_ROOT))])
        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
Esempio n. 10
0
    # Check if anything in the last commit's body message matches
    log_match, reason = check_match(log, SLOW_TEST_TRIGGERS)
    if log_match:
        print(f"Matched {reason} in commit message:\n{display(log)}, running slow tests")
        exit(1)

    print(
        f"Last commit:\n{display(log)}\ndid not have any of {SLOW_TEST_TRIGGERS}, checking PR body..."
    )

    if args.pr_body:
        body = args.pr_body
    else:
        remote = git(["config", "--get", f"remote.{args.remote}.url"])
        user, repo = parse_remote(remote)

        github = GitHubRepo(token=os.environ["GITHUB_TOKEN"], user=user, repo=repo)
        pr = github.get(f"pulls/{args.pr}")
        body = pr["body"]

    body_match, reason = check_match(body, SLOW_TEST_TRIGGERS)

    if body_match:
        print(f"Matched {reason} in PR body:\n{display(body)}, running slow tests")
        exit(1)

    print(
        f"PR Body:\n{display(body)}\ndid not have any of {SLOW_TEST_TRIGGERS}, skipping slow tests"
    )
    exit(0)
Esempio n. 11
0
    remote = git(["config", "--get", f"remote.{args.remote}.url"])
    user, repo = parse_remote(remote)

    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:
Esempio n. 12
0
        f"  time cutoff: {wait_time}\n"
        f"  number cutoff: {cutoff_pr_number}\n"
        f"  dry run: {args.dry_run}\n"
        f"  user/repo: {user}/{repo}\n",
        end="",
    )

    # [slow rollout]
    # This code is here to gate this feature to a limited set of people before
    # deploying it for everyone to avoid spamming in the case of bugs or
    # ongoing development.
    if args.allowlist:
        author_allowlist = args.allowlist.split(",")
    else:
        github = GitHubRepo(token=os.environ["GITHUB_TOKEN"],
                            user=user,
                            repo=repo)
        allowlist_issue = github.get("issues/9983")
        author_allowlist = set(find_reviewers(allowlist_issue["body"]))

    if args.pr_json:
        r = json.loads(args.pr_json)
    else:
        q = prs_query(user, repo)
        r = github.graphql(q)

    now = datetime.datetime.utcnow()
    if args.now:
        now = datetime.datetime.strptime(args.now, GIT_DATE_FORMAT)

    # Loop until all PRs have been checked
Esempio n. 13
0
    parser.add_argument(
        "--dry-run",
        action="store_true",
        default=False,
        help="run but don't send any request to GitHub",
    )
    args = parser.parse_args()

    remote = git(["config", "--get", f"remote.{args.remote}.url"])
    user, repo = parse_remote(remote)

    if args.team_issue_json:
        issue_data = json.loads(args.team_issue_json)
    else:
        github = GitHubRepo(token=os.environ["GITHUB_TOKEN"],
                            user=user,
                            repo=repo)
        issue_data = fetch_issue(github, issue_number=int(args.team_issue))

    # Fetch the list of teams
    teams = parse_teams(issue_data, issue_number=int(args.team_issue))
    # When rolling out this tool it is limited to certain users, so find that list
    rollout_users = find_rollout_users(issue_data)
    print(f"[slow rollout] Limiting to opted-in users: {rollout_users}")

    print(
        f"Found these teams in issue #{args.team_issue}\n{json.dumps(teams, indent=2)}"
    )

    # Extract the payload from GitHub Actions
    issue = json.loads(os.getenv("ISSUE", "null"))
Esempio n. 14
0
    pr = json.loads(os.environ["PR"])

    number = pr["number"]
    body = pr["body"]
    if body is None:
        body = ""

    new_reviewers = find_reviewers(body)
    print("Found these reviewers:", new_reviewers)

    if args.testing_reviews_json:
        existing_reviews = json.loads(args.testing_reviews_json)
    else:
        github = GitHubRepo(token=os.environ["GITHUB_TOKEN"],
                            user=user,
                            repo=repo)
        existing_reviews = github.get(f"pulls/{number}/reviews")

    existing_review_users = [
        review["user"]["login"] for review in existing_reviews
    ]
    print("PR has reviews from these users:", existing_review_users)
    existing_review_users = set(r.lower() for r in existing_review_users)

    existing_reviewers = [
        review["login"] for review in pr["requested_reviewers"]
    ]
    print("PR already had these reviewers requested:", existing_reviewers)

    existing_reviewers_lower = {
Esempio n. 15
0
    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)
    # TODO: Remove this before landing
    user, repo = ("apache", "tvm")

    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: