kaiten-cli 0.3.3__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.
- kaiten_cli/__init__.py +5 -0
- kaiten_cli/__main__.py +9 -0
- kaiten_cli/agent_gateway.py +577 -0
- kaiten_cli/app.py +1636 -0
- kaiten_cli/completion.py +570 -0
- kaiten_cli/discovery.py +39 -0
- kaiten_cli/errors.py +79 -0
- kaiten_cli/i18n.py +92 -0
- kaiten_cli/locales/ru.json +2967 -0
- kaiten_cli/localized_click.py +160 -0
- kaiten_cli/models.py +202 -0
- kaiten_cli/profiles.py +316 -0
- kaiten_cli/registry/__init__.py +260 -0
- kaiten_cli/registry/addons.py +392 -0
- kaiten_cli/registry/audit_and_analytics.py +451 -0
- kaiten_cli/registry/automation_schema.py +44 -0
- kaiten_cli/registry/automation_schemas.json +7417 -0
- kaiten_cli/registry/automations.py +342 -0
- kaiten_cli/registry/base.py +62 -0
- kaiten_cli/registry/blockers.py +320 -0
- kaiten_cli/registry/boards.py +520 -0
- kaiten_cli/registry/card_relations.py +376 -0
- kaiten_cli/registry/card_types.py +363 -0
- kaiten_cli/registry/cards.py +1329 -0
- kaiten_cli/registry/charts.py +539 -0
- kaiten_cli/registry/checklists.py +600 -0
- kaiten_cli/registry/columns.py +558 -0
- kaiten_cli/registry/comments.py +224 -0
- kaiten_cli/registry/custom_directories.py +980 -0
- kaiten_cli/registry/custom_properties.py +1460 -0
- kaiten_cli/registry/dashboards.py +564 -0
- kaiten_cli/registry/documents.py +761 -0
- kaiten_cli/registry/external_links.py +128 -0
- kaiten_cli/registry/files.py +738 -0
- kaiten_cli/registry/github_addon.py +613 -0
- kaiten_cli/registry/iterations.py +397 -0
- kaiten_cli/registry/lanes.py +205 -0
- kaiten_cli/registry/live_contracts.py +361 -0
- kaiten_cli/registry/members.py +336 -0
- kaiten_cli/registry/module_docs.py +108 -0
- kaiten_cli/registry/projects.py +369 -0
- kaiten_cli/registry/query.py +129 -0
- kaiten_cli/registry/restrictions.py +249 -0
- kaiten_cli/registry/roles_and_groups.py +1102 -0
- kaiten_cli/registry/scim.py +263 -0
- kaiten_cli/registry/service_desk.py +1270 -0
- kaiten_cli/registry/snapshot.py +187 -0
- kaiten_cli/registry/spaces.py +272 -0
- kaiten_cli/registry/subscribers.py +168 -0
- kaiten_cli/registry/tags.py +193 -0
- kaiten_cli/registry/time_logs.py +353 -0
- kaiten_cli/registry/tree.py +352 -0
- kaiten_cli/registry/utilities.py +287 -0
- kaiten_cli/registry/webhooks.py +265 -0
- kaiten_cli/runtime/__init__.py +1 -0
- kaiten_cli/runtime/automation_validation.py +115 -0
- kaiten_cli/runtime/behaviors.py +787 -0
- kaiten_cli/runtime/cache.py +733 -0
- kaiten_cli/runtime/client.py +387 -0
- kaiten_cli/runtime/endpoints.py +59 -0
- kaiten_cli/runtime/executor.py +269 -0
- kaiten_cli/runtime/fs_security.py +168 -0
- kaiten_cli/runtime/input.py +310 -0
- kaiten_cli/runtime/output.py +59 -0
- kaiten_cli/runtime/public_validation.py +35 -0
- kaiten_cli/runtime/snapshot_build.py +527 -0
- kaiten_cli/runtime/snapshot_common.py +363 -0
- kaiten_cli/runtime/snapshot_query.py +306 -0
- kaiten_cli/runtime/snapshot_store.py +1192 -0
- kaiten_cli/runtime/snapshots.py +62 -0
- kaiten_cli/runtime/sqlite_errors.py +23 -0
- kaiten_cli/runtime/support/__init__.py +1 -0
- kaiten_cli/runtime/support/addons.py +953 -0
- kaiten_cli/runtime/support/audit.py +70 -0
- kaiten_cli/runtime/support/batch.py +234 -0
- kaiten_cli/runtime/support/card_move_url.py +213 -0
- kaiten_cli/runtime/support/cards.py +174 -0
- kaiten_cli/runtime/support/checklists.py +21 -0
- kaiten_cli/runtime/support/company_users.py +117 -0
- kaiten_cli/runtime/support/dashboards.py +35 -0
- kaiten_cli/runtime/support/documents.py +175 -0
- kaiten_cli/runtime/support/files.py +561 -0
- kaiten_cli/runtime/support/markdown_export.py +525 -0
- kaiten_cli/runtime/support/pagination.py +167 -0
- kaiten_cli/runtime/support/projects.py +66 -0
- kaiten_cli/runtime/support/relations.py +86 -0
- kaiten_cli/runtime/support/restrictions.py +62 -0
- kaiten_cli/runtime/support/spaces.py +20 -0
- kaiten_cli/runtime/support/time_logs.py +61 -0
- kaiten_cli/runtime/support/tree.py +140 -0
- kaiten_cli/runtime/support/tree_sharing.py +447 -0
- kaiten_cli/runtime/trace.py +500 -0
- kaiten_cli/runtime/transforms.py +128 -0
- kaiten_cli/schema_docs.py +22 -0
- kaiten_cli/update_check.py +519 -0
- kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
- kaiten_cli-0.3.3.dist-info/RECORD +99 -0
- kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
- kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
"""SQLite schema and persistence for local Kaiten snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from platformdirs import user_data_path
|
|
15
|
+
|
|
16
|
+
from kaiten_cli.errors import ConfigError
|
|
17
|
+
from kaiten_cli.i18n import tr
|
|
18
|
+
from kaiten_cli.runtime.fs_security import (
|
|
19
|
+
ensure_private_file,
|
|
20
|
+
secure_directory,
|
|
21
|
+
secure_existing_file,
|
|
22
|
+
)
|
|
23
|
+
from kaiten_cli.runtime.snapshot_common import (
|
|
24
|
+
SNAPSHOT_DB_SCHEMA_VERSION,
|
|
25
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
26
|
+
_current_stage_entered_at,
|
|
27
|
+
_derive_done_at,
|
|
28
|
+
_duration_days,
|
|
29
|
+
_extract_id,
|
|
30
|
+
_extract_tag_ids,
|
|
31
|
+
_filter_time_logs_to_window,
|
|
32
|
+
_first_history_with_state,
|
|
33
|
+
_iso_timestamp,
|
|
34
|
+
_normalize_bool,
|
|
35
|
+
_normalize_int_list,
|
|
36
|
+
_now_iso,
|
|
37
|
+
_parse_timestamp,
|
|
38
|
+
_search_blob,
|
|
39
|
+
_time_log_minutes,
|
|
40
|
+
_time_log_timestamp,
|
|
41
|
+
)
|
|
42
|
+
from kaiten_cli.runtime.sqlite_errors import is_corrupt_database_error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def snapshot_db_path() -> Path:
|
|
46
|
+
return user_data_path("kaiten-cli") / "snapshots.sqlite3"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
STORAGE_READ_ONLY_ENV = "KAITEN_CLI_STORAGE_READ_ONLY"
|
|
50
|
+
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SnapshotStore:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
path: Path | None = None,
|
|
57
|
+
reporter=None,
|
|
58
|
+
*,
|
|
59
|
+
storage_read_only: bool | None = None,
|
|
60
|
+
):
|
|
61
|
+
self.path = path or snapshot_db_path()
|
|
62
|
+
self.reporter = reporter
|
|
63
|
+
environment_read_only = (
|
|
64
|
+
os.environ.get(STORAGE_READ_ONLY_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
|
|
65
|
+
)
|
|
66
|
+
self.storage_read_only = bool(storage_read_only) or environment_read_only
|
|
67
|
+
|
|
68
|
+
def ensure_writable(self) -> None:
|
|
69
|
+
if self.storage_read_only:
|
|
70
|
+
raise ConfigError(
|
|
71
|
+
tr("Local snapshot writes are disabled in this storage read-only environment.")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _debug(self, message: str) -> None:
|
|
75
|
+
if self.reporter is not None:
|
|
76
|
+
self.reporter(message)
|
|
77
|
+
|
|
78
|
+
def _open_connection(self) -> sqlite3.Connection:
|
|
79
|
+
conn = sqlite3.connect(self.path)
|
|
80
|
+
try:
|
|
81
|
+
conn.row_factory = sqlite3.Row
|
|
82
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
83
|
+
return conn
|
|
84
|
+
except BaseException:
|
|
85
|
+
self._close_quietly(conn)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
def _open_read_connection(self) -> sqlite3.Connection:
|
|
89
|
+
uri = f"{self.path.absolute().as_uri()}?mode=ro"
|
|
90
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
91
|
+
try:
|
|
92
|
+
conn.row_factory = sqlite3.Row
|
|
93
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
94
|
+
return conn
|
|
95
|
+
except BaseException:
|
|
96
|
+
self._close_quietly(conn)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
def _close_quietly(self, conn: sqlite3.Connection | None) -> None:
|
|
100
|
+
if conn is None:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
conn.close()
|
|
104
|
+
except sqlite3.Error:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
def _reset_error(self, action: str) -> ConfigError:
|
|
108
|
+
return ConfigError(
|
|
109
|
+
tr(
|
|
110
|
+
"Unable to {value_0} local snapshot store at {value_1}. Remove the file and retry.",
|
|
111
|
+
value_0=tr(action),
|
|
112
|
+
value_1=self.path,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _access_error(self, action: str, error: BaseException) -> ConfigError:
|
|
117
|
+
return ConfigError(
|
|
118
|
+
tr(
|
|
119
|
+
"Unable to {value_0} local snapshot store at {value_1}: {value_2}",
|
|
120
|
+
value_0=tr(action),
|
|
121
|
+
value_1=self.path,
|
|
122
|
+
value_2=error,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _reset_store(self, reason: str) -> sqlite3.Connection:
|
|
127
|
+
self._debug(
|
|
128
|
+
tr("snapshot: local store dropped store=snapshots reason={value_0}", value_0=reason)
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
self.path.unlink(missing_ok=True)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
raise self._reset_error("reset") from exc
|
|
134
|
+
conn: sqlite3.Connection | None = None
|
|
135
|
+
try:
|
|
136
|
+
ensure_private_file(self.path)
|
|
137
|
+
conn = self._open_connection()
|
|
138
|
+
secure_existing_file(self.path)
|
|
139
|
+
self._initialize_schema(conn)
|
|
140
|
+
self._debug(tr("snapshot: local store recreated store=snapshots"))
|
|
141
|
+
return conn
|
|
142
|
+
except (sqlite3.Error, OSError) as exc:
|
|
143
|
+
self._close_quietly(conn)
|
|
144
|
+
raise self._reset_error("recreate") from exc
|
|
145
|
+
except BaseException:
|
|
146
|
+
self._close_quietly(conn)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _connect(self) -> sqlite3.Connection:
|
|
150
|
+
conn: sqlite3.Connection | None = None
|
|
151
|
+
try:
|
|
152
|
+
secure_directory(self.path.parent)
|
|
153
|
+
existing = self.path.exists()
|
|
154
|
+
ensure_private_file(self.path)
|
|
155
|
+
conn = self._open_connection()
|
|
156
|
+
secure_existing_file(self.path)
|
|
157
|
+
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
158
|
+
if existing and version != SNAPSHOT_DB_SCHEMA_VERSION:
|
|
159
|
+
self._close_quietly(conn)
|
|
160
|
+
return self._reset_store(f"incompatible-schema:{version}")
|
|
161
|
+
self._initialize_schema(conn)
|
|
162
|
+
return conn
|
|
163
|
+
except sqlite3.Error as exc:
|
|
164
|
+
self._close_quietly(conn)
|
|
165
|
+
if is_corrupt_database_error(exc):
|
|
166
|
+
return self._reset_store(type(exc).__name__)
|
|
167
|
+
raise self._access_error("open", exc) from exc
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
self._close_quietly(conn)
|
|
170
|
+
raise self._access_error("open", exc) from exc
|
|
171
|
+
except BaseException:
|
|
172
|
+
self._close_quietly(conn)
|
|
173
|
+
raise
|
|
174
|
+
|
|
175
|
+
def _connect_read_only(self) -> sqlite3.Connection:
|
|
176
|
+
conn: sqlite3.Connection | None = None
|
|
177
|
+
try:
|
|
178
|
+
secure_existing_file(self.path)
|
|
179
|
+
conn = self._open_read_connection()
|
|
180
|
+
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
181
|
+
if version != SNAPSHOT_DB_SCHEMA_VERSION:
|
|
182
|
+
raise ConfigError(
|
|
183
|
+
tr(
|
|
184
|
+
"Local snapshot store at {value_0} has incompatible schema {value_1}; refresh or rebuild it outside the storage read-only sandbox.",
|
|
185
|
+
value_0=self.path,
|
|
186
|
+
value_1=version,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return conn
|
|
190
|
+
except ConfigError:
|
|
191
|
+
self._close_quietly(conn)
|
|
192
|
+
raise
|
|
193
|
+
except (OSError, sqlite3.Error) as exc:
|
|
194
|
+
self._close_quietly(conn)
|
|
195
|
+
raise self._access_error("read", exc) from exc
|
|
196
|
+
except BaseException:
|
|
197
|
+
self._close_quietly(conn)
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
@contextmanager
|
|
201
|
+
def _connection(self, *, read_only: bool = False) -> Iterator[sqlite3.Connection]:
|
|
202
|
+
"""Provide transactional access and always release the SQLite handle."""
|
|
203
|
+
|
|
204
|
+
if self.storage_read_only and not read_only:
|
|
205
|
+
self.ensure_writable()
|
|
206
|
+
conn = self._connect_read_only() if self.storage_read_only else self._connect()
|
|
207
|
+
try:
|
|
208
|
+
if self.storage_read_only:
|
|
209
|
+
yield conn
|
|
210
|
+
else:
|
|
211
|
+
with conn:
|
|
212
|
+
yield conn
|
|
213
|
+
except sqlite3.Error as exc:
|
|
214
|
+
if self.storage_read_only:
|
|
215
|
+
raise self._access_error("read", exc) from exc
|
|
216
|
+
raise
|
|
217
|
+
finally:
|
|
218
|
+
self._close_quietly(conn)
|
|
219
|
+
|
|
220
|
+
def _initialize_schema(self, conn: sqlite3.Connection) -> None:
|
|
221
|
+
conn.execute(
|
|
222
|
+
"""
|
|
223
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
224
|
+
name TEXT PRIMARY KEY,
|
|
225
|
+
profile_name TEXT,
|
|
226
|
+
domain TEXT,
|
|
227
|
+
space_id INTEGER NOT NULL,
|
|
228
|
+
board_ids_json TEXT NOT NULL,
|
|
229
|
+
preset TEXT NOT NULL,
|
|
230
|
+
window_start TEXT,
|
|
231
|
+
window_end TEXT,
|
|
232
|
+
schema_version INTEGER NOT NULL DEFAULT 2,
|
|
233
|
+
built_at TEXT NOT NULL,
|
|
234
|
+
spec_json TEXT NOT NULL,
|
|
235
|
+
dataset_counts_json TEXT NOT NULL,
|
|
236
|
+
build_trace_json TEXT NOT NULL
|
|
237
|
+
)
|
|
238
|
+
"""
|
|
239
|
+
)
|
|
240
|
+
columns = {row["name"] for row in conn.execute("PRAGMA table_info(snapshots)").fetchall()}
|
|
241
|
+
if "schema_version" not in columns:
|
|
242
|
+
conn.execute(
|
|
243
|
+
"ALTER TABLE snapshots ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 2"
|
|
244
|
+
)
|
|
245
|
+
conn.execute(
|
|
246
|
+
"""
|
|
247
|
+
CREATE TABLE IF NOT EXISTS snapshot_cards (
|
|
248
|
+
snapshot_name TEXT NOT NULL,
|
|
249
|
+
card_id INTEGER NOT NULL,
|
|
250
|
+
board_id INTEGER,
|
|
251
|
+
column_id INTEGER,
|
|
252
|
+
lane_id INTEGER,
|
|
253
|
+
type_id INTEGER,
|
|
254
|
+
owner_id INTEGER,
|
|
255
|
+
responsible_id INTEGER,
|
|
256
|
+
state INTEGER,
|
|
257
|
+
condition INTEGER,
|
|
258
|
+
created TEXT,
|
|
259
|
+
updated TEXT,
|
|
260
|
+
last_moved_to_done_at TEXT,
|
|
261
|
+
has_children INTEGER NOT NULL DEFAULT 0,
|
|
262
|
+
has_comments INTEGER NOT NULL DEFAULT 0,
|
|
263
|
+
children_count INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
comments_count INTEGER NOT NULL DEFAULT 0,
|
|
265
|
+
time_spent_total_minutes INTEGER NOT NULL DEFAULT 0,
|
|
266
|
+
last_time_log_at TEXT,
|
|
267
|
+
latest_column_id INTEGER,
|
|
268
|
+
latest_lane_id INTEGER,
|
|
269
|
+
current_stage_entered_at TEXT,
|
|
270
|
+
commitment_at TEXT,
|
|
271
|
+
work_started_at TEXT,
|
|
272
|
+
done_at TEXT,
|
|
273
|
+
age_days REAL,
|
|
274
|
+
lead_time_days REAL,
|
|
275
|
+
cycle_time_days REAL,
|
|
276
|
+
tag_ids_json TEXT NOT NULL,
|
|
277
|
+
search_text TEXT NOT NULL,
|
|
278
|
+
child_text TEXT NOT NULL,
|
|
279
|
+
comment_text TEXT NOT NULL,
|
|
280
|
+
card_json TEXT NOT NULL,
|
|
281
|
+
PRIMARY KEY (snapshot_name, card_id),
|
|
282
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
283
|
+
)
|
|
284
|
+
"""
|
|
285
|
+
)
|
|
286
|
+
conn.execute(
|
|
287
|
+
"""
|
|
288
|
+
CREATE TABLE IF NOT EXISTS snapshot_boards (
|
|
289
|
+
snapshot_name TEXT NOT NULL,
|
|
290
|
+
board_id INTEGER NOT NULL,
|
|
291
|
+
board_json TEXT NOT NULL,
|
|
292
|
+
PRIMARY KEY (snapshot_name, board_id),
|
|
293
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
294
|
+
)
|
|
295
|
+
"""
|
|
296
|
+
)
|
|
297
|
+
conn.execute(
|
|
298
|
+
"""
|
|
299
|
+
CREATE TABLE IF NOT EXISTS snapshot_columns (
|
|
300
|
+
snapshot_name TEXT NOT NULL,
|
|
301
|
+
board_id INTEGER NOT NULL,
|
|
302
|
+
column_id INTEGER NOT NULL,
|
|
303
|
+
title TEXT,
|
|
304
|
+
column_json TEXT NOT NULL,
|
|
305
|
+
PRIMARY KEY (snapshot_name, column_id),
|
|
306
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
307
|
+
)
|
|
308
|
+
"""
|
|
309
|
+
)
|
|
310
|
+
conn.execute(
|
|
311
|
+
"""
|
|
312
|
+
CREATE TABLE IF NOT EXISTS snapshot_lanes (
|
|
313
|
+
snapshot_name TEXT NOT NULL,
|
|
314
|
+
board_id INTEGER NOT NULL,
|
|
315
|
+
lane_id INTEGER NOT NULL,
|
|
316
|
+
title TEXT,
|
|
317
|
+
lane_json TEXT NOT NULL,
|
|
318
|
+
PRIMARY KEY (snapshot_name, lane_id),
|
|
319
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
320
|
+
)
|
|
321
|
+
"""
|
|
322
|
+
)
|
|
323
|
+
conn.execute(
|
|
324
|
+
"""
|
|
325
|
+
CREATE TABLE IF NOT EXISTS snapshot_activity (
|
|
326
|
+
snapshot_name TEXT NOT NULL,
|
|
327
|
+
row_index INTEGER NOT NULL,
|
|
328
|
+
created TEXT,
|
|
329
|
+
activity_json TEXT NOT NULL,
|
|
330
|
+
PRIMARY KEY (snapshot_name, row_index),
|
|
331
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
332
|
+
)
|
|
333
|
+
"""
|
|
334
|
+
)
|
|
335
|
+
conn.execute(
|
|
336
|
+
"""
|
|
337
|
+
CREATE TABLE IF NOT EXISTS snapshot_history (
|
|
338
|
+
snapshot_name TEXT NOT NULL,
|
|
339
|
+
card_id INTEGER NOT NULL,
|
|
340
|
+
row_index INTEGER NOT NULL,
|
|
341
|
+
changed TEXT,
|
|
342
|
+
column_id INTEGER,
|
|
343
|
+
lane_id INTEGER,
|
|
344
|
+
condition INTEGER,
|
|
345
|
+
state INTEGER,
|
|
346
|
+
history_json TEXT NOT NULL,
|
|
347
|
+
PRIMARY KEY (snapshot_name, card_id, row_index),
|
|
348
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
349
|
+
)
|
|
350
|
+
"""
|
|
351
|
+
)
|
|
352
|
+
conn.execute(
|
|
353
|
+
"""
|
|
354
|
+
CREATE TABLE IF NOT EXISTS snapshot_relations (
|
|
355
|
+
snapshot_name TEXT NOT NULL,
|
|
356
|
+
parent_card_id INTEGER NOT NULL,
|
|
357
|
+
child_card_id INTEGER NOT NULL,
|
|
358
|
+
relation_json TEXT NOT NULL,
|
|
359
|
+
PRIMARY KEY (snapshot_name, parent_card_id, child_card_id),
|
|
360
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
361
|
+
)
|
|
362
|
+
"""
|
|
363
|
+
)
|
|
364
|
+
conn.execute(
|
|
365
|
+
"""
|
|
366
|
+
CREATE TABLE IF NOT EXISTS snapshot_comments (
|
|
367
|
+
snapshot_name TEXT NOT NULL,
|
|
368
|
+
card_id INTEGER NOT NULL,
|
|
369
|
+
row_index INTEGER NOT NULL,
|
|
370
|
+
comment_id TEXT,
|
|
371
|
+
text TEXT,
|
|
372
|
+
comment_json TEXT NOT NULL,
|
|
373
|
+
PRIMARY KEY (snapshot_name, card_id, row_index),
|
|
374
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
375
|
+
)
|
|
376
|
+
"""
|
|
377
|
+
)
|
|
378
|
+
conn.execute(
|
|
379
|
+
"""
|
|
380
|
+
CREATE TABLE IF NOT EXISTS snapshot_time_logs (
|
|
381
|
+
snapshot_name TEXT NOT NULL,
|
|
382
|
+
card_id INTEGER NOT NULL,
|
|
383
|
+
row_index INTEGER NOT NULL,
|
|
384
|
+
time_log_id TEXT,
|
|
385
|
+
created TEXT,
|
|
386
|
+
for_date TEXT,
|
|
387
|
+
time_spent INTEGER,
|
|
388
|
+
comment TEXT,
|
|
389
|
+
time_log_json TEXT NOT NULL,
|
|
390
|
+
PRIMARY KEY (snapshot_name, card_id, row_index),
|
|
391
|
+
FOREIGN KEY (snapshot_name) REFERENCES snapshots(name) ON DELETE CASCADE
|
|
392
|
+
)
|
|
393
|
+
"""
|
|
394
|
+
)
|
|
395
|
+
card_columns = {
|
|
396
|
+
row["name"] for row in conn.execute("PRAGMA table_info(snapshot_cards)").fetchall()
|
|
397
|
+
}
|
|
398
|
+
card_alter_statements = {
|
|
399
|
+
"children_count": "ALTER TABLE snapshot_cards ADD COLUMN children_count INTEGER NOT NULL DEFAULT 0",
|
|
400
|
+
"comments_count": "ALTER TABLE snapshot_cards ADD COLUMN comments_count INTEGER NOT NULL DEFAULT 0",
|
|
401
|
+
"time_spent_total_minutes": "ALTER TABLE snapshot_cards ADD COLUMN time_spent_total_minutes INTEGER NOT NULL DEFAULT 0",
|
|
402
|
+
"last_time_log_at": "ALTER TABLE snapshot_cards ADD COLUMN last_time_log_at TEXT",
|
|
403
|
+
"age_days": "ALTER TABLE snapshot_cards ADD COLUMN age_days REAL",
|
|
404
|
+
"lead_time_days": "ALTER TABLE snapshot_cards ADD COLUMN lead_time_days REAL",
|
|
405
|
+
"cycle_time_days": "ALTER TABLE snapshot_cards ADD COLUMN cycle_time_days REAL",
|
|
406
|
+
}
|
|
407
|
+
for column_name, statement in card_alter_statements.items():
|
|
408
|
+
if column_name not in card_columns:
|
|
409
|
+
conn.execute(statement)
|
|
410
|
+
conn.execute(
|
|
411
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_board ON snapshot_cards(snapshot_name, board_id)"
|
|
412
|
+
)
|
|
413
|
+
conn.execute(
|
|
414
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_column ON snapshot_cards(snapshot_name, column_id)"
|
|
415
|
+
)
|
|
416
|
+
conn.execute(
|
|
417
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_lane ON snapshot_cards(snapshot_name, lane_id)"
|
|
418
|
+
)
|
|
419
|
+
conn.execute(
|
|
420
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_state ON snapshot_cards(snapshot_name, state)"
|
|
421
|
+
)
|
|
422
|
+
conn.execute(
|
|
423
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_condition ON snapshot_cards(snapshot_name, condition)"
|
|
424
|
+
)
|
|
425
|
+
conn.execute(
|
|
426
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_done ON snapshot_cards(snapshot_name, done_at)"
|
|
427
|
+
)
|
|
428
|
+
conn.execute(
|
|
429
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_cards_last_time_log ON snapshot_cards(snapshot_name, last_time_log_at)"
|
|
430
|
+
)
|
|
431
|
+
conn.execute(
|
|
432
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_history_changed ON snapshot_history(snapshot_name, changed)"
|
|
433
|
+
)
|
|
434
|
+
conn.execute(
|
|
435
|
+
"CREATE INDEX IF NOT EXISTS idx_snapshot_time_logs_created ON snapshot_time_logs(snapshot_name, created)"
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
conn.execute(
|
|
439
|
+
"""
|
|
440
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS snapshot_card_search
|
|
441
|
+
USING fts5(
|
|
442
|
+
snapshot_name UNINDEXED,
|
|
443
|
+
card_id UNINDEXED,
|
|
444
|
+
search_text,
|
|
445
|
+
child_text,
|
|
446
|
+
comment_text
|
|
447
|
+
)
|
|
448
|
+
"""
|
|
449
|
+
)
|
|
450
|
+
except sqlite3.OperationalError:
|
|
451
|
+
pass
|
|
452
|
+
conn.execute(f"PRAGMA user_version = {SNAPSHOT_DB_SCHEMA_VERSION}")
|
|
453
|
+
conn.commit()
|
|
454
|
+
|
|
455
|
+
def list_snapshots(self) -> list[dict[str, Any]]:
|
|
456
|
+
if self.storage_read_only and not self.path.exists() and not self.path.is_symlink():
|
|
457
|
+
return []
|
|
458
|
+
with self._connection(read_only=True) as conn:
|
|
459
|
+
rows = conn.execute(
|
|
460
|
+
"""
|
|
461
|
+
SELECT name, profile_name, domain, space_id, preset, window_start, window_end, built_at, dataset_counts_json
|
|
462
|
+
, schema_version
|
|
463
|
+
FROM snapshots
|
|
464
|
+
ORDER BY name
|
|
465
|
+
"""
|
|
466
|
+
).fetchall()
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
"name": row["name"],
|
|
470
|
+
"profile_name": row["profile_name"],
|
|
471
|
+
"domain": row["domain"],
|
|
472
|
+
"space_id": row["space_id"],
|
|
473
|
+
"preset": row["preset"],
|
|
474
|
+
"window": {"start": row["window_start"], "end": row["window_end"]},
|
|
475
|
+
"schema_version": row["schema_version"],
|
|
476
|
+
"built_at": row["built_at"],
|
|
477
|
+
"datasets": json.loads(row["dataset_counts_json"]),
|
|
478
|
+
}
|
|
479
|
+
for row in rows
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
def get_snapshot(self, name: str) -> dict[str, Any]:
|
|
483
|
+
if self.storage_read_only and not self.path.exists() and not self.path.is_symlink():
|
|
484
|
+
raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
|
|
485
|
+
with self._connection(read_only=True) as conn:
|
|
486
|
+
row = conn.execute(
|
|
487
|
+
"""
|
|
488
|
+
SELECT name, profile_name, domain, space_id, board_ids_json, preset, window_start, window_end,
|
|
489
|
+
schema_version,
|
|
490
|
+
built_at, spec_json, dataset_counts_json, build_trace_json
|
|
491
|
+
FROM snapshots
|
|
492
|
+
WHERE name = ?
|
|
493
|
+
""",
|
|
494
|
+
(name,),
|
|
495
|
+
).fetchone()
|
|
496
|
+
if row is None:
|
|
497
|
+
raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
|
|
498
|
+
built_at = _parse_timestamp(row["built_at"])
|
|
499
|
+
now = datetime.now(timezone.utc)
|
|
500
|
+
staleness_seconds = None
|
|
501
|
+
if built_at is not None:
|
|
502
|
+
staleness_seconds = max(0, int((now - built_at).total_seconds()))
|
|
503
|
+
return {
|
|
504
|
+
"name": row["name"],
|
|
505
|
+
"profile_name": row["profile_name"],
|
|
506
|
+
"domain": row["domain"],
|
|
507
|
+
"space_id": row["space_id"],
|
|
508
|
+
"board_ids": json.loads(row["board_ids_json"]),
|
|
509
|
+
"preset": row["preset"],
|
|
510
|
+
"window": {"start": row["window_start"], "end": row["window_end"]},
|
|
511
|
+
"schema_version": row["schema_version"],
|
|
512
|
+
"built_at": row["built_at"],
|
|
513
|
+
"spec": json.loads(row["spec_json"]),
|
|
514
|
+
"datasets": json.loads(row["dataset_counts_json"]),
|
|
515
|
+
"last_build_trace": json.loads(row["build_trace_json"]),
|
|
516
|
+
"staleness_seconds": staleness_seconds,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
def delete_snapshot(self, name: str) -> dict[str, Any]:
|
|
520
|
+
with self._connection() as conn:
|
|
521
|
+
deleted = conn.execute("DELETE FROM snapshots WHERE name = ?", (name,)).rowcount
|
|
522
|
+
try:
|
|
523
|
+
conn.execute("DELETE FROM snapshot_card_search WHERE snapshot_name = ?", (name,))
|
|
524
|
+
except sqlite3.OperationalError:
|
|
525
|
+
pass
|
|
526
|
+
conn.commit()
|
|
527
|
+
if not deleted:
|
|
528
|
+
raise ConfigError(tr("Unknown snapshot: {value_0}", value_0=name))
|
|
529
|
+
return {"name": name, "deleted": True}
|
|
530
|
+
|
|
531
|
+
def replace_snapshot(
|
|
532
|
+
self,
|
|
533
|
+
*,
|
|
534
|
+
name: str,
|
|
535
|
+
profile_name: str | None,
|
|
536
|
+
domain: str,
|
|
537
|
+
space_id: int,
|
|
538
|
+
board_ids: list[int],
|
|
539
|
+
preset: str,
|
|
540
|
+
window_start: str | None,
|
|
541
|
+
window_end: str | None,
|
|
542
|
+
spec: dict[str, Any],
|
|
543
|
+
dataset_counts: dict[str, int],
|
|
544
|
+
build_trace: dict[str, Any],
|
|
545
|
+
topology: dict[str, Any],
|
|
546
|
+
cards: list[dict[str, Any]],
|
|
547
|
+
history_map: dict[int, list[dict[str, Any]]],
|
|
548
|
+
children_map: dict[int, list[dict[str, Any]]],
|
|
549
|
+
comments_map: dict[int, list[dict[str, Any]]],
|
|
550
|
+
time_logs_map: dict[int, list[dict[str, Any]]],
|
|
551
|
+
activity_rows: list[dict[str, Any]],
|
|
552
|
+
) -> None:
|
|
553
|
+
built_at = _now_iso()
|
|
554
|
+
built_at_dt = _parse_timestamp(built_at)
|
|
555
|
+
window_start_dt = _parse_timestamp(window_start)
|
|
556
|
+
window_end_dt = _parse_timestamp(window_end)
|
|
557
|
+
cutoff_dt = window_end_dt or built_at_dt or datetime.now(timezone.utc)
|
|
558
|
+
card_rows: list[tuple[Any, ...]] = []
|
|
559
|
+
relation_rows: list[tuple[Any, ...]] = []
|
|
560
|
+
comment_rows: list[tuple[Any, ...]] = []
|
|
561
|
+
history_rows: list[tuple[Any, ...]] = []
|
|
562
|
+
time_log_rows: list[tuple[Any, ...]] = []
|
|
563
|
+
search_rows: list[tuple[str, int, str, str, str]] = []
|
|
564
|
+
|
|
565
|
+
for card in cards:
|
|
566
|
+
if not isinstance(card, dict) or "id" not in card:
|
|
567
|
+
continue
|
|
568
|
+
card_id = int(card["id"])
|
|
569
|
+
history = history_map.get(card_id, [])
|
|
570
|
+
children = children_map.get(card_id, [])
|
|
571
|
+
comments = comments_map.get(card_id, [])
|
|
572
|
+
time_logs = _filter_time_logs_to_window(
|
|
573
|
+
time_logs_map.get(card_id, []),
|
|
574
|
+
window_start=window_start_dt,
|
|
575
|
+
window_end=window_end_dt,
|
|
576
|
+
)
|
|
577
|
+
type_id = card.get("type_id")
|
|
578
|
+
if type_id is None and isinstance(card.get("type"), dict):
|
|
579
|
+
type_id = card["type"].get("id")
|
|
580
|
+
owner_id = card.get("owner_id")
|
|
581
|
+
if owner_id is None and isinstance(card.get("owner"), dict):
|
|
582
|
+
owner_id = card["owner"].get("id")
|
|
583
|
+
responsible_id = card.get("responsible_id")
|
|
584
|
+
if responsible_id is None and isinstance(card.get("responsible"), dict):
|
|
585
|
+
responsible_id = card["responsible"].get("id")
|
|
586
|
+
|
|
587
|
+
child_titles = [
|
|
588
|
+
str(item.get("title", "")) for item in children if isinstance(item, dict)
|
|
589
|
+
]
|
|
590
|
+
comment_texts = [
|
|
591
|
+
str(item.get("text", "")) for item in comments if isinstance(item, dict)
|
|
592
|
+
]
|
|
593
|
+
search_text = _search_blob(
|
|
594
|
+
[str(card.get("title", "")), str(card.get("description", ""))]
|
|
595
|
+
)
|
|
596
|
+
child_text = _search_blob(child_titles)
|
|
597
|
+
comment_text = _search_blob(comment_texts)
|
|
598
|
+
done_at = _derive_done_at(card, history)
|
|
599
|
+
work_started_at = _first_history_with_state(history, 2)
|
|
600
|
+
current_stage_entered_at = _current_stage_entered_at(history)
|
|
601
|
+
latest = history[-1] if history else {}
|
|
602
|
+
latest_column_id = latest.get("column_id", card.get("column_id"))
|
|
603
|
+
latest_lane_id = latest.get("lane_id", card.get("lane_id"))
|
|
604
|
+
last_time_log_at = None
|
|
605
|
+
time_spent_total_minutes = 0
|
|
606
|
+
for item in time_logs:
|
|
607
|
+
if not isinstance(item, dict):
|
|
608
|
+
continue
|
|
609
|
+
time_spent_total_minutes += _time_log_minutes(item)
|
|
610
|
+
changed = _time_log_timestamp(item)
|
|
611
|
+
if changed is not None and (last_time_log_at is None or changed > last_time_log_at):
|
|
612
|
+
last_time_log_at = changed
|
|
613
|
+
age_anchor = (
|
|
614
|
+
current_stage_entered_at
|
|
615
|
+
or _parse_timestamp(card.get("updated"))
|
|
616
|
+
or _parse_timestamp(card.get("created"))
|
|
617
|
+
)
|
|
618
|
+
age_days = None
|
|
619
|
+
if card.get("condition") == 1 and card.get("state") != 3:
|
|
620
|
+
age_days = _duration_days(age_anchor, cutoff_dt)
|
|
621
|
+
lead_time_days = _duration_days(_parse_timestamp(card.get("created")), done_at)
|
|
622
|
+
cycle_time_days = _duration_days(work_started_at, done_at)
|
|
623
|
+
|
|
624
|
+
derived = {
|
|
625
|
+
"has_children": bool(children),
|
|
626
|
+
"has_comments": bool(comments),
|
|
627
|
+
"children_count": len(children),
|
|
628
|
+
"comments_count": len(comments),
|
|
629
|
+
"time_spent_total_minutes": time_spent_total_minutes,
|
|
630
|
+
"last_time_log_at": _iso_timestamp(last_time_log_at),
|
|
631
|
+
"latest_stage": {"column_id": latest_column_id, "lane_id": latest_lane_id},
|
|
632
|
+
"latest_column_id": latest_column_id,
|
|
633
|
+
"latest_lane_id": latest_lane_id,
|
|
634
|
+
"search_text": search_text,
|
|
635
|
+
"child_text": child_text,
|
|
636
|
+
"comment_text": comment_text,
|
|
637
|
+
"current_stage_entered_at": _iso_timestamp(current_stage_entered_at),
|
|
638
|
+
"commitment_at": None,
|
|
639
|
+
"work_started_at": _iso_timestamp(work_started_at),
|
|
640
|
+
"done_at": _iso_timestamp(done_at),
|
|
641
|
+
"age_days": age_days,
|
|
642
|
+
"lead_time_days": lead_time_days,
|
|
643
|
+
"cycle_time_days": cycle_time_days,
|
|
644
|
+
}
|
|
645
|
+
card_rows.append(
|
|
646
|
+
(
|
|
647
|
+
name,
|
|
648
|
+
card_id,
|
|
649
|
+
card.get("board_id"),
|
|
650
|
+
card.get("column_id"),
|
|
651
|
+
card.get("lane_id"),
|
|
652
|
+
type_id,
|
|
653
|
+
owner_id,
|
|
654
|
+
responsible_id,
|
|
655
|
+
card.get("state"),
|
|
656
|
+
card.get("condition"),
|
|
657
|
+
card.get("created"),
|
|
658
|
+
card.get("updated"),
|
|
659
|
+
card.get("last_moved_to_done_at"),
|
|
660
|
+
1 if children else 0,
|
|
661
|
+
1 if comments else 0,
|
|
662
|
+
len(children),
|
|
663
|
+
len(comments),
|
|
664
|
+
time_spent_total_minutes,
|
|
665
|
+
derived["last_time_log_at"],
|
|
666
|
+
latest_column_id,
|
|
667
|
+
latest_lane_id,
|
|
668
|
+
derived["current_stage_entered_at"],
|
|
669
|
+
derived["commitment_at"],
|
|
670
|
+
derived["work_started_at"],
|
|
671
|
+
derived["done_at"],
|
|
672
|
+
derived["age_days"],
|
|
673
|
+
derived["lead_time_days"],
|
|
674
|
+
derived["cycle_time_days"],
|
|
675
|
+
json.dumps(_extract_tag_ids(card), ensure_ascii=False, separators=(",", ":")),
|
|
676
|
+
search_text,
|
|
677
|
+
child_text,
|
|
678
|
+
comment_text,
|
|
679
|
+
json.dumps(card, ensure_ascii=False, separators=(",", ":")),
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
search_rows.append((name, card_id, search_text, child_text, comment_text))
|
|
683
|
+
for child in children:
|
|
684
|
+
if not isinstance(child, dict):
|
|
685
|
+
continue
|
|
686
|
+
child_id = _extract_id(child)
|
|
687
|
+
if child_id is None:
|
|
688
|
+
continue
|
|
689
|
+
relation_rows.append(
|
|
690
|
+
(
|
|
691
|
+
name,
|
|
692
|
+
card_id,
|
|
693
|
+
child_id,
|
|
694
|
+
json.dumps(child, ensure_ascii=False, separators=(",", ":")),
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
for index, comment in enumerate(comments):
|
|
698
|
+
if not isinstance(comment, dict):
|
|
699
|
+
continue
|
|
700
|
+
comment_rows.append(
|
|
701
|
+
(
|
|
702
|
+
name,
|
|
703
|
+
card_id,
|
|
704
|
+
index,
|
|
705
|
+
str(comment.get("id")) if comment.get("id") is not None else None,
|
|
706
|
+
str(comment.get("text", "")),
|
|
707
|
+
json.dumps(comment, ensure_ascii=False, separators=(",", ":")),
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
for index, row in enumerate(history):
|
|
711
|
+
history_rows.append(
|
|
712
|
+
(
|
|
713
|
+
name,
|
|
714
|
+
card_id,
|
|
715
|
+
index,
|
|
716
|
+
row.get("changed"),
|
|
717
|
+
row.get("column_id"),
|
|
718
|
+
row.get("lane_id"),
|
|
719
|
+
row.get("condition"),
|
|
720
|
+
row.get("state"),
|
|
721
|
+
json.dumps(row, ensure_ascii=False, separators=(",", ":")),
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
for index, time_log in enumerate(time_logs):
|
|
725
|
+
if not isinstance(time_log, dict):
|
|
726
|
+
continue
|
|
727
|
+
time_log_rows.append(
|
|
728
|
+
(
|
|
729
|
+
name,
|
|
730
|
+
card_id,
|
|
731
|
+
index,
|
|
732
|
+
str(time_log.get("id")) if time_log.get("id") is not None else None,
|
|
733
|
+
_iso_timestamp(_time_log_timestamp(time_log)),
|
|
734
|
+
str(time_log.get("for_date") or time_log.get("forDate") or "") or None,
|
|
735
|
+
_time_log_minutes(time_log),
|
|
736
|
+
str(time_log.get("comment", "")),
|
|
737
|
+
json.dumps(time_log, ensure_ascii=False, separators=(",", ":")),
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
board_rows: list[tuple[Any, ...]] = []
|
|
742
|
+
column_rows: list[tuple[Any, ...]] = []
|
|
743
|
+
lane_rows: list[tuple[Any, ...]] = []
|
|
744
|
+
for board in topology.get("boards", []):
|
|
745
|
+
if not isinstance(board, dict) or "id" not in board:
|
|
746
|
+
continue
|
|
747
|
+
board_rows.append(
|
|
748
|
+
(name, board["id"], json.dumps(board, ensure_ascii=False, separators=(",", ":")))
|
|
749
|
+
)
|
|
750
|
+
for column in board.get("columns", []):
|
|
751
|
+
if isinstance(column, dict) and "id" in column:
|
|
752
|
+
column_rows.append(
|
|
753
|
+
(
|
|
754
|
+
name,
|
|
755
|
+
board["id"],
|
|
756
|
+
column["id"],
|
|
757
|
+
column.get("title"),
|
|
758
|
+
json.dumps(column, ensure_ascii=False, separators=(",", ":")),
|
|
759
|
+
)
|
|
760
|
+
)
|
|
761
|
+
for lane in board.get("lanes", []):
|
|
762
|
+
if isinstance(lane, dict) and "id" in lane:
|
|
763
|
+
lane_rows.append(
|
|
764
|
+
(
|
|
765
|
+
name,
|
|
766
|
+
board["id"],
|
|
767
|
+
lane["id"],
|
|
768
|
+
lane.get("title"),
|
|
769
|
+
json.dumps(lane, ensure_ascii=False, separators=(",", ":")),
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
activity_insert_rows = [
|
|
774
|
+
(
|
|
775
|
+
name,
|
|
776
|
+
index,
|
|
777
|
+
row.get("created") if isinstance(row, dict) else None,
|
|
778
|
+
json.dumps(row, ensure_ascii=False, separators=(",", ":")),
|
|
779
|
+
)
|
|
780
|
+
for index, row in enumerate(activity_rows)
|
|
781
|
+
if isinstance(row, dict)
|
|
782
|
+
]
|
|
783
|
+
|
|
784
|
+
with self._connection() as conn:
|
|
785
|
+
conn.execute("DELETE FROM snapshots WHERE name = ?", (name,))
|
|
786
|
+
try:
|
|
787
|
+
conn.execute("DELETE FROM snapshot_card_search WHERE snapshot_name = ?", (name,))
|
|
788
|
+
except sqlite3.OperationalError:
|
|
789
|
+
pass
|
|
790
|
+
conn.execute(
|
|
791
|
+
"""
|
|
792
|
+
INSERT INTO snapshots (
|
|
793
|
+
name, profile_name, domain, space_id, board_ids_json, preset, window_start, window_end,
|
|
794
|
+
schema_version, built_at, spec_json, dataset_counts_json, build_trace_json
|
|
795
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
796
|
+
""",
|
|
797
|
+
(
|
|
798
|
+
name,
|
|
799
|
+
profile_name,
|
|
800
|
+
domain,
|
|
801
|
+
space_id,
|
|
802
|
+
json.dumps(board_ids, ensure_ascii=False, separators=(",", ":")),
|
|
803
|
+
preset,
|
|
804
|
+
window_start,
|
|
805
|
+
window_end,
|
|
806
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
807
|
+
built_at,
|
|
808
|
+
json.dumps(spec, ensure_ascii=False, separators=(",", ":")),
|
|
809
|
+
json.dumps(dataset_counts, ensure_ascii=False, separators=(",", ":")),
|
|
810
|
+
json.dumps(build_trace, ensure_ascii=False, separators=(",", ":")),
|
|
811
|
+
),
|
|
812
|
+
)
|
|
813
|
+
if board_rows:
|
|
814
|
+
conn.executemany(
|
|
815
|
+
"INSERT INTO snapshot_boards (snapshot_name, board_id, board_json) VALUES (?, ?, ?)",
|
|
816
|
+
board_rows,
|
|
817
|
+
)
|
|
818
|
+
if column_rows:
|
|
819
|
+
conn.executemany(
|
|
820
|
+
"""
|
|
821
|
+
INSERT INTO snapshot_columns (snapshot_name, board_id, column_id, title, column_json)
|
|
822
|
+
VALUES (?, ?, ?, ?, ?)
|
|
823
|
+
""",
|
|
824
|
+
column_rows,
|
|
825
|
+
)
|
|
826
|
+
if lane_rows:
|
|
827
|
+
conn.executemany(
|
|
828
|
+
"""
|
|
829
|
+
INSERT INTO snapshot_lanes (snapshot_name, board_id, lane_id, title, lane_json)
|
|
830
|
+
VALUES (?, ?, ?, ?, ?)
|
|
831
|
+
""",
|
|
832
|
+
lane_rows,
|
|
833
|
+
)
|
|
834
|
+
if card_rows:
|
|
835
|
+
conn.executemany(
|
|
836
|
+
"""
|
|
837
|
+
INSERT INTO snapshot_cards (
|
|
838
|
+
snapshot_name, card_id, board_id, column_id, lane_id, type_id, owner_id, responsible_id,
|
|
839
|
+
state, condition, created, updated, last_moved_to_done_at, has_children, has_comments,
|
|
840
|
+
children_count, comments_count, time_spent_total_minutes, last_time_log_at,
|
|
841
|
+
latest_column_id, latest_lane_id, current_stage_entered_at, commitment_at, work_started_at,
|
|
842
|
+
done_at, age_days, lead_time_days, cycle_time_days, tag_ids_json, search_text, child_text,
|
|
843
|
+
comment_text, card_json
|
|
844
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
845
|
+
""",
|
|
846
|
+
card_rows,
|
|
847
|
+
)
|
|
848
|
+
if history_rows:
|
|
849
|
+
conn.executemany(
|
|
850
|
+
"""
|
|
851
|
+
INSERT INTO snapshot_history (
|
|
852
|
+
snapshot_name, card_id, row_index, changed, column_id, lane_id, condition, state, history_json
|
|
853
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
854
|
+
""",
|
|
855
|
+
history_rows,
|
|
856
|
+
)
|
|
857
|
+
if relation_rows:
|
|
858
|
+
conn.executemany(
|
|
859
|
+
"""
|
|
860
|
+
INSERT INTO snapshot_relations (snapshot_name, parent_card_id, child_card_id, relation_json)
|
|
861
|
+
VALUES (?, ?, ?, ?)
|
|
862
|
+
""",
|
|
863
|
+
relation_rows,
|
|
864
|
+
)
|
|
865
|
+
if comment_rows:
|
|
866
|
+
conn.executemany(
|
|
867
|
+
"""
|
|
868
|
+
INSERT INTO snapshot_comments (snapshot_name, card_id, row_index, comment_id, text, comment_json)
|
|
869
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
870
|
+
""",
|
|
871
|
+
comment_rows,
|
|
872
|
+
)
|
|
873
|
+
if time_log_rows:
|
|
874
|
+
conn.executemany(
|
|
875
|
+
"""
|
|
876
|
+
INSERT INTO snapshot_time_logs (
|
|
877
|
+
snapshot_name, card_id, row_index, time_log_id, created, for_date, time_spent, comment, time_log_json
|
|
878
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
879
|
+
""",
|
|
880
|
+
time_log_rows,
|
|
881
|
+
)
|
|
882
|
+
if activity_insert_rows:
|
|
883
|
+
conn.executemany(
|
|
884
|
+
"""
|
|
885
|
+
INSERT INTO snapshot_activity (snapshot_name, row_index, created, activity_json)
|
|
886
|
+
VALUES (?, ?, ?, ?)
|
|
887
|
+
""",
|
|
888
|
+
activity_insert_rows,
|
|
889
|
+
)
|
|
890
|
+
if search_rows:
|
|
891
|
+
try:
|
|
892
|
+
conn.executemany(
|
|
893
|
+
"""
|
|
894
|
+
INSERT INTO snapshot_card_search (snapshot_name, card_id, search_text, child_text, comment_text)
|
|
895
|
+
VALUES (?, ?, ?, ?, ?)
|
|
896
|
+
""",
|
|
897
|
+
search_rows,
|
|
898
|
+
)
|
|
899
|
+
except sqlite3.OperationalError:
|
|
900
|
+
pass
|
|
901
|
+
conn.commit()
|
|
902
|
+
|
|
903
|
+
def _row_to_card_record(self, row: sqlite3.Row) -> dict[str, Any]:
|
|
904
|
+
card = json.loads(row["card_json"])
|
|
905
|
+
derived = {
|
|
906
|
+
"has_children": bool(row["has_children"]),
|
|
907
|
+
"has_comments": bool(row["has_comments"]),
|
|
908
|
+
"children_count": row["children_count"],
|
|
909
|
+
"comments_count": row["comments_count"],
|
|
910
|
+
"time_spent_total_minutes": row["time_spent_total_minutes"],
|
|
911
|
+
"last_time_log_at": row["last_time_log_at"],
|
|
912
|
+
"latest_stage": {
|
|
913
|
+
"column_id": row["latest_column_id"],
|
|
914
|
+
"lane_id": row["latest_lane_id"],
|
|
915
|
+
},
|
|
916
|
+
"latest_column_id": row["latest_column_id"],
|
|
917
|
+
"latest_lane_id": row["latest_lane_id"],
|
|
918
|
+
"search_text": row["search_text"],
|
|
919
|
+
"child_text": row["child_text"],
|
|
920
|
+
"comment_text": row["comment_text"],
|
|
921
|
+
"current_stage_entered_at": row["current_stage_entered_at"],
|
|
922
|
+
"commitment_at": row["commitment_at"],
|
|
923
|
+
"work_started_at": row["work_started_at"],
|
|
924
|
+
"done_at": row["done_at"],
|
|
925
|
+
"age_days": row["age_days"],
|
|
926
|
+
"lead_time_days": row["lead_time_days"],
|
|
927
|
+
"cycle_time_days": row["cycle_time_days"],
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
"card_id": row["card_id"],
|
|
931
|
+
"board_id": row["board_id"],
|
|
932
|
+
"column_id": row["column_id"],
|
|
933
|
+
"lane_id": row["lane_id"],
|
|
934
|
+
"type_id": row["type_id"],
|
|
935
|
+
"owner_id": row["owner_id"],
|
|
936
|
+
"responsible_id": row["responsible_id"],
|
|
937
|
+
"state": row["state"],
|
|
938
|
+
"condition": row["condition"],
|
|
939
|
+
"created": row["created"],
|
|
940
|
+
"updated": row["updated"],
|
|
941
|
+
"done_at": row["done_at"],
|
|
942
|
+
"work_started_at": row["work_started_at"],
|
|
943
|
+
"current_stage_entered_at": row["current_stage_entered_at"],
|
|
944
|
+
"has_children": bool(row["has_children"]),
|
|
945
|
+
"has_comments": bool(row["has_comments"]),
|
|
946
|
+
"children_count": row["children_count"],
|
|
947
|
+
"comments_count": row["comments_count"],
|
|
948
|
+
"time_spent_total_minutes": row["time_spent_total_minutes"],
|
|
949
|
+
"last_time_log_at": row["last_time_log_at"],
|
|
950
|
+
"age_days": row["age_days"],
|
|
951
|
+
"lead_time_days": row["lead_time_days"],
|
|
952
|
+
"cycle_time_days": row["cycle_time_days"],
|
|
953
|
+
"tag_ids": json.loads(row["tag_ids_json"]),
|
|
954
|
+
"search_text": row["search_text"],
|
|
955
|
+
"child_text": row["child_text"],
|
|
956
|
+
"comment_text": row["comment_text"],
|
|
957
|
+
"card": card,
|
|
958
|
+
"derived": derived,
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
def load_card_records(self, snapshot_name: str) -> list[dict[str, Any]]:
|
|
962
|
+
self.get_snapshot(snapshot_name)
|
|
963
|
+
with self._connection(read_only=True) as conn:
|
|
964
|
+
rows = conn.execute(
|
|
965
|
+
"""
|
|
966
|
+
SELECT *
|
|
967
|
+
FROM snapshot_cards
|
|
968
|
+
WHERE snapshot_name = ?
|
|
969
|
+
ORDER BY card_id
|
|
970
|
+
""",
|
|
971
|
+
(snapshot_name,),
|
|
972
|
+
).fetchall()
|
|
973
|
+
return [self._row_to_card_record(row) for row in rows]
|
|
974
|
+
|
|
975
|
+
def _build_card_query_parts(
|
|
976
|
+
self,
|
|
977
|
+
snapshot_name: str,
|
|
978
|
+
filters: dict[str, Any],
|
|
979
|
+
candidate_ids: set[int] | None,
|
|
980
|
+
*,
|
|
981
|
+
include_tag_filter: bool,
|
|
982
|
+
) -> tuple[list[str], list[Any], bool]:
|
|
983
|
+
clauses = ["snapshot_name = ?"]
|
|
984
|
+
params: list[Any] = [snapshot_name]
|
|
985
|
+
fallback_tag_filter = False
|
|
986
|
+
mapping = {
|
|
987
|
+
"board_ids": "board_id",
|
|
988
|
+
"column_ids": "column_id",
|
|
989
|
+
"lane_ids": "lane_id",
|
|
990
|
+
"type_ids": "type_id",
|
|
991
|
+
"owner_ids": "owner_id",
|
|
992
|
+
"responsible_ids": "responsible_id",
|
|
993
|
+
"states": "state",
|
|
994
|
+
}
|
|
995
|
+
for filter_name, column_name in mapping.items():
|
|
996
|
+
values = _normalize_int_list(filters.get(filter_name))
|
|
997
|
+
if values:
|
|
998
|
+
placeholders = ",".join("?" for _ in values)
|
|
999
|
+
clauses.append(f"{column_name} IN ({placeholders})")
|
|
1000
|
+
params.extend(values)
|
|
1001
|
+
condition_values = _normalize_int_list(filters.get("condition"))
|
|
1002
|
+
if condition_values:
|
|
1003
|
+
placeholders = ",".join("?" for _ in condition_values)
|
|
1004
|
+
clauses.append(f"condition IN ({placeholders})")
|
|
1005
|
+
params.extend(condition_values)
|
|
1006
|
+
range_filters = (
|
|
1007
|
+
("created_after", "created", ">="),
|
|
1008
|
+
("created_before", "created", "<="),
|
|
1009
|
+
("updated_after", "updated", ">="),
|
|
1010
|
+
("updated_before", "updated", "<="),
|
|
1011
|
+
)
|
|
1012
|
+
for filter_name, column_name, operator in range_filters:
|
|
1013
|
+
value = filters.get(filter_name)
|
|
1014
|
+
if value:
|
|
1015
|
+
clauses.append(f"{column_name} {operator} ?")
|
|
1016
|
+
params.append(value)
|
|
1017
|
+
for filter_name, column_name in (
|
|
1018
|
+
("has_children", "has_children"),
|
|
1019
|
+
("has_comments", "has_comments"),
|
|
1020
|
+
):
|
|
1021
|
+
value = _normalize_bool(filters.get(filter_name))
|
|
1022
|
+
if value is not None:
|
|
1023
|
+
clauses.append(f"{column_name} = ?")
|
|
1024
|
+
params.append(1 if value else 0)
|
|
1025
|
+
if candidate_ids is not None:
|
|
1026
|
+
if not candidate_ids:
|
|
1027
|
+
clauses.append("1 = 0")
|
|
1028
|
+
else:
|
|
1029
|
+
ordered_ids = sorted(candidate_ids)
|
|
1030
|
+
placeholders = ",".join("?" for _ in ordered_ids)
|
|
1031
|
+
clauses.append(f"card_id IN ({placeholders})")
|
|
1032
|
+
params.extend(ordered_ids)
|
|
1033
|
+
if include_tag_filter:
|
|
1034
|
+
tag_ids = _normalize_int_list(filters.get("tag_ids"))
|
|
1035
|
+
if tag_ids:
|
|
1036
|
+
placeholders = ",".join("?" for _ in tag_ids)
|
|
1037
|
+
clauses.append(
|
|
1038
|
+
"EXISTS (SELECT 1 FROM json_each(snapshot_cards.tag_ids_json) WHERE json_each.value IN "
|
|
1039
|
+
f"({placeholders}))"
|
|
1040
|
+
)
|
|
1041
|
+
params.extend(tag_ids)
|
|
1042
|
+
fallback_tag_filter = True
|
|
1043
|
+
return clauses, params, fallback_tag_filter
|
|
1044
|
+
|
|
1045
|
+
def query_card_records(
|
|
1046
|
+
self,
|
|
1047
|
+
snapshot_name: str,
|
|
1048
|
+
filters: dict[str, Any],
|
|
1049
|
+
candidate_ids: set[int] | None,
|
|
1050
|
+
*,
|
|
1051
|
+
limit: int,
|
|
1052
|
+
offset: int,
|
|
1053
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
1054
|
+
self.get_snapshot(snapshot_name)
|
|
1055
|
+
clauses, params, fallback_tag_filter = self._build_card_query_parts(
|
|
1056
|
+
snapshot_name,
|
|
1057
|
+
filters,
|
|
1058
|
+
candidate_ids,
|
|
1059
|
+
include_tag_filter=True,
|
|
1060
|
+
)
|
|
1061
|
+
where_sql = " AND ".join(clauses)
|
|
1062
|
+
with self._connection(read_only=True) as conn:
|
|
1063
|
+
try:
|
|
1064
|
+
total = int(
|
|
1065
|
+
conn.execute(
|
|
1066
|
+
f"SELECT COUNT(*) AS total FROM snapshot_cards WHERE {where_sql}",
|
|
1067
|
+
params,
|
|
1068
|
+
).fetchone()["total"]
|
|
1069
|
+
)
|
|
1070
|
+
rows = conn.execute(
|
|
1071
|
+
f"""
|
|
1072
|
+
SELECT *
|
|
1073
|
+
FROM snapshot_cards
|
|
1074
|
+
WHERE {where_sql}
|
|
1075
|
+
ORDER BY card_id
|
|
1076
|
+
LIMIT ? OFFSET ?
|
|
1077
|
+
""",
|
|
1078
|
+
[*params, limit, offset],
|
|
1079
|
+
).fetchall()
|
|
1080
|
+
return [self._row_to_card_record(row) for row in rows], total
|
|
1081
|
+
except sqlite3.OperationalError:
|
|
1082
|
+
if not fallback_tag_filter:
|
|
1083
|
+
raise
|
|
1084
|
+
fallback_clauses, fallback_params, _ = self._build_card_query_parts(
|
|
1085
|
+
snapshot_name,
|
|
1086
|
+
{**filters, "tag_ids": None},
|
|
1087
|
+
candidate_ids,
|
|
1088
|
+
include_tag_filter=False,
|
|
1089
|
+
)
|
|
1090
|
+
fallback_where_sql = " AND ".join(fallback_clauses)
|
|
1091
|
+
rows = conn.execute(
|
|
1092
|
+
f"""
|
|
1093
|
+
SELECT *
|
|
1094
|
+
FROM snapshot_cards
|
|
1095
|
+
WHERE {fallback_where_sql}
|
|
1096
|
+
ORDER BY card_id
|
|
1097
|
+
""",
|
|
1098
|
+
fallback_params,
|
|
1099
|
+
).fetchall()
|
|
1100
|
+
matched = [
|
|
1101
|
+
record
|
|
1102
|
+
for record in (self._row_to_card_record(row) for row in rows)
|
|
1103
|
+
if not _normalize_int_list(filters.get("tag_ids"))
|
|
1104
|
+
or (set(record["tag_ids"]) & set(_normalize_int_list(filters.get("tag_ids"))))
|
|
1105
|
+
]
|
|
1106
|
+
return matched[offset : offset + limit], len(matched)
|
|
1107
|
+
|
|
1108
|
+
def load_metric_rows(
|
|
1109
|
+
self,
|
|
1110
|
+
snapshot_name: str,
|
|
1111
|
+
filters: dict[str, Any],
|
|
1112
|
+
candidate_ids: set[int] | None,
|
|
1113
|
+
columns: tuple[str, ...],
|
|
1114
|
+
) -> list[sqlite3.Row]:
|
|
1115
|
+
self.get_snapshot(snapshot_name)
|
|
1116
|
+
clauses, params, fallback_tag_filter = self._build_card_query_parts(
|
|
1117
|
+
snapshot_name,
|
|
1118
|
+
filters,
|
|
1119
|
+
candidate_ids,
|
|
1120
|
+
include_tag_filter=True,
|
|
1121
|
+
)
|
|
1122
|
+
where_sql = " AND ".join(clauses)
|
|
1123
|
+
selected_columns = ", ".join(columns)
|
|
1124
|
+
with self._connection(read_only=True) as conn:
|
|
1125
|
+
try:
|
|
1126
|
+
return conn.execute(
|
|
1127
|
+
f"""
|
|
1128
|
+
SELECT {selected_columns}
|
|
1129
|
+
FROM snapshot_cards
|
|
1130
|
+
WHERE {where_sql}
|
|
1131
|
+
ORDER BY card_id
|
|
1132
|
+
""",
|
|
1133
|
+
params,
|
|
1134
|
+
).fetchall()
|
|
1135
|
+
except sqlite3.OperationalError:
|
|
1136
|
+
if not fallback_tag_filter:
|
|
1137
|
+
raise
|
|
1138
|
+
fallback_clauses, fallback_params, _ = self._build_card_query_parts(
|
|
1139
|
+
snapshot_name,
|
|
1140
|
+
{**filters, "tag_ids": None},
|
|
1141
|
+
candidate_ids,
|
|
1142
|
+
include_tag_filter=False,
|
|
1143
|
+
)
|
|
1144
|
+
fallback_where_sql = " AND ".join(fallback_clauses)
|
|
1145
|
+
rows = conn.execute(
|
|
1146
|
+
f"""
|
|
1147
|
+
SELECT {selected_columns}, tag_ids_json
|
|
1148
|
+
FROM snapshot_cards
|
|
1149
|
+
WHERE {fallback_where_sql}
|
|
1150
|
+
ORDER BY card_id
|
|
1151
|
+
""",
|
|
1152
|
+
fallback_params,
|
|
1153
|
+
).fetchall()
|
|
1154
|
+
tag_ids = set(_normalize_int_list(filters.get("tag_ids")))
|
|
1155
|
+
return [row for row in rows if set(json.loads(row["tag_ids_json"])) & tag_ids]
|
|
1156
|
+
|
|
1157
|
+
def text_candidate_card_ids(self, snapshot_name: str, field: str, text: str) -> set[int]:
|
|
1158
|
+
self.get_snapshot(snapshot_name)
|
|
1159
|
+
lowered = text.strip().lower()
|
|
1160
|
+
if not lowered:
|
|
1161
|
+
return set()
|
|
1162
|
+
column_map = {
|
|
1163
|
+
"search_text": "search_text",
|
|
1164
|
+
"child_text": "child_text",
|
|
1165
|
+
"comment_text": "comment_text",
|
|
1166
|
+
}
|
|
1167
|
+
column = column_map[field]
|
|
1168
|
+
with self._connection(read_only=True) as conn:
|
|
1169
|
+
try:
|
|
1170
|
+
tokens = " ".join(part for part in lowered.split() if part)
|
|
1171
|
+
if tokens:
|
|
1172
|
+
rows = conn.execute(
|
|
1173
|
+
f"""
|
|
1174
|
+
SELECT card_id
|
|
1175
|
+
FROM snapshot_card_search
|
|
1176
|
+
WHERE snapshot_name = ? AND {column} MATCH ?
|
|
1177
|
+
""",
|
|
1178
|
+
(snapshot_name, tokens),
|
|
1179
|
+
).fetchall()
|
|
1180
|
+
if rows:
|
|
1181
|
+
return {int(row["card_id"]) for row in rows}
|
|
1182
|
+
except sqlite3.OperationalError:
|
|
1183
|
+
pass
|
|
1184
|
+
rows = conn.execute(
|
|
1185
|
+
f"""
|
|
1186
|
+
SELECT card_id
|
|
1187
|
+
FROM snapshot_cards
|
|
1188
|
+
WHERE snapshot_name = ? AND lower({column}) LIKE ?
|
|
1189
|
+
""",
|
|
1190
|
+
(snapshot_name, f"%{lowered}%"),
|
|
1191
|
+
).fetchall()
|
|
1192
|
+
return {int(row["card_id"]) for row in rows}
|