sqlmirror 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. sqlmirror/__init__.py +1 -0
  2. sqlmirror/apply.py +136 -0
  3. sqlmirror/backfill.py +65 -0
  4. sqlmirror/cli.py +335 -0
  5. sqlmirror/cron.py +103 -0
  6. sqlmirror/db.py +469 -0
  7. sqlmirror/db_mssql.py +530 -0
  8. sqlmirror/dev/__init__.py +1 -0
  9. sqlmirror/dev/cli.py +166 -0
  10. sqlmirror/dev/compare.py +87 -0
  11. sqlmirror/dev/e2e.py +261 -0
  12. sqlmirror/dev/golden.py +182 -0
  13. sqlmirror/dev/mock_jira.py +347 -0
  14. sqlmirror/dev/site.py +383 -0
  15. sqlmirror/dev/synth.py +769 -0
  16. sqlmirror/golden/01_issues_by_status.sql +8 -0
  17. sqlmirror/golden/02_issue_keys.sql +7 -0
  18. sqlmirror/golden/03_created_vs_resolved.sql +8 -0
  19. sqlmirror/golden/04_open_by_priority.sql +10 -0
  20. sqlmirror/golden/05_time_in_status.sql +16 -0
  21. sqlmirror/golden/06_cycle_time.sql +16 -0
  22. sqlmirror/golden/07_worklog_by_user.sql +11 -0
  23. sqlmirror/golden/08_fix_versions.sql +11 -0
  24. sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
  25. sqlmirror/golden/10_components.sql +10 -0
  26. sqlmirror/golden/11_top_labels.sql +9 -0
  27. sqlmirror/golden/12_select_field_distribution.sql +11 -0
  28. sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
  29. sqlmirror/golden/14_sprint_completion.sql +14 -0
  30. sqlmirror/golden/15_epics.sql +12 -0
  31. sqlmirror/golden/16_subtasks.sql +10 -0
  32. sqlmirror/golden/17_blocked_issues.sql +11 -0
  33. sqlmirror/golden/18_most_discussed.sql +10 -0
  34. sqlmirror/golden/19_assignee_workload.sql +11 -0
  35. sqlmirror/golden/20_resolutions.sql +7 -0
  36. sqlmirror/golden/21_issue_history.sql +10 -0
  37. sqlmirror/golden/22_reopened.sql +11 -0
  38. sqlmirror/golden/23_overdue.sql +8 -0
  39. sqlmirror/golden/24_cascading_select.sql +13 -0
  40. sqlmirror/golden/25_user_picker.sql +12 -0
  41. sqlmirror/golden/26_attachments.sql +9 -0
  42. sqlmirror/golden/27_project_categories.sql +8 -0
  43. sqlmirror/golden/28_issue_types.sql +7 -0
  44. sqlmirror/golden/29_estimate_accuracy.sql +11 -0
  45. sqlmirror/golden/30_project_leads.sql +7 -0
  46. sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
  47. sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
  48. sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
  49. sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
  50. sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
  51. sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
  52. sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
  53. sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
  54. sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
  55. sqlmirror/golden/tsql/10_components.sql +10 -0
  56. sqlmirror/golden/tsql/11_top_labels.sql +8 -0
  57. sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
  58. sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
  59. sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
  60. sqlmirror/golden/tsql/15_epics.sql +11 -0
  61. sqlmirror/golden/tsql/16_subtasks.sql +9 -0
  62. sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
  63. sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
  64. sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
  65. sqlmirror/golden/tsql/20_resolutions.sql +7 -0
  66. sqlmirror/golden/tsql/21_issue_history.sql +10 -0
  67. sqlmirror/golden/tsql/22_reopened.sql +11 -0
  68. sqlmirror/golden/tsql/23_overdue.sql +8 -0
  69. sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
  70. sqlmirror/golden/tsql/25_user_picker.sql +12 -0
  71. sqlmirror/golden/tsql/26_attachments.sql +9 -0
  72. sqlmirror/golden/tsql/27_project_categories.sql +8 -0
  73. sqlmirror/golden/tsql/28_issue_types.sql +7 -0
  74. sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
  75. sqlmirror/golden/tsql/30_project_leads.sql +7 -0
  76. sqlmirror/idmap.py +161 -0
  77. sqlmirror/jira.py +223 -0
  78. sqlmirror/mapper.py +774 -0
  79. sqlmirror/reconcile.py +73 -0
  80. sqlmirror/samples/dc_export_example.csv +19 -0
  81. sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
  82. sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
  83. sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
  84. sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
  85. sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
  86. sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
  87. sqlmirror/schema.py +518 -0
  88. sqlmirror/source.py +117 -0
  89. sqlmirror/sqlcheck.py +647 -0
  90. sqlmirror/util.py +80 -0
  91. sqlmirror-0.2.0.dist-info/METADATA +19 -0
  92. sqlmirror-0.2.0.dist-info/RECORD +95 -0
  93. sqlmirror-0.2.0.dist-info/WHEEL +5 -0
  94. sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
  95. sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/reconcile.py ADDED
@@ -0,0 +1,73 @@
1
+ """Compare the mirror with Jira and optionally repair differences."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable
6
+
7
+ from .db import Mirror
8
+ from .jira import JiraClient
9
+ from .mapper import Mapper
10
+ from .util import iso, parse_ts, utcnow
11
+
12
+
13
+ @dataclass
14
+ class ProjectReport:
15
+ key: str
16
+ jira: int | None
17
+ mirror: int
18
+ missing: list[int] = field(default_factory=list)
19
+ extra: list[int] = field(default_factory=list)
20
+ stale: list[int] = field(default_factory=list)
21
+
22
+ @property
23
+ def ok(self) -> bool:
24
+ return self.jira == self.mirror and not self.missing and not self.extra and not self.stale
25
+
26
+
27
+ def reconcile(client: JiraClient, db: Mirror, mapper: Mapper, *, projects: list[str] | None = None,
28
+ deep: bool = False, repair: bool = False, log: Callable[[str], None] = print) -> list[ProjectReport]:
29
+ db.load_registry(mapper)
30
+ counts = db.project_counts()
31
+ keys = [k for k in counts if not projects or k in {p.upper() for p in projects}]
32
+ reports: list[ProjectReport] = []
33
+ for key in keys:
34
+ jql = f'project = "{key}"'
35
+ rep = ProjectReport(key=key, jira=client.approximate_count(jql), mirror=counts[key])
36
+ if deep or repair:
37
+ jira_updated: dict[int, object] = {}
38
+ for page in client.search(jql + " ORDER BY id ASC", fields=["updated"], page_size=1000):
39
+ for issue in page:
40
+ jira_updated[int(issue["id"])] = parse_ts((issue.get("fields") or {}).get("updated"))
41
+ mirror_updated = db.issue_updated(key)
42
+ rep.jira = len(jira_updated)
43
+ rep.missing = sorted(set(jira_updated) - set(mirror_updated))
44
+ rep.extra = sorted(set(mirror_updated) - set(jira_updated))
45
+ # SQL Server's datetime rounds to 1/300 s; don't call that stale.
46
+ slack = db.timestamp_resolution
47
+ rep.stale = sorted(i for i in set(jira_updated) & set(mirror_updated)
48
+ if jira_updated[i] and mirror_updated[i] and jira_updated[i] > mirror_updated[i] + slack)
49
+ reports.append(rep)
50
+ if repair and (rep.missing or rep.stale or rep.extra):
51
+ fix = rep.missing + rep.stale
52
+ for i in range(0, len(fix), 100):
53
+ fetched_at = iso(utcnow())
54
+ issues = client.bulkfetch([str(x) for x in fix[i:i + 100]])
55
+ snaps = [client.complete_snapshot(issue, fetched_at) for issue in issues]
56
+ db.apply_records(mapper, snaps, source="reconcile")
57
+ if rep.extra:
58
+ now = iso(utcnow())
59
+ db.apply_records(mapper, [{"v": 1, "type": "deleted", "id": str(x), "deletedAt": now}
60
+ for x in rep.extra], source="reconcile")
61
+ log(f"[{key}] repaired {len(fix)} issues, removed {len(rep.extra)} deleted issues")
62
+ return reports
63
+
64
+
65
+ def format_reports(reports: list[ProjectReport], deep: bool) -> str:
66
+ lines = ["Project Jira Mirror " + ("Missing Extra Stale " if deep else "") + "Status"]
67
+ for r in reports:
68
+ status = "OK" if (r.ok if deep else r.jira == r.mirror) else "DIFFERENT"
69
+ line = f"{r.key:<9} {str(r.jira if r.jira is not None else '?'):>5} {r.mirror:>8} "
70
+ if deep:
71
+ line += f"{len(r.missing):>7} {len(r.extra):>6} {len(r.stale):>6} "
72
+ lines.append(line + status)
73
+ return "\n".join(lines)
@@ -0,0 +1,19 @@
1
+ entity,dc_id,name,extra
2
+ status,1,To Do,
3
+ status,3,In Progress,
4
+ status,10200,In Review,
5
+ status,6,Done,
6
+ priority,1,Highest,
7
+ priority,2,High,
8
+ priority,3,Medium,
9
+ priority,4,Low,
10
+ priority,5,Lowest,
11
+ project,10100,ALPHA,Alpha Project
12
+ project,10101,BETA,Beta Project
13
+ customfield,10500,Customer Impact,com.atlassian.jira.plugin.system.customfieldtypes:select
14
+ customfield,10101,Sprint,com.pyxis.greenhopper.jira:gh-sprint
15
+ customfield,10106,Story Points,com.atlassian.jira.plugin.system.customfieldtypes:float
16
+ option,10603,Critical,Customer Impact
17
+ option,10602,High,Customer Impact
18
+ user,jsmith,ana.smith@example.com,Ana Smith
19
+ user,JIRAUSER10200,nobody@example.com,Someone Who Left
@@ -0,0 +1,10 @@
1
+ -- MySQL: closed sprints with completed issue counts
2
+ SELECT s.NAME AS sprint, COUNT(*) AS issues,
3
+ SUM(CASE WHEN st.statuscategory = 3 THEN 1 ELSE 0 END) AS done
4
+ FROM AO_60DB71_SPRINT s
5
+ JOIN customfieldvalue cfv ON cfv.stringvalue = CAST(s.ID AS CHAR)
6
+ JOIN jiraissue ji ON ji.id = cfv.issue
7
+ JOIN issuestatus st ON st.id = ji.issuestatus
8
+ WHERE s.CLOSED = 1 AND cfv.customfield = 10101
9
+ GROUP BY s.NAME, s.ID
10
+ ORDER BY s.ID;
@@ -0,0 +1,6 @@
1
+ -- Postgres: everything assigned to one person, by Data Center username
2
+ SELECT ji.pkey, ji.summary, pr.pname AS priority
3
+ FROM jiraissue ji
4
+ JOIN priority pr ON pr.id = ji.priority
5
+ WHERE ji.assignee = 'jsmith'
6
+ ORDER BY ji.created;
@@ -0,0 +1,7 @@
1
+ -- Postgres: critical customer-impact issues, by hard-coded custom field and option ids
2
+ SELECT ji.pkey, ji.summary
3
+ FROM jiraissue ji
4
+ JOIN customfieldvalue cfv ON cfv.issue = ji.id
5
+ WHERE cfv.customfield = 10500
6
+ AND cfv.stringvalue = '10603'
7
+ ORDER BY ji.pkey;
@@ -0,0 +1,7 @@
1
+ -- Postgres: hours logged per project this year (works unchanged)
2
+ SELECT p.pkey, round(sum(w.timeworked) / 3600.0, 1) AS hours
3
+ FROM worklog w
4
+ JOIN jiraissue ji ON ji.id = w.issueid
5
+ JOIN project p ON p.id = ji.project
6
+ GROUP BY p.pkey
7
+ ORDER BY p.pkey;
@@ -0,0 +1,4 @@
1
+ -- Postgres: current workflow step per issue (uses workflow engine tables)
2
+ SELECT ji.pkey, cs.step_id
3
+ FROM jiraissue ji
4
+ JOIN os_currentstep cs ON cs.entry_id = ji.workflow_id;
@@ -0,0 +1,9 @@
1
+ -- SQL Server: open issues per status for one project, last 90 days
2
+ SELECT TOP 20 p.pkey + '-' + CAST(ji.issuenum AS varchar(20)) AS issue_key, s.pname, ji.created
3
+ FROM dbo.jiraissue ji WITH (NOLOCK)
4
+ JOIN dbo.project p ON p.id = ji.project
5
+ JOIN dbo.issuestatus s ON s.id = ji.issuestatus
6
+ WHERE ji.issuestatus IN ('1', '3')
7
+ AND ji.project = 10100
8
+ AND DATEDIFF(day, ji.created, GETDATE()) < 90
9
+ ORDER BY ji.created DESC;
sqlmirror/schema.py ADDED
@@ -0,0 +1,518 @@
1
+ """Table definitions for the Data-Center-compatible mirror.
2
+
3
+ One place defines every table: its columns, primary key, and how rows are
4
+ replaced when an issue changes. DDL for each dialect and the database writer
5
+ are generated from these definitions, so they cannot drift apart.
6
+
7
+ Column types are logical names mapped per dialect in ``column_type()``
8
+ (Postgres / SQL Server, the types Data Center uses on each):
9
+ id -> numeric(18,0) numeric(18,0)
10
+ num -> numeric(18,0) numeric(18,0)
11
+ float -> double precision float
12
+ str -> varchar(255) nvarchar(255)
13
+ text -> text nvarchar(max)
14
+ ts -> timestamp with time zone datetime (UTC; bookkeeping tables datetime2(6))
15
+ bool -> boolean bit
16
+ big -> bigint bigint
17
+ int -> integer int
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Table:
26
+ name: str
27
+ columns: tuple[tuple[str, str], ...]
28
+ pk: tuple[str, ...]
29
+ # How rows are replaced:
30
+ # "issue" - all rows for an issue are deleted and re-inserted on every snapshot;
31
+ # issue_col names the column holding the issue id.
32
+ # "upsert" - insert or update by primary key; null values never overwrite
33
+ # existing non-null values.
34
+ # "state" - mirror bookkeeping, managed directly by the writer.
35
+ mode: str
36
+ issue_col: str | None = None
37
+ quoted: bool = False # Active Objects tables use quoted upper-case names
38
+ indexes: tuple[tuple[str, ...], ...] = field(default_factory=tuple)
39
+
40
+ @property
41
+ def column_names(self) -> list[str]:
42
+ return [c for c, _ in self.columns]
43
+
44
+ def q(self, name: str) -> str:
45
+ """Quote an identifier if this table uses quoted names."""
46
+ return f'"{name}"' if self.quoted else name
47
+
48
+ @property
49
+ def qname(self) -> str:
50
+ return self.q(self.name)
51
+
52
+
53
+ def _cols(*pairs: str) -> tuple[tuple[str, str], ...]:
54
+ out = []
55
+ for p in pairs:
56
+ name, typ = p.split(":")
57
+ out.append((name, typ))
58
+ return tuple(out)
59
+
60
+
61
+ TABLES: list[Table] = [
62
+ # ---------------------------------------------------------------- issue data
63
+ Table(
64
+ "jiraissue",
65
+ _cols(
66
+ "id:id", "pkey:str", "issuenum:num", "project:id", "reporter:str",
67
+ "assignee:str", "creator:str", "issuetype:str", "summary:str",
68
+ "description:text", "environment:text", "priority:str", "resolution:str",
69
+ "issuestatus:str", "created:ts", "updated:ts", "duedate:ts",
70
+ "resolutiondate:ts", "votes:num", "watches:num", "timeoriginalestimate:num",
71
+ "timeestimate:num", "timespent:num", "workflow_id:id", "security:id",
72
+ "fixfor:id", "component:id", "archived:str", "archivedby:str",
73
+ "archiveddate:ts",
74
+ ),
75
+ pk=("id",),
76
+ mode="issue",
77
+ issue_col="id",
78
+ indexes=(("project", "issuenum"), ("issuestatus",), ("assignee",), ("updated",)),
79
+ ),
80
+ Table(
81
+ "changegroup",
82
+ _cols("id:id", "issueid:id", "author:str", "created:ts"),
83
+ pk=("id",),
84
+ mode="issue",
85
+ issue_col="issueid",
86
+ indexes=(("issueid",),),
87
+ ),
88
+ Table(
89
+ "changeitem",
90
+ _cols(
91
+ "id:id", "groupid:id", "fieldtype:str", "field:str", "oldvalue:text",
92
+ "oldstring:text", "newvalue:text", "newstring:text",
93
+ ),
94
+ pk=("id",),
95
+ mode="issue",
96
+ issue_col=None, # deleted through changegroup.issueid
97
+ indexes=(("groupid",), ("field",)),
98
+ ),
99
+ Table(
100
+ "jiraaction",
101
+ _cols(
102
+ "id:id", "issueid:id", "author:str", "actiontype:str", "actionlevel:str",
103
+ "rolelevel:id", "actionbody:text", "created:ts", "updateauthor:str",
104
+ "updated:ts", "actionnum:num",
105
+ ),
106
+ pk=("id",),
107
+ mode="issue",
108
+ issue_col="issueid",
109
+ indexes=(("issueid",),),
110
+ ),
111
+ Table(
112
+ "worklog",
113
+ _cols(
114
+ "id:id", "issueid:id", "author:str", "grouplevel:str", "rolelevel:id",
115
+ "worklogbody:text", "created:ts", "updateauthor:str", "updated:ts",
116
+ "startdate:ts", "timeworked:num",
117
+ ),
118
+ pk=("id",),
119
+ mode="issue",
120
+ issue_col="issueid",
121
+ indexes=(("issueid",), ("author",)),
122
+ ),
123
+ Table(
124
+ "customfieldvalue",
125
+ _cols(
126
+ "id:id", "issue:id", "customfield:id", "updated:big", "parentkey:str",
127
+ "stringvalue:str", "numbervalue:float", "textvalue:text", "datevalue:ts",
128
+ "valuetype:str",
129
+ ),
130
+ pk=("id",),
131
+ mode="issue",
132
+ issue_col="issue",
133
+ indexes=(("issue", "customfield"), ("customfield",)),
134
+ ),
135
+ Table(
136
+ "label",
137
+ _cols("id:id", "fieldid:id", "issue:id", "label:str"),
138
+ pk=("id",),
139
+ mode="issue",
140
+ issue_col="issue",
141
+ indexes=(("issue",), ("label",)),
142
+ ),
143
+ Table(
144
+ "nodeassociation",
145
+ _cols(
146
+ "source_node_id:id", "source_node_entity:str", "sink_node_id:id",
147
+ "sink_node_entity:str", "association_type:str", "sequence:num",
148
+ ),
149
+ pk=("source_node_id", "source_node_entity", "sink_node_id", "sink_node_entity", "association_type"),
150
+ mode="issue",
151
+ issue_col="source_node_id", # only rows with source_node_entity = 'Issue'
152
+ indexes=(("sink_node_id", "association_type"),),
153
+ ),
154
+ Table(
155
+ "issuelink",
156
+ _cols("id:id", "linktype:id", "source:id", "destination:id", "sequence:num"),
157
+ pk=("id",),
158
+ mode="issue",
159
+ issue_col=None, # deleted where source or destination is the issue
160
+ indexes=(("source",), ("destination",), ("linktype",)),
161
+ ),
162
+ Table(
163
+ "fileattachment",
164
+ _cols(
165
+ "id:id", "issueid:id", "mimetype:str", "filename:str", "created:ts",
166
+ "filesize:num", "author:str", "zip:int", "thumbnailable:int",
167
+ ),
168
+ pk=("id",),
169
+ mode="issue",
170
+ issue_col="issueid",
171
+ indexes=(("issueid",),),
172
+ ),
173
+ # ------------------------------------------------------------ configuration
174
+ Table(
175
+ "project",
176
+ _cols(
177
+ "id:id", "pname:str", "url:str", "lead:str", "description:text", "pkey:str",
178
+ "pcounter:num", "assigneetype:num", "avatar:id", "originalkey:str",
179
+ "projecttype:str",
180
+ ),
181
+ pk=("id",),
182
+ mode="upsert",
183
+ indexes=(("pkey",),),
184
+ ),
185
+ Table("project_key", _cols("id:id", "project_id:id", "project_key:str"), pk=("id",), mode="upsert"),
186
+ Table("projectcategory", _cols("id:id", "cname:str", "description:text"), pk=("id",), mode="upsert"),
187
+ Table(
188
+ "issuestatus",
189
+ _cols("id:str", "sequence:num", "pname:str", "description:text", "iconurl:str", "statuscategory:num"),
190
+ pk=("id",),
191
+ mode="upsert",
192
+ ),
193
+ Table(
194
+ "issuetype",
195
+ _cols("id:str", "sequence:num", "pname:str", "pstyle:str", "description:text", "iconurl:str", "avatar:id"),
196
+ pk=("id",),
197
+ mode="upsert",
198
+ ),
199
+ Table(
200
+ "priority",
201
+ _cols("id:str", "sequence:num", "pname:str", "description:text", "iconurl:str", "status_color:str"),
202
+ pk=("id",),
203
+ mode="upsert",
204
+ ),
205
+ Table(
206
+ "resolution",
207
+ _cols("id:str", "sequence:num", "pname:str", "description:text", "iconurl:str"),
208
+ pk=("id",),
209
+ mode="upsert",
210
+ ),
211
+ Table(
212
+ "projectversion",
213
+ _cols(
214
+ "id:id", "project:id", "vname:str", "description:text", "sequence:num",
215
+ "released:str", "archived:str", "url:str", "startdate:ts", "releasedate:ts",
216
+ ),
217
+ pk=("id",),
218
+ mode="upsert",
219
+ indexes=(("project",),),
220
+ ),
221
+ Table(
222
+ "component",
223
+ _cols(
224
+ "id:id", "project:id", "cname:str", "description:text", "url:str", "lead:str",
225
+ "assigneetype:num", "archived:str", "deleted:str",
226
+ ),
227
+ pk=("id",),
228
+ mode="upsert",
229
+ indexes=(("project",),),
230
+ ),
231
+ Table(
232
+ "customfield",
233
+ _cols(
234
+ "id:id", "cfkey:str", "customfieldtypekey:str", "customfieldsearcherkey:str",
235
+ "cfname:str", "description:text", "defaultvalue:str", "fieldtype:num",
236
+ "project:id", "issuetype:str",
237
+ ),
238
+ pk=("id",),
239
+ mode="upsert",
240
+ ),
241
+ Table(
242
+ "customfieldoption",
243
+ _cols(
244
+ "id:id", "customfield:id", "customfieldconfig:id", "parentoptionid:id",
245
+ "sequence:num", "customvalue:str", "optiontype:str", "disabled:str",
246
+ ),
247
+ pk=("id",),
248
+ mode="upsert",
249
+ indexes=(("customfield",),),
250
+ ),
251
+ Table(
252
+ "issuelinktype",
253
+ _cols("id:id", "linkname:str", "inward:str", "outward:str", "pstyle:str"),
254
+ pk=("id",),
255
+ mode="upsert",
256
+ ),
257
+ Table(
258
+ "app_user",
259
+ _cols("id:id", "user_key:str", "lower_user_name:str"),
260
+ pk=("id",),
261
+ mode="upsert",
262
+ indexes=(("user_key",), ("lower_user_name",)),
263
+ ),
264
+ Table(
265
+ "cwd_user",
266
+ _cols(
267
+ "id:id", "directory_id:id", "user_name:str", "lower_user_name:str", "active:int",
268
+ "created_date:ts", "updated_date:ts", "first_name:str", "lower_first_name:str",
269
+ "last_name:str", "lower_last_name:str", "display_name:str",
270
+ "lower_display_name:str", "email_address:str", "lower_email_address:str",
271
+ "external_id:str",
272
+ ),
273
+ pk=("id",),
274
+ mode="upsert",
275
+ indexes=(("lower_user_name",),),
276
+ ),
277
+ # Jira Software (Active Objects) tables keep their quoted upper-case names,
278
+ # exactly as on a Data Center Postgres database.
279
+ Table(
280
+ "AO_60DB71_SPRINT",
281
+ _cols(
282
+ "ID:big", "NAME:str", "GOAL:text", "STARTED:bool", "CLOSED:bool",
283
+ "START_DATE:big", "END_DATE:big", "COMPLETE_DATE:big", "ACTIVATED_DATE:big",
284
+ "RAPID_VIEW_ID:big", "SEQUENCE:big",
285
+ ),
286
+ pk=("ID",),
287
+ mode="upsert",
288
+ quoted=True,
289
+ ),
290
+ Table(
291
+ "AO_60DB71_RAPIDVIEW",
292
+ _cols("ID:big", "NAME:str", "FILTER_ID:big", "OWNER_USER_NAME:str"),
293
+ pk=("ID",),
294
+ mode="upsert",
295
+ quoted=True,
296
+ ),
297
+ # ------------------------------------------------------- mirror bookkeeping
298
+ Table(
299
+ "sqlmirror_issue_state",
300
+ _cols("issue_id:id", "fetched_at:ts", "deleted:bool", "source:str", "applied_at:ts"),
301
+ pk=("issue_id",),
302
+ mode="state",
303
+ ),
304
+ Table(
305
+ "sqlmirror_applied_file",
306
+ _cols("key:text", "records:int", "applied_at:ts"),
307
+ pk=("key",),
308
+ mode="state",
309
+ ),
310
+ Table("sqlmirror_meta", _cols("key:str", "value:text"), pk=("key",), mode="state"),
311
+ Table(
312
+ "sqlmirror_sync_log",
313
+ _cols(
314
+ "id:big", "started_at:ts", "finished_at:ts", "kind:str", "source:text", "files:int",
315
+ "issues:int", "tombstones:int", "skipped:int", "error:text",
316
+ ),
317
+ pk=("id",),
318
+ mode="state",
319
+ indexes=(("started_at",),),
320
+ ),
321
+ ]
322
+
323
+ TABLE_BY_NAME: dict[str, Table] = {t.name: t for t in TABLES}
324
+
325
+ # Tables whose rows belong to a single issue and are replaced on every snapshot.
326
+ ISSUE_TABLES = [t for t in TABLES if t.mode == "issue"]
327
+ UPSERT_TABLES = [t for t in TABLES if t.mode == "upsert"]
328
+
329
+ SCHEMA_VERSION = "1"
330
+
331
+ # Data Center tables a report might use that the mirror does not provide,
332
+ # with the reason. Used by the SQL checker.
333
+ UNSUPPORTED_TABLES: dict[str, str] = {
334
+ "os_wfentry": "workflow engine state; Cloud has no equivalent export",
335
+ "os_currentstep": "workflow engine state; Cloud has no equivalent export",
336
+ "os_historystep": "workflow engine state; use changeitem rows where field = 'status' instead",
337
+ "jiraworkflows": "workflow XML is not available through the Cloud REST API",
338
+ "workflowscheme": "scheme configuration is not mirrored",
339
+ "workflowschemeentity": "scheme configuration is not mirrored",
340
+ "searchrequest": "saved filters are not mirrored yet",
341
+ "schemepermissions": "permission schemes are not mirrored",
342
+ "permissionscheme": "permission schemes are not mirrored",
343
+ "cwd_group": "groups are not mirrored yet",
344
+ "cwd_membership": "group membership is not mirrored yet",
345
+ "cwd_directory": "Cloud users live in a single Atlassian directory; directory_id is always 1",
346
+ "propertyentry": "Data Center property storage has no Cloud equivalent",
347
+ "propertystring": "Data Center property storage has no Cloud equivalent",
348
+ "propertynumber": "Data Center property storage has no Cloud equivalent",
349
+ "propertytext": "Data Center property storage has no Cloud equivalent",
350
+ "fieldconfiguration": "field configuration schemes are not mirrored",
351
+ "fieldlayout": "field configuration schemes are not mirrored",
352
+ "fieldlayoutitem": "field configuration schemes are not mirrored",
353
+ "fieldscreen": "screens are not mirrored",
354
+ "configurationcontext": "custom field contexts are not mirrored",
355
+ "fieldconfigscheme": "custom field contexts are not mirrored",
356
+ "notificationinstance": "notification schemes are not mirrored",
357
+ "jiraperms": "permissions are not mirrored",
358
+ "userassociation": "watchers and voters are not mirrored per user (counts are in jiraissue.watches / votes)",
359
+ "audit_log": "the Cloud audit log is a separate API",
360
+ "projectroleactor": "project role membership is not mirrored yet",
361
+ "projectrole": "project roles are not mirrored yet",
362
+ "issuesecurityscheme": "issue security schemes are not mirrored",
363
+ "schemeissuesecuritylevels": "issue security schemes are not mirrored",
364
+ }
365
+
366
+ # Columns that exist for compatibility but are always NULL in the mirror.
367
+ ALWAYS_NULL_COLUMNS: dict[tuple[str, str], str] = {
368
+ ("jiraissue", "workflow_id"): "Cloud does not expose workflow instance ids",
369
+ ("jiraissue", "fixfor"): "legacy column; use nodeassociation with association_type = 'IssueFixVersion'",
370
+ ("jiraissue", "component"): "legacy column; use nodeassociation with association_type = 'IssueComponent'",
371
+ ("jiraaction", "actionnum"): "not provided by Cloud",
372
+ ("customfieldoption", "customfieldconfig"): "custom field contexts are not mirrored",
373
+ ("customfieldoption", "sequence"): "option order is not available from issue data",
374
+ ("AO_60DB71_RAPIDVIEW", "NAME"): "board names need Jira Software board scopes (planned)",
375
+ }
376
+
377
+ _DIALECT_TYPES = {
378
+ "postgres": {
379
+ "id": "numeric(18,0)",
380
+ "num": "numeric(18,0)",
381
+ "float": "double precision",
382
+ "str": "varchar(255)",
383
+ "text": "text",
384
+ "ts": "timestamp with time zone",
385
+ "bool": "boolean",
386
+ "big": "bigint",
387
+ "int": "integer",
388
+ },
389
+ # The column types Jira Data Center itself uses on SQL Server.
390
+ "tsql": {
391
+ "id": "numeric(18,0)",
392
+ "num": "numeric(18,0)",
393
+ "float": "float",
394
+ "str": "nvarchar(255)",
395
+ "text": "nvarchar(max)",
396
+ "ts": "datetime",
397
+ "bool": "bit",
398
+ "big": "bigint",
399
+ "int": "int",
400
+ },
401
+ }
402
+
403
+ DIALECT_ALIASES = {"postgres": "postgres", "postgresql": "postgres", "pg": "postgres",
404
+ "tsql": "tsql", "sqlserver": "tsql", "mssql": "tsql", "azuresql": "tsql"}
405
+
406
+ # SQL Server only: Data Center's own widths for the nodeassociation key columns
407
+ # (keeps the five-column primary key under SQL Server's 900-byte limit).
408
+ _TSQL_COLUMN_TYPES: dict[tuple[str, str], str] = {
409
+ ("nodeassociation", "source_node_entity"): "nvarchar(60)",
410
+ ("nodeassociation", "sink_node_entity"): "nvarchar(60)",
411
+ ("nodeassociation", "association_type"): "nvarchar(60)",
412
+ }
413
+
414
+
415
+ # Bucket object keys are case-sensitive; the default SQL Server collation is not.
416
+ _TSQL_BINARY_COLUMNS: dict[tuple[str, str], str] = {("sqlmirror_applied_file", "key"): "Latin1_General_100_BIN2"}
417
+
418
+
419
+ def normalize_dialect(dialect: str) -> str:
420
+ try:
421
+ return DIALECT_ALIASES[dialect.lower()]
422
+ except KeyError:
423
+ raise ValueError(f"unknown database dialect {dialect!r}; use postgres or sqlserver") from None
424
+
425
+
426
+ def column_type(t: Table, name: str, typ: str, dialect: str) -> str:
427
+ """The SQL type of one column in one dialect."""
428
+ dialect = normalize_dialect(dialect)
429
+ types = _DIALECT_TYPES[dialect]
430
+ if dialect == "tsql":
431
+ if (t.name, name) in _TSQL_COLUMN_TYPES:
432
+ return _TSQL_COLUMN_TYPES[(t.name, name)]
433
+ if typ == "ts" and t.mode == "state":
434
+ # Bookkeeping times must round-trip exactly (newest snapshot wins);
435
+ # datetime only keeps 1/300 s.
436
+ return "datetime2(6)"
437
+ sql_type = types[typ]
438
+ # Free-text columns that can exceed 255 characters in Cloud data.
439
+ if typ == "str" and name in _LONG_STR_COLUMNS.get(t.name, ()):
440
+ sql_type = types["text"]
441
+ if dialect == "tsql" and sql_type == "nvarchar(max)":
442
+ # nvarchar(max) can't be part of a key or an index.
443
+ if name in t.pk:
444
+ return "nvarchar(450)" # 900-byte primary key limit
445
+ if any(name in idx for idx in t.indexes):
446
+ return "nvarchar(850)" # 1700-byte index key limit
447
+ return sql_type
448
+
449
+
450
+ def tsql_ident(name: str) -> str:
451
+ return "[" + name.replace("]", "]]") + "]"
452
+
453
+
454
+ def ddl_statements(dialect: str = "postgres", schema: str | None = None) -> list[str]:
455
+ """CREATE TABLE / CREATE INDEX statements for every table (each one idempotent)."""
456
+ dialect = normalize_dialect(dialect)
457
+ if dialect == "tsql":
458
+ return _tsql_ddl(schema or "dbo")
459
+ out: list[str] = []
460
+ for t in TABLES:
461
+ cols = [f" {t.q(name)} {column_type(t, name, typ, dialect)}" for name, typ in t.columns]
462
+ pk = ", ".join(t.q(c) for c in t.pk)
463
+ cols.append(f" PRIMARY KEY ({pk})")
464
+ out.append(f"CREATE TABLE IF NOT EXISTS {t.qname} (\n" + ",\n".join(cols) + "\n);")
465
+ for idx in t.indexes:
466
+ idx_name = f"sqlm_{t.name.lower()}_{'_'.join(c.lower() for c in idx)}"
467
+ out.append(
468
+ f"CREATE INDEX IF NOT EXISTS {idx_name} ON {t.qname} ({', '.join(t.q(c) for c in idx)});"
469
+ )
470
+ return out
471
+
472
+
473
+ def _tsql_ddl(schema: str) -> list[str]:
474
+ q = tsql_ident
475
+ out: list[str] = []
476
+ if schema.lower() != "dbo":
477
+ lit = schema.replace("'", "''")
478
+ out.append(f"IF SCHEMA_ID(N'{lit}') IS NULL EXEC(N'CREATE SCHEMA {q(schema).replace(chr(39), chr(39) * 2)}');")
479
+ for t in TABLES:
480
+ full = f"{q(schema)}.{q(t.name)}"
481
+ lit = full.replace("'", "''")
482
+ cols = []
483
+ for name, typ in t.columns:
484
+ null = "NOT NULL" if name in t.pk else "NULL"
485
+ collate = f" COLLATE {_TSQL_BINARY_COLUMNS[(t.name, name)]}" if (t.name, name) in _TSQL_BINARY_COLUMNS else ""
486
+ cols.append(f" {q(name)} {column_type(t, name, typ, 'tsql')}{collate} {null}")
487
+ cols.append(f" CONSTRAINT {q('pk_sqlm_' + t.name.lower())} PRIMARY KEY ({', '.join(q(c) for c in t.pk)})")
488
+ out.append(f"IF OBJECT_ID(N'{lit}', N'U') IS NULL\nCREATE TABLE {full} (\n" + ",\n".join(cols) + "\n);")
489
+ for idx in t.indexes:
490
+ idx_name = f"sqlm_{t.name.lower()}_{'_'.join(c.lower() for c in idx)}"
491
+ out.append(
492
+ f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{idx_name}' AND object_id = OBJECT_ID(N'{lit}'))\n"
493
+ f"CREATE INDEX {q(idx_name)} ON {full} ({', '.join(q(c) for c in idx)});"
494
+ )
495
+ return out
496
+
497
+
498
+ def ddl(dialect: str = "postgres", schema: str | None = None) -> str:
499
+ """CREATE TABLE / CREATE INDEX statements for every table (idempotent)."""
500
+ return "\n".join(ddl_statements(dialect, schema)) + "\n"
501
+
502
+
503
+ # varchar(255) in Data Center, but Cloud values can be longer (for example
504
+ # changelog strings and option values); store as text so inserts never fail.
505
+ _LONG_STR_COLUMNS: dict[str, tuple[str, ...]] = {
506
+ "jiraissue": ("summary",),
507
+ "customfieldvalue": ("stringvalue",),
508
+ "customfieldoption": ("customvalue",),
509
+ "project": ("url",),
510
+ "projectversion": ("url",),
511
+ "component": ("url",),
512
+ "issuestatus": ("iconurl",),
513
+ "issuetype": ("iconurl",),
514
+ "priority": ("iconurl",),
515
+ "resolution": ("iconurl",),
516
+ "fileattachment": ("filename", "mimetype"),
517
+ "label": ("label",),
518
+ }