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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation of ``kb story``.
|
|
3
|
+
|
|
4
|
+
Wraps every story-related REST endpoint the CLI exposes (list, create,
|
|
5
|
+
show, edit, move, comment, link, delete). All references to stories
|
|
6
|
+
and epics accept human ids (``KAN-123``) or UUIDs, translated via the
|
|
7
|
+
by-key endpoints.
|
|
8
|
+
|
|
9
|
+
``kb story edit`` follows the ``git commit`` pattern: dump the current
|
|
10
|
+
markdown body to a temp file, launch ``$EDITOR`` (default ``vim``),
|
|
11
|
+
read the file back, and submit a PATCH. An unchanged buffer short-
|
|
12
|
+
circuits without hitting the server.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from kanbaroo_cli.client import ApiClient, ApiError, ApiRequestError
|
|
26
|
+
from kanbaroo_cli.context import build_client, require_config
|
|
27
|
+
from kanbaroo_cli.rendering import (
|
|
28
|
+
exit_on_api_error,
|
|
29
|
+
print_json,
|
|
30
|
+
print_table,
|
|
31
|
+
stderr_console,
|
|
32
|
+
stdout_console,
|
|
33
|
+
)
|
|
34
|
+
from kanbaroo_cli.resolvers import (
|
|
35
|
+
require_effective_workspace,
|
|
36
|
+
resolve_epic,
|
|
37
|
+
resolve_story,
|
|
38
|
+
resolve_workspace,
|
|
39
|
+
try_resolve_other,
|
|
40
|
+
)
|
|
41
|
+
from kanbaroo_cli.similar import fetch_similar_entities, print_similar_entities
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
name="story",
|
|
45
|
+
help="Create, inspect, and transition stories.",
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _story_rows(items: list[dict[str, Any]]) -> list[list[str]]:
|
|
51
|
+
"""
|
|
52
|
+
Format a list of story bodies into table rows.
|
|
53
|
+
"""
|
|
54
|
+
return [
|
|
55
|
+
[
|
|
56
|
+
str(item["human_id"]),
|
|
57
|
+
str(item["title"]),
|
|
58
|
+
str(item["state"]),
|
|
59
|
+
str(item["priority"]),
|
|
60
|
+
"yes" if item["deleted_at"] else "no",
|
|
61
|
+
]
|
|
62
|
+
for item in items
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_epic_id(client: ApiClient, epic_ref: str | None) -> str | None:
|
|
67
|
+
"""
|
|
68
|
+
Translate a ``--epic KAN-N`` flag into a UUID, or return ``None``
|
|
69
|
+
when the flag was not supplied.
|
|
70
|
+
"""
|
|
71
|
+
if epic_ref is None:
|
|
72
|
+
return None
|
|
73
|
+
epic = resolve_epic(client, epic_ref)
|
|
74
|
+
return str(epic["id"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("list")
|
|
78
|
+
def list_stories(
|
|
79
|
+
workspace: str | None = typer.Option(
|
|
80
|
+
None,
|
|
81
|
+
"--workspace",
|
|
82
|
+
help=(
|
|
83
|
+
"Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
|
|
84
|
+
"and default_workspace from config."
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
state: str | None = typer.Option(
|
|
88
|
+
None,
|
|
89
|
+
"--state",
|
|
90
|
+
help="Filter by state (backlog|todo|in_progress|in_review|done).",
|
|
91
|
+
),
|
|
92
|
+
priority: str | None = typer.Option(
|
|
93
|
+
None,
|
|
94
|
+
"--priority",
|
|
95
|
+
help="Filter by priority (none|low|medium|high).",
|
|
96
|
+
),
|
|
97
|
+
epic: str | None = typer.Option(
|
|
98
|
+
None,
|
|
99
|
+
"--epic",
|
|
100
|
+
help="Filter by epic (KAN-N or UUID).",
|
|
101
|
+
),
|
|
102
|
+
tag: str | None = typer.Option(None, "--tag", help="Filter by tag name."),
|
|
103
|
+
limit: int | None = typer.Option(
|
|
104
|
+
None,
|
|
105
|
+
"--limit",
|
|
106
|
+
help="Maximum number of stories to return (default: all pages).",
|
|
107
|
+
),
|
|
108
|
+
include_deleted: bool = typer.Option(
|
|
109
|
+
False,
|
|
110
|
+
"--include-deleted",
|
|
111
|
+
help="Include soft-deleted stories in the listing.",
|
|
112
|
+
),
|
|
113
|
+
as_json: bool = typer.Option(
|
|
114
|
+
False,
|
|
115
|
+
"--json",
|
|
116
|
+
help="Emit machine-readable JSON instead of a Rich table.",
|
|
117
|
+
),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
List stories in a workspace with optional filters.
|
|
121
|
+
"""
|
|
122
|
+
config = require_config()
|
|
123
|
+
workspace_ref = require_effective_workspace(workspace, config)
|
|
124
|
+
items: list[dict[str, Any]] = []
|
|
125
|
+
with build_client(config) as client:
|
|
126
|
+
try:
|
|
127
|
+
ws = resolve_workspace(client, workspace_ref)
|
|
128
|
+
epic_id = _resolve_epic_id(client, epic)
|
|
129
|
+
cursor: str | None = None
|
|
130
|
+
while True:
|
|
131
|
+
params: dict[str, Any] = {"limit": 200}
|
|
132
|
+
if state is not None:
|
|
133
|
+
params["state"] = state
|
|
134
|
+
if priority is not None:
|
|
135
|
+
params["priority"] = priority
|
|
136
|
+
if epic_id is not None:
|
|
137
|
+
params["epic_id"] = epic_id
|
|
138
|
+
if tag is not None:
|
|
139
|
+
params["tag"] = tag
|
|
140
|
+
if include_deleted:
|
|
141
|
+
params["include_deleted"] = True
|
|
142
|
+
if cursor is not None:
|
|
143
|
+
params["cursor"] = cursor
|
|
144
|
+
response = client.get(
|
|
145
|
+
f"/workspaces/{ws['id']}/stories",
|
|
146
|
+
params=params,
|
|
147
|
+
)
|
|
148
|
+
body = response.json()
|
|
149
|
+
items.extend(body["items"])
|
|
150
|
+
cursor = body.get("next_cursor")
|
|
151
|
+
if cursor is None or (limit is not None and len(items) >= limit):
|
|
152
|
+
break
|
|
153
|
+
if limit is not None:
|
|
154
|
+
items = items[:limit]
|
|
155
|
+
except ApiError as exc:
|
|
156
|
+
exit_on_api_error(exc)
|
|
157
|
+
|
|
158
|
+
if as_json:
|
|
159
|
+
print_json(items)
|
|
160
|
+
return
|
|
161
|
+
print_table(
|
|
162
|
+
columns=["human_id", "title", "state", "priority", "deleted"],
|
|
163
|
+
rows=_story_rows(items),
|
|
164
|
+
title=f"stories in {ws['key']}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("create")
|
|
169
|
+
def create_story(
|
|
170
|
+
workspace: str | None = typer.Option(
|
|
171
|
+
None,
|
|
172
|
+
"--workspace",
|
|
173
|
+
help=(
|
|
174
|
+
"Workspace key or UUID. Falls back to $KANBAROO_WORKSPACE "
|
|
175
|
+
"and default_workspace from config."
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
title: str = typer.Option(..., "--title", help="Story title."),
|
|
179
|
+
description: str | None = typer.Option(
|
|
180
|
+
None,
|
|
181
|
+
"--description",
|
|
182
|
+
help="Optional markdown description.",
|
|
183
|
+
),
|
|
184
|
+
priority: str = typer.Option(
|
|
185
|
+
"none",
|
|
186
|
+
"--priority",
|
|
187
|
+
help="Priority (none|low|medium|high). Defaults to none.",
|
|
188
|
+
),
|
|
189
|
+
epic: str | None = typer.Option(
|
|
190
|
+
None,
|
|
191
|
+
"--epic",
|
|
192
|
+
help="Associate the story with an epic (KAN-N or UUID).",
|
|
193
|
+
),
|
|
194
|
+
force: bool = typer.Option(
|
|
195
|
+
False,
|
|
196
|
+
"--force",
|
|
197
|
+
help="Skip the duplicate-title prompt and create the story regardless.",
|
|
198
|
+
),
|
|
199
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Create a new story.
|
|
203
|
+
|
|
204
|
+
Before posting we ask the server for stories with a normalised
|
|
205
|
+
title equivalent to ``title``. In interactive mode (no ``--json``)
|
|
206
|
+
a non-empty result triggers a yes/no prompt so the user can abort
|
|
207
|
+
a likely duplicate. ``--force`` skips the prompt; ``--json`` never
|
|
208
|
+
prompts but folds the matches into a ``warnings`` field on the
|
|
209
|
+
output.
|
|
210
|
+
"""
|
|
211
|
+
config = require_config()
|
|
212
|
+
workspace_ref = require_effective_workspace(workspace, config)
|
|
213
|
+
similar: list[dict[str, Any]] = []
|
|
214
|
+
with build_client(config) as client:
|
|
215
|
+
try:
|
|
216
|
+
ws = resolve_workspace(client, workspace_ref)
|
|
217
|
+
similar = fetch_similar_entities(
|
|
218
|
+
client,
|
|
219
|
+
workspace_id=str(ws["id"]),
|
|
220
|
+
resource="stories",
|
|
221
|
+
field_name="title",
|
|
222
|
+
value=title,
|
|
223
|
+
)
|
|
224
|
+
if similar and not as_json and not force:
|
|
225
|
+
print_similar_entities(similar, label_key="human_id", entity="story")
|
|
226
|
+
confirmed = typer.confirm("Create anyway?", default=False)
|
|
227
|
+
if not confirmed:
|
|
228
|
+
stdout_console.print("aborted: existing entity has a similar name")
|
|
229
|
+
raise typer.Exit(code=1)
|
|
230
|
+
payload: dict[str, Any] = {
|
|
231
|
+
"title": title,
|
|
232
|
+
"priority": priority,
|
|
233
|
+
}
|
|
234
|
+
if description is not None:
|
|
235
|
+
payload["description"] = description
|
|
236
|
+
epic_id = _resolve_epic_id(client, epic)
|
|
237
|
+
if epic_id is not None:
|
|
238
|
+
payload["epic_id"] = epic_id
|
|
239
|
+
response = client.post(
|
|
240
|
+
f"/workspaces/{ws['id']}/stories",
|
|
241
|
+
json=payload,
|
|
242
|
+
)
|
|
243
|
+
except ApiError as exc:
|
|
244
|
+
exit_on_api_error(exc)
|
|
245
|
+
body = response.json()
|
|
246
|
+
|
|
247
|
+
if as_json:
|
|
248
|
+
if similar:
|
|
249
|
+
body = {**body, "warnings": {"similar": [s["id"] for s in similar]}}
|
|
250
|
+
print_json(body)
|
|
251
|
+
return
|
|
252
|
+
print_table(
|
|
253
|
+
columns=["field", "value"],
|
|
254
|
+
rows=[
|
|
255
|
+
["human_id", body["human_id"]],
|
|
256
|
+
["title", body["title"]],
|
|
257
|
+
["state", body["state"]],
|
|
258
|
+
["priority", body["priority"]],
|
|
259
|
+
["id", body["id"]],
|
|
260
|
+
],
|
|
261
|
+
title=f"created story {body['human_id']}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("show")
|
|
266
|
+
def show_story(
|
|
267
|
+
ref: str = typer.Argument(..., help="Story handle (KAN-123) or UUID."),
|
|
268
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""
|
|
271
|
+
Show a single story.
|
|
272
|
+
|
|
273
|
+
A 404 from the story lookup falls through to an epic lookup with
|
|
274
|
+
the same ref; if that hits, the user gets a hint to run
|
|
275
|
+
``kb epic show`` instead of a bare "not found" message.
|
|
276
|
+
"""
|
|
277
|
+
config = require_config()
|
|
278
|
+
with build_client(config) as client:
|
|
279
|
+
try:
|
|
280
|
+
body = resolve_story(client, ref)
|
|
281
|
+
except ApiRequestError as exc:
|
|
282
|
+
if exc.status_code == 404:
|
|
283
|
+
_suggest_alternative(client, ref, missing="story", alternative="epic")
|
|
284
|
+
raise typer.Exit(code=1) from exc
|
|
285
|
+
exit_on_api_error(exc)
|
|
286
|
+
except ApiError as exc:
|
|
287
|
+
exit_on_api_error(exc)
|
|
288
|
+
epic_label = _fetch_epic_label(client, body.get("epic_id"))
|
|
289
|
+
|
|
290
|
+
if as_json:
|
|
291
|
+
print_json(body)
|
|
292
|
+
return
|
|
293
|
+
print_table(
|
|
294
|
+
columns=["field", "value"],
|
|
295
|
+
rows=[
|
|
296
|
+
["human_id", body["human_id"]],
|
|
297
|
+
["title", body["title"]],
|
|
298
|
+
["state", body["state"]],
|
|
299
|
+
["priority", body["priority"]],
|
|
300
|
+
["epic", epic_label],
|
|
301
|
+
["epic_id", body["epic_id"] or ""],
|
|
302
|
+
["description", body["description"] or ""],
|
|
303
|
+
["id", body["id"]],
|
|
304
|
+
["version", str(body["version"])],
|
|
305
|
+
],
|
|
306
|
+
title=f"story {body['human_id']}",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _suggest_alternative(
|
|
311
|
+
client: ApiClient,
|
|
312
|
+
ref: str,
|
|
313
|
+
*,
|
|
314
|
+
missing: str,
|
|
315
|
+
alternative: str,
|
|
316
|
+
) -> None:
|
|
317
|
+
"""
|
|
318
|
+
Print a Rich error explaining that ``ref`` is the other entity type.
|
|
319
|
+
|
|
320
|
+
``missing`` is the entity the user asked for (``"story"`` or
|
|
321
|
+
``"epic"``). ``alternative`` is the kind of entity we probe with
|
|
322
|
+
the same ref. When the probe also misses we fall back to the plain
|
|
323
|
+
not-found message so the user still gets a clear signal.
|
|
324
|
+
"""
|
|
325
|
+
stderr_console.print(
|
|
326
|
+
f"[red]Error (404 not_found):[/red] {missing} {ref!r} not found."
|
|
327
|
+
)
|
|
328
|
+
other = try_resolve_other(client, ref, other=alternative)
|
|
329
|
+
if other is None:
|
|
330
|
+
return
|
|
331
|
+
handle = other.get("human_id", ref)
|
|
332
|
+
stderr_console.print(
|
|
333
|
+
f"{handle} is an {alternative} - try `kb {alternative} show {handle}`."
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fetch_epic_label(client: ApiClient, epic_id: str | None) -> str:
|
|
338
|
+
"""
|
|
339
|
+
Return a human-readable label for the story's epic, or ``""``.
|
|
340
|
+
|
|
341
|
+
Fetches ``GET /epics/{id}`` when ``epic_id`` is set and returns
|
|
342
|
+
``"{human_id} / {title}"``. Any failure (404, transport) falls back
|
|
343
|
+
to an empty string so the ``epic_id`` row below still shows the
|
|
344
|
+
UUID without the extra context and the CLI never crashes on a race.
|
|
345
|
+
"""
|
|
346
|
+
if not epic_id:
|
|
347
|
+
return ""
|
|
348
|
+
try:
|
|
349
|
+
response = client.get(f"/epics/{epic_id}")
|
|
350
|
+
except ApiError:
|
|
351
|
+
return ""
|
|
352
|
+
epic = response.json()
|
|
353
|
+
human_id = str(epic.get("human_id", ""))
|
|
354
|
+
title = str(epic.get("title", ""))
|
|
355
|
+
if human_id and title:
|
|
356
|
+
return f"{human_id} / {title}"
|
|
357
|
+
return human_id or title
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _launch_editor(initial_text: str) -> str:
|
|
361
|
+
"""
|
|
362
|
+
Launch ``$EDITOR`` on a temp markdown file and return the edited
|
|
363
|
+
contents.
|
|
364
|
+
|
|
365
|
+
The default editor is ``vim``. Tests point ``$EDITOR`` at
|
|
366
|
+
``/bin/true`` so the subprocess is a cheap no-op; the buffer then
|
|
367
|
+
matches ``initial_text`` and the caller short-circuits without a
|
|
368
|
+
PATCH.
|
|
369
|
+
"""
|
|
370
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
371
|
+
with tempfile.NamedTemporaryFile(
|
|
372
|
+
mode="w",
|
|
373
|
+
suffix=".md",
|
|
374
|
+
delete=False,
|
|
375
|
+
encoding="utf-8",
|
|
376
|
+
) as fh:
|
|
377
|
+
fh.write(initial_text)
|
|
378
|
+
temp_path = Path(fh.name)
|
|
379
|
+
try:
|
|
380
|
+
subprocess.run([editor, str(temp_path)], check=True)
|
|
381
|
+
return temp_path.read_text(encoding="utf-8")
|
|
382
|
+
finally:
|
|
383
|
+
temp_path.unlink(missing_ok=True)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command("edit")
|
|
387
|
+
def edit_story(
|
|
388
|
+
ref: str = typer.Argument(..., help="Story handle (KAN-123) or UUID."),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""
|
|
391
|
+
Edit a story's description in ``$EDITOR``.
|
|
392
|
+
|
|
393
|
+
Writes the current markdown body to a temp file, launches the
|
|
394
|
+
editor, and submits a PATCH with the new body if anything changed.
|
|
395
|
+
Exiting the editor without saving short-circuits with a friendly
|
|
396
|
+
"no changes, nothing to do." on stdout.
|
|
397
|
+
"""
|
|
398
|
+
config = require_config()
|
|
399
|
+
with build_client(config) as client:
|
|
400
|
+
try:
|
|
401
|
+
story = resolve_story(client, ref)
|
|
402
|
+
original = story["description"] or ""
|
|
403
|
+
edited = _launch_editor(original)
|
|
404
|
+
if edited == original:
|
|
405
|
+
stdout_console.print("no changes, nothing to do.")
|
|
406
|
+
return
|
|
407
|
+
response = client.patch_with_etag(
|
|
408
|
+
f"/stories/{story['id']}",
|
|
409
|
+
json={"description": edited},
|
|
410
|
+
)
|
|
411
|
+
except ApiError as exc:
|
|
412
|
+
exit_on_api_error(exc)
|
|
413
|
+
body = response.json()
|
|
414
|
+
|
|
415
|
+
stdout_console.print(
|
|
416
|
+
f"updated [bold]{body['human_id']}[/bold] (version {body['version']})"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
STATE_PROGRESSION: list[str] = [
|
|
421
|
+
"backlog",
|
|
422
|
+
"todo",
|
|
423
|
+
"in_progress",
|
|
424
|
+
"in_review",
|
|
425
|
+
"done",
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _next_state(current: str) -> str | None:
|
|
430
|
+
"""
|
|
431
|
+
Return the next natural state after ``current``.
|
|
432
|
+
|
|
433
|
+
Follows the forward progression from spec section 4.3
|
|
434
|
+
(``backlog -> todo -> in_progress -> in_review -> done``). Returns
|
|
435
|
+
``None`` when ``current`` is already ``done`` so callers can flash
|
|
436
|
+
a no-op message rather than wrapping back to ``backlog``.
|
|
437
|
+
"""
|
|
438
|
+
try:
|
|
439
|
+
index = STATE_PROGRESSION.index(current)
|
|
440
|
+
except ValueError:
|
|
441
|
+
return None
|
|
442
|
+
if index + 1 >= len(STATE_PROGRESSION):
|
|
443
|
+
return None
|
|
444
|
+
return STATE_PROGRESSION[index + 1]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.command("move")
|
|
448
|
+
def move_story(
|
|
449
|
+
ref: str = typer.Argument(..., help="Story handle (KAN-123) or UUID."),
|
|
450
|
+
to_state: str | None = typer.Argument(
|
|
451
|
+
None,
|
|
452
|
+
help=(
|
|
453
|
+
"Target state. Omit to advance one step along the natural "
|
|
454
|
+
"progression (backlog -> todo -> in_progress -> in_review "
|
|
455
|
+
"-> done)."
|
|
456
|
+
),
|
|
457
|
+
),
|
|
458
|
+
reason: str | None = typer.Option(
|
|
459
|
+
None,
|
|
460
|
+
"--reason",
|
|
461
|
+
help="Optional free-text reason recorded in the audit log.",
|
|
462
|
+
),
|
|
463
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
464
|
+
) -> None:
|
|
465
|
+
"""
|
|
466
|
+
Transition a story into a new state.
|
|
467
|
+
|
|
468
|
+
When ``to_state`` is omitted the story advances one step along the
|
|
469
|
+
natural progression. A story already in ``done`` is a no-op: the
|
|
470
|
+
command prints a friendly note and exits 0 rather than wrapping
|
|
471
|
+
back to ``backlog``.
|
|
472
|
+
"""
|
|
473
|
+
config = require_config()
|
|
474
|
+
with build_client(config) as client:
|
|
475
|
+
try:
|
|
476
|
+
story = resolve_story(client, ref)
|
|
477
|
+
except ApiError as exc:
|
|
478
|
+
exit_on_api_error(exc)
|
|
479
|
+
target_state = to_state
|
|
480
|
+
if target_state is None:
|
|
481
|
+
current = str(story["state"])
|
|
482
|
+
if current == "done":
|
|
483
|
+
stdout_console.print(
|
|
484
|
+
f"[yellow]{story['human_id']} is already done; "
|
|
485
|
+
"nothing to move.[/yellow]"
|
|
486
|
+
)
|
|
487
|
+
return
|
|
488
|
+
next_state = _next_state(current)
|
|
489
|
+
if next_state is None:
|
|
490
|
+
stdout_console.print(
|
|
491
|
+
f"[yellow]{story['human_id']} is in state "
|
|
492
|
+
f"{current!r}; no natural next state.[/yellow]"
|
|
493
|
+
)
|
|
494
|
+
return
|
|
495
|
+
target_state = next_state
|
|
496
|
+
payload: dict[str, Any] = {"to_state": target_state}
|
|
497
|
+
if reason is not None:
|
|
498
|
+
payload["reason"] = reason
|
|
499
|
+
try:
|
|
500
|
+
response = client.post_with_etag(
|
|
501
|
+
f"/stories/{story['id']}",
|
|
502
|
+
f"/stories/{story['id']}/transition",
|
|
503
|
+
json=payload,
|
|
504
|
+
)
|
|
505
|
+
except ApiError as exc:
|
|
506
|
+
exit_on_api_error(exc)
|
|
507
|
+
body = response.json()
|
|
508
|
+
|
|
509
|
+
if as_json:
|
|
510
|
+
print_json(body)
|
|
511
|
+
return
|
|
512
|
+
stdout_console.print(
|
|
513
|
+
f"moved [bold]{body['human_id']}[/bold] to "
|
|
514
|
+
f"[green]{body['state']}[/green] "
|
|
515
|
+
f"(version {body['version']})"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command("comment")
|
|
520
|
+
def comment_story(
|
|
521
|
+
ref: str = typer.Argument(..., help="Story handle (KAN-123) or UUID."),
|
|
522
|
+
body_text: str = typer.Argument(..., help="Comment body (markdown)."),
|
|
523
|
+
parent: str | None = typer.Option(
|
|
524
|
+
None,
|
|
525
|
+
"--parent",
|
|
526
|
+
help="Reply to an existing comment UUID (single level).",
|
|
527
|
+
),
|
|
528
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
529
|
+
) -> None:
|
|
530
|
+
"""
|
|
531
|
+
Post a comment on a story (or reply to an existing comment).
|
|
532
|
+
"""
|
|
533
|
+
config = require_config()
|
|
534
|
+
payload: dict[str, Any] = {"body": body_text}
|
|
535
|
+
if parent is not None:
|
|
536
|
+
payload["parent_id"] = parent
|
|
537
|
+
with build_client(config) as client:
|
|
538
|
+
try:
|
|
539
|
+
story = resolve_story(client, ref)
|
|
540
|
+
response = client.post(
|
|
541
|
+
f"/stories/{story['id']}/comments",
|
|
542
|
+
json=payload,
|
|
543
|
+
)
|
|
544
|
+
except ApiError as exc:
|
|
545
|
+
exit_on_api_error(exc)
|
|
546
|
+
body = response.json()
|
|
547
|
+
|
|
548
|
+
if as_json:
|
|
549
|
+
print_json(body)
|
|
550
|
+
return
|
|
551
|
+
stdout_console.print(
|
|
552
|
+
f"commented on [bold]{story['human_id']}[/bold] (comment {body['id']})"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@app.command("link")
|
|
557
|
+
def link_story(
|
|
558
|
+
source: str = typer.Argument(..., help="Source story handle or UUID."),
|
|
559
|
+
link_type: str = typer.Argument(
|
|
560
|
+
...,
|
|
561
|
+
help="Link type (relates_to|blocks|is_blocked_by|duplicates|is_duplicated_by).",
|
|
562
|
+
),
|
|
563
|
+
target: str = typer.Argument(..., help="Target story handle or UUID."),
|
|
564
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
565
|
+
) -> None:
|
|
566
|
+
"""
|
|
567
|
+
Create a typed linkage between two stories.
|
|
568
|
+
"""
|
|
569
|
+
config = require_config()
|
|
570
|
+
with build_client(config) as client:
|
|
571
|
+
try:
|
|
572
|
+
src = resolve_story(client, source)
|
|
573
|
+
tgt = resolve_story(client, target)
|
|
574
|
+
payload: dict[str, Any] = {
|
|
575
|
+
"source_type": "story",
|
|
576
|
+
"source_id": src["id"],
|
|
577
|
+
"target_type": "story",
|
|
578
|
+
"target_id": tgt["id"],
|
|
579
|
+
"link_type": link_type,
|
|
580
|
+
}
|
|
581
|
+
response = client.post("/linkages", json=payload)
|
|
582
|
+
except ApiError as exc:
|
|
583
|
+
exit_on_api_error(exc)
|
|
584
|
+
body = response.json()
|
|
585
|
+
|
|
586
|
+
if as_json:
|
|
587
|
+
print_json(body)
|
|
588
|
+
return
|
|
589
|
+
stdout_console.print(
|
|
590
|
+
f"linked [bold]{src['human_id']}[/bold] "
|
|
591
|
+
f"[cyan]{link_type}[/cyan] "
|
|
592
|
+
f"[bold]{tgt['human_id']}[/bold] "
|
|
593
|
+
f"(linkage {body['id']})"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@app.command("delete")
|
|
598
|
+
def delete_story(
|
|
599
|
+
ref: str = typer.Argument(..., help="Story handle (KAN-123) or UUID."),
|
|
600
|
+
yes: bool = typer.Option(
|
|
601
|
+
False,
|
|
602
|
+
"--yes",
|
|
603
|
+
help="Skip the confirmation prompt.",
|
|
604
|
+
),
|
|
605
|
+
) -> None:
|
|
606
|
+
"""
|
|
607
|
+
Soft-delete a story. Prompts for confirmation unless ``--yes``.
|
|
608
|
+
"""
|
|
609
|
+
config = require_config()
|
|
610
|
+
with build_client(config) as client:
|
|
611
|
+
try:
|
|
612
|
+
story = resolve_story(client, ref)
|
|
613
|
+
except ApiError as exc:
|
|
614
|
+
exit_on_api_error(exc)
|
|
615
|
+
if not yes:
|
|
616
|
+
confirmed = typer.confirm(
|
|
617
|
+
f"Soft-delete story {story['human_id']} ({story['title']!r})?"
|
|
618
|
+
)
|
|
619
|
+
if not confirmed:
|
|
620
|
+
stdout_console.print("aborted.")
|
|
621
|
+
raise typer.Exit(code=0)
|
|
622
|
+
try:
|
|
623
|
+
client.delete_with_etag(f"/stories/{story['id']}")
|
|
624
|
+
except ApiError as exc:
|
|
625
|
+
exit_on_api_error(exc)
|
|
626
|
+
|
|
627
|
+
stdout_console.print(f"soft-deleted [bold]{story['human_id']}[/bold].")
|