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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
sqlmirror/apply.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Load change files written by the Forge app into the mirror database."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .cron import Cron
|
|
10
|
+
from .db import ApplyStats, Mirror
|
|
11
|
+
from .util import utcnow
|
|
12
|
+
from .mapper import Mapper
|
|
13
|
+
from .source import Source, parse_records
|
|
14
|
+
|
|
15
|
+
# How far back to re-list files on each run. Files that land late (a slow
|
|
16
|
+
# writer, clock skew) are still picked up because applied files are tracked
|
|
17
|
+
# individually rather than by a single high-water mark.
|
|
18
|
+
LOOKBACK = timedelta(days=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LicenseError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ApplyResult:
|
|
27
|
+
files: int
|
|
28
|
+
metadata_files: int
|
|
29
|
+
stats: ApplyStats
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _day_key(source: Source, sub: str, last_key: str | None) -> str | None:
|
|
33
|
+
"""Start listing one day before the newest applied file."""
|
|
34
|
+
if not last_key:
|
|
35
|
+
return None
|
|
36
|
+
rest = last_key[len(source.key(sub)):] if last_key.startswith(source.key(sub)) else ""
|
|
37
|
+
parts = rest.split("/")
|
|
38
|
+
try:
|
|
39
|
+
day = datetime(int(parts[0]), int(parts[1]), int(parts[2]))
|
|
40
|
+
except (ValueError, IndexError):
|
|
41
|
+
return None
|
|
42
|
+
start = day - LOOKBACK
|
|
43
|
+
return source.key(f"{sub}{start:%Y/%m/%d}/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_license(source: Source, log: Callable[[str], None]) -> None:
|
|
47
|
+
status = source.status()
|
|
48
|
+
if status is None:
|
|
49
|
+
log("Note: no app status file in the bucket yet (_sqlmirror/status.json).")
|
|
50
|
+
return
|
|
51
|
+
lic = status.get("license") or {}
|
|
52
|
+
if lic.get("active") is False:
|
|
53
|
+
raise LicenseError(
|
|
54
|
+
f"The SQL Mirror app on {status.get('site', 'your site')} reports an inactive license. "
|
|
55
|
+
"Renew it in Jira (Apps > Manage apps) to continue applying changes."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def apply_once(source: Source, db: Mirror, mapper: Mapper, *, log: Callable[[str], None] = print,
|
|
60
|
+
verbose: bool = False) -> ApplyResult:
|
|
61
|
+
db.load_registry(mapper)
|
|
62
|
+
check_license(source, log)
|
|
63
|
+
stats = ApplyStats()
|
|
64
|
+
|
|
65
|
+
# Metadata first, so new custom fields are known before issues reference them.
|
|
66
|
+
last_meta = db.last_applied_file(source.key("metadata/"))
|
|
67
|
+
meta_keys = list(source.list("metadata/", _day_key(source, "metadata/", last_meta)))
|
|
68
|
+
new_meta = [k for k in meta_keys if k not in db.applied_files(meta_keys)]
|
|
69
|
+
if new_meta:
|
|
70
|
+
newest = new_meta[-1]
|
|
71
|
+
raw = source.read(newest)
|
|
72
|
+
if raw is not None:
|
|
73
|
+
for rec in parse_records(newest, raw):
|
|
74
|
+
if rec.get("type") == "metadata":
|
|
75
|
+
db.apply_metadata(mapper, rec)
|
|
76
|
+
for k in new_meta:
|
|
77
|
+
db.mark_file_applied(k, 1 if k == newest else 0)
|
|
78
|
+
db.conn.commit()
|
|
79
|
+
if verbose:
|
|
80
|
+
log(f"Applied metadata {newest}")
|
|
81
|
+
|
|
82
|
+
last_issue = db.last_applied_file(source.key("issues/"))
|
|
83
|
+
keys = list(source.list("issues/", _day_key(source, "issues/", last_issue)))
|
|
84
|
+
applied = db.applied_files(keys)
|
|
85
|
+
todo = [k for k in keys if k not in applied]
|
|
86
|
+
for k in todo:
|
|
87
|
+
raw = source.read(k)
|
|
88
|
+
if raw is None:
|
|
89
|
+
continue
|
|
90
|
+
records = parse_records(k, raw)
|
|
91
|
+
file_stats = db.apply_records(mapper, records, source="forge")
|
|
92
|
+
db.mark_file_applied(k, len(records))
|
|
93
|
+
db.conn.commit()
|
|
94
|
+
stats.add(file_stats)
|
|
95
|
+
if verbose:
|
|
96
|
+
log(f"{k}: {file_stats.summary()}")
|
|
97
|
+
return ApplyResult(files=len(todo), metadata_files=len(new_meta), stats=stats)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply_logged(source: Source, db: Mirror, mapper: Mapper, *, log: Callable[[str], None] = print,
|
|
101
|
+
verbose: bool = False) -> ApplyResult:
|
|
102
|
+
"""apply_once plus a row in sqlmirror_sync_log (also on failure)."""
|
|
103
|
+
started = utcnow()
|
|
104
|
+
try:
|
|
105
|
+
res = apply_once(source, db, mapper, log=log, verbose=verbose)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
db.log_run("apply", source.describe(), started, error=str(exc)[:1000])
|
|
108
|
+
raise
|
|
109
|
+
if res.files or res.metadata_files:
|
|
110
|
+
db.log_run("apply", source.describe(), started, files=res.files + res.metadata_files, stats=res.stats)
|
|
111
|
+
return res
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def apply_loop(source: Source, db: Mirror, mapper: Mapper, *, interval: int = 60, cron: str | None = None,
|
|
115
|
+
log: Callable[[str], None] = print, verbose: bool = False) -> None:
|
|
116
|
+
"""Keep applying new files: every `interval` seconds, or on a cron schedule (UTC)."""
|
|
117
|
+
schedule = Cron.parse(cron) if cron else None
|
|
118
|
+
if schedule:
|
|
119
|
+
log(f"Watching {source.describe()} on schedule '{cron}' (UTC). Next run {schedule.next_after(utcnow()):%Y-%m-%d %H:%M} UTC. "
|
|
120
|
+
"Press Ctrl+C to stop.")
|
|
121
|
+
else:
|
|
122
|
+
log(f"Watching {source.describe()} every {interval}s. Press Ctrl+C to stop.")
|
|
123
|
+
while True:
|
|
124
|
+
if schedule:
|
|
125
|
+
wait = (schedule.next_after(utcnow()) - utcnow()).total_seconds()
|
|
126
|
+
time.sleep(max(0.0, wait))
|
|
127
|
+
try:
|
|
128
|
+
res = apply_logged(source, db, mapper, log=log, verbose=verbose)
|
|
129
|
+
if res.files or res.metadata_files:
|
|
130
|
+
log(f"{time.strftime('%H:%M:%S')} applied {res.files} files: {res.stats.summary()}")
|
|
131
|
+
except LicenseError as exc:
|
|
132
|
+
log(str(exc))
|
|
133
|
+
except Exception as exc: # keep watching; the failure is in the sync log
|
|
134
|
+
log(f"{time.strftime('%H:%M:%S')} apply failed: {exc}")
|
|
135
|
+
if not schedule:
|
|
136
|
+
time.sleep(interval)
|
sqlmirror/backfill.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Initial load: read every issue with the customer's API token and write the mirror.
|
|
2
|
+
|
|
3
|
+
Runs on the customer's machine. Uses the customer's own Jira rate limit, not
|
|
4
|
+
the Forge app's shared pool, so a 50k-issue history never starves the app's
|
|
5
|
+
incremental sync.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .db import ApplyStats, Mirror
|
|
14
|
+
from .jira import JiraClient
|
|
15
|
+
from .mapper import Mapper
|
|
16
|
+
from .util import iso, utcnow
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _jql_quote(key: str) -> str:
|
|
20
|
+
return '"' + key.replace('"', '\\"') + '"'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def backfill(client: JiraClient, db: Mirror, mapper: Mapper, *, projects: list[str] | None = None,
|
|
24
|
+
resume: bool = False, workers: int = 4, page_size: int = 100,
|
|
25
|
+
log: Callable[[str], None] = print) -> ApplyStats:
|
|
26
|
+
started = time.time()
|
|
27
|
+
db.load_registry(mapper)
|
|
28
|
+
log("Fetching configuration (projects, statuses, fields, versions, components)...")
|
|
29
|
+
meta = client.metadata()
|
|
30
|
+
db.apply_metadata(mapper, meta)
|
|
31
|
+
all_keys = [p["key"] for p in meta.get("projects", [])]
|
|
32
|
+
keys = [k for k in all_keys if not projects or k in {p.upper() for p in projects}]
|
|
33
|
+
missing = sorted({p.upper() for p in projects or []} - set(all_keys))
|
|
34
|
+
if missing:
|
|
35
|
+
log(f"Warning: projects not visible to this token: {', '.join(missing)}")
|
|
36
|
+
log(f"{len(meta.get('projects', []))} projects, {len(meta.get('fields', []))} fields. "
|
|
37
|
+
f"Mirroring: {', '.join(keys) or 'none'}")
|
|
38
|
+
|
|
39
|
+
total = ApplyStats()
|
|
40
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
41
|
+
for key in keys:
|
|
42
|
+
last_id = db.get_meta(f"backfill:{key}:last_id") if resume else None
|
|
43
|
+
jql = f"project = {_jql_quote(key)}"
|
|
44
|
+
if last_id:
|
|
45
|
+
jql += f" AND id > {int(last_id)}"
|
|
46
|
+
jql += " ORDER BY id ASC"
|
|
47
|
+
approx = client.approximate_count(f"project = {_jql_quote(key)}")
|
|
48
|
+
log(f"[{key}] {approx if approx is not None else '?'} issues" + (f", resuming after id {last_id}" if last_id else ""))
|
|
49
|
+
done = 0
|
|
50
|
+
fetched_at = iso(utcnow())
|
|
51
|
+
for page in client.search(jql, fields=["*all"], expand="changelog", page_size=page_size):
|
|
52
|
+
snapshots = list(pool.map(lambda i: client.complete_snapshot(i, fetched_at), page))
|
|
53
|
+
stats = db.apply_records(mapper, snapshots, source="backfill")
|
|
54
|
+
total.add(stats)
|
|
55
|
+
done += len(page)
|
|
56
|
+
db.set_meta(f"backfill:{key}:last_id", str(max(int(i["id"]) for i in page)))
|
|
57
|
+
db.conn.commit()
|
|
58
|
+
log(f"[{key}] {done} issues mirrored")
|
|
59
|
+
fetched_at = iso(utcnow())
|
|
60
|
+
db.set_meta(f"backfill:{key}:completed_at", iso(utcnow()))
|
|
61
|
+
db.conn.commit()
|
|
62
|
+
db.set_meta("backfill_completed_at", iso(utcnow()))
|
|
63
|
+
db.conn.commit()
|
|
64
|
+
log(f"Backfill finished in {time.time() - started:.0f}s: {total.summary()}. {client.api_call_summary()}.")
|
|
65
|
+
return total
|
sqlmirror/cli.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""sqlmirror: keep a Data-Center-compatible copy of Jira Cloud in your own Postgres or SQL Server.
|
|
2
|
+
|
|
3
|
+
Settings come from flags, environment variables, or a .env file in the
|
|
4
|
+
current folder:
|
|
5
|
+
|
|
6
|
+
DATABASE_URL postgresql://user:password@host:5432/dbname
|
|
7
|
+
or sqlserver://user:password@host:1433/dbname (add ?encrypt=true for Azure SQL)
|
|
8
|
+
JIRA_SITE https://your-site.atlassian.net
|
|
9
|
+
JIRA_EMAIL the account that owns the API token
|
|
10
|
+
JIRA_API_TOKEN an API token; it never leaves this machine
|
|
11
|
+
SQLMIRROR_SOURCE s3://bucket/prefix written by the Forge app (or a local folder)
|
|
12
|
+
SQLMIRROR_S3_ENDPOINT, AWS_PROFILE / AWS_* credentials for reading the bucket
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
from dotenv import load_dotenv
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _env(name: str) -> str | None:
|
|
29
|
+
v = os.environ.get(name)
|
|
30
|
+
return v.strip() if v else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _db(dsn: str | None, schema: str | None):
|
|
34
|
+
from .db import open_mirror
|
|
35
|
+
|
|
36
|
+
dsn = dsn or _env("DATABASE_URL")
|
|
37
|
+
if not dsn:
|
|
38
|
+
raise click.UsageError("Set DATABASE_URL or pass --dsn postgresql://user:pass@host/db "
|
|
39
|
+
"(or sqlserver://user:pass@host:1433/db)")
|
|
40
|
+
return open_mirror(dsn, schema=schema or _env("SQLMIRROR_SCHEMA"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _jira(verbose: bool = False):
|
|
44
|
+
from .jira import JiraClient
|
|
45
|
+
|
|
46
|
+
site, email = _env("JIRA_SITE"), _env("JIRA_EMAIL")
|
|
47
|
+
token = _env("JIRA_API_TOKEN") or _env("ATLASSIAN_API_TOKEN") or _env("ATLASSIAN_API")
|
|
48
|
+
missing = [n for n, v in (("JIRA_SITE", site), ("JIRA_EMAIL", email), ("JIRA_API_TOKEN", token)) if not v]
|
|
49
|
+
if missing:
|
|
50
|
+
raise click.UsageError(f"Set {', '.join(missing)} (environment or .env). Create a token at "
|
|
51
|
+
"https://id.atlassian.com/manage-profile/security/api-tokens")
|
|
52
|
+
return JiraClient(site, email, token, verbose=verbose)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _source(location: str | None, endpoint: str | None, region: str | None, profile: str | None):
|
|
56
|
+
from .source import open_source
|
|
57
|
+
|
|
58
|
+
location = location or _env("SQLMIRROR_SOURCE")
|
|
59
|
+
if not location:
|
|
60
|
+
raise click.UsageError("Give the bucket as s3://bucket/prefix (or set SQLMIRROR_SOURCE).")
|
|
61
|
+
kwargs = {}
|
|
62
|
+
if location.startswith("s3://"):
|
|
63
|
+
kwargs = {"endpoint_url": endpoint or _env("SQLMIRROR_S3_ENDPOINT"), "region": region or _env("AWS_REGION"),
|
|
64
|
+
"profile": profile or _env("AWS_PROFILE")}
|
|
65
|
+
return open_source(location, **kwargs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
dsn_option = click.option("--dsn", help="postgresql://... or sqlserver://... connection string (default: $DATABASE_URL).")
|
|
69
|
+
schema_option = click.option("--schema", help="Schema for the mirror tables (default: public on Postgres, dbo on SQL Server; "
|
|
70
|
+
"on SQL Server prefer a separate database).")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@click.group()
|
|
74
|
+
@click.version_option(__version__)
|
|
75
|
+
@click.option("--env-file", type=click.Path(dir_okay=False), help="Read settings from this file (default: ./.env).")
|
|
76
|
+
def main(env_file: str | None) -> None:
|
|
77
|
+
"""Mirror Jira Cloud into your own Postgres or SQL Server under Data Center table names."""
|
|
78
|
+
load_dotenv(env_file or Path.cwd() / ".env", override=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------- setup
|
|
82
|
+
@main.command("init-db")
|
|
83
|
+
@dsn_option
|
|
84
|
+
@schema_option
|
|
85
|
+
def init_db(dsn: str | None, schema: str | None) -> None:
|
|
86
|
+
"""Create the mirror tables (safe to run again)."""
|
|
87
|
+
db = _db(dsn, schema)
|
|
88
|
+
db.init_schema()
|
|
89
|
+
click.echo("Mirror tables are ready.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@main.command("ddl")
|
|
93
|
+
@click.option("--dialect", default=None, help="postgres or sqlserver (default: from $DATABASE_URL, else postgres).")
|
|
94
|
+
@schema_option
|
|
95
|
+
def print_ddl(dialect: str | None, schema: str | None) -> None:
|
|
96
|
+
"""Print the CREATE TABLE statements (for a DBA to run, if sqlmirror may not create tables)."""
|
|
97
|
+
from .db import is_sqlserver_dsn
|
|
98
|
+
from .schema import ddl
|
|
99
|
+
|
|
100
|
+
if not dialect:
|
|
101
|
+
dialect = "sqlserver" if is_sqlserver_dsn(_env("DATABASE_URL") or "") else "postgres"
|
|
102
|
+
try:
|
|
103
|
+
click.echo(ddl(dialect, schema or _env("SQLMIRROR_SCHEMA")))
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise click.BadParameter(str(exc), param_hint="--dialect")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@main.command("check-jira")
|
|
109
|
+
def check_jira() -> None:
|
|
110
|
+
"""Check the Jira token works and show what it can see."""
|
|
111
|
+
client = _jira()
|
|
112
|
+
me = client.myself()
|
|
113
|
+
click.echo(f"Signed in to {client.site} as {me.get('displayName')} ({me.get('accountId')}).")
|
|
114
|
+
projects = list(client.paged_values("/rest/api/2/project/search", 50))
|
|
115
|
+
click.echo(f"{len(projects)} projects visible: {', '.join(p['key'] for p in projects[:20])}"
|
|
116
|
+
+ (" ..." if len(projects) > 20 else ""))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------- sync
|
|
120
|
+
@main.command()
|
|
121
|
+
@dsn_option
|
|
122
|
+
@schema_option
|
|
123
|
+
@click.option("--project", "projects", multiple=True, help="Project key to mirror; repeat for more. Default: all.")
|
|
124
|
+
@click.option("--resume", is_flag=True, help="Continue an interrupted backfill instead of starting over.")
|
|
125
|
+
@click.option("--workers", default=4, show_default=True, help="Parallel requests for long histories.")
|
|
126
|
+
@click.option("--verbose", is_flag=True)
|
|
127
|
+
def backfill(dsn, schema, projects, resume, workers, verbose) -> None:
|
|
128
|
+
"""Load all issues and history with your own API token (run once)."""
|
|
129
|
+
from .backfill import backfill as run
|
|
130
|
+
from .mapper import Mapper
|
|
131
|
+
|
|
132
|
+
from .util import utcnow
|
|
133
|
+
|
|
134
|
+
db = _db(dsn, schema)
|
|
135
|
+
db.init_schema()
|
|
136
|
+
started = utcnow()
|
|
137
|
+
try:
|
|
138
|
+
stats = run(_jira(verbose), db, Mapper(), projects=list(projects) or None, resume=resume, workers=workers)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
db.log_run("backfill", ",".join(projects) or "all projects", started, error=str(exc)[:1000])
|
|
141
|
+
raise
|
|
142
|
+
db.log_run("backfill", ",".join(projects) or "all projects", started, stats=stats)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@main.command()
|
|
146
|
+
@click.argument("source", required=False)
|
|
147
|
+
@dsn_option
|
|
148
|
+
@schema_option
|
|
149
|
+
@click.option("--endpoint-url", help="S3-compatible endpoint (R2, GCS, MinIO). Default: AWS.")
|
|
150
|
+
@click.option("--region", help="Bucket region.")
|
|
151
|
+
@click.option("--profile", help="AWS profile to read the bucket with.")
|
|
152
|
+
@click.option("--watch", is_flag=True, help="Keep running and apply new files as they arrive.")
|
|
153
|
+
@click.option("--interval", default=60, show_default=True, help="Seconds between checks with --watch.")
|
|
154
|
+
@click.option("--cron", "cron_expr", help='Run on a cron schedule in UTC instead, e.g. "*/15 * * * *" (implies --watch).')
|
|
155
|
+
@click.option("--verbose", is_flag=True)
|
|
156
|
+
def apply(source, dsn, schema, endpoint_url, region, profile, watch, interval, cron_expr, verbose) -> None:
|
|
157
|
+
"""Apply change files written by the Forge app (s3://bucket/prefix or a folder)."""
|
|
158
|
+
from .apply import LicenseError, apply_logged, apply_loop
|
|
159
|
+
from .cron import Cron, CronError
|
|
160
|
+
from .mapper import Mapper
|
|
161
|
+
|
|
162
|
+
if cron_expr:
|
|
163
|
+
try:
|
|
164
|
+
Cron.parse(cron_expr)
|
|
165
|
+
except CronError as exc:
|
|
166
|
+
raise click.BadParameter(str(exc), param_hint="--cron")
|
|
167
|
+
src = _source(source, endpoint_url, region, profile)
|
|
168
|
+
db = _db(dsn, schema)
|
|
169
|
+
db.init_schema()
|
|
170
|
+
if watch or cron_expr:
|
|
171
|
+
apply_loop(src, db, Mapper(), interval=interval, cron=cron_expr, verbose=verbose)
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
res = apply_logged(src, db, Mapper(), verbose=verbose)
|
|
175
|
+
except LicenseError as exc:
|
|
176
|
+
raise click.ClickException(str(exc))
|
|
177
|
+
click.echo(f"Applied {res.files} change files and {res.metadata_files} configuration files from "
|
|
178
|
+
f"{src.describe()}: {res.stats.summary()}.")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@main.command()
|
|
182
|
+
@dsn_option
|
|
183
|
+
@schema_option
|
|
184
|
+
@click.option("--project", "projects", multiple=True)
|
|
185
|
+
@click.option("--deep", is_flag=True, help="Compare every issue id and updated time, not just counts.")
|
|
186
|
+
@click.option("--repair", is_flag=True, help="Re-fetch missing or stale issues and remove deleted ones.")
|
|
187
|
+
def reconcile(dsn, schema, projects, deep, repair) -> None:
|
|
188
|
+
"""Compare the mirror with Jira (uses your API token)."""
|
|
189
|
+
from .mapper import Mapper
|
|
190
|
+
from .reconcile import format_reports, reconcile as run
|
|
191
|
+
|
|
192
|
+
reports = run(_jira(), _db(dsn, schema), Mapper(), projects=list(projects) or None, deep=deep or repair,
|
|
193
|
+
repair=repair)
|
|
194
|
+
click.echo(format_reports(reports, deep or repair))
|
|
195
|
+
if not all(r.ok if (deep or repair) else r.jira == r.mirror for r in reports):
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@main.command()
|
|
200
|
+
@dsn_option
|
|
201
|
+
@schema_option
|
|
202
|
+
@click.option("--source", help="Also show the app status file from this bucket.")
|
|
203
|
+
@click.option("--endpoint-url")
|
|
204
|
+
@click.option("--region")
|
|
205
|
+
@click.option("--profile")
|
|
206
|
+
def status(dsn, schema, source, endpoint_url, region, profile) -> None:
|
|
207
|
+
"""Show what is in the mirror and how fresh it is."""
|
|
208
|
+
db = _db(dsn, schema)
|
|
209
|
+
counts = db.table_counts()
|
|
210
|
+
applied_at, fetched_at = db.freshness()
|
|
211
|
+
click.echo(f"Issues: {counts['jiraissue']} changes: {counts['changeitem']} comments: {counts['jiraaction']} "
|
|
212
|
+
f"worklogs: {counts['worklog']} custom field values: {counts['customfieldvalue']}")
|
|
213
|
+
click.echo(f"Projects: {', '.join(f'{k} ({v})' for k, v in db.project_counts().items())}")
|
|
214
|
+
click.echo(f"Newest data fetched from Jira: {fetched_at}; last write: {applied_at}")
|
|
215
|
+
click.echo(f"Last change file applied: {db.last_applied_file() or 'none'}")
|
|
216
|
+
click.echo(f"Configuration fetched: {db.get_meta('metadata_fetched_at') or 'never'}; "
|
|
217
|
+
f"backfill finished: {db.get_meta('backfill_completed_at') or 'never'}")
|
|
218
|
+
if source or _env("SQLMIRROR_SOURCE"):
|
|
219
|
+
st = _source(source, endpoint_url, region, profile).status()
|
|
220
|
+
click.echo("App status: " + (json.dumps(st, indent=2) if st else "no status file yet"))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@main.command("log")
|
|
224
|
+
@dsn_option
|
|
225
|
+
@schema_option
|
|
226
|
+
@click.option("--limit", default=20, show_default=True)
|
|
227
|
+
@click.option("--app", "show_app", is_flag=True, help="Also show the Forge app's sync log from the bucket.")
|
|
228
|
+
@click.option("--source", help="Bucket (default $SQLMIRROR_SOURCE), for --app.")
|
|
229
|
+
@click.option("--endpoint-url")
|
|
230
|
+
@click.option("--region")
|
|
231
|
+
@click.option("--profile")
|
|
232
|
+
def show_log(dsn, schema, limit, show_app, source, endpoint_url, region, profile) -> None:
|
|
233
|
+
"""Show recent runs: apply / backfill on this side, and with --app the app's sync jobs."""
|
|
234
|
+
db = _db(dsn, schema)
|
|
235
|
+
db.init_schema()
|
|
236
|
+
rows = db.recent_log(limit)
|
|
237
|
+
click.echo("Database side (apply, backfill):")
|
|
238
|
+
if not rows:
|
|
239
|
+
click.echo(" nothing yet")
|
|
240
|
+
for started, finished, kind, src, files, issues, tombstones, skipped, error in rows:
|
|
241
|
+
took = (finished - started).total_seconds() if finished and started else 0
|
|
242
|
+
line = (f" {started:%Y-%m-%d %H:%M:%S} UTC {kind:<9} {issues:>6} issues {tombstones:>4} deleted "
|
|
243
|
+
f"{files:>5} files {skipped:>5} skipped {took:>6.0f}s")
|
|
244
|
+
click.echo(line + (f" ERROR: {error}" if error else ""))
|
|
245
|
+
if show_app:
|
|
246
|
+
st = _source(source, endpoint_url, region, profile).status()
|
|
247
|
+
entries = (st or {}).get("log") or []
|
|
248
|
+
click.echo(f"\nApp side (sync jobs; schedule {(st or {}).get('schedule', '?')}):")
|
|
249
|
+
if not entries:
|
|
250
|
+
click.echo(" nothing yet (the app writes its log into _sqlmirror/status.json after each job)")
|
|
251
|
+
for e in entries[:limit]:
|
|
252
|
+
changed = ", ".join(e.get("changed") or [])
|
|
253
|
+
more = (e.get("changedCount") or 0) - len(e.get("changed") or [])
|
|
254
|
+
click.echo(f" {e['at'][:19].replace('T', ' ')} UTC {e.get('trigger', ''):<9} {e.get('issuesWritten', 0):>6} issues "
|
|
255
|
+
f"{e.get('tombstones', 0):>4} deleted {e.get('apiCalls', 0):>4} Jira calls"
|
|
256
|
+
+ (f" ERROR: {e['error']}" if e.get("error") else "")
|
|
257
|
+
+ (f"\n {changed}" + (f" and {more} more" if more > 0 else "") if changed else ""))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------- SQL check
|
|
261
|
+
@main.command("check-sql")
|
|
262
|
+
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
|
263
|
+
@click.option("--dialect", default="postgres", show_default=True,
|
|
264
|
+
help="Dialect the reports were written for: postgres, sqlserver, mysql, oracle.")
|
|
265
|
+
@click.option("--id-map", type=click.Path(exists=True), help="CSV from 'sqlmirror idmap build' to rewrite ids.")
|
|
266
|
+
@click.option("--out", "out_dir", type=click.Path(path_type=Path), default=Path("sqlmirror-checked"), show_default=True,
|
|
267
|
+
help="Folder for rewritten SQL and report.md.")
|
|
268
|
+
@click.option("--target", default=None,
|
|
269
|
+
help="Dialect to rewrite into: postgres or sqlserver (default: the mirror's, else postgres).")
|
|
270
|
+
@click.option("--explain/--no-explain", default=True, show_default=True,
|
|
271
|
+
help="Compile each rewritten statement against the mirror ($DATABASE_URL): EXPLAIN on Postgres, "
|
|
272
|
+
"sp_describe_first_result_set on SQL Server.")
|
|
273
|
+
@dsn_option
|
|
274
|
+
@schema_option
|
|
275
|
+
def check_sql(paths, dialect, id_map, target, out_dir, explain, dsn, schema) -> None:
|
|
276
|
+
"""Check Data Center report SQL against the mirror and rewrite what it can."""
|
|
277
|
+
from .db import is_sqlserver_dsn
|
|
278
|
+
from .idmap import IdMap
|
|
279
|
+
from .sqlcheck import Checker, check_paths, markdown_report, write_outputs
|
|
280
|
+
|
|
281
|
+
mirror = None
|
|
282
|
+
if explain and (dsn or _env("DATABASE_URL")):
|
|
283
|
+
mirror = _db(dsn, schema)
|
|
284
|
+
if not target:
|
|
285
|
+
if mirror is not None:
|
|
286
|
+
target = mirror.dialect
|
|
287
|
+
else:
|
|
288
|
+
target = "sqlserver" if is_sqlserver_dsn(dsn or _env("DATABASE_URL") or "") else "postgres"
|
|
289
|
+
try:
|
|
290
|
+
checker = Checker(dialect, idmap=IdMap.load(id_map) if id_map else None, mirror=mirror, target=target)
|
|
291
|
+
except ValueError as exc:
|
|
292
|
+
raise click.UsageError(str(exc))
|
|
293
|
+
results = check_paths(list(paths), checker)
|
|
294
|
+
write_outputs(results, out_dir)
|
|
295
|
+
report = markdown_report(results, dialect=dialect, explained=mirror is not None, target=checker.target)
|
|
296
|
+
(out_dir / "report.md").write_text(report, encoding="utf-8")
|
|
297
|
+
click.echo(report)
|
|
298
|
+
click.echo(f"\nRewritten SQL and report.md are in {out_dir}/")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@main.group()
|
|
302
|
+
def idmap() -> None:
|
|
303
|
+
"""Map Data Center ids (statuses, fields, users...) to Cloud ids."""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@idmap.command("export-sql")
|
|
307
|
+
@click.option("--dialect", default="postgres", show_default=True, help="Your Data Center database type.")
|
|
308
|
+
def idmap_export(dialect: str) -> None:
|
|
309
|
+
"""Print the query to run on the Data Center database (save the result as CSV)."""
|
|
310
|
+
from .idmap import export_sql
|
|
311
|
+
|
|
312
|
+
click.echo(export_sql(dialect))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@idmap.command("build")
|
|
316
|
+
@click.argument("dc_csv", type=click.Path(exists=True))
|
|
317
|
+
@click.option("--out", default="id_map.csv", show_default=True)
|
|
318
|
+
@dsn_option
|
|
319
|
+
@schema_option
|
|
320
|
+
def idmap_build(dc_csv: str, out: str, dsn, schema) -> None:
|
|
321
|
+
"""Match the Data Center export to the mirror by name and write id_map.csv."""
|
|
322
|
+
from .idmap import build, write_csv
|
|
323
|
+
|
|
324
|
+
with open(dc_csv, newline="", encoding="utf-8-sig") as fh:
|
|
325
|
+
rows = build(csv.DictReader(fh), _db(dsn, schema))
|
|
326
|
+
write_csv(rows, out)
|
|
327
|
+
matched = sum(1 for r in rows if r["cloud_id"])
|
|
328
|
+
click.echo(f"Matched {matched} of {len(rows)} Data Center ids; wrote {out}.")
|
|
329
|
+
for r in rows:
|
|
330
|
+
if not r["cloud_id"]:
|
|
331
|
+
click.echo(f" no match: {r['entity']} {r['dc_id']} ({r['name']}) - {r['match']}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|
sqlmirror/cron.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Minimal 5-field cron (UTC by default): minute hour day-of-month month day-of-week.
|
|
2
|
+
|
|
3
|
+
Supports *, numbers, ranges, lists, steps, month/day names and the presets
|
|
4
|
+
@hourly, @daily, @weekly, @monthly. Same rules as sql-mirror/src/cron.js.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
PRESETS = {"@hourly": "0 * * * *", "@daily": "0 0 * * *", "@midnight": "0 0 * * *",
|
|
12
|
+
"@weekly": "0 0 * * 0", "@monthly": "0 0 1 * *"}
|
|
13
|
+
MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
|
|
14
|
+
DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
|
|
15
|
+
FIELDS = [("minute", 0, 59, None, 0), ("hour", 0, 23, None, 0), ("day of month", 1, 31, None, 0),
|
|
16
|
+
("month", 1, 12, MONTHS, 1), ("day of week", 0, 7, DAYS, 0)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CronError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _value(token: str, field) -> int:
|
|
24
|
+
name, lo, hi, names, offset = field
|
|
25
|
+
if names and token.upper() in names:
|
|
26
|
+
return names.index(token.upper()) + offset
|
|
27
|
+
if not token.isdigit():
|
|
28
|
+
raise CronError(f'"{token}" is not valid in the {name} field')
|
|
29
|
+
n = int(token)
|
|
30
|
+
if n < lo or n > hi:
|
|
31
|
+
raise CronError(f"{n} is out of range for {name} ({lo}-{hi})")
|
|
32
|
+
return n
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _field(text: str, field) -> set[int]:
|
|
36
|
+
name, lo_f, hi_f, _, _ = field
|
|
37
|
+
out: set[int] = set()
|
|
38
|
+
for part in text.split(","):
|
|
39
|
+
rng, _, step_text = part.partition("/")
|
|
40
|
+
step = int(step_text) if step_text else 1
|
|
41
|
+
if step < 1:
|
|
42
|
+
raise CronError(f"bad step in {name}")
|
|
43
|
+
if rng == "*":
|
|
44
|
+
lo, hi = lo_f, hi_f
|
|
45
|
+
elif "-" in rng:
|
|
46
|
+
a, b = rng.split("-", 1)
|
|
47
|
+
lo, hi = _value(a, field), _value(b, field)
|
|
48
|
+
if lo > hi:
|
|
49
|
+
raise CronError(f"range {rng} goes backwards in {name}")
|
|
50
|
+
else:
|
|
51
|
+
lo = _value(rng, field)
|
|
52
|
+
hi = hi_f if step_text else lo
|
|
53
|
+
out.update(range(lo, hi + 1, step))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Cron:
|
|
59
|
+
expr: str
|
|
60
|
+
minute: set[int]
|
|
61
|
+
hour: set[int]
|
|
62
|
+
dom: set[int]
|
|
63
|
+
month: set[int]
|
|
64
|
+
dow: set[int]
|
|
65
|
+
dom_any: bool
|
|
66
|
+
dow_any: bool
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def parse(cls, expr: str) -> "Cron":
|
|
70
|
+
text = PRESETS.get(expr.strip().lower(), expr).strip()
|
|
71
|
+
parts = text.split()
|
|
72
|
+
if len(parts) != 5:
|
|
73
|
+
raise CronError("A schedule needs 5 fields: minute hour day-of-month month day-of-week")
|
|
74
|
+
sets = [_field(p, FIELDS[i]) for i, p in enumerate(parts)]
|
|
75
|
+
if 7 in sets[4]:
|
|
76
|
+
sets[4].add(0)
|
|
77
|
+
return cls(expr, *sets, parts[2] == "*", parts[4] == "*")
|
|
78
|
+
|
|
79
|
+
def matches(self, dt: datetime) -> bool:
|
|
80
|
+
if dt.minute not in self.minute or dt.hour not in self.hour or dt.month not in self.month:
|
|
81
|
+
return False
|
|
82
|
+
dom_ok = dt.day in self.dom
|
|
83
|
+
dow_ok = (dt.isoweekday() % 7) in self.dow # Sunday = 0
|
|
84
|
+
if self.dom_any and self.dow_any:
|
|
85
|
+
return True
|
|
86
|
+
if self.dom_any:
|
|
87
|
+
return dow_ok
|
|
88
|
+
if self.dow_any:
|
|
89
|
+
return dom_ok
|
|
90
|
+
return dom_ok or dow_ok
|
|
91
|
+
|
|
92
|
+
def next_after(self, dt: datetime) -> datetime:
|
|
93
|
+
t = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
94
|
+
limit = t + timedelta(days=366)
|
|
95
|
+
while t < limit:
|
|
96
|
+
if self.matches(t):
|
|
97
|
+
return t
|
|
98
|
+
t += timedelta(minutes=1)
|
|
99
|
+
raise CronError(f"schedule {self.expr!r} never runs")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def now_local(tz: timezone | None = None) -> datetime:
|
|
103
|
+
return datetime.now(tz or timezone.utc)
|