Skip to content

Commit 4d8ca76

Browse files
committed
feat(#96): use code suggestions for GitLab
1 parent 121ee69 commit 4d8ca76

File tree

17 files changed

+245
-40
lines changed

17 files changed

+245
-40
lines changed

assets/review-comment.png

49.6 KB
Loading

src/lgtm_ai/__main__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from lgtm_ai.ai.schemas import AgentSettings, CommentCategory, SupportedAIModels, SupportedAIModelsList
1616
from lgtm_ai.base.schemas import OutputFormat, PRUrl
17+
from lgtm_ai.base.utils import git_source_supports_suggestions
1718
from lgtm_ai.config.handler import ConfigHandler, PartialConfig
1819
from lgtm_ai.formatters.base import Formatter
1920
from lgtm_ai.formatters.json import JsonFormatter
@@ -138,7 +139,11 @@ def review(
138139
config_file=config,
139140
).resolve_config()
140141
agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
141-
git_client = get_git_client(pr_url=pr_url, config=resolved_config, formatter=MarkDownFormatter())
142+
git_client = get_git_client(
143+
pr_url=pr_url,
144+
config=resolved_config,
145+
formatter=MarkDownFormatter(use_suggestions=git_source_supports_suggestions(pr_url.source)),
146+
)
142147
code_reviewer = CodeReviewer(
143148
reviewer_agent=get_reviewer_agent_with_settings(agent_extra_settings),
144149
summarizing_agent=get_summarizing_agent_with_settings(agent_extra_settings),

src/lgtm_ai/ai/prompts.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,12 @@ def _get_full_category_explanation() -> str:
101101
- Evaluate whether some comments are more likely to simply be incorrect. If they are likely to be incorrect, remove them.
102102
- Merge duplicate comments. If there are two comments that refer to the same issue, merge them into one.
103103
- Comments have a code snippet that they refer to. Consider whether the snippet needs a bit more code context, and if so, expand the snippet. Otherwise don't touch them.
104-
- If you can add a suggestion code snippet to the comment text, do it. Use markdown code blocks with 5 backticks. Do it only when you are very sure about the suggestion with the context you have.
105104
- Check that categories of each comment are correct. Re-categorize them if needed.
106105
- Check the summary. Feel free to rephrase it, add more information, or generally improve it. The summary comment must be a general comment informing the PR author about the overall quality of the PR, the weakpoints it has, and which general issues need to be addressed.
106+
- If you can add a suggestion code snippet to the comment text, do it. Do it only when you are very sure about the suggestion with the context you have.
107+
- 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.
108+
- 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.
109+
- 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`.
107110
108111
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.
109112
You must evaluate the score, and change it if necessary. Here is some guidance:

src/lgtm_ai/ai/schemas.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
import zoneinfo
33
from dataclasses import dataclass
44
from functools import cached_property
5-
from typing import Annotated, Final, Literal, get_args
5+
from typing import Annotated, Final, Literal, Self, get_args
66
from uuid import uuid4
77

88
from lgtm_ai.git_client.schemas import PRDiff
99
from openai.types import ChatModel
10-
from pydantic import AfterValidator, BaseModel, Field, computed_field
10+
from pydantic import AfterValidator, BaseModel, Field, computed_field, model_validator
1111
from pydantic_ai.models.anthropic import LatestAnthropicModelNames
1212
from pydantic_ai.models.mistral import LatestMistralModelNames
1313
from pydantic_ai.usage import Usage
@@ -81,6 +81,35 @@
8181
}
8282

8383

84+
class CodeSuggestionOffset(BaseModel):
85+
offset: Annotated[int, Field(description="Offset relative to the comment line number")]
86+
direction: Annotated[
87+
Literal["+", "-"],
88+
Field(description="Direction of the offset. + means below, - means above"),
89+
]
90+
91+
@model_validator(mode="after")
92+
def change_direction_of_zero(self) -> Self:
93+
# GitLab freaks out if the offset is 0 and the direction is +. We change it to -.
94+
if self.offset == 0:
95+
self.direction = "-"
96+
return self
97+
98+
99+
class CodeSuggestion(BaseModel):
100+
start_offset: Annotated[
101+
CodeSuggestionOffset, Field(description="Offset (from comment line number) to start the suggestion")
102+
]
103+
end_offset: Annotated[
104+
CodeSuggestionOffset, Field(description="Offset (from comment line number) to end the suggestion")
105+
]
106+
snippet: Annotated[str, Field(description="Suggested code snippet to replace the commented code")]
107+
programming_language: Annotated[str, Field(description="Programming language of the code snippet")]
108+
ready_for_replacement: Annotated[
109+
bool, Field(description="Whether the suggestion is totally ready to be applied directly")
110+
] = False
111+
112+
84113
class ReviewComment(BaseModel):
85114
"""Individual comment representation in a PR code review."""
86115

@@ -94,6 +123,7 @@ class ReviewComment(BaseModel):
94123
is_comment_on_new_path: Annotated[bool, Field(description="Whether the comment is on a new path")]
95124
programming_language: Annotated[str, Field(description="Programming language of the file")]
96125
quote_snippet: Annotated[str | None, Field(description="Quoted code snippet")] = None
126+
suggestion: Annotated[CodeSuggestion | None, Field(description="Suggested code change")] = None
97127

98128

99129
class ReviewResponse(BaseModel):

src/lgtm_ai/base/schemas.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from dataclasses import dataclass
22
from enum import StrEnum
3-
from typing import Literal
3+
4+
5+
class PRSource(StrEnum):
6+
github = "github"
7+
gitlab = "gitlab"
48

59

610
@dataclass(frozen=True, slots=True)
711
class PRUrl:
812
full_url: str
913
repo_path: str
1014
pr_number: int
11-
source: Literal["github", "gitlab"]
15+
source: PRSource
1216

1317

1418
class OutputFormat(StrEnum):

src/lgtm_ai/base/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import fnmatch
22
import pathlib
33

4+
from lgtm_ai.base.schemas import PRSource
5+
46

57
def file_matches_any_pattern(file_name: str, patterns: tuple[str, ...]) -> bool:
68
for pattern in patterns:
@@ -10,3 +12,11 @@ def file_matches_any_pattern(file_name: str, patterns: tuple[str, ...]) -> bool:
1012
if matches:
1113
return True
1214
return False
15+
16+
17+
def git_source_supports_suggestions(source: PRSource) -> bool:
18+
"""For now, we only support suggestions in GitLab.
19+
20+
TODO: https://github.com/elementsinteractive/lgtm-ai/issues/96
21+
"""
22+
return source == PRSource.gitlab

src/lgtm_ai/formatters/markdown.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ class MarkDownFormatter(Formatter[str]):
1717
USAGES_SUMMARY_TEMPLATE: ClassVar[str] = "usages_summary.md.j2"
1818
METADATA_TEMPLATE: ClassVar[str] = "metadata.md.j2"
1919

20-
def __init__(self) -> None:
20+
def __init__(self, use_suggestions: bool = False) -> None:
21+
self.use_suggestions = use_suggestions
2122
template_dir = pathlib.Path(__file__).parent / "templates"
2223
self._template_env = Environment(loader=FileSystemLoader(template_dir), autoescape=True)
2324

@@ -52,6 +53,8 @@ def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = T
5253
severity_icon=severity_icon,
5354
snippet=snippet,
5455
comment=comment.comment,
56+
suggestion=comment.suggestion,
57+
use_suggestions=self.use_suggestions,
5558
with_footer=with_footer,
5659
new_path=comment.new_path,
5760
line_number=comment.line_number,

src/lgtm_ai/formatters/pretty.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = T
4343
padding=(1, 1),
4444
)
4545
content = Group(snippet_panel, Text(comment.comment))
46+
# TODO: add code suggestions
4647
else:
4748
content = Text(comment.comment)
4849

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
{% endif %}
99
{{ comment | safe }}
1010

11+
{% 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 }}
14+
{{ suggestion.snippet | safe }}
15+
`````
16+
{% else %}
17+
`````{{ suggestion.programming_language }}
18+
{{ suggestion.snippet | safe }}
19+
`````
20+
{% endif %}
21+
{% endif %}
22+
1123
{% if with_footer %}
1224
<details><summary>More information about this comment</summary>
1325

src/lgtm_ai/validators.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
from enum import StrEnum
2-
from typing import Literal
32
from urllib.parse import ParseResult, urlparse
43

54
import click
6-
from lgtm_ai.base.schemas import PRUrl
5+
from lgtm_ai.base.schemas import PRSource, PRUrl
76

87

98
class AllowedLocations(StrEnum):
@@ -35,7 +34,7 @@ def parse_pr_url(ctx: click.Context, param: str, value: object) -> PRUrl:
3534
return _parse_pr_url(
3635
parsed,
3736
split_str="/-/merge_requests/",
38-
source="gitlab",
37+
source=PRSource.gitlab,
3938
error_url_msg="The PR URL must be a merge request URL.",
4039
error_num_msg="The PR URL must contain a valid MR number.",
4140
)
@@ -44,7 +43,7 @@ def parse_pr_url(ctx: click.Context, param: str, value: object) -> PRUrl:
4443
return _parse_pr_url(
4544
parsed,
4645
split_str="/pull/",
47-
source="github",
46+
source=PRSource.github,
4847
error_url_msg="The PR URL must be a pull request URL.",
4948
error_num_msg="The PR URL must contain a valid PR number.",
5049
)
@@ -94,7 +93,7 @@ def validate_model_url(ctx: click.Context, param: click.Parameter, value: str |
9493

9594

9695
def _parse_pr_url(
97-
parsed: ParseResult, *, split_str: str, source: Literal["github", "gitlab"], error_url_msg: str, error_num_msg: str
96+
parsed: ParseResult, *, split_str: str, source: PRSource, error_url_msg: str, error_num_msg: str
9897
) -> PRUrl:
9998
full_project_path = parsed.path
10099
try:

0 commit comments

Comments
 (0)