blob: 4f8d8dfe91b0939000d80d7c83e2c3d9a4e47477 [file] [log] [blame]
#!/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())