Skip to content

Commit dd0a9d5

Browse files
Merge branch 'main' into vultaire/vue-frontend-poc
2 parents 8d0831a + c14f3bc commit dd0a9d5

File tree

14 files changed

+1940
-667
lines changed

14 files changed

+1940
-667
lines changed

backend/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ dependencies = [
1414
"requests>=2.31.0,<3.0.0",
1515
"sentry-sdk==0.10.2",
1616
"launchpadlib>=1.11.0,<2.0.0",
17+
"PyGithub>=2.0.0,<3.0.0",
18+
"jira>=3.10.0,<4.0.0",
1719
"email-validator>=2.1.0.post1,<3.0.0",
1820
"celery[redis]>=5.3.6,<6.0.0",
1921
"aiohttp>=3.9.4,<4.0.0",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (C) 2023 Canonical Ltd.
2+
#
3+
# This file is part of Test Observer Backend.
4+
#
5+
# Test Observer Backend is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# Test Observer Backend is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
18+
class IssueNotFoundError(Exception):
19+
"""Raised when an issue doesn't exist in the external system"""
20+
21+
pass
22+
23+
24+
class APIError(Exception):
25+
"""Raised when an API request fails"""
26+
27+
pass
28+
29+
30+
class RateLimitError(APIError):
31+
"""Raised when hitting rate limits"""
32+
33+
pass
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (C) 2023 Canonical Ltd.
2+
#
3+
# This file is part of Test Observer Backend.
4+
#
5+
# Test Observer Backend is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# Test Observer Backend is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
from .github_client import GitHubClient # re-export for convenience
18+
19+
__all__ = ["GitHubClient"]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright (C) 2023 Canonical Ltd.
2+
#
3+
# This file is part of Test Observer Backend.
4+
#
5+
# Test Observer Backend is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# Test Observer Backend is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
from __future__ import annotations
18+
19+
from github import Github, Auth, GithubException
20+
21+
from test_observer.external_apis.exceptions import (
22+
IssueNotFoundError,
23+
APIError,
24+
RateLimitError,
25+
)
26+
27+
from test_observer.external_apis.models import IssueData
28+
29+
30+
class GitHubClient:
31+
"""GitHub issue client using PyGithub"""
32+
33+
def __init__(self, token: str | None = None, timeout: int = 10):
34+
"""
35+
Args:
36+
token: Personal Access Token (classic PAT or fine-grained token)
37+
timeout: request timeout (seconds)
38+
"""
39+
self.token = token
40+
self.timeout = timeout
41+
42+
# Initialize Github client with proper auth
43+
auth = Auth.Token(token) if token else None
44+
self._github = Github(auth=auth, timeout=timeout)
45+
46+
def get_issue(self, project: str, key: str) -> IssueData:
47+
"""
48+
Fetch an issue from GitHub.
49+
50+
Args:
51+
project: "owner/repo" format
52+
key: issue number (string/int)
53+
54+
Returns:
55+
IssueData object
56+
Raises:
57+
IssueNotFoundError: Issue not found
58+
RateLimitError: Rate limit exceeded
59+
APIError: Other API errors
60+
"""
61+
try:
62+
repo = self._github.get_repo(project)
63+
issue = repo.get_issue(int(str(key).lstrip("#")))
64+
65+
return IssueData(
66+
title=issue.title,
67+
state=issue.state,
68+
state_reason=issue.state_reason,
69+
raw={
70+
"id": issue.id,
71+
"number": issue.number,
72+
"title": issue.title,
73+
"state": issue.state,
74+
"state_reason": issue.state_reason,
75+
"url": issue.html_url,
76+
},
77+
)
78+
except GithubException as e:
79+
if e.status == 404:
80+
raise IssueNotFoundError(
81+
f"GitHub issue {project}#{key} not found"
82+
) from e
83+
if e.status == 403 and "API rate limit" in str(e):
84+
raise RateLimitError(f"GitHub rate limit exceeded: {e}") from e
85+
if e.status == 401:
86+
raise APIError("GitHub authentication failed. Check token.") from e
87+
raise APIError(f"GitHub API error: {e}") from e
88+
except Exception as e:
89+
raise APIError(f"Failed to fetch GitHub issue: {e}") from e
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (C) 2023 Canonical Ltd.
2+
#
3+
# This file is part of Test Observer Backend.
4+
#
5+
# Test Observer Backend is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# Test Observer Backend is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
from .jira_client import JiraClient
18+
19+
__all__ = ["JiraClient"]
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright (C) 2023 Canonical Ltd.
2+
#
3+
# This file is part of Test Observer Backend.
4+
#
5+
# Test Observer Backend is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Affero General Public License version 3, as
7+
# published by the Free Software Foundation.
8+
#
9+
# Test Observer Backend is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
from __future__ import annotations
18+
19+
from jira import JIRA
20+
from jira.exceptions import JIRAError
21+
22+
from test_observer.external_apis.exceptions import (
23+
IssueNotFoundError,
24+
APIError,
25+
RateLimitError,
26+
)
27+
28+
from test_observer.external_apis.models import IssueData
29+
30+
31+
class JiraClient:
32+
"""Jira issue client using python-jira library"""
33+
34+
def __init__(
35+
self,
36+
base_url: str,
37+
*,
38+
email: str | None = None,
39+
api_token: str | None = None,
40+
bearer_token: str | None = None,
41+
timeout: int = 10,
42+
):
43+
"""
44+
Args:
45+
base_url: Jira instance URL (e.g., https://your-domain.atlassian.net)
46+
email: Email for Cloud authentication
47+
api_token: API token for Cloud authentication
48+
bearer_token: Bearer token for DC/Server authentication
49+
timeout: request timeout (seconds)
50+
"""
51+
self.base_url = base_url.rstrip("/")
52+
self.timeout = timeout
53+
54+
# Build auth tuple/token
55+
auth: tuple[str, str] | str | None = None
56+
if bearer_token:
57+
auth = bearer_token
58+
elif email and api_token:
59+
auth = (email, api_token)
60+
61+
# Initialize Jira client
62+
self._jira = JIRA(
63+
server=self.base_url,
64+
basic_auth=auth if isinstance(auth, tuple) else None,
65+
token_auth=auth if isinstance(auth, str) else None,
66+
timeout=timeout,
67+
)
68+
69+
def get_issue(self, project: str, key: str) -> IssueData:
70+
"""
71+
Fetch an issue from Jira.
72+
73+
Args:
74+
project: Project key (e.g., "TO")
75+
key: Issue key/number (e.g., "142" or "TO-142")
76+
77+
Returns:
78+
IssueData object
79+
Raises:
80+
IssueNotFoundError: Issue not found
81+
RateLimitError: Rate limit exceeded
82+
APIError: Other API errors
83+
"""
84+
try:
85+
# Normalize issue key
86+
issue_key = self._normalize_issue_key(project, key)
87+
88+
# Fetch issue
89+
issue = self._jira.issue(issue_key)
90+
resolution = (
91+
issue.fields.resolution.name if issue.fields.resolution else None
92+
)
93+
# Normalize status
94+
state = self._normalize_state(
95+
status_category=(
96+
issue.fields.status.statusCategory.key
97+
if issue.fields.status.statusCategory
98+
else None
99+
),
100+
resolution=resolution,
101+
)
102+
return IssueData(
103+
title=issue.fields.summary,
104+
state=state,
105+
state_reason=issue.fields.status.name,
106+
raw={
107+
"key": issue.key,
108+
"summary": issue.fields.summary,
109+
"status": issue.fields.status.name,
110+
"resolution": resolution,
111+
"url": issue.permalink(),
112+
},
113+
)
114+
except JIRAError as e:
115+
if e.status_code == 404:
116+
raise IssueNotFoundError(f"Jira issue {project}/{key} not found") from e
117+
if e.status_code == 429:
118+
raise RateLimitError(f"Jira rate limit exceeded: {e}") from e
119+
if e.status_code == 401:
120+
raise APIError("Jira authentication failed. Check credentials.") from e
121+
if e.status_code == 403:
122+
raise APIError("Jira permission denied. Check user permissions.") from e
123+
raise APIError(f"Jira API error: {e}") from e
124+
except Exception as e:
125+
raise APIError(f"Failed to fetch Jira issue: {e}") from e
126+
127+
def _normalize_issue_key(self, project: str, key: str) -> str:
128+
"""Normalize issue key to format PROJECT-123"""
129+
k = str(key).strip().upper()
130+
if k.startswith("#"):
131+
k = k[1:]
132+
if "-" not in k and project:
133+
return f"{project.strip().upper()}-{k}"
134+
return k
135+
136+
def _normalize_state(
137+
self, status_category: str | None = None, resolution: str | None = None
138+
) -> str:
139+
"""Map Jira status to simple open/closed format"""
140+
if status_category == "done":
141+
return "closed"
142+
if resolution:
143+
return "closed"
144+
return "open"

backend/test_observer/external_apis/launchpad/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@
1313
#
1414
# You should have received a copy of the GNU Affero General Public License
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
from .launchpad_client import LaunchpadClient
18+
19+
__all__ = ["LaunchpadClient"]

0 commit comments

Comments
 (0)