| #!/usr/bin/env python3 |
| # -*- coding: utf-8 -*- |
| |
| import fnmatch |
| import html |
| import json |
| import locale |
| import os |
| import os.path |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| from contextlib import closing |
| from distutils.version import LooseVersion |
| from urllib.request import urlopen |
| |
| BUILDIFIER_VERSION_PATTERN = re.compile( |
| r"^buildifier version: ([\.\w]+)$", re.MULTILINE |
| ) |
| |
| # https://github.com/bazelbuild/buildtools/blob/master/buildifier/buildifier.go#L333 |
| # Buildifier error code for "needs formatting". We should fail on all other error codes > 0 |
| # since they indicate a problem in how Buildifier is used. |
| BUILDIFIER_FORMAT_ERROR_CODE = 4 |
| |
| VERSION_ENV_VAR = "BUILDIFIER_VERSION" |
| |
| WARNINGS_ENV_VAR = "BUILDIFIER_WARNINGS" |
| |
| BUILDIFIER_RELEASES_URL = "https://api.github.com/repos/bazelbuild/buildtools/releases" |
| |
| BUILDIFIER_DEFAULT_DISPLAY_URL = ( |
| "https://github.com/bazelbuild/buildtools/tree/master/buildifier" |
| ) |
| |
| |
| def eprint(*args, **kwargs): |
| """ |
| Print to stderr and flush (just in case). |
| """ |
| print(*args, flush=True, file=sys.stderr, **kwargs) |
| |
| |
| def upload_output(output): |
| # Generate output usable by Buildkite's annotations. |
| eprint("--- :hammer_and_wrench: Printing raw output for debugging") |
| eprint(output) |
| |
| eprint("+++ :buildkite: Uploading output via 'buildkite annotate'") |
| result = subprocess.run( |
| [ |
| "buildkite-agent", |
| "annotate", |
| "--style", |
| "warning", |
| "--context", |
| "buildifier", |
| ], |
| input=output.encode(locale.getpreferredencoding(False)), |
| ) |
| if result.returncode != 0: |
| eprint( |
| ":rotating_light: 'buildkite-agent annotate' failed with exit code {}".format( |
| result.returncode |
| ) |
| ) |
| |
| |
| def print_error(failing_task, message): |
| output = "##### :bazel: buildifier: error while {}:\n".format(failing_task) |
| output += "<pre><code>{}</code></pre>".format(html.escape(message)) |
| if "BUILDKITE_JOB_ID" in os.environ: |
| output += "\n\nSee [job {job}](#{job})\n".format( |
| job=os.environ["BUILDKITE_JOB_ID"] |
| ) |
| |
| upload_output(output) |
| |
| |
| def get_file_url(filename, line): |
| commit = os.environ.get("BUILDKITE_COMMIT") |
| repo = os.environ.get( |
| "BUILDKITE_PULL_REQUEST_REPO", os.environ.get("BUILDKITE_REPO", None) |
| ) |
| if not commit or not repo: |
| return None |
| |
| # Example 1: https://github.com/bazelbuild/bazel.git |
| # Example 2: git://github.com/philwo/bazel.git |
| # Example 3: git@github.com:bazelbuild/bazel.git |
| match = re.match(r"(?:(?:git|https?)://|git@)(github.com[:/].*)\.git", repo) |
| if match: |
| return "https://{}/blob/{}/{}#L{}".format( |
| match[1].replace(":", "/"), commit, filename, line |
| ) |
| |
| return None |
| |
| |
| def run_buildifier(binary, flags, version=None, what=None): |
| label = "+++ :bazel: Running " |
| if version: |
| label += "Buildifier " + version |
| else: |
| label += "unreleased Buildifier" |
| if what: |
| label += ": " + what |
| |
| eprint(label) |
| |
| return subprocess.run( |
| [binary] + flags, capture_output=True, universal_newlines=True |
| ) |
| |
| |
| def create_heading(issue_type, issue_count): |
| return "##### :bazel: buildifier: found {} {} issue{} in your WORKSPACE, BUILD and *.bzl files\n".format( |
| issue_count, issue_type, "s" if issue_count > 1 else "" |
| ) |
| |
| |
| def get_buildifier_info(version): |
| all_releases = get_releases() |
| if not all_releases: |
| raise Exception("Could not get Buildifier releases from GitHub") |
| |
| resolved_version = version |
| if version == "latest": |
| resolved_version = str(max(LooseVersion(r) for r in all_releases)) |
| |
| if resolved_version not in all_releases: |
| raise Exception("Unknown Buildifier version '{}'".format(version)) |
| |
| display_url, download_url = all_releases.get(resolved_version) |
| return resolved_version, display_url, download_url |
| |
| |
| def get_releases(): |
| with closing(urlopen(BUILDIFIER_RELEASES_URL)) as res: |
| body = res.read() |
| content = body.decode(res.info().get_content_charset("iso-8859-1")) |
| |
| return { |
| r["tag_name"].lstrip("v"): get_release_urls(r) |
| for r in json.loads(content) |
| if not r["prerelease"] |
| } |
| |
| |
| def get_release_urls(release): |
| for asset in release["assets"]: |
| if asset["name"] in ["buildifier", "buildifier-linux-amd64"]: |
| return release["html_url"], asset["browser_download_url"] |
| raise Exception( |
| "There is no Buildifier binary for release {}".format(release["tag_name"]) |
| ) |
| |
| |
| def download_buildifier(url): |
| path = os.path.join(tempfile.mkdtemp(), "buildifier") |
| with closing(urlopen(url)) as response: |
| with open(path, "wb") as out_file: |
| shutil.copyfileobj(response, out_file) |
| os.chmod(path, 0o755) |
| return path |
| |
| |
| def format_lint_warning(filename, warning): |
| line_number = warning["start"]["line"] |
| link_start, link_end, column_text = "", "", "" |
| |
| file_url = get_file_url(filename, line_number) |
| if file_url: |
| link_start = '<a href="{}">'.format(file_url) |
| link_end = "</a>" |
| |
| column = warning["start"].get("column") |
| if column: |
| column_text = ":{}".format(column) |
| |
| return '{link_start}{filename}:{line}{column}{link_end}: <a href="{help_url}">{category}</a>: {message}'.format( |
| link_start=link_start, |
| filename=filename, |
| line=line_number, |
| column=column_text, |
| link_end=link_end, |
| help_url=warning["url"], |
| category=warning["category"], |
| message=warning["message"], |
| ) |
| |
| |
| def main(argv=None): |
| if argv is None: |
| argv = sys.argv[1:] |
| |
| buildifier_binary = "buildifier" |
| display_url = BUILDIFIER_DEFAULT_DISPLAY_URL |
| version = os.environ.get(VERSION_ENV_VAR, "latest") |
| eprint("+++ :github: Downloading Buildifier version '{}'".format(version)) |
| try: |
| version, display_url, download_url = get_buildifier_info(version) |
| eprint("Downloading Buildifier {} from {}".format(version, download_url)) |
| buildifier_binary = download_buildifier(download_url) |
| except Exception as ex: |
| print_error("downloading Buildifier", str(ex)) |
| return 1 |
| |
| flags = ["--mode=check", "--lint=warn"] |
| warnings = os.getenv(WARNINGS_ENV_VAR) |
| if warnings: |
| eprint("Running Buildifier with the following warnings: {}".format(warnings)) |
| flags.append("--warnings={}".format(warnings)) |
| |
| result = run_buildifier( |
| buildifier_binary, |
| flags + ["--format=json", "-r", "."], |
| version=version, |
| what="Format & lint checks", |
| ) |
| |
| if result.returncode and result.returncode != BUILDIFIER_FORMAT_ERROR_CODE: |
| print_error("Buildifier failed", result.stderr) |
| return result.returncode |
| |
| data = json.loads(result.stdout) |
| if data["success"]: |
| # If buildifier was happy, there's nothing left to do for us. |
| eprint("+++ :tada: Buildifier found nothing to complain about") |
| return 0 |
| |
| unformatted_files = [] |
| lint_findings = [] |
| for file in data["files"]: |
| filename = file["filename"] |
| |
| if not file["formatted"]: |
| unformatted_files.append(filename) |
| |
| for warning in file["warnings"]: |
| lint_findings.append(format_lint_warning(filename, warning)) |
| |
| output = "" |
| if unformatted_files: |
| eprint( |
| "+++ :construction: Found {} file(s) that must be formatted".format( |
| len(unformatted_files) |
| ) |
| ) |
| output = create_heading("format", len(unformatted_files)) |
| display_version = " {}".format(version) if version else "" |
| output += ( |
| 'If this repo uses a pre-commit hook, then you should install it. ' |
| 'Otherwise, please download <a href="{}">buildifier{}</a> and run the following ' |
| "command in your workspace:<br/><pre><code>buildifier {}</code></pre>" |
| "\n".format(display_url, display_version, " ".join(unformatted_files)) |
| ) |
| |
| if lint_findings: |
| eprint("+++ :gear: Rendering lint warnings") |
| output += create_heading("lint", len(lint_findings)) |
| output += "<pre><code>" |
| output += "\n".join(lint_findings) |
| output = output.strip() + "</pre></code>" |
| |
| upload_output(output) |
| |
| # Preserve buildifier's exit code. |
| return BUILDIFIER_FORMAT_ERROR_CODE |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |