diff --git a/.lintrunner.toml b/.lintrunner.toml index 05851828568c..5cb8e563db91 100644 --- a/.lintrunner.toml +++ b/.lintrunner.toml @@ -117,3 +117,15 @@ args = [ '--', '@{{PATHSFILE}}' ] + +[[linter]] +name = 'CIRCLECI' +include_patterns=['.circleci/**'] +args = [ + 'python3', + 'tools/linter/adapters/circleci_linter.py', + '--regen-script-working-dir=.circleci', + '--config-yml=.circleci/config.yml', + '--regen-script=generate_config_yml.py', +] +bypass_matched_file_filter = true diff --git a/tools/linter/adapters/circleci_linter.py b/tools/linter/adapters/circleci_linter.py new file mode 100644 index 000000000000..eec3c7f55617 --- /dev/null +++ b/tools/linter/adapters/circleci_linter.py @@ -0,0 +1,153 @@ +""" +Checks that the configuration in .circleci/config.yml has been properly regenerated. +""" + +import os +import argparse +import subprocess +import sys +import logging +import time +from enum import Enum +from typing import List, NamedTuple, Optional +import json + + +CHECKED_IN_FILE = "config.yml" +REGENERATION_SCRIPT = "regenerate.sh" + +PARENT_DIR = os.path.basename(os.path.dirname(os.path.abspath(__file__))) +README_PATH = os.path.join(PARENT_DIR, "README.md") + + +class LintSeverity(str, Enum): + ERROR = "error" + WARNING = "warning" + ADVICE = "advice" + DISABLED = "disabled" + + +class LintMessage(NamedTuple): + path: Optional[str] + line: Optional[int] + char: Optional[int] + code: str + severity: LintSeverity + name: str + original: Optional[str] + replacement: Optional[str] + description: Optional[str] + bypassChangedLineFiltering: Optional[bool] + + +IS_WINDOWS: bool = os.name == "nt" + + +def as_posix(name: str) -> str: + return name.replace("\\", "/") if IS_WINDOWS else name + + +def run_command(args: List[str], cwd: str) -> "subprocess.CompletedProcess[bytes]": + logging.debug("$ %s", " ".join(args)) + start_time = time.monotonic() + try: + return subprocess.run( + args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, + ) + finally: + end_time = time.monotonic() + logging.debug("took %dms", (end_time - start_time) * 1000) + + +def run_check( + regen_script_working_dir: str, regen_script: str, config_file: str +) -> List[LintMessage]: + try: + proc = run_command(["python3", regen_script], regen_script_working_dir) + except Exception as err: + return [ + LintMessage( + path=None, + line=None, + char=None, + code="CIRCLECI", + severity=LintSeverity.ERROR, + name="command-failed", + original=None, + replacement=None, + description=( + f"Failed due to {err.__class__.__name__}:\n{err}" + if not isinstance(err, subprocess.CalledProcessError) + else ( + "COMMAND (exit code {returncode})\n" + "{command}\n\n" + "STDERR\n{stderr}\n\n" + "STDOUT\n{stdout}" + ).format( + returncode=err.returncode, + command=" ".join(as_posix(x) for x in err.cmd), + stderr=err.stderr.decode("utf-8").strip() or "(empty)", + stdout=err.stdout.decode("utf-8").strip() or "(empty)", + ) + ), + bypassChangedLineFiltering=None, + ) + ] + + with open(config_file, mode="rb") as f: + config = f.read() + if proc.stdout == config: + return [] + + return [ + LintMessage( + path=config_file, + line=1, + char=1, + code="CIRCLECI", + severity=LintSeverity.ERROR, + name="config inconsistency", + original=config.decode("utf-8"), + replacement=proc.stdout.decode("utf-8"), + description=( + "The checked-in CircleCI config.yml file does not match what was generated by the scripts. " + "Re-run with '-a' to accept changes." + ), + bypassChangedLineFiltering=True, + ) + ] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="circleci consistency linter", fromfile_prefix_chars="@", + ) + parser.add_argument( + "--config-yml", required=True, help="location of config.yml", + ) + parser.add_argument( + "--regen-script-working-dir", + required=True, + help="this script will chdir to this argument before running --regen-script", + ) + parser.add_argument( + "--regen-script", + required=True, + help="location of the config generation script, relative to --regen-script-working-dir", + ) + parser.add_argument( + "--verbose", action="store_true", help="verbose logging", + ) + + args = parser.parse_args() + + logging.basicConfig( + format="<%(threadName)s:%(levelname)s> %(message)s", + level=logging.NOTSET if args.verbose else logging.DEBUG, + stream=sys.stderr, + ) + + for lint_message in run_check( + args.regen_script_working_dir, args.regen_script, args.config_yml + ): + print(json.dumps(lint_message._asdict()), flush=True) diff --git a/tools/linter/adapters/clangformat_linter.py b/tools/linter/adapters/clangformat_linter.py index 8b5133954f68..2229aa49c5fc 100644 --- a/tools/linter/adapters/clangformat_linter.py +++ b/tools/linter/adapters/clangformat_linter.py @@ -25,7 +25,7 @@ class LintSeverity(str, Enum): class LintMessage(NamedTuple): - path: str + path: Optional[str] line: Optional[int] char: Optional[int] code: str diff --git a/tools/linter/adapters/clangtidy_linter.py b/tools/linter/adapters/clangtidy_linter.py index 932e50c32228..dcaf1190c556 100644 --- a/tools/linter/adapters/clangtidy_linter.py +++ b/tools/linter/adapters/clangtidy_linter.py @@ -28,7 +28,7 @@ class LintSeverity(str, Enum): class LintMessage(NamedTuple): - path: str + path: Optional[str] line: Optional[int] char: Optional[int] code: str diff --git a/tools/linter/adapters/flake8_linter.py b/tools/linter/adapters/flake8_linter.py index 6be46c6b5304..8e6030211d47 100644 --- a/tools/linter/adapters/flake8_linter.py +++ b/tools/linter/adapters/flake8_linter.py @@ -26,7 +26,7 @@ class LintSeverity(str, Enum): class LintMessage(NamedTuple): - path: str + path: Optional[str] line: Optional[int] char: Optional[int] code: str diff --git a/tools/linter/adapters/grep_linter.py b/tools/linter/adapters/grep_linter.py index 171b671a6264..a6338f6793a2 100644 --- a/tools/linter/adapters/grep_linter.py +++ b/tools/linter/adapters/grep_linter.py @@ -24,7 +24,7 @@ class LintSeverity(str, Enum): class LintMessage(NamedTuple): - path: str + path: Optional[str] line: Optional[int] char: Optional[int] code: str @@ -107,7 +107,7 @@ def main() -> None: proc = run_command(["grep", "-nPH", args.pattern, *args.filenames]) except OSError as err: err_msg = LintMessage( - path="", + path=None, line=None, char=None, code=args.linter_name, diff --git a/tools/linter/adapters/mypy_linter.py b/tools/linter/adapters/mypy_linter.py index de2cbc77b08b..1075567f1002 100644 --- a/tools/linter/adapters/mypy_linter.py +++ b/tools/linter/adapters/mypy_linter.py @@ -26,7 +26,7 @@ class LintSeverity(str, Enum): class LintMessage(NamedTuple): - path: str + path: Optional[str] line: Optional[int] char: Optional[int] code: str @@ -97,7 +97,7 @@ def check_file( except OSError as err: return [ LintMessage( - path=filename, + path=None, line=None, char=None, code="MYPY",