kd-cli 2.0.4__py3-none-any.whl
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/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/primer.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Project primer: local knowledge capsule for LLM consumers.
|
|
2
|
+
|
|
3
|
+
The primer stores project-specific context — conventions, key article
|
|
4
|
+
references, do's and don'ts — in ``.kd/primer.yaml``. LLMs read
|
|
5
|
+
the primer once via ``/primer show`` to get instant project context.
|
|
6
|
+
|
|
7
|
+
Storage is separate from ``config.yaml`` (tool settings vs project
|
|
8
|
+
knowledge). The file is created lazily on first write operation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from kd.exceptions import ConfigError, ValidationError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Data model
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PrimerArticle(BaseModel):
|
|
28
|
+
"""A pinned article reference."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
summary: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PrimerData(BaseModel):
|
|
35
|
+
"""Root primer document."""
|
|
36
|
+
|
|
37
|
+
notes: list[str] = []
|
|
38
|
+
articles: list[PrimerArticle] = []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# File I/O
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
PRIMER_FILENAME = "primer.yaml"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_workspace_dir(start: Path | None = None) -> Path | None:
|
|
49
|
+
"""Walk up from *start*, bounded by the git root, return first ``.kd/`` dir found.
|
|
50
|
+
|
|
51
|
+
Stops at the git repository root to prevent walking into a parent
|
|
52
|
+
directory's ``.kd/`` that would act as an unintended global primer.
|
|
53
|
+
If not in a git repo, only checks *start* itself.
|
|
54
|
+
"""
|
|
55
|
+
current = (start or Path.cwd()).resolve()
|
|
56
|
+
# Find the ceiling — git root or cwd-only if no repo
|
|
57
|
+
ceiling = _find_git_root(current)
|
|
58
|
+
if ceiling is None:
|
|
59
|
+
# Not in a git repo — only check the start directory
|
|
60
|
+
candidate = current / ".kd"
|
|
61
|
+
return candidate if candidate.is_dir() else None
|
|
62
|
+
while True:
|
|
63
|
+
candidate = current / ".kd"
|
|
64
|
+
if candidate.is_dir():
|
|
65
|
+
return candidate
|
|
66
|
+
if current == ceiling:
|
|
67
|
+
return None
|
|
68
|
+
parent = current.parent
|
|
69
|
+
if parent == current:
|
|
70
|
+
return None
|
|
71
|
+
current = parent
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
75
|
+
"""Walk up from *start* looking for a ``.git`` directory."""
|
|
76
|
+
current = start.resolve()
|
|
77
|
+
while True:
|
|
78
|
+
if (current / ".git").is_dir():
|
|
79
|
+
return current
|
|
80
|
+
parent = current.parent
|
|
81
|
+
if parent == current:
|
|
82
|
+
return None
|
|
83
|
+
current = parent
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def require_workspace_dir(start: Path | None = None) -> Path:
|
|
87
|
+
"""Find ``.kd/`` directory or raise."""
|
|
88
|
+
workspace_dir = _find_workspace_dir(start)
|
|
89
|
+
if workspace_dir is None:
|
|
90
|
+
raise ConfigError("No .kd/ directory found. Run 'kd init' first.")
|
|
91
|
+
return workspace_dir
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _primer_path(start: Path | None = None) -> Path:
|
|
95
|
+
"""Return path to primer.yaml inside the nearest .kd/ dir."""
|
|
96
|
+
return require_workspace_dir(start) / PRIMER_FILENAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_primer(path: Path | None = None) -> PrimerData:
|
|
100
|
+
"""Load primer from YAML. Returns empty primer if file or .kd/ doesn't exist."""
|
|
101
|
+
if path is not None:
|
|
102
|
+
primer_file = path
|
|
103
|
+
else:
|
|
104
|
+
workspace_dir = _find_workspace_dir()
|
|
105
|
+
if workspace_dir is None:
|
|
106
|
+
return PrimerData()
|
|
107
|
+
primer_file = workspace_dir / PRIMER_FILENAME
|
|
108
|
+
if not primer_file.exists():
|
|
109
|
+
return PrimerData()
|
|
110
|
+
try:
|
|
111
|
+
raw = yaml.safe_load(primer_file.read_text()) or {}
|
|
112
|
+
return PrimerData(**raw)
|
|
113
|
+
except (yaml.YAMLError, TypeError, ValueError) as e:
|
|
114
|
+
raise ConfigError(f"Invalid primer at {primer_file}: {e}") from e
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_primer(data: PrimerData, path: Path | None = None) -> None:
|
|
118
|
+
"""Write primer to YAML.
|
|
119
|
+
|
|
120
|
+
Snapshots the current file before writing (copy-on-write) and uses
|
|
121
|
+
atomic writes to prevent partial-write corruption.
|
|
122
|
+
"""
|
|
123
|
+
from kd.history import atomic_write_text, snapshot_before_write
|
|
124
|
+
|
|
125
|
+
primer_file = path or _primer_path()
|
|
126
|
+
snapshot_before_write(primer_file)
|
|
127
|
+
dumped = data.model_dump(exclude_none=True)
|
|
128
|
+
# Omit empty sections from the file
|
|
129
|
+
if not dumped.get("notes"):
|
|
130
|
+
dumped.pop("notes", None)
|
|
131
|
+
if not dumped.get("articles"):
|
|
132
|
+
dumped.pop("articles", None)
|
|
133
|
+
# Serialize articles as list of dicts (not bare IDs)
|
|
134
|
+
if "articles" in dumped:
|
|
135
|
+
dumped["articles"] = [_article_dump(a) for a in dumped["articles"]]
|
|
136
|
+
atomic_write_text(
|
|
137
|
+
primer_file,
|
|
138
|
+
yaml.dump(dumped, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _article_dump(article: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
"""Ensure article dict always has id; include summary only if set."""
|
|
144
|
+
result: dict[str, Any] = {"id": article["id"]}
|
|
145
|
+
if article.get("summary"):
|
|
146
|
+
result["summary"] = article["summary"]
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Mutations
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_note(text: str, path: Path | None = None) -> int:
|
|
156
|
+
"""Append a note. Returns the 1-based index."""
|
|
157
|
+
primer_file = path or _primer_path()
|
|
158
|
+
data = load_primer(primer_file)
|
|
159
|
+
data.notes.append(text)
|
|
160
|
+
save_primer(data, primer_file)
|
|
161
|
+
return len(data.notes)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def remove_note(index: int, path: Path | None = None) -> str:
|
|
165
|
+
"""Remove a note by 1-based index. Returns the removed text."""
|
|
166
|
+
primer_file = path or _primer_path()
|
|
167
|
+
data = load_primer(primer_file)
|
|
168
|
+
if index < 1 or index > len(data.notes):
|
|
169
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
170
|
+
removed = data.notes.pop(index - 1)
|
|
171
|
+
save_primer(data, primer_file)
|
|
172
|
+
return removed
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_note(index: int, text: str, path: Path | None = None) -> str:
|
|
176
|
+
"""Replace a note by 1-based index. Returns the old text."""
|
|
177
|
+
primer_file = path or _primer_path()
|
|
178
|
+
data = load_primer(primer_file)
|
|
179
|
+
if index < 1 or index > len(data.notes):
|
|
180
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
181
|
+
old = data.notes[index - 1]
|
|
182
|
+
data.notes[index - 1] = text
|
|
183
|
+
save_primer(data, primer_file)
|
|
184
|
+
return old
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def clear_notes(path: Path | None = None) -> int:
|
|
188
|
+
"""Remove all notes. Returns the count removed."""
|
|
189
|
+
primer_file = path or _primer_path()
|
|
190
|
+
data = load_primer(primer_file)
|
|
191
|
+
count = len(data.notes)
|
|
192
|
+
data.notes.clear()
|
|
193
|
+
save_primer(data, primer_file)
|
|
194
|
+
return count
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def add_article(
|
|
198
|
+
article_id: str, summary: str | None = None, path: Path | None = None
|
|
199
|
+
) -> PrimerArticle:
|
|
200
|
+
"""Pin an article. Raises if already pinned."""
|
|
201
|
+
primer_file = path or _primer_path()
|
|
202
|
+
data = load_primer(primer_file)
|
|
203
|
+
for existing in data.articles:
|
|
204
|
+
if existing.id == article_id:
|
|
205
|
+
raise ValidationError(f"Article '{article_id}' is already pinned")
|
|
206
|
+
article = PrimerArticle(id=article_id, summary=summary)
|
|
207
|
+
data.articles.append(article)
|
|
208
|
+
save_primer(data, primer_file)
|
|
209
|
+
return article
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
|
|
213
|
+
"""Unpin an article by ID. Returns the removed entry."""
|
|
214
|
+
primer_file = path or _primer_path()
|
|
215
|
+
data = load_primer(primer_file)
|
|
216
|
+
for i, article in enumerate(data.articles):
|
|
217
|
+
if article.id == article_id:
|
|
218
|
+
removed = data.articles.pop(i)
|
|
219
|
+
save_primer(data, primer_file)
|
|
220
|
+
return removed
|
|
221
|
+
raise ValidationError(f"Article '{article_id}' is not pinned")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def clear_articles(path: Path | None = None) -> int:
|
|
225
|
+
"""Remove all pinned articles. Returns the count removed."""
|
|
226
|
+
primer_file = path or _primer_path()
|
|
227
|
+
data = load_primer(primer_file)
|
|
228
|
+
count = len(data.articles)
|
|
229
|
+
data.articles.clear()
|
|
230
|
+
save_primer(data, primer_file)
|
|
231
|
+
return count
|
kd/query_parser.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Parse YouTrack query strings for article-compatible field extraction.
|
|
2
|
+
|
|
3
|
+
Extracts ``project:`` and ``tag:`` clauses from saved-query text so that
|
|
4
|
+
tag-based queries can be translated into article-tag lookups. Queries
|
|
5
|
+
containing issue-specific fields (state, assignee, priority, etc.) are
|
|
6
|
+
rejected with a clear error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from kd.exceptions import ValidationError
|
|
14
|
+
|
|
15
|
+
# Fields that only apply to issues and cannot be translated to article lookups.
|
|
16
|
+
_UNSUPPORTED_FIELDS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"state",
|
|
19
|
+
"assignee",
|
|
20
|
+
"for",
|
|
21
|
+
"priority",
|
|
22
|
+
"type",
|
|
23
|
+
"has",
|
|
24
|
+
"created",
|
|
25
|
+
"updated",
|
|
26
|
+
"resolved",
|
|
27
|
+
"reporter",
|
|
28
|
+
"sprint",
|
|
29
|
+
"fix versions",
|
|
30
|
+
"affected versions",
|
|
31
|
+
"subsystem",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_SUPPORTED_FIELDS = frozenset({"project", "tag"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ParsedQuery:
|
|
40
|
+
"""Result of parsing a YouTrack query for article-compatible fields."""
|
|
41
|
+
|
|
42
|
+
project: str | None = None
|
|
43
|
+
tags: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_article_query(query: str) -> ParsedQuery:
|
|
47
|
+
"""Parse a YouTrack query string for article-compatible fields.
|
|
48
|
+
|
|
49
|
+
Supported fields: ``project`` (at most one) and ``tag`` (zero or more).
|
|
50
|
+
Raises :class:`~kd.exceptions.ValidationError` if unsupported
|
|
51
|
+
issue-specific fields or ``#macros`` are found.
|
|
52
|
+
"""
|
|
53
|
+
stripped = query.strip()
|
|
54
|
+
if not stripped:
|
|
55
|
+
return ParsedQuery()
|
|
56
|
+
|
|
57
|
+
tokens = _tokenize(stripped)
|
|
58
|
+
_reject_unsupported(tokens)
|
|
59
|
+
|
|
60
|
+
project: str | None = None
|
|
61
|
+
tags: list[str] = []
|
|
62
|
+
|
|
63
|
+
for field_name, value in tokens:
|
|
64
|
+
key = field_name.lower()
|
|
65
|
+
if key == "project":
|
|
66
|
+
project = value
|
|
67
|
+
elif key == "tag":
|
|
68
|
+
tags.append(value)
|
|
69
|
+
|
|
70
|
+
return ParsedQuery(project=project, tags=tags)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tokenize(query: str) -> list[tuple[str, str]]:
|
|
74
|
+
"""Tokenize a YouTrack query into (field, value) pairs.
|
|
75
|
+
|
|
76
|
+
Handles braced values (``tag: {multi word}``) and bare values
|
|
77
|
+
(``tag: single``). Also detects ``#macros`` like ``#Unresolved``.
|
|
78
|
+
"""
|
|
79
|
+
tokens: list[tuple[str, str]] = []
|
|
80
|
+
pos = 0
|
|
81
|
+
length = len(query)
|
|
82
|
+
|
|
83
|
+
while pos < length:
|
|
84
|
+
# Skip whitespace.
|
|
85
|
+
if query[pos].isspace():
|
|
86
|
+
pos += 1
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Detect #macros (e.g. #Unresolved, #Resolved).
|
|
90
|
+
if query[pos] == "#":
|
|
91
|
+
start = pos
|
|
92
|
+
pos += 1
|
|
93
|
+
while pos < length and not query[pos].isspace():
|
|
94
|
+
pos += 1
|
|
95
|
+
macro = query[start:pos]
|
|
96
|
+
tokens.append(("#macro", macro))
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Read a field name: word characters (letters, digits, underscore)
|
|
100
|
+
# and spaces (for multi-word fields like "fix versions").
|
|
101
|
+
field_start = pos
|
|
102
|
+
while pos < length and query[pos] != ":":
|
|
103
|
+
if query[pos].isspace():
|
|
104
|
+
# Peek ahead: if there's a colon after optional words, it's
|
|
105
|
+
# still part of a multi-word field name. Otherwise stop.
|
|
106
|
+
peek = pos
|
|
107
|
+
while peek < length and query[peek].isspace():
|
|
108
|
+
peek += 1
|
|
109
|
+
# If we hit a colon or another word char followed eventually
|
|
110
|
+
# by a colon, continue. Simple heuristic: look for colon
|
|
111
|
+
# within the next token.
|
|
112
|
+
colon_ahead = False
|
|
113
|
+
scan = peek
|
|
114
|
+
while scan < length and query[scan] not in (":", "#"):
|
|
115
|
+
if query[scan].isspace():
|
|
116
|
+
break
|
|
117
|
+
scan += 1
|
|
118
|
+
if scan < length and query[scan] == ":":
|
|
119
|
+
# The next word ends with colon → multi-word field.
|
|
120
|
+
pos = peek
|
|
121
|
+
continue
|
|
122
|
+
else:
|
|
123
|
+
colon_ahead = False # noqa: F841
|
|
124
|
+
break
|
|
125
|
+
pos += 1
|
|
126
|
+
|
|
127
|
+
if pos >= length or query[pos] != ":":
|
|
128
|
+
# Not a field: value pair — skip this word.
|
|
129
|
+
pos += 1
|
|
130
|
+
while pos < length and not query[pos].isspace():
|
|
131
|
+
pos += 1
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
field_name = query[field_start:pos].strip()
|
|
135
|
+
pos += 1 # skip the colon
|
|
136
|
+
|
|
137
|
+
# Skip whitespace after colon.
|
|
138
|
+
while pos < length and query[pos].isspace():
|
|
139
|
+
pos += 1
|
|
140
|
+
|
|
141
|
+
if pos >= length:
|
|
142
|
+
tokens.append((field_name, ""))
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
# Read the value: braced or bare.
|
|
146
|
+
if query[pos] == "{":
|
|
147
|
+
pos += 1 # skip opening brace
|
|
148
|
+
value_start = pos
|
|
149
|
+
while pos < length and query[pos] != "}":
|
|
150
|
+
pos += 1
|
|
151
|
+
value = query[value_start:pos]
|
|
152
|
+
if pos < length:
|
|
153
|
+
pos += 1 # skip closing brace
|
|
154
|
+
else:
|
|
155
|
+
value_start = pos
|
|
156
|
+
while pos < length and not query[pos].isspace():
|
|
157
|
+
pos += 1
|
|
158
|
+
value = query[value_start:pos]
|
|
159
|
+
|
|
160
|
+
tokens.append((field_name, value))
|
|
161
|
+
|
|
162
|
+
return tokens
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _reject_unsupported(tokens: list[tuple[str, str]]) -> None:
|
|
166
|
+
"""Raise ValidationError if any tokens are unsupported for article queries."""
|
|
167
|
+
unsupported: list[str] = []
|
|
168
|
+
|
|
169
|
+
for field_name, value in tokens:
|
|
170
|
+
if field_name == "#macro":
|
|
171
|
+
unsupported.append(value)
|
|
172
|
+
continue
|
|
173
|
+
if field_name.lower() not in _SUPPORTED_FIELDS:
|
|
174
|
+
unsupported.append(field_name)
|
|
175
|
+
|
|
176
|
+
if unsupported:
|
|
177
|
+
joined = ", ".join(unsupported)
|
|
178
|
+
raise ValidationError(
|
|
179
|
+
f"Cannot search articles with issue-specific fields: {joined}. "
|
|
180
|
+
"Only project and tag are supported."
|
|
181
|
+
)
|
kd/resources/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged resources for kd (config templates, etc.)."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Built-in command synonyms for LLM and CLI familiarity.
|
|
2
|
+
#
|
|
3
|
+
# Each entry maps a synonym to the canonical command name.
|
|
4
|
+
# Applied globally to any namespace that has the canonical command
|
|
5
|
+
# but does not already define a command with the synonym name.
|
|
6
|
+
#
|
|
7
|
+
# Keep this set small and curated. See YTCTL-8.
|
|
8
|
+
|
|
9
|
+
- synonym: get
|
|
10
|
+
canonical: show
|
|
11
|
+
|
|
12
|
+
- synonym: ls
|
|
13
|
+
canonical: list
|
|
14
|
+
|
|
15
|
+
- synonym: read
|
|
16
|
+
canonical: show
|
|
17
|
+
|
|
18
|
+
- synonym: rm
|
|
19
|
+
canonical: delete
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# kd project config
|
|
2
|
+
#
|
|
3
|
+
# Values here override ~/.config/kd/config.yaml for this directory tree.
|
|
4
|
+
# Resolution order: CLI flags > local config > connection > global config.
|
|
5
|
+
#
|
|
6
|
+
# Connection keys:
|
|
7
|
+
# url - YouTrack instance base URL
|
|
8
|
+
# token_cmd - Shell command that prints the API token to stdout
|
|
9
|
+
# token_env - Environment variable name containing the API token
|
|
10
|
+
# default_project - Default project key for commands that accept --project
|
|
11
|
+
# output - Default output format: table, json, yaml, md
|
|
12
|
+
# limit - Default result limit for list commands
|
|
13
|
+
# compatibility - Version targeting (target + mode)
|
|
14
|
+
#
|
|
15
|
+
# At least one of token_cmd or token_env is required per connection.
|
|
16
|
+
# Secrets are never stored as raw values in config.
|
|
17
|
+
|
|
18
|
+
# default_connection: main
|
|
19
|
+
|
|
20
|
+
# connections:
|
|
21
|
+
# main:
|
|
22
|
+
# provider: youtrack
|
|
23
|
+
# url: https://your-instance.youtrack.cloud
|
|
24
|
+
# # token_cmd: gopass show -o path/to/token
|
|
25
|
+
# token_env: KD_TOKEN
|
|
26
|
+
# # default_project: PROJ
|
|
27
|
+
# # output: table
|
|
28
|
+
# # limit: 50
|
|
29
|
+
# # defaults:
|
|
30
|
+
# # issue_list:
|
|
31
|
+
# # query: "for: me #Unresolved"
|
|
32
|
+
# # compatibility:
|
|
33
|
+
# # target: auto
|
|
34
|
+
# # mode: pinned
|
|
35
|
+
|
|
36
|
+
# Global fallbacks (apply when connection doesn't specify):
|
|
37
|
+
# output: table
|
|
38
|
+
# limit: 50
|
|
39
|
+
# debug: false
|
|
40
|
+
|
|
41
|
+
# Per-command defaults (namespace_command as key):
|
|
42
|
+
# defaults:
|
|
43
|
+
# issue_list:
|
|
44
|
+
# limit: 100
|
|
45
|
+
# query: "for: me #Unresolved"
|
|
46
|
+
# issue_show:
|
|
47
|
+
# detail: rich
|
|
48
|
+
|
|
49
|
+
# Per-project overrides:
|
|
50
|
+
# projects:
|
|
51
|
+
# FB:
|
|
52
|
+
# field_aliases:
|
|
53
|
+
# state: Stage
|
|
54
|
+
# type: Kind
|
|
55
|
+
# defaults:
|
|
56
|
+
# issue_list:
|
|
57
|
+
# fields: id,summary,state,priority,assignee
|
|
58
|
+
|
|
59
|
+
# History settings (copy-on-write snapshots before config/primer writes):
|
|
60
|
+
# history:
|
|
61
|
+
# retention: 20 # max snapshots per file (default: 20)
|
|
62
|
+
|
|
63
|
+
# Command aliases (shortcuts that expand to full commands):
|
|
64
|
+
# aliases:
|
|
65
|
+
# my-bugs: "/issue list --query 'for: me Type: Bug #Unresolved'"
|
|
66
|
+
# sprint: "/issue list --query '#Unresolved Board Sprint: {Current sprint}'"
|
|
67
|
+
# standup: "/issue list --query 'for: me updated: Today'"
|
|
68
|
+
#
|
|
69
|
+
# Invoke directly: kd my-bugs
|
|
70
|
+
# Explicit form: kd alias my-bugs
|
|
71
|
+
# With overrides: kd my-bugs --limit 5
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Error message mappings for kd
|
|
2
|
+
#
|
|
3
|
+
# Each entry rewrites an upstream API error into a user-friendly message
|
|
4
|
+
# with an actionable suggestion. Rules are matched in order; the first
|
|
5
|
+
# match wins. A rule matches iff every present filter matches:
|
|
6
|
+
#
|
|
7
|
+
# status - Optional HTTP status code. Exact integer equality.
|
|
8
|
+
# path - Optional regex matched against the request URL path.
|
|
9
|
+
# Use named groups like (?P<id>[^/]+) to expose values to
|
|
10
|
+
# the message/suggestion templates as {id}.
|
|
11
|
+
# pattern - Optional regex matched against the extracted response
|
|
12
|
+
# message. Positional groups are exposed as {1}, {2}, ...
|
|
13
|
+
#
|
|
14
|
+
# message - Rewritten user-facing message. Required.
|
|
15
|
+
# suggestion - Optional actionable hint shown below the error.
|
|
16
|
+
#
|
|
17
|
+
# Rule ordering convention:
|
|
18
|
+
# 1. Sub-resource 404 rules first (most specific path segments).
|
|
19
|
+
# 2. Top-level entity 404 rules (issues, articles).
|
|
20
|
+
# 3. Message-based rules (status/path unset) last.
|
|
21
|
+
# Reordering is load-bearing — narrower rules must precede broader ones.
|
|
22
|
+
|
|
23
|
+
# --- Time tracking 403 ---
|
|
24
|
+
|
|
25
|
+
- status: 403
|
|
26
|
+
path: '/issues/[^/]+/timeTracking'
|
|
27
|
+
message: 'Access denied for time tracking operation'
|
|
28
|
+
suggestion: >-
|
|
29
|
+
Time tracking may not be enabled for this project.
|
|
30
|
+
Enable it in YouTrack: project Settings → Time Tracking.
|
|
31
|
+
|
|
32
|
+
# --- Sub-resource 404s (issues) ---
|
|
33
|
+
|
|
34
|
+
- status: 404
|
|
35
|
+
path: '/issues/[^/]+/timeTracking/workItems/(?P<id>[^/]+)$'
|
|
36
|
+
message: 'Work item {id} not found'
|
|
37
|
+
suggestion: 'List work items with: kd /time list <ISSUE>.'
|
|
38
|
+
|
|
39
|
+
- status: 404
|
|
40
|
+
path: '/issues/[^/]+/attachments/(?P<id>[^/]+)(/content)?$'
|
|
41
|
+
message: 'Attachment {id} not found'
|
|
42
|
+
suggestion: 'List attachments with: kd /issue attachment list <ISSUE>.'
|
|
43
|
+
|
|
44
|
+
- status: 404
|
|
45
|
+
path: '/issues/[^/]+/comments/(?P<id>[^/]+)$'
|
|
46
|
+
message: 'Comment {id} not found'
|
|
47
|
+
suggestion: 'Show the parent issue to list its comments.'
|
|
48
|
+
|
|
49
|
+
# --- Sub-resource 404s (articles) ---
|
|
50
|
+
|
|
51
|
+
- status: 404
|
|
52
|
+
path: '/articles/[^/]+/attachments/(?P<id>[^/]+)(/content)?$'
|
|
53
|
+
message: 'Attachment {id} not found'
|
|
54
|
+
suggestion: 'List attachments with: kd /article attachment list <ARTICLE>.'
|
|
55
|
+
|
|
56
|
+
- status: 404
|
|
57
|
+
path: '/articles/[^/]+/comments/(?P<id>[^/]+)$'
|
|
58
|
+
message: 'Comment {id} not found'
|
|
59
|
+
suggestion: 'List comments with: kd /article comment list <ARTICLE>.'
|
|
60
|
+
|
|
61
|
+
# --- Top-level 404s ---
|
|
62
|
+
|
|
63
|
+
- status: 404
|
|
64
|
+
path: '/articles/(?P<id>[^/]+)(/.*)?$'
|
|
65
|
+
message: 'Article {id} not found'
|
|
66
|
+
suggestion: 'Check the article ID with: kd /article list.'
|
|
67
|
+
|
|
68
|
+
- status: 404
|
|
69
|
+
path: '/issues/(?P<id>[^/]+)(/.*)?$'
|
|
70
|
+
message: 'Issue {id} not found'
|
|
71
|
+
suggestion: 'Check the issue ID and project key.'
|
|
72
|
+
|
|
73
|
+
# --- Query parse errors ---
|
|
74
|
+
|
|
75
|
+
- pattern: "(?i)can.t parse search query"
|
|
76
|
+
message: 'Query syntax error'
|
|
77
|
+
suggestion: >-
|
|
78
|
+
Values containing special characters (colons, spaces, braces) must be
|
|
79
|
+
wrapped in braces, e.g. tag: {type:case}. See YouTrack query syntax reference.
|
|
80
|
+
|
|
81
|
+
# --- Message-based rules (any status, any path) ---
|
|
82
|
+
|
|
83
|
+
- pattern: 'The value "(.+)" isn''t used for the project field'
|
|
84
|
+
message: 'Project "{1}" does not exist'
|
|
85
|
+
suggestion: 'Run "kd project list" to see available projects.'
|
|
86
|
+
|
|
87
|
+
- pattern: "Unknown command: (.+)"
|
|
88
|
+
message: 'Unknown YouTrack command: {1}'
|
|
89
|
+
suggestion: 'Check command syntax in the YouTrack documentation.'
|
|
90
|
+
|
|
91
|
+
- pattern: "incompatible-issue-custom-field-name-(.+)"
|
|
92
|
+
message: 'Field "{1}" is not available in this project'
|
|
93
|
+
suggestion: 'Run "kd issue show <ID>" to see which fields this project uses.'
|
|
94
|
+
|
|
95
|
+
- pattern: "Token is incorrect or has expired"
|
|
96
|
+
message: "API token is invalid or expired"
|
|
97
|
+
suggestion: 'Check your token with "kd system ping" or generate a new one in YouTrack → Profile → Authentication.'
|
|
98
|
+
|
|
99
|
+
- pattern: "Banned: Too many failed authentication attempts"
|
|
100
|
+
message: "Too many failed authentication attempts"
|
|
101
|
+
suggestion: "Wait a few minutes and verify your token is correct."
|