kd-cli 2.1.6__tar.gz → 2.2.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.1.6/kd_cli.egg-info → kd_cli-2.2.0}/PKG-INFO +1 -1
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/tags.py +12 -2
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/youtrack.py +25 -2
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/issue/__init__.py +30 -21
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/primer/__init__.py +200 -10
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/system/__init__.py +22 -326
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/tag/__init__.py +233 -11
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/output.py +159 -7
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/client.py +274 -15
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/config.py +172 -0
- kd_cli-2.2.0/kd/config_read.py +194 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/exceptions.py +11 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/__init__.py +4 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/issue.py +3 -0
- kd_cli-2.2.0/kd/models/tag_entities.py +48 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/primer.py +59 -5
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/query_parser.py +68 -7
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/CHANGELOG.md +27 -0
- kd_cli-2.2.0/kd/resources/operating-guide.md +209 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/SOURCES.txt +3 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/pyproject.toml +1 -1
- {kd_cli-2.1.6 → kd_cli-2.2.0}/LICENSE +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/MANIFEST.in +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/README.md +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/articles.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/http.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/hub.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/article_marker.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/auth.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/input.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/main.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/config_schema.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/connection.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/debug.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/history.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/article.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/assist.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/comment.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/common.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/project.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/search.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/tag.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/user.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/release_notes.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.1.6 → kd_cli-2.2.0}/setup.cfg +0 -0
|
@@ -36,6 +36,7 @@ _TAGGED_ISSUE_FIELDS = str(
|
|
|
36
36
|
"id",
|
|
37
37
|
"idReadable",
|
|
38
38
|
"summary",
|
|
39
|
+
"updated",
|
|
39
40
|
project=FieldSet("id", "name", "shortName"),
|
|
40
41
|
)
|
|
41
42
|
)
|
|
@@ -49,11 +50,20 @@ class TagsAPI:
|
|
|
49
50
|
|
|
50
51
|
# --- Tag CRUD ---
|
|
51
52
|
|
|
52
|
-
def list_tags(
|
|
53
|
+
def list_tags(
|
|
54
|
+
self,
|
|
55
|
+
top: int = 50,
|
|
56
|
+
skip: int = 0,
|
|
57
|
+
*,
|
|
58
|
+
query: str | None = None,
|
|
59
|
+
) -> list[Tag]:
|
|
60
|
+
params: dict[str, object] = {"$top": top, "$skip": skip}
|
|
61
|
+
if query is not None:
|
|
62
|
+
params["query"] = query
|
|
53
63
|
data = self._http.get(
|
|
54
64
|
"/tags",
|
|
55
65
|
fields=_TAG_LIST_FIELDS,
|
|
56
|
-
params=
|
|
66
|
+
params=params,
|
|
57
67
|
)
|
|
58
68
|
return [Tag.model_validate(t) for t in data]
|
|
59
69
|
|
|
@@ -68,11 +68,29 @@ _ISSUE_FIELDS = str(
|
|
|
68
68
|
"summary",
|
|
69
69
|
"description",
|
|
70
70
|
"created",
|
|
71
|
+
"updated",
|
|
71
72
|
project=FieldSet("id", "name", "shortName"),
|
|
72
73
|
reporter=FieldSet("id", "login", "name"),
|
|
73
74
|
customFields=FieldSet("name", value=FieldSet("name")),
|
|
74
75
|
)
|
|
75
76
|
)
|
|
77
|
+
# Tag-aware list field set. Kept separate from _ISSUE_FIELDS so default
|
|
78
|
+
# /issue list calls don't pay the tag payload cost. Symmetric to
|
|
79
|
+
# _ARTICLE_TAG_SCAN_FIELDS in kd/api/articles.py.
|
|
80
|
+
_ISSUE_LIST_FIELDS_WITH_TAGS = str(
|
|
81
|
+
FieldSet(
|
|
82
|
+
"id",
|
|
83
|
+
"idReadable",
|
|
84
|
+
"summary",
|
|
85
|
+
"description",
|
|
86
|
+
"created",
|
|
87
|
+
"updated",
|
|
88
|
+
project=FieldSet("id", "name", "shortName"),
|
|
89
|
+
reporter=FieldSet("id", "login", "name"),
|
|
90
|
+
customFields=FieldSet("name", value=FieldSet("name")),
|
|
91
|
+
tags=FieldSet("id", "name"),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
76
94
|
_ISSUE_RICH_FIELDS = str(
|
|
77
95
|
FieldSet(
|
|
78
96
|
"id",
|
|
@@ -80,6 +98,7 @@ _ISSUE_RICH_FIELDS = str(
|
|
|
80
98
|
"summary",
|
|
81
99
|
"description",
|
|
82
100
|
"created",
|
|
101
|
+
"updated",
|
|
83
102
|
project=FieldSet("id", "name", "shortName"),
|
|
84
103
|
reporter=FieldSet("id", "login", "name"),
|
|
85
104
|
customFields=FieldSet("name", value=FieldSet("name")),
|
|
@@ -93,6 +112,7 @@ _ISSUE_RICH_FIELDS = str(
|
|
|
93
112
|
attachments=FieldSet(
|
|
94
113
|
"id", "name", "size", "mimeType", "created", author=FieldSet("id", "login", "name")
|
|
95
114
|
),
|
|
115
|
+
tags=FieldSet("id", "name"),
|
|
96
116
|
)
|
|
97
117
|
)
|
|
98
118
|
_COMMENT_FIELDS = "id,text,author(id,login,name),created,updated"
|
|
@@ -188,11 +208,14 @@ class YouTrackAPI:
|
|
|
188
208
|
|
|
189
209
|
# --- Issues ---
|
|
190
210
|
|
|
191
|
-
def list_issues(
|
|
211
|
+
def list_issues(
|
|
212
|
+
self, query: str = "", top: int = 50, skip: int = 0, with_tags: bool = False
|
|
213
|
+
) -> list[Issue]:
|
|
192
214
|
params: dict[str, Any] = {"$top": top, "$skip": skip}
|
|
193
215
|
if query:
|
|
194
216
|
params["query"] = query
|
|
195
|
-
|
|
217
|
+
fields = _ISSUE_LIST_FIELDS_WITH_TAGS if with_tags else _ISSUE_FIELDS
|
|
218
|
+
data = self._http.get("/issues", fields=fields, params=params)
|
|
196
219
|
return [Issue.model_validate(i) for i in data]
|
|
197
220
|
|
|
198
221
|
def get_issue(self, issue_id: str, rich: bool = False) -> Issue:
|
|
@@ -30,19 +30,6 @@ from kd.models import Attachment, CustomField, Issue
|
|
|
30
30
|
_SEMANTIC_SLOTS = ("type", "priority", "state")
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _get_field_aliases(client: Any, project_key: str) -> dict[str, str]:
|
|
34
|
-
"""Extract field aliases for a project from config."""
|
|
35
|
-
if not client or not hasattr(client, "config") or not client.config:
|
|
36
|
-
return {}
|
|
37
|
-
for source in (client.config.local_projects, client.config.global_projects):
|
|
38
|
-
proj_cfg = source.get(project_key)
|
|
39
|
-
if isinstance(proj_cfg, dict):
|
|
40
|
-
aliases = proj_cfg.get("field_aliases")
|
|
41
|
-
if isinstance(aliases, dict):
|
|
42
|
-
return {str(k): str(v) for k, v in aliases.items()}
|
|
43
|
-
return {}
|
|
44
|
-
|
|
45
|
-
|
|
46
33
|
def _build_custom_fields(
|
|
47
34
|
options: dict[str, Any],
|
|
48
35
|
client: Any = None,
|
|
@@ -64,7 +51,7 @@ def _build_custom_fields(
|
|
|
64
51
|
|
|
65
52
|
semantic_fields: list[CustomField] = []
|
|
66
53
|
if slots and client and project_key:
|
|
67
|
-
aliases =
|
|
54
|
+
aliases = client.config.field_aliases_for(project_key) if client.config else {}
|
|
68
55
|
semantic_fields = client.issues.resolve_custom_fields(project_key, slots, aliases) or []
|
|
69
56
|
elif slots:
|
|
70
57
|
# Fallback: hardcoded defaults (no client available)
|
|
@@ -122,9 +109,15 @@ def _extract_semantic_field(issue: Issue, candidates: set[str]) -> str:
|
|
|
122
109
|
return ""
|
|
123
110
|
|
|
124
111
|
|
|
125
|
-
def _issue_to_row(issue: Issue) -> dict[str, Any]:
|
|
126
|
-
"""Convert an Issue to a flat dict for table output.
|
|
127
|
-
|
|
112
|
+
def _issue_to_row(issue: Issue, with_tags: bool = False) -> dict[str, Any]:
|
|
113
|
+
"""Convert an Issue to a flat dict for table output.
|
|
114
|
+
|
|
115
|
+
When ``with_tags`` is True, a ``tags`` column is appended (comma-joined
|
|
116
|
+
tag names, parity with ``_article_to_detail``). Without the flag, the
|
|
117
|
+
column is omitted entirely so cheap ``/issue list`` calls don't surface
|
|
118
|
+
an empty column.
|
|
119
|
+
"""
|
|
120
|
+
row: dict[str, Any] = {
|
|
128
121
|
"id": issue.id_readable,
|
|
129
122
|
"summary": issue.summary,
|
|
130
123
|
"project": issue.project.name if issue.project else "",
|
|
@@ -132,6 +125,9 @@ def _issue_to_row(issue: Issue) -> dict[str, Any]:
|
|
|
132
125
|
"priority": _extract_semantic_field(issue, _PRIORITY_NAMES),
|
|
133
126
|
"state": _extract_semantic_field(issue, _STATE_NAMES),
|
|
134
127
|
}
|
|
128
|
+
if with_tags:
|
|
129
|
+
row["tags"] = ", ".join(t.name for t in issue.tags)
|
|
130
|
+
return row
|
|
135
131
|
|
|
136
132
|
|
|
137
133
|
_RICH_SECTIONS = {"description", "comments", "links", "attachments"}
|
|
@@ -151,6 +147,8 @@ def _issue_to_detail(issue: Issue, sections: set[str] | None = None) -> dict[str
|
|
|
151
147
|
"project": issue.project.name if issue.project else "",
|
|
152
148
|
"reporter": issue.reporter.name if issue.reporter else "",
|
|
153
149
|
"created": issue.created,
|
|
150
|
+
"updated": issue.updated,
|
|
151
|
+
"tags": ", ".join(t.name for t in issue.tags),
|
|
154
152
|
}
|
|
155
153
|
for cf in issue.custom_fields:
|
|
156
154
|
name = cf.name.lower().replace(" ", "_")
|
|
@@ -215,13 +213,14 @@ class IssueListCommand(Command):
|
|
|
215
213
|
"list",
|
|
216
214
|
"List issues",
|
|
217
215
|
requires_client=True,
|
|
218
|
-
usage="kd /issue list [--project P] [--query Q] [--limit N]",
|
|
216
|
+
usage="kd /issue list [--project P] [--query Q] [--limit N] [--with-tags]",
|
|
219
217
|
examples=[
|
|
220
218
|
"kd /issue list --project FB",
|
|
221
219
|
'kd /issue list --query "State: Open"',
|
|
222
220
|
"kd /issue list --project FB --limit 10",
|
|
221
|
+
"kd /issue list --project FB --with-tags",
|
|
223
222
|
],
|
|
224
|
-
see_also=["/issue show", "/issue create"],
|
|
223
|
+
see_also=["/issue show", "/issue create", "/tag issues"],
|
|
225
224
|
)
|
|
226
225
|
|
|
227
226
|
def _register_options(self) -> None:
|
|
@@ -235,6 +234,15 @@ class IssueListCommand(Command):
|
|
|
235
234
|
)
|
|
236
235
|
)
|
|
237
236
|
self.register_option(limit_option())
|
|
237
|
+
self.register_option(
|
|
238
|
+
OptionDeclaration(
|
|
239
|
+
name="with-tags",
|
|
240
|
+
layer=OptionLayer.COMMAND,
|
|
241
|
+
type=bool,
|
|
242
|
+
default=False,
|
|
243
|
+
help_text="Include tags in the response (opt-in; doubles payload size)",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
238
246
|
|
|
239
247
|
def execute(
|
|
240
248
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
@@ -248,8 +256,9 @@ class IssueListCommand(Command):
|
|
|
248
256
|
parts.append(query)
|
|
249
257
|
full_query = " ".join(parts)
|
|
250
258
|
limit = options.command.get("limit", 50)
|
|
251
|
-
|
|
252
|
-
|
|
259
|
+
with_tags = options.command.get("with-tags", False)
|
|
260
|
+
issues = client.issues.list(query=full_query, top=limit, with_tags=with_tags)
|
|
261
|
+
return [_issue_to_row(i, with_tags=with_tags) for i in issues]
|
|
253
262
|
|
|
254
263
|
|
|
255
264
|
class IssueCreateCommand(Command):
|
|
@@ -17,15 +17,19 @@ from kd.cli.core.option_registry import (
|
|
|
17
17
|
project_option,
|
|
18
18
|
)
|
|
19
19
|
from kd.cli.input import resolve_text_input
|
|
20
|
-
from kd.
|
|
20
|
+
from kd.cli.output import emit_warning
|
|
21
|
+
from kd.exceptions import KdError, NotFoundError, ValidationError
|
|
21
22
|
from kd.primer import (
|
|
22
23
|
add_article,
|
|
23
24
|
add_note,
|
|
25
|
+
add_tag,
|
|
24
26
|
clear_articles,
|
|
25
27
|
clear_notes,
|
|
28
|
+
clear_tags,
|
|
26
29
|
load_primer,
|
|
27
30
|
remove_article,
|
|
28
31
|
remove_note,
|
|
32
|
+
remove_tag,
|
|
29
33
|
update_note,
|
|
30
34
|
)
|
|
31
35
|
|
|
@@ -33,19 +37,33 @@ from kd.primer import (
|
|
|
33
37
|
def _assert_workspace_project(command: Command, options: ResolvedOptions) -> None:
|
|
34
38
|
"""Enforce the ``--project`` assertion on workspace-scoped primer commands.
|
|
35
39
|
|
|
36
|
-
The primer lives in ``.kd/primer.yaml`` and has no project dimension
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
The primer lives in ``.kd/primer.yaml`` and has no project dimension:
|
|
41
|
+
every workspace owns exactly one primer, and any entity (tag, article,
|
|
42
|
+
issue) from any project the token can see can be pinned from that
|
|
43
|
+
workspace. ``--project`` is therefore accepted as an **assertion** of
|
|
44
|
+
which workspace's primer the caller thinks it's editing, not an
|
|
45
|
+
override that selects a different primer file. Matches
|
|
46
|
+
``default_project`` → silent no-op; mismatches → raise so an agent
|
|
47
|
+
running from the wrong workspace fails loud.
|
|
40
48
|
"""
|
|
41
49
|
asserted = options.command.get("project")
|
|
42
50
|
if not asserted:
|
|
43
51
|
return
|
|
52
|
+
# Read commands (/primer show, /primer/*/list) declare the flag for
|
|
53
|
+
# symmetry with write commands but do not declare requires_config=True
|
|
54
|
+
# so they work in a fresh workspace before `kd init`. When no config
|
|
55
|
+
# is loaded the assertion silently passes — the flag is accepted, the
|
|
56
|
+
# user sees no "unknown option" rejection (KDCLI-84 symptom).
|
|
57
|
+
if command._resolved_config is None:
|
|
58
|
+
return
|
|
44
59
|
current = command.config.default_project
|
|
45
60
|
if current and asserted != current:
|
|
46
61
|
raise ValidationError(
|
|
47
|
-
f"Primer is workspace-scoped
|
|
48
|
-
f"
|
|
62
|
+
f"Primer is workspace-scoped (one .kd/primer.yaml per workspace). "
|
|
63
|
+
f"--project={asserted} does not match this workspace's "
|
|
64
|
+
f"default_project={current}. Primer pinning does not restrict "
|
|
65
|
+
f"which project an entity can belong to — you can pin {asserted} "
|
|
66
|
+
f"entities from this workspace. To edit a *different* workspace's "
|
|
49
67
|
f"primer, run from that workspace."
|
|
50
68
|
)
|
|
51
69
|
|
|
@@ -63,22 +81,28 @@ class PrimerShowCommand(Command):
|
|
|
63
81
|
"show",
|
|
64
82
|
"Show project primer",
|
|
65
83
|
usage="kd /primer show",
|
|
66
|
-
long_description="Displays the full project primer — notes, pinned
|
|
67
|
-
"and all other sections. Returns an empty primer if
|
|
84
|
+
long_description="Displays the full project primer — notes, pinned tags, "
|
|
85
|
+
"pinned articles, and all other sections. Returns an empty primer if "
|
|
86
|
+
"none exists yet.",
|
|
68
87
|
examples=[
|
|
69
88
|
"kd /primer show",
|
|
70
89
|
"kd /primer show --output json",
|
|
71
90
|
],
|
|
72
|
-
see_also=["/primer/note list", "/primer/article list"],
|
|
91
|
+
see_also=["/primer/note list", "/primer/tag list", "/primer/article list"],
|
|
73
92
|
)
|
|
74
93
|
self.display_hint = "primer_show"
|
|
75
94
|
|
|
95
|
+
def _register_options(self) -> None:
|
|
96
|
+
self.register_option(project_option(help_text="Assert which project's primer this shows"))
|
|
97
|
+
|
|
76
98
|
def execute(
|
|
77
99
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
78
100
|
) -> CommandResult:
|
|
101
|
+
_assert_workspace_project(self, options)
|
|
79
102
|
data = load_primer()
|
|
80
103
|
return {
|
|
81
104
|
"notes": [{"#": i + 1, "note": n} for i, n in enumerate(data.notes)],
|
|
105
|
+
"tags": [{"name": t.name, "summary": t.summary or ""} for t in data.tags],
|
|
82
106
|
"articles": [{"id": a.id, "summary": a.summary or ""} for a in data.articles],
|
|
83
107
|
}
|
|
84
108
|
|
|
@@ -101,9 +125,13 @@ class PrimerNoteListCommand(Command):
|
|
|
101
125
|
see_also=["/primer/note add", "/primer/note remove"],
|
|
102
126
|
)
|
|
103
127
|
|
|
128
|
+
def _register_options(self) -> None:
|
|
129
|
+
self.register_option(project_option(help_text="Assert which project's primer this reads"))
|
|
130
|
+
|
|
104
131
|
def execute(
|
|
105
132
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
106
133
|
) -> CommandResult:
|
|
134
|
+
_assert_workspace_project(self, options)
|
|
107
135
|
data = load_primer()
|
|
108
136
|
return [{"index": i + 1, "note": note} for i, note in enumerate(data.notes)]
|
|
109
137
|
|
|
@@ -248,9 +276,13 @@ class PrimerArticleListCommand(Command):
|
|
|
248
276
|
see_also=["/primer/article add", "/primer/article remove"],
|
|
249
277
|
)
|
|
250
278
|
|
|
279
|
+
def _register_options(self) -> None:
|
|
280
|
+
self.register_option(project_option(help_text="Assert which project's primer this reads"))
|
|
281
|
+
|
|
251
282
|
def execute(
|
|
252
283
|
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
253
284
|
) -> CommandResult:
|
|
285
|
+
_assert_workspace_project(self, options)
|
|
254
286
|
data = load_primer()
|
|
255
287
|
return [{"id": a.id, "summary": a.summary or ""} for a in data.articles]
|
|
256
288
|
|
|
@@ -342,6 +374,153 @@ class PrimerArticleRemoveCommand(Command):
|
|
|
342
374
|
return {"ok": True, "action": "remove", "id": removed.id}
|
|
343
375
|
|
|
344
376
|
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
# /primer/tag
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class PrimerTagListCommand(Command):
|
|
383
|
+
"""List pinned tags."""
|
|
384
|
+
|
|
385
|
+
def __init__(self) -> None:
|
|
386
|
+
super().__init__(
|
|
387
|
+
"list",
|
|
388
|
+
"List pinned tags",
|
|
389
|
+
usage="kd /primer/tag list",
|
|
390
|
+
long_description="Lists all tags pinned in the project primer. Pinned "
|
|
391
|
+
"tags act as the OR scope for `kd /tag entities --primer-scope`.",
|
|
392
|
+
examples=["kd /primer/tag list"],
|
|
393
|
+
see_also=["/primer/tag add", "/primer/tag remove", "/tag entities"],
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _register_options(self) -> None:
|
|
397
|
+
self.register_option(project_option(help_text="Assert which project's primer this reads"))
|
|
398
|
+
|
|
399
|
+
def execute(
|
|
400
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
401
|
+
) -> CommandResult:
|
|
402
|
+
_assert_workspace_project(self, options)
|
|
403
|
+
data = load_primer()
|
|
404
|
+
return [{"name": t.name, "summary": t.summary or ""} for t in data.tags]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class PrimerTagAddCommand(Command):
|
|
408
|
+
"""Pin a tag to the primer."""
|
|
409
|
+
|
|
410
|
+
def __init__(self) -> None:
|
|
411
|
+
super().__init__(
|
|
412
|
+
"add",
|
|
413
|
+
"Pin a tag to the primer",
|
|
414
|
+
requires_config=True,
|
|
415
|
+
requires_client=True,
|
|
416
|
+
usage="kd /primer/tag add TAG_NAME [--summary S]",
|
|
417
|
+
long_description="Adds a tag to the project primer. Upstream existence "
|
|
418
|
+
"is validated via the tag namespace: tags not found upstream are still "
|
|
419
|
+
"written but return a `tag_not_found` warning — this supports declaring "
|
|
420
|
+
"tag intent before the tag is created. A failed validation call "
|
|
421
|
+
"(network / auth / timeout) writes the entry with a "
|
|
422
|
+
"`tag_validation_skipped` warning so a local primer edit is not "
|
|
423
|
+
"silently blocked by an unreachable server.",
|
|
424
|
+
examples=[
|
|
425
|
+
"kd /primer/tag add kind:incident",
|
|
426
|
+
'kd /primer/tag add kind:incident --summary "Active incidents"',
|
|
427
|
+
],
|
|
428
|
+
see_also=["/primer/tag list", "/primer/tag remove", "/tag entities"],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _register_options(self) -> None:
|
|
432
|
+
self.register_option(
|
|
433
|
+
OptionDeclaration(
|
|
434
|
+
name="summary",
|
|
435
|
+
short_name="s",
|
|
436
|
+
layer=OptionLayer.COMMAND,
|
|
437
|
+
help_text="Description to store with the tag reference",
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
self.register_option(project_option(help_text="Assert which project's primer this edits"))
|
|
441
|
+
|
|
442
|
+
def execute(
|
|
443
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
444
|
+
) -> CommandResult:
|
|
445
|
+
_assert_workspace_project(self, options)
|
|
446
|
+
if not args:
|
|
447
|
+
raise ValidationError("Usage: kd /primer/tag add TAG_NAME")
|
|
448
|
+
name = args[0]
|
|
449
|
+
summary = options.command.get("summary")
|
|
450
|
+
|
|
451
|
+
warning: dict[str, Any] | None = None
|
|
452
|
+
try:
|
|
453
|
+
client.tags.get(name)
|
|
454
|
+
except NotFoundError as exc:
|
|
455
|
+
warning = emit_warning(
|
|
456
|
+
"tag_not_found",
|
|
457
|
+
f"Tag '{name}' not found upstream — pinning anyway (declare-intent).",
|
|
458
|
+
name=name,
|
|
459
|
+
detail=str(exc),
|
|
460
|
+
)
|
|
461
|
+
except KdError as exc:
|
|
462
|
+
warning = emit_warning(
|
|
463
|
+
"tag_validation_skipped",
|
|
464
|
+
f"Could not validate tag '{name}' upstream ({type(exc).__name__}) — "
|
|
465
|
+
"pinning the entry; verify the name when the upstream is reachable.",
|
|
466
|
+
name=name,
|
|
467
|
+
error_code=type(exc).__name__,
|
|
468
|
+
detail=str(exc),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
tag = add_tag(name, summary=summary)
|
|
472
|
+
result: dict[str, Any] = {"ok": True, "action": "add", "name": tag.name}
|
|
473
|
+
if tag.summary:
|
|
474
|
+
result["summary"] = tag.summary
|
|
475
|
+
if warning is not None:
|
|
476
|
+
result["warnings"] = [warning]
|
|
477
|
+
return result
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class PrimerTagRemoveCommand(Command):
|
|
481
|
+
"""Unpin a tag by name, or all tags with --all."""
|
|
482
|
+
|
|
483
|
+
def __init__(self) -> None:
|
|
484
|
+
super().__init__(
|
|
485
|
+
"remove",
|
|
486
|
+
"Unpin a tag or all tags",
|
|
487
|
+
requires_config=True,
|
|
488
|
+
usage="kd /primer/tag remove TAG_NAME | --all",
|
|
489
|
+
long_description="Removes the tag with the given name from the primer, "
|
|
490
|
+
"or all pinned tags if --all is passed. Local-only edit — no upstream "
|
|
491
|
+
"call.",
|
|
492
|
+
examples=[
|
|
493
|
+
"kd /primer/tag remove kind:incident",
|
|
494
|
+
"kd /primer/tag remove --all",
|
|
495
|
+
],
|
|
496
|
+
see_also=["/primer/tag list", "/primer/tag add"],
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def _register_options(self) -> None:
|
|
500
|
+
self.register_option(
|
|
501
|
+
OptionDeclaration(
|
|
502
|
+
name="all",
|
|
503
|
+
layer=OptionLayer.COMMAND,
|
|
504
|
+
type=bool,
|
|
505
|
+
default=False,
|
|
506
|
+
help_text="Remove all pinned tags",
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
self.register_option(project_option(help_text="Assert which project's primer this edits"))
|
|
510
|
+
|
|
511
|
+
def execute(
|
|
512
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
513
|
+
) -> CommandResult:
|
|
514
|
+
_assert_workspace_project(self, options)
|
|
515
|
+
if options.command.get("all"):
|
|
516
|
+
count = clear_tags()
|
|
517
|
+
return {"ok": True, "action": "clear", "removed": count}
|
|
518
|
+
if not args:
|
|
519
|
+
raise ValidationError("Usage: kd /primer/tag remove TAG_NAME | --all")
|
|
520
|
+
removed = remove_tag(args[0])
|
|
521
|
+
return {"ok": True, "action": "remove", "name": removed.name}
|
|
522
|
+
|
|
523
|
+
|
|
345
524
|
# ---------------------------------------------------------------------------
|
|
346
525
|
# Namespace factory
|
|
347
526
|
# ---------------------------------------------------------------------------
|
|
@@ -364,6 +543,17 @@ def create_primer_namespace() -> Namespace:
|
|
|
364
543
|
note_ns.register_command("update", PrimerNoteUpdateCommand())
|
|
365
544
|
ns.register_sub_namespace(note_ns)
|
|
366
545
|
|
|
546
|
+
# /primer/tag
|
|
547
|
+
tag_ns = Namespace(
|
|
548
|
+
"tag",
|
|
549
|
+
"Pinned tag references",
|
|
550
|
+
path="primer/tag",
|
|
551
|
+
)
|
|
552
|
+
tag_ns.register_command("list", PrimerTagListCommand())
|
|
553
|
+
tag_ns.register_command("add", PrimerTagAddCommand())
|
|
554
|
+
tag_ns.register_command("remove", PrimerTagRemoveCommand())
|
|
555
|
+
ns.register_sub_namespace(tag_ns)
|
|
556
|
+
|
|
367
557
|
# /primer/article
|
|
368
558
|
article_ns = Namespace(
|
|
369
559
|
"article",
|