1
1
import binascii
2
2
import logging
3
3
from functools import lru_cache
4
+ from typing import Any , Literal , cast
4
5
from urllib .parse import urlparse
5
6
6
7
import github
10
11
import github .PullRequest
11
12
import github .PullRequestReview
12
13
import github .Repository
13
- from lgtm_ai .ai .schemas import Review , ReviewGuide
14
+ from lgtm_ai .ai .schemas import CodeSuggestionOffset , Review , ReviewComment , ReviewGuide
14
15
from lgtm_ai .base .schemas import PRUrl
15
16
from lgtm_ai .formatters .base import Formatter
16
17
from lgtm_ai .git_client .base import GitClient
@@ -74,16 +75,8 @@ def publish_review(self, pr_url: PRUrl, review: Review) -> None:
74
75
Publish a main summary comment and then specific line comments.
75
76
"""
76
77
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 ]
87
80
try :
88
81
commit = pr .base .repo .get_commit (pr .head .sha )
89
82
pr .create_review (
@@ -92,8 +85,24 @@ def publish_review(self, pr_url: PRUrl, review: Review) -> None:
92
85
comments = comments ,
93
86
commit = commit ,
94
87
)
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
97
106
98
107
def get_pr_metadata (self , pr_url : PRUrl ) -> PRMetadata :
99
108
"""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
201
210
raise ValueError ("Invalid GitHub issues URL" )
202
211
repo_path = f"{ parts [0 ]} /{ parts [1 ]} "
203
212
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"
0 commit comments