kd-cli 2.0.4__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.
Files changed (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,959 @@
1
+ """Issue namespace: /issue
2
+
3
+ Commands: list, create, show, update, delete, assign, unassign, assignees, move
4
+ Sub-namespaces: /issue/comment, /issue/link, /issue/attachment
5
+ API layer: YouTrack REST API (/api/issues)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from kd.cli.core.command_base import Command, CommandResult
14
+ from kd.cli.core.namespaces import Namespace
15
+ from kd.cli.core.option_registry import (
16
+ OptionDeclaration,
17
+ OptionLayer,
18
+ ResolvedOptions,
19
+ limit_option,
20
+ priority_option,
21
+ project_option,
22
+ state_option,
23
+ type_option,
24
+ )
25
+ from kd.cli.input import resolve_text_input
26
+ from kd.exceptions import ValidationError
27
+ from kd.models import Attachment, CustomField, Issue
28
+
29
+
30
+ _SEMANTIC_SLOTS = ("type", "priority", "state")
31
+
32
+
33
+ def _get_field_aliases(client: Any, project_key: str) -> dict[str, str]:
34
+ """Extract field aliases for a project from config."""
35
+ if not client or not hasattr(client, "config") or not client.config:
36
+ return {}
37
+ for source in (client.config.local_projects, client.config.global_projects):
38
+ proj_cfg = source.get(project_key)
39
+ if isinstance(proj_cfg, dict):
40
+ aliases = proj_cfg.get("field_aliases")
41
+ if isinstance(aliases, dict):
42
+ return {str(k): str(v) for k, v in aliases.items()}
43
+ return {}
44
+
45
+
46
+ def _build_custom_fields(
47
+ options: dict[str, Any],
48
+ client: Any = None,
49
+ project_key: str | None = None,
50
+ ) -> list[CustomField] | None:
51
+ """Build CustomField list from semantic slots and generic ``--field`` pairs.
52
+
53
+ Semantic slots (``--type``, ``--priority``, ``--state``) are resolved via
54
+ field discovery when a client is available, otherwise via hardcoded defaults.
55
+
56
+ Generic ``--field NAME=VALUE`` pairs are resolved via fuzzy field discovery
57
+ (requires client). On field name collision, generic overrides semantic.
58
+ """
59
+ slots = {name: options[name] for name in _SEMANTIC_SLOTS if options.get(name)}
60
+ field_pairs: list[str] = options.get("field", [])
61
+
62
+ if not slots and not field_pairs:
63
+ return None
64
+
65
+ semantic_fields: list[CustomField] = []
66
+ if slots and client and project_key:
67
+ aliases = _get_field_aliases(client, project_key)
68
+ semantic_fields = client.issues.resolve_custom_fields(project_key, slots, aliases) or []
69
+ elif slots:
70
+ # Fallback: hardcoded defaults (no client available)
71
+ for cli_name, yt_name in [("type", "Type"), ("priority", "Priority"), ("state", "State")]:
72
+ value = slots.get(cli_name)
73
+ if value:
74
+ semantic_fields.append(
75
+ CustomField.model_validate(
76
+ {
77
+ "name": yt_name,
78
+ "value": {"name": value},
79
+ "$type": "SingleEnumIssueCustomField",
80
+ }
81
+ )
82
+ )
83
+
84
+ generic_fields: list[CustomField] = []
85
+ if field_pairs:
86
+ if not client or not project_key:
87
+ raise ValidationError("--field requires a connected client with project context")
88
+ generic_fields = client.issues.resolve_generic_fields(project_key, field_pairs)
89
+
90
+ # Merge: generic overrides semantic on name collision
91
+ if semantic_fields and generic_fields:
92
+ generic_names = {f.name for f in generic_fields}
93
+ merged = [f for f in semantic_fields if f.name not in generic_names]
94
+ merged.extend(generic_fields)
95
+ return merged or None
96
+ return (semantic_fields + generic_fields) or None
97
+
98
+
99
+ def _extract_field(issue: Issue, field_name: str) -> str:
100
+ """Extract a custom field value by name from an issue."""
101
+ for cf in issue.custom_fields:
102
+ if cf.name == field_name and cf.value:
103
+ if isinstance(cf.value, dict):
104
+ return str(cf.value.get("name", ""))
105
+ return str(cf.value)
106
+ return ""
107
+
108
+
109
+ # Known field name variants for display columns.
110
+ _STATE_NAMES = {"State", "Stage"}
111
+ _TYPE_NAMES = {"Type"}
112
+ _PRIORITY_NAMES = {"Priority"}
113
+
114
+
115
+ def _extract_semantic_field(issue: Issue, candidates: set[str]) -> str:
116
+ """Extract a custom field value by matching any of the candidate names."""
117
+ for cf in issue.custom_fields:
118
+ if cf.name in candidates and cf.value:
119
+ if isinstance(cf.value, dict):
120
+ return str(cf.value.get("name", ""))
121
+ return str(cf.value)
122
+ return ""
123
+
124
+
125
+ def _issue_to_row(issue: Issue) -> dict[str, Any]:
126
+ """Convert an Issue to a flat dict for table output."""
127
+ return {
128
+ "id": issue.id_readable,
129
+ "summary": issue.summary,
130
+ "project": issue.project.name if issue.project else "",
131
+ "type": _extract_semantic_field(issue, _TYPE_NAMES),
132
+ "priority": _extract_semantic_field(issue, _PRIORITY_NAMES),
133
+ "state": _extract_semantic_field(issue, _STATE_NAMES),
134
+ }
135
+
136
+
137
+ _RICH_SECTIONS = {"description", "comments", "links", "attachments"}
138
+
139
+
140
+ def _issue_to_detail(issue: Issue, sections: set[str] | None = None) -> dict[str, Any]:
141
+ """Convert an Issue to a detailed dict.
142
+
143
+ ``sections`` controls which rich sections to include. When *None*,
144
+ all available sections are included. Pass an empty set for brief mode.
145
+ """
146
+ include = sections if sections is not None else _RICH_SECTIONS
147
+
148
+ result: dict[str, Any] = {
149
+ "id": issue.id_readable,
150
+ "summary": issue.summary,
151
+ "project": issue.project.name if issue.project else "",
152
+ "reporter": issue.reporter.name if issue.reporter else "",
153
+ "created": issue.created,
154
+ }
155
+ for cf in issue.custom_fields:
156
+ name = cf.name.lower().replace(" ", "_")
157
+ if cf.value and isinstance(cf.value, dict):
158
+ result[name] = cf.value.get("name", "")
159
+ elif cf.value is not None:
160
+ result[name] = cf.value
161
+ else:
162
+ result[name] = ""
163
+
164
+ if "description" in include:
165
+ result["description"] = issue.description or ""
166
+
167
+ if "comments" in include and issue.comments is not None:
168
+ result["comments"] = [
169
+ {
170
+ "author": c.author.name if c.author else "",
171
+ "text": c.text,
172
+ "created": c.created,
173
+ }
174
+ for c in issue.comments[-5:]
175
+ ]
176
+
177
+ if "links" in include and issue.links is not None:
178
+ rows: list[dict[str, str]] = []
179
+ for link in issue.links:
180
+ link_type = link.get("linkType", {}).get("name", "")
181
+ direction = link.get("direction", "")
182
+ for linked in link.get("issues", []):
183
+ rows.append(
184
+ {
185
+ "type": link_type,
186
+ "direction": direction,
187
+ "issue": linked.get("idReadable", ""),
188
+ "summary": linked.get("summary", ""),
189
+ }
190
+ )
191
+ if rows:
192
+ result["links"] = rows
193
+
194
+ if "attachments" in include and issue.attachments is not None:
195
+ result["attachments"] = [
196
+ {
197
+ "id": a.id,
198
+ "name": a.name,
199
+ "size": a.size,
200
+ }
201
+ for a in issue.attachments
202
+ ]
203
+
204
+ return result
205
+
206
+
207
+ # --- Issue Commands ---
208
+
209
+
210
+ class IssueListCommand(Command):
211
+ """List issues."""
212
+
213
+ def __init__(self) -> None:
214
+ super().__init__(
215
+ "list",
216
+ "List issues",
217
+ requires_client=True,
218
+ usage="kd /issue list [--project P] [--query Q] [--limit N]",
219
+ examples=[
220
+ "kd /issue list --project FB",
221
+ 'kd /issue list --query "State: Open"',
222
+ "kd /issue list --project FB --limit 10",
223
+ ],
224
+ see_also=["/issue show", "/issue create"],
225
+ )
226
+
227
+ def _register_options(self) -> None:
228
+ self.register_option(project_option())
229
+ self.register_option(
230
+ OptionDeclaration(
231
+ name="query",
232
+ short_name="q",
233
+ layer=OptionLayer.COMMAND,
234
+ help_text="YouTrack search query",
235
+ )
236
+ )
237
+ self.register_option(limit_option())
238
+
239
+ def execute(
240
+ self, args: list[str], options: ResolvedOptions, client: Any = None
241
+ ) -> CommandResult:
242
+ parts: list[str] = []
243
+ project = options.command.get("project")
244
+ if project:
245
+ parts.append(f"project: {project}")
246
+ query = options.command.get("query")
247
+ if query:
248
+ parts.append(query)
249
+ full_query = " ".join(parts)
250
+ limit = options.command.get("limit", 50)
251
+ issues = client.issues.list(query=full_query, top=limit)
252
+ return [_issue_to_row(i) for i in issues]
253
+
254
+
255
+ class IssueCreateCommand(Command):
256
+ """Create an issue."""
257
+
258
+ def __init__(self) -> None:
259
+ super().__init__(
260
+ "create",
261
+ "Create an issue",
262
+ requires_client=True,
263
+ usage='kd /issue create "Summary" --project P [--field NAME=VALUE] [--attach FILE]',
264
+ long_description="Summary is the issue title. --project is the project key (e.g. FB).",
265
+ examples=[
266
+ 'kd /issue create "Fix router config" --project FB --type Task',
267
+ 'kd /issue create "Bug report" -p FB --attach screenshot.png --attach log.txt',
268
+ 'kd /issue create "Sprint task" -p FB --field "Sprint=Sprint 47"',
269
+ 'kd /issue create "Design doc" -p FB --description @design.md',
270
+ ],
271
+ see_also=["/issue list", "/issue show", "/issue/attachment add", "/project/field list"],
272
+ )
273
+
274
+ def _register_options(self) -> None:
275
+ self.register_option(project_option(required=True))
276
+ self.register_option(
277
+ OptionDeclaration(
278
+ name="description",
279
+ short_name="d",
280
+ layer=OptionLayer.COMMAND,
281
+ help_text="Issue description (Markdown). Use @file.md to read from file, or @- / - for stdin",
282
+ )
283
+ )
284
+ self.register_option(type_option())
285
+ self.register_option(priority_option())
286
+ self.register_option(state_option())
287
+ self.register_option(
288
+ OptionDeclaration(
289
+ name="field",
290
+ short_name="F",
291
+ layer=OptionLayer.COMMAND,
292
+ is_list=True,
293
+ help_text="Custom field as NAME=VALUE (repeatable)",
294
+ )
295
+ )
296
+ self.register_option(
297
+ OptionDeclaration(
298
+ name="attach",
299
+ short_name="a",
300
+ layer=OptionLayer.COMMAND,
301
+ is_list=True,
302
+ help_text="File to attach (repeatable)",
303
+ )
304
+ )
305
+
306
+ def execute(
307
+ self, args: list[str], options: ResolvedOptions, client: Any = None
308
+ ) -> CommandResult:
309
+ if not args:
310
+ raise ValidationError('Usage: kd /issue create "Summary" --project P')
311
+ project_key = options.command["project"]
312
+ summary = " ".join(args)
313
+ description = resolve_text_input(options.command.get("description"))
314
+ custom_fields = _build_custom_fields(
315
+ options.command, client=client, project_key=project_key
316
+ )
317
+ attach_paths = options.command.get("attach", [])
318
+ file_paths = [Path(p) for p in attach_paths] if attach_paths else None
319
+
320
+ if file_paths:
321
+ issue = client.create_issue_with_attachments(
322
+ project=project_key,
323
+ summary=summary,
324
+ description=description,
325
+ custom_fields=custom_fields,
326
+ file_paths=file_paths,
327
+ )
328
+ else:
329
+ issue = client.issues.create(
330
+ project=project_key,
331
+ summary=summary,
332
+ description=description,
333
+ custom_fields=custom_fields,
334
+ )
335
+ return _issue_to_detail(issue)
336
+
337
+
338
+ class IssueShowCommand(Command):
339
+ """Show issue details."""
340
+
341
+ def __init__(self) -> None:
342
+ super().__init__(
343
+ "show",
344
+ "Show issue details",
345
+ requires_client=True,
346
+ usage="kd /issue show ID [--brief|--rich] [--with S] [--without S]",
347
+ examples=[
348
+ "kd /issue show FB-42",
349
+ "kd /issue show FB-42 --brief",
350
+ "kd /issue show FB-42 --with comments,attachments",
351
+ "kd /issue show FB-42 --without comments",
352
+ ],
353
+ see_also=["/issue list", "/issue update"],
354
+ )
355
+
356
+ def _register_options(self) -> None:
357
+ self.register_option(
358
+ OptionDeclaration(
359
+ name="brief",
360
+ layer=OptionLayer.COMMAND,
361
+ type=bool,
362
+ default=False,
363
+ help_text="Show minimal view (header + custom fields only)",
364
+ )
365
+ )
366
+ self.register_option(
367
+ OptionDeclaration(
368
+ name="rich",
369
+ layer=OptionLayer.COMMAND,
370
+ type=bool,
371
+ default=False,
372
+ help_text="Show expanded view with all sections (default)",
373
+ )
374
+ )
375
+ self.register_option(
376
+ OptionDeclaration(
377
+ name="with",
378
+ layer=OptionLayer.COMMAND,
379
+ help_text="Comma-separated sections to include: description,comments,links,attachments",
380
+ )
381
+ )
382
+ self.register_option(
383
+ OptionDeclaration(
384
+ name="without",
385
+ layer=OptionLayer.COMMAND,
386
+ help_text="Comma-separated sections to exclude",
387
+ )
388
+ )
389
+
390
+ def execute(
391
+ self, args: list[str], options: ResolvedOptions, client: Any = None
392
+ ) -> CommandResult:
393
+ if not args:
394
+ raise ValidationError("Usage: kd /issue show ID")
395
+ is_brief = options.command.get("brief", False)
396
+ with_opt = options.command.get("with")
397
+ without_opt = options.command.get("without")
398
+
399
+ # Determine detail level for API call
400
+ detail = "brief" if is_brief and not with_opt else "rich"
401
+ issue = client.issues.get(args[0], detail=detail)
402
+
403
+ # Determine sections to render
404
+ if is_brief:
405
+ sections: set[str] = set()
406
+ else:
407
+ sections = set(_RICH_SECTIONS)
408
+
409
+ if with_opt:
410
+ for s in with_opt.split(","):
411
+ s = s.strip()
412
+ if s in _RICH_SECTIONS:
413
+ sections.add(s)
414
+ if without_opt:
415
+ for s in without_opt.split(","):
416
+ s = s.strip()
417
+ sections.discard(s)
418
+
419
+ return _issue_to_detail(issue, sections=sections)
420
+
421
+
422
+ class IssueUpdateCommand(Command):
423
+ """Update an issue."""
424
+
425
+ def __init__(self) -> None:
426
+ super().__init__(
427
+ "update",
428
+ "Update an issue",
429
+ requires_client=True,
430
+ usage="kd /issue update ID [--title T] [--field NAME=VALUE]",
431
+ examples=[
432
+ 'kd /issue update FB-42 --title "New title"',
433
+ "kd /issue update FB-42 --state Open --priority Critical",
434
+ 'kd /issue update FB-42 --field "Sprint=Sprint 48"',
435
+ "kd /issue update FB-42 --description @updated.md",
436
+ ],
437
+ see_also=["/issue show", "/project/field list"],
438
+ )
439
+
440
+ def _register_options(self) -> None:
441
+ self.register_option(
442
+ OptionDeclaration(
443
+ name="title",
444
+ short_name="s",
445
+ layer=OptionLayer.COMMAND,
446
+ help_text="Issue title",
447
+ aliases=["summary"],
448
+ )
449
+ )
450
+ self.register_option(
451
+ OptionDeclaration(
452
+ name="description",
453
+ short_name="d",
454
+ layer=OptionLayer.COMMAND,
455
+ help_text="New description (Markdown). Use @file.md to read from file, or @- / - for stdin",
456
+ )
457
+ )
458
+ self.register_option(type_option())
459
+ self.register_option(priority_option())
460
+ self.register_option(state_option())
461
+ self.register_option(
462
+ OptionDeclaration(
463
+ name="field",
464
+ short_name="F",
465
+ layer=OptionLayer.COMMAND,
466
+ is_list=True,
467
+ help_text="Custom field as NAME=VALUE (repeatable)",
468
+ )
469
+ )
470
+
471
+ def execute(
472
+ self, args: list[str], options: ResolvedOptions, client: Any = None
473
+ ) -> CommandResult:
474
+ if not args:
475
+ raise ValidationError("Usage: kd /issue update ID [--title T] ...")
476
+ issue_id = args[0]
477
+ # Extract project key from issue ID (e.g., "FB-42" -> "FB")
478
+ project_key = issue_id.rsplit("-", 1)[0] if "-" in issue_id else None
479
+ title = options.command.get("title")
480
+ description = resolve_text_input(options.command.get("description"))
481
+ custom_fields = _build_custom_fields(
482
+ options.command, client=client, project_key=project_key
483
+ )
484
+ if not title and not description and not custom_fields:
485
+ raise ValidationError("At least one field must be specified for update")
486
+ issue = client.issues.update(
487
+ issue_id,
488
+ summary=title,
489
+ description=description,
490
+ custom_fields=custom_fields,
491
+ )
492
+ return _issue_to_detail(issue)
493
+
494
+
495
+ class IssueDeleteCommand(Command):
496
+ """Delete an issue."""
497
+
498
+ def __init__(self) -> None:
499
+ super().__init__(
500
+ "delete",
501
+ "Delete an issue",
502
+ requires_client=True,
503
+ usage="kd /issue delete ID",
504
+ examples=["kd /issue delete FB-42"],
505
+ see_also=["/issue list"],
506
+ )
507
+
508
+ def execute(
509
+ self, args: list[str], options: ResolvedOptions, client: Any = None
510
+ ) -> CommandResult:
511
+ if not args:
512
+ raise ValidationError("Usage: kd /issue delete ID")
513
+ issue_id = args[0]
514
+ client.issues.delete(issue_id)
515
+ return {"ok": True, "action": "delete", "issue": issue_id}
516
+
517
+
518
+ # --- Comment Sub-namespace ---
519
+
520
+
521
+ class CommentListCommand(Command):
522
+ """List comments on an issue."""
523
+
524
+ def __init__(self) -> None:
525
+ super().__init__(
526
+ "list",
527
+ "List comments on an issue",
528
+ requires_client=True,
529
+ usage="kd /issue/comment list ID",
530
+ examples=["kd /issue/comment list FB-42"],
531
+ see_also=["/issue/comment add", "/issue show"],
532
+ )
533
+
534
+ def execute(
535
+ self, args: list[str], options: ResolvedOptions, client: Any = None
536
+ ) -> CommandResult:
537
+ if not args:
538
+ raise ValidationError("Usage: kd /issue/comment list ID")
539
+ comments = client.issues.list_comments(args[0])
540
+ return [
541
+ {
542
+ "id": c.id,
543
+ "author": c.author.name if c.author else "",
544
+ "text": c.text,
545
+ "created": c.created,
546
+ }
547
+ for c in comments
548
+ ]
549
+
550
+
551
+ class CommentAddCommand(Command):
552
+ """Add a comment to an issue."""
553
+
554
+ def __init__(self) -> None:
555
+ super().__init__(
556
+ "add",
557
+ "Add a comment to an issue",
558
+ requires_client=True,
559
+ usage='kd /issue/comment add ID "Text"',
560
+ examples=[
561
+ 'kd /issue/comment add FB-42 "Fixed in latest build"',
562
+ "kd /issue/comment add FB-42 @comment.md",
563
+ "echo 'Done' | kd /issue/comment add FB-42 @-",
564
+ ],
565
+ see_also=["/issue/comment list"],
566
+ )
567
+
568
+ def execute(
569
+ self, args: list[str], options: ResolvedOptions, client: Any = None
570
+ ) -> CommandResult:
571
+ if len(args) < 2:
572
+ raise ValidationError('Usage: kd /issue/comment add ID "Text"')
573
+ issue_id = args[0]
574
+ text = resolve_text_input(" ".join(args[1:]))
575
+ comment = client.issues.add_comment(issue_id, text)
576
+ return {
577
+ "id": comment.id,
578
+ "author": comment.author.name if comment.author else "",
579
+ "text": comment.text,
580
+ }
581
+
582
+
583
+ # --- Link Sub-namespace ---
584
+
585
+
586
+ class LinkListCommand(Command):
587
+ """List links on an issue."""
588
+
589
+ def __init__(self) -> None:
590
+ super().__init__(
591
+ "list",
592
+ "List links on an issue",
593
+ requires_client=True,
594
+ usage="kd /issue/link list ID",
595
+ examples=["kd /issue/link list FB-42"],
596
+ see_also=["/issue/link add"],
597
+ )
598
+
599
+ def execute(
600
+ self, args: list[str], options: ResolvedOptions, client: Any = None
601
+ ) -> CommandResult:
602
+ if not args:
603
+ raise ValidationError("Usage: kd /issue/link list ID")
604
+ links = client.issues.list_links(args[0])
605
+ rows: list[dict[str, Any]] = []
606
+ for link in links:
607
+ link_type = link.get("linkType", {}).get("name", "")
608
+ direction = link.get("direction", "")
609
+ for issue in link.get("issues", []):
610
+ rows.append(
611
+ {
612
+ "type": link_type,
613
+ "direction": direction,
614
+ "issue": issue.get("idReadable", ""),
615
+ "summary": issue.get("summary", ""),
616
+ }
617
+ )
618
+ return rows
619
+
620
+
621
+ class LinkAddCommand(Command):
622
+ """Link two issues."""
623
+
624
+ def __init__(self) -> None:
625
+ super().__init__(
626
+ "add",
627
+ "Link two issues",
628
+ requires_client=True,
629
+ usage="kd /issue/link add ID TYPE TARGET",
630
+ long_description="TYPE is one of: subtask-of, parent-for, depends-on, "
631
+ "required-for, duplicates, duplicated-by, relates-to.",
632
+ examples=[
633
+ "kd /issue/link add FB-42 subtask-of FB-1",
634
+ "kd /issue/link add FB-42 relates-to FB-43",
635
+ ],
636
+ see_also=["/issue/link list"],
637
+ )
638
+
639
+ def execute(
640
+ self, args: list[str], options: ResolvedOptions, client: Any = None
641
+ ) -> CommandResult:
642
+ if len(args) < 3:
643
+ raise ValidationError("Usage: kd /issue/link add ID TYPE TARGET")
644
+ issue_id = args[0]
645
+ link_type = args[1]
646
+ target_id = args[2]
647
+ client.issues.link(issue_id, link_type, target_id)
648
+ return {
649
+ "ok": True,
650
+ "action": "link",
651
+ "issue": issue_id,
652
+ "type": link_type,
653
+ "target": target_id,
654
+ }
655
+
656
+
657
+ # --- Assign / Unassign / Move ---
658
+
659
+
660
+ class IssueAssignCommand(Command):
661
+ """Assign an issue to a user."""
662
+
663
+ def __init__(self) -> None:
664
+ super().__init__(
665
+ "assign",
666
+ "Assign an issue to a user",
667
+ requires_client=True,
668
+ usage="kd /issue assign ID USER",
669
+ long_description="USER can be a login, email, display name, or 'me'.",
670
+ examples=[
671
+ "kd /issue assign FB-42 alice",
672
+ "kd /issue assign FB-42 me",
673
+ ],
674
+ see_also=["/issue unassign", "/issue assignees"],
675
+ )
676
+
677
+ def execute(
678
+ self, args: list[str], options: ResolvedOptions, client: Any = None
679
+ ) -> CommandResult:
680
+ if len(args) < 2:
681
+ raise ValidationError("Usage: kd /issue assign ID USER")
682
+ user = client.issues.assign(args[0], args[1])
683
+ return {"ok": True, "action": "assign", "issue": args[0], "assignee": user.login}
684
+
685
+
686
+ class IssueUnassignCommand(Command):
687
+ """Remove the assignee from an issue."""
688
+
689
+ def __init__(self) -> None:
690
+ super().__init__(
691
+ "unassign",
692
+ "Remove the assignee from an issue",
693
+ requires_client=True,
694
+ usage="kd /issue unassign ID",
695
+ examples=["kd /issue unassign FB-42"],
696
+ see_also=["/issue assign"],
697
+ )
698
+
699
+ def execute(
700
+ self, args: list[str], options: ResolvedOptions, client: Any = None
701
+ ) -> CommandResult:
702
+ if not args:
703
+ raise ValidationError("Usage: kd /issue unassign ID")
704
+ client.issues.unassign(args[0])
705
+ return {"ok": True, "action": "unassign", "issue": args[0]}
706
+
707
+
708
+ class IssueAssigneesCommand(Command):
709
+ """List users that can be assigned."""
710
+
711
+ def __init__(self) -> None:
712
+ super().__init__(
713
+ "assignees",
714
+ "List users that can be assigned",
715
+ requires_client=True,
716
+ usage="kd /issue assignees [--match QUERY]",
717
+ examples=[
718
+ "kd /issue assignees",
719
+ "kd /issue assignees --match alice",
720
+ ],
721
+ see_also=["/issue assign"],
722
+ )
723
+
724
+ def _register_options(self) -> None:
725
+ self.register_option(
726
+ OptionDeclaration(
727
+ name="match",
728
+ short_name="m",
729
+ layer=OptionLayer.COMMAND,
730
+ help_text="Filter users by name or login",
731
+ )
732
+ )
733
+
734
+ def execute(
735
+ self, args: list[str], options: ResolvedOptions, client: Any = None
736
+ ) -> CommandResult:
737
+ query = options.command.get("match", "")
738
+ users = client.issues.list_assignees(query=query)
739
+ return [
740
+ {
741
+ "login": u.login,
742
+ "name": u.name,
743
+ "email": u.email or "",
744
+ }
745
+ for u in users
746
+ ]
747
+
748
+
749
+ class IssueMoveCommand(Command):
750
+ """Move an issue to another project."""
751
+
752
+ def __init__(self) -> None:
753
+ super().__init__(
754
+ "move",
755
+ "Move an issue to another project",
756
+ requires_client=True,
757
+ usage="kd /issue move ID TARGET_PROJECT",
758
+ examples=["kd /issue move FB-42 BACKEND"],
759
+ see_also=["/issue show"],
760
+ )
761
+
762
+ def execute(
763
+ self, args: list[str], options: ResolvedOptions, client: Any = None
764
+ ) -> CommandResult:
765
+ if len(args) < 2:
766
+ raise ValidationError("Usage: kd /issue move ID TARGET_PROJECT")
767
+ client.issues.move(args[0], args[1])
768
+ return {"ok": True, "action": "move", "issue": args[0], "project": args[1]}
769
+
770
+
771
+ # --- Attachment Sub-namespace ---
772
+
773
+
774
+ def _attachment_to_row(a: Attachment) -> dict[str, Any]:
775
+ """Convert an Attachment to a flat dict for table output."""
776
+ return {
777
+ "id": a.id,
778
+ "name": a.name,
779
+ "size": a.size,
780
+ "mime_type": a.mime_type or "",
781
+ "author": a.author.name if a.author else "",
782
+ "created": a.created,
783
+ }
784
+
785
+
786
+ class AttachmentListCommand(Command):
787
+ """List attachments on an issue."""
788
+
789
+ def __init__(self) -> None:
790
+ super().__init__(
791
+ "list",
792
+ "List attachments on an issue",
793
+ requires_client=True,
794
+ usage="kd /issue/attachment list ID",
795
+ examples=["kd /issue/attachment list FB-42"],
796
+ see_also=["/issue/attachment add", "/issue/attachment download"],
797
+ )
798
+
799
+ def execute(
800
+ self, args: list[str], options: ResolvedOptions, client: Any = None
801
+ ) -> CommandResult:
802
+ if not args:
803
+ raise ValidationError("Usage: kd /issue/attachment list ID")
804
+ attachments = client.attachments.list(args[0])
805
+ return [_attachment_to_row(a) for a in attachments]
806
+
807
+
808
+ class AttachmentAddCommand(Command):
809
+ """Add attachments to an issue."""
810
+
811
+ def __init__(self) -> None:
812
+ super().__init__(
813
+ "add",
814
+ "Add attachments to an issue",
815
+ requires_client=True,
816
+ usage="kd /issue/attachment add ID FILE [FILE...]",
817
+ examples=[
818
+ "kd /issue/attachment add FB-42 screenshot.png",
819
+ "kd /issue/attachment add FB-42 log.txt report.pdf",
820
+ ],
821
+ see_also=["/issue/attachment list"],
822
+ )
823
+
824
+ def execute(
825
+ self, args: list[str], options: ResolvedOptions, client: Any = None
826
+ ) -> CommandResult:
827
+ if len(args) < 2:
828
+ raise ValidationError("Usage: kd /issue/attachment add ID FILE [FILE...]")
829
+ issue_id = args[0]
830
+ file_paths = [Path(p) for p in args[1:]]
831
+ attachments = client.attachments.add(issue_id, file_paths)
832
+ return [_attachment_to_row(a) for a in attachments]
833
+
834
+
835
+ class AttachmentShowCommand(Command):
836
+ """Show attachment metadata."""
837
+
838
+ def __init__(self) -> None:
839
+ super().__init__(
840
+ "show",
841
+ "Show attachment metadata",
842
+ requires_client=True,
843
+ usage="kd /issue/attachment show ISSUE_ID ATTACHMENT_ID",
844
+ examples=["kd /issue/attachment show FB-42 att-1"],
845
+ see_also=["/issue/attachment list", "/issue/attachment download"],
846
+ )
847
+
848
+ def execute(
849
+ self, args: list[str], options: ResolvedOptions, client: Any = None
850
+ ) -> CommandResult:
851
+ if len(args) < 2:
852
+ raise ValidationError("Usage: kd /issue/attachment show ISSUE_ID ATTACHMENT_ID")
853
+ a = client.attachments.get(args[0], args[1])
854
+ return {
855
+ "id": a.id,
856
+ "name": a.name,
857
+ "size": a.size,
858
+ "mime_type": a.mime_type or "",
859
+ "author": a.author.name if a.author else "",
860
+ "created": a.created,
861
+ "url": a.url or "",
862
+ }
863
+
864
+
865
+ class AttachmentDownloadCommand(Command):
866
+ """Download an attachment."""
867
+
868
+ def __init__(self) -> None:
869
+ super().__init__(
870
+ "download",
871
+ "Download an attachment",
872
+ requires_client=True,
873
+ usage="kd /issue/attachment download ISSUE_ID ATTACHMENT_ID [--to DIR]",
874
+ examples=[
875
+ "kd /issue/attachment download FB-42 att-1",
876
+ "kd /issue/attachment download FB-42 att-1 --to ./artifacts",
877
+ ],
878
+ see_also=["/issue/attachment list", "/issue/attachment show"],
879
+ )
880
+
881
+ def _register_options(self) -> None:
882
+ self.register_option(
883
+ OptionDeclaration(
884
+ name="to",
885
+ layer=OptionLayer.COMMAND,
886
+ help_text="Target directory for download (default: current directory)",
887
+ )
888
+ )
889
+
890
+ def execute(
891
+ self, args: list[str], options: ResolvedOptions, client: Any = None
892
+ ) -> CommandResult:
893
+ if len(args) < 2:
894
+ raise ValidationError("Usage: kd /issue/attachment download ISSUE_ID ATTACHMENT_ID")
895
+ target_dir = None
896
+ to_value = options.command.get("to")
897
+ if to_value:
898
+ target_dir = Path(to_value)
899
+ dest = client.attachments.download(args[0], args[1], target_dir=target_dir)
900
+ return {"ok": True, "action": "download", "issue": args[0], "path": str(dest)}
901
+
902
+
903
+ class AttachmentDeleteCommand(Command):
904
+ """Delete an attachment."""
905
+
906
+ def __init__(self) -> None:
907
+ super().__init__(
908
+ "delete",
909
+ "Delete an attachment",
910
+ requires_client=True,
911
+ usage="kd /issue/attachment delete ISSUE_ID ATTACHMENT_ID",
912
+ examples=["kd /issue/attachment delete FB-42 att-1"],
913
+ see_also=["/issue/attachment list"],
914
+ )
915
+
916
+ def execute(
917
+ self, args: list[str], options: ResolvedOptions, client: Any = None
918
+ ) -> CommandResult:
919
+ if len(args) < 2:
920
+ raise ValidationError("Usage: kd /issue/attachment delete ISSUE_ID ATTACHMENT_ID")
921
+ client.attachments.delete(args[0], args[1])
922
+ return {"ok": True, "action": "delete", "issue": args[0], "attachment": args[1]}
923
+
924
+
925
+ # --- Namespace Factory ---
926
+
927
+
928
+ def create_issue_namespace() -> Namespace:
929
+ """Build the /issue namespace tree."""
930
+ ns = Namespace("issue", "Issue management", path="issue")
931
+ ns.register_command("list", IssueListCommand())
932
+ ns.register_command("create", IssueCreateCommand())
933
+ ns.register_command("show", IssueShowCommand())
934
+ ns.register_command("update", IssueUpdateCommand())
935
+ ns.register_command("delete", IssueDeleteCommand())
936
+ ns.register_command("assign", IssueAssignCommand())
937
+ ns.register_command("unassign", IssueUnassignCommand())
938
+ ns.register_command("assignees", IssueAssigneesCommand())
939
+ ns.register_command("move", IssueMoveCommand())
940
+
941
+ comment_ns = Namespace("comment", "Issue comments", path="issue/comment")
942
+ comment_ns.register_command("list", CommentListCommand())
943
+ comment_ns.register_command("add", CommentAddCommand())
944
+ ns.register_sub_namespace(comment_ns)
945
+
946
+ link_ns = Namespace("link", "Issue links", path="issue/link")
947
+ link_ns.register_command("list", LinkListCommand())
948
+ link_ns.register_command("add", LinkAddCommand())
949
+ ns.register_sub_namespace(link_ns)
950
+
951
+ attachment_ns = Namespace("attachment", "Issue attachments", path="issue/attachment")
952
+ attachment_ns.register_command("list", AttachmentListCommand())
953
+ attachment_ns.register_command("add", AttachmentAddCommand())
954
+ attachment_ns.register_command("show", AttachmentShowCommand())
955
+ attachment_ns.register_command("download", AttachmentDownloadCommand())
956
+ attachment_ns.register_command("delete", AttachmentDeleteCommand())
957
+ ns.register_sub_namespace(attachment_ns)
958
+
959
+ return ns