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
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
|
+
)
|
kd/api/saved_queries.py
ADDED
|
@@ -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]
|