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/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """kd — knowledge dispatcher for live knowledge systems.
2
+
3
+ SDK-first design: import YouTrackClient for programmatic access,
4
+ or use the ``kd`` command for CLI operations.
5
+
6
+ Usage::
7
+
8
+ from kd import YouTrackClient
9
+
10
+ client = YouTrackClient(url="https://example.youtrack.cloud", token="...")
11
+ projects = client.projects.list()
12
+ """
13
+
14
+ from importlib.metadata import version as _pkg_version
15
+
16
+ __version__ = _pkg_version("kd-cli")
17
+
18
+ from kd.client import YouTrackClient
19
+ from kd.config import ConfigSource, ResolvedConfig, resolve_config
20
+ from kd.debug import DebugLevel, DebugLog
21
+ from kd.exceptions import (
22
+ APIError,
23
+ AuthError,
24
+ ConfigError,
25
+ ConflictError,
26
+ NotFoundError,
27
+ ValidationError,
28
+ KdError,
29
+ )
30
+
31
+ __all__ = [
32
+ "APIError",
33
+ "AuthError",
34
+ "ConfigError",
35
+ "ConfigSource",
36
+ "ConflictError",
37
+ "DebugLevel",
38
+ "DebugLog",
39
+ "NotFoundError",
40
+ "ResolvedConfig",
41
+ "ValidationError",
42
+ "YouTrackClient",
43
+ "KdError",
44
+ "resolve_config",
45
+ ]
kd/api/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Low-level API wrappers.
2
+
3
+ Four API layers, same bearer token:
4
+ - ArticlesAPI: ``/api/articles`` — knowledge base articles
5
+ - TagsAPI: ``/api/tags`` — tags, issue/article tagging
6
+ - YouTrackAPI: ``/api/...`` — projects, issues, fields, time tracking
7
+ - HubAPI: ``/hub/api/rest/...`` — users, groups, project teams, roles
8
+ """
9
+
10
+ from kd.api.articles import ArticlesAPI
11
+ from kd.api.hub import HubAPI
12
+ from kd.api.tags import TagsAPI
13
+ from kd.api.youtrack import YouTrackAPI
14
+
15
+ __all__ = ["ArticlesAPI", "HubAPI", "TagsAPI", "YouTrackAPI"]
kd/api/articles.py ADDED
@@ -0,0 +1,232 @@
1
+ """Articles REST API wrapper.
2
+
3
+ Covers ``/api/articles`` endpoints:
4
+ - Articles: list (global + project-scoped), get, create, update, delete
5
+ - Child articles: list, attach, detach parent
6
+ - Comments: list, create
7
+ - Attachments: list, get, create, delete
8
+
9
+ Articles have no ``query`` parameter — filtering is project-scoped only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from kd.api.http import HttpClient
15
+ from kd.models import (
16
+ Article,
17
+ ArticleCreate,
18
+ ArticleUpdate,
19
+ Attachment,
20
+ Comment,
21
+ CommentCreate,
22
+ FieldSet,
23
+ )
24
+
25
+ # List fields omit content to keep responses lightweight
26
+ _ARTICLE_LIST_FIELDS = str(
27
+ FieldSet(
28
+ "id",
29
+ "idReadable",
30
+ "summary",
31
+ "created",
32
+ "updated",
33
+ "hasChildren",
34
+ project=FieldSet("id", "name"),
35
+ reporter=FieldSet("id", "name"),
36
+ parentArticle=FieldSet("id", "idReadable"),
37
+ )
38
+ )
39
+
40
+ # Tag-scan fields include tags for client-side tag filtering.
41
+ # Kept separate from _ARTICLE_LIST_FIELDS so normal article list
42
+ # calls don't pay the tag payload cost.
43
+ _ARTICLE_TAG_SCAN_FIELDS = str(
44
+ FieldSet(
45
+ "id",
46
+ "idReadable",
47
+ "summary",
48
+ "created",
49
+ "updated",
50
+ "hasChildren",
51
+ project=FieldSet("id", "name"),
52
+ reporter=FieldSet("id", "name"),
53
+ parentArticle=FieldSet("id", "idReadable"),
54
+ tags=FieldSet("id", "name"),
55
+ )
56
+ )
57
+
58
+ # Detail fields include content for single-article views
59
+ _ARTICLE_DETAIL_FIELDS = str(
60
+ FieldSet(
61
+ "id",
62
+ "idReadable",
63
+ "summary",
64
+ "content",
65
+ "created",
66
+ "updated",
67
+ "hasChildren",
68
+ project=FieldSet("id", "name"),
69
+ reporter=FieldSet("id", "name"),
70
+ parentArticle=FieldSet("id", "idReadable"),
71
+ tags=FieldSet("id", "name"),
72
+ )
73
+ )
74
+
75
+
76
+ _ARTICLE_COMMENT_FIELDS = "id,text,pinned,author(id,login,name),created,updated"
77
+ _ARTICLE_ATTACHMENT_FIELDS = (
78
+ "id,name,size,mimeType,extension,author(id,login,name),created,updated,url"
79
+ )
80
+
81
+
82
+ class ArticlesAPI:
83
+ """Articles REST API wrapper (/api/articles)."""
84
+
85
+ def __init__(self, http: HttpClient) -> None:
86
+ self._http = http
87
+
88
+ # --- List ---
89
+
90
+ def list_articles(self, top: int = 50, skip: int = 0) -> list[Article]:
91
+ data = self._http.get(
92
+ "/articles",
93
+ fields=_ARTICLE_LIST_FIELDS,
94
+ params={"$top": top, "$skip": skip},
95
+ )
96
+ return [Article.model_validate(a) for a in data]
97
+
98
+ def list_project_articles(self, project_id: str, top: int = 50, skip: int = 0) -> list[Article]:
99
+ data = self._http.get(
100
+ f"/admin/projects/{project_id}/articles",
101
+ fields=_ARTICLE_LIST_FIELDS,
102
+ params={"$top": top, "$skip": skip},
103
+ )
104
+ return [Article.model_validate(a) for a in data]
105
+
106
+ # --- Tag scan (articles with tags for client-side filtering) ---
107
+
108
+ def list_articles_with_tags(self, top: int = 50, skip: int = 0) -> list[Article]:
109
+ data = self._http.get(
110
+ "/articles",
111
+ fields=_ARTICLE_TAG_SCAN_FIELDS,
112
+ params={"$top": top, "$skip": skip},
113
+ )
114
+ return [Article.model_validate(a) for a in data]
115
+
116
+ def list_project_articles_with_tags(
117
+ self, project_id: str, top: int = 50, skip: int = 0
118
+ ) -> list[Article]:
119
+ data = self._http.get(
120
+ f"/admin/projects/{project_id}/articles",
121
+ fields=_ARTICLE_TAG_SCAN_FIELDS,
122
+ params={"$top": top, "$skip": skip},
123
+ )
124
+ return [Article.model_validate(a) for a in data]
125
+
126
+ # --- Get ---
127
+
128
+ def get_article(self, article_id: str) -> Article:
129
+ data = self._http.get(
130
+ f"/articles/{article_id}",
131
+ fields=_ARTICLE_DETAIL_FIELDS,
132
+ )
133
+ return Article.model_validate(data)
134
+
135
+ # --- Children ---
136
+
137
+ def list_child_articles(self, article_id: str, top: int = 50, skip: int = 0) -> list[Article]:
138
+ data = self._http.get(
139
+ f"/articles/{article_id}/childArticles",
140
+ fields=_ARTICLE_LIST_FIELDS,
141
+ params={"$top": top, "$skip": skip},
142
+ )
143
+ return [Article.model_validate(a) for a in data]
144
+
145
+ # --- Create / Update / Delete ---
146
+
147
+ def create_article(self, article: ArticleCreate) -> Article:
148
+ data = self._http.post(
149
+ "/articles",
150
+ json=article.model_dump(by_alias=True, exclude_none=True),
151
+ fields=_ARTICLE_DETAIL_FIELDS,
152
+ )
153
+ return Article.model_validate(data)
154
+
155
+ def update_article(self, article_id: str, update: ArticleUpdate) -> Article:
156
+ data = self._http.post(
157
+ f"/articles/{article_id}",
158
+ json=update.model_dump(by_alias=True, exclude_none=True),
159
+ fields=_ARTICLE_DETAIL_FIELDS,
160
+ )
161
+ return Article.model_validate(data)
162
+
163
+ def delete_article(self, article_id: str) -> None:
164
+ self._http.delete(f"/articles/{article_id}")
165
+
166
+ # --- Comments ---
167
+
168
+ def list_article_comments(self, article_id: str) -> list[Comment]:
169
+ data = self._http.get(
170
+ f"/articles/{article_id}/comments",
171
+ fields=_ARTICLE_COMMENT_FIELDS,
172
+ )
173
+ return [Comment.model_validate(c) for c in data]
174
+
175
+ def create_article_comment(self, article_id: str, comment: CommentCreate) -> Comment:
176
+ data = self._http.post(
177
+ f"/articles/{article_id}/comments",
178
+ json=comment.model_dump(exclude_none=True),
179
+ fields=_ARTICLE_COMMENT_FIELDS,
180
+ )
181
+ return Comment.model_validate(data)
182
+
183
+ # --- Attachments ---
184
+
185
+ def list_article_attachments(self, article_id: str) -> list[Attachment]:
186
+ data = self._http.get(
187
+ f"/articles/{article_id}/attachments",
188
+ fields=_ARTICLE_ATTACHMENT_FIELDS,
189
+ )
190
+ return [Attachment.model_validate(a) for a in data]
191
+
192
+ def get_article_attachment(self, article_id: str, attachment_id: str) -> Attachment:
193
+ data = self._http.get(
194
+ f"/articles/{article_id}/attachments/{attachment_id}",
195
+ fields=_ARTICLE_ATTACHMENT_FIELDS,
196
+ )
197
+ return Attachment.model_validate(data)
198
+
199
+ def create_article_attachment(
200
+ self, article_id: str, file_name: str, file_content: bytes
201
+ ) -> Attachment:
202
+ data = self._http.post_file(
203
+ f"/articles/{article_id}/attachments",
204
+ file_name=file_name,
205
+ file_content=file_content,
206
+ fields=_ARTICLE_ATTACHMENT_FIELDS,
207
+ )
208
+ if isinstance(data, list):
209
+ return Attachment.model_validate(data[0])
210
+ return Attachment.model_validate(data)
211
+
212
+ def delete_article_attachment(self, article_id: str, attachment_id: str) -> None:
213
+ self._http.delete(f"/articles/{article_id}/attachments/{attachment_id}")
214
+
215
+ # --- Hierarchy ---
216
+
217
+ def attach_child_article(self, parent_id: str, child_id: str) -> None:
218
+ """Attach an existing article as a child of another."""
219
+ self._http.post(
220
+ f"/articles/{parent_id}/childArticles",
221
+ json={"id": child_id},
222
+ )
223
+
224
+ def detach_parent_article(self, article_id: str) -> None:
225
+ """Remove an article's parent (move to project root).
226
+
227
+ Uses POST update with parentArticle set to null.
228
+ """
229
+ self._http.post(
230
+ f"/articles/{article_id}",
231
+ json={"parentArticle": None},
232
+ )
kd/api/http.py ADDED
@@ -0,0 +1,234 @@
1
+ """Shared HTTP client.
2
+
3
+ Wraps httpx.Client with:
4
+ - Bearer token authentication
5
+ - Base URL management
6
+ - Explicit ``fields`` parameter support (YouTrack requires this)
7
+ - Error handling: maps HTTP errors to kd exceptions
8
+ - Retry logic for transient failures
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from kd.debug import DebugLog
20
+ from kd.exceptions import (
21
+ APIError,
22
+ AuthError,
23
+ ConflictError,
24
+ NotFoundError,
25
+ PermissionDeniedError,
26
+ enrich_error,
27
+ )
28
+
29
+ _RETRYABLE_STATUSES = {429, 502, 503}
30
+ _MAX_RETRIES = 3
31
+
32
+
33
+ class HttpClient:
34
+ """Low-level HTTP client with auth, retry, and error mapping."""
35
+
36
+ def __init__(
37
+ self,
38
+ base_url: str,
39
+ token: str,
40
+ timeout: float = 30.0,
41
+ debug_log: DebugLog | None = None,
42
+ ) -> None:
43
+ self._debug = debug_log or DebugLog()
44
+ self._client = httpx.Client(
45
+ base_url=base_url,
46
+ headers={
47
+ "Authorization": f"Bearer {token}",
48
+ "Accept": "application/json",
49
+ },
50
+ timeout=timeout,
51
+ )
52
+
53
+ def get(
54
+ self,
55
+ path: str,
56
+ fields: str | None = None,
57
+ params: dict[str, Any] | None = None,
58
+ ) -> Any:
59
+ """GET with automatic fields injection."""
60
+ all_params: dict[str, Any] = dict(params or {})
61
+ if fields:
62
+ all_params["fields"] = fields
63
+ response = self._request("GET", path, params=all_params)
64
+ return response.json()
65
+
66
+ def post(
67
+ self,
68
+ path: str,
69
+ json: dict[str, Any] | None = None,
70
+ fields: str | None = None,
71
+ params: dict[str, Any] | None = None,
72
+ ) -> Any:
73
+ """POST with optional fields on query string."""
74
+ all_params: dict[str, Any] = dict(params or {})
75
+ if fields:
76
+ all_params["fields"] = fields
77
+ response = self._request("POST", path, json=json, params=all_params or None)
78
+ if response.status_code == 204 or not response.content:
79
+ return {}
80
+ return response.json()
81
+
82
+ def post_file(
83
+ self,
84
+ path: str,
85
+ file_name: str,
86
+ file_content: bytes,
87
+ fields: str | None = None,
88
+ params: dict[str, Any] | None = None,
89
+ ) -> Any:
90
+ """POST multipart file upload."""
91
+ all_params: dict[str, Any] = dict(params or {})
92
+ if fields:
93
+ all_params["fields"] = fields
94
+ response = self._request(
95
+ "POST",
96
+ path,
97
+ files={"file": (file_name, file_content)},
98
+ params=all_params or None,
99
+ )
100
+ if response.status_code == 204 or not response.content:
101
+ return {}
102
+ return response.json()
103
+
104
+ def get_bytes(
105
+ self,
106
+ path: str,
107
+ params: dict[str, Any] | None = None,
108
+ ) -> bytes:
109
+ """GET raw bytes (for file downloads)."""
110
+ response = self._request("GET", path, params=params)
111
+ return response.content
112
+
113
+ def delete(self, path: str) -> None:
114
+ """DELETE. Returns nothing."""
115
+ self._request("DELETE", path)
116
+
117
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
118
+ """Execute request with retry and error mapping."""
119
+ d = self._debug
120
+
121
+ # Log request details before first attempt
122
+ if params := kwargs.get("params"):
123
+ d.debug("http", f"{method} {path}")
124
+ d.debug("http", f" params: {params}")
125
+ else:
126
+ d.debug("http", f"{method} {path}")
127
+
128
+ if kwargs.get("json") is not None:
129
+ d.trace("http", f" body: {kwargs['json']}")
130
+ d.trace("http", " headers: Authorization=Bearer ***")
131
+
132
+ start = time.monotonic()
133
+ last_response: httpx.Response | None = None
134
+ for attempt in range(_MAX_RETRIES):
135
+ try:
136
+ response = self._client.request(method, path, **kwargs)
137
+ except httpx.HTTPError as e:
138
+ d.info("http", f"{method} {path} -> connection error: {e}")
139
+ raise APIError(0, f"Connection error: {e}") from e
140
+
141
+ duration_ms = (time.monotonic() - start) * 1000
142
+
143
+ if response.status_code < 400:
144
+ d.info(
145
+ "http",
146
+ f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms)",
147
+ )
148
+ d.trace("http", f" response: {response.text[:1000]}")
149
+ return response
150
+
151
+ last_response = response
152
+ if response.status_code in _RETRYABLE_STATUSES and attempt < _MAX_RETRIES - 1:
153
+ d.debug(
154
+ "http",
155
+ f" retry {attempt + 1}/{_MAX_RETRIES} (status {response.status_code})",
156
+ )
157
+ continue
158
+
159
+ d.info(
160
+ "http",
161
+ f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms) FAILED",
162
+ )
163
+ d.trace("http", f" response: {response.text[:1000]}")
164
+ self._raise_for_status(response)
165
+
166
+ # Satisfy type checker — _raise_for_status always raises
167
+ assert last_response is not None
168
+ self._raise_for_status(last_response)
169
+ raise AssertionError("unreachable") # pragma: no cover
170
+
171
+ @staticmethod
172
+ def _extract_message(body: str) -> str:
173
+ """Extract a human-readable message from a YouTrack error response.
174
+
175
+ YouTrack returns structured JSON errors like::
176
+
177
+ {"error": "invalid_query",
178
+ "error_description": "Can't parse search query",
179
+ "error_children": [{"error": "detail message"}]}
180
+
181
+ This extracts the most specific message available, falling back
182
+ to the raw body for non-JSON responses.
183
+ """
184
+ try:
185
+ data = json.loads(body)
186
+ except (json.JSONDecodeError, TypeError):
187
+ return body
188
+
189
+ if not isinstance(data, dict):
190
+ return body
191
+
192
+ # Most specific: child error messages
193
+ children = data.get("error_children")
194
+ if isinstance(children, list) and children:
195
+ child_msgs = [c["error"] for c in children if isinstance(c, dict) and "error" in c]
196
+ if child_msgs:
197
+ return "; ".join(child_msgs)
198
+
199
+ # Next: error_description
200
+ desc = data.get("error_description")
201
+ if desc:
202
+ return str(desc)
203
+
204
+ # Fallback: error field (often a code like "invalid_query")
205
+ error = data.get("error")
206
+ if error:
207
+ return str(error)
208
+
209
+ return body
210
+
211
+ @staticmethod
212
+ def _raise_for_status(response: httpx.Response) -> None:
213
+ """Map HTTP status to kd exceptions. Always raises."""
214
+ body = response.text
215
+ status = response.status_code
216
+ extracted = HttpClient._extract_message(body)
217
+ request_path = response.request.url.path if response.request else None
218
+ message, suggestion = enrich_error(extracted, status=status, path=request_path)
219
+ if status == 401:
220
+ raise AuthError(f"Authentication failed: {message}")
221
+ if status == 403:
222
+ body_lower = body.lower()
223
+ if suggestion or "permission" in body_lower or "access" in body_lower:
224
+ raise PermissionDeniedError(message, body, suggestion)
225
+ raise APIError(403, f"Access denied: {message}", body, suggestion)
226
+ if status == 404:
227
+ raise NotFoundError(message, body, suggestion)
228
+ if status == 409:
229
+ raise ConflictError(message, body, suggestion)
230
+ raise APIError(status, f"API error ({status}): {message}", body, suggestion)
231
+
232
+ def close(self) -> None:
233
+ """Close the underlying httpx client."""
234
+ self._client.close()
kd/api/hub.py ADDED
@@ -0,0 +1,69 @@
1
+ """JetBrains Hub REST API wrapper.
2
+
3
+ Covers ``/hub/api/rest/...`` endpoints:
4
+ - Users: list, create, delete
5
+ - Project teams: list, add member
6
+ - Roles: list, assign to user for project
7
+
8
+ Hub uses UUIDs for all entity IDs. The ringId field on YT entities
9
+ bridges the two ID systems.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from kd.api.http import HttpClient
17
+ from kd.models import ProjectTeam, User, UserCreate
18
+
19
+
20
+ class HubAPI:
21
+ """JetBrains Hub REST API wrapper (/hub/api/rest/...)."""
22
+
23
+ def __init__(self, http: HttpClient) -> None:
24
+ self._http = http
25
+
26
+ # --- Users ---
27
+
28
+ def list_users(self, top: int = 100, skip: int = 0) -> list[User]:
29
+ data = self._http.get(
30
+ "/users",
31
+ fields="id,login,name,email,banned",
32
+ params={"$top": top, "$skip": skip},
33
+ )
34
+ users = data.get("users", data) if isinstance(data, dict) else data
35
+ return [User.model_validate(u) for u in users]
36
+
37
+ def create_user(self, user: UserCreate) -> User:
38
+ data = self._http.post("/users", json=user.model_dump(exclude_none=True))
39
+ return User.model_validate(data)
40
+
41
+ def delete_user(self, hub_user_id: str) -> None:
42
+ self._http.delete(f"/users/{hub_user_id}")
43
+
44
+ # --- Project Teams ---
45
+
46
+ def list_project_teams(self, top: int = 100, skip: int = 0) -> list[ProjectTeam]:
47
+ data = self._http.get(
48
+ "/projectteams",
49
+ fields="id,name,project(id,name)",
50
+ params={"$top": top, "$skip": skip},
51
+ )
52
+ teams = data.get("projectteams", data) if isinstance(data, dict) else data
53
+ return [ProjectTeam.model_validate(t) for t in teams]
54
+
55
+ def add_team_member(self, team_id: str, user_hub_id: str) -> None:
56
+ self._http.post(f"/projectteams/{team_id}/users", json={"id": user_hub_id})
57
+
58
+ # --- Roles ---
59
+
60
+ def list_roles(self) -> list[dict[str, Any]]:
61
+ data = self._http.get("/roles", fields="id,name,key", params={"$top": 50})
62
+ roles: list[dict[str, Any]] = data.get("roles", data) if isinstance(data, dict) else data
63
+ return roles
64
+
65
+ def assign_role(self, hub_user_id: str, role_id: str, project_ring_id: str) -> None:
66
+ self._http.post(
67
+ f"/users/{hub_user_id}/projectroles",
68
+ json={"role": {"id": role_id}, "project": {"id": project_ring_id}},
69
+ )
@@ -0,0 +1,76 @@
1
+ """Saved queries REST API wrapper.
2
+
3
+ Covers ``/api/savedQueries`` CRUD plus the user-scoped listing at
4
+ ``/api/users/{userID}/savedQueries``. Sharing settings
5
+ (``readSharingSettings`` / ``updateSharingSettings``) are deliberately
6
+ excluded from the default field set; M022 ships personal CRUD only and a
7
+ future milestone can layer permissions on top.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from kd.api.http import HttpClient
13
+ from kd.models import FieldSet, SavedQuery, SavedQueryCreate, SavedQueryUpdate
14
+
15
+ _SAVED_QUERY_FIELDS = str(
16
+ FieldSet(
17
+ "id",
18
+ "name",
19
+ "query",
20
+ owner=FieldSet("id", "name"),
21
+ )
22
+ )
23
+
24
+
25
+ class SavedQueriesAPI:
26
+ """Saved queries REST API wrapper (/api/savedQueries, /api/users/*/savedQueries)."""
27
+
28
+ def __init__(self, http: HttpClient) -> None:
29
+ self._http = http
30
+
31
+ def list_saved_queries(self, top: int = 50, skip: int = 0) -> list[SavedQuery]:
32
+ data = self._http.get(
33
+ "/savedQueries",
34
+ fields=_SAVED_QUERY_FIELDS,
35
+ params={"$top": top, "$skip": skip},
36
+ )
37
+ return [SavedQuery.model_validate(sq) for sq in data]
38
+
39
+ def get_saved_query(self, sq_id: str) -> SavedQuery:
40
+ data = self._http.get(f"/savedQueries/{sq_id}", fields=_SAVED_QUERY_FIELDS)
41
+ return SavedQuery.model_validate(data)
42
+
43
+ def create_saved_query(self, sq: SavedQueryCreate) -> SavedQuery:
44
+ data = self._http.post(
45
+ "/savedQueries",
46
+ json=sq.model_dump(by_alias=True),
47
+ fields=_SAVED_QUERY_FIELDS,
48
+ )
49
+ return SavedQuery.model_validate(data)
50
+
51
+ def update_saved_query(self, sq_id: str, update: SavedQueryUpdate) -> SavedQuery:
52
+ data = self._http.post(
53
+ f"/savedQueries/{sq_id}",
54
+ json=update.model_dump(by_alias=True, exclude_none=True),
55
+ fields=_SAVED_QUERY_FIELDS,
56
+ )
57
+ return SavedQuery.model_validate(data)
58
+
59
+ def delete_saved_query(self, sq_id: str) -> None:
60
+ self._http.delete(f"/savedQueries/{sq_id}")
61
+
62
+ def list_user_saved_queries(
63
+ self, user_id: str, top: int = 50, skip: int = 0
64
+ ) -> list[SavedQuery]:
65
+ """List saved queries owned by a specific user.
66
+
67
+ The literal string ``"me"`` is accepted as ``user_id`` and resolves
68
+ to the authenticated user server-side (verified against
69
+ ``vod.youtrack.cloud``).
70
+ """
71
+ data = self._http.get(
72
+ f"/users/{user_id}/savedQueries",
73
+ fields=_SAVED_QUERY_FIELDS,
74
+ params={"$top": top, "$skip": skip},
75
+ )
76
+ return [SavedQuery.model_validate(sq) for sq in data]