Skip to content

Commit 8c9c0ac

Browse files
authored
feat(#94): add support for JIRA issues (#123)
1 parent c915356 commit 8c9c0ac

File tree

11 files changed

+369
-9
lines changed

11 files changed

+369
-9
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ lgtm-ai can enhance code reviews by including context from linked issues or user
141141

142142
- Provide the following options to the `lgtm review` command:
143143
- `--issues-url`: The base URL of the issues or user story page.
144-
- `--issues-source`: The platform for the issues (e.g., `github`, `gitlab`).
144+
- `--issues-source`: The platform for the issues (e.g., `github`, `gitlab`, `jira`).
145145
- `--issues-regex`: (Optional) A regex pattern to extract the issue ID from the PR title or description.
146146
- `--issues-api-key`: (Optional) API key for the issues service (if different from `--git-api-key`).
147+
- `--issues-user`: (Optional) Username for the issues service (required if source is `jira`).
147148

148149
**Example:**
149150
```sh
@@ -161,7 +162,7 @@ lgtm review \
161162

162163
**Notes:**
163164

164-
- Only GitHub and GitLab issues are supported for now.
165+
- GitHub, GitLab, and [JIRA cloud](https://developer.atlassian.com/cloud/jira/platform/) issues are supported.
165166
- If `--issues-api-key` is not provided, lgtm will use `--git-api-key` for authentication.
166167
- If no issue is found, the review will proceed without issue context.
167168
- lgtm provides a default regex for extracting issue IDs that works with [conventional commits](https://www.conventionalcommits.org). This means you often do not need to specify `--issues-regex` if your PR titles or commit messages follow the conventional commit format (e.g., `feat(#123): add new feature`), or if your PR descriptions contain mentions to issues like: `refs: #123` or `closes: #123`.
@@ -392,6 +393,7 @@ When it comes to preference for selecting options, lgtm follows this preference
392393
| issues_source | Issues Integration | 🟡 Conditionally required | Required if `issues_url` is set. |
393394
| issues_regex | Issues Integration | 🟢 Optional | Regex for issue ID extraction. Defaults to conventional commit compatible regex. |
394395
| issues_api_key | Issues Integration | 🟢 Optional | API key for issues service (if different from `git_api_key`). Can't be given through config file. Also available through env variable `LGTM_ISSUES_API_KEY`. |
396+
| issues_user | Issues Integration | 🟡 Conditionally required | Username for accessing issues information. Only required for `issues_source=jira` |
395397

396398
</details>
397399

@@ -423,9 +425,10 @@ These options are only used when performing reviews through the command `lgtm re
423425
See [Using Issue/User Story Information section](#using-issueuser-story-information).
424426

425427
- **issues_url**: The base URL of the issues or user story page to fetch additional context for the PR. If set, `issues_source` becomes required.
426-
- **issues_source**: The platform for the issues (e.g., `github`, `gitlab`). Required if `issues_url` is set.
428+
- **issues_source**: The platform for the issues (e.g., `github`, `gitlab`, `jira`). Required if `issues_url` is set.
427429
- **issues_regex**: A regex pattern to extract the issue ID from the PR title or description. If omitted, lgtm uses a default regex compatible with conventional commits and common PR formats.
428430
- **issues_api_key**: API key for the issues service (if different from `git_api_key`). Can be given as a CLI argument, or as an environment variable (`LGTM_ISSUES_API_KEY`).
431+
- **issues_user**: Username for accessing the issues service (only necessary for `jira`). Can be given as a CLI argument, or as an environment variable (`LGTM_ISSUES_USER`).
429432

430433
#### Example `lgtm.toml`
431434

src/lgtm_ai/__main__.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +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.constants import DEFAULT_HTTPX_TIMEOUT
1617
from lgtm_ai.base.schemas import IntOrNoLimit, IssuesSource, OutputFormat, PRUrl
1718
from lgtm_ai.base.utils import git_source_supports_multiline_suggestions
1819
from lgtm_ai.config.constants import DEFAULT_INPUT_TOKEN_LIMIT
@@ -23,6 +24,7 @@
2324
from lgtm_ai.formatters.pretty import PrettyFormatter
2425
from lgtm_ai.git_client.base import GitClient
2526
from lgtm_ai.git_client.utils import get_git_client
27+
from lgtm_ai.jira.jira import JiraIssuesClient
2628
from lgtm_ai.review import CodeReviewer
2729
from lgtm_ai.review.context import ContextRetriever, IssuesClient
2830
from lgtm_ai.review.guide import ReviewGuideGenerator
@@ -117,6 +119,10 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
117119
"--issues-api-key",
118120
help="The optional API key to the issues service (Jira, GitLab, GitHub, etc.). If using GitHub or GitLab and not provided, `--git-api-key` will be used instead.",
119121
)
122+
@click.option(
123+
"--issues-user",
124+
help="The username to download issues information (only needed for Jira). Required if `--issues-source` is `jira`.",
125+
)
120126
@click.option(
121127
"--technologies",
122128
multiple=True,
@@ -148,6 +154,7 @@ def review(
148154
issues_regex: str | None,
149155
issues_source: IssuesSource | None,
150156
issues_api_key: str | None,
157+
issues_user: str | None,
151158
) -> None:
152159
"""Review a Pull Request using AI."""
153160
_set_logging_level(logger, verbose)
@@ -173,6 +180,7 @@ def review(
173180
issues_regex=issues_regex,
174181
issues_source=issues_source,
175182
issues_api_key=issues_api_key,
183+
issues_user=issues_user,
176184
),
177185
config_file=config,
178186
).resolve_config()
@@ -190,7 +198,7 @@ def review(
190198
model_name=resolved_config.model, api_key=resolved_config.ai_api_key, model_url=resolved_config.model_url
191199
),
192200
context_retriever=ContextRetriever(
193-
git_client=git_client, issues_client=issues_client, httpx_client=httpx.Client(timeout=3)
201+
git_client=git_client, issues_client=issues_client, httpx_client=httpx.Client(timeout=DEFAULT_HTTPX_TIMEOUT)
194202
),
195203
git_client=git_client,
196204
config=resolved_config,
@@ -305,15 +313,23 @@ def _get_issues_client(
305313
2) Be retrieved from a git platform and not elsewhere (e.g., Jira, Asana, etc.)
306314
3) Have a specific API key configured
307315
"""
308-
issues_client = git_client
316+
issues_client: IssuesClient = git_client
309317
if not resolved_config.issues_url or not resolved_config.issues_source or not resolved_config.issues_regex:
310318
return issues_client
311319
if resolved_config.issues_source.is_git_platform:
312320
if resolved_config.issues_api_key:
313321
issues_client = get_git_client(
314322
source=resolved_config.issues_source, token=resolved_config.issues_api_key, formatter=formatter
315323
)
324+
elif resolved_config.issues_source == IssuesSource.jira:
325+
if not resolved_config.issues_api_key or not resolved_config.issues_user:
326+
# This is validated earlier in config handler.
327+
raise ValueError("To use Jira as issues source, both `issues_user` and `issues_api_key` must be provided.")
328+
issues_client = JiraIssuesClient(
329+
issues_user=resolved_config.issues_user,
330+
issues_api_key=resolved_config.issues_api_key,
331+
httpx_client=httpx.Client(timeout=DEFAULT_HTTPX_TIMEOUT),
332+
)
316333
else:
317-
# TODO: implement other issues sources
318-
raise NotImplementedError("Only GitHub and GitLab are supported as issues sources for now.")
334+
raise NotImplementedError("Unsupported issues source")
319335
return issues_client

src/lgtm_ai/base/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from typing import Final
2+
3+
DEFAULT_HTTPX_TIMEOUT: Final[int] = 3

src/lgtm_ai/base/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class PRSource(StrEnum):
1414
class IssuesSource(StrEnum):
1515
github = "github"
1616
gitlab = "gitlab"
17+
jira = "jira"
1718

1819
@property
1920
def is_git_platform(self) -> bool:

src/lgtm_ai/config/handler.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class PartialConfig(BaseModel):
4747
git_api_key: str | None = None
4848
ai_api_key: str | None = None
4949
issues_api_key: str | None = None
50+
issues_user: str | None = None
5051

5152

5253
class ResolvedConfig(BaseModel):
@@ -107,18 +108,24 @@ class ResolvedConfig(BaseModel):
107108
issues_api_key: str | None = Field(default=None, repr=False)
108109
"""API key to interact with the issues service (GitHub, GitLab, Jira, etc.)."""
109110

111+
issues_user: str | None = Field(default=None, repr=False)
112+
"""Username to interact with the issues service (only needed for Jira)."""
113+
110114
@model_validator(mode="after")
111115
def validate_issues_options(self) -> Self:
112116
all_fields = (self.issues_url, self.issues_source)
113117
if any(field is not None for field in all_fields) and not all(field is not None for field in all_fields):
114118
raise MissingRequiredConfigError(
115119
"If any `--issues-*` configuration is provided, all issues fields must be provided. Check --help."
116120
)
117-
# TODO: Adapt needed credentials and extra options for JIRA (https://github.com/elementsinteractive/lgtm-ai/issues/94)
118121
if self.issues_source is not None and not self.issues_source.is_git_platform and not self.issues_api_key:
119122
raise MissingRequiredConfigError(
120123
f"An API key is required to access issues from {self.issues_source.value}. Please provide it via the --issues-api-key option or the LGTM_ISSUES_API_KEY environment variable."
121124
)
125+
if self.issues_source == IssuesSource.jira and (not self.issues_user or not self.issues_api_key):
126+
raise MissingRequiredConfigError(
127+
"A username and an api key are required to access issues from Jira. Please provide them via the --issues-user and --issues-api-key options."
128+
)
122129

123130
return self
124131

@@ -243,6 +250,7 @@ def _parse_cli_args(self) -> PartialConfig:
243250
issues_source=self.cli_args.issues_source or None,
244251
issues_regex=self.cli_args.issues_regex or None,
245252
issues_api_key=self.cli_args.issues_api_key or None,
253+
issues_user=self.cli_args.issues_user or None,
246254
)
247255

248256
def _parse_env(self) -> PartialConfig:
@@ -252,6 +260,7 @@ def _parse_env(self) -> PartialConfig:
252260
git_api_key=os.environ.get("LGTM_GIT_API_KEY", None),
253261
ai_api_key=os.environ.get("LGTM_AI_API_KEY", None),
254262
issues_api_key=os.environ.get("LGTM_ISSUES_API_KEY", None),
263+
issues_user=os.environ.get("LGTM_ISSUES_USER", None),
255264
)
256265
except ValidationError as err:
257266
raise InvalidConfigError(source="Environment variables", errors=err.errors()) from None
@@ -296,6 +305,9 @@ def _resolve_from_multiple_sources(
296305
issues_api_key=self.resolver.resolve_string_field(
297306
"issues_api_key", from_cli=from_cli, from_env=from_env, required=False, default=None
298307
),
308+
issues_user=self.resolver.resolve_string_field(
309+
"issues_user", from_cli=from_cli, from_env=from_env, required=False, default=None
310+
),
299311
)
300312
except ValidationError as err:
301313
raise InvalidOptionsError(err) from None

src/lgtm_ai/jira/__init__.py

Whitespace-only changes.

src/lgtm_ai/jira/jira.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import logging
2+
from typing import ClassVar
3+
4+
import httpx
5+
from lgtm_ai.git_client.schemas import IssueContent
6+
from pydantic import BaseModel, HttpUrl, ValidationError
7+
8+
logger = logging.getLogger("lgtm")
9+
10+
11+
class JiraIssuesClient:
12+
API_VERSION: ClassVar[int] = 3
13+
14+
def __init__(self, issues_user: str, issues_api_key: str, httpx_client: httpx.Client) -> None:
15+
self._issues_user = issues_user
16+
self._issues_api_key = issues_api_key
17+
self._httpx_client = httpx_client
18+
19+
def get_issue_content(self, issues_url: HttpUrl, issue_id: str) -> IssueContent | None:
20+
api_url = f"https://{issues_url.host}/rest/api/{self.API_VERSION}/issue/{issue_id}"
21+
22+
try:
23+
response = self._httpx_client.get(api_url, auth=(self._issues_user, self._issues_api_key))
24+
response.raise_for_status()
25+
jira_issue = _JiraIssueResponse.model_validate(response.json())
26+
return IssueContent(title=jira_issue.title, description=jira_issue.description_text)
27+
except httpx.HTTPError:
28+
logger.error("Error fetching issue content for %s from Jira at %s", issue_id, issues_url.host)
29+
return None
30+
except ValidationError as err:
31+
logger.error("Error parsing issue content for %s from Jira: %s", issue_id, err)
32+
return None
33+
except Exception as err:
34+
logger.error("Unexpected error fetching issue content for %s from Jira: %s", issue_id, err)
35+
return None
36+
37+
38+
class _JiraDescriptionContent(BaseModel):
39+
"""Represents content within a Jira description paragraph."""
40+
41+
type: str
42+
text: str | None = None
43+
44+
45+
class _JiraDescriptionParagraph(BaseModel):
46+
"""Represents a paragraph in Jira description."""
47+
48+
type: str
49+
content: list[_JiraDescriptionContent] | None = None
50+
51+
52+
class _JiraDescription(BaseModel):
53+
"""Represents the Jira description document structure."""
54+
55+
type: str
56+
version: int
57+
content: list[_JiraDescriptionParagraph]
58+
59+
@property
60+
def plain_text(self) -> str:
61+
"""Convert the description to plain text."""
62+
text_parts = []
63+
for paragraph in self.content:
64+
if paragraph.content:
65+
paragraph_text = []
66+
for content_item in paragraph.content:
67+
if content_item.text:
68+
paragraph_text.append(content_item.text)
69+
if paragraph_text:
70+
text_parts.append("".join(paragraph_text))
71+
return "\n\n".join(text_parts)
72+
73+
74+
class _JiraIssueFields(BaseModel):
75+
"""Represents the fields section of a Jira issue."""
76+
77+
summary: str
78+
description: _JiraDescription | None = None
79+
80+
81+
class _JiraIssueResponse(BaseModel):
82+
"""Represents a complete Jira issue response."""
83+
84+
id: str
85+
key: str
86+
fields: _JiraIssueFields
87+
88+
@property
89+
def title(self) -> str:
90+
"""Get the issue title (summary)."""
91+
return self.fields.summary
92+
93+
@property
94+
def description_text(self) -> str:
95+
"""Get the description as plain text."""
96+
if self.fields.description:
97+
return self.fields.description.plain_text
98+
return ""

src/lgtm_ai/review/guide.py

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

33
import httpx
44
from lgtm_ai.ai.schemas import GuideResponse, PublishMetadata, ReviewGuide
5+
from lgtm_ai.base.constants import DEFAULT_HTTPX_TIMEOUT
56
from lgtm_ai.base.schemas import PRUrl
67
from lgtm_ai.config.handler import ResolvedConfig
78
from lgtm_ai.git_client.base import GitClient
@@ -29,7 +30,7 @@ def __init__(
2930
self.git_client = git_client
3031
self.config = config
3132
self.context_retriever = ContextRetriever(
32-
git_client=git_client, issues_client=git_client, httpx_client=httpx.Client(timeout=3)
33+
git_client=git_client, issues_client=git_client, httpx_client=httpx.Client(timeout=DEFAULT_HTTPX_TIMEOUT)
3334
)
3435

3536
def generate_review_guide(self, pr_url: PRUrl) -> ReviewGuide:

tests/config/test_handler.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,19 @@ def test_issues_regex_invalid() -> None:
293293
)
294294
with pytest.raises(InvalidOptionsError, match="Invalid regex"):
295295
handler.resolve_config()
296+
297+
298+
@pytest.mark.usefixtures("inject_env_secrets")
299+
def test_issues_jira_missing_user() -> None:
300+
handler = ConfigHandler(
301+
cli_args=PartialConfig(
302+
issues_url="https://test.atlassian.net/browse/",
303+
issues_source="jira",
304+
issues_api_key="api-key",
305+
),
306+
config_file=None,
307+
)
308+
with pytest.raises(
309+
MissingRequiredConfigError, match="A username and an api key are required to access issues from Jira"
310+
):
311+
handler.resolve_config()

tests/jira/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)