Coverage for src/mcp_atlassian/jira/worklog.py: 96%
81 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 worklog operations."""
3import logging
4import re
5from typing import Any, Dict, List, Optional
7from ..document_types import Document
8from .client import JiraClient
10logger = logging.getLogger("mcp-jira")
13class WorklogMixin(JiraClient):
14 """Mixin for Jira worklog operations."""
16 def _parse_time_spent(self, time_spent: str) -> int:
17 """
18 Parse time spent string into seconds.
20 Args:
21 time_spent: Time spent string (e.g. 1h 30m, 1d, etc.)
23 Returns:
24 Time spent in seconds
25 """
26 # Base case for direct specification in seconds
27 if time_spent.endswith("s"):
28 try:
29 return int(time_spent[:-1])
30 except ValueError:
31 pass
33 total_seconds = 0
34 time_units = {
35 "w": 7 * 24 * 60 * 60, # weeks to seconds
36 "d": 24 * 60 * 60, # days to seconds
37 "h": 60 * 60, # hours to seconds
38 "m": 60, # minutes to seconds
39 }
41 # Regular expression to find time components like 1w, 2d, 3h, 4m
42 pattern = r"(\d+)([wdhm])"
43 matches = re.findall(pattern, time_spent)
45 for value, unit in matches:
46 # Convert value to int and multiply by the unit in seconds
47 seconds = int(value) * time_units[unit]
48 total_seconds += seconds
50 if total_seconds == 0:
51 # If we couldn't parse anything, try using the raw value
52 try:
53 return int(float(time_spent)) # Convert to float first, then to int
54 except ValueError:
55 # If all else fails, default to 60 seconds (1 minute)
56 logger.warning(
57 f"Could not parse time: {time_spent}, defaulting to 60 seconds"
58 )
59 return 60
61 return total_seconds
63 def add_worklog(
64 self,
65 issue_key: str,
66 time_spent: str,
67 comment: Optional[str] = None,
68 started: Optional[str] = None,
69 original_estimate: Optional[str] = None,
70 remaining_estimate: Optional[str] = None,
71 ) -> Dict[str, Any]:
72 """
73 Add a worklog to an issue with optional estimate updates.
75 Args:
76 issue_key: The issue key (e.g. 'PROJ-123')
77 time_spent: Time spent in Jira format (e.g., '1h 30m', '1d', '30m')
78 comment: Optional comment for the worklog (in Markdown format)
79 started: Optional start time in ISO format
80 (e.g. '2023-08-01T12:00:00.000+0000').
81 If not provided, current time will be used.
82 original_estimate: Optional original estimate in Jira format
83 (e.g., '1h 30m', '1d')
84 This will update the original estimate for the issue.
85 remaining_estimate: Optional remaining estimate in Jira format
86 (e.g., '1h', '30m')
87 This will update the remaining estimate for the issue.
89 Returns:
90 The created worklog details
92 Raises:
93 Exception: If there is an error adding the worklog
94 """
95 try:
96 # Convert time_spent string to seconds
97 time_spent_seconds = self._parse_time_spent(time_spent)
99 # Convert Markdown comment to Jira format if provided
100 if comment:
101 # Check if _markdown_to_jira is available (from CommentsMixin)
102 if hasattr(self, '_markdown_to_jira'):
103 comment = self._markdown_to_jira(comment)
105 # Step 1: Update original estimate if provided (separate API call)
106 original_estimate_updated = False
107 if original_estimate:
108 try:
109 fields = {"timetracking": {"originalEstimate": original_estimate}}
110 self.jira.edit_issue(issue_id_or_key=issue_key, fields=fields)
111 original_estimate_updated = True
112 logger.info(f"Updated original estimate for issue {issue_key}")
113 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
114 logger.error(
115 f"Failed to update original estimate for issue {issue_key}: "
116 f"{str(e)}"
117 )
118 # Continue with worklog creation even if estimate update fails
120 # Step 2: Prepare worklog data
121 worklog_data = {"timeSpentSeconds": time_spent_seconds}
122 if comment:
123 worklog_data["comment"] = comment
124 if started:
125 worklog_data["started"] = started
127 # Step 3: Prepare query parameters for remaining estimate
128 params = {}
129 remaining_estimate_updated = False
130 if remaining_estimate:
131 params["adjustEstimate"] = "new"
132 params["newEstimate"] = remaining_estimate
133 remaining_estimate_updated = True
135 # Step 4: Add the worklog with remaining estimate adjustment
136 base_url = self.jira.resource_url("issue")
137 url = f"{base_url}/{issue_key}/worklog"
138 result = self.jira.post(url, data=worklog_data, params=params)
140 # Format and return the result
141 return {
142 "id": result.get("id"),
143 "comment": self._clean_text(result.get("comment", "")),
144 "created": self._parse_date(result.get("created", "")),
145 "updated": self._parse_date(result.get("updated", "")),
146 "started": self._parse_date(result.get("started", "")),
147 "timeSpent": result.get("timeSpent", ""),
148 "timeSpentSeconds": result.get("timeSpentSeconds", 0),
149 "author": result.get("author", {}).get("displayName", "Unknown"),
150 "original_estimate_updated": original_estimate_updated,
151 "remaining_estimate_updated": remaining_estimate_updated,
152 }
153 except Exception as e:
154 logger.error(f"Error adding worklog to issue {issue_key}: {str(e)}")
155 raise Exception(f"Error adding worklog: {str(e)}") from e
157 def get_worklogs(self, issue_key: str) -> List[Dict[str, Any]]:
158 """
159 Get worklogs for an issue.
161 Args:
162 issue_key: The issue key (e.g. 'PROJ-123')
164 Returns:
165 List of worklog entries
167 Raises:
168 Exception: If there is an error getting worklogs
169 """
170 try:
171 result = self.jira.issue_get_worklog(issue_key)
173 # Process the worklogs
174 worklogs = []
175 for worklog in result.get("worklogs", []):
176 worklogs.append(
177 {
178 "id": worklog.get("id"),
179 "comment": self._clean_text(worklog.get("comment", "")),
180 "created": self._parse_date(worklog.get("created", "")),
181 "updated": self._parse_date(worklog.get("updated", "")),
182 "started": self._parse_date(worklog.get("started", "")),
183 "timeSpent": worklog.get("timeSpent", ""),
184 "timeSpentSeconds": worklog.get("timeSpentSeconds", 0),
185 "author": worklog.get("author", {}).get(
186 "displayName", "Unknown"
187 ),
188 }
189 )
191 return worklogs
192 except Exception as e:
193 logger.error(f"Error getting worklogs for issue {issue_key}: {str(e)}")
194 raise Exception(f"Error getting worklogs: {str(e)}") from e
196 def _parse_date(self, date_str: Optional[str]) -> str:
197 """
198 Parse a date string from ISO format to a more readable format.
200 This method is included for independence from other mixins,
201 but will use the implementation from other mixins if available.
203 Args:
204 date_str: Date string in ISO format or None
206 Returns:
207 Formatted date string or empty string if date_str is None
208 """
209 # If the date string is None, return empty string
210 if date_str is None:
211 return ""
213 # If another mixin has implemented this method, use that implementation
214 if hasattr(self, '_parse_date') and self.__class__._parse_date is not WorklogMixin._parse_date:
215 # This avoids infinite recursion by checking that the method is different
216 return super()._parse_date(date_str)
218 # Fallback implementation
219 try:
220 from datetime import datetime
221 date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
222 return date_obj.strftime("%Y-%m-%d")
223 except (ValueError, TypeError):
224 return date_str