blob: 6f2afc9b2ad06b9015258fe97a9950ef40f88f29 [file] [log] [blame] [edit]
#!/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(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)