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.
- {kd_cli-2.7.0/kd_cli.egg-info → kd_cli-2.8.0}/PKG-INFO +1 -1
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/youtrack.py +3 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/article/__init__.py +94 -3
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/issue/__init__.py +70 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/output.py +23 -25
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/client.py +107 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/CHANGELOG.md +33 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/operating-guide.md +2 -2
- {kd_cli-2.7.0 → kd_cli-2.8.0/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.7.0 → kd_cli-2.8.0}/pyproject.toml +1 -1
- {kd_cli-2.7.0 → kd_cli-2.8.0}/LICENSE +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/MANIFEST.in +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/README.md +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/articles.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/http.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/hub.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/api/tags.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/article_marker.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/auth.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cache.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/input.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/main.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config_read.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/config_schema.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/connection.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/debug.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/exceptions.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/history.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/locking.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/article.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/assist.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/comment.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/common.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/issue.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/project.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/search.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/tag.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/user.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/primer.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/query_parser.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/release_notes.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd/sdlc.py +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.7.0 → kd_cli-2.8.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.7.0 → 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
|
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
|