kd-cli 2.7.1__tar.gz → 2.8.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 (86) hide show
  1. {kd_cli-2.7.1/kd_cli.egg-info → kd_cli-2.8.0}/PKG-INFO +1 -1
  2. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/youtrack.py +3 -0
  3. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/article/__init__.py +93 -3
  4. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/issue/__init__.py +70 -0
  5. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/output.py +23 -25
  6. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/client.py +69 -0
  7. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/CHANGELOG.md +23 -0
  8. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/operating-guide.md +1 -1
  9. {kd_cli-2.7.1 → kd_cli-2.8.0/kd_cli.egg-info}/PKG-INFO +1 -1
  10. {kd_cli-2.7.1 → kd_cli-2.8.0}/pyproject.toml +1 -1
  11. {kd_cli-2.7.1 → kd_cli-2.8.0}/LICENSE +0 -0
  12. {kd_cli-2.7.1 → kd_cli-2.8.0}/MANIFEST.in +0 -0
  13. {kd_cli-2.7.1 → kd_cli-2.8.0}/README.md +0 -0
  14. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/__init__.py +0 -0
  15. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/__init__.py +0 -0
  16. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/articles.py +0 -0
  17. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/http.py +0 -0
  18. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/hub.py +0 -0
  19. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/saved_queries.py +0 -0
  20. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/tags.py +0 -0
  21. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/article_marker.py +0 -0
  22. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/auth.py +0 -0
  23. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cache.py +0 -0
  24. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/__init__.py +0 -0
  25. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/__init__.py +0 -0
  26. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/command_base.py +0 -0
  27. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/command_parser.py +0 -0
  28. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/confirm.py +0 -0
  29. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/namespaces.py +0 -0
  30. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/option_registry.py +0 -0
  31. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/input.py +0 -0
  32. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/main.py +0 -0
  33. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/__init__.py +0 -0
  34. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  35. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/cache/__init__.py +0 -0
  36. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/init.py +0 -0
  37. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/primer/__init__.py +0 -0
  38. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/project/__init__.py +0 -0
  39. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/query/__init__.py +0 -0
  40. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/release_notes.py +0 -0
  41. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/search/__init__.py +0 -0
  42. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/system/__init__.py +0 -0
  43. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/tag/__init__.py +0 -0
  44. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/time/__init__.py +0 -0
  45. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/user/__init__.py +0 -0
  46. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config.py +0 -0
  48. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config_read.py +0 -0
  49. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config_schema.py +0 -0
  50. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/connection.py +0 -0
  51. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/debug.py +0 -0
  52. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/exceptions.py +0 -0
  53. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/history.py +0 -0
  54. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/locking.py +0 -0
  55. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/__init__.py +0 -0
  56. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/article.py +0 -0
  57. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/assist.py +0 -0
  58. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/comment.py +0 -0
  60. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/common.py +0 -0
  61. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/issue.py +0 -0
  62. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/project.py +0 -0
  63. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/search.py +0 -0
  65. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/tag.py +0 -0
  66. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/user.py +0 -0
  68. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/primer.py +0 -0
  71. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/query_parser.py +0 -0
  72. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/release_notes.py +0 -0
  73. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/sdlc.py +0 -0
  81. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.7.1 → kd_cli-2.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.7.1
3
+ Version: 2.8.0
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -438,6 +438,9 @@ class YouTrackAPI:
438
438
  )
439
439
  return data
440
440
 
441
+ def delete_issue_link(self, issue_id: str, link_id: str, target_id: str) -> None:
442
+ self._http.delete(f"/issues/{issue_id}/links/{link_id}/issues/{target_id}")
443
+
441
444
  def apply_command(self, issue_id: str, command: str) -> None:
442
445
  """Apply a command to an issue via the commands API.
443
446
 
@@ -68,6 +68,35 @@ def _article_to_detail(article: Article) -> dict[str, Any]:
68
68
  return result
69
69
 
70
70
 
71
+ def _apply_content_truncation(
72
+ detail: dict[str, Any],
73
+ *,
74
+ no_content: bool,
75
+ body_lines: int | None,
76
+ ) -> None:
77
+ """Mutate detail in place to apply --no-content / --body-lines truncation."""
78
+ content = detail.get("content")
79
+ if no_content:
80
+ chars = len(content) if isinstance(content, str) else 0
81
+ lines = len(content.splitlines()) if isinstance(content, str) else 0
82
+ detail.pop("content", None)
83
+ detail["content_truncated"] = True
84
+ detail["content_omitted"] = True
85
+ detail["content_chars"] = chars
86
+ detail["content_lines"] = lines
87
+ return
88
+ if body_lines is None or not isinstance(content, str):
89
+ return
90
+ parts = content.splitlines()
91
+ if len(parts) <= body_lines:
92
+ return
93
+ detail["content"] = "\n".join(parts[:body_lines])
94
+ detail["content_truncated"] = True
95
+ detail["content_chars"] = len(content)
96
+ detail["content_lines"] = len(parts)
97
+ detail["content_preview_lines"] = body_lines
98
+
99
+
71
100
  # --- Article Commands ---
72
101
 
73
102
 
@@ -179,18 +208,79 @@ class ArticleShowCommand(Command):
179
208
  "show",
180
209
  "Show article details",
181
210
  requires_client=True,
182
- usage="kd /article show ARTICLE_ID",
183
- examples=["kd /article show FB-A-1"],
211
+ usage=("kd /article show ARTICLE_ID [--no-content | [--preview] [--body-lines N]]"),
212
+ long_description=(
213
+ "By default emits full metadata plus the rendered article body. "
214
+ "Pass --preview (sugar for --body-lines 40) or --body-lines N "
215
+ "to truncate the body to the first N lines; the structured "
216
+ "envelope gains content_truncated, content_chars, "
217
+ "content_lines, and content_preview_lines only when "
218
+ "truncation actually applies. Pass --no-content to omit the "
219
+ "body entirely; the envelope drops content and adds "
220
+ "content_omitted: true alongside content_chars and "
221
+ "content_lines. --no-content is mutually exclusive with "
222
+ "--body-lines and --preview. For the canonical raw body "
223
+ "(stdout, no envelope) use /article pull."
224
+ ),
225
+ examples=[
226
+ "kd /article show FB-A-1",
227
+ "kd /article show FB-A-1 --preview",
228
+ "kd /article show FB-A-1 --body-lines 10",
229
+ "kd /article show FB-A-1 --no-content -o yaml",
230
+ ],
184
231
  see_also=["/article list", "/article children", "/article pull"],
185
232
  )
186
233
 
234
+ def _register_options(self) -> None:
235
+ self.register_option(
236
+ OptionDeclaration(
237
+ name="no-content",
238
+ layer=OptionLayer.COMMAND,
239
+ type=bool,
240
+ default=False,
241
+ help_text=(
242
+ "Omit the article body; emit metadata-only output with content_omitted: true"
243
+ ),
244
+ )
245
+ )
246
+ self.register_option(
247
+ OptionDeclaration(
248
+ name="body-lines",
249
+ layer=OptionLayer.COMMAND,
250
+ type=int,
251
+ help_text=("Truncate the body to the first N lines; emit truncation metadata"),
252
+ )
253
+ )
254
+ self.register_option(
255
+ OptionDeclaration(
256
+ name="preview",
257
+ layer=OptionLayer.COMMAND,
258
+ type=bool,
259
+ default=False,
260
+ help_text="Preview mode — sugar for --body-lines 40",
261
+ )
262
+ )
263
+
187
264
  def execute(
188
265
  self, args: list[str], options: ResolvedOptions, client: Any = None
189
266
  ) -> CommandResult:
190
267
  if not args:
191
268
  raise ValidationError("Usage: kd /article show ARTICLE_ID")
269
+ no_content = bool(options.command.get("no-content"))
270
+ raw_body_lines = options.command.get("body-lines")
271
+ preview = bool(options.command.get("preview"))
272
+ if no_content and (raw_body_lines is not None or preview):
273
+ raise ValidationError(
274
+ "--no-content is mutually exclusive with --body-lines and --preview"
275
+ )
276
+ if raw_body_lines is not None and raw_body_lines < 0:
277
+ raise ValidationError("--body-lines must be a non-negative integer")
278
+ body_lines = raw_body_lines if raw_body_lines is not None else (40 if preview else None)
192
279
  article = client.articles.get(args[0])
193
- return _article_to_detail(article)
280
+ detail = _article_to_detail(article)
281
+ if no_content or body_lines is not None:
282
+ _apply_content_truncation(detail, no_content=no_content, body_lines=body_lines)
283
+ return detail
194
284
 
195
285
 
196
286
  class ArticlePullCommand(Command):
@@ -716,11 +716,13 @@ class LinkListCommand(Command):
716
716
  links = client.issues.list_links(args[0])
717
717
  rows: list[dict[str, Any]] = []
718
718
  for link in links:
719
+ link_id = link.get("id", "")
719
720
  link_type = link.get("linkType", {}).get("name", "")
720
721
  direction = link.get("direction", "")
721
722
  for issue in link.get("issues", []):
722
723
  rows.append(
723
724
  {
725
+ "id": link_id,
724
726
  "type": link_type,
725
727
  "direction": direction,
726
728
  "issue": issue.get("idReadable", ""),
@@ -772,6 +774,73 @@ class LinkAddCommand(Command):
772
774
  }
773
775
 
774
776
 
777
+ class LinkRemoveCommand(Command):
778
+ """Remove a link between two issues."""
779
+
780
+ def __init__(self) -> None:
781
+ super().__init__(
782
+ "remove",
783
+ "Remove a link between two issues",
784
+ requires_client=True,
785
+ usage="kd /issue/link remove ID TYPE TARGET | kd /issue/link remove ID --by-id LINK_ID",
786
+ long_description=(
787
+ "TYPE is one of: subtask-of, parent-for, depends-on, "
788
+ "required-for, duplicates, duplicated-by, relates-to. "
789
+ "Pass --by-id LINK_ID to disambiguate when the type+target "
790
+ "form is ambiguous; LINK_ID is the value surfaced in the "
791
+ "'id' column of `kd /issue/link list`. Returns a parseable "
792
+ "single-line ack: the last stdout line is "
793
+ "``ok: <ID> unlinked type=<TYPE> target=<TARGET>`` or "
794
+ "``ok: <ID> unlinked link_id=<LINK_ID>``. Pass --quiet to "
795
+ "suppress the detail block above the ack."
796
+ ),
797
+ examples=[
798
+ "kd /issue/link remove FB-42 subtask-of FB-1",
799
+ "kd /issue/link remove FB-42 --by-id 80-2s",
800
+ ],
801
+ see_also=["/issue/link add", "/issue/link list"],
802
+ )
803
+ self.display_hint = "mutation"
804
+
805
+ def _register_options(self) -> None:
806
+ self.register_option(
807
+ OptionDeclaration(
808
+ name="by-id",
809
+ layer=OptionLayer.COMMAND,
810
+ help_text="Remove the link with the given YT-internal link id (from `link list`)",
811
+ )
812
+ )
813
+
814
+ def execute(
815
+ self, args: list[str], options: ResolvedOptions, client: Any = None
816
+ ) -> CommandResult:
817
+ link_id = options.command.get("by-id")
818
+ if link_id:
819
+ if len(args) != 1:
820
+ raise ValidationError("Usage: kd /issue/link remove ID --by-id LINK_ID")
821
+ issue_id = args[0]
822
+ client.issues.unlink_by_id(issue_id, link_id)
823
+ return {
824
+ "ok": True,
825
+ "action": "unlinked",
826
+ "id": issue_id,
827
+ "link_id": link_id,
828
+ }
829
+ if len(args) != 3:
830
+ raise ValidationError("Usage: kd /issue/link remove ID TYPE TARGET")
831
+ issue_id = args[0]
832
+ link_type = args[1]
833
+ target_id = args[2]
834
+ client.issues.unlink(issue_id, link_type, target_id)
835
+ return {
836
+ "ok": True,
837
+ "action": "unlinked",
838
+ "id": issue_id,
839
+ "type": link_type,
840
+ "target": target_id,
841
+ }
842
+
843
+
775
844
  # --- Assign / Unassign / Move ---
776
845
 
777
846
 
@@ -1282,6 +1351,7 @@ def create_issue_namespace() -> Namespace:
1282
1351
  link_ns = Namespace("link", "Issue links", path="issue/link")
1283
1352
  link_ns.register_command("list", LinkListCommand())
1284
1353
  link_ns.register_command("add", LinkAddCommand())
1354
+ link_ns.register_command("remove", LinkRemoveCommand())
1285
1355
  ns.register_sub_namespace(link_ns)
1286
1356
 
1287
1357
  attachment_ns = Namespace("attachment", "Issue attachments", path="issue/attachment")
@@ -25,7 +25,6 @@ import yaml
25
25
  from tabulate import tabulate
26
26
 
27
27
  from kd.cli.core.command_base import CommandResult
28
- from kd.cli.tree import TreeNode, render_tree
29
28
 
30
29
  # Fields whose int values are Unix-epoch milliseconds.
31
30
  TIMESTAMP_FIELDS = frozenset({"created", "updated", "resolved"})
@@ -399,32 +398,31 @@ class OutputFormatter:
399
398
 
400
399
  @staticmethod
401
400
  def _render_tree_display(data: dict[str, Any]) -> str:
402
- """Render nested tree dict as pretty box-drawing output."""
401
+ """Render nested tree dict as pretty box-drawing output.
403
402
 
404
- def _to_node(d: dict[str, Any]) -> TreeNode:
405
- return TreeNode(
406
- id=d.get("id", ""),
407
- label=d.get("summary", d.get("id", "")),
408
- has_children=bool(d.get("children")),
409
- )
403
+ Walks ``data["children"]`` directly. Depth truncation is applied
404
+ upstream (e.g. by ``_collect_tree``); this renderer just walks the
405
+ shape it receives.
406
+ """
407
+
408
+ def _label(d: dict[str, Any]) -> str:
409
+ label = d.get("summary", d.get("id", ""))
410
+ return label if isinstance(label, str) else ""
410
411
 
411
- def _fetch_children(node_id: str) -> list[TreeNode]:
412
- # Walk the tree dict to find the node and return its children
413
- children = _find_children(data, node_id)
414
- return [_to_node(c) for c in children]
415
-
416
- def _find_children(node: dict[str, Any], target_id: str) -> list[dict[str, Any]]:
417
- kids: list[dict[str, Any]] = node.get("children", [])
418
- if node.get("id") == target_id:
419
- return kids
420
- for child in kids:
421
- result = _find_children(child, target_id)
422
- if result is not None:
423
- return result
424
- return []
425
-
426
- root = _to_node(data)
427
- return render_tree(root, _fetch_children)
412
+ lines: list[str] = [_label(data)]
413
+
414
+ def _walk(children: list[dict[str, Any]], prefix: str) -> None:
415
+ for i, child in enumerate(children):
416
+ is_last = i == len(children) - 1
417
+ connector = "└── " if is_last else "├── "
418
+ lines.append(f"{prefix}{connector}{_label(child)}")
419
+ grandchildren = child.get("children") or []
420
+ if grandchildren:
421
+ extension = " " if is_last else "│ "
422
+ _walk(grandchildren, prefix + extension)
423
+
424
+ _walk(data.get("children") or [], "")
425
+ return "\n".join(lines)
428
426
 
429
427
  # ------------------------------------------------------------------
430
428
  # Tag entities rendering
@@ -105,6 +105,22 @@ _LINK_TYPE_MAP: dict[str, str] = {
105
105
  "relates-to": "relates to",
106
106
  }
107
107
 
108
+ # Maps user-friendly link type to the (linkType.name, source-side direction)
109
+ # pair returned by GET /api/issues/{id}/links. Used by the unlink path to
110
+ # locate the right link entry on the source issue. Direction follows the YT
111
+ # spec table: when the user-friendly word matches sourceToTarget the
112
+ # source-side direction is OUTWARD, when it matches targetToSource it is
113
+ # INWARD; undirected types use BOTH.
114
+ _LINK_TYPE_NAME_MAP: dict[str, tuple[str, str]] = {
115
+ "subtask-of": ("Subtask", "INWARD"),
116
+ "parent-for": ("Subtask", "OUTWARD"),
117
+ "depends-on": ("Depend", "INWARD"),
118
+ "required-for": ("Depend", "OUTWARD"),
119
+ "duplicates": ("Duplicate", "INWARD"),
120
+ "duplicated-by": ("Duplicate", "OUTWARD"),
121
+ "relates-to": ("Relates", "BOTH"),
122
+ }
123
+
108
124
 
109
125
  def _issue_to_entity_ref(issue: Issue) -> EntityRef:
110
126
  """Build a cross-entity ``EntityRef`` row from an :class:`Issue`."""
@@ -960,6 +976,59 @@ class IssueOperations:
960
976
  command_str = _resolve_link_command(link_type)
961
977
  self._yt.apply_command(issue_id, f"{command_str} {target_id}")
962
978
 
979
+ def unlink(self, issue_id: str, link_type: str, target_id: str) -> None:
980
+ """Remove a link between two issues.
981
+
982
+ ``link_type`` is a user-friendly name like ``subtask-of``. Lists the
983
+ links on ``issue_id``, locates the entry matching the type/direction
984
+ pair from ``_LINK_TYPE_NAME_MAP`` whose target's ``idReadable``
985
+ equals ``target_id``, then issues the documented DELETE. The DELETE
986
+ URL requires the target's **internal** id (readable ids return 404
987
+ on the target slot — verified live on YouTrack 2025), so the
988
+ helper passes ``issue["id"]`` (internal) along with the source
989
+ readable id and the resolved linkID.
990
+ """
991
+ mapping = _LINK_TYPE_NAME_MAP.get(link_type.lower())
992
+ if mapping is None:
993
+ valid = ", ".join(sorted(_LINK_TYPE_NAME_MAP))
994
+ raise ValidationError(f"Unknown link type '{link_type}'. Valid types: {valid}")
995
+ type_name, direction = mapping
996
+ for link in self._yt.list_issue_links(issue_id):
997
+ if link.get("linkType", {}).get("name") != type_name:
998
+ continue
999
+ if link.get("direction") != direction:
1000
+ continue
1001
+ for issue in link.get("issues", []):
1002
+ if issue.get("idReadable") == target_id:
1003
+ self._yt.delete_issue_link(issue_id, link["id"], issue["id"])
1004
+ return
1005
+ raise NotFoundError(f"No {link_type} link from {issue_id} to {target_id}")
1006
+
1007
+ def unlink_by_id(self, issue_id: str, link_id: str) -> None:
1008
+ """Remove a link by its YT-internal link id.
1009
+
1010
+ Lists links on ``issue_id``, finds the entry whose ``id`` equals
1011
+ ``link_id``, and DELETEs the single target it points at. Raises
1012
+ ``ValidationError`` when the matched link contains more than one
1013
+ target (the operator must use ``unlink(issue_id, type, target)``
1014
+ instead). Raises ``NotFoundError`` when no link with that id exists
1015
+ on the issue. The DELETE uses the target's internal id (readable
1016
+ ids 404 on the target slot — see :meth:`unlink`).
1017
+ """
1018
+ for link in self._yt.list_issue_links(issue_id):
1019
+ if link.get("id") != link_id:
1020
+ continue
1021
+ issues = link.get("issues", [])
1022
+ if len(issues) != 1:
1023
+ raise ValidationError(
1024
+ f"Link {link_id} on {issue_id} has {len(issues)} targets; "
1025
+ "use 'kd /issue/link remove ID TYPE TARGET' to disambiguate"
1026
+ )
1027
+ target = issues[0]
1028
+ self._yt.delete_issue_link(issue_id, link_id, target["id"])
1029
+ return
1030
+ raise NotFoundError(f"No link with id '{link_id}' on {issue_id}")
1031
+
963
1032
  def assign(self, issue_id: str, user_ref: str) -> User:
964
1033
  """Assign an issue. Returns the resolved user.
965
1034
 
@@ -4,6 +4,29 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.8.0] — 2026-05-07
8
+
9
+ ### Added
10
+ - `kd /issue/link remove ID TYPE TARGET` and
11
+ `kd /issue/link remove ID --by-id LINK_ID` close the gap that left
12
+ operators in the YouTrack web UI to detach issues. `kd /issue/link list`
13
+ now surfaces link ids so the `--by-id` form is usable end-to-end.
14
+ Removal uses the documented `DELETE /api/issues/{id}/links/{linkID}/issues/{targetID}`
15
+ path, not the commands API (KDCLI-190).
16
+ - `kd /article show` gains `--preview` (first 40 lines), `--body-lines N`,
17
+ and `--no-content` for previewing or skipping the body on long articles.
18
+ Truncation lives in the CLI layer; the structured envelope adds
19
+ `content_truncated`, `content_chars`, `content_lines`, and
20
+ `content_preview_lines` only when truncation applies; `--no-content`
21
+ drops `content` and adds `content_omitted: true`. Default behaviour is
22
+ unchanged — full body, no new keys (KDCLI-188).
23
+
24
+ ### Fixed
25
+ - `kd /article tree` now renders the full tree at every branching factor.
26
+ The table/markdown renderer previously expanded only the first sibling's
27
+ subtree at each level; structured output (`-o yaml` / `-o json`) was
28
+ unaffected (KDCLI-189).
29
+
7
30
  ## [2.7.1] — 2026-05-02
8
31
 
9
32
  ### Fixed
@@ -200,7 +200,7 @@ Primer mutations are serialized per file through an advisory lock on `.kd/primer
200
200
 
201
201
  ## Articles And Push
202
202
 
203
- Use `/article show` for metadata plus rendered content. Use `/article pull` when you need raw article content on stdout; `pull` is format-agnostic and ignores `--output`.
203
+ Use `/article show` for metadata plus rendered content. Pass `--preview` (sugar for `--body-lines 40`) or `--body-lines N` for an N-line head; the structured envelope gains `content_truncated`, `content_chars`, `content_lines`, and `content_preview_lines` only when truncation actually applies. Pass `--no-content` for metadata only (drops `content`, sets `content_omitted: true`). `--no-content` is mutually exclusive with `--body-lines`/`--preview`. Use `/article pull` when you need raw article content on stdout; `pull` is format-agnostic and ignores `--output`.
204
204
 
205
205
  `/article push` is an idempotent file-to-YouTrack workflow. The local file gains a `<!-- kd-meta ... -->` marker exactly once — on the first push that creates or resolves the upstream article (or to repair a stale `upstream-id`). Subsequent pushes do not modify the file. Commit the one-time marker insertion with the content; from there `git status` stays clean across pushes. The marker is identity (`slug` + `upstream-id`), not tool state.
206
206
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kd-cli
3
- Version: 2.7.1
3
+ Version: 2.8.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.7.1"
7
+ version = "2.8.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
File without changes
File without changes
File without changes