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

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

2

3 import logging

4 from typing import Any, Dict, List, Optional

5

6 import requests

7

8 from ..document_types import Document

9 from .client import JiraClient

10 from .issues import IssuesMixin

11

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

13

14

15 class 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