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,363 @@
|
|
|
1
|
+
"""Shared snapshot constants and pure transformation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from kaiten_cli.errors import ValidationError
|
|
9
|
+
from kaiten_cli.i18n import tr
|
|
10
|
+
from kaiten_cli.runtime.transforms import compact_response, select_fields, strip_base64
|
|
11
|
+
|
|
12
|
+
SNAPSHOT_PRESETS = {"basic", "analytics", "evidence", "full"}
|
|
13
|
+
WINDOW_PRESETS = {"analytics", "full"}
|
|
14
|
+
QUERY_METRICS = {"count", "wip", "throughput", "lead_time", "cycle_time", "aging"}
|
|
15
|
+
QUERY_GROUP_BY = {
|
|
16
|
+
"board_id",
|
|
17
|
+
"column_id",
|
|
18
|
+
"lane_id",
|
|
19
|
+
"type_id",
|
|
20
|
+
"owner_id",
|
|
21
|
+
"responsible_id",
|
|
22
|
+
"state",
|
|
23
|
+
"condition",
|
|
24
|
+
}
|
|
25
|
+
QUERY_FILTER_KEYS = {
|
|
26
|
+
"board_ids",
|
|
27
|
+
"column_ids",
|
|
28
|
+
"lane_ids",
|
|
29
|
+
"type_ids",
|
|
30
|
+
"tag_ids",
|
|
31
|
+
"owner_ids",
|
|
32
|
+
"responsible_ids",
|
|
33
|
+
"states",
|
|
34
|
+
"condition",
|
|
35
|
+
"created_after",
|
|
36
|
+
"created_before",
|
|
37
|
+
"updated_after",
|
|
38
|
+
"updated_before",
|
|
39
|
+
"has_children",
|
|
40
|
+
"has_comments",
|
|
41
|
+
"text_query",
|
|
42
|
+
"child_text_query",
|
|
43
|
+
"comment_text_query",
|
|
44
|
+
}
|
|
45
|
+
DEFAULT_LOCAL_LIMIT = 100
|
|
46
|
+
SNAPSHOT_SCHEMA_VERSION = 2
|
|
47
|
+
SNAPSHOT_DB_SCHEMA_VERSION = 1
|
|
48
|
+
QUERY_CARD_VIEWS = {"summary", "detail", "evidence"}
|
|
49
|
+
|
|
50
|
+
SUMMARY_VIEW_FIELDS = (
|
|
51
|
+
"id",
|
|
52
|
+
"title",
|
|
53
|
+
"board_id",
|
|
54
|
+
"column_id",
|
|
55
|
+
"lane_id",
|
|
56
|
+
"type_id",
|
|
57
|
+
"owner_id",
|
|
58
|
+
"responsible_id",
|
|
59
|
+
"state",
|
|
60
|
+
"condition",
|
|
61
|
+
"created",
|
|
62
|
+
"updated",
|
|
63
|
+
"has_children",
|
|
64
|
+
"has_comments",
|
|
65
|
+
"children_count",
|
|
66
|
+
"comments_count",
|
|
67
|
+
"time_spent_total_minutes",
|
|
68
|
+
"last_time_log_at",
|
|
69
|
+
"current_stage_entered_at",
|
|
70
|
+
"done_at",
|
|
71
|
+
"age_days",
|
|
72
|
+
"lead_time_days",
|
|
73
|
+
"cycle_time_days",
|
|
74
|
+
)
|
|
75
|
+
DETAIL_VIEW_FIELDS = SUMMARY_VIEW_FIELDS + (
|
|
76
|
+
"description",
|
|
77
|
+
"tag_ids",
|
|
78
|
+
"latest_stage",
|
|
79
|
+
"latest_column_id",
|
|
80
|
+
"latest_lane_id",
|
|
81
|
+
"work_started_at",
|
|
82
|
+
"commitment_at",
|
|
83
|
+
)
|
|
84
|
+
EVIDENCE_VIEW_FIELDS = DETAIL_VIEW_FIELDS + (
|
|
85
|
+
"search_text",
|
|
86
|
+
"child_text",
|
|
87
|
+
"comment_text",
|
|
88
|
+
)
|
|
89
|
+
VIEW_FIELDS = {
|
|
90
|
+
"summary": SUMMARY_VIEW_FIELDS,
|
|
91
|
+
"detail": DETAIL_VIEW_FIELDS,
|
|
92
|
+
"evidence": EVIDENCE_VIEW_FIELDS,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_timestamp(value: str | None) -> datetime | None:
|
|
97
|
+
if not value:
|
|
98
|
+
return None
|
|
99
|
+
text = str(value).strip()
|
|
100
|
+
if not text:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
104
|
+
if parsed.tzinfo is None:
|
|
105
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
106
|
+
return parsed.astimezone(timezone.utc)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _iso_timestamp(value: datetime | None) -> str | None:
|
|
112
|
+
if value is None:
|
|
113
|
+
return None
|
|
114
|
+
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _now_iso() -> str:
|
|
118
|
+
return _iso_timestamp(datetime.now(timezone.utc)) or ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _stats_snapshot(stats) -> dict[str, int]:
|
|
122
|
+
return {
|
|
123
|
+
"http_request_count": stats.http_request_count,
|
|
124
|
+
"http_response_count": stats.http_response_count,
|
|
125
|
+
"http_error_count": stats.http_error_count,
|
|
126
|
+
"api_wait_ms": stats.api_wait_ms,
|
|
127
|
+
"http_wait_ms": stats.http_wait_ms,
|
|
128
|
+
"retry_count": stats.retry_count,
|
|
129
|
+
"request_cache_hits": stats.request_cache_hits,
|
|
130
|
+
"request_cache_misses": stats.request_cache_misses,
|
|
131
|
+
"inflight_dedup_hits": stats.inflight_dedup_hits,
|
|
132
|
+
"disk_cache_hits": stats.disk_cache_hits,
|
|
133
|
+
"disk_cache_misses": stats.disk_cache_misses,
|
|
134
|
+
"disk_cache_expired": stats.disk_cache_expired,
|
|
135
|
+
"disk_cache_bypasses": stats.disk_cache_bypasses,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stats_delta(after: dict[str, Any], before: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
return {key: after[key] - before.get(key, 0) for key in after}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _duration_stats(values: list[float]) -> dict[str, Any] | None:
|
|
144
|
+
if not values:
|
|
145
|
+
return None
|
|
146
|
+
ordered = sorted(values)
|
|
147
|
+
|
|
148
|
+
def percentile(percent: float) -> float:
|
|
149
|
+
if len(ordered) == 1:
|
|
150
|
+
return ordered[0]
|
|
151
|
+
rank = (len(ordered) - 1) * percent
|
|
152
|
+
lower = int(rank)
|
|
153
|
+
upper = min(lower + 1, len(ordered) - 1)
|
|
154
|
+
fraction = rank - lower
|
|
155
|
+
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"count": len(ordered),
|
|
159
|
+
"median_days": round(percentile(0.5), 2),
|
|
160
|
+
"p85_days": round(percentile(0.85), 2),
|
|
161
|
+
"max_days": round(ordered[-1], 2),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _normalize_int_list(value: Any) -> list[int]:
|
|
166
|
+
if value is None:
|
|
167
|
+
return []
|
|
168
|
+
if isinstance(value, list):
|
|
169
|
+
raw_items = value
|
|
170
|
+
elif isinstance(value, str):
|
|
171
|
+
raw_items = [item.strip() for item in value.split(",") if item.strip()]
|
|
172
|
+
else:
|
|
173
|
+
raw_items = [value]
|
|
174
|
+
normalized: list[int] = []
|
|
175
|
+
for item in raw_items:
|
|
176
|
+
try:
|
|
177
|
+
normalized.append(int(item))
|
|
178
|
+
except (TypeError, ValueError) as exc:
|
|
179
|
+
raise ValidationError(
|
|
180
|
+
tr("Invalid integer list value: {value_0}", value_0=item)
|
|
181
|
+
) from exc
|
|
182
|
+
return normalized
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _normalize_string(value: Any) -> str:
|
|
186
|
+
if value is None:
|
|
187
|
+
return ""
|
|
188
|
+
return str(value).strip().lower()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _normalize_bool(value: Any) -> bool | None:
|
|
192
|
+
if value is None:
|
|
193
|
+
return None
|
|
194
|
+
if isinstance(value, bool):
|
|
195
|
+
return value
|
|
196
|
+
raise ValidationError(tr("Boolean filters must use true or false."))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _extract_id(value: Any) -> int | None:
|
|
200
|
+
if isinstance(value, int):
|
|
201
|
+
return value
|
|
202
|
+
if isinstance(value, dict):
|
|
203
|
+
nested = value.get("id")
|
|
204
|
+
if isinstance(nested, int):
|
|
205
|
+
return nested
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _extract_tag_ids(card: dict[str, Any]) -> list[int]:
|
|
210
|
+
tags = card.get("tags")
|
|
211
|
+
if not isinstance(tags, list):
|
|
212
|
+
return []
|
|
213
|
+
tag_ids: list[int] = []
|
|
214
|
+
for tag in tags:
|
|
215
|
+
tag_id = _extract_id(tag)
|
|
216
|
+
if tag_id is not None:
|
|
217
|
+
tag_ids.append(tag_id)
|
|
218
|
+
return tag_ids
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _time_log_minutes(entry: dict[str, Any]) -> int:
|
|
222
|
+
for key in ("time_spent", "timeSpent", "minutes"):
|
|
223
|
+
value = entry.get(key)
|
|
224
|
+
if isinstance(value, int):
|
|
225
|
+
return value
|
|
226
|
+
if isinstance(value, str) and value.strip():
|
|
227
|
+
try:
|
|
228
|
+
return int(value)
|
|
229
|
+
except ValueError:
|
|
230
|
+
continue
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _time_log_timestamp(entry: dict[str, Any]) -> datetime | None:
|
|
235
|
+
for key in ("created", "updated", "for_date", "forDate", "date"):
|
|
236
|
+
changed = _parse_timestamp(entry.get(key))
|
|
237
|
+
if changed is not None:
|
|
238
|
+
return changed
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _search_blob(parts: list[str]) -> str:
|
|
243
|
+
normalized = [part.strip() for part in parts if part and str(part).strip()]
|
|
244
|
+
return "\n".join(normalized)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _duration_days(start: datetime | None, end: datetime | None) -> float | None:
|
|
248
|
+
if start is None or end is None or start > end:
|
|
249
|
+
return None
|
|
250
|
+
return round((end - start).total_seconds() / 86400, 2)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _effective_column_id(event: dict[str, Any]) -> int | None:
|
|
254
|
+
for key in ("subcolumn_id", "column_id"):
|
|
255
|
+
value = event.get(key)
|
|
256
|
+
if isinstance(value, int):
|
|
257
|
+
return value
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _sorted_history(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
262
|
+
return sorted(
|
|
263
|
+
[row for row in rows if isinstance(row, dict)],
|
|
264
|
+
key=lambda row: (
|
|
265
|
+
str(row.get("changed", "")),
|
|
266
|
+
int(row.get("column_id") or 0),
|
|
267
|
+
int(row.get("lane_id") or 0),
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _first_history_with_state(history: list[dict[str, Any]], state: int) -> datetime | None:
|
|
273
|
+
for row in history:
|
|
274
|
+
if row.get("state") == state:
|
|
275
|
+
return _parse_timestamp(row.get("changed"))
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _derive_done_at(card: dict[str, Any], history: list[dict[str, Any]]) -> datetime | None:
|
|
280
|
+
card_done = _parse_timestamp(card.get("last_moved_to_done_at"))
|
|
281
|
+
if card_done is not None:
|
|
282
|
+
return card_done
|
|
283
|
+
latest_done: datetime | None = None
|
|
284
|
+
for row in history:
|
|
285
|
+
changed = _parse_timestamp(row.get("changed"))
|
|
286
|
+
if changed is None:
|
|
287
|
+
continue
|
|
288
|
+
if row.get("state") == 3:
|
|
289
|
+
latest_done = changed
|
|
290
|
+
return latest_done
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _current_stage_entered_at(history: list[dict[str, Any]]) -> datetime | None:
|
|
294
|
+
if not history:
|
|
295
|
+
return None
|
|
296
|
+
latest = history[-1]
|
|
297
|
+
latest_column = _effective_column_id(latest)
|
|
298
|
+
latest_lane = latest.get("lane_id")
|
|
299
|
+
latest_condition = latest.get("condition")
|
|
300
|
+
latest_state = latest.get("state")
|
|
301
|
+
for row in reversed(history):
|
|
302
|
+
if (
|
|
303
|
+
_effective_column_id(row) != latest_column
|
|
304
|
+
or row.get("lane_id") != latest_lane
|
|
305
|
+
or row.get("condition") != latest_condition
|
|
306
|
+
or row.get("state") != latest_state
|
|
307
|
+
):
|
|
308
|
+
break
|
|
309
|
+
changed = _parse_timestamp(row.get("changed"))
|
|
310
|
+
if changed is not None:
|
|
311
|
+
return changed
|
|
312
|
+
return _parse_timestamp(latest.get("changed"))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _within_window(
|
|
316
|
+
changed: datetime | None, window_start: datetime | None, window_end: datetime | None
|
|
317
|
+
) -> bool:
|
|
318
|
+
if changed is None:
|
|
319
|
+
return False
|
|
320
|
+
if window_start is not None and changed < window_start:
|
|
321
|
+
return False
|
|
322
|
+
if window_end is not None and changed > window_end:
|
|
323
|
+
return False
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _filter_time_logs_to_window(
|
|
328
|
+
time_logs: list[dict[str, Any]],
|
|
329
|
+
*,
|
|
330
|
+
window_start: datetime | None,
|
|
331
|
+
window_end: datetime | None,
|
|
332
|
+
) -> list[dict[str, Any]]:
|
|
333
|
+
if window_start is None and window_end is None:
|
|
334
|
+
return [row for row in time_logs if isinstance(row, dict)]
|
|
335
|
+
return [
|
|
336
|
+
row
|
|
337
|
+
for row in time_logs
|
|
338
|
+
if isinstance(row, dict)
|
|
339
|
+
and _within_window(_time_log_timestamp(row), window_start, window_end)
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _view_fields(view: str, fields: str | None) -> str | None:
|
|
344
|
+
if fields:
|
|
345
|
+
return fields
|
|
346
|
+
return ",".join(VIEW_FIELDS[view])
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _shape_card_for_output(
|
|
350
|
+
record: dict[str, Any],
|
|
351
|
+
*,
|
|
352
|
+
view: str,
|
|
353
|
+
compact: bool,
|
|
354
|
+
fields: str | None,
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
payload = dict(record["card"])
|
|
357
|
+
payload.update(record["derived"])
|
|
358
|
+
view_field_names = VIEW_FIELDS[view]
|
|
359
|
+
payload = {field: payload[field] for field in view_field_names if field in payload}
|
|
360
|
+
shaped = compact_response(payload, compact)
|
|
361
|
+
shaped = select_fields(shaped, _view_fields(view, fields))
|
|
362
|
+
shaped, _ = strip_base64(shaped)
|
|
363
|
+
return shaped
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Local-only snapshot card queries and generic metric aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from kaiten_cli.errors import ValidationError
|
|
10
|
+
from kaiten_cli.i18n import tr
|
|
11
|
+
from kaiten_cli.runtime.snapshot_common import (
|
|
12
|
+
DEFAULT_LOCAL_LIMIT,
|
|
13
|
+
QUERY_FILTER_KEYS,
|
|
14
|
+
_duration_stats,
|
|
15
|
+
_iso_timestamp,
|
|
16
|
+
_normalize_bool,
|
|
17
|
+
_normalize_int_list,
|
|
18
|
+
_normalize_string,
|
|
19
|
+
_parse_timestamp,
|
|
20
|
+
_shape_card_for_output,
|
|
21
|
+
)
|
|
22
|
+
from kaiten_cli.runtime.snapshot_store import SnapshotStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_query_filter(tool, payload: dict[str, Any]) -> None:
|
|
26
|
+
filter_payload = payload.get("filter")
|
|
27
|
+
if filter_payload is None:
|
|
28
|
+
return
|
|
29
|
+
if not isinstance(filter_payload, dict):
|
|
30
|
+
raise ValidationError(tr("Field filter must be an object."))
|
|
31
|
+
unknown = sorted(set(filter_payload) - QUERY_FILTER_KEYS)
|
|
32
|
+
if unknown:
|
|
33
|
+
raise ValidationError(
|
|
34
|
+
tr("Unknown query filter field(s): {value_0}", value_0=", ".join(unknown))
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _matches_query_filter(record: dict[str, Any], filters: dict[str, Any]) -> bool:
|
|
39
|
+
board_ids = _normalize_int_list(filters.get("board_ids"))
|
|
40
|
+
if board_ids and record["board_id"] not in board_ids:
|
|
41
|
+
return False
|
|
42
|
+
column_ids = _normalize_int_list(filters.get("column_ids"))
|
|
43
|
+
if column_ids and record["column_id"] not in column_ids:
|
|
44
|
+
return False
|
|
45
|
+
lane_ids = _normalize_int_list(filters.get("lane_ids"))
|
|
46
|
+
if lane_ids and record["lane_id"] not in lane_ids:
|
|
47
|
+
return False
|
|
48
|
+
type_ids = _normalize_int_list(filters.get("type_ids"))
|
|
49
|
+
if type_ids and record["type_id"] not in type_ids:
|
|
50
|
+
return False
|
|
51
|
+
owner_ids = _normalize_int_list(filters.get("owner_ids"))
|
|
52
|
+
if owner_ids and record["owner_id"] not in owner_ids:
|
|
53
|
+
return False
|
|
54
|
+
responsible_ids = _normalize_int_list(filters.get("responsible_ids"))
|
|
55
|
+
if responsible_ids and record["responsible_id"] not in responsible_ids:
|
|
56
|
+
return False
|
|
57
|
+
states = _normalize_int_list(filters.get("states"))
|
|
58
|
+
if states and record["state"] not in states:
|
|
59
|
+
return False
|
|
60
|
+
condition_values = _normalize_int_list(filters.get("condition"))
|
|
61
|
+
if condition_values and record["condition"] not in condition_values:
|
|
62
|
+
return False
|
|
63
|
+
tag_ids = _normalize_int_list(filters.get("tag_ids"))
|
|
64
|
+
if tag_ids and not (set(record["tag_ids"]) & set(tag_ids)):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
created = _parse_timestamp(record["created"])
|
|
68
|
+
updated = _parse_timestamp(record["updated"])
|
|
69
|
+
created_after = _parse_timestamp(filters.get("created_after"))
|
|
70
|
+
if created_after is not None and (created is None or created < created_after):
|
|
71
|
+
return False
|
|
72
|
+
created_before = _parse_timestamp(filters.get("created_before"))
|
|
73
|
+
if created_before is not None and (created is None or created > created_before):
|
|
74
|
+
return False
|
|
75
|
+
updated_after = _parse_timestamp(filters.get("updated_after"))
|
|
76
|
+
if updated_after is not None and (updated is None or updated < updated_after):
|
|
77
|
+
return False
|
|
78
|
+
updated_before = _parse_timestamp(filters.get("updated_before"))
|
|
79
|
+
if updated_before is not None and (updated is None or updated > updated_before):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
has_children = _normalize_bool(filters.get("has_children"))
|
|
83
|
+
if has_children is not None and record["has_children"] != has_children:
|
|
84
|
+
return False
|
|
85
|
+
has_comments = _normalize_bool(filters.get("has_comments"))
|
|
86
|
+
if has_comments is not None and record["has_comments"] != has_comments:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
text_query = _normalize_string(filters.get("text_query"))
|
|
90
|
+
if text_query and text_query not in record["search_text"].lower():
|
|
91
|
+
return False
|
|
92
|
+
child_text_query = _normalize_string(filters.get("child_text_query"))
|
|
93
|
+
if child_text_query and child_text_query not in record["child_text"].lower():
|
|
94
|
+
return False
|
|
95
|
+
comment_text_query = _normalize_string(filters.get("comment_text_query"))
|
|
96
|
+
if comment_text_query and comment_text_query not in record["comment_text"].lower():
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _text_candidate_ids(
|
|
102
|
+
store: SnapshotStore, snapshot_name: str, filters: dict[str, Any]
|
|
103
|
+
) -> set[int] | None:
|
|
104
|
+
candidate_sets: list[set[int]] = []
|
|
105
|
+
mapping = {
|
|
106
|
+
"text_query": "search_text",
|
|
107
|
+
"child_text_query": "child_text",
|
|
108
|
+
"comment_text_query": "comment_text",
|
|
109
|
+
}
|
|
110
|
+
for filter_name, column_name in mapping.items():
|
|
111
|
+
query = _normalize_string(filters.get(filter_name))
|
|
112
|
+
if query:
|
|
113
|
+
candidate_sets.append(store.text_candidate_card_ids(snapshot_name, column_name, query))
|
|
114
|
+
if not candidate_sets:
|
|
115
|
+
return None
|
|
116
|
+
candidates = candidate_sets[0]
|
|
117
|
+
for subset in candidate_sets[1:]:
|
|
118
|
+
candidates &= subset
|
|
119
|
+
return candidates
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def execute_query_cards(
|
|
123
|
+
client,
|
|
124
|
+
tool,
|
|
125
|
+
payload: dict[str, Any],
|
|
126
|
+
path: str,
|
|
127
|
+
query: dict[str, Any] | None,
|
|
128
|
+
body: dict[str, Any] | None,
|
|
129
|
+
timeout: float,
|
|
130
|
+
reporter,
|
|
131
|
+
) -> Any:
|
|
132
|
+
store = SnapshotStore(reporter=reporter)
|
|
133
|
+
snapshot = store.get_snapshot(payload["snapshot"])
|
|
134
|
+
filters = payload.get("filter") or {}
|
|
135
|
+
candidate_ids = _text_candidate_ids(store, payload["snapshot"], filters)
|
|
136
|
+
offset = max(int(payload.get("offset", 0)), 0)
|
|
137
|
+
limit = max(int(payload.get("limit", DEFAULT_LOCAL_LIMIT)), 1)
|
|
138
|
+
view = payload.get("view", "summary")
|
|
139
|
+
sliced, total = store.query_card_records(
|
|
140
|
+
payload["snapshot"],
|
|
141
|
+
filters,
|
|
142
|
+
candidate_ids,
|
|
143
|
+
limit=limit,
|
|
144
|
+
offset=offset,
|
|
145
|
+
)
|
|
146
|
+
items = [
|
|
147
|
+
_shape_card_for_output(
|
|
148
|
+
record,
|
|
149
|
+
view=view,
|
|
150
|
+
compact=bool(payload.get("compact", False)),
|
|
151
|
+
fields=payload.get("fields"),
|
|
152
|
+
)
|
|
153
|
+
for record in sliced
|
|
154
|
+
]
|
|
155
|
+
return {
|
|
156
|
+
"snapshot": snapshot["name"],
|
|
157
|
+
"items": items,
|
|
158
|
+
"meta": {
|
|
159
|
+
"view": view,
|
|
160
|
+
"total": total,
|
|
161
|
+
"returned": len(items),
|
|
162
|
+
"offset": offset,
|
|
163
|
+
"limit": limit,
|
|
164
|
+
"window": snapshot["window"],
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _group_label(record: dict[str, Any], group_by: str | None) -> str:
|
|
170
|
+
if not group_by:
|
|
171
|
+
return "all"
|
|
172
|
+
value = record.get(group_by)
|
|
173
|
+
return "null" if value is None else str(value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _metric_cutoff(snapshot: dict[str, Any]) -> datetime:
|
|
177
|
+
cutoff = _parse_timestamp(snapshot["window"].get("end"))
|
|
178
|
+
if cutoff is not None:
|
|
179
|
+
return cutoff
|
|
180
|
+
built_at = _parse_timestamp(snapshot["built_at"])
|
|
181
|
+
return built_at or datetime.now(timezone.utc)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _metric_done_in_window(done_at: datetime | None, snapshot: dict[str, Any]) -> bool:
|
|
185
|
+
if done_at is None:
|
|
186
|
+
return False
|
|
187
|
+
start = _parse_timestamp(snapshot["window"].get("start"))
|
|
188
|
+
end = _parse_timestamp(snapshot["window"].get("end"))
|
|
189
|
+
if start is not None and done_at < start:
|
|
190
|
+
return False
|
|
191
|
+
if end is not None and done_at > end:
|
|
192
|
+
return False
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def execute_query_metrics(
|
|
197
|
+
client,
|
|
198
|
+
tool,
|
|
199
|
+
payload: dict[str, Any],
|
|
200
|
+
path: str,
|
|
201
|
+
query: dict[str, Any] | None,
|
|
202
|
+
body: dict[str, Any] | None,
|
|
203
|
+
timeout: float,
|
|
204
|
+
reporter,
|
|
205
|
+
) -> Any:
|
|
206
|
+
store = SnapshotStore(reporter=reporter)
|
|
207
|
+
snapshot = store.get_snapshot(payload["snapshot"])
|
|
208
|
+
filters = payload.get("filter") or {}
|
|
209
|
+
candidate_ids = _text_candidate_ids(store, payload["snapshot"], filters)
|
|
210
|
+
metric = payload["metric"]
|
|
211
|
+
group_by = payload.get("group_by")
|
|
212
|
+
cutoff = _metric_cutoff(snapshot)
|
|
213
|
+
metric_column_map = {
|
|
214
|
+
"count": ("card_id", group_by or "card_id"),
|
|
215
|
+
"wip": ("card_id", group_by or "card_id", "condition", "state"),
|
|
216
|
+
"throughput": ("card_id", group_by or "card_id", "done_at"),
|
|
217
|
+
"lead_time": ("card_id", group_by or "card_id", "lead_time_days", "done_at"),
|
|
218
|
+
"cycle_time": ("card_id", group_by or "card_id", "cycle_time_days", "done_at"),
|
|
219
|
+
"aging": ("card_id", group_by or "card_id", "age_days", "condition", "state"),
|
|
220
|
+
}
|
|
221
|
+
metric_columns = tuple(dict.fromkeys(metric_column_map[metric]))
|
|
222
|
+
rows_source = store.load_metric_rows(
|
|
223
|
+
payload["snapshot"], filters, candidate_ids, metric_columns
|
|
224
|
+
)
|
|
225
|
+
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
226
|
+
matched_cards = 0
|
|
227
|
+
for row in rows_source:
|
|
228
|
+
matched_cards += 1
|
|
229
|
+
row_payload = dict(row)
|
|
230
|
+
grouped[_group_label(row_payload, group_by)].append(row_payload)
|
|
231
|
+
|
|
232
|
+
rows: list[dict[str, Any]] = []
|
|
233
|
+
for group_value, group_records in sorted(grouped.items()):
|
|
234
|
+
if metric == "count":
|
|
235
|
+
rows.append({"group": group_value, "value": len(group_records)})
|
|
236
|
+
continue
|
|
237
|
+
if metric == "wip":
|
|
238
|
+
count = sum(
|
|
239
|
+
1 for record in group_records if record["condition"] == 1 and record["state"] != 3
|
|
240
|
+
)
|
|
241
|
+
rows.append({"group": group_value, "value": count, "as_of": _iso_timestamp(cutoff)})
|
|
242
|
+
continue
|
|
243
|
+
if metric == "throughput":
|
|
244
|
+
count = 0
|
|
245
|
+
for record in group_records:
|
|
246
|
+
if _metric_done_in_window(_parse_timestamp(record["done_at"]), snapshot):
|
|
247
|
+
count += 1
|
|
248
|
+
rows.append({"group": group_value, "value": count, "window": snapshot["window"]})
|
|
249
|
+
continue
|
|
250
|
+
if metric == "lead_time":
|
|
251
|
+
durations = []
|
|
252
|
+
for record in group_records:
|
|
253
|
+
done_at = _parse_timestamp(record["done_at"])
|
|
254
|
+
duration = record.get("lead_time_days")
|
|
255
|
+
if (
|
|
256
|
+
done_at is None
|
|
257
|
+
or duration is None
|
|
258
|
+
or not _metric_done_in_window(done_at, snapshot)
|
|
259
|
+
):
|
|
260
|
+
continue
|
|
261
|
+
durations.append(float(duration))
|
|
262
|
+
rows.append({"group": group_value, "stats": _duration_stats(durations)})
|
|
263
|
+
continue
|
|
264
|
+
if metric == "cycle_time":
|
|
265
|
+
durations = []
|
|
266
|
+
for record in group_records:
|
|
267
|
+
done_at = _parse_timestamp(record["done_at"])
|
|
268
|
+
duration = record.get("cycle_time_days")
|
|
269
|
+
if (
|
|
270
|
+
done_at is None
|
|
271
|
+
or duration is None
|
|
272
|
+
or not _metric_done_in_window(done_at, snapshot)
|
|
273
|
+
):
|
|
274
|
+
continue
|
|
275
|
+
durations.append(float(duration))
|
|
276
|
+
rows.append({"group": group_value, "stats": _duration_stats(durations)})
|
|
277
|
+
continue
|
|
278
|
+
if metric == "aging":
|
|
279
|
+
ages = []
|
|
280
|
+
for record in group_records:
|
|
281
|
+
if record["condition"] != 1 or record["state"] == 3:
|
|
282
|
+
continue
|
|
283
|
+
age_days = record.get("age_days")
|
|
284
|
+
if age_days is None:
|
|
285
|
+
continue
|
|
286
|
+
ages.append(float(age_days))
|
|
287
|
+
rows.append(
|
|
288
|
+
{
|
|
289
|
+
"group": group_value,
|
|
290
|
+
"stats": _duration_stats(ages),
|
|
291
|
+
"as_of": _iso_timestamp(cutoff),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
"snapshot": snapshot["name"],
|
|
298
|
+
"metric": metric,
|
|
299
|
+
"group_by": group_by,
|
|
300
|
+
"rows": rows,
|
|
301
|
+
"meta": {
|
|
302
|
+
"matched_cards": matched_cards,
|
|
303
|
+
"window": snapshot["window"],
|
|
304
|
+
"built_at": snapshot["built_at"],
|
|
305
|
+
},
|
|
306
|
+
}
|