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/golden.py
ADDED
|
@@ -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
|