Skip to content

Refactor creating PR review comments #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Sep 9, 2024
Merged
2 changes: 1 addition & 1 deletion .github/workflows/run-dev-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
matrix:
py: ['3.8', '3.9', '3.10', '3.11']
os: ['windows-latest', ubuntu-22.04]
version: ['17', '16', '15', '14', '13', '12', '11', '10', '9', '8', '7']
version: ['18', '17', '16', '15', '14', '13', '12', '11', '10', '9', '8', '7']

runs-on: ${{ matrix.os }}
steps:
Expand Down
14 changes: 6 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
exclude: ^tests/.*\.(?:patch|diff)$
Expand All @@ -15,14 +15,14 @@ repos:
args: ["--fix=lf"]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.3.3
rev: v0.6.4
hooks:
# Run the linter.
- id: ruff
# Run the formatter.
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v1.9.0'
rev: 'v1.11.2'
hooks:
- id: mypy
additional_dependencies:
Expand All @@ -32,9 +32,7 @@ repos:
- pytest
- requests-mock
- '.'
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
- repo: https://github.com/streetsidesoftware/cspell-cli
rev: v8.13.3
hooks:
- id: codespell
additional_dependencies:
- tomli
- id: cspell
14 changes: 10 additions & 4 deletions cpp_linter/clang_tools/clang_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from pathlib import PurePath
import subprocess
from typing import List, cast, Optional
from typing import List, cast

import xml.etree.ElementTree as ET

from ..common_fs import get_line_cnt_from_cols, FileObj
from ..loggers import logger
from .patcher import PatchMixin


class FormatReplacement:
Expand Down Expand Up @@ -53,7 +54,7 @@ def __repr__(self):
)


class FormatAdvice:
class FormatAdvice(PatchMixin):
"""A single object to represent each suggestion.

:param filename: The source file's name for which the contents of the xml
Expand All @@ -69,15 +70,20 @@ def __init__(self, filename: str):
"""A list of `FormatReplacementLine` representing replacement(s)
on a single line."""

#: A buffer of the applied fixes from clang-format
self.patched: Optional[bytes] = None
super().__init__()

def __repr__(self) -> str:
return (
f"<XMLFixit with {len(self.replaced_lines)} lines of "
f"replacements for {self.filename}>"
)

def get_suggestion_help(self, start, end) -> str:
return super().get_suggestion_help(start, end) + "suggestion\n"

def get_tool_name(self) -> str:
return "clang-format"


def tally_format_advice(files: List[FileObj]) -> int:
"""Returns the sum of clang-format errors"""
Expand Down
49 changes: 47 additions & 2 deletions cpp_linter/clang_tools/clang_tidy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Tuple, Union, List, cast, Optional, Dict, Set
from ..loggers import logger
from ..common_fs import FileObj
from .patcher import PatchMixin, ReviewComments, Suggestion

NOTE_HEADER = re.compile(r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+)\]$")
FIXED_NOTE = re.compile(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$")
Expand Down Expand Up @@ -98,10 +99,10 @@
)


class TidyAdvice:
class TidyAdvice(PatchMixin):
def __init__(self, notes: List[TidyNotification]) -> None:
#: A buffer of the applied fixes from clang-tidy
self.patched: Optional[bytes] = None
super().__init__()
self.notes = notes

def diagnostics_in_range(self, start: int, end: int) -> str:
Expand All @@ -115,6 +116,50 @@
break
return diagnostics

def get_suggestion_help(self, start: int, end: int) -> str:
diagnostics = self.diagnostics_in_range(start, end)
prefix = super().get_suggestion_help(start, end)
if diagnostics:
return prefix + "diagnostics\n" + diagnostics
return prefix + "suggestion\n"

def get_tool_name(self) -> str:
return "clang-tidy"

def get_suggestions_from_patch(
self, file_obj: FileObj, summary_only: bool, review_comments: ReviewComments
):
super().get_suggestions_from_patch(file_obj, summary_only, review_comments)

def _has_related_suggestion(suggestion: Suggestion) -> bool:
for known in review_comments.suggestions:
if known.line_end >= suggestion.line_end >= known.line_start:
known.comment += f"\n{suggestion.comment}"
return True
return False

Check warning on line 139 in cpp_linter/clang_tools/clang_tidy.py

View check run for this annotation

Codecov / codecov/patch

cpp_linter/clang_tools/clang_tidy.py#L139

Added line #L139 was not covered by tests

# now check for clang-tidy warnings with no fixes applied
for note in self.notes:
if not note.applied_fixes: # if no fix was applied
line_numb = int(note.line)
if not summary_only and file_obj.is_range_contained(
start=line_numb, end=line_numb + 1
):
suggestion = Suggestion(file_obj.name)
suggestion.line_end = line_numb
body = f"### clang-tidy diagnostic\n**{file_obj.name}:"
body += f"{note.line}:{note.cols}:** {note.severity}: "
body += f"[{note.diagnostic_link}]\n> {note.rationale}\n"
if note.fixit_lines:
body += f'```{Path(file_obj.name).suffix.lstrip(".")}\n'
for fixit_line in note.fixit_lines:
body += f"{fixit_line}\n"
body += "```\n"
suggestion.comment = body
review_comments.tool_total["clang-tidy"] += 1
if not _has_related_suggestion(suggestion):
review_comments.suggestions.append(suggestion)

Check warning on line 161 in cpp_linter/clang_tools/clang_tidy.py

View check run for this annotation

Codecov / codecov/patch

cpp_linter/clang_tools/clang_tidy.py#L161

Added line #L161 was not covered by tests


def tally_tidy_advice(files: List[FileObj]) -> int:
"""Returns the sum of clang-format errors"""
Expand Down
195 changes: 195 additions & 0 deletions cpp_linter/clang_tools/patcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""A module to contain the abstractions about creating suggestions from a diff generated
by the clang tool's output."""

from abc import ABC
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from pygit2 import Patch # type: ignore
from ..common_fs import FileObj

try:
from pygit2.enums import DiffOption # type: ignore

INDENT_HEURISTIC = DiffOption.INDENT_HEURISTIC
except ImportError: # if pygit2.__version__ < 1.14
from pygit2 import GIT_DIFF_INDENT_HEURISTIC # type: ignore

INDENT_HEURISTIC = GIT_DIFF_INDENT_HEURISTIC


class Suggestion:
"""A data structure to contain information about a single suggestion.

:param file_name: The path to the file that this suggestion pertains.
This should use posix path separators.
"""

def __init__(self, file_name: str) -> None:
#: The file's line number starting the suggested change.
self.line_start: int = -1
#: The file's line number ending the suggested change.
self.line_end: int = -1
#: The file's path about the suggested change.
self.file_name: str = file_name
#: The markdown comment about the suggestion.
self.comment: str = ""

def serialize_to_github_payload(self) -> Dict[str, Any]:
"""Serialize this object into a JSON compatible with Github's REST API."""
assert self.line_end > 0, "ending line number unknown"
result = {"path": self.file_name, "body": self.comment, "line": self.line_end}
if self.line_start != self.line_end and self.line_start > 0:
result["start_line"] = self.line_start
return result


class ReviewComments:
"""A data structure to contain PR review comments from a specific clang tool."""

def __init__(self) -> None:
#: The list of actual comments
self.suggestions: List[Suggestion] = []

self.tool_total: Dict[str, int] = {"clang-tidy": 0, "clang-format": 0}
"""The total number of concerns about a specific clang tool.

This may not equate to the length of `suggestions` because
1. There is no guarantee that all suggestions will fit within the PR's diff.
2. Suggestions are a combined result of advice from both tools.
"""

self.full_patch: Dict[str, str] = {"clang-tidy": "", "clang-format": ""}
"""The full patch of all the suggestions (including those that will not
fit within the diff)"""

def merge_similar_suggestion(self, suggestion: Suggestion) -> bool:
"""Merge a given ``suggestion`` into a similar `Suggestion`

:returns: `True` if the suggestion was merged, otherwise `False`.
"""
for known in self.suggestions:
if (
known.line_end == suggestion.line_end
and known.line_start == suggestion.line_start
):
known.comment += f"\n{suggestion.comment}"
return True
return False

def serialize_to_github_payload(self) -> Tuple[str, List[Dict[str, Any]]]:
"""Serialize this object into a summary and list of comments compatible
with Github's REST API.

:returns: The returned tuple contains a brief summary (at index ``0``)
that contains markdown text describing the summary of the review
comments.

The list of `suggestions` (at index ``1``) is the serialized JSON
object.
"""
summary = ""
comments = []
posted_tool_advice = {"clang-tidy": 0, "clang-format": 0}
for comment in self.suggestions:
comments.append(comment.serialize_to_github_payload())
if "### clang-format" in comment.comment:
posted_tool_advice["clang-format"] += 1
if "### clang-tidy" in comment.comment:
posted_tool_advice["clang-tidy"] += 1

for tool_name in ("clang-tidy", "clang-format"):
if (
len(comments)
and posted_tool_advice[tool_name] != self.tool_total[tool_name]
):
summary += (
f"Only {posted_tool_advice[tool_name]} out of "
+ f"{self.tool_total[tool_name]} {tool_name}"
+ " concerns fit within this pull request's diff.\n"
)
if self.full_patch[tool_name]:
summary += (
f"\n<details><summary>Click here for the full {tool_name} patch"
+ f"</summary>\n\n\n```diff\n{self.full_patch[tool_name]}\n"
+ "```\n\n\n</details>\n\n"
)
elif not self.tool_total[tool_name]:
summary += f"No concerns from {tool_name}.\n"
result = (summary, comments)
return result


class PatchMixin(ABC):
"""An abstract mixin that unified parsing of the suggestions into
PR review comments."""

def __init__(self) -> None:
#: A unified diff of the applied fixes from the clang tool's output
self.patched: Optional[bytes] = None

def get_suggestion_help(self, start, end) -> str:
"""Create helpful text about what the suggestion aims to fix.

The parameters ``start`` and ``end`` are the line numbers (relative to file's
original content) encapsulating the suggestion.
"""

return f"### {self.get_tool_name()} "

def get_tool_name(self) -> str:
"""A function that must be implemented by derivatives to
get the clang tool's name that generated the `patched` data."""

raise NotImplementedError("must be implemented by derivative")

def get_suggestions_from_patch(
self, file_obj: FileObj, summary_only: bool, review_comments: ReviewComments
):
"""Create a list of suggestions from the tool's `patched` output.

Results are stored in the ``review_comments`` parameter (passed by reference).
"""
assert (
self.patched
), f"{self.__class__.__name__} has no suggestions for {file_obj.name}"
patch = Patch.create_from(
Path(file_obj.name).read_bytes(),
self.patched,
file_obj.name,
file_obj.name,
context_lines=0, # exclude any surrounding unchanged lines
flag=INDENT_HEURISTIC,
)
tool_name = self.get_tool_name()
assert tool_name in review_comments.full_patch
review_comments.full_patch[tool_name] += f"{patch.text}"
assert tool_name in review_comments.tool_total
for hunk in patch.hunks:
review_comments.tool_total[tool_name] += 1
if summary_only:
continue
new_hunk_range = file_obj.is_hunk_contained(hunk)
if new_hunk_range is None:
continue
start_line, end_line = new_hunk_range
comment = Suggestion(file_obj.name)
body = self.get_suggestion_help(start=start_line, end=end_line)
if start_line < end_line:
comment.line_start = start_line
comment.line_end = end_line
removed = []
suggestion = ""
for line in hunk.lines:
if line.origin in ("+", " "):
suggestion += f"{line.content}"
else:
line_numb = line.old_lineno
removed.append(line_numb)
if not suggestion and removed:
body += "\nPlease remove the line(s)\n- "
body += "\n- ".join([str(x) for x in removed])
else:
body += f"\n```suggestion\n{suggestion}```"
comment.comment = body
if not review_comments.merge_similar_suggestion(comment):
review_comments.suggestions.append(comment)
3 changes: 3 additions & 0 deletions cpp_linter/common_fs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def __init__(
#: The results from clang-format
self.format_advice: Optional["FormatAdvice"] = None

def __repr__(self) -> str:
return f"<FileObj {self.name} added:{self.additions} chunks:{self.diff_chunks}>"

@staticmethod
def _consolidate_list_to_ranges(numbers: List[int]) -> List[List[int]]:
"""A helper function that is only used after parsing the lines from a diff that
Expand Down
Loading