kd-cli 2.10.0__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.10.0/kd_cli.egg-info → kd_cli-2.11.0}/PKG-INFO +18 -14
- {kd_cli-2.10.0 → kd_cli-2.11.0}/README.md +17 -13
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/http.py +97 -18
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/youtrack.py +3 -3
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/auth.py +2 -23
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cache.py +3 -3
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/command_base.py +3 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/input.py +14 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/main.py +218 -12
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/article/__init__.py +35 -21
- {kd_cli-2.10.0 → 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.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/issue/__init__.py +25 -26
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/primer/__init__.py +28 -19
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/project/__init__.py +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/query/__init__.py +2 -2
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/system/__init__.py +223 -117
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/tag/__init__.py +3 -3
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/output.py +13 -2
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/client.py +79 -17
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/config.py +661 -114
- kd_cli-2.11.0/kd/config_read.py +332 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/connection.py +110 -51
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/exceptions.py +56 -18
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/primer.py +114 -26
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/CHANGELOG.md +238 -237
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/command-synonyms.yaml +1 -1
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/error-messages.yaml +124 -13
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/operating-guide.md +120 -38
- kd_cli-2.11.0/kd/resources/primers/hitl.yaml +27 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0/kd_cli.egg-info}/PKG-INFO +18 -14
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/SOURCES.txt +0 -2
- {kd_cli-2.10.0 → kd_cli-2.11.0}/pyproject.toml +1 -1
- kd_cli-2.10.0/kd/cli/namespaces/init.py +0 -62
- kd_cli-2.10.0/kd/config_read.py +0 -194
- kd_cli-2.10.0/kd/resources/primers/hitl.yaml +0 -41
- kd_cli-2.10.0/kd/resources/sdlc-doctrine.md +0 -602
- kd_cli-2.10.0/kd/sdlc.py +0 -21
- {kd_cli-2.10.0 → kd_cli-2.11.0}/LICENSE +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/MANIFEST.in +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/articles.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/hub.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/api/tags.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/article_marker.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/cli/tree.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/config_schema.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/debug.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/history.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/locking.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/article.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/assist.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/attachment.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/comment.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/common.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/issue.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/project.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/saved_query.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/search.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/tag.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/user.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/models/work_item.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/operating_guide.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/query_parser.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/release_notes.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.10.0 → kd_cli-2.11.0}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.10.0 → 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
|
|
|
@@ -171,4 +175,4 @@ 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
|
|
|
@@ -131,4 +135,4 @@ 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
|
|
|
@@ -27,8 +28,11 @@ from kd.exceptions import (
|
|
|
27
28
|
enrich_error,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
def _refuse_control_characters(path: str) -> None:
|
|
@@ -47,6 +51,44 @@ def _refuse_control_characters(path: str) -> None:
|
|
|
47
51
|
)
|
|
48
52
|
|
|
49
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
|
|
90
|
+
|
|
91
|
+
|
|
50
92
|
class HttpClient:
|
|
51
93
|
"""Low-level HTTP client with auth, retry, and error mapping."""
|
|
52
94
|
|
|
@@ -86,12 +128,17 @@ class HttpClient:
|
|
|
86
128
|
json: dict[str, Any] | None = None,
|
|
87
129
|
fields: str | None = None,
|
|
88
130
|
params: dict[str, Any] | None = None,
|
|
131
|
+
safe: bool = False,
|
|
89
132
|
) -> Any:
|
|
90
|
-
"""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
|
+
"""
|
|
91
138
|
all_params: dict[str, Any] = dict(params or {})
|
|
92
139
|
if fields:
|
|
93
140
|
all_params["fields"] = fields
|
|
94
|
-
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)
|
|
95
142
|
if response.status_code == 204 or not response.content:
|
|
96
143
|
return {}
|
|
97
144
|
return self._decode_json(response)
|
|
@@ -131,10 +178,13 @@ class HttpClient:
|
|
|
131
178
|
"""DELETE. Returns nothing."""
|
|
132
179
|
self._request("DELETE", path)
|
|
133
180
|
|
|
134
|
-
def _request(
|
|
181
|
+
def _request(
|
|
182
|
+
self, method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
183
|
+
) -> httpx.Response:
|
|
135
184
|
"""Execute request with retry and error mapping."""
|
|
136
185
|
_refuse_control_characters(path)
|
|
137
186
|
d = self._debug
|
|
187
|
+
safe = safe or method == "GET"
|
|
138
188
|
|
|
139
189
|
# Log request details before first attempt
|
|
140
190
|
if params := kwargs.get("params"):
|
|
@@ -148,8 +198,7 @@ class HttpClient:
|
|
|
148
198
|
d.trace("http", " headers: Authorization=Bearer ***")
|
|
149
199
|
|
|
150
200
|
start = time.monotonic()
|
|
151
|
-
|
|
152
|
-
for attempt in range(_MAX_RETRIES):
|
|
201
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
153
202
|
try:
|
|
154
203
|
response = self._client.request(method, path, **kwargs)
|
|
155
204
|
except httpx.HTTPError as e:
|
|
@@ -166,12 +215,14 @@ class HttpClient:
|
|
|
166
215
|
d.trace("http", f" response: {response.text[:1000]}")
|
|
167
216
|
return response
|
|
168
217
|
|
|
169
|
-
|
|
170
|
-
if
|
|
218
|
+
delay = _retry_delay(response, attempt, safe=safe)
|
|
219
|
+
if delay is not None and attempt < _MAX_ATTEMPTS - 1:
|
|
171
220
|
d.debug(
|
|
172
221
|
"http",
|
|
173
|
-
f" retry {attempt + 1}/{
|
|
222
|
+
f" retry {attempt + 1}/{_MAX_ATTEMPTS} in {delay:g}s "
|
|
223
|
+
f"(status {response.status_code})",
|
|
174
224
|
)
|
|
225
|
+
time.sleep(delay)
|
|
175
226
|
continue
|
|
176
227
|
|
|
177
228
|
d.info(
|
|
@@ -179,11 +230,9 @@ class HttpClient:
|
|
|
179
230
|
f"{method} {path} -> {response.status_code} ({duration_ms:.0f}ms) FAILED",
|
|
180
231
|
)
|
|
181
232
|
d.trace("http", f" response: {response.text[:1000]}")
|
|
182
|
-
self._raise_for_status(response)
|
|
233
|
+
self._raise_for_status(response, unconfirmed_write=self._unconfirmed(response, safe))
|
|
183
234
|
|
|
184
|
-
#
|
|
185
|
-
assert last_response is not None
|
|
186
|
-
self._raise_for_status(last_response)
|
|
235
|
+
# Every attempt returns, raises or continues while attempts remain.
|
|
187
236
|
raise AssertionError("unreachable") # pragma: no cover
|
|
188
237
|
|
|
189
238
|
@staticmethod
|
|
@@ -249,15 +298,45 @@ class HttpClient:
|
|
|
249
298
|
return body
|
|
250
299
|
|
|
251
300
|
@staticmethod
|
|
252
|
-
def
|
|
253
|
-
"""
|
|
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
|
+
"""
|
|
254
313
|
body = response.text
|
|
255
314
|
status = response.status_code
|
|
256
315
|
extracted = HttpClient._extract_message(body)
|
|
257
316
|
request_path = response.request.url.path if response.request else None
|
|
258
|
-
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."
|
|
259
338
|
if status == 401:
|
|
260
|
-
raise AuthError(f"Authentication failed: {message}")
|
|
339
|
+
raise AuthError(f"Authentication failed: {message}", suggestion=suggestion)
|
|
261
340
|
if status == 403:
|
|
262
341
|
body_lower = body.lower()
|
|
263
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)
|
|
@@ -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
|
|
@@ -62,6 +62,9 @@ class Command(ABC):
|
|
|
62
62
|
self.examples: list[str] = examples or []
|
|
63
63
|
self.see_also: list[str] = see_also or []
|
|
64
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
|
|
65
68
|
self._options: list[OptionDeclaration] = []
|
|
66
69
|
self._resolved_config: ResolvedConfig | None = None
|
|
67
70
|
self._register_options()
|
|
@@ -12,6 +12,20 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from kd.exceptions import ValidationError
|
|
14
14
|
|
|
15
|
+
#: Help clause for every option and argument read through
|
|
16
|
+
#: :func:`resolve_text_input`; it names the guide section with the shell recipes.
|
|
17
|
+
TEXT_INPUT_HELP = (
|
|
18
|
+
'Pass "@file.md" to read a file or "@-" to read stdin, in quotes '
|
|
19
|
+
"(PowerShell needs them); see kd --guide §Markdown Bodies"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
#: Help sentence for the attachment commands: how a body references the file
|
|
23
|
+
#: the user passed in.
|
|
24
|
+
IMAGE_REFERENCE_HELP = (
|
|
25
|
+
"Reference an attached image in the body by its file name alone, as in "
|
|
26
|
+
"; see kd --guide §Markdown Bodies."
|
|
27
|
+
)
|
|
28
|
+
|
|
15
29
|
|
|
16
30
|
def resolve_text_input(value: str | None) -> str | None:
|
|
17
31
|
"""Resolve a text value that may reference a file or stdin.
|