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

1import logging 

2import os 

3from datetime import datetime 

4from typing import Any 

5 

6from atlassian import Jira 

7 

8from .config import JiraConfig 

9from .document_types import Document 

10from .preprocessing import TextPreprocessor 

11 

12# Configure logging 

13logger = logging.getLogger("mcp-jira") 

14 

15 

16class JiraFetcher: 

17 """Handles fetching and parsing content from Jira.""" 

18 

19 def __init__(self): 

20 url = os.getenv("JIRA_URL") 

21 username = os.getenv("JIRA_USERNAME") 

22 token = os.getenv("JIRA_API_TOKEN") 

23 

24 if not all([url, username, token]): 

25 raise ValueError("Missing required Jira environment variables") 

26 

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) 

35 

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 "" 

44 

45 return self.preprocessor.clean_jira_text(text) 

46 

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. 

57 

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 

64 

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 

76 

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}") 

82 

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 

87 

88 def update_issue(self, issue_key: str, fields: dict[str, Any] = None, **kwargs: Any) -> Document: 

89 """ 

90 Update an existing issue. 

91 

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 

96 

97 Returns: 

98 Document representing the updated issue 

99 """ 

100 fields = fields or {} 

101 for k, v in kwargs.items(): 

102 fields[k] = v 

103 

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 

110 

111 def delete_issue(self, issue_key: str) -> bool: 

112 """ 

113 Delete an existing issue. 

114 

115 Args: 

116 issue_key: The key of the issue (e.g. 'PROJ-123') 

117 

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 

127 

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 "" 

132 

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:] 

142 

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 

149 

150 def get_issue(self, issue_key: str, expand: str | None = None) -> Document: 

151 """ 

152 Get a single issue with all its details. 

153 

154 Args: 

155 issue_key: The issue key (e.g. 'PROJ-123') 

156 expand: Optional fields to expand 

157 

158 Returns: 

159 Document containing issue content and metadata 

160 """ 

161 try: 

162 issue = self.jira.issue(issue_key, expand=expand) 

163 

164 # Process description and comments 

165 description = self._clean_text(issue["fields"].get("description", "")) 

166 

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 ) 

181 

182 # Format created date using new parser 

183 created_date = self._parse_date(issue["fields"]["created"]) 

184 

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} 

191 

192Description: 

193{description} 

194 

195Comments: 

196""" + "\n".join([f"{c['created']} - {c['author']}: {c['body']}" for c in comments]) 

197 

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 } 

208 

209 return Document(page_content=content, metadata=metadata) 

210 

211 except Exception as e: 

212 logger.error(f"Error fetching issue {issue_key}: {str(e)}") 

213 raise 

214 

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. 

225 

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 

232 

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) 

238 

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) 

244 

245 return documents 

246 

247 except Exception as e: 

248 logger.error(f"Error searching issues with JQL {jql}: {str(e)}") 

249 raise 

250 

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. 

254 

255 Args: 

256 project_key: The project key 

257 start: Starting index 

258 limit: Maximum results to return 

259 

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)