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/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
|