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