xref: /llvm-project/llvm/utils/git/code-format-helper.py (revision da94bf0d561109529e4ab3dabfcbb8b6c258ea39)
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