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
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1from __future__ import annotations
3from datetime import datetime
4from typing import Any
6from .config import JiraExtraField, JiraFieldType
7from .models import JiraAttachment, JiraComment, JiraIssue, JiraIssueLink, JiraUser
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 )
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 ]
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 )
54 author = parse_user(raw_attachment.get("author"), required=True)
55 if author is None:
56 raise ValueError("Missing author in Jira attachment")
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
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 )
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")
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)
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 )
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)
113 if field_type in (JiraFieldType.SIMPLE, JiraFieldType.ARRAY):
114 return raw
116 if field_type == JiraFieldType.OBJECT:
117 if not isinstance(raw, dict):
118 return None
119 return raw.get(attr_name)
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)]
126 return None
129def _parse_linked_issues(raw_links: Any) -> list[JiraIssueLink]:
130 """Parse Jira `issuelinks` entries into JiraIssueLink, keeping direction and type.
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 []
139 links: list[JiraIssueLink] = []
140 for link in raw_links:
141 if not isinstance(link, dict):
142 continue
144 link_type = link.get("type")
145 type_name = link_type.get("name") if isinstance(link_type, dict) else None
147 outward = link.get("outwardIssue")
148 inward = link.get("inwardIssue")
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 )
175 return links
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>"
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 )
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 )
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 )
218 _require_dict_with_key(fields, "issuetype", "name")
219 _require_dict_with_key(fields, "status", "name")
220 _require_dict_with_key(fields, "project", "key")
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 )
229 # Parent key (optional)
230 parent = fields.get("parent")
231 parent_key = parent.get("key") if isinstance(parent, dict) else None
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 )
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 )
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 []
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 []
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 ]
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)
290 # Optional fields
291 priority_name = None
292 priority = fields.get("priority")
293 if isinstance(priority, dict):
294 priority_name = priority.get("name")
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)
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
350def adf_to_oneline_fulltext(node: dict) -> str:
351 """Convert Jira ADF structure to a single-line plain text string."""
353 def extract_text(obj: Any) -> list[str]:
354 texts: list[str] = []
356 if isinstance(obj, dict):
357 if "text" in obj and isinstance(obj["text"], str):
358 texts.append(obj["text"])
360 for key, value in obj.items():
361 if key != "text":
362 texts.extend(extract_text(value))
364 elif isinstance(obj, list):
365 for item in obj:
366 texts.extend(extract_text(item))
368 return texts
370 text_list = extract_text(node)
372 return " ".join(" ".join(text_list).split())