kd-cli 2.7.1__tar.gz → 2.8.1__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.1}/PKG-INFO +1 -1
  2. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/articles.py +1 -1
  3. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/youtrack.py +7 -2
  4. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cache.py +1 -0
  5. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/article/__init__.py +94 -3
  6. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/issue/__init__.py +72 -0
  7. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/output.py +23 -25
  8. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/client.py +69 -0
  9. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/comment.py +2 -1
  10. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/CHANGELOG.md +32 -0
  11. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/operating-guide.md +1 -1
  12. {kd_cli-2.7.1 → kd_cli-2.8.1/kd_cli.egg-info}/PKG-INFO +1 -1
  13. {kd_cli-2.7.1 → kd_cli-2.8.1}/pyproject.toml +1 -1
  14. {kd_cli-2.7.1 → kd_cli-2.8.1}/LICENSE +0 -0
  15. {kd_cli-2.7.1 → kd_cli-2.8.1}/MANIFEST.in +0 -0
  16. {kd_cli-2.7.1 → kd_cli-2.8.1}/README.md +0 -0
  17. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/__init__.py +0 -0
  18. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/__init__.py +0 -0
  19. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/http.py +0 -0
  20. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/hub.py +0 -0
  21. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/saved_queries.py +0 -0
  22. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/api/tags.py +0 -0
  23. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/article_marker.py +0 -0
  24. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/auth.py +0 -0
  25. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/__init__.py +0 -0
  26. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/__init__.py +0 -0
  27. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/command_base.py +0 -0
  28. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/command_parser.py +0 -0
  29. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/confirm.py +0 -0
  30. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/namespaces.py +0 -0
  31. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/core/option_registry.py +0 -0
  32. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/input.py +0 -0
  33. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/main.py +0 -0
  34. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/__init__.py +0 -0
  35. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/alias/__init__.py +0 -0
  36. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/cache/__init__.py +0 -0
  37. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/init.py +0 -0
  38. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/primer/__init__.py +0 -0
  39. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/project/__init__.py +0 -0
  40. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/query/__init__.py +0 -0
  41. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/release_notes.py +0 -0
  42. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/search/__init__.py +0 -0
  43. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/system/__init__.py +0 -0
  44. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/tag/__init__.py +0 -0
  45. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/time/__init__.py +0 -0
  46. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/namespaces/user/__init__.py +0 -0
  47. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/cli/tree.py +0 -0
  48. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/config.py +0 -0
  49. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/config_read.py +0 -0
  50. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/config_schema.py +0 -0
  51. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/connection.py +0 -0
  52. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/debug.py +0 -0
  53. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/exceptions.py +0 -0
  54. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/history.py +0 -0
  55. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/locking.py +0 -0
  56. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/__init__.py +0 -0
  57. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/article.py +0 -0
  58. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/assist.py +0 -0
  59. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/attachment.py +0 -0
  60. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/common.py +0 -0
  61. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/issue.py +0 -0
  62. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/project.py +0 -0
  63. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/search.py +0 -0
  65. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/tag.py +0 -0
  66. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/user.py +0 -0
  68. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/primer.py +0 -0
  71. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/query_parser.py +0 -0
  72. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/release_notes.py +0 -0
  73. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd/sdlc.py +0 -0
  81. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.7.1 → kd_cli-2.8.1}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.7.1 → kd_cli-2.8.1}/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.1
4
4
  Summary: Knowledge Dispatcher for issue trackers and knowledge bases
5
5
  Author: Sergiy Gladkyy
6
6
  License-Expression: MIT
@@ -77,7 +77,7 @@ _ARTICLE_DETAIL_FIELDS = str(
77
77
  )
78
78
 
79
79
 
80
- _ARTICLE_COMMENT_FIELDS = "id,text,pinned,author(id,login,name),created,updated"
80
+ _ARTICLE_COMMENT_FIELDS = "id,text,deleted,pinned,author(id,login,name),created,updated"
81
81
  _ARTICLE_ATTACHMENT_FIELDS = (
82
82
  "id,name,size,mimeType,extension,author(id,login,name),created,updated,url"
83
83
  )
@@ -127,7 +127,9 @@ _ISSUE_RICH_FIELDS = str(
127
127
  project=FieldSet("id", "name", "shortName"),
128
128
  reporter=FieldSet("id", "login", "name"),
129
129
  customFields=FieldSet("name", value=FieldSet("name")),
130
- comments=FieldSet("id", "text", "created", author=FieldSet("id", "login", "name")),
130
+ comments=FieldSet(
131
+ "id", "text", "deleted", "created", author=FieldSet("id", "login", "name")
132
+ ),
131
133
  links=FieldSet(
132
134
  "id",
133
135
  "direction",
@@ -140,7 +142,7 @@ _ISSUE_RICH_FIELDS = str(
140
142
  tags=FieldSet("id", "name"),
141
143
  )
142
144
  )
143
- _COMMENT_FIELDS = "id,text,author(id,login,name),created,updated"
145
+ _COMMENT_FIELDS = "id,text,deleted,author(id,login,name),created,updated"
144
146
  _WORK_ITEM_FIELDS = (
145
147
  "id,duration(minutes),text,type(id,name,autoAttached),author(id,login,name),date"
146
148
  )
@@ -438,6 +440,9 @@ class YouTrackAPI:
438
440
  )
439
441
  return data
440
442
 
443
+ def delete_issue_link(self, issue_id: str, link_id: str, target_id: str) -> None:
444
+ self._http.delete(f"/issues/{issue_id}/links/{link_id}/issues/{target_id}")
445
+
441
446
  def apply_command(self, issue_id: str, command: str) -> None:
442
447
  """Apply a command to an issue via the commands API.
443
448
 
@@ -2150,6 +2150,7 @@ def _issue_to_cache_payload(
2150
2150
  "author": c.author.name if c.author else "",
2151
2151
  "created": c.created,
2152
2152
  "text": c.text,
2153
+ "deleted": c.deleted,
2153
2154
  }
2154
2155
  for c in (issue.comments or [])
2155
2156
  ]
@@ -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):
@@ -746,6 +836,7 @@ class ArticleCommentListCommand(Command):
746
836
  "text": c.text,
747
837
  "pinned": c.pinned,
748
838
  "created": c.created,
839
+ "deleted": c.deleted,
749
840
  }
750
841
  for c in comments
751
842
  ]
@@ -176,6 +176,7 @@ def _issue_to_detail(issue: Issue, sections: set[str] | None = None) -> dict[str
176
176
  "author": c.author.name if c.author else "",
177
177
  "text": c.text,
178
178
  "created": c.created,
179
+ "deleted": c.deleted,
179
180
  }
180
181
  for c in issue.comments[-5:]
181
182
  ]
@@ -643,6 +644,7 @@ class CommentListCommand(Command):
643
644
  "author": c.author.name if c.author else "",
644
645
  "text": c.text,
645
646
  "created": c.created,
647
+ "deleted": c.deleted,
646
648
  }
647
649
  for c in comments
648
650
  ]
@@ -716,11 +718,13 @@ class LinkListCommand(Command):
716
718
  links = client.issues.list_links(args[0])
717
719
  rows: list[dict[str, Any]] = []
718
720
  for link in links:
721
+ link_id = link.get("id", "")
719
722
  link_type = link.get("linkType", {}).get("name", "")
720
723
  direction = link.get("direction", "")
721
724
  for issue in link.get("issues", []):
722
725
  rows.append(
723
726
  {
727
+ "id": link_id,
724
728
  "type": link_type,
725
729
  "direction": direction,
726
730
  "issue": issue.get("idReadable", ""),
@@ -772,6 +776,73 @@ class LinkAddCommand(Command):
772
776
  }
773
777
 
774
778
 
779
+ class LinkRemoveCommand(Command):
780
+ """Remove a link between two issues."""
781
+
782
+ def __init__(self) -> None:
783
+ super().__init__(
784
+ "remove",
785
+ "Remove a link between two issues",
786
+ requires_client=True,
787
+ usage="kd /issue/link remove ID TYPE TARGET | kd /issue/link remove ID --by-id LINK_ID",
788
+ long_description=(
789
+ "TYPE is one of: subtask-of, parent-for, depends-on, "
790
+ "required-for, duplicates, duplicated-by, relates-to. "
791
+ "Pass --by-id LINK_ID to disambiguate when the type+target "
792
+ "form is ambiguous; LINK_ID is the value surfaced in the "
793
+ "'id' column of `kd /issue/link list`. Returns a parseable "
794
+ "single-line ack: the last stdout line is "
795
+ "``ok: <ID> unlinked type=<TYPE> target=<TARGET>`` or "
796
+ "``ok: <ID> unlinked link_id=<LINK_ID>``. Pass --quiet to "
797
+ "suppress the detail block above the ack."
798
+ ),
799
+ examples=[
800
+ "kd /issue/link remove FB-42 subtask-of FB-1",
801
+ "kd /issue/link remove FB-42 --by-id 80-2s",
802
+ ],
803
+ see_also=["/issue/link add", "/issue/link list"],
804
+ )
805
+ self.display_hint = "mutation"
806
+
807
+ def _register_options(self) -> None:
808
+ self.register_option(
809
+ OptionDeclaration(
810
+ name="by-id",
811
+ layer=OptionLayer.COMMAND,
812
+ help_text="Remove the link with the given YT-internal link id (from `link list`)",
813
+ )
814
+ )
815
+
816
+ def execute(
817
+ self, args: list[str], options: ResolvedOptions, client: Any = None
818
+ ) -> CommandResult:
819
+ link_id = options.command.get("by-id")
820
+ if link_id:
821
+ if len(args) != 1:
822
+ raise ValidationError("Usage: kd /issue/link remove ID --by-id LINK_ID")
823
+ issue_id = args[0]
824
+ client.issues.unlink_by_id(issue_id, link_id)
825
+ return {
826
+ "ok": True,
827
+ "action": "unlinked",
828
+ "id": issue_id,
829
+ "link_id": link_id,
830
+ }
831
+ if len(args) != 3:
832
+ raise ValidationError("Usage: kd /issue/link remove ID TYPE TARGET")
833
+ issue_id = args[0]
834
+ link_type = args[1]
835
+ target_id = args[2]
836
+ client.issues.unlink(issue_id, link_type, target_id)
837
+ return {
838
+ "ok": True,
839
+ "action": "unlinked",
840
+ "id": issue_id,
841
+ "type": link_type,
842
+ "target": target_id,
843
+ }
844
+
845
+
775
846
  # --- Assign / Unassign / Move ---
776
847
 
777
848
 
@@ -1282,6 +1353,7 @@ def create_issue_namespace() -> Namespace:
1282
1353
  link_ns = Namespace("link", "Issue links", path="issue/link")
1283
1354
  link_ns.register_command("list", LinkListCommand())
1284
1355
  link_ns.register_command("add", LinkAddCommand())
1356
+ link_ns.register_command("remove", LinkRemoveCommand())
1285
1357
  ns.register_sub_namespace(link_ns)
1286
1358
 
1287
1359
  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
 
@@ -12,7 +12,8 @@ class Comment(IdEntity):
12
12
 
13
13
  model_config = ConfigDict(populate_by_name=True)
14
14
 
15
- text: str
15
+ text: str | None = None
16
+ deleted: bool = False
16
17
  author: NamedEntity | None = None
17
18
  created: int | None = None
18
19
  updated: int | None = None
@@ -4,6 +4,38 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.8.1] — 2026-05-08
8
+
9
+ ### Fixed
10
+ - `kd /issue show` and `kd /issue/comment list` no longer crash on
11
+ tombstoned (soft-deleted) comments. Structured output surfaces
12
+ `text: null, deleted: true` for tombstoned rows so operators can
13
+ see the row exists without fabricating content. Article comments
14
+ share the same fix (KDCLI-192).
15
+
16
+ ## [2.8.0] — 2026-05-07
17
+
18
+ ### Added
19
+ - `kd /issue/link remove ID TYPE TARGET` and
20
+ `kd /issue/link remove ID --by-id LINK_ID` close the gap that left
21
+ operators in the YouTrack web UI to detach issues. `kd /issue/link list`
22
+ now surfaces link ids so the `--by-id` form is usable end-to-end.
23
+ Removal uses the documented `DELETE /api/issues/{id}/links/{linkID}/issues/{targetID}`
24
+ path, not the commands API (KDCLI-190).
25
+ - `kd /article show` gains `--preview` (first 40 lines), `--body-lines N`,
26
+ and `--no-content` for previewing or skipping the body on long articles.
27
+ Truncation lives in the CLI layer; the structured envelope adds
28
+ `content_truncated`, `content_chars`, `content_lines`, and
29
+ `content_preview_lines` only when truncation applies; `--no-content`
30
+ drops `content` and adds `content_omitted: true`. Default behaviour is
31
+ unchanged — full body, no new keys (KDCLI-188).
32
+
33
+ ### Fixed
34
+ - `kd /article tree` now renders the full tree at every branching factor.
35
+ The table/markdown renderer previously expanded only the first sibling's
36
+ subtree at each level; structured output (`-o yaml` / `-o json`) was
37
+ unaffected (KDCLI-189).
38
+
7
39
  ## [2.7.1] — 2026-05-02
8
40
 
9
41
  ### 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.1
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.1"
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