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,953 @@
1
+ """Addon data helpers: deterministic addon UIDs and GitHub-addon attachment stores.
2
+
3
+ Kaiten addons keep their per-card state in `card_addon_data` rows addressed by
4
+ `(card, addon_uid)` and read/written through `/cards/{card_id}/addons-data/{addon_uid}`.
5
+ The server shallow-merges the PATCHed `data` object over the stored one, so every
6
+ write here sends the FULL replacement value for the key it touches and leaves
7
+ sibling keys of other addon features untouched.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+ from urllib.parse import urlparse
18
+
19
+ from kaiten_cli.errors import ApiError, TransportError, ValidationError
20
+ from kaiten_cli.i18n import tr
21
+
22
+ # Fixed namespace from kaiten-lib/src/shared/addons/generateAddonUid.js. On
23
+ # self-hosted Kaiten an addon UID is the UUID v5 of its normalized URL path, so
24
+ # the addons-data endpoint is addressable without registering or looking it up.
25
+ # The namespace and the normalization must stay byte-identical to the platform
26
+ # helper or derived UIDs stop matching.
27
+ KAITEN_ADDONS_NAMESPACE = uuid.UUID("d202834b-4740-4d9b-9ee0-cb1eb833124d")
28
+
29
+ GITHUB_ADDON_URL_PATH = "/github"
30
+
31
+ SHARED_SCOPE = "shared"
32
+
33
+ # Addon SDK keys the GitHub addon stores its attachments under (shared scope).
34
+ # Must match `setData('card', 'shared', <key>, ...)` in kaiten-addons/addons/github.
35
+ ATTACHED_PULLS_KEY = "attachedPulls"
36
+ ATTACHED_BRANCHES_KEY = "attachedBranches"
37
+ ATTACHED_COMMITS_KEY = "attachedCommits"
38
+ ATTACHED_ISSUES_KEY = "attachedIssues"
39
+
40
+ # Placeholder values the addon itself writes when GitHub omits a field; kept
41
+ # identical so a CLI-written entry is indistinguishable from a UI-written one.
42
+ UNKNOWN_AUTHOR = "Неизвестный автор"
43
+ FALLBACK_AUTHOR_URL = "https://github.com"
44
+ FALLBACK_AVATAR_URL = "https://avatars.githubusercontent.com/u/583231?v=4"
45
+ NO_DATA = "no data"
46
+
47
+ # Mirrors uuidIdRule from kaiten/shared/idRules.js: version 4 or 5, RFC-4122
48
+ # variant. A value the route regex rejects can never address anything, and a
49
+ # stray string in the path would silently redirect the call to another endpoint.
50
+ _UUID_PATTERN = re.compile(
51
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[45][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
52
+ )
53
+
54
+
55
+ def validate_addon_uid_value(value: Any, field: str = "addon_uid") -> str:
56
+ """Validate an addon UUID and normalize its case.
57
+
58
+ Kaiten stores addon ids in a Postgres uuid column and returns them
59
+ lowercase, while `checkAccessForUpdate` compares them to the path segment
60
+ with `===`. An uppercase UUID therefore reads fine and then fails every
61
+ write with 403, so the case is normalized here rather than passed through.
62
+ """
63
+
64
+ text = value.strip().lower() if isinstance(value, str) else ""
65
+ if not _UUID_PATTERN.match(text):
66
+ raise ValidationError(
67
+ tr(
68
+ "Field {value_0} must be an addon UUID such as 0ce23a01-560f-51e0-9982-1e3445dc5990; take it from space-addons.list, company-addons.list or addons.uid.",
69
+ value_0=field,
70
+ )
71
+ )
72
+ return text
73
+
74
+
75
+ def validate_addon_uid_payload(tool, payload: dict[str, Any]) -> None:
76
+ """Payload validator for the tools that take addon_uid as a path segment."""
77
+
78
+ validate_addon_uid_value(payload.get("addon_uid"))
79
+
80
+
81
+ def generate_addon_uid(url_path: str) -> str:
82
+ """Deterministic addon UID for a mount path, mirroring `generateAddonUid`."""
83
+
84
+ return str(uuid.uuid5(KAITEN_ADDONS_NAMESPACE, normalize_addon_url_path(url_path)))
85
+
86
+
87
+ def normalize_addon_url_path(url_path: str) -> str:
88
+ """Strip surrounding slashes and lowercase, exactly as the platform helper does."""
89
+
90
+ return url_path.strip("/").lower()
91
+
92
+
93
+ def explicit_addon_uid(payload: dict[str, Any]) -> str | None:
94
+ """The validated `--addon-uid` when the caller gave one."""
95
+
96
+ explicit = payload.get("addon_uid")
97
+ if isinstance(explicit, str) and explicit.strip():
98
+ return validate_addon_uid_value(explicit)
99
+ return None
100
+
101
+
102
+ def addon_url_path(payload: dict[str, Any]) -> str:
103
+ return payload.get("addon_url_path") or GITHUB_ADDON_URL_PATH
104
+
105
+
106
+ def mount_path_key(url_path: str) -> str:
107
+ """Comparison key for a mount path.
108
+
109
+ Only for matching, never for derivation: surrounding whitespace is stripped
110
+ because a registered `iframe_initial_url` really does carry it, while
111
+ `normalize_addon_url_path` must keep byte-matching the platform helper that
112
+ produces the UUID.
113
+ """
114
+
115
+ return normalize_addon_url_path(url_path.strip())
116
+
117
+
118
+ def _iframe_url_path(url: Any) -> str | None:
119
+ """Comparison key for an addon's iframe URL."""
120
+
121
+ if not isinstance(url, str) or not url.strip():
122
+ return None
123
+ try:
124
+ return mount_path_key(urlparse(url.strip()).path)
125
+ except ValueError: # pragma: no cover - urlparse only raises on malformed IPv6
126
+ return None
127
+
128
+
129
+ def _addon_uids_in(addons: Any, normalized_path: str) -> tuple[list[str], int]:
130
+ """Distinct addon UIDs mounted at `normalized_path`, plus a count of rejects.
131
+
132
+ A path is not an identity: two addons can be served from different hosts under
133
+ the same path, and on a cloud tenant their UIDs are unrelated random values.
134
+ Returning all of them lets the caller refuse to guess.
135
+
136
+ An addon that matched the path but whose id is outside the route contract is
137
+ counted, not dropped: we cannot address it, and it must not be mistaken for
138
+ a confirmed addon UID.
139
+ """
140
+
141
+ found: list[str] = []
142
+ rejected = 0
143
+ if not isinstance(addons, list):
144
+ return found, rejected
145
+ for addon in addons:
146
+ if not isinstance(addon, dict):
147
+ continue
148
+ if _iframe_url_path(addon.get("iframe_initial_url")) != normalized_path:
149
+ continue
150
+ uid = addon.get("id")
151
+ if not isinstance(uid, str) or not _UUID_PATTERN.match(uid.lower()):
152
+ rejected += 1
153
+ continue
154
+ lowered = uid.lower()
155
+ if lowered not in found:
156
+ found.append(lowered)
157
+ return found, rejected
158
+
159
+
160
+ def _single_addon_uid(uids: list[str], url_path: str, where: str) -> str | None:
161
+ """Exactly one candidate can be trusted; several are a refusal, not a choice."""
162
+
163
+ if not uids:
164
+ return None
165
+ if len(uids) == 1:
166
+ return uids[0]
167
+ raise ValidationError(
168
+ tr(
169
+ "{value_0} has {value_1} addons mounted at {value_2} ({value_3}), so the right one cannot be chosen automatically. Pass --addon-uid; writing to the wrong addon would put GitHub attachments into unrelated addon data.",
170
+ value_0=where,
171
+ value_1=len(uids),
172
+ value_2=url_path,
173
+ value_3=", ".join(uids),
174
+ )
175
+ )
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class AddonResolution:
180
+ """What the lookup established about the card's addon."""
181
+
182
+ uid: str | None
183
+ # Only a matching registration confirms a UID. A card's filtered addon
184
+ # listing cannot establish that no readable data exists under another UID.
185
+ authoritative: bool
186
+
187
+
188
+ def _card_board_spaces(card: dict[str, Any]) -> list[dict[str, Any]] | None:
189
+ """The board's spaces embedded in a card response, or None when absent.
190
+
191
+ The server filters addon registrations by card-read access in each space.
192
+ Missing embedded registrations do not prove that addon data is unreadable:
193
+ the addons-data endpoint only requires card access through any one space.
194
+ """
195
+
196
+ board = card.get("board")
197
+ spaces = board.get("spaces") if isinstance(board, dict) else None
198
+ if not isinstance(spaces, list):
199
+ return None
200
+ usable = [space for space in spaces if isinstance(space, dict)]
201
+ # A board always belongs to at least one space, so an empty listing means the
202
+ # server did not populate it rather than "the board has no spaces".
203
+ return usable or None
204
+
205
+
206
+ def _uids_from_card(spaces: list[dict[str, Any]], normalized_path: str) -> tuple[list[str], int]:
207
+ pooled: list[str] = []
208
+ rejected = 0
209
+ for space in spaces:
210
+ uids, space_rejected = _addon_uids_in(space.get("addons"), normalized_path)
211
+ rejected += space_rejected
212
+ for uid in uids:
213
+ if uid not in pooled:
214
+ pooled.append(uid)
215
+ return pooled, rejected
216
+
217
+
218
+ async def resolve_addon_uid(
219
+ client, card_id: Any, url_path: str, timeout: float, reporter
220
+ ) -> AddonResolution:
221
+ """Establish which addon registration at `url_path` this card may use.
222
+
223
+ One card read can identify a registration through `board.spaces[].addons`.
224
+ That listing is filtered by card-read access in each space, while reading
225
+ addon data only requires access to the card through any one space. An addon
226
+ installed in another, inaccessible space may therefore have readable data
227
+ even though its registration is omitted from the card response.
228
+
229
+ There is deliberately no reconstruction from `GET /spaces`: that listing is
230
+ filtered by space read access, so it can neither prove that a board's spaces
231
+ are all visible nor that a single candidate found in them is the only one. A
232
+ guess built on it could send a write into a different addon's data.
233
+ """
234
+
235
+ normalized = mount_path_key(url_path)
236
+ try:
237
+ card = await client.get(f"/cards/{card_id}", timeout=timeout)
238
+ except (ApiError, TransportError) as error:
239
+ if reporter:
240
+ reporter(
241
+ tr(
242
+ "addon lookup: /cards/{value_0} unavailable ({value_1})",
243
+ value_0=card_id,
244
+ value_1=error,
245
+ )
246
+ )
247
+ return AddonResolution(None, authoritative=False)
248
+ if not isinstance(card, dict) or not isinstance(card.get("board"), dict):
249
+ # Without the board object we cannot tell "no addons" from "no answer".
250
+ return AddonResolution(None, authoritative=False)
251
+
252
+ embedded = _card_board_spaces(card)
253
+ if embedded is None:
254
+ if reporter:
255
+ reporter(
256
+ tr("addon lookup: card response has no registrations; addon UID is unresolved")
257
+ )
258
+ return AddonResolution(None, authoritative=False)
259
+
260
+ pooled, rejected = _uids_from_card(embedded, normalized)
261
+ uid = _single_addon_uid(pooled, url_path, "This card's board")
262
+ if reporter:
263
+ reporter(
264
+ tr(
265
+ "addon lookup: card reported {value_0} usable and {value_1} unusable addon(s) at {value_2}",
266
+ value_0=len(pooled),
267
+ value_1=rejected,
268
+ value_2=url_path,
269
+ )
270
+ )
271
+ # No match in this filtered listing does not prove that no attachments exist.
272
+ return AddonResolution(uid, authoritative=uid is not None)
273
+
274
+
275
+ def shared_row(rows: Any) -> dict[str, Any] | None:
276
+ """The shared row (`user_uid: null`) out of an addons-data row list."""
277
+
278
+ if not isinstance(rows, list):
279
+ return None
280
+ for row in rows:
281
+ if isinstance(row, dict) and row.get("user_uid") is None:
282
+ return row
283
+ return None
284
+
285
+
286
+ UNSET = object()
287
+
288
+
289
+ def shared_data(rows: Any) -> Any:
290
+ """The whole `data` container of the shared row, or UNSET when there is no row.
291
+
292
+ Kept separate from `stored_value` because "there is no container" and "the
293
+ container is something we do not recognise" are different facts: only the
294
+ first one is a safe empty start for a write.
295
+ """
296
+
297
+ row = shared_row(rows)
298
+ if not isinstance(row, dict):
299
+ return UNSET
300
+ return row.get("data", UNSET)
301
+
302
+
303
+ def stored_value(rows: Any, key: str) -> Any:
304
+ """Raw value stored under `key` in the shared row, or UNSET when absent.
305
+
306
+ Callers that are about to write must look at the raw value: a read-modify-write
307
+ that silently normalizes it would persist the normalization and drop whatever
308
+ it did not understand.
309
+ """
310
+
311
+ data = shared_data(rows)
312
+ if not isinstance(data, dict) or key not in data:
313
+ return UNSET
314
+ return data[key]
315
+
316
+
317
+ def attached_items(rows: Any, key: str) -> list[dict[str, Any]]:
318
+ """Items stored under `key` in the shared row; [] for a missing/malformed blob.
319
+
320
+ Tolerant on purpose - this is the read path. The write path uses
321
+ `mutable_attached_items`, which refuses to normalize anything.
322
+ """
323
+
324
+ value = stored_value(rows, key)
325
+ if not isinstance(value, list):
326
+ return []
327
+ return [item for item in value if isinstance(item, dict)]
328
+
329
+
330
+ def mutable_attached_items(rows: Any, key: str) -> list[dict[str, Any]]:
331
+ """The stored list, or a hard failure when its shape is not what we can rewrite.
332
+
333
+ A write replaces the whole key, so anything the tolerant reader would have
334
+ dropped - a null, a legacy string marker, a value that is not a list at all -
335
+ would be destroyed by the rewrite. Refuse instead, and let the caller decide.
336
+ """
337
+
338
+ data = shared_data(rows)
339
+ if data is not UNSET and data is not None and not isinstance(data, dict):
340
+ # The row exists but its container is not an object. A write would replace
341
+ # it with one, so whatever is stored there would be gone.
342
+ raise ValidationError(
343
+ tr(
344
+ "Addon data for this card holds {value_0}, not an object, so this command cannot rewrite it without losing data. Inspect it with card-addon-data get and fix it with card-addon-data set.",
345
+ value_0=type(data).__name__,
346
+ )
347
+ )
348
+
349
+ value = stored_value(rows, key)
350
+ if value is UNSET or value is None:
351
+ # No key yet, or the addon cleared it: both mean "start from empty", which
352
+ # is exactly what the addon UI writes when the last entry is removed.
353
+ return []
354
+ if not isinstance(value, list):
355
+ raise ValidationError(
356
+ tr(
357
+ "Addon key {value_0} holds {value_1}, not a list, so this command cannot rewrite it without losing data. Inspect it with card-addon-data get and fix it with card-addon-data set.",
358
+ value_0=key,
359
+ value_1=type(value).__name__,
360
+ )
361
+ )
362
+ unexpected = [index for index, item in enumerate(value) if not isinstance(item, dict)]
363
+ if unexpected:
364
+ positions = ", ".join(str(index) for index in unexpected[:5])
365
+ raise ValidationError(
366
+ tr(
367
+ "Addon key {value_0} has non-object entries at position(s) {value_1}; rewriting the list would drop them. Inspect it with card-addon-data get and fix it with card-addon-data set.",
368
+ value_0=key,
369
+ value_1=positions,
370
+ )
371
+ )
372
+ return list(value)
373
+
374
+
375
+ def _require_object(value: Any, field: str) -> dict[str, Any]:
376
+ if not isinstance(value, dict):
377
+ raise ValidationError(
378
+ tr("Field {value_0} must be a JSON object with the GitHub REST payload.", value_0=field)
379
+ )
380
+ return value
381
+
382
+
383
+ def _require_int(value: Any, field: str) -> int:
384
+ if isinstance(value, bool) or not isinstance(value, int):
385
+ raise ValidationError(tr("Field {value_0} must be an integer.", value_0=field))
386
+ return value
387
+
388
+
389
+ def _require_text(value: Any, field: str) -> str:
390
+ text = value.strip() if isinstance(value, str) else ""
391
+ if not text:
392
+ raise ValidationError(tr("Field {value_0} must be a non-empty string.", value_0=field))
393
+ return text
394
+
395
+
396
+ def _optional_text(value: Any) -> str | None:
397
+ """A blank option (an unset shell variable) is absence, not a value."""
398
+
399
+ text = value.strip() if isinstance(value, str) else ""
400
+ return text or None
401
+
402
+
403
+ def _author(user: Any) -> dict[str, Any]:
404
+ user = user if isinstance(user, dict) else {}
405
+ return {
406
+ "login": user.get("login") or UNKNOWN_AUTHOR,
407
+ "htmlUrl": user.get("html_url") or FALLBACK_AUTHOR_URL,
408
+ "avatar": user.get("avatar_url") or FALLBACK_AVATAR_URL,
409
+ }
410
+
411
+
412
+ def _nested(source: Any, *path: str) -> Any:
413
+ current = source
414
+ for segment in path:
415
+ if not isinstance(current, dict):
416
+ return None
417
+ current = current.get(segment)
418
+ return current
419
+
420
+
421
+ def map_rest_pull(rest: Any, payload: dict[str, Any]) -> dict[str, Any]:
422
+ """Map a GitHub REST pull object to the addon's `attachedPulls` entry shape."""
423
+
424
+ pull = _require_object(rest, "pull_json")
425
+ # The card widget re-reads every attached PR by (repoOwner, repoName, number),
426
+ # so the repository is identity, not decoration: a placeholder here would
427
+ # store an entry that can never resolve on GitHub again. A trimmed payload
428
+ # (gh pr view --json ...) may omit base.repo, hence the explicit fallback.
429
+ owner = _nested(pull, "base", "repo", "owner", "login") or _optional_text(payload.get("owner"))
430
+ repo = _nested(pull, "base", "repo", "name") or _optional_text(payload.get("repo"))
431
+ if not owner or not repo:
432
+ raise ValidationError(
433
+ tr(
434
+ "Field pull_json has no base.repo.owner.login / base.repo.name; pass the full GitHub REST pull object (gh api repos/OWNER/REPO/pulls/NUMBER) or give --owner and --repo explicitly."
435
+ )
436
+ )
437
+ return {
438
+ "id": _require_int(pull.get("id"), "pull_json.id"),
439
+ "number": _require_int(pull.get("number"), "pull_json.number"),
440
+ # The addon always stores a real link: its own DTO reads html_url from a
441
+ # full REST response, and the widget falls back to the stored link when
442
+ # GitHub is unreachable.
443
+ "htmlUrl": _require_text(pull.get("html_url"), "pull_json.html_url"),
444
+ "state": pull.get("state"),
445
+ "title": pull.get("title"),
446
+ "body": pull.get("body"),
447
+ "createdAt": pull.get("created_at"),
448
+ "author": _author(pull.get("user")),
449
+ "baseBranch": _nested(pull, "base", "ref") or NO_DATA,
450
+ "headBranch": _nested(pull, "head", "ref") or NO_DATA,
451
+ "repoName": repo,
452
+ "repoOwner": owner,
453
+ }
454
+
455
+
456
+ def map_rest_branch(rest: Any, payload: dict[str, Any]) -> dict[str, Any]:
457
+ """Map a GitHub REST branch object to the addon's `attachedBranches` entry shape."""
458
+
459
+ branch = _require_object(rest, "branch_json")
460
+ owner = _require_text(payload.get("owner"), "owner")
461
+ repo = _require_text(payload.get("repo"), "repo")
462
+ name = _require_text(branch.get("name"), "branch_json.name")
463
+ return {
464
+ "branchName": name,
465
+ "htmlUrl": f"https://github.com/{owner}/{repo}/tree/{name}",
466
+ "commitUrl": _nested(branch, "commit", "url"),
467
+ "repo": repo,
468
+ "owner": owner,
469
+ "pseudoId": f"{owner}/{repo}/{name}",
470
+ }
471
+
472
+
473
+ def map_rest_commit(rest: Any, payload: dict[str, Any]) -> dict[str, Any]:
474
+ """Map a GitHub REST commit object to the addon's `attachedCommits` entry shape."""
475
+
476
+ commit = _require_object(rest, "commit_json")
477
+ owner = _require_text(payload.get("owner"), "owner")
478
+ repo = _require_text(payload.get("repo"), "repo")
479
+ # The commit author can come from the linked GitHub account or, for an
480
+ # unlinked email, only from the raw git metadata; the addon prefers the
481
+ # account and falls back to the git name.
482
+ account = commit.get("author") if isinstance(commit.get("author"), dict) else {}
483
+ git_author = _nested(commit, "commit", "author") or {}
484
+ # The addon's own DTO reads commit.commit.message and commit.commit.author.date
485
+ # without guards, so an entry without them is one the addon could not produce.
486
+ return {
487
+ "sha": _require_text(commit.get("sha"), "commit_json.sha"),
488
+ "htmlUrl": _require_text(commit.get("html_url"), "commit_json.html_url"),
489
+ "message": _require_text(
490
+ _nested(commit, "commit", "message"), "commit_json.commit.message"
491
+ ),
492
+ "date": _require_text(
493
+ git_author.get("date") if isinstance(git_author, dict) else None,
494
+ "commit_json.commit.author.date",
495
+ ),
496
+ "author": {
497
+ "login": account.get("login")
498
+ or (git_author.get("name") if isinstance(git_author, dict) else None)
499
+ or UNKNOWN_AUTHOR,
500
+ "htmlUrl": account.get("html_url") or FALLBACK_AUTHOR_URL,
501
+ "avatar": account.get("avatar_url") or FALLBACK_AVATAR_URL,
502
+ },
503
+ "repo": repo,
504
+ "owner": owner,
505
+ }
506
+
507
+
508
+ def map_rest_issue(rest: Any, payload: dict[str, Any]) -> dict[str, Any]:
509
+ """Map a GitHub REST issue object to the addon's `attachedIssues` entry shape."""
510
+
511
+ issue = _require_object(rest, "issue_json")
512
+ if issue.get("pull_request") is not None:
513
+ raise ValidationError(
514
+ tr(
515
+ "Field issue_json describes a pull request; attach it with github-addon pulls attach."
516
+ )
517
+ )
518
+ owner = _require_text(payload.get("owner"), "owner")
519
+ repo = _require_text(payload.get("repo"), "repo")
520
+ return {
521
+ "id": _require_int(issue.get("id"), "issue_json.id"),
522
+ "number": _require_int(issue.get("number"), "issue_json.number"),
523
+ "title": issue.get("title"),
524
+ "state": issue.get("state"),
525
+ "createdAt": issue.get("created_at"),
526
+ "htmlUrl": _require_text(issue.get("html_url"), "issue_json.html_url"),
527
+ # Issues store the author under `user`, pulls under `author`; both shapes
528
+ # come straight from the addon's own DTO creators.
529
+ "user": _author(issue.get("user")),
530
+ "repoName": repo,
531
+ "repoOwner": owner,
532
+ }
533
+
534
+
535
+ def _fold(value: Any) -> Any:
536
+ """Case-fold a GitHub identifier. Owners, repositories and shas are
537
+ case-insensitive on GitHub; branch names are not and stay untouched."""
538
+
539
+ return value.casefold() if isinstance(value, str) else value
540
+
541
+
542
+ # Attach dedup keys, one per store, matching what the addon UI compares on.
543
+ def _pull_identity(item: dict[str, Any]) -> Any:
544
+ return item.get("id")
545
+
546
+
547
+ def _branch_identity(item: dict[str, Any]) -> Any:
548
+ owner, repo = item.get("owner"), item.get("repo")
549
+ if isinstance(owner, str) and isinstance(repo, str):
550
+ return (_fold(owner), _fold(repo), item.get("branchName"))
551
+ return item.get("pseudoId")
552
+
553
+
554
+ def _commit_identity(item: dict[str, Any]) -> Any:
555
+ return _fold(item.get("sha"))
556
+
557
+
558
+ def _issue_identity(item: dict[str, Any]) -> Any:
559
+ return item.get("id")
560
+
561
+
562
+ def _repo_matches(
563
+ item: dict[str, Any], payload: dict[str, Any], owner_key: str, repo_key: str
564
+ ) -> bool:
565
+ """Optional owner/repo narrowing shared by every detach selector."""
566
+
567
+ for expected, field in ((payload.get("owner"), owner_key), (payload.get("repo"), repo_key)):
568
+ if expected is not None and _fold(item.get(field)) != _fold(expected):
569
+ return False
570
+ return True
571
+
572
+
573
+ def _option(field: str) -> str:
574
+ return f"--{field.replace('_', '-')}"
575
+
576
+
577
+ def _require_any_selector(payload: dict[str, Any], fields: tuple[str, ...]) -> None:
578
+ if not any(payload.get(field) is not None for field in fields):
579
+ options = ", ".join(_option(field) for field in fields)
580
+ raise ValidationError(tr("Provide at least one selector: {value_0}.", value_0=options))
581
+ # An empty string reaches here from an unset shell variable. Treated as a
582
+ # filter it would silently match nothing, so reject it instead.
583
+ for field in (*fields, "owner", "repo"):
584
+ value = payload.get(field)
585
+ if isinstance(value, str) and not value.strip():
586
+ raise ValidationError(tr("Field {value_0} must not be empty.", value_0=_option(field)))
587
+
588
+
589
+ def _pull_matches(item: dict[str, Any], payload: dict[str, Any]) -> bool:
590
+ if payload.get("pull_id") is not None and item.get("id") != payload["pull_id"]:
591
+ return False
592
+ if payload.get("number") is not None and item.get("number") != payload["number"]:
593
+ return False
594
+ return _repo_matches(item, payload, "repoOwner", "repoName")
595
+
596
+
597
+ def _branch_matches(item: dict[str, Any], payload: dict[str, Any]) -> bool:
598
+ if payload.get("pseudo_id") is not None and item.get("pseudoId") != payload["pseudo_id"]:
599
+ return False
600
+ if payload.get("branch_name") is not None and item.get("branchName") != payload["branch_name"]:
601
+ return False
602
+ return _repo_matches(item, payload, "owner", "repo")
603
+
604
+
605
+ def _commit_matches(item: dict[str, Any], payload: dict[str, Any]) -> bool:
606
+ if payload.get("sha") is not None and _fold(item.get("sha")) != _fold(payload["sha"]):
607
+ return False
608
+ return _repo_matches(item, payload, "owner", "repo")
609
+
610
+
611
+ def _issue_matches(item: dict[str, Any], payload: dict[str, Any]) -> bool:
612
+ if payload.get("issue_id") is not None and item.get("id") != payload["issue_id"]:
613
+ return False
614
+ if payload.get("number") is not None and item.get("number") != payload["number"]:
615
+ return False
616
+ return _repo_matches(item, payload, "repoOwner", "repoName")
617
+
618
+
619
+ @dataclass(frozen=True, slots=True)
620
+ class GithubEntity:
621
+ """Everything that differs between the four GitHub-addon attachment stores."""
622
+
623
+ name: str
624
+ key: str
625
+ payload_field: str
626
+ mapper: Callable[[Any, dict[str, Any]], dict[str, Any]]
627
+ identity: Callable[[dict[str, Any]], Any]
628
+ matches: Callable[[dict[str, Any], dict[str, Any]], bool]
629
+ selectors: tuple[str, ...]
630
+ # The addon UI prepends new branches and appends everything else; the stored
631
+ # order is what the card widget renders, so it is mirrored here.
632
+ prepend: bool = False
633
+
634
+
635
+ PULLS = GithubEntity(
636
+ name="pulls",
637
+ key=ATTACHED_PULLS_KEY,
638
+ payload_field="pull_json",
639
+ mapper=map_rest_pull,
640
+ identity=_pull_identity,
641
+ matches=_pull_matches,
642
+ selectors=("pull_id", "number"),
643
+ )
644
+ BRANCHES = GithubEntity(
645
+ name="branches",
646
+ key=ATTACHED_BRANCHES_KEY,
647
+ payload_field="branch_json",
648
+ mapper=map_rest_branch,
649
+ identity=_branch_identity,
650
+ matches=_branch_matches,
651
+ selectors=("pseudo_id", "branch_name"),
652
+ prepend=True,
653
+ )
654
+ COMMITS = GithubEntity(
655
+ name="commits",
656
+ key=ATTACHED_COMMITS_KEY,
657
+ payload_field="commit_json",
658
+ mapper=map_rest_commit,
659
+ identity=_commit_identity,
660
+ matches=_commit_matches,
661
+ selectors=("sha",),
662
+ )
663
+ ISSUES = GithubEntity(
664
+ name="issues",
665
+ key=ATTACHED_ISSUES_KEY,
666
+ payload_field="issue_json",
667
+ mapper=map_rest_issue,
668
+ identity=_issue_identity,
669
+ matches=_issue_matches,
670
+ selectors=("issue_id", "number"),
671
+ )
672
+
673
+
674
+ def _describe_item(entity: GithubEntity, item: dict[str, Any]) -> str:
675
+ """Short human-readable identity of one attachment, for error messages."""
676
+
677
+ if entity is BRANCHES:
678
+ return str(item.get("pseudoId") or item.get("branchName"))
679
+ if entity is COMMITS:
680
+ return f"{item.get('owner')}/{item.get('repo')}@{item.get('sha')}"
681
+ owner = item.get("repoOwner")
682
+ repo = item.get("repoName")
683
+ return f"{owner}/{repo}#{item.get('number')}"
684
+
685
+
686
+ def _addon_data_path(path: str, addon_uid: str) -> str:
687
+ return f"{path.rstrip('/')}/{addon_uid}"
688
+
689
+
690
+ @dataclass(frozen=True, slots=True)
691
+ class AttachedState:
692
+ """What one addons-data read found, and under which UID."""
693
+
694
+ addon_uid: str
695
+ row_found: bool
696
+ # True only when the UID is known to be the right one: it was given
697
+ # explicitly, or an addon registration was actually found for the card's
698
+ # board. "The space I could see has no such addon" is NOT a confirmation -
699
+ # the board may live in a space this user cannot read.
700
+ uid_confirmed: bool
701
+ items: list[dict[str, Any]]
702
+
703
+
704
+ async def _read_attached(
705
+ client,
706
+ payload: dict[str, Any],
707
+ path: str,
708
+ entity: GithubEntity,
709
+ timeout: float,
710
+ reporter,
711
+ *,
712
+ for_write: bool = False,
713
+ ) -> AttachedState:
714
+ """Read the shared attachments, re-resolving the addon UID if a guess missed.
715
+
716
+ Without an explicit `--addon-uid` the UID is derived from the mount path,
717
+ which only matches on-premises installations. An empty read is therefore
718
+ ambiguous: no attachments, or the wrong addon entirely. Ask which addon the
719
+ card's board actually has before believing the empty answer.
720
+
721
+ `for_write` switches the item parsing from tolerant to strict, because the
722
+ caller is about to rewrite the whole key.
723
+ """
724
+
725
+ explicit = explicit_addon_uid(payload)
726
+ url_path = addon_url_path(payload)
727
+ addon_uid = explicit or generate_addon_uid(url_path)
728
+ rows = await client.get(_addon_data_path(path, addon_uid), timeout=timeout)
729
+ confirmed = explicit is not None
730
+
731
+ if shared_row(rows) is None and explicit is None:
732
+ resolution = await resolve_addon_uid(
733
+ client, payload["card_id"], url_path, timeout, reporter
734
+ )
735
+ confirmed = resolution.authoritative
736
+ if for_write and resolution.uid is None:
737
+ # An unconfirmed UID must never be written to. The server can accept
738
+ # a PATCH for any addon the card may use, so a wrong guess does not
739
+ # bounce - it lands in another addon's data.
740
+ raise ValidationError(
741
+ tr(
742
+ "Cannot establish which addon at {value_0} this card uses, so there is nothing safe to write to: its registration could not be confirmed from the card response. Pass --addon-uid (see space-addons.list or company-addons.list) and retry.",
743
+ value_0=url_path,
744
+ )
745
+ )
746
+ if resolution.uid is not None and resolution.uid != addon_uid:
747
+ if reporter:
748
+ reporter(
749
+ tr(
750
+ "addon uid: derived {value_0} has no data, using registered {value_1}",
751
+ value_0=addon_uid,
752
+ value_1=resolution.uid,
753
+ )
754
+ )
755
+ addon_uid = resolution.uid
756
+ rows = await client.get(_addon_data_path(path, addon_uid), timeout=timeout)
757
+
758
+ read_items = mutable_attached_items if for_write else attached_items
759
+ return AttachedState(
760
+ addon_uid=addon_uid,
761
+ row_found=shared_row(rows) is not None,
762
+ uid_confirmed=confirmed,
763
+ items=read_items(rows, entity.key),
764
+ )
765
+
766
+
767
+ async def _write_attached(
768
+ client, path: str, addon_uid: str, key: str, items: list, timeout: float
769
+ ) -> Any:
770
+ # The addon UI clears a key by writing null rather than an empty array; keep
771
+ # the stored shape identical so the card widget renders the same way.
772
+ value: Any = items if items else None
773
+ return await client.patch(
774
+ _addon_data_path(path, addon_uid),
775
+ json={"type": SHARED_SCOPE, "data": {key: value}},
776
+ timeout=timeout,
777
+ )
778
+
779
+
780
+ def _envelope(
781
+ payload: dict[str, Any], state: AttachedState, entity: GithubEntity
782
+ ) -> dict[str, Any]:
783
+ return {
784
+ "card_id": payload["card_id"],
785
+ "addon_uid": state.addon_uid,
786
+ # False means the card has no data row for this addon at all, which also
787
+ # covers "the addon is not installed here" - useful when an empty list
788
+ # would otherwise read as "nothing is attached".
789
+ "addon_data_row_found": state.row_found,
790
+ "addon_uid_confirmed": state.uid_confirmed,
791
+ "key": entity.key,
792
+ "entity": entity.name,
793
+ }
794
+
795
+
796
+ def _make_list_executor(entity: GithubEntity):
797
+ async def execute(client, tool, payload, path, query, body, timeout, reporter):
798
+ if reporter:
799
+ reporter(tr("execution: read shared addon key {value_0}", value_0=entity.key))
800
+ state = await _read_attached(client, payload, path, entity, timeout, reporter)
801
+ if not state.row_found and not state.uid_confirmed:
802
+ # An empty list here would be indistinguishable from "we read the
803
+ # wrong addon". The card response may omit a registration whose data
804
+ # is still readable, so refuse to report that nothing is attached.
805
+ raise ValidationError(
806
+ tr(
807
+ "Cannot establish whether {value_0} is this card's GitHub addon: the UUID was derived from --addon-url-path, it holds no data, and the card response did not identify its registration. That response may omit an addon whose data is still readable. Pass --addon-uid (see space-addons.list or company-addons.list) and retry.",
808
+ value_0=state.addon_uid,
809
+ )
810
+ )
811
+ if reporter and not state.row_found:
812
+ reporter(
813
+ tr(
814
+ "addon data: no shared row under {value_0}; the card has no attachments or the addon is not installed here",
815
+ value_0=state.addon_uid,
816
+ )
817
+ )
818
+ return state.items
819
+
820
+ execute.__name__ = f"execute_github_addon_{entity.name}_list"
821
+ return execute
822
+
823
+
824
+ def _make_attach_executor(entity: GithubEntity):
825
+ async def execute(client, tool, payload, path, query, body, timeout, reporter):
826
+ item = entity.mapper(payload.get(entity.payload_field), payload)
827
+ if reporter:
828
+ reporter(
829
+ tr("execution: read-modify-write of shared addon key {value_0}", value_0=entity.key)
830
+ )
831
+ state = await _read_attached(
832
+ client, payload, path, entity, timeout, reporter, for_write=True
833
+ )
834
+ addon_uid, existing = state.addon_uid, state.items
835
+ result = _envelope(payload, state, entity)
836
+ result["action"] = "attach"
837
+ result["item"] = item
838
+
839
+ identity = entity.identity(item)
840
+ if any(entity.identity(current) == identity for current in existing):
841
+ result["status"] = "already_attached"
842
+ result["dry_run"] = bool(payload.get("dry_run", False))
843
+ result["attached_count"] = len(existing)
844
+ return result
845
+
846
+ updated = [item, *existing] if entity.prepend else [*existing, item]
847
+ result["attached_count"] = len(updated)
848
+ if payload.get("dry_run", False):
849
+ result["status"] = "would_attach"
850
+ result["dry_run"] = True
851
+ return result
852
+
853
+ await _write_attached(client, path, addon_uid, entity.key, updated, timeout)
854
+ result["status"] = "attached"
855
+ result["dry_run"] = False
856
+ return result
857
+
858
+ execute.__name__ = f"execute_github_addon_{entity.name}_attach"
859
+ return execute
860
+
861
+
862
+ def _make_detach_executor(entity: GithubEntity):
863
+ async def execute(client, tool, payload, path, query, body, timeout, reporter):
864
+ _require_any_selector(payload, entity.selectors)
865
+ if reporter:
866
+ reporter(
867
+ tr("execution: read-modify-write of shared addon key {value_0}", value_0=entity.key)
868
+ )
869
+ state = await _read_attached(
870
+ client, payload, path, entity, timeout, reporter, for_write=True
871
+ )
872
+ addon_uid, existing = state.addon_uid, state.items
873
+ selected = [entity.matches(item, payload) for item in existing]
874
+ removed = [item for item, hit in zip(existing, selected) if hit]
875
+ kept = [item for item, hit in zip(existing, selected) if not hit]
876
+
877
+ result = _envelope(payload, state, entity)
878
+ result["action"] = "detach"
879
+ result["removed"] = removed
880
+
881
+ if not removed:
882
+ # Nothing selected: skip the write entirely instead of rewriting the
883
+ # same list, so a mistyped selector cannot touch the shared row.
884
+ result["status"] = "not_found"
885
+ result["attached_count"] = len(existing)
886
+ result["dry_run"] = bool(payload.get("dry_run", False))
887
+ return result
888
+
889
+ # A number or a branch name is only unique inside one repository, so a
890
+ # selector that hits several attachments is ambiguous rather than a
891
+ # request to remove them all. Removing the extra ones has to be asked for.
892
+ if len(removed) > 1 and not payload.get("all", False):
893
+ raise ValidationError(
894
+ tr(
895
+ "Selector matches {count} attachments: {items}. Narrow it with --owner and --repo, or pass --all to remove every match.",
896
+ count=len(removed),
897
+ items=", ".join(_describe_item(entity, item) for item in removed),
898
+ )
899
+ )
900
+
901
+ result["attached_count"] = len(kept)
902
+
903
+ if payload.get("dry_run", False):
904
+ result["status"] = "would_detach"
905
+ result["dry_run"] = True
906
+ return result
907
+
908
+ await _write_attached(client, path, addon_uid, entity.key, kept, timeout)
909
+ result["status"] = "detached"
910
+ result["dry_run"] = False
911
+ return result
912
+
913
+ execute.__name__ = f"execute_github_addon_{entity.name}_detach"
914
+ return execute
915
+
916
+
917
+ execute_github_pulls_list = _make_list_executor(PULLS)
918
+ execute_github_pulls_attach = _make_attach_executor(PULLS)
919
+ execute_github_pulls_detach = _make_detach_executor(PULLS)
920
+
921
+ execute_github_branches_list = _make_list_executor(BRANCHES)
922
+ execute_github_branches_attach = _make_attach_executor(BRANCHES)
923
+ execute_github_branches_detach = _make_detach_executor(BRANCHES)
924
+
925
+ execute_github_commits_list = _make_list_executor(COMMITS)
926
+ execute_github_commits_attach = _make_attach_executor(COMMITS)
927
+ execute_github_commits_detach = _make_detach_executor(COMMITS)
928
+
929
+ execute_github_issues_list = _make_list_executor(ISSUES)
930
+ execute_github_issues_attach = _make_attach_executor(ISSUES)
931
+ execute_github_issues_detach = _make_detach_executor(ISSUES)
932
+
933
+
934
+ async def execute_addon_uid(client, tool, payload, path, query, body, timeout, reporter):
935
+ """Local-only: derive an addon UID without calling Kaiten."""
936
+
937
+ url_path = payload["url_path"]
938
+ if not normalize_addon_url_path(url_path):
939
+ # companyAddonsController derives a UID only for a non-empty path and
940
+ # leaves a random one otherwise, so a UUID for "/" would be a value the
941
+ # platform never assigns.
942
+ raise ValidationError(
943
+ tr(
944
+ "Field url_path must contain a path segment: Kaiten does not derive a UUID for an addon mounted at the root, it keeps the random one. Read the real value from space-addons.list or company-addons.list."
945
+ )
946
+ )
947
+ if reporter:
948
+ reporter(tr("execution: local UUID v5 derivation, no API call"))
949
+ return {
950
+ "url_path": url_path,
951
+ "normalized_url_path": normalize_addon_url_path(url_path),
952
+ "addon_uid": generate_addon_uid(url_path),
953
+ }