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 def should_include_extensionless_file(self, path: str) -> bool: 119 return path.startswith("libcxx/include") 120 121 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 122 filtered_files = [] 123 for path in changed_files: 124 _, ext = os.path.splitext(path) 125 if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"): 126 filtered_files.append(path) 127 elif ext == "" and self.should_include_extensionless_file(path): 128 filtered_files.append(path) 129 return filtered_files 130 131 def format_run( 132 self, changed_files: list[str], args: argparse.Namespace 133 ) -> str | None: 134 cpp_files = self.filter_changed_files(changed_files) 135 if not cpp_files: 136 return None 137 cf_cmd = [ 138 "git-clang-format", 139 "--diff", 140 args.start_rev, 141 args.end_rev, 142 "--", 143 ] + cpp_files 144 print(f"Running: {' '.join(cf_cmd)}") 145 self.cf_cmd = cf_cmd 146 proc = subprocess.run(cf_cmd, capture_output=True) 147 sys.stdout.write(proc.stderr.decode("utf-8")) 148 149 if proc.returncode != 0: 150 # formatting needed, or the command otherwise failed 151 print(f"error: {self.name} exited with code {proc.returncode}") 152 # Print the diff in the log so that it is viewable there 153 print(proc.stdout.decode("utf-8")) 154 return proc.stdout.decode("utf-8") 155 else: 156 sys.stdout.write(proc.stdout.decode("utf-8")) 157 return None 158 159 160class DarkerFormatHelper(FormatHelper): 161 name = "darker" 162 friendly_name = "Python code formatter" 163 164 @property 165 def instructions(self) -> str: 166 return " ".join(self.darker_cmd) 167 168 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 169 filtered_files = [] 170 for path in changed_files: 171 name, ext = os.path.splitext(path) 172 if ext == ".py": 173 filtered_files.append(path) 174 175 return filtered_files 176 177 def format_run( 178 self, changed_files: list[str], args: argparse.Namespace 179 ) -> str | None: 180 py_files = self.filter_changed_files(changed_files) 181 if not py_files: 182 return None 183 darker_cmd = [ 184 "darker", 185 "--check", 186 "--diff", 187 "-r", 188 f"{args.start_rev}..{args.end_rev}", 189 ] + py_files 190 print(f"Running: {' '.join(darker_cmd)}") 191 self.darker_cmd = darker_cmd 192 proc = subprocess.run(darker_cmd, capture_output=True) 193 sys.stdout.write(proc.stderr.decode("utf-8")) 194 195 if proc.returncode != 0: 196 # formatting needed, or the command otherwise failed 197 print(f"error: {self.name} exited with code {proc.returncode}") 198 # Print the diff in the log so that it is viewable there 199 print(proc.stdout.decode("utf-8")) 200 return proc.stdout.decode("utf-8") 201 else: 202 sys.stdout.write(proc.stdout.decode("utf-8")) 203 return None 204 205 206ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper()) 207 208if __name__ == "__main__": 209 parser = argparse.ArgumentParser() 210 parser.add_argument( 211 "--token", type=str, required=True, help="GitHub authentiation token" 212 ) 213 parser.add_argument( 214 "--repo", 215 type=str, 216 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 217 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 218 ) 219 parser.add_argument("--issue-number", type=int, required=True) 220 parser.add_argument( 221 "--start-rev", 222 type=str, 223 required=True, 224 help="Compute changes from this revision.", 225 ) 226 parser.add_argument( 227 "--end-rev", type=str, required=True, help="Compute changes to this revision" 228 ) 229 parser.add_argument( 230 "--changed-files", 231 type=str, 232 help="Comma separated list of files that has been changed", 233 ) 234 235 args = parser.parse_args() 236 237 changed_files = [] 238 if args.changed_files: 239 changed_files = args.changed_files.split(",") 240 241 failed_formatters = [] 242 for fmt in ALL_FORMATTERS: 243 if not fmt.run(changed_files, args): 244 failed_formatters.append(fmt.name) 245 246 if len(failed_formatters) > 0: 247 print(f"error: some formatters failed: {' '.join(failed_formatters)}") 248 sys.exit(1) 249