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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-08 06:05 +0000
1from __future__ import annotations
3from datetime import datetime
4from typing import Any
6from .models import JiraAttachment, JiraComment, JiraIssue, JiraUser
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 )
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 ]
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 )
51 author = parse_user(raw_attachment.get("author"), required=True)
52 if author is None:
53 raise ValueError("Missing author in Jira attachment")
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
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 )
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 )
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>"
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 )
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 )
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 )
128 _require_dict_with_key(fields, "issuetype", "name")
129 _require_dict_with_key(fields, "status", "name")
130 _require_dict_with_key(fields, "project", "key")
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 )
139 # Parent key (optional)
140 parent = fields.get("parent")
141 parent_key = parent.get("key") if isinstance(parent, dict) else None
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 )
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 )
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 []
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 []
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 ]
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 ]
206 # Optional fields
207 priority_name = None
208 priority = fields.get("priority")
209 if isinstance(priority, dict):
210 priority_name = priority.get("name")
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 )
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 )