1from __future__ import annotations 2 3import argparse 4import concurrent.futures 5import json 6import logging 7import os 8import re 9import subprocess 10import sys 11import time 12from enum import Enum 13from typing import NamedTuple 14 15 16LINTER_CODE = "ACTIONLINT" 17 18 19class LintSeverity(str, Enum): 20 ERROR = "error" 21 WARNING = "warning" 22 ADVICE = "advice" 23 DISABLED = "disabled" 24 25 26class LintMessage(NamedTuple): 27 path: str | None 28 line: int | None 29 char: int | None 30 code: str 31 severity: LintSeverity 32 name: str 33 original: str | None 34 replacement: str | None 35 description: str | None 36 37 38RESULTS_RE: re.Pattern[str] = re.compile( 39 r"""(?mx) 40 ^ 41 (?P<file>.*?): 42 (?P<line>\d+): 43 (?P<char>\d+): 44 \s(?P<message>.*) 45 \s(?P<code>\[.*\]) 46 $ 47 """ 48) 49 50 51def run_command( 52 args: list[str], 53) -> subprocess.CompletedProcess[bytes]: 54 logging.debug("$ %s", " ".join(args)) 55 start_time = time.monotonic() 56 try: 57 return subprocess.run( 58 args, 59 capture_output=True, 60 ) 61 finally: 62 end_time = time.monotonic() 63 logging.debug("took %dms", (end_time - start_time) * 1000) 64 65 66def check_file( 67 binary: str, 68 file: str, 69) -> list[LintMessage]: 70 try: 71 proc = run_command( 72 [ 73 binary, 74 "-ignore", 75 '"runs-on" section must be sequence node but got mapping node with "!!map" tag', 76 file, 77 ] 78 ) 79 except OSError as err: 80 return [ 81 LintMessage( 82 path=None, 83 line=None, 84 char=None, 85 code=LINTER_CODE, 86 severity=LintSeverity.ERROR, 87 name="command-failed", 88 original=None, 89 replacement=None, 90 description=(f"Failed due to {err.__class__.__name__}:\n{err}"), 91 ) 92 ] 93 stdout = str(proc.stdout, "utf-8").strip() 94 return [ 95 LintMessage( 96 path=match["file"], 97 name=match["code"], 98 description=match["message"], 99 line=int(match["line"]), 100 char=int(match["char"]), 101 code=LINTER_CODE, 102 severity=LintSeverity.ERROR, 103 original=None, 104 replacement=None, 105 ) 106 for match in RESULTS_RE.finditer(stdout) 107 ] 108 109 110if __name__ == "__main__": 111 parser = argparse.ArgumentParser( 112 description="actionlint runner", 113 fromfile_prefix_chars="@", 114 ) 115 parser.add_argument( 116 "--binary", 117 required=True, 118 help="actionlint binary path", 119 ) 120 parser.add_argument( 121 "filenames", 122 nargs="+", 123 help="paths to lint", 124 ) 125 126 args = parser.parse_args() 127 128 if not os.path.exists(args.binary): 129 err_msg = LintMessage( 130 path="<none>", 131 line=None, 132 char=None, 133 code=LINTER_CODE, 134 severity=LintSeverity.ERROR, 135 name="command-failed", 136 original=None, 137 replacement=None, 138 description=( 139 f"Could not find actionlint binary at {args.binary}," 140 " you may need to run `lintrunner init`." 141 ), 142 ) 143 print(json.dumps(err_msg._asdict()), flush=True) 144 sys.exit(0) 145 146 with concurrent.futures.ThreadPoolExecutor( 147 max_workers=os.cpu_count(), 148 thread_name_prefix="Thread", 149 ) as executor: 150 futures = { 151 executor.submit( 152 check_file, 153 args.binary, 154 filename, 155 ): filename 156 for filename in args.filenames 157 } 158 for future in concurrent.futures.as_completed(futures): 159 try: 160 for lint_message in future.result(): 161 print(json.dumps(lint_message._asdict()), flush=True) 162 except Exception: 163 logging.critical('Failed at "%s".', futures[future]) 164 raise 165