kd-cli 2.6.2__tar.gz → 2.6.4__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.6.2/kd_cli.egg-info → kd_cli-2.6.4}/PKG-INFO +2 -1
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/youtrack.py +87 -4
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cache.py +61 -43
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/input.py +23 -3
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/main.py +122 -1
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/system/__init__.py +7 -6
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/config.py +7 -7
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/config_read.py +1 -1
- kd_cli-2.6.4/kd/locking.py +68 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/primer.py +1 -1
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/CHANGELOG.md +93 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/operating-guide.md +26 -1
- {kd_cli-2.6.2 → kd_cli-2.6.4/kd_cli.egg-info}/PKG-INFO +2 -1
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd_cli.egg-info/requires.txt +1 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/pyproject.toml +2 -1
- kd_cli-2.6.2/kd/locking.py +0 -97
- {kd_cli-2.6.2 → kd_cli-2.6.4}/LICENSE +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/MANIFEST.in +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/README.md +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/articles.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/http.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/hub.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/api/tags.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/article_marker.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/auth.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/cache/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/output.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/cli/tree.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/client.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/config_schema.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/connection.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/debug.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/exceptions.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/history.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/article.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/assist.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/attachment.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/comment.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/common.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/issue.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/project.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/saved_query.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/search.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/tag.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/user.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/models/work_item.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/operating_guide.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/query_parser.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/release_notes.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/resources/sdlc-doctrine.md +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd/sdlc.py +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.6.2 → kd_cli-2.6.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kd-cli
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.4
|
|
4
4
|
Summary: Knowledge Dispatcher for issue trackers and knowledge bases
|
|
5
5
|
Author: Sergiy Gladkyy
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: filelock>=3.12
|
|
25
26
|
Requires-Dist: httpx>=0.27
|
|
26
27
|
Requires-Dist: packaging>=23.0
|
|
27
28
|
Requires-Dist: pydantic>=2.0
|
|
@@ -16,9 +16,11 @@ Uses HttpClient for transport and Pydantic models for serialization.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import re
|
|
19
20
|
from typing import Any
|
|
20
21
|
|
|
21
22
|
from kd.api.http import HttpClient
|
|
23
|
+
from kd.exceptions import KdError
|
|
22
24
|
from kd.models import (
|
|
23
25
|
AssistResponse,
|
|
24
26
|
Attachment,
|
|
@@ -169,6 +171,24 @@ _PROJECT_CUSTOM_FIELDS_PARAM = (
|
|
|
169
171
|
"canBeEmpty,emptyFieldText"
|
|
170
172
|
")"
|
|
171
173
|
)
|
|
174
|
+
# Per-issue projection that surfaces the same projectCustomField shape
|
|
175
|
+
# /admin/projects/{id} returns, but readable by any token with Read Issue.
|
|
176
|
+
# See KDCLI-A-22 for the YouTrack permission-tier rationale.
|
|
177
|
+
_PROJECT_FIELD_VIA_ISSUE_PARAM = (
|
|
178
|
+
"id,idReadable,"
|
|
179
|
+
"customFields("
|
|
180
|
+
"projectCustomField("
|
|
181
|
+
"field(name,fieldType(id)),"
|
|
182
|
+
"$type,"
|
|
183
|
+
"bundle(values(name,ordinal,isResolved)),"
|
|
184
|
+
"canBeEmpty,emptyFieldText"
|
|
185
|
+
")"
|
|
186
|
+
")"
|
|
187
|
+
)
|
|
188
|
+
# YouTrack internal-id format: "<entity-class>-<seq>", e.g. "0-9". Project
|
|
189
|
+
# short-names (KDCLI, VTX, FB) are alphabetic and never match this shape,
|
|
190
|
+
# so a regex match is a reliable "this is an id, not a key" signal.
|
|
191
|
+
_PROJECT_INTERNAL_ID_RE = re.compile(r"\d+-\d+")
|
|
172
192
|
|
|
173
193
|
|
|
174
194
|
class YouTrackAPI:
|
|
@@ -203,13 +223,76 @@ class YouTrackAPI:
|
|
|
203
223
|
self._http.delete(f"/admin/projects/{project_id}")
|
|
204
224
|
|
|
205
225
|
def list_project_custom_fields(self, project_id: str) -> list[dict[str, Any]]:
|
|
206
|
-
"""Fetch
|
|
207
|
-
|
|
226
|
+
"""Fetch a project's custom-field schema (name, type, bundle values).
|
|
227
|
+
|
|
228
|
+
Queries the per-issue ``projectCustomField`` projection so the call
|
|
229
|
+
works for any token with Read Issue, not just project admins. Falls
|
|
230
|
+
back to ``/admin/projects/{id}`` only when the project has no
|
|
231
|
+
issues to sample from. See KDCLI-A-22 for the permission-tier
|
|
232
|
+
rationale.
|
|
233
|
+
|
|
234
|
+
Accepts either the project short-name ("KDCLI") or the internal id
|
|
235
|
+
("0-9"); the search-query DSL needs the short-name, so internal
|
|
236
|
+
ids are resolved via a cheap shortName lookup that any contributor
|
|
237
|
+
token can make.
|
|
238
|
+
"""
|
|
239
|
+
short_name = self._resolve_project_short_name(project_id)
|
|
240
|
+
issues: list[dict[str, Any]] = self._http.get(
|
|
241
|
+
"/issues",
|
|
242
|
+
fields=_PROJECT_FIELD_VIA_ISSUE_PARAM,
|
|
243
|
+
params={"query": f"project: {short_name}", "$top": 1},
|
|
244
|
+
)
|
|
245
|
+
if issues:
|
|
246
|
+
sample = issues[0]
|
|
247
|
+
return [
|
|
248
|
+
cf["projectCustomField"]
|
|
249
|
+
for cf in sample.get("customFields") or []
|
|
250
|
+
if cf.get("projectCustomField")
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
admin: dict[str, Any] = self._http.get(
|
|
208
254
|
f"/admin/projects/{project_id}",
|
|
209
255
|
fields=_PROJECT_CUSTOM_FIELDS_PARAM,
|
|
210
256
|
)
|
|
211
|
-
|
|
212
|
-
|
|
257
|
+
admin_fields: list[dict[str, Any]] = admin.get("customFields") or []
|
|
258
|
+
if admin_fields:
|
|
259
|
+
return admin_fields
|
|
260
|
+
raise KdError(
|
|
261
|
+
f"No custom fields discovered for project '{project_id}'.",
|
|
262
|
+
suggestion=(
|
|
263
|
+
"Either the project has no issues to sample from, or the "
|
|
264
|
+
"active token lacks Read Project Full on this project. "
|
|
265
|
+
"Create one issue (any contributor token works), or use a "
|
|
266
|
+
"token with project-administration rights. See KDCLI-A-22."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _resolve_project_short_name(self, project_id: str) -> str:
|
|
271
|
+
"""Return a project short-name (key) usable in a YouTrack query.
|
|
272
|
+
|
|
273
|
+
Pass-through if ``project_id`` already looks like a key. If it
|
|
274
|
+
looks like an internal id (``\\d+-\\d+``), look up the short-name
|
|
275
|
+
via ``GET /admin/projects/{id}?fields=shortName`` — that endpoint
|
|
276
|
+
is readable by any token with Read Project Basic, so it works
|
|
277
|
+
for contributor tokens.
|
|
278
|
+
"""
|
|
279
|
+
if not _PROJECT_INTERNAL_ID_RE.fullmatch(project_id):
|
|
280
|
+
return project_id
|
|
281
|
+
meta: dict[str, Any] = self._http.get(
|
|
282
|
+
f"/admin/projects/{project_id}",
|
|
283
|
+
fields="shortName",
|
|
284
|
+
)
|
|
285
|
+
short_name = meta.get("shortName")
|
|
286
|
+
if not short_name:
|
|
287
|
+
raise KdError(
|
|
288
|
+
f"Could not resolve project '{project_id}' to a short name.",
|
|
289
|
+
suggestion=(
|
|
290
|
+
"Verify the project exists and the active token has "
|
|
291
|
+
"Read Project Basic. Try `kd /project list` to see "
|
|
292
|
+
"projects this token can read."
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
return str(short_name)
|
|
213
296
|
|
|
214
297
|
# --- Time Tracking ---
|
|
215
298
|
|
|
@@ -21,7 +21,7 @@ import dataclasses
|
|
|
21
21
|
import re
|
|
22
22
|
from dataclasses import dataclass, field
|
|
23
23
|
from datetime import datetime, timedelta, timezone
|
|
24
|
-
from pathlib import Path
|
|
24
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
25
25
|
from typing import Any, Literal
|
|
26
26
|
|
|
27
27
|
import yaml
|
|
@@ -597,6 +597,22 @@ class CacheScopeEnumeration:
|
|
|
597
597
|
# ---------------------------------------------------------------------------
|
|
598
598
|
|
|
599
599
|
|
|
600
|
+
def _report_path(value: Path | PurePath | str) -> str:
|
|
601
|
+
"""Normalize a filesystem path to POSIX form for structured-report fields.
|
|
602
|
+
|
|
603
|
+
Single chokepoint for every path-bearing field on the cache report
|
|
604
|
+
dataclasses (``HydratedIssue.path``, ``HydratedArticle.path``,
|
|
605
|
+
``EpicRefreshReport.manifest_path``, ``CacheStatusInfo.path``,
|
|
606
|
+
``CacheSearchMatch.path`` and siblings). The structured envelope of
|
|
607
|
+
``/cache refresh``, ``/cache clear``, ``/cache status``, and
|
|
608
|
+
``/cache search`` uses ``/`` separators on Windows and POSIX alike so
|
|
609
|
+
LLM consumers see a platform-stable shape.
|
|
610
|
+
"""
|
|
611
|
+
if isinstance(value, (Path, PurePath)):
|
|
612
|
+
return value.as_posix()
|
|
613
|
+
return PurePosixPath(value.replace("\\", "/")).as_posix()
|
|
614
|
+
|
|
615
|
+
|
|
600
616
|
def cache_dir(start: Path | None = None) -> Path:
|
|
601
617
|
"""Return the cache directory inside the nearest ``.kd/`` workspace.
|
|
602
618
|
|
|
@@ -1133,19 +1149,19 @@ def _clear_subtree(directory: Path, errors: list[ClearReportError]) -> int:
|
|
|
1133
1149
|
_remove_file(entry)
|
|
1134
1150
|
removed += 1
|
|
1135
1151
|
except OSError as e:
|
|
1136
|
-
errors.append(ClearReportError(path=
|
|
1152
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1137
1153
|
elif entry.is_dir():
|
|
1138
1154
|
removed += _clear_subtree(entry, errors)
|
|
1139
1155
|
try:
|
|
1140
1156
|
entry.rmdir()
|
|
1141
1157
|
except OSError as e:
|
|
1142
|
-
errors.append(ClearReportError(path=
|
|
1158
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1143
1159
|
else:
|
|
1144
1160
|
try:
|
|
1145
1161
|
_remove_file(entry)
|
|
1146
1162
|
removed += 1
|
|
1147
1163
|
except OSError as e:
|
|
1148
|
-
errors.append(ClearReportError(path=
|
|
1164
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1149
1165
|
return removed
|
|
1150
1166
|
|
|
1151
1167
|
|
|
@@ -1183,25 +1199,25 @@ def clear_cache(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1183
1199
|
_remove_file(entry)
|
|
1184
1200
|
counts["other"] += 1
|
|
1185
1201
|
except OSError as e:
|
|
1186
|
-
errors.append(ClearReportError(path=
|
|
1202
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1187
1203
|
elif entry.is_dir():
|
|
1188
1204
|
kind = _classify(entry.name)
|
|
1189
1205
|
counts[kind] += _clear_subtree(entry, errors)
|
|
1190
1206
|
try:
|
|
1191
1207
|
entry.rmdir()
|
|
1192
1208
|
except OSError as e:
|
|
1193
|
-
errors.append(ClearReportError(path=
|
|
1209
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1194
1210
|
else:
|
|
1195
1211
|
try:
|
|
1196
1212
|
_remove_file(entry)
|
|
1197
1213
|
counts["other"] += 1
|
|
1198
1214
|
except OSError as e:
|
|
1199
|
-
errors.append(ClearReportError(path=
|
|
1215
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1200
1216
|
|
|
1201
1217
|
try:
|
|
1202
1218
|
target.rmdir()
|
|
1203
1219
|
except OSError as e:
|
|
1204
|
-
errors.append(ClearReportError(path=
|
|
1220
|
+
errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
|
|
1205
1221
|
|
|
1206
1222
|
return ClearReport(
|
|
1207
1223
|
existed=True,
|
|
@@ -1244,7 +1260,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1244
1260
|
try:
|
|
1245
1261
|
entry.rmdir()
|
|
1246
1262
|
except OSError as e:
|
|
1247
|
-
errors.append(ClearReportError(path=
|
|
1263
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1248
1264
|
|
|
1249
1265
|
for entry in sorted(target.iterdir()):
|
|
1250
1266
|
if entry.is_symlink():
|
|
@@ -1252,7 +1268,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1252
1268
|
_remove_file(entry)
|
|
1253
1269
|
counts["other"] += 1
|
|
1254
1270
|
except OSError as e:
|
|
1255
|
-
errors.append(ClearReportError(path=
|
|
1271
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1256
1272
|
elif entry.is_dir():
|
|
1257
1273
|
if entry.name == CONNECTIONS_DIRNAME:
|
|
1258
1274
|
for scope in sorted(entry.iterdir()):
|
|
@@ -1261,7 +1277,7 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1261
1277
|
_remove_file(scope)
|
|
1262
1278
|
counts["other"] += 1
|
|
1263
1279
|
except OSError as e:
|
|
1264
|
-
errors.append(ClearReportError(path=
|
|
1280
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1265
1281
|
elif scope.is_dir():
|
|
1266
1282
|
for inner in sorted(scope.iterdir()):
|
|
1267
1283
|
if inner.is_dir():
|
|
@@ -1271,21 +1287,23 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1271
1287
|
_remove_file(inner)
|
|
1272
1288
|
counts["other"] += 1
|
|
1273
1289
|
except OSError as e:
|
|
1274
|
-
errors.append(
|
|
1290
|
+
errors.append(
|
|
1291
|
+
ClearReportError(path=_report_path(inner), reason=str(e))
|
|
1292
|
+
)
|
|
1275
1293
|
try:
|
|
1276
1294
|
scope.rmdir()
|
|
1277
1295
|
except OSError as e:
|
|
1278
|
-
errors.append(ClearReportError(path=
|
|
1296
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1279
1297
|
else:
|
|
1280
1298
|
try:
|
|
1281
1299
|
_remove_file(scope)
|
|
1282
1300
|
counts["other"] += 1
|
|
1283
1301
|
except OSError as e:
|
|
1284
|
-
errors.append(ClearReportError(path=
|
|
1302
|
+
errors.append(ClearReportError(path=_report_path(scope), reason=str(e)))
|
|
1285
1303
|
try:
|
|
1286
1304
|
entry.rmdir()
|
|
1287
1305
|
except OSError as e:
|
|
1288
|
-
errors.append(ClearReportError(path=
|
|
1306
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1289
1307
|
else:
|
|
1290
1308
|
_tally_kind_subdir(entry)
|
|
1291
1309
|
else:
|
|
@@ -1293,12 +1311,12 @@ def clear_all_scopes(*, cache_root: Path | None = None) -> ClearReport:
|
|
|
1293
1311
|
_remove_file(entry)
|
|
1294
1312
|
counts["other"] += 1
|
|
1295
1313
|
except OSError as e:
|
|
1296
|
-
errors.append(ClearReportError(path=
|
|
1314
|
+
errors.append(ClearReportError(path=_report_path(entry), reason=str(e)))
|
|
1297
1315
|
|
|
1298
1316
|
try:
|
|
1299
1317
|
target.rmdir()
|
|
1300
1318
|
except OSError as e:
|
|
1301
|
-
errors.append(ClearReportError(path=
|
|
1319
|
+
errors.append(ClearReportError(path=_report_path(target), reason=str(e)))
|
|
1302
1320
|
|
|
1303
1321
|
return ClearReport(
|
|
1304
1322
|
existed=True,
|
|
@@ -1405,7 +1423,7 @@ def enumerate_cache_scopes(cache_root: Path | None = None) -> CacheScopeEnumerat
|
|
|
1405
1423
|
# a logic error, not user-recoverable state.
|
|
1406
1424
|
assert data is not None
|
|
1407
1425
|
manifest = _validate_scope_manifest_schema(data, manifest_path)
|
|
1408
|
-
scopes.append(CacheScopeRef(slug=entry.name, path=
|
|
1426
|
+
scopes.append(CacheScopeRef(slug=entry.name, path=_report_path(entry), manifest=manifest))
|
|
1409
1427
|
|
|
1410
1428
|
return CacheScopeEnumeration(scopes=scopes, orphan_dirs=orphans)
|
|
1411
1429
|
|
|
@@ -1459,7 +1477,7 @@ def _scan_articles_dir(
|
|
|
1459
1477
|
CacheStatusWarning(
|
|
1460
1478
|
code="article_pair_mismatch",
|
|
1461
1479
|
message=f"article body without sidecar: {article_id}.md",
|
|
1462
|
-
path=
|
|
1480
|
+
path=_report_path(bodies[article_id]),
|
|
1463
1481
|
)
|
|
1464
1482
|
)
|
|
1465
1483
|
for article_id in sorted(only_sidecar):
|
|
@@ -1467,7 +1485,7 @@ def _scan_articles_dir(
|
|
|
1467
1485
|
CacheStatusWarning(
|
|
1468
1486
|
code="article_pair_mismatch",
|
|
1469
1487
|
message=f"article sidecar without body: {article_id}.meta.yaml",
|
|
1470
|
-
path=
|
|
1488
|
+
path=_report_path(sidecars[article_id]),
|
|
1471
1489
|
)
|
|
1472
1490
|
)
|
|
1473
1491
|
|
|
@@ -1483,7 +1501,7 @@ def _scan_articles_dir(
|
|
|
1483
1501
|
CacheStatusWarning(
|
|
1484
1502
|
code="sidecar_unparseable",
|
|
1485
1503
|
message=f"unable to parse fetched_at from {sidecar_path.name}: {exc}",
|
|
1486
|
-
path=
|
|
1504
|
+
path=_report_path(sidecar_path),
|
|
1487
1505
|
)
|
|
1488
1506
|
)
|
|
1489
1507
|
continue
|
|
@@ -1522,7 +1540,7 @@ def _scan_yaml_kind_dir(
|
|
|
1522
1540
|
CacheStatusWarning(
|
|
1523
1541
|
code=code,
|
|
1524
1542
|
message=f"unable to parse banner from {path.name}: {exc}",
|
|
1525
|
-
path=
|
|
1543
|
+
path=_report_path(path),
|
|
1526
1544
|
)
|
|
1527
1545
|
)
|
|
1528
1546
|
continue
|
|
@@ -1701,7 +1719,7 @@ def compute_cache_status(
|
|
|
1701
1719
|
when = now if now is not None else datetime.now(timezone.utc)
|
|
1702
1720
|
target = cache_dir() if cache_root is None else Path(cache_root).resolve()
|
|
1703
1721
|
|
|
1704
|
-
info_path =
|
|
1722
|
+
info_path = _report_path(target)
|
|
1705
1723
|
if not target.exists():
|
|
1706
1724
|
return CacheStatusReport(
|
|
1707
1725
|
cache=CacheStatusInfo(
|
|
@@ -1855,7 +1873,7 @@ def _refresh_primer_articles(
|
|
|
1855
1873
|
id=article.id_readable,
|
|
1856
1874
|
summary=article.summary,
|
|
1857
1875
|
upstream_updated=meta.upstream_updated,
|
|
1858
|
-
path=
|
|
1876
|
+
path=_report_path(cache_root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
|
|
1859
1877
|
)
|
|
1860
1878
|
)
|
|
1861
1879
|
except APIError as e:
|
|
@@ -1906,7 +1924,7 @@ def _refresh_primer_query(
|
|
|
1906
1924
|
hydrated=[
|
|
1907
1925
|
HydratedQuery(
|
|
1908
1926
|
slug=PRIMER_SCOPE_QUERY_SLUG,
|
|
1909
|
-
path=
|
|
1927
|
+
path=_report_path(cache_root / QUERIES_DIRNAME / f"{PRIMER_SCOPE_QUERY_SLUG}.yaml"),
|
|
1910
1928
|
issue_count=issue_count,
|
|
1911
1929
|
article_count=article_count,
|
|
1912
1930
|
)
|
|
@@ -2102,7 +2120,7 @@ def refresh_issue(
|
|
|
2102
2120
|
id=issue.id_readable,
|
|
2103
2121
|
summary=issue.summary,
|
|
2104
2122
|
upstream_updated=_ms_to_iso(issue.updated),
|
|
2105
|
-
path=
|
|
2123
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{issue.id_readable}.yaml"),
|
|
2106
2124
|
comments_count=comments_count,
|
|
2107
2125
|
)
|
|
2108
2126
|
],
|
|
@@ -2314,7 +2332,7 @@ def refresh_query(
|
|
|
2314
2332
|
query_name=sq.name,
|
|
2315
2333
|
query_id=sq.id,
|
|
2316
2334
|
slug=slug,
|
|
2317
|
-
path=
|
|
2335
|
+
path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
|
|
2318
2336
|
count=len(issues),
|
|
2319
2337
|
truncated=truncated,
|
|
2320
2338
|
)
|
|
@@ -2458,7 +2476,7 @@ def refresh_milestone(
|
|
|
2458
2476
|
field_name=actual_field_name,
|
|
2459
2477
|
query_text=query_text,
|
|
2460
2478
|
slug=slug,
|
|
2461
|
-
path=
|
|
2479
|
+
path=_report_path(root / QUERIES_DIRNAME / f"{slug}.yaml"),
|
|
2462
2480
|
count=len(issues),
|
|
2463
2481
|
truncated=truncated,
|
|
2464
2482
|
)
|
|
@@ -2616,7 +2634,7 @@ def refresh_epic_with_children(
|
|
|
2616
2634
|
id=epic.id_readable,
|
|
2617
2635
|
summary=epic.summary,
|
|
2618
2636
|
upstream_updated=_ms_to_iso(epic.updated),
|
|
2619
|
-
path=
|
|
2637
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{epic.id_readable}.yaml"),
|
|
2620
2638
|
comments_count=None,
|
|
2621
2639
|
)
|
|
2622
2640
|
|
|
@@ -2657,7 +2675,7 @@ def refresh_epic_with_children(
|
|
|
2657
2675
|
id=child.id_readable,
|
|
2658
2676
|
summary=child.summary,
|
|
2659
2677
|
upstream_updated=_ms_to_iso(child.updated),
|
|
2660
|
-
path=
|
|
2678
|
+
path=_report_path(root / ISSUES_DIRNAME / f"{child.id_readable}.yaml"),
|
|
2661
2679
|
comments_count=None,
|
|
2662
2680
|
)
|
|
2663
2681
|
)
|
|
@@ -2701,7 +2719,7 @@ def refresh_epic_with_children(
|
|
|
2701
2719
|
"children": manifest_rows,
|
|
2702
2720
|
}
|
|
2703
2721
|
write_cached_query(slug, manifest_payload, fetched_at=when, cache_root=root)
|
|
2704
|
-
manifest_path =
|
|
2722
|
+
manifest_path = _report_path(root / QUERIES_DIRNAME / f"{slug}.yaml")
|
|
2705
2723
|
|
|
2706
2724
|
return EpicRefreshReport(
|
|
2707
2725
|
epic=epic_hydrated,
|
|
@@ -2754,7 +2772,7 @@ def refresh_article(
|
|
|
2754
2772
|
id=article.id_readable,
|
|
2755
2773
|
summary=article.summary,
|
|
2756
2774
|
upstream_updated=meta.upstream_updated,
|
|
2757
|
-
path=
|
|
2775
|
+
path=_report_path(root / ARTICLES_DIRNAME / f"{article.id_readable}.md"),
|
|
2758
2776
|
)
|
|
2759
2777
|
],
|
|
2760
2778
|
failed=[],
|
|
@@ -2907,7 +2925,7 @@ def _search_article_dir(
|
|
|
2907
2925
|
CacheStatusWarning(
|
|
2908
2926
|
code="sidecar_unparseable",
|
|
2909
2927
|
message=f"unable to parse {sidecar_path.name}: {exc}",
|
|
2910
|
-
path=
|
|
2928
|
+
path=_report_path(sidecar_path),
|
|
2911
2929
|
)
|
|
2912
2930
|
)
|
|
2913
2931
|
try:
|
|
@@ -2917,7 +2935,7 @@ def _search_article_dir(
|
|
|
2917
2935
|
CacheStatusWarning(
|
|
2918
2936
|
code="banner_unparseable",
|
|
2919
2937
|
message=f"unable to read {body_path.name}: {exc}",
|
|
2920
|
-
path=
|
|
2938
|
+
path=_report_path(body_path),
|
|
2921
2939
|
)
|
|
2922
2940
|
)
|
|
2923
2941
|
continue
|
|
@@ -2928,7 +2946,7 @@ def _search_article_dir(
|
|
|
2928
2946
|
CacheStatusWarning(
|
|
2929
2947
|
code="banner_unparseable",
|
|
2930
2948
|
message=f"unable to parse banner from {body_path.name}: {exc}",
|
|
2931
|
-
path=
|
|
2949
|
+
path=_report_path(body_path),
|
|
2932
2950
|
)
|
|
2933
2951
|
)
|
|
2934
2952
|
continue
|
|
@@ -2940,7 +2958,7 @@ def _search_article_dir(
|
|
|
2940
2958
|
continue
|
|
2941
2959
|
matches.append(
|
|
2942
2960
|
CacheSearchMatch(
|
|
2943
|
-
path=
|
|
2961
|
+
path=_report_path(body_path),
|
|
2944
2962
|
connection=connection,
|
|
2945
2963
|
slug=slug,
|
|
2946
2964
|
kind="article",
|
|
@@ -2972,7 +2990,7 @@ def _load_yaml_payload(
|
|
|
2972
2990
|
CacheStatusWarning(
|
|
2973
2991
|
code="payload_unparseable",
|
|
2974
2992
|
message=f"unable to read {path.name}: {exc}",
|
|
2975
|
-
path=
|
|
2993
|
+
path=_report_path(path),
|
|
2976
2994
|
)
|
|
2977
2995
|
)
|
|
2978
2996
|
return None
|
|
@@ -2983,7 +3001,7 @@ def _load_yaml_payload(
|
|
|
2983
3001
|
CacheStatusWarning(
|
|
2984
3002
|
code="banner_unparseable",
|
|
2985
3003
|
message=f"unable to parse banner from {path.name}: {exc}",
|
|
2986
|
-
path=
|
|
3004
|
+
path=_report_path(path),
|
|
2987
3005
|
)
|
|
2988
3006
|
)
|
|
2989
3007
|
body = raw # try the full file — payload may parse without banner
|
|
@@ -2994,7 +3012,7 @@ def _load_yaml_payload(
|
|
|
2994
3012
|
CacheStatusWarning(
|
|
2995
3013
|
code="payload_unparseable",
|
|
2996
3014
|
message=f"unable to parse YAML payload from {path.name}: {exc}",
|
|
2997
|
-
path=
|
|
3015
|
+
path=_report_path(path),
|
|
2998
3016
|
)
|
|
2999
3017
|
)
|
|
3000
3018
|
return None
|
|
@@ -3003,7 +3021,7 @@ def _load_yaml_payload(
|
|
|
3003
3021
|
CacheStatusWarning(
|
|
3004
3022
|
code="payload_unparseable",
|
|
3005
3023
|
message=f"YAML payload in {path.name} is not a mapping",
|
|
3006
|
-
path=
|
|
3024
|
+
path=_report_path(path),
|
|
3007
3025
|
)
|
|
3008
3026
|
)
|
|
3009
3027
|
return None
|
|
@@ -3052,7 +3070,7 @@ def _search_issue_dir(
|
|
|
3052
3070
|
continue
|
|
3053
3071
|
matches.append(
|
|
3054
3072
|
CacheSearchMatch(
|
|
3055
|
-
path=
|
|
3073
|
+
path=_report_path(path),
|
|
3056
3074
|
connection=connection,
|
|
3057
3075
|
slug=slug,
|
|
3058
3076
|
kind="issue",
|
|
@@ -3181,7 +3199,7 @@ def _search_query_dir(
|
|
|
3181
3199
|
CacheStatusWarning(
|
|
3182
3200
|
code="query_shape_unrecognized",
|
|
3183
3201
|
message=f"unknown query cache shape in {path.name}",
|
|
3184
|
-
path=
|
|
3202
|
+
path=_report_path(path),
|
|
3185
3203
|
)
|
|
3186
3204
|
)
|
|
3187
3205
|
continue
|
|
@@ -3189,7 +3207,7 @@ def _search_query_dir(
|
|
|
3189
3207
|
continue
|
|
3190
3208
|
matches.append(
|
|
3191
3209
|
CacheSearchMatch(
|
|
3192
|
-
path=
|
|
3210
|
+
path=_report_path(path),
|
|
3193
3211
|
connection=connection,
|
|
3194
3212
|
slug=slug,
|
|
3195
3213
|
kind="query",
|
|
@@ -25,9 +25,23 @@ def resolve_text_input(value: str | None) -> str | None:
|
|
|
25
25
|
if not value:
|
|
26
26
|
return value
|
|
27
27
|
if value == "-":
|
|
28
|
-
|
|
28
|
+
try:
|
|
29
|
+
return sys.stdin.read()
|
|
30
|
+
except UnicodeDecodeError as e:
|
|
31
|
+
raise ValidationError(
|
|
32
|
+
f"Stdin input could not be decoded as UTF-8: {e.reason}",
|
|
33
|
+
suggestion="Ensure the upstream shell pipes UTF-8 bytes "
|
|
34
|
+
"(heredoc, pipe, or redirected file).",
|
|
35
|
+
) from e
|
|
29
36
|
if value == "@-":
|
|
30
|
-
|
|
37
|
+
try:
|
|
38
|
+
text = sys.stdin.read()
|
|
39
|
+
except UnicodeDecodeError as e:
|
|
40
|
+
raise ValidationError(
|
|
41
|
+
f"Stdin input could not be decoded as UTF-8: {e.reason}",
|
|
42
|
+
suggestion="Ensure the upstream shell pipes UTF-8 bytes "
|
|
43
|
+
"(heredoc, pipe, or redirected file).",
|
|
44
|
+
) from e
|
|
31
45
|
if not text:
|
|
32
46
|
raise ValidationError("No input received from stdin")
|
|
33
47
|
return text
|
|
@@ -35,7 +49,13 @@ def resolve_text_input(value: str | None) -> str | None:
|
|
|
35
49
|
file_path = Path(value[1:])
|
|
36
50
|
if not file_path.is_file():
|
|
37
51
|
raise ValidationError(f"File not found: {file_path}")
|
|
38
|
-
|
|
52
|
+
try:
|
|
53
|
+
text = file_path.read_text(encoding="utf-8")
|
|
54
|
+
except UnicodeDecodeError as e:
|
|
55
|
+
raise ValidationError(
|
|
56
|
+
f"File {file_path} could not be decoded as UTF-8: {e.reason}",
|
|
57
|
+
suggestion="Save the file as UTF-8 (without a BOM) and retry.",
|
|
58
|
+
) from e
|
|
39
59
|
if not text:
|
|
40
60
|
raise ValidationError(f"File is empty: {file_path}")
|
|
41
61
|
return text
|