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.
Files changed (82) hide show
  1. {kd_cli-2.5.0/kd_cli.egg-info → kd_cli-2.5.2}/PKG-INFO +1 -1
  2. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/article/__init__.py +26 -2
  3. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/issue/__init__.py +21 -1
  4. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/client.py +98 -38
  5. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/CHANGELOG.md +16 -0
  6. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/operating-guide.md +4 -0
  7. {kd_cli-2.5.0 → kd_cli-2.5.2/kd_cli.egg-info}/PKG-INFO +1 -1
  8. {kd_cli-2.5.0 → kd_cli-2.5.2}/pyproject.toml +1 -1
  9. {kd_cli-2.5.0 → kd_cli-2.5.2}/LICENSE +0 -0
  10. {kd_cli-2.5.0 → kd_cli-2.5.2}/MANIFEST.in +0 -0
  11. {kd_cli-2.5.0 → kd_cli-2.5.2}/README.md +0 -0
  12. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/__init__.py +0 -0
  13. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/__init__.py +0 -0
  14. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/articles.py +0 -0
  15. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/http.py +0 -0
  16. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/hub.py +0 -0
  17. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/saved_queries.py +0 -0
  18. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/tags.py +0 -0
  19. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/api/youtrack.py +0 -0
  20. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/article_marker.py +0 -0
  21. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/auth.py +0 -0
  22. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/__init__.py +0 -0
  23. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/__init__.py +0 -0
  24. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/command_base.py +0 -0
  25. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/command_parser.py +0 -0
  26. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/confirm.py +0 -0
  27. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/namespaces.py +0 -0
  28. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/core/option_registry.py +0 -0
  29. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/input.py +0 -0
  30. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/main.py +0 -0
  31. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/__init__.py +0 -0
  32. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/alias/__init__.py +0 -0
  33. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/init.py +0 -0
  34. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/primer/__init__.py +0 -0
  35. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/project/__init__.py +0 -0
  36. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/query/__init__.py +0 -0
  37. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/release_notes.py +0 -0
  38. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/search/__init__.py +0 -0
  39. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/system/__init__.py +0 -0
  40. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/tag/__init__.py +0 -0
  41. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/time/__init__.py +0 -0
  42. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/namespaces/user/__init__.py +0 -0
  43. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/output.py +0 -0
  44. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/cli/tree.py +0 -0
  45. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config.py +0 -0
  46. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config_read.py +0 -0
  47. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/config_schema.py +0 -0
  48. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/connection.py +0 -0
  49. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/debug.py +0 -0
  50. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/exceptions.py +0 -0
  51. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/history.py +0 -0
  52. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/locking.py +0 -0
  53. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/__init__.py +0 -0
  54. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/article.py +0 -0
  55. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/assist.py +0 -0
  56. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/attachment.py +0 -0
  57. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/comment.py +0 -0
  58. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/common.py +0 -0
  59. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/issue.py +0 -0
  60. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/project.py +0 -0
  61. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/saved_query.py +0 -0
  62. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/search.py +0 -0
  63. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/tag.py +0 -0
  64. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/tag_entities.py +0 -0
  65. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/user.py +0 -0
  66. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/models/work_item.py +0 -0
  67. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/operating_guide.py +0 -0
  68. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/primer.py +0 -0
  69. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/query_parser.py +0 -0
  70. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/release_notes.py +0 -0
  71. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/__init__.py +0 -0
  72. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/command-synonyms.yaml +0 -0
  73. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/config-template.yaml +0 -0
  74. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/error-messages.yaml +0 -0
  75. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/primers/__init__.py +0 -0
  76. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd/resources/primers/hitl.yaml +0 -0
  77. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/SOURCES.txt +0 -0
  78. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/dependency_links.txt +0 -0
  79. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/entry_points.txt +0 -0
  80. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/requires.txt +0 -0
  81. {kd_cli-2.5.0 → kd_cli-2.5.2}/kd_cli.egg-info/top_level.txt +0 -0
  82. {kd_cli-2.5.0 → kd_cli-2.5.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.5.0
3
+ Version: 2.5.2
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -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] [--force] [--dry-run]"
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="Summary is the issue title. --project is the project key (e.g. FB).",
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
- # Maps project custom field $type to the $type needed for issue mutations.
134
- _PROJECT_TO_ISSUE_TYPE: dict[str, str] = {
135
- "EnumProjectCustomField": "SingleEnumIssueCustomField",
136
- "StateProjectCustomField": "StateIssueCustomField",
137
- "UserProjectCustomField": "SingleUserIssueCustomField",
138
- "OwnedProjectCustomField": "SingleOwnedIssueCustomField",
139
- "VersionProjectCustomField": "MultiVersionIssueCustomField",
140
- "BuildProjectCustomField": "SingleBuildIssueCustomField",
141
- "PeriodProjectCustomField": "PeriodIssueCustomField",
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 = _PROJECT_TO_ISSUE_TYPE.get(field.project_field_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 = self._field_value_payload(field, validated_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 = _PROJECT_TO_ISSUE_TYPE.get(field.project_field_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 = self._field_value_payload(field, validated_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
- def _field_value_payload(
392
- self,
419
+ @staticmethod
420
+ def _resolve_dispatch(
393
421
  field: ProjectCustomField,
394
- value: str | None,
395
- ) -> dict[str, str] | None:
396
- """Build the YouTrack issue-field value payload.
397
-
398
- ``None`` is a deliberate clear operation and must serialize as
399
- ``"value": null`` in the final issue mutation payload.
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
- if value is None:
402
- return None
403
- return {"login": value} if self._is_user_field(field) else {"name": value}
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 field (period, text) — accept as-is
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.strip())
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
- if existing_remote is not None and existing_remote.updated is not None:
1409
- existing_sec = existing_remote.updated // 1000
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
- _atomic_write_text(file_path, final_content)
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`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.5.0
3
+ Version: 2.5.2
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.5.0"
7
+ version = "2.5.2"
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
File without changes
File without changes
File without changes