Repository: cpp-linter/cpp-linter (language: python)

Issue:
Switch to unidiff
This migrates diff-parsing fallback mechanism (previously using regex patterns) to new dependecy [unidiff](https://pypi.org/project/unidiff).

This allows for a few new tactics:
- able to parse partial patches provided by REST API endpoints that list files with individual patch for each file (addresses cpp-linter/cpp-linter-action#249)
- able to move PR review comment generation from `rest_api/github_api.py` module into new `clang_tools.patcher.py` module because we don't need to keep data (previously used by `pygit2.Patch`) alive for the entire process (which avoided data corruption when the string of changes dropped out of scope).

   This change employs a mixin class to generate PR suggestions (for better/clearer code reuse).

I originally intended to use this to merge suggestions from both tools, but that isn't as easy as it sounds. Perhaps later, I can experiment with diff merging using these changes.
resort to paginated request(s) to get file changes
This uses the paginated JSON responses from Github's REST API to get file changes (with individual diffs) when the full diff is too large for 1 HTTP request/response.
- [endpoint that lists the files in a PR](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests-files)
- [endpoint that lists the files in a commits](https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit)

These changes are abstracted from the initial proposal in #115 and solely aims to address cpp-linter/cpp-linter-action#249


Discussion / hints:
It seems that our pr-review test wasn't generating suggestions with clang tools v7 and v8. I'm inclined to skip those versions when running tests, but It might be better to adjust the checks so that those versions generate suggestions.

Also, there seems to be a problem in CI where the coverage data (`.coverage*` files) isn't generated into repo root. This doesn't happen locally, so I'll have to investigate more in depth.


Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0fe15440..030c5ae7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -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)$
@@ -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:
@@ -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
diff --git a/cpp_linter/clang_tools/clang_format.py b/cpp_linter/clang_tools/clang_format.py
index 913cf300..a32139cf 100644
--- a/cpp_linter/clang_tools/clang_format.py
+++ b/cpp_linter/clang_tools/clang_format.py
@@ -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:
@@ -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
@@ -69,8 +70,7 @@ 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 (
@@ -78,6 +78,12 @@ def __repr__(self) -> str:
             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"""
diff --git a/cpp_linter/clang_tools/clang_tidy.py b/cpp_linter/clang_tools/clang_tidy.py
index 48b156c4..af4821ba 100644
--- a/cpp_linter/clang_tools/clang_tidy.py
+++ b/cpp_linter/clang_tools/clang_tidy.py
@@ -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$")
@@ -98,10 +99,10 @@ def __repr__(self) -> str:
         )
 
 
-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:
@@ -115,6 +116,50 @@ def diagnostics_in_range(self, start: int, end: int) -> str:
                     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
+
+        # 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)
+
 
 def tally_tidy_advice(files: List[FileObj]) -> int:
     """Returns the sum of clang-format errors"""
diff --git a/cpp_linter/clang_tools/patcher.py b/cpp_linter/clang_tools/patcher.py
new file mode 100644
index 00000000..7354f773
--- /dev/null
+++ b/cpp_linter/clang_tools/patcher.py
@@ -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)
diff --git a/cpp_linter/common_fs/__init__.py b/cpp_linter/common_fs/__init__.py
index 9199ce49..974b8dd6 100644
--- a/cpp_linter/common_fs/__init__.py
+++ b/cpp_linter/common_fs/__init__.py
@@ -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
diff --git a/cpp_linter/rest_api/github_api.py b/cpp_linter/rest_api/github_api.py
index 92731223..f2aa102a 100644
--- a/cpp_linter/rest_api/github_api.py
+++ b/cpp_linter/rest_api/github_api.py
@@ -15,31 +15,21 @@
 from pathlib import Path
 import urllib.parse
 import sys
-from typing import Dict, List, Any, cast, Optional, Tuple, Union
+from typing import Dict, List, Any, cast, Optional
 
-from pygit2 import Patch  # type: ignore
 from ..common_fs import FileObj, CACHE_PATH
 from ..common_fs.file_filter import FileFilter
 from ..clang_tools.clang_format import (
-    FormatAdvice,
     formalize_style_name,
     tally_format_advice,
 )
-from ..clang_tools.clang_tidy import TidyAdvice, tally_tidy_advice
+from ..clang_tools.clang_tidy import tally_tidy_advice
+from ..clang_tools.patcher import ReviewComments, PatchMixin
 from ..cli import Args
 from ..loggers import logger, log_commander
 from ..git import parse_diff, get_diff
 from . import RestApiClient, USER_OUTREACH, COMMENT_MARKER, RateLimitHeaders
 
-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
-
 RATE_LIMIT_HEADERS = RateLimitHeaders(
     reset="x-ratelimit-reset",
     remaining="x-ratelimit-remaining",
@@ -413,31 +403,27 @@ def post_review(
             return  # don't post reviews
         body = f"{COMMENT_MARKER}## Cpp-linter Review\n"
         payload_comments = []
-        total_changes = 0
-        summary_only = (
-            environ.get("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY", "false") == "true"
-        )
-        advice: Dict[str, bool] = {}
+        summary_only = environ.get(
+            "CPP_LINTER_PR_REVIEW_SUMMARY_ONLY", "false"
+        ).lower() in ("true", "on", "1")
+        advice = []
         if format_review:
-            advice["clang-format"] = False
+            advice.append("clang-format")
         if tidy_review:
-            advice["clang-tidy"] = True
-        for tool_name, tidy_tool in advice.items():
-            comments, total, patch = self.create_review_comments(
-                files, tidy_tool, summary_only
+            advice.append("clang-tidy")
+        review_comments = ReviewComments()
+        for tool_name in advice:
+            self.create_review_comments(
+                files=files,
+                tidy_tool=tool_name == "clang-tidy",
+                summary_only=summary_only,
+                review_comments=review_comments,
             )
-            total_changes += total
-            if not summary_only:
-                payload_comments.extend(comments)
-                if total and total != len(comments):
-                    body += f"Only {len(comments)} out of {total} {tool_name} "
-                    body += "concerns fit within this pull request's diff.\n"
-            if patch:
-                body += f"\n<details><summary>Click here for the full {tool_name} patch"
-                body += f"</summary>\n\n\n```diff\n{patch}\n```\n\n\n</details>\n\n"
-            elif not total:
-                body += f"No concerns from {tool_name}.\n"
-        if total_changes:
+        (summary, comments) = review_comments.serialize_to_github_payload()
+        if not summary_only:
+            payload_comments.extend(comments)
+        body += summary
+        if sum(review_comments.tool_total.values()):
             event = "REQUEST_CHANGES"
         else:
             if no_lgtm:
@@ -460,92 +446,28 @@ def create_review_comments(
         files: List[FileObj],
         tidy_tool: bool,
         summary_only: bool,
-    ) -> Tuple[List[Dict[str, Any]], int, str]:
-        """Creates a batch of comments for a specific clang tool's PR review"""
-        total = 0
-        comments = []
-        full_patch = ""
+        review_comments: ReviewComments,
+    ):
+        """Creates a batch of comments for a specific clang tool's PR review.
+
+        :param files: The list of files to traverse.
+        :param tidy_tool: A flag to indicate if the suggestions should originate
+            from clang-tidy.
+        :param summary_only: A flag to indicate if only the review summary is desired.
+        :param review_comments: An object (passed by reference) that is used to store
+            the results.
+        """
         for file_obj in files:
-            tool_advice: Optional[Union[TidyAdvice, FormatAdvice]]
+            tool_advice: Optional[PatchMixin]
             if tidy_tool:
                 tool_advice = file_obj.tidy_advice
             else:
                 tool_advice = file_obj.format_advice
             if not tool_advice:
                 continue
-            assert tool_advice.patched, f"No suggested patch found for {file_obj.name}"
-            patch = Patch.create_from(
-                old=Path(file_obj.name).read_bytes(),
-                new=tool_advice.patched,
-                old_as_path=file_obj.name,
-                new_as_path=file_obj.name,
-                flag=INDENT_HEURISTIC,
-                context_lines=0,  # trim all unchanged lines from start/end of hunks
+            tool_advice.get_suggestions_from_patch(
+                file_obj, summary_only, review_comments
             )
-            full_patch += patch.text
-            for hunk in patch.hunks:
-                total += 1
-                if summary_only:
-                    continue
-                new_hunk_range = file_obj.is_hunk_contained(hunk)
-                if new_hunk_range is None:
-                    continue
-                start_lines, end_lines = new_hunk_range
-                comment: Dict[str, Any] = {"path": file_obj.name}
-                body = ""
-                if tidy_tool and file_obj.tidy_advice:
-                    body += "### clang-tidy "
-                    diagnostics = file_obj.tidy_advice.diagnostics_in_range(
-                        start_lines, end_lines
-                    )
-                    if diagnostics:
-                        body += "diagnostics\n" + diagnostics
-                    else:
-                        body += "suggestions\n"
-                elif not tidy_tool:
-                    body += "### clang-format suggestions\n"
-                if start_lines < end_lines:
-                    comment["start_line"] = start_lines
-                comment["line"] = end_lines
-                suggestion = ""
-                removed = []
-                for line in hunk.lines:
-                    if line.origin in ["+", " "]:
-                        suggestion += line.content
-                    else:
-                        removed.append(line.old_lineno)
-                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["body"] = body
-                comments.append(comment)
-
-            # now check for clang-tidy warnings with no fixes applied
-            if tidy_tool and file_obj.tidy_advice:
-                for note in file_obj.tidy_advice.notes:
-                    if not note.applied_fixes:  # if no fix was applied
-                        total += 1
-                        line_numb = int(note.line)
-                        if file_obj.is_range_contained(
-                            start=line_numb, end=line_numb + 1
-                        ):
-                            diag: Dict[str, Any] = {
-                                "path": file_obj.name,
-                                "line": note.line,
-                            }
-                            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 line in note.fixit_lines:
-                                    body += f"{line}\n"
-                                body += "```\n"
-                            diag["body"] = body
-                            comments.append(diag)
-        return (comments, total, full_patch)
 
     def _dismiss_stale_reviews(self, url: str):
         """Dismiss all reviews that were previously created by cpp-linter"""
diff --git a/cspell.config.yml b/cspell.config.yml
new file mode 100644
index 00000000..563d93c8
--- /dev/null
+++ b/cspell.config.yml
@@ -0,0 +1,64 @@
+version: "0.2"
+language: en
+words:
+  - argnames
+  - argvalues
+  - automodule
+  - bndy
+  - bugprone
+  - bysource
+  - caplog
+  - capsys
+  - codecov
+  - codespell
+  - consts
+  - cppcoreguidelines
+  - cstdio
+  - docutils
+  - endgroup
+  - Fixit
+  - fontawesome
+  - gitmodules
+  - gmtime
+  - intersphinx
+  - iomanip
+  - keepends
+  - levelno
+  - libgit
+  - libvips
+  - markdownlint
+  - maxsplit
+  - mktime
+  - mypy
+  - posix
+  - pybind
+  - pygit
+  - pypi
+  - pyproject
+  - pytest
+  - ratelimit
+  - revparse
+  - seealso
+  - setenv
+  - shenxianpeng
+  - srcdir
+  - stddef
+  - tada
+  - toctree
+  - tofile
+  - tomli
+  - undoc
+  - vararg
+  - venv
+  - viewcode
+ignorePaths:
+  - .env/**
+  - .venv/**
+  - env/**
+  - venv/**
+  - tests/**/*.{json,h,c,cpp,hpp,patch,diff}
+  - "**.clang-tidy"
+  - "**.clang-format"
+  - pyproject.toml
+  - .gitignore
+  - "**/*.{yml,yaml,txt}"
diff --git a/docs/API-Reference/cpp_linter.clang_tools.patcher.rst b/docs/API-Reference/cpp_linter.clang_tools.patcher.rst
new file mode 100644
index 00000000..e7165b6c
--- /dev/null
+++ b/docs/API-Reference/cpp_linter.clang_tools.patcher.rst
@@ -0,0 +1,5 @@
+``clang_tools.patcher``
+=======================
+
+.. automodule:: cpp_linter.clang_tools.patcher
+    :members:
diff --git a/docs/index.rst b/docs/index.rst
index b65a4815..e51602b1 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -16,6 +16,7 @@
    API-Reference/cpp_linter.clang_tools
    API-Reference/cpp_linter.clang_tools.clang_format
    API-Reference/cpp_linter.clang_tools.clang_tidy
+   API-Reference/cpp_linter.clang_tools.patcher
    API-Reference/cpp_linter.rest_api
    API-Reference/cpp_linter.rest_api.github_api
    API-Reference/cpp_linter.git
diff --git a/docs/pr_review_caveats.rst b/docs/pr_review_caveats.rst
index 77888bcc..fcf024b5 100644
--- a/docs/pr_review_caveats.rst
+++ b/docs/pr_review_caveats.rst
@@ -65,8 +65,8 @@ GitHub REST API does not provide a way to hide comments or mark review suggestio
 .. tip::
 
     We do support an environment variable named ``CPP_LINTER_PR_REVIEW_SUMMARY_ONLY``.
-    If the variable is set to ``true``, then the review only contains a summary comment
-    with no suggestions posted in the diff.
+    If the variable is set either ``true``, ``on``, or ``1``, then the review only
+    contains a summary comment with no suggestions posted in the diff.
 
 Probable non-exhaustive reviews
 -------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index b1f7f673..bf5348b6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [build-system]
-requires = ["setuptools>=61", "setuptools-scm"]
+requires = ["setuptools>=62.6", "setuptools-scm"]
 build-backend = "setuptools.build_meta"
 
 [project]
@@ -12,11 +12,6 @@ authors = [
     { name = "Brendan Doherty", email = "2bndy5@gmail.com" },
     { name = "Peter Shen", email = "xianpeng.shen@gmail.com" },
 ]
-dependencies = [
-    "requests",
-    "pyyaml",
-    "pygit2",
-]
 classifiers = [
     # https://pypi.org/pypi?%3Aaction=list_classifiers
     "Development Status :: 5 - Production/Stable",
@@ -31,7 +26,7 @@ classifiers = [
     "Programming Language :: Python :: 3",
     "Topic :: Software Development :: Build Tools",
 ]
-dynamic = ["version"]
+dynamic = ["version", "dependencies"]
 
 [project.scripts]
 cpp-linter = "cpp_linter:main"
@@ -47,6 +42,9 @@ tracker = "https://github.com/cpp-linter/cpp-linter/issues"
 zip-safe = false
 packages = ["cpp_linter"]
 
+[tool.setuptools.dynamic]
+dependencies = {file = ["requirements.txt"]}
+
 [tool.setuptools_scm]
 # It would be nice to include the commit hash in the version, but that
 # can't be done in a PEP 440-compatible way.
@@ -61,7 +59,7 @@ show_column_numbers = true
 
 [tool.pytest.ini_options]
 minversion = "6.0"
-addopts = "-vv"
+addopts = "-vv --durations=8 --color=yes"
 testpaths = ["tests"]
 
 [tool.coverage]


Test patch:
diff --git a/.github/workflows/run-dev-tests.yml b/.github/workflows/run-dev-tests.yml
index 0704ae95..b7022ecd 100644
--- a/.github/workflows/run-dev-tests.yml
+++ b/.github/workflows/run-dev-tests.yml
@@ -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:
diff --git a/tests/demo/.clang-tidy b/tests/demo/.clang-tidy
index d3865ade..ba113044 100644
--- a/tests/demo/.clang-tidy
+++ b/tests/demo/.clang-tidy
@@ -2,7 +2,6 @@
 Checks:          'clang-diagnostic-*,clang-analyzer-*,-*,performance-*,bugprone-*,clang-analyzer-*,mpi-*,misc-*,readability-*'
 WarningsAsErrors: ''
 HeaderFilterRegex: ''
-AnalyzeTemporaryDtors: false
 FormatStyle:     'file'
 CheckOptions:
   - key:             bugprone-argument-comment.CommentBoolLiterals
diff --git a/tests/reviews/test_pr_review.py b/tests/reviews/test_pr_review.py
index 2853b8ff..3f7de7aa 100644
--- a/tests/reviews/test_pr_review.py
+++ b/tests/reviews/test_pr_review.py
@@ -53,6 +53,7 @@ def mk_param_set(**kwargs) -> OrderedDict:
         tuple(mk_param_set(force_approved=True).values()),
         tuple(mk_param_set(force_approved=True, no_lgtm=True).values()),
         tuple(mk_param_set(tidy_review=True, format_review=False).values()),
+        tuple(mk_param_set(tidy_review=True, format_review=True).values()),
         tuple(mk_param_set(format_review=True).values()),
         tuple(mk_param_set(tidy_review=True, changes=1).values()),
         tuple(mk_param_set(tidy_review=True, changes=0).values()),
@@ -66,6 +67,7 @@ def mk_param_set(**kwargs) -> OrderedDict:
         "approved",
         "no_lgtm",
         "tidy",  # changes == diff_chunks only
+        "tidy+format",  # changes == diff_chunks only
         "format",  # changes == diff_chunks only
         "lines_added",
         "all_lines",
@@ -123,7 +125,7 @@ def test_post_review(
         # load mock responses for pull_request event
         mock.get(
             base_url,
-            headers={"Accept": "application/vnd.github.diff"},
+            request_headers={"Accept": "application/vnd.github.diff"},
             text=(cache_path / f"pr_{TEST_PR}.diff").read_text(encoding="utf-8"),
         )
         reviews = (cache_path / "pr_reviews.json").read_text(encoding="utf-8")
diff --git a/tests/test_rate_limits.py b/tests/test_rate_limits.py
index 7c08288f..9d24009c 100644
--- a/tests/test_rate_limits.py
+++ b/tests/test_rate_limits.py
@@ -41,5 +41,5 @@ def test_rate_limit(monkeypatch: pytest.MonkeyPatch, response_headers: Dict[str,
         # ensure function exits early
         with pytest.raises(SystemExit) as exc:
             gh_client.api_request(url)
-        assert exc.type == SystemExit
+        assert exc.type is SystemExit
         assert exc.value.code == 1