|
| 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" |
0 commit comments