Skip to content

Commit 1aa92b3

Browse files
committed
feat(#18): add json formatter and new --output-format option and config
1 parent 6b303e6 commit 1aa92b3

File tree

19 files changed

+351
-60
lines changed

19 files changed

+351
-60
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ lgtm uses a `.toml` file to configure how it works. It will autodetect a `lgtm.t
296296
- **model**: Choose which AI model you want lgtm to use.
297297
- **model_url**: When not using one of the specific supported models from the providers mentioned above, you can pass a custom url where the model is deployed.
298298
- **exclude**: Instruct lgtm to ignore certain files. This is important to reduce noise in reviews, but also to reduce the amount of tokens used for each review (and to avoid running into token limits). You can specify file patterns (`exclude = ["*.md", "package-lock.json"]`)
299+
- **output_format**: Format of the terminal output of lgtm. Can be `pretty` (default), `json`, and `markdown`.
299300
- **silent**: Do not print the review in the terminal.
300301
- **publish**: If `true`, it will post the review as comments on the PR page.
301302
- **ai_api_key**: API key to call the selected AI model. Can be given as a CLI argument, or as an environment variable (`LGTM_AI_API_KEY`).

src/lgtm_ai/__main__.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from collections.abc import Callable
44
from importlib.metadata import version
5-
from typing import get_args
5+
from typing import Any, assert_never, get_args
66

77
import click
88
import rich
@@ -13,10 +13,12 @@
1313
get_summarizing_agent_with_settings,
1414
)
1515
from lgtm_ai.ai.schemas import AgentSettings, CommentCategory, SupportedAIModels, SupportedAIModelsList
16-
from lgtm_ai.base.schemas import PRUrl
16+
from lgtm_ai.base.schemas import OutputFormat, PRUrl
1717
from lgtm_ai.config.handler import ConfigHandler, PartialConfig
18+
from lgtm_ai.formatters.base import Formatter
19+
from lgtm_ai.formatters.json import JsonFormatter
1820
from lgtm_ai.formatters.markdown import MarkDownFormatter
19-
from lgtm_ai.formatters.terminal import TerminalFormatter
21+
from lgtm_ai.formatters.pretty import PrettyFormatter
2022
from lgtm_ai.git_client.utils import get_git_client
2123
from lgtm_ai.review import CodeReviewer
2224
from lgtm_ai.review.guide import ReviewGuideGenerator
@@ -25,14 +27,15 @@
2527
parse_pr_url,
2628
validate_model_url,
2729
)
30+
from rich.console import Console
2831
from rich.logging import RichHandler
2932

3033
__version__ = version("lgtm-ai")
3134

3235
logging.basicConfig(
3336
format="%(message)s",
3437
datefmt="[%X]",
35-
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
38+
handlers=[RichHandler(rich_tracebacks=True, show_path=False, console=Console(stderr=True))],
3639
)
3740
logger = logging.getLogger("lgtm")
3841

@@ -68,6 +71,7 @@ def _common_options[**P, T](func: Callable[P, T]) -> Callable[P, T]:
6871
help="Exclude files from the review. If not provided, all files in the PR will be reviewed. Uses UNIX-style wildcards.",
6972
)
7073
@click.option("--publish", is_flag=True, help="Publish the review or guide to the git service.")
74+
@click.option("--output-format", type=click.Choice([format.value for format in OutputFormat]))
7175
@click.option("--silent", is_flag=True, help="Do not print the review or guide to the console.")
7276
@click.option(
7377
"--ai-retries",
@@ -104,6 +108,7 @@ def review(
104108
config: str | None,
105109
exclude: tuple[str, ...],
106110
publish: bool,
111+
output_format: OutputFormat | None,
107112
silent: bool,
108113
ai_retries: int | None,
109114
verbose: int,
@@ -125,6 +130,7 @@ def review(
125130
model=model,
126131
model_url=model_url,
127132
publish=publish,
133+
output_format=output_format,
128134
silent=silent,
129135
ai_retries=ai_retries,
130136
),
@@ -146,10 +152,10 @@ def review(
146152

147153
if not resolved_config.silent:
148154
logger.info("Printing review to console")
149-
terminal_formatter = TerminalFormatter()
150-
rich.print(terminal_formatter.format_review_summary_section(review))
155+
formatter, printer = _get_formatter_and_printer(resolved_config.output_format)
156+
printer(formatter.format_review_summary_section(review))
151157
if review.review_response.comments:
152-
rich.print(terminal_formatter.format_review_comments_section(review.review_response.comments))
158+
printer(formatter.format_review_comments_section(review.review_response.comments))
153159

154160
if resolved_config.publish:
155161
logger.info("Publishing review to git service")
@@ -168,6 +174,7 @@ def guide(
168174
config: str | None,
169175
exclude: tuple[str, ...],
170176
publish: bool,
177+
output_format: OutputFormat | None,
171178
silent: bool,
172179
ai_retries: int | None,
173180
verbose: int,
@@ -184,6 +191,7 @@ def guide(
184191
ai_api_key=ai_api_key,
185192
model=model,
186193
publish=publish,
194+
output_format=output_format,
187195
silent=silent,
188196
ai_retries=ai_retries,
189197
),
@@ -203,8 +211,8 @@ def guide(
203211

204212
if not resolved_config.silent:
205213
logger.info("Printing review to console")
206-
terminal_formatter = TerminalFormatter()
207-
rich.print(terminal_formatter.format_guide(guide))
214+
formatter, printer = _get_formatter_and_printer(resolved_config.output_format)
215+
printer(formatter.format_guide(guide))
208216

209217
if resolved_config.publish:
210218
logger.info("Publishing review guide to git service")
@@ -220,3 +228,15 @@ def _set_logging_level(logger: logging.Logger, verbose: int) -> None:
220228
else:
221229
logger.setLevel(logging.DEBUG)
222230
logger.info("Logging level set to %s", logging.getLevelName(logger.level))
231+
232+
233+
def _get_formatter_and_printer(output_format: OutputFormat) -> tuple[Formatter[Any], Callable[[Any], None]]:
234+
"""Get the formatter and the print method based on the output format."""
235+
if output_format == OutputFormat.pretty:
236+
return PrettyFormatter(), rich.print
237+
elif output_format == OutputFormat.markdown:
238+
return MarkDownFormatter(), print
239+
elif output_format == OutputFormat.json:
240+
return JsonFormatter(), print
241+
else:
242+
assert_never(output_format)

src/lgtm_ai/ai/schemas.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,7 @@ class GuideResponse(BaseModel):
138138
references: Annotated[list[GuideReference], Field(description="References to external resources")]
139139

140140

141-
@dataclass(frozen=True)
142-
class PublishMetadata:
141+
class PublishMetadata(BaseModel):
143142
model_name: str
144143
usages: list[Usage]
145144

@@ -152,17 +151,15 @@ def uuid(self) -> str:
152151
return uuid4().hex
153152

154153

155-
@dataclass(frozen=True, slots=True)
156-
class Review:
154+
class Review(BaseModel):
157155
"""Represent a full code review performed by any AI agent."""
158156

159157
pr_diff: PRDiff
160158
review_response: ReviewResponse
161159
metadata: PublishMetadata
162160

163161

164-
@dataclass(frozen=True, slots=True)
165-
class ReviewGuide:
162+
class ReviewGuide(BaseModel):
166163
"""Represent a code review guide generated by the AI agent."""
167164

168165
pr_diff: PRDiff

src/lgtm_ai/base/schemas.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass
2+
from enum import StrEnum
23
from typing import Literal
34

45

@@ -8,3 +9,9 @@ class PRUrl:
89
repo_path: str
910
pr_number: int
1011
source: Literal["github", "gitlab"]
12+
13+
14+
class OutputFormat(StrEnum):
15+
pretty = "pretty"
16+
json = "json"
17+
markdown = "markdown"

src/lgtm_ai/config/handler.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, ClassVar, Literal, cast, get_args, overload
88

99
from lgtm_ai.ai.schemas import CommentCategory, SupportedAIModels
10+
from lgtm_ai.base.schemas import OutputFormat
1011
from lgtm_ai.config.constants import DEFAULT_AI_MODEL
1112
from lgtm_ai.config.exceptions import (
1213
ConfigFileNotFoundError,
@@ -31,6 +32,7 @@ class PartialConfig(BaseModel):
3132
categories: tuple[CommentCategory, ...] | None = None
3233
exclude: tuple[str, ...] | None = None
3334
publish: bool = False
35+
output_format: OutputFormat | None = None
3436
silent: bool = False
3537
ai_retries: int | None = None
3638

@@ -63,6 +65,9 @@ class ResolvedConfig(BaseModel):
6365
publish: bool = False
6466
"""Publish the review to the git service as comments."""
6567

68+
output_format: OutputFormat = OutputFormat.pretty
69+
"""Output format for the review, defaults to pretty."""
70+
6671
silent: bool = False
6772
"""Suppress terminal output."""
6873

@@ -125,6 +130,7 @@ def _parse_config_file(self) -> PartialConfig:
125130
categories=config_data.get("categories", None),
126131
exclude=config_data.get("exclude", None),
127132
publish=config_data.get("publish", False),
133+
output_format=config_data.get("output_format", None),
128134
silent=config_data.get("silent", False),
129135
ai_retries=config_data.get("ai_retries", None),
130136
)
@@ -181,6 +187,7 @@ def _parse_cli_args(self) -> PartialConfig:
181187
exclude=self.cli_args.exclude or None,
182188
ai_api_key=self.cli_args.ai_api_key or None,
183189
git_api_key=self.cli_args.git_api_key or None,
190+
output_format=self.cli_args.output_format or None,
184191
silent=self.cli_args.silent,
185192
publish=self.cli_args.publish,
186193
ai_retries=self.cli_args.ai_retries or None,
@@ -221,6 +228,7 @@ def _resolve_from_multiple_sources(
221228
),
222229
model_url=from_cli.model_url or from_file.model_url,
223230
publish=from_cli.publish or from_file.publish,
231+
output_format=from_cli.output_format or from_file.output_format or OutputFormat.pretty,
224232
silent=from_cli.silent or from_file.silent,
225233
ai_retries=from_cli.ai_retries or from_file.ai_retries,
226234
git_api_key=self.resolver.resolve_string_field("git_api_key", from_cli=from_cli, from_env=from_env),

src/lgtm_ai/formatters/json.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from lgtm_ai.ai.schemas import Review, ReviewComment, ReviewGuide
2+
from lgtm_ai.formatters.base import Formatter
3+
4+
5+
class JsonFormatter(Formatter[str]):
6+
def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> str:
7+
"""Format the **whole** review as JSON."""
8+
return review.model_dump_json(indent=2)
9+
10+
def format_review_comments_section(self, comments: list[ReviewComment]) -> str:
11+
"""No-op.
12+
13+
Formatting the comments section alone as JSON does not really make sense, so this is a no-op.
14+
"""
15+
return ""
16+
17+
def format_review_comment(self, comment: ReviewComment, *, with_footer: bool = True) -> str:
18+
"""Format a single comment as JSON."""
19+
return comment.model_dump_json(indent=2)
20+
21+
def format_guide(self, guide: ReviewGuide) -> str:
22+
"""Format the review guide as JSON."""
23+
return guide.model_dump_json(indent=2)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
logger = logging.getLogger("lgtm")
1313

1414

15-
class TerminalFormatter(Formatter[Panel | Layout]):
15+
class PrettyFormatter(Formatter[Panel | Layout]):
1616
def format_review_summary_section(self, review: Review, comments: list[ReviewComment] | None = None) -> Panel:
1717
if comments:
1818
logger.warning("Comments are not supported in the terminal formatter summary section")

src/lgtm_ai/git_client/gitlab.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def get_diff_from_url(self, pr_url: PRUrl) -> PRDiff:
5050
raise PullRequestDiffError from err
5151

5252
return PRDiff(
53-
diff.id,
53+
id=diff.id,
5454
diff=self._parse_gitlab_git_diff(diff.diffs),
5555
changed_files=[change["new_path"] for change in diff.diffs],
5656
target_branch=pr.target_branch,

src/lgtm_ai/git_client/schemas.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
1-
from dataclasses import dataclass
2-
1+
from groq import BaseModel
32
from lgtm_ai.git_parser.parser import DiffResult
43

54

6-
@dataclass(frozen=True, slots=True)
7-
class PRDiff:
5+
class PRDiff(BaseModel):
86
id: int
97
diff: list[DiffResult]
108
changed_files: list[str]
119
target_branch: str
1210
source_branch: str
1311

1412

15-
@dataclass(frozen=True, slots=True)
16-
class PRContextFileContents:
13+
class PRContextFileContents(BaseModel):
1714
file_path: str
1815
content: str
1916

2017

21-
@dataclass(slots=True)
22-
class PRContext:
18+
class PRContext(BaseModel):
2319
"""Represents the context a reviewer might need when reviewing PRs.
2420
2521
At the moment, it is just the contents of the files that are changed in the PR.
@@ -31,10 +27,9 @@ def __bool__(self) -> bool:
3127
return bool(self.file_contents)
3228

3329
def add_file(self, file_path: str, content: str) -> None:
34-
self.file_contents.append(PRContextFileContents(file_path, content))
30+
self.file_contents.append(PRContextFileContents(file_path=file_path, content=content))
3531

3632

37-
@dataclass(frozen=True, slots=True)
38-
class PRMetadata:
33+
class PRMetadata(BaseModel):
3934
title: str
4035
description: str

src/lgtm_ai/review/reviewer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def review_pull_request(self, pr_url: PRUrl) -> Review:
7676
)
7777

7878
return Review(
79-
pr_diff,
80-
final_res.output,
79+
pr_diff=pr_diff,
80+
review_response=final_res.output,
8181
metadata=PublishMetadata(model_name=self.config.model, usages=[initial_usage, final_usage]),
8282
)

0 commit comments

Comments
 (0)