Coverage for src/mcp_atlassian/jira.py: 97%
100 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-22 16:34 +0900
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-22 16:34 +0900
1import logging
2import os
3from datetime import datetime
4from typing import Any
6from atlassian import Jira
8from .config import JiraConfig
9from .document_types import Document
10from .preprocessing import TextPreprocessor
12# Configure logging
13logger = logging.getLogger("mcp-jira")
16class JiraFetcher:
17 """Handles fetching and parsing content from Jira."""
19 def __init__(self):
20 url = os.getenv("JIRA_URL")
21 username = os.getenv("JIRA_USERNAME")
22 token = os.getenv("JIRA_API_TOKEN")
24 if not all([url, username, token]):
25 raise ValueError("Missing required Jira environment variables")
27 self.config = JiraConfig(url=url, username=username, api_token=token)
28 self.jira = Jira(
29 url=self.config.url,
30 username=self.config.username,
31 password=self.config.api_token, # API token is used as password
32 cloud=True,
33 )
34 self.preprocessor = TextPreprocessor(self.config.url)
36 def _clean_text(self, text: str) -> str:
37 """
38 Clean text content by:
39 1. Processing user mentions and links
40 2. Converting HTML/wiki markup to markdown
41 """
42 if not text:
43 return ""
45 return self.preprocessor.clean_jira_text(text)
47 def create_issue(
48 self,
49 project_key: str,
50 summary: str,
51 issue_type: str,
52 description: str = "",
53 **kwargs: Any,
54 ) -> Document:
55 """
56 Create a new issue in Jira and return it as a Document.
58 Args:
59 project_key: The key of the project (e.g. 'PROJ')
60 summary: Summary of the issue
61 issue_type: Issue type (e.g. 'Task', 'Bug', 'Story')
62 description: Issue description
63 kwargs: Any other custom Jira fields
65 Returns:
66 Document representing the newly created issue
67 """
68 fields = {
69 "project": {"key": project_key},
70 "summary": summary,
71 "issuetype": {"name": issue_type},
72 "description": description,
73 }
74 for key, value in kwargs.items():
75 fields[key] = value
77 try:
78 created = self.jira.issue_create(fields=fields)
79 issue_key = created.get("key")
80 if not issue_key:
81 raise ValueError(f"Failed to create issue in project {project_key}")
83 return self.get_issue(issue_key)
84 except Exception as e:
85 logger.error(f"Error creating issue in project {project_key}: {str(e)}")
86 raise
88 def update_issue(self, issue_key: str, fields: dict[str, Any] = None, **kwargs: Any) -> Document:
89 """
90 Update an existing issue.
92 Args:
93 issue_key: The key of the issue (e.g. 'PROJ-123')
94 fields: Dictionary of fields to update
95 kwargs: Additional fields to update
97 Returns:
98 Document representing the updated issue
99 """
100 fields = fields or {}
101 for k, v in kwargs.items():
102 fields[k] = v
104 try:
105 self.jira.issue_update(issue_key, fields=fields)
106 return self.get_issue(issue_key)
107 except Exception as e:
108 logger.error(f"Error updating issue {issue_key}: {str(e)}")
109 raise
111 def delete_issue(self, issue_key: str) -> bool:
112 """
113 Delete an existing issue.
115 Args:
116 issue_key: The key of the issue (e.g. 'PROJ-123')
118 Returns:
119 True if delete succeeded, otherwise raise an exception
120 """
121 try:
122 self.jira.delete_issue(issue_key)
123 return True
124 except Exception as e:
125 logger.error(f"Error deleting issue {issue_key}: {str(e)}")
126 raise
128 def _parse_date(self, date_str: str) -> str:
129 """Parse date string to handle various ISO formats."""
130 if not date_str:
131 return ""
133 # Handle various timezone formats
134 if "+0000" in date_str:
135 date_str = date_str.replace("+0000", "+00:00")
136 elif "-0000" in date_str:
137 date_str = date_str.replace("-0000", "+00:00")
138 # Handle other timezone formats like +0900, -0500, etc.
139 elif len(date_str) >= 5 and date_str[-5] in "+-" and date_str[-4:].isdigit():
140 # Insert colon between hours and minutes of timezone
141 date_str = date_str[:-2] + ":" + date_str[-2:]
143 try:
144 date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
145 return date.strftime("%Y-%m-%d")
146 except Exception as e:
147 logger.warning(f"Error parsing date {date_str}: {e}")
148 return date_str
150 def get_issue(self, issue_key: str, expand: str | None = None) -> Document:
151 """
152 Get a single issue with all its details.
154 Args:
155 issue_key: The issue key (e.g. 'PROJ-123')
156 expand: Optional fields to expand
158 Returns:
159 Document containing issue content and metadata
160 """
161 try:
162 issue = self.jira.issue(issue_key, expand=expand)
164 # Process description and comments
165 description = self._clean_text(issue["fields"].get("description", ""))
167 # Get comments
168 comments = []
169 if "comment" in issue["fields"]:
170 for comment in issue["fields"]["comment"]["comments"]:
171 processed_comment = self._clean_text(comment["body"])
172 created = self._parse_date(comment["created"])
173 author = comment["author"].get("displayName", "Unknown")
174 comments.append(
175 {
176 "body": processed_comment,
177 "created": created,
178 "author": author,
179 }
180 )
182 # Format created date using new parser
183 created_date = self._parse_date(issue["fields"]["created"])
185 # Combine content in a more structured way
186 content = f"""Issue: {issue_key}
187Title: {issue['fields'].get('summary', '')}
188Type: {issue['fields']['issuetype']['name']}
189Status: {issue['fields']['status']['name']}
190Created: {created_date}
192Description:
193{description}
195Comments:
196""" + "\n".join([f"{c['created']} - {c['author']}: {c['body']}" for c in comments])
198 # Streamlined metadata with only essential information
199 metadata = {
200 "key": issue_key,
201 "title": issue["fields"].get("summary", ""),
202 "type": issue["fields"]["issuetype"]["name"],
203 "status": issue["fields"]["status"]["name"],
204 "created_date": created_date,
205 "priority": issue["fields"].get("priority", {}).get("name", "None"),
206 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}",
207 }
209 return Document(page_content=content, metadata=metadata)
211 except Exception as e:
212 logger.error(f"Error fetching issue {issue_key}: {str(e)}")
213 raise
215 def search_issues(
216 self,
217 jql: str,
218 fields: str = "*all",
219 start: int = 0,
220 limit: int = 50,
221 expand: str | None = None,
222 ) -> list[Document]:
223 """
224 Search for issues using JQL.
226 Args:
227 jql: JQL query string
228 fields: Comma-separated string of fields to return
229 start: Starting index
230 limit: Maximum results to return
231 expand: Fields to expand
233 Returns:
234 List of Documents containing matching issues
235 """
236 try:
237 results = self.jira.jql(jql, fields=fields, start=start, limit=limit, expand=expand)
239 documents = []
240 for issue in results["issues"]:
241 # Get full issue details
242 doc = self.get_issue(issue["key"], expand=expand)
243 documents.append(doc)
245 return documents
247 except Exception as e:
248 logger.error(f"Error searching issues with JQL {jql}: {str(e)}")
249 raise
251 def get_project_issues(self, project_key: str, start: int = 0, limit: int = 50) -> list[Document]:
252 """
253 Get all issues for a project.
255 Args:
256 project_key: The project key
257 start: Starting index
258 limit: Maximum results to return
260 Returns:
261 List of Documents containing project issues
262 """
263 jql = f"project = {project_key} ORDER BY created DESC"
264 return self.search_issues(jql, start=start, limit=limit)