kd-cli 2.9.1__tar.gz → 2.11.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.9.1/kd_cli.egg-info → kd_cli-2.11.0}/PKG-INFO +19 -15
- {kd_cli-2.9.1 → kd_cli-2.11.0}/README.md +18 -14
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/http.py +115 -18
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/youtrack.py +3 -3
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/article_marker.py +23 -9
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/auth.py +2 -23
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cache.py +3 -3
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/command_base.py +21 -0
- kd_cli-2.11.0/kd/cli/core/command_parser.py +348 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/option_registry.py +24 -8
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/input.py +14 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/main.py +218 -12
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/article/__init__.py +59 -29
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/cache/__init__.py +33 -22
- kd_cli-2.11.0/kd/cli/namespaces/init.py +135 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/issue/__init__.py +89 -48
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/primer/__init__.py +84 -27
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/project/__init__.py +12 -12
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/query/__init__.py +16 -20
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/system/__init__.py +229 -120
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/tag/__init__.py +10 -6
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/time/__init__.py +2 -2
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/user/__init__.py +5 -6
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/output.py +69 -21
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/client.py +164 -25
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/config.py +661 -114
- kd_cli-2.11.0/kd/config_read.py +332 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/connection.py +110 -51
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/exceptions.py +56 -18
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/primer.py +114 -26
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/CHANGELOG.md +247 -85
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/command-synonyms.yaml +1 -1
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/error-messages.yaml +124 -13
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/operating-guide.md +179 -44
- kd_cli-2.11.0/kd/resources/primers/hitl.yaml +27 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0/kd_cli.egg-info}/PKG-INFO +19 -15
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/SOURCES.txt +0 -2
- {kd_cli-2.9.1 → kd_cli-2.11.0}/pyproject.toml +2 -1
- kd_cli-2.9.1/kd/cli/core/command_parser.py +0 -246
- kd_cli-2.9.1/kd/cli/namespaces/init.py +0 -61
- kd_cli-2.9.1/kd/config_read.py +0 -194
- kd_cli-2.9.1/kd/resources/primers/hitl.yaml +0 -41
- kd_cli-2.9.1/kd/resources/sdlc-doctrine.md +0 -602
- kd_cli-2.9.1/kd/sdlc.py +0 -21
- {kd_cli-2.9.1 → kd_cli-2.11.0}/LICENSE +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/MANIFEST.in +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/articles.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/hub.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/api/tags.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/config_schema.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/debug.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/history.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/locking.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/article.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/assist.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/comment.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/common.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/issue.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/project.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/search.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/tag.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/user.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/query_parser.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/release_notes.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.9.1 → kd_cli-2.11.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kd-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.0
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,7 +59,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
|
|
|
59
59
|
It exists to solve a problem that has gotten worse as LLMs have become central to real work:
|
|
60
60
|
|
|
61
61
|
- **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
|
|
62
|
-
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT
|
|
62
|
+
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
|
|
63
63
|
- **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
|
|
64
64
|
- **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
|
|
65
65
|
|
|
@@ -83,20 +83,24 @@ uv tool install kd-cli
|
|
|
83
83
|
|
|
84
84
|
The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
|
|
85
85
|
|
|
86
|
-
Release notes for the installed version: `kd /release-notes`.
|
|
86
|
+
Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
|
|
87
87
|
|
|
88
88
|
## Configure
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
#
|
|
92
|
-
kd init
|
|
91
|
+
# Global config with your YouTrack URL (recommended on first install).
|
|
92
|
+
kd init --global https://your-instance.youtrack.cloud
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
export KD_TOKEN=
|
|
96
|
-
|
|
94
|
+
# The scaffolded connection reads its token from KD_TOKEN (its token_env).
|
|
95
|
+
export KD_TOKEN=perm:your-token-here
|
|
96
|
+
|
|
97
|
+
# Check the connection.
|
|
98
|
+
kd /system info
|
|
97
99
|
```
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
|
|
102
|
+
|
|
103
|
+
Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
|
|
100
104
|
|
|
101
105
|
## Usage
|
|
102
106
|
|
|
@@ -116,13 +120,13 @@ kd /issue show FB-1
|
|
|
116
120
|
kd /time log FB-1 45 --type Development --description "Router reconfiguration"
|
|
117
121
|
|
|
118
122
|
# Articles
|
|
119
|
-
kd /article list --project
|
|
120
|
-
kd /article push guide.md --project
|
|
123
|
+
kd /article list --project FB
|
|
124
|
+
kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
|
|
121
125
|
kd /article pull FB-A-42 > setup.md
|
|
122
126
|
|
|
123
127
|
# Tags
|
|
124
128
|
kd /tag list
|
|
125
|
-
kd /tag articles type:case --project
|
|
129
|
+
kd /tag articles type:case --project FB
|
|
126
130
|
|
|
127
131
|
# Saved queries
|
|
128
132
|
kd /query list
|
|
@@ -156,7 +160,7 @@ client = YouTrackClient(
|
|
|
156
160
|
projects = client.projects.list()
|
|
157
161
|
issue = client.issues.create(project="FB", summary="Fix router config")
|
|
158
162
|
client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
|
|
159
|
-
articles = client.tags.articles(["type:case"], project="
|
|
163
|
+
articles = client.tags.articles(["type:case"], project="FB")
|
|
160
164
|
results = client.search.search("router", types=["issue", "article"])
|
|
161
165
|
```
|
|
162
166
|
|
|
@@ -167,8 +171,8 @@ pytest tests/ -v --tb=short
|
|
|
167
171
|
|
|
168
172
|
ruff format kd/ tests/
|
|
169
173
|
ruff check --fix kd/ tests/
|
|
170
|
-
mypy
|
|
174
|
+
uv run mypy
|
|
171
175
|
```
|
|
172
176
|
|
|
173
177
|
---
|
|
174
|
-
Updated: 2026-
|
|
178
|
+
Updated: 2026-11-01
|
|
@@ -19,7 +19,7 @@ The **primer** (`kd /primer`) is the centerpiece: a small, workspace-local file
|
|
|
19
19
|
It exists to solve a problem that has gotten worse as LLMs have become central to real work:
|
|
20
20
|
|
|
21
21
|
- **Visible memory.** Agentic tools accumulate hidden "memories" in opaque per-LLM buckets you cannot easily read, edit, or audit. The primer is a plain file you can open, version, and review. If an LLM appears to "remember" something load-bearing, you can see exactly where that came from.
|
|
22
|
-
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT
|
|
22
|
+
- **LLM-portable.** One primer works for any model. Switch from Claude to GPT to the next generation — the primer travels with the project, not with a vendor.
|
|
23
23
|
- **Shared by humans and LLMs.** A developer jotting "on this project the sprint field is called `Board`" in the primer is building the same artifact an LLM would build, in the same place, readable by both. No separate convention to maintain.
|
|
24
24
|
- **A routing layer, not a copy.** The primer can pin real knowledge-base articles (`kd /primer/article add FB-A-42`). It never duplicates their content — it points at them. The rest of kd extends this shape: pull what the current task needs from live systems, reason, push updates back. Nothing gets mirrored into a parallel store.
|
|
25
25
|
|
|
@@ -43,20 +43,24 @@ uv tool install kd-cli
|
|
|
43
43
|
|
|
44
44
|
The PyPI distribution name is `kd-cli` (two-letter `kd` is reserved by PyPI policy). The installed command, the import name, and the CLI entry point are all `kd`.
|
|
45
45
|
|
|
46
|
-
Release notes for the installed version: `kd /release-notes`.
|
|
46
|
+
Release notes for the installed version: `kd /release-notes`. Every release is listed in the [changelog](https://github.com/IntellectSolutionsCom/kd-cli/blob/main/CHANGELOG.md).
|
|
47
47
|
|
|
48
48
|
## Configure
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
#
|
|
52
|
-
kd init
|
|
51
|
+
# Global config with your YouTrack URL (recommended on first install).
|
|
52
|
+
kd init --global https://your-instance.youtrack.cloud
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
export KD_TOKEN=
|
|
56
|
-
|
|
54
|
+
# The scaffolded connection reads its token from KD_TOKEN (its token_env).
|
|
55
|
+
export KD_TOKEN=perm:your-token-here
|
|
56
|
+
|
|
57
|
+
# Check the connection.
|
|
58
|
+
kd /system info
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
Create the token in YouTrack under Profile → Account Security → New token. In PowerShell, set it with `$env:KD_TOKEN = "perm:your-token-here"`, or `setx KD_TOKEN perm:your-token-here` to keep it, then open a new terminal. `token_cmd:` in the connection reads the token from a command, such as a secrets manager, instead.
|
|
62
|
+
|
|
63
|
+
Global config lives at `~/.config/kd/config.yaml` (Windows: `C:\Users\<you>\.config\kd\config.yaml`); `kd init <url>` without `--global` writes `.kd/config.yaml` in the current directory. `KD_CONNECTION` picks the connection by name.
|
|
60
64
|
|
|
61
65
|
## Usage
|
|
62
66
|
|
|
@@ -76,13 +80,13 @@ kd /issue show FB-1
|
|
|
76
80
|
kd /time log FB-1 45 --type Development --description "Router reconfiguration"
|
|
77
81
|
|
|
78
82
|
# Articles
|
|
79
|
-
kd /article list --project
|
|
80
|
-
kd /article push guide.md --project
|
|
83
|
+
kd /article list --project FB
|
|
84
|
+
kd /article push guide.md --project FB --title "Setup Guide" --tag "type:guide"
|
|
81
85
|
kd /article pull FB-A-42 > setup.md
|
|
82
86
|
|
|
83
87
|
# Tags
|
|
84
88
|
kd /tag list
|
|
85
|
-
kd /tag articles type:case --project
|
|
89
|
+
kd /tag articles type:case --project FB
|
|
86
90
|
|
|
87
91
|
# Saved queries
|
|
88
92
|
kd /query list
|
|
@@ -116,7 +120,7 @@ client = YouTrackClient(
|
|
|
116
120
|
projects = client.projects.list()
|
|
117
121
|
issue = client.issues.create(project="FB", summary="Fix router config")
|
|
118
122
|
client.time.log(issue_id="FB-1", minutes=45, work_type="Development")
|
|
119
|
-
articles = client.tags.articles(["type:case"], project="
|
|
123
|
+
articles = client.tags.articles(["type:case"], project="FB")
|
|
120
124
|
results = client.search.search("router", types=["issue", "article"])
|
|
121
125
|
```
|
|
122
126
|
|
|
@@ -127,8 +131,8 @@ pytest tests/ -v --tb=short
|
|
|
127
131
|
|
|
128
132
|
ruff format kd/ tests/
|
|
129
133
|
ruff check --fix kd/ tests/
|
|
130
|
-
mypy
|
|
134
|
+
uv run mypy
|
|
131
135
|
```
|
|
132
136
|
|
|
133
137
|
---
|
|
134
|
-
Updated: 2026-
|
|
138
|
+
Updated: 2026-11-01
|
|
@@ -11,6 +11,7 @@ Wraps httpx.Client with:
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
|
+
import math
|
|
14
15
|
import time
|
|
15
16
|
from typing import Any
|
|
16
17
|
|
|
@@ -23,11 +24,69 @@ from kd.exceptions import (
|
|
|
23
24
|
ConflictError,
|
|
24
25
|
NotFoundError,
|
|
25
26
|
PermissionDeniedError,
|
|
27
|
+
ValidationError,
|
|
26
28
|
enrich_error,
|
|
27
29
|
)
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
_GATEWAY_RETRY = {502, 503} # retried, only for a request that cannot write
|
|
32
|
+
_GATEWAY_UNCONFIRMED = {502, 503, 504} # on a write: may have been applied
|
|
33
|
+
_MAX_ATTEMPTS = 3
|
|
34
|
+
_BACKOFF_BASE = 1.0 # seconds; doubles per attempt
|
|
35
|
+
_RETRY_AFTER_CAP = 10.0 # seconds; a longer Retry-After ends the request
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _refuse_control_characters(path: str) -> None:
|
|
39
|
+
"""Refuse a path that no URL can carry, before it is sent.
|
|
40
|
+
|
|
41
|
+
The refused set is exactly what httpx rejects as non-printable ASCII:
|
|
42
|
+
C0 controls and DEL. The user values kd puts in a path are entity ids,
|
|
43
|
+
one per segment, so the error names the segment that carries the
|
|
44
|
+
character, escaped onto one line.
|
|
45
|
+
"""
|
|
46
|
+
for segment in path.split("/"):
|
|
47
|
+
if any(c < " " or c == "\x7f" for c in segment):
|
|
48
|
+
raise ValidationError(
|
|
49
|
+
f"Invalid id {segment!r}: it contains a control character "
|
|
50
|
+
"(line break, tab or similar). Pass one id per argument."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _retry_after_seconds(response: httpx.Response) -> float | None:
|
|
55
|
+
"""Return ``Retry-After`` as non-negative seconds, or ``None``.
|
|
56
|
+
|
|
57
|
+
The HTTP-date form, a negative value and anything unparseable count
|
|
58
|
+
as no header.
|
|
59
|
+
"""
|
|
60
|
+
raw = response.headers.get("Retry-After")
|
|
61
|
+
if raw is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
value = float(raw)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
if value < 0 or not math.isfinite(value):
|
|
68
|
+
return None
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _retry_delay(response: httpx.Response, attempt: int, *, safe: bool) -> float | None:
|
|
73
|
+
"""Return the delay before the next attempt, or ``None`` to stop.
|
|
74
|
+
|
|
75
|
+
A 429 is retried for any method: the server refused before processing
|
|
76
|
+
it. It waits ``Retry-After`` up to the cap, and above the cap it stops.
|
|
77
|
+
A 502 or 503 is retried only for a request that cannot write, since a
|
|
78
|
+
proxy can answer one after the server applied the write. A 504 is not
|
|
79
|
+
retried.
|
|
80
|
+
"""
|
|
81
|
+
backoff: float = _BACKOFF_BASE * 2.0**attempt
|
|
82
|
+
if response.status_code == 429:
|
|
83
|
+
wait = _retry_after_seconds(response)
|
|
84
|
+
if wait is None:
|
|
85
|
+
return backoff
|
|
86
|
+
return wait if wait <= _RETRY_AFTER_CAP else None
|
|
87
|
+
if response.status_code in _GATEWAY_RETRY and safe:
|
|
88
|
+
return backoff
|
|
89
|
+
return None
|
|
31
90
|
|
|
32
91
|
|
|
33
92
|
class HttpClient:
|
|
@@ -69,12 +128,17 @@ class HttpClient:
|
|
|
69
128
|
json: dict[str, Any] | None = None,
|
|
70
129
|
fields: str | None = None,
|
|
71
130
|
params: dict[str, Any] | None = None,
|
|
131
|
+
safe: bool = False,
|
|
72
132
|
) -> Any:
|
|
73
|
-
"""POST with optional fields on query string.
|
|
133
|
+
"""POST with optional fields on query string.
|
|
134
|
+
|
|
135
|
+
``safe=True`` marks a POST that only reads (an assist call): it is
|
|
136
|
+
retried like a GET, and a failure is never reported as a write.
|
|
137
|
+
"""
|
|
74
138
|
all_params: dict[str, Any] = dict(params or {})
|
|
75
139
|
if fields:
|
|
76
140
|
all_params["fields"] = fields
|
|
77
|
-
response = self._request("POST", path, json=json, params=all_params or None)
|
|
141
|
+
response = self._request("POST", path, json=json, params=all_params or None, safe=safe)
|
|
78
142
|
if response.status_code == 204 or not response.content:
|
|
79
143
|
return {}
|
|
80
144
|
return self._decode_json(response)
|
|
@@ -114,9 +178,13 @@ class HttpClient:
|
|
|
114
178
|
"""DELETE. Returns nothing."""
|
|
115
179
|
self._request("DELETE", path)
|
|
116
180
|
|
|
117
|
-
def _request(
|
|
181
|
+
def _request(
|
|
182
|
+
self, method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
183
|
+
) -> httpx.Response:
|
|
118
184
|
"""Execute request with retry and error mapping."""
|
|
185
|
+
_refuse_control_characters(path)
|
|
119
186
|
d = self._debug
|
|
187
|
+
safe = safe or method == "GET"
|
|
120
188
|
|
|
121
189
|
# Log request details before first attempt
|
|
122
190
|
if params := kwargs.get("params"):
|
|
@@ -130,8 +198,7 @@ class HttpClient:
|
|
|
130
198
|
d.trace("http", " headers: Authorization=Bearer ***")
|
|
131
199
|
|
|
132
200
|
start = time.monotonic()
|
|
133
|
-
|
|
134
|
-
for attempt in range(_MAX_RETRIES):
|
|
201
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
135
202
|
try:
|
|
136
203
|
response = self._client.request(method, path, **kwargs)
|
|
137
204
|
except httpx.HTTPError as e:
|
|
@@ -148,12 +215,14 @@ class HttpClient:
|
|
|
148
215
|
d.trace("http", f" response: {response.text[:1000]}")
|
|
149
216
|
return response
|
|
150
217
|
|
|
151
|
-
|
|
152
|
-
if
|
|
218
|
+
delay = _retry_delay(response, attempt, safe=safe)
|
|
219
|
+
if delay is not None and attempt < _MAX_ATTEMPTS - 1:
|
|
153
220
|
d.debug(
|
|
154
221
|
"http",
|
|
155
|
-
f" retry {attempt + 1}/{
|
|
222
|
+
f" retry {attempt + 1}/{_MAX_ATTEMPTS} in {delay:g}s "
|
|
223
|
+
f"(status {response.status_code})",
|
|
156
224
|
)
|
|
225
|
+
time.sleep(delay)
|
|
157
226
|
continue
|
|
158
227
|
|
|
159
228
|
d.info(
|
|
@@ -161,11 +230,9 @@ class HttpClient:
|
|
|
161
230
|
f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms) FAILED",
|
|
162
231
|
)
|
|
163
232
|
d.trace("http", f" response: {response.text[:1000]}")
|
|
164
|
-
self._raise_for_status(response)
|
|
233
|
+
self._raise_for_status(response, unconfirmed_write=self._unconfirmed(response, safe))
|
|
165
234
|
|
|
166
|
-
#
|
|
167
|
-
assert last_response is not None
|
|
168
|
-
self._raise_for_status(last_response)
|
|
235
|
+
# Every attempt returns, raises or continues while attempts remain.
|
|
169
236
|
raise AssertionError("unreachable") # pragma: no cover
|
|
170
237
|
|
|
171
238
|
@staticmethod
|
|
@@ -231,15 +298,45 @@ class HttpClient:
|
|
|
231
298
|
return body
|
|
232
299
|
|
|
233
300
|
@staticmethod
|
|
234
|
-
def
|
|
235
|
-
"""
|
|
301
|
+
def _unconfirmed(response: httpx.Response, safe: bool) -> bool:
|
|
302
|
+
"""A 502, 503 or 504 on a request that can write: it may have been applied."""
|
|
303
|
+
return response.status_code in _GATEWAY_UNCONFIRMED and not safe
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _raise_for_status(response: httpx.Response, *, unconfirmed_write: bool = False) -> None:
|
|
307
|
+
"""Map HTTP status to kd exceptions. Always raises.
|
|
308
|
+
|
|
309
|
+
*unconfirmed_write* marks a 502, 503 or 504 on a request kd did not
|
|
310
|
+
resend because it can write: the message says so and the hint
|
|
311
|
+
names a read that checks it.
|
|
312
|
+
"""
|
|
236
313
|
body = response.text
|
|
237
314
|
status = response.status_code
|
|
238
315
|
extracted = HttpClient._extract_message(body)
|
|
239
316
|
request_path = response.request.url.path if response.request else None
|
|
240
|
-
message, suggestion = enrich_error(
|
|
317
|
+
message, suggestion = enrich_error(
|
|
318
|
+
extracted,
|
|
319
|
+
status=status,
|
|
320
|
+
path=request_path,
|
|
321
|
+
unconfirmed_write=unconfirmed_write,
|
|
322
|
+
)
|
|
323
|
+
if unconfirmed_write:
|
|
324
|
+
method = response.request.method if response.request else "request"
|
|
325
|
+
lines = message.strip().splitlines()
|
|
326
|
+
server = lines[0] if lines else "(empty response)"
|
|
327
|
+
raise APIError(
|
|
328
|
+
status,
|
|
329
|
+
f"API error ({status}) on {method} {request_path}: kd did not resend it, "
|
|
330
|
+
f"because the server may have applied it. Server said: {server}",
|
|
331
|
+
body,
|
|
332
|
+
suggestion,
|
|
333
|
+
)
|
|
334
|
+
if status == 429:
|
|
335
|
+
wait = _retry_after_seconds(response)
|
|
336
|
+
if wait is not None:
|
|
337
|
+
message = f"{message}; the server asked to wait {wait:g} s."
|
|
241
338
|
if status == 401:
|
|
242
|
-
raise AuthError(f"Authentication failed: {message}")
|
|
339
|
+
raise AuthError(f"Authentication failed: {message}", suggestion=suggestion)
|
|
243
340
|
if status == 403:
|
|
244
341
|
body_lower = body.lower()
|
|
245
342
|
if suggestion or "permission" in body_lower or "access" in body_lower:
|
|
@@ -267,7 +267,7 @@ class YouTrackAPI:
|
|
|
267
267
|
"Create one issue (any contributor token works), or use a "
|
|
268
268
|
"token with project-administration rights. Or omit "
|
|
269
269
|
"--type / --priority / --state to create a minimal first "
|
|
270
|
-
"issue, then update it via `/issue update`.
|
|
270
|
+
"issue, then update it via `/issue update`."
|
|
271
271
|
),
|
|
272
272
|
)
|
|
273
273
|
|
|
@@ -525,7 +525,7 @@ class YouTrackAPI:
|
|
|
525
525
|
body: dict[str, Any] = {"query": query}
|
|
526
526
|
if caret is not None:
|
|
527
527
|
body["caret"] = caret
|
|
528
|
-
data = self._http.post("/search/assist", json=body, fields=_ASSIST_FIELDS)
|
|
528
|
+
data = self._http.post("/search/assist", json=body, fields=_ASSIST_FIELDS, safe=True)
|
|
529
529
|
return AssistResponse.model_validate(data)
|
|
530
530
|
|
|
531
531
|
def commands_assist(
|
|
@@ -546,5 +546,5 @@ class YouTrackAPI:
|
|
|
546
546
|
}
|
|
547
547
|
if caret is not None:
|
|
548
548
|
body["caret"] = caret
|
|
549
|
-
data = self._http.post("/commands/assist", json=body, fields=_ASSIST_FIELDS)
|
|
549
|
+
data = self._http.post("/commands/assist", json=body, fields=_ASSIST_FIELDS, safe=True)
|
|
550
550
|
return AssistResponse.model_validate(data)
|
|
@@ -18,6 +18,9 @@ Grammar (informal):
|
|
|
18
18
|
Rules (pinned down by ``tests/test_article_marker.py``):
|
|
19
19
|
|
|
20
20
|
- Open and close lines must be exact (``<!-- kd-meta`` / ``-->``).
|
|
21
|
+
- Only a marker that opens on the first non-blank line counts; a
|
|
22
|
+
marker-open line after body content is body. A second marker block
|
|
23
|
+
directly below the top one (only blank lines between) is an error.
|
|
21
24
|
- Keys match ``[a-z][a-z0-9-]*``; uppercase and underscores are
|
|
22
25
|
rejected to keep the grammar ASCII-boring.
|
|
23
26
|
- Unknown keys are preserved verbatim on round-trip — the load-bearing
|
|
@@ -49,8 +52,9 @@ Legacy marker synonym (transitional, proposal 28 / M003 §S4):
|
|
|
49
52
|
upstream population of ``ytctl-meta`` is empty. No auto-disable,
|
|
50
53
|
no timer — a deliberate future milestone.
|
|
51
54
|
|
|
52
|
-
Stdlib only; no third-party deps. The push workflow
|
|
53
|
-
``kd.client.ArticleOperations.push``)
|
|
55
|
+
Stdlib only; no third-party deps. The push workflow
|
|
56
|
+
(``kd.client.ArticleOperations.push``), ``/article update``
|
|
57
|
+
(``kd.client.ArticleOperations.update``) and their tests are the only
|
|
54
58
|
importers.
|
|
55
59
|
"""
|
|
56
60
|
|
|
@@ -248,13 +252,17 @@ def parse_content(text: str) -> ParsedFile:
|
|
|
248
252
|
if close_idx is None:
|
|
249
253
|
raise MarkerError("marker block not closed (expected '-->' on its own line)")
|
|
250
254
|
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
# A second marker block with only blank lines above it would become
|
|
256
|
+
# the identity once the top block is stripped (``/article update
|
|
257
|
+
# --content``) or edited away, so it is refused. A marker-open line
|
|
258
|
+
# after body content stays body. This check only refuses, so it
|
|
259
|
+
# compares leniently (whitespace, stray CR, BOM): an editor or
|
|
260
|
+
# encoding round trip can turn a near-exact open into an exact one.
|
|
261
|
+
k = close_idx + 1
|
|
262
|
+
while k < len(lines) and _loose(lines[k]) == "":
|
|
263
|
+
k += 1
|
|
264
|
+
if k < len(lines) and _loose(lines[k]) in _RECOGNIZED_OPENS:
|
|
265
|
+
raise MarkerError("a second kd-meta block directly follows the top marker; keep one")
|
|
258
266
|
|
|
259
267
|
# Parse key:value lines between open and close.
|
|
260
268
|
marker = ArticleMarker()
|
|
@@ -291,6 +299,12 @@ def parse_content(text: str) -> ParsedFile:
|
|
|
291
299
|
)
|
|
292
300
|
|
|
293
301
|
|
|
302
|
+
def _loose(line: str) -> str:
|
|
303
|
+
"""Normalize a line for the stacked-block refusal only: drop BOMs and
|
|
304
|
+
surrounding whitespace. Never used to detect the top marker."""
|
|
305
|
+
return line.replace("\ufeff", "").strip()
|
|
306
|
+
|
|
307
|
+
|
|
294
308
|
def _join_body(lines: list[str], trailing_newline: bool) -> str:
|
|
295
309
|
"""Join body lines with ``\\n``, restoring the final terminator if any."""
|
|
296
310
|
if not lines:
|
|
@@ -69,9 +69,8 @@ def resolve_connection_token(
|
|
|
69
69
|
value = os.environ.get(conn.token_env)
|
|
70
70
|
if value:
|
|
71
71
|
return value
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
raise _no_auth_source_error(conn, name, origin)
|
|
72
|
+
# ConnectionConfig requires token_cmd or token_env, so only an unset env var lands here.
|
|
73
|
+
raise _missing_token_error(conn, name, origin)
|
|
75
74
|
|
|
76
75
|
|
|
77
76
|
def _run_token_cmd(cmd: str) -> str:
|
|
@@ -122,26 +121,6 @@ def _missing_token_error(
|
|
|
122
121
|
return AuthError(message, suggestion=suggestion)
|
|
123
122
|
|
|
124
123
|
|
|
125
|
-
def _no_auth_source_error(
|
|
126
|
-
conn: ConnectionConfig,
|
|
127
|
-
name: str | None,
|
|
128
|
-
origin: ConnectionOrigin | None,
|
|
129
|
-
) -> AuthError:
|
|
130
|
-
"""Build an ``AuthError`` for a connection with no ``token_cmd`` or ``token_env``."""
|
|
131
|
-
where = _describe_connection(conn, name)
|
|
132
|
-
message = f"{where} has no auth source configured."
|
|
133
|
-
suggestion = _build_suggestion(
|
|
134
|
-
lines=[
|
|
135
|
-
"Set one of `token_cmd:` or `token_env:` on the connection:",
|
|
136
|
-
f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
|
|
137
|
-
f" connections.{name or '<name>'}.token_env: KD_{(name or 'NAME').upper()}_TOKEN",
|
|
138
|
-
],
|
|
139
|
-
origin=origin,
|
|
140
|
-
key=None,
|
|
141
|
-
)
|
|
142
|
-
return AuthError(message, suggestion=suggestion)
|
|
143
|
-
|
|
144
|
-
|
|
145
124
|
def _describe_connection(conn: ConnectionConfig, name: str | None) -> str:
|
|
146
125
|
"""Render the ``Connection '<name>' (<url>)`` prefix, degraded when no name."""
|
|
147
126
|
if name:
|
|
@@ -153,7 +153,7 @@ SCOPE_MANIFEST_KIND = "cache_scope"
|
|
|
153
153
|
|
|
154
154
|
#: Markdown banner for cached article bodies. Five-space continuation
|
|
155
155
|
#: indent matches ``"<!-- "`` width so the banner reads as one logical
|
|
156
|
-
#: line. Pinned by tests;
|
|
156
|
+
#: line. Pinned by tests; described in ``kd --guide`` §Cache.
|
|
157
157
|
BANNER_TEMPLATE_MD = (
|
|
158
158
|
"<!-- kd-cache: upstream={id} fetched={iso}.\n"
|
|
159
159
|
" Disposable mirror. Edits lost on refresh. Not authoritative. -->"
|
|
@@ -2026,7 +2026,7 @@ def refresh_primer_scope(
|
|
|
2026
2026
|
Read-only by contract: writes only under ``cache_root``; the primer
|
|
2027
2027
|
is read but never mutated; no upstream mutation.
|
|
2028
2028
|
|
|
2029
|
-
See
|
|
2029
|
+
See ``kd --guide`` §Cache.
|
|
2030
2030
|
"""
|
|
2031
2031
|
workspace_dir = require_workspace_dir()
|
|
2032
2032
|
primer = load_primer(workspace_dir / PRIMER_FILENAME)
|
|
@@ -2440,7 +2440,7 @@ def refresh_milestone(
|
|
|
2440
2440
|
"""Hydrate a milestone-scoped query into ``cache_root/queries/milestone-<slug>.yaml``.
|
|
2441
2441
|
|
|
2442
2442
|
Resolves the project's milestone field through the per-project
|
|
2443
|
-
``field_aliases`` map
|
|
2443
|
+
``field_aliases`` map — if the project
|
|
2444
2444
|
aliased ``milestone`` → ``Stage``, the discovered field is ``Stage``
|
|
2445
2445
|
and the cache file's ``source.field_name`` records that. Without an
|
|
2446
2446
|
alias, falls back to the literal ``Milestone`` name. The discovery
|
|
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
13
|
from kd.cli.core.option_registry import OptionDeclaration, ResolvedOptions
|
|
14
|
+
from kd.exceptions import ValidationError
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from kd.config import ResolvedConfig
|
|
@@ -20,6 +21,18 @@ if TYPE_CHECKING:
|
|
|
20
21
|
CommandResult = list[dict[str, Any]] | dict[str, Any] | str | None
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def reject_extra_args(args: list[str], allowed: int, usage: str) -> None:
|
|
25
|
+
"""Refuse positionals beyond the first *allowed* ones.
|
|
26
|
+
|
|
27
|
+
For commands whose positional count is fixed and whose effect is
|
|
28
|
+
outward or irreversible: a word left over by the parser (for example
|
|
29
|
+
``off`` after a boolean option) must not be dropped silently. The
|
|
30
|
+
error names the first extra word and the command's usage line.
|
|
31
|
+
"""
|
|
32
|
+
if len(args) > allowed:
|
|
33
|
+
raise ValidationError(f"Unexpected argument '{args[allowed]}'. Usage: {usage}")
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
class Command(ABC):
|
|
24
37
|
"""Base class for all CLI commands."""
|
|
25
38
|
|
|
@@ -31,6 +44,7 @@ class Command(ABC):
|
|
|
31
44
|
requires_client: bool = False,
|
|
32
45
|
requires_config: bool = False,
|
|
33
46
|
usage: str = "",
|
|
47
|
+
joins_text: bool = False,
|
|
34
48
|
long_description: str = "",
|
|
35
49
|
examples: list[str] | None = None,
|
|
36
50
|
see_also: list[str] | None = None,
|
|
@@ -40,10 +54,17 @@ class Command(ABC):
|
|
|
40
54
|
self.requires_client = requires_client
|
|
41
55
|
self.requires_config = requires_config
|
|
42
56
|
self.usage = usage
|
|
57
|
+
# The command joins its arguments into one text (a summary, comment,
|
|
58
|
+
# note, name or query). The parser then refuses a bare switch that is
|
|
59
|
+
# directly followed by a word when an argument comes before it.
|
|
60
|
+
self.joins_text = joins_text
|
|
43
61
|
self.long_description = long_description
|
|
44
62
|
self.examples: list[str] = examples or []
|
|
45
63
|
self.see_also: list[str] = see_also or []
|
|
46
64
|
self.display_hint: str | None = None
|
|
65
|
+
# A connection the command's own output names as invalid: the dispatcher
|
|
66
|
+
# adds no connection_invalid warning about it (kd init's next line).
|
|
67
|
+
self.invalid_connection_named: str | None = None
|
|
47
68
|
self._options: list[OptionDeclaration] = []
|
|
48
69
|
self._resolved_config: ResolvedConfig | None = None
|
|
49
70
|
self._register_options()
|