Coverage for src/mcp_atlassian/jira.py: 97%

1 import logging

2 import os

3 from datetime import datetime

4 from typing import Any

5

6 from atlassian import Jira

7

8 from .config import JiraConfig

9 from .document_types import Document

10 from .preprocessing import TextPreprocessor

11

12# Configure logging

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

14

15

16 class 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}

187 Title: {issue['fields'].get('summary', '')}

188 Type: {issue['fields']['issuetype']['name']}

189 Status: {issue['fields']['status']['name']}

190 Created: {created_date}

191

192 Description:

193{description}

194

195 Comments:

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)