Coverage for src/mcp_atlassian/jira/search.py: 95%

93 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 03:26 +0900

1"""Module for Jira search operations.""" 

2 

3import logging 

4from typing import Any, Dict, List, Optional 

5 

6import requests 

7 

8from ..document_types import Document 

9from .client import JiraClient 

10from .issues import IssuesMixin 

11 

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

13 

14 

15class SearchMixin(JiraClient): 

16 """Mixin for Jira search operations.""" 

17 

18 def search_issues( 

19 self, 

20 jql: str, 

21 fields: str = "*all", 

22 start: int = 0, 

23 limit: int = 50, 

24 expand: Optional[str] = None, 

25 ) -> List[Document]: 

26 """ 

27 Search for issues using JQL (Jira Query Language). 

28 

29 Args: 

30 jql: JQL query string 

31 fields: Fields to return (comma-separated string or "*all") 

32 start: Starting index 

33 limit: Maximum issues to return 

34 expand: Optional items to expand (comma-separated) 

35 

36 Returns: 

37 List of Documents representing the search results 

38 

39 Raises: 

40 Exception: If there is an error searching for issues 

41 """ 

42 try: 

43 issues = self.jira.jql( 

44 jql, fields=fields, start=start, limit=limit, expand=expand 

45 ) 

46 documents = [] 

47 

48 for issue in issues.get("issues", []): 

49 issue_key = issue["key"] 

50 fields_data = issue.get("fields", {}) 

51 

52 # Safely handle fields that might not be included in the response 

53 summary = fields_data.get("summary", "") 

54 

55 # Handle issuetype field with fallback to "Unknown" if missing 

56 issue_type = "Unknown" 

57 issuetype_data = fields_data.get("issuetype") 

58 if issuetype_data is not None: 

59 issue_type = issuetype_data.get("name", "Unknown") 

60 

61 # Handle status field with fallback to "Unknown" if missing 

62 status = "Unknown" 

63 status_data = fields_data.get("status") 

64 if status_data is not None: 

65 status = status_data.get("name", "Unknown") 

66 

67 # Process description field 

68 description = fields_data.get("description") 

69 desc = self._clean_text(description) if description is not None else "" 

70 

71 # Process created date field 

72 created_date = "" 

73 created = fields_data.get("created") 

74 if created is not None: 

75 created_date = self._parse_date(created) 

76 

77 # Process priority field 

78 priority = "None" 

79 priority_data = fields_data.get("priority") 

80 if priority_data is not None: 

81 priority = priority_data.get("name", "None") 

82 

83 # Add basic metadata 

84 metadata = { 

85 "key": issue_key, 

86 "title": summary, 

87 "type": issue_type, 

88 "status": status, 

89 "created_date": created_date, 

90 "priority": priority, 

91 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}", 

92 } 

93 

94 # Prepare content 

95 content = desc if desc else f"{summary} [{status}]" 

96 

97 documents.append(Document(page_content=content, metadata=metadata)) 

98 

99 return documents 

100 except Exception as e: 

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

102 raise Exception(f"Error searching issues: {str(e)}") from e 

103 

104 def get_project_issues( 

105 self, project_key: str, start: int = 0, limit: int = 50 

106 ) -> List[Document]: 

107 """ 

108 Get all issues for a project. 

109 

110 Args: 

111 project_key: The project key 

112 start: Starting index 

113 limit: Maximum results to return 

114 

115 Returns: 

116 List of Documents containing project issues 

117 

118 Raises: 

119 Exception: If there is an error getting project issues 

120 """ 

121 jql = f"project = {project_key} ORDER BY created DESC" 

122 return self.search_issues(jql, start=start, limit=limit) 

123 

124 def get_epic_issues(self, epic_key: str, limit: int = 50) -> List[Document]: 

125 """ 

126 Get all issues linked to a specific epic. 

127 

128 Args: 

129 epic_key: The key of the epic (e.g. 'PROJ-123') 

130 limit: Maximum number of issues to return 

131 

132 Returns: 

133 List of Documents representing the issues linked to the epic 

134 

135 Raises: 

136 ValueError: If the issue is not an Epic 

137 Exception: If there is an error getting epic issues 

138 """ 

139 try: 

140 # First, check if the issue is an Epic 

141 epic = self.jira.issue(epic_key) 

142 fields_data = epic.get("fields", {}) 

143 

144 # Safely check if the issue is an Epic 

145 issue_type = None 

146 issuetype_data = fields_data.get("issuetype") 

147 if issuetype_data is not None: 

148 issue_type = issuetype_data.get("name", "") 

149 

150 if issue_type != "Epic": 

151 error_msg = ( 

152 f"Issue {epic_key} is not an Epic, it is a " 

153 f"{issue_type or 'unknown type'}" 

154 ) 

155 raise ValueError(error_msg) 

156 

157 # Get the dynamic field IDs for this Jira instance 

158 if hasattr(self, 'get_jira_field_ids'): 

159 field_ids = self.get_jira_field_ids() 

160 else: 

161 # Fallback for when we're not using IssuesMixin 

162 field_ids = {} 

163 

164 # Build JQL queries based on discovered field IDs 

165 jql_queries = [] 

166 

167 # Add queries based on discovered fields 

168 if "parent" in field_ids: 

169 jql_queries.append(f"parent = {epic_key}") 

170 

171 if "epic_link" in field_ids: 

172 field_name = field_ids["epic_link"] 

173 jql_queries.append(f'"{field_name}" = {epic_key}') 

174 jql_queries.append(f'"{field_name}" ~ {epic_key}') 

175 

176 # Add standard fallback queries 

177 jql_queries.extend( 

178 [ 

179 f"parent = {epic_key}", # Common in most instances 

180 f"'Epic Link' = {epic_key}", # Some instances 

181 f"'Epic' = {epic_key}", # Some instances 

182 f"issue in childIssuesOf('{epic_key}')", # Some instances 

183 ] 

184 ) 

185 

186 # Try each query until we get results or run out of options 

187 documents = [] 

188 for jql in jql_queries: 

189 try: 

190 logger.info(f"Trying to get epic issues with JQL: {jql}") 

191 documents = self.search_issues(jql, limit=limit) 

192 if documents: 

193 return documents 

194 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

195 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}") 

196 continue 

197 

198 # If we've tried all queries and got no results, return an empty list 

199 # but also log a warning that we might be missing the right field 

200 if not documents: 

201 logger.warning( 

202 f"Couldn't find issues linked to epic {epic_key}. " 

203 "Your Jira instance might use a different field for epic links." 

204 ) 

205 

206 return documents 

207 

208 except ValueError as e: 

209 # Re-raise ValueError for non-epic issues 

210 raise 

211 

212 except Exception as e: 

213 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}") 

214 raise Exception(f"Error getting epic issues: {str(e)}") from e 

215 

216 def _parse_date(self, date_str: str) -> str: 

217 """ 

218 Parse a date string from ISO format to a more readable format. 

219  

220 This method is included in the SearchMixin for independence from other mixins, 

221 but will use the implementation from IssuesMixin if available. 

222  

223 Args: 

224 date_str: Date string in ISO format 

225  

226 Returns: 

227 Formatted date string 

228 """ 

229 # If we're also using IssuesMixin, use its implementation 

230 if hasattr(self, '_parse_date') and self.__class__._parse_date is not SearchMixin._parse_date: 

231 # This avoids infinite recursion by checking that the method is different 

232 return super()._parse_date(date_str) 

233 

234 # Fallback implementation 

235 try: 

236 from datetime import datetime 

237 date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00")) 

238 return date_obj.strftime("%Y-%m-%d") 

239 except (ValueError, TypeError): 

240 return date_str