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