Skip to content

Commit a2a3dfb

Browse files
authored
fix(#97): prevent suggestions on GitHub to start outside of diff hunk (#115)
1 parent 427e82e commit a2a3dfb

File tree

10 files changed

+131
-10
lines changed

10 files changed

+131
-10
lines changed

src/lgtm_ai/ai/prompts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def _get_full_category_explanation() -> str:
108108
- Suggestions must be passed separately (not as part of the comment content), and they must include how many lines above and below the comment to include in the suggestion.
109109
- The offsets of suggestions must encompass all the code that needs to be changed. e.g., if you intend to change a whole function, the suggestion must include the full function. If you intend to change a single line, then the offsets will be 0.
110110
- If a suggestion is given, a flag indicating whether the suggestion is ready to be applied directly by the author must be given. That is, if the suggestion includes comments to be filled by the author, or skips parts and is intended for clarification, the flag `ready_for_replacement` must be set to `false`.
111+
- Ensure that suggestions don't span outside git hunk boundaries (`hunk_start_new` and `hunk_start_old` in the modified lines; new for comments on new path, old for comments on old path). If they do, adjust the suggestion to fit within the hunk.
111112
112113
The review will have a score for the PR (1-5, with 5 being the best). It is your job to evaluate whether this score holds after removing the comments.
113114
You must evaluate the score, and change it if necessary. Here is some guidance:

src/lgtm_ai/formatters/markdown.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = T
5757
new_path=comment.new_path,
5858
line_number=comment.line_number,
5959
relative_line_number=comment.relative_line_number,
60+
with_suggestion=bool(comment.suggestion),
61+
is_suggestion_ready=comment.suggestion and comment.suggestion.ready_for_replacement,
6062
)
6163

6264
def format_guide(self, guide: ReviewGuide) -> str:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@
2626
- **File**: `{{ new_path }}`
2727
- **Line**: `{{ line_number }}`
2828
- **Relative line**: `{{ relative_line_number }}`
29+
- **With suggestion**: `{{ 'Yes' if with_suggestion else 'No' }}`
30+
- **Suggestion ready for replacement**: `{{ 'Yes' if is_suggestion_ready else 'No' }}`
2931
</details>
3032
{% endif %}

src/lgtm_ai/git_parser/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class ModifiedLine(BaseModel):
1010
line_number: int
1111
relative_line_number: int
1212
modification_type: Literal["added", "removed"]
13+
hunk_start_new: int | None = None
14+
hunk_start_old: int | None = None
1315

1416

1517
class DiffFileMetadata(BaseModel):
@@ -37,6 +39,8 @@ def parse_diff_patch(metadata: DiffFileMetadata, diff_text: object) -> DiffResul
3739
old_line_num = 0
3840
new_line_num = 0
3941
rel_position = -1 # We just don't count the first hunk, but we do count the rest
42+
hunk_start_old = None
43+
hunk_start_new = None
4044

4145
try:
4246
for line in lines:
@@ -45,6 +49,8 @@ def parse_diff_patch(metadata: DiffFileMetadata, diff_text: object) -> DiffResul
4549
if hunk_match:
4650
old_line_num = int(hunk_match.group(1))
4751
new_line_num = int(hunk_match.group(2))
52+
hunk_start_new = new_line_num
53+
hunk_start_old = old_line_num
4854
continue
4955

5056
if line.startswith("+") and not line.startswith("+++"):
@@ -54,6 +60,8 @@ def parse_diff_patch(metadata: DiffFileMetadata, diff_text: object) -> DiffResul
5460
line_number=new_line_num,
5561
relative_line_number=rel_position,
5662
modification_type="added",
63+
hunk_start_new=hunk_start_new,
64+
hunk_start_old=hunk_start_old,
5765
)
5866
)
5967
new_line_num += 1
@@ -65,6 +73,8 @@ def parse_diff_patch(metadata: DiffFileMetadata, diff_text: object) -> DiffResul
6573
line_number=old_line_num,
6674
relative_line_number=rel_position,
6775
modification_type="removed",
76+
hunk_start_new=hunk_start_new,
77+
hunk_start_old=hunk_start_old,
6878
)
6979
)
7080
old_line_num += 1

tests/formatters/test_json.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def test_format_summary_section(self) -> None:
5656
"line_number": 2,
5757
"relative_line_number": 1,
5858
"modification_type": "removed",
59+
"hunk_start_new": None,
60+
"hunk_start_old": None,
5961
}
6062
],
6163
},
@@ -73,6 +75,8 @@ def test_format_summary_section(self) -> None:
7375
"line_number": 20,
7476
"relative_line_number": 2,
7577
"modification_type": "removed",
78+
"hunk_start_new": None,
79+
"hunk_start_old": None,
7680
}
7781
],
7882
},
@@ -149,6 +153,8 @@ def test_format_guide(self) -> None:
149153
"line_number": 2,
150154
"relative_line_number": 1,
151155
"modification_type": "removed",
156+
"hunk_start_new": None,
157+
"hunk_start_old": None,
152158
}
153159
],
154160
},
@@ -166,6 +172,8 @@ def test_format_guide(self) -> None:
166172
"line_number": 20,
167173
"relative_line_number": 2,
168174
"modification_type": "removed",
175+
"hunk_start_new": None,
176+
"hunk_start_old": None,
169177
}
170178
],
171179
},

tests/formatters/test_markdown.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,8 @@ def test_format_comment_with_snippet(self) -> None:
335335
"- **File**: `new_path`",
336336
"- **Line**: `1`",
337337
"- **Relative line**: `1`",
338+
"- **With suggestion**: `No`",
339+
"- **Suggestion ready for replacement**: `No`",
338340
"</details>",
339341
"",
340342
]

tests/git_client/fixtures.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,43 @@
2323
line_number=48,
2424
relative_line_number=4,
2525
modification_type="removed",
26+
hunk_start_new=45,
27+
hunk_start_old=45,
2628
),
2729
ModifiedLine(
2830
line=' {{ run }} ruff check {{ target_dirs }} {{ if report == "true" { "--output-format gitlab > tests/gl-code-quality-report.json" } else { "" } }}',
2931
line_number=48,
3032
relative_line_number=5,
3133
modification_type="added",
34+
hunk_start_new=45,
35+
hunk_start_old=45,
3236
),
3337
],
3438
),
3539
DiffResult(
3640
metadata=DiffFileMetadata(
37-
new_file=False, deleted_file=False, renamed_file=False, new_path="pyproject.toml", old_path="pyproject.toml"
41+
new_file=False,
42+
deleted_file=False,
43+
renamed_file=False,
44+
new_path="pyproject.toml",
45+
old_path="pyproject.toml",
3846
),
3947
modified_lines=[
4048
ModifiedLine(
41-
line="[tool.ruff.per-file-ignores]", line_number=78, relative_line_number=4, modification_type="removed"
49+
line="[tool.ruff.per-file-ignores]",
50+
line_number=78,
51+
relative_line_number=4,
52+
modification_type="removed",
53+
hunk_start_new=75,
54+
hunk_start_old=75,
4255
),
4356
ModifiedLine(
4457
line="[tool.ruff.lint.per-file-ignores]",
4558
line_number=78,
4659
relative_line_number=5,
4760
modification_type="added",
61+
hunk_start_new=75,
62+
hunk_start_old=75,
4863
),
4964
],
5065
),

tests/git_client/test_github.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,16 @@ def test_get_diff_from_url_successful() -> None:
136136
line_number=48,
137137
relative_line_number=5,
138138
modification_type="removed",
139+
hunk_start_new=48,
140+
hunk_start_old=48,
139141
),
140142
ModifiedLine(
141143
line=' {{ run }} ruff check {{ target_dirs }} {{ if report == "true" { "--output-format gitlab > tests/gl-code-quality-report.json" } else { "" } }}',
142144
line_number=48,
143145
relative_line_number=6,
144146
modification_type="added",
147+
hunk_start_new=48,
148+
hunk_start_old=48,
145149
),
146150
],
147151
),
@@ -159,12 +163,16 @@ def test_get_diff_from_url_successful() -> None:
159163
line_number=78,
160164
relative_line_number=5,
161165
modification_type="removed",
166+
hunk_start_new=78,
167+
hunk_start_old=78,
162168
),
163169
ModifiedLine(
164170
line="[tool.ruff.lint.per-file-ignores]",
165171
line_number=78,
166172
relative_line_number=6,
167173
modification_type="added",
174+
hunk_start_new=78,
175+
hunk_start_old=78,
168176
),
169177
],
170178
),

tests/git_parser/fixtures.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,16 @@
2626
line_number=48,
2727
relative_line_number=4,
2828
modification_type="removed",
29+
hunk_start_new=45,
30+
hunk_start_old=45,
2931
),
3032
ModifiedLine(
3133
line=' {{ run }} ruff check {{ target_dirs }} {{ if report == "true" { "--output-format gitlab > tests/gl-code-quality-report.json" } else { "" } }}',
3234
line_number=48,
3335
relative_line_number=5,
3436
modification_type="added",
37+
hunk_start_new=45,
38+
hunk_start_old=45,
3539
),
3640
],
3741
)
@@ -49,17 +53,43 @@
4953
PARSED_REFACTOR_DIFF = DiffResult(
5054
metadata=DUMMY_METADATA,
5155
modified_lines=[
52-
ModifiedLine(line=" for p in prices:", line_number=11, relative_line_number=2, modification_type="removed"),
53-
ModifiedLine(line=" total += p", line_number=12, relative_line_number=3, modification_type="removed"),
5456
ModifiedLine(
55-
line=" for price in prices:", line_number=11, relative_line_number=4, modification_type="added"
57+
line=" for p in prices:",
58+
line_number=11,
59+
relative_line_number=2,
60+
modification_type="removed",
61+
hunk_start_new=10,
62+
hunk_start_old=10,
63+
),
64+
ModifiedLine(
65+
line=" total += p",
66+
line_number=12,
67+
relative_line_number=3,
68+
modification_type="removed",
69+
hunk_start_new=10,
70+
hunk_start_old=10,
71+
),
72+
ModifiedLine(
73+
line=" for price in prices:",
74+
line_number=11,
75+
relative_line_number=4,
76+
modification_type="added",
77+
hunk_start_new=10,
78+
hunk_start_old=10,
79+
),
80+
ModifiedLine(
81+
line=" total += price",
82+
line_number=12,
83+
relative_line_number=5,
84+
modification_type="added",
85+
hunk_start_new=10,
86+
hunk_start_old=10,
5687
),
57-
ModifiedLine(line=" total += price", line_number=12, relative_line_number=5, modification_type="added"),
5888
],
5989
)
6090

6191
COMPLEX_DIFF_TEXT = '''
62-
'@@ -2,7 +2,7 @@
92+
@@ -2,7 +2,7 @@
6393
import logging
6494
from collections.abc import Callable
6595
from importlib.metadata import version
@@ -180,5 +210,5 @@
180210
+ elif output_format == OutputFormat.json:
181211
+ return JsonFormatter(), print
182212
+ else:
183-
+ assert_never(output_format)'
213+
+ assert_never(output_format)
184214
'''

tests/git_parser/test_parser.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
@pytest.mark.parametrize(
1515
("input_diff", "expected"),
1616
[
17-
(SIMPLE_DIFF, PARSED_SIMPLE_DIFF),
18-
(REFACTOR_DIFF, PARSED_REFACTOR_DIFF),
17+
pytest.param(SIMPLE_DIFF, PARSED_SIMPLE_DIFF, id="simple"),
18+
pytest.param(REFACTOR_DIFF, PARSED_REFACTOR_DIFF, id="refactor"),
1919
],
2020
)
2121
def test_parse_diff_patch(input_diff: str, expected: DiffResult) -> None:
@@ -42,3 +42,46 @@ def test_parse_diff_relative_line_multiple_hunks(content: str, expected_line: in
4242
assert parsed_line.relative_line_number == expected_line, (
4343
f"Expected line number {expected_line}, got {parsed_line.line_number}"
4444
)
45+
46+
47+
def test_parse_diff_hunk_starts_set_correctly() -> None:
48+
"""Test that hunk_start_new and hunk_start_old are correctly set for all modified lines."""
49+
parsed = parse_diff_patch(DUMMY_METADATA, COMPLEX_DIFF_TEXT)
50+
51+
# Check that no modified lines have None for hunk starts
52+
for line in parsed.modified_lines:
53+
assert line.hunk_start_new is not None, f"hunk_start_new is None for line: {line.line}"
54+
assert line.hunk_start_old is not None, f"hunk_start_old is None for line: {line.line}"
55+
56+
# Test specific lines and their expected hunk starts
57+
expected_hunk_starts = [
58+
# First hunk @@ -2,7 +2,7 @@
59+
("from typing import get_args", 2, 2),
60+
("from typing import Any, assert_never, get_args", 2, 2),
61+
# Second hunk @@ -13,10 +13,12 @@
62+
("from lgtm_ai.base.schemas import PRUrl", 13, 13),
63+
("from lgtm_ai.base.schemas import OutputFormat, PRUrl", 13, 13),
64+
("from lgtm_ai.formatters.base import Formatter", 13, 13),
65+
# Third hunk @@ -25,14 +27,15 @@
66+
("from rich.console import Console", 25, 27),
67+
# Fourth hunk @@ -68,6 +71,7 @@
68+
('@click.option("--output-format", type=click.Choice([format.value for format in OutputFormat]))', 68, 71),
69+
# Last hunk @@ -220,3 +228,15 @@
70+
(
71+
"def _get_formatter_and_printer(output_format: OutputFormat) -> tuple[Formatter[Any], Callable[[Any], None]]:",
72+
220,
73+
228,
74+
),
75+
]
76+
77+
for content, expected_old, expected_new in expected_hunk_starts:
78+
matching_lines = [line for line in parsed.modified_lines if content in line.line]
79+
assert len(matching_lines) > 0, f"No line found containing: {content}"
80+
81+
line = matching_lines[0]
82+
assert line.hunk_start_old == expected_old, (
83+
f"Expected hunk_start_old {expected_old}, got {line.hunk_start_old} for line: {content}"
84+
)
85+
assert line.hunk_start_new == expected_new, (
86+
f"Expected hunk_start_new {expected_new}, got {line.hunk_start_new} for line: {content}"
87+
)

0 commit comments

Comments
 (0)