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
kd/api/tags.py ADDED
@@ -0,0 +1,107 @@
1
+ """Tags REST API wrapper.
2
+
3
+ Covers three endpoint groups:
4
+ - ``/api/tags`` — tag CRUD and tagged-issue listing
5
+ - ``/api/issues/{id}/tags`` — add/remove tags on issues
6
+ - ``/api/articles/{id}/tags`` — add/remove tags on articles
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from kd.api.http import HttpClient
12
+ from kd.models import FieldSet, Issue, Tag
13
+
14
+ _TAG_LIST_FIELDS = str(
15
+ FieldSet(
16
+ "id",
17
+ "name",
18
+ "untagOnResolve",
19
+ owner=FieldSet("id", "name"),
20
+ color=FieldSet("id", "background", "foreground"),
21
+ )
22
+ )
23
+
24
+ _TAG_DETAIL_FIELDS = str(
25
+ FieldSet(
26
+ "id",
27
+ "name",
28
+ "untagOnResolve",
29
+ owner=FieldSet("id", "name"),
30
+ color=FieldSet("id", "background", "foreground"),
31
+ )
32
+ )
33
+
34
+ _TAGGED_ISSUE_FIELDS = str(
35
+ FieldSet(
36
+ "id",
37
+ "idReadable",
38
+ "summary",
39
+ project=FieldSet("id", "name", "shortName"),
40
+ )
41
+ )
42
+
43
+
44
+ class TagsAPI:
45
+ """Tags REST API wrapper (/api/tags, /api/issues/*/tags, /api/articles/*/tags)."""
46
+
47
+ def __init__(self, http: HttpClient) -> None:
48
+ self._http = http
49
+
50
+ # --- Tag CRUD ---
51
+
52
+ def list_tags(self, top: int = 50, skip: int = 0) -> list[Tag]:
53
+ data = self._http.get(
54
+ "/tags",
55
+ fields=_TAG_LIST_FIELDS,
56
+ params={"$top": top, "$skip": skip},
57
+ )
58
+ return [Tag.model_validate(t) for t in data]
59
+
60
+ def get_tag(self, tag_id: str) -> Tag:
61
+ data = self._http.get(f"/tags/{tag_id}", fields=_TAG_DETAIL_FIELDS)
62
+ return Tag.model_validate(data)
63
+
64
+ def create_tag(self, name: str) -> Tag:
65
+ data = self._http.post(
66
+ "/tags",
67
+ json={"name": name},
68
+ fields=_TAG_DETAIL_FIELDS,
69
+ )
70
+ return Tag.model_validate(data)
71
+
72
+ def update_tag(self, tag_id: str, name: str) -> Tag:
73
+ data = self._http.post(
74
+ f"/tags/{tag_id}",
75
+ json={"name": name},
76
+ fields=_TAG_DETAIL_FIELDS,
77
+ )
78
+ return Tag.model_validate(data)
79
+
80
+ def delete_tag(self, tag_id: str) -> None:
81
+ self._http.delete(f"/tags/{tag_id}")
82
+
83
+ # --- Tagged issues ---
84
+
85
+ def list_tag_issues(self, tag_id: str, top: int = 50, skip: int = 0) -> list[Issue]:
86
+ data = self._http.get(
87
+ f"/tags/{tag_id}/issues",
88
+ fields=_TAGGED_ISSUE_FIELDS,
89
+ params={"$top": top, "$skip": skip},
90
+ )
91
+ return [Issue.model_validate(i) for i in data]
92
+
93
+ # --- Issue tags ---
94
+
95
+ def add_tag_to_issue(self, issue_id: str, tag_id: str) -> None:
96
+ self._http.post(f"/issues/{issue_id}/tags", json={"id": tag_id})
97
+
98
+ def remove_tag_from_issue(self, issue_id: str, tag_id: str) -> None:
99
+ self._http.delete(f"/issues/{issue_id}/tags/{tag_id}")
100
+
101
+ # --- Article tags ---
102
+
103
+ def add_tag_to_article(self, article_id: str, tag_id: str) -> None:
104
+ self._http.post(f"/articles/{article_id}/tags", json={"id": tag_id})
105
+
106
+ def remove_tag_from_article(self, article_id: str, tag_id: str) -> None:
107
+ self._http.delete(f"/articles/{article_id}/tags/{tag_id}")
kd/api/youtrack.py ADDED
@@ -0,0 +1,414 @@
1
+ """YouTrack REST API wrapper.
2
+
3
+ Covers ``/api/...`` endpoints:
4
+ - Projects: list, get, create, delete, configure time tracking
5
+ - Issues: create, get, update, search (YT query language)
6
+ - Comments: list, add
7
+ - Work items: list, add
8
+ - Work item types: list
9
+ - Custom fields: list bundles
10
+ - Issue links: list, list types
11
+ - Users: search (for ringId resolution)
12
+
13
+ Each method maps to a single REST endpoint. One method per operation.
14
+ Uses HttpClient for transport and Pydantic models for serialization.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from kd.api.http import HttpClient
22
+ from kd.models import (
23
+ AssistResponse,
24
+ Attachment,
25
+ Comment,
26
+ CommentCreate,
27
+ FieldSet,
28
+ Issue,
29
+ IssueCreate,
30
+ IssueUpdate,
31
+ Project,
32
+ ProjectCreate,
33
+ TimeTrackingSettings,
34
+ User,
35
+ WorkItem,
36
+ WorkItemCreate,
37
+ WorkItemUpdate,
38
+ WorkItemType,
39
+ )
40
+
41
+ # Default field selections for common queries
42
+ _PROJECT_LIST_FIELDS = str(
43
+ FieldSet(
44
+ "id",
45
+ "name",
46
+ "shortName",
47
+ "archived",
48
+ "description",
49
+ "ringId",
50
+ leader=FieldSet("id", "name"),
51
+ )
52
+ )
53
+ _PROJECT_DETAIL_FIELDS = str(
54
+ FieldSet(
55
+ "id",
56
+ "name",
57
+ "shortName",
58
+ "description",
59
+ "archived",
60
+ "ringId",
61
+ leader=FieldSet("id", "name"),
62
+ )
63
+ )
64
+ _ISSUE_FIELDS = str(
65
+ FieldSet(
66
+ "id",
67
+ "idReadable",
68
+ "summary",
69
+ "description",
70
+ "created",
71
+ project=FieldSet("id", "name", "shortName"),
72
+ reporter=FieldSet("id", "login", "name"),
73
+ customFields=FieldSet("name", value=FieldSet("name")),
74
+ )
75
+ )
76
+ _ISSUE_RICH_FIELDS = str(
77
+ FieldSet(
78
+ "id",
79
+ "idReadable",
80
+ "summary",
81
+ "description",
82
+ "created",
83
+ project=FieldSet("id", "name", "shortName"),
84
+ reporter=FieldSet("id", "login", "name"),
85
+ customFields=FieldSet("name", value=FieldSet("name")),
86
+ comments=FieldSet("id", "text", "created", author=FieldSet("id", "login", "name")),
87
+ links=FieldSet(
88
+ "id",
89
+ "direction",
90
+ linkType=FieldSet("name"),
91
+ issues=FieldSet("id", "idReadable", "summary"),
92
+ ),
93
+ attachments=FieldSet(
94
+ "id", "name", "size", "mimeType", "created", author=FieldSet("id", "login", "name")
95
+ ),
96
+ )
97
+ )
98
+ _COMMENT_FIELDS = "id,text,author(id,login,name),created,updated"
99
+ _WORK_ITEM_FIELDS = (
100
+ "id,duration(minutes),text,type(id,name,autoAttached),author(id,login,name),date"
101
+ )
102
+ _ATTACHMENT_FIELDS = "id,name,size,mimeType,author(id,login,name),created,url"
103
+ _USER_FIELDS = "id,login,name,email,ringId"
104
+ _ASSIST_FIELDS = str(
105
+ FieldSet(
106
+ "query",
107
+ "caret",
108
+ suggestions=FieldSet(
109
+ "option",
110
+ "description",
111
+ "prefix",
112
+ "suffix",
113
+ "completionStart",
114
+ "completionEnd",
115
+ "matchingStart",
116
+ "matchingEnd",
117
+ "group",
118
+ ),
119
+ )
120
+ )
121
+ _PROJECT_CUSTOM_FIELDS_PARAM = (
122
+ "customFields("
123
+ "field(name,fieldType(id)),"
124
+ "$type,"
125
+ "bundle(values(name,ordinal,isResolved)),"
126
+ "canBeEmpty,emptyFieldText"
127
+ ")"
128
+ )
129
+
130
+
131
+ class YouTrackAPI:
132
+ """YouTrack REST API wrapper (/api/...)."""
133
+
134
+ def __init__(self, http: HttpClient) -> None:
135
+ self._http = http
136
+
137
+ # --- Projects ---
138
+
139
+ def list_projects(self, top: int = 50, skip: int = 0) -> list[Project]:
140
+ data = self._http.get(
141
+ "/admin/projects",
142
+ fields=_PROJECT_LIST_FIELDS,
143
+ params={"$top": top, "$skip": skip},
144
+ )
145
+ return [Project.model_validate(p) for p in data]
146
+
147
+ def get_project(self, project_id: str) -> Project:
148
+ data = self._http.get(f"/admin/projects/{project_id}", fields=_PROJECT_DETAIL_FIELDS)
149
+ return Project.model_validate(data)
150
+
151
+ def create_project(self, project: ProjectCreate) -> Project:
152
+ data = self._http.post(
153
+ "/admin/projects",
154
+ json=project.model_dump(by_alias=True, exclude_none=True),
155
+ fields=_PROJECT_LIST_FIELDS,
156
+ )
157
+ return Project.model_validate(data)
158
+
159
+ def delete_project(self, project_id: str) -> None:
160
+ self._http.delete(f"/admin/projects/{project_id}")
161
+
162
+ def list_project_custom_fields(self, project_id: str) -> list[dict[str, Any]]:
163
+ """Fetch custom fields for a project with bundle values."""
164
+ data: dict[str, Any] = self._http.get(
165
+ f"/admin/projects/{project_id}",
166
+ fields=_PROJECT_CUSTOM_FIELDS_PARAM,
167
+ )
168
+ result: list[dict[str, Any]] = data.get("customFields", [])
169
+ return result
170
+
171
+ # --- Time Tracking ---
172
+
173
+ def get_time_tracking_settings(self, project_id: str) -> TimeTrackingSettings:
174
+ data = self._http.get(
175
+ f"/admin/projects/{project_id}/timeTrackingSettings",
176
+ fields="enabled,estimate(field(name)),timeSpent(field(name)),workItemTypes(id,name)",
177
+ )
178
+ return TimeTrackingSettings.model_validate(data)
179
+
180
+ def update_time_tracking_settings(self, project_id: str, enabled: bool) -> TimeTrackingSettings:
181
+ data = self._http.post(
182
+ f"/admin/projects/{project_id}/timeTrackingSettings",
183
+ json={"enabled": enabled},
184
+ )
185
+ if data:
186
+ return TimeTrackingSettings.model_validate(data)
187
+ return TimeTrackingSettings(enabled=enabled)
188
+
189
+ # --- Issues ---
190
+
191
+ def list_issues(self, query: str = "", top: int = 50, skip: int = 0) -> list[Issue]:
192
+ params: dict[str, Any] = {"$top": top, "$skip": skip}
193
+ if query:
194
+ params["query"] = query
195
+ data = self._http.get("/issues", fields=_ISSUE_FIELDS, params=params)
196
+ return [Issue.model_validate(i) for i in data]
197
+
198
+ def get_issue(self, issue_id: str, rich: bool = False) -> Issue:
199
+ fields = _ISSUE_RICH_FIELDS if rich else _ISSUE_FIELDS
200
+ data = self._http.get(f"/issues/{issue_id}", fields=fields)
201
+ return Issue.model_validate(data)
202
+
203
+ def create_issue(self, issue: IssueCreate) -> Issue:
204
+ data = self._http.post(
205
+ "/issues",
206
+ json=issue.model_dump(by_alias=True, exclude_none=True),
207
+ fields=_ISSUE_FIELDS,
208
+ )
209
+ return Issue.model_validate(data)
210
+
211
+ def update_issue(self, issue_id: str, update: IssueUpdate) -> Issue:
212
+ data = self._http.post(
213
+ f"/issues/{issue_id}",
214
+ json=update.model_dump(by_alias=True, exclude_none=True),
215
+ fields=_ISSUE_FIELDS,
216
+ )
217
+ return Issue.model_validate(data)
218
+
219
+ def delete_issue(self, issue_id: str) -> None:
220
+ self._http.delete(f"/issues/{issue_id}")
221
+
222
+ # --- Comments ---
223
+
224
+ def list_comments(self, issue_id: str) -> list[Comment]:
225
+ data = self._http.get(f"/issues/{issue_id}/comments", fields=_COMMENT_FIELDS)
226
+ return [Comment.model_validate(c) for c in data]
227
+
228
+ def create_comment(self, issue_id: str, comment: CommentCreate) -> Comment:
229
+ data = self._http.post(
230
+ f"/issues/{issue_id}/comments",
231
+ json=comment.model_dump(exclude_none=True),
232
+ fields=_COMMENT_FIELDS,
233
+ )
234
+ return Comment.model_validate(data)
235
+
236
+ # --- Work Items ---
237
+
238
+ def list_work_items(self, issue_id: str) -> list[WorkItem]:
239
+ data = self._http.get(
240
+ f"/issues/{issue_id}/timeTracking/workItems",
241
+ fields=_WORK_ITEM_FIELDS,
242
+ )
243
+ return [WorkItem.model_validate(w) for w in data]
244
+
245
+ def create_work_item(self, issue_id: str, work_item: WorkItemCreate) -> WorkItem:
246
+ data = self._http.post(
247
+ f"/issues/{issue_id}/timeTracking/workItems",
248
+ json=work_item.model_dump(by_alias=True, exclude_none=True),
249
+ fields=_WORK_ITEM_FIELDS,
250
+ )
251
+ return WorkItem.model_validate(data)
252
+
253
+ def get_work_item(self, issue_id: str, work_item_id: str) -> WorkItem:
254
+ data = self._http.get(
255
+ f"/issues/{issue_id}/timeTracking/workItems/{work_item_id}",
256
+ fields=_WORK_ITEM_FIELDS,
257
+ )
258
+ return WorkItem.model_validate(data)
259
+
260
+ def update_work_item(
261
+ self, issue_id: str, work_item_id: str, update: WorkItemUpdate
262
+ ) -> WorkItem:
263
+ data = self._http.post(
264
+ f"/issues/{issue_id}/timeTracking/workItems/{work_item_id}",
265
+ json=update.model_dump(by_alias=True, exclude_none=True),
266
+ fields=_WORK_ITEM_FIELDS,
267
+ )
268
+ return WorkItem.model_validate(data)
269
+
270
+ def delete_work_item(self, issue_id: str, work_item_id: str) -> None:
271
+ self._http.delete(f"/issues/{issue_id}/timeTracking/workItems/{work_item_id}")
272
+
273
+ # --- Work Item Types ---
274
+
275
+ def list_work_item_types(self) -> list[WorkItemType]:
276
+ data = self._http.get(
277
+ "/admin/timeTrackingSettings/workItemTypes",
278
+ fields="id,name,autoAttached",
279
+ params={"$top": 50},
280
+ )
281
+ return [WorkItemType.model_validate(t) for t in data]
282
+
283
+ # --- Custom Field Bundles ---
284
+
285
+ def list_enum_bundles(self) -> list[dict[str, Any]]:
286
+ data: list[dict[str, Any]] = self._http.get(
287
+ "/admin/customFieldSettings/bundles/enum",
288
+ fields="id,name,values(name,ordinal)",
289
+ params={"$top": 50},
290
+ )
291
+ return data
292
+
293
+ def list_state_bundles(self) -> list[dict[str, Any]]:
294
+ data: list[dict[str, Any]] = self._http.get(
295
+ "/admin/customFieldSettings/bundles/state",
296
+ fields="id,name,values(name,isResolved,ordinal)",
297
+ params={"$top": 50},
298
+ )
299
+ return data
300
+
301
+ # --- Issue Links ---
302
+
303
+ def list_issue_links(self, issue_id: str) -> list[dict[str, Any]]:
304
+ data: list[dict[str, Any]] = self._http.get(
305
+ f"/issues/{issue_id}/links",
306
+ fields="id,direction,linkType(name),issues(id,idReadable,summary)",
307
+ )
308
+ return data
309
+
310
+ def apply_command(self, issue_id: str, command: str) -> None:
311
+ """Apply a command to an issue via the commands API.
312
+
313
+ Uses ``POST /commands`` which accepts YT command language strings
314
+ like ``"subtask of FB-2"`` or ``"State: Open"``.
315
+ """
316
+ self._http.post(
317
+ "/commands",
318
+ json={
319
+ "query": command,
320
+ "issues": [{"idReadable": issue_id}],
321
+ },
322
+ )
323
+
324
+ # --- Attachments ---
325
+
326
+ def list_attachments(self, issue_id: str) -> list[Attachment]:
327
+ data = self._http.get(f"/issues/{issue_id}/attachments", fields=_ATTACHMENT_FIELDS)
328
+ return [Attachment.model_validate(a) for a in data]
329
+
330
+ def get_attachment(self, issue_id: str, attachment_id: str) -> Attachment:
331
+ data = self._http.get(
332
+ f"/issues/{issue_id}/attachments/{attachment_id}",
333
+ fields=_ATTACHMENT_FIELDS,
334
+ )
335
+ return Attachment.model_validate(data)
336
+
337
+ def create_attachment(self, issue_id: str, file_name: str, file_content: bytes) -> Attachment:
338
+ data = self._http.post_file(
339
+ f"/issues/{issue_id}/attachments",
340
+ file_name=file_name,
341
+ file_content=file_content,
342
+ fields=_ATTACHMENT_FIELDS,
343
+ )
344
+ # YouTrack returns a list of created attachments
345
+ if isinstance(data, list):
346
+ return Attachment.model_validate(data[0])
347
+ return Attachment.model_validate(data)
348
+
349
+ def delete_attachment(self, issue_id: str, attachment_id: str) -> None:
350
+ self._http.delete(f"/issues/{issue_id}/attachments/{attachment_id}")
351
+
352
+ def download_attachment(self, issue_id: str, attachment_id: str) -> bytes:
353
+ """Download attachment content.
354
+
355
+ Fetches the attachment metadata first to get the signed download URL,
356
+ then downloads the binary content from that URL.
357
+ """
358
+ meta = self.get_attachment(issue_id, attachment_id)
359
+ if not meta.url:
360
+ raise ValueError(f"Attachment {attachment_id} has no download URL")
361
+ return self._http.get_bytes(meta.url)
362
+
363
+ def list_link_types(self) -> list[dict[str, Any]]:
364
+ data: list[dict[str, Any]] = self._http.get(
365
+ "/issueLinkTypes",
366
+ fields="id,name,sourceToTarget,targetToSource,directed",
367
+ params={"$top": 50},
368
+ )
369
+ return data
370
+
371
+ # --- Current User ---
372
+
373
+ def get_me(self) -> dict[str, Any]:
374
+ """Get the currently authenticated user. Used for connectivity check."""
375
+ data: dict[str, Any] = self._http.get("/users/me", fields=_USER_FIELDS)
376
+ return data
377
+
378
+ # --- Users (for ringId resolution) ---
379
+
380
+ def search_users(self, query: str) -> list[User]:
381
+ """Search users via YT API. Used to resolve login -> ringId."""
382
+ data = self._http.get("/users", fields=_USER_FIELDS, params={"query": query})
383
+ return [User.model_validate(u) for u in data]
384
+
385
+ # --- Query / Command Assist ---
386
+
387
+ def search_assist(self, query: str, caret: int | None = None) -> AssistResponse:
388
+ """Autocomplete for YouTrack search queries (``POST /api/search/assist``)."""
389
+ body: dict[str, Any] = {"query": query}
390
+ if caret is not None:
391
+ body["caret"] = caret
392
+ data = self._http.post("/search/assist", json=body, fields=_ASSIST_FIELDS)
393
+ return AssistResponse.model_validate(data)
394
+
395
+ def commands_assist(
396
+ self,
397
+ query: str,
398
+ caret: int | None = None,
399
+ issue_ids: list[str] | None = None,
400
+ ) -> AssistResponse:
401
+ """Autocomplete for YT command-language strings (``POST /api/commands/assist``).
402
+
403
+ ``issue_ids`` is the list of issue readable IDs the command would apply to;
404
+ when empty, the assist still returns field/value suggestions that work for
405
+ any issue.
406
+ """
407
+ body: dict[str, Any] = {
408
+ "query": query,
409
+ "issues": [{"idReadable": i} for i in (issue_ids or [])],
410
+ }
411
+ if caret is not None:
412
+ body["caret"] = caret
413
+ data = self._http.post("/commands/assist", json=body, fields=_ASSIST_FIELDS)
414
+ return AssistResponse.model_validate(data)