Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ lgtm-ai is your AI-powered code review companion. It generates code reviews usin
**Table of Contents**
- [Quick Usage](#quick-usage)
- [Review](#review)
- [Local Changes](#local-changes)
- [Reviewer Guide](#reviewer-guide)
- [Installation](#installation)
- [How it works](#how-it-works)
Expand Down Expand Up @@ -64,6 +65,17 @@ This will generate a **review** like this one:
<img src="./assets/review-comment.png" alt="lgtm-review-comment" height="250"/>


#### Local Changes

You can also review local changes without a pull request:

```sh
lgtm review --ai-api-key $OPENAI_API_KEY \
--model gpt-4.1 \
--compare main \
path/to/git/repo
```

### Reviewer Guide

```sh
Expand Down Expand Up @@ -384,11 +396,12 @@ When it comes to preference for selecting options, lgtm follows this preference
| silent | Main (review + guide) | 🟢 Optional | Suppress terminal output. Default: false. |
| ai_retries | Main (review + guide) | 🟢 Optional | Number of retries for AI agent queries. Default: 1. |
| ai_input_tokens_limit| Main (review + guide) | 🟢 Optional | Max input tokens for LLM. Default: 500,000. Use `"no-limit"` to disable. |
| 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`. |
| 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.). |
| 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`. |
| technologies | Review Only | 🟢 Optional | List of technologies for reviewer expertise. |
| categories | Review Only | 🟢 Optional | Review categories. Defaults to all (`Quality`, `Correctness`, `Testing`, `Security`). |
| additional_context | Review Only | 🟢 Optional | Extra context for the LLM (array of prompts/paths/URLs). Can't be given through the CLI |
| compare | Review Only | 🟢 Optional | If reviewing local changes, what to compare against (branch, commit, range, etc.). CLI only. |
| issues_url | Issues Integration | 🟢 Optional | Enables issue context. If set, `issues_platform` becomes required. |
| issues_platform | Issues Integration | 🟡 Conditionally required | Required if `issues_url` is set. |
| issues_regex | Issues Integration | 🟢 Optional | Regex for issue ID extraction. Defaults to conventional commit compatible regex. |
Expand All @@ -409,7 +422,7 @@ These options apply to both reviews and guides generated by lgtm.
- **silent**: Do not print the review in the terminal. Default is `false`.
- **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).
- **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"`.
- **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`).
- **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.
- **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`).

#### Review options
Expand All @@ -419,6 +432,7 @@ These options are only used when performing reviews through the command `lgtm re
- **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.
- **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).
- **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).
- **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.

#### Issues Integration options

Expand Down
50 changes: 48 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"pygithub>=2.6.1,<3.0.0",
"httpx (>=0.28.1,<0.29.0)",
"jinja2 (>=3.1.6,<4.0.0)",
"gitpython (>=3.1.45,<4.0.0)",
]

[tool.poetry.group.dev.dependencies]
Expand Down
6 changes: 3 additions & 3 deletions scripts/evaluate_review_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from lgtm_ai.git_client.gitlab import GitlabClient
from lgtm_ai.review import CodeReviewer
from lgtm_ai.review.context import ContextRetriever
from lgtm_ai.validators import parse_pr_url
from lgtm_ai.validators import parse_target
from rich.logging import RichHandler

PRS_FOR_EVALUATION = {
Expand Down Expand Up @@ -94,7 +94,7 @@ def get_git_branch() -> str:
def perform_review(
output_dir: str, pr_url: str, pr_name: str, sample: int, model: SupportedAIModels, git_api_key: str, ai_api_key: str
) -> None:
url = parse_pr_url(mock.Mock(), "pr_url", pr_url)
url = parse_target(mock.Mock(), "pr_url", pr_url)
git_client = GitlabClient(client=gitlab.Gitlab(private_token=git_api_key), formatter=MarkDownFormatter())
code_reviewer = CodeReviewer(
reviewer_agent=get_reviewer_agent_with_settings(),
Expand All @@ -106,7 +106,7 @@ def perform_review(
),
config=ResolvedConfig(model=model, technologies=("Python", "Django", "FastAPI")),
)
review = code_reviewer.review_pull_request(pr_url=url)
review = code_reviewer.review_pull_request(target=url)
write_review_to_dir(model, output_dir, pr_name, sample, review)


Expand Down
74 changes: 47 additions & 27 deletions src/lgtm_ai/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from lgtm_ai.ai.schemas import AgentSettings, CommentCategory, SupportedAIModels, SupportedAIModelsList
from lgtm_ai.base.constants import DEFAULT_HTTPX_TIMEOUT
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesPlatform, OutputFormat, PRUrl
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesPlatform, LocalRepository, OutputFormat, PRUrl
from lgtm_ai.base.utils import git_source_supports_multiline_suggestions
from lgtm_ai.config.constants import DEFAULT_INPUT_TOKEN_LIMIT
from lgtm_ai.config.handler import ConfigHandler, PartialConfig, ResolvedConfig
Expand All @@ -31,7 +31,7 @@
from lgtm_ai.validators import (
IntOrNoLimitType,
ModelChoice,
parse_pr_url,
parse_target,
validate_model_url,
)
from rich.console import Console
Expand All @@ -56,7 +56,7 @@ def entry_point() -> None:
def _common_options[**P, T](func: Callable[P, T]) -> Callable[P, T]:
"""Wrap a click command and adds common options for lgtm commands."""

@click.argument("pr-url", required=True, callback=parse_pr_url)
@click.argument("target", required=True, callback=parse_target)
@click.option(
"--model",
type=ModelChoice(SupportedAIModelsList),
Expand All @@ -69,7 +69,10 @@ def _common_options[**P, T](func: Callable[P, T]) -> Callable[P, T]:
default=None,
callback=validate_model_url,
)
@click.option("--git-api-key", help="The API key to the git service (GitLab, GitHub, etc.)")
@click.option(
"--git-api-key",
help="The API key to the git service (GitLab, GitHub, etc.). Required if the target is a PR URL.",
)
@click.option("--ai-api-key", help="The API key to the AI model service (OpenAI, etc.)")
@click.option("--config", type=click.STRING, help="Path to the configuration file.")
@click.option(
Expand Down Expand Up @@ -134,8 +137,13 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
type=click.Choice(get_args(CommentCategory)),
help="List of categories the reviewer should focus on. If not provided, the reviewer will focus on all categories.",
)
@click.option(
"--compare",
default=None,
help="If reviewing a local repository, what to compare against (branch, commit, or HEAD for working dir). Default: HEAD",
)
def review(
pr_url: PRUrl,
target: PRUrl | LocalRepository,
model: SupportedAIModels | None,
model_url: str | None,
git_api_key: str | None,
Expand All @@ -155,16 +163,26 @@ def review(
issues_platform: IssuesPlatform | None,
issues_api_key: str | None,
issues_user: str | None,
compare: str | None,
) -> None:
"""Review a Pull Request using AI.
"""Review a Pull Request or local repository using AI.

TARGET can be either:

PR_URL is the URL of the pull request to review.
- A pull request URL (GitHub, GitLab, etc.).

- A local directory path (use --compare to specify what to compare against).
"""
_set_logging_level(logger, verbose)

if compare and not isinstance(target, LocalRepository):
logger.warning(
"`--compare` option is only used when reviewing a local repository. Ignoring the provided value."
)

logger.info("lgtm-ai version: %s", __version__)
logger.debug("Parsed PR URL: %s", pr_url)
logger.info("Starting review of %s", pr_url.full_url)
logger.debug("Parsed PR URL: %s", target)
logger.info("Starting review of %s", target.full_url)
resolved_config = ConfigHandler(
cli_args=PartialConfig(
technologies=technologies,
Expand All @@ -184,14 +202,16 @@ def review(
issues_platform=issues_platform,
issues_api_key=issues_api_key,
issues_user=issues_user,
compare=compare,
),
config_file=config,
).resolve_config()
).resolve_config(target)

agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
formatter: Formatter[Any] = MarkDownFormatter(
add_ranges_to_suggestions=git_source_supports_multiline_suggestions(pr_url.source)
add_ranges_to_suggestions=git_source_supports_multiline_suggestions(target.source)
)
git_client = get_git_client(source=pr_url.source, token=resolved_config.git_api_key, formatter=formatter)
git_client = get_git_client(source=target.source, token=resolved_config.git_api_key, formatter=formatter)
issues_client = _get_issues_client(resolved_config, git_client, formatter)

code_reviewer = CodeReviewer(
Expand All @@ -206,7 +226,7 @@ def review(
git_client=git_client,
config=resolved_config,
)
review = code_reviewer.review_pull_request(pr_url=pr_url)
review = code_reviewer.review_pull_request(target=target)
logger.info("Review completed, total comments: %d", len(review.review_response.comments))

if not resolved_config.silent:
Expand All @@ -216,16 +236,16 @@ def review(
if review.review_response.comments:
printer(formatter.format_review_comments_section(review.review_response.comments))

if resolved_config.publish:
if resolved_config.publish and isinstance(target, PRUrl) and git_client:
logger.info("Publishing review to git service")
git_client.publish_review(pr_url=pr_url, review=review)
git_client.publish_review(pr_url=target, review=review)
logger.info("Review published successfully")


@entry_point.command()
@_common_options
def guide(
pr_url: PRUrl,
target: PRUrl,
model: SupportedAIModels | None,
model_url: str | None,
git_api_key: str | None,
Expand All @@ -241,13 +261,13 @@ def guide(
) -> None:
"""Generate a review guide for a Pull Request using AI.

PR_URL is the URL of the pull request to generate a guide for.
TARGET is the URL of the pull request to generate a guide for.
"""
_set_logging_level(logger, verbose)

logger.info("lgtm-ai version: %s", __version__)
logger.debug("Parsed PR URL: %s", pr_url)
logger.info("Starting generating guide of %s", pr_url.full_url)
logger.debug("Parsed PR URL: %s", target)
logger.info("Starting generating guide of %s", target.full_url)
resolved_config = ConfigHandler(
cli_args=PartialConfig(
exclude=exclude,
Expand All @@ -262,9 +282,9 @@ def guide(
ai_input_tokens_limit=ai_input_tokens_limit,
),
config_file=config,
).resolve_config()
).resolve_config(target)
agent_extra_settings = AgentSettings(retries=resolved_config.ai_retries)
git_client = get_git_client(source=pr_url.source, token=resolved_config.git_api_key, formatter=MarkDownFormatter())
git_client = get_git_client(source=target.source, token=resolved_config.git_api_key, formatter=MarkDownFormatter())
review_guide = ReviewGuideGenerator(
guide_agent=get_guide_agent_with_settings(agent_extra_settings),
model=get_ai_model(
Expand All @@ -273,16 +293,16 @@ def guide(
git_client=git_client,
config=resolved_config,
)
guide = review_guide.generate_review_guide(pr_url=pr_url)
guide = review_guide.generate_review_guide(pr_url=target)

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

if resolved_config.publish:
if resolved_config.publish and git_client:
logger.info("Publishing review guide to git service")
git_client.publish_guide(pr_url=pr_url, guide=guide)
git_client.publish_guide(pr_url=target, guide=guide)
logger.info("Review Guide published successfully")


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


def _get_issues_client(
resolved_config: ResolvedConfig, git_client: GitClient, formatter: Formatter[Any]
) -> IssuesClient:
resolved_config: ResolvedConfig, git_client: GitClient | None, formatter: Formatter[Any]
) -> IssuesClient | None:
"""Select a different issues client for retrieving issues.

Will only return a different client if all of the following are true:
1) Be used at all
2) Be retrieved from a git platform and not elsewhere (e.g., Jira, Asana, etc.)
3) Have a specific API key configured
"""
issues_client: IssuesClient = git_client
issues_client: IssuesClient | None = git_client
if not resolved_config.issues_url or not resolved_config.issues_platform or not resolved_config.issues_regex:
return issues_client
if resolved_config.issues_platform.is_git_platform:
Expand Down
12 changes: 12 additions & 0 deletions src/lgtm_ai/base/schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pathlib
from dataclasses import dataclass
from enum import StrEnum
from typing import Literal
Expand All @@ -9,6 +10,7 @@
class PRSource(StrEnum):
github = "github"
gitlab = "gitlab"
local = "local"


class IssuesPlatform(StrEnum):
Expand All @@ -29,6 +31,16 @@ class PRUrl:
source: PRSource


@dataclass(frozen=True, slots=True)
class LocalRepository:
repo_path: pathlib.Path
source: PRSource = PRSource.local

@property
def full_url(self) -> str:
return self.repo_path.as_posix()


class OutputFormat(StrEnum):
pretty = "pretty"
json = "json"
Expand Down
Loading