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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Cascading select values (parentkey = '1' marks the child value)
|
|
2
|
+
-- expect: rows
|
|
3
|
+
-- needs: cascading select values
|
|
4
|
+
SELECT cf.cfname, parent_opt.customvalue AS parent, child_opt.customvalue AS child, COUNT(*) AS issues
|
|
5
|
+
FROM customfieldvalue pv
|
|
6
|
+
JOIN customfield cf ON cf.id = pv.customfield
|
|
7
|
+
AND cf.customfieldtypekey = 'com.atlassian.jira.plugin.system.customfieldtypes:cascadingselect'
|
|
8
|
+
JOIN customfieldoption parent_opt ON CAST(parent_opt.id AS NVARCHAR(255)) = pv.stringvalue
|
|
9
|
+
LEFT JOIN customfieldvalue cv ON cv.issue = pv.issue AND cv.customfield = pv.customfield AND cv.parentkey = '1'
|
|
10
|
+
LEFT JOIN customfieldoption child_opt ON CAST(child_opt.id AS NVARCHAR(255)) = cv.stringvalue
|
|
11
|
+
WHERE pv.parentkey IS NULL
|
|
12
|
+
GROUP BY cf.cfname, parent_opt.customvalue, child_opt.customvalue
|
|
13
|
+
ORDER BY 1, 2, 3;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- User picker custom fields resolved to display names
|
|
2
|
+
-- expect: rows
|
|
3
|
+
-- needs: user picker custom field values
|
|
4
|
+
SELECT cf.cfname, u.display_name, COUNT(*) AS issues
|
|
5
|
+
FROM customfieldvalue cfv
|
|
6
|
+
JOIN customfield cf ON cf.id = cfv.customfield
|
|
7
|
+
AND cf.customfieldtypekey IN ('com.atlassian.jira.plugin.system.customfieldtypes:userpicker',
|
|
8
|
+
'com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker')
|
|
9
|
+
JOIN app_user au ON au.user_key = cfv.stringvalue
|
|
10
|
+
JOIN cwd_user u ON u.lower_user_name = au.lower_user_name
|
|
11
|
+
GROUP BY cf.cfname, u.display_name
|
|
12
|
+
ORDER BY issues DESC;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Attachment volume per project
|
|
2
|
+
-- expect: rows
|
|
3
|
+
-- needs: attachments
|
|
4
|
+
SELECT p.pkey, COUNT(*) AS files, ROUND(SUM(fa.filesize) / 1048576.0, 1) AS megabytes
|
|
5
|
+
FROM fileattachment fa
|
|
6
|
+
JOIN jiraissue ji ON ji.id = fa.issueid
|
|
7
|
+
JOIN project p ON p.id = ji.project
|
|
8
|
+
GROUP BY p.pkey
|
|
9
|
+
ORDER BY p.pkey;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Projects and their categories
|
|
2
|
+
-- expect: rows
|
|
3
|
+
SELECT p.pkey, p.pname, pc.cname AS category
|
|
4
|
+
FROM project p
|
|
5
|
+
LEFT JOIN nodeassociation na ON na.source_node_id = p.id AND na.source_node_entity = 'Project'
|
|
6
|
+
AND na.association_type = 'ProjectCategory'
|
|
7
|
+
LEFT JOIN projectcategory pc ON pc.id = na.sink_node_id
|
|
8
|
+
ORDER BY p.pkey;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Original estimate vs time spent on resolved issues
|
|
2
|
+
-- expect: rows
|
|
3
|
+
-- needs: resolved issues with an estimate and logged time
|
|
4
|
+
SELECT p.pkey,
|
|
5
|
+
ROUND(SUM(ji.timeoriginalestimate) / 3600.0, 1) AS estimated_hours,
|
|
6
|
+
ROUND(SUM(ji.timespent) / 3600.0, 1) AS spent_hours
|
|
7
|
+
FROM jiraissue ji
|
|
8
|
+
JOIN project p ON p.id = ji.project
|
|
9
|
+
WHERE ji.resolutiondate IS NOT NULL AND ji.timeoriginalestimate IS NOT NULL AND ji.timespent IS NOT NULL
|
|
10
|
+
GROUP BY p.pkey
|
|
11
|
+
ORDER BY p.pkey;
|
sqlmirror/idmap.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Map Data Center ids to Cloud ids by name.
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. ``sqlmirror idmap export-sql --dialect sqlserver`` prints a query.
|
|
5
|
+
2. Run it on the Data Center database (before it is switched off) and save
|
|
6
|
+
the result as CSV with columns entity, dc_id, name, extra.
|
|
7
|
+
3. ``sqlmirror idmap build dc_export.csv`` matches each row to the mirror
|
|
8
|
+
by name and writes id_map.csv (entity, dc_id, cloud_id, name, match).
|
|
9
|
+
4. ``sqlmirror check-sql --id-map id_map.csv`` rewrites hard-coded ids.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterable
|
|
17
|
+
|
|
18
|
+
import sqlglot
|
|
19
|
+
|
|
20
|
+
EXPORT_SQL = """
|
|
21
|
+
SELECT 'status' AS entity, CAST(id AS VARCHAR(255)) AS dc_id, pname AS name, CAST(NULL AS VARCHAR(255)) AS extra FROM issuestatus
|
|
22
|
+
UNION ALL SELECT 'priority', CAST(id AS VARCHAR(255)), pname, NULL FROM priority
|
|
23
|
+
UNION ALL SELECT 'resolution', CAST(id AS VARCHAR(255)), pname, NULL FROM resolution
|
|
24
|
+
UNION ALL SELECT 'issuetype', CAST(id AS VARCHAR(255)), pname, NULL FROM issuetype
|
|
25
|
+
UNION ALL SELECT 'project', CAST(id AS VARCHAR(255)), pkey, pname FROM project
|
|
26
|
+
UNION ALL SELECT 'customfield', CAST(id AS VARCHAR(255)), cfname, customfieldtypekey FROM customfield
|
|
27
|
+
UNION ALL SELECT 'issuelinktype', CAST(id AS VARCHAR(255)), linkname, NULL FROM issuelinktype
|
|
28
|
+
UNION ALL SELECT 'version', CAST(v.id AS VARCHAR(255)), v.vname, p.pkey FROM projectversion v JOIN project p ON p.id = v.project
|
|
29
|
+
UNION ALL SELECT 'component', CAST(c.id AS VARCHAR(255)), c.cname, p.pkey FROM component c JOIN project p ON p.id = c.project
|
|
30
|
+
UNION ALL SELECT 'option', CAST(o.id AS VARCHAR(255)), o.customvalue, cf.cfname FROM customfieldoption o JOIN customfield cf ON cf.id = o.customfield
|
|
31
|
+
UNION ALL SELECT 'user', au.user_key, u.email_address, u.display_name FROM app_user au JOIN cwd_user u ON u.lower_user_name = au.lower_user_name
|
|
32
|
+
UNION ALL SELECT 'sprint', CAST(s.ID AS VARCHAR(255)), s.NAME, NULL FROM AO_60DB71_SPRINT s
|
|
33
|
+
""".strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def export_sql(dialect: str = "postgres") -> str:
|
|
37
|
+
from .sqlcheck import DIALECTS
|
|
38
|
+
|
|
39
|
+
target = DIALECTS[dialect.lower()]
|
|
40
|
+
tree = sqlglot.parse_one(EXPORT_SQL, read="mysql")
|
|
41
|
+
if target == "postgres":
|
|
42
|
+
# Active Objects tables are quoted upper-case on Postgres.
|
|
43
|
+
from sqlglot import exp
|
|
44
|
+
|
|
45
|
+
for t in tree.find_all(exp.Table):
|
|
46
|
+
if t.name.upper().startswith("AO_"):
|
|
47
|
+
t.set("this", exp.Identifier(this=t.name.upper(), quoted=True))
|
|
48
|
+
for c in tree.find_all(exp.Column):
|
|
49
|
+
if c.table == "s":
|
|
50
|
+
c.set("this", exp.Identifier(this=c.name.upper(), quoted=True))
|
|
51
|
+
return tree.sql(dialect=target, pretty=True) + ";"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IdMap:
|
|
55
|
+
def __init__(self, mapping: dict[tuple[str, str], str]):
|
|
56
|
+
self.mapping = mapping
|
|
57
|
+
|
|
58
|
+
def lookup(self, entity: str, dc_id: str | None) -> str | None:
|
|
59
|
+
if dc_id is None:
|
|
60
|
+
return None
|
|
61
|
+
key = dc_id if entity == "user" else str(dc_id).strip()
|
|
62
|
+
return self.mapping.get((entity, key))
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def load(cls, path: str | Path) -> "IdMap":
|
|
66
|
+
mapping = {}
|
|
67
|
+
with open(path, newline="", encoding="utf-8") as fh:
|
|
68
|
+
for row in csv.DictReader(fh):
|
|
69
|
+
if row.get("cloud_id"):
|
|
70
|
+
mapping[(row["entity"], row["dc_id"])] = row["cloud_id"]
|
|
71
|
+
return cls(mapping)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mirror_names(db: Any) -> dict[str, dict[tuple[str, ...], list[str]]]:
|
|
75
|
+
"""entity -> normalized name key -> cloud ids.
|
|
76
|
+
|
|
77
|
+
``db`` is a mirror (PgMirror / MssqlMirror) or a plain Postgres connection.
|
|
78
|
+
"""
|
|
79
|
+
conn = getattr(db, "conn", db)
|
|
80
|
+
if getattr(db, "dialect", "postgres") == "tsql":
|
|
81
|
+
T = db.t # schema-qualified, bracketed
|
|
82
|
+
|
|
83
|
+
def C(name: str) -> str:
|
|
84
|
+
return f"[{name}]"
|
|
85
|
+
else:
|
|
86
|
+
def T(name: str) -> str:
|
|
87
|
+
return f'"{name}"' if name.startswith("AO_") else name
|
|
88
|
+
|
|
89
|
+
def C(name: str) -> str:
|
|
90
|
+
return f'"{name}"' if name != name.lower() else name
|
|
91
|
+
queries = {
|
|
92
|
+
"status": f"SELECT id, pname FROM {T('issuestatus')}",
|
|
93
|
+
"priority": f"SELECT id, pname FROM {T('priority')}",
|
|
94
|
+
"resolution": f"SELECT id, pname FROM {T('resolution')}",
|
|
95
|
+
"issuetype": f"SELECT id, pname FROM {T('issuetype')}",
|
|
96
|
+
"project": f"SELECT id, pkey FROM {T('project')}",
|
|
97
|
+
"customfield": f"SELECT id, cfname FROM {T('customfield')}",
|
|
98
|
+
"issuelinktype": f"SELECT id, linkname FROM {T('issuelinktype')}",
|
|
99
|
+
"version": f"SELECT v.id, v.vname, p.pkey FROM {T('projectversion')} v JOIN {T('project')} p ON p.id = v.project",
|
|
100
|
+
"component": f"SELECT c.id, c.cname, p.pkey FROM {T('component')} c JOIN {T('project')} p ON p.id = c.project",
|
|
101
|
+
"option": f"SELECT o.id, o.customvalue, cf.cfname FROM {T('customfieldoption')} o "
|
|
102
|
+
f"JOIN {T('customfield')} cf ON cf.id = o.customfield",
|
|
103
|
+
"sprint": f"SELECT {C('ID')}, {C('NAME')} FROM {T('AO_60DB71_SPRINT')}",
|
|
104
|
+
}
|
|
105
|
+
out: dict[str, dict[tuple[str, ...], list[str]]] = {}
|
|
106
|
+
with conn.cursor() as cur:
|
|
107
|
+
for entity, q in queries.items():
|
|
108
|
+
index: dict[tuple[str, ...], list[str]] = defaultdict(list)
|
|
109
|
+
cur.execute(q)
|
|
110
|
+
for row in cur.fetchall():
|
|
111
|
+
key = tuple((str(v) if v is not None else "").strip().lower() for v in row[1:])
|
|
112
|
+
index[key].append(str(row[0]))
|
|
113
|
+
out[entity] = index
|
|
114
|
+
users_email: dict[tuple[str, ...], list[str]] = defaultdict(list)
|
|
115
|
+
users_name: dict[tuple[str, ...], list[str]] = defaultdict(list)
|
|
116
|
+
cur.execute(f"SELECT au.user_key, u.email_address, u.display_name FROM {T('app_user')} au "
|
|
117
|
+
f"JOIN {T('cwd_user')} u ON u.id = au.id")
|
|
118
|
+
for key, email, display in cur.fetchall():
|
|
119
|
+
if email:
|
|
120
|
+
users_email[(email.strip().lower(),)].append(key)
|
|
121
|
+
if display:
|
|
122
|
+
users_name[(display.strip().lower(),)].append(key)
|
|
123
|
+
out["user_email"] = users_email
|
|
124
|
+
out["user_name"] = users_name
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build(dc_rows: Iterable[dict[str, str]], db: Any) -> list[dict[str, str]]:
|
|
129
|
+
names = _mirror_names(db)
|
|
130
|
+
out = []
|
|
131
|
+
for row in dc_rows:
|
|
132
|
+
entity = (row.get("entity") or "").strip().lower()
|
|
133
|
+
dc_id = (row.get("dc_id") or "").strip()
|
|
134
|
+
name = (row.get("name") or "").strip()
|
|
135
|
+
extra = (row.get("extra") or "").strip()
|
|
136
|
+
match, cloud = "none", ""
|
|
137
|
+
if entity == "user":
|
|
138
|
+
for kind, index, key in (("email", names["user_email"], (name.lower(),)),
|
|
139
|
+
("display_name", names["user_name"], (extra.lower(),))):
|
|
140
|
+
ids = index.get(key, []) if key[0] else []
|
|
141
|
+
if len(ids) == 1:
|
|
142
|
+
match, cloud = kind, ids[0]
|
|
143
|
+
break
|
|
144
|
+
if len(ids) > 1:
|
|
145
|
+
match = "ambiguous"
|
|
146
|
+
elif entity in names:
|
|
147
|
+
key = (name.lower(), extra.lower()) if entity in ("version", "component", "option") else (name.lower(),)
|
|
148
|
+
ids = names[entity].get(key, [])
|
|
149
|
+
if len(ids) == 1:
|
|
150
|
+
match, cloud = "name", ids[0]
|
|
151
|
+
elif len(ids) > 1:
|
|
152
|
+
match = "ambiguous"
|
|
153
|
+
out.append({"entity": entity, "dc_id": dc_id, "cloud_id": cloud, "name": name, "match": match})
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_csv(rows: list[dict[str, str]], path: str | Path) -> None:
|
|
158
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
159
|
+
w = csv.DictWriter(fh, fieldnames=["entity", "dc_id", "cloud_id", "name", "match"])
|
|
160
|
+
w.writeheader()
|
|
161
|
+
w.writerows(rows)
|
sqlmirror/jira.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Jira Cloud REST client used by the CLI (backfill, reconcile).
|
|
2
|
+
|
|
3
|
+
Authenticates with the customer's own email + API token. The token is only
|
|
4
|
+
ever sent to the customer's Jira site from the customer's machine.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any, Iterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .util import iso, utcnow
|
|
18
|
+
|
|
19
|
+
ISSUE_FIELDS = ["*all"]
|
|
20
|
+
RATE_HEADER_HINTS = ("ratelimit", "retry-after", "rate-limit", "beta-retry")
|
|
21
|
+
_NUM = re.compile(r"/\d+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JiraError(RuntimeError):
|
|
25
|
+
def __init__(self, status: int, message: str):
|
|
26
|
+
super().__init__(f"Jira returned {status}: {message}")
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JiraClient:
|
|
31
|
+
def __init__(self, site: str, email: str | None, token: str | None, *, timeout: float = 60.0,
|
|
32
|
+
max_retries: int = 8, verbose: bool = False):
|
|
33
|
+
site = site.rstrip("/")
|
|
34
|
+
if not site.startswith("http"):
|
|
35
|
+
site = "https://" + site
|
|
36
|
+
self.site = site
|
|
37
|
+
auth = (email, token) if email and token else None
|
|
38
|
+
self.http = httpx.Client(base_url=site, auth=auth, timeout=timeout,
|
|
39
|
+
headers={"Accept": "application/json", "User-Agent": "sqlmirror-cli/0.1"})
|
|
40
|
+
self.max_retries = max_retries
|
|
41
|
+
self.verbose = verbose
|
|
42
|
+
self.calls: Counter[str] = Counter()
|
|
43
|
+
self.rate_limited = 0
|
|
44
|
+
self.last_rate_headers: dict[str, str] = {}
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
self.http.close()
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------- transport
|
|
51
|
+
def request(self, method: str, path: str, *, params: dict[str, Any] | None = None,
|
|
52
|
+
json: Any = None, allow_404: bool = False) -> Any:
|
|
53
|
+
attempt = 0
|
|
54
|
+
while True:
|
|
55
|
+
try:
|
|
56
|
+
resp = self.http.request(method, path, params=params, json=json)
|
|
57
|
+
except httpx.TransportError as exc:
|
|
58
|
+
attempt += 1
|
|
59
|
+
if attempt > self.max_retries:
|
|
60
|
+
raise
|
|
61
|
+
time.sleep(min(60, 2 ** attempt) + random.random())
|
|
62
|
+
if self.verbose:
|
|
63
|
+
print(f" network error on {path}: {exc}; retrying")
|
|
64
|
+
continue
|
|
65
|
+
with self._lock:
|
|
66
|
+
self.calls[_NUM.sub("/{id}", path.split("?")[0])] += 1
|
|
67
|
+
hdrs = {k: v for k, v in resp.headers.items() if any(h in k.lower() for h in RATE_HEADER_HINTS)}
|
|
68
|
+
if hdrs:
|
|
69
|
+
self.last_rate_headers = hdrs
|
|
70
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
71
|
+
attempt += 1
|
|
72
|
+
if resp.status_code == 429:
|
|
73
|
+
with self._lock:
|
|
74
|
+
self.rate_limited += 1
|
|
75
|
+
if attempt > self.max_retries:
|
|
76
|
+
raise JiraError(resp.status_code, resp.text[:300])
|
|
77
|
+
retry_after = resp.headers.get("Retry-After")
|
|
78
|
+
try:
|
|
79
|
+
delay = float(retry_after) if retry_after else min(60, 2 ** attempt)
|
|
80
|
+
except ValueError:
|
|
81
|
+
delay = min(60, 2 ** attempt)
|
|
82
|
+
delay = delay * (1 + random.random() * 0.3)
|
|
83
|
+
if self.verbose:
|
|
84
|
+
print(f" {resp.status_code} on {path}; waiting {delay:.1f}s")
|
|
85
|
+
time.sleep(delay)
|
|
86
|
+
continue
|
|
87
|
+
if resp.status_code == 404 and allow_404:
|
|
88
|
+
return None
|
|
89
|
+
if resp.status_code >= 400:
|
|
90
|
+
raise JiraError(resp.status_code, resp.text[:500])
|
|
91
|
+
if not resp.content:
|
|
92
|
+
return None
|
|
93
|
+
return resp.json()
|
|
94
|
+
|
|
95
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
96
|
+
return self.request("GET", path, params=params or None)
|
|
97
|
+
|
|
98
|
+
def post(self, path: str, body: Any) -> Any:
|
|
99
|
+
return self.request("POST", path, json=body)
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------- helpers
|
|
102
|
+
def paged_values(self, path: str, page_size: int = 50, **params: Any) -> Iterator[dict[str, Any]]:
|
|
103
|
+
"""Iterate a PageBean endpoint (startAt / maxResults / isLast / values)."""
|
|
104
|
+
start = 0
|
|
105
|
+
while True:
|
|
106
|
+
page = self.get(path, startAt=start, maxResults=page_size, **params)
|
|
107
|
+
values = page.get("values") or []
|
|
108
|
+
yield from values
|
|
109
|
+
start += len(values)
|
|
110
|
+
if page.get("isLast", True) or not values or start >= page.get("total", 10**12):
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
def myself(self) -> dict[str, Any]:
|
|
114
|
+
return self.get("/rest/api/2/myself")
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------- metadata
|
|
117
|
+
def metadata(self) -> dict[str, Any]:
|
|
118
|
+
"""Configuration objects the mirror needs. Same shape the Forge app writes."""
|
|
119
|
+
fetched = iso(utcnow())
|
|
120
|
+
projects = list(self.paged_values("/rest/api/2/project/search", 50, expand="lead,description"))
|
|
121
|
+
try:
|
|
122
|
+
priorities = list(self.paged_values("/rest/api/2/priority/search", 50))
|
|
123
|
+
except JiraError:
|
|
124
|
+
priorities = self.get("/rest/api/2/priority") or []
|
|
125
|
+
try:
|
|
126
|
+
resolutions = list(self.paged_values("/rest/api/2/resolution/search", 50))
|
|
127
|
+
except JiraError:
|
|
128
|
+
resolutions = self.get("/rest/api/2/resolution") or []
|
|
129
|
+
link_types = (self.get("/rest/api/2/issueLinkType") or {}).get("issueLinkTypes", [])
|
|
130
|
+
versions, components = {}, {}
|
|
131
|
+
for p in projects:
|
|
132
|
+
versions[p["id"]] = self.request("GET", f"/rest/api/2/project/{p['id']}/versions", allow_404=True) or []
|
|
133
|
+
components[p["id"]] = self.request("GET", f"/rest/api/2/project/{p['id']}/components",
|
|
134
|
+
allow_404=True) or []
|
|
135
|
+
return {
|
|
136
|
+
"v": 1, "type": "metadata", "fetchedAt": fetched,
|
|
137
|
+
"projects": projects,
|
|
138
|
+
"statuses": self.get("/rest/api/2/status") or [],
|
|
139
|
+
"priorities": priorities,
|
|
140
|
+
"resolutions": resolutions,
|
|
141
|
+
"issueTypes": self.get("/rest/api/2/issuetype") or [],
|
|
142
|
+
"fields": self.get("/rest/api/2/field") or [],
|
|
143
|
+
"issueLinkTypes": link_types,
|
|
144
|
+
"projectCategories": self.request("GET", "/rest/api/2/projectCategory", allow_404=True) or [],
|
|
145
|
+
"versions": versions,
|
|
146
|
+
"components": components,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------- issues
|
|
150
|
+
def search(self, jql: str, *, fields: list[str], expand: str | None = None,
|
|
151
|
+
page_size: int = 100) -> Iterator[list[dict[str, Any]]]:
|
|
152
|
+
"""Enhanced JQL search (POST /rest/api/2/search/jql), one page at a time."""
|
|
153
|
+
token = None
|
|
154
|
+
while True:
|
|
155
|
+
body: dict[str, Any] = {"jql": jql, "fields": fields, "maxResults": page_size}
|
|
156
|
+
if expand:
|
|
157
|
+
body["expand"] = expand
|
|
158
|
+
if token:
|
|
159
|
+
body["nextPageToken"] = token
|
|
160
|
+
page = self.post("/rest/api/2/search/jql", body)
|
|
161
|
+
issues = page.get("issues") or []
|
|
162
|
+
if issues:
|
|
163
|
+
yield issues
|
|
164
|
+
token = page.get("nextPageToken")
|
|
165
|
+
if page.get("isLast", not token) or not token:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
def approximate_count(self, jql: str) -> int | None:
|
|
169
|
+
try:
|
|
170
|
+
res = self.post("/rest/api/2/search/approximate-count", {"jql": jql})
|
|
171
|
+
return int(res.get("count")) if res and res.get("count") is not None else None
|
|
172
|
+
except JiraError:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def bulkfetch(self, ids: list[str]) -> list[dict[str, Any]]:
|
|
176
|
+
if not ids:
|
|
177
|
+
return []
|
|
178
|
+
res = self.post("/rest/api/2/issue/bulkfetch",
|
|
179
|
+
{"issueIdsOrKeys": [str(i) for i in ids], "fields": ISSUE_FIELDS, "expand": ["changelog"]})
|
|
180
|
+
return (res or {}).get("issues") or []
|
|
181
|
+
|
|
182
|
+
def complete_snapshot(self, issue: dict[str, Any], fetched_at: str) -> dict[str, Any]:
|
|
183
|
+
"""Turn a search/bulkfetch result into a snapshot with complete lists."""
|
|
184
|
+
iid = str(issue["id"])
|
|
185
|
+
fields = issue.setdefault("fields", {})
|
|
186
|
+
|
|
187
|
+
changelog = issue.pop("changelog", None) or {}
|
|
188
|
+
histories = changelog.get("histories") or []
|
|
189
|
+
if changelog.get("total", len(histories)) > len(histories):
|
|
190
|
+
histories = list(self.paged_values(f"/rest/api/2/issue/{iid}/changelog", 100))
|
|
191
|
+
|
|
192
|
+
cwrap = fields.pop("comment", None) or {}
|
|
193
|
+
comments = cwrap.get("comments") or []
|
|
194
|
+
if cwrap.get("total", len(comments)) > len(comments):
|
|
195
|
+
comments = []
|
|
196
|
+
start = 0
|
|
197
|
+
while True:
|
|
198
|
+
page = self.get(f"/rest/api/2/issue/{iid}/comment", startAt=start, maxResults=100, orderBy="created")
|
|
199
|
+
batch = page.get("comments") or []
|
|
200
|
+
comments.extend(batch)
|
|
201
|
+
start += len(batch)
|
|
202
|
+
if not batch or start >= page.get("total", 0):
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
wwrap = fields.pop("worklog", None) or {}
|
|
206
|
+
worklogs = wwrap.get("worklogs") or []
|
|
207
|
+
if wwrap.get("total", len(worklogs)) > len(worklogs):
|
|
208
|
+
worklogs = []
|
|
209
|
+
start = 0
|
|
210
|
+
while True:
|
|
211
|
+
page = self.get(f"/rest/api/2/issue/{iid}/worklog", startAt=start, maxResults=1000)
|
|
212
|
+
batch = page.get("worklogs") or []
|
|
213
|
+
worklogs.extend(batch)
|
|
214
|
+
start += len(batch)
|
|
215
|
+
if not batch or start >= page.get("total", 0):
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
return {"v": 1, "type": "issue", "id": iid, "fetchedAt": fetched_at, "issue": issue,
|
|
219
|
+
"changelog": histories, "comments": comments, "worklogs": worklogs}
|
|
220
|
+
|
|
221
|
+
def api_call_summary(self) -> str:
|
|
222
|
+
total = sum(self.calls.values())
|
|
223
|
+
return f"{total} Jira API calls, {self.rate_limited} rate-limited responses"
|