Coverage for src/mcp_atlassian/jira/worklog.py: 96%

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

2

3 import logging

4 import re

5 from typing import Any, Dict, List, Optional

6

7 from ..document_types import Document

8 from .client import JiraClient

9

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

11

12

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