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,465 @@
1
+ """Query namespace: /query
2
+
3
+ Commands: list, show, create, update, delete, issues, articles, validate, assist
4
+ API layer: YouTrack REST API (/api/savedQueries, /api/search/assist)
5
+
6
+ The namespace is named ``/query`` rather than ``/saved-query`` to match the
7
+ single-word cadence of the other namespaces (/tag, /article, /time, /alias).
8
+ Subcommands disambiguate intent — ``list`` and ``show`` inspect saved queries,
9
+ ``issues`` and ``articles`` execute one against the respective entity type,
10
+ and ``assist`` calls the search-autocomplete endpoint on a free-form query
11
+ string.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from typing import Any
18
+
19
+ from kd.cli.core.command_base import Command, CommandResult
20
+ from kd.cli.core.namespaces import Namespace
21
+ from kd.cli.core.option_registry import (
22
+ OptionDeclaration,
23
+ OptionLayer,
24
+ ResolvedOptions,
25
+ limit_option,
26
+ project_option,
27
+ )
28
+ from kd.cli.namespaces.issue import _issue_to_row
29
+ from kd.cli.namespaces.tag import _article_to_row
30
+ from kd.exceptions import ValidationError
31
+ from kd.models import AssistSuggestion, QueryValidationResult, SavedQuery
32
+
33
+
34
+ def _saved_query_to_row(sq: SavedQuery) -> dict[str, Any]:
35
+ """Convert a SavedQuery to a flat dict for table output."""
36
+ return {
37
+ "id": sq.id,
38
+ "name": sq.name,
39
+ "query": sq.query,
40
+ "owner": sq.owner.name if sq.owner and sq.owner.name else "",
41
+ }
42
+
43
+
44
+ def _saved_query_to_detail(sq: SavedQuery) -> dict[str, Any]:
45
+ """Convert a SavedQuery to a detail dict for show/create/update output."""
46
+ return {
47
+ "id": sq.id,
48
+ "name": sq.name,
49
+ "query": sq.query,
50
+ "owner": sq.owner.name if sq.owner and sq.owner.name else "",
51
+ }
52
+
53
+
54
+ def _suggestion_to_row(s: AssistSuggestion) -> dict[str, Any]:
55
+ """Convert an AssistSuggestion to a flat dict for table output."""
56
+ return {
57
+ "option": s.option,
58
+ "description": s.description or "",
59
+ "prefix": s.prefix or "",
60
+ "suffix": s.suffix or "",
61
+ "caret": s.completion_start if s.completion_start is not None else "",
62
+ }
63
+
64
+
65
+ def _warn_invalid_query(result: QueryValidationResult, name: str) -> None:
66
+ """Print a validation warning to stderr if the query is invalid."""
67
+ if result.valid:
68
+ return
69
+ print(f"Warning: query syntax error — {result.error}", file=sys.stderr)
70
+ if result.suggestion:
71
+ print(f"Hint: {result.suggestion}", file=sys.stderr)
72
+ print(f'Hint: delete with: kd /query delete "{name}"', file=sys.stderr)
73
+
74
+
75
+ # --- Commands ---
76
+
77
+
78
+ class QueryListCommand(Command):
79
+ """List saved queries."""
80
+
81
+ def __init__(self) -> None:
82
+ super().__init__(
83
+ "list",
84
+ "List saved queries",
85
+ requires_client=True,
86
+ usage="kd /query list [--mine] [--limit N]",
87
+ long_description=(
88
+ "Lists saved queries visible to the authenticated user. "
89
+ "Pass --mine to restrict to queries you own."
90
+ ),
91
+ examples=[
92
+ "kd /query list",
93
+ "kd /query list --mine",
94
+ "kd /query list --limit 100",
95
+ ],
96
+ see_also=["/query show", "/query issues", "/query articles"],
97
+ )
98
+
99
+ def _register_options(self) -> None:
100
+ self.register_option(
101
+ OptionDeclaration(
102
+ name="mine",
103
+ layer=OptionLayer.COMMAND,
104
+ type=bool,
105
+ default=False,
106
+ help_text="Only queries owned by the current user",
107
+ )
108
+ )
109
+ self.register_option(limit_option())
110
+
111
+ def execute(
112
+ self, args: list[str], options: ResolvedOptions, client: Any = None
113
+ ) -> CommandResult:
114
+ limit = options.command.get("limit", 50)
115
+ mine = options.command.get("mine", False)
116
+ owner = "me" if mine else None
117
+ queries = client.queries.list(owner=owner, top=limit)
118
+ return [_saved_query_to_row(sq) for sq in queries]
119
+
120
+
121
+ class QueryShowCommand(Command):
122
+ """Show saved query details."""
123
+
124
+ def __init__(self) -> None:
125
+ super().__init__(
126
+ "show",
127
+ "Show saved query details",
128
+ requires_client=True,
129
+ usage="kd /query show NAME_OR_ID",
130
+ long_description=(
131
+ "Resolves NAME_OR_ID to a saved query. Names are matched "
132
+ "case-insensitively; use the ID (e.g. 11-24) to disambiguate "
133
+ "duplicates."
134
+ ),
135
+ examples=[
136
+ 'kd /query show "Assigned to me"',
137
+ "kd /query show 11-24",
138
+ ],
139
+ see_also=["/query list", "/query issues", "/query articles"],
140
+ )
141
+
142
+ def execute(
143
+ self, args: list[str], options: ResolvedOptions, client: Any = None
144
+ ) -> CommandResult:
145
+ if not args:
146
+ raise ValidationError("Usage: kd /query show NAME_OR_ID")
147
+ sq = client.queries.get(args[0])
148
+ return _saved_query_to_detail(sq)
149
+
150
+
151
+ class QueryCreateCommand(Command):
152
+ """Create a saved query."""
153
+
154
+ def __init__(self) -> None:
155
+ super().__init__(
156
+ "create",
157
+ "Create a saved query",
158
+ requires_client=True,
159
+ usage='kd /query create NAME "QUERY_TEXT"',
160
+ long_description=(
161
+ "Creates a personal saved query. Sharing settings default to "
162
+ "owner-only; other users can be granted access via the "
163
+ "YouTrack web UI.\n\n"
164
+ "The query is validated after saving by executing a dry-run "
165
+ "search. If the syntax is invalid, a warning is printed but "
166
+ "the query is still saved.\n\n"
167
+ "Syntax tip: values containing colons, spaces, or braces must "
168
+ "be wrapped in braces — e.g. tag: {type:case} instead of "
169
+ "tag: type:case."
170
+ ),
171
+ examples=[
172
+ 'kd /query create "My Bugs" "for: me Type: Bug #Unresolved"',
173
+ 'kd /query create "Sprint 47" "Sprint: {Sprint 47}"',
174
+ ],
175
+ see_also=["/query list", "/query delete"],
176
+ )
177
+
178
+ def execute(
179
+ self, args: list[str], options: ResolvedOptions, client: Any = None
180
+ ) -> CommandResult:
181
+ if len(args) < 2:
182
+ raise ValidationError('Usage: kd /query create NAME "QUERY_TEXT"')
183
+ name, query_text = args[0], args[1]
184
+ sq = client.queries.create(name, query_text)
185
+ vr = client.queries.validate(query_text)
186
+ _warn_invalid_query(vr, name)
187
+ return _saved_query_to_detail(sq)
188
+
189
+
190
+ class QueryUpdateCommand(Command):
191
+ """Update a saved query."""
192
+
193
+ def __init__(self) -> None:
194
+ super().__init__(
195
+ "update",
196
+ "Update a saved query",
197
+ requires_client=True,
198
+ usage="kd /query update NAME_OR_ID [--name NEW_NAME] [--query NEW_QUERY]",
199
+ long_description=(
200
+ "Partial update — pass whichever of --name / --query you want "
201
+ "to change. At least one is required.\n\n"
202
+ "When --query is provided, the new query text is validated by "
203
+ "executing a dry-run search. A warning is printed if the "
204
+ "syntax is invalid.\n\n"
205
+ "Syntax tip: values containing colons, spaces, or braces must "
206
+ "be wrapped in braces — e.g. tag: {type:case} instead of "
207
+ "tag: type:case."
208
+ ),
209
+ examples=[
210
+ 'kd /query update "My Bugs" --name "My Bugs (archived)"',
211
+ 'kd /query update 11-24 --query "project: FB #Unresolved"',
212
+ 'kd /query update 11-24 --name "New" --query "project: FB"',
213
+ ],
214
+ see_also=["/query show", "/query create"],
215
+ )
216
+
217
+ def _register_options(self) -> None:
218
+ self.register_option(
219
+ OptionDeclaration(
220
+ name="name",
221
+ layer=OptionLayer.COMMAND,
222
+ help_text="New name",
223
+ )
224
+ )
225
+ self.register_option(
226
+ OptionDeclaration(
227
+ name="query",
228
+ layer=OptionLayer.COMMAND,
229
+ help_text="New query text",
230
+ )
231
+ )
232
+
233
+ def execute(
234
+ self, args: list[str], options: ResolvedOptions, client: Any = None
235
+ ) -> CommandResult:
236
+ if not args:
237
+ raise ValidationError("Usage: kd /query update NAME_OR_ID [--name ...] [--query ...]")
238
+ new_name = options.command.get("name")
239
+ new_query = options.command.get("query")
240
+ if new_name is None and new_query is None:
241
+ raise ValidationError("Provide at least one of --name or --query to update.")
242
+ sq = client.queries.update(args[0], name=new_name, query=new_query)
243
+ if new_query is not None:
244
+ vr = client.queries.validate(new_query)
245
+ _warn_invalid_query(vr, sq.name)
246
+ return _saved_query_to_detail(sq)
247
+
248
+
249
+ class QueryDeleteCommand(Command):
250
+ """Delete a saved query."""
251
+
252
+ def __init__(self) -> None:
253
+ super().__init__(
254
+ "delete",
255
+ "Delete a saved query",
256
+ requires_client=True,
257
+ usage="kd /query delete NAME_OR_ID",
258
+ examples=[
259
+ 'kd /query delete "My Bugs"',
260
+ "kd /query delete 11-24",
261
+ ],
262
+ see_also=["/query list", "/query create"],
263
+ )
264
+
265
+ def execute(
266
+ self, args: list[str], options: ResolvedOptions, client: Any = None
267
+ ) -> CommandResult:
268
+ if not args:
269
+ raise ValidationError("Usage: kd /query delete NAME_OR_ID")
270
+ target = args[0]
271
+ client.queries.delete(target)
272
+ return {"ok": True, "action": "delete", "query": target}
273
+
274
+
275
+ class QueryIssuesCommand(Command):
276
+ """Run a saved query and return matching issues."""
277
+
278
+ def __init__(self) -> None:
279
+ super().__init__(
280
+ "issues",
281
+ "Execute a saved query against issues",
282
+ requires_client=True,
283
+ usage="kd /query issues NAME_OR_ID [--project P] [--limit N]",
284
+ long_description=(
285
+ "Resolves the saved query, fetches its query text, and runs "
286
+ "it through /issue list. Use --project to scope results to a "
287
+ "specific project. Output columns match `kd /issue list`."
288
+ ),
289
+ examples=[
290
+ 'kd /query issues "My Bugs"',
291
+ 'kd /query issues "Sprint 47" --limit 100',
292
+ 'kd /query issues "My Bugs" --project FB',
293
+ ],
294
+ see_also=["/query list", "/query articles", "/issue list"],
295
+ )
296
+
297
+ def _register_options(self) -> None:
298
+ self.register_option(project_option(help_text="Scope results to a project"))
299
+ self.register_option(limit_option())
300
+
301
+ def execute(
302
+ self, args: list[str], options: ResolvedOptions, client: Any = None
303
+ ) -> CommandResult:
304
+ if not args:
305
+ raise ValidationError("Usage: kd /query issues NAME_OR_ID")
306
+ limit = options.command.get("limit", 50)
307
+ project = options.command.get("project")
308
+ issues = client.queries.run_issues(args[0], top=limit, project=project)
309
+ return [_issue_to_row(i) for i in issues]
310
+
311
+
312
+ class QueryArticlesCommand(Command):
313
+ """Run a saved query and return matching articles."""
314
+
315
+ def __init__(self) -> None:
316
+ super().__init__(
317
+ "articles",
318
+ "Execute a saved query against articles",
319
+ requires_client=True,
320
+ usage="kd /query articles NAME_OR_ID [--project P] [--limit N]",
321
+ long_description=(
322
+ "Resolves the saved query, parses project and tag fields from "
323
+ "the query text, and finds articles matching those tags. "
324
+ "Use --project to scope or override the project parsed from "
325
+ "the query text. Only project: and tag: fields are supported — "
326
+ "queries with issue-specific fields (state, assignee, priority, "
327
+ "etc.) are rejected with an error.\n\n"
328
+ "Output columns match `kd /tag articles`."
329
+ ),
330
+ examples=[
331
+ 'kd /query articles "CMCSA Cases"',
332
+ 'kd /query articles "Tagged Guides" --limit 100',
333
+ 'kd /query articles "Tagged Guides" --project CMCSA',
334
+ ],
335
+ see_also=["/query list", "/query issues", "/tag articles"],
336
+ )
337
+
338
+ def _register_options(self) -> None:
339
+ self.register_option(project_option(help_text="Scope results to a project"))
340
+ self.register_option(limit_option())
341
+
342
+ def execute(
343
+ self, args: list[str], options: ResolvedOptions, client: Any = None
344
+ ) -> CommandResult:
345
+ if not args:
346
+ raise ValidationError("Usage: kd /query articles NAME_OR_ID")
347
+ limit = options.command.get("limit", 50)
348
+ project = options.command.get("project")
349
+ articles = client.queries.run_articles(args[0], top=limit, project=project)
350
+ return [_article_to_row(a) for a in articles]
351
+
352
+
353
+ class QueryValidateCommand(Command):
354
+ """Validate saved query syntax."""
355
+
356
+ def __init__(self) -> None:
357
+ super().__init__(
358
+ "validate",
359
+ "Validate saved query syntax against the search API",
360
+ requires_client=True,
361
+ usage="kd /query validate [NAME_OR_ID]",
362
+ long_description=(
363
+ "Validates query syntax by executing a dry-run search. "
364
+ "With no argument, validates all saved queries and reports "
365
+ "which ones have syntax errors. With a name or ID, validates "
366
+ "a single saved query."
367
+ ),
368
+ examples=[
369
+ "kd /query validate",
370
+ 'kd /query validate "My Bugs"',
371
+ "kd /query validate 11-24",
372
+ ],
373
+ see_also=["/query list", "/query create"],
374
+ )
375
+
376
+ def execute(
377
+ self, args: list[str], options: ResolvedOptions, client: Any = None
378
+ ) -> CommandResult:
379
+ if args:
380
+ sq = client.queries.get(args[0])
381
+ vr = client.queries.validate(sq.query)
382
+ return _validation_to_row(sq, vr)
383
+ queries = client.queries.list(top=500)
384
+ rows: list[dict[str, Any]] = []
385
+ for sq in queries:
386
+ vr = client.queries.validate(sq.query)
387
+ rows.append(_validation_to_row(sq, vr))
388
+ return rows
389
+
390
+
391
+ def _validation_to_row(sq: SavedQuery, vr: QueryValidationResult) -> dict[str, Any]:
392
+ """Convert a validation result to a flat dict for output."""
393
+ return {
394
+ "name": sq.name,
395
+ "query": sq.query,
396
+ "status": "ok" if vr.valid else "error",
397
+ "error": vr.error or "",
398
+ }
399
+
400
+
401
+ class QueryAssistCommand(Command):
402
+ """Fetch search-query autocomplete suggestions."""
403
+
404
+ def __init__(self) -> None:
405
+ super().__init__(
406
+ "assist",
407
+ "Autocomplete a YouTrack search query",
408
+ requires_client=True,
409
+ usage='kd /query assist "QUERY_TEXT" [--caret N]',
410
+ long_description=(
411
+ "Wraps POST /api/search/assist. --caret defaults to the end "
412
+ "of QUERY_TEXT so typical use (tab-completion at the tail) "
413
+ "works without specifying it."
414
+ ),
415
+ examples=[
416
+ 'kd /query assist "project: "',
417
+ 'kd /query assist "for: " --caret 5',
418
+ 'kd /query assist "Assignee: ad"',
419
+ ],
420
+ see_also=["/query issues", "/query create"],
421
+ )
422
+
423
+ def _register_options(self) -> None:
424
+ self.register_option(
425
+ OptionDeclaration(
426
+ name="caret",
427
+ layer=OptionLayer.COMMAND,
428
+ type=int,
429
+ help_text="Caret position (defaults to end of query)",
430
+ )
431
+ )
432
+
433
+ def execute(
434
+ self, args: list[str], options: ResolvedOptions, client: Any = None
435
+ ) -> CommandResult:
436
+ if not args:
437
+ raise ValidationError('Usage: kd /query assist "QUERY_TEXT" [--caret N]')
438
+ query_text = args[0]
439
+ caret = options.command.get("caret")
440
+ if caret is None:
441
+ caret = len(query_text)
442
+ response = client.assist.search(query_text, caret=caret)
443
+ return [_suggestion_to_row(s) for s in response.suggestions]
444
+
445
+
446
+ # --- Namespace factory ---
447
+
448
+
449
+ def create_query_namespace() -> Namespace:
450
+ """Build the /query namespace."""
451
+ ns = Namespace(
452
+ "query",
453
+ "Saved queries and search autocomplete",
454
+ path="query",
455
+ )
456
+ ns.register_command("list", QueryListCommand())
457
+ ns.register_command("show", QueryShowCommand())
458
+ ns.register_command("create", QueryCreateCommand())
459
+ ns.register_command("update", QueryUpdateCommand())
460
+ ns.register_command("delete", QueryDeleteCommand())
461
+ ns.register_command("issues", QueryIssuesCommand())
462
+ ns.register_command("articles", QueryArticlesCommand())
463
+ ns.register_command("validate", QueryValidateCommand())
464
+ ns.register_command("assist", QueryAssistCommand())
465
+ return ns
@@ -0,0 +1,82 @@
1
+ """Search command: /search
2
+
3
+ Cross-entity aggregated search across issues, users, tags, projects,
4
+ and articles. Backed by composed calls to multiple YouTrack REST API
5
+ endpoints (client-side aggregation, not a single upstream endpoint).
6
+
7
+ Registered as a root command (not a namespace) so that the first
8
+ positional argument is the search query, not a subcommand name.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from kd.cli.core.command_base import Command, CommandResult
16
+ from kd.cli.core.option_registry import (
17
+ OptionDeclaration,
18
+ OptionLayer,
19
+ ResolvedOptions,
20
+ limit_option,
21
+ project_option,
22
+ )
23
+ from kd.exceptions import ValidationError
24
+
25
+
26
+ class SearchCommand(Command):
27
+ """Cross-entity search."""
28
+
29
+ def __init__(self) -> None:
30
+ super().__init__(
31
+ "search",
32
+ "Search across entity types",
33
+ requires_client=True,
34
+ usage="kd /search QUERY [--types TYPE,...] [--project P] [--limit N]",
35
+ long_description=(
36
+ "Aggregated search across issues, users, tags, projects, and articles. "
37
+ "Issue and user searches use server-side query APIs. Tag, project, and "
38
+ "article searches use client-side substring matching."
39
+ ),
40
+ examples=[
41
+ 'kd /search "router"',
42
+ 'kd /search "router" --types issue,article',
43
+ 'kd /search "alice" --types user',
44
+ 'kd /search "dns" --project FB',
45
+ 'kd /search "router" --limit 10',
46
+ ],
47
+ see_also=["/issue list"],
48
+ )
49
+
50
+ def _register_options(self) -> None:
51
+ self.register_option(
52
+ OptionDeclaration(
53
+ name="types",
54
+ layer=OptionLayer.COMMAND,
55
+ type=str,
56
+ default=None,
57
+ help_text="Comma-separated entity types: issue,user,tag,project,article",
58
+ )
59
+ )
60
+ self.register_option(project_option(help_text="Scope issues and articles to a project"))
61
+ self.register_option(limit_option())
62
+
63
+ def execute(
64
+ self, args: list[str], options: ResolvedOptions, client: Any = None
65
+ ) -> CommandResult:
66
+ if not args:
67
+ raise ValidationError("Usage: kd /search QUERY")
68
+ query = args[0]
69
+ types_raw = options.command.get("types")
70
+ types = types_raw.split(",") if types_raw else None
71
+ project = options.command.get("project")
72
+ limit = options.command.get("limit", 50)
73
+ results = client.search.search(query, types=types, project=project, limit=limit)
74
+ return [
75
+ {
76
+ "type": r.type,
77
+ "id": r.id,
78
+ "title": r.title,
79
+ "project": r.project,
80
+ }
81
+ for r in results
82
+ ]