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.
@@ -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].")