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/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,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,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
|
+
}
|