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/connection.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Named connection configuration and runtime resolution.
|
|
2
|
+
|
|
3
|
+
A connection represents a complete working context: URL, auth source,
|
|
4
|
+
defaults, and compatibility profile for a single YouTrack instance.
|
|
5
|
+
|
|
6
|
+
Config-level models (:class:`ConnectionConfig`, :class:`CompatibilityConfig`)
|
|
7
|
+
map directly to YAML. :class:`ConnectionContext` is the frozen runtime object
|
|
8
|
+
that commands and the SDK receive after token resolution and override merging.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, model_validator
|
|
17
|
+
|
|
18
|
+
from kd.auth import resolve_connection_token
|
|
19
|
+
from kd.exceptions import ConfigError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from kd.debug import DebugLog
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Config models (YAML ↔ Pydantic)
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CompatibilityConfig(BaseModel):
|
|
31
|
+
"""Per-connection compatibility settings.
|
|
32
|
+
|
|
33
|
+
Only ``pinned`` mode in M031. ``probe`` mode arrives in M033.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
target: str = "auto"
|
|
37
|
+
mode: Literal["pinned"] = "pinned"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConnectionConfig(BaseModel):
|
|
41
|
+
"""One named connection in the ``connections:`` config section."""
|
|
42
|
+
|
|
43
|
+
provider: str = "youtrack"
|
|
44
|
+
url: str
|
|
45
|
+
token_cmd: str | None = None
|
|
46
|
+
token_env: str | None = None
|
|
47
|
+
default_project: str | None = None
|
|
48
|
+
output: str | None = None
|
|
49
|
+
limit: int | None = None
|
|
50
|
+
defaults: dict[str, dict[str, Any]] = {}
|
|
51
|
+
compatibility: CompatibilityConfig = CompatibilityConfig()
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def _require_auth_source(self) -> ConnectionConfig:
|
|
55
|
+
if not self.token_cmd and not self.token_env:
|
|
56
|
+
msg = (
|
|
57
|
+
"Connection must specify at least one of 'token_cmd' or 'token_env'. "
|
|
58
|
+
"Secrets are never stored as raw values in config."
|
|
59
|
+
)
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Runtime context (frozen, post-resolution)
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ConnectionContext:
|
|
71
|
+
"""Resolved runtime state for the active connection.
|
|
72
|
+
|
|
73
|
+
Created by :func:`resolve_connection` after token resolution and
|
|
74
|
+
override merging. Commands receive this — never raw config models.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
provider: str
|
|
79
|
+
base_url: str
|
|
80
|
+
token: str
|
|
81
|
+
default_project: str | None
|
|
82
|
+
output: str | None
|
|
83
|
+
limit: int | None
|
|
84
|
+
defaults: dict[str, dict[str, Any]]
|
|
85
|
+
compat_tier: str
|
|
86
|
+
capabilities: frozenset[str] # empty in M031, populated in M033
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Connection resolution
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_connection(
|
|
95
|
+
connections: dict[str, ConnectionConfig],
|
|
96
|
+
default_connection: str | None,
|
|
97
|
+
*,
|
|
98
|
+
debug_log: DebugLog | None = None,
|
|
99
|
+
) -> ConnectionContext:
|
|
100
|
+
"""Resolve the active connection into a :class:`ConnectionContext`.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
connections:
|
|
105
|
+
All parsed ``ConnectionConfig`` objects keyed by name.
|
|
106
|
+
default_connection:
|
|
107
|
+
Name of the connection to resolve (from ``default_connection`` config key).
|
|
108
|
+
debug_log:
|
|
109
|
+
Optional debug logger.
|
|
110
|
+
"""
|
|
111
|
+
if not connections:
|
|
112
|
+
raise ConfigError(
|
|
113
|
+
"No connections configured. Add a 'connections:' section to your config "
|
|
114
|
+
"(run 'kd init' to scaffold one)."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not default_connection:
|
|
118
|
+
if len(connections) == 1:
|
|
119
|
+
default_connection = next(iter(connections))
|
|
120
|
+
else:
|
|
121
|
+
names = ", ".join(sorted(connections))
|
|
122
|
+
raise ConfigError(
|
|
123
|
+
f"No default_connection set and multiple connections exist ({names}). "
|
|
124
|
+
"Use -c <name> or set 'default_connection' in your config."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if default_connection not in connections:
|
|
128
|
+
names = ", ".join(sorted(connections))
|
|
129
|
+
raise ConfigError(
|
|
130
|
+
f"Connection '{default_connection}' not found. Available connections: {names}. "
|
|
131
|
+
"Use -c <name> or set 'default_connection' in your config."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
conn = connections[default_connection]
|
|
135
|
+
|
|
136
|
+
if debug_log:
|
|
137
|
+
debug_log.info("connection", f"resolving connection '{default_connection}'")
|
|
138
|
+
|
|
139
|
+
token = resolve_connection_token(conn, debug_log=debug_log)
|
|
140
|
+
|
|
141
|
+
if debug_log:
|
|
142
|
+
debug_log.info("connection", f"url={conn.url}")
|
|
143
|
+
debug_log.info("connection", f"compat_tier={conn.compatibility.target}")
|
|
144
|
+
|
|
145
|
+
return ConnectionContext(
|
|
146
|
+
name=default_connection,
|
|
147
|
+
provider=conn.provider,
|
|
148
|
+
base_url=conn.url,
|
|
149
|
+
token=token,
|
|
150
|
+
default_project=conn.default_project,
|
|
151
|
+
output=conn.output,
|
|
152
|
+
limit=conn.limit,
|
|
153
|
+
defaults=conn.defaults,
|
|
154
|
+
compat_tier=conn.compatibility.target,
|
|
155
|
+
capabilities=frozenset(),
|
|
156
|
+
)
|
kd/debug.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Progressive debug logging.
|
|
2
|
+
|
|
3
|
+
Three verbosity levels via stacking ``-D`` flags:
|
|
4
|
+
|
|
5
|
+
- ``-D`` (info): high-level flow — config source, API status, item count
|
|
6
|
+
- ``-DD`` (debug): request details, config chain, retries, elapsed timestamps
|
|
7
|
+
- ``-DDD`` (trace): full bodies, headers, per-source walk, correlation ID
|
|
8
|
+
|
|
9
|
+
All output goes to **stderr** so stdout remains pipeable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from enum import IntEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DebugLevel(IntEnum):
|
|
21
|
+
"""Verbosity levels for diagnostic output."""
|
|
22
|
+
|
|
23
|
+
SILENT = 0
|
|
24
|
+
INFO = 1
|
|
25
|
+
DEBUG = 2
|
|
26
|
+
TRACE = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DebugLog:
|
|
30
|
+
"""Component-tagged diagnostic logger writing to stderr.
|
|
31
|
+
|
|
32
|
+
Create once per invocation and thread through the call stack
|
|
33
|
+
or retrieve via :func:`get_debug_log`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, level: DebugLevel = DebugLevel.SILENT) -> None:
|
|
37
|
+
self._level = level
|
|
38
|
+
self._start = time.monotonic()
|
|
39
|
+
self._correlation_id = uuid.uuid4().hex[:8]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def level(self) -> DebugLevel:
|
|
43
|
+
"""Current verbosity level (read-only)."""
|
|
44
|
+
return self._level
|
|
45
|
+
|
|
46
|
+
def info(self, component: str, message: str) -> None:
|
|
47
|
+
"""Emit at level 1 (info) — high-level flow."""
|
|
48
|
+
if self._level >= DebugLevel.INFO:
|
|
49
|
+
self._emit("info", component, message)
|
|
50
|
+
|
|
51
|
+
def debug(self, component: str, message: str) -> None:
|
|
52
|
+
"""Emit at level 2 (debug) — request details, config chain."""
|
|
53
|
+
if self._level >= DebugLevel.DEBUG:
|
|
54
|
+
self._emit("debug", component, message)
|
|
55
|
+
|
|
56
|
+
def trace(self, component: str, message: str) -> None:
|
|
57
|
+
"""Emit at level 3 (trace) — full bodies, headers, walk."""
|
|
58
|
+
if self._level >= DebugLevel.TRACE:
|
|
59
|
+
self._emit("trace", component, message)
|
|
60
|
+
|
|
61
|
+
def _emit(self, level: str, component: str, message: str) -> None:
|
|
62
|
+
parts: list[str] = [f"[{level:<5}]", f"({component})"]
|
|
63
|
+
if self._level >= DebugLevel.TRACE:
|
|
64
|
+
parts.append(f"[{self._correlation_id}]")
|
|
65
|
+
if self._level >= DebugLevel.DEBUG:
|
|
66
|
+
elapsed = time.monotonic() - self._start
|
|
67
|
+
parts.append(f"+{elapsed:.1f}s")
|
|
68
|
+
parts.append(message)
|
|
69
|
+
print(" ".join(parts), file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def redact_token(token: str) -> str:
|
|
73
|
+
"""Redact an API token for debug display.
|
|
74
|
+
|
|
75
|
+
Shows the first 8 characters followed by ``***``.
|
|
76
|
+
Short or empty tokens become just ``***``.
|
|
77
|
+
"""
|
|
78
|
+
if len(token) >= 8:
|
|
79
|
+
return token[:8] + "***"
|
|
80
|
+
return "***"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Module-level singleton ──────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
_log: DebugLog | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def init_debug_log(level: DebugLevel) -> DebugLog:
|
|
89
|
+
"""Create and store the module-level :class:`DebugLog`.
|
|
90
|
+
|
|
91
|
+
Called once from ``main()`` after parsing the ``-D`` flags.
|
|
92
|
+
Returns the new instance for explicit threading.
|
|
93
|
+
"""
|
|
94
|
+
global _log # noqa: PLW0603
|
|
95
|
+
_log = DebugLog(level)
|
|
96
|
+
return _log
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_debug_log() -> DebugLog:
|
|
100
|
+
"""Return the module-level :class:`DebugLog`.
|
|
101
|
+
|
|
102
|
+
Returns a silent (level 0) instance when :func:`init_debug_log`
|
|
103
|
+
has not been called — callers never need to guard against ``None``.
|
|
104
|
+
"""
|
|
105
|
+
if _log is not None:
|
|
106
|
+
return _log
|
|
107
|
+
return DebugLog()
|
kd/exceptions.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Exception hierarchy for kd.
|
|
2
|
+
|
|
3
|
+
All kd exceptions inherit from KdError. API-layer exceptions
|
|
4
|
+
carry the HTTP status code and response body for debugging.
|
|
5
|
+
|
|
6
|
+
Error messages from the YouTrack API are rewritten into user-friendly
|
|
7
|
+
text using regex mappings loaded from ``resources/error-messages.yaml``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.resources
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KdError(Exception):
|
|
20
|
+
"""Base exception for all kd errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthError(KdError):
|
|
24
|
+
"""Token missing, invalid, or expired."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(KdError):
|
|
28
|
+
"""Config file missing, malformed, or incomplete."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIError(KdError):
|
|
32
|
+
"""HTTP error from YouTrack or Hub API."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
status_code: int,
|
|
37
|
+
message: str,
|
|
38
|
+
response_body: str = "",
|
|
39
|
+
suggestion: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.response_body = response_body
|
|
43
|
+
self.suggestion = suggestion
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(APIError):
|
|
48
|
+
"""404 — entity does not exist."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
response_body: str = "",
|
|
54
|
+
suggestion: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(404, message, response_body, suggestion)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConflictError(APIError):
|
|
60
|
+
"""409 — duplicate project key, etc."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
message: str,
|
|
65
|
+
response_body: str = "",
|
|
66
|
+
suggestion: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(409, message, response_body, suggestion)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PermissionDeniedError(APIError):
|
|
72
|
+
"""403 — insufficient permissions for the requested operation."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
message: str,
|
|
77
|
+
response_body: str = "",
|
|
78
|
+
suggestion: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(403, message, response_body, suggestion)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ValidationError(KdError):
|
|
84
|
+
"""Invalid input before API call."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Error message enrichment
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_ErrorMapping = dict[str, Any]
|
|
92
|
+
_error_mappings: list[_ErrorMapping] | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_mappings() -> list[_ErrorMapping]:
|
|
96
|
+
"""Load and compile error message mappings from the resource file.
|
|
97
|
+
|
|
98
|
+
Each entry supports three optional filters — ``status``, ``path``,
|
|
99
|
+
and ``pattern`` — plus a required ``message`` and an optional
|
|
100
|
+
``suggestion``. All present filters must match for the rule to apply.
|
|
101
|
+
"""
|
|
102
|
+
global _error_mappings # noqa: PLW0603
|
|
103
|
+
if _error_mappings is not None:
|
|
104
|
+
return _error_mappings
|
|
105
|
+
|
|
106
|
+
raw = (
|
|
107
|
+
importlib.resources.files("kd.resources")
|
|
108
|
+
.joinpath("error-messages.yaml")
|
|
109
|
+
.read_text(encoding="utf-8")
|
|
110
|
+
)
|
|
111
|
+
entries: list[dict[str, Any]] = yaml.safe_load(raw) or []
|
|
112
|
+
compiled: list[_ErrorMapping] = []
|
|
113
|
+
for e in entries:
|
|
114
|
+
compiled.append(
|
|
115
|
+
{
|
|
116
|
+
"status": e.get("status"),
|
|
117
|
+
"path_re": re.compile(e["path"]) if e.get("path") else None,
|
|
118
|
+
"msg_re": re.compile(e["pattern"]) if e.get("pattern") else None,
|
|
119
|
+
"message": e["message"],
|
|
120
|
+
"suggestion": e.get("suggestion", ""),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
_error_mappings = compiled
|
|
124
|
+
return _error_mappings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _substitute(
|
|
128
|
+
template: str,
|
|
129
|
+
named: dict[str, str],
|
|
130
|
+
positional: tuple[str | None, ...],
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Replace ``{name}`` and ``{1}..{N}`` placeholders in *template*.
|
|
133
|
+
|
|
134
|
+
Unmatched optional groups (``None``) are skipped.
|
|
135
|
+
"""
|
|
136
|
+
out = template
|
|
137
|
+
for key, named_val in named.items():
|
|
138
|
+
out = out.replace(f"{{{key}}}", named_val)
|
|
139
|
+
for i, pos_val in enumerate(positional, 1):
|
|
140
|
+
if pos_val is not None:
|
|
141
|
+
out = out.replace(f"{{{i}}}", pos_val)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def enrich_error(
|
|
146
|
+
message: str,
|
|
147
|
+
*,
|
|
148
|
+
status: int | None = None,
|
|
149
|
+
path: str | None = None,
|
|
150
|
+
) -> tuple[str, str | None]:
|
|
151
|
+
"""Match *message* against known API error patterns.
|
|
152
|
+
|
|
153
|
+
Rules may filter on HTTP ``status``, request URL ``path``, and/or
|
|
154
|
+
the extracted response ``message`` text. All present filters must
|
|
155
|
+
match. The first matching rule wins.
|
|
156
|
+
|
|
157
|
+
Returns ``(rewritten_message, suggestion)`` if a rule matches,
|
|
158
|
+
or ``(message, None)`` otherwise.
|
|
159
|
+
"""
|
|
160
|
+
for mapping in _load_mappings():
|
|
161
|
+
if mapping["status"] is not None and mapping["status"] != status:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
path_match: re.Match[str] | None = None
|
|
165
|
+
if mapping["path_re"] is not None:
|
|
166
|
+
if path is None:
|
|
167
|
+
continue
|
|
168
|
+
path_match = mapping["path_re"].search(path)
|
|
169
|
+
if path_match is None:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
msg_match: re.Match[str] | None = None
|
|
173
|
+
if mapping["msg_re"] is not None:
|
|
174
|
+
msg_match = mapping["msg_re"].search(message)
|
|
175
|
+
if msg_match is None:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
named: dict[str, str] = {}
|
|
179
|
+
if path_match is not None:
|
|
180
|
+
named.update({k: v for k, v in path_match.groupdict().items() if v is not None})
|
|
181
|
+
if msg_match is not None:
|
|
182
|
+
named.update({k: v for k, v in msg_match.groupdict().items() if v is not None})
|
|
183
|
+
positional = msg_match.groups() if msg_match is not None else ()
|
|
184
|
+
|
|
185
|
+
rewritten = _substitute(mapping["message"], named, positional)
|
|
186
|
+
suggestion_tmpl = mapping["suggestion"] or None
|
|
187
|
+
suggestion = _substitute(suggestion_tmpl, named, positional) if suggestion_tmpl else None
|
|
188
|
+
return rewritten, suggestion
|
|
189
|
+
return message, None
|
kd/history.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Copy-on-write history for config and primer files.
|
|
2
|
+
|
|
3
|
+
Provides automatic snapshots before every mutation, retention pruning,
|
|
4
|
+
atomic file writes, and diff utilities. Snapshot files are stored in a
|
|
5
|
+
``history/`` subdirectory next to the original file.
|
|
6
|
+
|
|
7
|
+
Phase 1 — local snapshots only. Git auto-commit is deferred to Phase 2.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import NamedTuple
|
|
20
|
+
|
|
21
|
+
from kd.exceptions import ConfigError, KdError
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_RETENTION = 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Atomic writes
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
34
|
+
"""Write *text* to *path* atomically.
|
|
35
|
+
|
|
36
|
+
Writes to a temp file in the same directory, then ``os.replace``
|
|
37
|
+
to swap. Either the new content is fully committed or the old
|
|
38
|
+
content is fully intact.
|
|
39
|
+
"""
|
|
40
|
+
dir_ = str(path.parent) if str(path.parent) else "."
|
|
41
|
+
fd, tmp = tempfile.mkstemp(dir=dir_, prefix=f".{path.name}.", suffix=".tmp")
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
44
|
+
f.write(text)
|
|
45
|
+
os.replace(tmp, path)
|
|
46
|
+
except OSError as e:
|
|
47
|
+
try:
|
|
48
|
+
os.unlink(tmp)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
raise KdError(f"Cannot write to {path}: {e}") from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Timestamp helpers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_TS_FORMAT = "%Y-%m-%dT%H-%M-%S"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _timestamp() -> str:
|
|
62
|
+
"""Return a filesystem-safe ISO 8601 timestamp (UTC, second precision)."""
|
|
63
|
+
return datetime.now(timezone.utc).strftime(_TS_FORMAT)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_timestamp(ts: str) -> datetime:
|
|
67
|
+
"""Parse a filesystem-safe timestamp back to datetime."""
|
|
68
|
+
return datetime.strptime(ts, _TS_FORMAT).replace(tzinfo=timezone.utc)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Snapshot operations
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def snapshot_before_write(path: Path, *, retention: int = DEFAULT_RETENTION) -> Path | None:
|
|
77
|
+
"""Copy *path* to ``<parent>/history/<name>.<timestamp>`` before mutation.
|
|
78
|
+
|
|
79
|
+
Returns the snapshot path, or ``None`` if the file does not exist yet
|
|
80
|
+
(first write — nothing to snapshot). Prunes oldest snapshots beyond
|
|
81
|
+
*retention*. Best-effort: failures log a warning but do not block the
|
|
82
|
+
caller's write.
|
|
83
|
+
"""
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
history_dir = path.parent / "history"
|
|
88
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
snapshot_name = f"{path.name}.{_timestamp()}"
|
|
90
|
+
snapshot_path = history_dir / snapshot_name
|
|
91
|
+
shutil.copy2(path, snapshot_path)
|
|
92
|
+
_prune_snapshots(history_dir, path.name, retention)
|
|
93
|
+
return snapshot_path
|
|
94
|
+
except OSError as e:
|
|
95
|
+
log.warning("Failed to snapshot %s: %s", path, e)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _prune_snapshots(history_dir: Path, filename: str, retention: int) -> None:
|
|
100
|
+
"""Remove oldest snapshots of *filename* beyond *retention* count."""
|
|
101
|
+
snapshots = _sorted_snapshots(history_dir, filename)
|
|
102
|
+
for info in snapshots[retention:]:
|
|
103
|
+
try:
|
|
104
|
+
info.path.unlink()
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Listing and lookup
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SnapshotInfo(NamedTuple):
|
|
115
|
+
"""Metadata for a single snapshot file."""
|
|
116
|
+
|
|
117
|
+
path: Path
|
|
118
|
+
timestamp: str
|
|
119
|
+
size: int
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_snapshots(history_dir: Path, filename: str) -> list[SnapshotInfo]:
|
|
123
|
+
"""Return snapshots for *filename*, newest first.
|
|
124
|
+
|
|
125
|
+
Returns an empty list if the history directory does not exist.
|
|
126
|
+
"""
|
|
127
|
+
if not history_dir.is_dir():
|
|
128
|
+
return []
|
|
129
|
+
return _sorted_snapshots(history_dir, filename)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sorted_snapshots(history_dir: Path, filename: str) -> list[SnapshotInfo]:
|
|
133
|
+
"""Glob and sort snapshots for *filename*, newest first."""
|
|
134
|
+
prefix = f"{filename}."
|
|
135
|
+
results: list[SnapshotInfo] = []
|
|
136
|
+
for entry in history_dir.iterdir():
|
|
137
|
+
if entry.name.startswith(prefix) and entry.is_file():
|
|
138
|
+
ts = entry.name[len(prefix) :]
|
|
139
|
+
try:
|
|
140
|
+
_parse_timestamp(ts)
|
|
141
|
+
except ValueError:
|
|
142
|
+
continue # skip files that don't match the timestamp format
|
|
143
|
+
results.append(SnapshotInfo(path=entry, timestamp=ts, size=entry.stat().st_size))
|
|
144
|
+
results.sort(key=lambda s: s.timestamp, reverse=True)
|
|
145
|
+
return results
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_snapshot_path(history_dir: Path, filename: str, timestamp: str) -> Path:
|
|
149
|
+
"""Resolve a snapshot by timestamp, or raise ``ConfigError``."""
|
|
150
|
+
path = history_dir / f"{filename}.{timestamp}"
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
raise ConfigError(f"Snapshot not found: {path.name}")
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Diff
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def diff_text(text_a: str, text_b: str, label_a: str, label_b: str) -> str:
|
|
162
|
+
"""Return a unified diff between two text strings.
|
|
163
|
+
|
|
164
|
+
Returns an empty string when the texts are identical.
|
|
165
|
+
"""
|
|
166
|
+
lines_a = text_a.splitlines(keepends=True)
|
|
167
|
+
lines_b = text_b.splitlines(keepends=True)
|
|
168
|
+
diff = difflib.unified_diff(lines_a, lines_b, fromfile=label_a, tofile=label_b)
|
|
169
|
+
return "".join(diff)
|
kd/models/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pydantic models for YouTrack entities.
|
|
2
|
+
|
|
3
|
+
All models use field aliases to map between Python snake_case
|
|
4
|
+
and YouTrack's camelCase API responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from kd.models.article import Article, ArticleCreate, ArticleRef, ArticleUpdate
|
|
8
|
+
from kd.models.assist import AssistResponse, AssistSuggestion
|
|
9
|
+
from kd.models.attachment import Attachment
|
|
10
|
+
from kd.models.comment import Comment, CommentCreate
|
|
11
|
+
from kd.models.common import FieldSet, IdEntity, NamedEntity
|
|
12
|
+
from kd.models.issue import CustomField, Issue, IssueCreate, IssueUpdate
|
|
13
|
+
from kd.models.project import (
|
|
14
|
+
BundleValue,
|
|
15
|
+
Project,
|
|
16
|
+
ProjectCreate,
|
|
17
|
+
ProjectCustomField,
|
|
18
|
+
TimeTrackingSettings,
|
|
19
|
+
)
|
|
20
|
+
from kd.models.saved_query import (
|
|
21
|
+
QueryValidationResult,
|
|
22
|
+
SavedQuery,
|
|
23
|
+
SavedQueryCreate,
|
|
24
|
+
SavedQueryUpdate,
|
|
25
|
+
)
|
|
26
|
+
from kd.models.search import SearchResult
|
|
27
|
+
from kd.models.tag import Tag, TagColor
|
|
28
|
+
from kd.models.user import Group, ProjectTeam, User, UserCreate
|
|
29
|
+
from kd.models.work_item import (
|
|
30
|
+
Duration,
|
|
31
|
+
WorkItem,
|
|
32
|
+
WorkItemCreate,
|
|
33
|
+
WorkItemType,
|
|
34
|
+
WorkItemUpdate,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Article",
|
|
39
|
+
"ArticleCreate",
|
|
40
|
+
"ArticleRef",
|
|
41
|
+
"ArticleUpdate",
|
|
42
|
+
"AssistResponse",
|
|
43
|
+
"AssistSuggestion",
|
|
44
|
+
"Attachment",
|
|
45
|
+
"BundleValue",
|
|
46
|
+
"Comment",
|
|
47
|
+
"CommentCreate",
|
|
48
|
+
"CustomField",
|
|
49
|
+
"Duration",
|
|
50
|
+
"FieldSet",
|
|
51
|
+
"Group",
|
|
52
|
+
"IdEntity",
|
|
53
|
+
"NamedEntity",
|
|
54
|
+
"Issue",
|
|
55
|
+
"IssueCreate",
|
|
56
|
+
"IssueUpdate",
|
|
57
|
+
"QueryValidationResult",
|
|
58
|
+
"Project",
|
|
59
|
+
"ProjectCreate",
|
|
60
|
+
"ProjectCustomField",
|
|
61
|
+
"ProjectTeam",
|
|
62
|
+
"SavedQuery",
|
|
63
|
+
"SavedQueryCreate",
|
|
64
|
+
"SavedQueryUpdate",
|
|
65
|
+
"SearchResult",
|
|
66
|
+
"Tag",
|
|
67
|
+
"TagColor",
|
|
68
|
+
"TimeTrackingSettings",
|
|
69
|
+
"User",
|
|
70
|
+
"UserCreate",
|
|
71
|
+
"WorkItem",
|
|
72
|
+
"WorkItemCreate",
|
|
73
|
+
"WorkItemType",
|
|
74
|
+
"WorkItemUpdate",
|
|
75
|
+
]
|