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.
- kd/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- 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
|