| #!/usr/bin/env python3 |
| # |
| # Copyright 2018 The Bazel Authors. All rights reserved. |
| # |
| # Licensed 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 argparse |
| import collections |
| import os |
| import re |
| import requests |
| import sys |
| import threading |
| |
| import bazelci |
| |
| BUILDKITE_ORG = os.environ["BUILDKITE_ORGANIZATION_SLUG"] |
| |
| PIPELINE = os.environ["BUILDKITE_PIPELINE_SLUG"] |
| |
| FAIL_IF_MIGRATION_REQUIRED = os.environ.get("USE_BAZELISK_MIGRATE", "").upper() == "FAIL" |
| |
| REPO_PATTERN = re.compile(r"https?://github.com/(?P<owner>[^/]+)/(?P<repo>[^/]+).git") |
| |
| EMOJI_PATTERN = re.compile(r":([\w+-]+):") |
| |
| EMOJI_IMAGE_TEMPLATE = '<img src="https://raw.githubusercontent.com/buildkite/emojis/master/img-buildkite-64/{}.png" height="16"/>' |
| |
| INCOMPATIBLE_FLAG_LINE_PATTERN = re.compile( |
| r"\s*(?P<flag>--incompatible_\S+)\s*(\(Bazel (?P<version>.+?): (?P<url>.+?)\))?" |
| ) |
| |
| ISSUE_TEMPLATE = """Incompatible flag {flag} will be enabled by default in {version}, thus breaking {project}. |
| |
| The flag is documented here: {issue_url} |
| |
| Please check the following CI builds for build and test results: |
| |
| {links} |
| |
| Never heard of incompatible flags before? We have [documentation](https://docs.bazel.build/versions/master/backward-compatibility.html) that explains everything. |
| |
| If you don't want to receive any future issues for {project} or if you have any questions, |
| please file an issue in https://github.com/bazelbuild/continuous-integration |
| |
| **Important**: Please do NOT modify the issue title since that might break our tools. |
| {version_footnote} |
| """ |
| |
| GITHUB_ISSUE_REPORTER = "bazel-flag-bot" |
| |
| GITHUB_TOKEN_KMS_KEY = "github-api-token" |
| |
| ENCRYPTED_GITHUB_API_TOKEN = """ |
| CiQA6OLsm0n0R4F/5qdkav2pVIJ+SJnDwcW0+aMgmE0m2UfAtgESUQBsAAJAzHhCcAOfDkOiO0VI7hdPac |
| vKgsR3LRgJhvwAhomic1ijEXFwUSOcCgvPYXcQK04YCKMJf+/DaExdtRmslvvCBkGI4tjUlMRhJ+8RLQ== |
| """.strip() |
| |
| |
| FlagDetails = collections.namedtuple("FlagDetails", ["bazel_version", "issue_url"]) |
| |
| |
| class GitHubError(Exception): |
| def __init__(self, code, message): |
| super(GitHubError, self).__init__("{}: {}".format(code, message)) |
| self.code = code |
| self.message = message |
| |
| |
| class GitHubIssueClient(object): |
| |
| LINK_PATTERN = re.compile(r'<(?P<url>.*?)>; rel="(?P<type>\w+)"') |
| |
| def __init__(self, reporter, oauth_token): |
| self._reporter = reporter |
| self._session = requests.Session() |
| self._session.headers.update( |
| { |
| "Accept": "application/vnd.github.v3+json", |
| "Authorization": "token {}".format(oauth_token), |
| "Content-Type": "application/json", |
| } |
| ) |
| |
| def get_issue(self, repo_owner, repo_name, title): |
| # Returns an arbitrary matching issue if multiple matching issues exist. |
| generator = self._send_request(repo_owner, repo_name, params={"creator": self._reporter}) |
| for issue_subset in generator: |
| for i in issue_subset: |
| if i["title"] == title: |
| return i["number"] |
| |
| def create_issue(self, repo_owner, repo_name, title, body): |
| generator = self._send_request( |
| repo_owner, |
| repo_name, |
| verb="post", |
| json={"title": title, "body": body, "assignee": None, "labels": [], "milestone": None}, |
| ) |
| return next(generator).get("number", "") |
| |
| def update_title(self, repo_owner, repo_name, issue_number, title): |
| self._send_request( |
| repo_owner, repo_name, issue=issue_number, verb="patch", json={"title": title} |
| ) |
| |
| def _send_request(self, repo_owner, repo_name, issue=None, verb="get", **kwargs): |
| url = "https://api.github.com/repos/{}/{}/issues".format(repo_owner, repo_name) |
| if issue: |
| url = os.path.join(url, str(issue)) |
| |
| method = getattr(self._session, verb) |
| |
| while url: |
| response = method(url, **kwargs) |
| if response.status_code // 100 != 2: |
| raise GitHubError(response.status_code, response.content) |
| |
| url = self.get_next_page_url(response.headers) |
| yield response.json() |
| |
| def get_next_page_url(self, headers): |
| link = headers.get("Link", "") |
| for part in link.split(","): |
| match = self.LINK_PATTERN.match(part.strip()) |
| if match and match.group("type") == "next": |
| return match.group("url") |
| |
| |
| class LogFetcher(threading.Thread): |
| def __init__(self, job, client): |
| threading.Thread.__init__(self) |
| self.job = job |
| self.client = client |
| self.log = None |
| |
| def run(self): |
| self.log = self.client.get_build_log(self.job) |
| |
| |
| def process_build_log(failed_jobs_per_flag, already_failing_jobs, log, job, details_per_flag): |
| if "Failure: Command failed, even without incompatible flags." in log: |
| already_failing_jobs.append(job) |
| |
| def handle_failing_flags(line, details_per_flag): |
| flag = extract_flag_details(line, details_per_flag) |
| if flag: |
| failed_jobs_per_flag[flag][job["id"]] = job |
| |
| # bazelisk --migrate might run for multiple times for run / build / test, |
| # so there could be several "+++ Result" sections. |
| while "+++ Result" in log: |
| index_success = log.rfind("Command was successful with the following flags:") |
| index_failure = log.rfind("Migration is needed for the following flags:") |
| if index_success == -1 or index_failure == -1: |
| raise bazelci.BuildkiteException("Cannot recognize log of " + job["web_url"]) |
| |
| extract_all_flags(log[index_success:index_failure], extract_flag_details, details_per_flag) |
| extract_all_flags(log[index_failure:], handle_failing_flags, details_per_flag) |
| log = log[0 : log.rfind("+++ Result")] |
| |
| # If the job failed for other reasons, we add it into already failing jobs. |
| if job["state"] == "failed": |
| already_failing_jobs.append(job) |
| |
| |
| def extract_all_flags(log, line_callback, details_per_flag): |
| for line in log.split("\n"): |
| line_callback(line, details_per_flag) |
| |
| |
| def extract_flag_details(line, details_per_flag): |
| match = INCOMPATIBLE_FLAG_LINE_PATTERN.match(line) |
| if match: |
| flag = match.group("flag") |
| if details_per_flag.get(flag, (None, None)) == (None, None): |
| details_per_flag[flag] = FlagDetails( |
| bazel_version=match.group("version"), issue_url=match.group("url") |
| ) |
| |
| return flag |
| |
| |
| def get_html_link_text(content, link): |
| return f'<a href="{link}" target="_blank">{content}</a>' |
| |
| |
| # Check if any of the given jobs needs to be migrated by the Bazel team |
| def needs_bazel_team_migrate(jobs): |
| for job in jobs: |
| pipeline, _ = get_pipeline_and_platform(job) |
| if pipeline in bazelci.DOWNSTREAM_PROJECTS and bazelci.DOWNSTREAM_PROJECTS[pipeline].get("owned_by_bazel"): |
| return True |
| return False |
| |
| |
| def print_flags_ready_to_flip(failed_jobs_per_flag, details_per_flag): |
| info_text1 = ["#### The following flags didn't break any passing projects"] |
| for flag in sorted(list(details_per_flag.keys())): |
| if flag not in failed_jobs_per_flag: |
| html_link_text = get_html_link_text(":github:", details_per_flag[flag].issue_url) |
| info_text1.append(f"* **{flag}** {html_link_text}") |
| |
| if len(info_text1) == 1: |
| info_text1 = [] |
| |
| info_text2 = ["#### The following flags didn't break any passing Bazel team owned/co-owned projects"] |
| for flag, jobs in failed_jobs_per_flag.items(): |
| if not needs_bazel_team_migrate(jobs.values()): |
| failed_cnt = len(jobs) |
| s1 = "" if failed_cnt == 1 else "s" |
| s2 = "s" if failed_cnt == 1 else "" |
| html_link_text = get_html_link_text(":github:", details_per_flag[flag].issue_url) |
| info_text2.append(f"* **{flag}** {html_link_text} ({failed_cnt} other job{s1} need{s2} migration)") |
| |
| if len(info_text2) == 1: |
| info_text2 = [] |
| |
| print_info("flags_ready_to_flip", "success", info_text1 + info_text2) |
| |
| |
| def print_already_fail_jobs(already_failing_jobs): |
| info_text = ["#### The following jobs already fail without incompatible flags"] |
| info_text += merge_and_format_jobs(already_failing_jobs, "* **{}**: {}") |
| if len(info_text) == 1: |
| return |
| print_info("already_fail_jobs", "warning", info_text) |
| |
| |
| def print_projects_need_to_migrate(failed_jobs_per_flag): |
| info_text = ["#### The following projects need migration"] |
| jobs_need_migration = {} |
| for jobs in failed_jobs_per_flag.values(): |
| for job in jobs.values(): |
| jobs_need_migration[job["name"]] = job |
| |
| job_list = jobs_need_migration.values() |
| job_num = len(job_list) |
| if job_num == 0: |
| return |
| |
| projects = set() |
| for job in job_list: |
| project, _ = get_pipeline_and_platform(job) |
| projects.add(project) |
| project_num = len(projects) |
| |
| s1 = "" if project_num == 1 else "s" |
| s2 = "s" if project_num == 1 else "" |
| info_text.append( |
| f"<details><summary>{project_num} project{s1} need{s2} migration, click to see details</summary><ul>" |
| ) |
| |
| entries = merge_and_format_jobs(job_list, " <li><strong>{}</strong>: {}</li>") |
| info_text += entries |
| info_text.append("</ul></details>") |
| |
| info_str = "\n".join(info_text) |
| bazelci.execute_command( |
| [ |
| "buildkite-agent", |
| "annotate", |
| "--append", |
| f"--context=projects_need_migration", |
| f"--style=error", |
| f"\n{info_str}\n", |
| ] |
| ) |
| |
| |
| def print_flags_need_to_migrate(failed_jobs_per_flag, details_per_flag): |
| # The info box printed later is above info box printed before, |
| # so reverse the flag list to maintain the same order. |
| printed_flag_boxes = False |
| for flag in sorted(list(failed_jobs_per_flag.keys()), reverse=True): |
| jobs = failed_jobs_per_flag[flag] |
| if jobs: |
| github_url = details_per_flag[flag].issue_url |
| info_text = [f"* **{flag}** " + get_html_link_text(":github:", github_url)] |
| jobs_per_pipeline = merge_jobs(jobs.values()) |
| for pipeline, platforms in jobs_per_pipeline.items(): |
| bazel_mark = "" |
| if pipeline in bazelci.DOWNSTREAM_PROJECTS and bazelci.DOWNSTREAM_PROJECTS[pipeline].get("owned_by_bazel"): |
| bazel_mark = ":bazel:" |
| platforms_text = ", ".join(platforms) |
| info_text.append(f" - {bazel_mark}**{pipeline}**: {platforms_text}") |
| # Use flag as the context so that each flag gets a different info box. |
| print_info(flag, "error", info_text) |
| printed_flag_boxes = True |
| if not printed_flag_boxes: |
| return |
| info_text = [ |
| "#### Downstream projects need to migrate for the following flags:", |
| "Projects marked with :bazel: need to be migrated by the Bazel team.", |
| ] |
| print_info("flags_need_to_migrate", "error", info_text) |
| |
| |
| def merge_jobs(jobs): |
| jobs_per_pipeline = collections.defaultdict(list) |
| for job in sorted(jobs, key=lambda s: s["name"].lower()): |
| pipeline, platform = get_pipeline_and_platform(job) |
| jobs_per_pipeline[pipeline].append(get_html_link_text(platform, job["web_url"])) |
| return jobs_per_pipeline |
| |
| |
| def merge_and_format_jobs(jobs, line_pattern): |
| # Merges all jobs for a single pipeline into one line. |
| # Example: |
| # pipeline (platform1) |
| # pipeline (platform2) |
| # pipeline (platform3) |
| # with line_pattern ">> {}: {}" becomes |
| # >> pipeline: platform1, platform2, platform3 |
| jobs_per_pipeline = merge_jobs(jobs) |
| return [ |
| line_pattern.format(pipeline, ", ".join(platforms)) |
| for pipeline, platforms in jobs_per_pipeline.items() |
| ] |
| |
| |
| def get_pipeline_and_platform(job): |
| name = job["name"] |
| platform = "" |
| for p in bazelci.PLATFORMS.values(): |
| platform_label = p.get("emoji-name") |
| if platform_label in name: |
| platform = platform_label |
| name = name.replace(platform_label, "") |
| break |
| |
| name = name.partition("-")[0].partition("(")[0].strip() |
| return name, platform |
| |
| |
| def print_info(context, style, info): |
| # CHUNK_SIZE is to prevent buildkite-agent "argument list too long" error |
| CHUNK_SIZE = 20 |
| for i in range(0, len(info), CHUNK_SIZE): |
| info_str = "\n".join(info[i : i + CHUNK_SIZE]) |
| bazelci.execute_command( |
| [ |
| "buildkite-agent", |
| "annotate", |
| "--append", |
| f"--context={context}", |
| f"--style={style}", |
| f"\n{info_str}\n", |
| ] |
| ) |
| |
| |
| def analyze_logs(build_number, client): |
| build_info = client.get_build_info(build_number) |
| |
| already_failing_jobs = [] |
| |
| # dict(flag name -> dict(job id -> job)) |
| failed_jobs_per_flag = collections.defaultdict(dict) |
| # dict(flag name -> (Bazel version where it's flipped, GitHub issue URL)) |
| details_per_flag = {} |
| |
| threads = [] |
| for job in build_info["jobs"]: |
| # Some irrelevant job has no "state" field |
| if "state" in job: |
| thread = LogFetcher(job, client) |
| threads.append(thread) |
| thread.start() |
| |
| for thread in threads: |
| thread.join() |
| process_build_log( |
| failed_jobs_per_flag, already_failing_jobs, thread.log, thread.job, details_per_flag |
| ) |
| |
| return already_failing_jobs, failed_jobs_per_flag, details_per_flag |
| |
| |
| def handle_already_flipped_flags(failed_jobs_per_flag, details_per_flag): |
| # Process and remove all flags that have already been flipped. |
| # Bazelisk may return already flipped flags if a project uses an old Bazel version |
| # via its .bazelversion file. |
| current_major_version = bazelci.get_bazel_major_version() |
| failed_jobs_for_new_flags = {} |
| details_for_new_flags = {} |
| |
| for flag, details in details_per_flag.items(): |
| if details.bazel_version < current_major_version: |
| # TOOD(fweikert): maybe display a Buildkite annotation |
| bazelci.eprint( |
| "Ignoring {} since it has already been flipped in Bazel {} (latest is {}).".format( |
| flag, details.bazel_version, current_major_version |
| ) |
| ) |
| continue |
| |
| details_for_new_flags[flag] = details |
| if flag in failed_jobs_per_flag: |
| failed_jobs_for_new_flags[flag] = failed_jobs_per_flag[flag] |
| |
| return failed_jobs_for_new_flags, details_for_new_flags |
| |
| |
| def print_result_info(already_failing_jobs, failed_jobs_per_flag, details_per_flag): |
| print_flags_need_to_migrate(failed_jobs_per_flag, details_per_flag) |
| |
| print_projects_need_to_migrate(failed_jobs_per_flag) |
| |
| print_already_fail_jobs(already_failing_jobs) |
| |
| print_flags_ready_to_flip(failed_jobs_per_flag, details_per_flag) |
| |
| return bool(failed_jobs_per_flag) |
| |
| |
| def notify_projects(failed_jobs_per_flag, details_per_flag): |
| links_per_project_and_flag = collect_notification_links(failed_jobs_per_flag) |
| create_all_issues(details_per_flag, links_per_project_and_flag) |
| |
| |
| def collect_notification_links(failed_jobs_per_flag): |
| links_per_project_and_flag = collections.defaultdict(set) |
| for flag, job_data in failed_jobs_per_flag.items(): |
| for job in job_data.values(): |
| project_label, platform = get_pipeline_and_platform(job) |
| link = get_link_for_build(platform, job["web_url"]) |
| links_per_project_and_flag[(project_label, flag)].add(link) |
| |
| return links_per_project_and_flag |
| |
| |
| def get_link_for_build(platform, url): |
| names = [v["name"] for v in bazelci.PLATFORMS.values() if v["emoji-name"] == platform] |
| display_name = names[0] if names else "" |
| |
| match = EMOJI_PATTERN.search(platform) |
| img = "" |
| if match: |
| # darwin emoji has image mac.png |
| emoji = match.group(1).replace("darwin", "mac") |
| img = EMOJI_IMAGE_TEMPLATE.format(emoji) |
| |
| text = (img + display_name) or platform |
| return get_html_link_text(text, url) |
| |
| |
| def create_all_issues(details_per_flag, links_per_project_and_flag): |
| errors = set() |
| issue_client = get_github_client() |
| for (project_label, flag), links in links_per_project_and_flag.items(): |
| try: |
| details = details_per_flag.get(flag, (None, None)) |
| if details.bazel_version in (None, "unreleased binary"): |
| raise bazelci.BuildkiteException( |
| "Notifications: Invalid Bazel version '{}' for flag {}".format( |
| details.bazel_version or "", flag |
| ) |
| ) |
| |
| if not details.issue_url: |
| raise bazelci.BuildkiteException( |
| "Notifications: Missing GitHub issue URL for flag {}".format(flag) |
| ) |
| |
| repo_owner, repo_name, do_not_notify = get_project_details(project_label) |
| if do_not_notify: |
| bazelci.eprint("{} has opted out of notifications.".format(project_label)) |
| continue |
| |
| temporary_title = get_temporary_issue_title(project_label, flag) |
| final_title = get_final_issue_title(project_label, details.bazel_version, flag) |
| has_target_release = details.bazel_version != "TBD" |
| |
| # Three possible scenarios: |
| # 1. There is already an issue with the target release in the title -> do nothing |
| # 2. There is an issue, but without the target release, and we now know the target release -> update title |
| # 3. There is no issue -> create one |
| if issue_client.get_issue(repo_owner, repo_name, final_title): |
| bazelci.eprint( |
| "There is already an issue in {}/{} for project {}, flag {} and Bazel {}".format( |
| repo_owner, repo_name, project_label, flag, details.bazel_version |
| ) |
| ) |
| else: |
| number = issue_client.get_issue(repo_owner, repo_name, temporary_title) |
| if number: |
| if has_target_release: |
| issue_client.update_title(repo_owner, repo_name, number, final_title) |
| else: |
| body = create_issue_body(project_label, flag, details, links) |
| title = final_title if has_target_release else temporary_title |
| issue_client.create_issue(repo_owner, repo_name, title, body) |
| except (bazelci.BuildkiteException, GitHubError) as ex: |
| errors.add("Could not notify project '{}': {}".format(project_label, ex)) |
| |
| if errors: |
| print_info("notify_errors", "error", list(errors)) |
| |
| |
| def get_github_client(): |
| try: |
| github_token = bazelci.decrypt_token( |
| encrypted_token=ENCRYPTED_GITHUB_API_TOKEN, kms_key=GITHUB_TOKEN_KMS_KEY |
| ) |
| except Exception as ex: |
| raise bazelci.BuildkiteException("Failed to decrypt GitHub API token: {}".format(ex)) |
| |
| return GitHubIssueClient(reporter=GITHUB_ISSUE_REPORTER, oauth_token=github_token) |
| |
| |
| def get_project_details(project_label): |
| entry = bazelci.DOWNSTREAM_PROJECTS.get(project_label, {}) |
| full_repo = entry.get("git_repository", "") |
| if not full_repo: |
| raise bazelci.BuildkiteException( |
| "Could not retrieve Git repository for project '{}'".format(project_label) |
| ) |
| match = REPO_PATTERN.match(full_repo) |
| if not match: |
| raise bazelci.BuildkiteException( |
| "Hosts other than GitHub are currently not supported ({})".format(full_repo) |
| ) |
| |
| return match.group("owner"), match.group("repo"), entry.get("do_not_notify", False) |
| |
| |
| def get_temporary_issue_title(project_label, flag): |
| return "Flag {} will break {} in a future Bazel release".format(flag, project_label) |
| |
| |
| def get_final_issue_title(project_label, bazel_version, flag): |
| return "Flag {} will break {} in Bazel {}".format(flag, project_label, bazel_version) |
| |
| |
| def create_issue_body(project_label, flag, details, links): |
| if details.bazel_version == "TBD": |
| version = "a future Bazel release [1]" |
| version_footnote = ( |
| "\n[1] The target release hasn't been determined yet. " |
| "Our tool will update the issue title once the flag flip has been scheduled." |
| ) |
| else: |
| version = "Bazel {}".format(details.bazel_version) |
| version_footnote = "" |
| |
| return ISSUE_TEMPLATE.format( |
| project=project_label, |
| version=version, |
| version_footnote=version_footnote, |
| issue_url=details.issue_url, |
| flag=flag, |
| links="\n".join("* {}".format(l) for l in links), |
| ) |
| |
| |
| def main(argv=None): |
| if argv is None: |
| argv = sys.argv[1:] |
| |
| parser = argparse.ArgumentParser( |
| description="Script to aggregate `bazelisk --migrate` test result for incompatible flags and generate pretty Buildkite info messages." |
| ) |
| parser.add_argument("--build_number", type=str) |
| parser.add_argument("--notify", type=bool, nargs="?", const=True) |
| |
| args = parser.parse_args(argv) |
| try: |
| if args.build_number: |
| client = bazelci.BuildkiteClient(org=BUILDKITE_ORG, pipeline=PIPELINE) |
| already_failing_jobs, failed_jobs_per_flag, details_per_flag = analyze_logs( |
| args.build_number, client |
| ) |
| failed_jobs_per_flag, details_per_flag = handle_already_flipped_flags( |
| failed_jobs_per_flag, details_per_flag |
| ) |
| migration_required = print_result_info( |
| already_failing_jobs, failed_jobs_per_flag, details_per_flag |
| ) |
| |
| if args.notify: |
| notify_projects(failed_jobs_per_flag, details_per_flag) |
| |
| if migration_required and FAIL_IF_MIGRATION_REQUIRED: |
| bazelci.eprint("Exiting with code 3 since a migration is required.") |
| return 3 |
| else: |
| parser.print_help() |
| return 2 |
| |
| except bazelci.BuildkiteException as e: |
| bazelci.eprint(str(e)) |
| return 1 |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |