kanbaroo-cli 0.2.2__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.
- kanbaroo_cli/__init__.py +5 -0
- kanbaroo_cli/app.py +114 -0
- kanbaroo_cli/client.py +304 -0
- kanbaroo_cli/commands/__init__.py +6 -0
- kanbaroo_cli/commands/audit.py +123 -0
- kanbaroo_cli/commands/backup.py +78 -0
- kanbaroo_cli/commands/epic.py +323 -0
- kanbaroo_cli/commands/export.py +81 -0
- kanbaroo_cli/commands/init.py +160 -0
- kanbaroo_cli/commands/server.py +133 -0
- kanbaroo_cli/commands/story.py +627 -0
- kanbaroo_cli/commands/tag.py +252 -0
- kanbaroo_cli/commands/token.py +150 -0
- kanbaroo_cli/commands/workspace.py +263 -0
- kanbaroo_cli/config.py +233 -0
- kanbaroo_cli/context.py +104 -0
- kanbaroo_cli/rendering.py +83 -0
- kanbaroo_cli/resolvers.py +185 -0
- kanbaroo_cli/similar.py +67 -0
- kanbaroo_cli-0.2.2.dist-info/METADATA +26 -0
- kanbaroo_cli-0.2.2.dist-info/RECORD +23 -0
- kanbaroo_cli-0.2.2.dist-info/WHEEL +4 -0
- kanbaroo_cli-0.2.2.dist-info/entry_points.txt +3 -0
kanbaroo_cli/__init__.py
ADDED
kanbaroo_cli/app.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kanbaroo CLI entry point.
|
|
3
|
+
|
|
4
|
+
Exposes the root Typer app and mounts every sub-app defined in
|
|
5
|
+
``kanbaroo_cli.commands``. The root callback is a no-op so Typer
|
|
6
|
+
always treats registered entries as subcommands; this mirrors the
|
|
7
|
+
pattern established when only ``kb init`` existed and keeps the help
|
|
8
|
+
output consistent as new commands arrive.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from kanbaroo_cli.commands import audit as audit_command
|
|
17
|
+
from kanbaroo_cli.commands import backup as backup_command
|
|
18
|
+
from kanbaroo_cli.commands import epic as epic_command
|
|
19
|
+
from kanbaroo_cli.commands import export as export_command
|
|
20
|
+
from kanbaroo_cli.commands import init as init_command
|
|
21
|
+
from kanbaroo_cli.commands import server as server_command
|
|
22
|
+
from kanbaroo_cli.commands import story as story_command
|
|
23
|
+
from kanbaroo_cli.commands import tag as tag_command
|
|
24
|
+
from kanbaroo_cli.commands import token as token_command
|
|
25
|
+
from kanbaroo_cli.commands import workspace as workspace_command
|
|
26
|
+
from kanbaroo_cli.rendering import stdout_console
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="kanbaroo",
|
|
30
|
+
help="Kanbaroo: a kanban-style issue tracker for terminals and AI agents.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _installed_version() -> str:
|
|
37
|
+
"""
|
|
38
|
+
Return the installed ``kanbaroo-cli`` version, or ``"unknown"`` when
|
|
39
|
+
the package is not installed (e.g. running directly from source
|
|
40
|
+
without an editable install).
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return _pkg_version("kanbaroo-cli")
|
|
44
|
+
except PackageNotFoundError:
|
|
45
|
+
return "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _version_callback(value: bool) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Eager Typer option callback for ``--version``. Prints the installed
|
|
51
|
+
version and exits cleanly before any subcommand runs.
|
|
52
|
+
"""
|
|
53
|
+
if value:
|
|
54
|
+
stdout_console.print(_installed_version())
|
|
55
|
+
raise typer.Exit(code=0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.callback()
|
|
59
|
+
def _root(
|
|
60
|
+
_version: bool = typer.Option(
|
|
61
|
+
False,
|
|
62
|
+
"--version",
|
|
63
|
+
help="Print the installed kanbaroo-cli version and exit.",
|
|
64
|
+
is_eager=True,
|
|
65
|
+
callback=_version_callback,
|
|
66
|
+
),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Root callback. Exists only so Typer treats registered commands as
|
|
70
|
+
subcommands; it also wires the ``--version`` option.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command(
|
|
75
|
+
name="version",
|
|
76
|
+
help="Print the installed kanbaroo-cli version.",
|
|
77
|
+
)
|
|
78
|
+
def version_command() -> None:
|
|
79
|
+
"""
|
|
80
|
+
Print the installed kanbaroo-cli version. Mirrors ``kb --version``.
|
|
81
|
+
"""
|
|
82
|
+
stdout_console.print(_installed_version())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
app.command(
|
|
86
|
+
name="init",
|
|
87
|
+
help="Initialise the Kanbaroo config directory, database, and first token.",
|
|
88
|
+
)(init_command.init)
|
|
89
|
+
|
|
90
|
+
app.command(
|
|
91
|
+
name="audit",
|
|
92
|
+
help="Show the audit history for a story or epic.",
|
|
93
|
+
)(audit_command.audit_command)
|
|
94
|
+
|
|
95
|
+
app.command(
|
|
96
|
+
name="export",
|
|
97
|
+
help="Download an export archive for a workspace.",
|
|
98
|
+
)(export_command.export_command)
|
|
99
|
+
|
|
100
|
+
app.command(
|
|
101
|
+
name="backup",
|
|
102
|
+
help="Copy the configured SQLite database file to a timestamped path.",
|
|
103
|
+
)(backup_command.backup_command)
|
|
104
|
+
|
|
105
|
+
app.add_typer(workspace_command.app, name="workspace")
|
|
106
|
+
app.add_typer(story_command.app, name="story")
|
|
107
|
+
app.add_typer(epic_command.app, name="epic")
|
|
108
|
+
app.add_typer(tag_command.app, name="tag")
|
|
109
|
+
app.add_typer(token_command.app, name="token")
|
|
110
|
+
app.add_typer(server_command.app, name="server")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
app()
|
kanbaroo_cli/client.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin HTTP client wrapper for the Kanbaroo CLI.
|
|
3
|
+
|
|
4
|
+
Every command that hits the server goes through :class:`ApiClient` so
|
|
5
|
+
that authentication, base URLs, error-envelope handling, and ETag /
|
|
6
|
+
``If-Match`` plumbing live in exactly one place. The wrapper is
|
|
7
|
+
intentionally not a general-purpose HTTP abstraction: its job is to
|
|
8
|
+
turn the server's documented REST surface into Python calls that raise
|
|
9
|
+
typed exceptions on failure.
|
|
10
|
+
|
|
11
|
+
The client is built on top of :class:`httpx.Client` and accepts an
|
|
12
|
+
optional ``transport`` argument so tests can swap in
|
|
13
|
+
:class:`httpx.MockTransport` without monkey-patching. This is the same
|
|
14
|
+
pattern the FastAPI ``TestClient`` uses internally.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
API_PREFIX = "/api/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApiError(Exception):
|
|
28
|
+
"""
|
|
29
|
+
Base class for every error surfaced from the CLI HTTP client.
|
|
30
|
+
|
|
31
|
+
Commands translate this into a Rich-rendered message and a
|
|
32
|
+
non-zero exit code; no command handler should ever let a raw
|
|
33
|
+
:class:`httpx.HTTPError` escape.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiRequestError(ApiError):
|
|
38
|
+
"""
|
|
39
|
+
Raised when the server returns a non-2xx response.
|
|
40
|
+
|
|
41
|
+
``code`` and ``message`` come from the canonical error envelope
|
|
42
|
+
defined in ``docs/spec.md`` section 4.1. ``details`` is the
|
|
43
|
+
per-error dict the server chose to attach (or ``None``).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
status_code: int,
|
|
50
|
+
code: str,
|
|
51
|
+
message: str,
|
|
52
|
+
details: dict[str, Any] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(f"{code}: {message}")
|
|
55
|
+
self.status_code = status_code
|
|
56
|
+
self.code = code
|
|
57
|
+
self.message = message
|
|
58
|
+
self.details = details
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApiTransportError(ApiError):
|
|
62
|
+
"""
|
|
63
|
+
Raised when the HTTP round-trip itself fails (DNS, refused
|
|
64
|
+
connection, timeout). Distinct from :class:`ApiRequestError`
|
|
65
|
+
because the user's remediation is different: start the server,
|
|
66
|
+
check the network, fix the URL.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _extract_error(response: httpx.Response) -> ApiRequestError:
|
|
71
|
+
"""
|
|
72
|
+
Parse the canonical error envelope out of a non-2xx response.
|
|
73
|
+
|
|
74
|
+
Falls back to a best-effort message if the server returned a
|
|
75
|
+
body that is not valid JSON or does not match the envelope shape;
|
|
76
|
+
the CLI still surfaces a non-zero exit with a human-readable
|
|
77
|
+
message in that case.
|
|
78
|
+
"""
|
|
79
|
+
code = "unknown"
|
|
80
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
81
|
+
details: dict[str, Any] | None = None
|
|
82
|
+
try:
|
|
83
|
+
body = response.json()
|
|
84
|
+
except ValueError:
|
|
85
|
+
body = None
|
|
86
|
+
if isinstance(body, dict) and "error" in body and isinstance(body["error"], dict):
|
|
87
|
+
error = body["error"]
|
|
88
|
+
code = str(error.get("code", code))
|
|
89
|
+
message = str(error.get("message", message))
|
|
90
|
+
raw_details = error.get("details")
|
|
91
|
+
if isinstance(raw_details, dict):
|
|
92
|
+
details = raw_details
|
|
93
|
+
return ApiRequestError(
|
|
94
|
+
status_code=response.status_code,
|
|
95
|
+
code=code,
|
|
96
|
+
message=message,
|
|
97
|
+
details=details,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ApiClient:
|
|
102
|
+
"""
|
|
103
|
+
Authenticated HTTP wrapper around the Kanbaroo REST API.
|
|
104
|
+
|
|
105
|
+
The wrapper handles four things the command handlers would
|
|
106
|
+
otherwise duplicate:
|
|
107
|
+
|
|
108
|
+
* Base URL composition: every call takes a path (``/workspaces``)
|
|
109
|
+
and the client prepends ``{base_url}{API_PREFIX}``.
|
|
110
|
+
* Auth: ``Authorization: Bearer <token>`` on every request.
|
|
111
|
+
* Error envelope translation: non-2xx responses become
|
|
112
|
+
:class:`ApiRequestError` instances.
|
|
113
|
+
* ETag / If-Match round trips: :meth:`patch_with_etag` and
|
|
114
|
+
:meth:`delete_with_etag` take an entity path and do the GET to
|
|
115
|
+
read the ETag before issuing the mutation.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
base_url: str,
|
|
122
|
+
token: str,
|
|
123
|
+
transport: httpx.BaseTransport | None = None,
|
|
124
|
+
timeout: float = 30.0,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Build a new client.
|
|
128
|
+
|
|
129
|
+
``transport`` is injected so tests can drive the CLI with an
|
|
130
|
+
:class:`httpx.MockTransport` without touching the network.
|
|
131
|
+
"""
|
|
132
|
+
self._base_url = base_url.rstrip("/")
|
|
133
|
+
self._client = httpx.Client(
|
|
134
|
+
base_url=f"{self._base_url}{API_PREFIX}",
|
|
135
|
+
headers={
|
|
136
|
+
"Authorization": f"Bearer {token}",
|
|
137
|
+
"Accept": "application/json",
|
|
138
|
+
},
|
|
139
|
+
transport=transport,
|
|
140
|
+
timeout=timeout,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def __enter__(self) -> ApiClient:
|
|
144
|
+
"""
|
|
145
|
+
Support ``with ApiClient(...) as client`` in command handlers.
|
|
146
|
+
"""
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(
|
|
150
|
+
self,
|
|
151
|
+
exc_type: type[BaseException] | None,
|
|
152
|
+
exc: BaseException | None,
|
|
153
|
+
tb: TracebackType | None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Close the underlying ``httpx.Client`` on context exit.
|
|
157
|
+
"""
|
|
158
|
+
self.close()
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Close the underlying ``httpx.Client``.
|
|
163
|
+
"""
|
|
164
|
+
self._client.close()
|
|
165
|
+
|
|
166
|
+
def request(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
path: str,
|
|
170
|
+
*,
|
|
171
|
+
params: dict[str, Any] | None = None,
|
|
172
|
+
json: Any | None = None,
|
|
173
|
+
headers: dict[str, str] | None = None,
|
|
174
|
+
) -> httpx.Response:
|
|
175
|
+
"""
|
|
176
|
+
Issue an HTTP request and return the raw response.
|
|
177
|
+
|
|
178
|
+
Low-level escape hatch: most callers should use the typed
|
|
179
|
+
wrappers (:meth:`get`, :meth:`post`, ...). Non-2xx responses
|
|
180
|
+
still raise :class:`ApiRequestError` so callers never have to
|
|
181
|
+
inspect ``response.status_code`` themselves.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
response = self._client.request(
|
|
185
|
+
method=method,
|
|
186
|
+
url=path,
|
|
187
|
+
params=params,
|
|
188
|
+
json=json,
|
|
189
|
+
headers=headers,
|
|
190
|
+
)
|
|
191
|
+
except httpx.TransportError as exc:
|
|
192
|
+
raise ApiTransportError(f"could not reach {self._base_url}: {exc}") from exc
|
|
193
|
+
if response.status_code >= 400:
|
|
194
|
+
raise _extract_error(response)
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
def get(
|
|
198
|
+
self,
|
|
199
|
+
path: str,
|
|
200
|
+
*,
|
|
201
|
+
params: dict[str, Any] | None = None,
|
|
202
|
+
) -> httpx.Response:
|
|
203
|
+
"""
|
|
204
|
+
Issue a GET request.
|
|
205
|
+
"""
|
|
206
|
+
return self.request("GET", path, params=params)
|
|
207
|
+
|
|
208
|
+
def post(
|
|
209
|
+
self,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
json: Any | None = None,
|
|
213
|
+
params: dict[str, Any] | None = None,
|
|
214
|
+
headers: dict[str, str] | None = None,
|
|
215
|
+
) -> httpx.Response:
|
|
216
|
+
"""
|
|
217
|
+
Issue a POST request.
|
|
218
|
+
"""
|
|
219
|
+
return self.request("POST", path, params=params, json=json, headers=headers)
|
|
220
|
+
|
|
221
|
+
def patch(
|
|
222
|
+
self,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
json: Any | None = None,
|
|
226
|
+
headers: dict[str, str] | None = None,
|
|
227
|
+
) -> httpx.Response:
|
|
228
|
+
"""
|
|
229
|
+
Issue a PATCH request. Callers that need automatic ETag
|
|
230
|
+
round-tripping should use :meth:`patch_with_etag`.
|
|
231
|
+
"""
|
|
232
|
+
return self.request("PATCH", path, json=json, headers=headers)
|
|
233
|
+
|
|
234
|
+
def delete(
|
|
235
|
+
self,
|
|
236
|
+
path: str,
|
|
237
|
+
*,
|
|
238
|
+
headers: dict[str, str] | None = None,
|
|
239
|
+
) -> httpx.Response:
|
|
240
|
+
"""
|
|
241
|
+
Issue a DELETE request. Callers that need automatic ETag
|
|
242
|
+
round-tripping should use :meth:`delete_with_etag`.
|
|
243
|
+
"""
|
|
244
|
+
return self.request("DELETE", path, headers=headers)
|
|
245
|
+
|
|
246
|
+
def patch_with_etag(
|
|
247
|
+
self,
|
|
248
|
+
path: str,
|
|
249
|
+
*,
|
|
250
|
+
json: Any | None = None,
|
|
251
|
+
) -> httpx.Response:
|
|
252
|
+
"""
|
|
253
|
+
PATCH ``path`` after fetching its current ETag via GET.
|
|
254
|
+
|
|
255
|
+
Convenience for the common CLI flow: fetch, mutate, send back.
|
|
256
|
+
If the server returns a 412 the caller gets the raw
|
|
257
|
+
:class:`ApiRequestError` so it can render a friendly
|
|
258
|
+
"version conflict" message.
|
|
259
|
+
"""
|
|
260
|
+
etag = self._fetch_etag(path)
|
|
261
|
+
return self.patch(path, json=json, headers={"If-Match": etag})
|
|
262
|
+
|
|
263
|
+
def delete_with_etag(self, path: str) -> httpx.Response:
|
|
264
|
+
"""
|
|
265
|
+
DELETE ``path`` after fetching its current ETag via GET.
|
|
266
|
+
"""
|
|
267
|
+
etag = self._fetch_etag(path)
|
|
268
|
+
return self.delete(path, headers={"If-Match": etag})
|
|
269
|
+
|
|
270
|
+
def post_with_etag(
|
|
271
|
+
self,
|
|
272
|
+
entity_path: str,
|
|
273
|
+
action_path: str,
|
|
274
|
+
*,
|
|
275
|
+
json: Any | None = None,
|
|
276
|
+
) -> httpx.Response:
|
|
277
|
+
"""
|
|
278
|
+
POST to ``action_path`` after fetching the ETag of
|
|
279
|
+
``entity_path``.
|
|
280
|
+
|
|
281
|
+
Used for the ``/epics/{id}/close`` and ``/stories/{id}/transition``
|
|
282
|
+
endpoints, which require ``If-Match`` but do not share a URL
|
|
283
|
+
with their parent entity.
|
|
284
|
+
"""
|
|
285
|
+
etag = self._fetch_etag(entity_path)
|
|
286
|
+
return self.post(action_path, json=json, headers={"If-Match": etag})
|
|
287
|
+
|
|
288
|
+
def _fetch_etag(self, path: str) -> str:
|
|
289
|
+
"""
|
|
290
|
+
GET ``path`` and return the ``ETag`` header value.
|
|
291
|
+
|
|
292
|
+
Raises :class:`ApiError` if the response did not include an
|
|
293
|
+
ETag header, which signals that the caller mixed up a
|
|
294
|
+
non-versioned resource (tags, linkages) with the versioned
|
|
295
|
+
helper.
|
|
296
|
+
"""
|
|
297
|
+
response = self.get(path)
|
|
298
|
+
etag = response.headers.get("etag")
|
|
299
|
+
if etag is None:
|
|
300
|
+
raise ApiError(
|
|
301
|
+
f"response for {path} did not include an ETag header; "
|
|
302
|
+
"this resource does not support optimistic concurrency"
|
|
303
|
+
)
|
|
304
|
+
return str(etag)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb audit``.
|
|
3
|
+
|
|
4
|
+
Calls the ``/audit/entity/{entity_type}/{id}`` endpoint per spec
|
|
5
|
+
section 4.2. That endpoint is not yet implemented on the server as of
|
|
6
|
+
this cage (the GET-audit router belongs to a later cage); when the
|
|
7
|
+
server returns 404 the CLI prints a clear message rather than a
|
|
8
|
+
traceback, so the contract is stable for callers even while the
|
|
9
|
+
backend catches up.
|
|
10
|
+
|
|
11
|
+
``kb audit`` accepts a human id (``KAN-123``) or a UUID. For human
|
|
12
|
+
ids we try ``/stories/by-key`` first and fall back to
|
|
13
|
+
``/epics/by-key``; that lets the same command serve either kind of
|
|
14
|
+
entity without the user having to specify the type.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from kanbaroo_cli.client import ApiClient, ApiError, ApiRequestError
|
|
24
|
+
from kanbaroo_cli.context import build_client, require_config
|
|
25
|
+
from kanbaroo_cli.rendering import (
|
|
26
|
+
exit_on_api_error,
|
|
27
|
+
print_json,
|
|
28
|
+
print_table,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_entity(client: ApiClient, ref: str) -> tuple[str, str, str]:
|
|
33
|
+
"""
|
|
34
|
+
Resolve ``ref`` to a ``(entity_type, entity_id, human_id_or_ref)``
|
|
35
|
+
triple.
|
|
36
|
+
|
|
37
|
+
For a human id (``KAN-N``) this hits ``by-key`` on both stories
|
|
38
|
+
and epics; for a UUID it tries story-by-id then epic-by-id.
|
|
39
|
+
Raises :class:`ApiRequestError` with ``not_found`` when neither
|
|
40
|
+
lookup succeeds, so the CLI's error path is uniform.
|
|
41
|
+
"""
|
|
42
|
+
if "-" in ref and ref.rsplit("-", 1)[-1].isdigit():
|
|
43
|
+
try:
|
|
44
|
+
body = client.get(f"/stories/by-key/{ref}").json()
|
|
45
|
+
return "story", str(body["id"]), str(body["human_id"])
|
|
46
|
+
except ApiRequestError as exc:
|
|
47
|
+
if exc.code != "not_found":
|
|
48
|
+
raise
|
|
49
|
+
try:
|
|
50
|
+
body = client.get(f"/epics/by-key/{ref}").json()
|
|
51
|
+
return "epic", str(body["id"]), str(body["human_id"])
|
|
52
|
+
except ApiRequestError as exc:
|
|
53
|
+
if exc.code != "not_found":
|
|
54
|
+
raise
|
|
55
|
+
else:
|
|
56
|
+
try:
|
|
57
|
+
body = client.get(f"/stories/{ref}").json()
|
|
58
|
+
return "story", str(body["id"]), str(body["human_id"])
|
|
59
|
+
except ApiRequestError as exc:
|
|
60
|
+
if exc.code != "not_found":
|
|
61
|
+
raise
|
|
62
|
+
try:
|
|
63
|
+
body = client.get(f"/epics/{ref}").json()
|
|
64
|
+
return "epic", str(body["id"]), str(body["human_id"])
|
|
65
|
+
except ApiRequestError as exc:
|
|
66
|
+
if exc.code != "not_found":
|
|
67
|
+
raise
|
|
68
|
+
raise ApiRequestError(
|
|
69
|
+
status_code=404,
|
|
70
|
+
code="not_found",
|
|
71
|
+
message=f"no story or epic matches {ref!r}",
|
|
72
|
+
details={"ref": ref},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def audit_command(
|
|
77
|
+
ref: str = typer.Argument(
|
|
78
|
+
...,
|
|
79
|
+
help="Story or epic handle (KAN-123) or UUID.",
|
|
80
|
+
),
|
|
81
|
+
as_json: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--json",
|
|
84
|
+
help="Emit JSON output instead of a Rich table.",
|
|
85
|
+
),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Show the audit history for a single story or epic.
|
|
89
|
+
"""
|
|
90
|
+
config = require_config()
|
|
91
|
+
with build_client(config) as client:
|
|
92
|
+
try:
|
|
93
|
+
entity_type, entity_id, human_id = _resolve_entity(client, ref)
|
|
94
|
+
response = client.get(f"/audit/entity/{entity_type}/{entity_id}")
|
|
95
|
+
except ApiError as exc:
|
|
96
|
+
exit_on_api_error(exc)
|
|
97
|
+
payload: Any = response.json()
|
|
98
|
+
|
|
99
|
+
items: list[dict[str, Any]]
|
|
100
|
+
if isinstance(payload, dict) and "items" in payload:
|
|
101
|
+
items = list(payload["items"])
|
|
102
|
+
elif isinstance(payload, list):
|
|
103
|
+
items = list(payload)
|
|
104
|
+
else:
|
|
105
|
+
items = []
|
|
106
|
+
|
|
107
|
+
if as_json:
|
|
108
|
+
print_json(items)
|
|
109
|
+
return
|
|
110
|
+
rows = [
|
|
111
|
+
[
|
|
112
|
+
str(item.get("occurred_at", "")),
|
|
113
|
+
str(item.get("action", "")),
|
|
114
|
+
f"{item.get('actor_type', '')}/{item.get('actor_id', '')}",
|
|
115
|
+
str(item.get("id", "")),
|
|
116
|
+
]
|
|
117
|
+
for item in items
|
|
118
|
+
]
|
|
119
|
+
print_table(
|
|
120
|
+
columns=["occurred_at", "action", "actor", "event_id"],
|
|
121
|
+
rows=rows,
|
|
122
|
+
title=f"audit trail for {human_id}",
|
|
123
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb backup``.
|
|
3
|
+
|
|
4
|
+
Local-only command: reads the configured SQLite file path from the
|
|
5
|
+
CLI config (or ``$KANBAROO_DATABASE_URL``) and copies it to a
|
|
6
|
+
timestamped path under ``--output`` (default ``~/.kanbaroo/backups``).
|
|
7
|
+
Does not touch the running server: snapshotting a SQLite file while
|
|
8
|
+
the writer is live is safe as long as readers see a consistent page
|
|
9
|
+
view, and using the filesystem here lets the command work even when
|
|
10
|
+
the server is offline.
|
|
11
|
+
|
|
12
|
+
Any database URL that is not SQLite (i.e. Postgres in a later phase)
|
|
13
|
+
causes the command to print a warning and exit with code 0. The
|
|
14
|
+
contract is "SQLite only for now"; we do not want to silently imply a
|
|
15
|
+
Postgres backup is happening.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import shutil
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from kanbaroo_cli.config import default_config_dir
|
|
27
|
+
from kanbaroo_cli.context import require_config
|
|
28
|
+
from kanbaroo_cli.rendering import stderr_console, stdout_console
|
|
29
|
+
|
|
30
|
+
SQLITE_PREFIXES: tuple[str, ...] = ("sqlite:///", "sqlite+pysqlite:///")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sqlite_path_from_url(url: str) -> Path | None:
|
|
34
|
+
"""
|
|
35
|
+
Return the file path embedded in a ``sqlite://`` URL, or ``None``
|
|
36
|
+
if the URL does not point at a SQLite file.
|
|
37
|
+
|
|
38
|
+
``sqlite:///:memory:`` and other non-file forms return ``None``.
|
|
39
|
+
"""
|
|
40
|
+
for prefix in SQLITE_PREFIXES:
|
|
41
|
+
if url.startswith(prefix):
|
|
42
|
+
raw = url[len(prefix) :]
|
|
43
|
+
if not raw or raw == ":memory:":
|
|
44
|
+
return None
|
|
45
|
+
return Path(raw)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def backup_command(
|
|
50
|
+
output: Path | None = typer.Option(
|
|
51
|
+
None,
|
|
52
|
+
"--output",
|
|
53
|
+
help="Directory to write the backup into. Default: ~/.kanbaroo/backups.",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Copy the configured SQLite database file to a timestamped path.
|
|
58
|
+
"""
|
|
59
|
+
config = require_config()
|
|
60
|
+
assert config.database_url is not None # require_config() guarantees this
|
|
61
|
+
src = _sqlite_path_from_url(config.database_url)
|
|
62
|
+
if src is None:
|
|
63
|
+
stderr_console.print(
|
|
64
|
+
"[yellow]Warning:[/yellow] database_url is not a SQLite file URL; "
|
|
65
|
+
"kb backup only supports SQLite in v1. Skipping."
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(code=0)
|
|
68
|
+
|
|
69
|
+
if not src.exists():
|
|
70
|
+
stderr_console.print(f"[red]Error:[/red] database file {src} does not exist.")
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
dest_dir = output if output is not None else default_config_dir() / "backups"
|
|
74
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
|
76
|
+
dest = dest_dir / f"kanbaroo-{timestamp}.db"
|
|
77
|
+
shutil.copy2(src, dest)
|
|
78
|
+
stdout_console.print(f"wrote [bold]{dest}[/bold].")
|