kd-cli 2.7.0__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.0/kd_cli.egg-info → kd_cli-2.8.0}/PKG-INFO +1 -1
  2. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/youtrack.py +3 -0
  3. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/article/__init__.py +94 -3
  4. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/issue/__init__.py +70 -0
  5. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/output.py +23 -25
  6. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/client.py +107 -0
  7. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/CHANGELOG.md +33 -0
  8. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/operating-guide.md +2 -2
  9. {kd_cli-2.7.0 → kd_cli-2.8.0/kd_cli.egg-info}/PKG-INFO +1 -1
  10. {kd_cli-2.7.0 → kd_cli-2.8.0}/pyproject.toml +1 -1
  11. {kd_cli-2.7.0 → kd_cli-2.8.0}/LICENSE +0 -0
  12. {kd_cli-2.7.0 → kd_cli-2.8.0}/MANIFEST.in +0 -0
  13. {kd_cli-2.7.0 → kd_cli-2.8.0}/README.md +0 -0
  14. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/__init__.py +0 -0
  15. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/__init__.py +0 -0
  16. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/articles.py +0 -0
  17. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/http.py +0 -0
  18. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/hub.py +0 -0
  19. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/saved_queries.py +0 -0
  20. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/tags.py +0 -0
  21. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/article_marker.py +0 -0
  22. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/auth.py +0 -0
  23. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cache.py +0 -0
  24. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/__init__.py +0 -0
  25. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/__init__.py +0 -0
  26. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/command_base.py +0 -0
  27. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/command_parser.py +0 -0
  28. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/confirm.py +0 -0
  29. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/namespaces.py +0 -0
  30. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/option_registry.py +0 -0
  31. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/input.py +0 -0
  32. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/main.py +0 -0
  33. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/__init__.py +0 -0
  34. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/alias/__init__.py +0 -0
  35. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/cache/__init__.py +0 -0
  36. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/init.py +0 -0
  37. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/primer/__init__.py +0 -0
  38. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/project/__init__.py +0 -0
  39. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/query/__init__.py +0 -0
  40. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/release_notes.py +0 -0
  41. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/search/__init__.py +0 -0
  42. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/system/__init__.py +0 -0
  43. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/tag/__init__.py +0 -0
  44. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/time/__init__.py +0 -0
  45. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/user/__init__.py +0 -0
  46. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/tree.py +0 -0
  47. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config.py +0 -0
  48. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config_read.py +0 -0
  49. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config_schema.py +0 -0
  50. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/connection.py +0 -0
  51. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/debug.py +0 -0
  52. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/exceptions.py +0 -0
  53. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/history.py +0 -0
  54. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/locking.py +0 -0
  55. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/__init__.py +0 -0
  56. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/article.py +0 -0
  57. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/assist.py +0 -0
  58. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/attachment.py +0 -0
  59. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/comment.py +0 -0
  60. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/common.py +0 -0
  61. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/issue.py +0 -0
  62. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/project.py +0 -0
  63. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/saved_query.py +0 -0
  64. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/search.py +0 -0
  65. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/tag.py +0 -0
  66. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/tag_entities.py +0 -0
  67. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/user.py +0 -0
  68. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/work_item.py +0 -0
  69. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/operating_guide.py +0 -0
  70. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/primer.py +0 -0
  71. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/query_parser.py +0 -0
  72. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/release_notes.py +0 -0
  73. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/__init__.py +0 -0
  74. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/command-synonyms.yaml +0 -0
  75. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/config-template.yaml +0 -0
  76. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/error-messages.yaml +0 -0
  77. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/primers/__init__.py +0 -0
  78. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/primers/hitl.yaml +0 -0
  79. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/sdlc-doctrine.md +0 -0
  80. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/sdlc.py +0 -0
  81. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/SOURCES.txt +0 -0
  82. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/dependency_links.txt +0 -0
  83. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/entry_points.txt +0 -0
  84. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/requires.txt +0 -0
  85. {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/top_level.txt +0 -0
  86. {kd_cli-2.7.0 → 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.0
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
 
@@ -141,6 +170,7 @@ class ArticleSearchCommand(Command):
141
170
  examples=[
142
171
  'kd /article search "project: {Code OnTime} {wix}"',
143
172
  'kd /article search "project: {Intellect Solutions} content: HKEY_LOCAL_MACHINE"',
173
+ 'kd /article search "project: {COT} tag: {kind:proposal}"',
144
174
  'kd /article search "summary: timeout" --limit 10',
145
175
  ],
146
176
  see_also=["/article list", "/search", "/tag articles"],
@@ -178,18 +208,79 @@ class ArticleShowCommand(Command):
178
208
  "show",
179
209
  "Show article details",
180
210
  requires_client=True,
181
- usage="kd /article show ARTICLE_ID",
182
- 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
+ ],
183
231
  see_also=["/article list", "/article children", "/article pull"],
184
232
  )
185
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
+
186
264
  def execute(
187
265
  self, args: list[str], options: ResolvedOptions, client: Any = None
188
266
  ) -> CommandResult:
189
267
  if not args:
190
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)
191
279
  article = client.articles.get(args[0])
192
- 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
193
284
 
194
285
 
195
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
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import builtins
22
22
  import difflib
23
+ import re
23
24
  import shlex
24
25
  from collections import OrderedDict
25
26
  from collections.abc import Callable
@@ -104,6 +105,22 @@ _LINK_TYPE_MAP: dict[str, str] = {
104
105
  "relates-to": "relates to",
105
106
  }
106
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
+
107
124
 
108
125
  def _issue_to_entity_ref(issue: Issue) -> EntityRef:
109
126
  """Build a cross-entity ``EntityRef`` row from an :class:`Issue`."""
@@ -959,6 +976,59 @@ class IssueOperations:
959
976
  command_str = _resolve_link_command(link_type)
960
977
  self._yt.apply_command(issue_id, f"{command_str} {target_id}")
961
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
+
962
1032
  def assign(self, issue_id: str, user_ref: str) -> User:
963
1033
  """Assign an issue. Returns the resolved user.
964
1034
 
@@ -1345,6 +1415,36 @@ def _is_unknown_query_param(exc: APIError) -> bool:
1345
1415
  return ("parameter" in body) and ("unknown" in body or "unrecognized" in body)
1346
1416
 
1347
1417
 
1418
+ _TAG_BARE_COLON_LITERAL_RE = re.compile(r"\btag\s*:\s*(?!\{)\S*:\S*")
1419
+
1420
+
1421
+ def _is_tag_literal_misparse(exc: APIError, query: str) -> bool:
1422
+ """Heuristic: did YT's article query DSL parse a colon inside a
1423
+ bare ``tag:`` literal as a field separator?
1424
+
1425
+ Two gates, both required:
1426
+
1427
+ 1. The 400 body matches the observed phrasing — *"... isn't used
1428
+ for the tag field"* or its non-curly variant.
1429
+ 2. The user's *input query* contains a ``tag:`` clause followed
1430
+ by a non-brace token that itself contains a colon. Without
1431
+ this the same upstream phrase fires for legitimate unknown-tag
1432
+ errors (e.g. ``tag: NOPE``), where the brace-wrapping hint
1433
+ would be misleading.
1434
+
1435
+ The regex is intentionally conservative — it does not understand
1436
+ quoted strings or full YT-DSL grammar; that is what the M012
1437
+ verbatim-passthrough contract trades away. False negatives prefer
1438
+ the no-hint path (raw YT message still surfaces) over the noisy
1439
+ false positive of suggesting braces when no colon was typed.
1440
+ """
1441
+ body = (exc.response_body or "").lower()
1442
+ body_match = "isn't used for the tag field" in body or "is not used for the tag field" in body
1443
+ if not body_match:
1444
+ return False
1445
+ return _TAG_BARE_COLON_LITERAL_RE.search(query) is not None
1446
+
1447
+
1348
1448
  class ArticleOperations:
1349
1449
  """High-level knowledge base article operations."""
1350
1450
 
@@ -1392,6 +1492,13 @@ class ArticleOperations:
1392
1492
  except APIError as exc:
1393
1493
  if exc.status_code == 400 and _is_unknown_query_param(exc):
1394
1494
  raise ValidationError(_CAPABILITY_MISSING_MSG) from exc
1495
+ if exc.status_code == 400 and _is_tag_literal_misparse(exc, query):
1496
+ raise ValidationError(
1497
+ f"{exc}\n"
1498
+ "Hint: tag names with colons need brace wrapping in YouTrack "
1499
+ "query syntax — use `tag: {kind:proposal}`, not "
1500
+ "`tag: kind:proposal`. See `kd --guide` §YouTrack Gotchas."
1501
+ ) from exc
1395
1502
  if exc.status_code == 400:
1396
1503
  raise ValidationError(str(exc)) from exc
1397
1504
  raise
@@ -4,6 +4,39 @@ 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
+
30
+ ## [2.7.1] — 2026-05-02
31
+
32
+ ### Fixed
33
+ - `kd /article search` now appends a brace-wrapping hint when YouTrack returns
34
+ the *"… isn't used for the tag field"* 400 caused by a bare colon-bearing
35
+ `tag:` literal (e.g. `tag: kind:proposal`). The upstream message stays
36
+ visible; kd points to the fix — `tag: {kind:proposal}` — and references
37
+ `kd --guide` §YouTrack Gotchas. Docs and `kd /article search --help` now
38
+ show the brace form (KDCLI-186).
39
+
7
40
  ## [2.7.0] — 2026-05-02
8
41
 
9
42
  This release adds server-backed full-text search for knowledge-base
@@ -112,7 +112,7 @@ YouTrack's article query API is undocumented but supported on the YT tiers kd ta
112
112
 
113
113
  Saved queries are YouTrack issue-search objects. `/query issues` executes them directly. `/query articles` only translates supported `project:` and `tag:` fields into article scans; issue-specific fields such as state or assignee are rejected for articles.
114
114
 
115
- Tag names with colons or spaces need YouTrack query literal handling. `tag: type:case` is wrong; YouTrack reads `type` as the tag value and `case` as another field. Use `tag: {type:case}` in hand-written queries. kd command paths that compose tag queries use the shared literal helper.
115
+ Tag names with colons or spaces need YouTrack query literal handling in both issue and article queries (the article query DSL is the same syntax family). `tag: type:case` is wrong; YouTrack reads `type` as the tag value and `case` as another field. Use `tag: {type:case}` in hand-written queries — for `/issue list --query` and `/article search` alike. kd command paths that compose tag queries use the shared literal helper; `/article search` preserves the YouTrack 400 and appends a brace-wrapping hint when a bare colon-bearing `tag:` literal trips the misparse.
116
116
 
117
117
  Repeated same-field clauses in YouTrack query syntax default to OR. `tag: A tag: B` matches either tag. kd multi-tag issue operations join tag clauses with explicit `and` when intersection is required.
118
118
 
@@ -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.0
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.0"
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