Skip to content

Commit 9e4f5c0

Browse files
authored
feat(#93): lgtm can now optionally use issues from GitLab (#106)
1 parent 35bbb8f commit 9e4f5c0

File tree

22 files changed

+543
-50
lines changed

22 files changed

+543
-50
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ mypy_path = "src"
126126
strict = true # strict type checking.
127127
warn_no_return = false # allow functions without explicit return.
128128
pretty = true # show error messages in a readable format.
129+
plugins = ['pydantic.mypy']
129130

130131
[tool.twyn]
131132
allowlist = [

src/lgtm_ai/__main__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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 IntOrNoLimit, OutputFormat, PRUrl
16+
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesSource, OutputFormat, PRUrl
1717
from lgtm_ai.base.utils import git_source_supports_suggestions
1818
from lgtm_ai.config.constants import DEFAULT_INPUT_TOKEN_LIMIT
1919
from lgtm_ai.config.handler import ConfigHandler, PartialConfig
@@ -95,6 +95,21 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
9595

9696

9797
@entry_point.command()
98+
@click.option(
99+
"--issues-url",
100+
type=click.STRING,
101+
help="The URL of the issues page to retrieve additional context from.",
102+
)
103+
@click.option(
104+
"--issues-source",
105+
type=click.Choice([source.value for source in IssuesSource]),
106+
help="The platform of the issues page.",
107+
)
108+
@click.option(
109+
"--issues-regex",
110+
type=click.STRING,
111+
help="Regex to extract issue ID from the PR title and description.",
112+
)
98113
@click.option(
99114
"--technologies",
100115
multiple=True,
@@ -123,6 +138,9 @@ def review(
123138
verbose: int,
124139
technologies: tuple[str, ...],
125140
categories: tuple[CommentCategory, ...],
141+
issues_url: str | None,
142+
issues_regex: str | None,
143+
issues_source: IssuesSource | None,
126144
) -> None:
127145
"""Review a Pull Request using AI."""
128146
_set_logging_level(logger, verbose)
@@ -144,6 +162,9 @@ def review(
144162
silent=silent,
145163
ai_retries=ai_retries,
146164
ai_input_tokens_limit=ai_input_tokens_limit,
165+
issues_url=issues_url,
166+
issues_regex=issues_regex,
167+
issues_source=issues_source,
147168
),
148169
config_file=config,
149170
).resolve_config()

src/lgtm_ai/ai/prompts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def _get_full_category_explanation() -> str:
5555
}}
5656
```
5757
- `Context`, which consists on the contents of each of the changed files in the source (PR) branch or the target branch. This should help you to understand the context of the PR.
58+
- Optionally, `User Story` that the PR is implementing, which will consist of a title and a description. You must evaluate whether the PR is correctly implementing the user story (in its totality or partially).
5859
- Optionally, `Additional context` that the author of the PR has provided, which may contain a prompt (to give you a hint on what to use it for), and some content.
5960
6061
You should make two types of comments:

src/lgtm_ai/base/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ class PRSource(StrEnum):
1111
gitlab = "gitlab"
1212

1313

14+
class IssuesSource(StrEnum):
15+
gitlab = "gitlab"
16+
17+
@property
18+
def is_git_platform(self) -> bool:
19+
return self in {IssuesSource.gitlab}
20+
21+
1422
@dataclass(frozen=True, slots=True)
1523
class PRUrl:
1624
full_url: str

src/lgtm_ai/config/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
DEFAULT_AI_MODEL: SupportedAIModels = "gemini-2.0-flash"
44
DEFAULT_INPUT_TOKEN_LIMIT = 500000
5+
DEFAULT_ISSUE_REGEX = r"(?:refs?|closes?)[:\s]*((?:#\d+)|(?:#?[A-Z]+-\d+))|(?:fix|feat|docs|style|refactor|perf|test|build|ci)\((?:#(\d+)|#?([A-Z]+-\d+))\)!?:"

src/lgtm_ai/config/exceptions.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from lgtm_ai.base.exceptions import LGTMException
2+
from pydantic import ValidationError
23
from pydantic_core import ErrorDetails
34

45

@@ -18,8 +19,28 @@ def __str__(self) -> str:
1819
return self.message
1920

2021
def _generate_message(self) -> str:
21-
messages = [f"'{str(error['loc'][0])}': {error['msg']}" for error in self.errors]
22+
messages = _extract_errors_from_validation_error(self.errors)
2223
return f"Invalid config file '{self.source}':\n" + "\n".join(messages)
2324

2425

26+
class InvalidOptionsError(LGTMException):
27+
"""Raised when options (no matter where they come from) are invalid."""
28+
29+
def __init__(self, err: ValidationError) -> None:
30+
self.err = err
31+
self.message = self._generate_message()
32+
33+
def __str__(self) -> str:
34+
return self.message
35+
36+
def _generate_message(self) -> str:
37+
messages = _extract_errors_from_validation_error(self.err)
38+
return "Invalid options:\n" + "\n".join(messages)
39+
40+
2541
class MissingRequiredConfigError(LGTMException): ...
42+
43+
44+
def _extract_errors_from_validation_error(err: ValidationError | list[ErrorDetails]) -> list[str]:
45+
errors = err.errors() if isinstance(err, ValidationError) else err
46+
return [f"'{str(error['loc'][0])}': {error['msg']}" for error in errors]

src/lgtm_ai/config/handler.py

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44
import tomllib
55
from collections.abc import Sequence
66
from pathlib import Path
7-
from typing import Any, ClassVar, Literal, cast, get_args, overload
7+
from typing import Annotated, Any, ClassVar, Literal, Self, cast, get_args, overload
88

99
from lgtm_ai.ai.schemas import AdditionalContext, CommentCategory, SupportedAIModels
10-
from lgtm_ai.base.schemas import IntOrNoLimit, OutputFormat
11-
from lgtm_ai.config.constants import DEFAULT_AI_MODEL, DEFAULT_INPUT_TOKEN_LIMIT
10+
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesSource, OutputFormat
11+
from lgtm_ai.config.constants import DEFAULT_AI_MODEL, DEFAULT_INPUT_TOKEN_LIMIT, DEFAULT_ISSUE_REGEX
1212
from lgtm_ai.config.exceptions import (
1313
ConfigFileNotFoundError,
1414
InvalidConfigError,
1515
InvalidConfigFileError,
16+
InvalidOptionsError,
1617
MissingRequiredConfigError,
1718
)
18-
from pydantic import BaseModel, Field, ValidationError
19+
from lgtm_ai.config.validators import validate_regex
20+
from pydantic import AfterValidator, BaseModel, Field, HttpUrl, ValidationError, model_validator
1921

2022
logger = logging.getLogger("lgtm")
2123

@@ -37,6 +39,9 @@ class PartialConfig(BaseModel):
3739
silent: bool = False
3840
ai_retries: int | None = None
3941
ai_input_tokens_limit: IntOrNoLimit | None = None
42+
issues_url: str | None = None
43+
issues_regex: str | None = None
44+
issues_source: IssuesSource | None = None
4045

4146
# Secrets
4247
git_api_key: str | None = None
@@ -82,13 +87,32 @@ class ResolvedConfig(BaseModel):
8287
ai_input_tokens_limit: int | None = DEFAULT_INPUT_TOKEN_LIMIT
8388
"""Maximum number of input tokens allowed to send to all AI models in total."""
8489

90+
issues_url: HttpUrl | None = None
91+
"""The URL of the issues page to retrieve additional context from."""
92+
93+
issues_regex: Annotated[str, AfterValidator(validate_regex)] = DEFAULT_ISSUE_REGEX
94+
"""Regex to extract issue ID from the PR title and description."""
95+
96+
issues_source: IssuesSource | None = None
97+
"""The platform of the issues page."""
98+
8599
# Secrets
86100
git_api_key: str = Field(default="", repr=False)
87101
"""API key to interact with the git service (GitLab, GitHub, etc.)."""
88102

89103
ai_api_key: str = Field(default="", repr=False)
90104
"""API key to interact with the AI model service (OpenAI, etc.)."""
91105

106+
@model_validator(mode="after")
107+
def validate_issues_options(self) -> Self:
108+
all_fields = (self.issues_url, self.issues_source)
109+
if any(field is not None for field in all_fields) and not all(field is not None for field in all_fields):
110+
raise MissingRequiredConfigError(
111+
"If any `--issues-*` configuration is provided, all issues fields must be provided. Check --help."
112+
)
113+
# TODO: if source is JIRA, we need to ensure we have credentials
114+
return self
115+
92116

93117
class ConfigHandler:
94118
"""Handler for the configuration of lgtm.
@@ -143,6 +167,9 @@ def _parse_config_file(self) -> PartialConfig:
143167
silent=config_data.get("silent", False),
144168
ai_retries=config_data.get("ai_retries", None),
145169
ai_input_tokens_limit=config_data.get("ai_input_tokens_limit", None),
170+
issues_url=config_data.get("issues_url", None),
171+
issues_source=config_data.get("issues_source", None),
172+
issues_regex=config_data.get("issues_regex", None),
146173
)
147174
except ValidationError as err:
148175
raise InvalidConfigError(source=file_to_read.name, errors=err.errors()) from None
@@ -203,6 +230,9 @@ def _parse_cli_args(self) -> PartialConfig:
203230
publish=self.cli_args.publish,
204231
ai_retries=self.cli_args.ai_retries or None,
205232
ai_input_tokens_limit=self.cli_args.ai_input_tokens_limit or None,
233+
issues_url=self.cli_args.issues_url or None,
234+
issues_source=self.cli_args.issues_source or None,
235+
issues_regex=self.cli_args.issues_regex or None,
206236
)
207237

208238
def _parse_env(self) -> PartialConfig:
@@ -219,36 +249,42 @@ def _resolve_from_multiple_sources(
219249
self, *, from_cli: PartialConfig, from_file: PartialConfig, from_env: PartialConfig
220250
) -> ResolvedConfig:
221251
"""Resolve the config fields given all the config sources."""
222-
resolved = ResolvedConfig(
223-
technologies=self.resolver.resolve_tuple_field("technologies", from_cli=from_cli, from_file=from_file),
224-
categories=cast(
225-
tuple[CommentCategory, ...],
226-
self.resolver.resolve_tuple_field(
227-
"categories", from_cli=from_cli, from_file=from_file, default=get_args(CommentCategory)
252+
try:
253+
resolved = ResolvedConfig(
254+
technologies=self.resolver.resolve_tuple_field("technologies", from_cli=from_cli, from_file=from_file),
255+
categories=cast(
256+
tuple[CommentCategory, ...],
257+
self.resolver.resolve_tuple_field(
258+
"categories", from_cli=from_cli, from_file=from_file, default=get_args(CommentCategory)
259+
),
228260
),
229-
),
230-
exclude=self.resolver.resolve_tuple_field("exclude", from_cli=from_cli, from_file=from_file),
231-
model=self.resolver.resolve_string_field(
232-
"model",
233-
from_cli=from_cli,
234-
from_file=from_file,
235-
required=False,
236-
default=DEFAULT_AI_MODEL,
237-
),
238-
model_url=from_cli.model_url or from_file.model_url,
239-
additional_context=from_file.additional_context or (),
240-
publish=from_cli.publish or from_file.publish,
241-
output_format=from_cli.output_format or from_file.output_format or OutputFormat.pretty,
242-
silent=from_cli.silent or from_file.silent,
243-
ai_retries=from_cli.ai_retries or from_file.ai_retries,
244-
git_api_key=self.resolver.resolve_string_field("git_api_key", from_cli=from_cli, from_env=from_env),
245-
ai_api_key=self.resolver.resolve_string_field(
246-
"ai_api_key", from_cli=from_cli, from_env=from_env, required=False, default=""
247-
),
248-
ai_input_tokens_limit=_transform_nolimit_to_none(
249-
from_cli.ai_input_tokens_limit or from_file.ai_input_tokens_limit or DEFAULT_INPUT_TOKEN_LIMIT
250-
),
251-
)
261+
exclude=self.resolver.resolve_tuple_field("exclude", from_cli=from_cli, from_file=from_file),
262+
model=self.resolver.resolve_string_field(
263+
"model",
264+
from_cli=from_cli,
265+
from_file=from_file,
266+
required=False,
267+
default=DEFAULT_AI_MODEL,
268+
),
269+
model_url=from_cli.model_url or from_file.model_url,
270+
additional_context=from_file.additional_context or (),
271+
publish=from_cli.publish or from_file.publish,
272+
output_format=from_cli.output_format or from_file.output_format or OutputFormat.pretty,
273+
silent=from_cli.silent or from_file.silent,
274+
ai_retries=from_cli.ai_retries or from_file.ai_retries,
275+
git_api_key=self.resolver.resolve_string_field("git_api_key", from_cli=from_cli, from_env=from_env),
276+
ai_api_key=self.resolver.resolve_string_field(
277+
"ai_api_key", from_cli=from_cli, from_env=from_env, required=False, default=""
278+
),
279+
ai_input_tokens_limit=_transform_nolimit_to_none(
280+
from_cli.ai_input_tokens_limit or from_file.ai_input_tokens_limit or DEFAULT_INPUT_TOKEN_LIMIT
281+
),
282+
issues_regex=from_cli.issues_regex or from_file.issues_regex or DEFAULT_ISSUE_REGEX,
283+
issues_url=from_cli.issues_url or from_file.issues_url,
284+
issues_source=from_cli.issues_source or from_file.issues_source,
285+
)
286+
except ValidationError as err:
287+
raise InvalidOptionsError(err) from None
252288
logger.debug("Resolved config: %s", resolved)
253289
return resolved
254290

src/lgtm_ai/config/validators.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import re
2+
3+
4+
def validate_regex(value: str | None) -> str | None:
5+
if value is None:
6+
return value
7+
8+
try:
9+
re.compile(value)
10+
except re.error as err:
11+
raise ValueError(f"Invalid regex: {err}") from err
12+
else:
13+
return value

src/lgtm_ai/git_client/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from lgtm_ai.ai.schemas import Review, ReviewGuide
44
from lgtm_ai.base.schemas import PRUrl
5-
from lgtm_ai.git_client.schemas import ContextBranch, PRDiff, PRMetadata
5+
from lgtm_ai.git_client.schemas import ContextBranch, IssueContent, PRDiff, PRMetadata
6+
from pydantic import HttpUrl
67

78

89
class GitClient(Protocol):
@@ -17,6 +18,9 @@ def publish_review(self, pr_url: PRUrl, review: Review) -> None:
1718
def get_pr_metadata(self, pr_url: PRUrl) -> PRMetadata:
1819
"""Get metadata for the PR given its URL."""
1920

21+
def get_issue_content(self, issues_url: HttpUrl, issue_id: str) -> IssueContent | None:
22+
"""Fetch the content of an issue from its URL."""
23+
2024
def publish_guide(self, pr_url: PRUrl, guide: ReviewGuide) -> None:
2125
"""Publish a review guide to the PR."""
2226

src/lgtm_ai/git_client/github.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from lgtm_ai.git_client.schemas import ContextBranch, PRDiff, PRMetadata
2323
from lgtm_ai.git_parser.exceptions import GitDiffParseError
2424
from lgtm_ai.git_parser.parser import DiffFileMetadata, DiffResult, parse_diff_patch
25+
from pydantic import HttpUrl
2526

2627
logger = logging.getLogger("lgtm.git")
2728

@@ -104,6 +105,9 @@ def get_pr_metadata(self, pr_url: PRUrl) -> PRMetadata:
104105

105106
return PRMetadata(title=pr.title or "", description=pr.body or "")
106107

108+
def get_issue_content(self, issue_url: HttpUrl, issue_id: str) -> None:
109+
raise NotImplementedError("GitHub issues are not yet supported")
110+
107111
def publish_guide(self, pr_url: PRUrl, guide: ReviewGuide) -> None:
108112
pr = _get_pr(self.client, pr_url)
109113
try:

0 commit comments

Comments
 (0)