kd-cli 2.4.0__tar.gz → 2.5.0__tar.gz
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_cli-2.4.0/kd_cli.egg-info → kd_cli-2.5.0}/PKG-INFO +1 -1
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/tags.py +1 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/article/__init__.py +5 -1
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/system/__init__.py +10 -2
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/tag/__init__.py +16 -4
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/client.py +82 -21
- kd_cli-2.5.0/kd/locking.py +97 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/__init__.py +2 -1
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/tag.py +17 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/primer.py +74 -64
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/CHANGELOG.md +20 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/error-messages.yaml +11 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/operating-guide.md +10 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd_cli.egg-info/SOURCES.txt +1 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/pyproject.toml +1 -1
- {kd_cli-2.4.0 → kd_cli-2.5.0}/LICENSE +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/MANIFEST.in +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/README.md +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/articles.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/http.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/hub.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/api/youtrack.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/article_marker.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/auth.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/input.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/main.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/output.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/config.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/config_read.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/config_schema.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/connection.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/debug.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/exceptions.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/history.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/article.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/assist.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/comment.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/common.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/issue.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/project.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/search.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/user.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/query_parser.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/release_notes.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.5.0}/setup.cfg +0 -0
|
@@ -353,7 +353,11 @@ class ArticleCreateCommand(Command):
|
|
|
353
353
|
"title. Pass --parent to attach the new article under an "
|
|
354
354
|
"existing one in a single call, avoiding a create-then-move "
|
|
355
355
|
"two-step in migration scripts. Pass --tag (repeatable) to "
|
|
356
|
-
"apply tags at creation time
|
|
356
|
+
"apply tags at creation time: every --tag must exist, the "
|
|
357
|
+
"article is refused before creation if any tag is missing "
|
|
358
|
+
"(no orphan article on unknown tags), and the structured "
|
|
359
|
+
"response reflects post-tag state so it matches a "
|
|
360
|
+
"subsequent /article show on the returned id."
|
|
357
361
|
),
|
|
358
362
|
examples=[
|
|
359
363
|
'kd /article create "Setup Guide" --project FB --content @guide.md',
|
|
@@ -9,10 +9,12 @@ copy-on-write history for config and primer files.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import difflib
|
|
12
|
+
import platform as _platform
|
|
12
13
|
from dataclasses import asdict
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any
|
|
15
16
|
|
|
17
|
+
from kd import __version__ as _kd_version
|
|
16
18
|
from kd.cli.core.command_base import Command, CommandResult
|
|
17
19
|
from kd.cli.core.namespaces import Namespace
|
|
18
20
|
from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, ResolvedOptions
|
|
@@ -51,10 +53,13 @@ class SystemInfoCommand(Command):
|
|
|
51
53
|
requires_client=True,
|
|
52
54
|
usage="kd /system info",
|
|
53
55
|
long_description="Connects to the YouTrack instance and displays the current "
|
|
54
|
-
"user, server URL, and connection status
|
|
55
|
-
"(
|
|
56
|
+
"user, server URL, and connection status, plus kd client/runtime metadata "
|
|
57
|
+
"(`kd_version`, `python_version`, `platform`) so bug reports are "
|
|
58
|
+
"self-describing without hand-typing the version. Uses the active "
|
|
59
|
+
"connection (selected via -c or 'default_connection').",
|
|
56
60
|
examples=[
|
|
57
61
|
"kd /system info",
|
|
62
|
+
"kd /system info -o yaml",
|
|
58
63
|
"kd -c upstream /system info",
|
|
59
64
|
],
|
|
60
65
|
see_also=["/system/config list"],
|
|
@@ -73,6 +78,9 @@ class SystemInfoCommand(Command):
|
|
|
73
78
|
"user": me.get("login", "unknown"),
|
|
74
79
|
"name": me.get("name", ""),
|
|
75
80
|
"email": me.get("email", ""),
|
|
81
|
+
"kd_version": _kd_version,
|
|
82
|
+
"python_version": _platform.python_version(),
|
|
83
|
+
"platform": _platform.platform(),
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
|
|
@@ -152,7 +152,7 @@ class TagShowCommand(Command):
|
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
class TagCreateCommand(Command):
|
|
155
|
-
"""Create a tag."""
|
|
155
|
+
"""Create a tag (idempotent)."""
|
|
156
156
|
|
|
157
157
|
def __init__(self) -> None:
|
|
158
158
|
super().__init__(
|
|
@@ -160,8 +160,16 @@ class TagCreateCommand(Command):
|
|
|
160
160
|
"Create a tag",
|
|
161
161
|
requires_client=True,
|
|
162
162
|
usage="kd /tag create TAG_NAME",
|
|
163
|
+
long_description=(
|
|
164
|
+
"Idempotent: if a tag with the requested name is already "
|
|
165
|
+
"visible, the command returns the existing tag with "
|
|
166
|
+
"action: exists and does not POST. Response envelope: "
|
|
167
|
+
"{ok, action, tag} with action ∈ {create, exists} in both "
|
|
168
|
+
"branches. Name resolution is case-insensitive (same "
|
|
169
|
+
"resolver /tag show uses)."
|
|
170
|
+
),
|
|
163
171
|
examples=['kd /tag create "sprint-47"'],
|
|
164
|
-
see_also=["/tag list", "/tag delete"],
|
|
172
|
+
see_also=["/tag list", "/tag show", "/tag delete"],
|
|
165
173
|
)
|
|
166
174
|
|
|
167
175
|
def execute(
|
|
@@ -169,8 +177,12 @@ class TagCreateCommand(Command):
|
|
|
169
177
|
) -> CommandResult:
|
|
170
178
|
if not args:
|
|
171
179
|
raise ValidationError("Usage: kd /tag create TAG_NAME")
|
|
172
|
-
|
|
173
|
-
return
|
|
180
|
+
result = client.tags.create(args[0])
|
|
181
|
+
return {
|
|
182
|
+
"ok": True,
|
|
183
|
+
"action": result.action,
|
|
184
|
+
"tag": _tag_to_detail(result.tag),
|
|
185
|
+
}
|
|
174
186
|
|
|
175
187
|
|
|
176
188
|
class TagRenameCommand(Command):
|
|
@@ -72,6 +72,7 @@ from kd.models import (
|
|
|
72
72
|
EntityRef,
|
|
73
73
|
KindMeta,
|
|
74
74
|
Tag,
|
|
75
|
+
TagCreateResult,
|
|
75
76
|
TagEntitiesResult,
|
|
76
77
|
TimeTrackingSettings,
|
|
77
78
|
User,
|
|
@@ -1031,24 +1032,44 @@ def _extract_tag_suggestions(error: NotFoundError) -> builtins.list[str]:
|
|
|
1031
1032
|
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
1032
1033
|
|
|
1033
1034
|
|
|
1034
|
-
def _missing_tag_resolution(
|
|
1035
|
-
|
|
1035
|
+
def _missing_tag_resolution(
|
|
1036
|
+
tag: str,
|
|
1037
|
+
error: NotFoundError,
|
|
1038
|
+
*,
|
|
1039
|
+
context: Literal["create", "push"] = "push",
|
|
1040
|
+
) -> str:
|
|
1041
|
+
"""Format an actionable missing-tag instruction for ``/article create`` or ``/article push``.
|
|
1042
|
+
|
|
1043
|
+
*context* selects the retry-wording so the create path does not ship
|
|
1044
|
+
push-specific recovery text and vice versa. The shared prelude
|
|
1045
|
+
(heading, near-matches, ``/tag list --prefix``, ``/tag create``)
|
|
1046
|
+
is identical across callers.
|
|
1047
|
+
"""
|
|
1036
1048
|
lines = [f"Tag '{tag}' not found."]
|
|
1037
1049
|
suggestions = _extract_tag_suggestions(error)
|
|
1038
1050
|
if suggestions:
|
|
1039
1051
|
lines.append(f" Near matches: {', '.join(suggestions)}")
|
|
1040
1052
|
prefix = _tag_prefix_for_list(tag)
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1053
|
+
shared = [
|
|
1054
|
+
"",
|
|
1055
|
+
f"To use an existing tag: kd /tag list --prefix {shlex.quote(prefix)}",
|
|
1056
|
+
f"To create a new tag: kd /tag create {shlex.quote(tag)}",
|
|
1057
|
+
]
|
|
1058
|
+
if context == "push":
|
|
1059
|
+
recovery = [
|
|
1046
1060
|
f"To retry push without this tag: omit --tag {shlex.quote(tag)}",
|
|
1047
1061
|
"",
|
|
1048
1062
|
"If you are still curating the tag set, push without --tag and apply tags",
|
|
1049
1063
|
f"separately with kd /tag add ARTICLE-ID {shlex.quote(tag)}.",
|
|
1050
1064
|
]
|
|
1051
|
-
|
|
1065
|
+
else:
|
|
1066
|
+
recovery = [
|
|
1067
|
+
f"To retry create without this tag: omit --tag {shlex.quote(tag)} from kd /article create",
|
|
1068
|
+
"",
|
|
1069
|
+
"If you are still curating the tag set, create the article without --tag and apply",
|
|
1070
|
+
f"tags separately with kd /tag add ARTICLE-ID {shlex.quote(tag)}.",
|
|
1071
|
+
]
|
|
1072
|
+
lines.extend(shared + recovery)
|
|
1052
1073
|
return "\n".join(lines)
|
|
1053
1074
|
|
|
1054
1075
|
|
|
@@ -1126,9 +1147,21 @@ class ArticleOperations:
|
|
|
1126
1147
|
|
|
1127
1148
|
When *parent* is given (readable id like ``FB-A-1`` or internal id),
|
|
1128
1149
|
the new article is attached to that parent in a single POST.
|
|
1129
|
-
When *tags* is given,
|
|
1150
|
+
When *tags* is given, tag existence is validated **before** the
|
|
1151
|
+
article write so an unknown tag aborts with an actionable error
|
|
1152
|
+
instead of creating an orphan article that then fails on
|
|
1153
|
+
attachment. After every applied tag, the article is re-fetched
|
|
1154
|
+
so the returned :class:`Article` reflects post-tag state and
|
|
1155
|
+
matches a subsequent ``/article show`` on the returned ID.
|
|
1130
1156
|
"""
|
|
1131
1157
|
self._debug.info("api", f"creating article in project {project}")
|
|
1158
|
+
if tags:
|
|
1159
|
+
preflight_failed = self._preflight_tags(tags, context="create")
|
|
1160
|
+
if preflight_failed:
|
|
1161
|
+
raise KdError(
|
|
1162
|
+
"Article create aborted because tagging failed.\n\n"
|
|
1163
|
+
+ "\n\n".join(preflight_failed)
|
|
1164
|
+
)
|
|
1132
1165
|
project_obj = self._yt.get_project(project)
|
|
1133
1166
|
parent_ref: IdEntity | None = None
|
|
1134
1167
|
if parent is not None:
|
|
@@ -1152,6 +1185,8 @@ class ArticleOperations:
|
|
|
1152
1185
|
f"Applied: {applied or '(none)'}. "
|
|
1153
1186
|
f"Failed: {', '.join(failed)}."
|
|
1154
1187
|
)
|
|
1188
|
+
if applied:
|
|
1189
|
+
result = self._articles.get_article(result.id_readable)
|
|
1155
1190
|
return result
|
|
1156
1191
|
|
|
1157
1192
|
def _apply_tags(
|
|
@@ -1182,12 +1217,14 @@ class ArticleOperations:
|
|
|
1182
1217
|
*,
|
|
1183
1218
|
create_missing: bool = False,
|
|
1184
1219
|
dry_run: bool = False,
|
|
1220
|
+
context: Literal["create", "push"] = "push",
|
|
1185
1221
|
) -> builtins.list[str]:
|
|
1186
|
-
"""Resolve tags before
|
|
1222
|
+
"""Resolve tags before ``/article create`` or ``/article push`` writes.
|
|
1187
1223
|
|
|
1188
|
-
Returns actionable failure blocks.
|
|
1189
|
-
missing
|
|
1190
|
-
|
|
1224
|
+
Returns actionable failure blocks. *context* selects the retry-wording
|
|
1225
|
+
for missing-tag recovery so create and push do not share push-only
|
|
1226
|
+
prose. ``create_missing`` and ``dry_run`` are push-only flags today
|
|
1227
|
+
and are expected to stay false on the create path.
|
|
1191
1228
|
"""
|
|
1192
1229
|
if self._tag_ops is None:
|
|
1193
1230
|
raise KdError("Tag operations not available")
|
|
@@ -1206,7 +1243,7 @@ class ArticleOperations:
|
|
|
1206
1243
|
f"Tag '{tag}' not found and could not be created. {create_exc}"
|
|
1207
1244
|
)
|
|
1208
1245
|
continue
|
|
1209
|
-
failed.append(_missing_tag_resolution(tag, exc))
|
|
1246
|
+
failed.append(_missing_tag_resolution(tag, exc, context=context))
|
|
1210
1247
|
except KdError as exc:
|
|
1211
1248
|
failed.append(f"Tag '{tag}' could not be resolved. {exc}")
|
|
1212
1249
|
return failed
|
|
@@ -1631,12 +1668,28 @@ class TagOperations:
|
|
|
1631
1668
|
tag_id = self._resolve_tag(name)
|
|
1632
1669
|
return self._tags.get_tag(tag_id)
|
|
1633
1670
|
|
|
1634
|
-
def create(self, name: str) ->
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1671
|
+
def create(self, name: str) -> TagCreateResult:
|
|
1672
|
+
"""Idempotent tag creation.
|
|
1673
|
+
|
|
1674
|
+
Pre-checks existence via :meth:`_resolve_tag` — the same resolver
|
|
1675
|
+
``/tag show`` uses, so case-insensitive by construction — and
|
|
1676
|
+
short-circuits with ``action="exists"`` if a tag by that name is
|
|
1677
|
+
already visible. Otherwise POSTs and returns ``action="create"``.
|
|
1678
|
+
``Tag.name-is-invalid`` from the upstream POST is enriched by
|
|
1679
|
+
``kd/resources/error-messages.yaml`` into a factual diagnostic
|
|
1680
|
+
naming sharing/visibility and name-acceptance as plausible causes.
|
|
1681
|
+
"""
|
|
1682
|
+
try:
|
|
1683
|
+
tag_id = self._resolve_tag(name)
|
|
1684
|
+
except NotFoundError:
|
|
1685
|
+
self._debug.info("api", f"creating tag '{name}'")
|
|
1686
|
+
created = self._tags.create_tag(name)
|
|
1687
|
+
self._name_cache[name.lower()] = created.id
|
|
1688
|
+
self._debug.info("api", f"created tag '{name}' ({created.id})")
|
|
1689
|
+
return TagCreateResult(action="create", tag=created)
|
|
1690
|
+
self._debug.info("api", f"tag '{name}' already exists ({tag_id})")
|
|
1691
|
+
existing = self._tags.get_tag(tag_id)
|
|
1692
|
+
return TagCreateResult(action="exists", tag=existing)
|
|
1640
1693
|
|
|
1641
1694
|
def rename(self, name: str, new_name: str) -> Tag:
|
|
1642
1695
|
tag_id = self._resolve_tag(name)
|
|
@@ -1697,6 +1750,14 @@ class TagOperations:
|
|
|
1697
1750
|
composes the OR query (``tag: A or tag: B``). The single-tag fast
|
|
1698
1751
|
path is unaffected by ``match`` because one tag is itself the same
|
|
1699
1752
|
set regardless of combinator.
|
|
1753
|
+
|
|
1754
|
+
Both dispatch paths unconditionally project ``tags(id,name)`` on
|
|
1755
|
+
issue rows — the fast-path field-set (``_TAGGED_ISSUE_FIELDS`` in
|
|
1756
|
+
:mod:`kd.api.tags`) includes it, and the composed-query call
|
|
1757
|
+
passes ``with_tags=True`` to :meth:`YouTrackAPI.list_issues`.
|
|
1758
|
+
Keeps ``/tag entities`` discovery rows in parity with
|
|
1759
|
+
``/issue show --rich`` and ``/issue list --with-tags`` so
|
|
1760
|
+
tag-matched rows expose which tags caused the match.
|
|
1700
1761
|
"""
|
|
1701
1762
|
tag_names = [tags] if isinstance(tags, str) else list(tags)
|
|
1702
1763
|
if not tag_names:
|
|
@@ -1710,7 +1771,7 @@ class TagOperations:
|
|
|
1710
1771
|
if project is not None:
|
|
1711
1772
|
query += f" project: {youtrack_query_literal(project)}"
|
|
1712
1773
|
self._debug.info("api", f"composing multi-tag issue query ({match}): {query}")
|
|
1713
|
-
return self._yt.list_issues(query=query, top=top, skip=skip)
|
|
1774
|
+
return self._yt.list_issues(query=query, top=top, skip=skip, with_tags=True)
|
|
1714
1775
|
|
|
1715
1776
|
def articles(
|
|
1716
1777
|
self,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Advisory file locking for read-modify-write transactions on kd-managed files.
|
|
2
|
+
|
|
3
|
+
Provides :func:`file_transaction` — a context manager that acquires an
|
|
4
|
+
exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
|
|
5
|
+
duration of a load → mutate → save cycle. Concurrent kd processes that
|
|
6
|
+
enter the same transaction serialize rather than clobber each other.
|
|
7
|
+
|
|
8
|
+
Design notes:
|
|
9
|
+
|
|
10
|
+
* **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
|
|
11
|
+
kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
|
|
12
|
+
file's inode on every write. Holding ``flock`` on the target inode
|
|
13
|
+
would not protect a second writer that opens the freshly-replaced file.
|
|
14
|
+
Locking a separate sidecar file that nobody rewrites keeps the lock
|
|
15
|
+
stable across atomic replaces of the data file.
|
|
16
|
+
|
|
17
|
+
* **No unlink on release.** Removing the lockfile after releasing the
|
|
18
|
+
lock is a classic race: process B can open the doomed inode while
|
|
19
|
+
process C creates a fresh one, and both end up holding "exclusive"
|
|
20
|
+
locks on different inodes. Empty sidecar files are cheap; leave them.
|
|
21
|
+
|
|
22
|
+
* **POSIX only.** ``fcntl`` is imported lazily inside the context
|
|
23
|
+
manager so that unrelated kd commands remain importable on Windows.
|
|
24
|
+
On non-POSIX platforms, :func:`file_transaction` raises ``ConfigError``
|
|
25
|
+
with a suggestion to run the command sequentially.
|
|
26
|
+
|
|
27
|
+
* **NFS caveat.** ``flock`` on NFS has been unreliable historically.
|
|
28
|
+
Modern Linux kernels implement it correctly, but operators using a
|
|
29
|
+
networked ``$HOME`` should be aware that concurrency guarantees weaken.
|
|
30
|
+
Local ``.kd/`` directories inside a repo are unaffected.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from collections.abc import Generator
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from kd.exceptions import ConfigError
|
|
43
|
+
|
|
44
|
+
LOCK_SUFFIX = ".lock"
|
|
45
|
+
_POLL_INTERVAL_SECONDS = 0.05
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
|
|
50
|
+
"""Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
|
|
51
|
+
|
|
52
|
+
The sidecar lockfile is created if missing (``O_RDWR | O_CREAT``,
|
|
53
|
+
never truncated). Acquisition polls non-blocking ``flock`` until
|
|
54
|
+
the lock is granted or *timeout* seconds elapse. On timeout, raises
|
|
55
|
+
:class:`ConfigError` naming the contested file. The lockfile is
|
|
56
|
+
intentionally left on disk after release.
|
|
57
|
+
|
|
58
|
+
Raises :class:`ConfigError` on non-POSIX platforms.
|
|
59
|
+
"""
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
raise ConfigError(
|
|
62
|
+
"File locking is not available on this platform (POSIX only).",
|
|
63
|
+
suggestion="Run the kd command sequentially; concurrent mutations are not supported on Windows.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
import fcntl # noqa: PLC0415 — lazy import keeps kd importable on Windows
|
|
67
|
+
|
|
68
|
+
lock_path = path.with_name(path.name + LOCK_SUFFIX)
|
|
69
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
72
|
+
try:
|
|
73
|
+
deadline = time.monotonic() + timeout
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
77
|
+
break
|
|
78
|
+
except BlockingIOError:
|
|
79
|
+
if time.monotonic() >= deadline:
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
f"Another kd process is writing to {path}; retry in a moment.",
|
|
82
|
+
suggestion=f"If no other kd process is running, remove the stale lockfile: {lock_path}",
|
|
83
|
+
) from None
|
|
84
|
+
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
finally:
|
|
94
|
+
try:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
@@ -24,7 +24,7 @@ from kd.models.saved_query import (
|
|
|
24
24
|
SavedQueryUpdate,
|
|
25
25
|
)
|
|
26
26
|
from kd.models.search import SearchResult
|
|
27
|
-
from kd.models.tag import Tag, TagColor
|
|
27
|
+
from kd.models.tag import Tag, TagColor, TagCreateResult
|
|
28
28
|
from kd.models.tag_entities import EntityRef, KindMeta, TagEntitiesResult
|
|
29
29
|
from kd.models.user import Group, ProjectTeam, User, UserCreate
|
|
30
30
|
from kd.models.work_item import (
|
|
@@ -68,6 +68,7 @@ __all__ = [
|
|
|
68
68
|
"SearchResult",
|
|
69
69
|
"Tag",
|
|
70
70
|
"TagColor",
|
|
71
|
+
"TagCreateResult",
|
|
71
72
|
"TagEntitiesResult",
|
|
72
73
|
"TimeTrackingSettings",
|
|
73
74
|
"User",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
5
7
|
from pydantic import BaseModel, ConfigDict, Field
|
|
6
8
|
|
|
7
9
|
from kd.models.common import IdEntity, NamedEntity
|
|
@@ -28,3 +30,18 @@ class Tag(IdEntity):
|
|
|
28
30
|
owner: NamedEntity | None = None
|
|
29
31
|
color: TagColor | None = None
|
|
30
32
|
untag_on_resolve: bool = Field(default=False, alias="untagOnResolve")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TagCreateResult(BaseModel):
|
|
36
|
+
"""Result of an idempotent tag-create call.
|
|
37
|
+
|
|
38
|
+
``action`` is ``"create"`` when YouTrack minted a new tag and
|
|
39
|
+
``"exists"`` when the pre-check resolved a tag with the requested
|
|
40
|
+
name (short-circuit, no POST). The ``tag`` field carries the current
|
|
41
|
+
upstream state either way.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
45
|
+
|
|
46
|
+
action: Literal["create", "exists"]
|
|
47
|
+
tag: Tag
|
|
@@ -11,6 +11,8 @@ knowledge). The file is created lazily on first write operation.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import importlib.resources
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from contextlib import contextmanager
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any
|
|
16
18
|
|
|
@@ -18,6 +20,7 @@ import yaml
|
|
|
18
20
|
from pydantic import BaseModel
|
|
19
21
|
|
|
20
22
|
from kd.exceptions import ConfigError, ValidationError
|
|
23
|
+
from kd.locking import file_transaction
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
# ---------------------------------------------------------------------------
|
|
@@ -125,10 +128,15 @@ def load_primer(path: Path | None = None) -> PrimerData:
|
|
|
125
128
|
|
|
126
129
|
|
|
127
130
|
def save_primer(data: PrimerData, path: Path | None = None) -> None:
|
|
128
|
-
"""Write primer to YAML.
|
|
131
|
+
"""Write primer to YAML — low-level primitive.
|
|
129
132
|
|
|
130
133
|
Snapshots the current file before writing (copy-on-write) and uses
|
|
131
|
-
atomic writes to prevent partial-write corruption.
|
|
134
|
+
atomic writes to prevent partial-write corruption. Does **not**
|
|
135
|
+
serialize concurrent writers on its own; callers that do
|
|
136
|
+
``load_primer → mutate → save_primer`` across processes risk lost
|
|
137
|
+
updates. Prefer the mutator functions below (:func:`add_note`,
|
|
138
|
+
:func:`update_note`, etc.), which wrap load/save in
|
|
139
|
+
:func:`primer_transaction` to guarantee per-file serialization.
|
|
132
140
|
"""
|
|
133
141
|
from kd.history import atomic_write_text, snapshot_before_write
|
|
134
142
|
|
|
@@ -172,46 +180,56 @@ def _tag_dump(tag: dict[str, Any]) -> dict[str, Any]:
|
|
|
172
180
|
# ---------------------------------------------------------------------------
|
|
173
181
|
|
|
174
182
|
|
|
183
|
+
@contextmanager
|
|
184
|
+
def _primer_transaction(path: Path) -> Generator[PrimerData, None, None]:
|
|
185
|
+
"""Serialize a primer read-modify-write cycle under an advisory lock.
|
|
186
|
+
|
|
187
|
+
Holds an exclusive advisory lock on ``<path>.lock`` for the full
|
|
188
|
+
cycle: loads the primer, yields a ``PrimerData`` for the caller to
|
|
189
|
+
mutate in place, and saves on clean exit. If the caller raises,
|
|
190
|
+
the save is skipped (the file is not modified).
|
|
191
|
+
"""
|
|
192
|
+
with file_transaction(path):
|
|
193
|
+
data = load_primer(path)
|
|
194
|
+
yield data
|
|
195
|
+
save_primer(data, path)
|
|
196
|
+
|
|
197
|
+
|
|
175
198
|
def add_note(text: str, path: Path | None = None) -> int:
|
|
176
199
|
"""Append a note. Returns the 1-based index."""
|
|
177
200
|
primer_file = path or _primer_path()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return len(data.notes)
|
|
201
|
+
with _primer_transaction(primer_file) as data:
|
|
202
|
+
data.notes.append(text)
|
|
203
|
+
return len(data.notes)
|
|
182
204
|
|
|
183
205
|
|
|
184
206
|
def remove_note(index: int, path: Path | None = None) -> str:
|
|
185
207
|
"""Remove a note by 1-based index. Returns the removed text."""
|
|
186
208
|
primer_file = path or _primer_path()
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
save_primer(data, primer_file)
|
|
192
|
-
return removed
|
|
209
|
+
with _primer_transaction(primer_file) as data:
|
|
210
|
+
if index < 1 or index > len(data.notes):
|
|
211
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
212
|
+
return data.notes.pop(index - 1)
|
|
193
213
|
|
|
194
214
|
|
|
195
215
|
def update_note(index: int, text: str, path: Path | None = None) -> str:
|
|
196
216
|
"""Replace a note by 1-based index. Returns the old text."""
|
|
197
217
|
primer_file = path or _primer_path()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return old
|
|
218
|
+
with _primer_transaction(primer_file) as data:
|
|
219
|
+
if index < 1 or index > len(data.notes):
|
|
220
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
221
|
+
old = data.notes[index - 1]
|
|
222
|
+
data.notes[index - 1] = text
|
|
223
|
+
return old
|
|
205
224
|
|
|
206
225
|
|
|
207
226
|
def clear_notes(path: Path | None = None) -> int:
|
|
208
227
|
"""Remove all notes. Returns the count removed."""
|
|
209
228
|
primer_file = path or _primer_path()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return count
|
|
229
|
+
with _primer_transaction(primer_file) as data:
|
|
230
|
+
count = len(data.notes)
|
|
231
|
+
data.notes.clear()
|
|
232
|
+
return count
|
|
215
233
|
|
|
216
234
|
|
|
217
235
|
def add_article(
|
|
@@ -219,71 +237,63 @@ def add_article(
|
|
|
219
237
|
) -> PrimerArticle:
|
|
220
238
|
"""Pin an article. Raises if already pinned."""
|
|
221
239
|
primer_file = path or _primer_path()
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return article
|
|
240
|
+
with _primer_transaction(primer_file) as data:
|
|
241
|
+
for existing in data.articles:
|
|
242
|
+
if existing.id == article_id:
|
|
243
|
+
raise ValidationError(f"Article '{article_id}' is already pinned")
|
|
244
|
+
article = PrimerArticle(id=article_id, summary=summary)
|
|
245
|
+
data.articles.append(article)
|
|
246
|
+
return article
|
|
230
247
|
|
|
231
248
|
|
|
232
249
|
def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
|
|
233
250
|
"""Unpin an article by ID. Returns the removed entry."""
|
|
234
251
|
primer_file = path or _primer_path()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return removed
|
|
241
|
-
raise ValidationError(f"Article '{article_id}' is not pinned")
|
|
252
|
+
with _primer_transaction(primer_file) as data:
|
|
253
|
+
for i, article in enumerate(data.articles):
|
|
254
|
+
if article.id == article_id:
|
|
255
|
+
return data.articles.pop(i)
|
|
256
|
+
raise ValidationError(f"Article '{article_id}' is not pinned")
|
|
242
257
|
|
|
243
258
|
|
|
244
259
|
def clear_articles(path: Path | None = None) -> int:
|
|
245
260
|
"""Remove all pinned articles. Returns the count removed."""
|
|
246
261
|
primer_file = path or _primer_path()
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return count
|
|
262
|
+
with _primer_transaction(primer_file) as data:
|
|
263
|
+
count = len(data.articles)
|
|
264
|
+
data.articles.clear()
|
|
265
|
+
return count
|
|
252
266
|
|
|
253
267
|
|
|
254
268
|
def add_tag(name: str, summary: str | None = None, path: Path | None = None) -> PrimerTag:
|
|
255
269
|
"""Pin a tag. Raises if already pinned."""
|
|
256
270
|
primer_file = path or _primer_path()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return tag
|
|
271
|
+
with _primer_transaction(primer_file) as data:
|
|
272
|
+
for existing in data.tags:
|
|
273
|
+
if existing.name == name:
|
|
274
|
+
raise ValidationError(f"Tag '{name}' is already pinned")
|
|
275
|
+
tag = PrimerTag(name=name, summary=summary)
|
|
276
|
+
data.tags.append(tag)
|
|
277
|
+
return tag
|
|
265
278
|
|
|
266
279
|
|
|
267
280
|
def remove_tag(name: str, path: Path | None = None) -> PrimerTag:
|
|
268
281
|
"""Unpin a tag by name. Returns the removed entry."""
|
|
269
282
|
primer_file = path or _primer_path()
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return removed
|
|
276
|
-
raise ValidationError(f"Tag '{name}' is not pinned")
|
|
283
|
+
with _primer_transaction(primer_file) as data:
|
|
284
|
+
for i, tag in enumerate(data.tags):
|
|
285
|
+
if tag.name == name:
|
|
286
|
+
return data.tags.pop(i)
|
|
287
|
+
raise ValidationError(f"Tag '{name}' is not pinned")
|
|
277
288
|
|
|
278
289
|
|
|
279
290
|
def clear_tags(path: Path | None = None) -> int:
|
|
280
291
|
"""Remove all pinned tags. Returns the count removed."""
|
|
281
292
|
primer_file = path or _primer_path()
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return count
|
|
293
|
+
with _primer_transaction(primer_file) as data:
|
|
294
|
+
count = len(data.tags)
|
|
295
|
+
data.tags.clear()
|
|
296
|
+
return count
|
|
287
297
|
|
|
288
298
|
|
|
289
299
|
# ---------------------------------------------------------------------------
|
|
@@ -4,6 +4,26 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.5.0] — 2026-04-19
|
|
8
|
+
|
|
9
|
+
Dogfooding friction fixes across tag and article workflows, plus self-describing `/system info` output for bug reports. Focus of this release: the taxonomy and article-create loops now behave the way field use expects, and a paste of `kd /system info -o yaml` replaces hand-typed version footers.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `kd /system info` now carries three additional fields alongside the existing connection identity: `kd_version` (matches `kd --version`), `python_version`, and `platform`. Paste `kd /system info -o yaml` into a bug report to tie an observed behavior to a specific build without hand-typing the version. The four existing keys (`url`, `user`, `name`, `email`) are unchanged; redact `url` / `email` before sharing externally (KDCLI-104).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- `kd /tag create TAG_NAME` is now idempotent and returns a `{ok, action, tag}` envelope with `action` being either `create` or `exists`. Re-running create on an already-visible tag is safe and no longer surfaces `Tag.name-is-invalid`. **Breaking vs. the prior flat tag object** — downstream scripts and SDK consumers that parsed the top-level tag fields must now read from the nested `tag:` block. Python SDK: the `create(...)` method on the tag operations client returns a `TagCreateResult(action, tag)` object; unpack `.tag` to reach the tag fields (KDCLI-105).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `kd /tag create TAG_NAME` against an already-existing tag no longer emits the raw upstream `Tag.name-is-invalid` error. When the POST still fails (genuinely unacceptable name, or a tag hidden by sharing/visibility that the caller cannot see), the error names both plausible causes without inventing a character grammar (KDCLI-105).
|
|
19
|
+
- `kd /tag entities` structured output (`-o yaml` / `-o json`, including `--primer-scope`) now populates the `tags` field on issue rows, bringing the verb to parity with `/issue show --rich` and `/issue list --with-tags`. Before: structured issue rows reported `tags: []` even when they matched via a pinned tag, forcing a second `/issue show` round-trip to see which tag caused the match. Table output is unchanged and still omits the tag column by design (KDCLI-100).
|
|
20
|
+
- `kd /article create --tag TAG` structured output now returns the post-tag article state: the response `tags` field matches a subsequent `kd /article show` on the returned id. Missing tags are rejected **before** the article is created, so a typo in `--tag` no longer leaves an untagged article behind; the error names `kd /tag create` for the missing tag, retrying `kd /article create` without the tag, or applying the tag after create via `kd /tag add ARTICLE-ID TAG`. Before: the CLI rendered `tags: ''` even when tags were attached upstream (KDCLI-101).
|
|
21
|
+
|
|
22
|
+
## [2.4.1] — 2026-04-19
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Concurrent `kd /primer/note`, `/primer/tag`, and `/primer/article` commands against the same `.kd/primer.yaml` no longer silently lose writes. Before: running three `kd /primer/note update` calls in parallel (common in agent-driven sessions) all reported `ok: true` but only one update survived. Now: each command runs inside an advisory file lock on a sidecar `.kd/primer.yaml.lock`, so concurrent kd processes queue rather than clobber each other and every successful call is reflected in the final file. Concurrent acquirers wait up to 30 s before erroring with `Another kd process is writing to …; retry in a moment`; the error includes the path of the sidecar lockfile for stale-holder recovery. POSIX only; first lock attempt on Windows raises a clear error pointing at sequential invocation (KDCLI-98).
|
|
26
|
+
|
|
7
27
|
## [2.4.0] — 2026-04-19
|
|
8
28
|
|
|
9
29
|
### Added
|
|
@@ -70,6 +70,17 @@
|
|
|
70
70
|
message: 'Issue {id} not found'
|
|
71
71
|
suggestion: 'Check the issue ID and project key.'
|
|
72
72
|
|
|
73
|
+
# --- Tag create 400 (path-scoped so rename POSTs are unaffected) ---
|
|
74
|
+
|
|
75
|
+
- status: 400
|
|
76
|
+
path: '/tags$'
|
|
77
|
+
pattern: 'Tag\.name-is-invalid'
|
|
78
|
+
message: 'YouTrack rejected the tag name.'
|
|
79
|
+
suggestion: >-
|
|
80
|
+
The tag may already exist but be hidden by sharing or visibility
|
|
81
|
+
settings, or the name may contain characters YouTrack does not accept.
|
|
82
|
+
Verify with 'kd /tag show TAG_NAME' or try a different name.
|
|
83
|
+
|
|
73
84
|
# --- Query parse errors ---
|
|
74
85
|
|
|
75
86
|
- pattern: "(?i)can.t parse search query"
|
|
@@ -146,6 +146,8 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
|
|
|
146
146
|
|
|
147
147
|
Keep the primer local and curated. If a note becomes generally true for kd, move it into docs or this guide. If it is workspace-specific, keep it in `.kd/primer.yaml`.
|
|
148
148
|
|
|
149
|
+
Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates.
|
|
150
|
+
|
|
149
151
|
## Articles And Push
|
|
150
152
|
|
|
151
153
|
Use `/article show` for metadata plus rendered content. Use `/article pull` when you need raw article content on stdout; `pull` is format-agnostic and ignores `--output`.
|
|
@@ -156,6 +158,8 @@ Do not use push for one-off web edits or broad tree migration. It is single-file
|
|
|
156
158
|
|
|
157
159
|
When pushing with `--tag`, kd validates all tags before the article write. Missing tags abort with commands to list the namespace, create the tag, retry without it, or apply the tag separately after push. `--create-tags` is the explicit opt-in for automation that should create missing tags first.
|
|
158
160
|
|
|
161
|
+
`/article create --tag` applies the same preflight: every tag must exist, missing tags abort before the article write (no orphan article on a typo), and the structured response reflects post-tag state so it matches an immediate `/article show` on the returned id. There is no `--create-tags` on `/article create` — curate the namespace with `/tag create` first, or omit `--tag` and apply tags after creation via `/tag add ARTICLE-ID TAG`.
|
|
162
|
+
|
|
159
163
|
## Tags And Discovery
|
|
160
164
|
|
|
161
165
|
Tags are the main YouTrack-native cross-entity discovery primitive. A namespaced taxonomy such as `kind:*`, `component:*`, and `outcome:*` gives both humans and LLMs a stable way to move across issues and articles.
|
|
@@ -180,6 +184,10 @@ Use `/tag entities` when the question is "what do we know about this topic acros
|
|
|
180
184
|
|
|
181
185
|
Do not treat `--primer-scope` as implicit global state. It is a deliberate flag for content-management triage and workspace orientation. When you need a precise topical intersection, pass the tags explicitly.
|
|
182
186
|
|
|
187
|
+
`/tag entities` structured output (`-o yaml` / `-o json`) populates the `tags` field on both issue and article rows with the entity's visible tags, so a primer-scope or explicit-tag match is self-describing — no second `/issue show` round-trip is needed to confirm which tags caused a row to surface. Table output groups by kind and does not render a tag column.
|
|
188
|
+
|
|
189
|
+
`/tag create TAG_NAME` is idempotent. It resolves the name first (same case-insensitive resolver `/tag show` uses) and returns `{ok, action, tag}` with `action: exists` when the tag is already visible, or `action: create` after a successful POST. Taxonomy bootstrapping and fixture setup can re-run the command safely without decoding `Tag.name-is-invalid`. When the POST still fails — either a genuinely unacceptable name or an existing tag hidden by sharing/visibility — kd surfaces a factual diagnostic naming both plausible causes rather than the raw upstream error.
|
|
190
|
+
|
|
183
191
|
## Output And Debugging
|
|
184
192
|
|
|
185
193
|
For deterministic machine consumption, always pass an explicit output format:
|
|
@@ -209,6 +217,8 @@ kd /system/config which primer_show.output
|
|
|
209
217
|
|
|
210
218
|
If a command exits 0 but includes `warnings` in JSON or YAML, keep the primary result and inspect the warning code. For example, `tag_not_found` on `/primer/tag add` means the local primer changed but the upstream tag did not resolve at validation time.
|
|
211
219
|
|
|
220
|
+
When filing a kd bug, include the output of `kd /system info -o yaml`. It names the active connection alongside `kd_version`, `python_version`, and `platform`, so the report is self-describing without hand-typing the version. Redact `url` / `email` before sharing externally.
|
|
221
|
+
|
|
212
222
|
## Where To Go Next
|
|
213
223
|
|
|
214
224
|
Use these sources together:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|