kd-cli 2.5.0__tar.gz → 2.5.2__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.5.0/kd_cli.egg-info → kd_cli-2.5.2}/PKG-INFO +1 -1
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/article/__init__.py +26 -2
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/issue/__init__.py +21 -1
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/client.py +98 -38
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/CHANGELOG.md +16 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/operating-guide.md +4 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.5.0 → kd_cli-2.5.2}/pyproject.toml +1 -1
- {kd_cli-2.5.0 → kd_cli-2.5.2}/LICENSE +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/MANIFEST.in +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/README.md +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/articles.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/http.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/hub.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/tags.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/youtrack.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/article_marker.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/auth.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/input.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/main.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/output.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/tree.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config_read.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config_schema.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/connection.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/debug.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/exceptions.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/history.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/locking.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/article.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/assist.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/attachment.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/comment.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/common.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/issue.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/project.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/saved_query.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/search.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/tag.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/user.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/work_item.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/operating_guide.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/primer.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/query_parser.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/release_notes.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.5.0 → kd_cli-2.5.2}/setup.cfg +0 -0
|
@@ -181,7 +181,8 @@ class ArticlePushCommand(Command):
|
|
|
181
181
|
requires_client=True,
|
|
182
182
|
usage=(
|
|
183
183
|
"kd /article push FILE --project P --title T "
|
|
184
|
-
"[--slug S] [--parent ID] [--tag T] [--create-tags]
|
|
184
|
+
"[--slug S] [--parent ID] [--tag T] [--create-tags] "
|
|
185
|
+
"[--force] [--dry-run] [--no-marker-write]"
|
|
185
186
|
),
|
|
186
187
|
long_description=(
|
|
187
188
|
"Push a local markdown file to YouTrack as a knowledge base "
|
|
@@ -194,7 +195,13 @@ class ArticlePushCommand(Command):
|
|
|
194
195
|
"the marker — treat the file as owned by push after the first "
|
|
195
196
|
"run, like Cargo.lock. "
|
|
196
197
|
"Refuses to overwrite a remotely-edited article unless --force "
|
|
197
|
-
"is passed. --dry-run exits 1 on would-conflict, 0 on would-succeed."
|
|
198
|
+
"is passed. --dry-run exits 1 on would-conflict, 0 on would-succeed. "
|
|
199
|
+
"Pass --no-marker-write for one-way migrations where the local "
|
|
200
|
+
"file will be deleted after the push: the article is still "
|
|
201
|
+
"created/updated and tags are still applied, but the local file "
|
|
202
|
+
"is not rewritten. Re-running --no-marker-write pays the "
|
|
203
|
+
"scan-by-slug cost on every invocation since no upstream-id is "
|
|
204
|
+
"cached locally."
|
|
198
205
|
),
|
|
199
206
|
examples=[
|
|
200
207
|
'kd /article push docs/infra/dns-setup.md --project FB --title "DNS Setup Guide"',
|
|
@@ -204,6 +211,8 @@ class ArticlePushCommand(Command):
|
|
|
204
211
|
'kd /article push guide.md --project FB --title "Guide" --tag "type:guide" --create-tags',
|
|
205
212
|
'kd /article push guide.md --project FB --title "Guide" --dry-run',
|
|
206
213
|
'kd /article push guide.md --project FB --title "Guide" --force',
|
|
214
|
+
'kd /article push migrated.md --project FB --title "Migrated" --no-marker-write',
|
|
215
|
+
'kd /article push migrated.md --project FB --title "Migrated" --no-marker-write --tag "type:guide"',
|
|
207
216
|
],
|
|
208
217
|
see_also=[
|
|
209
218
|
"/article pull",
|
|
@@ -274,6 +283,18 @@ class ArticlePushCommand(Command):
|
|
|
274
283
|
help_text="Create missing tags before pushing",
|
|
275
284
|
)
|
|
276
285
|
)
|
|
286
|
+
self.register_option(
|
|
287
|
+
OptionDeclaration(
|
|
288
|
+
name="no-marker-write",
|
|
289
|
+
layer=OptionLayer.COMMAND,
|
|
290
|
+
type=bool,
|
|
291
|
+
default=False,
|
|
292
|
+
help_text=(
|
|
293
|
+
"Skip the local kd-meta marker rewrite for one-way "
|
|
294
|
+
"migrations; the server write and tag apply still run"
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
277
298
|
|
|
278
299
|
def execute(
|
|
279
300
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
@@ -300,6 +321,7 @@ class ArticlePushCommand(Command):
|
|
|
300
321
|
dry_run=bool(options.command.get("dry-run")),
|
|
301
322
|
tags=tags or None,
|
|
302
323
|
create_tags=bool(options.command.get("create-tags")),
|
|
324
|
+
write_marker=not bool(options.command.get("no-marker-write")),
|
|
303
325
|
)
|
|
304
326
|
return {
|
|
305
327
|
"ok": True,
|
|
@@ -310,6 +332,8 @@ class ArticlePushCommand(Command):
|
|
|
310
332
|
"dry_run": result.dry_run,
|
|
311
333
|
"tags_applied": result.tags_applied,
|
|
312
334
|
"tags_failed": result.tags_failed,
|
|
335
|
+
"marker_written": result.marker_written,
|
|
336
|
+
"parent": result.parent,
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
|
|
@@ -270,11 +270,20 @@ class IssueCreateCommand(Command):
|
|
|
270
270
|
"Create an issue",
|
|
271
271
|
requires_client=True,
|
|
272
272
|
usage='kd /issue create "Summary" --project P [--field NAME=VALUE] [--attach FILE]',
|
|
273
|
-
long_description=
|
|
273
|
+
long_description=(
|
|
274
|
+
"Summary is the issue title. --project is the project key (e.g. FB). "
|
|
275
|
+
"--field NAME=VALUE supports bundle families (enum, state, owned, "
|
|
276
|
+
"version, build), user logins, period, and string custom fields; the "
|
|
277
|
+
"field name is fuzzy-matched and the value is passed verbatim. "
|
|
278
|
+
"Unsupported families (text, Multi*, and integer/float/date subtypes "
|
|
279
|
+
"of SimpleProjectCustomField) fail client-side with an error naming "
|
|
280
|
+
"both the project $type and fieldType.id subtype."
|
|
281
|
+
),
|
|
274
282
|
examples=[
|
|
275
283
|
'kd /issue create "Fix router config" --project FB --type Task',
|
|
276
284
|
'kd /issue create "Bug report" -p FB --attach screenshot.png --attach log.txt',
|
|
277
285
|
'kd /issue create "Sprint task" -p FB --field "Sprint=Sprint 47"',
|
|
286
|
+
'kd /issue create "Tracked" -p VIS --field "Milestone=M039"',
|
|
278
287
|
'kd /issue create "Design doc" -p FB --description @design.md',
|
|
279
288
|
],
|
|
280
289
|
see_also=["/issue list", "/issue show", "/issue/attachment add", "/project/field list"],
|
|
@@ -437,10 +446,21 @@ class IssueUpdateCommand(Command):
|
|
|
437
446
|
"Update an issue",
|
|
438
447
|
requires_client=True,
|
|
439
448
|
usage="kd /issue update ID [--title T] [--field NAME=VALUE]",
|
|
449
|
+
long_description=(
|
|
450
|
+
"--field NAME=VALUE supports bundle families (enum, state, owned, "
|
|
451
|
+
"version, build), user logins, period, and string custom fields; the "
|
|
452
|
+
"field name is fuzzy-matched and the value is passed verbatim. "
|
|
453
|
+
"Clear optional custom fields with NAME= (empty value). Unsupported "
|
|
454
|
+
"families (text, Multi*, and integer/float/date subtypes of "
|
|
455
|
+
"SimpleProjectCustomField) fail client-side with an error naming "
|
|
456
|
+
"both the project $type and fieldType.id subtype."
|
|
457
|
+
),
|
|
440
458
|
examples=[
|
|
441
459
|
'kd /issue update FB-42 --title "New title"',
|
|
442
460
|
"kd /issue update FB-42 --state Open --priority Critical",
|
|
443
461
|
'kd /issue update FB-42 --field "Sprint=Sprint 48"',
|
|
462
|
+
'kd /issue update VIS-21 --field "Milestone=M039"',
|
|
463
|
+
'kd /issue update FB-42 --field "Milestone="',
|
|
444
464
|
"kd /issue update FB-42 --description @updated.md",
|
|
445
465
|
],
|
|
446
466
|
see_also=["/issue show", "/project/field list"],
|
|
@@ -22,6 +22,7 @@ import builtins
|
|
|
22
22
|
import difflib
|
|
23
23
|
import shlex
|
|
24
24
|
from collections import OrderedDict
|
|
25
|
+
from collections.abc import Callable
|
|
25
26
|
from dataclasses import dataclass, field as dataclass_field
|
|
26
27
|
from datetime import datetime, timezone
|
|
27
28
|
from pathlib import Path
|
|
@@ -130,15 +131,43 @@ def _resolve_link_command(link_type: str) -> str:
|
|
|
130
131
|
return result
|
|
131
132
|
|
|
132
133
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
134
|
+
# Dispatch over (project_field_type, field_type_id) → (issue_type, payload_builder).
|
|
135
|
+
#
|
|
136
|
+
# The builder wraps a validated value into the exact shape YouTrack expects in
|
|
137
|
+
# the ``customFields[].value`` slot of an issue create/update payload. Bundle
|
|
138
|
+
# families wrap as ``{"name": ...}``; user fields wrap as ``{"login": ...}``;
|
|
139
|
+
# the scalar simple-string family passes through verbatim.
|
|
140
|
+
#
|
|
141
|
+
# ``""`` as the second tuple element means "any field_type_id under this
|
|
142
|
+
# ``$type``" — used when every subtype of a family shares one payload shape.
|
|
143
|
+
# A specific ``field_type_id`` key wins over the wildcard when both are present
|
|
144
|
+
# (see ``SimpleProjectCustomField`` which only supports the ``string`` subtype
|
|
145
|
+
# this session; ``integer``/``float``/``date`` stay unmapped).
|
|
146
|
+
|
|
147
|
+
FieldValueBuilder = Callable[[str], Any]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _bundle_name(value: str) -> dict[str, str]:
|
|
151
|
+
return {"name": value}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _user_login(value: str) -> dict[str, str]:
|
|
155
|
+
return {"login": value}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _scalar(value: str) -> str:
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_FIELD_DISPATCH: dict[tuple[str, str], tuple[str, FieldValueBuilder]] = {
|
|
163
|
+
("EnumProjectCustomField", ""): ("SingleEnumIssueCustomField", _bundle_name),
|
|
164
|
+
("StateProjectCustomField", ""): ("StateIssueCustomField", _bundle_name),
|
|
165
|
+
("UserProjectCustomField", ""): ("SingleUserIssueCustomField", _user_login),
|
|
166
|
+
("OwnedProjectCustomField", ""): ("SingleOwnedIssueCustomField", _bundle_name),
|
|
167
|
+
("VersionProjectCustomField", ""): ("MultiVersionIssueCustomField", _bundle_name),
|
|
168
|
+
("BuildProjectCustomField", ""): ("SingleBuildIssueCustomField", _bundle_name),
|
|
169
|
+
("PeriodProjectCustomField", ""): ("PeriodIssueCustomField", _bundle_name),
|
|
170
|
+
("SimpleProjectCustomField", "string"): ("SimpleIssueCustomField", _scalar),
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
# Default target field names for semantic CLI slots.
|
|
@@ -166,6 +195,11 @@ class PushResult:
|
|
|
166
195
|
- ``remote_updated_sec`` is the epoch-seconds timestamp of the
|
|
167
196
|
article's ``updated`` field on the upstream side after the push.
|
|
168
197
|
Zero for dry-runs that did not resolve an existing article.
|
|
198
|
+
- ``marker_written`` is ``True`` iff the local file was rewritten
|
|
199
|
+
on this invocation. ``False`` for dry-runs and for
|
|
200
|
+
``write_marker=False`` pushes (one-way migrations).
|
|
201
|
+
- ``parent`` is the ``id_readable`` of the target article's parent
|
|
202
|
+
on the upstream side, or ``None`` for root articles.
|
|
169
203
|
"""
|
|
170
204
|
|
|
171
205
|
action: Literal["create", "update"]
|
|
@@ -176,6 +210,8 @@ class PushResult:
|
|
|
176
210
|
dry_run: bool
|
|
177
211
|
tags_applied: builtins.list[str] = dataclass_field(default_factory=list)
|
|
178
212
|
tags_failed: builtins.list[str] = dataclass_field(default_factory=list)
|
|
213
|
+
marker_written: bool = False
|
|
214
|
+
parent: str | None = None
|
|
179
215
|
|
|
180
216
|
|
|
181
217
|
def _derive_slug_from_path(file_path: Path) -> str:
|
|
@@ -274,14 +310,10 @@ class FieldSchema:
|
|
|
274
310
|
self._debug.debug("fields", f"resolving slot '{slot}' -> target '{target_name}'")
|
|
275
311
|
|
|
276
312
|
field = self._find_field(project_fields, target_name, slot)
|
|
277
|
-
issue_type =
|
|
278
|
-
if not issue_type:
|
|
279
|
-
raise ValidationError(
|
|
280
|
-
f"Unsupported field type '{field.project_field_type}' for field '{field.name}'"
|
|
281
|
-
)
|
|
313
|
+
issue_type, builder = self._resolve_dispatch(field)
|
|
282
314
|
|
|
283
315
|
validated_value = self._validate_field_value(field, value)
|
|
284
|
-
field_value =
|
|
316
|
+
field_value = builder(validated_value) if validated_value is not None else None
|
|
285
317
|
self._debug.debug(
|
|
286
318
|
"fields",
|
|
287
319
|
f"resolved '{slot}' -> field '{field.name}' ($type={issue_type})",
|
|
@@ -342,13 +374,9 @@ class FieldSchema:
|
|
|
342
374
|
"""
|
|
343
375
|
project_fields = self.get_fields(project_id)
|
|
344
376
|
field = self._find_field_fuzzy(project_fields, field_name)
|
|
345
|
-
issue_type =
|
|
346
|
-
if not issue_type:
|
|
347
|
-
raise ValidationError(
|
|
348
|
-
f"Unsupported field type '{field.project_field_type}' for field '{field.name}'"
|
|
349
|
-
)
|
|
377
|
+
issue_type, builder = self._resolve_dispatch(field)
|
|
350
378
|
validated_value = self._validate_field_value(field, value)
|
|
351
|
-
field_value =
|
|
379
|
+
field_value = builder(validated_value) if validated_value is not None else None
|
|
352
380
|
self._debug.debug(
|
|
353
381
|
"fields",
|
|
354
382
|
f"resolved generic '{field_name}' -> field '{field.name}' "
|
|
@@ -388,19 +416,30 @@ class FieldSchema:
|
|
|
388
416
|
"""Return True if this field is a user-type field (user[1])."""
|
|
389
417
|
return field.field_type_id.startswith("user")
|
|
390
418
|
|
|
391
|
-
|
|
392
|
-
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _resolve_dispatch(
|
|
393
421
|
field: ProjectCustomField,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
422
|
+
) -> tuple[str, FieldValueBuilder]:
|
|
423
|
+
"""Look up the issue-side ``$type`` and payload builder for *field*.
|
|
424
|
+
|
|
425
|
+
Prefers an exact ``(project_field_type, field_type_id)`` key, then
|
|
426
|
+
falls back to the wildcard ``(project_field_type, "")`` key for
|
|
427
|
+
families where every subtype shares a payload shape. Unmapped
|
|
428
|
+
combinations raise a ``ValidationError`` naming both identifiers so
|
|
429
|
+
a follow-on ticket has the upstream type info it needs.
|
|
400
430
|
"""
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
431
|
+
key = (field.project_field_type, field.field_type_id)
|
|
432
|
+
if key in _FIELD_DISPATCH:
|
|
433
|
+
return _FIELD_DISPATCH[key]
|
|
434
|
+
wildcard = (field.project_field_type, "")
|
|
435
|
+
if wildcard in _FIELD_DISPATCH:
|
|
436
|
+
return _FIELD_DISPATCH[wildcard]
|
|
437
|
+
raise ValidationError(
|
|
438
|
+
f"Unsupported custom field type '{field.project_field_type}' "
|
|
439
|
+
f"with subtype '{field.field_type_id}' for field '{field.name}'. "
|
|
440
|
+
"kd does not know how to build a safe value payload for this "
|
|
441
|
+
"YouTrack field family."
|
|
442
|
+
)
|
|
404
443
|
|
|
405
444
|
def _validate_field_value(
|
|
406
445
|
self,
|
|
@@ -421,7 +460,7 @@ class FieldSchema:
|
|
|
421
460
|
if self._is_user_field(field):
|
|
422
461
|
return value # User fields: server validates against group members
|
|
423
462
|
if not field.bundle_values:
|
|
424
|
-
return value # Freeform
|
|
463
|
+
return value # Freeform family (period or scalar string) — server validates
|
|
425
464
|
|
|
426
465
|
# Case-insensitive exact match → return canonical name
|
|
427
466
|
value_lower = value.lower()
|
|
@@ -684,7 +723,7 @@ class IssueOperations:
|
|
|
684
723
|
if "=" not in pair:
|
|
685
724
|
raise ValidationError(f"Invalid field format: '{pair}'. Expected NAME=VALUE")
|
|
686
725
|
name, value = pair.split("=", 1)
|
|
687
|
-
cf = self._field_schema.resolve_field(project_obj.id, name.strip(), value
|
|
726
|
+
cf = self._field_schema.resolve_field(project_obj.id, name.strip(), value)
|
|
688
727
|
result.append(cf)
|
|
689
728
|
return result
|
|
690
729
|
|
|
@@ -1272,6 +1311,7 @@ class ArticleOperations:
|
|
|
1272
1311
|
dry_run: bool = False,
|
|
1273
1312
|
tags: builtins.list[str] | None = None,
|
|
1274
1313
|
create_tags: bool = False,
|
|
1314
|
+
write_marker: bool = True,
|
|
1275
1315
|
) -> PushResult:
|
|
1276
1316
|
"""Idempotently upsert a local markdown file as a YouTrack article.
|
|
1277
1317
|
|
|
@@ -1282,6 +1322,10 @@ class ArticleOperations:
|
|
|
1282
1322
|
|
|
1283
1323
|
On success, the local file is rewritten atomically to refresh
|
|
1284
1324
|
the marker's ``upstream-id`` and ``upstream-updated`` fields.
|
|
1325
|
+
Pass ``write_marker=False`` for one-way migrations where the
|
|
1326
|
+
local file will be deleted after the push — the outgoing
|
|
1327
|
+
server payload is unchanged, only the local write-back is
|
|
1328
|
+
skipped. See ``PushResult.marker_written`` (KDCLI-110).
|
|
1285
1329
|
"""
|
|
1286
1330
|
# Step 1: read the local file and parse the marker.
|
|
1287
1331
|
try:
|
|
@@ -1405,8 +1449,12 @@ class ArticleOperations:
|
|
|
1405
1449
|
# Dry-run bails out here with all validation done but no writes.
|
|
1406
1450
|
if dry_run:
|
|
1407
1451
|
existing_sec = 0
|
|
1408
|
-
|
|
1409
|
-
|
|
1452
|
+
existing_parent: str | None = None
|
|
1453
|
+
if existing_remote is not None:
|
|
1454
|
+
if existing_remote.updated is not None:
|
|
1455
|
+
existing_sec = existing_remote.updated // 1000
|
|
1456
|
+
if existing_remote.parent_article is not None:
|
|
1457
|
+
existing_parent = existing_remote.parent_article.id_readable
|
|
1410
1458
|
return PushResult(
|
|
1411
1459
|
action="update" if is_update else "create",
|
|
1412
1460
|
article_id=existing_remote.id_readable if existing_remote else "",
|
|
@@ -1414,6 +1462,8 @@ class ArticleOperations:
|
|
|
1414
1462
|
path=file_path,
|
|
1415
1463
|
remote_updated_sec=existing_sec,
|
|
1416
1464
|
dry_run=True,
|
|
1465
|
+
marker_written=False,
|
|
1466
|
+
parent=existing_parent,
|
|
1417
1467
|
)
|
|
1418
1468
|
|
|
1419
1469
|
# Step 7: build outgoing content. `upstream-updated` is intentionally
|
|
@@ -1482,10 +1532,18 @@ class ArticleOperations:
|
|
|
1482
1532
|
)
|
|
1483
1533
|
final_content = render_content(final)
|
|
1484
1534
|
|
|
1485
|
-
# Step 10: atomic local write-back
|
|
1486
|
-
|
|
1535
|
+
# Step 10: atomic local write-back (skipped for one-way
|
|
1536
|
+
# migration flows that opt out via write_marker=False; the
|
|
1537
|
+
# server payload in Step 7 is unchanged either way).
|
|
1538
|
+
if write_marker:
|
|
1539
|
+
_atomic_write_text(file_path, final_content)
|
|
1487
1540
|
|
|
1488
1541
|
# Step 11: return result.
|
|
1542
|
+
result_parent: str | None = (
|
|
1543
|
+
result_article.parent_article.id_readable
|
|
1544
|
+
if result_article.parent_article is not None
|
|
1545
|
+
else None
|
|
1546
|
+
)
|
|
1489
1547
|
push_result = PushResult(
|
|
1490
1548
|
action="update" if is_update else "create",
|
|
1491
1549
|
article_id=result_article.id_readable,
|
|
@@ -1495,6 +1553,8 @@ class ArticleOperations:
|
|
|
1495
1553
|
dry_run=False,
|
|
1496
1554
|
tags_applied=tags_applied,
|
|
1497
1555
|
tags_failed=tags_failed,
|
|
1556
|
+
marker_written=write_marker,
|
|
1557
|
+
parent=result_parent,
|
|
1498
1558
|
)
|
|
1499
1559
|
if tags_failed:
|
|
1500
1560
|
raise KdError(
|
|
@@ -4,6 +4,22 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.5.2] — 2026-04-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- `kd /article push` one-way migrations no longer force `git rm -f`. Before: push always rewrote the local markdown file with a `kd-meta` marker on success, which left the file "locally modified" and made `git rm` refuse without `-f` on migrations that delete the local copy after push. Now: pass `--no-marker-write` to skip the local rewrite. The upstream article is still created or updated, `--tag` values are still applied, and the outgoing server payload still carries a marker with the slug — so scan-by-slug resolves the article on any later push even without the local file. Default behavior is unchanged; the flag is opt-in (KDCLI-110).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `kd /article push` structured output (`-o yaml` / `-o json`) now carries two sibling keys on every push: `marker_written` (`false` for dry-runs and `--no-marker-write` pushes, `true` on the default path after the local rewrite) and `parent` (readable id of the target article's upstream parent, or `null` for root articles). Scripts can assert local-file state and the article's place in the tree from the push response directly (KDCLI-110).
|
|
14
|
+
|
|
15
|
+
## [2.5.1] — 2026-04-19
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `kd /issue create --field NAME=VALUE` and `kd /issue update --field NAME=VALUE` now accept **string custom fields**. Before: `kd /issue update VIS-21 --field Milestone=M039` rejected with `Error: Unsupported field type 'SimpleProjectCustomField' for field 'Milestone'`, blocking workflows that track freeform per-issue metadata such as a rolling milestone string. `--field "NAME="` clears an optional string field just like it clears enum/user fields today; required string fields reject empty values with the same client-side error shipped in 2.2.2. Whitespace inside the value is preserved — `--field "Milestone= M 039 "` sends ` M 039 ` verbatim. Only the field **name** is trimmed (KDCLI-106).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Unsupported custom-field errors now name both identifiers and the cause. Before: `Error: Unsupported field type 'X' for field 'Y'`. Now: `Error: Unsupported custom field type 'X' with subtype 'Y' for field 'Z'. kd does not know how to build a safe value payload for this YouTrack field family.` Applies to YouTrack text fields, multi-arity enum/user variants, and integer/float/date simple-field subtypes — paste the message into a bug report and the two type identifiers are already there (KDCLI-106).
|
|
22
|
+
|
|
7
23
|
## [2.5.0] — 2026-04-19
|
|
8
24
|
|
|
9
25
|
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.
|
|
@@ -108,6 +108,8 @@ kd /project/field list KDCLI
|
|
|
108
108
|
|
|
109
109
|
Clear optional custom fields with an empty value, for example `kd /issue update KDCLI-42 --field "Milestone="`. Required fields reject empty values before any upstream write.
|
|
110
110
|
|
|
111
|
+
`--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.
|
|
112
|
+
|
|
111
113
|
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.
|
|
112
114
|
|
|
113
115
|
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.
|
|
@@ -156,6 +158,8 @@ Use `/article show` for metadata plus rendered content. Use `/article pull` when
|
|
|
156
158
|
|
|
157
159
|
Do not use push for one-off web edits or broad tree migration. It is single-file by design. If the remote changed, pull, diff, merge by hand, then push again. Use `--force` only after inspecting the remote state.
|
|
158
160
|
|
|
161
|
+
For one-way migrations where the local file will be deleted after the push, pass `--no-marker-write`. The server article is still created or updated and tags are still applied, but kd skips the local `kd-meta` rewrite so `git rm` works without `-f`. The structured response gains `marker_written: false`; re-running `--no-marker-write` pays the scan-by-slug cost every invocation because no `upstream-id` is cached locally.
|
|
162
|
+
|
|
159
163
|
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
164
|
|
|
161
165
|
`/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`.
|
|
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
|
|
File without changes
|
|
File without changes
|