github-license-scanner 0.1.0__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.
gls/github_api.py ADDED
@@ -0,0 +1,514 @@
1
+ """
2
+ GitHub REST API helpers.
3
+
4
+ Responsibilities:
5
+ - Parse GitHub repository URLs into (owner, repo)
6
+ - Fetch repository metadata (license, language, topics, default branch)
7
+ - Walk the git tree to locate dependency manifest files
8
+ - Download file contents for parsing
9
+
10
+ Uses httpx for all HTTP calls. Optional GITHUB_TOKEN env var raises rate limits.
11
+
12
+ Security:
13
+ - Only the official api.github.com host is contacted (no user-controlled hosts).
14
+ - Owner/repo are validated against a strict charset before interpolation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import base64
21
+ import re
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ try:
27
+ from .config import GITHUB_TOKEN, MAX_DEPENDENCY_FILES, USER_AGENT
28
+ except Exception: # noqa: BLE001
29
+ import os
30
+
31
+ GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
32
+ MAX_DEPENDENCY_FILES = 30
33
+ USER_AGENT = "github-license-scanner/1.1 (educational)"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Constants
37
+ # ---------------------------------------------------------------------------
38
+
39
+ GITHUB_API = "https://api.github.com"
40
+
41
+ # Reserved / non-repo path segments that must never be treated as owner/repo
42
+ _RESERVED_OWNERS = {
43
+ "settings",
44
+ "marketplace",
45
+ "explore",
46
+ "topics",
47
+ "notifications",
48
+ "login",
49
+ "join",
50
+ "features",
51
+ "pricing",
52
+ "about",
53
+ "site",
54
+ "orgs",
55
+ "users",
56
+ "search",
57
+ "pulls",
58
+ "issues",
59
+ "codespaces",
60
+ "sponsors",
61
+ "customer-stories",
62
+ "team",
63
+ "enterprise",
64
+ "security",
65
+ "readme",
66
+ }
67
+
68
+ # Path patterns that identify dependency manifests.
69
+ # Each entry: (basename_regex or exact basename, preferred weight for sorting)
70
+ DEPENDENCY_BASENAMES = {
71
+ "package.json",
72
+ "package-lock.json", # we skip lockfiles in scanner; listed only for detection skip
73
+ "requirements.txt",
74
+ "pyproject.toml",
75
+ "setup.py",
76
+ "Pipfile",
77
+ "Cargo.toml",
78
+ "go.mod",
79
+ "Gemfile",
80
+ "composer.json",
81
+ "pom.xml",
82
+ "build.gradle",
83
+ "build.gradle.kts",
84
+ }
85
+
86
+ # requirements-dev.txt, requirements-prod.txt, etc.
87
+ REQUIREMENTS_GLOB = re.compile(r"^requirements(-[\w.-]+)?\.txt$", re.IGNORECASE)
88
+
89
+ # Docker-related files used by deploy_advisor signals
90
+ DOCKER_BASENAMES = {
91
+ "Dockerfile",
92
+ "dockerfile",
93
+ "docker-compose.yml",
94
+ "docker-compose.yaml",
95
+ "compose.yml",
96
+ "compose.yaml",
97
+ }
98
+
99
+
100
+ class GitHubAPIError(Exception):
101
+ """Raised when GitHub API returns an error or the repo cannot be read."""
102
+
103
+ def __init__(self, message: str, status_code: int | None = None):
104
+ super().__init__(message)
105
+ self.status_code = status_code
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # URL parsing
110
+ # ---------------------------------------------------------------------------
111
+
112
+ _URL_PATTERNS = [
113
+ # https://github.com/owner/repo[.git][/...]
114
+ re.compile(
115
+ r"(?:https?://)?(?:www\.)?github\.com/"
116
+ r"(?P<owner>[A-Za-z0-9_.-]+)/"
117
+ r"(?P<repo>[A-Za-z0-9_.-]+?)(?:\.git)?(?:/.*)?$",
118
+ re.IGNORECASE,
119
+ ),
120
+ # git@github.com:owner/repo.git
121
+ re.compile(
122
+ r"git@github\.com:"
123
+ r"(?P<owner>[A-Za-z0-9_.-]+)/"
124
+ r"(?P<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$",
125
+ re.IGNORECASE,
126
+ ),
127
+ # ssh://git@github.com/owner/repo.git
128
+ re.compile(
129
+ r"ssh://git@github\.com/"
130
+ r"(?P<owner>[A-Za-z0-9_.-]+)/"
131
+ r"(?P<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$",
132
+ re.IGNORECASE,
133
+ ),
134
+ ]
135
+
136
+
137
+ def parse_github_url(url: str) -> tuple[str, str]:
138
+ """
139
+ Extract (owner, repo) from a GitHub URL or shorthand.
140
+
141
+ Accepts:
142
+ - https://github.com/owner/repo
143
+ - https://github.com/owner/repo.git
144
+ - https://github.com/owner/repo/tree/main/...
145
+ - git@github.com:owner/repo.git
146
+ - owner/repo (shorthand)
147
+
148
+ Raises:
149
+ ValueError: if the URL cannot be parsed as a GitHub repository.
150
+ """
151
+ text = (url or "").strip()
152
+ if not text:
153
+ raise ValueError("Empty GitHub URL")
154
+
155
+ # Reject URL schemes other than http(s)/git/ssh shorthand to avoid SSRF-style abuse
156
+ if "://" in text:
157
+ scheme = text.split("://", 1)[0].lower()
158
+ if scheme not in {"http", "https", "ssh", "git"}:
159
+ raise ValueError(f"Unsupported URL scheme: {scheme!r}")
160
+
161
+ # Shorthand: owner/repo
162
+ if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", text):
163
+ owner, repo = text.split("/", 1)
164
+ return _validate_owner_repo(owner, repo.removesuffix(".git"))
165
+
166
+ for pattern in _URL_PATTERNS:
167
+ match = pattern.match(text)
168
+ if match:
169
+ owner = match.group("owner")
170
+ repo = match.group("repo").removesuffix(".git")
171
+ return _validate_owner_repo(owner, repo)
172
+
173
+ raise ValueError(f"Not a valid GitHub repository URL: {url!r}")
174
+
175
+
176
+ def _validate_owner_repo(owner: str, repo: str) -> tuple[str, str]:
177
+ """Validate and normalize owner/repo identifiers."""
178
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", owner or ""):
179
+ raise ValueError(f"Invalid GitHub owner: {owner!r}")
180
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", repo or ""):
181
+ raise ValueError(f"Invalid GitHub repository name: {repo!r}")
182
+ if owner.lower() in _RESERVED_OWNERS:
183
+ raise ValueError(f"Not a repository path (reserved segment): {owner!r}")
184
+ if len(owner) > 100 or len(repo) > 100:
185
+ raise ValueError("Owner or repository name is too long")
186
+ return owner, repo
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # HTTP client helpers
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def _auth_headers() -> dict[str, str]:
194
+ """Build default headers, attaching GITHUB_TOKEN when available."""
195
+ headers = {
196
+ "Accept": "application/vnd.github+json",
197
+ "User-Agent": USER_AGENT,
198
+ "X-GitHub-Api-Version": "2022-11-28",
199
+ }
200
+ if GITHUB_TOKEN:
201
+ headers["Authorization"] = f"Bearer {GITHUB_TOKEN}"
202
+ return headers
203
+
204
+
205
+ def create_client(timeout: float = 30.0) -> httpx.AsyncClient:
206
+ """Create a configured async httpx client for GitHub API calls."""
207
+ return httpx.AsyncClient(
208
+ base_url=GITHUB_API,
209
+ headers=_auth_headers(),
210
+ timeout=timeout,
211
+ follow_redirects=True,
212
+ # Never follow redirects off github.com for API paths
213
+ # (httpx still only uses our base_url paths when we pass relative paths)
214
+ )
215
+
216
+
217
+ async def _get_json(client: httpx.AsyncClient, path: str) -> Any:
218
+ """
219
+ GET a GitHub API path and return parsed JSON.
220
+
221
+ Raises GitHubAPIError with a human-readable message on failure.
222
+ """
223
+ # Paths must be relative API routes — never absolute external URLs
224
+ if path.startswith("http://") or path.startswith("https://"):
225
+ raise GitHubAPIError("Refusing absolute URL in GitHub API client")
226
+
227
+ response = await client.get(path)
228
+ if response.status_code == 404:
229
+ raise GitHubAPIError(
230
+ "Repository not found (or private without a valid GITHUB_TOKEN).",
231
+ status_code=404,
232
+ )
233
+ if response.status_code == 403:
234
+ remaining = response.headers.get("X-RateLimit-Remaining", "?")
235
+ reset = response.headers.get("X-RateLimit-Reset", "?")
236
+ retry_after = response.headers.get("Retry-After", "")
237
+ extra = f" Retry-After: {retry_after}." if retry_after else ""
238
+ raise GitHubAPIError(
239
+ f"GitHub API forbidden (rate limit or permissions). "
240
+ f"Remaining: {remaining}, reset: {reset}.{extra} "
241
+ f"Set GITHUB_TOKEN for higher limits.",
242
+ status_code=403,
243
+ )
244
+ if response.status_code == 401:
245
+ raise GitHubAPIError(
246
+ "GitHub authentication failed. Check GITHUB_TOKEN.",
247
+ status_code=401,
248
+ )
249
+ if response.status_code == 429:
250
+ retry_after = response.headers.get("Retry-After", "?")
251
+ raise GitHubAPIError(
252
+ f"GitHub API rate limit exceeded. Retry-After: {retry_after}. "
253
+ f"Set GITHUB_TOKEN or wait before scanning again.",
254
+ status_code=429,
255
+ )
256
+ if response.status_code >= 400:
257
+ # Do not echo large response bodies (may contain tokens in rare edge cases)
258
+ body = (response.text or "")[:180].replace("\n", " ")
259
+ raise GitHubAPIError(
260
+ f"GitHub API error {response.status_code}: {body}",
261
+ status_code=response.status_code,
262
+ )
263
+ return response.json()
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Repository metadata
268
+ # ---------------------------------------------------------------------------
269
+
270
+ async def get_repo_info(
271
+ client: httpx.AsyncClient,
272
+ owner: str,
273
+ repo: str,
274
+ ) -> dict[str, Any]:
275
+ """
276
+ Fetch repository metadata from GET /repos/{owner}/{repo}.
277
+
278
+ Returns a normalized dict with keys:
279
+ license_spdx, license_name, default_branch, language, description,
280
+ topics, html_url, full_name
281
+ """
282
+ data = await _get_json(client, f"/repos/{owner}/{repo}")
283
+
284
+ license_info = data.get("license") or {}
285
+ # GitHub may return null license when LICENSE file is missing/custom
286
+ license_spdx = license_info.get("spdx_id") if license_info else None
287
+ # "NOASSERTION" means GitHub could not determine the license
288
+ if license_spdx in (None, "NOASSERTION", "NONE"):
289
+ license_spdx = None
290
+
291
+ return {
292
+ "owner": owner,
293
+ "repo": repo,
294
+ "full_name": data.get("full_name", f"{owner}/{repo}"),
295
+ "html_url": data.get("html_url", f"https://github.com/{owner}/{repo}"),
296
+ "description": data.get("description"),
297
+ "default_branch": data.get("default_branch") or "main",
298
+ "language": data.get("language"),
299
+ "topics": data.get("topics") or [],
300
+ "license_spdx": license_spdx,
301
+ "license_name": license_info.get("name") if license_info else None,
302
+ "license_url": license_info.get("url") if license_info else None,
303
+ "private": bool(data.get("private")),
304
+ }
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Tree walking & file download
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _is_dependency_path(path: str) -> bool:
312
+ """Return True if the path looks like a dependency manifest we can parse."""
313
+ name = path.rsplit("/", 1)[-1]
314
+ # Skip lockfiles (they duplicate package.json / Cargo.lock noise)
315
+ if name in {
316
+ "package-lock.json",
317
+ "yarn.lock",
318
+ "pnpm-lock.yaml",
319
+ "Cargo.lock",
320
+ "poetry.lock",
321
+ "Pipfile.lock",
322
+ "composer.lock",
323
+ "go.sum",
324
+ }:
325
+ return False
326
+ if name in DEPENDENCY_BASENAMES:
327
+ return True
328
+ if REQUIREMENTS_GLOB.match(name):
329
+ return True
330
+ return False
331
+
332
+
333
+ def _is_docker_path(path: str) -> bool:
334
+ """Return True if path is a Docker-related config file."""
335
+ name = path.rsplit("/", 1)[-1]
336
+ return name in DOCKER_BASENAMES
337
+
338
+
339
+ def _prefer_root_sort_key(path: str) -> tuple[int, int, str]:
340
+ """
341
+ Sort key that prefers root-level manifests over deep nested ones.
342
+
343
+ Root files (depth 0) first, then shallower paths, then alphabetical.
344
+ """
345
+ depth = path.count("/")
346
+ # Prefer well-known root names slightly
347
+ name = path.rsplit("/", 1)[-1]
348
+ priority = 0 if name in {
349
+ "package.json",
350
+ "requirements.txt",
351
+ "pyproject.toml",
352
+ "Cargo.toml",
353
+ "go.mod",
354
+ "composer.json",
355
+ "Gemfile",
356
+ "pom.xml",
357
+ } else 1
358
+ return (depth, priority, path.lower())
359
+
360
+
361
+ async def list_repo_files(
362
+ client: httpx.AsyncClient,
363
+ owner: str,
364
+ repo: str,
365
+ branch: str,
366
+ ) -> list[str]:
367
+ """
368
+ List all file paths in the repository via the recursive git tree API.
369
+
370
+ Returns an empty list if the tree is too large or truncated without paths.
371
+ """
372
+ data = await _get_json(
373
+ client,
374
+ f"/repos/{owner}/{repo}/git/trees/{branch}?recursive=1",
375
+ )
376
+ tree = data.get("tree") or []
377
+ paths: list[str] = []
378
+ for item in tree:
379
+ if item.get("type") == "blob" and item.get("path"):
380
+ paths.append(item["path"])
381
+ return paths
382
+
383
+
384
+ async def find_dependency_files(
385
+ client: httpx.AsyncClient,
386
+ owner: str,
387
+ repo: str,
388
+ branch: str,
389
+ ) -> tuple[list[str], bool]:
390
+ """
391
+ Discover dependency manifest paths and whether a Dockerfile exists.
392
+
393
+ Returns:
394
+ (dependency_paths, has_dockerfile)
395
+ """
396
+ try:
397
+ all_paths = await list_repo_files(client, owner, repo, branch)
398
+ except GitHubAPIError:
399
+ # Fall back to probing common root files only
400
+ return await _probe_root_manifests(client, owner, repo), False
401
+
402
+ dep_paths = [p for p in all_paths if _is_dependency_path(p)]
403
+ dep_paths.sort(key=_prefer_root_sort_key)
404
+ dep_paths = dep_paths[:MAX_DEPENDENCY_FILES]
405
+
406
+ has_docker = any(_is_docker_path(p) for p in all_paths)
407
+ return dep_paths, has_docker
408
+
409
+
410
+ async def _probe_root_manifests(
411
+ client: httpx.AsyncClient,
412
+ owner: str,
413
+ repo: str,
414
+ ) -> list[str]:
415
+ """
416
+ Probe common root-level dependency files one by one.
417
+
418
+ Used when the recursive tree API fails (rare / truncated trees).
419
+ """
420
+ candidates = [
421
+ "package.json",
422
+ "requirements.txt",
423
+ "pyproject.toml",
424
+ "setup.py",
425
+ "Pipfile",
426
+ "Cargo.toml",
427
+ "go.mod",
428
+ "Gemfile",
429
+ "composer.json",
430
+ "pom.xml",
431
+ "build.gradle",
432
+ "build.gradle.kts",
433
+ ]
434
+ found: list[str] = []
435
+ for path in candidates:
436
+ try:
437
+ response = await client.get(f"/repos/{owner}/{repo}/contents/{path}")
438
+ if response.status_code == 200:
439
+ found.append(path)
440
+ except httpx.HTTPError:
441
+ continue
442
+ return found
443
+
444
+
445
+ async def get_file_content(
446
+ client: httpx.AsyncClient,
447
+ owner: str,
448
+ repo: str,
449
+ path: str,
450
+ ref: str | None = None,
451
+ ) -> str:
452
+ """
453
+ Download a text file from the repository and return decoded UTF-8 content.
454
+
455
+ Uses the Contents API (base64) which works for files up to 1 MB.
456
+ """
457
+ api_path = f"/repos/{owner}/{repo}/contents/{path}"
458
+ if ref:
459
+ api_path += f"?ref={ref}"
460
+
461
+ data = await _get_json(client, api_path)
462
+
463
+ # Directory response is a list — not a file
464
+ if isinstance(data, list):
465
+ raise GitHubAPIError(f"Path is a directory, not a file: {path}")
466
+
467
+ encoding = data.get("encoding")
468
+ content = data.get("content") or ""
469
+ if encoding == "base64":
470
+ try:
471
+ raw = base64.b64decode(content)
472
+ return raw.decode("utf-8", errors="replace")
473
+ except Exception as exc: # noqa: BLE001
474
+ raise GitHubAPIError(f"Failed to decode {path}: {exc}") from exc
475
+
476
+ # Some responses may return raw content (unlikely with default Accept)
477
+ if isinstance(content, str):
478
+ return content
479
+
480
+ raise GitHubAPIError(f"Unexpected content payload for {path}")
481
+
482
+
483
+ async def download_dependency_files(
484
+ client: httpx.AsyncClient,
485
+ owner: str,
486
+ repo: str,
487
+ paths: list[str],
488
+ ref: str | None = None,
489
+ ) -> dict[str, str]:
490
+ """
491
+ Download multiple dependency files concurrently (bounded).
492
+
493
+ Returns a mapping of path -> text content. Failed downloads are skipped
494
+ (caller can treat missing keys as soft errors).
495
+ """
496
+ result: dict[str, str] = {}
497
+ if not paths:
498
+ return result
499
+
500
+ semaphore = asyncio.Semaphore(6)
501
+
502
+ async def one(path: str) -> tuple[str, str | None]:
503
+ async with semaphore:
504
+ try:
505
+ content = await get_file_content(client, owner, repo, path, ref=ref)
506
+ return path, content
507
+ except (GitHubAPIError, httpx.HTTPError):
508
+ return path, None
509
+
510
+ pairs = await asyncio.gather(*(one(p) for p in paths))
511
+ for path, content in pairs:
512
+ if content is not None:
513
+ result[path] = content
514
+ return result
gls/history_store.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Persistent scan history stored as local JSON file(s).
3
+
4
+ Files live in the per-user data directory (platformdirs), never inside the
5
+ installed package and never in the current working directory.
6
+
7
+ Privacy notes:
8
+ - Without auth: single shared file <data_dir>/history.json (local/dev).
9
+ - With multi-user auth: <data_dir>/history/<username>.json per user.
10
+ - Entries are pruned by count and optional max age (see config).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import tempfile
19
+ import threading
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .models import ScanResult
25
+
26
+ try:
27
+ from .config import DATA_DIR, HISTORY_MAX_AGE_DAYS, HISTORY_MAX_ENTRIES
28
+ except Exception: # noqa: BLE001
29
+ from platformdirs import user_data_dir
30
+
31
+ HISTORY_MAX_ENTRIES = 100
32
+ HISTORY_MAX_AGE_DAYS = 90
33
+ DATA_DIR = Path(user_data_dir("github-license-scanner", "NezbiT"))
34
+
35
+ HISTORY_PATH = DATA_DIR / "history.json"
36
+ HISTORY_USERS_DIR = DATA_DIR / "history"
37
+
38
+ _lock = threading.Lock()
39
+ _SAFE_USER = re.compile(r"^[a-z0-9_.-]{1,64}$")
40
+
41
+
42
+ def _ensure_store(path: Path) -> None:
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ if not path.exists():
45
+ _atomic_write(path, "[]")
46
+
47
+
48
+ def _atomic_write(path: Path, text: str) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ fd, tmp_name = tempfile.mkstemp(
51
+ dir=str(path.parent),
52
+ prefix=".history_",
53
+ suffix=".tmp",
54
+ )
55
+ try:
56
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
57
+ fh.write(text)
58
+ fh.flush()
59
+ os.fsync(fh.fileno())
60
+ Path(tmp_name).replace(path)
61
+ except Exception:
62
+ try:
63
+ os.unlink(tmp_name)
64
+ except OSError:
65
+ pass
66
+ raise
67
+
68
+
69
+ def history_path_for(user: str | None = None) -> Path:
70
+ """Resolve history file path for optional authenticated user."""
71
+ if user:
72
+ key = user.strip().lower()
73
+ if not _SAFE_USER.match(key):
74
+ raise ValueError(f"Invalid history user key: {user!r}")
75
+ return HISTORY_USERS_DIR / f"{key}.json"
76
+ return HISTORY_PATH
77
+
78
+
79
+ def _parse_scanned_at(value: Any) -> datetime | None:
80
+ if not value or not isinstance(value, str):
81
+ return None
82
+ text = value.strip()
83
+ for fmt in ("%Y-%m-%d %H:%M:%S UTC", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d"):
84
+ try:
85
+ candidate = text
86
+ if fmt.endswith("%z") and candidate.endswith("Z"):
87
+ candidate = candidate[:-1] + "+0000"
88
+ dt = datetime.strptime(
89
+ candidate.replace(" UTC", ""),
90
+ fmt.replace(" UTC", ""),
91
+ )
92
+ if dt.tzinfo is None:
93
+ dt = dt.replace(tzinfo=timezone.utc)
94
+ return dt
95
+ except ValueError:
96
+ continue
97
+ return None
98
+
99
+
100
+ def _prune(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
101
+ if HISTORY_MAX_AGE_DAYS and HISTORY_MAX_AGE_DAYS > 0:
102
+ cutoff = datetime.now(timezone.utc).timestamp() - (
103
+ HISTORY_MAX_AGE_DAYS * 24 * 3600
104
+ )
105
+ kept: list[dict[str, Any]] = []
106
+ for e in entries:
107
+ dt = _parse_scanned_at(e.get("scanned_at"))
108
+ if dt is None or dt.timestamp() >= cutoff:
109
+ kept.append(e)
110
+ entries = kept
111
+ return entries[:HISTORY_MAX_ENTRIES]
112
+
113
+
114
+ def load_history(user: str | None = None) -> list[dict[str, Any]]:
115
+ """Return history entries (newest first) for the given user scope."""
116
+ path = history_path_for(user)
117
+ with _lock:
118
+ _ensure_store(path)
119
+ try:
120
+ raw = path.read_text(encoding="utf-8")
121
+ data = json.loads(raw or "[]")
122
+ if not isinstance(data, list):
123
+ return []
124
+ pruned = _prune(data)
125
+ if len(pruned) != len(data):
126
+ _atomic_write(
127
+ path,
128
+ json.dumps(pruned, indent=2, ensure_ascii=False),
129
+ )
130
+ return pruned
131
+ except (json.JSONDecodeError, OSError):
132
+ return []
133
+
134
+
135
+ def save_history(entries: list[dict[str, Any]], user: str | None = None) -> None:
136
+ path = history_path_for(user)
137
+ with _lock:
138
+ _ensure_store(path)
139
+ trimmed = _prune(entries)
140
+ _atomic_write(
141
+ path,
142
+ json.dumps(trimmed, indent=2, ensure_ascii=False),
143
+ )
144
+
145
+
146
+ def append_scan(result: ScanResult, user: str | None = None) -> None:
147
+ """Prepend a complete scan; unique by owner/repo within the user scope."""
148
+ if not result.owner or not result.repo:
149
+ return
150
+ path = history_path_for(user)
151
+ with _lock:
152
+ _ensure_store(path)
153
+ try:
154
+ raw = path.read_text(encoding="utf-8")
155
+ entries = json.loads(raw or "[]")
156
+ if not isinstance(entries, list):
157
+ entries = []
158
+ except (json.JSONDecodeError, OSError):
159
+ entries = []
160
+
161
+ key = f"{result.owner}/{result.repo}".lower()
162
+ entries = [
163
+ e
164
+ for e in entries
165
+ if f"{e.get('owner', '')}/{e.get('repo', '')}".lower() != key
166
+ ]
167
+ entries.insert(0, result.to_history_entry())
168
+ trimmed = _prune(entries)
169
+ _atomic_write(
170
+ path,
171
+ json.dumps(trimmed, indent=2, ensure_ascii=False),
172
+ )
173
+
174
+
175
+ def clear_history(user: str | None = None) -> None:
176
+ """Erase history for the given scope."""
177
+ save_history([], user=user)