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/site.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Tools that write to a real (demo) Jira Cloud site, for live testing.
|
|
2
|
+
|
|
3
|
+
Never point these at a production site: they create and edit issues.
|
|
4
|
+
|
|
5
|
+
load_site create versions, components, epics, stories/tasks/bugs,
|
|
6
|
+
sub-tasks, links, comments, worklogs and transitions
|
|
7
|
+
simulate_edits a steady stream of realistic edits (drives Forge events)
|
|
8
|
+
bulk_edit edit many issues quickly (the bulk-edit rate-limit test)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
|
+
from datetime import date, timedelta
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from ..jira import JiraClient, JiraError
|
|
19
|
+
|
|
20
|
+
WORDS = ("login checkout payment search cache api export report dashboard sync timeout crash mobile invoice "
|
|
21
|
+
"billing token session upload import permission email notification onboarding filter latency memory "
|
|
22
|
+
"index migration webhook audit").split()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _sentence(rng: random.Random, n: int) -> str:
|
|
26
|
+
return " ".join(rng.choice(WORDS) for _ in range(n)).capitalize()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_project(client: JiraClient, key: str, name: str, log: Callable[[str], None]) -> dict[str, Any]:
|
|
30
|
+
existing = client.request("GET", f"/rest/api/2/project/{key}", allow_404=True)
|
|
31
|
+
if existing:
|
|
32
|
+
log(f"Using existing project {key}")
|
|
33
|
+
return existing
|
|
34
|
+
me = client.myself()
|
|
35
|
+
for template in ("com.pyxis.greenhopper.jira:gh-simplified-scrum-classic",
|
|
36
|
+
"com.pyxis.greenhopper.jira:gh-scrum-template"):
|
|
37
|
+
try:
|
|
38
|
+
client.post("/rest/api/2/project", {"key": key, "name": name, "projectTypeKey": "software",
|
|
39
|
+
"projectTemplateKey": template, "leadAccountId": me["accountId"],
|
|
40
|
+
"assigneeType": "UNASSIGNED"})
|
|
41
|
+
log(f"Created project {key} ({template})")
|
|
42
|
+
return client.get(f"/rest/api/2/project/{key}")
|
|
43
|
+
except JiraError as exc:
|
|
44
|
+
last = exc
|
|
45
|
+
raise SystemExit(f"Could not create project {key}: {last}. Create a company-managed Scrum project with key "
|
|
46
|
+
f"{key} in Jira, then run this again.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _create_meta(client: JiraClient, key: str) -> dict[str, dict[str, Any]]:
|
|
50
|
+
"""issue type name -> {"id": ..., "fields": {fieldId: field meta}}"""
|
|
51
|
+
out = {}
|
|
52
|
+
types = client.get(f"/rest/api/2/issue/createmeta/{key}/issuetypes", maxResults=50)
|
|
53
|
+
for t in types.get("issueTypes", types.get("values", [])):
|
|
54
|
+
fields = {}
|
|
55
|
+
start = 0
|
|
56
|
+
while True:
|
|
57
|
+
page = client.get(f"/rest/api/2/issue/createmeta/{key}/issuetypes/{t['id']}", startAt=start, maxResults=50)
|
|
58
|
+
vals = page.get("fields", page.get("values", []))
|
|
59
|
+
for f in vals:
|
|
60
|
+
fields[f["fieldId"]] = f
|
|
61
|
+
start += len(vals)
|
|
62
|
+
if not vals or start >= page.get("total", 0):
|
|
63
|
+
break
|
|
64
|
+
out[t["name"]] = {"id": t["id"], "subtask": t.get("subtask", False), "fields": fields}
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _custom_values(rng: random.Random, meta: dict[str, Any], users: list[str]) -> dict[str, Any]:
|
|
69
|
+
"""Fill custom fields that are on the create screen, with values of the right shape."""
|
|
70
|
+
values: dict[str, Any] = {}
|
|
71
|
+
for fid, f in meta["fields"].items():
|
|
72
|
+
if not fid.startswith("customfield_") or rng.random() < 0.4:
|
|
73
|
+
continue
|
|
74
|
+
schema = f.get("schema") or {}
|
|
75
|
+
custom = (schema.get("custom") or "").rsplit(":", 1)[-1]
|
|
76
|
+
allowed = f.get("allowedValues") or []
|
|
77
|
+
if custom in ("select", "radiobuttons") and allowed:
|
|
78
|
+
values[fid] = {"id": rng.choice(allowed)["id"]}
|
|
79
|
+
elif custom in ("multiselect", "multicheckboxes") and allowed:
|
|
80
|
+
values[fid] = [{"id": a["id"]} for a in rng.sample(allowed, min(len(allowed), rng.randint(1, 2)))]
|
|
81
|
+
elif custom in ("float", "jsw-story-points"):
|
|
82
|
+
values[fid] = float(rng.choice([1, 2, 3, 5, 8]))
|
|
83
|
+
elif custom == "textfield":
|
|
84
|
+
values[fid] = rng.choice(["Acme Corp", "Globex", "Initech"])
|
|
85
|
+
elif custom == "textarea":
|
|
86
|
+
values[fid] = f"* {_sentence(rng, 5)}\n* {_sentence(rng, 4)}"
|
|
87
|
+
elif custom == "datepicker":
|
|
88
|
+
values[fid] = (date.today() + timedelta(days=rng.randint(5, 60))).isoformat()
|
|
89
|
+
elif custom == "labels":
|
|
90
|
+
values[fid] = [rng.choice(["vip", "regression", "q3"])]
|
|
91
|
+
elif custom == "userpicker" and users:
|
|
92
|
+
values[fid] = {"accountId": rng.choice(users)}
|
|
93
|
+
return values
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_site(client: JiraClient, *, project: str = "SQLM", issues: int = 500, seed: int = 1, workers: int = 4,
|
|
97
|
+
log: Callable[[str], None] = print) -> None:
|
|
98
|
+
rng = random.Random(seed)
|
|
99
|
+
proj = ensure_project(client, project, "SQL Mirror Test", log)
|
|
100
|
+
me = client.myself()
|
|
101
|
+
users = [me["accountId"]]
|
|
102
|
+
|
|
103
|
+
versions = [v["name"] for v in client.get(f"/rest/api/2/project/{project}/versions") or []]
|
|
104
|
+
for i in range(1, 4):
|
|
105
|
+
name = f"{project} {i}.0"
|
|
106
|
+
if name not in versions:
|
|
107
|
+
client.post("/rest/api/2/version", {"name": name, "projectId": int(proj["id"]), "released": i == 1,
|
|
108
|
+
"releaseDate": (date.today() + timedelta(days=30 * (i - 2))).isoformat()})
|
|
109
|
+
comps = [c["name"] for c in client.get(f"/rest/api/2/project/{project}/components") or []]
|
|
110
|
+
for name in ("Backend", "Frontend", "API"):
|
|
111
|
+
if name not in comps:
|
|
112
|
+
client.post("/rest/api/2/component", {"name": name, "project": project})
|
|
113
|
+
versions = client.get(f"/rest/api/2/project/{project}/versions") or []
|
|
114
|
+
components = client.get(f"/rest/api/2/project/{project}/components") or []
|
|
115
|
+
|
|
116
|
+
meta = _create_meta(client, project)
|
|
117
|
+
log(f"Issue types: {', '.join(meta)}")
|
|
118
|
+
standard = [n for n, m in meta.items() if not m["subtask"] and n.lower() != "epic"]
|
|
119
|
+
subtask_type = next((n for n, m in meta.items() if m["subtask"]), None)
|
|
120
|
+
epic_type = next((n for n in meta if n.lower() == "epic"), None)
|
|
121
|
+
|
|
122
|
+
def fields_for(type_name: str, parent_key: str | None = None) -> dict[str, Any]:
|
|
123
|
+
m = meta[type_name]
|
|
124
|
+
f: dict[str, Any] = {"project": {"key": project}, "issuetype": {"id": m["id"]},
|
|
125
|
+
"summary": _sentence(rng, rng.randint(3, 7))}
|
|
126
|
+
if "description" in m["fields"]:
|
|
127
|
+
f["description"] = f"h2. Context\n{_sentence(rng, 10)}.\n\n* {_sentence(rng, 4)}"
|
|
128
|
+
if "labels" in m["fields"]:
|
|
129
|
+
f["labels"] = rng.sample(WORDS, rng.randint(0, 2))
|
|
130
|
+
if "priority" in m["fields"] and m["fields"]["priority"].get("allowedValues"):
|
|
131
|
+
f["priority"] = {"id": rng.choice(m["fields"]["priority"]["allowedValues"])["id"]}
|
|
132
|
+
if "components" in m["fields"] and components and rng.random() < 0.6:
|
|
133
|
+
f["components"] = [{"id": rng.choice(components)["id"]}]
|
|
134
|
+
if "fixVersions" in m["fields"] and versions and rng.random() < 0.5:
|
|
135
|
+
f["fixVersions"] = [{"id": rng.choice(versions)["id"]}]
|
|
136
|
+
if "duedate" in m["fields"] and rng.random() < 0.3:
|
|
137
|
+
f["duedate"] = (date.today() + timedelta(days=rng.randint(-20, 40))).isoformat()
|
|
138
|
+
if "timetracking" in m["fields"] and rng.random() < 0.4:
|
|
139
|
+
f["timetracking"] = {"originalEstimate": rng.choice(["1h", "2h", "4h", "1d"])}
|
|
140
|
+
if parent_key and "parent" in m["fields"]:
|
|
141
|
+
f["parent"] = {"key": parent_key}
|
|
142
|
+
f.update(_custom_values(rng, m, users))
|
|
143
|
+
return f
|
|
144
|
+
|
|
145
|
+
def create_bulk(field_sets: list[dict[str, Any]]) -> list[str]:
|
|
146
|
+
keys = []
|
|
147
|
+
for i in range(0, len(field_sets), 50):
|
|
148
|
+
res = client.post("/rest/api/2/issue/bulk", {"issueUpdates": [{"fields": f} for f in field_sets[i:i + 50]]})
|
|
149
|
+
keys.extend(x["key"] for x in res.get("issues", []))
|
|
150
|
+
for err in res.get("errors", []):
|
|
151
|
+
log(f" create error: {str(err)[:300]}")
|
|
152
|
+
return keys
|
|
153
|
+
|
|
154
|
+
n_epics = max(1, issues // 25) if epic_type else 0
|
|
155
|
+
epic_keys = create_bulk([fields_for(epic_type) for _ in range(n_epics)]) if epic_type else []
|
|
156
|
+
log(f"Created {len(epic_keys)} epics")
|
|
157
|
+
main = []
|
|
158
|
+
for _ in range(issues - n_epics - issues // 10):
|
|
159
|
+
main.append(fields_for(rng.choice(standard), rng.choice(epic_keys) if epic_keys and rng.random() < 0.5 else None))
|
|
160
|
+
keys = create_bulk(main)
|
|
161
|
+
log(f"Created {len(keys)} issues")
|
|
162
|
+
if subtask_type and keys:
|
|
163
|
+
subs = create_bulk([fields_for(subtask_type, rng.choice(keys)) for _ in range(issues // 10)])
|
|
164
|
+
log(f"Created {len(subs)} sub-tasks")
|
|
165
|
+
else:
|
|
166
|
+
subs = []
|
|
167
|
+
|
|
168
|
+
link_types = (client.get("/rest/api/2/issueLinkType") or {}).get("issueLinkTypes", [])
|
|
169
|
+
blocks = next((t for t in link_types if t["name"] == "Blocks"), link_types[0] if link_types else None)
|
|
170
|
+
|
|
171
|
+
def enrich(key: str) -> None:
|
|
172
|
+
r = random.Random(f"{seed}-{key}")
|
|
173
|
+
for _ in range(r.choice([0, 0, 1, 2, 3])):
|
|
174
|
+
client.post(f"/rest/api/2/issue/{key}/comment", {"body": _sentence(r, r.randint(4, 12)) + "."})
|
|
175
|
+
if r.random() < 0.5:
|
|
176
|
+
transitions = client.get(f"/rest/api/2/issue/{key}/transitions").get("transitions", [])
|
|
177
|
+
for _ in range(r.choice([1, 1, 2])):
|
|
178
|
+
if not transitions:
|
|
179
|
+
break
|
|
180
|
+
t = r.choice(transitions)
|
|
181
|
+
client.post(f"/rest/api/2/issue/{key}/transitions", {"transition": {"id": t["id"]}})
|
|
182
|
+
transitions = client.get(f"/rest/api/2/issue/{key}/transitions").get("transitions", [])
|
|
183
|
+
if r.random() < 0.3:
|
|
184
|
+
client.post(f"/rest/api/2/issue/{key}/worklog", {"timeSpentSeconds": r.choice([900, 1800, 3600]),
|
|
185
|
+
"comment": _sentence(r, 3)})
|
|
186
|
+
if blocks and r.random() < 0.08 and keys:
|
|
187
|
+
other = r.choice(keys)
|
|
188
|
+
if other != key:
|
|
189
|
+
client.post("/rest/api/2/issueLink", {"type": {"name": blocks["name"]}, "inwardIssue": {"key": key},
|
|
190
|
+
"outwardIssue": {"key": other}})
|
|
191
|
+
|
|
192
|
+
done = 0
|
|
193
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
194
|
+
futures = [pool.submit(enrich, k) for k in keys + subs]
|
|
195
|
+
for f in as_completed(futures):
|
|
196
|
+
try:
|
|
197
|
+
f.result()
|
|
198
|
+
except JiraError as exc:
|
|
199
|
+
log(f" enrich error: {exc}")
|
|
200
|
+
done += 1
|
|
201
|
+
if done % 50 == 0:
|
|
202
|
+
log(f" enriched {done}/{len(futures)}")
|
|
203
|
+
enrich_site(client, project=project, seed=seed, workers=workers, log=log)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _done_and_open_transitions(client: JiraClient, key: str) -> tuple[list[dict], list[dict]]:
|
|
207
|
+
ts = client.get(f"/rest/api/2/issue/{key}/transitions").get("transitions", [])
|
|
208
|
+
done = [t for t in ts if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"]
|
|
209
|
+
other = [t for t in ts if t not in done]
|
|
210
|
+
return done, other
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def enrich_site(client: JiraClient, *, project: str = "SQLM", seed: int = 3, workers: int = 4,
|
|
214
|
+
log: Callable[[str], None] = print) -> None:
|
|
215
|
+
"""Add the kinds of data report queries look for to existing issues:
|
|
216
|
+
assignees, Affects versions on bugs, estimates with logged time, attachments,
|
|
217
|
+
reopened issues, and sprints (closed, active and future)."""
|
|
218
|
+
rng = random.Random(seed)
|
|
219
|
+
me = client.myself()["accountId"]
|
|
220
|
+
issues = []
|
|
221
|
+
for page in client.search(f'project = "{project}" ORDER BY id ASC',
|
|
222
|
+
fields=["issuetype", "assignee", "status", "versions"], page_size=100):
|
|
223
|
+
issues.extend(page)
|
|
224
|
+
if not issues:
|
|
225
|
+
raise SystemExit(f"No issues in {project}; run load-site first.")
|
|
226
|
+
versions = client.get(f"/rest/api/2/project/{project}/versions") or []
|
|
227
|
+
counts: dict[str, int] = {}
|
|
228
|
+
errors: dict[str, str] = {}
|
|
229
|
+
|
|
230
|
+
def note(kind: str, fn) -> None:
|
|
231
|
+
try:
|
|
232
|
+
fn()
|
|
233
|
+
counts[kind] = counts.get(kind, 0) + 1
|
|
234
|
+
except JiraError as exc:
|
|
235
|
+
errors.setdefault(kind, str(exc)[:200])
|
|
236
|
+
|
|
237
|
+
def enrich(issue: dict[str, Any]) -> None:
|
|
238
|
+
key = issue["key"]
|
|
239
|
+
f = issue.get("fields") or {}
|
|
240
|
+
r = random.Random(f"{seed}-{key}")
|
|
241
|
+
if not f.get("assignee") and r.random() < 0.7:
|
|
242
|
+
note("assigned", lambda: client.request("PUT", f"/rest/api/2/issue/{key}/assignee", json={"accountId": me}))
|
|
243
|
+
if (f.get("issuetype") or {}).get("name") == "Bug" and versions and not f.get("versions"):
|
|
244
|
+
note("affects version", lambda: client.request(
|
|
245
|
+
"PUT", f"/rest/api/2/issue/{key}", json={"fields": {"versions": [{"id": r.choice(versions)["id"]}]}}))
|
|
246
|
+
if r.random() < 0.4:
|
|
247
|
+
note("estimate", lambda: client.request(
|
|
248
|
+
"PUT", f"/rest/api/2/issue/{key}", json={"fields": {"timetracking": {"originalEstimate": r.choice(["2h", "4h", "1d"])}}}))
|
|
249
|
+
note("worklog", lambda: client.post(f"/rest/api/2/issue/{key}/worklog",
|
|
250
|
+
{"timeSpentSeconds": r.choice([1800, 3600, 7200]), "comment": "Work"}))
|
|
251
|
+
done, _ = _done_and_open_transitions(client, key)
|
|
252
|
+
if done:
|
|
253
|
+
note("resolved", lambda: client.post(f"/rest/api/2/issue/{key}/transitions",
|
|
254
|
+
{"transition": {"id": done[0]["id"]}}))
|
|
255
|
+
if r.random() < 0.12:
|
|
256
|
+
def upload() -> None:
|
|
257
|
+
resp = client.http.post(f"/rest/api/2/issue/{key}/attachments",
|
|
258
|
+
files={"file": (f"{key}-notes.txt", f"Notes for {key}\n".encode(), "text/plain")},
|
|
259
|
+
headers={"X-Atlassian-Token": "no-check"})
|
|
260
|
+
if resp.status_code >= 400:
|
|
261
|
+
raise JiraError(resp.status_code, resp.text[:200])
|
|
262
|
+
note("attachment", upload)
|
|
263
|
+
if r.random() < 0.08:
|
|
264
|
+
def reopen() -> None:
|
|
265
|
+
done, _ = _done_and_open_transitions(client, key)
|
|
266
|
+
if done:
|
|
267
|
+
client.post(f"/rest/api/2/issue/{key}/transitions", {"transition": {"id": done[0]["id"]}})
|
|
268
|
+
_, back = _done_and_open_transitions(client, key)
|
|
269
|
+
if not back:
|
|
270
|
+
raise JiraError(400, "no transition out of Done")
|
|
271
|
+
client.post(f"/rest/api/2/issue/{key}/transitions", {"transition": {"id": back[0]["id"]}})
|
|
272
|
+
note("reopened", reopen)
|
|
273
|
+
|
|
274
|
+
log(f"Enriching {len(issues)} issues in {project}...")
|
|
275
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
276
|
+
for f in as_completed([pool.submit(enrich, i) for i in issues]):
|
|
277
|
+
f.result()
|
|
278
|
+
|
|
279
|
+
# Sprints: one closed, one active, one future (needs a Scrum board).
|
|
280
|
+
try:
|
|
281
|
+
boards = client.get("/rest/agile/1.0/board", projectKeyOrId=project).get("values", [])
|
|
282
|
+
board = next((b for b in boards if b.get("type") == "scrum"), None)
|
|
283
|
+
if not board:
|
|
284
|
+
errors["sprints"] = "no Scrum board for this project (create the project from the Scrum template)"
|
|
285
|
+
else:
|
|
286
|
+
keys = [i["key"] for i in issues if not (i["fields"].get("issuetype") or {}).get("subtask")]
|
|
287
|
+
rng.shuffle(keys)
|
|
288
|
+
today = date.today()
|
|
289
|
+
plan = [("closed", -28, keys[:40]), ("active", -7, keys[40:80]), ("future", 7, keys[80:110])]
|
|
290
|
+
for n, (state, offset, chunk) in enumerate(plan, 1):
|
|
291
|
+
start = today + timedelta(days=offset)
|
|
292
|
+
sprint = client.post("/rest/agile/1.0/sprint", {
|
|
293
|
+
"name": f"{project} Sprint {int(time.time()) % 10000}-{n}", "originBoardId": board["id"],
|
|
294
|
+
"goal": f"Sprint {n} goal"})
|
|
295
|
+
for i in range(0, len(chunk), 50):
|
|
296
|
+
client.post(f"/rest/agile/1.0/sprint/{sprint['id']}/issue", {"issues": chunk[i:i + 50]})
|
|
297
|
+
if state in ("active", "closed"):
|
|
298
|
+
client.post(f"/rest/agile/1.0/sprint/{sprint['id']}", {
|
|
299
|
+
"state": "active", "startDate": f"{start.isoformat()}T09:00:00.000Z",
|
|
300
|
+
"endDate": f"{(start + timedelta(days=14)).isoformat()}T17:00:00.000Z"})
|
|
301
|
+
if state == "closed":
|
|
302
|
+
client.post(f"/rest/agile/1.0/sprint/{sprint['id']}", {"state": "closed"})
|
|
303
|
+
counts[f"{state} sprint"] = len(chunk)
|
|
304
|
+
except JiraError as exc:
|
|
305
|
+
errors["sprints"] = str(exc)[:200]
|
|
306
|
+
|
|
307
|
+
log("Added: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
|
|
308
|
+
for kind, err in errors.items():
|
|
309
|
+
log(f" could not add {kind}: {err}")
|
|
310
|
+
log(f"Done. {client.api_call_summary()}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _issue_keys(client: JiraClient, project: str) -> list[str]:
|
|
314
|
+
keys = []
|
|
315
|
+
for page in client.search(f'project = "{project}" ORDER BY id ASC', fields=["id", "key"], page_size=5000):
|
|
316
|
+
keys.extend(i["key"] for i in page)
|
|
317
|
+
return keys
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _edit_once(client: JiraClient, rng: random.Random, key: str) -> str:
|
|
321
|
+
kind = rng.choice(["summary", "comment", "comment", "label", "worklog", "transition"])
|
|
322
|
+
if kind == "summary":
|
|
323
|
+
client.request("PUT", f"/rest/api/2/issue/{key}", json={"fields": {"summary": _sentence(rng, 5)}})
|
|
324
|
+
elif kind == "comment":
|
|
325
|
+
client.post(f"/rest/api/2/issue/{key}/comment", {"body": _sentence(rng, 8) + "."})
|
|
326
|
+
elif kind == "label":
|
|
327
|
+
client.request("PUT", f"/rest/api/2/issue/{key}", json={"update": {"labels": [{"add": rng.choice(WORDS)}]}})
|
|
328
|
+
elif kind == "worklog":
|
|
329
|
+
client.post(f"/rest/api/2/issue/{key}/worklog", {"timeSpentSeconds": 900, "comment": "Simulated work"})
|
|
330
|
+
else:
|
|
331
|
+
transitions = client.get(f"/rest/api/2/issue/{key}/transitions").get("transitions", [])
|
|
332
|
+
if transitions:
|
|
333
|
+
client.post(f"/rest/api/2/issue/{key}/transitions", {"transition": {"id": rng.choice(transitions)["id"]}})
|
|
334
|
+
return kind
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def simulate_edits(client: JiraClient, *, project: str, per_hour: int = 300, minutes: int = 60, seed: int = 2,
|
|
338
|
+
log: Callable[[str], None] = print) -> None:
|
|
339
|
+
"""Edit random issues at a steady rate. 300/hour is a busy 250-user team."""
|
|
340
|
+
rng = random.Random(seed)
|
|
341
|
+
keys = _issue_keys(client, project)
|
|
342
|
+
if not keys:
|
|
343
|
+
raise SystemExit(f"No issues in {project}; run load-site first.")
|
|
344
|
+
interval = 3600.0 / per_hour
|
|
345
|
+
end = time.time() + minutes * 60
|
|
346
|
+
n = 0
|
|
347
|
+
log(f"Editing {per_hour} times per hour for {minutes} minutes across {len(keys)} issues. Ctrl+C to stop.")
|
|
348
|
+
while time.time() < end:
|
|
349
|
+
started = time.time()
|
|
350
|
+
try:
|
|
351
|
+
kind = _edit_once(client, rng, rng.choice(keys))
|
|
352
|
+
n += 1
|
|
353
|
+
if n % 25 == 0:
|
|
354
|
+
log(f" {n} edits ({kind}); {client.api_call_summary()}")
|
|
355
|
+
except JiraError as exc:
|
|
356
|
+
log(f" edit failed: {exc}")
|
|
357
|
+
time.sleep(max(0.0, interval - (time.time() - started)))
|
|
358
|
+
log(f"Done: {n} edits.")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def bulk_edit(client: JiraClient, *, project: str, count: int = 5000, workers: int = 8,
|
|
362
|
+
log: Callable[[str], None] = print) -> None:
|
|
363
|
+
"""Add one label to `count` issues as fast as the site allows (each edit fires a Jira event)."""
|
|
364
|
+
keys = _issue_keys(client, project)[:count]
|
|
365
|
+
label = f"bulk-{int(time.time())}"
|
|
366
|
+
log(f"Adding label {label} to {len(keys)} issues with {workers} workers...")
|
|
367
|
+
started = time.time()
|
|
368
|
+
done = 0
|
|
369
|
+
|
|
370
|
+
def edit(key: str) -> None:
|
|
371
|
+
client.request("PUT", f"/rest/api/2/issue/{key}", json={"update": {"labels": [{"add": label}]}})
|
|
372
|
+
|
|
373
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
374
|
+
for f in as_completed([pool.submit(edit, k) for k in keys]):
|
|
375
|
+
try:
|
|
376
|
+
f.result()
|
|
377
|
+
except JiraError as exc:
|
|
378
|
+
log(f" {exc}")
|
|
379
|
+
done += 1
|
|
380
|
+
if done % 250 == 0:
|
|
381
|
+
log(f" {done}/{len(keys)}")
|
|
382
|
+
log(f"Edited {done} issues in {time.time() - started:.0f}s. Now watch the SQL Mirror admin page: every edit "
|
|
383
|
+
"fires an event, and sync jobs fetch them 100 per Jira call.")
|