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.
- {kd_cli-2.7.1/kd_cli.egg-info → kd_cli-2.8.0}/PKG-INFO +1 -1
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/youtrack.py +3 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/article/__init__.py +93 -3
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/issue/__init__.py +70 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/output.py +23 -25
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/client.py +69 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/CHANGELOG.md +23 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/operating-guide.md +1 -1
- {kd_cli-2.7.1 → kd_cli-2.8.0/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.7.1 → kd_cli-2.8.0}/pyproject.toml +1 -1
- {kd_cli-2.7.1 → kd_cli-2.8.0}/LICENSE +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/MANIFEST.in +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/README.md +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/articles.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/http.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/hub.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/api/tags.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/article_marker.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/auth.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cache.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/input.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/main.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config_read.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/config_schema.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/connection.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/debug.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/exceptions.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/history.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/locking.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/article.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/assist.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/comment.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/common.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/issue.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/project.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/search.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/tag.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/user.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/primer.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/query_parser.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/release_notes.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd/sdlc.py +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.7.1 → kd_cli-2.8.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|