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/dev/synth.py ADDED
@@ -0,0 +1,769 @@
1
+ """Synthetic Jira Cloud tenant.
2
+
3
+ Builds an in-memory tenant whose JSON matches the Jira Cloud REST v2 shapes
4
+ the mirror consumes: issues with system and custom fields, epics, sub-tasks,
5
+ links, sprints, versions, components, changelogs, comments and worklogs.
6
+
7
+ The same tenant is served by the mock Jira server (``mock_jira``) so the CLI
8
+ backfill and the Forge app's sync code can both run against it, and
9
+ ``mutate`` applies realistic edits and deletions and reports the Jira events
10
+ they would fire, so incremental sync can be tested offline.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import gzip
16
+ import json
17
+ import random
18
+ import string
19
+ import uuid
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ SITE = "https://synthetic-sqlmirror.atlassian.net"
25
+ EPOCH = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
26
+
27
+ STATUS_TODO, STATUS_PROGRESS, STATUS_REVIEW, STATUS_DONE, STATUS_WONTDO = "10000", "3", "10002", "10001", "10003"
28
+ STATUSES = [
29
+ {"id": STATUS_TODO, "name": "To Do", "cat": (2, "new", "blue-gray", "To Do")},
30
+ {"id": STATUS_PROGRESS, "name": "In Progress", "cat": (4, "indeterminate", "yellow", "In Progress")},
31
+ {"id": STATUS_REVIEW, "name": "In Review", "cat": (4, "indeterminate", "yellow", "In Progress")},
32
+ {"id": STATUS_DONE, "name": "Done", "cat": (3, "done", "green", "Done")},
33
+ {"id": STATUS_WONTDO, "name": "Won't Do", "cat": (3, "done", "green", "Done")},
34
+ ]
35
+ PRIORITIES = [("1", "Highest", "#d04437"), ("2", "High", "#f15C75"), ("3", "Medium", "#f79232"),
36
+ ("4", "Low", "#707070"), ("5", "Lowest", "#999999")]
37
+ RESOLUTIONS = [("10000", "Done"), ("10001", "Won't Do"), ("10002", "Duplicate"), ("10003", "Cannot Reproduce")]
38
+ ISSUE_TYPES = [
39
+ ("10000", "Epic", False, 1), ("10001", "Story", False, 0), ("10002", "Task", False, 0),
40
+ ("10003", "Bug", False, 0), ("10004", "Sub-task", True, -1),
41
+ ]
42
+ LINK_TYPES = [("10000", "Blocks", "is blocked by", "blocks"), ("10001", "Cloners", "is cloned by", "clones"),
43
+ ("10002", "Duplicate", "is duplicated by", "duplicates"), ("10003", "Relates", "relates to", "relates to")]
44
+
45
+ CFT = "com.atlassian.jira.plugin.system.customfieldtypes:"
46
+ GH = "com.pyxis.greenhopper.jira:"
47
+ CUSTOM_FIELDS = [
48
+ # (number, name, type key, schema type)
49
+ (10020, "Sprint", GH + "gh-sprint", "array"),
50
+ (10019, "Rank", GH + "gh-lexo-rank", "any"),
51
+ (10011, "Epic Name", GH + "gh-epic-label", "string"),
52
+ (10028, "Story Points", CFT + "float", "number"),
53
+ (10100, "Customer Impact", CFT + "select", "option"),
54
+ (10101, "Platforms", CFT + "multiselect", "array"),
55
+ (10102, "Customer", CFT + "textfield", "string"),
56
+ (10103, "Acceptance Criteria", CFT + "textarea", "string"),
57
+ (10104, "Cost", CFT + "float", "number"),
58
+ (10105, "Target Date", CFT + "datepicker", "date"),
59
+ (10106, "QA Owner", CFT + "userpicker", "user"),
60
+ (10107, "Region", CFT + "cascadingselect", "option-with-child"),
61
+ (10108, "Tags", CFT + "labels", "array"),
62
+ (10109, "Go-live", CFT + "datetime", "datetime"),
63
+ (10110, "Reviewers", CFT + "multiuserpicker", "array"),
64
+ (10111, "Team", "com.atlassian.jira.plugin.system.customfieldtypes:atlassian-team", "team"),
65
+ (10112, "Severity", CFT + "radiobuttons", "option"),
66
+ ]
67
+ SELECT_OPTIONS = {
68
+ 10100: [("10200", "Low"), ("10201", "Medium"), ("10202", "High"), ("10203", "Critical")],
69
+ 10101: [("10210", "Web"), ("10211", "iOS"), ("10212", "Android"), ("10213", "Desktop")],
70
+ 10112: [("10220", "S1"), ("10221", "S2"), ("10222", "S3"), ("10223", "S4")],
71
+ }
72
+ REGION_OPTIONS = {("10230", "EMEA"): [("10240", "UK"), ("10241", "Germany"), ("10242", "France")],
73
+ ("10231", "AMER"): [("10243", "US"), ("10244", "Canada"), ("10245", "Brazil")],
74
+ ("10232", "APAC"): [("10246", "Japan"), ("10247", "Australia"), ("10248", "India")]}
75
+ TEAMS = [{"id": str(uuid.UUID(int=i + 1)), "name": n} for i, n in enumerate(["Platform", "Mobile", "Growth"])]
76
+
77
+ FIRST = ["Ana", "Ben", "Chen", "Dana", "Eli", "Fatima", "Goran", "Hana", "Ivan", "Jia", "Kofi", "Lena",
78
+ "Mateo", "Nora", "Omar", "Priya", "Quinn", "Rosa", "Sven", "Tara", "Umar", "Vera", "Wei", "Xena",
79
+ "Yusuf", "Zoe"]
80
+ LAST = ["Smith", "Garcia", "Kim", "Okafor", "Novak", "Silva", "Tanaka", "Muller", "Rossi", "Nguyen", "Brown",
81
+ "Ahmed", "Larsen", "Costa", "Ivanova"]
82
+ WORDS = ("login checkout payment search cache api export report dashboard sync timeout crash mobile "
83
+ "invoice billing token session upload import permission email notification onboarding filter "
84
+ "sprint backlog latency memory index migration webhook audit").split()
85
+
86
+
87
+ def jts(dt: datetime) -> str:
88
+ """Jira's timestamp format: 2026-01-02T10:00:00.000+0000."""
89
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}+0000"
90
+
91
+
92
+ def iso_z(dt: datetime) -> str:
93
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
94
+
95
+
96
+ class Tenant:
97
+ """A synthetic Jira Cloud site."""
98
+
99
+ def __init__(self, data: dict[str, Any]):
100
+ self.data = data
101
+
102
+ # ------------------------------------------------------------ persistence
103
+ def save(self, path: str | Path) -> None:
104
+ path = Path(path)
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ with gzip.open(path, "wt", encoding="utf-8") as fh:
107
+ json.dump(self.data, fh)
108
+
109
+ @classmethod
110
+ def load(cls, path: str | Path) -> "Tenant":
111
+ with gzip.open(path, "rt", encoding="utf-8") as fh:
112
+ return cls(json.load(fh))
113
+
114
+ # --------------------------------------------------------------- helpers
115
+ @property
116
+ def issues(self) -> dict[str, dict[str, Any]]:
117
+ return self.data["issues"]
118
+
119
+ def user(self, account_id: str | None) -> dict[str, Any] | None:
120
+ if account_id is None:
121
+ return None
122
+ return copy.deepcopy(self.data["usersById"][account_id])
123
+
124
+ def issue_ref(self, iid: str) -> dict[str, Any]:
125
+ """The short issue object Jira embeds in parent/subtasks/issuelinks."""
126
+ issue = self.issues[iid]
127
+ f = issue["fields"]
128
+ return {
129
+ "id": iid,
130
+ "key": issue["key"],
131
+ "self": f"{SITE}/rest/api/2/issue/{iid}",
132
+ "fields": {
133
+ "summary": f["summary"],
134
+ "status": copy.deepcopy(f["status"]),
135
+ "priority": copy.deepcopy(f["priority"]),
136
+ "issuetype": copy.deepcopy(f["issuetype"]),
137
+ },
138
+ }
139
+
140
+ def full_fields(self, iid: str) -> dict[str, Any]:
141
+ """All fields as GET issue returns them, with parent/subtasks/links resolved."""
142
+ issue = self.issues[iid]
143
+ f = copy.deepcopy(issue["fields"])
144
+ parent_id = issue.get("_parent")
145
+ f["parent"] = self.issue_ref(parent_id) if parent_id and parent_id in self.issues else None
146
+ if f["parent"] is None:
147
+ f.pop("parent")
148
+ f["subtasks"] = [self.issue_ref(s) for s in issue.get("_subtasks", []) if s in self.issues]
149
+ links = []
150
+ for link in self.data["links"].values():
151
+ if link["source"] == iid and link["destination"] in self.issues:
152
+ links.append({"id": link["id"], "self": f"{SITE}/rest/api/2/issueLink/{link['id']}",
153
+ "type": self.link_type(link["type"]), "outwardIssue": self.issue_ref(link["destination"])})
154
+ elif link["destination"] == iid and link["source"] in self.issues:
155
+ links.append({"id": link["id"], "self": f"{SITE}/rest/api/2/issueLink/{link['id']}",
156
+ "type": self.link_type(link["type"]), "inwardIssue": self.issue_ref(link["source"])})
157
+ f["issuelinks"] = links
158
+ comments = self.data["comments"].get(iid, [])
159
+ f["comment"] = {"comments": copy.deepcopy(comments), "self": f"{SITE}/rest/api/2/issue/{iid}/comment",
160
+ "maxResults": len(comments), "total": len(comments), "startAt": 0}
161
+ worklogs = self.data["worklogs"].get(iid, [])
162
+ f["worklog"] = {"startAt": 0, "maxResults": 20, "total": len(worklogs), "worklogs": copy.deepcopy(worklogs[:20])}
163
+ return f
164
+
165
+ def link_type(self, tid: str) -> dict[str, Any]:
166
+ for t in self.data["issueLinkTypes"]:
167
+ if t["id"] == tid:
168
+ return copy.deepcopy(t)
169
+ raise KeyError(tid)
170
+
171
+ def metadata(self) -> dict[str, Any]:
172
+ """The metadata document, exactly as the Forge app and the CLI build it."""
173
+ d = self.data
174
+ return {
175
+ "v": 1, "type": "metadata", "fetchedAt": iso_z(datetime.now(timezone.utc)),
176
+ "projects": copy.deepcopy(d["projects"]), "statuses": copy.deepcopy(d["statuses"]),
177
+ "priorities": copy.deepcopy(d["priorities"]), "resolutions": copy.deepcopy(d["resolutions"]),
178
+ "issueTypes": copy.deepcopy(d["issueTypes"]), "fields": copy.deepcopy(d["fields"]),
179
+ "issueLinkTypes": copy.deepcopy(d["issueLinkTypes"]),
180
+ "projectCategories": copy.deepcopy(d["projectCategories"]),
181
+ "versions": copy.deepcopy(d["versions"]), "components": copy.deepcopy(d["components"]),
182
+ }
183
+
184
+ def snapshot(self, iid: str, fetched_at: datetime | None = None) -> dict[str, Any]:
185
+ """A complete snapshot record, as the Forge app writes it."""
186
+ issue = self.issues[iid]
187
+ fields = self.full_fields(iid)
188
+ fields.pop("comment", None)
189
+ fields.pop("worklog", None)
190
+ return {
191
+ "v": 1, "type": "issue", "id": iid,
192
+ "fetchedAt": iso_z(fetched_at or datetime.now(timezone.utc)),
193
+ "issue": {"id": iid, "key": issue["key"], "self": f"{SITE}/rest/api/2/issue/{iid}", "fields": fields},
194
+ "changelog": copy.deepcopy(self.data["changelogs"].get(iid, [])),
195
+ "comments": copy.deepcopy(self.data["comments"].get(iid, [])),
196
+ "worklogs": copy.deepcopy(self.data["worklogs"].get(iid, [])),
197
+ }
198
+
199
+ def next_id(self, kind: str) -> str:
200
+ n = self.data["nextIds"][kind]
201
+ self.data["nextIds"][kind] = n + 1
202
+ return str(n)
203
+
204
+
205
+ # ---------------------------------------------------------------- generation
206
+ def _user(rng: random.Random, i: int) -> dict[str, Any]:
207
+ first, last = FIRST[i % len(FIRST)], LAST[(i * 7) % len(LAST)]
208
+ if i % 3 == 0:
209
+ account_id = "712020:" + str(uuid.UUID(int=rng.getrandbits(128)))
210
+ else:
211
+ account_id = "".join(rng.choice("0123456789abcdef") for _ in range(24))
212
+ user = {
213
+ "self": f"{SITE}/rest/api/2/user?accountId={account_id}",
214
+ "accountId": account_id,
215
+ "avatarUrls": {"48x48": "https://avatar.example/48.png"},
216
+ "displayName": f"{first} {last}",
217
+ "active": i % 17 != 5,
218
+ "timeZone": "Europe/London",
219
+ "accountType": "atlassian",
220
+ }
221
+ if i % 2 == 0: # many sites hide email addresses
222
+ user["emailAddress"] = f"{first.lower()}.{last.lower()}@example.com"
223
+ return user
224
+
225
+
226
+ def build_tenant(issues: int = 2000, projects: int = 3, users: int = 40, seed: int = 7,
227
+ heavy_every: int = 97) -> Tenant:
228
+ """Generate a tenant with roughly ``issues`` issues spread over ``projects`` projects.
229
+
230
+ Every ``heavy_every``-th issue gets more than 100 changelog entries, 60
231
+ comments and 30 worklogs, so pagination code paths are exercised.
232
+ """
233
+ rng = random.Random(seed)
234
+ data: dict[str, Any] = {"site": SITE, "nextIds": {}}
235
+
236
+ user_list = [_user(rng, i) for i in range(users)]
237
+ data["users"] = user_list
238
+ data["usersById"] = {u["accountId"]: u for u in user_list}
239
+ active_users = [u["accountId"] for u in user_list if u["active"]]
240
+
241
+ data["projectCategories"] = [{"self": f"{SITE}/rest/api/2/projectCategory/10000", "id": "10000",
242
+ "name": "Engineering", "description": "Product engineering"},
243
+ {"self": f"{SITE}/rest/api/2/projectCategory/10001", "id": "10001",
244
+ "name": "Operations", "description": "Ops and support"}]
245
+
246
+ data["statuses"] = []
247
+ for s in STATUSES:
248
+ cid, ckey, color, cname = s["cat"]
249
+ data["statuses"].append({
250
+ "self": f"{SITE}/rest/api/2/status/{s['id']}", "description": "", "iconUrl": f"{SITE}/images/icons/statuses/generic.png",
251
+ "name": s["name"], "untranslatedName": s["name"], "id": s["id"],
252
+ "statusCategory": {"self": f"{SITE}/rest/api/2/statuscategory/{cid}", "id": cid, "key": ckey,
253
+ "colorName": color, "name": cname},
254
+ })
255
+ status_by_id = {s["id"]: s for s in data["statuses"]}
256
+ data["priorities"] = [{"self": f"{SITE}/rest/api/2/priority/{pid}", "statusColor": color, "description": "",
257
+ "iconUrl": f"{SITE}/images/icons/priorities/{name.lower()}.svg", "name": name, "id": pid,
258
+ "isDefault": pid == "3"} for pid, name, color in PRIORITIES]
259
+ prio_by_id = {p["id"]: p for p in data["priorities"]}
260
+ data["resolutions"] = [{"self": f"{SITE}/rest/api/2/resolution/{rid}", "id": rid, "description": "",
261
+ "name": name, "isDefault": rid == "10000"} for rid, name in RESOLUTIONS]
262
+ res_by_id = {r["id"]: r for r in data["resolutions"]}
263
+ data["issueTypes"] = [{"self": f"{SITE}/rest/api/2/issuetype/{tid}", "id": tid, "description": "",
264
+ "iconUrl": f"{SITE}/images/icons/issuetypes/{name.lower()}.svg", "name": name,
265
+ "untranslatedName": name, "subtask": sub, "avatarId": 10300 + int(tid) % 100,
266
+ "hierarchyLevel": level} for tid, name, sub, level in ISSUE_TYPES]
267
+ type_by_id = {t["id"]: t for t in data["issueTypes"]}
268
+ data["issueLinkTypes"] = [{"id": tid, "name": name, "inward": inward, "outward": outward,
269
+ "self": f"{SITE}/rest/api/2/issueLinkType/{tid}"}
270
+ for tid, name, inward, outward in LINK_TYPES]
271
+
272
+ system_fields = ["summary", "description", "environment", "issuetype", "project", "status", "priority",
273
+ "resolution", "assignee", "reporter", "creator", "created", "updated", "resolutiondate",
274
+ "duedate", "labels", "components", "fixVersions", "versions", "timeoriginalestimate",
275
+ "timeestimate", "timespent", "votes", "watches", "security", "parent", "subtasks",
276
+ "issuelinks", "attachment", "comment", "worklog"]
277
+ data["fields"] = [{"id": f, "key": f, "name": f.capitalize(), "custom": False, "orderable": True,
278
+ "navigable": True, "searchable": True, "clauseNames": [f], "schema": {"type": "any", "system": f}}
279
+ for f in system_fields]
280
+ for num, name, typekey, stype in CUSTOM_FIELDS:
281
+ data["fields"].append({"id": f"customfield_{num}", "key": f"customfield_{num}", "name": name, "untranslatedName": name,
282
+ "custom": True, "orderable": True, "navigable": True, "searchable": True,
283
+ "clauseNames": [f"cf[{num}]", name],
284
+ "schema": {"type": stype, "custom": typekey, "customId": num}})
285
+
286
+ data["projects"], data["versions"], data["components"] = [], {}, {}
287
+ project_keys = ["ALPHA", "BETA", "GAMMA", "DELTA", "OMEGA", "SIGMA", "THETA", "KAPPA"]
288
+ version_id, comp_id = 10000, 10000
289
+ for p in range(projects):
290
+ pid = str(10000 + p)
291
+ key = project_keys[p % len(project_keys)] + ("" if p < len(project_keys) else str(p))
292
+ lead = data["usersById"][active_users[p % len(active_users)]]
293
+ cat = data["projectCategories"][p % 2]
294
+ data["projects"].append({
295
+ "self": f"{SITE}/rest/api/2/project/{pid}", "id": pid, "key": key, "name": key.capitalize() + " Project",
296
+ "description": f"The {key.lower()} product", "lead": copy.deepcopy(lead),
297
+ "projectTypeKey": "software", "simplified": False, "style": "classic", "isPrivate": False,
298
+ "projectCategory": copy.deepcopy(cat), "avatarUrls": {},
299
+ })
300
+ versions = []
301
+ for v in range(5):
302
+ released = v < 3
303
+ start = EPOCH - timedelta(days=360 - v * 90)
304
+ versions.append({"self": f"{SITE}/rest/api/2/version/{version_id}", "id": str(version_id),
305
+ "name": f"{key} {v + 1}.0", "description": f"Release {v + 1}", "archived": v == 0,
306
+ "released": released, "startDate": start.strftime("%Y-%m-%d"),
307
+ "releaseDate": (start + timedelta(days=85)).strftime("%Y-%m-%d"),
308
+ "projectId": int(pid)})
309
+ version_id += 1
310
+ data["versions"][pid] = versions
311
+ comps = []
312
+ for name in ("Backend", "Frontend", "API", "Infra"):
313
+ comps.append({"self": f"{SITE}/rest/api/2/component/{comp_id}", "id": str(comp_id), "name": name,
314
+ "description": f"{name} work", "lead": copy.deepcopy(data["usersById"][rng.choice(active_users)]),
315
+ "assigneeType": "PROJECT_DEFAULT", "project": key, "projectId": int(pid)})
316
+ comp_id += 1
317
+ data["components"][pid] = comps
318
+
319
+ # Sprints: one board per project, two-week sprints.
320
+ sprints_by_project: dict[str, list[dict[str, Any]]] = {}
321
+ sprint_id = 1
322
+ for p, proj in enumerate(data["projects"]):
323
+ sprints = []
324
+ start = EPOCH - timedelta(days=392)
325
+ n = 0
326
+ while start < EPOCH + timedelta(days=14):
327
+ n += 1
328
+ end = start + timedelta(days=14)
329
+ if end <= EPOCH:
330
+ state = "closed"
331
+ elif start <= EPOCH:
332
+ state = "active"
333
+ else:
334
+ state = "future"
335
+ s = {"id": sprint_id, "name": f"{proj['key']} Sprint {n}", "state": state, "boardId": p + 1,
336
+ "goal": f"Sprint {n} goal" if n % 3 == 0 else "", "_start": start, "_end": end}
337
+ if state != "future":
338
+ s["startDate"] = iso_z(start)
339
+ s["endDate"] = iso_z(end)
340
+ if state == "closed":
341
+ s["completeDate"] = iso_z(end - timedelta(hours=2))
342
+ sprints.append(s)
343
+ sprint_id += 1
344
+ start = end
345
+ sprints_by_project[proj["id"]] = sprints
346
+ data["sprints"] = {pid: [{k: (iso_z(v) if isinstance(v, datetime) else v) for k, v in s.items()} for s in sl]
347
+ for pid, sl in sprints_by_project.items()}
348
+
349
+ data["issues"], data["changelogs"], data["comments"], data["worklogs"], data["links"] = {}, {}, {}, {}, {}
350
+ ids = {"issue": 10000, "history": 20000, "comment": 30000, "worklog": 40000, "attachment": 50000, "link": 60000}
351
+ counters = {proj["id"]: 0 for proj in data["projects"]}
352
+ epics: dict[str, list[str]] = {proj["id"]: [] for proj in data["projects"]}
353
+ parents: dict[str, list[str]] = {proj["id"]: [] for proj in data["projects"]}
354
+
355
+ def new_id(kind: str) -> str:
356
+ ids[kind] += 1
357
+ return str(ids[kind])
358
+
359
+ def sentence(n: int) -> str:
360
+ return " ".join(rng.choice(WORDS) for _ in range(n)).capitalize()
361
+
362
+ def sprint_value(proj_id: str, created: datetime) -> list[dict[str, Any]]:
363
+ out = []
364
+ for s in sprints_by_project[proj_id]:
365
+ if s["_end"] > created and len(out) < 3 and rng.random() < (0.9 if not out else 0.35):
366
+ out.append({k: v for k, v in data["sprints"][proj_id][sprints_by_project[proj_id].index(s)].items()})
367
+ if out and rng.random() < 0.5:
368
+ break
369
+ return out
370
+
371
+ for n in range(issues):
372
+ proj = data["projects"][n % len(data["projects"])]
373
+ pid = proj["id"]
374
+ counters[pid] += 1
375
+ iid = new_id("issue")
376
+ key = f"{proj['key']}-{counters[pid]}"
377
+ created = EPOCH - timedelta(days=rng.uniform(1, 390), minutes=rng.randint(0, 1440))
378
+ r = rng.random()
379
+ if r < 0.05 or not epics[pid]:
380
+ tid = "10000"
381
+ elif r < 0.10 and parents[pid]:
382
+ tid = "10004"
383
+ else:
384
+ tid = rng.choice(["10001", "10001", "10002", "10003"])
385
+ itype = type_by_id[tid]
386
+ heavy = n % heavy_every == heavy_every - 1
387
+ reporter = rng.choice(active_users)
388
+ assignee = rng.choice(active_users) if rng.random() < 0.85 else None
389
+ prio = rng.choice(["2", "3", "3", "3", "4", "1", "5"])
390
+
391
+ histories: list[dict[str, Any]] = []
392
+ t = created
393
+ status = STATUS_TODO
394
+ resolution = None
395
+ resolutiondate = None
396
+
397
+ def history(author: str, when: datetime, items: list[dict[str, Any]]) -> None:
398
+ histories.append({"id": new_id("history"), "author": copy.deepcopy(data["usersById"][author]),
399
+ "created": jts(when), "items": items})
400
+
401
+ path = [STATUS_PROGRESS, STATUS_REVIEW, STATUS_DONE]
402
+ if rng.random() < 0.15:
403
+ path = [STATUS_PROGRESS, STATUS_REVIEW, STATUS_PROGRESS, STATUS_REVIEW, STATUS_DONE]
404
+ steps = rng.choice([0, 1, 2, len(path), len(path), len(path)])
405
+ if heavy:
406
+ steps = len(path)
407
+ for nxt in path[:steps]:
408
+ t = t + timedelta(hours=rng.uniform(2, 24 * 6))
409
+ if t > EPOCH:
410
+ break
411
+ items = [{"field": "status", "fieldtype": "jira", "fieldId": "status", "from": status,
412
+ "fromString": status_by_id[status]["name"], "to": nxt, "toString": status_by_id[nxt]["name"]}]
413
+ if nxt == STATUS_DONE:
414
+ resolution = "10000" if rng.random() < 0.9 else "10001"
415
+ if resolution == "10001":
416
+ nxt = STATUS_WONTDO
417
+ items[0]["to"], items[0]["toString"] = nxt, status_by_id[nxt]["name"]
418
+ items.append({"field": "resolution", "fieldtype": "jira", "fieldId": "resolution", "from": None,
419
+ "fromString": None, "to": resolution, "toString": res_by_id[resolution]["name"]})
420
+ resolutiondate = t
421
+ history(assignee or reporter, t, items)
422
+ status = nxt
423
+ if assignee and rng.random() < 0.3:
424
+ t2 = created + timedelta(hours=rng.uniform(1, 30))
425
+ other = rng.choice(active_users)
426
+ history(reporter, min(t2, EPOCH), [{"field": "assignee", "fieldtype": "jira", "fieldId": "assignee",
427
+ "from": None, "fromString": data["usersById"][other]["displayName"],
428
+ "to": None, "toString": data["usersById"][assignee]["displayName"],
429
+ "tmpFromAccountId": other, "tmpToAccountId": assignee}])
430
+ if heavy:
431
+ for k in range(110):
432
+ when = created + timedelta(minutes=10 * (k + 1))
433
+ history(reporter, min(when, EPOCH), [{"field": "summary", "fieldtype": "jira", "fieldId": "summary",
434
+ "from": None, "fromString": f"Draft {k}", "to": None,
435
+ "toString": f"Draft {k + 1}"}])
436
+ histories.sort(key=lambda h: h["created"])
437
+
438
+ updated = max([created] + [datetime.fromisoformat(h["created"].replace("+0000", "+00:00")) for h in histories])
439
+
440
+ fields: dict[str, Any] = {
441
+ "summary": sentence(rng.randint(3, 8)),
442
+ "description": (f"h2. Context\n{sentence(12)}.\n\n* {sentence(5)}\n* {sentence(4)}"
443
+ if rng.random() < 0.8 else None),
444
+ "environment": "Production" if rng.random() < 0.1 else None,
445
+ "issuetype": copy.deepcopy(itype),
446
+ "project": {"self": proj["self"], "id": pid, "key": proj["key"], "name": proj["name"],
447
+ "projectTypeKey": "software", "simplified": False, "avatarUrls": {},
448
+ "projectCategory": copy.deepcopy(proj["projectCategory"])},
449
+ "status": copy.deepcopy(status_by_id[status]),
450
+ "priority": copy.deepcopy(prio_by_id[prio]),
451
+ "resolution": copy.deepcopy(res_by_id[resolution]) if resolution else None,
452
+ "assignee": copy.deepcopy(data["usersById"][assignee]) if assignee else None,
453
+ "reporter": copy.deepcopy(data["usersById"][reporter]),
454
+ "creator": copy.deepcopy(data["usersById"][reporter]),
455
+ "created": jts(created),
456
+ "updated": jts(updated),
457
+ "resolutiondate": jts(resolutiondate) if resolutiondate else None,
458
+ "duedate": (created + timedelta(days=rng.randint(5, 60))).strftime("%Y-%m-%d") if rng.random() < 0.3 else None,
459
+ "labels": sorted(set(rng.sample(WORDS, rng.randint(0, 3)))),
460
+ "components": [{"self": c["self"], "id": c["id"], "name": c["name"]}
461
+ for c in rng.sample(data["components"][pid], rng.randint(0, 2))],
462
+ "fixVersions": [],
463
+ "versions": [],
464
+ "timeoriginalestimate": rng.choice([None, 3600, 7200, 14400, 28800]),
465
+ "timeestimate": None,
466
+ "timespent": None,
467
+ "votes": {"self": f"{SITE}/rest/api/2/issue/{key}/votes", "votes": rng.randint(0, 3), "hasVoted": False},
468
+ "watches": {"self": f"{SITE}/rest/api/2/issue/{key}/watchers", "watchCount": rng.randint(1, 5),
469
+ "isWatching": False},
470
+ "security": None,
471
+ "attachment": [],
472
+ }
473
+ released = [v for v in data["versions"][pid] if v["released"]]
474
+ if resolution and rng.random() < 0.7:
475
+ v = rng.choice(released)
476
+ fields["fixVersions"] = [{k: v[k] for k in ("self", "id", "name", "archived", "released", "releaseDate")}]
477
+ history(assignee or reporter, resolutiondate or created, [{
478
+ "field": "Fix Version", "fieldtype": "jira", "fieldId": "fixVersions", "from": None, "fromString": None,
479
+ "to": v["id"], "toString": v["name"]}])
480
+ if tid == "10003" and rng.random() < 0.6:
481
+ v = rng.choice(released)
482
+ fields["versions"] = [{k: v[k] for k in ("self", "id", "name", "archived", "released", "releaseDate")}]
483
+
484
+ # Custom fields
485
+ if tid != "10000":
486
+ sv = sprint_value(pid, created)
487
+ fields["customfield_10020"] = [{k: v for k, v in s.items() if not k.startswith("_")} for s in sv] or None
488
+ if len(sv) > 1:
489
+ history(reporter, min(created + timedelta(days=14), EPOCH), [{
490
+ "field": "Sprint", "fieldtype": "custom", "fieldId": "customfield_10020",
491
+ "from": str(sv[0]["id"]), "fromString": sv[0]["name"],
492
+ "to": ", ".join(str(s["id"]) for s in sv[:2]),
493
+ "toString": ", ".join(s["name"] for s in sv[:2])}])
494
+ else:
495
+ fields["customfield_10011"] = sentence(2)
496
+ fields["customfield_10019"] = "0|i" + "".join(rng.choice(string.ascii_lowercase + string.digits) for _ in range(5)) + ":"
497
+ if tid in ("10001", "10002", "10003"):
498
+ sp = rng.choice([None, 1, 2, 3, 5, 8, 13])
499
+ fields["customfield_10028"] = float(sp) if sp is not None else None
500
+ if sp is not None and rng.random() < 0.3:
501
+ history(reporter, created + timedelta(minutes=5), [{
502
+ "field": "Story Points", "fieldtype": "custom", "fieldId": "customfield_10028",
503
+ "from": None, "fromString": None, "to": None, "toString": str(sp)}])
504
+ if rng.random() < 0.6:
505
+ oid, val = rng.choice(SELECT_OPTIONS[10100])
506
+ fields["customfield_10100"] = {"self": f"{SITE}/rest/api/2/customFieldOption/{oid}", "value": val, "id": oid}
507
+ if rng.random() < 0.5:
508
+ fields["customfield_10101"] = [{"self": f"{SITE}/rest/api/2/customFieldOption/{oid}", "value": val, "id": oid}
509
+ for oid, val in rng.sample(SELECT_OPTIONS[10101], rng.randint(1, 3))]
510
+ if rng.random() < 0.4:
511
+ fields["customfield_10102"] = rng.choice(["Acme Corp", "Globex", "Initech", "Umbrella", "Hooli"])
512
+ if rng.random() < 0.4:
513
+ fields["customfield_10103"] = f"* {sentence(6)}\n* {sentence(5)}"
514
+ if rng.random() < 0.3:
515
+ fields["customfield_10104"] = round(rng.uniform(10, 5000), 2)
516
+ if rng.random() < 0.3:
517
+ fields["customfield_10105"] = (created + timedelta(days=rng.randint(10, 90))).strftime("%Y-%m-%d")
518
+ if rng.random() < 0.4:
519
+ fields["customfield_10106"] = copy.deepcopy(data["usersById"][rng.choice(active_users)])
520
+ if rng.random() < 0.35:
521
+ (poid, pval), children = rng.choice(list(REGION_OPTIONS.items()))
522
+ coid, cval = rng.choice(children)
523
+ fields["customfield_10107"] = {"self": f"{SITE}/rest/api/2/customFieldOption/{poid}", "value": pval, "id": poid,
524
+ "child": {"self": f"{SITE}/rest/api/2/customFieldOption/{coid}", "value": cval,
525
+ "id": coid}}
526
+ if rng.random() < 0.3:
527
+ fields["customfield_10108"] = sorted(set(rng.sample(["vip", "regression", "customer", "q3", "tech-debt"],
528
+ rng.randint(1, 2))))
529
+ if rng.random() < 0.2:
530
+ fields["customfield_10109"] = jts(created + timedelta(days=rng.randint(10, 90), hours=rng.randint(0, 23)))
531
+ if rng.random() < 0.3:
532
+ fields["customfield_10110"] = [copy.deepcopy(data["usersById"][u]) for u in rng.sample(active_users, 2)]
533
+ if rng.random() < 0.4:
534
+ fields["customfield_10111"] = copy.deepcopy(rng.choice(TEAMS))
535
+ if tid == "10003" and rng.random() < 0.8:
536
+ oid, val = rng.choice(SELECT_OPTIONS[10112])
537
+ fields["customfield_10112"] = {"self": f"{SITE}/rest/api/2/customFieldOption/{oid}", "value": val, "id": oid}
538
+
539
+ # Attachments
540
+ for _ in range(rng.choice([0, 0, 0, 1, 2])):
541
+ aid = new_id("attachment")
542
+ fields["attachment"].append({"self": f"{SITE}/rest/api/2/attachment/{aid}", "id": aid,
543
+ "filename": f"{rng.choice(WORDS)}.png",
544
+ "author": copy.deepcopy(data["usersById"][reporter]),
545
+ "created": jts(created + timedelta(minutes=3)), "size": rng.randint(1000, 900000),
546
+ "mimeType": "image/png", "content": f"{SITE}/secure/attachment/{aid}/x.png",
547
+ "thumbnail": f"{SITE}/secure/thumbnail/{aid}/x.png"})
548
+
549
+ # Comments and worklogs
550
+ comments = []
551
+ for c in range(60 if heavy else rng.choice([0, 0, 1, 2, 3, 5, 8])):
552
+ cid = new_id("comment")
553
+ author = rng.choice(active_users)
554
+ when = min(created + timedelta(hours=rng.uniform(1, 24 * 20)), EPOCH)
555
+ comment = {"self": f"{SITE}/rest/api/2/issue/{iid}/comment/{cid}", "id": cid,
556
+ "author": copy.deepcopy(data["usersById"][author]), "body": sentence(rng.randint(4, 14)) + ".",
557
+ "updateAuthor": copy.deepcopy(data["usersById"][author]), "created": jts(when),
558
+ "updated": jts(when), "jsdPublic": True}
559
+ if rng.random() < 0.05:
560
+ comment["visibility"] = {"type": "group", "value": "jira-developers", "identifier": "jira-developers"}
561
+ comments.append(comment)
562
+ comments.sort(key=lambda c: c["created"])
563
+ worklogs = []
564
+ spent = 0
565
+ for w in range(30 if heavy else (rng.choice([0, 0, 1, 2, 4, 6]) if status != STATUS_TODO else 0)):
566
+ wid = new_id("worklog")
567
+ author = assignee or rng.choice(active_users)
568
+ started = min(created + timedelta(hours=rng.uniform(1, 24 * 20)), EPOCH)
569
+ secs = rng.choice([900, 1800, 3600, 7200, 14400])
570
+ spent += secs
571
+ worklogs.append({"self": f"{SITE}/rest/api/2/issue/{iid}/worklog/{wid}",
572
+ "author": copy.deepcopy(data["usersById"][author]),
573
+ "updateAuthor": copy.deepcopy(data["usersById"][author]),
574
+ "comment": sentence(rng.randint(2, 6)), "created": jts(started), "updated": jts(started),
575
+ "started": jts(started), "timeSpent": f"{secs // 60}m", "timeSpentSeconds": secs,
576
+ "id": wid, "issueId": iid})
577
+ worklogs.sort(key=lambda w: w["started"])
578
+ if spent:
579
+ fields["timespent"] = spent
580
+ if fields["timeoriginalestimate"]:
581
+ fields["timeestimate"] = max(0, fields["timeoriginalestimate"] - spent)
582
+ all_times = [updated] + [datetime.fromisoformat(c["updated"].replace("+0000", "+00:00")) for c in comments] + \
583
+ [datetime.fromisoformat(w["updated"].replace("+0000", "+00:00")) for w in worklogs] + \
584
+ [datetime.fromisoformat(h["created"].replace("+0000", "+00:00")) for h in histories]
585
+ fields["updated"] = jts(max(all_times))
586
+
587
+ issue = {"id": iid, "key": key, "fields": fields}
588
+ if tid == "10004":
589
+ parent = rng.choice(parents[pid])
590
+ issue["_parent"] = parent
591
+ data["issues"][parent].setdefault("_subtasks", []).append(iid)
592
+ elif tid != "10000" and rng.random() < 0.6 and epics[pid]:
593
+ issue["_parent"] = rng.choice(epics[pid])
594
+ if tid == "10000":
595
+ epics[pid].append(iid)
596
+ elif tid != "10004":
597
+ parents[pid].append(iid)
598
+
599
+ histories.sort(key=lambda h: (h["created"], int(h["id"])))
600
+ data["issues"][iid] = issue
601
+ data["changelogs"][iid] = histories
602
+ data["comments"][iid] = comments
603
+ data["worklogs"][iid] = worklogs
604
+
605
+ # Issue links between issues of the same project.
606
+ all_ids = list(data["issues"])
607
+ for iid in all_ids:
608
+ if rng.random() < 0.08:
609
+ other = rng.choice(all_ids)
610
+ if other == iid:
611
+ continue
612
+ lid = new_id("link")
613
+ data["links"][lid] = {"id": lid, "type": rng.choice(["10000", "10003", "10002"]), "source": iid,
614
+ "destination": other}
615
+
616
+ data["nextIds"] = {k: v + 1 for k, v in ids.items()}
617
+ return Tenant(data)
618
+
619
+
620
+ # ------------------------------------------------------------------ mutation
621
+ def mutate(tenant: Tenant, *, edits: int = 20, deletes: int = 3, creates: int = 5, links: int = 4,
622
+ seed: int = 99, now: datetime | None = None) -> list[dict[str, Any]]:
623
+ """Change the tenant the way users would, and return the Jira events fired.
624
+
625
+ Events use the shapes Forge product triggers deliver (eventType plus the
626
+ issue / worklog / issueLink objects), trimmed to what the app reads.
627
+ """
628
+ rng = random.Random(seed)
629
+ d = tenant.data
630
+ # Jira's "updated" only moves forward; keep the synthetic clock monotonic
631
+ # across successive mutate() calls.
632
+ now = now or datetime.now(timezone.utc)
633
+ if d.get("clock"):
634
+ now = max(now, datetime.fromisoformat(d["clock"]))
635
+ events: list[dict[str, Any]] = []
636
+ active_users = [u["accountId"] for u in d["users"] if u["active"]]
637
+ status_by_id = {s["id"]: s for s in d["statuses"]}
638
+
639
+ def tick() -> datetime:
640
+ nonlocal now
641
+ now = now + timedelta(seconds=1)
642
+ return now
643
+
644
+ def touch(iid: str, when: datetime) -> None:
645
+ tenant.issues[iid]["fields"]["updated"] = jts(when)
646
+
647
+ def issue_event(event_type: str, iid: str) -> dict[str, Any]:
648
+ issue = tenant.issues[iid]
649
+ return {"eventType": event_type, "issue": {"id": iid, "key": issue["key"],
650
+ "fields": {"project": {"id": issue["fields"]["project"]["id"],
651
+ "key": issue["fields"]["project"]["key"]}}}}
652
+
653
+ candidates = [i for i in tenant.issues]
654
+ for _ in range(edits):
655
+ iid = rng.choice(candidates)
656
+ if iid not in tenant.issues:
657
+ continue
658
+ issue = tenant.issues[iid]
659
+ f = issue["fields"]
660
+ when = tick()
661
+ kind = rng.choice(["status", "summary", "comment", "worklog", "assignee", "labels", "storypoints"])
662
+ author = rng.choice(active_users)
663
+ if kind == "status":
664
+ nxt = STATUS_PROGRESS if f["status"]["id"] == STATUS_TODO else STATUS_TODO
665
+ d["changelogs"][iid].append({"id": tenant.next_id("history"), "author": tenant.user(author),
666
+ "created": jts(when), "items": [{
667
+ "field": "status", "fieldtype": "jira", "fieldId": "status",
668
+ "from": f["status"]["id"], "fromString": f["status"]["name"], "to": nxt,
669
+ "toString": status_by_id[nxt]["name"]}]})
670
+ f["status"] = copy.deepcopy(status_by_id[nxt])
671
+ if nxt == STATUS_TODO and f.get("resolution"):
672
+ f["resolution"] = None
673
+ f["resolutiondate"] = None
674
+ events.append(issue_event("avi:jira:updated:issue", iid))
675
+ elif kind == "summary":
676
+ old = f["summary"]
677
+ f["summary"] = old + " (edited)"
678
+ d["changelogs"][iid].append({"id": tenant.next_id("history"), "author": tenant.user(author),
679
+ "created": jts(when), "items": [{
680
+ "field": "summary", "fieldtype": "jira", "fieldId": "summary",
681
+ "from": None, "fromString": old, "to": None, "toString": f["summary"]}]})
682
+ events.append(issue_event("avi:jira:updated:issue", iid))
683
+ elif kind == "comment":
684
+ cid = tenant.next_id("comment")
685
+ d["comments"].setdefault(iid, []).append({
686
+ "self": f"{SITE}/rest/api/2/issue/{iid}/comment/{cid}", "id": cid, "author": tenant.user(author),
687
+ "body": "Follow-up comment.", "updateAuthor": tenant.user(author), "created": jts(when),
688
+ "updated": jts(when), "jsdPublic": True})
689
+ ev = issue_event("avi:jira:commented:issue", iid)
690
+ ev["comment"] = {"id": cid}
691
+ events.append(ev)
692
+ elif kind == "worklog":
693
+ wid = tenant.next_id("worklog")
694
+ d["worklogs"].setdefault(iid, []).append({
695
+ "self": f"{SITE}/rest/api/2/issue/{iid}/worklog/{wid}", "author": tenant.user(author),
696
+ "updateAuthor": tenant.user(author), "comment": "Pairing", "created": jts(when), "updated": jts(when),
697
+ "started": jts(when), "timeSpent": "30m", "timeSpentSeconds": 1800, "id": wid, "issueId": iid})
698
+ f["timespent"] = (f.get("timespent") or 0) + 1800
699
+ events.append({"eventType": "avi:jira:created:worklog", "worklog": {"id": wid, "issueId": iid}})
700
+ elif kind == "assignee":
701
+ old = (f.get("assignee") or {}).get("accountId")
702
+ new = rng.choice(active_users)
703
+ f["assignee"] = tenant.user(new)
704
+ d["changelogs"][iid].append({"id": tenant.next_id("history"), "author": tenant.user(author),
705
+ "created": jts(when), "items": [{
706
+ "field": "assignee", "fieldtype": "jira", "fieldId": "assignee",
707
+ "from": None, "fromString": None, "to": None,
708
+ "toString": d["usersById"][new]["displayName"],
709
+ "tmpFromAccountId": old, "tmpToAccountId": new}]})
710
+ events.append(issue_event("avi:jira:updated:issue", iid))
711
+ elif kind == "labels":
712
+ f["labels"] = sorted(set(f.get("labels") or []) | {"edited"})
713
+ events.append(issue_event("avi:jira:updated:issue", iid))
714
+ else:
715
+ f["customfield_10028"] = 21.0
716
+ events.append(issue_event("avi:jira:updated:issue", iid))
717
+ touch(iid, when)
718
+
719
+ for _ in range(links):
720
+ a, b = rng.sample([i for i in tenant.issues], 2)
721
+ lid = tenant.next_id("link")
722
+ d["links"][lid] = {"id": lid, "type": "10003", "source": a, "destination": b}
723
+ when = tick()
724
+ touch(a, when)
725
+ touch(b, when)
726
+ events.append({"eventType": "avi:jira:created:issuelink",
727
+ "issueLink": {"id": lid, "sourceIssueId": a, "destinationIssueId": b}})
728
+
729
+ for _ in range(creates):
730
+ proj = rng.choice(d["projects"])
731
+ pid = proj["id"]
732
+ num = max(int(i["key"].rsplit("-", 1)[1]) for i in tenant.issues.values()
733
+ if i["fields"]["project"]["id"] == pid) + 1
734
+ iid = tenant.next_id("issue")
735
+ when = tick()
736
+ reporter = rng.choice(active_users)
737
+ itype = next(t for t in d["issueTypes"] if t["id"] == "10002")
738
+ tenant.issues[iid] = {"id": iid, "key": f"{proj['key']}-{num}", "fields": {
739
+ "summary": "New task from mutation", "description": None, "environment": None,
740
+ "issuetype": copy.deepcopy(itype),
741
+ "project": {"self": proj["self"], "id": pid, "key": proj["key"], "name": proj["name"],
742
+ "projectTypeKey": "software", "simplified": False, "avatarUrls": {}},
743
+ "status": copy.deepcopy(status_by_id[STATUS_TODO]),
744
+ "priority": copy.deepcopy(next(p for p in d["priorities"] if p["id"] == "3")),
745
+ "resolution": None, "assignee": None, "reporter": tenant.user(reporter), "creator": tenant.user(reporter),
746
+ "created": jts(when), "updated": jts(when), "resolutiondate": None, "duedate": None, "labels": [],
747
+ "components": [], "fixVersions": [], "versions": [], "timeoriginalestimate": None, "timeestimate": None,
748
+ "timespent": None, "votes": {"votes": 0, "hasVoted": False}, "watches": {"watchCount": 1, "isWatching": False},
749
+ "security": None, "attachment": [], "customfield_10019": "0|zzzzz:"}}
750
+ d["changelogs"][iid], d["comments"][iid], d["worklogs"][iid] = [], [], []
751
+ events.append(issue_event("avi:jira:created:issue", iid))
752
+
753
+ deletable = [i for i, issue in tenant.issues.items() if not issue.get("_subtasks")
754
+ and issue["fields"]["issuetype"]["id"] != "10000"]
755
+ for iid in rng.sample(deletable, min(deletes, len(deletable))):
756
+ issue = tenant.issues[iid]
757
+ ev = issue_event("avi:jira:deleted:issue", iid)
758
+ parent = issue.get("_parent")
759
+ if parent and parent in tenant.issues and iid in tenant.issues[parent].get("_subtasks", []):
760
+ tenant.issues[parent]["_subtasks"].remove(iid)
761
+ for lid in [lid for lid, link in d["links"].items() if iid in (link["source"], link["destination"])]:
762
+ del d["links"][lid]
763
+ del tenant.issues[iid]
764
+ d["changelogs"].pop(iid, None)
765
+ d["comments"].pop(iid, None)
766
+ d["worklogs"].pop(iid, None)
767
+ events.append(ev)
768
+ d["clock"] = now.isoformat()
769
+ return events