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/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)
|