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.
Files changed (99) hide show
  1. kaiten_cli/__init__.py +5 -0
  2. kaiten_cli/__main__.py +9 -0
  3. kaiten_cli/agent_gateway.py +577 -0
  4. kaiten_cli/app.py +1636 -0
  5. kaiten_cli/completion.py +570 -0
  6. kaiten_cli/discovery.py +39 -0
  7. kaiten_cli/errors.py +79 -0
  8. kaiten_cli/i18n.py +92 -0
  9. kaiten_cli/locales/ru.json +2967 -0
  10. kaiten_cli/localized_click.py +160 -0
  11. kaiten_cli/models.py +202 -0
  12. kaiten_cli/profiles.py +316 -0
  13. kaiten_cli/registry/__init__.py +260 -0
  14. kaiten_cli/registry/addons.py +392 -0
  15. kaiten_cli/registry/audit_and_analytics.py +451 -0
  16. kaiten_cli/registry/automation_schema.py +44 -0
  17. kaiten_cli/registry/automation_schemas.json +7417 -0
  18. kaiten_cli/registry/automations.py +342 -0
  19. kaiten_cli/registry/base.py +62 -0
  20. kaiten_cli/registry/blockers.py +320 -0
  21. kaiten_cli/registry/boards.py +520 -0
  22. kaiten_cli/registry/card_relations.py +376 -0
  23. kaiten_cli/registry/card_types.py +363 -0
  24. kaiten_cli/registry/cards.py +1329 -0
  25. kaiten_cli/registry/charts.py +539 -0
  26. kaiten_cli/registry/checklists.py +600 -0
  27. kaiten_cli/registry/columns.py +558 -0
  28. kaiten_cli/registry/comments.py +224 -0
  29. kaiten_cli/registry/custom_directories.py +980 -0
  30. kaiten_cli/registry/custom_properties.py +1460 -0
  31. kaiten_cli/registry/dashboards.py +564 -0
  32. kaiten_cli/registry/documents.py +761 -0
  33. kaiten_cli/registry/external_links.py +128 -0
  34. kaiten_cli/registry/files.py +738 -0
  35. kaiten_cli/registry/github_addon.py +613 -0
  36. kaiten_cli/registry/iterations.py +397 -0
  37. kaiten_cli/registry/lanes.py +205 -0
  38. kaiten_cli/registry/live_contracts.py +361 -0
  39. kaiten_cli/registry/members.py +336 -0
  40. kaiten_cli/registry/module_docs.py +108 -0
  41. kaiten_cli/registry/projects.py +369 -0
  42. kaiten_cli/registry/query.py +129 -0
  43. kaiten_cli/registry/restrictions.py +249 -0
  44. kaiten_cli/registry/roles_and_groups.py +1102 -0
  45. kaiten_cli/registry/scim.py +263 -0
  46. kaiten_cli/registry/service_desk.py +1270 -0
  47. kaiten_cli/registry/snapshot.py +187 -0
  48. kaiten_cli/registry/spaces.py +272 -0
  49. kaiten_cli/registry/subscribers.py +168 -0
  50. kaiten_cli/registry/tags.py +193 -0
  51. kaiten_cli/registry/time_logs.py +353 -0
  52. kaiten_cli/registry/tree.py +352 -0
  53. kaiten_cli/registry/utilities.py +287 -0
  54. kaiten_cli/registry/webhooks.py +265 -0
  55. kaiten_cli/runtime/__init__.py +1 -0
  56. kaiten_cli/runtime/automation_validation.py +115 -0
  57. kaiten_cli/runtime/behaviors.py +787 -0
  58. kaiten_cli/runtime/cache.py +733 -0
  59. kaiten_cli/runtime/client.py +387 -0
  60. kaiten_cli/runtime/endpoints.py +59 -0
  61. kaiten_cli/runtime/executor.py +269 -0
  62. kaiten_cli/runtime/fs_security.py +168 -0
  63. kaiten_cli/runtime/input.py +310 -0
  64. kaiten_cli/runtime/output.py +59 -0
  65. kaiten_cli/runtime/public_validation.py +35 -0
  66. kaiten_cli/runtime/snapshot_build.py +527 -0
  67. kaiten_cli/runtime/snapshot_common.py +363 -0
  68. kaiten_cli/runtime/snapshot_query.py +306 -0
  69. kaiten_cli/runtime/snapshot_store.py +1192 -0
  70. kaiten_cli/runtime/snapshots.py +62 -0
  71. kaiten_cli/runtime/sqlite_errors.py +23 -0
  72. kaiten_cli/runtime/support/__init__.py +1 -0
  73. kaiten_cli/runtime/support/addons.py +953 -0
  74. kaiten_cli/runtime/support/audit.py +70 -0
  75. kaiten_cli/runtime/support/batch.py +234 -0
  76. kaiten_cli/runtime/support/card_move_url.py +213 -0
  77. kaiten_cli/runtime/support/cards.py +174 -0
  78. kaiten_cli/runtime/support/checklists.py +21 -0
  79. kaiten_cli/runtime/support/company_users.py +117 -0
  80. kaiten_cli/runtime/support/dashboards.py +35 -0
  81. kaiten_cli/runtime/support/documents.py +175 -0
  82. kaiten_cli/runtime/support/files.py +561 -0
  83. kaiten_cli/runtime/support/markdown_export.py +525 -0
  84. kaiten_cli/runtime/support/pagination.py +167 -0
  85. kaiten_cli/runtime/support/projects.py +66 -0
  86. kaiten_cli/runtime/support/relations.py +86 -0
  87. kaiten_cli/runtime/support/restrictions.py +62 -0
  88. kaiten_cli/runtime/support/spaces.py +20 -0
  89. kaiten_cli/runtime/support/time_logs.py +61 -0
  90. kaiten_cli/runtime/support/tree.py +140 -0
  91. kaiten_cli/runtime/support/tree_sharing.py +447 -0
  92. kaiten_cli/runtime/trace.py +500 -0
  93. kaiten_cli/runtime/transforms.py +128 -0
  94. kaiten_cli/schema_docs.py +22 -0
  95. kaiten_cli/update_check.py +519 -0
  96. kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
  97. kaiten_cli-0.3.3.dist-info/RECORD +99 -0
  98. kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
  99. 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
+ }