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(self, diff: str) -> str: 40 return f""" 41{self.comment_tag} 42 43:warning: {self.friendly_name}, {self.name} found issues in your code. :warning: 44 45<details> 46<summary> 47You can test this locally with the following command: 48</summary> 49 50``````````bash 51{self.instructions} 52`````````` 53 54</details> 55 56<details> 57<summary> 58View the diff from {self.name} here. 59</summary> 60 61``````````diff 62{diff} 63`````````` 64 65</details> 66""" 67 68 def find_comment( 69 self, pr: PullRequest.PullRequest 70 ) -> IssueComment.IssueComment | None: 71 for comment in pr.as_issue().get_comments(): 72 if self.comment_tag in comment.body: 73 return comment 74 return None 75 76 def update_pr(self, diff: str, args: argparse.Namespace) -> None: 77 repo = github.Github(args.token).get_repo(args.repo) 78 pr = repo.get_issue(args.issue_number).as_pull_request() 79 80 existing_comment = self.find_comment(pr) 81 pr_text = self.pr_comment_text(diff) 82 83 if existing_comment: 84 existing_comment.edit(pr_text) 85 else: 86 pr.as_issue().create_comment(pr_text) 87 88 def update_pr_success(self, args: argparse.Namespace) -> None: 89 repo = github.Github(args.token).get_repo(args.repo) 90 pr = repo.get_issue(args.issue_number).as_pull_request() 91 92 existing_comment = self.find_comment(pr) 93 if existing_comment: 94 existing_comment.edit( 95 f""" 96{self.comment_tag} 97:white_check_mark: With the latest revision this PR passed the {self.friendly_name}. 98""" 99 ) 100 101 def run(self, changed_files: list[str], args: argparse.Namespace) -> bool: 102 diff = self.format_run(changed_files, args) 103 if diff: 104 self.update_pr(diff, args) 105 return False 106 else: 107 self.update_pr_success(args) 108 return True 109 110 111class ClangFormatHelper(FormatHelper): 112 name = "clang-format" 113 friendly_name = "C/C++ code formatter" 114 115 @property 116 def instructions(self) -> str: 117 return " ".join(self.cf_cmd) 118 119 @cached_property 120 def libcxx_excluded_files(self) -> list[str]: 121 with open("libcxx/utils/data/ignore_format.txt", "r") as ifd: 122 return [excl.strip() for excl in ifd.readlines()] 123 124 def should_be_excluded(self, path: str) -> bool: 125 if path in self.libcxx_excluded_files: 126 print(f"Excluding file {path}") 127 return True 128 return False 129 130 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 131 filtered_files = [] 132 for path in changed_files: 133 _, ext = os.path.splitext(path) 134 if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx"): 135 if not self.should_be_excluded(path): 136 filtered_files.append(path) 137 return filtered_files 138 139 def format_run( 140 self, changed_files: list[str], args: argparse.Namespace 141 ) -> str | None: 142 cpp_files = self.filter_changed_files(changed_files) 143 if not cpp_files: 144 return None 145 cf_cmd = [ 146 "git-clang-format", 147 "--diff", 148 args.start_rev, 149 args.end_rev, 150 "--", 151 ] + cpp_files 152 print(f"Running: {' '.join(cf_cmd)}") 153 self.cf_cmd = cf_cmd 154 proc = subprocess.run(cf_cmd, capture_output=True) 155 156 # formatting needed 157 if proc.returncode == 1: 158 return proc.stdout.decode("utf-8") 159 160 return None 161 162 163class DarkerFormatHelper(FormatHelper): 164 name = "darker" 165 friendly_name = "Python code formatter" 166 167 @property 168 def instructions(self) -> str: 169 return " ".join(self.darker_cmd) 170 171 def filter_changed_files(self, changed_files: list[str]) -> list[str]: 172 filtered_files = [] 173 for path in changed_files: 174 name, ext = os.path.splitext(path) 175 if ext == ".py": 176 filtered_files.append(path) 177 178 return filtered_files 179 180 def format_run( 181 self, changed_files: list[str], args: argparse.Namespace 182 ) -> str | None: 183 py_files = self.filter_changed_files(changed_files) 184 if not py_files: 185 return None 186 darker_cmd = [ 187 "darker", 188 "--check", 189 "--diff", 190 "-r", 191 f"{args.start_rev}..{args.end_rev}", 192 ] + py_files 193 print(f"Running: {' '.join(darker_cmd)}") 194 self.darker_cmd = darker_cmd 195 proc = subprocess.run(darker_cmd, capture_output=True) 196 197 # formatting needed 198 if proc.returncode == 1: 199 return proc.stdout.decode("utf-8") 200 201 return None 202 203 204ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper()) 205 206if __name__ == "__main__": 207 parser = argparse.ArgumentParser() 208 parser.add_argument( 209 "--token", type=str, required=True, help="GitHub authentiation token" 210 ) 211 parser.add_argument( 212 "--repo", 213 type=str, 214 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 215 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 216 ) 217 parser.add_argument("--issue-number", type=int, required=True) 218 parser.add_argument( 219 "--start-rev", 220 type=str, 221 required=True, 222 help="Compute changes from this revision.", 223 ) 224 parser.add_argument( 225 "--end-rev", type=str, required=True, help="Compute changes to this revision" 226 ) 227 parser.add_argument( 228 "--changed-files", 229 type=str, 230 help="Comma separated list of files that has been changed", 231 ) 232 233 args = parser.parse_args() 234 235 changed_files = [] 236 if args.changed_files: 237 changed_files = args.changed_files.split(",") 238 239 exit_code = 0 240 for fmt in ALL_FORMATTERS: 241 if not fmt.run(changed_files, args): 242 exit_code = 1 243 244 sys.exit(exit_code) 245