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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 03:26 +0900
1"""Module for Jira search operations."""
3import logging
4from typing import Any, Dict, List, Optional
6import requests
8from ..document_types import Document
9from .client import JiraClient
10from .issues import IssuesMixin
12logger = logging.getLogger("mcp-jira")
15class SearchMixin(JiraClient):
16 """Mixin for Jira search operations."""
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).
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)
36 Returns:
37 List of Documents representing the search results
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 = []
48 for issue in issues.get("issues", []):
49 issue_key = issue["key"]
50 fields_data = issue.get("fields", {})
52 # Safely handle fields that might not be included in the response
53 summary = fields_data.get("summary", "")
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")
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")
67 # Process description field
68 description = fields_data.get("description")
69 desc = self._clean_text(description) if description is not None else ""
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)
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")
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 }
94 # Prepare content
95 content = desc if desc else f"{summary} [{status}]"
97 documents.append(Document(page_content=content, metadata=metadata))
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
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.
110 Args:
111 project_key: The project key
112 start: Starting index
113 limit: Maximum results to return
115 Returns:
116 List of Documents containing project issues
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)
124 def get_epic_issues(self, epic_key: str, limit: int = 50) -> List[Document]:
125 """
126 Get all issues linked to a specific epic.
128 Args:
129 epic_key: The key of the epic (e.g. 'PROJ-123')
130 limit: Maximum number of issues to return
132 Returns:
133 List of Documents representing the issues linked to the epic
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", {})
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", "")
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)
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 = {}
164 # Build JQL queries based on discovered field IDs
165 jql_queries = []
167 # Add queries based on discovered fields
168 if "parent" in field_ids:
169 jql_queries.append(f"parent = {epic_key}")
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}')
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 )
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
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 )
206 return documents
208 except ValueError as e:
209 # Re-raise ValueError for non-epic issues
210 raise
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
216 def _parse_date(self, date_str: str) -> str:
217 """
218 Parse a date string from ISO format to a more readable format.
220 This method is included in the SearchMixin for independence from other mixins,
221 but will use the implementation from IssuesMixin if available.
223 Args:
224 date_str: Date string in ISO format
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)
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