Coverage for src/qdrant_loader/connectors/jira/mappers.py: 74%

91 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-08 06:05 +0000

1from __future__ import annotations 

2 

3from datetime import datetime 

4from typing import Any 

5 

6from .models import JiraAttachment, JiraComment, JiraIssue, JiraUser 

7 

8 

9def parse_user( 

10 raw_user: dict[str, Any] | None, required: bool = False 

11) -> JiraUser | None: 

12 if not raw_user: 

13 if required: 

14 raise ValueError("User data is required but not provided") 

15 return None 

16 account_id = ( 

17 raw_user.get("accountId") or raw_user.get("name") or raw_user.get("key") 

18 ) 

19 if not account_id: 

20 if required: 

21 raise ValueError( 

22 "User data missing required identifier (accountId, name, or key)" 

23 ) 

24 return None 

25 return JiraUser( 

26 account_id=account_id, 

27 display_name=( 

28 raw_user.get("displayName") or raw_user.get("name") or account_id 

29 ), 

30 email_address=raw_user.get("emailAddress"), 

31 ) 

32 

33 

34def parse_attachment(raw_attachment: dict[str, Any]) -> JiraAttachment: 

35 required_keys = [ 

36 "id", 

37 "filename", 

38 "size", 

39 "mimeType", 

40 "content", 

41 "created", 

42 "author", 

43 ] 

44 

45 missing_keys = [key for key in required_keys if key not in raw_attachment] 

46 if missing_keys: 

47 raise ValueError( 

48 f"Attachment missing required keys: {', '.join(missing_keys)}. Received: {list(raw_attachment.keys())}" 

49 ) 

50 

51 author = parse_user(raw_attachment.get("author"), required=True) 

52 if author is None: 

53 raise ValueError("Missing author in Jira attachment") 

54 

55 created_raw = raw_attachment.get("created") 

56 try: 

57 created_dt = datetime.fromisoformat(created_raw.replace("Z", "+00:00")) 

58 except Exception as e: 

59 raise ValueError( 

60 f"Invalid created timestamp in attachment: {created_raw!r}" 

61 ) from e 

62 

63 return JiraAttachment( 

64 id=raw_attachment.get("id"), 

65 filename=raw_attachment.get("filename"), 

66 size=raw_attachment.get("size"), 

67 mime_type=raw_attachment.get("mimeType"), 

68 content_url=raw_attachment.get("content"), 

69 created=created_dt, 

70 author=author, 

71 ) 

72 

73 

74def parse_comment(raw_comment: dict[str, Any]) -> JiraComment: 

75 author = parse_user(raw_comment["author"], required=True) 

76 if author is None: 

77 raise ValueError("Missing author in Jira comment") 

78 return JiraComment( 

79 id=raw_comment["id"], 

80 body=raw_comment["body"], 

81 created=datetime.fromisoformat(raw_comment["created"].replace("Z", "+00:00")), 

82 updated=( 

83 datetime.fromisoformat(raw_comment["updated"].replace("Z", "+00:00")) 

84 if "updated" in raw_comment 

85 else None 

86 ), 

87 author=author, 

88 ) 

89 

90 

91def parse_issue(raw_issue: dict[str, Any]) -> JiraIssue: 

92 # Gather identifiers early for clearer error messages 

93 issue_id = raw_issue.get("id") 

94 issue_key = raw_issue.get("key") 

95 issue_identifier = issue_key or issue_id or "<unknown>" 

96 

97 # Validate presence of fields 

98 fields = raw_issue.get("fields") 

99 if not isinstance(fields, dict): 

100 raise ValueError( 

101 f"Jira issue {issue_identifier} missing required 'fields' object" 

102 ) 

103 

104 # Validate required top-level keys within fields 

105 required_field_keys = ["summary", "created", "updated", "reporter"] 

106 missing_simple = [ 

107 k for k in required_field_keys if k not in fields or fields.get(k) is None 

108 ] 

109 if missing_simple: 

110 raise ValueError( 

111 f"Jira issue {issue_identifier} missing required field(s): {', '.join(missing_simple)}" 

112 ) 

113 

114 # Validate nested required keys 

115 def _require_dict_with_key( 

116 container: dict[str, Any], outer_key: str, inner_key: str 

117 ) -> None: 

118 value = container.get(outer_key) 

119 if ( 

120 not isinstance(value, dict) 

121 or inner_key not in value 

122 or value.get(inner_key) is None 

123 ): 

124 raise ValueError( 

125 f"Jira issue {issue_identifier} missing required '{outer_key}.{inner_key}'" 

126 ) 

127 

128 _require_dict_with_key(fields, "issuetype", "name") 

129 _require_dict_with_key(fields, "status", "name") 

130 _require_dict_with_key(fields, "project", "key") 

131 

132 # Parse reporter (required) 

133 reporter = parse_user(fields.get("reporter"), required=True) 

134 if reporter is None: 

135 raise ValueError( 

136 f"Missing reporter for Jira issue {issue_identifier}: {fields.get('reporter')!r}" 

137 ) 

138 

139 # Parent key (optional) 

140 parent = fields.get("parent") 

141 parent_key = parent.get("key") if isinstance(parent, dict) else None 

142 

143 # Timestamps with clear error messages 

144 created_raw = fields.get("created") 

145 updated_raw = fields.get("updated") 

146 try: 

147 created_dt = ( 

148 datetime.fromisoformat(created_raw.replace("Z", "+00:00")) 

149 if isinstance(created_raw, str) 

150 else None 

151 ) 

152 except Exception as e: 

153 raise ValueError( 

154 f"Invalid 'created' timestamp for Jira issue {issue_identifier}: {created_raw!r}" 

155 ) from e 

156 if created_dt is None: 

157 raise ValueError( 

158 f"Jira issue {issue_identifier} missing valid 'created' timestamp" 

159 ) 

160 

161 try: 

162 updated_dt = ( 

163 datetime.fromisoformat(updated_raw.replace("Z", "+00:00")) 

164 if isinstance(updated_raw, str) 

165 else None 

166 ) 

167 except Exception as e: 

168 raise ValueError( 

169 f"Invalid 'updated' timestamp for Jira issue {issue_identifier}: {updated_raw!r}" 

170 ) from e 

171 if updated_dt is None: 

172 raise ValueError( 

173 f"Jira issue {issue_identifier} missing valid 'updated' timestamp" 

174 ) 

175 

176 # Safely extract attachments: support both 'attachment' and 'attachments' 

177 raw_attachments = fields.get("attachment") 

178 if raw_attachments is None: 

179 raw_attachments = fields.get("attachments") 

180 attachments_list = raw_attachments if isinstance(raw_attachments, list) else [] 

181 

182 # Safely extract comments from fields.comment.comments 

183 comment_field = fields.get("comment") 

184 if isinstance(comment_field, dict): 

185 raw_comments = comment_field.get("comments", []) 

186 else: 

187 raw_comments = [] 

188 comments_list = raw_comments if isinstance(raw_comments, list) else [] 

189 

190 # Safely extract subtasks keys 

191 raw_subtasks = fields.get("subtasks", []) 

192 subtasks_keys = [ 

193 st.get("key") for st in raw_subtasks if isinstance(st, dict) and st.get("key") 

194 ] 

195 

196 # Safely extract linked issues (outward only as before) 

197 raw_links = fields.get("issuelinks", []) 

198 linked_outward = [ 

199 link.get("outwardIssue", {}).get("key") 

200 for link in raw_links 

201 if isinstance(link, dict) 

202 and isinstance(link.get("outwardIssue"), dict) 

203 and link.get("outwardIssue", {}).get("key") 

204 ] 

205 

206 # Optional fields 

207 priority_name = None 

208 priority = fields.get("priority") 

209 if isinstance(priority, dict): 

210 priority_name = priority.get("name") 

211 

212 # Validate id/key presence for the model 

213 if not issue_id or not issue_key: 

214 raise ValueError( 

215 f"Jira issue missing required top-level identifier(s): id={issue_id!r}, key={issue_key!r}" 

216 ) 

217 

218 return JiraIssue( 

219 id=issue_id, 

220 key=issue_key, 

221 summary=str(fields.get("summary")), 

222 description=fields.get("description"), 

223 issue_type=fields.get("issuetype", {}).get("name"), 

224 status=fields.get("status", {}).get("name"), 

225 priority=priority_name, 

226 project_key=fields.get("project", {}).get("key"), 

227 created=created_dt, 

228 updated=updated_dt, 

229 reporter=reporter, 

230 assignee=parse_user(fields.get("assignee")), 

231 labels=( 

232 fields.get("labels", []) if isinstance(fields.get("labels"), list) else [] 

233 ), 

234 attachments=[ 

235 parse_attachment(att) for att in attachments_list if isinstance(att, dict) 

236 ], 

237 comments=[ 

238 parse_comment(comment) 

239 for comment in comments_list 

240 if isinstance(comment, dict) 

241 ], 

242 parent_key=parent_key, 

243 subtasks=subtasks_keys, 

244 linked_issues=[key for key in linked_outward if key], 

245 )