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/config.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config loader for the Kanbaroo CLI.
|
|
3
|
+
|
|
4
|
+
Every command that talks to the server needs three things: an
|
|
5
|
+
``api_url``, a ``token``, and (for ``kb backup``) a ``database_url``.
|
|
6
|
+
All three are written to ``$KANBAROO_CONFIG_DIR/config.toml`` by
|
|
7
|
+
``kb init``. This module provides a single :func:`load_config` entry
|
|
8
|
+
point that reads that file, applies environment-variable overrides,
|
|
9
|
+
and surfaces a dataclass the command handlers can consume without
|
|
10
|
+
re-parsing TOML every time.
|
|
11
|
+
|
|
12
|
+
If the config file is missing the loader raises
|
|
13
|
+
:class:`ConfigNotFoundError`, which the CLI translates into a clean
|
|
14
|
+
Rich-rendered message pointing the user at ``kb init``. We deliberately
|
|
15
|
+
do not fall back to a partial config: if ``config.toml`` does not exist
|
|
16
|
+
the user has not finished setup and we should say so loudly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""
|
|
28
|
+
Base class for CLI configuration failures.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigNotFoundError(ConfigError):
|
|
33
|
+
"""
|
|
34
|
+
Raised when the expected ``config.toml`` does not exist.
|
|
35
|
+
|
|
36
|
+
The CLI translates this into a 1-exit with a Rich-rendered hint
|
|
37
|
+
pointing the user at ``kb init``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: Path) -> None:
|
|
41
|
+
super().__init__(f"Kanbaroo config not found at {path}")
|
|
42
|
+
self.path = path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigMalformedError(ConfigError):
|
|
46
|
+
"""
|
|
47
|
+
Raised when ``config.toml`` is present but missing a required key or
|
|
48
|
+
contains a value of the wrong type.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class CliConfig:
|
|
54
|
+
"""
|
|
55
|
+
In-memory representation of the CLI's on-disk configuration.
|
|
56
|
+
|
|
57
|
+
``database_url`` is needed by ``kb backup`` to locate the raw
|
|
58
|
+
SQLite file for a local snapshot; every other command only needs
|
|
59
|
+
``api_url`` and ``token``. The field is typed ``str | None`` so the
|
|
60
|
+
API-only loader can skip the database-url validation and still
|
|
61
|
+
return a usable config (the HTTP-probe path in ``kb server start
|
|
62
|
+
--wait`` does not care where the database lives). Commands that
|
|
63
|
+
actually need the URL route through :func:`load_config`, which keeps
|
|
64
|
+
it mandatory.
|
|
65
|
+
|
|
66
|
+
``default_workspace`` is the optional workspace key most
|
|
67
|
+
mutating/listing commands fall back to when neither ``--workspace``
|
|
68
|
+
nor ``$KANBAROO_WORKSPACE`` is set. It is written by
|
|
69
|
+
``kb workspace use`` and may be absent in older configs.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
api_url: str
|
|
73
|
+
token: str
|
|
74
|
+
database_url: str | None
|
|
75
|
+
config_path: Path
|
|
76
|
+
default_workspace: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def default_config_dir() -> Path:
|
|
80
|
+
"""
|
|
81
|
+
Resolve the config directory from ``$KANBAROO_CONFIG_DIR`` or fall
|
|
82
|
+
back to ``$HOME/.kanbaroo``.
|
|
83
|
+
|
|
84
|
+
Mirrors the helper inside ``kb init`` so both commands share the
|
|
85
|
+
same resolution logic without leaking the init module's internals.
|
|
86
|
+
"""
|
|
87
|
+
override = os.environ.get("KANBAROO_CONFIG_DIR")
|
|
88
|
+
if override:
|
|
89
|
+
return Path(override)
|
|
90
|
+
return Path.home() / ".kanbaroo"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def default_config_path() -> Path:
|
|
94
|
+
"""
|
|
95
|
+
Resolve the canonical ``config.toml`` path.
|
|
96
|
+
"""
|
|
97
|
+
return default_config_dir() / "config.toml"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_config(
|
|
101
|
+
path: Path | None = None,
|
|
102
|
+
*,
|
|
103
|
+
require_database_url: bool = True,
|
|
104
|
+
) -> CliConfig:
|
|
105
|
+
"""
|
|
106
|
+
Read ``config.toml`` and return a :class:`CliConfig`.
|
|
107
|
+
|
|
108
|
+
``$KANBAROO_API_URL``, ``$KANBAROO_TOKEN``, and
|
|
109
|
+
``$KANBAROO_DATABASE_URL`` each override the matching TOML field
|
|
110
|
+
when set; this lets the test suite and CI pipelines drive the CLI
|
|
111
|
+
without touching a user's config.
|
|
112
|
+
|
|
113
|
+
``api_url`` and ``token`` are always required.
|
|
114
|
+
``require_database_url`` defaults to ``True``; pass ``False`` via
|
|
115
|
+
:func:`load_config_api_only` for commands that only hit the HTTP
|
|
116
|
+
API and never open the database directly (e.g.
|
|
117
|
+
``kb server start --wait``).
|
|
118
|
+
|
|
119
|
+
TODO: broader CLI split needed between API-client commands (that
|
|
120
|
+
speak HTTP) and direct-DB-client commands (``kb backup``) so the
|
|
121
|
+
required-field set is implicit from the command rather than
|
|
122
|
+
manually wired per call site. Track alongside the API-client vs
|
|
123
|
+
direct-DB-client modes note in TODO.md.
|
|
124
|
+
|
|
125
|
+
Raises :class:`ConfigNotFoundError` when the file is absent and
|
|
126
|
+
:class:`ConfigMalformedError` when it is present but incomplete.
|
|
127
|
+
"""
|
|
128
|
+
resolved_path = path or default_config_path()
|
|
129
|
+
if not resolved_path.exists():
|
|
130
|
+
raise ConfigNotFoundError(resolved_path)
|
|
131
|
+
|
|
132
|
+
with resolved_path.open("rb") as fh:
|
|
133
|
+
raw = tomllib.load(fh)
|
|
134
|
+
|
|
135
|
+
api_url = os.environ.get("KANBAROO_API_URL") or raw.get("api_url")
|
|
136
|
+
token = os.environ.get("KANBAROO_TOKEN") or raw.get("token")
|
|
137
|
+
database_url_raw = os.environ.get("KANBAROO_DATABASE_URL") or raw.get(
|
|
138
|
+
"database_url"
|
|
139
|
+
)
|
|
140
|
+
default_workspace_raw = raw.get("default_workspace")
|
|
141
|
+
|
|
142
|
+
missing: list[str] = []
|
|
143
|
+
if not isinstance(api_url, str) or not api_url:
|
|
144
|
+
missing.append("api_url")
|
|
145
|
+
if not isinstance(token, str) or not token:
|
|
146
|
+
missing.append("token")
|
|
147
|
+
if require_database_url and (
|
|
148
|
+
not isinstance(database_url_raw, str) or not database_url_raw
|
|
149
|
+
):
|
|
150
|
+
missing.append("database_url")
|
|
151
|
+
if missing:
|
|
152
|
+
raise ConfigMalformedError(
|
|
153
|
+
f"config.toml at {resolved_path} is missing: {', '.join(missing)}"
|
|
154
|
+
)
|
|
155
|
+
if default_workspace_raw is not None and (
|
|
156
|
+
not isinstance(default_workspace_raw, str) or not default_workspace_raw
|
|
157
|
+
):
|
|
158
|
+
raise ConfigMalformedError(
|
|
159
|
+
f"config.toml at {resolved_path} has an invalid default_workspace"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
assert isinstance(api_url, str)
|
|
163
|
+
assert isinstance(token, str)
|
|
164
|
+
database_url: str | None = (
|
|
165
|
+
database_url_raw
|
|
166
|
+
if isinstance(database_url_raw, str) and database_url_raw
|
|
167
|
+
else None
|
|
168
|
+
)
|
|
169
|
+
default_workspace: str | None = (
|
|
170
|
+
default_workspace_raw if isinstance(default_workspace_raw, str) else None
|
|
171
|
+
)
|
|
172
|
+
return CliConfig(
|
|
173
|
+
api_url=api_url,
|
|
174
|
+
token=token,
|
|
175
|
+
database_url=database_url,
|
|
176
|
+
config_path=resolved_path,
|
|
177
|
+
default_workspace=default_workspace,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load_config_api_only(path: Path | None = None) -> CliConfig:
|
|
182
|
+
"""
|
|
183
|
+
Like :func:`load_config` but does not require ``database_url``.
|
|
184
|
+
|
|
185
|
+
Used by commands that only need to talk to the running HTTP API
|
|
186
|
+
and never read the local database. ``database_url`` may be absent
|
|
187
|
+
from ``config.toml`` entirely; in that case the returned
|
|
188
|
+
``CliConfig.database_url`` is ``None``.
|
|
189
|
+
"""
|
|
190
|
+
return load_config(path, require_database_url=False)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _escape_toml(value: str) -> str:
|
|
194
|
+
"""
|
|
195
|
+
Escape a string value for a TOML basic string literal.
|
|
196
|
+
"""
|
|
197
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _render_config_toml(values: dict[str, Any]) -> str:
|
|
201
|
+
"""
|
|
202
|
+
Render a minimal ``config.toml`` from a mapping of string scalars.
|
|
203
|
+
|
|
204
|
+
``None`` values are omitted. Key order is preserved so rewrites do
|
|
205
|
+
not reshuffle the file unnecessarily.
|
|
206
|
+
"""
|
|
207
|
+
lines: list[str] = []
|
|
208
|
+
for key, value in values.items():
|
|
209
|
+
if value is None:
|
|
210
|
+
continue
|
|
211
|
+
if not isinstance(value, str):
|
|
212
|
+
raise TypeError(f"config value for {key!r} must be str, got {type(value)}")
|
|
213
|
+
lines.append(f'{key} = "{_escape_toml(value)}"')
|
|
214
|
+
return "\n".join(lines) + "\n"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def write_default_workspace(path: Path, workspace_key: str | None) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Rewrite ``config.toml`` at ``path`` to set ``default_workspace`` to
|
|
220
|
+
``workspace_key`` (or remove it when ``None``).
|
|
221
|
+
|
|
222
|
+
Preserves every other key present in the file. Used by
|
|
223
|
+
``kb workspace use``.
|
|
224
|
+
"""
|
|
225
|
+
if not path.exists():
|
|
226
|
+
raise ConfigNotFoundError(path)
|
|
227
|
+
with path.open("rb") as fh:
|
|
228
|
+
raw = tomllib.load(fh)
|
|
229
|
+
if workspace_key is None:
|
|
230
|
+
raw.pop("default_workspace", None)
|
|
231
|
+
else:
|
|
232
|
+
raw["default_workspace"] = workspace_key
|
|
233
|
+
path.write_text(_render_config_toml(raw), encoding="utf-8")
|
kanbaroo_cli/context.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-command context helpers.
|
|
3
|
+
|
|
4
|
+
Most command handlers load the CLI config and spin up an
|
|
5
|
+
:class:`~kanbaroo_cli.client.ApiClient` in the same pattern. Centralising
|
|
6
|
+
that pattern here keeps each command handler thin and makes it trivial
|
|
7
|
+
to test: injecting a custom :class:`httpx.MockTransport` only happens in
|
|
8
|
+
one place.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from kanbaroo_cli.client import ApiClient
|
|
19
|
+
from kanbaroo_cli.config import (
|
|
20
|
+
CliConfig,
|
|
21
|
+
ConfigMalformedError,
|
|
22
|
+
ConfigNotFoundError,
|
|
23
|
+
load_config,
|
|
24
|
+
load_config_api_only,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ClientFactory = Callable[[CliConfig], ApiClient]
|
|
28
|
+
|
|
29
|
+
_stderr_console = Console(stderr=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_config() -> CliConfig:
|
|
33
|
+
"""
|
|
34
|
+
Load the CLI config or exit with a friendly error message.
|
|
35
|
+
|
|
36
|
+
Keeps every command from re-implementing the same error-render
|
|
37
|
+
plumbing.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return load_config()
|
|
41
|
+
except ConfigNotFoundError as exc:
|
|
42
|
+
_stderr_console.print(
|
|
43
|
+
f"[red]Error:[/red] no Kanbaroo config at {exc.path}.\n"
|
|
44
|
+
"Run [bold]kb init[/bold] to create one."
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(code=1) from exc
|
|
47
|
+
except ConfigMalformedError as exc:
|
|
48
|
+
_stderr_console.print(f"[red]Error:[/red] {exc}")
|
|
49
|
+
raise typer.Exit(code=1) from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_config_api_only() -> CliConfig:
|
|
53
|
+
"""
|
|
54
|
+
Like :func:`require_config` but does not demand ``database_url``.
|
|
55
|
+
|
|
56
|
+
For commands that only hit the HTTP API (e.g.
|
|
57
|
+
``kb server start --wait``). The returned
|
|
58
|
+
:class:`CliConfig.database_url` may be ``None``; the caller must
|
|
59
|
+
not dereference it.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return load_config_api_only()
|
|
63
|
+
except ConfigNotFoundError as exc:
|
|
64
|
+
_stderr_console.print(
|
|
65
|
+
f"[red]Error:[/red] no Kanbaroo config at {exc.path}.\n"
|
|
66
|
+
"Run [bold]kb init[/bold] to create one."
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(code=1) from exc
|
|
69
|
+
except ConfigMalformedError as exc:
|
|
70
|
+
_stderr_console.print(f"[red]Error:[/red] {exc}")
|
|
71
|
+
raise typer.Exit(code=1) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_client_factory(config: CliConfig) -> ApiClient:
|
|
75
|
+
"""
|
|
76
|
+
Default implementation of :data:`_client_factory`: spin up a real
|
|
77
|
+
HTTP-backed client against the user's configured API URL.
|
|
78
|
+
"""
|
|
79
|
+
return ApiClient(base_url=config.api_url, token=config.token)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_client_factory: ClientFactory = _default_client_factory
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_client(config: CliConfig) -> ApiClient:
|
|
86
|
+
"""
|
|
87
|
+
Build an :class:`ApiClient` wired to the loaded config.
|
|
88
|
+
|
|
89
|
+
Tests override this through :func:`set_client_factory` so they can
|
|
90
|
+
hand back a client pre-bound to an :class:`httpx.MockTransport`.
|
|
91
|
+
"""
|
|
92
|
+
return _client_factory(config)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_client_factory(factory: ClientFactory | None) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Override the client factory for the duration of a test.
|
|
98
|
+
|
|
99
|
+
Passing ``None`` restores the default (real-HTTP) factory. The
|
|
100
|
+
indirection is deliberate: patching the factory keeps the call
|
|
101
|
+
sites in the command handlers identical to the production path.
|
|
102
|
+
"""
|
|
103
|
+
global _client_factory
|
|
104
|
+
_client_factory = factory or _default_client_factory
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared rendering helpers for CLI commands.
|
|
3
|
+
|
|
4
|
+
Every list/show command supports ``--json`` for scripting and defaults
|
|
5
|
+
to a Rich-rendered table. Keeping the two rendering paths in one place
|
|
6
|
+
means individual commands only choose *what* to display, not *how*.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json as _json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from kanbaroo_cli.client import ApiError, ApiRequestError
|
|
19
|
+
|
|
20
|
+
stdout_console = Console()
|
|
21
|
+
stderr_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_json(payload: Any) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Emit ``payload`` as compact JSON on stdout.
|
|
27
|
+
|
|
28
|
+
Uses :func:`json.dumps` directly (rather than Rich's
|
|
29
|
+
``print_json``) so the output is deterministic and pipe-friendly.
|
|
30
|
+
"""
|
|
31
|
+
stdout_console.print_json(_json.dumps(payload))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_table(
|
|
35
|
+
*,
|
|
36
|
+
columns: list[str],
|
|
37
|
+
rows: list[list[str]],
|
|
38
|
+
title: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Render a Rich table with the given columns and rows on stdout.
|
|
42
|
+
|
|
43
|
+
Empty ``rows`` renders the header with a single "(none)" cell so
|
|
44
|
+
the user never sees a blank box.
|
|
45
|
+
"""
|
|
46
|
+
table = Table(title=title, show_lines=False)
|
|
47
|
+
for col in columns:
|
|
48
|
+
table.add_column(col)
|
|
49
|
+
if not rows:
|
|
50
|
+
table.add_row(*["(none)"] + [""] * (len(columns) - 1))
|
|
51
|
+
else:
|
|
52
|
+
for row in rows:
|
|
53
|
+
table.add_row(*row)
|
|
54
|
+
stdout_console.print(table)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_api_error(exc: ApiError) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Print a friendly error message for an API failure on stderr.
|
|
60
|
+
|
|
61
|
+
Wraps both transport errors (server unreachable) and HTTP errors
|
|
62
|
+
(canonical envelope) so command handlers can call this once and
|
|
63
|
+
raise :class:`typer.Exit` immediately after.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(exc, ApiRequestError):
|
|
66
|
+
stderr_console.print(
|
|
67
|
+
f"[red]Error ({exc.status_code} {exc.code}):[/red] {exc.message}"
|
|
68
|
+
)
|
|
69
|
+
if exc.details:
|
|
70
|
+
stderr_console.print(f"[dim]details:[/dim] {exc.details}")
|
|
71
|
+
else:
|
|
72
|
+
stderr_console.print(f"[red]Error:[/red] {exc}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def exit_on_api_error(exc: ApiError) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Render the error and raise :class:`typer.Exit` with code 1.
|
|
78
|
+
|
|
79
|
+
Used by every command handler so the exit-code contract is
|
|
80
|
+
identical across the CLI surface.
|
|
81
|
+
"""
|
|
82
|
+
render_api_error(exc)
|
|
83
|
+
raise typer.Exit(code=1) from exc
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ID-resolution helpers shared by commands.
|
|
3
|
+
|
|
4
|
+
Stories and epics are addressed in the CLI by their ``{KEY}-{N}`` human
|
|
5
|
+
identifiers (``KAN-123``); workspaces are addressed by their short key
|
|
6
|
+
(``KAN``) or by UUID. The REST API serves by-key lookups for every
|
|
7
|
+
entity directly so the CLI never has to walk the workspace list.
|
|
8
|
+
|
|
9
|
+
Most commands that accept ``--workspace`` also support a default: when
|
|
10
|
+
the flag is omitted, :func:`resolve_effective_workspace` falls back to
|
|
11
|
+
``$KANBAROO_WORKSPACE`` and then to ``default_workspace`` from
|
|
12
|
+
``config.toml`` before surfacing a friendly error.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from kanbaroo_cli.client import ApiClient, ApiRequestError
|
|
23
|
+
from kanbaroo_cli.config import CliConfig
|
|
24
|
+
from kanbaroo_cli.rendering import stderr_console
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _looks_like_human_id(candidate: str) -> bool:
|
|
28
|
+
"""
|
|
29
|
+
Return True if ``candidate`` is shaped like ``{KEY}-{N}``.
|
|
30
|
+
"""
|
|
31
|
+
if "-" not in candidate:
|
|
32
|
+
return False
|
|
33
|
+
head, tail = candidate.rsplit("-", 1)
|
|
34
|
+
return bool(head) and tail.isdigit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_workspace(client: ApiClient, key_or_id: str) -> dict[str, Any]:
|
|
38
|
+
"""
|
|
39
|
+
Resolve a workspace by short key or UUID and return its full body.
|
|
40
|
+
|
|
41
|
+
Workspace keys do not contain dashes (the conventional ``KAN``/
|
|
42
|
+
``DATA`` shape) whereas UUIDs do. Route any dashless reference
|
|
43
|
+
straight to ``GET /workspaces/by-key/{key}``. Otherwise try the
|
|
44
|
+
direct UUID endpoint first and fall back to the by-key endpoint for
|
|
45
|
+
the rare hyphenated-key case. A 404 from the final lookup surfaces
|
|
46
|
+
as :class:`ApiRequestError` with ``code="not_found"``.
|
|
47
|
+
"""
|
|
48
|
+
if "-" not in key_or_id:
|
|
49
|
+
response = client.get(f"/workspaces/by-key/{key_or_id}")
|
|
50
|
+
body: dict[str, Any] = response.json()
|
|
51
|
+
return body
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
response = client.get(f"/workspaces/{key_or_id}")
|
|
55
|
+
except ApiRequestError as exc:
|
|
56
|
+
if exc.code != "not_found":
|
|
57
|
+
raise
|
|
58
|
+
else:
|
|
59
|
+
direct: dict[str, Any] = response.json()
|
|
60
|
+
return direct
|
|
61
|
+
|
|
62
|
+
response = client.get(f"/workspaces/by-key/{key_or_id}")
|
|
63
|
+
fallback: dict[str, Any] = response.json()
|
|
64
|
+
return fallback
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
WORKSPACE_ENV_VAR = "KANBAROO_WORKSPACE"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WorkspaceSource:
|
|
71
|
+
"""
|
|
72
|
+
Labels for where an effective workspace came from.
|
|
73
|
+
|
|
74
|
+
Consumed by ``kb workspace current`` to tell the user which source
|
|
75
|
+
supplied the value.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
FLAG = "flag"
|
|
79
|
+
ENV = "env"
|
|
80
|
+
CONFIG = "config"
|
|
81
|
+
UNSET = "unset"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def effective_workspace(
|
|
85
|
+
workspace_arg: str | None,
|
|
86
|
+
config: CliConfig,
|
|
87
|
+
) -> tuple[str | None, str]:
|
|
88
|
+
"""
|
|
89
|
+
Resolve the effective ``--workspace`` value and its source.
|
|
90
|
+
|
|
91
|
+
Order, highest to lowest precedence:
|
|
92
|
+
|
|
93
|
+
1. ``workspace_arg`` (the explicit ``--workspace`` flag),
|
|
94
|
+
2. ``$KANBAROO_WORKSPACE`` environment variable,
|
|
95
|
+
3. ``default_workspace`` in ``config.toml``.
|
|
96
|
+
|
|
97
|
+
Returns ``(value, source)`` where ``value`` is ``None`` only when
|
|
98
|
+
none of the three supplied a non-empty string. Callers that need a
|
|
99
|
+
guaranteed value should use :func:`require_effective_workspace`.
|
|
100
|
+
"""
|
|
101
|
+
if workspace_arg:
|
|
102
|
+
return workspace_arg, WorkspaceSource.FLAG
|
|
103
|
+
env_value = os.environ.get(WORKSPACE_ENV_VAR)
|
|
104
|
+
if env_value:
|
|
105
|
+
return env_value, WorkspaceSource.ENV
|
|
106
|
+
if config.default_workspace:
|
|
107
|
+
return config.default_workspace, WorkspaceSource.CONFIG
|
|
108
|
+
return None, WorkspaceSource.UNSET
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def require_effective_workspace(
|
|
112
|
+
workspace_arg: str | None,
|
|
113
|
+
config: CliConfig,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Resolve the effective workspace, exiting with a friendly Rich
|
|
117
|
+
error when none of the three sources supplies one.
|
|
118
|
+
|
|
119
|
+
Keeps every command handler's call site to a single line while
|
|
120
|
+
still yielding the same ``exit 1`` / stderr-hint UX across the CLI.
|
|
121
|
+
"""
|
|
122
|
+
value, _ = effective_workspace(workspace_arg, config)
|
|
123
|
+
if value is None:
|
|
124
|
+
stderr_console.print(
|
|
125
|
+
"[red]Error:[/red] no workspace specified. "
|
|
126
|
+
"Pass [bold]--workspace KEY[/bold], "
|
|
127
|
+
f"set [bold]${WORKSPACE_ENV_VAR}[/bold], or run "
|
|
128
|
+
"[bold]kb workspace use KEY[/bold]."
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(code=1)
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_story(client: ApiClient, ref: str) -> dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Resolve a story by ``{KEY}-{N}`` human id or by UUID.
|
|
137
|
+
|
|
138
|
+
Uses ``GET /stories/by-key/{ref}`` when ``ref`` matches the
|
|
139
|
+
human-id pattern, otherwise treats ``ref`` as a UUID and calls
|
|
140
|
+
``GET /stories/{ref}``.
|
|
141
|
+
"""
|
|
142
|
+
if _looks_like_human_id(ref):
|
|
143
|
+
response = client.get(f"/stories/by-key/{ref}")
|
|
144
|
+
else:
|
|
145
|
+
response = client.get(f"/stories/{ref}")
|
|
146
|
+
body: dict[str, Any] = response.json()
|
|
147
|
+
return body
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_epic(client: ApiClient, ref: str) -> dict[str, Any]:
|
|
151
|
+
"""
|
|
152
|
+
Resolve an epic by ``{KEY}-{N}`` human id or by UUID.
|
|
153
|
+
|
|
154
|
+
Uses ``GET /epics/by-key/{ref}`` when ``ref`` matches the human-id
|
|
155
|
+
pattern, otherwise treats ``ref`` as a UUID and calls
|
|
156
|
+
``GET /epics/{ref}``.
|
|
157
|
+
"""
|
|
158
|
+
if _looks_like_human_id(ref):
|
|
159
|
+
response = client.get(f"/epics/by-key/{ref}")
|
|
160
|
+
else:
|
|
161
|
+
response = client.get(f"/epics/{ref}")
|
|
162
|
+
body: dict[str, Any] = response.json()
|
|
163
|
+
return body
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def try_resolve_other(
|
|
167
|
+
client: ApiClient,
|
|
168
|
+
ref: str,
|
|
169
|
+
*,
|
|
170
|
+
other: str,
|
|
171
|
+
) -> dict[str, Any] | None:
|
|
172
|
+
"""
|
|
173
|
+
Attempt to resolve ``ref`` as the other entity kind and return it.
|
|
174
|
+
|
|
175
|
+
``other`` is either ``"story"`` or ``"epic"``. Used by ``kb story
|
|
176
|
+
show`` and ``kb epic show`` to turn a 404 into a helpful "this
|
|
177
|
+
looks like the other kind of entity" hint. Returns ``None`` when
|
|
178
|
+
the other lookup also misses so callers fall back to the plain
|
|
179
|
+
not-found message.
|
|
180
|
+
"""
|
|
181
|
+
resolver = resolve_story if other == "story" else resolve_epic
|
|
182
|
+
try:
|
|
183
|
+
return resolver(client, ref)
|
|
184
|
+
except ApiRequestError:
|
|
185
|
+
return None
|
kanbaroo_cli/similar.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared helpers for the duplicate-title check used by ``kb story``,
|
|
3
|
+
``kb epic``, and ``kb tag`` ``create`` commands.
|
|
4
|
+
|
|
5
|
+
The wired flow is the same for every entity type: query the
|
|
6
|
+
``similar`` endpoint with the proposed title (or name), and either
|
|
7
|
+
prompt the user to abort, skip the prompt under ``--force``, or fold
|
|
8
|
+
the matches into a ``warnings`` field for ``--json`` output. The
|
|
9
|
+
shape of the entity differs (stories and epics carry ``human_id``;
|
|
10
|
+
tags carry ``name``), so callers pick the label key they want
|
|
11
|
+
displayed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from kanbaroo_cli.client import ApiClient
|
|
19
|
+
from kanbaroo_cli.rendering import stdout_console
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_similar_entities(
|
|
23
|
+
client: ApiClient,
|
|
24
|
+
*,
|
|
25
|
+
workspace_id: str,
|
|
26
|
+
resource: str,
|
|
27
|
+
field_name: str,
|
|
28
|
+
value: str,
|
|
29
|
+
) -> list[dict[str, Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Call ``GET /workspaces/{id}/{resource}/similar?{field_name}=...``
|
|
32
|
+
and return the ``items`` list.
|
|
33
|
+
|
|
34
|
+
``resource`` is the URL segment (``stories``, ``epics``,
|
|
35
|
+
``tags``); ``field_name`` is the query-string key the endpoint
|
|
36
|
+
expects (``title`` for stories and epics, ``name`` for tags).
|
|
37
|
+
"""
|
|
38
|
+
response = client.get(
|
|
39
|
+
f"/workspaces/{workspace_id}/{resource}/similar",
|
|
40
|
+
params={field_name: value},
|
|
41
|
+
)
|
|
42
|
+
body: dict[str, Any] = response.json()
|
|
43
|
+
items = body.get("items") or []
|
|
44
|
+
return [item for item in items if isinstance(item, dict)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_similar_entities(
|
|
48
|
+
items: list[dict[str, Any]],
|
|
49
|
+
*,
|
|
50
|
+
label_key: str,
|
|
51
|
+
entity: str,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Render a short list of similar entities to stdout for the
|
|
55
|
+
interactive prompt.
|
|
56
|
+
|
|
57
|
+
``label_key`` is the field rendered in the second column
|
|
58
|
+
(``human_id`` for stories and epics, ``name`` for tags). The
|
|
59
|
+
table is intentionally small; we want the user to see the
|
|
60
|
+
overlap, not browse a result set.
|
|
61
|
+
"""
|
|
62
|
+
plural = entity if items and len(items) == 1 else f"{entity}s"
|
|
63
|
+
stdout_console.print(f"[yellow]Found {len(items)} similar {plural}:[/yellow]")
|
|
64
|
+
for item in items:
|
|
65
|
+
label = str(item.get(label_key, "?"))
|
|
66
|
+
title = str(item.get("title") or item.get("name") or "")
|
|
67
|
+
stdout_console.print(f" - {label} {title}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kanbaroo-cli
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Kanbaroo command-line interface
|
|
5
|
+
Author: Adam Reese
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: kanbaroo-core
|
|
10
|
+
Requires-Dist: rich>=13.7
|
|
11
|
+
Requires-Dist: typer>=0.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# kanbaroo-cli
|
|
15
|
+
|
|
16
|
+
Typer-based command-line interface for [Kanbaroo](https://github.com/areese801/kanbaroo), a kanban-style issue tracker. Provides the `kanbaroo` and `kb` console scripts for working with workspaces, epics, stories, comments, tags, linkages, tokens, audit trails, export, and backups from the shell.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install kanbaroo-cli
|
|
20
|
+
kb init # mint your first token
|
|
21
|
+
kb --help
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Full docs: [github.com/areese801/kanbaroo](https://github.com/areese801/kanbaroo).
|
|
25
|
+
|
|
26
|
+
License: MIT.
|