Skip to content

Commit 023e3a2

Browse files
committed
feat(#22): add ability to review local changes without a PR
1 parent 868ff54 commit 023e3a2

30 files changed

+738
-129
lines changed

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ lgtm-ai is your AI-powered code review companion. It generates code reviews usin
1818
**Table of Contents**
1919
- [Quick Usage](#quick-usage)
2020
- [Review](#review)
21+
- [Local Changes](#local-changes)
2122
- [Reviewer Guide](#reviewer-guide)
2223
- [Installation](#installation)
2324
- [How it works](#how-it-works)
@@ -64,6 +65,17 @@ This will generate a **review** like this one:
6465
<img src="./assets/review-comment.png" alt="lgtm-review-comment" height="250"/>
6566

6667

68+
#### Local Changes
69+
70+
You can also review local changes without a pull request:
71+
72+
```sh
73+
lgtm review --ai-api-key $OPENAI_API_KEY \
74+
--model gpt-4.1 \
75+
--compare main \
76+
path/to/git/repo
77+
```
78+
6779
### Reviewer Guide
6880

6981
```sh
@@ -384,11 +396,12 @@ When it comes to preference for selecting options, lgtm follows this preference
384396
| silent | Main (review + guide) | 🟢 Optional | Suppress terminal output. Default: false. |
385397
| ai_retries | Main (review + guide) | 🟢 Optional | Number of retries for AI agent queries. Default: 1. |
386398
| ai_input_tokens_limit| Main (review + guide) | 🟢 Optional | Max input tokens for LLM. Default: 500,000. Use `"no-limit"` to disable. |
387-
| git_api_key | Main (review + guide) | 🔴 Required* | API key for git service (GitHub/GitLab). Can't be given through config file. Also available through env variable `LGTM_GIT_API_KEY`. |
399+
| git_api_key | Main (review + guide) | 🟡 Conditionally required | API key for git service (GitHub/GitLab). Can't be given through config file. Also available through env variable `LGTM_GIT_API_KEY`. Required if reviewing a PR URL from a remote repository service (GitHub, GitLab, etc.). |
388400
| ai_api_key | Main (review + guide) | 🔴 Required* | API key for AI model. Can't be given through config file. Also available through env variable `LGTM_AI_API_KEY`. |
389401
| technologies | Review Only | 🟢 Optional | List of technologies for reviewer expertise. |
390402
| categories | Review Only | 🟢 Optional | Review categories. Defaults to all (`Quality`, `Correctness`, `Testing`, `Security`). |
391403
| additional_context | Review Only | 🟢 Optional | Extra context for the LLM (array of prompts/paths/URLs). Can't be given through the CLI |
404+
| compare | Review Only | 🟢 Optional | If reviewing local changes, what to compare against (branch, commit, range, etc.). CLI only. |
392405
| issues_url | Issues Integration | 🟢 Optional | Enables issue context. If set, `issues_platform` becomes required. |
393406
| issues_platform | Issues Integration | 🟡 Conditionally required | Required if `issues_url` is set. |
394407
| issues_regex | Issues Integration | 🟢 Optional | Regex for issue ID extraction. Defaults to conventional commit compatible regex. |
@@ -409,7 +422,7 @@ These options apply to both reviews and guides generated by lgtm.
409422
- **silent**: Do not print the review in the terminal. Default is `false`.
410423
- **ai_retries**: How many times to retry calls to the LLM when they do not succeed. By default, this is set to 1 (no retries at all).
411424
- **ai_input_tokens_limit**: Set a limit on the input tokens sent to the LLM in total. Default is 500,000. To disable the limit, you can pass the string `"no-limit"`.
412-
- **git_api_key**: API key to post the review in the source system of the PR. Can be given as a CLI argument, or as an environment variable (`LGTM_GIT_API_KEY`).
425+
- **git_api_key**: API key to post the review in the source system of the PR. Can be given as a CLI argument, or as an environment variable (`LGTM_GIT_API_KEY`). You can omit this option if reviewing local changes.
413426
- **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`).
414427

415428
#### Review options
@@ -419,6 +432,7 @@ These options are only used when performing reviews through the command `lgtm re
419432
- **technologies**: Specify, as a list of free strings, which technologies lgtm specializes in. This can help direct the reviewer towards specific technologies. By default, lgtm won't assume any technology and will just review the PR considering itself an "expert" in it.
420433
- **categories**: lgtm will, by default, evaluate several areas of the given PR (`Quality`, `Correctness`, `Testing`, and `Security`). You can choose any subset of these (e.g., if you are only interested in `Correctness`, you can configure `categories` so that lgtm does not evaluate the other missing areas).
421434
- **additional_context**: TOML array of extra context to send to the LLM. It supports setting the context directly in the `context` field, passing a relative file path so that lgtm downloads it from the repository, or passing any URL from which to download the context. Each element of the array must contain `prompt`, and either `context` (directly injecting context) or `file_url` (for directing lgtm to download it from there).
435+
- **compare**: When reviewing local changes (the positional argument to `lgtm` is a valid `git` path), you can choose what to compare against to generate a git diff. You can pass branch names, commits, etc. Default is `HEAD`. Only available as a CLI option.
422436

423437
#### Issues Integration options
424438

poetry.lock

Lines changed: 48 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"pygithub>=2.6.1,<3.0.0",
4040
"httpx (>=0.28.1,<0.29.0)",
4141
"jinja2 (>=3.1.6,<4.0.0)",
42+
"gitpython (>=3.1.45,<4.0.0)",
4243
]
4344

4445
[tool.poetry.group.dev.dependencies]

scripts/evaluate_review_quality.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from lgtm_ai.git_client.gitlab import GitlabClient
1616
from lgtm_ai.review import CodeReviewer
1717
from lgtm_ai.review.context import ContextRetriever
18-
from lgtm_ai.validators import parse_pr_url
18+
from lgtm_ai.validators import parse_target
1919
from rich.logging import RichHandler
2020

2121
PRS_FOR_EVALUATION = {
@@ -94,7 +94,7 @@ def get_git_branch() -> str:
9494
def perform_review(
9595
output_dir: str, pr_url: str, pr_name: str, sample: int, model: SupportedAIModels, git_api_key: str, ai_api_key: str
9696
) -> None:
97-
url = parse_pr_url(mock.Mock(), "pr_url", pr_url)
97+
url = parse_target(mock.Mock(), "pr_url", pr_url)
9898
git_client = GitlabClient(client=gitlab.Gitlab(private_token=git_api_key), formatter=MarkDownFormatter())
9999
code_reviewer = CodeReviewer(
100100
reviewer_agent=get_reviewer_agent_with_settings(),
@@ -106,7 +106,7 @@ def perform_review(
106106
),
107107
config=ResolvedConfig(model=model, technologies=("Python", "Django", "FastAPI")),
108108
)
109-
review = code_reviewer.review_pull_request(pr_url=url)
109+
review = code_reviewer.review_pull_request(target=url)
110110
write_review_to_dir(model, output_dir, pr_name, sample, review)
111111

112112

src/lgtm_ai/__main__.py

Lines changed: 47 additions & 27 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.constants import DEFAULT_HTTPX_TIMEOUT
17-
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesPlatform, OutputFormat, PRUrl
17+
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesPlatform, LocalRepository, OutputFormat, PRUrl
1818
from lgtm_ai.base.utils import git_source_supports_multiline_suggestions
1919
from lgtm_ai.config.constants import DEFAULT_INPUT_TOKEN_LIMIT
2020
from lgtm_ai.config.handler import ConfigHandler, PartialConfig, ResolvedConfig
@@ -31,7 +31,7 @@
3131
from lgtm_ai.validators import (
3232
IntOrNoLimitType,
3333
ModelChoice,
34-
parse_pr_url,
34+
parse_target,
3535
validate_model_url,
3636
)
3737
from rich.console import Console
@@ -56,7 +56,7 @@ def entry_point() -> None:
5656
def _common_options[**P, T](func: Callable[P, T]) -> Callable[P, T]:
5757
"""Wrap a click command and adds common options for lgtm commands."""
5858

59-
@click.argument("pr-url", required=True, callback=parse_pr_url)
59+
@click.argument("target", required=True, callback=parse_target)
6060
@click.option(
6161
"--model",
6262
type=ModelChoice(SupportedAIModelsList),
@@ -69,7 +69,10 @@ def _common_options[**P, T](func: Callable[P, T]) -> Callable[P, T]:
6969
default=None,
7070
callback=validate_model_url,
7171
)
72-
@click.option("--git-api-key", help="The API key to the git service (GitLab, GitHub, etc.)")
72+
@click.option(
73+
"--git-api-key",
74+
help="The API key to the git service (GitLab, GitHub, etc.). Required if the target is a PR URL.",
75+
)
7376
@click.option("--ai-api-key", help="The API key to the AI model service (OpenAI, etc.)")
7477
@click.option("--config", type=click.STRING, help="Path to the configuration file.")
7578
@click.option(
@@ -134,8 +137,13 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
134137
type=click.Choice(get_args(CommentCategory)),
135138
help="List of categories the reviewer should focus on. If not provided, the reviewer will focus on all categories.",
136139
)
140+
@click.option(
141+
"--compare",
142+
default=None,
143+
help="If reviewing a local repository, what to compare against (branch, commit, or HEAD for working dir). Default: HEAD",
144+
)
137145
def review(
138-
pr_url: PRUrl,
146+
target: PRUrl | LocalRepository,
139147
model: SupportedAIModels | None,
140148
model_url: str | None,
141149
git_api_key: str | None,
@@ -155,16 +163,26 @@ def review(
155163
issues_platform: IssuesPlatform | None,
156164
issues_api_key: str | None,
157165
issues_user: str | None,
166+
compare: str | None,
158167
) -> None:
159-
"""Review a Pull Request using AI.
168+
"""Review a Pull Request or local repository using AI.
169+
170+
TARGET can be either:
160171
161-
PR_URL is the URL of the pull request to review.
172+
- A pull request URL (GitHub, GitLab, etc.).
173+
174+
- A local directory path (use --compare to specify what to compare against).
162175
"""
163176
_set_logging_level(logger, verbose)
164177

178+
if compare and not isinstance(target, LocalRepository):
179+
logger.warning(
180+
"`--compare` option is only used when reviewing a local repository. Ignoring the provided value."
181+
)
182+
165183
logger.info("lgtm-ai version: %s", __version__)
166-
logger.debug("Parsed PR URL: %s", pr_url)
167-
logger.info("Starting review of %s", pr_url.full_url)
184+
logger.debug("Parsed PR URL: %s", target)
185+
logger.info("Starting review of %s", target.full_url)
168186
resolved_config = ConfigHandler(
169187
cli_args=PartialConfig(
170188
technologies=technologies,
@@ -184,14 +202,16 @@ def review(
184202
issues_platform=issues_platform,
185203
issues_api_key=issues_api_key,
186204
issues_user=issues_user,
205+
compare=compare,
187206
),
188207
config_file=config,
189-
).resolve_config()
208+
).resolve_config(target)
209+
190210
agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
191211
formatter: Formatter[Any] = MarkDownFormatter(
192-
add_ranges_to_suggestions=git_source_supports_multiline_suggestions(pr_url.source)
212+
add_ranges_to_suggestions=git_source_supports_multiline_suggestions(target.source)
193213
)
194-
git_client = get_git_client(source=pr_url.source, token=resolved_config.git_api_key, formatter=formatter)
214+
git_client = get_git_client(source=target.source, token=resolved_config.git_api_key, formatter=formatter)
195215
issues_client = _get_issues_client(resolved_config, git_client, formatter)
196216

197217
code_reviewer = CodeReviewer(
@@ -206,7 +226,7 @@ def review(
206226
git_client=git_client,
207227
config=resolved_config,
208228
)
209-
review = code_reviewer.review_pull_request(pr_url=pr_url)
229+
review = code_reviewer.review_pull_request(target=target)
210230
logger.info("Review completed, total comments: %d", len(review.review_response.comments))
211231

212232
if not resolved_config.silent:
@@ -216,16 +236,16 @@ def review(
216236
if review.review_response.comments:
217237
printer(formatter.format_review_comments_section(review.review_response.comments))
218238

219-
if resolved_config.publish:
239+
if resolved_config.publish and isinstance(target, PRUrl) and git_client:
220240
logger.info("Publishing review to git service")
221-
git_client.publish_review(pr_url=pr_url, review=review)
241+
git_client.publish_review(pr_url=target, review=review)
222242
logger.info("Review published successfully")
223243

224244

225245
@entry_point.command()
226246
@_common_options
227247
def guide(
228-
pr_url: PRUrl,
248+
target: PRUrl,
229249
model: SupportedAIModels | None,
230250
model_url: str | None,
231251
git_api_key: str | None,
@@ -241,13 +261,13 @@ def guide(
241261
) -> None:
242262
"""Generate a review guide for a Pull Request using AI.
243263
244-
PR_URL is the URL of the pull request to generate a guide for.
264+
TARGET is the URL of the pull request to generate a guide for.
245265
"""
246266
_set_logging_level(logger, verbose)
247267

248268
logger.info("lgtm-ai version: %s", __version__)
249-
logger.debug("Parsed PR URL: %s", pr_url)
250-
logger.info("Starting generating guide of %s", pr_url.full_url)
269+
logger.debug("Parsed PR URL: %s", target)
270+
logger.info("Starting generating guide of %s", target.full_url)
251271
resolved_config = ConfigHandler(
252272
cli_args=PartialConfig(
253273
exclude=exclude,
@@ -262,9 +282,9 @@ def guide(
262282
ai_input_tokens_limit=ai_input_tokens_limit,
263283
),
264284
config_file=config,
265-
).resolve_config()
285+
).resolve_config(target)
266286
agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
267-
git_client = get_git_client(source=pr_url.source, token=resolved_config.git_api_key, formatter=MarkDownFormatter())
287+
git_client = get_git_client(source=target.source, token=resolved_config.git_api_key, formatter=MarkDownFormatter())
268288
review_guide = ReviewGuideGenerator(
269289
guide_agent=get_guide_agent_with_settings(agent_extra_settings),
270290
model=get_ai_model(
@@ -273,16 +293,16 @@ def guide(
273293
git_client=git_client,
274294
config=resolved_config,
275295
)
276-
guide = review_guide.generate_review_guide(pr_url=pr_url)
296+
guide = review_guide.generate_review_guide(pr_url=target)
277297

278298
if not resolved_config.silent:
279299
logger.info("Printing review to console")
280300
formatter, printer = _get_formatter_and_printer(resolved_config.output_format)
281301
printer(formatter.format_guide(guide))
282302

283-
if resolved_config.publish:
303+
if resolved_config.publish and git_client:
284304
logger.info("Publishing review guide to git service")
285-
git_client.publish_guide(pr_url=pr_url, guide=guide)
305+
git_client.publish_guide(pr_url=target, guide=guide)
286306
logger.info("Review Guide published successfully")
287307

288308

@@ -310,16 +330,16 @@ def _get_formatter_and_printer(output_format: OutputFormat) -> tuple[Formatter[A
310330

311331

312332
def _get_issues_client(
313-
resolved_config: ResolvedConfig, git_client: GitClient, formatter: Formatter[Any]
314-
) -> IssuesClient:
333+
resolved_config: ResolvedConfig, git_client: GitClient | None, formatter: Formatter[Any]
334+
) -> IssuesClient | None:
315335
"""Select a different issues client for retrieving issues.
316336
317337
Will only return a different client if all of the following are true:
318338
1) Be used at all
319339
2) Be retrieved from a git platform and not elsewhere (e.g., Jira, Asana, etc.)
320340
3) Have a specific API key configured
321341
"""
322-
issues_client: IssuesClient = git_client
342+
issues_client: IssuesClient | None = git_client
323343
if not resolved_config.issues_url or not resolved_config.issues_platform or not resolved_config.issues_regex:
324344
return issues_client
325345
if resolved_config.issues_platform.is_git_platform:

src/lgtm_ai/base/schemas.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pathlib
12
from dataclasses import dataclass
23
from enum import StrEnum
34
from typing import Literal
@@ -9,6 +10,7 @@
910
class PRSource(StrEnum):
1011
github = "github"
1112
gitlab = "gitlab"
13+
local = "local"
1214

1315

1416
class IssuesPlatform(StrEnum):
@@ -29,6 +31,16 @@ class PRUrl:
2931
source: PRSource
3032

3133

34+
@dataclass(frozen=True, slots=True)
35+
class LocalRepository:
36+
repo_path: pathlib.Path
37+
source: PRSource = PRSource.local
38+
39+
@property
40+
def full_url(self) -> str:
41+
return self.repo_path.as_posix()
42+
43+
3244
class OutputFormat(StrEnum):
3345
pretty = "pretty"
3446
json = "json"

0 commit comments

Comments
 (0)