kd-cli 2.6.3__tar.gz → 2.6.5__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.6.3/kd_cli.egg-info → kd_cli-2.6.5}/PKG-INFO +1 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/youtrack.py +5 -3
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/input.py +23 -3
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/main.py +14 -7
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/system/__init__.py +6 -6
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/client.py +74 -2
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config.py +7 -7
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config_read.py +1 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/exceptions.py +13 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/primer.py +1 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/CHANGELOG.md +53 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/operating-guide.md +3 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5}/pyproject.toml +1 -1
- {kd_cli-2.6.3 → kd_cli-2.6.5}/LICENSE +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/MANIFEST.in +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/README.md +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/articles.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/http.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/hub.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/api/tags.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/article_marker.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/auth.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cache.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/output.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/cli/tree.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/config_schema.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/connection.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/debug.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/history.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/locking.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/article.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/assist.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/attachment.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/comment.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/common.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/issue.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/project.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/saved_query.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/search.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/tag.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/user.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/models/work_item.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/operating_guide.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/query_parser.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/release_notes.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd/sdlc.py +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.6.3 → kd_cli-2.6.5}/setup.cfg +0 -0
|
@@ -20,7 +20,7 @@ import re
|
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
22
|
from kd.api.http import HttpClient
|
|
23
|
-
from kd.exceptions import KdError
|
|
23
|
+
from kd.exceptions import FieldDiscoveryError, KdError
|
|
24
24
|
from kd.models import (
|
|
25
25
|
AssistResponse,
|
|
26
26
|
Attachment,
|
|
@@ -257,13 +257,15 @@ class YouTrackAPI:
|
|
|
257
257
|
admin_fields: list[dict[str, Any]] = admin.get("customFields") or []
|
|
258
258
|
if admin_fields:
|
|
259
259
|
return admin_fields
|
|
260
|
-
raise
|
|
260
|
+
raise FieldDiscoveryError(
|
|
261
261
|
f"No custom fields discovered for project '{project_id}'.",
|
|
262
262
|
suggestion=(
|
|
263
263
|
"Either the project has no issues to sample from, or the "
|
|
264
264
|
"active token lacks Read Project Full on this project. "
|
|
265
265
|
"Create one issue (any contributor token works), or use a "
|
|
266
|
-
"token with project-administration rights.
|
|
266
|
+
"token with project-administration rights. Or omit "
|
|
267
|
+
"--type / --priority / --state to create a minimal first "
|
|
268
|
+
"issue, then update it via `/issue update`. See KDCLI-A-22."
|
|
267
269
|
),
|
|
268
270
|
)
|
|
269
271
|
|
|
@@ -25,9 +25,23 @@ def resolve_text_input(value: str | None) -> str | None:
|
|
|
25
25
|
if not value:
|
|
26
26
|
return value
|
|
27
27
|
if value == "-":
|
|
28
|
-
|
|
28
|
+
try:
|
|
29
|
+
return sys.stdin.read()
|
|
30
|
+
except UnicodeDecodeError as e:
|
|
31
|
+
raise ValidationError(
|
|
32
|
+
f"Stdin input could not be decoded as UTF-8: {e.reason}",
|
|
33
|
+
suggestion="Ensure the upstream shell pipes UTF-8 bytes "
|
|
34
|
+
"(heredoc, pipe, or redirected file).",
|
|
35
|
+
) from e
|
|
29
36
|
if value == "@-":
|
|
30
|
-
|
|
37
|
+
try:
|
|
38
|
+
text = sys.stdin.read()
|
|
39
|
+
except UnicodeDecodeError as e:
|
|
40
|
+
raise ValidationError(
|
|
41
|
+
f"Stdin input could not be decoded as UTF-8: {e.reason}",
|
|
42
|
+
suggestion="Ensure the upstream shell pipes UTF-8 bytes "
|
|
43
|
+
"(heredoc, pipe, or redirected file).",
|
|
44
|
+
) from e
|
|
31
45
|
if not text:
|
|
32
46
|
raise ValidationError("No input received from stdin")
|
|
33
47
|
return text
|
|
@@ -35,7 +49,13 @@ def resolve_text_input(value: str | None) -> str | None:
|
|
|
35
49
|
file_path = Path(value[1:])
|
|
36
50
|
if not file_path.is_file():
|
|
37
51
|
raise ValidationError(f"File not found: {file_path}")
|
|
38
|
-
|
|
52
|
+
try:
|
|
53
|
+
text = file_path.read_text(encoding="utf-8")
|
|
54
|
+
except UnicodeDecodeError as e:
|
|
55
|
+
raise ValidationError(
|
|
56
|
+
f"File {file_path} could not be decoded as UTF-8: {e.reason}",
|
|
57
|
+
suggestion="Save the file as UTF-8 (without a BOM) and retry.",
|
|
58
|
+
) from e
|
|
39
59
|
if not text:
|
|
40
60
|
raise ValidationError(f"File is empty: {file_path}")
|
|
41
61
|
return text
|
|
@@ -142,17 +142,24 @@ def _print_error(error: KdError) -> None:
|
|
|
142
142
|
|
|
143
143
|
|
|
144
144
|
def _configure_windows_stdio() -> None:
|
|
145
|
-
"""Force UTF-8 on stdout/stderr when running on Windows.
|
|
146
|
-
|
|
147
|
-
No-op on POSIX.
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
"""Force UTF-8 on stdin/stdout/stderr when running on Windows.
|
|
146
|
+
|
|
147
|
+
No-op on POSIX. Stdout/stderr use ``errors="replace"`` so a single
|
|
148
|
+
corrupted byte never aborts a whole command's output. Stdin uses
|
|
149
|
+
``errors="strict"`` so invalid bytes raise loudly rather than silently
|
|
150
|
+
writing replacement chars to disk via the input layer (see
|
|
151
|
+
``kd/cli/input.py``). Errors during reconfigure are swallowed; embedded
|
|
152
|
+
interpreters and pytest captures may lack ``reconfigure`` on any of the
|
|
153
|
+
three streams.
|
|
150
154
|
"""
|
|
151
155
|
if sys.platform != "win32":
|
|
152
156
|
return
|
|
153
|
-
for stream in (sys.stdout, sys.stderr):
|
|
157
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
154
158
|
try:
|
|
155
|
-
stream.reconfigure(
|
|
159
|
+
stream.reconfigure( # type: ignore[union-attr,unused-ignore]
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
errors="strict" if stream is sys.stdin else "replace",
|
|
162
|
+
)
|
|
156
163
|
except Exception:
|
|
157
164
|
pass
|
|
158
165
|
|
|
@@ -709,7 +709,7 @@ def _resolve_target(name: str) -> tuple[Path, Path, str]:
|
|
|
709
709
|
|
|
710
710
|
def _diff_summary(snapshot: SnapshotInfo, current_text: str) -> str:
|
|
711
711
|
"""One-line diff summary: ``+N -M`` lines vs current file."""
|
|
712
|
-
snap_lines = snapshot.path.read_text().splitlines()
|
|
712
|
+
snap_lines = snapshot.path.read_text(encoding="utf-8").splitlines()
|
|
713
713
|
curr_lines = current_text.splitlines()
|
|
714
714
|
added = removed = 0
|
|
715
715
|
for line in difflib.unified_diff(snap_lines, curr_lines, n=0):
|
|
@@ -751,7 +751,7 @@ class SystemHistoryCommand(Command):
|
|
|
751
751
|
rows: list[dict[str, Any]] = []
|
|
752
752
|
for target_name in targets:
|
|
753
753
|
live_path, history_dir, filename = _resolve_target(target_name)
|
|
754
|
-
current_text = live_path.read_text() if live_path.exists() else ""
|
|
754
|
+
current_text = live_path.read_text(encoding="utf-8") if live_path.exists() else ""
|
|
755
755
|
for snap in list_snapshots(history_dir, filename):
|
|
756
756
|
rows.append(
|
|
757
757
|
{
|
|
@@ -791,7 +791,7 @@ class SystemRestoreCommand(Command):
|
|
|
791
791
|
snap_path = get_snapshot_path(history_dir, filename, timestamp)
|
|
792
792
|
# Snapshot current before restoring (so restore is undoable)
|
|
793
793
|
snapshot_before_write(live_path)
|
|
794
|
-
restored_text = snap_path.read_text()
|
|
794
|
+
restored_text = snap_path.read_text(encoding="utf-8")
|
|
795
795
|
atomic_write_text(live_path, restored_text)
|
|
796
796
|
return {
|
|
797
797
|
"ok": True,
|
|
@@ -830,15 +830,15 @@ class SystemDiffCommand(Command):
|
|
|
830
830
|
live_path, history_dir, filename = _resolve_target(target_name)
|
|
831
831
|
|
|
832
832
|
snap_a = get_snapshot_path(history_dir, filename, args[1])
|
|
833
|
-
text_a = snap_a.read_text()
|
|
833
|
+
text_a = snap_a.read_text(encoding="utf-8")
|
|
834
834
|
label_a = f"{filename}.{args[1]}"
|
|
835
835
|
|
|
836
836
|
if len(args) >= 3:
|
|
837
837
|
snap_b = get_snapshot_path(history_dir, filename, args[2])
|
|
838
|
-
text_b = snap_b.read_text()
|
|
838
|
+
text_b = snap_b.read_text(encoding="utf-8")
|
|
839
839
|
label_b = f"{filename}.{args[2]}"
|
|
840
840
|
else:
|
|
841
|
-
text_b = live_path.read_text() if live_path.exists() else ""
|
|
841
|
+
text_b = live_path.read_text(encoding="utf-8") if live_path.exists() else ""
|
|
842
842
|
label_b = filename
|
|
843
843
|
|
|
844
844
|
result = diff_text(text_a, text_b, label_a, label_b)
|
|
@@ -45,7 +45,14 @@ from kd.article_marker import (
|
|
|
45
45
|
from kd.config import ResolvedConfig
|
|
46
46
|
from kd.connection import ConnectionContext
|
|
47
47
|
from kd.debug import DebugLog
|
|
48
|
-
from kd.exceptions import
|
|
48
|
+
from kd.exceptions import (
|
|
49
|
+
APIError,
|
|
50
|
+
ConfigError,
|
|
51
|
+
FieldDiscoveryError,
|
|
52
|
+
KdError,
|
|
53
|
+
NotFoundError,
|
|
54
|
+
ValidationError,
|
|
55
|
+
)
|
|
49
56
|
from kd.query_parser import youtrack_query_literal
|
|
50
57
|
from kd.models import (
|
|
51
58
|
Article,
|
|
@@ -178,6 +185,19 @@ _SLOT_DEFAULTS: dict[str, str] = {
|
|
|
178
185
|
}
|
|
179
186
|
|
|
180
187
|
|
|
188
|
+
# Bootstrap synthesis dispatch: semantic slot -> project-side field
|
|
189
|
+
# ``$type`` used to look up the issue-side ``$type`` and payload builder
|
|
190
|
+
# in :data:`_FIELD_DISPATCH` when schema discovery returned no fields
|
|
191
|
+
# (KDCLI-171). All three semantic slots are wired: HITL #2 confirmed
|
|
192
|
+
# YouTrack accepts a synthetic ``StateIssueCustomField`` payload with a
|
|
193
|
+
# ``name``-only value reference on an empty fresh project.
|
|
194
|
+
_BOOTSTRAP_SLOT_TO_PROJECT_TYPE: dict[str, str] = {
|
|
195
|
+
"type": "EnumProjectCustomField",
|
|
196
|
+
"priority": "EnumProjectCustomField",
|
|
197
|
+
"state": "StateProjectCustomField",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
181
201
|
# ---------------------------------------------------------------------------
|
|
182
202
|
# Article push — types and helpers
|
|
183
203
|
# ---------------------------------------------------------------------------
|
|
@@ -806,9 +826,61 @@ class IssueOperations:
|
|
|
806
826
|
``project`` is the project shortName (KEY).
|
|
807
827
|
``slots`` maps semantic names to values, e.g. ``{"type": "Bug"}``.
|
|
808
828
|
``aliases`` maps semantic names to actual field names from config.
|
|
829
|
+
|
|
830
|
+
On a fresh (zero-issue + non-admin) project, schema discovery
|
|
831
|
+
returns no fields and ``resolve_semantic_fields`` raises
|
|
832
|
+
:class:`FieldDiscoveryError`. For semantic-slot creates we
|
|
833
|
+
synthesize a best-effort payload using the post-discovery
|
|
834
|
+
``$type`` mapping and let YouTrack accept or reject (KDCLI-171).
|
|
835
|
+
Generic ``--field`` flows route through
|
|
836
|
+
:meth:`resolve_generic_fields` instead and stay uncaught — without
|
|
837
|
+
server-side ``$type`` knowledge, synthesizing a guess is unsafe.
|
|
809
838
|
"""
|
|
810
839
|
project_obj = self._yt.get_project(project)
|
|
811
|
-
|
|
840
|
+
try:
|
|
841
|
+
return self._field_schema.resolve_semantic_fields(project_obj.id, slots, aliases)
|
|
842
|
+
except FieldDiscoveryError as e:
|
|
843
|
+
return self._synthesize_for_empty_discovery(slots, aliases, original=e)
|
|
844
|
+
|
|
845
|
+
def _synthesize_for_empty_discovery(
|
|
846
|
+
self,
|
|
847
|
+
slots: dict[str, str],
|
|
848
|
+
aliases: dict[str, str] | None,
|
|
849
|
+
original: FieldDiscoveryError,
|
|
850
|
+
) -> builtins.list[CustomField]:
|
|
851
|
+
"""Synthesize CustomField payloads when schema discovery is empty.
|
|
852
|
+
|
|
853
|
+
Honors the alias layer (``state`` -> ``Stage`` for Scrum projects)
|
|
854
|
+
via ``aliases.get(slot, _SLOT_DEFAULTS[slot])`` — same shape as
|
|
855
|
+
:meth:`FieldSchema.resolve_semantic_fields`. Re-raises the
|
|
856
|
+
*original* :class:`FieldDiscoveryError` (full three-bullet hint
|
|
857
|
+
preserved verbatim) when any slot is not in
|
|
858
|
+
:data:`_BOOTSTRAP_SLOT_TO_PROJECT_TYPE` — partial-bootstrap is
|
|
859
|
+
not a valid posture; the operator either gets all slots they
|
|
860
|
+
asked for, or the unchanged improved hint pointing at all three
|
|
861
|
+
workarounds (sample issue, admin token, or omit-and-update).
|
|
862
|
+
"""
|
|
863
|
+
aliases = aliases or {}
|
|
864
|
+
unsupported = [s for s in slots if s not in _BOOTSTRAP_SLOT_TO_PROJECT_TYPE]
|
|
865
|
+
if unsupported:
|
|
866
|
+
raise original
|
|
867
|
+
|
|
868
|
+
result: builtins.list[CustomField] = []
|
|
869
|
+
for slot, value in slots.items():
|
|
870
|
+
project_field_type = _BOOTSTRAP_SLOT_TO_PROJECT_TYPE[slot]
|
|
871
|
+
issue_type, builder = _FIELD_DISPATCH[(project_field_type, "")]
|
|
872
|
+
field_name = aliases.get(slot, _SLOT_DEFAULTS.get(slot, slot))
|
|
873
|
+
self._debug.debug(
|
|
874
|
+
"fields",
|
|
875
|
+
f"synthesizing slot '{slot}' -> field '{field_name}' "
|
|
876
|
+
f"($type={issue_type}) on empty discovery",
|
|
877
|
+
)
|
|
878
|
+
result.append(
|
|
879
|
+
CustomField.model_validate(
|
|
880
|
+
{"name": field_name, "value": builder(value), "$type": issue_type}
|
|
881
|
+
)
|
|
882
|
+
)
|
|
883
|
+
return result
|
|
812
884
|
|
|
813
885
|
def resolve_generic_fields(
|
|
814
886
|
self,
|
|
@@ -406,7 +406,7 @@ def load_config(path: Path | None = None) -> YtctlConfig:
|
|
|
406
406
|
if not config_path.exists():
|
|
407
407
|
return YtctlConfig()
|
|
408
408
|
try:
|
|
409
|
-
raw = yaml.safe_load(config_path.read_text()) or {}
|
|
409
|
+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
410
410
|
return YtctlConfig(**raw)
|
|
411
411
|
except (yaml.YAMLError, TypeError, ValueError) as e:
|
|
412
412
|
raise ConfigError(f"Invalid config at {config_path}: {e}") from e
|
|
@@ -511,7 +511,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
|
|
|
511
511
|
# Load raw YAML, capture original text for rollback.
|
|
512
512
|
raw = _load_raw_yaml(config_path)
|
|
513
513
|
original_text: str | None
|
|
514
|
-
original_text = config_path.read_text() if config_path.exists() else None
|
|
514
|
+
original_text = config_path.read_text(encoding="utf-8") if config_path.exists() else None
|
|
515
515
|
|
|
516
516
|
retention = _retention_from_raw(raw, DEFAULT_RETENTION)
|
|
517
517
|
snapshot_before_write(config_path, retention=retention)
|
|
@@ -619,7 +619,7 @@ def _load_raw_yaml(path: Path) -> dict[str, Any]:
|
|
|
619
619
|
if not path.exists():
|
|
620
620
|
return {}
|
|
621
621
|
try:
|
|
622
|
-
data = yaml.safe_load(path.read_text())
|
|
622
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
623
623
|
except yaml.YAMLError as e:
|
|
624
624
|
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
|
|
625
625
|
return data or {}
|
|
@@ -1395,16 +1395,16 @@ def _manage_gitignore(directory: Path) -> str:
|
|
|
1395
1395
|
|
|
1396
1396
|
gitignore = git_root / ".gitignore"
|
|
1397
1397
|
if gitignore.exists():
|
|
1398
|
-
content = gitignore.read_text()
|
|
1398
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
1399
1399
|
lines = {line.strip() for line in content.splitlines()}
|
|
1400
1400
|
if lines & _GITIGNORE_PATTERNS:
|
|
1401
1401
|
return "already_ignored"
|
|
1402
1402
|
# Append with a trailing newline if file doesn't end with one
|
|
1403
1403
|
sep = "" if content.endswith("\n") else "\n"
|
|
1404
|
-
gitignore.write_text(f"{content}{sep}\n# kd local config\n.kd/\n")
|
|
1404
|
+
gitignore.write_text(f"{content}{sep}\n# kd local config\n.kd/\n", encoding="utf-8")
|
|
1405
1405
|
return "added"
|
|
1406
1406
|
|
|
1407
|
-
gitignore.write_text("# kd local config\n.kd/\n")
|
|
1407
|
+
gitignore.write_text("# kd local config\n.kd/\n", encoding="utf-8")
|
|
1408
1408
|
return "created"
|
|
1409
1409
|
|
|
1410
1410
|
|
|
@@ -1466,7 +1466,7 @@ def init_project_config(
|
|
|
1466
1466
|
)
|
|
1467
1467
|
|
|
1468
1468
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
1469
|
-
config_path.write_text(template)
|
|
1469
|
+
config_path.write_text(template, encoding="utf-8")
|
|
1470
1470
|
|
|
1471
1471
|
if global_tier:
|
|
1472
1472
|
gitignore_action = "not_applicable"
|
|
@@ -174,7 +174,7 @@ def _get_history(path: str, key: str) -> dict[str, Any] | None:
|
|
|
174
174
|
if value is None:
|
|
175
175
|
continue
|
|
176
176
|
# Only surface values actually written to the file, not schema defaults.
|
|
177
|
-
raw = yaml.safe_load(tier_path.read_text()) or {}
|
|
177
|
+
raw = yaml.safe_load(tier_path.read_text(encoding="utf-8")) or {}
|
|
178
178
|
history_block = raw.get("history") if isinstance(raw.get("history"), dict) else {}
|
|
179
179
|
if not isinstance(history_block, dict) or key not in history_block:
|
|
180
180
|
continue
|
|
@@ -95,6 +95,19 @@ class ValidationError(KdError):
|
|
|
95
95
|
"""Invalid input before API call."""
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
class FieldDiscoveryError(KdError):
|
|
99
|
+
"""Schema discovery returned no fields for a project.
|
|
100
|
+
|
|
101
|
+
Raised by ``list_project_custom_fields`` when both the per-issue
|
|
102
|
+
projection and the admin projection produce empty results — the
|
|
103
|
+
'fresh project + non-admin token' bootstrap shape. Caught at the
|
|
104
|
+
SDK layer (``IssueOperations.resolve_custom_fields``) so semantic
|
|
105
|
+
create-with-slots flows can synthesize a best-effort payload;
|
|
106
|
+
generic ``--field`` flows let it propagate so the operator sees
|
|
107
|
+
the improved hint.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
98
111
|
class QueryLiteralError(KdError):
|
|
99
112
|
"""Name cannot be encoded as a YouTrack DSL value literal.
|
|
100
113
|
|
|
@@ -121,7 +121,7 @@ def load_primer(path: Path | None = None) -> PrimerData:
|
|
|
121
121
|
if not primer_file.exists():
|
|
122
122
|
return PrimerData()
|
|
123
123
|
try:
|
|
124
|
-
raw = yaml.safe_load(primer_file.read_text()) or {}
|
|
124
|
+
raw = yaml.safe_load(primer_file.read_text(encoding="utf-8")) or {}
|
|
125
125
|
return PrimerData(**raw)
|
|
126
126
|
except (yaml.YAMLError, TypeError, ValueError) as e:
|
|
127
127
|
raise ConfigError(f"Invalid primer at {primer_file}: {e}") from e
|
|
@@ -4,6 +4,59 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.6.5] — 2026-04-30
|
|
8
|
+
|
|
9
|
+
This release closes M011 by adding empty-project bootstrap support to
|
|
10
|
+
`/issue create`. Together with M011 S1 + S2 (the Windows UTF-8 audit in
|
|
11
|
+
2.6.4), the milestone delivers a kd-only path from a fresh primer to
|
|
12
|
+
the first ticket on a brand-new project on Windows: file IO, stdin,
|
|
13
|
+
and field-resolution all work without falling back to the web UI.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `/issue create` semantic bootstrap now succeeds on fresh
|
|
17
|
+
(zero-issue, contributor-token) projects: when both schema-discovery
|
|
18
|
+
paths return empty, kd synthesizes a best-effort payload for
|
|
19
|
+
`--type`, `--priority`, and `--state` using the post-discovery
|
|
20
|
+
`$type` mapping and lets YouTrack accept or reject. Pre-fix, kd
|
|
21
|
+
bailed before the API call with `Error: No custom fields discovered
|
|
22
|
+
for project '<id>'`, telling the operator to fall back to the web UI
|
|
23
|
+
for the very first issue — a kd-only bootstrap workflow could not
|
|
24
|
+
populate Type / Priority / State on a brand-new project.
|
|
25
|
+
`--field NAME=VALUE` still raises with the improved hint (kd cannot
|
|
26
|
+
guess server-side `$type` for arbitrary fields). The hint gains a
|
|
27
|
+
third bullet pointing at the simplest workaround: `Or omit --type /
|
|
28
|
+
--priority / --state to create a minimal first issue, then update it
|
|
29
|
+
via `/issue update``. See KDCLI-A-22 for the empty-project bootstrap
|
|
30
|
+
detail (KDCLI-171).
|
|
31
|
+
|
|
32
|
+
## [2.6.4] — 2026-04-30
|
|
33
|
+
|
|
34
|
+
This release closes the Windows UTF-8 audit started in 2.6.3. The
|
|
35
|
+
stream-encoding side (stdout/stderr) shipped earlier; 2.6.4 closes the
|
|
36
|
+
file-IO and stdin sides so primer notes, `--content @file`, and heredoc
|
|
37
|
+
or piped input round-trip non-cp1252 characters cleanly.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- File reads and writes on Windows are now explicit UTF-8 at every
|
|
41
|
+
kd-owned text-IO call site (primer, config, history/diff snapshots,
|
|
42
|
+
`--description @file` / `--content @file` content). The headline
|
|
43
|
+
reproducer was `kd /primer show` raising `'charmap' codec can't
|
|
44
|
+
decode byte 0x9d` whenever `.kd/primer.yaml` contained any non-cp1252
|
|
45
|
+
byte (em-dash, arrow, right double quotation mark) — a single primer
|
|
46
|
+
note authored on macOS/Linux was enough to read-block every
|
|
47
|
+
downstream Windows session (KDCLI-170).
|
|
48
|
+
- Stdin on Windows is now forced to UTF-8 with strict error handling,
|
|
49
|
+
so heredocs and pipes carrying non-ASCII content (em-dash, arrow,
|
|
50
|
+
right double quotation mark) into `kd /primer/note update -`,
|
|
51
|
+
`kd /article create --content @-`, `--description -`, or any other
|
|
52
|
+
stdin-consuming command no longer get cp1252-mojibaked at the input
|
|
53
|
+
layer before being persisted. Invalid UTF-8 bytes on stdin (and
|
|
54
|
+
non-UTF-8 `@file` content) now surface as a clean
|
|
55
|
+
`Error: Stdin input could not be decoded as UTF-8 ... / Hint: Ensure
|
|
56
|
+
the upstream shell pipes UTF-8 bytes ...` (or
|
|
57
|
+
`Hint: Save the file as UTF-8 ...`) instead of the generic "internal
|
|
58
|
+
error: ... This is a bug." block (KDCLI-169).
|
|
59
|
+
|
|
7
60
|
## [2.6.3] — 2026-04-30
|
|
8
61
|
|
|
9
62
|
This release focuses on Windows shakedown: encoding crashes, primer
|
|
@@ -126,6 +126,8 @@ Clear optional custom fields with an empty value, for example `kd /issue update
|
|
|
126
126
|
|
|
127
127
|
`--field` supports bundle families (enum, state, owned, version, build), user fields, period, and string `SimpleProjectCustomField` subtypes; whitespace inside the value is preserved. Unsupported families (text, `Multi*` enum/user variants, and `SimpleProjectCustomField` with `integer` / `float` / `date` subtypes) fail client-side with a message naming both the project `$type` and the `fieldType.id` subtype so a follow-on bug report can skip the probe.
|
|
128
128
|
|
|
129
|
+
On fresh (zero-issue, contributor-token) projects where schema discovery returns no fields, kd synthesizes a best-effort bootstrap payload for `--type`, `--priority`, and `--state` and lets YouTrack accept or reject; YouTrack's rejection text surfaces unchanged so an unusual project setup is auditable upstream. `--field NAME=VALUE` still requires a sample issue or admin token because kd lacks server-side `$type` knowledge to synthesize generic fields safely. KDCLI-A-22 carries the detail.
|
|
130
|
+
|
|
129
131
|
Attachment metadata download URLs may start with `/api/`. kd handles this internally on download commands; do not assume the raw URL can be appended unchanged to an API base URL in custom scripts.
|
|
130
132
|
|
|
131
133
|
Tag existence, tag visibility, and tag sharing rights are different concerns. A visible tag may not be usable for every operation, and sharing settings are provider-specific security posture, not just display metadata.
|
|
@@ -244,7 +246,7 @@ kd -DDD /issue list --project KDCLI
|
|
|
244
246
|
|
|
245
247
|
Debug output goes to stderr and tokens are redacted. Use `-DDD` when investigating upstream API behavior because it includes request and response details.
|
|
246
248
|
|
|
247
|
-
On Windows, kd forces UTF-8 stdout and stderr at the entry point so non-ASCII characters (`→`, `—`, `≤`, em-dash) render correctly regardless of the console's default code page (cp1252 on most installs). No `PYTHONIOENCODING` workaround is needed.
|
|
249
|
+
On Windows, kd forces UTF-8 on stdin, stdout, and stderr at the entry point so non-ASCII characters (`→`, `—`, `≤`, em-dash) render correctly regardless of the console's default code page (cp1252 on most installs), and every kd-owned file read/write (primer, config, history snapshots, `--content @file` and `--description @file`) uses explicit UTF-8 so primer/config files written by macOS or Linux load cleanly on Windows. Stdin uses strict decoding so invalid UTF-8 piped into `kd /primer/note update -` or `kd /article create --content @-` fails loudly with a clear diagnostic (`Error: Stdin input could not be decoded as UTF-8 ...`) rather than silently corrupting persisted content. No `PYTHONIOENCODING` or `PYTHONUTF8` workaround is needed.
|
|
248
250
|
|
|
249
251
|
Unhandled exceptions render a one-line or one-paragraph friendly diagnostic and exit non-zero. To see the full traceback (for bug reports or local debugging), re-run with `--debug` / `-D`; the wrapper re-raises the original exception so the runtime prints the traceback. `KdError` (expected user-facing failures) keep their existing one-line `Error: ...` / optional `Hint: ...` shape on every platform.
|
|
250
252
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|