ktr-cli 0.1.0__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.
- kantree_cli/__init__.py +3 -0
- kantree_cli/cli.py +7922 -0
- kantree_cli/core/__init__.py +1 -0
- kantree_cli/core/client.py +230 -0
- kantree_cli/core/config.py +226 -0
- kantree_cli/core/context.py +141 -0
- kantree_cli/core/errors.py +32 -0
- kantree_cli/core/output.py +64 -0
- kantree_cli/core/response.py +86 -0
- kantree_cli/services/__init__.py +1 -0
- kantree_cli/services/cards.py +726 -0
- kantree_cli/services/importers.py +304 -0
- kantree_cli/services/kql.py +43 -0
- kantree_cli/services/resolver.py +236 -0
- kantree_cli/services/search.py +67 -0
- kantree_cli/services/views.py +124 -0
- kantree_cli/services/webhooks.py +120 -0
- kantree_cli/services/workspaces.py +1300 -0
- ktr_cli-0.1.0.dist-info/METADATA +173 -0
- ktr_cli-0.1.0.dist-info/RECORD +22 -0
- ktr_cli-0.1.0.dist-info/WHEEL +4 -0
- ktr_cli-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from kantree_cli.core.client import KantreeClient
|
|
6
|
+
from kantree_cli.core.errors import ValidationError
|
|
7
|
+
|
|
8
|
+
JsonObject = dict[str, Any]
|
|
9
|
+
|
|
10
|
+
_SUPPORTED_SPREADSHEET_EXTENSIONS = {".csv", ".xlsx", ".ods"}
|
|
11
|
+
|
|
12
|
+
# Assumption: these field names match the spreadsheet-import REST API payload.
|
|
13
|
+
# They are centralized here so we can adjust quickly if endpoint verification differs.
|
|
14
|
+
_IMPORT_PAYLOAD_PROJECT_KEY = "project_id"
|
|
15
|
+
_IMPORT_PAYLOAD_DISCOVER_FILE_ID_KEY = "discover_file_id"
|
|
16
|
+
_IMPORT_PAYLOAD_MAPPING_KEY = "mapping"
|
|
17
|
+
_IMPORT_PAYLOAD_UPDATE_FIELD_KEY = "update_field"
|
|
18
|
+
_IMPORT_PAYLOAD_UPDATE_ONLY_KEY = "update_only"
|
|
19
|
+
_IMPORT_PAYLOAD_OPTIONS_KEY = "options"
|
|
20
|
+
_IMPORT_PAYLOAD_IGNORE_HEADER_KEY = "ignore_header"
|
|
21
|
+
_IMPORT_PAYLOAD_SUPPRESS_ACTIVITIES_KEY = "suppress_activities"
|
|
22
|
+
_TEMPLATE_WRAPPER_KEYS = {"discover_file_id", "source_columns", "fields"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def discover_spreadsheet_import(
|
|
26
|
+
client: KantreeClient,
|
|
27
|
+
*,
|
|
28
|
+
spreadsheet_path: Path,
|
|
29
|
+
) -> Any:
|
|
30
|
+
normalized_path = _validate_spreadsheet_path(spreadsheet_path)
|
|
31
|
+
mime_type = _guess_spreadsheet_mime_type(normalized_path)
|
|
32
|
+
with normalized_path.open("rb") as stream:
|
|
33
|
+
response = client.request(
|
|
34
|
+
method="POST",
|
|
35
|
+
path="/importers/spreadsheet/discover",
|
|
36
|
+
files={"file": (normalized_path.name, stream, mime_type)},
|
|
37
|
+
)
|
|
38
|
+
return response.data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply_spreadsheet_import(
|
|
42
|
+
client: KantreeClient,
|
|
43
|
+
*,
|
|
44
|
+
workspace_id: int,
|
|
45
|
+
discover_file_id: str,
|
|
46
|
+
mapping: Any,
|
|
47
|
+
update_field: str | None,
|
|
48
|
+
update_only: bool,
|
|
49
|
+
ignore_header: bool,
|
|
50
|
+
suppress_activities: bool,
|
|
51
|
+
) -> Any:
|
|
52
|
+
payload = _build_spreadsheet_import_payload(
|
|
53
|
+
workspace_id=workspace_id,
|
|
54
|
+
discover_file_id=discover_file_id,
|
|
55
|
+
mapping=mapping,
|
|
56
|
+
update_field=update_field,
|
|
57
|
+
update_only=update_only,
|
|
58
|
+
ignore_header=ignore_header,
|
|
59
|
+
suppress_activities=suppress_activities,
|
|
60
|
+
)
|
|
61
|
+
response = client.request(
|
|
62
|
+
method="POST",
|
|
63
|
+
path="/importers/spreadsheet/import",
|
|
64
|
+
json_body=payload,
|
|
65
|
+
)
|
|
66
|
+
return response.data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_template_from_discovery(discovery_payload: Any) -> JsonObject:
|
|
70
|
+
source_fields = _extract_discovery_fields(discovery_payload)
|
|
71
|
+
mapping: JsonObject = {}
|
|
72
|
+
for index, (field_key, field_name) in enumerate(source_fields):
|
|
73
|
+
if index == 0:
|
|
74
|
+
mapping[field_key] = {"type": "title"}
|
|
75
|
+
continue
|
|
76
|
+
mapping[field_key] = {
|
|
77
|
+
"type": "attribute",
|
|
78
|
+
"attr_type": "text",
|
|
79
|
+
"name": field_name,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"discover_file_id": _extract_discovery_file_id(discovery_payload),
|
|
84
|
+
"source_columns": [field_name for _, field_name in source_fields],
|
|
85
|
+
"fields": [[field_key, field_name] for field_key, field_name in source_fields],
|
|
86
|
+
"mapping": mapping,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalize_spreadsheet_import_mapping(mapping_payload: JsonObject) -> JsonObject:
|
|
91
|
+
if not (_TEMPLATE_WRAPPER_KEYS & mapping_payload.keys()):
|
|
92
|
+
return mapping_payload
|
|
93
|
+
|
|
94
|
+
nested_mapping = mapping_payload.get(_IMPORT_PAYLOAD_MAPPING_KEY)
|
|
95
|
+
if not isinstance(nested_mapping, dict):
|
|
96
|
+
raise ValidationError("`--mapping` template output must contain a JSON object `mapping`.")
|
|
97
|
+
return nested_mapping
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_spreadsheet_update_field(mapping: JsonObject, update_field: str | None) -> str | None:
|
|
101
|
+
if update_field is None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
normalized = update_field.strip()
|
|
105
|
+
if not normalized:
|
|
106
|
+
raise ValidationError("`--update-field` expects a non-empty value.")
|
|
107
|
+
if normalized in mapping:
|
|
108
|
+
return normalized
|
|
109
|
+
|
|
110
|
+
matches = [
|
|
111
|
+
key
|
|
112
|
+
for key, mapinfo in mapping.items()
|
|
113
|
+
if _mapping_entry_matches_update_field(mapinfo, normalized)
|
|
114
|
+
]
|
|
115
|
+
if len(matches) == 1:
|
|
116
|
+
return str(matches[0])
|
|
117
|
+
if len(matches) > 1:
|
|
118
|
+
raise ValidationError(
|
|
119
|
+
"`--update-field` matched multiple import mapping entries; use the column key instead."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
raise ValidationError(
|
|
123
|
+
"`--update-field` must match an import mapping column key or mapped field name."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _mapping_entry_matches_update_field(mapinfo: Any, update_field: str) -> bool:
|
|
128
|
+
if not isinstance(mapinfo, dict):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
normalized = update_field.lower()
|
|
132
|
+
if mapinfo.get("type") in {"title", "ref"} and normalized == mapinfo.get("type"):
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
for key in ("name", "title", "label", "source_column", "attribute", "attribute_name"):
|
|
136
|
+
raw_value = mapinfo.get(key)
|
|
137
|
+
if isinstance(raw_value, str) and raw_value.strip().lower() == normalized:
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_spreadsheet_import_payload(
|
|
143
|
+
*,
|
|
144
|
+
workspace_id: int,
|
|
145
|
+
discover_file_id: str,
|
|
146
|
+
mapping: Any,
|
|
147
|
+
update_field: str | None,
|
|
148
|
+
update_only: bool,
|
|
149
|
+
ignore_header: bool,
|
|
150
|
+
suppress_activities: bool,
|
|
151
|
+
) -> JsonObject:
|
|
152
|
+
payload: JsonObject = {
|
|
153
|
+
_IMPORT_PAYLOAD_PROJECT_KEY: workspace_id,
|
|
154
|
+
_IMPORT_PAYLOAD_DISCOVER_FILE_ID_KEY: discover_file_id,
|
|
155
|
+
_IMPORT_PAYLOAD_MAPPING_KEY: mapping,
|
|
156
|
+
}
|
|
157
|
+
if update_field is not None:
|
|
158
|
+
payload[_IMPORT_PAYLOAD_UPDATE_FIELD_KEY] = update_field
|
|
159
|
+
if update_only:
|
|
160
|
+
payload[_IMPORT_PAYLOAD_UPDATE_ONLY_KEY] = True
|
|
161
|
+
if ignore_header:
|
|
162
|
+
payload[_IMPORT_PAYLOAD_OPTIONS_KEY] = {_IMPORT_PAYLOAD_IGNORE_HEADER_KEY: True}
|
|
163
|
+
if suppress_activities:
|
|
164
|
+
payload[_IMPORT_PAYLOAD_SUPPRESS_ACTIVITIES_KEY] = True
|
|
165
|
+
return payload
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_spreadsheet_path(path: Path) -> Path:
|
|
169
|
+
resolved = path.expanduser()
|
|
170
|
+
if not resolved.exists():
|
|
171
|
+
raise ValidationError(f"Spreadsheet path does not exist: `{resolved}`")
|
|
172
|
+
if not resolved.is_file():
|
|
173
|
+
raise ValidationError(f"Spreadsheet path is not a file: `{resolved}`")
|
|
174
|
+
suffix = resolved.suffix.lower()
|
|
175
|
+
if suffix not in _SUPPORTED_SPREADSHEET_EXTENSIONS:
|
|
176
|
+
expected = ", ".join(sorted(_SUPPORTED_SPREADSHEET_EXTENSIONS))
|
|
177
|
+
raise ValidationError(
|
|
178
|
+
f"Unsupported spreadsheet format `{resolved.suffix}`. Expected one of: {expected}."
|
|
179
|
+
)
|
|
180
|
+
return resolved
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _guess_spreadsheet_mime_type(path: Path) -> str:
|
|
184
|
+
guessed, _ = mimetypes.guess_type(str(path))
|
|
185
|
+
if guessed:
|
|
186
|
+
return guessed
|
|
187
|
+
return "application/octet-stream"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_discovery_file_id(payload: Any) -> str | None:
|
|
191
|
+
candidates: list[Any] = []
|
|
192
|
+
if isinstance(payload, dict):
|
|
193
|
+
for key in ("discover_file_id", "file_id", "id"):
|
|
194
|
+
candidates.append(payload.get(key))
|
|
195
|
+
|
|
196
|
+
file_obj = payload.get("file")
|
|
197
|
+
if isinstance(file_obj, dict):
|
|
198
|
+
for key in ("discover_file_id", "file_id", "id"):
|
|
199
|
+
candidates.append(file_obj.get(key))
|
|
200
|
+
|
|
201
|
+
for candidate in candidates:
|
|
202
|
+
if candidate is None:
|
|
203
|
+
continue
|
|
204
|
+
if isinstance(candidate, str):
|
|
205
|
+
normalized = candidate.strip()
|
|
206
|
+
if normalized:
|
|
207
|
+
return normalized
|
|
208
|
+
continue
|
|
209
|
+
return str(candidate)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _extract_discovery_fields(payload: Any) -> list[tuple[str, str]]:
|
|
214
|
+
if not isinstance(payload, dict):
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
for key in ("fields", "columns", "headers"):
|
|
218
|
+
maybe_fields = payload.get(key)
|
|
219
|
+
parsed_fields = _normalize_discovery_fields(maybe_fields)
|
|
220
|
+
if parsed_fields:
|
|
221
|
+
return parsed_fields
|
|
222
|
+
|
|
223
|
+
file_obj = payload.get("file")
|
|
224
|
+
if isinstance(file_obj, dict):
|
|
225
|
+
for key in ("fields", "columns", "headers"):
|
|
226
|
+
maybe_fields = file_obj.get(key)
|
|
227
|
+
parsed_fields = _normalize_discovery_fields(maybe_fields)
|
|
228
|
+
if parsed_fields:
|
|
229
|
+
return parsed_fields
|
|
230
|
+
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _normalize_discovery_fields(raw_fields: Any) -> list[tuple[str, str]]:
|
|
235
|
+
if not isinstance(raw_fields, list):
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
normalized_fields: list[tuple[str, str]] = []
|
|
239
|
+
seen_keys: set[str] = set()
|
|
240
|
+
for index, field in enumerate(raw_fields):
|
|
241
|
+
normalized_field = _normalize_discovery_field(field, index=index)
|
|
242
|
+
if normalized_field is None:
|
|
243
|
+
continue
|
|
244
|
+
field_key, _ = normalized_field
|
|
245
|
+
if field_key in seen_keys:
|
|
246
|
+
continue
|
|
247
|
+
seen_keys.add(field_key)
|
|
248
|
+
normalized_fields.append(normalized_field)
|
|
249
|
+
return normalized_fields
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _normalize_discovery_field(value: Any, *, index: int) -> tuple[str, str] | None:
|
|
253
|
+
if isinstance(value, (list, tuple)):
|
|
254
|
+
if len(value) < 2:
|
|
255
|
+
return None
|
|
256
|
+
field_key = _normalize_field_key(value[0])
|
|
257
|
+
field_name = _normalize_field_name(value[1])
|
|
258
|
+
if field_key is None or field_name is None:
|
|
259
|
+
return None
|
|
260
|
+
return field_key, field_name
|
|
261
|
+
|
|
262
|
+
if isinstance(value, dict):
|
|
263
|
+
field_key = None
|
|
264
|
+
for key in ("id", "key", "index", "column"):
|
|
265
|
+
field_key = _normalize_field_key(value.get(key))
|
|
266
|
+
if field_key is not None:
|
|
267
|
+
break
|
|
268
|
+
if field_key is None:
|
|
269
|
+
field_key = str(index)
|
|
270
|
+
|
|
271
|
+
field_name = None
|
|
272
|
+
for key in ("name", "title", "label"):
|
|
273
|
+
field_name = _normalize_field_name(value.get(key))
|
|
274
|
+
if field_name is not None:
|
|
275
|
+
break
|
|
276
|
+
if field_name is None:
|
|
277
|
+
return None
|
|
278
|
+
return field_key, field_name
|
|
279
|
+
|
|
280
|
+
field_name = _normalize_field_name(value)
|
|
281
|
+
if field_name is None:
|
|
282
|
+
return None
|
|
283
|
+
return str(index), field_name
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _normalize_field_key(value: Any) -> str | None:
|
|
287
|
+
if isinstance(value, bool):
|
|
288
|
+
return None
|
|
289
|
+
if isinstance(value, int):
|
|
290
|
+
return str(value)
|
|
291
|
+
if isinstance(value, str):
|
|
292
|
+
normalized = value.strip()
|
|
293
|
+
if normalized:
|
|
294
|
+
return normalized
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _normalize_field_name(value: Any) -> str | None:
|
|
299
|
+
if not isinstance(value, str):
|
|
300
|
+
return None
|
|
301
|
+
normalized = value.strip()
|
|
302
|
+
if not normalized:
|
|
303
|
+
return None
|
|
304
|
+
return normalized
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from kantree_cli.core.client import KantreeClient
|
|
4
|
+
from kantree_cli.core.errors import ApiError
|
|
5
|
+
from kantree_cli.core.response import JsonObject, extract_object_list
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_kql(client: KantreeClient, *, kql_text: str) -> Any:
|
|
9
|
+
return _query_kql_endpoint(client, endpoint="/kql/validate", kql_text=kql_text)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_kql(client: KantreeClient, *, kql_text: str) -> Any:
|
|
13
|
+
return _query_kql_endpoint(client, endpoint="/kql/parse-filter", kql_text=kql_text)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def list_kql_functions(client: KantreeClient) -> list[JsonObject]:
|
|
17
|
+
payload = client.get_json("/kql/autocomplete/functions")
|
|
18
|
+
return extract_object_list(
|
|
19
|
+
payload,
|
|
20
|
+
context="`GET /kql/autocomplete/functions`",
|
|
21
|
+
preferred_keys=("functions", "data", "items", "results"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _query_kql_endpoint(client: KantreeClient, *, endpoint: str, kql_text: str) -> Any:
|
|
26
|
+
encoded = _quote_kql(kql_text)
|
|
27
|
+
try:
|
|
28
|
+
return client.get_json(f"{endpoint}?source={encoded}")
|
|
29
|
+
except ApiError as exc:
|
|
30
|
+
if not _is_q_required_error(exc):
|
|
31
|
+
raise
|
|
32
|
+
return client.get_json(f"{endpoint}?q={encoded}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_q_required_error(exc: ApiError) -> bool:
|
|
36
|
+
message = str(exc).lower()
|
|
37
|
+
return "q:" in message and "required field" in message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quote_kql(text: str) -> str:
|
|
41
|
+
import urllib.parse
|
|
42
|
+
|
|
43
|
+
return urllib.parse.quote(text)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from kantree_cli.core.client import KantreeClient
|
|
5
|
+
from kantree_cli.core.errors import AmbiguityError, NotFoundError, ValidationError
|
|
6
|
+
from kantree_cli.services.cards import card_id as card_identifier
|
|
7
|
+
from kantree_cli.services.cards import get_card, get_workspace_card_by_ref
|
|
8
|
+
from kantree_cli.services.workspaces import get_workspace, list_workspaces
|
|
9
|
+
from kantree_cli.services.workspaces import workspace_id as workspace_identifier
|
|
10
|
+
|
|
11
|
+
JsonObject = dict[str, Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ResolvedWorkspace:
|
|
16
|
+
workspace: JsonObject
|
|
17
|
+
selector_source: str
|
|
18
|
+
selector_value: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ResolvedCard:
|
|
23
|
+
card: JsonObject
|
|
24
|
+
selector_source: str
|
|
25
|
+
selector_value: str
|
|
26
|
+
workspace: JsonObject | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class ResolvedCardSelector:
|
|
31
|
+
card_id: int
|
|
32
|
+
selector_source: str
|
|
33
|
+
selector_value: str
|
|
34
|
+
workspace: JsonObject | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_workspace(
|
|
38
|
+
client: KantreeClient,
|
|
39
|
+
*,
|
|
40
|
+
organization_id: int | None = None,
|
|
41
|
+
workspace_id: int | None = None,
|
|
42
|
+
workspace_name: str | None = None,
|
|
43
|
+
default_workspace: str | None = None,
|
|
44
|
+
default_workspace_source: str | None = None,
|
|
45
|
+
require_selector: bool = True,
|
|
46
|
+
) -> ResolvedWorkspace | None:
|
|
47
|
+
if workspace_id is not None:
|
|
48
|
+
workspace = get_workspace(client, workspace_id)
|
|
49
|
+
return ResolvedWorkspace(
|
|
50
|
+
workspace=workspace,
|
|
51
|
+
selector_source="flag:--workspace-id",
|
|
52
|
+
selector_value=str(workspace_id),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if workspace_name is not None:
|
|
56
|
+
normalized_name = _normalize_workspace_name(
|
|
57
|
+
workspace_name,
|
|
58
|
+
source="flag:--workspace",
|
|
59
|
+
)
|
|
60
|
+
return _resolve_workspace_by_name(
|
|
61
|
+
client,
|
|
62
|
+
organization_id=organization_id,
|
|
63
|
+
workspace_name=normalized_name,
|
|
64
|
+
selector_source="flag:--workspace",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if default_workspace:
|
|
68
|
+
normalized_default = default_workspace.strip()
|
|
69
|
+
if normalized_default:
|
|
70
|
+
parsed_default_id = _parse_int(normalized_default)
|
|
71
|
+
if parsed_default_id is not None:
|
|
72
|
+
source = default_workspace_source or "runtime:default_workspace"
|
|
73
|
+
if parsed_default_id < 1:
|
|
74
|
+
raise ValidationError(
|
|
75
|
+
f"{source} expects a positive integer workspace id when numeric, "
|
|
76
|
+
f"got `{normalized_default}`."
|
|
77
|
+
)
|
|
78
|
+
workspace = get_workspace(client, parsed_default_id)
|
|
79
|
+
return ResolvedWorkspace(
|
|
80
|
+
workspace=workspace,
|
|
81
|
+
selector_source=source,
|
|
82
|
+
selector_value=normalized_default,
|
|
83
|
+
)
|
|
84
|
+
source = default_workspace_source or "runtime:default_workspace"
|
|
85
|
+
return _resolve_workspace_by_name(
|
|
86
|
+
client,
|
|
87
|
+
organization_id=organization_id,
|
|
88
|
+
workspace_name=normalized_default,
|
|
89
|
+
selector_source=source,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if require_selector:
|
|
93
|
+
raise ValidationError(
|
|
94
|
+
"Missing workspace selector. Use `--workspace-id`, `--workspace`, or configure a default workspace."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_card_for_show(
|
|
101
|
+
client: KantreeClient,
|
|
102
|
+
*,
|
|
103
|
+
card_id_value: int | None,
|
|
104
|
+
card_ref: int | None,
|
|
105
|
+
workspace_id_value: int | None,
|
|
106
|
+
workspace_name_value: str | None,
|
|
107
|
+
organization_id: int | None = None,
|
|
108
|
+
default_workspace: str | None = None,
|
|
109
|
+
default_workspace_source: str | None = None,
|
|
110
|
+
) -> ResolvedCard:
|
|
111
|
+
selector = resolve_card_selector(
|
|
112
|
+
client,
|
|
113
|
+
card_id_value=card_id_value,
|
|
114
|
+
card_ref=card_ref,
|
|
115
|
+
workspace_id_value=workspace_id_value,
|
|
116
|
+
workspace_name_value=workspace_name_value,
|
|
117
|
+
organization_id=organization_id,
|
|
118
|
+
default_workspace=default_workspace,
|
|
119
|
+
default_workspace_source=default_workspace_source,
|
|
120
|
+
)
|
|
121
|
+
return ResolvedCard(
|
|
122
|
+
card=get_card(client, selector.card_id),
|
|
123
|
+
selector_source=selector.selector_source,
|
|
124
|
+
selector_value=selector.selector_value,
|
|
125
|
+
workspace=selector.workspace,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_card_selector(
|
|
130
|
+
client: KantreeClient,
|
|
131
|
+
*,
|
|
132
|
+
card_id_value: int | None,
|
|
133
|
+
card_ref: int | None,
|
|
134
|
+
workspace_id_value: int | None,
|
|
135
|
+
workspace_name_value: str | None,
|
|
136
|
+
organization_id: int | None = None,
|
|
137
|
+
default_workspace: str | None = None,
|
|
138
|
+
default_workspace_source: str | None = None,
|
|
139
|
+
) -> ResolvedCardSelector:
|
|
140
|
+
if card_id_value is not None and card_ref is not None:
|
|
141
|
+
raise ValidationError("Use either `CARD_ID` or `--ref`, not both.")
|
|
142
|
+
|
|
143
|
+
if card_id_value is not None:
|
|
144
|
+
return ResolvedCardSelector(
|
|
145
|
+
card_id=card_id_value,
|
|
146
|
+
selector_source="arg:CARD_ID",
|
|
147
|
+
selector_value=str(card_id_value),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if card_ref is None:
|
|
151
|
+
raise ValidationError("Missing card selector. Provide `CARD_ID` or `--ref`.")
|
|
152
|
+
|
|
153
|
+
resolved_workspace = resolve_workspace(
|
|
154
|
+
client,
|
|
155
|
+
organization_id=organization_id,
|
|
156
|
+
workspace_id=workspace_id_value,
|
|
157
|
+
workspace_name=workspace_name_value,
|
|
158
|
+
default_workspace=default_workspace,
|
|
159
|
+
default_workspace_source=default_workspace_source,
|
|
160
|
+
require_selector=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
workspace = resolved_workspace.workspace
|
|
164
|
+
resolved_workspace_id = workspace_identifier(workspace)
|
|
165
|
+
match = get_workspace_card_by_ref(
|
|
166
|
+
client,
|
|
167
|
+
workspace_id=resolved_workspace_id,
|
|
168
|
+
card_ref=card_ref,
|
|
169
|
+
)
|
|
170
|
+
resolved_id = card_identifier(match, context="Card lookup response")
|
|
171
|
+
return ResolvedCardSelector(
|
|
172
|
+
card_id=resolved_id,
|
|
173
|
+
selector_source="flag:--ref",
|
|
174
|
+
selector_value=str(card_ref),
|
|
175
|
+
workspace=workspace,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resolve_workspace_by_name(
|
|
180
|
+
client: KantreeClient,
|
|
181
|
+
*,
|
|
182
|
+
organization_id: int | None,
|
|
183
|
+
workspace_name: str,
|
|
184
|
+
selector_source: str,
|
|
185
|
+
) -> ResolvedWorkspace:
|
|
186
|
+
workspace_pool = list_workspaces(
|
|
187
|
+
client,
|
|
188
|
+
organization_id=organization_id,
|
|
189
|
+
include_team_projects=organization_id is not None,
|
|
190
|
+
)
|
|
191
|
+
matches = [
|
|
192
|
+
workspace for workspace in workspace_pool if workspace.get("title") == workspace_name
|
|
193
|
+
]
|
|
194
|
+
if not matches:
|
|
195
|
+
if organization_id is None:
|
|
196
|
+
raise NotFoundError(
|
|
197
|
+
f"Workspace `{workspace_name}` not found. If it belongs to an organization "
|
|
198
|
+
"team, retry with root `--org ORG`."
|
|
199
|
+
)
|
|
200
|
+
raise NotFoundError(
|
|
201
|
+
f"Workspace `{workspace_name}` not found in organization `{organization_id}`."
|
|
202
|
+
)
|
|
203
|
+
if len(matches) > 1:
|
|
204
|
+
ids = ", ".join(str(match.get("id")) for match in matches)
|
|
205
|
+
raise AmbiguityError(
|
|
206
|
+
f"Workspace `{workspace_name}` is ambiguous ({len(matches)} matches: {ids}). "
|
|
207
|
+
"Use `--workspace-id`."
|
|
208
|
+
)
|
|
209
|
+
return ResolvedWorkspace(
|
|
210
|
+
workspace=matches[0],
|
|
211
|
+
selector_source=selector_source,
|
|
212
|
+
selector_value=workspace_name,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _normalize_workspace_name(workspace_name: str, *, source: str) -> str:
|
|
217
|
+
normalized = workspace_name.strip()
|
|
218
|
+
if not normalized:
|
|
219
|
+
raise ValidationError(f"{source} expects a non-empty workspace name.")
|
|
220
|
+
return normalized
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_int(value: str) -> int | None:
|
|
224
|
+
try:
|
|
225
|
+
return int(value)
|
|
226
|
+
except ValueError:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _workspace_label(workspace: JsonObject) -> str:
|
|
231
|
+
title = workspace.get("title")
|
|
232
|
+
if isinstance(title, str):
|
|
233
|
+
normalized_title = title.strip()
|
|
234
|
+
if normalized_title:
|
|
235
|
+
return normalized_title
|
|
236
|
+
return str(workspace_identifier(workspace))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from kantree_cli.core.client import KantreeClient
|
|
4
|
+
from kantree_cli.core.errors import ApiError, ValidationError
|
|
5
|
+
from kantree_cli.core.response import extract_object_list, next_page_header_value
|
|
6
|
+
|
|
7
|
+
JsonObject = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def search_cards(
|
|
11
|
+
client: KantreeClient,
|
|
12
|
+
*,
|
|
13
|
+
organization_id: int | None = None,
|
|
14
|
+
filter_text: str | None = None,
|
|
15
|
+
limit: int | None = None,
|
|
16
|
+
) -> list[JsonObject]:
|
|
17
|
+
if limit is not None and limit < 1:
|
|
18
|
+
raise ValidationError("`--limit` expects a positive integer.")
|
|
19
|
+
|
|
20
|
+
params: dict[str, str] = {}
|
|
21
|
+
if filter_text is not None:
|
|
22
|
+
normalized_filter = filter_text.strip()
|
|
23
|
+
if not normalized_filter:
|
|
24
|
+
raise ValidationError("`--filter` expects a non-empty value.")
|
|
25
|
+
params["filters"] = normalized_filter
|
|
26
|
+
|
|
27
|
+
path = "/search" if organization_id is None else f"/organizations/{organization_id}/search"
|
|
28
|
+
merged_cards: list[JsonObject] = []
|
|
29
|
+
next_page = 1
|
|
30
|
+
legacy_filter_param = False
|
|
31
|
+
while True:
|
|
32
|
+
request_params = dict(params)
|
|
33
|
+
if legacy_filter_param and "filters" in request_params:
|
|
34
|
+
request_params["filter"] = request_params.pop("filters")
|
|
35
|
+
request_params["page"] = str(next_page)
|
|
36
|
+
try:
|
|
37
|
+
response = client.request(method="GET", path=path, params=request_params)
|
|
38
|
+
except ApiError as exc:
|
|
39
|
+
if not legacy_filter_param and "filters" in params and _is_filter_required_error(exc):
|
|
40
|
+
legacy_filter_param = True
|
|
41
|
+
retry_params = dict(params)
|
|
42
|
+
retry_params["filter"] = retry_params.pop("filters")
|
|
43
|
+
retry_params["page"] = str(next_page)
|
|
44
|
+
response = client.request(method="GET", path=path, params=retry_params)
|
|
45
|
+
else:
|
|
46
|
+
raise
|
|
47
|
+
page_cards = extract_object_list(
|
|
48
|
+
response.data,
|
|
49
|
+
context=f"`GET {path}`",
|
|
50
|
+
preferred_keys=("cards", "data", "items", "results"),
|
|
51
|
+
)
|
|
52
|
+
merged_cards.extend(page_cards)
|
|
53
|
+
|
|
54
|
+
if limit is not None and len(merged_cards) >= limit:
|
|
55
|
+
return merged_cards[:limit]
|
|
56
|
+
|
|
57
|
+
parsed_next_page = next_page_header_value(response.headers)
|
|
58
|
+
if parsed_next_page is None:
|
|
59
|
+
break
|
|
60
|
+
next_page = parsed_next_page
|
|
61
|
+
|
|
62
|
+
return merged_cards
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_filter_required_error(exc: ApiError) -> bool:
|
|
66
|
+
message = str(exc).lower()
|
|
67
|
+
return "filter:" in message and "required field" in message
|