kd-cli 2.8.0__tar.gz → 2.9.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.8.0/kd_cli.egg-info → kd_cli-2.9.0}/PKG-INFO +1 -1
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/articles.py +1 -1
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/youtrack.py +4 -2
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cache.py +1 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/article/__init__.py +1 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/issue/__init__.py +2 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/tag/__init__.py +95 -24
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/client.py +427 -27
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/comment.py +2 -1
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/CHANGELOG.md +41 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/operating-guide.md +18 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.8.0 → kd_cli-2.9.0}/pyproject.toml +1 -1
- {kd_cli-2.8.0 → kd_cli-2.9.0}/LICENSE +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/MANIFEST.in +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/README.md +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/http.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/hub.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/api/tags.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/article_marker.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/auth.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/input.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/main.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/output.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/config.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/config_read.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/config_schema.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/connection.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/debug.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/exceptions.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/history.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/locking.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/article.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/assist.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/common.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/issue.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/project.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/search.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/tag.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/user.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/primer.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/query_parser.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/release_notes.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd/sdlc.py +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.8.0 → kd_cli-2.9.0}/setup.cfg +0 -0
|
@@ -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(
|
|
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
|
)
|
|
@@ -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
|
]
|
|
@@ -138,8 +138,18 @@ class TagShowCommand(Command):
|
|
|
138
138
|
"show",
|
|
139
139
|
"Show tag details",
|
|
140
140
|
requires_client=True,
|
|
141
|
-
usage="kd /tag show
|
|
142
|
-
|
|
141
|
+
usage="kd /tag show TAG_NAME_OR_ID",
|
|
142
|
+
long_description=(
|
|
143
|
+
"Accepts either a display name or the tag-id form "
|
|
144
|
+
"(NN-NN); the id form skips name resolution and is the "
|
|
145
|
+
"disambiguator when two visible tags share a name. A "
|
|
146
|
+
"name that resolves to multiple visible tags refuses "
|
|
147
|
+
"with a candidate list — pass the tag id to pick."
|
|
148
|
+
),
|
|
149
|
+
examples=[
|
|
150
|
+
"kd /tag show sprint-47",
|
|
151
|
+
"kd /tag show 10-178",
|
|
152
|
+
],
|
|
143
153
|
see_also=["/tag list", "/tag issues"],
|
|
144
154
|
)
|
|
145
155
|
|
|
@@ -162,13 +172,16 @@ class TagCreateCommand(Command):
|
|
|
162
172
|
requires_client=True,
|
|
163
173
|
usage="kd /tag create TAG_NAME",
|
|
164
174
|
long_description=(
|
|
165
|
-
"Idempotent: if
|
|
166
|
-
"visible, the command returns ``action: noop``
|
|
167
|
-
"not POST. Otherwise the tag is created with "
|
|
175
|
+
"Idempotent: if exactly one tag with the requested name "
|
|
176
|
+
"is already visible, the command returns ``action: noop`` "
|
|
177
|
+
"and does not POST. Otherwise the tag is created with "
|
|
168
178
|
"``action: created``. Returns a parseable single-line ack: "
|
|
169
179
|
"the last stdout line is ``ok: <NAME> created`` or "
|
|
170
180
|
"``ok: <NAME> noop``. Name resolution is case-insensitive "
|
|
171
|
-
"(same resolver /tag show uses).
|
|
181
|
+
"(same resolver /tag show uses). When the name collides "
|
|
182
|
+
"with multiple visible tags, create refuses with a "
|
|
183
|
+
"candidate list rather than picking one — inspect them "
|
|
184
|
+
"with ``kd /tag show <id>``. Pass --quiet to suppress "
|
|
172
185
|
"the detail block above the ack."
|
|
173
186
|
),
|
|
174
187
|
examples=['kd /tag create "sprint-47"'],
|
|
@@ -206,17 +219,25 @@ class TagRenameCommand(Command):
|
|
|
206
219
|
"rename",
|
|
207
220
|
"Rename a tag",
|
|
208
221
|
requires_client=True,
|
|
209
|
-
usage="kd /tag rename
|
|
222
|
+
usage="kd /tag rename TAG_NAME_OR_ID NEW_NAME",
|
|
210
223
|
long_description=(
|
|
211
224
|
"Returns a parseable single-line ack: the last stdout line "
|
|
212
225
|
"is ``ok: <NEW_NAME> renamed old_name=<TAG_NAME>``. The "
|
|
213
226
|
"tail-line ID is the **new name**, since scripts that "
|
|
214
227
|
"follow the rename refer to the tag by its new identity. "
|
|
215
228
|
"The previous name is preserved in ``old_name=`` so "
|
|
216
|
-
"audit/log consumers can correlate.
|
|
217
|
-
"
|
|
229
|
+
"audit/log consumers can correlate. The first argument "
|
|
230
|
+
"accepts either a display name or the tag-id form "
|
|
231
|
+
"(NN-NN); the id form skips name resolution and is the "
|
|
232
|
+
"disambiguator when two visible tags share a name. A "
|
|
233
|
+
"name that resolves to multiple visible tags refuses "
|
|
234
|
+
"with a candidate list — pass the tag id to pick. Pass "
|
|
235
|
+
"--quiet to suppress the detail block above the ack."
|
|
218
236
|
),
|
|
219
|
-
examples=[
|
|
237
|
+
examples=[
|
|
238
|
+
'kd /tag rename sprint-47 "sprint-48"',
|
|
239
|
+
'kd /tag rename 10-178 "lifecycle:final"',
|
|
240
|
+
],
|
|
220
241
|
see_also=["/tag show"],
|
|
221
242
|
)
|
|
222
243
|
self.display_hint = "mutation"
|
|
@@ -244,15 +265,23 @@ class TagDeleteCommand(Command):
|
|
|
244
265
|
"delete",
|
|
245
266
|
"Delete a tag",
|
|
246
267
|
requires_client=True,
|
|
247
|
-
usage="kd /tag delete
|
|
268
|
+
usage="kd /tag delete TAG_NAME_OR_ID",
|
|
248
269
|
long_description=(
|
|
249
270
|
"Returns a parseable single-line ack: the last stdout line "
|
|
250
271
|
"is ``ok: <NAME> deleted``. --confirm is required and is "
|
|
251
272
|
"**not** softened by --quiet — the safety gate fires "
|
|
252
|
-
"before any envelope is rendered.
|
|
253
|
-
"the
|
|
273
|
+
"before any envelope is rendered. The argument accepts "
|
|
274
|
+
"either a display name or the tag-id form (NN-NN); the "
|
|
275
|
+
"id form skips name resolution and is the disambiguator "
|
|
276
|
+
"when two visible tags share a name. A name that "
|
|
277
|
+
"resolves to multiple visible tags refuses with a "
|
|
278
|
+
"candidate list — pass the tag id to pick. Pass "
|
|
279
|
+
"--quiet to suppress the detail block above the ack."
|
|
254
280
|
),
|
|
255
|
-
examples=[
|
|
281
|
+
examples=[
|
|
282
|
+
"kd /tag delete sprint-47 --confirm",
|
|
283
|
+
"kd /tag delete 10-178 --confirm",
|
|
284
|
+
],
|
|
256
285
|
see_also=["/tag list", "/tag create"],
|
|
257
286
|
)
|
|
258
287
|
self.display_hint = "mutation"
|
|
@@ -298,13 +327,20 @@ class TagAddCommand(Command):
|
|
|
298
327
|
"add",
|
|
299
328
|
"Tag an issue or article",
|
|
300
329
|
requires_client=True,
|
|
301
|
-
usage="kd /tag add ENTITY_ID
|
|
330
|
+
usage="kd /tag add ENTITY_ID TAG_NAME_OR_ID",
|
|
302
331
|
long_description=(
|
|
303
|
-
"Entity type is auto-detected from the ID format: FB-123 =
|
|
332
|
+
"Entity type is auto-detected from the ID format: FB-123 = "
|
|
333
|
+
"issue, FB-A-1 = article. The tag argument accepts either a "
|
|
334
|
+
"display name or the tag-id form (NN-NN); the id form skips "
|
|
335
|
+
"name resolution and is the disambiguator when two tags share "
|
|
336
|
+
"a name. Adding a name already attached to the entity is an "
|
|
337
|
+
"idempotent re-tag; adding a name that has multiple visible "
|
|
338
|
+
"tags refuses with a candidate list — pass the tag id to pick."
|
|
304
339
|
),
|
|
305
340
|
examples=[
|
|
306
341
|
"kd /tag add FB-123 sprint-47",
|
|
307
342
|
"kd /tag add FB-A-1 reviewed",
|
|
343
|
+
"kd /tag add FB-A-1 10-178",
|
|
308
344
|
],
|
|
309
345
|
see_also=["/tag remove", "/tag issues"],
|
|
310
346
|
)
|
|
@@ -327,10 +363,15 @@ class TagRemoveCommand(Command):
|
|
|
327
363
|
"remove",
|
|
328
364
|
"Untag an issue or article",
|
|
329
365
|
requires_client=True,
|
|
330
|
-
usage="kd /tag remove ENTITY_ID
|
|
366
|
+
usage="kd /tag remove ENTITY_ID TAG_NAME_OR_ID",
|
|
331
367
|
long_description=(
|
|
332
368
|
"Entity type is auto-detected from the ID format: FB-123 = "
|
|
333
|
-
"issue, FB-A-1 = article. "
|
|
369
|
+
"issue, FB-A-1 = article. The tag argument is resolved "
|
|
370
|
+
"against the tags attached to the entity — names that are "
|
|
371
|
+
"not attached refuse loudly even when a same-name tag "
|
|
372
|
+
"exists globally. Pass the tag-id form (NN-NN) to skip name "
|
|
373
|
+
"resolution; this is the disambiguator when multiple "
|
|
374
|
+
"same-name tags are attached. "
|
|
334
375
|
"Returns a parseable single-line ack: the last stdout line "
|
|
335
376
|
"is ``ok: <ENTITY_ID> removed tag=<TAG_NAME>``. The "
|
|
336
377
|
"entity ID is the primary tail-line ID (``$2``); the "
|
|
@@ -340,6 +381,7 @@ class TagRemoveCommand(Command):
|
|
|
340
381
|
examples=[
|
|
341
382
|
"kd /tag remove FB-123 sprint-47",
|
|
342
383
|
"kd /tag remove FB-A-1 reviewed",
|
|
384
|
+
"kd /tag remove FB-A-1 10-178",
|
|
343
385
|
],
|
|
344
386
|
see_also=["/tag add", "/tag issues"],
|
|
345
387
|
)
|
|
@@ -363,16 +405,26 @@ class TagIssuesCommand(Command):
|
|
|
363
405
|
"issues",
|
|
364
406
|
"List issues with a tag",
|
|
365
407
|
requires_client=True,
|
|
366
|
-
usage="kd /tag issues
|
|
408
|
+
usage="kd /tag issues TAG_NAME_OR_ID [--tag TAG ...] [--project P] [--limit N]",
|
|
367
409
|
long_description=(
|
|
368
|
-
"Lists issues tagged with the given tag.
|
|
410
|
+
"Lists issues tagged with the given tag. Single-tag uses the "
|
|
369
411
|
"fast server-side tag-issues endpoint; multi-tag composes a "
|
|
370
412
|
"YouTrack query and uses /issues?query=... with AND semantics. "
|
|
371
413
|
"Use --project to scope results to a single project (forces "
|
|
372
|
-
"the composed-query path)."
|
|
414
|
+
"the composed-query path). The tag argument accepts either a "
|
|
415
|
+
"display name or the tag-id form (NN-NN) on the fast path "
|
|
416
|
+
"(single tag, no --project); the id form skips name "
|
|
417
|
+
"resolution and is the disambiguator when two visible tags "
|
|
418
|
+
"share a name. On the composed-query path (--project set or "
|
|
419
|
+
"multiple --tag arguments), the id form is rejected because "
|
|
420
|
+
"YouTrack's ``tag:`` predicate is name-keyed — use the name "
|
|
421
|
+
"form (which refuses on duplicate-name collision) or the "
|
|
422
|
+
"fast path. A name that resolves to multiple visible tags "
|
|
423
|
+
"refuses with a candidate list before the query is composed."
|
|
373
424
|
),
|
|
374
425
|
examples=[
|
|
375
426
|
"kd /tag issues sprint-47",
|
|
427
|
+
"kd /tag issues 10-178",
|
|
376
428
|
"kd /tag issues blocked --limit 100",
|
|
377
429
|
"kd /tag issues kind:note --tag component:authservice",
|
|
378
430
|
"kd /tag issues kind:note --tag component:authservice --project KDCLI",
|
|
@@ -414,16 +466,22 @@ class TagArticlesCommand(Command):
|
|
|
414
466
|
"articles",
|
|
415
467
|
"List articles with a tag",
|
|
416
468
|
requires_client=True,
|
|
417
|
-
usage="kd /tag articles
|
|
469
|
+
usage="kd /tag articles TAG_NAME_OR_ID [--tag TAG ...] [--project P] [--limit N]",
|
|
418
470
|
long_description=(
|
|
419
471
|
"Lists articles tagged with the given tag. Uses client-side "
|
|
420
472
|
"filtering because the YouTrack API has no server-side "
|
|
421
473
|
"article-by-tag endpoint. Use --project to scope the scan "
|
|
422
474
|
"to a single project. Repeat --tag to require multiple tags "
|
|
423
|
-
"(AND semantics)."
|
|
475
|
+
"(AND semantics). Every tag argument accepts either a "
|
|
476
|
+
"display name or the tag-id form (NN-NN); the id form "
|
|
477
|
+
"filters precisely by id and is the disambiguator when "
|
|
478
|
+
"two visible tags share a name. A name that resolves to "
|
|
479
|
+
"multiple visible tags refuses with a candidate list — "
|
|
480
|
+
"pass the tag id to pick."
|
|
424
481
|
),
|
|
425
482
|
examples=[
|
|
426
483
|
"kd /tag articles reviewed",
|
|
484
|
+
"kd /tag articles 10-178",
|
|
427
485
|
'kd /tag articles "type:guide" --project FB',
|
|
428
486
|
"kd /tag articles type:case --tag component:authservice -p CMCSA",
|
|
429
487
|
"kd /tag articles blocked --limit 100",
|
|
@@ -484,10 +542,23 @@ class TagEntitiesCommand(Command):
|
|
|
484
542
|
"--limit-issues and --limit-articles override per kind. "
|
|
485
543
|
"--primer-scope substitutes the primer's pinned tags as an "
|
|
486
544
|
"OR scope (match=any); mutually exclusive with the positional "
|
|
487
|
-
"TAG and --tag arguments."
|
|
545
|
+
"TAG and --tag arguments. "
|
|
546
|
+
"The tag argument accepts either a display name or the "
|
|
547
|
+
"tag-id form (NN-NN); the id form skips name resolution "
|
|
548
|
+
"and is the disambiguator when two visible tags share a "
|
|
549
|
+
"name. Id-form scope: allowed when the issue half routes "
|
|
550
|
+
"through the fast path (single tag, no --project) or when "
|
|
551
|
+
"only --kind article is requested (article filtering is "
|
|
552
|
+
"client-side by id). With --project or multiple tags AND "
|
|
553
|
+
"the issue kind included, id-form is rejected because "
|
|
554
|
+
"YouTrack's ``tag:`` predicate is name-keyed. Names are "
|
|
555
|
+
"pre-validated before the issue/article fanout — a "
|
|
556
|
+
"duplicate-name refusal is the whole-command outcome, not "
|
|
557
|
+
"a partial-success row."
|
|
488
558
|
),
|
|
489
559
|
examples=[
|
|
490
560
|
"kd /tag entities kind:note",
|
|
561
|
+
"kd /tag entities 10-178",
|
|
491
562
|
"kd /tag entities kind:note --tag component:authservice --project KDCLI",
|
|
492
563
|
"kd /tag entities kind:note --kind article --limit-articles 5",
|
|
493
564
|
"kd /tag entities kind:note --limit 20 -o json",
|
|
@@ -23,7 +23,7 @@ import difflib
|
|
|
23
23
|
import re
|
|
24
24
|
import shlex
|
|
25
25
|
from collections import OrderedDict
|
|
26
|
-
from collections.abc import Callable
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
27
|
from dataclasses import dataclass, field as dataclass_field
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
from pathlib import Path
|
|
@@ -2184,6 +2184,52 @@ def _detect_entity_type(entity_id: str) -> str:
|
|
|
2184
2184
|
return "article" if "-A-" in entity_id else "issue"
|
|
2185
2185
|
|
|
2186
2186
|
|
|
2187
|
+
_TAG_ID_RE = re.compile(r"\d+-\d+")
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
def _is_tag_id(s: str) -> bool:
|
|
2191
|
+
"""True if *s* matches YouTrack's tag-id shape (``\\d+-\\d+``).
|
|
2192
|
+
|
|
2193
|
+
Names following kd's namespace convention (``lifecycle:draft``)
|
|
2194
|
+
contain ``:`` and never match.
|
|
2195
|
+
"""
|
|
2196
|
+
return bool(_TAG_ID_RE.fullmatch(s))
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
# KDCLI-200: retry-line templates for the global tag resolver. Keyed by
|
|
2200
|
+
# command verb. ``{id}`` substitutes the candidate's tag id; ``rename``
|
|
2201
|
+
# carries a literal ``<new-name>`` placeholder because the operator
|
|
2202
|
+
# supplies the new name on retry. ``create`` has no valid retry shape
|
|
2203
|
+
# (creating with an existing id is meaningless), so it is absent from
|
|
2204
|
+
# this table and uses ``_TAG_INSPECT_HINTS`` instead.
|
|
2205
|
+
_TAG_RETRY_TEMPLATES: dict[str, str] = {
|
|
2206
|
+
"show": "kd /tag show {id}",
|
|
2207
|
+
"delete": "kd /tag delete {id}",
|
|
2208
|
+
"rename": "kd /tag rename {id} <new-name>",
|
|
2209
|
+
"issues": "kd /tag issues {id}",
|
|
2210
|
+
"articles": "kd /tag articles {id}",
|
|
2211
|
+
"entities": "kd /tag entities {id}",
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
# KDCLI-200: trailer-only hint when no valid retry shape exists. Used
|
|
2215
|
+
# by ``/tag create`` so an operator who hits a collision can inspect the
|
|
2216
|
+
# existing tags without inventing a ``create <id>`` shape.
|
|
2217
|
+
_TAG_INSPECT_HINTS: dict[str, str] = {
|
|
2218
|
+
"create": "kd /tag show <id>",
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def _format_owner(tag: Tag) -> str:
|
|
2223
|
+
"""Render the ``owner=`` field for a tag in ambiguity diagnostics.
|
|
2224
|
+
|
|
2225
|
+
``Tag.owner`` is ``NamedEntity | None`` and ``NamedEntity.name`` is
|
|
2226
|
+
itself ``str | None``, so both branches collapse to ``<unknown>``.
|
|
2227
|
+
"""
|
|
2228
|
+
if tag.owner is None:
|
|
2229
|
+
return "<unknown>"
|
|
2230
|
+
return tag.owner.name or "<unknown>"
|
|
2231
|
+
|
|
2232
|
+
|
|
2187
2233
|
class TagOperations:
|
|
2188
2234
|
"""High-level tag operations with name resolution and entity detection."""
|
|
2189
2235
|
|
|
@@ -2198,7 +2244,9 @@ class TagOperations:
|
|
|
2198
2244
|
self._articles = articles_api
|
|
2199
2245
|
self._yt = yt
|
|
2200
2246
|
self._debug = debug_log or DebugLog()
|
|
2201
|
-
|
|
2247
|
+
# KDCLI-200: list-valued cache so display-name collisions can
|
|
2248
|
+
# be detected and refused instead of silently last-one-wins.
|
|
2249
|
+
self._name_cache: dict[str, builtins.list[Tag]] = {}
|
|
2202
2250
|
|
|
2203
2251
|
def list(
|
|
2204
2252
|
self,
|
|
@@ -2261,7 +2309,7 @@ class TagOperations:
|
|
|
2261
2309
|
return (matched[:top], cap_hit)
|
|
2262
2310
|
|
|
2263
2311
|
def get(self, name: str) -> Tag:
|
|
2264
|
-
tag_id = self._resolve_tag(name)
|
|
2312
|
+
tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="show")
|
|
2265
2313
|
return self._tags.get_tag(tag_id)
|
|
2266
2314
|
|
|
2267
2315
|
def create(self, name: str) -> TagCreateResult:
|
|
@@ -2276,11 +2324,11 @@ class TagOperations:
|
|
|
2276
2324
|
naming sharing/visibility and name-acceptance as plausible causes.
|
|
2277
2325
|
"""
|
|
2278
2326
|
try:
|
|
2279
|
-
tag_id = self._resolve_tag(name)
|
|
2327
|
+
tag_id = self._resolve_tag(name, command="create")
|
|
2280
2328
|
except NotFoundError:
|
|
2281
2329
|
self._debug.info("api", f"creating tag '{name}'")
|
|
2282
2330
|
created = self._tags.create_tag(name)
|
|
2283
|
-
self._name_cache
|
|
2331
|
+
self._name_cache.setdefault(name.lower(), []).append(created)
|
|
2284
2332
|
self._debug.info("api", f"created tag '{name}' ({created.id})")
|
|
2285
2333
|
return TagCreateResult(action="create", tag=created)
|
|
2286
2334
|
self._debug.info("api", f"tag '{name}' already exists ({tag_id})")
|
|
@@ -2288,22 +2336,28 @@ class TagOperations:
|
|
|
2288
2336
|
return TagCreateResult(action="exists", tag=existing)
|
|
2289
2337
|
|
|
2290
2338
|
def rename(self, name: str, new_name: str) -> Tag:
|
|
2291
|
-
tag_id = self._resolve_tag(name)
|
|
2339
|
+
tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="rename")
|
|
2292
2340
|
self._debug.info("api", f"renaming tag '{name}' → '{new_name}'")
|
|
2293
2341
|
tag = self._tags.update_tag(tag_id, new_name)
|
|
2294
|
-
|
|
2295
|
-
self._name_cache
|
|
2342
|
+
self._invalidate_tag(tag.id)
|
|
2343
|
+
self._name_cache.setdefault(new_name.lower(), []).append(tag)
|
|
2296
2344
|
return tag
|
|
2297
2345
|
|
|
2298
2346
|
def delete(self, name: str) -> None:
|
|
2299
|
-
tag_id = self._resolve_tag(name)
|
|
2347
|
+
tag_id = name if _is_tag_id(name) else self._resolve_tag(name, command="delete")
|
|
2300
2348
|
self._debug.info("api", f"deleting tag '{name}' ({tag_id})")
|
|
2301
2349
|
self._tags.delete_tag(tag_id)
|
|
2302
|
-
self.
|
|
2350
|
+
self._invalidate_tag(tag_id)
|
|
2303
2351
|
|
|
2304
2352
|
def add(self, entity_id: str, tag: str) -> None:
|
|
2305
|
-
"""Add a tag to an issue or article. Entity type is auto-detected.
|
|
2306
|
-
|
|
2353
|
+
"""Add a tag to an issue or article. Entity type is auto-detected.
|
|
2354
|
+
|
|
2355
|
+
Tag resolution is entity-anchored (KDCLI-198 S1): if a same-name
|
|
2356
|
+
tag is already attached, it is reused (idempotent re-tag);
|
|
2357
|
+
otherwise exactly one visible global match is required. The
|
|
2358
|
+
tag-id form (``\\d+-\\d+``) is a passthrough.
|
|
2359
|
+
"""
|
|
2360
|
+
tag_id = self._resolve_tag_to_attach(tag, entity_id)
|
|
2307
2361
|
entity_type = _detect_entity_type(entity_id)
|
|
2308
2362
|
self._debug.info("api", f"tagging {entity_type} {entity_id} with '{tag}'")
|
|
2309
2363
|
if entity_type == "article":
|
|
@@ -2312,8 +2366,13 @@ class TagOperations:
|
|
|
2312
2366
|
self._tags.add_tag_to_issue(entity_id, tag_id)
|
|
2313
2367
|
|
|
2314
2368
|
def remove(self, entity_id: str, tag: str) -> None:
|
|
2315
|
-
"""Remove a tag from an issue or article. Entity type is auto-detected.
|
|
2316
|
-
|
|
2369
|
+
"""Remove a tag from an issue or article. Entity type is auto-detected.
|
|
2370
|
+
|
|
2371
|
+
Tag resolution is attached-anchored (KDCLI-198 S1): the name
|
|
2372
|
+
resolves only against the tags attached to the entity. The
|
|
2373
|
+
tag-id form (``\\d+-\\d+``) is a passthrough.
|
|
2374
|
+
"""
|
|
2375
|
+
tag_id = self._resolve_attached_tag(tag, entity_id)
|
|
2317
2376
|
entity_type = _detect_entity_type(entity_id)
|
|
2318
2377
|
self._debug.info("api", f"untagging {entity_type} {entity_id} from '{tag}'")
|
|
2319
2378
|
if entity_type == "article":
|
|
@@ -2359,8 +2418,35 @@ class TagOperations:
|
|
|
2359
2418
|
if not tag_names:
|
|
2360
2419
|
raise ValidationError("At least one tag is required")
|
|
2361
2420
|
if len(tag_names) == 1 and project is None:
|
|
2362
|
-
|
|
2421
|
+
arg = tag_names[0]
|
|
2422
|
+
tag_id = arg if _is_tag_id(arg) else self._resolve_tag(arg, command="issues")
|
|
2363
2423
|
return self._tags.list_tag_issues(tag_id, top=top, skip=skip)
|
|
2424
|
+
# Multi-tag / project path composes a YouTrack query rather than
|
|
2425
|
+
# hitting ``/tags/{id}/issues``. YouTrack's ``tag:`` predicate is
|
|
2426
|
+
# name-keyed (see docs/specs/001-youtrack-api-reference.md §574
|
|
2427
|
+
# and kd/query_parser.py); there is no id-keyed tag predicate.
|
|
2428
|
+
# Accepting an id-form arg here would emit ``tag: 10-178`` which
|
|
2429
|
+
# YouTrack parses as the literal tag *name* "10-178" — wrong
|
|
2430
|
+
# rows, silently. Refuse before composition; the operator can
|
|
2431
|
+
# use the fast path (single tag, no --project) for id-keyed
|
|
2432
|
+
# precision or pass the name form (which refuses on collision).
|
|
2433
|
+
id_args = [a for a in tag_names if _is_tag_id(a)]
|
|
2434
|
+
if id_args:
|
|
2435
|
+
raise ValidationError(
|
|
2436
|
+
"Tag-id form ("
|
|
2437
|
+
+ ", ".join(id_args)
|
|
2438
|
+
+ ") is not supported with --project or multiple --tag arguments. "
|
|
2439
|
+
"YouTrack's query DSL identifies tags by name. Use the fast path "
|
|
2440
|
+
"(single tag, no --project) for id-keyed lookup, or pass the name "
|
|
2441
|
+
"form (which refuses on duplicate-name collision)."
|
|
2442
|
+
)
|
|
2443
|
+
# Every name arg is pre-validated via ``_resolve_tag`` so
|
|
2444
|
+
# duplicate visible names never reach the YouTrack query
|
|
2445
|
+
# composition. The resolver result is discarded — the composed
|
|
2446
|
+
# query uses the names directly, with YouTrack handling
|
|
2447
|
+
# server-side matching.
|
|
2448
|
+
for arg in tag_names:
|
|
2449
|
+
self._resolve_tag(arg, command="issues")
|
|
2364
2450
|
tag_clauses = [f"tag: {youtrack_query_literal(name)}" for name in tag_names]
|
|
2365
2451
|
joiner = " or " if match == "any" else " and "
|
|
2366
2452
|
query = joiner.join(tag_clauses)
|
|
@@ -2386,7 +2472,10 @@ class TagOperations:
|
|
|
2386
2472
|
*top* matches are collected or the article source is exhausted.
|
|
2387
2473
|
"""
|
|
2388
2474
|
tag_names = [tags] if isinstance(tags, str) else tags
|
|
2389
|
-
tag_ids = frozenset(
|
|
2475
|
+
tag_ids = frozenset(
|
|
2476
|
+
arg if _is_tag_id(arg) else self._resolve_tag(arg, command="articles")
|
|
2477
|
+
for arg in tag_names
|
|
2478
|
+
)
|
|
2390
2479
|
self._debug.info("api", f"scanning articles for tags {tag_names} ({tag_ids}) match={match}")
|
|
2391
2480
|
|
|
2392
2481
|
project_id: str | None = None
|
|
@@ -2480,6 +2569,38 @@ class TagOperations:
|
|
|
2480
2569
|
if not kinds:
|
|
2481
2570
|
raise ValidationError("At least one kind is required")
|
|
2482
2571
|
|
|
2572
|
+
# KDCLI-200: refuse id-form when the issue half would route
|
|
2573
|
+
# through the composed-query path (--project set or multiple
|
|
2574
|
+
# tags) because YouTrack's ``tag:`` predicate is name-keyed —
|
|
2575
|
+
# accepting an id literal there would silently emit wrong
|
|
2576
|
+
# rows. The article half is client-side-filtered by id and
|
|
2577
|
+
# works fine with id-form, so the refusal is scoped to runs
|
|
2578
|
+
# that actually dispatch through ``issues()`` composed query.
|
|
2579
|
+
id_args = [a for a in tag_names if _is_tag_id(a)]
|
|
2580
|
+
issue_dispatched = "issue" in kinds
|
|
2581
|
+
issue_would_compose = project is not None or len(tag_names) > 1
|
|
2582
|
+
if id_args and issue_dispatched and issue_would_compose:
|
|
2583
|
+
raise ValidationError(
|
|
2584
|
+
"Tag-id form ("
|
|
2585
|
+
+ ", ".join(id_args)
|
|
2586
|
+
+ ") is not supported on /tag entities when the issue half "
|
|
2587
|
+
"would route through a composed query (--project set or "
|
|
2588
|
+
"multiple tags). YouTrack's query DSL identifies tags by "
|
|
2589
|
+
"name. Drop --project and pass a single tag for id-keyed "
|
|
2590
|
+
"issue lookup, restrict to --kind article (which filters "
|
|
2591
|
+
"by id client-side), or use the name form (which refuses "
|
|
2592
|
+
"on duplicate-name collision)."
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
# Pre-validate every name argument before fanout so an
|
|
2596
|
+
# ambiguity diagnostic is the whole-command outcome rather than
|
|
2597
|
+
# a partial-success row. The per-kind ``except KdError`` below
|
|
2598
|
+
# (which converts transport-flavoured failures into a scanned=
|
|
2599
|
+
# False meta entry) MUST NOT swallow resolver refusal.
|
|
2600
|
+
for arg in tag_names:
|
|
2601
|
+
if not _is_tag_id(arg):
|
|
2602
|
+
self._resolve_tag(arg, command="entities")
|
|
2603
|
+
|
|
2483
2604
|
defaults = limit_per_kind or {}
|
|
2484
2605
|
limits = {
|
|
2485
2606
|
"issue": defaults.get("issue", 50),
|
|
@@ -2551,22 +2672,301 @@ class TagOperations:
|
|
|
2551
2672
|
)
|
|
2552
2673
|
return TagEntitiesResult(results=results, meta=meta)
|
|
2553
2674
|
|
|
2554
|
-
def
|
|
2555
|
-
"""
|
|
2675
|
+
def _invalidate_tag(self, tag_id: str) -> None:
|
|
2676
|
+
"""Remove a single tag from every name-cache bucket containing it.
|
|
2677
|
+
|
|
2678
|
+
Called by :meth:`rename` and :meth:`delete` after a successful
|
|
2679
|
+
mutation. Sweeping every bucket (rather than relying on a
|
|
2680
|
+
caller-supplied key) keeps invalidation correct when the
|
|
2681
|
+
caller addressed the tag by id form and so does not know the
|
|
2682
|
+
original cached name. Buckets that empty out are dropped; other
|
|
2683
|
+
tags sharing the same case-folded name remain intact.
|
|
2684
|
+
"""
|
|
2685
|
+
for key in list(self._name_cache.keys()):
|
|
2686
|
+
bucket = self._name_cache[key]
|
|
2687
|
+
bucket[:] = [t for t in bucket if t.id != tag_id]
|
|
2688
|
+
if not bucket:
|
|
2689
|
+
del self._name_cache[key]
|
|
2690
|
+
|
|
2691
|
+
def _resolve_tag(self, name: str, *, command: str) -> str:
|
|
2692
|
+
"""Resolve a tag name to a single tag id.
|
|
2693
|
+
|
|
2694
|
+
Lazily builds a list-valued name cache on first call (one
|
|
2695
|
+
``list_tags(top=500)`` round-trip) and reuses it for the
|
|
2696
|
+
client's lifetime, with surgical invalidation on
|
|
2697
|
+
create/rename/delete.
|
|
2698
|
+
|
|
2699
|
+
Three outcomes (KDCLI-200):
|
|
2700
|
+
|
|
2701
|
+
- exactly one visible tag with the given case-folded name →
|
|
2702
|
+
returns that id.
|
|
2703
|
+
- multiple visible tags share the name → refuses via
|
|
2704
|
+
:meth:`_format_tag_ambiguity` with the supplied ``command``
|
|
2705
|
+
so the caller's retry template renders correctly. The
|
|
2706
|
+
legacy last-one-wins behaviour is gone.
|
|
2707
|
+
- no visible match → :class:`NotFoundError` with the
|
|
2708
|
+
close-match hint, same shape as before.
|
|
2709
|
+
|
|
2710
|
+
``command`` threads through to the ambiguity diagnostic; each
|
|
2711
|
+
call site is expected to pass its own verb. Tag-id form
|
|
2712
|
+
(``\\d+-\\d+``) is handled at the call sites (not here): names
|
|
2713
|
+
following kd's namespace convention never collide with the id
|
|
2714
|
+
shape, and the passthrough belongs alongside other input-shape
|
|
2715
|
+
decisions.
|
|
2716
|
+
"""
|
|
2556
2717
|
key = name.lower()
|
|
2557
|
-
if
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2718
|
+
if not self._name_cache:
|
|
2719
|
+
self._debug.info("api", f"resolving tag '{name}'")
|
|
2720
|
+
for t in self._tags.list_tags(top=500):
|
|
2721
|
+
self._name_cache.setdefault(t.name.lower(), []).append(t)
|
|
2722
|
+
bucket = self._name_cache.get(key, [])
|
|
2723
|
+
if len(bucket) == 1:
|
|
2724
|
+
return bucket[0].id
|
|
2725
|
+
if len(bucket) > 1:
|
|
2726
|
+
raise self._format_tag_ambiguity(
|
|
2727
|
+
name=name,
|
|
2728
|
+
command=command,
|
|
2729
|
+
global_candidates=list(bucket),
|
|
2730
|
+
retry_template=_TAG_RETRY_TEMPLATES.get(command),
|
|
2731
|
+
inspect_hint=_TAG_INSPECT_HINTS.get(command),
|
|
2732
|
+
)
|
|
2733
|
+
available = [t.name for ts in self._name_cache.values() for t in ts]
|
|
2566
2734
|
close = difflib.get_close_matches(name, available, n=3, cutoff=0.5)
|
|
2567
2735
|
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
2568
2736
|
raise NotFoundError(f"Tag '{name}' not found.{hint}")
|
|
2569
2737
|
|
|
2738
|
+
# --- Entity-anchored resolvers (KDCLI-198 S1) ---
|
|
2739
|
+
#
|
|
2740
|
+
# These bypass ``_resolve_tag`` / ``_name_cache`` deliberately: a
|
|
2741
|
+
# display name can map to multiple tag ids, and the global cache
|
|
2742
|
+
# silently picks one. ``/tag add`` and ``/tag remove`` both take an
|
|
2743
|
+
# entity argument, so the attached tag list disambiguates by
|
|
2744
|
+
# construction. S2 (KDCLI-200) replaces the global cache for the
|
|
2745
|
+
# remaining tag commands.
|
|
2746
|
+
|
|
2747
|
+
def _fetch_attached_tags(self, entity_id: str) -> builtins.list[Tag]:
|
|
2748
|
+
"""Return the tags currently attached to ``entity_id``.
|
|
2749
|
+
|
|
2750
|
+
Uses the existing rich detail field set on both sides
|
|
2751
|
+
(``_ARTICLE_DETAIL_FIELDS``, ``_ISSUE_RICH_FIELDS``) which
|
|
2752
|
+
already projects ``tags(id,name)``. No additional request shape
|
|
2753
|
+
is required.
|
|
2754
|
+
"""
|
|
2755
|
+
if _detect_entity_type(entity_id) == "article":
|
|
2756
|
+
return list(self._articles.get_article(entity_id).tags)
|
|
2757
|
+
return list(self._yt.get_issue(entity_id, rich=True).tags)
|
|
2758
|
+
|
|
2759
|
+
def _resolve_attached_tag(self, name_or_id: str, entity_id: str) -> str:
|
|
2760
|
+
"""Resolve ``name_or_id`` to a tag id attached to ``entity_id``.
|
|
2761
|
+
|
|
2762
|
+
Used by ``/tag remove``. Refuses to fall through to a global
|
|
2763
|
+
lookup so the operator cannot accidentally address a same-name
|
|
2764
|
+
tag that is not on the entity.
|
|
2765
|
+
|
|
2766
|
+
- id-form (``\\d+-\\d+``) → passthrough; existence checked by
|
|
2767
|
+
the downstream HTTP call.
|
|
2768
|
+
- exactly one same-name attached → return that id.
|
|
2769
|
+
- none attached → :class:`ValidationError` ("not attached").
|
|
2770
|
+
When a same-name tag exists globally, the diagnostic lists it
|
|
2771
|
+
(without an ``[attached]`` marker) so the operator can see
|
|
2772
|
+
the tag is real but not on this entity.
|
|
2773
|
+
- multiple attached → :class:`ValidationError` with a candidate
|
|
2774
|
+
diagnostic; require the id form.
|
|
2775
|
+
"""
|
|
2776
|
+
if _is_tag_id(name_or_id):
|
|
2777
|
+
return name_or_id
|
|
2778
|
+
attached = self._fetch_attached_tags(entity_id)
|
|
2779
|
+
key = name_or_id.lower()
|
|
2780
|
+
matches = [t for t in attached if t.name.lower() == key]
|
|
2781
|
+
if len(matches) == 1:
|
|
2782
|
+
return matches[0].id
|
|
2783
|
+
if not matches:
|
|
2784
|
+
global_candidates = [t for t in self._tags.list_tags(top=500) if t.name.lower() == key]
|
|
2785
|
+
raise self._format_tag_ambiguity(
|
|
2786
|
+
name=name_or_id,
|
|
2787
|
+
entity_id=entity_id,
|
|
2788
|
+
attached=attached,
|
|
2789
|
+
command="remove",
|
|
2790
|
+
global_candidates=global_candidates,
|
|
2791
|
+
reason="not_attached",
|
|
2792
|
+
)
|
|
2793
|
+
raise self._format_tag_ambiguity(
|
|
2794
|
+
name=name_or_id,
|
|
2795
|
+
entity_id=entity_id,
|
|
2796
|
+
attached=attached,
|
|
2797
|
+
command="remove",
|
|
2798
|
+
retry_template="kd /tag {command} {entity_id} {id}",
|
|
2799
|
+
)
|
|
2800
|
+
|
|
2801
|
+
def _resolve_tag_to_attach(self, name_or_id: str, entity_id: str) -> str:
|
|
2802
|
+
"""Resolve ``name_or_id`` to a tag id suitable for ``/tag add``.
|
|
2803
|
+
|
|
2804
|
+
- id-form (``\\d+-\\d+``) → passthrough.
|
|
2805
|
+
- one same-name tag already attached → return that id
|
|
2806
|
+
(idempotent re-tag).
|
|
2807
|
+
- multiple same-name tags attached → :class:`ValidationError`
|
|
2808
|
+
with a candidate diagnostic; require the id form.
|
|
2809
|
+
- none attached, exactly one visible global match → return that
|
|
2810
|
+
id.
|
|
2811
|
+
- none attached, multiple visible global matches →
|
|
2812
|
+
:class:`ValidationError` with a candidate diagnostic.
|
|
2813
|
+
- none attached, no visible global match → :class:`NotFoundError`
|
|
2814
|
+
with a close-match hint (same shape as :meth:`_resolve_tag`).
|
|
2815
|
+
"""
|
|
2816
|
+
if _is_tag_id(name_or_id):
|
|
2817
|
+
return name_or_id
|
|
2818
|
+
attached = self._fetch_attached_tags(entity_id)
|
|
2819
|
+
key = name_or_id.lower()
|
|
2820
|
+
attached_matches = [t for t in attached if t.name.lower() == key]
|
|
2821
|
+
if len(attached_matches) == 1:
|
|
2822
|
+
return attached_matches[0].id
|
|
2823
|
+
if len(attached_matches) > 1:
|
|
2824
|
+
raise self._format_tag_ambiguity(
|
|
2825
|
+
name=name_or_id,
|
|
2826
|
+
entity_id=entity_id,
|
|
2827
|
+
attached=attached,
|
|
2828
|
+
command="add",
|
|
2829
|
+
retry_template="kd /tag {command} {entity_id} {id}",
|
|
2830
|
+
)
|
|
2831
|
+
all_tags = self._tags.list_tags(top=500)
|
|
2832
|
+
global_matches = [t for t in all_tags if t.name.lower() == key]
|
|
2833
|
+
if len(global_matches) == 1:
|
|
2834
|
+
return global_matches[0].id
|
|
2835
|
+
if len(global_matches) > 1:
|
|
2836
|
+
raise self._format_tag_ambiguity(
|
|
2837
|
+
name=name_or_id,
|
|
2838
|
+
entity_id=entity_id,
|
|
2839
|
+
attached=attached,
|
|
2840
|
+
command="add",
|
|
2841
|
+
global_candidates=global_matches,
|
|
2842
|
+
retry_template="kd /tag {command} {entity_id} {id}",
|
|
2843
|
+
)
|
|
2844
|
+
available = [t.name for t in all_tags]
|
|
2845
|
+
close = difflib.get_close_matches(name_or_id, available, n=3, cutoff=0.5)
|
|
2846
|
+
hint = f" Did you mean: {', '.join(close)}?" if close else ""
|
|
2847
|
+
raise NotFoundError(f"Tag '{name_or_id}' not found.{hint}")
|
|
2848
|
+
|
|
2849
|
+
def _format_tag_ambiguity(
|
|
2850
|
+
self,
|
|
2851
|
+
*,
|
|
2852
|
+
name: str,
|
|
2853
|
+
command: str,
|
|
2854
|
+
entity_id: str | None = None,
|
|
2855
|
+
attached: Sequence[Tag] = (),
|
|
2856
|
+
global_candidates: builtins.list[Tag] | None = None,
|
|
2857
|
+
reason: Literal["ambiguous", "not_attached"] = "ambiguous",
|
|
2858
|
+
retry_template: str | None = None,
|
|
2859
|
+
inspect_hint: str | None = None,
|
|
2860
|
+
) -> ValidationError:
|
|
2861
|
+
"""Build a :class:`ValidationError` carrying the shared tag
|
|
2862
|
+
diagnostic.
|
|
2863
|
+
|
|
2864
|
+
Candidates are the union of (same-name tags attached to the
|
|
2865
|
+
entity) and (same-name tags visible globally), de-duplicated by
|
|
2866
|
+
id. ``global_candidates`` is the pre-fetched global slice when
|
|
2867
|
+
the caller already paid for ``list_tags`` — otherwise this
|
|
2868
|
+
helper fetches it so attached-only ambiguity still gets enriched
|
|
2869
|
+
with ``owner=`` metadata.
|
|
2870
|
+
|
|
2871
|
+
``entity_id`` is omitted by S2 (global-command) callers; the
|
|
2872
|
+
header drops the "on <entity>" clause and ``[attached]`` markers
|
|
2873
|
+
do not appear because ``attached`` defaults to empty.
|
|
2874
|
+
|
|
2875
|
+
``retry_template`` is rendered once per candidate via
|
|
2876
|
+
``.format(id=..., entity_id=..., command=...)``. When ``None``,
|
|
2877
|
+
the retry block is omitted entirely (``/tag create`` collision
|
|
2878
|
+
path, where ``create <id>`` is not a valid retry). The
|
|
2879
|
+
``inspect_hint`` parameter appends a single trailer line for the
|
|
2880
|
+
no-retry case (e.g. ``kd /tag show <id>``).
|
|
2881
|
+
|
|
2882
|
+
``reason="ambiguous"`` (default): the name resolves to multiple
|
|
2883
|
+
candidates that the resolver cannot disambiguate. The diagnostic
|
|
2884
|
+
lists candidates and the retry block proposes id-form retries.
|
|
2885
|
+
When exactly one candidate is attached the retry block points to
|
|
2886
|
+
that id (the operator clearly meant the attached one); otherwise
|
|
2887
|
+
every candidate gets its own retry line so the emitter never
|
|
2888
|
+
picks for the operator.
|
|
2889
|
+
|
|
2890
|
+
``reason="not_attached"``: the name is not on the entity. When
|
|
2891
|
+
same-name globals exist they are listed (no ``[attached]``
|
|
2892
|
+
markers, since none are) so the operator can see the tag is
|
|
2893
|
+
real but not here; there is no retry block. When no globals
|
|
2894
|
+
match, the message is the bare "not attached" sentence.
|
|
2895
|
+
``reason="not_attached"`` is an S1 (entity-anchored) shape and
|
|
2896
|
+
requires ``entity_id``.
|
|
2897
|
+
"""
|
|
2898
|
+
key = name.lower()
|
|
2899
|
+
if global_candidates is None:
|
|
2900
|
+
global_candidates = [t for t in self._tags.list_tags(top=500) if t.name.lower() == key]
|
|
2901
|
+
attached_ids = {t.id for t in attached if t.name.lower() == key}
|
|
2902
|
+
# Prefer the global slice for each id (it carries owner metadata
|
|
2903
|
+
# that the attached tags list, projected as ``tags(id,name)``,
|
|
2904
|
+
# does not). Fall back to the attached entry only when the tag
|
|
2905
|
+
# is invisible to the running principal in ``list_tags``.
|
|
2906
|
+
by_id: dict[str, Tag] = {t.id: t for t in global_candidates}
|
|
2907
|
+
for t in attached:
|
|
2908
|
+
if t.name.lower() == key:
|
|
2909
|
+
by_id.setdefault(t.id, t)
|
|
2910
|
+
candidates = sorted(by_id.values(), key=lambda t: t.id)
|
|
2911
|
+
|
|
2912
|
+
if reason == "not_attached":
|
|
2913
|
+
lines = [f"Tag '{name}' is not attached to {entity_id}."]
|
|
2914
|
+
if candidates:
|
|
2915
|
+
lines.extend(
|
|
2916
|
+
[
|
|
2917
|
+
"",
|
|
2918
|
+
"Same-name tags exist globally but are not on this entity:",
|
|
2919
|
+
]
|
|
2920
|
+
)
|
|
2921
|
+
for t in candidates:
|
|
2922
|
+
lines.append(f" {t.id} {t.name} owner={_format_owner(t)}")
|
|
2923
|
+
return ValidationError("\n".join(lines))
|
|
2924
|
+
|
|
2925
|
+
header = (
|
|
2926
|
+
f"Tag '{name}' is ambiguous on {entity_id}."
|
|
2927
|
+
if entity_id is not None
|
|
2928
|
+
else f"Tag '{name}' is ambiguous."
|
|
2929
|
+
)
|
|
2930
|
+
lines = [header, "", "Candidates:"]
|
|
2931
|
+
for t in candidates:
|
|
2932
|
+
mark = " [attached]" if t.id in attached_ids else ""
|
|
2933
|
+
lines.append(f" {t.id} {t.name} owner={_format_owner(t)}{mark}")
|
|
2934
|
+
attached_candidates = [t for t in candidates if t.id in attached_ids]
|
|
2935
|
+
if retry_template is not None:
|
|
2936
|
+
lines.append("")
|
|
2937
|
+
if len(attached_candidates) == 1:
|
|
2938
|
+
# Exactly one attached among many candidates: the
|
|
2939
|
+
# operator clearly meant the attached one — emit a
|
|
2940
|
+
# single retry.
|
|
2941
|
+
only = attached_candidates[0]
|
|
2942
|
+
lines.extend(
|
|
2943
|
+
[
|
|
2944
|
+
"Retry with the tag id:",
|
|
2945
|
+
" "
|
|
2946
|
+
+ retry_template.format(
|
|
2947
|
+
id=only.id,
|
|
2948
|
+
entity_id=entity_id,
|
|
2949
|
+
command=command,
|
|
2950
|
+
),
|
|
2951
|
+
]
|
|
2952
|
+
)
|
|
2953
|
+
else:
|
|
2954
|
+
# Zero or multiple attached: do not pick. List every
|
|
2955
|
+
# candidate's retry so the operator chooses.
|
|
2956
|
+
lines.append("Retry with one of:")
|
|
2957
|
+
for t in candidates:
|
|
2958
|
+
lines.append(
|
|
2959
|
+
" "
|
|
2960
|
+
+ retry_template.format(
|
|
2961
|
+
id=t.id,
|
|
2962
|
+
entity_id=entity_id,
|
|
2963
|
+
command=command,
|
|
2964
|
+
)
|
|
2965
|
+
)
|
|
2966
|
+
elif inspect_hint is not None:
|
|
2967
|
+
lines.extend(["", "Inspect a candidate with:", f" {inspect_hint}"])
|
|
2968
|
+
return ValidationError("\n".join(lines))
|
|
2969
|
+
|
|
2570
2970
|
|
|
2571
2971
|
class SavedQueryOperations:
|
|
2572
2972
|
"""High-level saved-query operations with name-or-ID resolution."""
|
|
@@ -4,6 +4,47 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.9.0] — 2026-05-12
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Tag-name resolution now refuses on visible-global name collisions
|
|
11
|
+
instead of silently picking one tag id. The legacy last-one-wins
|
|
12
|
+
behaviour is gone across the entire `/tag` namespace: `/tag show`,
|
|
13
|
+
`/tag delete`, `/tag rename`, `/tag create`, `/tag add`, `/tag remove`,
|
|
14
|
+
`/tag issues`, `/tag articles`, and `/tag entities` all surface a
|
|
15
|
+
structured candidate list (id, name, owner) when two visible tags
|
|
16
|
+
share a display name. Each candidate is rendered with a retry shape
|
|
17
|
+
appropriate to the command (`kd /tag show <id>`, `kd /tag rename
|
|
18
|
+
<id> <new-name>`, etc.); `/tag create` lists candidates without a
|
|
19
|
+
retry block and suggests `kd /tag show <id>` for inspection. The
|
|
20
|
+
tag-id form (`NN-NN`) is a passthrough on every command that accepts
|
|
21
|
+
a tag identifier — use it to disambiguate (KDCLI-198: KDCLI-199 +
|
|
22
|
+
KDCLI-200).
|
|
23
|
+
- `/tag issues` and `/tag entities` pre-validate every tag argument
|
|
24
|
+
before composing the YouTrack query or fanning out across kinds, so
|
|
25
|
+
ambiguity refusal is a hard precondition rather than a partial-
|
|
26
|
+
success row. The multi-tag / project slow path on `/tag issues`
|
|
27
|
+
previously bypassed the resolver entirely; that gap is closed.
|
|
28
|
+
- Tag-id form scope is narrower on `/tag issues` and `/tag entities`
|
|
29
|
+
than on the other tag commands because YouTrack's `tag:` query DSL
|
|
30
|
+
predicate is name-keyed (no id-keyed predicate exists). `/tag issues`
|
|
31
|
+
accepts id form on the fast path only (single tag, no `--project`);
|
|
32
|
+
any combination with `--project` or multiple `--tag` arguments
|
|
33
|
+
refuses with guidance. `/tag entities` accepts id form when the
|
|
34
|
+
issue half stays on the fast path or when only `--kind article` is
|
|
35
|
+
requested; combining id form with `--project` or multiple tags AND
|
|
36
|
+
the issue kind refuses. The other tag commands (`show`, `delete`,
|
|
37
|
+
`rename`, `add`, `remove`, `articles`) are unaffected.
|
|
38
|
+
|
|
39
|
+
## [2.8.1] — 2026-05-08
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- `kd /issue show` and `kd /issue/comment list` no longer crash on
|
|
43
|
+
tombstoned (soft-deleted) comments. Structured output surfaces
|
|
44
|
+
`text: null, deleted: true` for tombstoned rows so operators can
|
|
45
|
+
see the row exists without fabricating content. Article comments
|
|
46
|
+
share the same fix (KDCLI-192).
|
|
47
|
+
|
|
7
48
|
## [2.8.0] — 2026-05-07
|
|
8
49
|
|
|
9
50
|
### Added
|
|
@@ -257,6 +257,24 @@ Do not treat `--primer-scope` as implicit global state. It is a deliberate flag
|
|
|
257
257
|
|
|
258
258
|
`/tag create TAG_NAME` is idempotent. It resolves the name first (same case-insensitive resolver `/tag show` uses) and returns `{ok, action, tag}` with `action: exists` when the tag is already visible, or `action: create` after a successful POST. Taxonomy bootstrapping and fixture setup can re-run the command safely without decoding `Tag.name-is-invalid`. When the POST still fails — either a genuinely unacceptable name or an existing tag hidden by sharing/visibility — kd surfaces a factual diagnostic naming both plausible causes rather than the raw upstream error.
|
|
259
259
|
|
|
260
|
+
### Tag Name Resolution And Duplicates
|
|
261
|
+
|
|
262
|
+
Tags are a **global** YouTrack entity. Two different users can independently create tags with the same display name (e.g., one automation account owns a private `lifecycle:draft` taxonomy and a user re-creates `lifecycle:draft` they cannot see) — both tags are real and both can be visible to a third principal. The case-folded name is therefore not a unique identifier; the tag id (`NN-NN`) is.
|
|
263
|
+
|
|
264
|
+
`kd` exposes this distinction directly:
|
|
265
|
+
|
|
266
|
+
- **Name form (`sprint-47`, `lifecycle:draft`).** Used by every tag command. Resolves to exactly one visible tag, or refuses with a structured candidate list listing each id and owner. The resolver never silently picks among duplicates.
|
|
267
|
+
- **Id form (`NN-NN`, e.g. `10-178`).** Skips name resolution. Accepted by `/tag show`, `/tag delete`, `/tag rename` (first arg), `/tag add`, `/tag remove`, `/tag articles`, and — with scope limits — `/tag issues` and `/tag entities`. Use the id form to disambiguate when a name collides.
|
|
268
|
+
|
|
269
|
+
The id-form scope for `/tag issues` and `/tag entities` is narrower because YouTrack's query DSL `tag:` predicate is name-keyed; there is no id-keyed tag predicate. Concretely:
|
|
270
|
+
|
|
271
|
+
- `/tag issues TAG_NAME_OR_ID` — id form works on the fast path (single tag, no `--project`). Add `--project` or another `--tag`, and the id form is refused with guidance to use the fast path or the name form.
|
|
272
|
+
- `/tag entities TAG_NAME_OR_ID` — id form works when the issue half stays on the fast path (single tag, no `--project`) or when the run is restricted to `--kind article` (article filtering is client-side by id). With `--project` or multiple tags AND the issue kind included, id form is refused.
|
|
273
|
+
|
|
274
|
+
`/tag create TAG_NAME` is the one exception: it refuses on visible-global collision rather than silently returning "the first existing tag", and does not accept an id form (creating with an existing id is meaningless). Inspect each candidate with `kd /tag show <id>` to decide whether to reuse or rename one.
|
|
275
|
+
|
|
276
|
+
`/tag entities` pre-validates every tag argument before fanout. Ambiguity refusal is therefore the whole-command outcome — duplicate-name refusal is never reported as a per-kind partial-success row.
|
|
277
|
+
|
|
260
278
|
## Output And Debugging
|
|
261
279
|
|
|
262
280
|
For deterministic machine consumption, always pass an explicit output format:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|