kd-cli 2.4.1__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.
Files changed (82) hide show
  1. {kd_cli-2.4.1/kd_cli.egg-info → kd_cli-2.5.0}/PKG-INFO +1 -1
  2. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/tags.py +1 -0
  3. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/article/__init__.py +5 -1
  4. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/system/__init__.py +10 -2
  5. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/tag/__init__.py +16 -4
  6. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/client.py +82 -21
  7. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/__init__.py +2 -1
  8. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/tag.py +17 -0
  9. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/CHANGELOG.md +15 -0
  10. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/error-messages.yaml +11 -0
  11. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/operating-guide.md +8 -0
  12. {kd_cli-2.4.1 → kd_cli-2.5.0/kd_cli.egg-info}/PKG-INFO +1 -1
  13. {kd_cli-2.4.1 → kd_cli-2.5.0}/pyproject.toml +1 -1
  14. {kd_cli-2.4.1 → kd_cli-2.5.0}/LICENSE +0 -0
  15. {kd_cli-2.4.1 → kd_cli-2.5.0}/MANIFEST.in +0 -0
  16. {kd_cli-2.4.1 → kd_cli-2.5.0}/README.md +0 -0
  17. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/__init__.py +0 -0
  18. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/__init__.py +0 -0
  19. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/articles.py +0 -0
  20. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/http.py +0 -0
  21. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/hub.py +0 -0
  22. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/saved_queries.py +0 -0
  23. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/api/youtrack.py +0 -0
  24. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/article_marker.py +0 -0
  25. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/auth.py +0 -0
  26. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/__init__.py +0 -0
  27. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/__init__.py +0 -0
  28. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/command_base.py +0 -0
  29. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/command_parser.py +0 -0
  30. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/confirm.py +0 -0
  31. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/namespaces.py +0 -0
  32. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/core/option_registry.py +0 -0
  33. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/input.py +0 -0
  34. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/main.py +0 -0
  35. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/__init__.py +0 -0
  36. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  37. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/init.py +0 -0
  38. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/issue/__init__.py +0 -0
  39. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/primer/__init__.py +0 -0
  40. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/project/__init__.py +0 -0
  41. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/query/__init__.py +0 -0
  42. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/release_notes.py +0 -0
  43. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/search/__init__.py +0 -0
  44. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/time/__init__.py +0 -0
  45. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/namespaces/user/__init__.py +0 -0
  46. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/output.py +0 -0
  47. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/cli/tree.py +0 -0
  48. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/config.py +0 -0
  49. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/config_read.py +0 -0
  50. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/config_schema.py +0 -0
  51. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/connection.py +0 -0
  52. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/debug.py +0 -0
  53. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/exceptions.py +0 -0
  54. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/history.py +0 -0
  55. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/locking.py +0 -0
  56. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/article.py +0 -0
  57. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/assist.py +0 -0
  58. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/comment.py +0 -0
  60. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/common.py +0 -0
  61. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/issue.py +0 -0
  62. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/project.py +0 -0
  63. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/search.py +0 -0
  65. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/tag_entities.py +0 -0
  66. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/user.py +0 -0
  67. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/models/work_item.py +0 -0
  68. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/operating_guide.py +0 -0
  69. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/primer.py +0 -0
  70. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/query_parser.py +0 -0
  71. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/release_notes.py +0 -0
  72. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/__init__.py +0 -0
  73. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/command-synonyms.yaml +0 -0
  74. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/config-template.yaml +0 -0
  75. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/primers/__init__.py +0 -0
  76. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd/resources/primers/hitl.yaml +0 -0
  77. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd_cli.egg-info/SOURCES.txt +0 -0
  78. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  79. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd_cli.egg-info/entry_points.txt +0 -0
  80. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd_cli.egg-info/requires.txt +0 -0
  81. {kd_cli-2.4.1 → kd_cli-2.5.0}/kd_cli.egg-info/top_level.txt +0 -0
  82. {kd_cli-2.4.1 → kd_cli-2.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.4.1
3
+ Version: 2.5.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -38,6 +38,7 @@ _TAGGED_ISSUE_FIELDS = str(
38
38
  "summary",
39
39
  "updated",
40
40
  project=FieldSet("id", "name", "shortName"),
41
+ tags=FieldSet("id", "name"),
41
42
  )
42
43
  )
43
44
 
@@ -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. Uses the active connection "
55
- "(selected via -c or 'default_connection').",
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
- tag = client.tags.create(args[0])
173
- return _tag_to_detail(tag)
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(tag: str, error: NotFoundError) -> str:
1035
- """Format an actionable missing-tag instruction for article push."""
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
- lines.extend(
1042
- [
1043
- "",
1044
- f"To use an existing tag: kd /tag list --prefix {shlex.quote(prefix)}",
1045
- f"To create a new tag: kd /tag create {shlex.quote(tag)}",
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, each tag is applied after article creation.
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 mutating an article.
1222
+ """Resolve tags before ``/article create`` or ``/article push`` writes.
1187
1223
 
1188
- Returns actionable failure blocks. When ``create_missing`` is true,
1189
- missing tags are created before the article write; ``dry_run`` never
1190
- writes tags and treats missing tags as would-create.
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) -> Tag:
1635
- self._debug.info("api", f"creating tag '{name}'")
1636
- tag = self._tags.create_tag(name)
1637
- self._name_cache[name.lower()] = tag.id
1638
- self._debug.info("api", f"created tag '{name}' ({tag.id})")
1639
- return tag
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,
@@ -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
@@ -4,6 +4,21 @@ 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
+
7
22
  ## [2.4.1] — 2026-04-19
8
23
 
9
24
  ### Fixed
@@ -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"
@@ -158,6 +158,8 @@ Do not use push for one-off web edits or broad tree migration. It is single-file
158
158
 
159
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.
160
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
+
161
163
  ## Tags And Discovery
162
164
 
163
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.
@@ -182,6 +184,10 @@ Use `/tag entities` when the question is "what do we know about this topic acros
182
184
 
183
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.
184
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
+
185
191
  ## Output And Debugging
186
192
 
187
193
  For deterministic machine consumption, always pass an explicit output format:
@@ -211,6 +217,8 @@ kd /system/config which primer_show.output
211
217
 
212
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.
213
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
+
214
222
  ## Where To Go Next
215
223
 
216
224
  Use these sources together:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.4.1
3
+ Version: 2.5.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kd-cli"
7
- version = "2.4.1"
7
+ version = "2.5.0"
8
8
  description = "Knowledge Dispatcher for issue trackers and knowledge bases"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
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