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

1"""Module for Jira worklog operations.""" 

2 

3import logging 

4import re 

5from typing import Any, Dict, List, Optional 

6 

7from ..document_types import Document 

8from .client import JiraClient 

9 

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

11 

12 

13class WorklogMixin(JiraClient): 

14 """Mixin for Jira worklog operations.""" 

15 

16 def _parse_time_spent(self, time_spent: str) -> int: 

17 """ 

18 Parse time spent string into seconds. 

19 

20 Args: 

21 time_spent: Time spent string (e.g. 1h 30m, 1d, etc.) 

22 

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 

32 

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 } 

40 

41 # Regular expression to find time components like 1w, 2d, 3h, 4m 

42 pattern = r"(\d+)([wdhm])" 

43 matches = re.findall(pattern, time_spent) 

44 

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 

49 

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 

60 

61 return total_seconds 

62 

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. 

74 

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. 

88 

89 Returns: 

90 The created worklog details 

91 

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) 

98 

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) 

104 

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 

119 

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 

126 

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 

134 

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) 

139 

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 

156 

157 def get_worklogs(self, issue_key: str) -> List[Dict[str, Any]]: 

158 """ 

159 Get worklogs for an issue. 

160 

161 Args: 

162 issue_key: The issue key (e.g. 'PROJ-123') 

163 

164 Returns: 

165 List of worklog entries 

166 

167 Raises: 

168 Exception: If there is an error getting worklogs 

169 """ 

170 try: 

171 result = self.jira.issue_get_worklog(issue_key) 

172 

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 ) 

190 

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 

195 

196 def _parse_date(self, date_str: Optional[str]) -> str: 

197 """ 

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

199  

200 This method is included for independence from other mixins, 

201 but will use the implementation from other mixins if available. 

202  

203 Args: 

204 date_str: Date string in ISO format or None 

205  

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 "" 

212 

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) 

217 

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