kd-cli 2.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/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