PyGithub deprecated this a while back. An earlier patch attempted to fix all the deprecations, but it looks like my grep expression missed a bunch of cases. This patch should catch the remaining ones (minus libc++ which have been split for review by libc++ maintainers).
442 lines
14 KiB
Python
442 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
#
|
|
# ====- clang-tidy-helper, runs clang-tidy from the ci --*- python -*-------==#
|
|
#
|
|
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
|
|
# See https://llvm.org/LICENSE.txt for license information.
|
|
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
|
|
#
|
|
# ==-------------------------------------------------------------------------==#
|
|
"""A helper script to run clang-tidy linter in GitHub actions
|
|
|
|
This script is run by GitHub actions to ensure that the code in PR's conform to
|
|
the coding style of LLVM. The canonical source of this script is in the LLVM
|
|
source tree under llvm/utils/git.
|
|
|
|
You can learn more about the LLVM coding style on llvm.org:
|
|
https://llvm.org/docs/CodingStandards.html
|
|
"""
|
|
|
|
import argparse
|
|
import github
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from typing import Any, Dict, Final, Iterable, List, Sequence
|
|
|
|
|
|
class LintArgs:
|
|
start_rev: str
|
|
end_rev: str
|
|
repo: str
|
|
changed_files: Sequence[str]
|
|
token: str
|
|
verbose: bool = True
|
|
issue_number: int = 0
|
|
build_path: str = "build"
|
|
clang_tidy_binary: str = "clang-tidy"
|
|
doc8_binary: str = "doc8"
|
|
|
|
def __init__(self, args: argparse.Namespace) -> None:
|
|
if args is not None:
|
|
self.start_rev = args.start_rev
|
|
self.end_rev = args.end_rev
|
|
self.repo = args.repo
|
|
self.token = args.token
|
|
self.changed_files = (
|
|
args.changed_files.split(",") if args.changed_files else []
|
|
)
|
|
self.issue_number = args.issue_number
|
|
self.verbose = args.verbose
|
|
self.build_path = args.build_path
|
|
self.clang_tidy_binary = args.clang_tidy_binary
|
|
self.doc8_binary = args.doc8_binary
|
|
|
|
|
|
class LintHelper:
|
|
COMMENT_TAG: Final = "<!--LLVM CODE LINT COMMENT: {linter}-->"
|
|
name: str
|
|
friendly_name: str
|
|
comment: Dict[str, Any] = {}
|
|
|
|
@property
|
|
def comment_tag(self) -> str:
|
|
return self.COMMENT_TAG.format(linter=self.name)
|
|
|
|
def instructions(self, files_to_lint: Sequence[str], args: LintArgs) -> str:
|
|
raise NotImplementedError()
|
|
|
|
def filter_changed_files(self, changed_files: Sequence[str]) -> Sequence[str]:
|
|
raise NotImplementedError()
|
|
|
|
def run_linter_tool(self, files_to_lint: Sequence[str], args: LintArgs) -> str:
|
|
raise NotImplementedError()
|
|
|
|
def create_comment_text(
|
|
self, linter_output: str, files_to_lint: Sequence[str], args: LintArgs
|
|
) -> str:
|
|
instructions = self.instructions(files_to_lint, args)
|
|
return f"""
|
|
:warning: {self.friendly_name}, {self.name} found issues in your code. :warning:
|
|
|
|
<details>
|
|
<summary>
|
|
You can test this locally with the following command:
|
|
</summary>
|
|
|
|
```bash
|
|
{instructions}
|
|
```
|
|
|
|
</details>
|
|
|
|
<details>
|
|
<summary>
|
|
View the output from {self.name} here.
|
|
</summary>
|
|
|
|
```
|
|
{linter_output}
|
|
```
|
|
|
|
</details>
|
|
"""
|
|
|
|
def find_comment(self, pr: Any) -> Any:
|
|
for comment in pr.as_issue().get_comments():
|
|
if comment.body.startswith(self.comment_tag):
|
|
return comment
|
|
return None
|
|
|
|
def update_pr(self, comment_text: str, args: LintArgs, create_new: bool) -> None:
|
|
assert args.repo is not None
|
|
repo = github.Github(auth=github.Auth.Token(args.token)).get_repo(args.repo)
|
|
pr = repo.get_issue(args.issue_number).as_pull_request()
|
|
|
|
comment_text = f"{self.comment_tag}\n\n{comment_text}"
|
|
|
|
existing_comment = self.find_comment(pr)
|
|
|
|
if existing_comment:
|
|
self.comment = {"body": comment_text, "id": existing_comment.id}
|
|
elif create_new:
|
|
self.comment = {"body": comment_text}
|
|
|
|
|
|
def run(self, args: LintArgs) -> bool:
|
|
if args.verbose:
|
|
print(f"got changed files: {args.changed_files}")
|
|
|
|
files_to_lint = self.filter_changed_files(args.changed_files)
|
|
|
|
if not files_to_lint and args.verbose:
|
|
print("no modified files found")
|
|
|
|
is_success = True
|
|
linter_output = ""
|
|
|
|
if files_to_lint:
|
|
linter_output = self.run_linter_tool(files_to_lint, args)
|
|
if linter_output:
|
|
is_success = False
|
|
|
|
should_update_gh = args.token is not None and args.repo is not None
|
|
|
|
if is_success:
|
|
if should_update_gh:
|
|
comment_text = (
|
|
":white_check_mark: With the latest revision "
|
|
f"this PR passed the {self.friendly_name}."
|
|
)
|
|
self.update_pr(comment_text, args, create_new=False)
|
|
return True
|
|
else:
|
|
if should_update_gh:
|
|
if linter_output:
|
|
comment_text = self.create_comment_text(
|
|
linter_output, files_to_lint, args
|
|
)
|
|
self.update_pr(comment_text, args, create_new=True)
|
|
else:
|
|
# The linter failed but didn't output a result (e.g. some sort of
|
|
# infrastructure failure).
|
|
comment_text = (
|
|
f":warning: The {self.friendly_name} failed without printing "
|
|
"an output. Check the logs for output. :warning:"
|
|
)
|
|
self.update_pr(comment_text, args, create_new=False)
|
|
else:
|
|
if linter_output:
|
|
print(
|
|
f"Warning: {self.friendly_name}, {self.name} detected "
|
|
"some issues with your code..."
|
|
)
|
|
print(linter_output)
|
|
else:
|
|
print(f"Warning: {self.friendly_name}, {self.name} failed to run.")
|
|
return False
|
|
|
|
|
|
class ClangTidyLintHelper(LintHelper):
|
|
name: Final = "clang-tidy"
|
|
friendly_name: Final = "C/C++ code linter"
|
|
|
|
def instructions(self, files_to_lint: Sequence[str], args: LintArgs) -> str:
|
|
files_str = " ".join(files_to_lint)
|
|
return f"""
|
|
git diff -U0 origin/main...HEAD -- {files_str} |
|
|
python3 clang-tools-extra/clang-tidy/tool/clang-tidy-diff.py \
|
|
-path {args.build_path} -p1 -quiet"""
|
|
|
|
def filter_changed_files(self, changed_files: Sequence[str]) -> Sequence[str]:
|
|
clang_tidy_changed_files = [
|
|
arg for arg in changed_files if "third-party" not in arg
|
|
]
|
|
|
|
filtered_files: List[str] = []
|
|
for filepath in clang_tidy_changed_files:
|
|
_, ext = os.path.splitext(filepath)
|
|
if ext not in (".c", ".cpp", ".cxx", ".h", ".hpp", ".hxx"):
|
|
continue
|
|
if not self._should_lint_file(filepath):
|
|
continue
|
|
if os.path.exists(filepath):
|
|
filtered_files.append(filepath)
|
|
return filtered_files
|
|
|
|
def _should_lint_file(self, filepath: str) -> bool:
|
|
# TODO: Add more rules when enabling other projects to use clang-tidy in CI.
|
|
return filepath.startswith("clang-tools-extra/clang-tidy/")
|
|
|
|
def run_linter_tool(self, files_to_lint: Sequence[str], args: LintArgs) -> str:
|
|
if not files_to_lint:
|
|
return ""
|
|
|
|
git_diff_cmd = [
|
|
"git",
|
|
"diff",
|
|
"-U0",
|
|
f"{args.start_rev}...{args.end_rev}",
|
|
"--",
|
|
]
|
|
git_diff_cmd.extend(files_to_lint)
|
|
|
|
diff_proc = subprocess.run(
|
|
git_diff_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
if diff_proc.returncode != 0:
|
|
print(f"Git diff failed: {diff_proc.stderr}")
|
|
return ""
|
|
|
|
diff_content = diff_proc.stdout
|
|
if not diff_content.strip():
|
|
if args.verbose:
|
|
print("No diff content found")
|
|
return ""
|
|
|
|
tidy_diff_cmd = [
|
|
"clang-tools-extra/clang-tidy/tool/clang-tidy-diff.py",
|
|
"-path",
|
|
args.build_path,
|
|
"-p1",
|
|
"-quiet",
|
|
]
|
|
|
|
if args.verbose:
|
|
print(f"Running clang-tidy-diff: {' '.join(tidy_diff_cmd)}")
|
|
|
|
proc = subprocess.run(
|
|
tidy_diff_cmd,
|
|
input=diff_content,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
clean_output = self._clean_clang_tidy_output(proc.stdout.strip())
|
|
return clean_output
|
|
|
|
def _clean_clang_tidy_output(self, output: str) -> str:
|
|
"""
|
|
- Remove 'Running clang-tidy in X threads...' line
|
|
- Remove 'N warnings generated.' line
|
|
- Strip leading workspace path from file paths
|
|
"""
|
|
if not output or output == "No relevant changes found.":
|
|
return ""
|
|
|
|
lines = output.split("\n")
|
|
cleaned_lines = []
|
|
|
|
for line in lines:
|
|
if line.startswith("Running clang-tidy in") or line.endswith("generated."):
|
|
continue
|
|
|
|
# Remove everything up to rightmost "llvm-project/" for correct files names
|
|
idx = line.rfind("llvm-project/")
|
|
if idx != -1:
|
|
line = line[idx + len("llvm-project/") :]
|
|
|
|
cleaned_lines.append(line)
|
|
|
|
if cleaned_lines:
|
|
return "\n".join(cleaned_lines)
|
|
return ""
|
|
|
|
|
|
class Doc8LintHelper(LintHelper):
|
|
name: Final = "doc8"
|
|
friendly_name: Final = "RST documentation linter"
|
|
|
|
def instructions(self, files_to_lint: Iterable[str], args: LintArgs) -> str:
|
|
return f"doc8 -q {' '.join(files_to_lint)} --config clang-tools-extra/clang-tidy/doc8.ini"
|
|
|
|
def create_comment_text(
|
|
self, linter_output: str, files_to_lint: Sequence[str], args: LintArgs
|
|
) -> str:
|
|
comment = super().create_comment_text(linter_output, files_to_lint, args)
|
|
if "D001" in linter_output or "Line too long" in linter_output:
|
|
parts = comment.rsplit("</details>", 1)
|
|
if len(parts) == 2:
|
|
return (
|
|
parts[0]
|
|
+ "\nNote: documentation lines should be less than 80 characters wide.\n</details>"
|
|
+ parts[1]
|
|
)
|
|
return comment
|
|
|
|
def filter_changed_files(self, changed_files: Iterable[str]) -> Sequence[str]:
|
|
return list(filter(self._should_lint_file, changed_files))
|
|
|
|
def _should_lint_file(self, filepath: str) -> bool:
|
|
return (
|
|
os.path.splitext(filepath)[1] == ".rst"
|
|
and filepath.startswith("clang-tools-extra/docs/clang-tidy/")
|
|
and os.path.exists(filepath)
|
|
)
|
|
|
|
def run_linter_tool(self, files_to_lint: Iterable[str], args: LintArgs) -> str:
|
|
if not files_to_lint:
|
|
return ""
|
|
|
|
doc8_cmd = [
|
|
args.doc8_binary,
|
|
"-q",
|
|
"--config",
|
|
"clang-tools-extra/clang-tidy/doc8.ini",
|
|
]
|
|
doc8_cmd.extend(files_to_lint)
|
|
|
|
if args.verbose:
|
|
print(f"Running doc8: {' '.join(doc8_cmd)}")
|
|
|
|
proc = subprocess.run(
|
|
doc8_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
if proc.returncode == 0:
|
|
return ""
|
|
|
|
parts: List[str] = []
|
|
if output := proc.stdout.strip():
|
|
parts.append(output)
|
|
|
|
if error_output := proc.stderr.strip():
|
|
parts.append(f"stderr:\n{error_output}")
|
|
|
|
if parts:
|
|
return "\n\n".join(parts)
|
|
return f"doc8 exited with return code {proc.returncode} but no output."
|
|
|
|
|
|
ALL_LINTERS = (ClangTidyLintHelper(), Doc8LintHelper())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--token", type=str, required=True, help="GitHub authentication token"
|
|
)
|
|
parser.add_argument("--issue-number", type=int, required=True)
|
|
parser.add_argument(
|
|
"--repo",
|
|
type=str,
|
|
default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
|
|
help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
|
|
)
|
|
parser.add_argument(
|
|
"--start-rev",
|
|
type=str,
|
|
required=True,
|
|
help="Compute changes from this revision.",
|
|
)
|
|
parser.add_argument(
|
|
"--end-rev", type=str, required=True, help="Compute changes to this revision"
|
|
)
|
|
parser.add_argument(
|
|
"--changed-files",
|
|
type=str,
|
|
help="Comma separated list of files that has been changed",
|
|
)
|
|
parser.add_argument(
|
|
"--build-path",
|
|
type=str,
|
|
default="build",
|
|
help="Path to build directory with compile_commands.json",
|
|
)
|
|
parser.add_argument(
|
|
"--clang-tidy-binary",
|
|
type=str,
|
|
default="clang-tidy",
|
|
help="Path to clang-tidy binary",
|
|
)
|
|
parser.add_argument(
|
|
"--doc8-binary",
|
|
type=str,
|
|
default="doc8",
|
|
help="Path to doc8 binary",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose", action="store_true", default=True, help="Verbose output"
|
|
)
|
|
|
|
parsed_args = parser.parse_args()
|
|
args = LintArgs(parsed_args)
|
|
|
|
failed_linters: List[str] = []
|
|
comments: List[Dict[str, Any]] = []
|
|
|
|
for linter in ALL_LINTERS:
|
|
if args.verbose:
|
|
print(f"running linter {linter.name}")
|
|
|
|
if not linter.run(args):
|
|
failed_linters.append(linter.name)
|
|
if args.verbose:
|
|
print(f"linter {linter.name} failed")
|
|
|
|
# Write comments file if we have a comment
|
|
if linter.comment:
|
|
comments.append(linter.comment)
|
|
if args.verbose:
|
|
print(f"linter {linter.name} has comment: {linter.comment}")
|
|
|
|
if len(comments):
|
|
with open("comments", "w") as f:
|
|
json.dump(comments, f)
|
|
|
|
if len(failed_linters) > 0:
|
|
print(f"error: some linters failed: {' '.join(failed_linters)}")
|
|
sys.exit(1)
|