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.
- sqlmirror/__init__.py +1 -0
- sqlmirror/apply.py +136 -0
- sqlmirror/backfill.py +65 -0
- sqlmirror/cli.py +335 -0
- sqlmirror/cron.py +103 -0
- sqlmirror/db.py +469 -0
- sqlmirror/db_mssql.py +530 -0
- sqlmirror/dev/__init__.py +1 -0
- sqlmirror/dev/cli.py +166 -0
- sqlmirror/dev/compare.py +87 -0
- sqlmirror/dev/e2e.py +261 -0
- sqlmirror/dev/golden.py +182 -0
- sqlmirror/dev/mock_jira.py +347 -0
- sqlmirror/dev/site.py +383 -0
- sqlmirror/dev/synth.py +769 -0
- sqlmirror/golden/01_issues_by_status.sql +8 -0
- sqlmirror/golden/02_issue_keys.sql +7 -0
- sqlmirror/golden/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/04_open_by_priority.sql +10 -0
- sqlmirror/golden/05_time_in_status.sql +16 -0
- sqlmirror/golden/06_cycle_time.sql +16 -0
- sqlmirror/golden/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/08_fix_versions.sql +11 -0
- sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/10_components.sql +10 -0
- sqlmirror/golden/11_top_labels.sql +9 -0
- sqlmirror/golden/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/14_sprint_completion.sql +14 -0
- sqlmirror/golden/15_epics.sql +12 -0
- sqlmirror/golden/16_subtasks.sql +10 -0
- sqlmirror/golden/17_blocked_issues.sql +11 -0
- sqlmirror/golden/18_most_discussed.sql +10 -0
- sqlmirror/golden/19_assignee_workload.sql +11 -0
- sqlmirror/golden/20_resolutions.sql +7 -0
- sqlmirror/golden/21_issue_history.sql +10 -0
- sqlmirror/golden/22_reopened.sql +11 -0
- sqlmirror/golden/23_overdue.sql +8 -0
- sqlmirror/golden/24_cascading_select.sql +13 -0
- sqlmirror/golden/25_user_picker.sql +12 -0
- sqlmirror/golden/26_attachments.sql +9 -0
- sqlmirror/golden/27_project_categories.sql +8 -0
- sqlmirror/golden/28_issue_types.sql +7 -0
- sqlmirror/golden/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/30_project_leads.sql +7 -0
- sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
- sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
- sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
- sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
- sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
- sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
- sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/tsql/10_components.sql +10 -0
- sqlmirror/golden/tsql/11_top_labels.sql +8 -0
- sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
- sqlmirror/golden/tsql/15_epics.sql +11 -0
- sqlmirror/golden/tsql/16_subtasks.sql +9 -0
- sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
- sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
- sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
- sqlmirror/golden/tsql/20_resolutions.sql +7 -0
- sqlmirror/golden/tsql/21_issue_history.sql +10 -0
- sqlmirror/golden/tsql/22_reopened.sql +11 -0
- sqlmirror/golden/tsql/23_overdue.sql +8 -0
- sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
- sqlmirror/golden/tsql/25_user_picker.sql +12 -0
- sqlmirror/golden/tsql/26_attachments.sql +9 -0
- sqlmirror/golden/tsql/27_project_categories.sql +8 -0
- sqlmirror/golden/tsql/28_issue_types.sql +7 -0
- sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/tsql/30_project_leads.sql +7 -0
- sqlmirror/idmap.py +161 -0
- sqlmirror/jira.py +223 -0
- sqlmirror/mapper.py +774 -0
- sqlmirror/reconcile.py +73 -0
- sqlmirror/samples/dc_export_example.csv +19 -0
- sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
- sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
- sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
- sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
- sqlmirror/schema.py +518 -0
- sqlmirror/source.py +117 -0
- sqlmirror/sqlcheck.py +647 -0
- sqlmirror/util.py +80 -0
- sqlmirror-0.2.0.dist-info/METADATA +19 -0
- sqlmirror-0.2.0.dist-info/RECORD +95 -0
- sqlmirror-0.2.0.dist-info/WHEEL +5 -0
- sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
- 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
|
+
})
|