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

153 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1from __future__ import annotations 

2 

3from datetime import datetime 

4from typing import Any 

5 

6from .config import JiraExtraField, JiraFieldType 

7from .models import JiraAttachment, JiraComment, JiraIssue, JiraIssueLink, JiraUser 

8 

9 

10def parse_user( 

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

12) -> JiraUser | None: 

13 """Parse a raw user from the Jira response into a JiraUser object.""" 

14 if not raw_user: 

15 if required: 

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

17 return None 

18 account_id = ( 

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

20 ) 

21 if not account_id: 

22 if required: 

23 raise ValueError( 

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

25 ) 

26 return None 

27 return JiraUser( 

28 account_id=account_id, 

29 display_name=( 

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

31 ), 

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

33 ) 

34 

35 

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

37 """Parse a raw attachment from the Jira response into a JiraAttachment object.""" 

38 required_keys = [ 

39 "id", 

40 "filename", 

41 "size", 

42 "mimeType", 

43 "content", 

44 "created", 

45 "author", 

46 ] 

47 

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

49 if missing_keys: 

50 raise ValueError( 

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

52 ) 

53 

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

55 if author is None: 

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

57 

58 created_raw = raw_attachment.get("created") 

59 try: 

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

61 except Exception as e: 

62 raise ValueError( 

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

64 ) from e 

65 

66 return JiraAttachment( 

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

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

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

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

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

72 created=created_dt, 

73 author=author, 

74 ) 

75 

76 

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

78 """Parse a raw comment from the Jira response into a JiraComment object.""" 

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

80 if author is None: 

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

82 

83 body = raw_comment.get("body") 

84 if body is None: 

85 body = "" 

86 elif not isinstance(body, str): 

87 if not isinstance(body, dict): 

88 raise ValueError(f"Unexpected body type in Jira comment: {type(body)}") 

89 body = adf_to_oneline_fulltext(body) 

90 

91 return JiraComment( 

92 id=raw_comment["id"], 

93 body=body, 

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

95 updated=( 

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

97 if "updated" in raw_comment 

98 else None 

99 ), 

100 author=author, 

101 ) 

102 

103 

104def _extract_extra_field_value( 

105 container: dict[str, Any], 

106 param_name: str, 

107 field_type: JiraFieldType, 

108 attr_name: str | None, 

109) -> Any: 

110 """Extract a single extra field value from the issue fields dict.""" 

111 raw = container.get(param_name) 

112 

113 if field_type in (JiraFieldType.SIMPLE, JiraFieldType.ARRAY): 

114 return raw 

115 

116 if field_type == JiraFieldType.OBJECT: 

117 if not isinstance(raw, dict): 

118 return None 

119 return raw.get(attr_name) 

120 

121 if field_type == JiraFieldType.ARRAY_OBJECT: 

122 if not isinstance(raw, list): 

123 return [] 

124 return [item.get(attr_name) for item in raw if isinstance(item, dict)] 

125 

126 return None 

127 

128 

129def _parse_linked_issues(raw_links: Any) -> list[JiraIssueLink]: 

130 """Parse Jira `issuelinks` entries into JiraIssueLink, keeping direction and type. 

131 

132 Each entry has either an `outwardIssue` or an `inwardIssue`, and a `type` object 

133 whose `outward`/`inward` string is the relationship phrase to use for that side 

134 (e.g. type.outward="clones", type.inward="is cloned by"). 

135 """ 

136 if not isinstance(raw_links, list): 

137 return [] 

138 

139 links: list[JiraIssueLink] = [] 

140 for link in raw_links: 

141 if not isinstance(link, dict): 

142 continue 

143 

144 link_type = link.get("type") 

145 type_name = link_type.get("name") if isinstance(link_type, dict) else None 

146 

147 outward = link.get("outwardIssue") 

148 inward = link.get("inwardIssue") 

149 

150 if isinstance(outward, dict) and outward.get("key"): 

151 links.append( 

152 JiraIssueLink( 

153 key=outward["key"], 

154 link_type=type_name, 

155 direction="outward", 

156 relation=( 

157 link_type.get("outward") 

158 if isinstance(link_type, dict) 

159 else None 

160 ), 

161 ) 

162 ) 

163 elif isinstance(inward, dict) and inward.get("key"): 

164 links.append( 

165 JiraIssueLink( 

166 key=inward["key"], 

167 link_type=type_name, 

168 direction="inward", 

169 relation=( 

170 link_type.get("inward") if isinstance(link_type, dict) else None 

171 ), 

172 ) 

173 ) 

174 

175 return links 

176 

177 

178def parse_issue( 

179 raw_issue: dict[str, Any], extra_fields: list[JiraExtraField] | None = None 

180) -> JiraIssue: 

181 """Parse a raw issue from the Jira response into a JiraIssue object.""" 

182 # Gather identifiers early for clearer error messages 

183 issue_id = raw_issue.get("id") 

184 issue_key = raw_issue.get("key") 

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

186 

187 # Validate presence of fields 

188 fields = raw_issue.get("fields") 

189 if not isinstance(fields, dict): 

190 raise ValueError( 

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

192 ) 

193 

194 # Validate required top-level keys within fields 

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

196 missing_simple = [ 

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

198 ] 

199 if missing_simple: 

200 raise ValueError( 

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

202 ) 

203 

204 # Validate nested required keys 

205 def _require_dict_with_key( 

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

207 ) -> None: 

208 value = container.get(outer_key) 

209 if ( 

210 not isinstance(value, dict) 

211 or inner_key not in value 

212 or value.get(inner_key) is None 

213 ): 

214 raise ValueError( 

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

216 ) 

217 

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

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

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

221 

222 # Parse reporter (required) 

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

224 if reporter is None: 

225 raise ValueError( 

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

227 ) 

228 

229 # Parent key (optional) 

230 parent = fields.get("parent") 

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

232 

233 # Timestamps with clear error messages 

234 created_raw = fields.get("created") 

235 updated_raw = fields.get("updated") 

236 try: 

237 created_dt = ( 

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

239 if isinstance(created_raw, str) 

240 else None 

241 ) 

242 except Exception as e: 

243 raise ValueError( 

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

245 ) from e 

246 if created_dt is None: 

247 raise ValueError( 

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

249 ) 

250 

251 try: 

252 updated_dt = ( 

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

254 if isinstance(updated_raw, str) 

255 else None 

256 ) 

257 except Exception as e: 

258 raise ValueError( 

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

260 ) from e 

261 if updated_dt is None: 

262 raise ValueError( 

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

264 ) 

265 

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

267 raw_attachments = fields.get("attachment") 

268 if raw_attachments is None: 

269 raw_attachments = fields.get("attachments") 

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

271 

272 # Safely extract comments from fields.comment.comments 

273 comment_field = fields.get("comment") 

274 if isinstance(comment_field, dict): 

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

276 else: 

277 raw_comments = [] 

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

279 

280 # Safely extract subtasks keys 

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

282 subtasks_keys = [ 

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

284 ] 

285 

286 # Safely extract linked issues, preserving direction and relationship type 

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

288 linked_issue_details = _parse_linked_issues(raw_links) 

289 

290 # Optional fields 

291 priority_name = None 

292 priority = fields.get("priority") 

293 if isinstance(priority, dict): 

294 priority_name = priority.get("name") 

295 

296 # Validate id/key presence for the model 

297 if not issue_id or not issue_key: 

298 raise ValueError( 

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

300 ) 

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

302 if description is not None and not isinstance(description, str): 

303 if not isinstance(description, dict): 

304 raise ValueError( 

305 f"Unexpected description type for Jira issue {issue_identifier}: {type(description)}" 

306 ) 

307 description = adf_to_oneline_fulltext(description) 

308 

309 jira_issue = JiraIssue( 

310 id=issue_id, 

311 key=issue_key, 

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

313 description=description, 

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

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

316 priority=priority_name, 

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

318 created=created_dt, 

319 updated=updated_dt, 

320 reporter=reporter, 

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

322 labels=( 

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

324 ), 

325 attachments=[ 

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

327 ], 

328 comments=[ 

329 parse_comment(comment) 

330 for comment in comments_list 

331 if isinstance(comment, dict) 

332 ], 

333 parent_key=parent_key, 

334 subtasks=subtasks_keys, 

335 linked_issues=[link.key for link in linked_issue_details], 

336 linked_issue_details=linked_issue_details, 

337 ) 

338 if extra_fields: 

339 for field in extra_fields: 

340 value = _extract_extra_field_value( 

341 fields, 

342 field.param_name, 

343 field.field_type, 

344 field.attr_name, 

345 ) 

346 setattr(jira_issue, field.name, value) 

347 return jira_issue 

348 

349 

350def adf_to_oneline_fulltext(node: dict) -> str: 

351 """Convert Jira ADF structure to a single-line plain text string.""" 

352 

353 def extract_text(obj: Any) -> list[str]: 

354 texts: list[str] = [] 

355 

356 if isinstance(obj, dict): 

357 if "text" in obj and isinstance(obj["text"], str): 

358 texts.append(obj["text"]) 

359 

360 for key, value in obj.items(): 

361 if key != "text": 

362 texts.extend(extract_text(value)) 

363 

364 elif isinstance(obj, list): 

365 for item in obj: 

366 texts.extend(extract_text(item)) 

367 

368 return texts 

369 

370 text_list = extract_text(node) 

371 

372 return " ".join(" ".join(text_list).split())