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,337 @@
1
+ """Tag namespace: /tag
2
+
3
+ Commands: list, show, create, rename, delete, add, remove, issues, articles
4
+ API layer: YouTrack REST API (/api/tags, /api/issues/*/tags, /api/articles/*/tags)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from kd.cli.core.command_base import Command, CommandResult
12
+ from kd.cli.core.namespaces import Namespace
13
+ from kd.cli.core.option_registry import (
14
+ OptionDeclaration,
15
+ OptionLayer,
16
+ ResolvedOptions,
17
+ limit_option,
18
+ project_option,
19
+ )
20
+ from kd.exceptions import ValidationError
21
+ from kd.models import Article, Issue, Tag
22
+
23
+
24
+ def _tag_to_row(tag: Tag) -> dict[str, Any]:
25
+ """Convert a Tag to a flat dict for table output."""
26
+ return {
27
+ "name": tag.name,
28
+ "owner": tag.owner.name if tag.owner else "",
29
+ "color": tag.color.id if tag.color else "",
30
+ "untag_on_resolve": tag.untag_on_resolve,
31
+ }
32
+
33
+
34
+ def _tag_to_detail(tag: Tag) -> dict[str, Any]:
35
+ """Convert a Tag to a detailed dict for show output."""
36
+ result: dict[str, Any] = {
37
+ "name": tag.name,
38
+ "owner": tag.owner.name if tag.owner else "",
39
+ "untag_on_resolve": tag.untag_on_resolve,
40
+ }
41
+ if tag.color:
42
+ result["color"] = tag.color.id
43
+ if tag.color.background:
44
+ result["background"] = tag.color.background
45
+ if tag.color.foreground:
46
+ result["foreground"] = tag.color.foreground
47
+ return result
48
+
49
+
50
+ def _issue_to_row(issue: Issue) -> dict[str, Any]:
51
+ """Convert a tagged Issue to a flat dict for table output."""
52
+ return {
53
+ "id": issue.id_readable,
54
+ "summary": issue.summary,
55
+ "project": issue.project.name if issue.project else "",
56
+ }
57
+
58
+
59
+ def _article_to_row(article: Article) -> dict[str, Any]:
60
+ """Convert a tagged Article to a flat dict for table output."""
61
+ return {
62
+ "id": article.id_readable,
63
+ "summary": article.summary,
64
+ "project": article.project.name if article.project else "",
65
+ }
66
+
67
+
68
+ # --- Tag Commands ---
69
+
70
+
71
+ class TagListCommand(Command):
72
+ """List all tags."""
73
+
74
+ def __init__(self) -> None:
75
+ super().__init__(
76
+ "list",
77
+ "List tags",
78
+ requires_client=True,
79
+ usage="kd /tag list [--limit N]",
80
+ examples=["kd /tag list", "kd /tag list --limit 100"],
81
+ see_also=["/tag show"],
82
+ )
83
+
84
+ def _register_options(self) -> None:
85
+ self.register_option(limit_option())
86
+
87
+ def execute(
88
+ self, args: list[str], options: ResolvedOptions, client: Any = None
89
+ ) -> CommandResult:
90
+ limit = options.command.get("limit", 50)
91
+ tags = client.tags.list(top=limit)
92
+ return [_tag_to_row(t) for t in tags]
93
+
94
+
95
+ class TagShowCommand(Command):
96
+ """Show tag details."""
97
+
98
+ def __init__(self) -> None:
99
+ super().__init__(
100
+ "show",
101
+ "Show tag details",
102
+ requires_client=True,
103
+ usage="kd /tag show TAG_NAME",
104
+ examples=["kd /tag show sprint-47"],
105
+ see_also=["/tag list", "/tag issues"],
106
+ )
107
+
108
+ def execute(
109
+ self, args: list[str], options: ResolvedOptions, client: Any = None
110
+ ) -> CommandResult:
111
+ if not args:
112
+ raise ValidationError("Usage: kd /tag show TAG_NAME")
113
+ tag = client.tags.get(args[0])
114
+ return _tag_to_detail(tag)
115
+
116
+
117
+ class TagCreateCommand(Command):
118
+ """Create a tag."""
119
+
120
+ def __init__(self) -> None:
121
+ super().__init__(
122
+ "create",
123
+ "Create a tag",
124
+ requires_client=True,
125
+ usage="kd /tag create TAG_NAME",
126
+ examples=['kd /tag create "sprint-47"'],
127
+ see_also=["/tag list", "/tag delete"],
128
+ )
129
+
130
+ def execute(
131
+ self, args: list[str], options: ResolvedOptions, client: Any = None
132
+ ) -> CommandResult:
133
+ if not args:
134
+ raise ValidationError("Usage: kd /tag create TAG_NAME")
135
+ tag = client.tags.create(args[0])
136
+ return _tag_to_detail(tag)
137
+
138
+
139
+ class TagRenameCommand(Command):
140
+ """Rename a tag."""
141
+
142
+ def __init__(self) -> None:
143
+ super().__init__(
144
+ "rename",
145
+ "Rename a tag",
146
+ requires_client=True,
147
+ usage="kd /tag rename TAG_NAME NEW_NAME",
148
+ examples=['kd /tag rename sprint-47 "sprint-48"'],
149
+ see_also=["/tag show"],
150
+ )
151
+
152
+ def execute(
153
+ self, args: list[str], options: ResolvedOptions, client: Any = None
154
+ ) -> CommandResult:
155
+ if len(args) < 2:
156
+ raise ValidationError("Usage: kd /tag rename TAG_NAME NEW_NAME")
157
+ tag = client.tags.rename(args[0], args[1])
158
+ return _tag_to_detail(tag)
159
+
160
+
161
+ class TagDeleteCommand(Command):
162
+ """Delete a tag."""
163
+
164
+ def __init__(self) -> None:
165
+ super().__init__(
166
+ "delete",
167
+ "Delete a tag",
168
+ requires_client=True,
169
+ usage="kd /tag delete TAG_NAME",
170
+ examples=["kd /tag delete sprint-47"],
171
+ see_also=["/tag list", "/tag create"],
172
+ )
173
+
174
+ def execute(
175
+ self, args: list[str], options: ResolvedOptions, client: Any = None
176
+ ) -> CommandResult:
177
+ if not args:
178
+ raise ValidationError("Usage: kd /tag delete TAG_NAME")
179
+ name = args[0]
180
+ client.tags.delete(name)
181
+ return {"ok": True, "action": "delete", "tag": name}
182
+
183
+
184
+ class TagAddCommand(Command):
185
+ """Add a tag to an issue or article."""
186
+
187
+ def __init__(self) -> None:
188
+ super().__init__(
189
+ "add",
190
+ "Tag an issue or article",
191
+ requires_client=True,
192
+ usage="kd /tag add ENTITY_ID TAG_NAME",
193
+ long_description=(
194
+ "Entity type is auto-detected from the ID format: FB-123 = issue, FB-A-1 = article."
195
+ ),
196
+ examples=[
197
+ "kd /tag add FB-123 sprint-47",
198
+ "kd /tag add FB-A-1 reviewed",
199
+ ],
200
+ see_also=["/tag remove", "/tag issues"],
201
+ )
202
+
203
+ def execute(
204
+ self, args: list[str], options: ResolvedOptions, client: Any = None
205
+ ) -> CommandResult:
206
+ if len(args) < 2:
207
+ raise ValidationError("Usage: kd /tag add ENTITY_ID TAG_NAME")
208
+ entity_id, tag_name = args[0], args[1]
209
+ client.tags.add(entity_id, tag_name)
210
+ return {"ok": True, "action": "add", "entity": entity_id, "tag": tag_name}
211
+
212
+
213
+ class TagRemoveCommand(Command):
214
+ """Remove a tag from an issue or article."""
215
+
216
+ def __init__(self) -> None:
217
+ super().__init__(
218
+ "remove",
219
+ "Untag an issue or article",
220
+ requires_client=True,
221
+ usage="kd /tag remove ENTITY_ID TAG_NAME",
222
+ long_description=(
223
+ "Entity type is auto-detected from the ID format: FB-123 = issue, FB-A-1 = article."
224
+ ),
225
+ examples=[
226
+ "kd /tag remove FB-123 sprint-47",
227
+ "kd /tag remove FB-A-1 reviewed",
228
+ ],
229
+ see_also=["/tag add", "/tag issues"],
230
+ )
231
+
232
+ def execute(
233
+ self, args: list[str], options: ResolvedOptions, client: Any = None
234
+ ) -> CommandResult:
235
+ if len(args) < 2:
236
+ raise ValidationError("Usage: kd /tag remove ENTITY_ID TAG_NAME")
237
+ entity_id, tag_name = args[0], args[1]
238
+ client.tags.remove(entity_id, tag_name)
239
+ return {"ok": True, "action": "remove", "entity": entity_id, "tag": tag_name}
240
+
241
+
242
+ class TagIssuesCommand(Command):
243
+ """List issues tagged with a specific tag."""
244
+
245
+ def __init__(self) -> None:
246
+ super().__init__(
247
+ "issues",
248
+ "List issues with a tag",
249
+ requires_client=True,
250
+ usage="kd /tag issues TAG_NAME [--limit N]",
251
+ long_description=(
252
+ "Lists issues tagged with the given tag using the server-side tag-issues endpoint."
253
+ ),
254
+ examples=["kd /tag issues sprint-47", "kd /tag issues blocked --limit 100"],
255
+ see_also=["/tag show", "/tag add", "/tag articles"],
256
+ )
257
+
258
+ def _register_options(self) -> None:
259
+ self.register_option(limit_option())
260
+
261
+ def execute(
262
+ self, args: list[str], options: ResolvedOptions, client: Any = None
263
+ ) -> CommandResult:
264
+ if not args:
265
+ raise ValidationError("Usage: kd /tag issues TAG_NAME")
266
+ limit = options.command.get("limit", 50)
267
+ issues = client.tags.issues(args[0], top=limit)
268
+ return [_issue_to_row(i) for i in issues]
269
+
270
+
271
+ class TagArticlesCommand(Command):
272
+ """List articles tagged with a specific tag (or intersection of tags)."""
273
+
274
+ def __init__(self) -> None:
275
+ super().__init__(
276
+ "articles",
277
+ "List articles with a tag",
278
+ requires_client=True,
279
+ usage="kd /tag articles TAG_NAME [--tag TAG ...] [--project P] [--limit N]",
280
+ long_description=(
281
+ "Lists articles tagged with the given tag. Uses client-side "
282
+ "filtering because the YouTrack API has no server-side "
283
+ "article-by-tag endpoint. Use --project to scope the scan "
284
+ "to a single project. Repeat --tag to require multiple tags "
285
+ "(AND semantics)."
286
+ ),
287
+ examples=[
288
+ "kd /tag articles reviewed",
289
+ 'kd /tag articles "type:guide" --project FB',
290
+ "kd /tag articles type:case --tag component:authservice -p CMCSA",
291
+ "kd /tag articles blocked --limit 100",
292
+ ],
293
+ see_also=["/tag issues", "/tag show", "/tag add"],
294
+ )
295
+
296
+ def _register_options(self) -> None:
297
+ self.register_option(
298
+ OptionDeclaration(
299
+ name="tag",
300
+ short_name="t",
301
+ layer=OptionLayer.COMMAND,
302
+ is_list=True,
303
+ help_text="Additional tag filter (repeatable, AND semantics)",
304
+ )
305
+ )
306
+ self.register_option(project_option(help_text="Scope scan to a project"))
307
+ self.register_option(limit_option())
308
+
309
+ def execute(
310
+ self, args: list[str], options: ResolvedOptions, client: Any = None
311
+ ) -> CommandResult:
312
+ if not args:
313
+ raise ValidationError("Usage: kd /tag articles TAG_NAME")
314
+ extra_tags: list[str] = options.command.get("tag", [])
315
+ tags = [args[0], *extra_tags]
316
+ limit = options.command.get("limit", 50)
317
+ project = options.command.get("project")
318
+ articles = client.tags.articles(tags, project=project, top=limit)
319
+ return [_article_to_row(a) for a in articles]
320
+
321
+
322
+ # --- Namespace Factory ---
323
+
324
+
325
+ def create_tag_namespace() -> Namespace:
326
+ """Build the /tag namespace."""
327
+ ns = Namespace("tag", "Tag management and entity tagging", path="tag")
328
+ ns.register_command("list", TagListCommand())
329
+ ns.register_command("show", TagShowCommand())
330
+ ns.register_command("create", TagCreateCommand())
331
+ ns.register_command("rename", TagRenameCommand())
332
+ ns.register_command("delete", TagDeleteCommand())
333
+ ns.register_command("add", TagAddCommand())
334
+ ns.register_command("remove", TagRemoveCommand())
335
+ ns.register_command("issues", TagIssuesCommand())
336
+ ns.register_command("articles", TagArticlesCommand())
337
+ return ns
@@ -0,0 +1,307 @@
1
+ """Time namespace: /time
2
+
3
+ Commands: list, log, update, delete
4
+ API layer: YouTrack REST API (/api/issues/{id}/timeTracking/workItems)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from kd.cli.core.command_base import Command, CommandResult
13
+ from kd.cli.core.namespaces import Namespace
14
+ from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, ResolvedOptions
15
+ from kd.exceptions import ValidationError
16
+
17
+ _MS_PER_DAY = 86_400_000
18
+
19
+
20
+ def _parse_date(raw: str) -> int:
21
+ """Parse ``YYYY-MM-DD`` into epoch-ms at UTC midnight."""
22
+ try:
23
+ d = datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=timezone.utc)
24
+ except ValueError:
25
+ raise ValidationError(f"Invalid date '{raw}'. Expected YYYY-MM-DD.") from None
26
+ return int(d.timestamp() * 1000)
27
+
28
+
29
+ def _work_item_to_dict(w: Any) -> dict[str, Any]:
30
+ return {
31
+ "id": w.id,
32
+ "minutes": w.duration.minutes if w.duration else 0,
33
+ "type": w.type.name if w.type else "",
34
+ "author": w.author.name if w.author else "",
35
+ "text": w.text or "",
36
+ "date": w.date,
37
+ }
38
+
39
+
40
+ class TimeListCommand(Command):
41
+ """List work items on an issue."""
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__(
45
+ "list",
46
+ "List work items on an issue",
47
+ requires_client=True,
48
+ usage="kd /time list ISSUE_ID [--date DATE] [--from DATE --to DATE]",
49
+ examples=[
50
+ "kd /time list FB-42",
51
+ "kd /time list FB-42 --date 2025-12-31",
52
+ "kd /time list FB-42 --from 2025-12-01 --to 2025-12-31",
53
+ ],
54
+ see_also=["/time log", "/time update", "/time delete"],
55
+ )
56
+
57
+ def _register_options(self) -> None:
58
+ self.register_option(
59
+ OptionDeclaration(
60
+ name="date",
61
+ layer=OptionLayer.COMMAND,
62
+ help_text="Filter to a single date (YYYY-MM-DD)",
63
+ )
64
+ )
65
+ self.register_option(
66
+ OptionDeclaration(
67
+ name="from",
68
+ layer=OptionLayer.COMMAND,
69
+ help_text="Start of date range (YYYY-MM-DD, inclusive)",
70
+ )
71
+ )
72
+ self.register_option(
73
+ OptionDeclaration(
74
+ name="to",
75
+ layer=OptionLayer.COMMAND,
76
+ help_text="End of date range (YYYY-MM-DD, inclusive)",
77
+ )
78
+ )
79
+
80
+ def execute(
81
+ self, args: list[str], options: ResolvedOptions, client: Any = None
82
+ ) -> CommandResult:
83
+ if not args:
84
+ raise ValidationError("Usage: kd /time list ISSUE_ID")
85
+ date_opt = options.command.get("date")
86
+ from_opt = options.command.get("from")
87
+ to_opt = options.command.get("to")
88
+
89
+ if date_opt and (from_opt or to_opt):
90
+ raise ValidationError("--date is mutually exclusive with --from/--to")
91
+ if (from_opt and not to_opt) or (to_opt and not from_opt):
92
+ raise ValidationError("--from and --to must be supplied together")
93
+
94
+ date_from: int | None = None
95
+ date_to: int | None = None
96
+ if date_opt:
97
+ day = _parse_date(date_opt)
98
+ date_from = day
99
+ date_to = day + _MS_PER_DAY - 1
100
+ elif from_opt and to_opt:
101
+ date_from = _parse_date(from_opt)
102
+ date_to = _parse_date(to_opt) + _MS_PER_DAY - 1
103
+ if date_from > date_to:
104
+ raise ValidationError("--from must not be later than --to")
105
+
106
+ items = client.time.list(args[0], date_from=date_from, date_to=date_to)
107
+ return [_work_item_to_dict(w) for w in items]
108
+
109
+
110
+ class TimeLogCommand(Command):
111
+ """Log work on an issue."""
112
+
113
+ def __init__(self) -> None:
114
+ super().__init__(
115
+ "log",
116
+ "Log work on an issue",
117
+ requires_client=True,
118
+ usage=(
119
+ "kd /time log ISSUE_ID MINUTES [--date DATE] [--type TYPE] [--description DESC]"
120
+ ),
121
+ examples=[
122
+ "kd /time log FB-42 30",
123
+ 'kd /time log FB-42 60 --type Development --description "SDK work"',
124
+ "kd /time log FB-42 90 --date 2025-12-31 --type Development",
125
+ ],
126
+ see_also=["/time list", "/time update", "/time delete"],
127
+ )
128
+
129
+ def _register_options(self) -> None:
130
+ self.register_option(
131
+ OptionDeclaration(
132
+ name="type",
133
+ short_name="t",
134
+ layer=OptionLayer.COMMAND,
135
+ help_text="Work item type (Development, Testing, etc.)",
136
+ )
137
+ )
138
+ self.register_option(
139
+ OptionDeclaration(
140
+ name="description",
141
+ short_name="d",
142
+ layer=OptionLayer.COMMAND,
143
+ help_text="Work item description",
144
+ )
145
+ )
146
+ self.register_option(
147
+ OptionDeclaration(
148
+ name="date",
149
+ layer=OptionLayer.COMMAND,
150
+ help_text="Work date (YYYY-MM-DD, defaults to today)",
151
+ )
152
+ )
153
+
154
+ def execute(
155
+ self, args: list[str], options: ResolvedOptions, client: Any = None
156
+ ) -> CommandResult:
157
+ if len(args) < 2:
158
+ raise ValidationError("Usage: kd /time log ISSUE_ID MINUTES")
159
+ issue_id = args[0]
160
+ try:
161
+ minutes = int(args[1])
162
+ except ValueError:
163
+ raise ValidationError(f"MINUTES must be a number, got '{args[1]}'") from None
164
+ work_type = options.command.get("type")
165
+ description = options.command.get("description")
166
+ date_raw = options.command.get("date")
167
+ date_ms = _parse_date(date_raw) if date_raw else None
168
+ item = client.time.log(
169
+ issue_id,
170
+ minutes,
171
+ work_type=work_type,
172
+ description=description,
173
+ date=date_ms,
174
+ )
175
+ return {
176
+ "id": item.id,
177
+ "minutes": item.duration.minutes if item.duration else 0,
178
+ "type": item.type.name if item.type else "",
179
+ "date": item.date,
180
+ }
181
+
182
+
183
+ class TimeUpdateCommand(Command):
184
+ """Update a work item."""
185
+
186
+ def __init__(self) -> None:
187
+ super().__init__(
188
+ "update",
189
+ "Update an existing work item",
190
+ requires_client=True,
191
+ usage=(
192
+ "kd /time update ISSUE_ID WORK_ITEM_ID "
193
+ "[--minutes M] [--date DATE] [--type TYPE] [--description DESC]"
194
+ ),
195
+ examples=[
196
+ "kd /time update FB-42 199-54 --minutes 45",
197
+ 'kd /time update FB-42 199-54 --date 2026-01-02 --description "updated"',
198
+ ],
199
+ see_also=["/time log", "/time list", "/time delete"],
200
+ )
201
+
202
+ def _register_options(self) -> None:
203
+ self.register_option(
204
+ OptionDeclaration(
205
+ name="minutes",
206
+ short_name="m",
207
+ layer=OptionLayer.COMMAND,
208
+ help_text="New duration in minutes",
209
+ )
210
+ )
211
+ self.register_option(
212
+ OptionDeclaration(
213
+ name="date",
214
+ layer=OptionLayer.COMMAND,
215
+ help_text="New work date (YYYY-MM-DD)",
216
+ )
217
+ )
218
+ self.register_option(
219
+ OptionDeclaration(
220
+ name="type",
221
+ short_name="t",
222
+ layer=OptionLayer.COMMAND,
223
+ help_text="New work item type",
224
+ )
225
+ )
226
+ self.register_option(
227
+ OptionDeclaration(
228
+ name="description",
229
+ short_name="d",
230
+ layer=OptionLayer.COMMAND,
231
+ help_text="New description",
232
+ )
233
+ )
234
+
235
+ def execute(
236
+ self, args: list[str], options: ResolvedOptions, client: Any = None
237
+ ) -> CommandResult:
238
+ if len(args) < 2:
239
+ raise ValidationError("Usage: kd /time update ISSUE_ID WORK_ITEM_ID")
240
+ issue_id = args[0]
241
+ work_item_id = args[1]
242
+
243
+ minutes_raw = options.command.get("minutes")
244
+ date_raw = options.command.get("date")
245
+ work_type = options.command.get("type")
246
+ description = options.command.get("description")
247
+
248
+ if minutes_raw is None and date_raw is None and work_type is None and description is None:
249
+ raise ValidationError(
250
+ "Provide at least one of --minutes, --date, --type, --description"
251
+ )
252
+
253
+ minutes: int | None = None
254
+ if minutes_raw is not None:
255
+ try:
256
+ minutes = int(minutes_raw)
257
+ except ValueError:
258
+ raise ValidationError(f"--minutes must be a number, got '{minutes_raw}'") from None
259
+
260
+ date_ms = _parse_date(date_raw) if date_raw else None
261
+
262
+ item = client.time.update(
263
+ issue_id,
264
+ work_item_id,
265
+ minutes=minutes,
266
+ work_type=work_type,
267
+ description=description,
268
+ date=date_ms,
269
+ )
270
+ return _work_item_to_dict(item)
271
+
272
+
273
+ class TimeDeleteCommand(Command):
274
+ """Delete a work item."""
275
+
276
+ def __init__(self) -> None:
277
+ super().__init__(
278
+ "delete",
279
+ "Delete a work item",
280
+ requires_client=True,
281
+ usage="kd /time delete ISSUE_ID WORK_ITEM_ID",
282
+ examples=["kd /time delete FB-42 199-54"],
283
+ see_also=["/time list", "/time log", "/time update"],
284
+ )
285
+
286
+ def execute(
287
+ self, args: list[str], options: ResolvedOptions, client: Any = None
288
+ ) -> CommandResult:
289
+ if len(args) < 2:
290
+ raise ValidationError("Usage: kd /time delete ISSUE_ID WORK_ITEM_ID")
291
+ client.time.delete(args[0], args[1])
292
+ return {
293
+ "ok": True,
294
+ "action": "delete",
295
+ "issue": args[0],
296
+ "work_item": args[1],
297
+ }
298
+
299
+
300
+ def create_time_namespace() -> Namespace:
301
+ """Build the /time namespace tree."""
302
+ ns = Namespace("time", "Time tracking", path="time")
303
+ ns.register_command("list", TimeListCommand())
304
+ ns.register_command("log", TimeLogCommand())
305
+ ns.register_command("update", TimeUpdateCommand())
306
+ ns.register_command("delete", TimeDeleteCommand())
307
+ return ns