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,182 @@
1
+ """Run the golden Data Center report queries against a mirror.
2
+
3
+ golden/*.sql are the reports as a Data Center customer on Postgres writes
4
+ them; golden/tsql/*.sql are the same reports as a customer on SQL Server
5
+ writes them (TOP, CROSS APPLY, + with CAST, DATEDIFF). Each mirror runs the
6
+ set for its own database, and ``compare_golden`` checks that both sets give
7
+ the same answers on the same data.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import time
13
+ from datetime import date, datetime, timezone
14
+ from decimal import Decimal
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ GOLDEN_DIR = Path(__file__).resolve().parent.parent / "golden"
19
+ TSQL_GOLDEN_DIR = GOLDEN_DIR / "tsql"
20
+
21
+
22
+ def load_golden(directory: Path = GOLDEN_DIR) -> list[dict[str, Any]]:
23
+ out = []
24
+ for path in sorted(directory.glob("*.sql")):
25
+ text = path.read_text()
26
+ title = text.splitlines()[0].lstrip("- ").strip() if text.startswith("--") else path.stem
27
+ expect = "rows" if "-- expect: rows" in text else "any"
28
+ needs = None
29
+ for line in text.splitlines():
30
+ if line.startswith("-- needs:"):
31
+ needs = line.split(":", 1)[1].strip()
32
+ out.append({"name": path.stem, "title": title, "sql": text, "expect": expect, "needs": needs})
33
+ return out
34
+
35
+
36
+ def _status(q: dict[str, Any], rows: list, strict: bool) -> tuple[str, str | None]:
37
+ if rows or q["expect"] == "any":
38
+ return "pass", None
39
+ if q["needs"] and not strict:
40
+ return "empty", f"no {q['needs']} on this site yet"
41
+ return "fail", "query returned no rows"
42
+
43
+
44
+ def run_golden(dsn: Any, directory: Path | None = None, strict: bool = True) -> list[dict[str, Any]]:
45
+ """Run every golden query.
46
+
47
+ ``dsn`` is a connection string (postgresql://... or sqlserver://...) or an
48
+ open mirror. status: "pass" (rows returned), "empty" (no rows, and the
49
+ query needs data the site may simply not have; only allowed when
50
+ strict=False), or "fail" (SQL error, or no rows where rows are required).
51
+ """
52
+ from ..db import is_sqlserver_dsn
53
+
54
+ if not isinstance(dsn, str):
55
+ return _run_on_mirror(dsn, directory, strict)
56
+ if is_sqlserver_dsn(dsn):
57
+ from ..db import open_mirror
58
+
59
+ db = open_mirror(dsn)
60
+ try:
61
+ return _run_on_mirror(db, directory, strict)
62
+ finally:
63
+ db.close()
64
+ return _run_postgres(dsn, directory or GOLDEN_DIR, strict)
65
+
66
+
67
+ def _run_postgres(dsn: str, directory: Path, strict: bool) -> list[dict[str, Any]]:
68
+ import psycopg
69
+
70
+ results = []
71
+ with psycopg.connect(dsn) as conn:
72
+ for q in load_golden(directory):
73
+ started = time.time()
74
+ try:
75
+ with conn.cursor() as cur:
76
+ cur.execute("SET LOCAL statement_timeout = '60s'")
77
+ cur.execute(q["sql"])
78
+ rows = cur.fetchall()
79
+ conn.rollback()
80
+ status, error = _status(q, rows, strict)
81
+ results.append({**q, "ok": status != "fail", "status": status, "rows": len(rows), "sample": rows[:3],
82
+ "data": rows, "error": error, "ms": int((time.time() - started) * 1000)})
83
+ except psycopg.Error as exc:
84
+ conn.rollback()
85
+ results.append({**q, "ok": False, "status": "fail", "rows": 0, "sample": [], "data": [],
86
+ "error": str(exc).strip(), "ms": int((time.time() - started) * 1000)})
87
+ return results
88
+
89
+
90
+ def _run_on_mirror(db: Any, directory: Path | None, strict: bool) -> list[dict[str, Any]]:
91
+ if directory is None:
92
+ directory = TSQL_GOLDEN_DIR if db.dialect == "tsql" else GOLDEN_DIR
93
+ results = []
94
+ for q in load_golden(directory):
95
+ started = time.time()
96
+ try:
97
+ rows = [tuple(r) for r in db.query(q["sql"])]
98
+ status, error = _status(q, rows, strict)
99
+ results.append({**q, "ok": status != "fail", "status": status, "rows": len(rows), "sample": rows[:3],
100
+ "data": rows, "error": error, "ms": int((time.time() - started) * 1000)})
101
+ except Exception as exc: # noqa: BLE001 - any driver's error
102
+ results.append({**q, "ok": False, "status": "fail", "rows": 0, "sample": [], "data": [],
103
+ "error": str(exc).strip().splitlines()[0] if str(exc).strip() else repr(exc),
104
+ "ms": int((time.time() - started) * 1000)})
105
+ return results
106
+
107
+
108
+ # ------------------------------------------------------- cross-database check
109
+ def _norm(v: Any) -> Any:
110
+ """One value in a form both databases agree on."""
111
+ if v is None:
112
+ return None
113
+ if isinstance(v, bool):
114
+ return int(v)
115
+ if isinstance(v, datetime):
116
+ if v.tzinfo is not None:
117
+ v = v.astimezone(timezone.utc).replace(tzinfo=None)
118
+ # SQL Server datetime keeps 1/300 s; compare at that resolution.
119
+ ticks = round((v - datetime(1900, 1, 1)).total_seconds() * 300)
120
+ return ("ts", ticks)
121
+ if isinstance(v, date):
122
+ return _norm(datetime(v.year, v.month, v.day))
123
+ if isinstance(v, (int, Decimal, float)):
124
+ f = float(v)
125
+ if math.isfinite(f) and f == int(f) and abs(f) < 2**53:
126
+ return int(f)
127
+ return round(f, 6)
128
+ return v
129
+
130
+
131
+ def _close(a: Any, b: Any) -> bool:
132
+ if isinstance(a, tuple) and isinstance(b, tuple) and a and a[0] == "ts" and b and b[0] == "ts":
133
+ return abs(a[1] - b[1]) <= 1
134
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool):
135
+ # Reports round to one decimal; second-vs-millisecond date maths can tip the rounding.
136
+ return abs(a - b) <= 0.1 + 1e-9
137
+ return a == b
138
+
139
+
140
+ def _rows_match(a: list[tuple], b: list[tuple]) -> bool:
141
+ if len(a) != len(b):
142
+ return False
143
+ key = lambda r: tuple((0, "") if x is None else (1, str(x)) for x in r) # noqa: E731
144
+ left = sorted(([_norm(v) for v in r] for r in a), key=key)
145
+ right = sorted(([_norm(v) for v in r] for r in b), key=key)
146
+ if all(len(x) == len(y) and all(_close(p, q) for p, q in zip(x, y)) for x, y in zip(left, right)):
147
+ return True
148
+ # Sorting can pair rows differently when values are only "close"; fall back to greedy matching.
149
+ pool = list(right)
150
+ for row in left:
151
+ hit = next((i for i, cand in enumerate(pool)
152
+ if len(cand) == len(row) and all(_close(p, q) for p, q in zip(row, cand))), None)
153
+ if hit is None:
154
+ return False
155
+ pool.pop(hit)
156
+ return True
157
+
158
+
159
+ # Queries whose TOP / LIMIT cuts through ties, so which rows come back is not defined.
160
+ TIE_BREAK_UNDEFINED = {"02_issue_keys"}
161
+
162
+
163
+ def compare_golden(a: list[dict[str, Any]], b: list[dict[str, Any]]) -> list[dict[str, Any]]:
164
+ """Compare two run_golden results (e.g. Postgres vs SQL Server) query by query."""
165
+ by_name = {r["name"]: r for r in b}
166
+ out = []
167
+ for ra in a:
168
+ rb = by_name.get(ra["name"])
169
+ if rb is None:
170
+ out.append({"name": ra["name"], "verdict": "missing", "detail": "no matching query"})
171
+ continue
172
+ if not (ra["ok"] and rb["ok"]):
173
+ out.append({"name": ra["name"], "verdict": "error", "detail": ra["error"] or rb["error"]})
174
+ elif ra["rows"] != rb["rows"]:
175
+ out.append({"name": ra["name"], "verdict": "different", "detail": f"{ra['rows']} vs {rb['rows']} rows"})
176
+ elif ra["name"] in TIE_BREAK_UNDEFINED or _rows_match(ra["data"], rb["data"]):
177
+ out.append({"name": ra["name"], "verdict": "same", "detail": f"{ra['rows']} rows"})
178
+ else:
179
+ diff = next(((x, y) for x, y in zip(sorted(ra["data"], key=str), sorted(rb["data"], key=str))
180
+ if [_norm(v) for v in x] != [_norm(v) for v in y]), None)
181
+ out.append({"name": ra["name"], "verdict": "different", "detail": f"same row count, values differ: {diff}"})
182
+ return out
@@ -0,0 +1,347 @@
1
+ """A small HTTP server that imitates the Jira Cloud REST endpoints the mirror uses.
2
+
3
+ It serves a synthetic tenant (see ``synth``) so the CLI backfill and the Forge
4
+ app's sync code can run end to end without a real Jira site. It deliberately
5
+ truncates embedded lists the way Jira does (changelog, comments, worklogs) so
6
+ pagination code is exercised, and can inject 429 responses.
7
+
8
+ Endpoints: myself, project/search, status, priority/search, resolution/search,
9
+ issuetype, field, issueLinkType, projectCategory, project/{id}/versions,
10
+ project/{id}/components, search/jql, search/approximate-count, issue/bulkfetch,
11
+ issue/{id}, issue/{id}/changelog, issue/{id}/comment, issue/{id}/worklog.
12
+ Test-only: POST /__mock/mutate, GET /__mock/stats.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import random
19
+ import re
20
+ import threading
21
+ from collections import Counter
22
+ from datetime import datetime, timedelta, timezone
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from typing import Any
25
+ from urllib.parse import parse_qs, urlparse
26
+
27
+ from .synth import Tenant, mutate
28
+
29
+ EMBED_CHANGELOG = 40 # histories embedded in search/bulkfetch results
30
+ EMBED_COMMENTS = 10 # comments embedded in the comment field
31
+ EMBED_WORKLOGS = 20 # worklogs embedded in the worklog field (Jira's real limit)
32
+
33
+
34
+ def _token(offset: int) -> str:
35
+ return base64.urlsafe_b64encode(str(offset).encode()).decode()
36
+
37
+
38
+ def _offset(token: str | None) -> int:
39
+ if not token:
40
+ return 0
41
+ return int(base64.urlsafe_b64decode(token.encode()).decode())
42
+
43
+
44
+ def _parse_jira_time(s: str) -> datetime:
45
+ return datetime.fromisoformat(s.replace("+0000", "+00:00"))
46
+
47
+
48
+ class JqlError(ValueError):
49
+ pass
50
+
51
+
52
+ _COND = re.compile(r"^\s*(\w+)\s*(=|!=|>=|<=|>|<|\bin\b|\bIN\b)\s*(.+?)\s*$", re.IGNORECASE)
53
+
54
+
55
+ def _values(raw: str) -> list[str]:
56
+ raw = raw.strip()
57
+ if raw.startswith("("):
58
+ raw = raw.strip("()")
59
+ return [v.strip().strip('"').strip("'") for v in raw.split(",") if v.strip()]
60
+ return [raw.strip('"').strip("'")]
61
+
62
+
63
+ def _relative_or_absolute(value: str, now: datetime) -> datetime:
64
+ m = re.fullmatch(r"-(\d+)([mhdw])", value.strip())
65
+ if m:
66
+ n, unit = int(m.group(1)), m.group(2)
67
+ delta = {"m": timedelta(minutes=n), "h": timedelta(hours=n), "d": timedelta(days=n), "w": timedelta(weeks=n)}
68
+ return now - delta[unit]
69
+ v = value.replace("/", "-")
70
+ try:
71
+ dt = datetime.fromisoformat(v)
72
+ except ValueError as exc:
73
+ raise JqlError(f"bad date {value}") from exc
74
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
75
+
76
+
77
+ def compile_jql(jql: str, now: datetime | None = None):
78
+ """Compile the JQL subset the mirror generates into (predicate, sort key, descending)."""
79
+ now = now or datetime.now(timezone.utc)
80
+ order_field, desc = "id", False
81
+ m = re.search(r"\border\s+by\s+(\w+)(\s+(asc|desc))?\s*$", jql, re.IGNORECASE)
82
+ if m:
83
+ order_field = m.group(1).lower()
84
+ desc = (m.group(3) or "").lower() == "desc"
85
+ jql = jql[: m.start()]
86
+ preds = []
87
+ if jql.strip():
88
+ for clause in re.split(r"\s+AND\s+", jql.strip(), flags=re.IGNORECASE):
89
+ cm = _COND.match(clause)
90
+ if not cm:
91
+ raise JqlError(f"unsupported clause: {clause}")
92
+ field, op, raw = cm.group(1).lower(), cm.group(2).lower(), cm.group(3)
93
+ vals = _values(raw)
94
+ preds.append((field, op, vals))
95
+ if not preds:
96
+ raise JqlError("Unbounded JQL queries are not allowed here. Please add a search restriction to your query.")
97
+
98
+ def pred(issue: dict[str, Any]) -> bool:
99
+ f = issue["fields"]
100
+ for field, op, vals in preds:
101
+ if field == "project":
102
+ actual = [f["project"]["key"], f["project"]["id"]]
103
+ ok = any(v.upper() in (a.upper() for a in actual) for v in vals)
104
+ if op == "!=":
105
+ ok = not ok
106
+ elif field in ("id", "issuekey", "key"):
107
+ if field == "id" and op in (">", ">=", "<", "<="):
108
+ a, b = int(issue["id"]), int(vals[0])
109
+ ok = {">": a > b, ">=": a >= b, "<": a < b, "<=": a <= b}[op]
110
+ else:
111
+ ok = issue["id"] in vals or issue["key"] in vals
112
+ elif field in ("updated", "created"):
113
+ a = _parse_jira_time(f[field])
114
+ b = _relative_or_absolute(vals[0], now)
115
+ ok = {">": a > b, ">=": a >= b, "<": a < b, "<=": a <= b, "=": a == b}[op]
116
+ else:
117
+ raise JqlError(f"unsupported field {field}")
118
+ if not ok:
119
+ return False
120
+ return True
121
+
122
+ def sort_key(issue: dict[str, Any]):
123
+ if order_field in ("id",):
124
+ return int(issue["id"])
125
+ if order_field == "key":
126
+ k = issue["key"].rsplit("-", 1)
127
+ return (k[0], int(k[1]))
128
+ return issue["fields"].get(order_field) or ""
129
+
130
+ return pred, sort_key, desc
131
+
132
+
133
+ class MockJira:
134
+ def __init__(self, tenant: Tenant, flaky: float = 0.0, seed: int = 1):
135
+ self.tenant = tenant
136
+ self.lock = threading.RLock()
137
+ self.flaky = flaky
138
+ self.rng = random.Random(seed)
139
+ self.calls: Counter[str] = Counter()
140
+
141
+ # ----------------------------------------------------------- formatting
142
+ def issue_json(self, iid: str, fields_req: list[str] | None, expand: list[str]) -> dict[str, Any]:
143
+ t = self.tenant
144
+ issue = t.issues[iid]
145
+ out: dict[str, Any] = {"expand": "renderedFields,names,schema,operations,editmeta,changelog",
146
+ "id": iid, "self": f"{t.data['site']}/rest/api/2/issue/{iid}", "key": issue["key"]}
147
+ want_all = fields_req is None or "*all" in fields_req or "*navigable" in fields_req
148
+ requested = set(fields_req or [])
149
+ excluded = {f[1:] for f in requested if f.startswith("-")}
150
+ if want_all or (requested - {"id", "key"} - {f for f in requested if f.startswith("-")}):
151
+ full = t.full_fields(iid)
152
+ comments = full["comment"]["comments"]
153
+ if len(comments) > EMBED_COMMENTS:
154
+ full["comment"]["comments"] = comments[:EMBED_COMMENTS]
155
+ full["comment"]["maxResults"] = EMBED_COMMENTS
156
+ if want_all:
157
+ fields = {k: v for k, v in full.items() if k not in excluded}
158
+ else:
159
+ fields = {k: v for k, v in full.items() if k in requested}
160
+ out["fields"] = fields
161
+ else:
162
+ out["fields"] = {}
163
+ if "changelog" in expand:
164
+ hist = t.data["changelogs"].get(iid, [])
165
+ out["changelog"] = {"startAt": 0, "maxResults": min(len(hist), EMBED_CHANGELOG), "total": len(hist),
166
+ "histories": json.loads(json.dumps(hist[:EMBED_CHANGELOG]))}
167
+ return out
168
+
169
+ # ------------------------------------------------------------- handlers
170
+ def handle(self, method: str, path: str, query: dict[str, list[str]], body: Any) -> tuple[int, Any, dict[str, str]]:
171
+ t = self.tenant
172
+ d = t.data
173
+ q = {k: v[0] for k, v in query.items()}
174
+ headers: dict[str, str] = {}
175
+ norm = re.sub(r"/\d+", "/{id}", path)
176
+ self.calls[f"{method} {norm}"] += 1
177
+ if self.flaky and not path.startswith("/__mock") and self.rng.random() < self.flaky:
178
+ return 429, {"errorMessages": ["Rate limit exceeded."]}, {"Retry-After": "1"}
179
+ headers.update({"X-RateLimit-Limit": "350", "X-RateLimit-Remaining": "349",
180
+ "X-RateLimit-Reset": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")})
181
+
182
+ def paged(values: list[Any], default: int = 50):
183
+ start = int(q.get("startAt", 0))
184
+ size = min(int(q.get("maxResults", default)), default)
185
+ chunk = values[start:start + size]
186
+ return {"self": "", "maxResults": size, "startAt": start, "total": len(values),
187
+ "isLast": start + size >= len(values), "values": chunk}
188
+
189
+ with self.lock:
190
+ if path == "/__mock/mutate" and method == "POST":
191
+ body = body or {}
192
+ events = mutate(t, edits=body.get("edits", 20), deletes=body.get("deletes", 3),
193
+ creates=body.get("creates", 5), links=body.get("links", 4), seed=body.get("seed", 99))
194
+ return 200, {"events": events}, headers
195
+ if path == "/__mock/stats":
196
+ return 200, {"calls": dict(self.calls), "issues": len(t.issues)}, headers
197
+ if path == "/rest/api/2/mypermissions":
198
+ return 200, {"permissions": {"ADMINISTER": {"key": "ADMINISTER", "havePermission": True}}}, headers
199
+ if path == "/rest/api/2/serverInfo":
200
+ return 200, {"baseUrl": d["site"], "deploymentType": "Cloud", "version": "1001.0.0"}, headers
201
+ if path == "/rest/api/2/myself":
202
+ u = d["users"][0]
203
+ return 200, u, headers
204
+ if path == "/rest/api/2/project/search":
205
+ return 200, paged(d["projects"], 50), headers
206
+ if path == "/rest/api/2/status":
207
+ return 200, d["statuses"], headers
208
+ if path == "/rest/api/2/priority/search":
209
+ return 200, paged(d["priorities"], 50), headers
210
+ if path == "/rest/api/2/resolution/search":
211
+ return 200, paged(d["resolutions"], 50), headers
212
+ if path == "/rest/api/2/issuetype":
213
+ return 200, d["issueTypes"], headers
214
+ if path == "/rest/api/2/field":
215
+ return 200, d["fields"], headers
216
+ if path == "/rest/api/2/issueLinkType":
217
+ return 200, {"issueLinkTypes": d["issueLinkTypes"]}, headers
218
+ if path == "/rest/api/2/projectCategory":
219
+ return 200, d["projectCategories"], headers
220
+ m = re.fullmatch(r"/rest/api/2/project/(\d+)/(versions|components)", path)
221
+ if m:
222
+ return 200, d[m.group(2)].get(m.group(1), []), headers
223
+
224
+ if path in ("/rest/api/2/search/jql", "/rest/api/3/search/jql") and method in ("POST", "GET"):
225
+ body = body or {}
226
+ if method == "GET":
227
+ body = {"jql": q.get("jql", ""), "fields": (q.get("fields") or "").split(",") if q.get("fields") else None,
228
+ "expand": q.get("expand"), "maxResults": int(q.get("maxResults", 50)),
229
+ "nextPageToken": q.get("nextPageToken")}
230
+ try:
231
+ pred, key, desc = compile_jql(body.get("jql", ""))
232
+ except JqlError as exc:
233
+ return 400, {"errorMessages": [str(exc)]}, headers
234
+ fields_req = body.get("fields") or ["id"]
235
+ if isinstance(fields_req, str):
236
+ fields_req = fields_req.split(",")
237
+ expand = body.get("expand") or ""
238
+ expand = expand.split(",") if isinstance(expand, str) else list(expand)
239
+ matched = sorted((i for i in t.issues.values() if pred(i)), key=key, reverse=desc)
240
+ id_only = set(fields_req) <= {"id", "key"}
241
+ cap = 5000 if id_only else 100
242
+ size = min(int(body.get("maxResults") or 50), cap)
243
+ start = _offset(body.get("nextPageToken"))
244
+ chunk = matched[start:start + size]
245
+ issues = [self.issue_json(i["id"], fields_req, expand) for i in chunk]
246
+ is_last = start + size >= len(matched)
247
+ res = {"issues": issues, "isLast": is_last}
248
+ if not is_last:
249
+ res["nextPageToken"] = _token(start + size)
250
+ return 200, res, headers
251
+
252
+ if path == "/rest/api/2/search/approximate-count":
253
+ try:
254
+ pred, _, _ = compile_jql((body or {}).get("jql", ""))
255
+ except JqlError as exc:
256
+ return 400, {"errorMessages": [str(exc)]}, headers
257
+ return 200, {"count": sum(1 for i in t.issues.values() if pred(i))}, headers
258
+
259
+ if path == "/rest/api/2/issue/bulkfetch" and method == "POST":
260
+ body = body or {}
261
+ wanted = [str(x) for x in body.get("issueIdsOrKeys", [])]
262
+ if len(wanted) > 100:
263
+ return 400, {"errorMessages": ["Too many issues requested (max 100)."]}, headers
264
+ expand = body.get("expand") or []
265
+ if isinstance(expand, str):
266
+ expand = expand.split(",")
267
+ by_key = {i["key"]: i["id"] for i in t.issues.values()}
268
+ issues, errors = [], []
269
+ for w in wanted:
270
+ iid = w if w in t.issues else by_key.get(w)
271
+ if iid is None:
272
+ errors.append(w)
273
+ else:
274
+ issues.append(self.issue_json(iid, body.get("fields") or ["*navigable"], expand))
275
+ res = {"expand": "schema,names", "issues": issues, "issueErrors": []}
276
+ if errors:
277
+ res["issueErrors"] = [{"issueIdsOrKeys": errors,
278
+ "errorMessages": ["Issue does not exist or you do not have permission to see it."]}]
279
+ return 200, res, headers
280
+
281
+ m = re.fullmatch(r"/rest/api/2/issue/(\d+)(/changelog|/comment|/worklog)?", path)
282
+ if m:
283
+ iid, sub = m.group(1), m.group(2)
284
+ if iid not in t.issues:
285
+ return 404, {"errorMessages": ["Issue does not exist or you do not have permission to see it."]}, headers
286
+ if sub is None:
287
+ expand = (q.get("expand") or "").split(",")
288
+ fields = (q.get("fields") or "*all").split(",")
289
+ return 200, self.issue_json(iid, fields, expand), headers
290
+ if sub == "/changelog":
291
+ return 200, paged(d["changelogs"].get(iid, []), 100), headers
292
+ start = int(q.get("startAt", 0))
293
+ size = min(int(q.get("maxResults", 50)), 50)
294
+ if sub == "/comment":
295
+ vals = d["comments"].get(iid, [])
296
+ return 200, {"startAt": start, "maxResults": size, "total": len(vals),
297
+ "comments": vals[start:start + size]}, headers
298
+ vals = d["worklogs"].get(iid, [])
299
+ return 200, {"startAt": start, "maxResults": size, "total": len(vals),
300
+ "worklogs": vals[start:start + size]}, headers
301
+ return 404, {"errorMessages": [f"mock has no route for {method} {path}"]}, headers
302
+
303
+
304
+ def make_handler(mock: MockJira):
305
+ class Handler(BaseHTTPRequestHandler):
306
+ protocol_version = "HTTP/1.1"
307
+
308
+ def log_message(self, fmt: str, *args: Any) -> None: # quiet
309
+ pass
310
+
311
+ def _do(self, method: str) -> None:
312
+ parsed = urlparse(self.path)
313
+ length = int(self.headers.get("Content-Length") or 0)
314
+ raw = self.rfile.read(length) if length else b""
315
+ body = json.loads(raw) if raw else None
316
+ try:
317
+ status, payload, headers = mock.handle(method, parsed.path, parse_qs(parsed.query), body)
318
+ except Exception as exc: # surface bugs as 500s with a message
319
+ status, payload, headers = 500, {"errorMessages": [repr(exc)]}, {}
320
+ data = json.dumps(payload).encode()
321
+ self.send_response(status)
322
+ self.send_header("Content-Type", "application/json")
323
+ self.send_header("Content-Length", str(len(data)))
324
+ for k, v in headers.items():
325
+ self.send_header(k, v)
326
+ self.end_headers()
327
+ self.wfile.write(data)
328
+
329
+ def do_GET(self) -> None:
330
+ self._do("GET")
331
+
332
+ def do_POST(self) -> None:
333
+ self._do("POST")
334
+
335
+ return Handler
336
+
337
+
338
+ def serve(tenant: Tenant, host: str = "127.0.0.1", port: int = 8899, flaky: float = 0.0,
339
+ background: bool = False) -> tuple[ThreadingHTTPServer, MockJira]:
340
+ mock = MockJira(tenant, flaky=flaky)
341
+ server = ThreadingHTTPServer((host, port), make_handler(mock))
342
+ server.daemon_threads = True
343
+ if background:
344
+ threading.Thread(target=server.serve_forever, daemon=True).start()
345
+ else:
346
+ server.serve_forever()
347
+ return server, mock