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/client.py
ADDED
|
@@ -0,0 +1,1953 @@
|
|
|
1
|
+
"""YouTrackClient — main SDK entry point.
|
|
2
|
+
|
|
3
|
+
Composes YouTrackAPI and HubAPI into a single high-level interface.
|
|
4
|
+
Accepts a :class:`~kd.connection.ConnectionContext` (resolved URL + token)
|
|
5
|
+
and provides grouped access to operations:
|
|
6
|
+
|
|
7
|
+
from kd.connection import ConnectionContext
|
|
8
|
+
ctx = ConnectionContext(name="main", provider="youtrack",
|
|
9
|
+
base_url="https://yt.example.com", token="perm:...",
|
|
10
|
+
default_project=None, output=None, limit=None,
|
|
11
|
+
defaults={}, compat_tier="auto", capabilities=frozenset())
|
|
12
|
+
client = YouTrackClient(connection=ctx)
|
|
13
|
+
|
|
14
|
+
For quick SDK usage, ``url`` and ``token`` keyword arguments are also accepted:
|
|
15
|
+
|
|
16
|
+
client = YouTrackClient(url="https://yt.example.com", token="perm:...")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import builtins
|
|
22
|
+
import difflib
|
|
23
|
+
from collections import OrderedDict
|
|
24
|
+
from dataclasses import dataclass, field as dataclass_field
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
from kd.api.articles import ArticlesAPI
|
|
30
|
+
from kd.api.http import HttpClient
|
|
31
|
+
from kd.api.hub import HubAPI
|
|
32
|
+
from kd.api.saved_queries import SavedQueriesAPI
|
|
33
|
+
from kd.api.tags import TagsAPI
|
|
34
|
+
from kd.api.youtrack import YouTrackAPI
|
|
35
|
+
from kd.article_marker import (
|
|
36
|
+
ArticleMarker,
|
|
37
|
+
MarkerError,
|
|
38
|
+
ParsedFile,
|
|
39
|
+
parse_content,
|
|
40
|
+
render_content,
|
|
41
|
+
validate_slug,
|
|
42
|
+
)
|
|
43
|
+
from kd.config import ResolvedConfig
|
|
44
|
+
from kd.connection import ConnectionContext
|
|
45
|
+
from kd.debug import DebugLog
|
|
46
|
+
from kd.exceptions import APIError, ConfigError, NotFoundError, ValidationError, KdError
|
|
47
|
+
from kd.models import (
|
|
48
|
+
Article,
|
|
49
|
+
ArticleCreate,
|
|
50
|
+
ArticleUpdate,
|
|
51
|
+
AssistResponse,
|
|
52
|
+
Attachment,
|
|
53
|
+
BundleValue,
|
|
54
|
+
Comment,
|
|
55
|
+
CommentCreate,
|
|
56
|
+
CustomField,
|
|
57
|
+
Duration,
|
|
58
|
+
IdEntity,
|
|
59
|
+
Issue,
|
|
60
|
+
IssueCreate,
|
|
61
|
+
IssueUpdate,
|
|
62
|
+
Project,
|
|
63
|
+
ProjectCreate,
|
|
64
|
+
ProjectCustomField,
|
|
65
|
+
QueryValidationResult,
|
|
66
|
+
SavedQuery,
|
|
67
|
+
SavedQueryCreate,
|
|
68
|
+
SavedQueryUpdate,
|
|
69
|
+
SearchResult,
|
|
70
|
+
Tag,
|
|
71
|
+
TimeTrackingSettings,
|
|
72
|
+
User,
|
|
73
|
+
UserCreate,
|
|
74
|
+
WorkItem,
|
|
75
|
+
WorkItemCreate,
|
|
76
|
+
WorkItemType,
|
|
77
|
+
WorkItemUpdate,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Maps user-friendly link type names to YT command language strings.
|
|
82
|
+
# Used with POST /api/commands to create issue links.
|
|
83
|
+
_LINK_TYPE_MAP: dict[str, str] = {
|
|
84
|
+
"subtask-of": "subtask of",
|
|
85
|
+
"parent-for": "parent for",
|
|
86
|
+
"depends-on": "depends on",
|
|
87
|
+
"required-for": "is required for",
|
|
88
|
+
"duplicates": "duplicates",
|
|
89
|
+
"duplicated-by": "is duplicated by",
|
|
90
|
+
"relates-to": "relates to",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_link_command(link_type: str) -> str:
|
|
95
|
+
"""Resolve a user-friendly link type to a YT command string."""
|
|
96
|
+
result = _LINK_TYPE_MAP.get(link_type.lower())
|
|
97
|
+
if not result:
|
|
98
|
+
valid = ", ".join(sorted(_LINK_TYPE_MAP))
|
|
99
|
+
raise ValidationError(f"Unknown link type '{link_type}'. Valid types: {valid}")
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Maps project custom field $type to the $type needed for issue mutations.
|
|
104
|
+
_PROJECT_TO_ISSUE_TYPE: dict[str, str] = {
|
|
105
|
+
"EnumProjectCustomField": "SingleEnumIssueCustomField",
|
|
106
|
+
"StateProjectCustomField": "StateIssueCustomField",
|
|
107
|
+
"UserProjectCustomField": "SingleUserIssueCustomField",
|
|
108
|
+
"OwnedProjectCustomField": "SingleOwnedIssueCustomField",
|
|
109
|
+
"VersionProjectCustomField": "MultiVersionIssueCustomField",
|
|
110
|
+
"BuildProjectCustomField": "SingleBuildIssueCustomField",
|
|
111
|
+
"PeriodProjectCustomField": "PeriodIssueCustomField",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Default target field names for semantic CLI slots.
|
|
115
|
+
_SLOT_DEFAULTS: dict[str, str] = {
|
|
116
|
+
"state": "State",
|
|
117
|
+
"type": "Type",
|
|
118
|
+
"priority": "Priority",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Article push — types and helpers
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class PushResult:
|
|
129
|
+
"""Outcome of ``ArticleOperations.push``.
|
|
130
|
+
|
|
131
|
+
- ``action`` is ``"create"`` or ``"update"``. An ``"unchanged"``
|
|
132
|
+
no-op would require a content-hash optimization the M029 marker
|
|
133
|
+
format explicitly defers to a later milestone.
|
|
134
|
+
- ``article_id`` is the readable id of the target article (empty
|
|
135
|
+
string for dry-run of a create that has not written yet).
|
|
136
|
+
- ``remote_updated_sec`` is the epoch-seconds timestamp of the
|
|
137
|
+
article's ``updated`` field on the upstream side after the push.
|
|
138
|
+
Zero for dry-runs that did not resolve an existing article.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
action: Literal["create", "update"]
|
|
142
|
+
article_id: str
|
|
143
|
+
slug: str
|
|
144
|
+
path: Path
|
|
145
|
+
remote_updated_sec: int
|
|
146
|
+
dry_run: bool
|
|
147
|
+
tags_applied: builtins.list[str] = dataclass_field(default_factory=list)
|
|
148
|
+
tags_failed: builtins.list[str] = dataclass_field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _derive_slug_from_path(file_path: Path) -> str:
|
|
152
|
+
"""Derive a slug from a file path: stem relative to CWD if possible.
|
|
153
|
+
|
|
154
|
+
``docs/infra/dns-setup.md`` → ``docs/infra/dns-setup`` (when run
|
|
155
|
+
from the directory containing ``docs``).
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
rel = file_path.resolve().relative_to(Path.cwd().resolve())
|
|
159
|
+
except ValueError:
|
|
160
|
+
rel = Path(file_path.name)
|
|
161
|
+
# Drop the last suffix only; keep intermediate dots (e.g. v1.2).
|
|
162
|
+
parent = rel.parent
|
|
163
|
+
stem = rel.stem
|
|
164
|
+
if str(parent) in ("", "."):
|
|
165
|
+
return stem
|
|
166
|
+
return f"{parent.as_posix()}/{stem}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _iso_to_epoch_sec(iso: str) -> int:
|
|
170
|
+
"""Parse ``YYYY-MM-DDTHH:MM:SSZ`` into epoch seconds (UTC).
|
|
171
|
+
|
|
172
|
+
Raises ``ValueError`` on malformed input; callers decide whether
|
|
173
|
+
to treat that as a conflict or a first-push.
|
|
174
|
+
"""
|
|
175
|
+
return int(
|
|
176
|
+
datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _epoch_sec_to_iso(sec: int) -> str:
|
|
181
|
+
"""Format epoch seconds (UTC) as ``YYYY-MM-DDTHH:MM:SSZ``."""
|
|
182
|
+
return datetime.fromtimestamp(sec, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
186
|
+
"""Write *text* to *path* atomically.
|
|
187
|
+
|
|
188
|
+
Delegates to :func:`kd.history.atomic_write_text`.
|
|
189
|
+
"""
|
|
190
|
+
from kd.history import atomic_write_text
|
|
191
|
+
|
|
192
|
+
atomic_write_text(path, text)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class FieldSchema:
|
|
196
|
+
"""Per-project custom field discovery and semantic resolution.
|
|
197
|
+
|
|
198
|
+
Caches project field metadata on first access. Resolves semantic
|
|
199
|
+
CLI slots (state, type, priority) to actual project field names
|
|
200
|
+
using config aliases, name matching, and field type matching.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, yt: YouTrackAPI, debug_log: DebugLog | None = None) -> None:
|
|
204
|
+
self._yt = yt
|
|
205
|
+
self._debug = debug_log or DebugLog()
|
|
206
|
+
self._cache: dict[str, builtins.list[ProjectCustomField]] = {}
|
|
207
|
+
|
|
208
|
+
def get_fields(self, project_id: str) -> builtins.list[ProjectCustomField]:
|
|
209
|
+
"""Fetch and cache custom fields for a project."""
|
|
210
|
+
if project_id in self._cache:
|
|
211
|
+
self._debug.debug("fields", f"cache hit for project {project_id}")
|
|
212
|
+
return self._cache[project_id]
|
|
213
|
+
|
|
214
|
+
self._debug.info("fields", f"discovering custom fields for project {project_id}")
|
|
215
|
+
raw = self._yt.list_project_custom_fields(project_id)
|
|
216
|
+
fields = [self._convert(item) for item in raw]
|
|
217
|
+
self._cache[project_id] = fields
|
|
218
|
+
self._debug.debug(
|
|
219
|
+
"fields",
|
|
220
|
+
f"discovered {len(fields)} fields: {', '.join(f.name for f in fields)}",
|
|
221
|
+
)
|
|
222
|
+
return fields
|
|
223
|
+
|
|
224
|
+
def resolve_semantic_fields(
|
|
225
|
+
self,
|
|
226
|
+
project_id: str,
|
|
227
|
+
slots: dict[str, str],
|
|
228
|
+
aliases: dict[str, str] | None = None,
|
|
229
|
+
) -> builtins.list[CustomField]:
|
|
230
|
+
"""Resolve semantic slot names to CustomField objects for issue mutations.
|
|
231
|
+
|
|
232
|
+
``slots`` maps semantic names to values, e.g. ``{"type": "Bug", "state": "Open"}``.
|
|
233
|
+
``aliases`` maps semantic names to actual field names from config.
|
|
234
|
+
"""
|
|
235
|
+
if not slots:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
project_fields = self.get_fields(project_id)
|
|
239
|
+
aliases = aliases or {}
|
|
240
|
+
result: builtins.list[CustomField] = []
|
|
241
|
+
|
|
242
|
+
for slot, value in slots.items():
|
|
243
|
+
target_name = aliases.get(slot, _SLOT_DEFAULTS.get(slot, slot))
|
|
244
|
+
self._debug.debug("fields", f"resolving slot '{slot}' -> target '{target_name}'")
|
|
245
|
+
|
|
246
|
+
field = self._find_field(project_fields, target_name, slot)
|
|
247
|
+
issue_type = _PROJECT_TO_ISSUE_TYPE.get(field.project_field_type)
|
|
248
|
+
if not issue_type:
|
|
249
|
+
raise ValidationError(
|
|
250
|
+
f"Unsupported field type '{field.project_field_type}' for field '{field.name}'"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
validated_value = self._validate_field_value(field, value)
|
|
254
|
+
field_value = (
|
|
255
|
+
{"login": validated_value}
|
|
256
|
+
if self._is_user_field(field)
|
|
257
|
+
else {"name": validated_value}
|
|
258
|
+
)
|
|
259
|
+
self._debug.debug(
|
|
260
|
+
"fields",
|
|
261
|
+
f"resolved '{slot}' -> field '{field.name}' ($type={issue_type})",
|
|
262
|
+
)
|
|
263
|
+
result.append(
|
|
264
|
+
CustomField.model_validate(
|
|
265
|
+
{"name": field.name, "value": field_value, "$type": issue_type}
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
def _find_field(
|
|
272
|
+
self,
|
|
273
|
+
fields: builtins.list[ProjectCustomField],
|
|
274
|
+
target_name: str,
|
|
275
|
+
slot: str,
|
|
276
|
+
) -> ProjectCustomField:
|
|
277
|
+
"""Find a project field by name match, then type-based fallback for state."""
|
|
278
|
+
target_lower = target_name.lower()
|
|
279
|
+
|
|
280
|
+
# Name match (case-insensitive)
|
|
281
|
+
for f in fields:
|
|
282
|
+
if f.name.lower() == target_lower:
|
|
283
|
+
return f
|
|
284
|
+
|
|
285
|
+
# Type-based fallback: state slot -> field with state[1] type
|
|
286
|
+
if slot == "state":
|
|
287
|
+
for f in fields:
|
|
288
|
+
if f.field_type_id == "state[1]":
|
|
289
|
+
self._debug.info(
|
|
290
|
+
"fields",
|
|
291
|
+
f"no field named '{target_name}', "
|
|
292
|
+
f"falling back to state-type field '{f.name}'",
|
|
293
|
+
)
|
|
294
|
+
return f
|
|
295
|
+
|
|
296
|
+
field_names = [f.name for f in fields]
|
|
297
|
+
close = difflib.get_close_matches(target_name, field_names, n=3, cutoff=0.6)
|
|
298
|
+
candidates = ", ".join(f"{f.name} ({f.field_type_id})" for f in fields)
|
|
299
|
+
msg = f"Cannot resolve field '{slot}' (target: '{target_name}') in project."
|
|
300
|
+
if close:
|
|
301
|
+
msg += f" Did you mean: {', '.join(close)}?"
|
|
302
|
+
msg += f" Available fields: {candidates}"
|
|
303
|
+
raise ValidationError(msg)
|
|
304
|
+
|
|
305
|
+
def resolve_field(
|
|
306
|
+
self,
|
|
307
|
+
project_id: str,
|
|
308
|
+
field_name: str,
|
|
309
|
+
value: str,
|
|
310
|
+
) -> CustomField:
|
|
311
|
+
"""Resolve a generic field name + value to a CustomField for issue mutations.
|
|
312
|
+
|
|
313
|
+
Uses fuzzy matching for field names and validates values against
|
|
314
|
+
the field's bundle. Auto-corrects case on exact case-insensitive
|
|
315
|
+
value matches.
|
|
316
|
+
"""
|
|
317
|
+
project_fields = self.get_fields(project_id)
|
|
318
|
+
field = self._find_field_fuzzy(project_fields, field_name)
|
|
319
|
+
issue_type = _PROJECT_TO_ISSUE_TYPE.get(field.project_field_type)
|
|
320
|
+
if not issue_type:
|
|
321
|
+
raise ValidationError(
|
|
322
|
+
f"Unsupported field type '{field.project_field_type}' for field '{field.name}'"
|
|
323
|
+
)
|
|
324
|
+
validated_value = self._validate_field_value(field, value)
|
|
325
|
+
field_value = (
|
|
326
|
+
{"login": validated_value} if self._is_user_field(field) else {"name": validated_value}
|
|
327
|
+
)
|
|
328
|
+
self._debug.debug(
|
|
329
|
+
"fields",
|
|
330
|
+
f"resolved generic '{field_name}' -> field '{field.name}' "
|
|
331
|
+
f"value '{validated_value}' ($type={issue_type})",
|
|
332
|
+
)
|
|
333
|
+
return CustomField.model_validate(
|
|
334
|
+
{"name": field.name, "value": field_value, "$type": issue_type}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _find_field_fuzzy(
|
|
338
|
+
self,
|
|
339
|
+
fields: builtins.list[ProjectCustomField],
|
|
340
|
+
target_name: str,
|
|
341
|
+
) -> ProjectCustomField:
|
|
342
|
+
"""Find a field by name with fuzzy matching fallback."""
|
|
343
|
+
target_lower = target_name.lower()
|
|
344
|
+
|
|
345
|
+
# Exact case-insensitive match
|
|
346
|
+
for f in fields:
|
|
347
|
+
if f.name.lower() == target_lower:
|
|
348
|
+
return f
|
|
349
|
+
|
|
350
|
+
# Fuzzy match — suggest close names
|
|
351
|
+
field_names = [f.name for f in fields]
|
|
352
|
+
close = difflib.get_close_matches(target_name, field_names, n=3, cutoff=0.6)
|
|
353
|
+
candidates = ", ".join(f"{f.name} ({f.field_type_id})" for f in fields)
|
|
354
|
+
if close:
|
|
355
|
+
raise ValidationError(
|
|
356
|
+
f"Unknown field '{target_name}'. "
|
|
357
|
+
f"Did you mean: {', '.join(close)}? "
|
|
358
|
+
f"Available fields: {candidates}"
|
|
359
|
+
)
|
|
360
|
+
raise ValidationError(f"Unknown field '{target_name}'. Available fields: {candidates}")
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _is_user_field(field: ProjectCustomField) -> bool:
|
|
364
|
+
"""Return True if this field is a user-type field (user[1])."""
|
|
365
|
+
return field.field_type_id.startswith("user")
|
|
366
|
+
|
|
367
|
+
def _validate_field_value(
|
|
368
|
+
self,
|
|
369
|
+
field: ProjectCustomField,
|
|
370
|
+
value: str,
|
|
371
|
+
) -> str:
|
|
372
|
+
"""Validate a value against the field's bundle.
|
|
373
|
+
|
|
374
|
+
Returns the canonical value name (case-corrected).
|
|
375
|
+
Raises ValidationError with allowed values on mismatch.
|
|
376
|
+
"""
|
|
377
|
+
if self._is_user_field(field):
|
|
378
|
+
return value # User fields: server validates against group members
|
|
379
|
+
if not field.bundle_values:
|
|
380
|
+
return value # Freeform field (period, text) — accept as-is
|
|
381
|
+
|
|
382
|
+
# Case-insensitive exact match → return canonical name
|
|
383
|
+
value_lower = value.lower()
|
|
384
|
+
for bv in field.bundle_values:
|
|
385
|
+
if bv.name.lower() == value_lower:
|
|
386
|
+
return bv.name
|
|
387
|
+
|
|
388
|
+
# No match — suggest close values
|
|
389
|
+
value_names = [bv.name for bv in field.bundle_values]
|
|
390
|
+
close = difflib.get_close_matches(value, value_names, n=3, cutoff=0.6)
|
|
391
|
+
allowed = ", ".join(value_names)
|
|
392
|
+
if close:
|
|
393
|
+
raise ValidationError(
|
|
394
|
+
f"Invalid value '{value}' for field '{field.name}'. "
|
|
395
|
+
f"Did you mean: {', '.join(close)}? "
|
|
396
|
+
f"Allowed values: {allowed}"
|
|
397
|
+
)
|
|
398
|
+
raise ValidationError(
|
|
399
|
+
f"Invalid value '{value}' for field '{field.name}'. Allowed values: {allowed}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _convert(raw: dict[str, Any]) -> ProjectCustomField:
|
|
404
|
+
"""Convert raw API dict to ProjectCustomField model."""
|
|
405
|
+
field_info = raw.get("field", {})
|
|
406
|
+
field_type = field_info.get("fieldType", {})
|
|
407
|
+
bundle = raw.get("bundle", {})
|
|
408
|
+
bundle_vals = bundle.get("values", [])
|
|
409
|
+
|
|
410
|
+
return ProjectCustomField(
|
|
411
|
+
name=field_info.get("name", ""),
|
|
412
|
+
field_type_id=field_type.get("id", ""),
|
|
413
|
+
project_field_type=raw.get("$type", ""),
|
|
414
|
+
can_be_empty=raw.get("canBeEmpty", True),
|
|
415
|
+
empty_field_text=raw.get("emptyFieldText"),
|
|
416
|
+
bundle_values=[
|
|
417
|
+
BundleValue.model_validate(
|
|
418
|
+
{
|
|
419
|
+
"name": v.get("name", ""),
|
|
420
|
+
"ordinal": v.get("ordinal", 0),
|
|
421
|
+
"isResolved": v.get("isResolved"),
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
for v in bundle_vals
|
|
425
|
+
],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class YouTrackClient:
|
|
430
|
+
"""Main SDK entry point."""
|
|
431
|
+
|
|
432
|
+
def __init__(
|
|
433
|
+
self,
|
|
434
|
+
connection: ConnectionContext | None = None,
|
|
435
|
+
*,
|
|
436
|
+
url: str | None = None,
|
|
437
|
+
token: str | None = None,
|
|
438
|
+
config: ResolvedConfig | None = None,
|
|
439
|
+
debug_log: DebugLog | None = None,
|
|
440
|
+
) -> None:
|
|
441
|
+
self._debug = debug_log or DebugLog()
|
|
442
|
+
|
|
443
|
+
if connection is None:
|
|
444
|
+
# SDK convenience: build a synthetic ConnectionContext from url/token
|
|
445
|
+
if not url:
|
|
446
|
+
raise ConfigError(
|
|
447
|
+
"No YouTrack URL. Provide a ConnectionContext or url= keyword argument."
|
|
448
|
+
)
|
|
449
|
+
if not token:
|
|
450
|
+
raise ConfigError(
|
|
451
|
+
"No API token. Provide a ConnectionContext or token= keyword argument."
|
|
452
|
+
)
|
|
453
|
+
connection = ConnectionContext(
|
|
454
|
+
name="sdk",
|
|
455
|
+
provider="youtrack",
|
|
456
|
+
base_url=url,
|
|
457
|
+
token=token,
|
|
458
|
+
default_project=None,
|
|
459
|
+
output=None,
|
|
460
|
+
limit=None,
|
|
461
|
+
defaults={},
|
|
462
|
+
compat_tier="auto",
|
|
463
|
+
capabilities=frozenset(),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
base_url = connection.base_url
|
|
467
|
+
resolved_token = connection.token
|
|
468
|
+
|
|
469
|
+
self._yt_http = HttpClient(
|
|
470
|
+
base_url=f"{base_url}/api",
|
|
471
|
+
token=resolved_token,
|
|
472
|
+
debug_log=self._debug,
|
|
473
|
+
)
|
|
474
|
+
self._hub_http = HttpClient(
|
|
475
|
+
base_url=f"{base_url}/hub/api/rest",
|
|
476
|
+
token=resolved_token,
|
|
477
|
+
debug_log=self._debug,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
self._config = config
|
|
481
|
+
self._yt_api = YouTrackAPI(self._yt_http)
|
|
482
|
+
self._hub_api = HubAPI(self._hub_http)
|
|
483
|
+
self._articles_api = ArticlesAPI(self._yt_http)
|
|
484
|
+
self._tags_api = TagsAPI(self._yt_http)
|
|
485
|
+
self._saved_queries_api = SavedQueriesAPI(self._yt_http)
|
|
486
|
+
self._field_schema = FieldSchema(self._yt_api, self._debug)
|
|
487
|
+
|
|
488
|
+
self.tags = TagOperations(self._tags_api, self._articles_api, self._yt_api, self._debug)
|
|
489
|
+
self.articles = ArticleOperations(
|
|
490
|
+
self._articles_api, self._yt_api, tag_ops=self.tags, debug_log=self._debug
|
|
491
|
+
)
|
|
492
|
+
self.projects = ProjectOperations(self._yt_api, self._field_schema, self._debug)
|
|
493
|
+
self.issues = IssueOperations(self._yt_api, self._field_schema, self._debug)
|
|
494
|
+
self.attachments = AttachmentOperations(self._yt_api, self._debug)
|
|
495
|
+
self.users = UserOperations(self._yt_api, self._hub_api, self._debug)
|
|
496
|
+
self.time = TimeOperations(self._yt_api, self._debug)
|
|
497
|
+
self.queries = SavedQueryOperations(
|
|
498
|
+
self._saved_queries_api, self._yt_api, tag_ops=self.tags, debug_log=self._debug
|
|
499
|
+
)
|
|
500
|
+
self.assist = AssistOperations(self._yt_api, self._debug)
|
|
501
|
+
self.search = SearchOperations(
|
|
502
|
+
self._yt_api, self._articles_api, self._tags_api, self._debug
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def config(self) -> ResolvedConfig | None:
|
|
507
|
+
"""Resolved config, if available."""
|
|
508
|
+
return self._config
|
|
509
|
+
|
|
510
|
+
def create_issue_with_attachments(
|
|
511
|
+
self,
|
|
512
|
+
project: str,
|
|
513
|
+
summary: str,
|
|
514
|
+
description: str | None = None,
|
|
515
|
+
custom_fields: builtins.list[CustomField] | None = None,
|
|
516
|
+
file_paths: builtins.list[Path] | None = None,
|
|
517
|
+
) -> Issue:
|
|
518
|
+
"""Composite: create issue + upload attachments.
|
|
519
|
+
|
|
520
|
+
Returns the issue with attachment metadata populated.
|
|
521
|
+
If attachment upload fails, the issue still exists (no rollback).
|
|
522
|
+
"""
|
|
523
|
+
issue = self.issues.create(
|
|
524
|
+
project=project,
|
|
525
|
+
summary=summary,
|
|
526
|
+
description=description,
|
|
527
|
+
custom_fields=custom_fields,
|
|
528
|
+
)
|
|
529
|
+
if file_paths:
|
|
530
|
+
self._debug.info(
|
|
531
|
+
"api",
|
|
532
|
+
f"uploading {len(file_paths)} attachment(s) to {issue.id_readable}",
|
|
533
|
+
)
|
|
534
|
+
self.attachments.add(issue.id_readable, file_paths)
|
|
535
|
+
issue = self.issues.get(issue.id_readable, detail="rich")
|
|
536
|
+
return issue
|
|
537
|
+
|
|
538
|
+
def close(self) -> None:
|
|
539
|
+
"""Close underlying HTTP connections."""
|
|
540
|
+
self._yt_http.close()
|
|
541
|
+
self._hub_http.close()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class ProjectOperations:
|
|
545
|
+
"""High-level project operations."""
|
|
546
|
+
|
|
547
|
+
def __init__(
|
|
548
|
+
self,
|
|
549
|
+
yt: YouTrackAPI,
|
|
550
|
+
field_schema: FieldSchema,
|
|
551
|
+
debug_log: DebugLog | None = None,
|
|
552
|
+
) -> None:
|
|
553
|
+
self._yt = yt
|
|
554
|
+
self._field_schema = field_schema
|
|
555
|
+
self._debug = debug_log or DebugLog()
|
|
556
|
+
|
|
557
|
+
def list(self, top: int = 50, skip: int = 0) -> builtins.list[Project]:
|
|
558
|
+
return self._yt.list_projects(top=top, skip=skip)
|
|
559
|
+
|
|
560
|
+
def list_fields(self, project_id: str) -> builtins.list[ProjectCustomField]:
|
|
561
|
+
"""List custom fields for a project."""
|
|
562
|
+
return self._field_schema.get_fields(project_id)
|
|
563
|
+
|
|
564
|
+
def get(self, project_id: str) -> Project:
|
|
565
|
+
return self._yt.get_project(project_id)
|
|
566
|
+
|
|
567
|
+
def create(self, name: str, short_name: str, leader_id: str | None = None) -> Project:
|
|
568
|
+
"""Create a project. Defaults leader to the current user if not specified."""
|
|
569
|
+
self._debug.info("api", f"creating project {name} ({short_name})")
|
|
570
|
+
if not leader_id:
|
|
571
|
+
me = self._yt.get_me()
|
|
572
|
+
leader_id = me["id"]
|
|
573
|
+
result = self._yt.create_project(
|
|
574
|
+
ProjectCreate(name=name, shortName=short_name, leader=IdEntity(id=leader_id))
|
|
575
|
+
)
|
|
576
|
+
self._debug.info("api", f"created project {result.short_name}")
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
def delete(self, project_id: str) -> None:
|
|
580
|
+
self._yt.delete_project(project_id)
|
|
581
|
+
|
|
582
|
+
def configure_time_tracking(self, project_id: str, enabled: bool) -> TimeTrackingSettings:
|
|
583
|
+
"""Enable or disable time tracking on a project."""
|
|
584
|
+
self._yt.update_time_tracking_settings(project_id, enabled)
|
|
585
|
+
return self._yt.get_time_tracking_settings(project_id)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class IssueOperations:
|
|
589
|
+
"""High-level issue operations."""
|
|
590
|
+
|
|
591
|
+
def __init__(
|
|
592
|
+
self,
|
|
593
|
+
yt: YouTrackAPI,
|
|
594
|
+
field_schema: FieldSchema,
|
|
595
|
+
debug_log: DebugLog | None = None,
|
|
596
|
+
) -> None:
|
|
597
|
+
self._yt = yt
|
|
598
|
+
self._field_schema = field_schema
|
|
599
|
+
self._debug = debug_log or DebugLog()
|
|
600
|
+
|
|
601
|
+
def list(self, query: str = "", top: int = 50, skip: int = 0) -> builtins.list[Issue]:
|
|
602
|
+
return self._yt.list_issues(query=query, top=top, skip=skip)
|
|
603
|
+
|
|
604
|
+
def resolve_custom_fields(
|
|
605
|
+
self,
|
|
606
|
+
project: str,
|
|
607
|
+
slots: dict[str, str],
|
|
608
|
+
aliases: dict[str, str] | None = None,
|
|
609
|
+
) -> builtins.list[CustomField]:
|
|
610
|
+
"""Resolve semantic field names to CustomField objects for a project.
|
|
611
|
+
|
|
612
|
+
``project`` is the project shortName (KEY).
|
|
613
|
+
``slots`` maps semantic names to values, e.g. ``{"type": "Bug"}``.
|
|
614
|
+
``aliases`` maps semantic names to actual field names from config.
|
|
615
|
+
"""
|
|
616
|
+
project_obj = self._yt.get_project(project)
|
|
617
|
+
return self._field_schema.resolve_semantic_fields(project_obj.id, slots, aliases)
|
|
618
|
+
|
|
619
|
+
def resolve_generic_fields(
|
|
620
|
+
self,
|
|
621
|
+
project: str,
|
|
622
|
+
field_pairs: builtins.list[str],
|
|
623
|
+
) -> builtins.list[CustomField]:
|
|
624
|
+
"""Resolve generic NAME=VALUE field pairs to CustomField objects.
|
|
625
|
+
|
|
626
|
+
Each pair is a string like ``"Sprint=Sprint 47"`` or ``"Story Points=5"``.
|
|
627
|
+
Uses fuzzy matching for field names and validates values against bundles.
|
|
628
|
+
"""
|
|
629
|
+
if not field_pairs:
|
|
630
|
+
return []
|
|
631
|
+
project_obj = self._yt.get_project(project)
|
|
632
|
+
result: builtins.list[CustomField] = []
|
|
633
|
+
for pair in field_pairs:
|
|
634
|
+
if "=" not in pair:
|
|
635
|
+
raise ValidationError(f"Invalid field format: '{pair}'. Expected NAME=VALUE")
|
|
636
|
+
name, value = pair.split("=", 1)
|
|
637
|
+
cf = self._field_schema.resolve_field(project_obj.id, name.strip(), value.strip())
|
|
638
|
+
result.append(cf)
|
|
639
|
+
return result
|
|
640
|
+
|
|
641
|
+
def get(self, issue_id: str, detail: str = "rich") -> Issue:
|
|
642
|
+
"""Fetch an issue. ``detail`` is 'rich' (default) or 'brief'."""
|
|
643
|
+
return self._yt.get_issue(issue_id, rich=(detail == "rich"))
|
|
644
|
+
|
|
645
|
+
def create(
|
|
646
|
+
self,
|
|
647
|
+
project: str,
|
|
648
|
+
summary: str,
|
|
649
|
+
description: str | None = None,
|
|
650
|
+
custom_fields: builtins.list[CustomField] | None = None,
|
|
651
|
+
) -> Issue:
|
|
652
|
+
"""Create an issue. ``project`` is the project shortName (KEY)."""
|
|
653
|
+
self._debug.info("api", f"creating issue in project {project}")
|
|
654
|
+
project_obj = self._yt.get_project(project)
|
|
655
|
+
result = self._yt.create_issue(
|
|
656
|
+
IssueCreate(
|
|
657
|
+
project=IdEntity(id=project_obj.id),
|
|
658
|
+
summary=summary,
|
|
659
|
+
description=description,
|
|
660
|
+
customFields=custom_fields,
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
self._debug.info("api", f"created {result.id_readable}")
|
|
664
|
+
return result
|
|
665
|
+
|
|
666
|
+
def update(
|
|
667
|
+
self,
|
|
668
|
+
issue_id: str,
|
|
669
|
+
summary: str | None = None,
|
|
670
|
+
description: str | None = None,
|
|
671
|
+
custom_fields: builtins.list[CustomField] | None = None,
|
|
672
|
+
) -> Issue:
|
|
673
|
+
return self._yt.update_issue(
|
|
674
|
+
issue_id,
|
|
675
|
+
IssueUpdate(summary=summary, description=description, customFields=custom_fields),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def delete(self, issue_id: str) -> None:
|
|
679
|
+
self._yt.delete_issue(issue_id)
|
|
680
|
+
|
|
681
|
+
def list_comments(self, issue_id: str) -> builtins.list[Comment]:
|
|
682
|
+
return self._yt.list_comments(issue_id)
|
|
683
|
+
|
|
684
|
+
def add_comment(self, issue_id: str, text: str) -> Comment:
|
|
685
|
+
return self._yt.create_comment(issue_id, CommentCreate(text=text))
|
|
686
|
+
|
|
687
|
+
def list_links(self, issue_id: str) -> builtins.list[dict[str, Any]]:
|
|
688
|
+
"""List all links on an issue."""
|
|
689
|
+
return self._yt.list_issue_links(issue_id)
|
|
690
|
+
|
|
691
|
+
def link(self, issue_id: str, link_type: str, target_id: str) -> None:
|
|
692
|
+
"""Link two issues. ``link_type`` is a user-friendly name like 'subtask-of'."""
|
|
693
|
+
command_str = _resolve_link_command(link_type)
|
|
694
|
+
self._yt.apply_command(issue_id, f"{command_str} {target_id}")
|
|
695
|
+
|
|
696
|
+
def assign(self, issue_id: str, user_ref: str) -> User:
|
|
697
|
+
"""Assign an issue. Returns the resolved user.
|
|
698
|
+
|
|
699
|
+
``user_ref`` may be a login, email, display name, or ``"me"``.
|
|
700
|
+
"""
|
|
701
|
+
self._debug.info("api", f"assigning {issue_id} to '{user_ref}'")
|
|
702
|
+
user = self._resolve_user(user_ref)
|
|
703
|
+
self._debug.debug("api", f"resolved user '{user_ref}' -> {user.login}")
|
|
704
|
+
self._yt.apply_command(issue_id, f"Assignee {user.login}")
|
|
705
|
+
return user
|
|
706
|
+
|
|
707
|
+
def unassign(self, issue_id: str) -> None:
|
|
708
|
+
"""Remove the assignee from an issue."""
|
|
709
|
+
self._yt.apply_command(issue_id, "Assignee Unassigned")
|
|
710
|
+
|
|
711
|
+
def list_assignees(self, query: str = "") -> builtins.list[User]:
|
|
712
|
+
"""Search users that could be assigned."""
|
|
713
|
+
return self._yt.search_users(query)
|
|
714
|
+
|
|
715
|
+
def move(self, issue_id: str, target_project: str) -> None:
|
|
716
|
+
"""Move an issue to another project via the commands API."""
|
|
717
|
+
self._yt.apply_command(issue_id, f"move to {target_project}")
|
|
718
|
+
|
|
719
|
+
def _resolve_user(self, user_ref: str) -> User:
|
|
720
|
+
"""Resolve a flexible user reference to a User object.
|
|
721
|
+
|
|
722
|
+
Resolution order:
|
|
723
|
+
1. ``"me"`` → current authenticated user
|
|
724
|
+
2. Exact match on login
|
|
725
|
+
3. Exact match on email
|
|
726
|
+
4. Exact match on name (case-insensitive)
|
|
727
|
+
5. Unique prefix match on login
|
|
728
|
+
6. Fail with candidates
|
|
729
|
+
"""
|
|
730
|
+
if user_ref.lower() == "me":
|
|
731
|
+
data = self._yt.get_me()
|
|
732
|
+
return User.model_validate(data)
|
|
733
|
+
|
|
734
|
+
candidates = self._yt.search_users(user_ref)
|
|
735
|
+
self._debug.debug("api", f"user resolution: {len(candidates)} candidates for '{user_ref}'")
|
|
736
|
+
if not candidates:
|
|
737
|
+
raise NotFoundError(f"User not found: '{user_ref}'")
|
|
738
|
+
|
|
739
|
+
# Exact login
|
|
740
|
+
for u in candidates:
|
|
741
|
+
if u.login == user_ref:
|
|
742
|
+
return u
|
|
743
|
+
|
|
744
|
+
# Exact email
|
|
745
|
+
for u in candidates:
|
|
746
|
+
if u.email and u.email == user_ref:
|
|
747
|
+
return u
|
|
748
|
+
|
|
749
|
+
# Exact name (case-insensitive)
|
|
750
|
+
ref_lower = user_ref.lower()
|
|
751
|
+
for u in candidates:
|
|
752
|
+
if u.name.lower() == ref_lower:
|
|
753
|
+
return u
|
|
754
|
+
|
|
755
|
+
# Unique prefix on login
|
|
756
|
+
prefix_matches = [u for u in candidates if u.login.startswith(user_ref)]
|
|
757
|
+
if len(prefix_matches) == 1:
|
|
758
|
+
return prefix_matches[0]
|
|
759
|
+
|
|
760
|
+
# Ambiguous — show rich candidate info
|
|
761
|
+
lines = [f"Ambiguous user reference '{user_ref}'. Candidates:"]
|
|
762
|
+
for u in candidates:
|
|
763
|
+
parts = [f" {u.login}"]
|
|
764
|
+
if u.name:
|
|
765
|
+
parts.append(f"({u.name})")
|
|
766
|
+
if u.email:
|
|
767
|
+
parts.append(f"<{u.email}>")
|
|
768
|
+
lines.append(" ".join(parts))
|
|
769
|
+
raise ValidationError("\n".join(lines))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class AttachmentOperations:
|
|
773
|
+
"""High-level attachment operations."""
|
|
774
|
+
|
|
775
|
+
def __init__(self, yt: YouTrackAPI, debug_log: DebugLog | None = None) -> None:
|
|
776
|
+
self._yt = yt
|
|
777
|
+
self._debug = debug_log or DebugLog()
|
|
778
|
+
|
|
779
|
+
def list(self, issue_id: str) -> builtins.list[Attachment]:
|
|
780
|
+
return self._yt.list_attachments(issue_id)
|
|
781
|
+
|
|
782
|
+
def get(self, issue_id: str, attachment_id: str) -> Attachment:
|
|
783
|
+
return self._yt.get_attachment(issue_id, attachment_id)
|
|
784
|
+
|
|
785
|
+
def add(self, issue_id: str, file_paths: builtins.list[Path]) -> builtins.list[Attachment]:
|
|
786
|
+
"""Upload one or more files as attachments."""
|
|
787
|
+
self._debug.info("api", f"uploading {len(file_paths)} attachment(s) to {issue_id}")
|
|
788
|
+
results: builtins.list[Attachment] = []
|
|
789
|
+
for fp in file_paths:
|
|
790
|
+
if not fp.is_file():
|
|
791
|
+
raise ValidationError(f"File not found: {fp}")
|
|
792
|
+
content = fp.read_bytes()
|
|
793
|
+
att = self._yt.create_attachment(issue_id, fp.name, content)
|
|
794
|
+
self._debug.debug("api", f" uploaded {fp.name} ({len(content)} bytes)")
|
|
795
|
+
results.append(att)
|
|
796
|
+
return results
|
|
797
|
+
|
|
798
|
+
def download(
|
|
799
|
+
self,
|
|
800
|
+
issue_id: str,
|
|
801
|
+
attachment_id: str,
|
|
802
|
+
target_dir: Path | None = None,
|
|
803
|
+
) -> Path:
|
|
804
|
+
"""Download an attachment to disk. Returns the written file path."""
|
|
805
|
+
meta = self._yt.get_attachment(issue_id, attachment_id)
|
|
806
|
+
if not meta.url:
|
|
807
|
+
raise ValidationError(f"Attachment {attachment_id} has no download URL")
|
|
808
|
+
# The URL from YT starts with /api/ but HttpClient base already includes /api
|
|
809
|
+
download_path = meta.url
|
|
810
|
+
if download_path.startswith("/api/"):
|
|
811
|
+
download_path = download_path[4:] # strip /api prefix
|
|
812
|
+
content = self._yt._http.get_bytes(download_path)
|
|
813
|
+
dest_dir = target_dir or Path.cwd()
|
|
814
|
+
dest = dest_dir / meta.name
|
|
815
|
+
try:
|
|
816
|
+
dest.write_bytes(content)
|
|
817
|
+
except OSError as e:
|
|
818
|
+
raise KdError(f"Cannot write to {dest}: {e}") from e
|
|
819
|
+
return dest
|
|
820
|
+
|
|
821
|
+
def delete(self, issue_id: str, attachment_id: str) -> None:
|
|
822
|
+
self._yt.delete_attachment(issue_id, attachment_id)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
class UserOperations:
|
|
826
|
+
"""High-level user operations. Bridges YT and Hub APIs."""
|
|
827
|
+
|
|
828
|
+
def __init__(self, yt: YouTrackAPI, hub: HubAPI, debug_log: DebugLog | None = None) -> None:
|
|
829
|
+
self._yt = yt
|
|
830
|
+
self._hub = hub
|
|
831
|
+
self._debug = debug_log or DebugLog()
|
|
832
|
+
|
|
833
|
+
def list(self, top: int = 100, skip: int = 0) -> builtins.list[User]:
|
|
834
|
+
return self._hub.list_users(top=top, skip=skip)
|
|
835
|
+
|
|
836
|
+
def create(self, login: str, name: str, email: str) -> User:
|
|
837
|
+
return self._hub.create_user(UserCreate(login=login, name=name, email=email))
|
|
838
|
+
|
|
839
|
+
def delete(self, hub_user_id: str) -> None:
|
|
840
|
+
self._hub.delete_user(hub_user_id)
|
|
841
|
+
|
|
842
|
+
def add_to_team(self, user_login: str, project_id: str) -> None:
|
|
843
|
+
"""Add user to project team. Multi-step cross-API operation.
|
|
844
|
+
|
|
845
|
+
1. Get project ringId from YT API
|
|
846
|
+
2. Find matching Hub project team
|
|
847
|
+
3. Get user ringId from YT API
|
|
848
|
+
4. Add user to team via Hub API
|
|
849
|
+
"""
|
|
850
|
+
self._debug.info("api", f"adding {user_login} to project {project_id} team")
|
|
851
|
+
# 1. Get project ringId
|
|
852
|
+
project = self._yt.get_project(project_id)
|
|
853
|
+
if not project.ring_id:
|
|
854
|
+
raise ValidationError(f"Project {project_id} has no ringId")
|
|
855
|
+
|
|
856
|
+
# 2. Find Hub team for this project
|
|
857
|
+
teams = self._hub.list_project_teams()
|
|
858
|
+
team = next(
|
|
859
|
+
(t for t in teams if t.project and t.project.id == project.ring_id),
|
|
860
|
+
None,
|
|
861
|
+
)
|
|
862
|
+
if not team:
|
|
863
|
+
raise NotFoundError(f"No project team found for project {project_id}")
|
|
864
|
+
|
|
865
|
+
# 3. Get user ringId via YT API search
|
|
866
|
+
users = self._yt.search_users(user_login)
|
|
867
|
+
user = next((u for u in users if u.login == user_login), None)
|
|
868
|
+
if not user or not user.ring_id:
|
|
869
|
+
raise NotFoundError(f"User '{user_login}' not found or has no ringId")
|
|
870
|
+
|
|
871
|
+
# 4. Add to team
|
|
872
|
+
self._hub.add_team_member(team.id, user.ring_id)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
class TimeOperations:
|
|
876
|
+
"""High-level time tracking operations."""
|
|
877
|
+
|
|
878
|
+
def __init__(self, yt: YouTrackAPI, debug_log: DebugLog | None = None) -> None:
|
|
879
|
+
self._yt = yt
|
|
880
|
+
self._debug = debug_log or DebugLog()
|
|
881
|
+
self._work_item_types: builtins.list[WorkItemType] | None = None
|
|
882
|
+
|
|
883
|
+
def list(
|
|
884
|
+
self,
|
|
885
|
+
issue_id: str,
|
|
886
|
+
date_from: int | None = None,
|
|
887
|
+
date_to: int | None = None,
|
|
888
|
+
) -> builtins.list[WorkItem]:
|
|
889
|
+
"""List work items on an issue, optionally filtered by date range (epoch ms)."""
|
|
890
|
+
self._debug.info("sdk", f"listing work items for {issue_id}")
|
|
891
|
+
items = self._yt.list_work_items(issue_id)
|
|
892
|
+
if date_from is None and date_to is None:
|
|
893
|
+
return items
|
|
894
|
+
return [
|
|
895
|
+
w
|
|
896
|
+
for w in items
|
|
897
|
+
if w.date is not None
|
|
898
|
+
and (date_from is None or w.date >= date_from)
|
|
899
|
+
and (date_to is None or w.date <= date_to)
|
|
900
|
+
]
|
|
901
|
+
|
|
902
|
+
def log(
|
|
903
|
+
self,
|
|
904
|
+
issue_id: str,
|
|
905
|
+
minutes: int,
|
|
906
|
+
work_type: str | None = None,
|
|
907
|
+
description: str | None = None,
|
|
908
|
+
date: int | None = None,
|
|
909
|
+
) -> WorkItem:
|
|
910
|
+
"""Log work. Resolves work_type name to ID transparently."""
|
|
911
|
+
self._debug.info("sdk", f"logging {minutes}m on {issue_id}")
|
|
912
|
+
type_entity = None
|
|
913
|
+
if work_type:
|
|
914
|
+
type_entity = IdEntity(id=self._resolve_work_type_id(work_type))
|
|
915
|
+
return self._yt.create_work_item(
|
|
916
|
+
issue_id,
|
|
917
|
+
WorkItemCreate(
|
|
918
|
+
duration=Duration(minutes=minutes),
|
|
919
|
+
text=description,
|
|
920
|
+
type=type_entity,
|
|
921
|
+
date=date,
|
|
922
|
+
),
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
def get(self, issue_id: str, work_item_id: str) -> WorkItem:
|
|
926
|
+
"""Fetch a single work item."""
|
|
927
|
+
self._debug.info("sdk", f"getting work item {work_item_id} on {issue_id}")
|
|
928
|
+
return self._yt.get_work_item(issue_id, work_item_id)
|
|
929
|
+
|
|
930
|
+
def update(
|
|
931
|
+
self,
|
|
932
|
+
issue_id: str,
|
|
933
|
+
work_item_id: str,
|
|
934
|
+
minutes: int | None = None,
|
|
935
|
+
work_type: str | None = None,
|
|
936
|
+
description: str | None = None,
|
|
937
|
+
date: int | None = None,
|
|
938
|
+
) -> WorkItem:
|
|
939
|
+
"""Update a work item. Only supplied fields are sent."""
|
|
940
|
+
self._debug.info("sdk", f"updating work item {work_item_id} on {issue_id}")
|
|
941
|
+
type_entity = None
|
|
942
|
+
if work_type is not None:
|
|
943
|
+
type_entity = IdEntity(id=self._resolve_work_type_id(work_type))
|
|
944
|
+
update = WorkItemUpdate(
|
|
945
|
+
duration=Duration(minutes=minutes) if minutes is not None else None,
|
|
946
|
+
text=description,
|
|
947
|
+
type=type_entity,
|
|
948
|
+
date=date,
|
|
949
|
+
)
|
|
950
|
+
return self._yt.update_work_item(issue_id, work_item_id, update)
|
|
951
|
+
|
|
952
|
+
def delete(self, issue_id: str, work_item_id: str) -> None:
|
|
953
|
+
"""Delete a work item."""
|
|
954
|
+
self._debug.info("sdk", f"deleting work item {work_item_id} on {issue_id}")
|
|
955
|
+
self._yt.delete_work_item(issue_id, work_item_id)
|
|
956
|
+
|
|
957
|
+
def _resolve_work_type_id(self, name: str) -> str:
|
|
958
|
+
"""Resolve work item type name to ID. Caches types on first call."""
|
|
959
|
+
if self._work_item_types is None:
|
|
960
|
+
self._work_item_types = self._yt.list_work_item_types()
|
|
961
|
+
for t in self._work_item_types:
|
|
962
|
+
if t.name.lower() == name.lower():
|
|
963
|
+
return t.id
|
|
964
|
+
available = ", ".join(t.name for t in self._work_item_types)
|
|
965
|
+
raise ValidationError(f"Unknown work item type '{name}'. Available: {available}")
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
class ArticleOperations:
|
|
969
|
+
"""High-level knowledge base article operations."""
|
|
970
|
+
|
|
971
|
+
def __init__(
|
|
972
|
+
self,
|
|
973
|
+
articles_api: ArticlesAPI,
|
|
974
|
+
yt: YouTrackAPI,
|
|
975
|
+
tag_ops: TagOperations | None = None,
|
|
976
|
+
debug_log: DebugLog | None = None,
|
|
977
|
+
) -> None:
|
|
978
|
+
self._articles = articles_api
|
|
979
|
+
self._yt = yt
|
|
980
|
+
self._tag_ops = tag_ops
|
|
981
|
+
self._debug = debug_log or DebugLog()
|
|
982
|
+
|
|
983
|
+
def list(
|
|
984
|
+
self,
|
|
985
|
+
project: str | None = None,
|
|
986
|
+
top: int = 50,
|
|
987
|
+
skip: int = 0,
|
|
988
|
+
) -> builtins.list[Article]:
|
|
989
|
+
"""List articles. If *project* is given, scopes to that project."""
|
|
990
|
+
if project:
|
|
991
|
+
self._debug.info("api", f"listing articles for project {project}")
|
|
992
|
+
project_obj = self._yt.get_project(project)
|
|
993
|
+
return self._articles.list_project_articles(project_obj.id, top=top, skip=skip)
|
|
994
|
+
return self._articles.list_articles(top=top, skip=skip)
|
|
995
|
+
|
|
996
|
+
def get(self, article_id: str) -> Article:
|
|
997
|
+
return self._articles.get_article(article_id)
|
|
998
|
+
|
|
999
|
+
def pull(self, article_id: str) -> str | None:
|
|
1000
|
+
"""Fetch an article and return its content with a fresh marker.
|
|
1001
|
+
|
|
1002
|
+
The server does not store ``upstream-updated`` inside article
|
|
1003
|
+
content (see YTCTL-6); pull injects it from ``article.updated``
|
|
1004
|
+
so a pull -> edit -> push round-trip does not trip the conflict
|
|
1005
|
+
check. Marker-less content passes through verbatim. Malformed
|
|
1006
|
+
markers pass through verbatim so the user can see and fix them.
|
|
1007
|
+
Returns ``None`` if the article has no content.
|
|
1008
|
+
"""
|
|
1009
|
+
article = self._articles.get_article(article_id)
|
|
1010
|
+
if article.content is None:
|
|
1011
|
+
return None
|
|
1012
|
+
try:
|
|
1013
|
+
parsed = parse_content(article.content)
|
|
1014
|
+
except MarkerError:
|
|
1015
|
+
return article.content
|
|
1016
|
+
if parsed.marker is None:
|
|
1017
|
+
return article.content
|
|
1018
|
+
if article.updated is not None:
|
|
1019
|
+
parsed.marker.set(
|
|
1020
|
+
"upstream-updated",
|
|
1021
|
+
_epoch_sec_to_iso(article.updated // 1000),
|
|
1022
|
+
)
|
|
1023
|
+
else:
|
|
1024
|
+
parsed.marker.data.pop("upstream-updated", None)
|
|
1025
|
+
return render_content(parsed)
|
|
1026
|
+
|
|
1027
|
+
def children(self, article_id: str, top: int = 50, skip: int = 0) -> builtins.list[Article]:
|
|
1028
|
+
return self._articles.list_child_articles(article_id, top=top, skip=skip)
|
|
1029
|
+
|
|
1030
|
+
def create(
|
|
1031
|
+
self,
|
|
1032
|
+
project: str,
|
|
1033
|
+
summary: str,
|
|
1034
|
+
content: str | None = None,
|
|
1035
|
+
parent: str | None = None,
|
|
1036
|
+
tags: builtins.list[str] | None = None,
|
|
1037
|
+
) -> Article:
|
|
1038
|
+
"""Create an article. *project* is the project shortName (key).
|
|
1039
|
+
|
|
1040
|
+
When *parent* is given (readable id like ``FB-A-1`` or internal id),
|
|
1041
|
+
the new article is attached to that parent in a single POST.
|
|
1042
|
+
When *tags* is given, each tag is applied after article creation.
|
|
1043
|
+
"""
|
|
1044
|
+
self._debug.info("api", f"creating article in project {project}")
|
|
1045
|
+
project_obj = self._yt.get_project(project)
|
|
1046
|
+
parent_ref: IdEntity | None = None
|
|
1047
|
+
if parent is not None:
|
|
1048
|
+
parent_obj = self._articles.get_article(parent)
|
|
1049
|
+
parent_ref = IdEntity(id=parent_obj.id)
|
|
1050
|
+
self._debug.info("api", f"attaching to parent {parent} ({parent_obj.id})")
|
|
1051
|
+
result = self._articles.create_article(
|
|
1052
|
+
ArticleCreate(
|
|
1053
|
+
project=IdEntity(id=project_obj.id),
|
|
1054
|
+
summary=summary,
|
|
1055
|
+
content=content,
|
|
1056
|
+
parentArticle=parent_ref,
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
self._debug.info("api", f"created {result.id_readable}")
|
|
1060
|
+
if tags:
|
|
1061
|
+
applied, failed = self._apply_tags(result.id_readable, tags)
|
|
1062
|
+
if failed:
|
|
1063
|
+
raise KdError(
|
|
1064
|
+
f"Article {result.id_readable} created but tagging failed. "
|
|
1065
|
+
f"Applied: {applied or '(none)'}. "
|
|
1066
|
+
f"Failed: {', '.join(failed)}."
|
|
1067
|
+
)
|
|
1068
|
+
return result
|
|
1069
|
+
|
|
1070
|
+
def _apply_tags(
|
|
1071
|
+
self,
|
|
1072
|
+
article_id: str,
|
|
1073
|
+
tags: builtins.list[str],
|
|
1074
|
+
) -> tuple[builtins.list[str], builtins.list[str]]:
|
|
1075
|
+
"""Apply tags to an article. Returns (applied, failed) lists.
|
|
1076
|
+
|
|
1077
|
+
Failed entries include the reason so the caller can distinguish
|
|
1078
|
+
not-found from permission-denied (tag visible but not usable).
|
|
1079
|
+
"""
|
|
1080
|
+
if self._tag_ops is None:
|
|
1081
|
+
raise KdError("Tag operations not available")
|
|
1082
|
+
applied: builtins.list[str] = []
|
|
1083
|
+
failed: builtins.list[str] = []
|
|
1084
|
+
for tag in tags:
|
|
1085
|
+
try:
|
|
1086
|
+
self._tag_ops.add(article_id, tag)
|
|
1087
|
+
applied.append(tag)
|
|
1088
|
+
except (NotFoundError, KdError) as exc:
|
|
1089
|
+
failed.append(f"{tag} ({exc})")
|
|
1090
|
+
return applied, failed
|
|
1091
|
+
|
|
1092
|
+
def update(
|
|
1093
|
+
self,
|
|
1094
|
+
article_id: str,
|
|
1095
|
+
summary: str | None = None,
|
|
1096
|
+
content: str | None = None,
|
|
1097
|
+
) -> Article:
|
|
1098
|
+
if content is not None:
|
|
1099
|
+
content = parse_content(content).body
|
|
1100
|
+
return self._articles.update_article(
|
|
1101
|
+
article_id,
|
|
1102
|
+
ArticleUpdate(summary=summary, content=content),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
def push(
|
|
1106
|
+
self,
|
|
1107
|
+
file_path: Path,
|
|
1108
|
+
project: str,
|
|
1109
|
+
summary: str,
|
|
1110
|
+
slug: str | None = None,
|
|
1111
|
+
parent: str | None = None,
|
|
1112
|
+
force: bool = False,
|
|
1113
|
+
dry_run: bool = False,
|
|
1114
|
+
tags: builtins.list[str] | None = None,
|
|
1115
|
+
) -> PushResult:
|
|
1116
|
+
"""Idempotently upsert a local markdown file as a YouTrack article.
|
|
1117
|
+
|
|
1118
|
+
See ``docs/proposals/19-article-push-proposal.md`` for the
|
|
1119
|
+
design rationale. The marker block inside the file (or
|
|
1120
|
+
synthesized on first push) is the upsert key; ``slug`` is the
|
|
1121
|
+
user-chosen identity.
|
|
1122
|
+
|
|
1123
|
+
On success, the local file is rewritten atomically to refresh
|
|
1124
|
+
the marker's ``upstream-id`` and ``upstream-updated`` fields.
|
|
1125
|
+
"""
|
|
1126
|
+
# Step 1: read the local file and parse the marker.
|
|
1127
|
+
try:
|
|
1128
|
+
text = file_path.read_text(encoding="utf-8")
|
|
1129
|
+
except OSError as e:
|
|
1130
|
+
raise KdError(f"Cannot read {file_path}: {e}") from e
|
|
1131
|
+
parsed = parse_content(text)
|
|
1132
|
+
marker = parsed.marker if parsed.marker is not None else ArticleMarker()
|
|
1133
|
+
|
|
1134
|
+
# Step 2: resolve slug (priority: marker → --slug → file path).
|
|
1135
|
+
marker_slug = marker.get("slug")
|
|
1136
|
+
if marker_slug and slug and marker_slug != slug:
|
|
1137
|
+
raise ValidationError(
|
|
1138
|
+
f"File {file_path} has slug {marker_slug!r} in its marker; "
|
|
1139
|
+
f"--slug {slug!r} does not match. Refusing to change an existing "
|
|
1140
|
+
"slug. Edit the marker directly."
|
|
1141
|
+
)
|
|
1142
|
+
resolved_slug = marker_slug or slug or _derive_slug_from_path(file_path)
|
|
1143
|
+
if not resolved_slug:
|
|
1144
|
+
raise ValidationError(
|
|
1145
|
+
f"Cannot derive slug from {file_path}. Pass --slug or put "
|
|
1146
|
+
"'slug: <value>' in a marker at the top of the file."
|
|
1147
|
+
)
|
|
1148
|
+
validate_slug(resolved_slug)
|
|
1149
|
+
marker.set("slug", resolved_slug)
|
|
1150
|
+
|
|
1151
|
+
# Step 3: summary is required — no auto-derivation.
|
|
1152
|
+
resolved_summary = summary
|
|
1153
|
+
|
|
1154
|
+
# Step 4: resolve target article.
|
|
1155
|
+
existing_remote: Article | None = None
|
|
1156
|
+
cached_upstream_id = marker.get("upstream-id")
|
|
1157
|
+
if cached_upstream_id:
|
|
1158
|
+
try:
|
|
1159
|
+
existing_remote = self._articles.get_article(cached_upstream_id)
|
|
1160
|
+
except NotFoundError:
|
|
1161
|
+
self._debug.info(
|
|
1162
|
+
"api",
|
|
1163
|
+
f"cached upstream-id {cached_upstream_id} stale; scanning by slug",
|
|
1164
|
+
)
|
|
1165
|
+
existing_remote = None
|
|
1166
|
+
|
|
1167
|
+
if existing_remote is None:
|
|
1168
|
+
# Scan by slug across the project's articles. List first
|
|
1169
|
+
# (cheap), then fetch content per candidate until we find a
|
|
1170
|
+
# match. Stops on first match so the common "no duplicate"
|
|
1171
|
+
# case is O(N) fetches worst case; a cached upstream-id
|
|
1172
|
+
# avoids this scan entirely.
|
|
1173
|
+
project_obj = self._yt.get_project(project)
|
|
1174
|
+
stubs = self._articles.list_project_articles(project_obj.id, top=500)
|
|
1175
|
+
matches: builtins.list[Article] = []
|
|
1176
|
+
for stub in stubs:
|
|
1177
|
+
full = self._articles.get_article(stub.id_readable)
|
|
1178
|
+
if full.content is None:
|
|
1179
|
+
continue
|
|
1180
|
+
try:
|
|
1181
|
+
stub_parsed = parse_content(full.content)
|
|
1182
|
+
except ValidationError:
|
|
1183
|
+
# A malformed marker on a remote article should not
|
|
1184
|
+
# poison an unrelated push — skip and continue.
|
|
1185
|
+
continue
|
|
1186
|
+
if stub_parsed.marker and stub_parsed.marker.slug == resolved_slug:
|
|
1187
|
+
matches.append(full)
|
|
1188
|
+
if len(matches) > 1:
|
|
1189
|
+
ids = ", ".join(m.id_readable for m in matches)
|
|
1190
|
+
raise ValidationError(
|
|
1191
|
+
f"Multiple articles in project {project} have slug "
|
|
1192
|
+
f"{resolved_slug!r}: {ids}. Delete duplicates, or fix the "
|
|
1193
|
+
"marker to include upstream-id."
|
|
1194
|
+
)
|
|
1195
|
+
if len(matches) == 1:
|
|
1196
|
+
existing_remote = matches[0]
|
|
1197
|
+
|
|
1198
|
+
is_update = existing_remote is not None
|
|
1199
|
+
|
|
1200
|
+
# Step 5: conflict check on the update path.
|
|
1201
|
+
if is_update and not force:
|
|
1202
|
+
assert existing_remote is not None
|
|
1203
|
+
marker_iso = marker.get("upstream-updated")
|
|
1204
|
+
if (
|
|
1205
|
+
marker_iso
|
|
1206
|
+
and marker_iso != "1970-01-01T00:00:00Z"
|
|
1207
|
+
and existing_remote.updated is not None
|
|
1208
|
+
):
|
|
1209
|
+
try:
|
|
1210
|
+
marker_sec = _iso_to_epoch_sec(marker_iso)
|
|
1211
|
+
except ValueError:
|
|
1212
|
+
marker_sec = 0
|
|
1213
|
+
remote_sec = existing_remote.updated // 1000
|
|
1214
|
+
if remote_sec > marker_sec:
|
|
1215
|
+
remote_iso = _epoch_sec_to_iso(remote_sec)
|
|
1216
|
+
raise ValidationError(
|
|
1217
|
+
f"Article {existing_remote.id_readable} was updated "
|
|
1218
|
+
f"upstream at {remote_iso} after our last push at "
|
|
1219
|
+
f"{marker_iso}. Pass --force to overwrite, or inspect "
|
|
1220
|
+
f"the remote: kd /article pull "
|
|
1221
|
+
f"{existing_remote.id_readable}."
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
# Step 6: reject --parent on the update path.
|
|
1225
|
+
if is_update and parent is not None:
|
|
1226
|
+
assert existing_remote is not None
|
|
1227
|
+
raise ValidationError(
|
|
1228
|
+
f"--parent can only be used when creating a new article. To "
|
|
1229
|
+
f"reparent {existing_remote.id_readable}, use: kd /article "
|
|
1230
|
+
f"move {existing_remote.id_readable} --parent {parent}."
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
# Dry-run bails out here with all validation done but no writes.
|
|
1234
|
+
if dry_run:
|
|
1235
|
+
existing_sec = 0
|
|
1236
|
+
if existing_remote is not None and existing_remote.updated is not None:
|
|
1237
|
+
existing_sec = existing_remote.updated // 1000
|
|
1238
|
+
return PushResult(
|
|
1239
|
+
action="update" if is_update else "create",
|
|
1240
|
+
article_id=existing_remote.id_readable if existing_remote else "",
|
|
1241
|
+
slug=resolved_slug,
|
|
1242
|
+
path=file_path,
|
|
1243
|
+
remote_updated_sec=existing_sec,
|
|
1244
|
+
dry_run=True,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
# Step 7: build outgoing content. `upstream-updated` is intentionally
|
|
1248
|
+
# omitted from the server payload — storing a snapshot of `updated`
|
|
1249
|
+
# inside the content creates an unsolvable chicken-and-egg (YTCTL-6).
|
|
1250
|
+
# `pull` injects it from `article.updated` on read.
|
|
1251
|
+
if existing_remote is not None:
|
|
1252
|
+
marker.set("upstream-id", existing_remote.id_readable)
|
|
1253
|
+
|
|
1254
|
+
outgoing_data: OrderedDict[str, str] = OrderedDict(
|
|
1255
|
+
(k, v) for k, v in marker.data.items() if k != "upstream-updated"
|
|
1256
|
+
)
|
|
1257
|
+
outgoing_marker = ArticleMarker(data=outgoing_data)
|
|
1258
|
+
outgoing = ParsedFile(
|
|
1259
|
+
marker=outgoing_marker,
|
|
1260
|
+
body=parsed.body,
|
|
1261
|
+
newline=parsed.newline,
|
|
1262
|
+
trailing_newline=parsed.trailing_newline,
|
|
1263
|
+
body_separator=parsed.body_separator,
|
|
1264
|
+
)
|
|
1265
|
+
outgoing_content = render_content(outgoing)
|
|
1266
|
+
|
|
1267
|
+
# Step 8: write remote.
|
|
1268
|
+
if is_update:
|
|
1269
|
+
assert existing_remote is not None
|
|
1270
|
+
self._debug.info("api", f"updating article {existing_remote.id_readable}")
|
|
1271
|
+
result_article = self._articles.update_article(
|
|
1272
|
+
existing_remote.id_readable,
|
|
1273
|
+
ArticleUpdate(summary=resolved_summary, content=outgoing_content),
|
|
1274
|
+
)
|
|
1275
|
+
else:
|
|
1276
|
+
self._debug.info("api", f"creating new article in project {project}")
|
|
1277
|
+
result_article = self.create(
|
|
1278
|
+
project=project,
|
|
1279
|
+
summary=resolved_summary,
|
|
1280
|
+
content=outgoing_content,
|
|
1281
|
+
parent=parent,
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
# Step 8b: apply tags. Tagging bumps the server-side `updated`
|
|
1285
|
+
# timestamp, so re-fetch the article afterward to capture the
|
|
1286
|
+
# final timestamp for the marker (avoids false conflict on next push).
|
|
1287
|
+
tags_applied: builtins.list[str] = []
|
|
1288
|
+
tags_failed: builtins.list[str] = []
|
|
1289
|
+
if tags:
|
|
1290
|
+
tags_applied, tags_failed = self._apply_tags(result_article.id_readable, tags)
|
|
1291
|
+
if tags_applied:
|
|
1292
|
+
result_article = self._articles.get_article(result_article.id_readable)
|
|
1293
|
+
|
|
1294
|
+
# Step 9: compute final marker with the server-reported timestamp.
|
|
1295
|
+
remote_updated_sec = (
|
|
1296
|
+
result_article.updated // 1000 if result_article.updated is not None else 0
|
|
1297
|
+
)
|
|
1298
|
+
remote_iso = (
|
|
1299
|
+
_epoch_sec_to_iso(remote_updated_sec) if remote_updated_sec else "1970-01-01T00:00:00Z"
|
|
1300
|
+
)
|
|
1301
|
+
marker.set("upstream-id", result_article.id_readable)
|
|
1302
|
+
marker.set("upstream-updated", remote_iso)
|
|
1303
|
+
|
|
1304
|
+
final = ParsedFile(
|
|
1305
|
+
marker=marker,
|
|
1306
|
+
body=parsed.body,
|
|
1307
|
+
newline=parsed.newline,
|
|
1308
|
+
trailing_newline=parsed.trailing_newline,
|
|
1309
|
+
body_separator=parsed.body_separator,
|
|
1310
|
+
)
|
|
1311
|
+
final_content = render_content(final)
|
|
1312
|
+
|
|
1313
|
+
# Step 10: atomic local write-back.
|
|
1314
|
+
_atomic_write_text(file_path, final_content)
|
|
1315
|
+
|
|
1316
|
+
# Step 11: return result.
|
|
1317
|
+
push_result = PushResult(
|
|
1318
|
+
action="update" if is_update else "create",
|
|
1319
|
+
article_id=result_article.id_readable,
|
|
1320
|
+
slug=resolved_slug,
|
|
1321
|
+
path=file_path,
|
|
1322
|
+
remote_updated_sec=remote_updated_sec,
|
|
1323
|
+
dry_run=False,
|
|
1324
|
+
tags_applied=tags_applied,
|
|
1325
|
+
tags_failed=tags_failed,
|
|
1326
|
+
)
|
|
1327
|
+
if tags_failed:
|
|
1328
|
+
raise KdError(
|
|
1329
|
+
f"Article {result_article.id_readable} {'updated' if is_update else 'created'} "
|
|
1330
|
+
f"but tagging failed. "
|
|
1331
|
+
f"Applied: {tags_applied or '(none)'}. "
|
|
1332
|
+
f"Failed: {', '.join(tags_failed)}."
|
|
1333
|
+
)
|
|
1334
|
+
return push_result
|
|
1335
|
+
|
|
1336
|
+
def delete(self, article_id: str) -> None:
|
|
1337
|
+
self._articles.delete_article(article_id)
|
|
1338
|
+
|
|
1339
|
+
# --- Comments ---
|
|
1340
|
+
|
|
1341
|
+
def list_comments(self, article_id: str) -> builtins.list[Comment]:
|
|
1342
|
+
return self._articles.list_article_comments(article_id)
|
|
1343
|
+
|
|
1344
|
+
def add_comment(self, article_id: str, text: str) -> Comment:
|
|
1345
|
+
return self._articles.create_article_comment(article_id, CommentCreate(text=text))
|
|
1346
|
+
|
|
1347
|
+
# --- Attachments ---
|
|
1348
|
+
|
|
1349
|
+
def list_attachments(self, article_id: str) -> builtins.list[Attachment]:
|
|
1350
|
+
return self._articles.list_article_attachments(article_id)
|
|
1351
|
+
|
|
1352
|
+
def get_attachment(self, article_id: str, attachment_id: str) -> Attachment:
|
|
1353
|
+
return self._articles.get_article_attachment(article_id, attachment_id)
|
|
1354
|
+
|
|
1355
|
+
def add_attachments(
|
|
1356
|
+
self, article_id: str, file_paths: builtins.list[Path]
|
|
1357
|
+
) -> builtins.list[Attachment]:
|
|
1358
|
+
"""Upload one or more files as article attachments."""
|
|
1359
|
+
self._debug.info("api", f"uploading {len(file_paths)} attachment(s) to {article_id}")
|
|
1360
|
+
results: builtins.list[Attachment] = []
|
|
1361
|
+
for fp in file_paths:
|
|
1362
|
+
if not fp.is_file():
|
|
1363
|
+
raise ValidationError(f"File not found: {fp}")
|
|
1364
|
+
content = fp.read_bytes()
|
|
1365
|
+
att = self._articles.create_article_attachment(article_id, fp.name, content)
|
|
1366
|
+
self._debug.debug("api", f" uploaded {fp.name} ({len(content)} bytes)")
|
|
1367
|
+
results.append(att)
|
|
1368
|
+
return results
|
|
1369
|
+
|
|
1370
|
+
def download_attachment(
|
|
1371
|
+
self,
|
|
1372
|
+
article_id: str,
|
|
1373
|
+
attachment_id: str,
|
|
1374
|
+
target_dir: Path | None = None,
|
|
1375
|
+
) -> Path:
|
|
1376
|
+
"""Download an article attachment to disk. Returns the written file path."""
|
|
1377
|
+
meta = self._articles.get_article_attachment(article_id, attachment_id)
|
|
1378
|
+
if not meta.url:
|
|
1379
|
+
raise ValidationError(f"Attachment {attachment_id} has no download URL")
|
|
1380
|
+
download_path = meta.url
|
|
1381
|
+
if download_path.startswith("/api/"):
|
|
1382
|
+
download_path = download_path[4:] # strip /api prefix
|
|
1383
|
+
content = self._articles._http.get_bytes(download_path)
|
|
1384
|
+
dest_dir = target_dir or Path.cwd()
|
|
1385
|
+
dest = dest_dir / meta.name
|
|
1386
|
+
try:
|
|
1387
|
+
dest.write_bytes(content)
|
|
1388
|
+
except OSError as e:
|
|
1389
|
+
raise KdError(f"Cannot write to {dest}: {e}") from e
|
|
1390
|
+
return dest
|
|
1391
|
+
|
|
1392
|
+
def delete_attachment(self, article_id: str, attachment_id: str) -> None:
|
|
1393
|
+
self._articles.delete_article_attachment(article_id, attachment_id)
|
|
1394
|
+
|
|
1395
|
+
# --- Hierarchy ---
|
|
1396
|
+
|
|
1397
|
+
def move(self, article_id: str, parent: str | None) -> Article:
|
|
1398
|
+
"""Move an article to a new parent, or to project root if parent is None."""
|
|
1399
|
+
# The childArticles endpoint requires the database ID in the body
|
|
1400
|
+
child = self._articles.get_article(article_id)
|
|
1401
|
+
if parent is not None:
|
|
1402
|
+
self._debug.info("api", f"moving {article_id} under {parent}")
|
|
1403
|
+
self._articles.attach_child_article(parent, child.id)
|
|
1404
|
+
else:
|
|
1405
|
+
self._debug.info("api", f"moving {article_id} to root")
|
|
1406
|
+
self._articles.detach_parent_article(article_id)
|
|
1407
|
+
return self._articles.get_article(article_id)
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _detect_entity_type(entity_id: str) -> str:
|
|
1411
|
+
"""Determine entity type from ID format.
|
|
1412
|
+
|
|
1413
|
+
Article IDs contain an ``-A-`` infix (e.g. ``FB-A-1``).
|
|
1414
|
+
Issue IDs are ``PROJECT-NUMBER`` (e.g. ``FB-123``).
|
|
1415
|
+
"""
|
|
1416
|
+
return "article" if "-A-" in entity_id else "issue"
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
class TagOperations:
|
|
1420
|
+
"""High-level tag operations with name resolution and entity detection."""
|
|
1421
|
+
|
|
1422
|
+
def __init__(
|
|
1423
|
+
self,
|
|
1424
|
+
tags_api: TagsAPI,
|
|
1425
|
+
articles_api: ArticlesAPI,
|
|
1426
|
+
yt: YouTrackAPI,
|
|
1427
|
+
debug_log: DebugLog | None = None,
|
|
1428
|
+
) -> None:
|
|
1429
|
+
self._tags = tags_api
|
|
1430
|
+
self._articles = articles_api
|
|
1431
|
+
self._yt = yt
|
|
1432
|
+
self._debug = debug_log or DebugLog()
|
|
1433
|
+
self._name_cache: dict[str, str] = {}
|
|
1434
|
+
|
|
1435
|
+
def list(self, top: int = 50, skip: int = 0) -> builtins.list[Tag]:
|
|
1436
|
+
return self._tags.list_tags(top=top, skip=skip)
|
|
1437
|
+
|
|
1438
|
+
def get(self, name: str) -> Tag:
|
|
1439
|
+
tag_id = self._resolve_tag(name)
|
|
1440
|
+
return self._tags.get_tag(tag_id)
|
|
1441
|
+
|
|
1442
|
+
def create(self, name: str) -> Tag:
|
|
1443
|
+
self._debug.info("api", f"creating tag '{name}'")
|
|
1444
|
+
tag = self._tags.create_tag(name)
|
|
1445
|
+
self._name_cache[name.lower()] = tag.id
|
|
1446
|
+
self._debug.info("api", f"created tag '{name}' ({tag.id})")
|
|
1447
|
+
return tag
|
|
1448
|
+
|
|
1449
|
+
def rename(self, name: str, new_name: str) -> Tag:
|
|
1450
|
+
tag_id = self._resolve_tag(name)
|
|
1451
|
+
self._debug.info("api", f"renaming tag '{name}' → '{new_name}'")
|
|
1452
|
+
tag = self._tags.update_tag(tag_id, new_name)
|
|
1453
|
+
del self._name_cache[name.lower()]
|
|
1454
|
+
self._name_cache[new_name.lower()] = tag.id
|
|
1455
|
+
return tag
|
|
1456
|
+
|
|
1457
|
+
def delete(self, name: str) -> None:
|
|
1458
|
+
tag_id = self._resolve_tag(name)
|
|
1459
|
+
self._debug.info("api", f"deleting tag '{name}' ({tag_id})")
|
|
1460
|
+
self._tags.delete_tag(tag_id)
|
|
1461
|
+
self._name_cache.pop(name.lower(), None)
|
|
1462
|
+
|
|
1463
|
+
def add(self, entity_id: str, tag: str) -> None:
|
|
1464
|
+
"""Add a tag to an issue or article. Entity type is auto-detected."""
|
|
1465
|
+
tag_id = self._resolve_tag(tag)
|
|
1466
|
+
entity_type = _detect_entity_type(entity_id)
|
|
1467
|
+
self._debug.info("api", f"tagging {entity_type} {entity_id} with '{tag}'")
|
|
1468
|
+
if entity_type == "article":
|
|
1469
|
+
self._tags.add_tag_to_article(entity_id, tag_id)
|
|
1470
|
+
else:
|
|
1471
|
+
self._tags.add_tag_to_issue(entity_id, tag_id)
|
|
1472
|
+
|
|
1473
|
+
def remove(self, entity_id: str, tag: str) -> None:
|
|
1474
|
+
"""Remove a tag from an issue or article. Entity type is auto-detected."""
|
|
1475
|
+
tag_id = self._resolve_tag(tag)
|
|
1476
|
+
entity_type = _detect_entity_type(entity_id)
|
|
1477
|
+
self._debug.info("api", f"untagging {entity_type} {entity_id} from '{tag}'")
|
|
1478
|
+
if entity_type == "article":
|
|
1479
|
+
self._tags.remove_tag_from_article(entity_id, tag_id)
|
|
1480
|
+
else:
|
|
1481
|
+
self._tags.remove_tag_from_issue(entity_id, tag_id)
|
|
1482
|
+
|
|
1483
|
+
def issues(self, tag: str, top: int = 50, skip: int = 0) -> builtins.list[Issue]:
|
|
1484
|
+
"""List issues tagged with the given tag."""
|
|
1485
|
+
tag_id = self._resolve_tag(tag)
|
|
1486
|
+
return self._tags.list_tag_issues(tag_id, top=top, skip=skip)
|
|
1487
|
+
|
|
1488
|
+
def articles(
|
|
1489
|
+
self,
|
|
1490
|
+
tags: str | builtins.list[str],
|
|
1491
|
+
project: str | None = None,
|
|
1492
|
+
top: int = 50,
|
|
1493
|
+
) -> builtins.list[Article]:
|
|
1494
|
+
"""List articles tagged with the given tag(s).
|
|
1495
|
+
|
|
1496
|
+
The YouTrack API has no server-side article-by-tag endpoint,
|
|
1497
|
+
so this scans article pages with the tags field and filters
|
|
1498
|
+
client-side. When multiple tags are given, only articles that
|
|
1499
|
+
carry **all** of them (AND semantics) are returned. Stops once
|
|
1500
|
+
*top* matches are collected or the article source is exhausted.
|
|
1501
|
+
"""
|
|
1502
|
+
tag_names = [tags] if isinstance(tags, str) else tags
|
|
1503
|
+
tag_ids = frozenset(self._resolve_tag(name) for name in tag_names)
|
|
1504
|
+
self._debug.info("api", f"scanning articles for tags {tag_names} ({tag_ids})")
|
|
1505
|
+
|
|
1506
|
+
project_id: str | None = None
|
|
1507
|
+
if project:
|
|
1508
|
+
project_id = self._yt.get_project(project).id
|
|
1509
|
+
|
|
1510
|
+
matched: builtins.list[Article] = []
|
|
1511
|
+
page_size = max(top, 50)
|
|
1512
|
+
skip = 0
|
|
1513
|
+
|
|
1514
|
+
while len(matched) < top:
|
|
1515
|
+
if project_id:
|
|
1516
|
+
page = self._articles.list_project_articles_with_tags(
|
|
1517
|
+
project_id,
|
|
1518
|
+
top=page_size,
|
|
1519
|
+
skip=skip,
|
|
1520
|
+
)
|
|
1521
|
+
else:
|
|
1522
|
+
page = self._articles.list_articles_with_tags(
|
|
1523
|
+
top=page_size,
|
|
1524
|
+
skip=skip,
|
|
1525
|
+
)
|
|
1526
|
+
if not page:
|
|
1527
|
+
break
|
|
1528
|
+
for article in page:
|
|
1529
|
+
if tag_ids.issubset({t.id for t in article.tags}):
|
|
1530
|
+
matched.append(article)
|
|
1531
|
+
if len(matched) >= top:
|
|
1532
|
+
break
|
|
1533
|
+
skip += len(page)
|
|
1534
|
+
|
|
1535
|
+
return matched
|
|
1536
|
+
|
|
1537
|
+
def _resolve_tag(self, name: str) -> str:
|
|
1538
|
+
"""Resolve tag name to ID. Cached for client lifetime."""
|
|
1539
|
+
key = name.lower()
|
|
1540
|
+
if key in self._name_cache:
|
|
1541
|
+
return self._name_cache[key]
|
|
1542
|
+
self._debug.info("api", f"resolving tag '{name}'")
|
|
1543
|
+
all_tags = self._tags.list_tags(top=500)
|
|
1544
|
+
for t in all_tags:
|
|
1545
|
+
self._name_cache[t.name.lower()] = t.id
|
|
1546
|
+
if key in self._name_cache:
|
|
1547
|
+
return self._name_cache[key]
|
|
1548
|
+
available = [t.name for t in all_tags]
|
|
1549
|
+
close = difflib.get_close_matches(name, available, n=3, cutoff=0.5)
|
|
1550
|
+
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
1551
|
+
raise NotFoundError(f"Tag '{name}' not found.{hint}")
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
class SavedQueryOperations:
|
|
1555
|
+
"""High-level saved-query operations with name-or-ID resolution."""
|
|
1556
|
+
|
|
1557
|
+
def __init__(
|
|
1558
|
+
self,
|
|
1559
|
+
saved_queries_api: SavedQueriesAPI,
|
|
1560
|
+
yt: YouTrackAPI,
|
|
1561
|
+
tag_ops: TagOperations | None = None,
|
|
1562
|
+
debug_log: DebugLog | None = None,
|
|
1563
|
+
) -> None:
|
|
1564
|
+
self._api = saved_queries_api
|
|
1565
|
+
self._yt = yt
|
|
1566
|
+
self._tag_ops = tag_ops
|
|
1567
|
+
self._debug = debug_log or DebugLog()
|
|
1568
|
+
# Lowercase-name → ID. Populated lazily on first resolve.
|
|
1569
|
+
self._name_cache: dict[str, str] = {}
|
|
1570
|
+
# Cached full listing used by name resolution (avoids re-fetch).
|
|
1571
|
+
self._last_listing: builtins.list[SavedQuery] | None = None
|
|
1572
|
+
|
|
1573
|
+
# --- Listing / reads ---
|
|
1574
|
+
|
|
1575
|
+
def list(
|
|
1576
|
+
self,
|
|
1577
|
+
owner: str | None = None,
|
|
1578
|
+
top: int = 50,
|
|
1579
|
+
skip: int = 0,
|
|
1580
|
+
) -> builtins.list[SavedQuery]:
|
|
1581
|
+
"""List saved queries. If ``owner`` is given, scopes to that user.
|
|
1582
|
+
|
|
1583
|
+
``owner`` may be a user login or the literal string ``"me"`` (accepted
|
|
1584
|
+
verbatim by the upstream ``/api/users/{userID}/savedQueries`` route).
|
|
1585
|
+
"""
|
|
1586
|
+
if owner:
|
|
1587
|
+
self._debug.info("api", f"listing saved queries owned by {owner}")
|
|
1588
|
+
return self._api.list_user_saved_queries(owner, top=top, skip=skip)
|
|
1589
|
+
self._debug.info("api", "listing saved queries")
|
|
1590
|
+
return self._api.list_saved_queries(top=top, skip=skip)
|
|
1591
|
+
|
|
1592
|
+
def get(self, name_or_id: str) -> SavedQuery:
|
|
1593
|
+
sq_id = self._resolve(name_or_id)
|
|
1594
|
+
return self._api.get_saved_query(sq_id)
|
|
1595
|
+
|
|
1596
|
+
# --- Writes ---
|
|
1597
|
+
|
|
1598
|
+
def create(self, name: str, query: str) -> SavedQuery:
|
|
1599
|
+
self._debug.info("api", f"creating saved query '{name}'")
|
|
1600
|
+
sq = self._api.create_saved_query(SavedQueryCreate(name=name, query=query))
|
|
1601
|
+
self._name_cache[name.lower()] = sq.id
|
|
1602
|
+
self._last_listing = None # invalidate
|
|
1603
|
+
self._debug.info("api", f"created saved query '{name}' ({sq.id})")
|
|
1604
|
+
return sq
|
|
1605
|
+
|
|
1606
|
+
def update(
|
|
1607
|
+
self,
|
|
1608
|
+
name_or_id: str,
|
|
1609
|
+
name: str | None = None,
|
|
1610
|
+
query: str | None = None,
|
|
1611
|
+
) -> SavedQuery:
|
|
1612
|
+
"""Update a saved query. Both fields optional at the SDK layer.
|
|
1613
|
+
|
|
1614
|
+
The CLI enforces "at least one" as a user-facing business rule; the SDK
|
|
1615
|
+
stays permissive so programmatic callers can compose updates freely.
|
|
1616
|
+
"""
|
|
1617
|
+
sq_id = self._resolve(name_or_id)
|
|
1618
|
+
self._debug.info("api", f"updating saved query {sq_id}")
|
|
1619
|
+
sq = self._api.update_saved_query(sq_id, SavedQueryUpdate(name=name, query=query))
|
|
1620
|
+
if name is not None:
|
|
1621
|
+
# Rename invalidates the old cache key.
|
|
1622
|
+
self._name_cache.pop(name_or_id.lower(), None)
|
|
1623
|
+
self._name_cache[name.lower()] = sq.id
|
|
1624
|
+
self._last_listing = None
|
|
1625
|
+
return sq
|
|
1626
|
+
|
|
1627
|
+
def delete(self, name_or_id: str) -> None:
|
|
1628
|
+
sq_id = self._resolve(name_or_id)
|
|
1629
|
+
self._debug.info("api", f"deleting saved query {sq_id}")
|
|
1630
|
+
self._api.delete_saved_query(sq_id)
|
|
1631
|
+
self._name_cache.pop(name_or_id.lower(), None)
|
|
1632
|
+
self._last_listing = None
|
|
1633
|
+
|
|
1634
|
+
# --- Composition ---
|
|
1635
|
+
|
|
1636
|
+
def run_issues(
|
|
1637
|
+
self,
|
|
1638
|
+
name_or_id: str,
|
|
1639
|
+
top: int = 50,
|
|
1640
|
+
skip: int = 0,
|
|
1641
|
+
project: str | None = None,
|
|
1642
|
+
) -> builtins.list[Issue]:
|
|
1643
|
+
"""Resolve a saved query and execute it via ``issues.list``."""
|
|
1644
|
+
sq_id = self._resolve(name_or_id)
|
|
1645
|
+
sq = self._api.get_saved_query(sq_id)
|
|
1646
|
+
query = sq.query
|
|
1647
|
+
if project:
|
|
1648
|
+
query = f"project: {project} {query}"
|
|
1649
|
+
self._debug.info("api", f"running saved query '{sq.name}' as issues → {query!r}")
|
|
1650
|
+
return self._yt.list_issues(query=query, top=top, skip=skip)
|
|
1651
|
+
|
|
1652
|
+
def run_articles(
|
|
1653
|
+
self,
|
|
1654
|
+
name_or_id: str,
|
|
1655
|
+
top: int = 50,
|
|
1656
|
+
project: str | None = None,
|
|
1657
|
+
) -> builtins.list[Article]:
|
|
1658
|
+
"""Resolve a saved query, parse for tag/project fields, find matching articles.
|
|
1659
|
+
|
|
1660
|
+
Raises :class:`~kd.exceptions.ValidationError` if the query contains
|
|
1661
|
+
issue-specific fields that cannot be translated to article lookups.
|
|
1662
|
+
"""
|
|
1663
|
+
from dataclasses import replace
|
|
1664
|
+
|
|
1665
|
+
from kd.query_parser import parse_article_query
|
|
1666
|
+
|
|
1667
|
+
if self._tag_ops is None:
|
|
1668
|
+
raise KdError("Tag operations not available")
|
|
1669
|
+
sq_id = self._resolve(name_or_id)
|
|
1670
|
+
sq = self._api.get_saved_query(sq_id)
|
|
1671
|
+
self._debug.info("api", f"running saved query '{sq.name}' as articles → {sq.query!r}")
|
|
1672
|
+
parsed = parse_article_query(sq.query)
|
|
1673
|
+
if project:
|
|
1674
|
+
parsed = replace(parsed, project=project)
|
|
1675
|
+
if not parsed.tags and not parsed.project:
|
|
1676
|
+
raise ValidationError(
|
|
1677
|
+
"Query is empty — cannot search articles without at least a project or tag filter."
|
|
1678
|
+
)
|
|
1679
|
+
return self._tag_ops.articles(parsed.tags, project=parsed.project, top=top)
|
|
1680
|
+
|
|
1681
|
+
# --- Validation ---
|
|
1682
|
+
|
|
1683
|
+
def validate(self, query: str) -> QueryValidationResult:
|
|
1684
|
+
"""Validate query syntax by executing a dry-run search.
|
|
1685
|
+
|
|
1686
|
+
Sends the query to ``/api/issues?$top=1`` and checks whether
|
|
1687
|
+
YouTrack accepts it. A 400 response indicates a syntax error;
|
|
1688
|
+
all other API errors are re-raised.
|
|
1689
|
+
"""
|
|
1690
|
+
self._debug.info("api", f"validating query: {query!r}")
|
|
1691
|
+
try:
|
|
1692
|
+
self._yt.list_issues(query=query, top=1)
|
|
1693
|
+
return QueryValidationResult(valid=True, query=query)
|
|
1694
|
+
except APIError as e:
|
|
1695
|
+
if e.status_code == 400:
|
|
1696
|
+
return QueryValidationResult(
|
|
1697
|
+
valid=False,
|
|
1698
|
+
query=query,
|
|
1699
|
+
error=str(e),
|
|
1700
|
+
suggestion=e.suggestion,
|
|
1701
|
+
)
|
|
1702
|
+
raise
|
|
1703
|
+
|
|
1704
|
+
# --- Resolution ---
|
|
1705
|
+
|
|
1706
|
+
def _resolve(self, name_or_id: str) -> str:
|
|
1707
|
+
"""Resolve a saved-query argument to its ID.
|
|
1708
|
+
|
|
1709
|
+
Strategy (matches TagOperations._resolve_tag with two extensions):
|
|
1710
|
+
|
|
1711
|
+
1. Direct-ID shortcut: ``NN-NN`` matches an ID we have already seen.
|
|
1712
|
+
2. Lowercase-name cache hit.
|
|
1713
|
+
3. Cache miss → fetch full listing, repopulate, retry. A duplicate-name
|
|
1714
|
+
match raises ``ValidationError`` with the conflicting IDs.
|
|
1715
|
+
4. Still no match → ``NotFoundError`` with difflib suggestions.
|
|
1716
|
+
"""
|
|
1717
|
+
key = name_or_id.lower()
|
|
1718
|
+
# Direct-ID shortcut — only trusts IDs we have already seen so that a
|
|
1719
|
+
# typo like ``11-9`` which happens to look like an ID doesn't sneak
|
|
1720
|
+
# through without validation.
|
|
1721
|
+
if self._last_listing is not None:
|
|
1722
|
+
for sq in self._last_listing:
|
|
1723
|
+
if sq.id == name_or_id:
|
|
1724
|
+
return sq.id
|
|
1725
|
+
if key in self._name_cache:
|
|
1726
|
+
return self._name_cache[key]
|
|
1727
|
+
|
|
1728
|
+
self._debug.info("api", f"resolving saved query '{name_or_id}'")
|
|
1729
|
+
listing = self._api.list_saved_queries(top=500)
|
|
1730
|
+
self._last_listing = listing
|
|
1731
|
+
|
|
1732
|
+
# Check for a direct ID match against the fresh listing before any
|
|
1733
|
+
# name matching — IDs are unambiguous.
|
|
1734
|
+
for sq in listing:
|
|
1735
|
+
if sq.id == name_or_id:
|
|
1736
|
+
return sq.id
|
|
1737
|
+
|
|
1738
|
+
# Build name → [IDs] map to detect duplicates.
|
|
1739
|
+
by_name: dict[str, builtins.list[str]] = {}
|
|
1740
|
+
for sq in listing:
|
|
1741
|
+
by_name.setdefault(sq.name.lower(), []).append(sq.id)
|
|
1742
|
+
# Repopulate single-ID names into the cache; duplicates stay out so
|
|
1743
|
+
# every future lookup re-runs the duplicate check.
|
|
1744
|
+
for lname, ids in by_name.items():
|
|
1745
|
+
if len(ids) == 1:
|
|
1746
|
+
self._name_cache[lname] = ids[0]
|
|
1747
|
+
|
|
1748
|
+
matches = by_name.get(key, [])
|
|
1749
|
+
if len(matches) == 1:
|
|
1750
|
+
return matches[0]
|
|
1751
|
+
if len(matches) > 1:
|
|
1752
|
+
joined = ", ".join(matches)
|
|
1753
|
+
raise ValidationError(
|
|
1754
|
+
f"Multiple saved queries named '{name_or_id}' (IDs: {joined}). Use the ID instead."
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
available = [sq.name for sq in listing]
|
|
1758
|
+
close = difflib.get_close_matches(name_or_id, available, n=3, cutoff=0.5)
|
|
1759
|
+
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
1760
|
+
raise NotFoundError(f"Saved query '{name_or_id}' not found.{hint}")
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
class AssistOperations:
|
|
1764
|
+
"""Wrappers around ``/api/search/assist`` and ``/api/commands/assist``.
|
|
1765
|
+
|
|
1766
|
+
These two endpoints provide autocomplete suggestions for YouTrack search
|
|
1767
|
+
queries and YT command-language strings respectively. They are free-form
|
|
1768
|
+
helpers that operate on caller-supplied strings, so they live on their
|
|
1769
|
+
own operation class rather than being attached to any domain entity.
|
|
1770
|
+
"""
|
|
1771
|
+
|
|
1772
|
+
def __init__(
|
|
1773
|
+
self,
|
|
1774
|
+
yt: YouTrackAPI,
|
|
1775
|
+
debug_log: DebugLog | None = None,
|
|
1776
|
+
) -> None:
|
|
1777
|
+
self._yt = yt
|
|
1778
|
+
self._debug = debug_log or DebugLog()
|
|
1779
|
+
|
|
1780
|
+
def search(self, query: str, caret: int | None = None) -> AssistResponse:
|
|
1781
|
+
"""Autocomplete a YouTrack search query."""
|
|
1782
|
+
self._debug.info("api", f"search assist query={query!r} caret={caret}")
|
|
1783
|
+
return self._yt.search_assist(query, caret=caret)
|
|
1784
|
+
|
|
1785
|
+
def command(
|
|
1786
|
+
self,
|
|
1787
|
+
query: str,
|
|
1788
|
+
caret: int | None = None,
|
|
1789
|
+
issue_ids: builtins.list[str] | None = None,
|
|
1790
|
+
) -> AssistResponse:
|
|
1791
|
+
"""Autocomplete a YT command-language string.
|
|
1792
|
+
|
|
1793
|
+
``issue_ids`` is the readable IDs of the issues the command would
|
|
1794
|
+
target. An empty list (or ``None``) still produces useful field/value
|
|
1795
|
+
suggestions.
|
|
1796
|
+
"""
|
|
1797
|
+
self._debug.info(
|
|
1798
|
+
"api",
|
|
1799
|
+
f"command assist query={query!r} caret={caret} issues={issue_ids or []}",
|
|
1800
|
+
)
|
|
1801
|
+
return self._yt.commands_assist(query, caret=caret, issue_ids=issue_ids)
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
class SearchOperations:
|
|
1805
|
+
"""Aggregated cross-entity search.
|
|
1806
|
+
|
|
1807
|
+
Fans out to per-type APIs and returns a unified list of
|
|
1808
|
+
:class:`SearchResult` objects. Issues and users use server-side
|
|
1809
|
+
query parameters; tags, projects, and articles use client-side
|
|
1810
|
+
substring matching over list endpoints.
|
|
1811
|
+
"""
|
|
1812
|
+
|
|
1813
|
+
VALID_TYPES: frozenset[str] = frozenset({"issue", "user", "tag", "project", "article"})
|
|
1814
|
+
|
|
1815
|
+
def __init__(
|
|
1816
|
+
self,
|
|
1817
|
+
yt: YouTrackAPI,
|
|
1818
|
+
articles_api: ArticlesAPI,
|
|
1819
|
+
tags_api: TagsAPI,
|
|
1820
|
+
debug_log: DebugLog | None = None,
|
|
1821
|
+
) -> None:
|
|
1822
|
+
self._yt = yt
|
|
1823
|
+
self._articles = articles_api
|
|
1824
|
+
self._tags = tags_api
|
|
1825
|
+
self._debug = debug_log or DebugLog()
|
|
1826
|
+
|
|
1827
|
+
def search(
|
|
1828
|
+
self,
|
|
1829
|
+
query: str,
|
|
1830
|
+
types: builtins.list[str] | None = None,
|
|
1831
|
+
project: str | None = None,
|
|
1832
|
+
limit: int = 50,
|
|
1833
|
+
) -> builtins.list[SearchResult]:
|
|
1834
|
+
"""Search across entity types and return unified results.
|
|
1835
|
+
|
|
1836
|
+
Args:
|
|
1837
|
+
query: Search term.
|
|
1838
|
+
types: Entity types to search. Defaults to all five.
|
|
1839
|
+
project: Scope issue queries and article scans to this project key.
|
|
1840
|
+
limit: Maximum total results returned.
|
|
1841
|
+
"""
|
|
1842
|
+
requested = builtins.list(types or self.VALID_TYPES)
|
|
1843
|
+
invalid = set(requested) - self.VALID_TYPES
|
|
1844
|
+
if invalid:
|
|
1845
|
+
raise ValidationError(
|
|
1846
|
+
f"Invalid search type(s): {', '.join(sorted(invalid))}. "
|
|
1847
|
+
f"Valid types: {', '.join(sorted(self.VALID_TYPES))}"
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
self._debug.info("search", f"query={query!r} types={requested} project={project}")
|
|
1851
|
+
|
|
1852
|
+
buckets: dict[str, builtins.list[SearchResult]] = {}
|
|
1853
|
+
for t in requested:
|
|
1854
|
+
buckets[t] = getattr(self, f"_search_{t}s")(query, project)
|
|
1855
|
+
|
|
1856
|
+
return self._interleave(buckets, requested, limit)
|
|
1857
|
+
|
|
1858
|
+
# --- per-type search methods ---
|
|
1859
|
+
|
|
1860
|
+
def _search_issues(self, query: str, project: str | None) -> builtins.list[SearchResult]:
|
|
1861
|
+
full_query = f"project: {project} {query}" if project else query
|
|
1862
|
+
issues = self._yt.list_issues(query=full_query, top=50)
|
|
1863
|
+
return [
|
|
1864
|
+
SearchResult(
|
|
1865
|
+
type="issue",
|
|
1866
|
+
id=i.id_readable,
|
|
1867
|
+
title=i.summary,
|
|
1868
|
+
project=i.project.name or "" if i.project else "",
|
|
1869
|
+
)
|
|
1870
|
+
for i in issues
|
|
1871
|
+
]
|
|
1872
|
+
|
|
1873
|
+
def _search_users(
|
|
1874
|
+
self,
|
|
1875
|
+
query: str,
|
|
1876
|
+
project: str | None, # noqa: ARG002
|
|
1877
|
+
) -> builtins.list[SearchResult]:
|
|
1878
|
+
users = self._yt.search_users(query)
|
|
1879
|
+
return [SearchResult(type="user", id=u.login, title=u.name, project="") for u in users]
|
|
1880
|
+
|
|
1881
|
+
def _search_tags(
|
|
1882
|
+
self,
|
|
1883
|
+
query: str,
|
|
1884
|
+
project: str | None, # noqa: ARG002
|
|
1885
|
+
) -> builtins.list[SearchResult]:
|
|
1886
|
+
tags = self._tags.list_tags(top=500)
|
|
1887
|
+
q = query.lower()
|
|
1888
|
+
return [
|
|
1889
|
+
SearchResult(type="tag", id=t.name, title=t.name, project="")
|
|
1890
|
+
for t in tags
|
|
1891
|
+
if q in t.name.lower()
|
|
1892
|
+
]
|
|
1893
|
+
|
|
1894
|
+
def _search_projects(
|
|
1895
|
+
self,
|
|
1896
|
+
query: str,
|
|
1897
|
+
project: str | None, # noqa: ARG002
|
|
1898
|
+
) -> builtins.list[SearchResult]:
|
|
1899
|
+
projects = self._yt.list_projects(top=200)
|
|
1900
|
+
q = query.lower()
|
|
1901
|
+
return [
|
|
1902
|
+
SearchResult(
|
|
1903
|
+
type="project",
|
|
1904
|
+
id=p.short_name,
|
|
1905
|
+
title=p.name,
|
|
1906
|
+
project=p.short_name,
|
|
1907
|
+
)
|
|
1908
|
+
for p in projects
|
|
1909
|
+
if q in p.name.lower() or q in p.short_name.lower()
|
|
1910
|
+
]
|
|
1911
|
+
|
|
1912
|
+
def _search_articles(self, query: str, project: str | None) -> builtins.list[SearchResult]:
|
|
1913
|
+
if project:
|
|
1914
|
+
articles = self._articles.list_project_articles(project, top=200)
|
|
1915
|
+
else:
|
|
1916
|
+
articles = self._articles.list_articles(top=200)
|
|
1917
|
+
q = query.lower()
|
|
1918
|
+
return [
|
|
1919
|
+
SearchResult(
|
|
1920
|
+
type="article",
|
|
1921
|
+
id=a.id_readable,
|
|
1922
|
+
title=a.summary,
|
|
1923
|
+
project=a.project.name or "" if a.project else "",
|
|
1924
|
+
)
|
|
1925
|
+
for a in articles
|
|
1926
|
+
if q in a.summary.lower()
|
|
1927
|
+
]
|
|
1928
|
+
|
|
1929
|
+
# --- result merging ---
|
|
1930
|
+
|
|
1931
|
+
@staticmethod
|
|
1932
|
+
def _interleave(
|
|
1933
|
+
buckets: dict[str, builtins.list[SearchResult]],
|
|
1934
|
+
order: builtins.list[str],
|
|
1935
|
+
limit: int,
|
|
1936
|
+
) -> builtins.list[SearchResult]:
|
|
1937
|
+
"""Round-robin interleave results from each type bucket."""
|
|
1938
|
+
merged: builtins.list[SearchResult] = []
|
|
1939
|
+
indices = {t: 0 for t in order}
|
|
1940
|
+
while len(merged) < limit:
|
|
1941
|
+
added = False
|
|
1942
|
+
for t in order:
|
|
1943
|
+
items = buckets.get(t, [])
|
|
1944
|
+
idx = indices[t]
|
|
1945
|
+
if idx < len(items):
|
|
1946
|
+
merged.append(items[idx])
|
|
1947
|
+
indices[t] = idx + 1
|
|
1948
|
+
added = True
|
|
1949
|
+
if len(merged) >= limit:
|
|
1950
|
+
break
|
|
1951
|
+
if not added:
|
|
1952
|
+
break
|
|
1953
|
+
return merged
|