1#!/usr/bin/env python3 2# 3# ====- code-format-helper, runs code formatters from the ci --*- python -*--==# 4# 5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. 6# See https://llvm.org/LICENSE.txt for license information. 7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception 8# 9# ==-------------------------------------------------------------------------==# 10 11import argparse 12import os 13import subprocess 14import sys 15from functools import cached_property 16 17import github 18from github import IssueComment, PullRequest 19 20 21class FormatHelper: 22 COMMENT_TAG = "<!--LLVM CODE FORMAT COMMENT: {fmt}-->" 23 name: str 24 friendly_name: str 25 26 @property 27 def comment_tag(self) -> str: 28 return self.COMMENT_TAG.replace("fmt", self.name) 29 30 @property 31 def instructions(self) -> str: 32 raise NotImplementedError() 33 34 def format_run( 35 self, changed_files: list[str], args: argparse.Namespace 36 ) -> str | None: 37 raise NotImplementedError() 38 39 def pr_comment_text_for_diff(self, diff: str) -> str: 40 return f""" 41:warning: {self.friendly_name}, {self.name} found issues in your code. :warning: 42 43<details> 44<summary> 45You can test this locally with the following command: 46</summary> 47 48``````````bash 49{self.instructions} 50`````````` 51 52</details> 53 54<details> 55<summary> 56View the diff from {self.name} here. 57</summary> 58 59``````````diff 60{diff} 61`````````` 62 63</details> 64""" 65 66 def find_comment( 67 self, pr: PullRequest.PullRequest 68 ) -> IssueComment.IssueComment | None: 69 for comment in pr.as_issue().get_comments(): 70 if self.comment_tag in comment.body: 71 return comment 72 return None 73 74 def update_pr( 75 self, comment_text: str, args: argparse.Namespace, create_new: bool 76 ) -> None: 77 repo = github.Github(args.token).get_repo(args.repo) 78 pr = repo.get_issue(args.issue_number).as_pull_request() 79 80 comment_text = self.comment_tag + "\n\n" + comment_text 81 82 existing_comment = self.find_comment(pr) 83 if existing_comment: 84 existing_comment.edit(comment_text) 85 elif create_new: 86 pr.as_issue().create_comment(comment_text) 87 88 def run(self, changed_files: list[str], args: argparse.Namespace) -> bool: 89 diff = self.format_run(changed_files, args) 90 if diff is None: 91 comment_text = f""" 92:white_check_mark: With the latest revision this PR passed the {self.friendly_name}. 93""" 94 self.update_pr(comment_text, args, create_new=False) 95 return True 96 elif len(diff) > 0: 97 comment_text = self.pr_comment_text_for_diff(diff) 98 self.update_pr(comment_text, args, create_new=True) 99 return False 100 else: 101 # The formatter failed but didn't output a diff (e.g. some sort of 102 # infrastructure failure). 103 comment_text = f""" 104:warning: The {self.friendly_name} failed without printing a diff. Check the logs for stderr output. :warning: 105""" 106 self.update_pr(comment_text, args, create_new=False) 107 return False 108 109 110class ClangFormatHelper(FormatHelper): 111 name = "clang-format" 112 friendly_name = "C/C++ code formatter" 113 114 @property 115 def instructions(self) -> str: 116 return " ".join(self.cf_cmd) 117 118 @cached_property 119 def libcxx_excluded_files(self) -> list[str]: 120 with open("libcxx/utils/data/ignore_format.txt", "r") as ifd: 121 return [excl.strip() for excl in ifd.readlines()] 122 123 def should_be_excluded(self, path: str) -> bool: 124 if path in self.libcxx_excluded_files: 125 print(f"{self.name}: Excluding file {path}") 126 return True 127 return False 128 129 def should_include_extensionless_file(self, path: str) -> bool: 130 return path.startswith("libcxx/include") 131 132 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 133 filtered_files = [] 134 for path in changed_files: 135 _, ext = os.path.splitext(path) 136 if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"): 137 if not self.should_be_excluded(path): 138 filtered_files.append(path) 139 elif ext == "" and self.should_include_extensionless_file(path): 140 filtered_files.append(path) 141 return filtered_files 142 143 def format_run( 144 self, changed_files: list[str], args: argparse.Namespace 145 ) -> str | None: 146 cpp_files = self.filter_changed_files(changed_files) 147 if not cpp_files: 148 return None 149 cf_cmd = [ 150 "git-clang-format", 151 "--diff", 152 args.start_rev, 153 args.end_rev, 154 "--", 155 ] + cpp_files 156 print(f"Running: {' '.join(cf_cmd)}") 157 self.cf_cmd = cf_cmd 158 proc = subprocess.run(cf_cmd, capture_output=True) 159 sys.stdout.write(proc.stderr.decode("utf-8")) 160 161 if proc.returncode != 0: 162 # formatting needed, or the command otherwise failed 163 print(f"error: {self.name} exited with code {proc.returncode}") 164 # Print the diff in the log so that it is viewable there 165 print(proc.stdout.decode("utf-8")) 166 return proc.stdout.decode("utf-8") 167 else: 168 sys.stdout.write(proc.stdout.decode("utf-8")) 169 return None 170 171 172class DarkerFormatHelper(FormatHelper): 173 name = "darker" 174 friendly_name = "Python code formatter" 175 176 @property 177 def instructions(self) -> str: 178 return " ".join(self.darker_cmd) 179 180 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 181 filtered_files = [] 182 for path in changed_files: 183 name, ext = os.path.splitext(path) 184 if ext == ".py": 185 filtered_files.append(path) 186 187 return filtered_files 188 189 def format_run( 190 self, changed_files: list[str], args: argparse.Namespace 191 ) -> str | None: 192 py_files = self.filter_changed_files(changed_files) 193 if not py_files: 194 return None 195 darker_cmd = [ 196 "darker", 197 "--check", 198 "--diff", 199 "-r", 200 f"{args.start_rev}..{args.end_rev}", 201 ] + py_files 202 print(f"Running: {' '.join(darker_cmd)}") 203 self.darker_cmd = darker_cmd 204 proc = subprocess.run(darker_cmd, capture_output=True) 205 sys.stdout.write(proc.stderr.decode("utf-8")) 206 207 if proc.returncode != 0: 208 # formatting needed, or the command otherwise failed 209 print(f"error: {self.name} exited with code {proc.returncode}") 210 # Print the diff in the log so that it is viewable there 211 print(proc.stdout.decode("utf-8")) 212 return proc.stdout.decode("utf-8") 213 else: 214 sys.stdout.write(proc.stdout.decode("utf-8")) 215 return None 216 217 218ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper()) 219 220if __name__ == "__main__": 221 parser = argparse.ArgumentParser() 222 parser.add_argument( 223 "--token", type=str, required=True, help="GitHub authentiation token" 224 ) 225 parser.add_argument( 226 "--repo", 227 type=str, 228 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 229 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 230 ) 231 parser.add_argument("--issue-number", type=int, required=True) 232 parser.add_argument( 233 "--start-rev", 234 type=str, 235 required=True, 236 help="Compute changes from this revision.", 237 ) 238 parser.add_argument( 239 "--end-rev", type=str, required=True, help="Compute changes to this revision" 240 ) 241 parser.add_argument( 242 "--changed-files", 243 type=str, 244 help="Comma separated list of files that has been changed", 245 ) 246 247 args = parser.parse_args() 248 249 changed_files = [] 250 if args.changed_files: 251 changed_files = args.changed_files.split(",") 252 253 failed_formatters = [] 254 for fmt in ALL_FORMATTERS: 255 if not fmt.run(changed_files, args): 256 failed_formatters.append(fmt.name) 257 258 if len(failed_formatters) > 0: 259 print(f"error: some formatters failed: {' '.join(failed_formatters)}") 260 sys.exit(1) 261