sqlmirror 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/mapper.py ADDED
@@ -0,0 +1,774 @@
1
+ """Convert Jira Cloud REST (v2) data into Data-Center-shaped table rows.
2
+
3
+ Input formats (the contract shared by the Forge app and the CLI):
4
+
5
+ Issue snapshot
6
+ {"v": 1, "type": "issue", "id": "10001", "fetchedAt": "...Z",
7
+ "issue": {...v2 issue JSON, fields included...},
8
+ "changelog": [...all histories...],
9
+ "comments": [...all comments...],
10
+ "worklogs": [...all worklogs...]}
11
+
12
+ Tombstone
13
+ {"v": 1, "type": "deleted", "id": "10001", "deletedAt": "...Z"}
14
+
15
+ Metadata
16
+ {"v": 1, "type": "metadata", "fetchedAt": "...Z", "projects": [...],
17
+ "statuses": [...], "priorities": [...], "resolutions": [...],
18
+ "issueTypes": [...], "fields": [...], "issueLinkTypes": [...],
19
+ "projectCategories": [...], "versions": {projectId: [...]},
20
+ "components": {projectId: [...]}}
21
+
22
+ The mapper is pure: it returns rows and never touches the database.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from collections import defaultdict
28
+ from typing import Any, Iterable
29
+
30
+ from .util import parse_ts, stable_id, to_epoch_ms, to_float, to_int
31
+
32
+ Rows = dict[str, list[dict[str, Any]]]
33
+
34
+ CF_PREFIX = "customfield_"
35
+
36
+ # Synthetic link types for parent/child relationships. Cloud models these as a
37
+ # "parent" field; Data Center models them as issue links. Ids are far above the
38
+ # range Cloud uses, so they never collide with real link types.
39
+ SUBTASK_LINKTYPE_ID = 9_000_000_000_000_001
40
+ EPIC_LINKTYPE_ID = 9_000_000_000_000_002
41
+ PARENT_LINKTYPE_ID = 9_000_000_000_000_003
42
+ SYNTHETIC_LINKTYPES = [
43
+ {"id": SUBTASK_LINKTYPE_ID, "linkname": "jira_subtask_link", "inward": "jira_subtask_inward",
44
+ "outward": "jira_subtask_outward", "pstyle": "jira_subtask"},
45
+ {"id": EPIC_LINKTYPE_ID, "linkname": "Epic-Story Link", "inward": "has Epic",
46
+ "outward": "is Epic of", "pstyle": "jira_gh_epic_story"},
47
+ {"id": PARENT_LINKTYPE_ID, "linkname": "Parent-Child Link", "inward": "has parent",
48
+ "outward": "is parent of", "pstyle": "sqlmirror_parent_child"},
49
+ ]
50
+ SYNTHETIC_LINKTYPE_IDS = [t["id"] for t in SYNTHETIC_LINKTYPES]
51
+
52
+ _SPRINT_STR = re.compile(r"(\w+)=([^,\]]*)")
53
+
54
+
55
+ def cf_number(field_id: str) -> int | None:
56
+ if field_id and field_id.startswith(CF_PREFIX):
57
+ return to_int(field_id[len(CF_PREFIX):])
58
+ return None
59
+
60
+
61
+ def adf_to_text(node: Any) -> str | None:
62
+ """Flatten Atlassian Document Format to plain text (used only if a v3 body sneaks in)."""
63
+ if node is None:
64
+ return None
65
+ if isinstance(node, str):
66
+ return node
67
+ if not isinstance(node, dict):
68
+ return str(node)
69
+ parts: list[str] = []
70
+
71
+ def walk(n: Any) -> None:
72
+ if isinstance(n, dict):
73
+ if n.get("type") == "text":
74
+ parts.append(n.get("text", ""))
75
+ elif n.get("type") == "hardBreak":
76
+ parts.append("\n")
77
+ for child in n.get("content", []) or []:
78
+ walk(child)
79
+ if n.get("type") in ("paragraph", "heading", "listItem", "codeBlock", "blockquote"):
80
+ parts.append("\n")
81
+ elif isinstance(n, list):
82
+ for child in n:
83
+ walk(child)
84
+
85
+ walk(node)
86
+ return "".join(parts).strip("\n")
87
+
88
+
89
+ def _text(value: Any) -> str | None:
90
+ if value is None:
91
+ return None
92
+ if isinstance(value, (dict, list)):
93
+ return adf_to_text(value)
94
+ return str(value)
95
+
96
+
97
+ class RowSet:
98
+ """Accumulates rows per table, de-duplicating upsert rows by primary key."""
99
+
100
+ def __init__(self) -> None:
101
+ self.rows: dict[str, list[dict[str, Any]]] = defaultdict(list)
102
+ self._seen: dict[str, dict[Any, dict[str, Any]]] = defaultdict(dict)
103
+
104
+ def add(self, table: str, row: dict[str, Any]) -> None:
105
+ self.rows[table].append(row)
106
+
107
+ def upsert(self, table: str, key: Any, row: dict[str, Any]) -> None:
108
+ """Merge rows for the same key: later non-null values win."""
109
+ existing = self._seen[table].get(key)
110
+ if existing is None:
111
+ self._seen[table][key] = row
112
+ self.rows[table].append(row)
113
+ else:
114
+ for k, v in row.items():
115
+ if v is not None:
116
+ existing[k] = v
117
+
118
+ def as_dict(self) -> Rows:
119
+ return dict(self.rows)
120
+
121
+
122
+ class Mapper:
123
+ """Holds what we know about custom fields and turns API data into rows."""
124
+
125
+ def __init__(self) -> None:
126
+ # customfield number -> (type key, name)
127
+ self.custom_fields: dict[int, tuple[str | None, str | None]] = {}
128
+
129
+ # ------------------------------------------------------------ field registry
130
+ def register_fields(self, fields: Iterable[dict[str, Any]]) -> None:
131
+ for f in fields:
132
+ num = cf_number(f.get("id", ""))
133
+ if num is None:
134
+ continue
135
+ schema = f.get("schema") or {}
136
+ self.custom_fields[num] = (schema.get("custom"), f.get("name"))
137
+
138
+ def register_custom_field(self, num: int, typekey: str | None, name: str | None) -> None:
139
+ self.custom_fields[num] = (typekey, name)
140
+
141
+ # ------------------------------------------------------------------ users
142
+ @staticmethod
143
+ def user_key(user: Any) -> str | None:
144
+ if isinstance(user, dict):
145
+ return user.get("accountId") or user.get("key") or user.get("name")
146
+ if isinstance(user, str) and user:
147
+ return user
148
+ return None
149
+
150
+ def _add_user(self, rs: RowSet, user: Any) -> str | None:
151
+ key = self.user_key(user)
152
+ if not key:
153
+ return None
154
+ if not isinstance(user, dict):
155
+ user = {"accountId": key}
156
+ uid = stable_id("user", key)
157
+ display = user.get("displayName")
158
+ email = user.get("emailAddress")
159
+ first = last = None
160
+ if display:
161
+ bits = display.split(" ", 1)
162
+ first = bits[0]
163
+ last = bits[1] if len(bits) > 1 else None
164
+ active = user.get("active")
165
+ rs.upsert("app_user", uid, {"id": uid, "user_key": key, "lower_user_name": key.lower()})
166
+ rs.upsert(
167
+ "cwd_user",
168
+ uid,
169
+ {
170
+ "id": uid,
171
+ "directory_id": 1,
172
+ "user_name": key,
173
+ "lower_user_name": key.lower(),
174
+ "active": None if active is None else (1 if active else 0),
175
+ "created_date": None,
176
+ "updated_date": None,
177
+ "first_name": first,
178
+ "lower_first_name": first.lower() if first else None,
179
+ "last_name": last,
180
+ "lower_last_name": last.lower() if last else None,
181
+ "display_name": display,
182
+ "lower_display_name": display.lower() if display else None,
183
+ "email_address": email,
184
+ "lower_email_address": email.lower() if email else None,
185
+ "external_id": key,
186
+ },
187
+ )
188
+ return key
189
+
190
+ # --------------------------------------------------------- config objects
191
+ @staticmethod
192
+ def _status_row(s: dict[str, Any], seq: int | None = None) -> dict[str, Any]:
193
+ cat = s.get("statusCategory") or {}
194
+ return {
195
+ "id": str(s.get("id")),
196
+ "sequence": seq,
197
+ "pname": s.get("name"),
198
+ "description": s.get("description") or None,
199
+ "iconurl": s.get("iconUrl") or None,
200
+ "statuscategory": to_int(cat.get("id")),
201
+ }
202
+
203
+ @staticmethod
204
+ def _issuetype_row(t: dict[str, Any], seq: int | None = None) -> dict[str, Any]:
205
+ return {
206
+ "id": str(t.get("id")),
207
+ "sequence": seq,
208
+ "pname": t.get("name"),
209
+ "pstyle": "jira_subtask" if t.get("subtask") else None,
210
+ "description": t.get("description") or None,
211
+ "iconurl": t.get("iconUrl") or None,
212
+ "avatar": to_int(t.get("avatarId")),
213
+ }
214
+
215
+ @staticmethod
216
+ def _priority_row(p: dict[str, Any], seq: int | None = None) -> dict[str, Any]:
217
+ return {
218
+ "id": str(p.get("id")),
219
+ "sequence": seq,
220
+ "pname": p.get("name"),
221
+ "description": p.get("description") or None,
222
+ "iconurl": p.get("iconUrl") or None,
223
+ "status_color": p.get("statusColor") or None,
224
+ }
225
+
226
+ @staticmethod
227
+ def _resolution_row(r: dict[str, Any], seq: int | None = None) -> dict[str, Any]:
228
+ return {
229
+ "id": str(r.get("id")),
230
+ "sequence": seq,
231
+ "pname": r.get("name"),
232
+ "description": r.get("description") or None,
233
+ "iconurl": r.get("iconUrl") or None,
234
+ }
235
+
236
+ def _project_row(self, rs: RowSet, p: dict[str, Any]) -> int | None:
237
+ pid = to_int(p.get("id"))
238
+ if pid is None:
239
+ return None
240
+ lead = self._add_user(rs, p.get("lead")) if p.get("lead") else None
241
+ rs.upsert(
242
+ "project",
243
+ pid,
244
+ {
245
+ "id": pid,
246
+ "pname": p.get("name"),
247
+ "url": p.get("url") or None,
248
+ "lead": lead,
249
+ "description": _text(p.get("description")) or None,
250
+ "pkey": p.get("key"),
251
+ "pcounter": None,
252
+ "assigneetype": None,
253
+ "avatar": None,
254
+ "originalkey": p.get("key"),
255
+ "projecttype": p.get("projectTypeKey"),
256
+ },
257
+ )
258
+ if p.get("key"):
259
+ rs.upsert("project_key", pid, {"id": pid, "project_id": pid, "project_key": p.get("key")})
260
+ cat = p.get("projectCategory")
261
+ if isinstance(cat, dict) and cat.get("id"):
262
+ cid = to_int(cat.get("id"))
263
+ rs.upsert("projectcategory", cid, {"id": cid, "cname": cat.get("name"),
264
+ "description": cat.get("description") or None})
265
+ rs.add("project_category_assoc", {"project": pid, "category": cid})
266
+ return pid
267
+
268
+ def _version_row(self, rs: RowSet, v: dict[str, Any], project_id: int | None, seq: int | None = None) -> int | None:
269
+ vid = to_int(v.get("id"))
270
+ if vid is None:
271
+ return None
272
+ rs.upsert(
273
+ "projectversion",
274
+ vid,
275
+ {
276
+ "id": vid,
277
+ "project": to_int(v.get("projectId")) or project_id,
278
+ "vname": v.get("name"),
279
+ "description": v.get("description") or None,
280
+ "sequence": seq,
281
+ "released": "true" if v.get("released") else None,
282
+ "archived": "true" if v.get("archived") else None,
283
+ "url": None,
284
+ "startdate": parse_ts(v.get("startDate")),
285
+ "releasedate": parse_ts(v.get("releaseDate")),
286
+ },
287
+ )
288
+ return vid
289
+
290
+ def _component_row(self, rs: RowSet, c: dict[str, Any], project_id: int | None) -> int | None:
291
+ cid = to_int(c.get("id"))
292
+ if cid is None:
293
+ return None
294
+ lead = self._add_user(rs, c.get("lead")) if c.get("lead") else None
295
+ rs.upsert(
296
+ "component",
297
+ cid,
298
+ {
299
+ "id": cid,
300
+ "project": to_int(c.get("projectId")) or project_id,
301
+ "cname": c.get("name"),
302
+ "description": c.get("description") or None,
303
+ "url": None,
304
+ "lead": lead,
305
+ "assigneetype": None,
306
+ "archived": "Y" if c.get("archived") else "N",
307
+ "deleted": "N",
308
+ },
309
+ )
310
+ return cid
311
+
312
+ def _linktype_row(self, rs: RowSet, t: dict[str, Any]) -> int | None:
313
+ tid = to_int(t.get("id"))
314
+ if tid is None:
315
+ return None
316
+ rs.upsert("issuelinktype", tid, {"id": tid, "linkname": t.get("name"), "inward": t.get("inward"),
317
+ "outward": t.get("outward"), "pstyle": None})
318
+ return tid
319
+
320
+ # ------------------------------------------------------------- metadata
321
+ def map_metadata(self, meta: dict[str, Any]) -> Rows:
322
+ rs = RowSet()
323
+ self.register_fields(meta.get("fields") or [])
324
+
325
+ for cat in meta.get("projectCategories") or []:
326
+ cid = to_int(cat.get("id"))
327
+ if cid is not None:
328
+ rs.upsert("projectcategory", cid, {"id": cid, "cname": cat.get("name"),
329
+ "description": cat.get("description") or None})
330
+ for p in meta.get("projects") or []:
331
+ self._project_row(rs, p)
332
+ for i, s in enumerate(meta.get("statuses") or []):
333
+ rs.upsert("issuestatus", str(s.get("id")), self._status_row(s, i + 1))
334
+ for i, t in enumerate(meta.get("issueTypes") or []):
335
+ rs.upsert("issuetype", str(t.get("id")), self._issuetype_row(t, i + 1))
336
+ for i, p in enumerate(meta.get("priorities") or []):
337
+ rs.upsert("priority", str(p.get("id")), self._priority_row(p, i + 1))
338
+ for i, r in enumerate(meta.get("resolutions") or []):
339
+ rs.upsert("resolution", str(r.get("id")), self._resolution_row(r, i + 1))
340
+ for t in meta.get("issueLinkTypes") or []:
341
+ self._linktype_row(rs, t)
342
+ for t in SYNTHETIC_LINKTYPES:
343
+ rs.upsert("issuelinktype", t["id"], dict(t))
344
+ for pid, versions in (meta.get("versions") or {}).items():
345
+ for i, v in enumerate(versions or []):
346
+ self._version_row(rs, v, to_int(pid), i + 1)
347
+ for pid, comps in (meta.get("components") or {}).items():
348
+ for c in comps or []:
349
+ self._component_row(rs, c, to_int(pid))
350
+ for f in meta.get("fields") or []:
351
+ num = cf_number(f.get("id", ""))
352
+ if num is None:
353
+ continue
354
+ schema = f.get("schema") or {}
355
+ rs.upsert(
356
+ "customfield",
357
+ num,
358
+ {
359
+ "id": num,
360
+ "cfkey": f.get("id"),
361
+ "customfieldtypekey": schema.get("custom"),
362
+ "customfieldsearcherkey": None,
363
+ "cfname": f.get("name"),
364
+ "description": f.get("description") or None,
365
+ "defaultvalue": None,
366
+ "fieldtype": None,
367
+ "project": None,
368
+ "issuetype": None,
369
+ },
370
+ )
371
+ return rs.as_dict()
372
+
373
+ # ---------------------------------------------------------------- issues
374
+ def map_issue(self, snap: dict[str, Any]) -> Rows:
375
+ rs = RowSet()
376
+ issue = snap.get("issue") or {}
377
+ f = issue.get("fields") or {}
378
+ iid = to_int(issue.get("id") or snap.get("id"))
379
+ if iid is None:
380
+ return rs.as_dict()
381
+
382
+ key = issue.get("key")
383
+ issuenum = to_int(key.rsplit("-", 1)[1]) if key and "-" in key else None
384
+
385
+ project = f.get("project") or {}
386
+ project_id = self._project_row(rs, project) if project else None
387
+
388
+ status = f.get("status") or {}
389
+ if status.get("id") is not None:
390
+ rs.upsert("issuestatus", str(status["id"]), self._status_row(status))
391
+ itype = f.get("issuetype") or {}
392
+ if itype.get("id") is not None:
393
+ rs.upsert("issuetype", str(itype["id"]), self._issuetype_row(itype))
394
+ prio = f.get("priority") or {}
395
+ if prio.get("id") is not None:
396
+ rs.upsert("priority", str(prio["id"]), self._priority_row(prio))
397
+ res = f.get("resolution") or {}
398
+ if res.get("id") is not None:
399
+ rs.upsert("resolution", str(res["id"]), self._resolution_row(res))
400
+
401
+ reporter = self._add_user(rs, f.get("reporter"))
402
+ assignee = self._add_user(rs, f.get("assignee"))
403
+ creator = self._add_user(rs, f.get("creator"))
404
+
405
+ votes = f.get("votes") or {}
406
+ watches = f.get("watches") or {}
407
+ security = f.get("security") or {}
408
+ rs.add(
409
+ "jiraissue",
410
+ {
411
+ "id": iid,
412
+ "pkey": key,
413
+ "issuenum": issuenum,
414
+ "project": project_id,
415
+ "reporter": reporter,
416
+ "assignee": assignee,
417
+ "creator": creator,
418
+ "issuetype": str(itype["id"]) if itype.get("id") is not None else None,
419
+ "summary": f.get("summary"),
420
+ "description": _text(f.get("description")),
421
+ "environment": _text(f.get("environment")),
422
+ "priority": str(prio["id"]) if prio.get("id") is not None else None,
423
+ "resolution": str(res["id"]) if res.get("id") is not None else None,
424
+ "issuestatus": str(status["id"]) if status.get("id") is not None else None,
425
+ "created": parse_ts(f.get("created")),
426
+ "updated": parse_ts(f.get("updated")),
427
+ "duedate": parse_ts(f.get("duedate")),
428
+ "resolutiondate": parse_ts(f.get("resolutiondate")),
429
+ "votes": to_int(votes.get("votes")) if isinstance(votes, dict) else None,
430
+ "watches": to_int(watches.get("watchCount")) if isinstance(watches, dict) else None,
431
+ "timeoriginalestimate": to_int(f.get("timeoriginalestimate")),
432
+ "timeestimate": to_int(f.get("timeestimate")),
433
+ "timespent": to_int(f.get("timespent")),
434
+ "workflow_id": None,
435
+ "security": to_int(security.get("id")) if isinstance(security, dict) else None,
436
+ "fixfor": None,
437
+ "component": None,
438
+ "archived": "N",
439
+ "archivedby": None,
440
+ "archiveddate": None,
441
+ },
442
+ )
443
+
444
+ # Versions and components: config rows plus issue associations.
445
+ for assoc, field_name in (("IssueFixVersion", "fixVersions"), ("IssueVersion", "versions")):
446
+ for v in f.get(field_name) or []:
447
+ vid = self._version_row(rs, v, project_id)
448
+ if vid is not None:
449
+ rs.upsert("nodeassociation", (iid, vid, assoc), {
450
+ "source_node_id": iid, "source_node_entity": "Issue", "sink_node_id": vid,
451
+ "sink_node_entity": "Version", "association_type": assoc, "sequence": None})
452
+ for c in f.get("components") or []:
453
+ cid = self._component_row(rs, c, project_id)
454
+ if cid is not None:
455
+ rs.upsert("nodeassociation", (iid, cid, "IssueComponent"), {
456
+ "source_node_id": iid, "source_node_entity": "Issue", "sink_node_id": cid,
457
+ "sink_node_entity": "Component", "association_type": "IssueComponent", "sequence": None})
458
+
459
+ for label in f.get("labels") or []:
460
+ rs.upsert("label", ("sys", label), {"id": stable_id("label", iid, "", label), "fieldid": None,
461
+ "issue": iid, "label": label})
462
+
463
+ self._map_links(rs, iid, f)
464
+
465
+ for a in f.get("attachment") or []:
466
+ aid = to_int(a.get("id"))
467
+ if aid is None:
468
+ continue
469
+ rs.upsert("fileattachment", aid, {
470
+ "id": aid,
471
+ "issueid": iid,
472
+ "mimetype": a.get("mimeType"),
473
+ "filename": a.get("filename"),
474
+ "created": parse_ts(a.get("created")),
475
+ "filesize": to_int(a.get("size")),
476
+ "author": self._add_user(rs, a.get("author")),
477
+ "zip": None,
478
+ "thumbnailable": 1 if a.get("thumbnail") else 0,
479
+ })
480
+
481
+ for field_id, value in f.items():
482
+ num = cf_number(field_id)
483
+ if num is None or value is None:
484
+ continue
485
+ self._map_custom_value(rs, iid, num, value)
486
+
487
+ self._map_changelog(rs, iid, snap.get("changelog"))
488
+ self._map_comments(rs, iid, snap.get("comments"))
489
+ self._map_worklogs(rs, iid, snap.get("worklogs"))
490
+ return rs.as_dict()
491
+
492
+ # ------------------------------------------------------------------ links
493
+ def _map_links(self, rs: RowSet, iid: int, f: dict[str, Any]) -> None:
494
+ for link in f.get("issuelinks") or []:
495
+ lid = to_int(link.get("id"))
496
+ ltype = link.get("type") or {}
497
+ tid = self._linktype_row(rs, ltype) if ltype else None
498
+ if lid is None or tid is None:
499
+ continue
500
+ if link.get("outwardIssue"):
501
+ src, dst = iid, to_int(link["outwardIssue"].get("id"))
502
+ elif link.get("inwardIssue"):
503
+ src, dst = to_int(link["inwardIssue"].get("id")), iid
504
+ else:
505
+ continue
506
+ if src is None or dst is None:
507
+ continue
508
+ rs.upsert("issuelink", lid, {"id": lid, "linktype": tid, "source": src, "destination": dst,
509
+ "sequence": None})
510
+
511
+ # Parent/child relationships are owned by the child's snapshot.
512
+ parent = f.get("parent") or {}
513
+ pid = to_int(parent.get("id")) if parent else None
514
+ if pid is None:
515
+ return
516
+ own_type = f.get("issuetype") or {}
517
+ parent_type = ((parent.get("fields") or {}).get("issuetype")) or {}
518
+ if own_type.get("subtask"):
519
+ tid, tag = SUBTASK_LINKTYPE_ID, "subtask"
520
+ else:
521
+ parent_level = to_int(parent_type.get("hierarchyLevel"))
522
+ own_level = to_int(own_type.get("hierarchyLevel")) or 0
523
+ if parent_level is None:
524
+ parent_level = own_level + 1
525
+ if parent_level == 1:
526
+ tid, tag = EPIC_LINKTYPE_ID, "epic"
527
+ else:
528
+ tid, tag = PARENT_LINKTYPE_ID, "parent"
529
+ lid = stable_id(tag, pid, iid)
530
+ rs.upsert("issuelink", lid, {"id": lid, "linktype": tid, "source": pid, "destination": iid,
531
+ "sequence": None})
532
+
533
+ # ---------------------------------------------------------- custom fields
534
+ def _cfv(self, rs: RowSet, iid: int, num: int, idx: int, *, parentkey: str | None = None,
535
+ stringvalue: Any = None, numbervalue: Any = None, textvalue: Any = None,
536
+ datevalue: Any = None) -> None:
537
+ rs.add("customfieldvalue", {
538
+ "id": stable_id("cfv", iid, num, idx, parentkey or ""),
539
+ "issue": iid,
540
+ "customfield": num,
541
+ "updated": None,
542
+ "parentkey": parentkey,
543
+ "stringvalue": None if stringvalue is None else str(stringvalue),
544
+ "numbervalue": numbervalue,
545
+ "textvalue": textvalue,
546
+ "datevalue": datevalue,
547
+ "valuetype": None,
548
+ })
549
+
550
+ def _option(self, rs: RowSet, num: int, opt: dict[str, Any], parent_id: int | None = None) -> int | None:
551
+ oid = to_int(opt.get("id"))
552
+ if oid is None:
553
+ return None
554
+ rs.upsert("customfieldoption", oid, {
555
+ "id": oid, "customfield": num, "customfieldconfig": None, "parentoptionid": parent_id,
556
+ "sequence": None, "customvalue": opt.get("value") if opt.get("value") is not None else opt.get("name"),
557
+ "optiontype": None, "disabled": "Y" if opt.get("disabled") else "N",
558
+ })
559
+ return oid
560
+
561
+ def _map_custom_value(self, rs: RowSet, iid: int, num: int, value: Any) -> None:
562
+ typekey, _name = self.custom_fields.get(num, (None, None))
563
+ suffix = (typekey or "").rsplit(":", 1)[-1]
564
+ values = value if isinstance(value, list) else [value]
565
+
566
+ if suffix == "gh-sprint":
567
+ for i, s in enumerate(values):
568
+ sprint = self._sprint(rs, s)
569
+ if sprint is not None:
570
+ self._cfv(rs, iid, num, i, stringvalue=sprint)
571
+ return
572
+ if suffix in ("gh-epic-link",):
573
+ return # represented by the Epic-Story Link issue link
574
+ if suffix == "labels":
575
+ for v in values:
576
+ if v:
577
+ rs.upsert("label", (num, v), {"id": stable_id("label", iid, num, v), "fieldid": num,
578
+ "issue": iid, "label": str(v)})
579
+ return
580
+ if suffix == "cascadingselect" and isinstance(value, dict):
581
+ parent_id = self._option(rs, num, value)
582
+ if parent_id is not None:
583
+ self._cfv(rs, iid, num, 0, stringvalue=parent_id)
584
+ child = value.get("child")
585
+ if isinstance(child, dict):
586
+ child_id = self._option(rs, num, child, parent_id)
587
+ if child_id is not None:
588
+ self._cfv(rs, iid, num, 0, parentkey="1", stringvalue=child_id)
589
+ return
590
+ if suffix in ("select", "multiselect", "radiobuttons", "multicheckboxes", "gh-epic-status"):
591
+ for i, v in enumerate(values):
592
+ if isinstance(v, dict):
593
+ oid = self._option(rs, num, v)
594
+ if oid is not None:
595
+ self._cfv(rs, iid, num, i, stringvalue=oid)
596
+ return
597
+ if suffix in ("userpicker", "multiuserpicker", "people"):
598
+ for i, v in enumerate(values):
599
+ k = self._add_user(rs, v)
600
+ if k:
601
+ self._cfv(rs, iid, num, i, stringvalue=k)
602
+ return
603
+ if suffix in ("grouppicker", "multigrouppicker"):
604
+ for i, v in enumerate(values):
605
+ name = v.get("name") if isinstance(v, dict) else v
606
+ if name:
607
+ self._cfv(rs, iid, num, i, stringvalue=name)
608
+ return
609
+ if suffix in ("version", "multiversion"):
610
+ for i, v in enumerate(values):
611
+ if isinstance(v, dict):
612
+ vid = self._version_row(rs, v, None)
613
+ if vid is not None:
614
+ self._cfv(rs, iid, num, i, numbervalue=float(vid))
615
+ return
616
+ if suffix == "project" and isinstance(value, dict):
617
+ pid = self._project_row(rs, value)
618
+ if pid is not None:
619
+ self._cfv(rs, iid, num, 0, numbervalue=float(pid))
620
+ return
621
+ if suffix in ("float", "importid"):
622
+ n = to_float(value)
623
+ if n is not None:
624
+ self._cfv(rs, iid, num, 0, numbervalue=n)
625
+ return
626
+ if suffix in ("datepicker", "datetime"):
627
+ self._cfv(rs, iid, num, 0, datevalue=parse_ts(value))
628
+ return
629
+ if suffix == "textarea":
630
+ self._cfv(rs, iid, num, 0, textvalue=_text(value))
631
+ return
632
+ if suffix in ("textfield", "url", "readonlyfield", "gh-epic-label", "gh-epic-color", "gh-lexo-rank"):
633
+ self._cfv(rs, iid, num, 0, stringvalue=_text(value))
634
+ return
635
+ self._map_unknown_value(rs, iid, num, values)
636
+
637
+ def _map_unknown_value(self, rs: RowSet, iid: int, num: int, values: list[Any]) -> None:
638
+ """Best effort for field types we have no rule for: infer from the value's shape."""
639
+ for i, v in enumerate(values):
640
+ if v is None:
641
+ continue
642
+ if isinstance(v, bool):
643
+ self._cfv(rs, iid, num, i, stringvalue="true" if v else "false")
644
+ elif isinstance(v, (int, float)):
645
+ self._cfv(rs, iid, num, i, numbervalue=float(v))
646
+ elif isinstance(v, str):
647
+ ts = parse_ts(v) if len(v) in (10, 24, 28, 29) and v[:4].isdigit() else None
648
+ if ts is not None:
649
+ self._cfv(rs, iid, num, i, datevalue=ts)
650
+ elif len(v) > 255 or "\n" in v:
651
+ self._cfv(rs, iid, num, i, textvalue=v)
652
+ else:
653
+ self._cfv(rs, iid, num, i, stringvalue=v)
654
+ elif isinstance(v, dict):
655
+ if v.get("accountId"):
656
+ self._cfv(rs, iid, num, i, stringvalue=self._add_user(rs, v))
657
+ elif "value" in v and v.get("id") is not None:
658
+ oid = self._option(rs, num, v)
659
+ self._cfv(rs, iid, num, i, stringvalue=oid)
660
+ elif v.get("type") == "doc":
661
+ self._cfv(rs, iid, num, i, textvalue=adf_to_text(v))
662
+ elif v.get("id") is not None:
663
+ self._cfv(rs, iid, num, i, stringvalue=v.get("id"))
664
+ elif v.get("name") is not None:
665
+ self._cfv(rs, iid, num, i, stringvalue=v.get("name"))
666
+
667
+ def _sprint(self, rs: RowSet, s: Any) -> int | None:
668
+ if isinstance(s, str): # legacy "Sprint@abc[id=1,rapidViewId=2,state=CLOSED,...]" format
669
+ parsed = {k: (None if v in ("<null>", "") else v) for k, v in _SPRINT_STR.findall(s)}
670
+ s = {
671
+ "id": parsed.get("id"), "name": parsed.get("name"), "state": (parsed.get("state") or "").lower(),
672
+ "boardId": parsed.get("rapidViewId"), "goal": parsed.get("goal"),
673
+ "startDate": parsed.get("startDate"), "endDate": parsed.get("endDate"),
674
+ "completeDate": parsed.get("completeDate"), "activatedDate": parsed.get("activatedDate"),
675
+ "sequence": parsed.get("sequence"),
676
+ }
677
+ if not isinstance(s, dict):
678
+ return None
679
+ sid = to_int(s.get("id"))
680
+ if sid is None:
681
+ return None
682
+ state = (s.get("state") or "").lower()
683
+ board = to_int(s.get("boardId") or s.get("originBoardId"))
684
+ rs.upsert("AO_60DB71_SPRINT", sid, {
685
+ "ID": sid,
686
+ "NAME": s.get("name"),
687
+ "GOAL": s.get("goal") or None,
688
+ "STARTED": state in ("active", "closed"),
689
+ "CLOSED": state == "closed",
690
+ "START_DATE": to_epoch_ms(s.get("startDate")),
691
+ "END_DATE": to_epoch_ms(s.get("endDate")),
692
+ "COMPLETE_DATE": to_epoch_ms(s.get("completeDate")),
693
+ "ACTIVATED_DATE": to_epoch_ms(s.get("activatedDate") or s.get("startDate")),
694
+ "RAPID_VIEW_ID": board,
695
+ "SEQUENCE": to_int(s.get("sequence")) or sid,
696
+ })
697
+ if board is not None:
698
+ rs.upsert("AO_60DB71_RAPIDVIEW", board, {"ID": board, "NAME": None, "FILTER_ID": None,
699
+ "OWNER_USER_NAME": None})
700
+ return sid
701
+
702
+ # ---------------------------------------------------- changelog, comments
703
+ def _map_changelog(self, rs: RowSet, iid: int, histories: Any) -> None:
704
+ for h in histories or []:
705
+ hid = to_int(h.get("id"))
706
+ if hid is None:
707
+ continue
708
+ rs.upsert("changegroup", hid, {"id": hid, "issueid": iid, "author": self._add_user(rs, h.get("author")),
709
+ "created": parse_ts(h.get("created"))})
710
+ for idx, item in enumerate(h.get("items") or []):
711
+ old_value = item.get("from")
712
+ new_value = item.get("to")
713
+ if item.get("tmpFromAccountId") is not None or item.get("tmpToAccountId") is not None:
714
+ old_value = item.get("tmpFromAccountId") or old_value
715
+ new_value = item.get("tmpToAccountId") or new_value
716
+ rs.add("changeitem", {
717
+ "id": stable_id("ci", hid, idx),
718
+ "groupid": hid,
719
+ "fieldtype": item.get("fieldtype"),
720
+ "field": item.get("field"),
721
+ "oldvalue": None if old_value is None else str(old_value),
722
+ "oldstring": item.get("fromString"),
723
+ "newvalue": None if new_value is None else str(new_value),
724
+ "newstring": item.get("toString"),
725
+ })
726
+
727
+ @staticmethod
728
+ def _visibility(obj: dict[str, Any]) -> tuple[str | None, int | None]:
729
+ vis = obj.get("visibility") or {}
730
+ if vis.get("type") == "group":
731
+ return vis.get("value") or vis.get("identifier"), None
732
+ if vis.get("type") == "role":
733
+ return None, to_int(vis.get("identifier"))
734
+ return None, None
735
+
736
+ def _map_comments(self, rs: RowSet, iid: int, comments: Any) -> None:
737
+ for c in comments or []:
738
+ cid = to_int(c.get("id"))
739
+ if cid is None:
740
+ continue
741
+ group, role = self._visibility(c)
742
+ rs.upsert("jiraaction", cid, {
743
+ "id": cid,
744
+ "issueid": iid,
745
+ "author": self._add_user(rs, c.get("author")),
746
+ "actiontype": "comment",
747
+ "actionlevel": group,
748
+ "rolelevel": role,
749
+ "actionbody": _text(c.get("body")),
750
+ "created": parse_ts(c.get("created")),
751
+ "updateauthor": self._add_user(rs, c.get("updateAuthor")),
752
+ "updated": parse_ts(c.get("updated")),
753
+ "actionnum": None,
754
+ })
755
+
756
+ def _map_worklogs(self, rs: RowSet, iid: int, worklogs: Any) -> None:
757
+ for w in worklogs or []:
758
+ wid = to_int(w.get("id"))
759
+ if wid is None:
760
+ continue
761
+ group, role = self._visibility(w)
762
+ rs.upsert("worklog", wid, {
763
+ "id": wid,
764
+ "issueid": iid,
765
+ "author": self._add_user(rs, w.get("author")),
766
+ "grouplevel": group,
767
+ "rolelevel": role,
768
+ "worklogbody": _text(w.get("comment")),
769
+ "created": parse_ts(w.get("created")),
770
+ "updateauthor": self._add_user(rs, w.get("updateAuthor")),
771
+ "updated": parse_ts(w.get("updated")),
772
+ "startdate": parse_ts(w.get("started")),
773
+ "timeworked": to_int(w.get("timeSpentSeconds")),
774
+ })