Skip to content

Commit 427e82e

Browse files
authored
feat(#97): add code suggestions to GitHub comments (#113)
1 parent 69d516e commit 427e82e

File tree

7 files changed

+660
-31
lines changed

7 files changed

+660
-31
lines changed

src/lgtm_ai/__main__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from lgtm_ai.ai.schemas import AgentSettings, CommentCategory, SupportedAIModels, SupportedAIModelsList
1616
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesSource, OutputFormat, PRUrl
17-
from lgtm_ai.base.utils import git_source_supports_suggestions
17+
from lgtm_ai.base.utils import git_source_supports_multiline_suggestions
1818
from lgtm_ai.config.constants import DEFAULT_INPUT_TOKEN_LIMIT
1919
from lgtm_ai.config.handler import ConfigHandler, PartialConfig, ResolvedConfig
2020
from lgtm_ai.formatters.base import Formatter
@@ -177,7 +177,9 @@ def review(
177177
config_file=config,
178178
).resolve_config()
179179
agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
180-
formatter: Formatter[Any] = MarkDownFormatter(use_suggestions=git_source_supports_suggestions(pr_url.source))
180+
formatter: Formatter[Any] = MarkDownFormatter(
181+
add_ranges_to_suggestions=git_source_supports_multiline_suggestions(pr_url.source)
182+
)
181183
git_client = get_git_client(source=pr_url.source, token=resolved_config.git_api_key, formatter=formatter)
182184
issues_client = _get_issues_client(resolved_config, git_client, formatter)
183185

src/lgtm_ai/base/utils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ def file_matches_any_pattern(file_name: str, patterns: tuple[str, ...]) -> bool:
1414
return False
1515

1616

17-
def git_source_supports_suggestions(source: PRSource) -> bool:
18-
"""For now, we only support suggestions in GitLab.
17+
def git_source_supports_multiline_suggestions(source: PRSource) -> bool:
18+
"""Check if the given git source supports multiline suggestions.
1919
20-
TODO: https://github.com/elementsinteractive/lgtm-ai/issues/96
20+
GitLab does support specifying suggestions that span multiple lines in single-line comments.
21+
GitHub requires the comment to be multi-line, and the suggestion does not need special markup with ranges.
2122
"""
2223
return source == PRSource.gitlab

src/lgtm_ai/formatters/markdown.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ class MarkDownFormatter(Formatter[str]):
1515
SNIPPET_TEMPLATE: ClassVar[str] = "snippet.md.j2"
1616
METADATA_TEMPLATE: ClassVar[str] = "metadata.md.j2"
1717

18-
def __init__(self, use_suggestions: bool = False) -> None:
19-
self.use_suggestions = use_suggestions
18+
def __init__(self, add_ranges_to_suggestions: bool = False) -> None:
19+
self.add_ranges_to_suggestions = add_ranges_to_suggestions
2020
template_dir = pathlib.Path(__file__).parent / "templates"
2121
self._template_env = Environment(loader=FileSystemLoader(template_dir), autoescape=True)
2222

@@ -52,7 +52,7 @@ def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = T
5252
snippet=snippet,
5353
comment=comment.comment,
5454
suggestion=comment.suggestion,
55-
use_suggestions=self.use_suggestions,
55+
add_ranges_to_suggestions=self.add_ranges_to_suggestions,
5656
with_footer=with_footer,
5757
new_path=comment.new_path,
5858
line_number=comment.line_number,

src/lgtm_ai/formatters/templates/review_comment.md.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
{{ comment | safe }}
1010

1111
{% if suggestion %}
12-
{% if use_suggestions and suggestion.ready_for_replacement %}
13-
`````suggestion:{{ suggestion.start_offset.direction}}{{ suggestion.start_offset.offset }}{{ suggestion.end_offset.direction }}{{ suggestion.end_offset.offset }}
12+
{% if suggestion.ready_for_replacement %}
13+
`````suggestion{% if add_ranges_to_suggestions %}:{{ suggestion.start_offset.direction}}{{ suggestion.start_offset.offset }}{{ suggestion.end_offset.direction }}{{ suggestion.end_offset.offset }}{% endif %}
1414
{{ suggestion.snippet | safe }}
1515
`````
1616
{% else %}

src/lgtm_ai/git_client/github.py

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import binascii
22
import logging
33
from functools import lru_cache
4+
from typing import Any, Literal, cast
45
from urllib.parse import urlparse
56

67
import github
@@ -10,7 +11,7 @@
1011
import github.PullRequest
1112
import github.PullRequestReview
1213
import github.Repository
13-
from lgtm_ai.ai.schemas import Review, ReviewGuide
14+
from lgtm_ai.ai.schemas import CodeSuggestionOffset, Review, ReviewComment, ReviewGuide
1415
from lgtm_ai.base.schemas import PRUrl
1516
from lgtm_ai.formatters.base import Formatter
1617
from lgtm_ai.git_client.base import GitClient
@@ -74,16 +75,8 @@ def publish_review(self, pr_url: PRUrl, review: Review) -> None:
7475
Publish a main summary comment and then specific line comments.
7576
"""
7677
pr = _get_pr(self.client, pr_url)
77-
# Prepare the list of inline comments
78-
comments: list[github.PullRequest.ReviewComment] = []
79-
for c in review.review_response.comments:
80-
comments.append(
81-
{
82-
"path": c.new_path,
83-
"position": c.relative_line_number,
84-
"body": self.formatter.format_review_comment(c),
85-
}
86-
)
78+
comment_builder = CommentBuilder(self.formatter)
79+
comments = [comment_builder.generate_comment_payload(c) for c in review.review_response.comments]
8780
try:
8881
commit = pr.base.repo.get_commit(pr.head.sha)
8982
pr.create_review(
@@ -92,8 +85,24 @@ def publish_review(self, pr_url: PRUrl, review: Review) -> None:
9285
comments=comments,
9386
commit=commit,
9487
)
95-
except github.GithubException as err:
96-
raise PublishReviewError from err
88+
except github.GithubException:
89+
try:
90+
# Fallback to single-line comments if multi-line comments fail
91+
logger.warning(
92+
"Failed to publish review with multi-line comments, falling back to single-line comments"
93+
)
94+
comments = [
95+
comment_builder.generate_comment_payload(c, force_single_line=True)
96+
for c in review.review_response.comments
97+
]
98+
pr.create_review(
99+
body=self.formatter.format_review_summary_section(review),
100+
event="COMMENT",
101+
comments=comments,
102+
commit=commit,
103+
)
104+
except github.GithubException as err:
105+
raise PublishReviewError from err
97106

98107
def get_pr_metadata(self, pr_url: PRUrl) -> PRMetadata:
99108
"""Return a PRMetadata object containing the metadata of the given pull request URL."""
@@ -201,3 +210,82 @@ def _get_repo_from_issues_url(client: github.Github, issues_url: HttpUrl) -> git
201210
raise ValueError("Invalid GitHub issues URL")
202211
repo_path = f"{parts[0]}/{parts[1]}"
203212
return _get_repo(client, repo_path)
213+
214+
215+
class CommentBuilder:
216+
def __init__(self, formatter: Formatter[str]) -> None:
217+
self.formatter = formatter
218+
219+
def generate_comment_payload(
220+
self, comment: ReviewComment, *, force_single_line: bool = False
221+
) -> github.PullRequest.ReviewComment:
222+
"""Prepare comment data for GitHub API, handling both single-line and multi-line comments."""
223+
comment_data: dict[str, Any] = {
224+
"path": comment.new_path,
225+
"body": self.formatter.format_review_comment(comment),
226+
}
227+
228+
if not force_single_line and comment.suggestion and self._should_create_multiline_comment(comment):
229+
# Use the new GitHub API parameters for multi-line comments
230+
start_line, end_line = self._calculate_multiline_range(comment)
231+
side = self._determine_comment_side(comment)
232+
comment_data.update(
233+
{
234+
"line": end_line,
235+
"side": side,
236+
"start_line": start_line,
237+
"start_side": side,
238+
}
239+
)
240+
else:
241+
# Single-line comment using position (legacy parameter)
242+
comment_data["position"] = comment.relative_line_number
243+
244+
return cast(github.PullRequest.ReviewComment, comment_data)
245+
246+
def _should_create_multiline_comment(self, comment: ReviewComment) -> bool:
247+
"""Determine if a comment should be created as multi-line based on suggestion offsets."""
248+
if not comment.suggestion or not comment.suggestion.ready_for_replacement:
249+
return False
250+
251+
start_offset = comment.suggestion.start_offset
252+
end_offset = comment.suggestion.end_offset
253+
254+
# Check if the range spans more than one line
255+
start_line_offset = self._calculate_line_offset(start_offset)
256+
end_line_offset = self._calculate_line_offset(end_offset)
257+
258+
return start_line_offset != end_line_offset
259+
260+
def _calculate_multiline_range(self, comment: ReviewComment) -> tuple[int, int]:
261+
"""Calculate the start and end line numbers for a multi-line comment."""
262+
if not comment.suggestion:
263+
return comment.line_number, comment.line_number
264+
265+
base_line = comment.line_number
266+
start_line_offset = self._calculate_line_offset(comment.suggestion.start_offset)
267+
end_line_offset = self._calculate_line_offset(comment.suggestion.end_offset)
268+
269+
start_line = base_line + start_line_offset
270+
end_line = base_line + end_line_offset
271+
272+
# Ensure valid line numbers (must be positive)
273+
start_line = max(1, start_line)
274+
end_line = max(1, end_line)
275+
276+
# Ensure start_line <= end_line
277+
if start_line > end_line:
278+
start_line, end_line = end_line, start_line
279+
280+
return start_line, end_line
281+
282+
def _calculate_line_offset(self, suggestion_offset: CodeSuggestionOffset) -> int:
283+
"""Calculate the line offset from a CodeSuggestionOffset."""
284+
if suggestion_offset.direction == "-":
285+
return -suggestion_offset.offset
286+
else:
287+
return suggestion_offset.offset
288+
289+
def _determine_comment_side(self, comment: ReviewComment) -> Literal["LEFT", "RIGHT"]:
290+
"""Determine the correct side for GitHub API based on line type."""
291+
return "RIGHT" if comment.is_comment_on_new_path else "LEFT"

tests/formatters/test_markdown.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def test_format_comments_section_several_comments(self) -> None:
168168
assert self.formatter.format_review_comments_section(review.review_response.comments).split("\n") == expected
169169

170170
def test_format_comments_with_suggestions(self) -> None:
171-
formatter = MarkDownFormatter(use_suggestions=True)
171+
formatter = MarkDownFormatter(add_ranges_to_suggestions=True)
172172
review = Review(
173173
metadata=PublishMetadata(model_name="whatever", usage=MOCK_USAGE),
174174
review_response=ReviewResponse(
@@ -223,16 +223,20 @@ def test_format_comments_with_suggestions(self) -> None:
223223
assert formatter.format_review_comments_section(review.review_response.comments).split("\n") == expected
224224

225225
@pytest.mark.parametrize(
226-
("use_suggestions", "ready_for_replacement"),
226+
("add_ranges_to_suggestions", "ready_for_replacement", "header"),
227227
[
228-
(True, False), # should not format as suggestion block if not ready
229-
(False, True), # even if ready, should not format as suggestion block if use_suggestions is False
228+
(True, False, "python"), # should not format as suggestion block if not ready
229+
(
230+
False,
231+
True,
232+
"suggestion",
233+
), # even if ready, should not add ranges if the flag is off
230234
],
231235
)
232236
def test_format_comments_with_suggestions_but_formatted_normally(
233-
self, use_suggestions: bool, ready_for_replacement: bool
237+
self, add_ranges_to_suggestions: bool, ready_for_replacement: bool, header: str
234238
) -> None:
235-
formatter = MarkDownFormatter(use_suggestions=use_suggestions)
239+
formatter = MarkDownFormatter(add_ranges_to_suggestions=add_ranges_to_suggestions)
236240
review = Review(
237241
metadata=PublishMetadata(model_name="whatever", usage=MOCK_USAGE),
238242
review_response=ReviewResponse(
@@ -274,7 +278,7 @@ def test_format_comments_with_suggestions_but_formatted_normally(
274278
"",
275279
"",
276280
"",
277-
"`````python", # not a suggestion block
281+
f"`````{header}",
278282
"print('Hello World')",
279283
"`````",
280284
"",

0 commit comments

Comments
 (0)