sqlmirror 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
@@ -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,7 @@
1
+ -- Issues per issue type, sub-tasks flagged by pstyle
2
+ -- expect: rows
3
+ SELECT it.pname, it.pstyle, COUNT(*) AS issues
4
+ FROM jiraissue ji
5
+ JOIN issuetype it ON it.id = ji.issuetype
6
+ GROUP BY it.pname, it.pstyle
7
+ ORDER BY issues DESC;
@@ -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;
@@ -0,0 +1,7 @@
1
+ -- Project leads
2
+ -- expect: rows
3
+ SELECT p.pkey, p.pname, u.display_name AS lead
4
+ FROM project p
5
+ LEFT JOIN app_user au ON au.user_key = p.lead
6
+ LEFT JOIN cwd_user u ON u.lower_user_name = au.lower_user_name
7
+ 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"