sqlmirror 0.2.0__tar.gz

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 (110) hide show
  1. sqlmirror-0.2.0/PKG-INFO +19 -0
  2. sqlmirror-0.2.0/README.md +26 -0
  3. sqlmirror-0.2.0/pyproject.toml +45 -0
  4. sqlmirror-0.2.0/setup.cfg +4 -0
  5. sqlmirror-0.2.0/sqlmirror/__init__.py +1 -0
  6. sqlmirror-0.2.0/sqlmirror/apply.py +136 -0
  7. sqlmirror-0.2.0/sqlmirror/backfill.py +65 -0
  8. sqlmirror-0.2.0/sqlmirror/cli.py +335 -0
  9. sqlmirror-0.2.0/sqlmirror/cron.py +103 -0
  10. sqlmirror-0.2.0/sqlmirror/db.py +469 -0
  11. sqlmirror-0.2.0/sqlmirror/db_mssql.py +530 -0
  12. sqlmirror-0.2.0/sqlmirror/dev/__init__.py +1 -0
  13. sqlmirror-0.2.0/sqlmirror/dev/cli.py +166 -0
  14. sqlmirror-0.2.0/sqlmirror/dev/compare.py +87 -0
  15. sqlmirror-0.2.0/sqlmirror/dev/e2e.py +261 -0
  16. sqlmirror-0.2.0/sqlmirror/dev/golden.py +182 -0
  17. sqlmirror-0.2.0/sqlmirror/dev/mock_jira.py +347 -0
  18. sqlmirror-0.2.0/sqlmirror/dev/site.py +383 -0
  19. sqlmirror-0.2.0/sqlmirror/dev/synth.py +769 -0
  20. sqlmirror-0.2.0/sqlmirror/golden/01_issues_by_status.sql +8 -0
  21. sqlmirror-0.2.0/sqlmirror/golden/02_issue_keys.sql +7 -0
  22. sqlmirror-0.2.0/sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  23. sqlmirror-0.2.0/sqlmirror/golden/04_open_by_priority.sql +10 -0
  24. sqlmirror-0.2.0/sqlmirror/golden/05_time_in_status.sql +16 -0
  25. sqlmirror-0.2.0/sqlmirror/golden/06_cycle_time.sql +16 -0
  26. sqlmirror-0.2.0/sqlmirror/golden/07_worklog_by_user.sql +11 -0
  27. sqlmirror-0.2.0/sqlmirror/golden/08_fix_versions.sql +11 -0
  28. sqlmirror-0.2.0/sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  29. sqlmirror-0.2.0/sqlmirror/golden/10_components.sql +10 -0
  30. sqlmirror-0.2.0/sqlmirror/golden/11_top_labels.sql +9 -0
  31. sqlmirror-0.2.0/sqlmirror/golden/12_select_field_distribution.sql +11 -0
  32. sqlmirror-0.2.0/sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  33. sqlmirror-0.2.0/sqlmirror/golden/14_sprint_completion.sql +14 -0
  34. sqlmirror-0.2.0/sqlmirror/golden/15_epics.sql +12 -0
  35. sqlmirror-0.2.0/sqlmirror/golden/16_subtasks.sql +10 -0
  36. sqlmirror-0.2.0/sqlmirror/golden/17_blocked_issues.sql +11 -0
  37. sqlmirror-0.2.0/sqlmirror/golden/18_most_discussed.sql +10 -0
  38. sqlmirror-0.2.0/sqlmirror/golden/19_assignee_workload.sql +11 -0
  39. sqlmirror-0.2.0/sqlmirror/golden/20_resolutions.sql +7 -0
  40. sqlmirror-0.2.0/sqlmirror/golden/21_issue_history.sql +10 -0
  41. sqlmirror-0.2.0/sqlmirror/golden/22_reopened.sql +11 -0
  42. sqlmirror-0.2.0/sqlmirror/golden/23_overdue.sql +8 -0
  43. sqlmirror-0.2.0/sqlmirror/golden/24_cascading_select.sql +13 -0
  44. sqlmirror-0.2.0/sqlmirror/golden/25_user_picker.sql +12 -0
  45. sqlmirror-0.2.0/sqlmirror/golden/26_attachments.sql +9 -0
  46. sqlmirror-0.2.0/sqlmirror/golden/27_project_categories.sql +8 -0
  47. sqlmirror-0.2.0/sqlmirror/golden/28_issue_types.sql +7 -0
  48. sqlmirror-0.2.0/sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  49. sqlmirror-0.2.0/sqlmirror/golden/30_project_leads.sql +7 -0
  50. sqlmirror-0.2.0/sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  51. sqlmirror-0.2.0/sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  52. sqlmirror-0.2.0/sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  53. sqlmirror-0.2.0/sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  54. sqlmirror-0.2.0/sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  55. sqlmirror-0.2.0/sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  56. sqlmirror-0.2.0/sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  57. sqlmirror-0.2.0/sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  58. sqlmirror-0.2.0/sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  59. sqlmirror-0.2.0/sqlmirror/golden/tsql/10_components.sql +10 -0
  60. sqlmirror-0.2.0/sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  61. sqlmirror-0.2.0/sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  62. sqlmirror-0.2.0/sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  63. sqlmirror-0.2.0/sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  64. sqlmirror-0.2.0/sqlmirror/golden/tsql/15_epics.sql +11 -0
  65. sqlmirror-0.2.0/sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  66. sqlmirror-0.2.0/sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  67. sqlmirror-0.2.0/sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  68. sqlmirror-0.2.0/sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  69. sqlmirror-0.2.0/sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  70. sqlmirror-0.2.0/sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  71. sqlmirror-0.2.0/sqlmirror/golden/tsql/22_reopened.sql +11 -0
  72. sqlmirror-0.2.0/sqlmirror/golden/tsql/23_overdue.sql +8 -0
  73. sqlmirror-0.2.0/sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  74. sqlmirror-0.2.0/sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  75. sqlmirror-0.2.0/sqlmirror/golden/tsql/26_attachments.sql +9 -0
  76. sqlmirror-0.2.0/sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  77. sqlmirror-0.2.0/sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  78. sqlmirror-0.2.0/sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  79. sqlmirror-0.2.0/sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  80. sqlmirror-0.2.0/sqlmirror/idmap.py +161 -0
  81. sqlmirror-0.2.0/sqlmirror/jira.py +223 -0
  82. sqlmirror-0.2.0/sqlmirror/mapper.py +774 -0
  83. sqlmirror-0.2.0/sqlmirror/reconcile.py +73 -0
  84. sqlmirror-0.2.0/sqlmirror/samples/dc_export_example.csv +19 -0
  85. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  86. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  87. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  88. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  89. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  90. sqlmirror-0.2.0/sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  91. sqlmirror-0.2.0/sqlmirror/schema.py +518 -0
  92. sqlmirror-0.2.0/sqlmirror/source.py +117 -0
  93. sqlmirror-0.2.0/sqlmirror/sqlcheck.py +647 -0
  94. sqlmirror-0.2.0/sqlmirror/util.py +80 -0
  95. sqlmirror-0.2.0/sqlmirror.egg-info/PKG-INFO +19 -0
  96. sqlmirror-0.2.0/sqlmirror.egg-info/SOURCES.txt +108 -0
  97. sqlmirror-0.2.0/sqlmirror.egg-info/dependency_links.txt +1 -0
  98. sqlmirror-0.2.0/sqlmirror.egg-info/entry_points.txt +3 -0
  99. sqlmirror-0.2.0/sqlmirror.egg-info/requires.txt +16 -0
  100. sqlmirror-0.2.0/sqlmirror.egg-info/top_level.txt +1 -0
  101. sqlmirror-0.2.0/tests/test_cron_and_log.py +44 -0
  102. sqlmirror-0.2.0/tests/test_db.py +92 -0
  103. sqlmirror-0.2.0/tests/test_e2e.py +19 -0
  104. sqlmirror-0.2.0/tests/test_mapper.py +131 -0
  105. sqlmirror-0.2.0/tests/test_mock_and_backfill.py +54 -0
  106. sqlmirror-0.2.0/tests/test_mssql_harness.py +61 -0
  107. sqlmirror-0.2.0/tests/test_mssql_live.py +112 -0
  108. sqlmirror-0.2.0/tests/test_mssql_offline.py +275 -0
  109. sqlmirror-0.2.0/tests/test_sigv4.py +40 -0
  110. sqlmirror-0.2.0/tests/test_sqlcheck.py +91 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlmirror
3
+ Version: 0.2.0
4
+ Summary: Mirror Jira Cloud into your own Postgres or SQL Server under Data Center table names
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: boto3>=1.28
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: psycopg[binary]>=3.2
10
+ Requires-Dist: python-dotenv>=1.0
11
+ Requires-Dist: sqlglot>=25
12
+ Provides-Extra: sqlserver
13
+ Requires-Dist: python-tds>=1.15; extra == "sqlserver"
14
+ Requires-Dist: pyOpenSSL>=23; extra == "sqlserver"
15
+ Requires-Dist: certifi; extra == "sqlserver"
16
+ Provides-Extra: dev
17
+ Requires-Dist: moto[server]>=5; extra == "dev"
18
+ Requires-Dist: pytest>=8; extra == "dev"
19
+ Requires-Dist: python-tds>=1.15; extra == "dev"
@@ -0,0 +1,26 @@
1
+ # sqlmirror CLI
2
+
3
+ ```bash
4
+ pip install -e ".[dev]" # from this folder
5
+ pip install -e ".[sqlserver]" # to write to SQL Server / Azure SQL as well
6
+ sqlmirror --help
7
+ ```
8
+
9
+ The database is chosen by `DATABASE_URL`: `postgresql://user:pass@host:5432/db` or `sqlserver://user:pass@host:1433/db` (`?encrypt=true` for Azure SQL). See TESTING.md part 4 for SQL Server options.
10
+
11
+ | Command | Does |
12
+ |---|---|
13
+ | `sqlmirror init-db` | Create the Data-Center-compatible tables in `$DATABASE_URL` (Postgres or SQL Server). |
14
+ | `sqlmirror ddl [--dialect sqlserver]` | Print the CREATE TABLE statements, for a DBA to run. |
15
+ | `sqlmirror backfill [--project KEY] [--resume]` | Load everything with your API token (runs on your machine, uses your own rate limit). |
16
+ | `sqlmirror apply s3://bucket/prefix [--watch \| --cron "*/15 * * * *"]` | Load the Forge app's change files, once, continuously, or on a cron schedule (UTC). Safe to run repeatedly; files are tracked individually. |
17
+ | `sqlmirror reconcile [--deep] [--repair]` | Compare counts (or every id and updated time) with Jira; `--repair` fixes differences. |
18
+ | `sqlmirror status` | Counts and freshness. |
19
+ | `sqlmirror log [--app]` | Recent apply / backfill runs; with `--app`, the Forge app's sync jobs and which issues each changed. |
20
+ | `sqlmirror check-sql reports/ --dialect sqlserver [--target sqlserver] [--id-map id_map.csv]` | Check Data Center report SQL against the mirror, rewrite it for the mirror's database, compile the result there. |
21
+ | `sqlmirror idmap export-sql --dialect sqlserver` | Query to run on the Data Center database to export id/name pairs. |
22
+ | `sqlmirror idmap build dc_export.csv` | Match those to Cloud ids by name, write `id_map.csv`. |
23
+
24
+ Test tools (`sqlmirror-dev`): `e2e`, `golden`, `compare`, `synth`, `mock-jira`, and for demo sites only: `load-site`, `simulate-edits`, `bulk-edit`.
25
+
26
+ Tests: `pytest -q` (needs Postgres at `$SQLMIRROR_TEST_SERVER_DSN`; the pipeline test also needs Node and `npm install` in `../sql-mirror`; the SQL Server tests run when `$SQLMIRROR_TEST_MSSQL_DSN` is set and are otherwise skipped).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sqlmirror"
7
+ version = "0.2.0"
8
+ description = "Mirror Jira Cloud into your own Postgres or SQL Server under Data Center table names"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "boto3>=1.28",
12
+ "click>=8.1",
13
+ "httpx>=0.27",
14
+ "psycopg[binary]>=3.2",
15
+ "python-dotenv>=1.0",
16
+ "sqlglot>=25",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ # SQL Server / Azure SQL output. python-tds is pure Python (no ODBC driver);
21
+ # pyOpenSSL is needed for encrypted connections (Azure SQL), certifi for its CA list.
22
+ sqlserver = [
23
+ "python-tds>=1.15",
24
+ "pyOpenSSL>=23",
25
+ "certifi",
26
+ ]
27
+ dev = [
28
+ "moto[server]>=5",
29
+ "pytest>=8",
30
+ "python-tds>=1.15",
31
+ ]
32
+
33
+ [project.scripts]
34
+ sqlmirror = "sqlmirror.cli:main"
35
+ sqlmirror-dev = "sqlmirror.dev.cli:main"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["sqlmirror*"]
39
+
40
+ [tool.setuptools.package-data]
41
+ sqlmirror = ["golden/*.sql", "golden/tsql/*.sql", "samples/*.csv", "samples/dc_reports/*.sql"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ markers = ["slow: end-to-end tests that need Node and the Forge app's npm packages"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -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)
@@ -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
@@ -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()