Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:52 +02:00
commit 0adf8ef5d9
90 files changed
+14315

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""RepoPulse backend package."""
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from threading import Lock, RLock
from urllib.parse import urlsplit
from .models import Repository
SAFE_REVISION = re.compile(r"^[0-9a-fA-F]{7,64}$")
SAFE_BRANCH = re.compile(r"^[A-Za-z0-9._/-]+$")
SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9-]+$")
class AcquisitionError(RuntimeError):
def __init__(self, code: str, detail: str) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
@dataclass(frozen=True)
class AcquiredSource:
path: Path
actual_revision: str
branch: str
class GitAcquirer:
"""Maintain bounded bare mirrors and scan detached exact-revision worktrees."""
def __init__(
self,
cache_root: str | Path | None = None,
token: str | None = None,
*,
timeout: int = 180,
allowed_clone_origin: str | None = None,
) -> None:
raw_root = cache_root or os.getenv("REPOPULSE_REPOSITORY_CACHE", "data/repos")
self.cache_root = Path(raw_root).expanduser().resolve()
self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "")
self.timeout = max(10, timeout)
configured_origin = allowed_clone_origin or os.getenv("REPOPULSE_GITEA_URL", "")
self.allowed_clone_origin = self._origin(configured_origin)
self.mirrors_root = self.cache_root / "mirrors"
self.worktrees_root = self.cache_root / "worktrees"
self.mirrors_root.mkdir(parents=True, exist_ok=True)
self.worktrees_root.mkdir(parents=True, exist_ok=True)
self._lock_guard = Lock()
self._repository_locks: dict[str, RLock] = {}
@staticmethod
def _origin(value: str) -> str | None:
if not value:
return None
try:
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except ValueError:
return None
return f"{parsed.scheme.casefold()}://{parsed.hostname.casefold()}:{port}"
def _repository_lock(self, repository_id: str) -> RLock:
with self._lock_guard:
return self._repository_locks.setdefault(repository_id, RLock())
def _git_env(self) -> dict[str, str]:
env = os.environ.copy()
if self.token:
env.update(
{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "http.extraHeader",
"GIT_CONFIG_VALUE_0": f"Authorization: token {self.token}",
}
)
return env
def _run(self, args: list[str]) -> str:
try:
result = subprocess.run(
args,
check=True,
capture_output=True,
text=True,
errors="replace",
timeout=self.timeout,
env=self._git_env(),
)
except subprocess.TimeoutExpired as exc:
raise AcquisitionError("timeout", "Git source acquisition timed out") from exc
except (OSError, subprocess.CalledProcessError) as exc:
# Git stderr may contain remote URLs or server-specific details; do
# not persist or expose it because the job API is user-visible.
raise AcquisitionError("git_failed", "Git source acquisition failed") from exc
return result.stdout.strip()
def _mirror_path(self, repository: Repository) -> Path:
identity = f"{repository.provider}-{repository.provider_repository_id}"
safe = re.sub(r"[^A-Za-z0-9._-]", "_", identity)
return self.mirrors_root / f"{safe}.git"
def _worktree_path(self, job_id: str) -> Path:
if not SAFE_JOB_ID.fullmatch(job_id):
raise AcquisitionError("invalid_job", "Scan job identity is invalid")
return self.worktrees_root / job_id
@staticmethod
def _validate_branch(branch: str) -> None:
if (
not SAFE_BRANCH.fullmatch(branch)
or branch.startswith("-")
or ".." in branch
or branch.endswith(("/", "."))
):
raise AcquisitionError("invalid_branch", "Repository default branch is invalid")
def _validate_clone_url(self, clone_url: str | None) -> str:
if not clone_url or "\x00" in clone_url:
raise AcquisitionError("missing_clone_url", "Repository clone URL is unavailable")
try:
parsed = urlsplit(clone_url)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise AcquisitionError("unsafe_clone_url", "Repository clone URL contains unsafe credentials or metadata")
if parsed.scheme in {"http", "https"}:
origin = self._origin(clone_url)
if self.token and (self.allowed_clone_origin is None or origin != self.allowed_clone_origin):
raise AcquisitionError("unsafe_clone_url", "Repository clone URL is outside the configured Gitea origin")
elif parsed.scheme != "file":
raise AcquisitionError("unsafe_clone_url", "Repository clone URL uses an unsupported scheme")
except ValueError as exc:
raise AcquisitionError("unsafe_clone_url", "Repository clone URL is invalid") from exc
return clone_url
def acquire(
self,
repository: Repository,
job_id: str,
requested_revision: str | None = None,
) -> AcquiredSource:
with self._repository_lock(repository.id):
return self._acquire(repository, job_id, requested_revision)
def _acquire(
self,
repository: Repository,
job_id: str,
requested_revision: str | None = None,
) -> AcquiredSource:
branch = repository.default_branch or "main"
self._validate_branch(branch)
if requested_revision is not None and not SAFE_REVISION.fullmatch(requested_revision):
raise AcquisitionError("invalid_revision", "Requested revision is not a valid commit identifier")
clone_url = self._validate_clone_url(repository.clone_url)
mirror = self._mirror_path(repository)
if mirror.exists() and not self._mirror_is_usable(mirror):
self._remove_mirror_if_owned(mirror)
if not mirror.exists():
self._run(["git", "clone", "--mirror", clone_url, str(mirror)])
else:
self._run(["git", "--git-dir", str(mirror), "remote", "set-url", "origin", clone_url])
self._run(
[
"git",
"--git-dir",
str(mirror),
"fetch",
"--prune",
"--no-tags",
"origin",
f"+refs/heads/{branch}:refs/remotes/origin/{branch}",
]
)
if requested_revision:
try:
actual_revision = self._run(
["git", "--git-dir", str(mirror), "rev-parse", f"{requested_revision}^{{commit}}"]
)
except AcquisitionError as exc:
raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror") from exc
if actual_revision.casefold() != requested_revision.casefold() and len(requested_revision) == 40:
raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror")
else:
actual_revision = self._run(
[
"git",
"--git-dir",
str(mirror),
"rev-parse",
f"refs/remotes/origin/{branch}^{{commit}}",
]
)
if not SAFE_REVISION.fullmatch(actual_revision):
raise AcquisitionError("invalid_revision", "Git did not return a usable commit identifier")
worktree = self._worktree_path(job_id)
self._remove_worktree_if_owned(mirror, worktree)
try:
self._run(
[
"git",
"--git-dir",
str(mirror),
"worktree",
"add",
"--detach",
str(worktree),
actual_revision,
]
)
except AcquisitionError:
self._remove_worktree_if_owned(mirror, worktree)
raise
return AcquiredSource(path=worktree, actual_revision=actual_revision, branch=branch)
def cleanup(self, source: AcquiredSource, repository: Repository) -> None:
with self._repository_lock(repository.id):
mirror = self._mirror_path(repository)
self._remove_worktree_if_owned(mirror, source.path)
def _mirror_is_usable(self, mirror: Path) -> bool:
try:
return self._run(["git", "--git-dir", str(mirror), "rev-parse", "--is-bare-repository"]) == "true"
except AcquisitionError:
return False
def _remove_mirror_if_owned(self, mirror: Path) -> None:
try:
relative = mirror.resolve().relative_to(self.mirrors_root.resolve())
except (OSError, RuntimeError, ValueError):
raise AcquisitionError("unsafe_mirror", "Repository mirror path escaped the repository cache")
if not relative.parts:
raise AcquisitionError("unsafe_mirror", "Repository mirror path is invalid")
if mirror.is_dir() and not mirror.is_symlink():
shutil.rmtree(mirror)
elif mirror.exists() or mirror.is_symlink():
mirror.unlink()
def _remove_worktree_if_owned(self, mirror: Path, worktree: Path) -> None:
try:
relative = worktree.resolve().relative_to(self.worktrees_root.resolve())
except (OSError, RuntimeError, ValueError):
raise AcquisitionError("unsafe_worktree", "Worktree path escaped the repository cache")
if not relative.parts:
raise AcquisitionError("unsafe_worktree", "Worktree path is invalid")
if worktree.exists():
try:
self._run(["git", "--git-dir", str(mirror), "worktree", "remove", "--force", str(worktree)])
except AcquisitionError:
# A stale registration must not prevent a safe bounded cleanup.
pass
if worktree.exists():
shutil.rmtree(worktree)
try:
self._run(["git", "--git-dir", str(mirror), "worktree", "prune"])
except AcquisitionError:
pass
+340
View File
@@ -0,0 +1,340 @@
from __future__ import annotations
import ast
import json
import math
import os
import re
from collections import Counter
from pathlib import Path
from .models import ArchitectureEdge, ArchitectureGraph, ArchitectureNode, Hotspot
JS_IMPORT_RE = re.compile(
r"(?:\bfrom\s+|\bimport\s*|\brequire\(\s*|\bimport\(\s*)['\"](?P<target>[^'\"]+)['\"]"
)
JS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
SOURCE_ROOTS = {"src", "app", "backend", "frontend", "lib", "packages"}
CONFIG_IGNORED_DIRS = {".git", ".venv", "node_modules", "dist", "build", "coverage", "target", "vendor"}
def _bounded_config_files(root: Path, names: set[str], *, maximum: int = 20) -> list[Path]:
files: list[Path] = []
for current, directories, candidates in os.walk(root, followlinks=False):
directories[:] = [name for name in directories if name.casefold() not in CONFIG_IGNORED_DIRS]
for name in candidates:
if name in names:
files.append(Path(current) / name)
if len(files) >= maximum:
return sorted(files)
return sorted(files)
def _ts_path_aliases(root: Path) -> list[tuple[str, str]]:
"""Read a small local subset of tsconfig paths without resolving packages or URLs."""
aliases: list[tuple[str, str]] = []
root_resolved = root.resolve()
for config in _bounded_config_files(root, {"tsconfig.json", "jsconfig.json"}):
try:
if config.stat().st_size > 256_000:
continue
raw = config.read_text(encoding="utf-8")
# Most project tsconfigs are JSON. Comments are deliberately not
# interpreted as configuration to keep this scanner deterministic.
data = json.loads(raw)
options = data.get("compilerOptions", {})
paths = options.get("paths", {}) if isinstance(options, dict) else {}
if not isinstance(paths, dict):
continue
base_url = options.get("baseUrl", ".") if isinstance(options, dict) else "."
base = (config.parent / str(base_url)).resolve()
if base != root_resolved and root_resolved not in base.parents:
continue
for alias, targets in paths.items():
if not isinstance(alias, str) or not isinstance(targets, list):
continue
for target in targets[:5]:
if not isinstance(target, str) or target.startswith(("/", "\\")):
continue
resolved = (base / target.replace("*", "placeholder")).resolve()
if resolved != root_resolved and root_resolved not in resolved.parents:
continue
aliases.append((alias, str((base / target).relative_to(root_resolved)).replace("\\", "/")))
except (OSError, ValueError, json.JSONDecodeError, TypeError):
continue
return aliases[:100]
def _workspace_packages(root: Path) -> dict[str, Path]:
packages: dict[str, Path] = {}
for manifest in _bounded_config_files(root, {"package.json"}, maximum=100):
try:
if manifest.stat().st_size > 256_000:
continue
data = json.loads(manifest.read_text(encoding="utf-8"))
name = data.get("name") if isinstance(data, dict) else None
if isinstance(name, str) and name and not name.startswith((".", "/")):
packages.setdefault(name, manifest.parent)
except (OSError, ValueError, json.JSONDecodeError, TypeError):
continue
return packages
def _module_names(path: str) -> list[str]:
parts = list(Path(path).with_suffix("").parts)
if parts and parts[-1] == "__init__":
parts = parts[:-1]
names: list[str] = []
if parts:
names.append(".".join(parts))
if parts[0] in SOURCE_ROOTS and len(parts) > 1:
names.append(".".join(parts[1:]))
if len(parts) > 2 and parts[1] in SOURCE_ROOTS:
names.append(".".join(parts[2:]))
return [name for name in names if name]
def _python_module_map(paths: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for path in paths:
if not path.endswith(".py"):
continue
for module in _module_names(path):
result.setdefault(module, path)
return result
def _resolve_python_target(module: str, module_map: dict[str, str]) -> str | None:
candidate = module
while candidate:
if candidate in module_map:
return module_map[candidate]
if "." not in candidate:
break
candidate = candidate.rsplit(".", 1)[0]
return None
def _python_imports(path: str, text: str, module_map: dict[str, str]) -> set[str]:
try:
tree = ast.parse(text)
except SyntaxError:
return set()
current_names = _module_names(path)
current = current_names[0] if current_names else ""
current_parts = current.split(".") if current else []
if not path.endswith("/__init__.py") and current_parts:
package_parts = current_parts[:-1]
else:
package_parts = current_parts
imports: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
target = _resolve_python_target(alias.name, module_map)
if target and target != path:
imports.add(target)
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
if node.level:
trim = max(0, node.level - 1)
prefix = package_parts[:-trim] if trim else package_parts
absolute = ".".join([*prefix, *([module] if module else [])])
else:
absolute = module
target = _resolve_python_target(absolute, module_map)
if target and target != path:
imports.add(target)
return imports
def _resolve_js_target(
root: Path,
source_path: str,
specifier: str,
known: set[str],
aliases: list[tuple[str, str]],
workspace_packages: dict[str, Path],
) -> str | None:
if specifier.startswith("."):
source_dir = (root / source_path).parent
base = (source_dir / specifier).resolve()
else:
base = None
for alias, target in aliases:
if alias.endswith("/*") and specifier.startswith(alias[:-1]):
base = (root / target.replace("*", specifier[len(alias) - 1 :])).resolve()
break
if alias == specifier:
base = (root / target).resolve()
break
if base is None:
package_name = next(
(name for name in workspace_packages if specifier == name or specifier.startswith(f"{name}/")),
None,
)
if package_name:
package_root = workspace_packages[package_name]
suffix = specifier.removeprefix(package_name).lstrip("/")
base = package_root / suffix if suffix else package_root / "src" / "index"
if base is None:
return None
try:
relative_base = base.relative_to(root.resolve()).as_posix()
except ValueError:
return None
candidates = [relative_base]
suffix = Path(relative_base).suffix.lower()
if suffix in JS_EXTENSIONS:
extensionless = relative_base[: -len(suffix)]
candidates.extend(extensionless + extension for extension in JS_EXTENSIONS)
candidates.extend(f"{extensionless}/index{extension}" for extension in JS_EXTENSIONS)
elif not suffix:
candidates.extend(relative_base + extension for extension in JS_EXTENSIONS)
candidates.extend(f"{relative_base}/index{extension}" for extension in JS_EXTENSIONS)
return next((candidate for candidate in candidates if candidate in known), None)
def _js_imports(
root: Path,
path: str,
text: str,
known: set[str],
aliases: list[tuple[str, str]],
workspace_packages: dict[str, Path],
) -> set[str]:
imports: set[str] = set()
for match in JS_IMPORT_RE.finditer(text):
target = _resolve_js_target(root, path, match.group("target"), known, aliases, workspace_packages)
if target and target != path:
imports.add(target)
return imports
def build_architecture(
root: Path,
source_texts: dict[str, str],
language_by_path: dict[str, str],
loc_by_path: dict[str, int],
debt_by_path: dict[str, int],
churn_by_path: dict[str, int],
*,
max_nodes: int = 160,
max_edges: int = 600,
) -> tuple[ArchitectureGraph, list[Hotspot]]:
max_nodes = max(1, max_nodes)
max_edges = max(0, max_edges)
known_paths = set(source_texts)
module_map = _python_module_map(list(known_paths))
ts_aliases = _ts_path_aliases(root)
workspace_packages = _workspace_packages(root)
edge_pairs: set[tuple[str, str]] = set()
total_inbound: Counter[str] = Counter()
total_outbound: Counter[str] = Counter()
total_edges = 0
truncated = len(known_paths) > max_nodes
for path in sorted(source_texts):
text = source_texts[path]
language = language_by_path.get(path, "")
if language == "Python":
targets = _python_imports(path, text, module_map)
elif language in {"TypeScript", "JavaScript"}:
targets = _js_imports(root, path, text, known_paths, ts_aliases, workspace_packages)
else:
targets = set()
for target in sorted(targets):
if target not in known_paths:
continue
pair = (path, target)
total_edges += 1
total_outbound[path] += 1
total_inbound[target] += 1
if len(edge_pairs) < max_edges:
edge_pairs.add(pair)
else:
truncated = True
hotspots: list[Hotspot] = []
for path in known_paths:
loc = loc_by_path.get(path, 0)
debt = debt_by_path.get(path, 0)
churn = churn_by_path.get(path, 0)
coupling = total_inbound[path] + total_outbound[path]
score = min(
100,
round(
min(30, loc / 40)
+ min(20, debt * 4)
+ min(30, math.log10(churn + 1) * 10 if churn else 0)
+ min(20, coupling * 2.5)
),
)
reasons: list[str] = []
if loc >= 500:
reasons.append(f"large module ({loc} LOC)")
if debt:
reasons.append(f"{debt} debt markers")
if churn >= 300:
reasons.append(f"high recent churn ({churn} changed lines)")
if coupling >= 8:
reasons.append(f"high coupling ({coupling} local edges)")
if not reasons and score >= 30:
reasons.append("combined size, churn and coupling")
hotspots.append(
Hotspot(
path=path,
language=language_by_path.get(path, "Other"),
lines_of_code=loc,
debt_markers=debt,
churn=churn,
inbound=total_inbound[path],
outbound=total_outbound[path],
score=score,
reasons=reasons,
)
)
hotspots.sort(key=lambda item: (-item.score, -item.churn, -item.lines_of_code, item.path))
selected_paths = {item.path for item in hotspots[:max_nodes]}
graph_edges = [
ArchitectureEdge(source=source, target=target)
for source, target in sorted(edge_pairs)
if source in selected_paths and target in selected_paths
][:max_edges]
visible_inbound: Counter[str] = Counter()
visible_outbound: Counter[str] = Counter()
for edge in graph_edges:
visible_outbound[edge.source] += 1
visible_inbound[edge.target] += 1
hotspot_scores = {item.path: item.score for item in hotspots}
nodes = [
ArchitectureNode(
id=path,
label=Path(path).name,
path=path,
language=language_by_path.get(path, "Other"),
inbound=total_inbound[path],
outbound=total_outbound[path],
visible_inbound=visible_inbound[path],
visible_outbound=visible_outbound[path],
hotspot_score=hotspot_scores[path],
)
for path in sorted(selected_paths)
]
clusters = Counter((Path(path).parts[0] if len(Path(path).parts) > 1 else "root") for path in selected_paths)
return (
ArchitectureGraph(
nodes=nodes,
edges=graph_edges,
clusters=dict(clusters.most_common()),
truncated=truncated,
total_nodes=len(known_paths),
total_edges=total_edges,
),
hotspots[:30],
)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import subprocess
from collections import Counter
from datetime import datetime
from pathlib import Path
from .models import GitSignals
def _run(root: Path, *args: str, timeout: int = 8) -> str | None:
try:
result = subprocess.run(
["git", "-C", str(root), *args],
check=True,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
)
except (OSError, UnicodeError, subprocess.SubprocessError):
return None
return result.stdout
def analyze_git(root: Path) -> tuple[GitSignals, dict[str, int]]:
top_level = _run(root, "rev-parse", "--show-toplevel")
if not top_level:
return GitSignals(), {}
try:
if Path(top_level.strip()).resolve() != root.resolve():
return GitSignals(), {}
except (OSError, RuntimeError, ValueError):
return GitSignals(), {}
inside = _run(root, "rev-parse", "--is-inside-work-tree")
if not inside or inside.strip() != "true":
return GitSignals(), {}
raw_count = _run(root, "rev-list", "--count", "HEAD")
raw_latest = _run(root, "log", "-1", "--format=%aI")
raw_authors = _run(root, "log", "--format=%aN%x00%aE", "-n", "2000")
raw_files = _run(root, "ls-files", "-z")
raw_churn = _run(root, "log", "--numstat", "--format=", "--find-renames=20%", "-n", "200")
raw_renames = _run(root, "log", "--name-status", "--format=", "--find-renames=20%", "-n", "200")
contributors: set[str] = set()
if raw_authors:
for line in raw_authors.splitlines():
if line.strip():
contributors.add(line.strip())
churn: Counter[str] = Counter()
renamed_files: set[str] = set()
rename_map: dict[str, str] = {}
if raw_renames:
for line in raw_renames.splitlines():
parts = line.split("\t")
if len(parts) >= 3 and parts[0].startswith("R"):
old_path, new_path = parts[-2:]
rename_map[old_path] = new_path
renamed_files.add(new_path)
if raw_churn:
for line in raw_churn.splitlines():
parts = line.split("\t", 2)
if len(parts) != 3:
continue
additions, deletions, path = parts
churn_path = _rename_target(path) if " => " in path else rename_map.get(path, path)
if " => " in path:
renamed_files.add(churn_path)
if additions.isdigit():
churn[churn_path] += int(additions)
if deletions.isdigit():
churn[churn_path] += int(deletions)
latest_commit_at = None
if raw_latest and raw_latest.strip():
try:
latest_commit_at = datetime.fromisoformat(raw_latest.strip())
except ValueError:
latest_commit_at = None
return (
GitSignals(
available=True,
commit_count=int(raw_count.strip()) if raw_count and raw_count.strip().isdigit() else None,
contributor_count=len(contributors) if contributors else None,
latest_commit_at=latest_commit_at,
tracked_files=len([item for item in raw_files.split("\0") if item]) if raw_files is not None else None,
renamed_files=sorted(renamed_files),
),
dict(churn),
)
def _rename_target(path: str) -> str:
"""Normalize Git's brace-formatted rename path to the new path."""
if "{" in path and "}" in path:
prefix, remainder = path.split("{", 1)
inner, suffix = remainder.split("}", 1)
if " => " in inner:
return f"{prefix}{inner.split(' => ', 1)[1]}{suffix}"
return path.rsplit(" => ", 1)[-1]
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from typing import Any
from .models import Repository, RepositoryPolicy, RepositorySyncResult
JsonTransport = Callable[[str, dict[str, str]], tuple[int, bytes]]
class GiteaError(RuntimeError):
def __init__(self, code: str, detail: str, *, status_code: int | None = None) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
self.status_code = status_code
def _parse_time(value: object) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
class GiteaClient:
"""Small, read-only Gitea API client with bounded pagination and safe errors."""
def __init__(
self,
base_url: str | None = None,
token: str | None = None,
*,
timeout: float = 10.0,
page_size: int = 50,
max_pages: int = 20,
max_repositories: int = 500,
max_branch_workers: int | None = None,
transport: JsonTransport | None = None,
) -> None:
self.base_url = (base_url or os.getenv("REPOPULSE_GITEA_URL", "")).rstrip("/")
self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "")
self.timeout = max(1.0, timeout)
self.page_size = max(1, min(page_size, 100))
self.max_pages = max(1, max_pages)
self.max_repositories = max(1, max_repositories)
if max_branch_workers is None:
try:
configured_workers = int(os.getenv("REPOPULSE_GITEA_BRANCH_WORKERS", "4"))
except ValueError:
configured_workers = 4
else:
configured_workers = max_branch_workers
self.max_branch_workers = min(8, max(1, configured_workers))
self.transport = transport
self._listing_complete = True
@property
def configured(self) -> bool:
return bool(self.base_url and self.token)
def _request_json(self, path: str, params: dict[str, str | int] | None = None) -> Any:
if not self.base_url:
raise GiteaError("not_configured", "Gitea URL is not configured")
query = urllib.parse.urlencode(params or {})
url = f"{self.base_url}/api/v1/{path.lstrip('/')}"
if query:
url = f"{url}?{query}"
headers = {
"Accept": "application/json",
"User-Agent": "RepoPulse/1.5",
}
if self.token:
headers["Authorization"] = f"token {self.token}"
try:
if self.transport:
status_code, raw = self.transport(url, headers)
if status_code >= 400:
self._raise_for_status(status_code)
else:
request = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(request, timeout=self.timeout) as response:
status_code = int(response.status)
raw = response.read(2_000_001)
if status_code >= 400:
self._raise_for_status(status_code)
except GiteaError:
raise
except urllib.error.HTTPError as exc:
self._raise_for_status(exc.code)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise GiteaError("unreachable", "Gitea could not be reached") from exc
if not isinstance(raw, bytes):
raise GiteaError("invalid_response", "Gitea returned an invalid response body")
if len(raw) > 2_000_000:
raise GiteaError("response_too_large", "Gitea response exceeded the configured bound")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GiteaError("invalid_response", "Gitea returned invalid JSON") from exc
@staticmethod
def _raise_for_status(status_code: int) -> None:
if status_code in {401, 403}:
raise GiteaError("authentication", "Gitea authentication was rejected", status_code=status_code)
if status_code == 404:
raise GiteaError("not_found", "The requested Gitea resource was not found", status_code=status_code)
if status_code == 429:
raise GiteaError("rate_limited", "Gitea rate-limited the request", status_code=status_code)
if status_code >= 500:
raise GiteaError("upstream", "Gitea returned a server error", status_code=status_code)
raise GiteaError("request_failed", "Gitea rejected the request", status_code=status_code)
def _repository_from_payload(self, payload: object) -> Repository:
if not isinstance(payload, dict):
raise GiteaError("invalid_repository", "Gitea returned an invalid repository record")
raw_id = payload.get("id")
if isinstance(raw_id, bool) or not isinstance(raw_id, (int, str)) or not str(raw_id).isdigit():
raise GiteaError("invalid_repository", "Gitea repository identity is invalid")
owner_payload = payload.get("owner")
owner = owner_payload.get("login") if isinstance(owner_payload, dict) else None
owner = owner or payload.get("owner_name")
name = payload.get("name")
full_name = payload.get("full_name")
if not isinstance(owner, str) or not isinstance(name, str):
if isinstance(full_name, str) and "/" in full_name:
owner, name = full_name.split("/", 1)
else:
raise GiteaError("invalid_repository", "Gitea repository name is invalid")
full_name = full_name if isinstance(full_name, str) else f"{owner}/{name}"
repository_id = str(raw_id)
# Do not trust a provider-supplied clone host when constructing the
# credential-bearing acquisition URL. The configured Gitea origin is
# the only network destination that may receive the server token.
clone_url = (
f"{self.base_url}/{urllib.parse.quote(owner, safe='')}/"
f"{urllib.parse.quote(name, safe='')}.git"
)
html_url = payload.get("html_url")
default_branch = payload.get("default_branch") or "main"
return Repository(
id=f"gitea:{repository_id}",
provider="gitea",
provider_repository_id=repository_id,
owner=owner,
name=name,
full_name=full_name,
description=payload.get("description") if isinstance(payload.get("description"), str) else None,
html_url=html_url if isinstance(html_url, str) else None,
clone_url=clone_url if isinstance(clone_url, str) else None,
default_branch=str(default_branch),
private=bool(payload.get("private", False)),
archived=bool(payload.get("archived", False)),
latest_revision_at=_parse_time(payload.get("updated_at")),
policy=RepositoryPolicy(),
)
def list_repositories(self) -> list[Repository]:
if not self.configured:
raise GiteaError("not_configured", "Gitea connector is not configured")
repositories: list[Repository] = []
self._listing_complete = False
for page in range(1, self.max_pages + 1):
payload = self._request_json(
"user/repos",
{"limit": self.page_size, "page": page, "sort": "updated", "direction": "desc"},
)
if not isinstance(payload, list):
raise GiteaError("invalid_response", "Gitea repository listing was not a list")
for item in payload:
if len(repositories) >= self.max_repositories:
return repositories
repositories.append(self._repository_from_payload(item))
if len(payload) < self.page_size:
self._listing_complete = True
break
return repositories
def branch_revision(self, repository: Repository) -> str:
owner = urllib.parse.quote(repository.owner, safe="")
name = urllib.parse.quote(repository.name, safe="")
branch = urllib.parse.quote(repository.default_branch, safe="")
payload = self._request_json(f"repos/{owner}/{name}/branches/{branch}")
if not isinstance(payload, dict):
raise GiteaError("invalid_response", "Gitea branch response was invalid")
commit = payload.get("commit")
revision = None
if isinstance(commit, dict):
revision = commit.get("id") or commit.get("sha")
if (
not isinstance(revision, str)
or not 7 <= len(revision) <= 64
or not all(char in "0123456789abcdefABCDEF" for char in revision)
):
raise GiteaError("invalid_revision", "Gitea did not return a usable branch revision")
return revision
def discover_with_errors(self) -> tuple[list[Repository], list[tuple[Repository, str]], list[str]]:
"""Resolve branch tips independently so one repository cannot abort a catalog sync."""
repositories = self.list_repositories()
def resolve(repository: Repository) -> tuple[Repository, str | None, str | None]:
try:
return repository, self.branch_revision(repository), None
except GiteaError as exc:
return repository, None, f"{repository.full_name}: {exc.code}: {exc.detail}"
with ThreadPoolExecutor(max_workers=min(self.max_branch_workers, len(repositories) or 1)) as executor:
outcomes = list(executor.map(resolve, repositories))
discovered = [
(repository.model_copy(update={"latest_revision": revision}), revision)
for repository, revision, error in outcomes
if revision is not None and error is None
]
errors = [error for _, _, error in outcomes if error]
return repositories, discovered, errors
def discover(self) -> list[tuple[Repository, str]]:
"""Compatibility helper for callers needing only successfully resolved repositories."""
_, discovered, _ = self.discover_with_errors()
return discovered
def sync(self, store) -> RepositorySyncResult:
synced_at = datetime.now(UTC)
try:
listed, discovered, branch_errors = self.discover_with_errors()
except GiteaError as exc:
error = f"{exc.code}: {exc.detail}"
for existing in store.list_repositories(limit=1000):
if existing.provider == "gitea" and existing.available:
store.update_repository_sync(existing, synced_at=synced_at, error=error)
result = RepositorySyncResult(
provider="gitea",
errors=[error],
synced_at=synced_at,
)
store.record_sync_result(result)
return result
created = 0
updated = 0
listed_ids = {repository.id for repository in listed}
seen_ids: set[str] = set()
for repository, revision in discovered:
seen_ids.add(repository.id)
before = store.get_repository(repository.id)
merged = repository.model_copy(
update={
"available": True,
"last_synced_at": synced_at,
"sync_error": None,
"latest_revision": revision,
"policy": before.policy if before else RepositoryPolicy(),
}
)
store.upsert_repository(merged)
if before is None:
created += 1
else:
updated += 1
for repository in listed:
if repository.id in seen_ids:
continue
before = store.get_repository(repository.id)
error = next((item for item in branch_errors if item.startswith(f"{repository.full_name}:")), None)
merged = repository.model_copy(
update={
"available": True,
"last_synced_at": synced_at,
"sync_error": error or "branch: revision could not be resolved",
"latest_revision": before.latest_revision if before else None,
"policy": before.policy if before else RepositoryPolicy(),
}
)
store.upsert_repository(merged)
if before is None:
created += 1
else:
updated += 1
unavailable = 0
if self._listing_complete:
for existing in store.list_repositories(limit=1000):
if existing.provider == "gitea" and existing.id not in listed_ids and existing.available:
store.update_repository_sync(
existing.model_copy(update={"available": False}),
synced_at=synced_at,
error="not_discovered",
)
unavailable += 1
result = RepositorySyncResult(
provider="gitea",
discovered=len(discovered),
created=created,
updated=updated,
unavailable=unavailable,
errors=branch_errors,
partial=bool(branch_errors) or not self._listing_complete,
branch_failures=len(branch_errors),
listing_complete=self._listing_complete,
synced_at=synced_at,
)
store.record_sync_result(result)
return result
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import logging
import os
from datetime import UTC, datetime
from threading import Event, Lock, Thread, current_thread
from time import monotonic
from .acquisition import AcquisitionError, GitAcquirer
from .gitea import GiteaClient
from .models import (
Repository,
RepositorySyncResult,
ScanJob,
ScanJobStatus,
ScanSource,
ScanTrigger,
SourceOrigin,
)
from .scanner import scan_repository
from .store import MaintenanceModeError, QueueFullError, ScanStore, job_lease_seconds
LOGGER = logging.getLogger(__name__)
def _positive_int(name: str, default: int, *, maximum: int | None = None) -> int:
try:
value = max(1, int(os.getenv(name, str(default))))
except ValueError:
value = default
return min(value, maximum) if maximum else value
def _positive_float(name: str, default: float) -> float:
try:
return max(0.2, float(os.getenv(name, str(default))))
except ValueError:
return default
class ScanJobRunner:
"""Single-process durable worker and bounded Gitea scheduler."""
def __init__(
self,
store: ScanStore,
*,
gitea: GiteaClient | None = None,
acquirer: GitAcquirer | None = None,
poll_seconds: float = 0.5,
sync_interval_seconds: float | None = None,
max_queue: int | None = None,
) -> None:
self.store = store
self.gitea = gitea or GiteaClient()
self.acquirer = acquirer or GitAcquirer()
self.poll_seconds = max(0.2, poll_seconds)
self.sync_interval_seconds = sync_interval_seconds or _positive_float(
"REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS", 300.0
)
self.max_queue = max_queue or _positive_int("REPOPULSE_MAX_QUEUED_SCANS", 100, maximum=1000)
self._stop = Event()
self._thread: Thread | None = None
self._last_sync = float("-inf")
self._sync_lock = Lock()
@property
def started(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
if self.started:
return
self._stop.clear()
self._thread = Thread(target=self._run, name="repopulse-scan-worker", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
thread = self._thread
if thread and thread is not current_thread():
thread.join(timeout=5)
if thread is None or not thread.is_alive():
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
try:
job = self._run_iteration()
except MaintenanceModeError:
job = None
except Exception:
self._last_sync = monotonic()
LOGGER.exception("RepoPulse worker loop recovered from an unexpected failure")
job = None
if job is None:
self._stop.wait(self.poll_seconds)
def _run_iteration(self) -> ScanJob | None:
"""Perform one scheduler pass unless durable maintenance has paused work."""
if self.store.maintenance_status()["enabled"]:
return None
if not self.store.touch_worker_heartbeat_if_active(datetime.now(UTC).isoformat()):
return None
if self.gitea.configured and monotonic() - self._last_sync >= self.sync_interval_seconds:
self.sync_now()
if self.store.maintenance_status()["enabled"]:
return None
return self.process_one()
def sync_now(self) -> RepositorySyncResult:
with self._sync_lock:
if self.store.maintenance_status()["enabled"]:
raise MaintenanceModeError("RepoPulse is in maintenance mode")
if not self.gitea.configured:
result = RepositorySyncResult(
provider="gitea",
errors=["not_configured: Gitea connector is not configured"],
synced_at=datetime.now(UTC),
)
self.store.record_sync_result(result)
self._last_sync = monotonic()
return result
try:
result = self.gitea.sync(self.store)
except Exception:
self._last_sync = monotonic()
raise
self._last_sync = monotonic()
if not result.errors:
self._schedule_changed_repositories()
return result
def _schedule_changed_repositories(self) -> None:
if self.store.maintenance_status()["enabled"]:
return
for summary in self.store.list_repositories(limit=1000):
if summary.provider != "gitea" or summary.archived or not summary.latest_revision:
continue
if not summary.policy.auto_scan:
continue
if summary.last_scan_revision and summary.last_scan_revision == summary.latest_revision:
continue
try:
self.store.enqueue_job(
summary.id,
summary.latest_revision,
ScanTrigger.SCHEDULED,
max_queue=self.max_queue,
)
except (MaintenanceModeError, QueueFullError, KeyError):
continue
def set_maintenance_mode(self, enabled: bool) -> dict[str, object]:
"""Serialize maintenance changes with catalog reconciliation and scan scheduling."""
with self._sync_lock:
return self.store.set_maintenance_mode(enabled)
def enqueue(
self,
repository: Repository,
*,
force: bool = False,
trigger: ScanTrigger = ScanTrigger.MANUAL,
) -> ScanJob:
requested_revision = repository.latest_revision
return self.store.enqueue_job(
repository.id,
requested_revision,
trigger,
force=force,
max_queue=self.max_queue,
)
def process_one(self) -> ScanJob | None:
job = self.store.claim_next_job()
if job is None:
return None
try:
self._process(job)
except Exception:
LOGGER.exception("RepoPulse scan job failed outside the isolated job handler")
try:
self.store.update_job(
job.id,
status=ScanJobStatus.FAILED,
phase="failed",
progress=100,
error_code="internal_error",
error_detail="Unexpected repository analysis failure",
message="Repository analysis failed",
)
except Exception:
LOGGER.exception("RepoPulse could not persist an unexpected job failure")
return self.store.get_job(job.id)
class _LeaseHeartbeat:
def __init__(self, store: ScanStore, job_id: str) -> None:
self.store = store
self.job_id = job_id
self.stop_event = Event()
self.thread = Thread(target=self._run, name=f"repopulse-lease-{job_id[:8]}", daemon=True)
def start(self) -> None:
self.thread.start()
def stop(self) -> None:
self.stop_event.set()
self.thread.join(timeout=2)
def _run(self) -> None:
interval = max(1.0, job_lease_seconds() / 3)
while not self.stop_event.wait(interval):
try:
self.store.renew_job_lease(self.job_id)
except Exception:
LOGGER.warning("RepoPulse could not renew a scan-job lease", exc_info=True)
def _process(self, job: ScanJob) -> None:
repository = self.store.get_repository(job.repository_id)
if repository is None:
self.store.update_job(
job.id,
status=ScanJobStatus.FAILED,
phase="failed",
progress=100,
error_code="repository_not_found",
error_detail="Repository catalog entry no longer exists",
)
return
source_path = None
acquired = None
lease_heartbeat = self._LeaseHeartbeat(self.store, job.id)
lease_heartbeat.start()
try:
if repository.provider != "gitea":
source_path = self.store.get_repository_source_path(repository.id)
if not source_path:
raise AcquisitionError("source_unavailable", "Local repository source is unavailable")
actual_revision = job.requested_revision
branch = repository.default_branch
else:
acquired = self.acquirer.acquire(repository, job.id, job.requested_revision)
source_path = str(acquired.path)
actual_revision = acquired.actual_revision
branch = acquired.branch
self.store.update_job(
job.id,
status=ScanJobStatus.SCANNING,
phase="scanning",
progress=30,
actual_revision=actual_revision,
message="Running deterministic repository analysis",
)
source = ScanSource(
origin=SourceOrigin.GITEA if repository.provider == "gitea" else SourceOrigin.LOCAL,
provider=repository.provider,
repository_id=repository.id,
repository_full_name=repository.full_name,
branch=branch,
requested_revision=job.requested_revision,
actual_revision=actual_revision,
trigger=job.trigger,
)
report = scan_repository(source_path, source=source, display_name=repository.name)
self.store.update_job(
job.id,
status=ScanJobStatus.ANALYZING,
phase="analyzing",
progress=80,
message="Persisting report and comparison baseline",
)
self.store.update_job(
job.id,
status=ScanJobStatus.PERSISTING,
phase="persisting",
progress=90,
message="Saving report and revision history",
)
saved = self.store.save(report)
self.store.upsert_repository(repository.model_copy(update={"policy": saved.policy}))
self.store.update_job(
job.id,
status=ScanJobStatus.COMPLETED,
phase="completed",
progress=100,
actual_revision=saved.source.actual_revision,
scan_id=saved.id,
message="Scan completed",
)
except AcquisitionError as exc:
self.store.update_job(
job.id,
status=ScanJobStatus.FAILED,
phase="failed",
progress=100,
error_code=exc.code,
error_detail=exc.detail,
message="Repository source acquisition failed",
)
except (ValueError, PermissionError, OSError) as exc:
self.store.update_job(
job.id,
status=ScanJobStatus.FAILED,
phase="failed",
progress=100,
error_code="scan_failed",
error_detail=str(exc) if isinstance(exc, ValueError) else "Repository analysis failed",
message="Repository analysis failed",
)
except Exception:
LOGGER.exception("RepoPulse scan job failed without exposing job details")
self.store.update_job(
job.id,
status=ScanJobStatus.FAILED,
phase="failed",
progress=100,
error_code="internal_error",
error_detail="Unexpected repository analysis failure",
message="Repository analysis failed",
)
finally:
lease_heartbeat.stop()
if acquired is not None and repository is not None:
try:
self.acquirer.cleanup(acquired, repository)
except AcquisitionError:
LOGGER.warning("Unable to clean one RepoPulse worktree")
+491
View File
@@ -0,0 +1,491 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from threading import BoundedSemaphore, Lock
from fastapi import FastAPI, HTTPException, Query, Request, Response, status
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from .acquisition import GitAcquirer
from .gitea import GiteaClient
from .jobs import ScanJobRunner
from .models import (
RepositoryDetail,
RepositoryScanRequest,
RepositorySummary,
RepositorySyncResult,
ScanComparison,
ScanJob,
ScanReport,
ScanRequest,
ScanSummary,
ScanTrigger,
TrendPoint,
WebhookResponse,
)
from .reporting import content_disposition, render_html_report, safe_report_filename
from .scanner import scan_repository
from .store import MaintenanceModeError, QueueFullError, ScanStore
VERSION = "1.5.0"
store = ScanStore()
SCAN_ROOT = os.getenv("REPOPULSE_SCAN_ROOT")
def _positive_int_env(name: str, default: int) -> int:
try:
return max(1, int(os.getenv(name, str(default))))
except ValueError:
return default
# The durable queue has one real worker. Keep direct legacy path scans at the
# same capacity rather than advertising concurrency that cannot be delivered.
REQUESTED_MAX_CONCURRENT_SCANS = _positive_int_env("REPOPULSE_MAX_CONCURRENT_SCANS", 1)
MAX_CONCURRENT_SCANS = 1
SCAN_SLOTS = BoundedSemaphore(MAX_CONCURRENT_SCANS)
gitea_client = GiteaClient()
repository_acquirer = GitAcquirer()
job_runner: ScanJobRunner | None = None
_api_mutation_lock = Lock()
_MAINTENANCE_ENTER_PATH = "/api/v1/operator/maintenance/enter"
_MAINTENANCE_EXIT_PATH = "/api/v1/operator/maintenance/exit"
def _get_runner() -> ScanJobRunner:
global job_runner
if job_runner is None or job_runner.store is not store:
job_runner = ScanJobRunner(
store,
gitea=gitea_client,
acquirer=repository_acquirer,
)
return job_runner
@asynccontextmanager
async def lifespan(_app: FastAPI):
runner = _get_runner()
runner.start()
try:
yield
finally:
runner.stop()
app = FastAPI(title="RepoPulse API", version=VERSION, lifespan=lifespan)
@app.middleware("http")
async def maintenance_mutation_gate(request: Request, call_next):
"""Serialize external API writes with maintenance transitions and reject writes while paused."""
if not request.url.path.startswith("/api/v1/") or request.method in {"GET", "HEAD", "OPTIONS"}:
return await call_next(request)
if request.url.path in {_MAINTENANCE_ENTER_PATH, _MAINTENANCE_EXIT_PATH}:
return await call_next(request)
await run_in_threadpool(_api_mutation_lock.acquire)
try:
if store.maintenance_status()["enabled"]:
return JSONResponse(
status_code=status.HTTP_423_LOCKED,
content={"detail": "RepoPulse is in maintenance mode"},
headers={"Retry-After": "30"},
)
return await call_next(request)
finally:
_api_mutation_lock.release()
def _maintenance_snapshot() -> dict[str, object]:
mode = store.maintenance_status()
stats = store.queue_stats()
queued = int(stats["queued"])
active = int(stats["active"])
return {
**mode,
"queue_depth": queued,
"active_jobs": active,
"active_drained": active == 0,
"drained": queued == 0 and active == 0,
}
def _require_mutation_authorization(request: Request, *, strict: bool = False) -> None:
"""Use an operator token when configured; deployment perimeter remains explicit otherwise."""
token = os.getenv("REPOPULSE_OPERATOR_TOKEN", "")
if strict:
token = os.getenv("REPOPULSE_MAINTENANCE_TOKEN", "") or token
if not token:
if strict:
raise HTTPException(status_code=503, detail="Operator token is not configured")
return
supplied = request.headers.get("x-repopulse-token", "")
if not hmac.compare_digest(supplied, token):
raise HTTPException(status_code=401, detail="Operator authorization is required")
def _resolve_scan_path(raw_path: str) -> Path:
supplied = Path(raw_path).expanduser()
if SCAN_ROOT:
root = Path(SCAN_ROOT).expanduser().resolve()
candidate = (root / supplied).resolve() if not supplied.is_absolute() else supplied.resolve()
if candidate != root and root not in candidate.parents:
raise ValueError(f"Path must be inside configured scan root {root}")
return candidate
return supplied.resolve()
@app.get("/health")
def health() -> dict[str, str]:
recovery = store.database_recovery_status()
status = "degraded" if recovery.get("status") == "degraded_recovered" else "ok"
return {"status": status}
@app.get("/api/v1/meta")
def meta() -> dict[str, object]:
stats = store.queue_stats()
repository_states = store.repository_state_counts()
last_sync = store.get_state("last_gitea_sync")
parsed_sync: object = None
if last_sync:
try:
parsed_sync = json.loads(last_sync)
except json.JSONDecodeError:
parsed_sync = last_sync
return {
"name": "RepoPulse",
"version": VERSION,
"stage": "v1.5.0",
"scan_root_restricted": bool(SCAN_ROOT),
"scan_root": SCAN_ROOT,
"max_concurrent_scans": MAX_CONCURRENT_SCANS,
"requested_max_concurrent_scans": REQUESTED_MAX_CONCURRENT_SCANS,
"repository_count": store.repository_count(),
"repository_states": repository_states,
"current_repositories": repository_states.get("current", 0),
"stale_repositories": repository_states.get("stale", 0),
"queue_depth": stats["queued"],
"active_jobs": stats["active"],
"completed_jobs": stats["completed"],
"failed_jobs": stats["failed"],
"worker_heartbeat": stats["worker_heartbeat"],
"gitea": {
"configured": gitea_client.configured,
"url": gitea_client.base_url or None,
"last_sync": parsed_sync,
},
"storage": store.storage_stats(),
"database_recovery": store.database_recovery_status(),
"maintenance": _maintenance_snapshot(),
"mutation_authentication": "operator_token" if os.getenv("REPOPULSE_OPERATOR_TOKEN") else "perimeter_required",
}
@app.get("/api/v1/operator/maintenance")
def get_maintenance_status(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
return _maintenance_snapshot()
@app.post("/api/v1/operator/maintenance/enter")
def enter_maintenance(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
with _api_mutation_lock:
if store.maintenance_status()["enabled"]:
raise HTTPException(status_code=423, detail="RepoPulse is already in maintenance mode")
_get_runner().set_maintenance_mode(True)
return _maintenance_snapshot()
@app.post("/api/v1/operator/maintenance/exit")
def exit_maintenance(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
with _api_mutation_lock:
_get_runner().set_maintenance_mode(False)
return _maintenance_snapshot()
@app.post("/api/v1/scans", response_model=ScanReport, status_code=status.HTTP_201_CREATED)
async def create_scan(payload: ScanRequest, request: Request) -> ScanReport:
_require_mutation_authorization(request)
try:
repository_path = _resolve_scan_path(payload.path)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not SCAN_SLOTS.acquire(blocking=False):
raise HTTPException(
status_code=429,
detail="Repository scan capacity is currently in use; retry after the active scan completes",
headers={"Retry-After": "2"},
)
try:
report = await asyncio.to_thread(scan_repository, str(repository_path))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail="Repository path cannot be read") from exc
except OSError as exc:
raise HTTPException(status_code=422, detail="Repository path cannot be scanned") from exc
finally:
SCAN_SLOTS.release()
return store.save(report)
@app.get("/api/v1/repositories", response_model=list[RepositorySummary])
def list_repositories(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0, le=100_000),
) -> list[RepositorySummary]:
return store.list_repositories(limit, offset)
@app.post("/api/v1/repositories/sync", response_model=RepositorySyncResult)
async def sync_repositories(request: Request) -> RepositorySyncResult:
_require_mutation_authorization(request)
if not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
try:
result = await asyncio.to_thread(_get_runner().sync_now)
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
if result.errors and result.branch_failures == 0:
raise HTTPException(status_code=502, detail=result.errors[0])
return result
@app.get("/api/v1/repositories/{repository_id}", response_model=RepositoryDetail)
def get_repository(repository_id: str) -> RepositoryDetail:
repository = store.get_repository_detail(repository_id)
if repository is None:
raise HTTPException(status_code=404, detail="Repository not found")
return repository
@app.get("/api/v1/repositories/{repository_id}/scans", response_model=list[ScanSummary])
def list_repository_scans(
repository_id: str,
limit: int = Query(default=50, ge=1, le=200),
) -> list[ScanSummary]:
if store.get_repository(repository_id) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return store.repository_scans(repository_id, limit)
@app.get("/api/v1/repositories/{repository_id}/trend", response_model=list[TrendPoint])
def repository_trend(
repository_id: str,
limit: int = Query(default=50, ge=1, le=200),
) -> list[TrendPoint]:
if store.get_repository(repository_id) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return store.trend(repository_id, limit)
@app.post("/api/v1/repositories/{repository_id}/scans", response_model=ScanJob, status_code=202)
async def create_repository_scan(
request: Request,
repository_id: str,
payload: RepositoryScanRequest | None = None,
) -> ScanJob:
_require_mutation_authorization(request)
repository = store.get_repository(repository_id)
if repository is None:
raise HTTPException(status_code=404, detail="Repository not found")
if repository.archived:
raise HTTPException(status_code=409, detail="Archived repositories are not scanned")
if not repository.available:
raise HTTPException(status_code=409, detail="Repository is no longer available to the Gitea connector")
if repository.provider == "gitea" and not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
try:
return _get_runner().enqueue(repository, force=bool(payload and payload.force))
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
except QueueFullError as exc:
raise HTTPException(
status_code=429,
detail=str(exc),
headers={"Retry-After": "5"},
) from exc
@app.get("/api/v1/scan-jobs/{job_id}", response_model=ScanJob)
def get_scan_job(job_id: str) -> ScanJob:
job = store.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Scan job not found")
return job
@app.get("/api/v1/repositories/{repository_id}/scans/{scan_id}", response_model=ScanReport)
def get_repository_scan(repository_id: str, scan_id: str) -> ScanReport:
report = store.get(scan_id)
if report is None or report.source.repository_id != repository_id:
raise HTTPException(status_code=404, detail="Repository scan not found")
return report
@app.post("/api/v1/webhooks/gitea", response_model=WebhookResponse)
async def gitea_webhook(request: Request) -> WebhookResponse:
secret = os.getenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "")
if not secret:
raise HTTPException(status_code=404, detail="Gitea webhook integration is not configured")
body = await request.body()
if len(body) > 2_000_000:
raise HTTPException(status_code=413, detail="Webhook payload is too large")
signature = request.headers.get("x-gitea-signature", "").removeprefix("sha256=")
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
if not signature or not hmac.compare_digest(signature.casefold(), expected.casefold()):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=400, detail="Webhook payload is invalid JSON") from exc
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Webhook payload must be an object")
repository_payload = payload.get("repository")
if not isinstance(repository_payload, dict):
raise HTTPException(status_code=400, detail="Webhook repository is missing")
raw_id = repository_payload.get("id")
repository_id = f"gitea:{raw_id}" if str(raw_id).isdigit() else None
repository = store.get_repository(repository_id) if repository_id else None
if repository is None:
return WebhookResponse(
accepted=False,
message="Repository is not present in the synchronized catalog",
)
event = request.headers.get("x-gitea-event", "push").casefold()
if event != "push":
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Non-push webhook acknowledged without scheduling a scan",
)
ref = payload.get("ref")
if ref != f"refs/heads/{repository.default_branch}":
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Non-default branch push acknowledged without scheduling a scan",
)
after = payload.get("after")
if (
not isinstance(after, str)
or not 7 <= len(after) <= 64
or not all(char in "0123456789abcdefABCDEF" for char in after)
or not set(after) - {"0"}
):
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Push acknowledged without a usable commit revision",
)
if not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
updated = repository.model_copy(update={"latest_revision": after, "sync_error": None})
store.upsert_repository(updated)
try:
job = _get_runner().enqueue(updated, trigger=ScanTrigger.WEBHOOK)
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
except QueueFullError as exc:
raise HTTPException(status_code=429, detail=str(exc), headers={"Retry-After": "5"}) from exc
return WebhookResponse(
accepted=True,
repository_id=repository.id,
job_id=job.id,
message="Push accepted and scan scheduled",
)
@app.post("/api/v1/operator/database-recovery/acknowledge", status_code=status.HTTP_204_NO_CONTENT)
def acknowledge_database_recovery(request: Request) -> Response:
_require_mutation_authorization(request, strict=True)
store.acknowledge_database_recovery()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get("/api/v1/scans", response_model=list[ScanReport])
def list_scans(limit: int = Query(default=20, ge=1, le=100)) -> list[ScanReport]:
return store.recent(limit)
@app.get("/api/v1/scans/{scan_id}", response_model=ScanReport)
def get_scan(scan_id: str) -> ScanReport:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
return report
@app.get("/api/v1/scans/{scan_id}/comparison", response_model=ScanComparison)
def get_scan_comparison(scan_id: str) -> ScanComparison:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
return store.compare(report)
@app.get("/api/v1/scans/{scan_id}/delta")
def get_scan_delta(scan_id: str) -> dict[str, int | None]:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
previous = store.previous_for_repository(
report.source.repository_id or report.repository_path,
report.id,
)
return {
"previous_health_score": previous.health_score if previous else None,
"health_score_delta": report.health_score - previous.health_score if previous else None,
"previous_finding_count": len(previous.findings) if previous else None,
"finding_count_delta": len(report.findings) - len(previous.findings) if previous else None,
}
@app.get("/api/v1/trend", response_model=list[TrendPoint])
def trend(path: str, limit: int = Query(default=30, ge=1, le=100)) -> list[TrendPoint]:
try:
resolved = _resolve_scan_path(path)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return store.trend(str(resolved), limit)
@app.get("/api/v1/scans/{scan_id}/export")
def export_scan(scan_id: str, format: str = Query(default="html", pattern="^(html|json)$")) -> Response:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
if format == "json":
filename = safe_report_filename(report.repository_name, "json")
return Response(
report.model_dump_json(indent=2),
media_type="application/json",
headers={"Content-Disposition": content_disposition(filename)},
)
filename = safe_report_filename(report.repository_name, "html")
return Response(
render_html_report(report),
media_type="text/html",
headers={"Content-Disposition": content_disposition(filename)},
)
static_dir = Path(os.getenv("REPOPULSE_STATIC_DIR", "/app/static"))
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
+397
View File
@@ -0,0 +1,397 @@
from __future__ import annotations
import re
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from pydantic import BaseModel, Field
class Severity(StrEnum):
INFO = "info"
WARNING = "warning"
BLOCKER = "blocker"
class GateState(StrEnum):
PASS = "pass"
WARN = "warn"
BLOCK = "block"
class SourceOrigin(StrEnum):
LOCAL = "local"
GITEA = "gitea"
class ScanTrigger(StrEnum):
MANUAL = "manual"
SCHEDULED = "scheduled"
WEBHOOK = "webhook"
LOCAL = "local"
class ScanJobStatus(StrEnum):
QUEUED = "queued"
ACQUIRING_SOURCE = "acquiring_source"
SCANNING = "scanning"
ANALYZING = "analyzing"
PERSISTING = "persisting"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
SUPERSEDED = "superseded"
class RepositoryState(StrEnum):
NEVER_SCANNED = "never_scanned"
CURRENT = "current"
STALE = "stale"
QUEUED = "queued"
SCANNING = "scanning"
FAILED = "failed"
UNAVAILABLE = "unavailable"
ARCHIVED = "archived"
class RepositoryPolicy(BaseModel):
auto_scan: bool = True
min_health_score: int = Field(default=0, ge=0, le=100)
max_blockers: int = Field(default=0, ge=0)
require_tests: bool = False
require_ci: bool = False
suppressions: list[str] = Field(default_factory=list)
class RemediationSignal(BaseModel):
"""A compact, redacted finding summary safe for catalog-level triage."""
code: str
category: str
severity: Severity
message: str
count: int = Field(default=1, ge=1)
guidance: FindingGuidance | None = None
class ScanSource(BaseModel):
origin: SourceOrigin = SourceOrigin.LOCAL
provider: str | None = None
repository_id: str | None = None
repository_full_name: str | None = None
branch: str | None = None
requested_revision: str | None = None
actual_revision: str | None = None
trigger: ScanTrigger = ScanTrigger.MANUAL
class Repository(BaseModel):
id: str
provider: str
provider_repository_id: str
owner: str
name: str
full_name: str
description: str | None = None
html_url: str | None = None
clone_url: str | None = None
default_branch: str = "main"
private: bool = False
archived: bool = False
available: bool = True
latest_revision: str | None = None
latest_revision_at: datetime | None = None
last_synced_at: datetime | None = None
sync_error: str | None = None
state: RepositoryState = RepositoryState.NEVER_SCANNED
stale: bool = False
active_job_id: str | None = None
last_scan_id: str | None = None
last_scan_at: datetime | None = None
last_scan_health: int | None = None
last_scan_readiness: str | None = None
last_scan_revision: str | None = None
policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy)
class RepositorySummary(Repository):
"""Catalog row intentionally contains no full scan report."""
last_scan_blockers: int = 0
last_scan_warnings: int = 0
last_scan_primary_signal: RemediationSignal | None = None
last_scan_health_delta: int | None = None
last_scan_added_findings: int = 0
last_scan_resolved_findings: int = 0
last_scan_priority: int = 0
last_scan_priority_rationale: str | None = None
class RepositoryDetail(RepositorySummary):
latest_scan: ScanReport | None = None
class ScanJob(BaseModel):
id: str
repository_id: str
status: ScanJobStatus
trigger: ScanTrigger
requested_revision: str | None = None
actual_revision: str | None = None
phase: str = "queued"
progress: int = Field(default=0, ge=0, le=100)
message: str | None = None
created_at: datetime
started_at: datetime | None = None
updated_at: datetime
finished_at: datetime | None = None
duration_ms: float | None = None
scan_id: str | None = None
error_code: str | None = None
error_detail: str | None = None
attempt: int = 0
deduplicated: bool = False
class RepositoryScanRequest(BaseModel):
force: bool = False
class RepositorySyncResult(BaseModel):
provider: str
discovered: int = 0
created: int = 0
updated: int = 0
unavailable: int = 0
errors: list[str] = Field(default_factory=list)
partial: bool = False
branch_failures: int = 0
listing_complete: bool = True
synced_at: datetime
class WebhookResponse(BaseModel):
accepted: bool
repository_id: str | None = None
job_id: str | None = None
message: str
class FindingGuidance(BaseModel):
"""Backend-owned, safe-to-display explanation of a deterministic rule."""
remediation_class: str
confidence: str
why: str
recommended_action: str
priority: int = Field(default=0, ge=0, le=1000)
priority_rationale: str
class Finding(BaseModel):
code: str
category: str
severity: Severity
message: str
path: str | None = None
line: int | None = None
deduction: int = Field(default=0, ge=0)
fingerprint: str | None = None
suppressed: bool = False
guidance: FindingGuidance | None = None
def finding_fingerprint(finding: Finding) -> str:
"""Build a stable semantic identity that ignores line movement and counters."""
normalized_message = re.sub(r"\b\d+(?:\.\d+)?\b", "#", finding.message.casefold())
payload = "\0".join(
(
finding.code,
finding.category,
finding.path or "",
normalized_message,
)
)
return sha256(payload.encode("utf-8")).hexdigest()[:32]
class RepositoryMetrics(BaseModel):
files: int = 0
source_files: int = 0
lines_of_code: int = 0
test_files: int = 0
todo: int = 0
fixme: int = 0
hack: int = 0
xxx: int = 0
dependency_manifests: int = 0
declared_dependencies: int = 0
large_files: int = 0
large_source_files: int = 0
class TestSignals(BaseModel):
frameworks: list[str] = Field(default_factory=list)
coverage_artifacts: list[str] = Field(default_factory=list)
ci_workflows: list[str] = Field(default_factory=list)
ci_validation_workflows: list[str] = Field(default_factory=list)
ci_build_workflows: list[str] = Field(default_factory=list)
ci_deploy_only_workflows: list[str] = Field(default_factory=list)
coverage_evidence: list[str] = Field(default_factory=list)
test_source_ratio: float | None = None
class GitSignals(BaseModel):
available: bool = False
commit_count: int | None = None
contributor_count: int | None = None
latest_commit_at: datetime | None = None
tracked_files: int | None = None
renamed_files: list[str] = Field(default_factory=list)
class DependencySignals(BaseModel):
manifests: list[str] = Field(default_factory=list)
lockfiles: list[str] = Field(default_factory=list)
declared_dependencies: int = 0
unbounded_dependencies: int = 0
unpinned_dependencies: int = 0
duplicate_dependencies: list[str] = Field(default_factory=list)
included_requirements: list[str] = Field(default_factory=list)
class ArchitectureNode(BaseModel):
id: str
label: str
path: str
language: str
inbound: int = 0
outbound: int = 0
visible_inbound: int = 0
visible_outbound: int = 0
hotspot_score: int = Field(default=0, ge=0, le=100)
class ArchitectureEdge(BaseModel):
source: str
target: str
kind: str = "import"
class ArchitectureGraph(BaseModel):
nodes: list[ArchitectureNode] = Field(default_factory=list)
edges: list[ArchitectureEdge] = Field(default_factory=list)
clusters: dict[str, int] = Field(default_factory=dict)
truncated: bool = False
total_nodes: int = 0
total_edges: int = 0
class Hotspot(BaseModel):
path: str
language: str
lines_of_code: int
debt_markers: int = 0
churn: int = 0
inbound: int = 0
outbound: int = 0
score: int = Field(ge=0, le=100)
reasons: list[str] = Field(default_factory=list)
class GateCheck(BaseModel):
key: str
label: str
state: GateState
detail: str
class ReleaseGate(BaseModel):
status: str
checks: list[GateCheck]
class ScanLimits(BaseModel):
max_files: int = Field(default=10_000, ge=1, le=100_000)
max_text_bytes_per_file: int = Field(default=2_000_000, ge=1_024, le=10_000_000)
max_total_text_bytes: int = Field(default=50_000_000, ge=1_000_000, le=500_000_000)
max_findings: int = Field(default=2_000, ge=10, le=20_000)
max_secret_findings: int = Field(default=20, ge=1, le=500)
class ScanCoverage(BaseModel):
limits: ScanLimits
discovered_files: int = 0
scanned_files: int = 0
skipped_files_by_budget: int = 0
skipped_files_by_size: int = 0
skipped_files_by_text_budget: int = 0
text_bytes_scanned: int = 0
truncated: bool = False
notes: list[str] = Field(default_factory=list)
class ScanReport(BaseModel):
id: str
repository_path: str
repository_name: str
source: ScanSource = Field(default_factory=ScanSource)
policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy)
scanned_at: datetime
duration_ms: float
metrics: RepositoryMetrics
languages: dict[str, int]
category_scores: dict[str, int]
health_score: int
release_readiness: str
findings: list[Finding]
tests: TestSignals = Field(default_factory=TestSignals)
git: GitSignals = Field(default_factory=GitSignals)
dependencies: DependencySignals = Field(default_factory=DependencySignals)
architecture: ArchitectureGraph = Field(default_factory=ArchitectureGraph)
hotspots: list[Hotspot] = Field(default_factory=list)
release_gate: ReleaseGate | None = None
coverage: ScanCoverage | None = None
compacted: bool = False
class ScanSummary(BaseModel):
id: str
repository_id: str
scanned_at: datetime
branch: str | None = None
requested_revision: str | None = None
actual_revision: str | None = None
trigger: ScanTrigger = ScanTrigger.MANUAL
health_score: int
release_readiness: str
findings: int
duration_ms: float
class ScanRequest(BaseModel):
path: str = Field(min_length=1)
class ScanComparison(BaseModel):
current_scan_id: str
previous_scan_id: str | None = None
health_score_delta: int | None = None
category_score_deltas: dict[str, int] = Field(default_factory=dict)
metric_deltas: dict[str, int] = Field(default_factory=dict)
added_findings: list[Finding] = Field(default_factory=list)
resolved_findings: list[Finding] = Field(default_factory=list)
class TrendPoint(BaseModel):
scan_id: str
scanned_at: datetime
health_score: int
findings: int
blockers: int
warnings: int
debt_markers: int
RepositoryDetail.model_rebuild()
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from pathlib import Path
from .models import RepositoryPolicy
POLICY_NAMES = (".repopulse.yml", ".repopulse.yaml")
MAX_POLICY_BYTES = 64_000
def _scalar(value: str) -> bool | int | str:
normalized = value.strip().strip('"\'')
lowered = normalized.casefold()
if lowered in {"true", "yes", "on"}:
return True
if lowered in {"false", "no", "off"}:
return False
try:
return int(normalized)
except ValueError:
return normalized
def load_policy(root: Path) -> RepositoryPolicy:
policy_path = next((root / name for name in POLICY_NAMES if (root / name).is_file()), None)
if policy_path is None:
return RepositoryPolicy()
try:
if policy_path.stat().st_size > MAX_POLICY_BYTES:
return RepositoryPolicy()
lines = policy_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return RepositoryPolicy()
values: dict[str, object] = {}
suppressions: list[str] = []
in_suppressions = False
for raw_line in lines:
line = raw_line.split("#", 1)[0].rstrip()
if not line.strip():
continue
stripped = line.strip()
if stripped == "suppressions:":
in_suppressions = True
continue
if in_suppressions and stripped.startswith("-"):
value = stripped[1:].strip().strip('"\'')
if value:
suppressions.append(value)
continue
in_suppressions = False
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
key = key.strip()
if key in {"auto_scan", "min_health_score", "max_blockers", "require_tests", "require_ci"}:
values[key] = _scalar(value)
values["suppressions"] = suppressions
try:
return RepositoryPolicy.model_validate(values)
except ValueError:
return RepositoryPolicy()
def apply_policy(report, policy: RepositoryPolicy):
findings = [
item.model_copy(
update={
"suppressed": item.suppressed or item.code in set(policy.suppressions),
}
)
for item in report.findings
]
active_findings = [item for item in findings if not item.suppressed]
scores = {category: 100 for category in report.category_scores}
for finding in active_findings:
scores[finding.category] = max(0, scores[finding.category] - finding.deduction)
health_score = round(sum(scores.values()) / len(scores)) if scores else 100
from .release_gate import build_release_gate
release_gate = build_release_gate(
health_score=health_score,
findings=findings,
metrics=report.metrics,
tests=report.tests,
dependencies=report.dependencies,
policy=policy,
)
return report.model_copy(
update={
"findings": findings,
"category_scores": scores,
"health_score": health_score,
"release_readiness": release_gate.status,
"release_gate": release_gate,
"policy": policy,
}
)
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
from .models import (
DependencySignals,
Finding,
GateCheck,
GateState,
ReleaseGate,
RepositoryMetrics,
RepositoryPolicy,
ScanCoverage,
Severity,
TestSignals,
)
def build_release_gate(
*,
health_score: int,
findings: list[Finding],
metrics: RepositoryMetrics,
tests: TestSignals,
dependencies: DependencySignals,
coverage: ScanCoverage | None = None,
policy: RepositoryPolicy | None = None,
) -> ReleaseGate:
policy = policy or RepositoryPolicy()
active_findings = [item for item in findings if not item.suppressed]
blockers = [item for item in active_findings if item.severity == Severity.BLOCKER]
warnings = [item for item in active_findings if item.severity == Severity.WARNING]
dependency_issues = dependencies.unbounded_dependencies + dependencies.unpinned_dependencies
checks = [
GateCheck(
key="security",
label="Security basics",
state=GateState.BLOCK if len(blockers) > policy.max_blockers else GateState.PASS,
detail=(
f"{len(blockers)} blocker finding(s), policy allows {policy.max_blockers}"
if blockers
else "No blocker-level secret/environment findings"
),
),
GateCheck(
key="tests",
label="Tests" + (" (required)" if policy.require_tests else ""),
state=(
GateState.BLOCK
if policy.require_tests and not metrics.test_files
else GateState.PASS
if metrics.test_files
else GateState.WARN
),
detail=(
f"{metrics.test_files} test file(s) detected"
if metrics.test_files
else "Tests are required by repository policy"
if policy.require_tests
else "No test files detected"
),
),
GateCheck(
key="ci",
label="Continuous integration" + (" (required)" if policy.require_ci else ""),
state=(
GateState.BLOCK
if policy.require_ci and not tests.ci_validation_workflows
else GateState.PASS
if tests.ci_validation_workflows
else GateState.WARN
),
detail=(
f"{len(tests.ci_validation_workflows)} validation workflow(s) detected"
if tests.ci_validation_workflows
else f"{len(tests.ci_deploy_only_workflows)} deploy-only workflow(s) do not provide validation evidence"
if tests.ci_deploy_only_workflows
else "CI is required by repository policy"
if policy.require_ci
else "No validation workflow detected"
),
),
GateCheck(
key="coverage",
label="Coverage evidence",
state=(
GateState.PASS
if tests.coverage_evidence
else GateState.WARN
if metrics.test_files
else GateState.PASS
),
detail=(
f"{len(tests.coverage_evidence)} coverage evidence source(s) detected"
if tests.coverage_evidence
else "Tests exist but no coverage-producing command or retained coverage output was detected"
if metrics.test_files
else "No test suite is present to produce coverage evidence"
),
),
GateCheck(
key="analysis_coverage",
label="Analysis coverage",
state=GateState.WARN if coverage and coverage.truncated else GateState.PASS,
detail=(
"; ".join(coverage.notes) if coverage and coverage.truncated else "Analysis completed within configured limits"
),
),
GateCheck(
key="dependencies",
label="Dependency hygiene",
state=GateState.WARN if dependency_issues else GateState.PASS,
detail=(
f"{dependencies.unbounded_dependencies} unbounded and {dependencies.unpinned_dependencies} unpinned declaration(s)"
if dependency_issues
else f"{dependencies.declared_dependencies} dependency declaration(s), no unbounded or unpinned versions"
),
),
GateCheck(
key="health",
label="Repository health",
state=(
GateState.PASS
if health_score >= max(85, policy.min_health_score)
else GateState.WARN
if health_score >= max(65, policy.min_health_score)
else GateState.BLOCK
),
detail=(
f"Health score {health_score}/100"
if not policy.min_health_score
else f"Health score {health_score}/100; policy minimum {policy.min_health_score}"
),
),
GateCheck(
key="warnings",
label="Engineering warnings",
state=GateState.WARN if warnings else GateState.PASS,
detail=f"{len(warnings)} warning finding(s)" if warnings else "No warning-level findings",
),
]
if any(check.state == GateState.BLOCK for check in checks):
status = "BLOCKED"
elif any(check.state == GateState.WARN for check in checks):
status = "READY WITH WARNINGS"
else:
status = "READY"
return ReleaseGate(status=status, checks=checks)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import re
import unicodedata
from html import escape
from urllib.parse import quote
from .models import ScanReport
INVALID_FILENAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]')
REPORT_MARK = """<svg class=\"brand-mark\" viewBox=\"0 0 32 32\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"32\" height=\"32\" rx=\"8\" fill=\"#123245\"/><path d=\"M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25\" fill=\"none\" stroke=\"#91ebe0\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"7\" cy=\"9.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"7\" cy=\"22.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"25\" cy=\"16\" r=\"2\" fill=\"#91ebe0\"/></svg>"""
def safe_report_filename(repository_name: str, extension: str) -> str:
cleaned = INVALID_FILENAME_CHARS.sub("_", repository_name).strip(" .")
cleaned = cleaned[:100].rstrip(" .") or "repository"
return f"repopulse-{cleaned}.{extension}"
def content_disposition(filename: str) -> str:
fallback = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
fallback = INVALID_FILENAME_CHARS.sub("_", fallback).strip(" .") or "repopulse-report"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
def render_html_report(report: ScanReport) -> str:
category_rows = "".join(
f"<tr><td>{escape(name)}</td><td>{score}/100</td></tr>"
for name, score in report.category_scores.items()
)
finding_rows = "".join(
"<tr>"
f"<td>{escape(item.severity.value)}</td>"
f"<td>{escape(item.category)}</td>"
f"<td>{escape(item.message)}</td>"
f"<td>{escape(item.path or '')}{':' + str(item.line) if item.line else ''}</td>"
"</tr>"
for item in report.findings
)
hotspot_rows = "".join(
"<tr>"
f"<td>{escape(item.path)}</td><td>{escape(item.language)}</td>"
f"<td>{item.score}</td><td>{item.lines_of_code}</td><td>{item.churn}</td>"
"</tr>"
for item in report.hotspots[:25]
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RepoPulse report — {escape(report.repository_name)}</title>
<style>
*{{box-sizing:border-box}}body{{font-family:Inter,ui-sans-serif,system-ui,sans-serif;margin:42px auto;max-width:1120px;color:#172638;line-height:1.45}}
.brand{{display:flex;align-items:center;gap:10px;margin-bottom:34px}}.brand-mark{{width:34px;height:34px;flex:0 0 auto}}.brand strong{{display:block;font-size:15px;letter-spacing:-.03em}}.brand small{{display:block;color:#66808d;font-size:8px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}}
.hero{{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:23px 25px;border:1px solid #dce8e8;border-radius:18px;background:linear-gradient(120deg,#effafa,#f9fcfc)}}h1{{margin:0 0 4px;font-size:30px;letter-spacing:-.04em}}h2{{margin-top:36px;font-size:18px;letter-spacing:-.02em}}.muted{{color:#607588}}.score{{color:#176c70;font-size:54px;font-weight:750;letter-spacing:-.08em}}.readiness{{color:#176c70;font-size:11px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}}
table{{width:100%;border-collapse:collapse;margin:14px 0 34px}}th,td{{text-align:left;padding:10px 9px;border-bottom:1px solid #e4ebef;vertical-align:top}}th{{color:#607588;font-size:10px;letter-spacing:.06em;text-transform:uppercase}}.grid{{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;overflow:hidden;border:1px solid #dce8e8;border-radius:13px;background:#dce8e8}}.card{{padding:15px;background:#fff}}.card strong{{color:#176c70;font-size:24px}}
@media print{{body{{margin:18mm;max-width:none}}.hero{{background:#fff}}}}@media(max-width:760px){{.grid{{grid-template-columns:1fr 1fr}}body{{margin:22px}}.hero{{align-items:start;flex-direction:column}}}}
</style>
</head>
<body>
<div class="brand">{REPORT_MARK}<span><strong>RepoPulse</strong><small>Repository intelligence</small></span></div>
<section class="hero"><div><p class="muted">Deterministic repository intelligence report</p><h1>{escape(report.repository_name)}</h1><p class="muted">{escape(report.repository_path)} · {escape(report.scanned_at.isoformat())}</p></div><div><div class="score">{report.health_score}</div><div class="readiness">{escape(report.release_readiness)}</div></div></section>
<div class="grid">
<div class="card"><strong>{report.metrics.source_files}</strong><br>source files</div>
<div class="card"><strong>{report.metrics.lines_of_code}</strong><br>LOC</div>
<div class="card"><strong>{report.metrics.test_files}</strong><br>test files</div>
<div class="card"><strong>{len(report.findings)}</strong><br>findings</div>
</div>
<h2>Category scores</h2><table><tbody>{category_rows}</tbody></table>
<h2>Hotspots</h2><table><thead><tr><th>Path</th><th>Language</th><th>Score</th><th>LOC</th><th>Churn</th></tr></thead><tbody>{hotspot_rows}</tbody></table>
<h2>Findings</h2><table><thead><tr><th>Severity</th><th>Category</th><th>Finding</th><th>Location</th></tr></thead><tbody>{finding_rows}</tbody></table>
</body>
</html>"""
+83
View File
@@ -0,0 +1,83 @@
from .models import Finding, FindingGuidance, Severity
_RULES: dict[str, tuple[str, str, str, str]] = {
"security.secret-assignment": (
"security remediation",
"A credential-shaped assignment was found in tracked content.",
"Move the value to a secret store or runtime environment and rotate it if it was genuine.",
"high",
),
"security.env-committed": (
"security remediation",
"A live environment file is tracked, so its contents require deliberate review.",
"Remove the live file from version control and retain a sanitized .env.example template.",
"high",
),
"testing.none-detected": (
"test coverage",
"No conventionally named test source was detected for a repository with source files.",
"Add a focused deterministic smoke test, or record a repository policy that explains why tests do not apply.",
"medium",
),
"testing.no-ci": (
"delivery evidence",
"No repository workflow was found that contains a validation command.",
"Add a CI validation workflow that runs the repository's deterministic checks.",
"medium",
),
"dependency.no-lockfile": (
"dependency reproducibility",
"A JavaScript manifest was found without a lockfile in its package or workspace root.",
"Commit the package-manager lockfile used by the build.",
"high",
),
"hygiene.missing-gitignore": (
"repository hygiene",
"The repository has no root ignore policy.",
"Add a minimal .gitignore for generated files, local settings and build outputs.",
"high",
),
}
def _default_rule(finding: Finding) -> tuple[str, str, str, str]:
if finding.code.startswith("dependency."):
return (
"dependency hygiene",
"The dependency declaration has a deterministic hygiene signal.",
"Review the declared version and lockfile semantics, then make the smallest reproducible correction.",
"medium",
)
if finding.code.startswith("architecture."):
return (
"architecture review",
"The bounded static graph has a structural signal.",
"Inspect the affected module boundary before planning a contained refactor.",
"medium",
)
return (
"engineering hygiene",
"A deterministic repository rule produced this signal.",
"Review the retained evidence and make the smallest safe correction.",
"medium",
)
def enrich_finding(finding: Finding) -> Finding:
remediation_class, why, action, confidence = _RULES.get(finding.code, _default_rule(finding))
severity_weight = {Severity.BLOCKER: 700, Severity.WARNING: 400, Severity.INFO: 100}[finding.severity]
priority = min(1000, severity_weight + finding.deduction * 5)
rationale = f"{finding.severity.value.title()} signal; {finding.deduction} health-point impact."
guidance = FindingGuidance(
remediation_class=remediation_class,
confidence=confidence,
why=why,
recommended_action=action,
priority=priority,
priority_rationale=rationale,
)
return finding.model_copy(update={"guidance": guidance})
def enrich_findings(findings: list[Finding]) -> list[Finding]:
return [enrich_finding(item) for item in findings]
+695
View File
@@ -0,0 +1,695 @@
from __future__ import annotations
import ast
import fnmatch
import os
import re
import subprocess
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from uuid import uuid4
from .architecture import build_architecture
from .git_analysis import analyze_git
from .models import (
Finding,
RepositoryMetrics,
RepositoryPolicy,
ScanCoverage,
ScanLimits,
ScanReport,
ScanSource,
Severity,
finding_fingerprint,
)
from .policy import load_policy
from .release_gate import build_release_gate
from .rules import enrich_findings
from .signals import analyze_dependencies, detect_test_signals
LANGUAGES = {
".py": "Python",
".ts": "TypeScript",
".tsx": "TypeScript",
".js": "JavaScript",
".jsx": "JavaScript",
".mjs": "JavaScript",
".cjs": "JavaScript",
".cs": "C#",
".java": "Java",
".go": "Go",
".rs": "Rust",
".cpp": "C++",
".cc": "C++",
".c": "C",
".h": "C/C++ Header",
".hpp": "C++ Header",
".php": "PHP",
".rb": "Ruby",
".kt": "Kotlin",
".swift": "Swift",
".vue": "Vue",
".svelte": "Svelte",
".html": "HTML",
".css": "CSS",
".scss": "SCSS",
".sh": "Shell",
".ps1": "PowerShell",
".sql": "SQL",
}
IGNORED_DIRS = {
".git",
".idea",
".vscode",
".venv",
"venv",
"node_modules",
"dist",
"build",
"coverage",
".next",
".nuxt",
".pytest_cache",
"__pycache__",
"vendor",
"target",
"bin",
"obj",
}
MAX_TEXT_BYTES = 2_000_000
MAX_SECRET_FINDINGS = 20
DEBT_PATTERN = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b")
SECRET_PATTERN = re.compile(
r"""(?ix)
(?:
^\s*(?:export\s+)?(?:(?:const|let|var|final|static|private|public|protected|internal)\s+)?(?:[A-Za-z_]\w*\.)*
|[,{]\s*
)
[\"']?(?P<name>api[_-]?key|secret|password|passwd|token|access[_-]?key)[\"']?
\s*[:=]\s*[\"']?(?P<value>[^\s\"']{8,})
"""
)
PLACEHOLDER_SECRET_MARKERS = ("example", "placeholder", "dummy", "changeme", "not-a-real", "replace-me", "your-", "fake", "test-")
SECRET_REFERENCE_MARKERS = (
"os.environ",
"os.getenv",
"getenv",
"process.env",
"env[",
"config.",
"settings.",
)
SECRET_SCAN_CONFIG_SUFFIXES = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".md", ".txt"}
CATEGORY_NAMES = (
"Architecture",
"Testing",
"Maintainability",
"Documentation",
"Dependencies",
"Repository hygiene",
"Security basics",
)
def _ignored(relative: str) -> bool:
return any(part.casefold() in IGNORED_DIRS for part in Path(relative).parts)
def _is_env_template(name: str) -> bool:
return name == ".env.example" or (name.startswith(".env.") and name.endswith(".example"))
def _is_secret_scan_candidate(path_obj: Path, relative: str, language: str | None) -> bool:
return language is not None or path_obj.suffix.casefold() in SECRET_SCAN_CONFIG_SUFFIXES or path_obj.name.casefold().startswith(".env")
def _bounded_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
try:
value = int(os.getenv(name, str(default)))
except ValueError:
value = default
return min(maximum, max(minimum, value))
def scan_limits_from_environment() -> ScanLimits:
"""Return bounded analysis limits; input cannot turn a scan into an unbounded job."""
return ScanLimits(
max_files=_bounded_env_int("REPOPULSE_SCAN_MAX_FILES", 10_000, minimum=1, maximum=100_000),
max_text_bytes_per_file=_bounded_env_int(
"REPOPULSE_SCAN_MAX_TEXT_BYTES", MAX_TEXT_BYTES, minimum=1_024, maximum=10_000_000
),
max_total_text_bytes=_bounded_env_int(
"REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES", 50_000_000, minimum=1_000_000, maximum=500_000_000
),
max_findings=_bounded_env_int("REPOPULSE_SCAN_MAX_FINDINGS", 2_000, minimum=10, maximum=20_000),
max_secret_findings=_bounded_env_int(
"REPOPULSE_SCAN_MAX_SECRET_FINDINGS", MAX_SECRET_FINDINGS, minimum=1, maximum=500
),
)
class _GitIgnoreMatcher:
"""Bounded root/nested .gitignore support for non-Git directory scans."""
def __init__(self, root: Path) -> None:
self.rules: list[tuple[str, str, bool, bool, bool]] = []
for current, dirs, names in os.walk(root, followlinks=False):
dirs[:] = [
name
for name in dirs
if name.casefold() not in IGNORED_DIRS and not (Path(current) / name).is_symlink()
]
if ".gitignore" not in names:
continue
ignore_file = Path(current) / ".gitignore"
try:
lines = ignore_file.read_text(encoding="utf-8", errors="replace").splitlines()
base_path = Path(current).relative_to(root)
base = "" if str(base_path) == "." else base_path.as_posix()
except (OSError, ValueError):
continue
for raw in lines:
pattern = raw.strip()
if not pattern or pattern.startswith("#"):
continue
negated = pattern.startswith("!")
if negated:
pattern = pattern[1:]
pattern = pattern.replace("\\", "/")
anchored = pattern.startswith("/")
pattern = pattern.lstrip("/")
dir_only = pattern.endswith("/")
pattern = pattern.rstrip("/")
if pattern:
self.rules.append((base, pattern, negated, dir_only, anchored))
def is_ignored(self, relative: str, *, is_dir: bool) -> bool:
normalized = relative.replace("\\", "/").strip("/")
ignored = False
for base, pattern, negated, dir_only, anchored in self.rules:
if dir_only and not is_dir:
continue
if base:
if normalized == base:
target = ""
elif normalized.startswith(f"{base}/"):
target = normalized[len(base) + 1 :]
else:
continue
else:
target = normalized
if not target:
continue
if "/" not in pattern:
matched = (
fnmatch.fnmatch(target, pattern)
if anchored
else any(fnmatch.fnmatch(part, pattern) for part in target.split("/")
)
)
else:
matched = fnmatch.fnmatch(target, pattern)
if not matched and pattern.startswith("**/"):
matched = fnmatch.fnmatch(target, pattern[3:])
if matched:
ignored = not negated
return ignored
def _git_worktree_root(root: Path) -> Path | None:
try:
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
check=True,
capture_output=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return None
try:
return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip()).resolve()
except (OSError, ValueError):
return None
def _safe_file(root: Path, candidate: Path) -> Path | None:
"""Return a resolved regular file only when every path component is safe."""
try:
relative = candidate.relative_to(root)
if _ignored(str(relative)):
return None
current = root
for part in Path(relative).parts:
current /= part
if current.is_symlink():
return None
resolved = candidate.resolve(strict=True)
root_resolved = root.resolve()
if resolved == root_resolved or root_resolved not in resolved.parents:
return None
if not resolved.is_file():
return None
except (OSError, RuntimeError, ValueError):
return None
return resolved
def _tracked_files(root: Path) -> list[Path] | None:
if _git_worktree_root(root) != root:
return None
try:
result = subprocess.run(
["git", "-C", str(root), "ls-files", "-z"],
check=True,
capture_output=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return None
paths: list[Path] = []
for raw in result.stdout.split(b"\0"):
if not raw:
continue
relative = raw.decode("utf-8", errors="surrogateescape")
relative_path = Path(relative)
if relative_path.is_absolute() or _ignored(relative):
continue
safe = _safe_file(root, root / relative_path)
if safe is not None:
paths.append(safe)
return paths
def _walk_files(root: Path) -> list[Path]:
files: list[Path] = []
gitignore = _GitIgnoreMatcher(root)
for current, dirs, names in os.walk(root, followlinks=False):
current_path = Path(current)
dirs[:] = [
name
for name in dirs
if name.casefold() not in IGNORED_DIRS
and not (current_path / name).is_symlink()
and not gitignore.is_ignored(
str((current_path / name).relative_to(root)),
is_dir=True,
)
]
for name in names:
candidate = current_path / name
try:
relative = str(candidate.relative_to(root))
except (OSError, ValueError):
continue
if _ignored(relative):
continue
if gitignore.is_ignored(relative, is_dir=False):
continue
safe = _safe_file(root, candidate)
if safe is not None:
files.append(safe)
return files
def _read_text(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> str | None:
try:
if path.stat().st_size > max_bytes:
return None
raw = path.read_bytes()
except OSError:
return None
if b"\0" in raw[:8192]:
return None
return raw.decode("utf-8", errors="replace")
def _is_test_file(relative: str) -> bool:
normalized = relative.replace("\\", "/").lower()
name = Path(normalized).name
return (
"/tests/" in f"/{normalized}"
or "/test/" in f"/{normalized}"
or name.startswith("test_")
or ".test." in name
or ".spec." in name
or name.endswith(("tests.cs", "test.cs", "_test.go"))
)
def _python_function_findings(relative: str, text: str) -> list[Finding]:
try:
tree = ast.parse(text)
except SyntaxError:
return []
findings: list[Finding] = []
branch_types = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.Match, ast.BoolOp)
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
end_line = getattr(node, "end_lineno", node.lineno)
length = max(1, end_line - node.lineno + 1)
branches = sum(1 for child in ast.walk(node) if isinstance(child, branch_types))
if length >= 140:
findings.append(
Finding(
code="maintainability.long-function",
category="Maintainability",
severity=Severity.WARNING,
message=f"Function {node.name} spans {length} lines",
path=relative,
line=node.lineno,
deduction=4,
)
)
if branches >= 20:
findings.append(
Finding(
code="maintainability.branch-heavy-function",
category="Maintainability",
severity=Severity.WARNING,
message=f"Function {node.name} contains {branches} branch constructs",
path=relative,
line=node.lineno,
deduction=4,
)
)
return findings[:12]
def _is_placeholder_secret(match: re.Match[str]) -> bool:
value = match.group("value").casefold().strip("`$(){}[]<>")
exact_placeholder = value in {"", "none", "null", "undefined", "xxx", "xxxxx"}
obvious_placeholder = any(value.startswith(marker) for marker in PLACEHOLDER_SECRET_MARKERS)
return exact_placeholder or obvious_placeholder or any(
value.startswith(marker) for marker in SECRET_REFERENCE_MARKERS
)
def scan_repository(
path: str,
*,
source: ScanSource | None = None,
display_name: str | None = None,
policy: RepositoryPolicy | None = None,
limits: ScanLimits | None = None,
) -> ScanReport:
started = perf_counter()
root = Path(path).expanduser().resolve()
if not root.exists() or not root.is_dir():
raise ValueError("Repository path does not exist or is not a directory")
limits = limits or scan_limits_from_environment()
tracked_files = _tracked_files(root)
all_files = _walk_files(root) if tracked_files is None else tracked_files
all_files.sort(key=lambda item: str(item.relative_to(root)).replace("\\", "/").casefold())
files = all_files[: limits.max_files]
coverage = ScanCoverage(
limits=limits,
discovered_files=len(all_files),
skipped_files_by_budget=max(0, len(all_files) - len(files)),
truncated=len(all_files) > len(files),
notes=(
[f"File analysis limited to {limits.max_files} of {len(all_files)} discovered safe files."]
if len(all_files) > len(files)
else []
),
)
languages: Counter[str] = Counter()
findings: list[Finding] = []
metrics = RepositoryMetrics(files=len(all_files))
files_by_name: dict[str, list[Path]] = {}
root_entries = {entry.name.lower() for entry in root.iterdir()}
text_by_path: dict[str, str] = {}
source_texts: dict[str, str] = {}
language_by_path: dict[str, str] = {}
loc_by_path: dict[str, int] = {}
debt_by_path: dict[str, int] = {}
secret_findings = 0
secret_findings_truncated = False
text_budget_exhausted = False
for path_obj in files:
try:
relative = str(path_obj.relative_to(root)).replace("\\", "/")
size = path_obj.stat().st_size
except (OSError, ValueError):
continue
files_by_name.setdefault(path_obj.name, []).append(path_obj)
suffix = path_obj.suffix.lower()
language = LANGUAGES.get(suffix)
if size >= 1_000_000:
metrics.large_files += 1
findings.append(
Finding(
code="hygiene.large-file",
category="Repository hygiene",
severity=Severity.INFO,
message=f"Large tracked file ({size / 1_000_000:.1f} MB)",
path=relative,
deduction=1,
)
)
name = path_obj.name.casefold()
if name == ".env" or (name.startswith(".env.") and not _is_env_template(name)):
findings.append(
Finding(
code="security.env-committed",
category="Security basics",
severity=Severity.BLOCKER,
message="Environment file is tracked; review it for credentials",
path=relative,
deduction=30,
)
)
if size > limits.max_text_bytes_per_file:
coverage.skipped_files_by_size += 1
continue
if coverage.text_bytes_scanned + size > limits.max_total_text_bytes:
coverage.skipped_files_by_text_budget += 1
text_budget_exhausted = True
continue
text = _read_text(path_obj, limits.max_text_bytes_per_file)
if text is None:
continue
coverage.scanned_files += 1
coverage.text_bytes_scanned += len(text.encode("utf-8", errors="replace"))
text_by_path[relative] = text
if language:
loc = sum(1 for line in text.splitlines() if line.strip())
metrics.source_files += 1
metrics.lines_of_code += loc
languages[language] += loc
source_texts[relative] = text
language_by_path[relative] = language
loc_by_path[relative] = loc
if loc > 1000:
metrics.large_source_files += 1
findings.append(
Finding(
code="maintainability.large-source",
category="Maintainability",
severity=Severity.WARNING,
message=f"Source file has {loc} non-empty lines",
path=relative,
deduction=5,
)
)
if language == "Python":
findings.extend(_python_function_findings(relative, text))
if _is_test_file(relative):
metrics.test_files += 1
debt = Counter(DEBT_PATTERN.findall(text))
metrics.todo += debt["TODO"]
metrics.fixme += debt["FIXME"]
metrics.hack += debt["HACK"]
metrics.xxx += debt["XXX"]
debt_by_path[relative] = sum(debt.values())
if _is_secret_scan_candidate(path_obj, relative, language) and secret_findings < limits.max_secret_findings:
for line_number, line in enumerate(text.splitlines(), start=1):
match = SECRET_PATTERN.search(line)
if match is None or _is_placeholder_secret(match):
continue
findings.append(
Finding(
code="security.secret-assignment",
category="Security basics",
severity=Severity.BLOCKER,
message="Potential credential assignment detected; value intentionally redacted",
path=relative,
line=line_number,
deduction=20,
)
)
secret_findings += 1
if secret_findings >= limits.max_secret_findings:
secret_findings_truncated = True
break
if text_budget_exhausted:
coverage.truncated = True
coverage.notes.append(
f"Text analysis limited to {limits.max_total_text_bytes} bytes across repository files."
)
if secret_findings_truncated:
findings.append(
Finding(
code="security.secret-findings-truncated",
category="Security basics",
severity=Severity.INFO,
message=f"Potential credential findings are capped at {limits.max_secret_findings}",
deduction=0,
)
)
if not any(name.startswith("readme") for name in root_entries):
findings.append(Finding(code="docs.missing-readme", category="Documentation", severity=Severity.WARNING, message="No root README detected", deduction=20))
if not any(name.startswith(("license", "copying")) for name in root_entries):
findings.append(Finding(code="docs.missing-license", category="Documentation", severity=Severity.INFO, message="No root license file detected", deduction=5))
if ".gitignore" not in root_entries:
findings.append(Finding(code="hygiene.missing-gitignore", category="Repository hygiene", severity=Severity.WARNING, message="No root .gitignore detected", deduction=15))
if not any(_is_env_template(name) for name in root_entries) and any(name.startswith(".env") for name in root_entries):
findings.append(Finding(code="hygiene.missing-env-example", category="Repository hygiene", severity=Severity.INFO, message="Environment configuration exists without a root .env.example", deduction=3))
test_signals = detect_test_signals(
[str(path.relative_to(root)).replace("\\", "/") for path in files],
metrics.source_files,
metrics.test_files,
text_by_path,
)
if metrics.source_files and metrics.test_files == 0:
findings.append(Finding(code="testing.none-detected", category="Testing", severity=Severity.WARNING, message="No test files detected", deduction=30))
if not test_signals.ci_workflows:
findings.append(Finding(code="testing.no-ci", category="Testing", severity=Severity.WARNING, message="No recognized CI workflow detected", deduction=15))
if metrics.test_files and not test_signals.coverage_artifacts:
findings.append(Finding(code="testing.no-coverage-artifact", category="Testing", severity=Severity.INFO, message="Tests are present but no coverage artifact is tracked", deduction=2))
common_source_roots = {"src", "app", "backend", "frontend", "lib", "packages"}
if metrics.source_files >= 10 and not (root_entries & common_source_roots):
findings.append(Finding(code="architecture.flat-layout", category="Architecture", severity=Severity.INFO, message="No conventional source root detected in a non-trivial repository", deduction=8))
debt_total = metrics.todo + metrics.fixme + metrics.hack + metrics.xxx
if debt_total:
findings.append(Finding(code="maintainability.debt-markers", category="Maintainability", severity=Severity.INFO, message=f"Detected {debt_total} TODO/FIXME/HACK/XXX markers", deduction=min(20, max(1, debt_total // 5))))
dependency_signals, dependency_findings = analyze_dependencies(root, files_by_name)
metrics.dependency_manifests = len(dependency_signals.manifests)
metrics.declared_dependencies = dependency_signals.declared_dependencies
findings.extend(dependency_findings)
git_signals, churn_by_path = analyze_git(root)
architecture, hotspots = build_architecture(
root,
source_texts,
language_by_path,
loc_by_path,
debt_by_path,
churn_by_path,
)
for hotspot in hotspots[:5]:
if hotspot.score >= 70:
findings.append(
Finding(
code="maintainability.hotspot",
category="Maintainability",
severity=Severity.WARNING,
message=f"Engineering hotspot score {hotspot.score}/100: {', '.join(hotspot.reasons) or 'combined risk signals'}",
path=hotspot.path,
deduction=4,
)
)
if architecture.truncated:
findings.append(
Finding(
code="architecture.graph-truncated",
category="Architecture",
severity=Severity.INFO,
message="Architecture graph was bounded to keep analysis responsive",
deduction=0,
)
)
severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2}
if len(findings) > limits.max_findings:
discarded = len(findings) - limits.max_findings
findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0))
findings = findings[: limits.max_findings]
findings.append(
Finding(
code="analysis.findings-truncated",
category="Repository hygiene",
severity=Severity.INFO,
message=f"{discarded} lower-priority findings were omitted by the analysis finding budget",
deduction=0,
)
)
coverage.truncated = True
coverage.notes.append(f"Finding output limited to {limits.max_findings} deterministic signals.")
policy = policy or load_policy(root)
suppressed_codes = set(policy.suppressions)
findings = [
item.model_copy(update={"suppressed": item.suppressed or item.code in suppressed_codes})
for item in findings
]
scores = {category: 100 for category in CATEGORY_NAMES}
for finding in findings:
if not finding.suppressed:
scores[finding.category] = max(0, scores[finding.category] - finding.deduction)
health_score = round(sum(scores.values()) / len(scores))
release_gate = build_release_gate(
health_score=health_score,
findings=findings,
metrics=metrics,
tests=test_signals,
dependencies=dependency_signals,
coverage=coverage,
policy=policy,
)
findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0))
findings = [
item.model_copy(update={"fingerprint": item.fingerprint or finding_fingerprint(item)})
for item in findings
]
findings = enrich_findings(findings)
return ScanReport(
id=str(uuid4()),
repository_path=str(root),
repository_name=display_name or root.name,
source=source or ScanSource(),
policy=policy,
scanned_at=datetime.now(UTC),
duration_ms=round((perf_counter() - started) * 1000, 2),
metrics=metrics,
languages=dict(languages.most_common()),
category_scores=scores,
health_score=health_score,
release_readiness=release_gate.status,
findings=findings,
tests=test_signals,
git=git_signals,
dependencies=dependency_signals,
architecture=architecture,
hotspots=hotspots,
release_gate=release_gate,
coverage=coverage,
)
+494
View File
@@ -0,0 +1,494 @@
from __future__ import annotations
import json
import re
import tomllib
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
from .models import DependencySignals, Finding, Severity, TestSignals
LOCKFILES = {
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lock",
"bun.lockb",
"uv.lock",
"poetry.lock",
"Pipfile.lock",
"Cargo.lock",
"go.sum",
}
MANIFEST_MAX_BYTES = 2_000_000
COVERAGE_NAMES = {
"coverage.xml",
"lcov.info",
"cobertura.xml",
".coverage",
"jacoco.xml",
}
COVERAGE_NAMES_CASEFOLD = {item.casefold() for item in COVERAGE_NAMES}
def _python_dependency_name(declaration: str) -> str:
return re.split(r"[<>=!~ @;\[]", declaration, maxsplit=1)[0].strip()
def _python_constraint_kind(declaration: str) -> str:
"""Classify the useful subset of Python version constraints without penalising bounded ranges."""
value = declaration.split(";", maxsplit=1)[0].strip().lower()
if " @ " in value or "==" in value:
return "pinned"
if "~=" in value or "^" in value:
return "bounded"
has_lower = ">" in value
has_upper = "<" in value
if has_lower and has_upper:
return "bounded"
return "unbounded"
def _python_dependency_finding(declaration: str, relative: str) -> Finding | None:
if _python_constraint_kind(declaration) != "unbounded":
return None
return Finding(
code="dependency.python-unbounded",
category="Dependencies",
severity=Severity.WARNING,
message=f"Python dependency has no bounded version range: {declaration.split(';')[0]}",
path=relative,
deduction=3,
)
def detect_test_signals(
relative_paths: list[str],
source_files: int,
test_files: int,
text_by_path: dict[str, str],
) -> TestSignals:
frameworks: set[str] = set()
coverage: list[str] = []
ci: list[str] = []
ci_validation: list[str] = []
ci_build: list[str] = []
ci_deploy_only: list[str] = []
coverage_evidence: list[str] = []
for path in relative_paths:
normalized = path.replace("\\", "/")
name = Path(normalized).name
name_lower = name.casefold()
lower = normalized.lower()
if name_lower in COVERAGE_NAMES_CASEFOLD or "/coverage/" in f"/{lower}":
coverage.append(normalized)
if lower.startswith((".github/workflows/", ".gitea/workflows/")) or name_lower in {
".gitlab-ci.yml",
"jenkinsfile",
"azure-pipelines.yml",
}:
ci.append(normalized)
if (name_lower.startswith("test_") or "/tests/" in f"/{lower}") and name_lower.endswith(".py"):
frameworks.add("pytest/unittest")
if (".test." in name_lower or ".spec." in name_lower) and name_lower.endswith(
(".ts", ".tsx", ".js", ".jsx")
):
frameworks.add("JavaScript test runner")
if name_lower.endswith("_test.go"):
frameworks.add("go test")
if name_lower.endswith(("tests.cs", "test.cs")):
frameworks.add(".NET test")
for path, text in text_by_path.items():
name = Path(path).name
normalized = path.replace("\\", "/")
lowered_text = text.casefold()
if normalized in ci:
validates = any(
token in lowered_text
for token in ("pytest", "vitest", "jest", "npm test", "pnpm test", "go test", "dotnet test", "ruff", "eslint", "typecheck", "py_compile")
)
builds = any(
token in lowered_text
for token in ("npm run build", "pnpm build", "docker build", "vite build", "cargo build", "go build", "dotnet build")
)
deploys = any(token in lowered_text for token in ("deploy", "release", "publish", "kubectl apply"))
if validates:
ci_validation.append(normalized)
if builds:
ci_build.append(normalized)
if deploys and not validates:
ci_deploy_only.append(normalized)
if any(token in lowered_text for token in ("--cov", "coverage", "lcov", "codecov")):
coverage_evidence.append(normalized)
if name.casefold() in COVERAGE_NAMES_CASEFOLD:
coverage_evidence.append(normalized)
if name.casefold() == "package.json":
try:
data = json.loads(text)
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(data, dict):
continue
deps: dict[object, object] = {}
for section_name in (
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
):
section = data.get(section_name, {})
if isinstance(section, dict):
deps.update(section)
if "vitest" in deps:
frameworks.add("Vitest")
if "jest" in deps:
frameworks.add("Jest")
if "@playwright/test" in deps:
frameworks.add("Playwright")
if "cypress" in deps:
frameworks.add("Cypress")
elif name.casefold() == "pyproject.toml":
lowered = text.lower()
if "pytest" in lowered:
frameworks.add("pytest")
ratio = round((test_files / source_files) * 100, 2) if source_files else None
return TestSignals(
frameworks=sorted(frameworks),
coverage_artifacts=sorted(set(coverage))[:30],
ci_workflows=sorted(set(ci))[:30],
ci_validation_workflows=sorted(set(ci_validation))[:30],
ci_build_workflows=sorted(set(ci_build))[:30],
ci_deploy_only_workflows=sorted(set(ci_deploy_only))[:30],
coverage_evidence=sorted(set(coverage_evidence))[:30],
test_source_ratio=ratio,
)
def analyze_dependencies(
root: Path,
files_by_name: dict[str, list[Path]],
) -> tuple[DependencySignals, list[Finding]]:
findings: list[Finding] = []
manifests: list[str] = []
lockfiles: list[str] = []
dependency_origins: dict[str, set[str]] = defaultdict(set)
declared = 0
unbounded = 0
unpinned = 0
for lock_name in sorted(LOCKFILES):
for path in files_by_name.get(lock_name, []):
lockfiles.append(str(path.relative_to(root)).replace("\\", "/"))
for package_json in files_by_name.get("package.json", []):
relative = str(package_json.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
if package_json.stat().st_size > MANIFEST_MAX_BYTES:
raise ValueError("package.json is too large to parse")
data = json.loads(package_json.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise TypeError("package.json root must be an object")
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
findings.append(
Finding(
code="dependency.invalid-package-json",
category="Dependencies",
severity=Severity.WARNING,
message="package.json could not be parsed safely",
path=relative,
deduction=8,
)
)
continue
deps: dict[str, object] = {}
invalid_sections = False
for section_name in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
section = data.get(section_name, {})
if section is None:
continue
if not isinstance(section, dict):
invalid_sections = True
continue
deps.update(section)
if invalid_sections:
findings.append(
Finding(
code="dependency.invalid-package-json",
category="Dependencies",
severity=Severity.WARNING,
message="One or more package dependency sections are not objects",
path=relative,
deduction=8,
)
)
declared += len(deps)
for name, version in deps.items():
dependency_name = str(name)
dependency_origins[dependency_name.casefold()].add(relative)
raw = str(version).strip().lower()
if raw in {"*", "latest", "x"} and not raw.startswith("workspace:"):
unbounded += 1
findings.append(
Finding(
code="dependency.unbounded",
category="Dependencies",
severity=Severity.WARNING,
message=f"Dependency {dependency_name} uses an unbounded version",
path=relative,
deduction=5,
)
)
parent = package_json.parent
package_roots = [parent, *[ancestor for ancestor in parent.parents if ancestor == root or root in ancestor.parents]]
if not any(
(package_root / lock).exists()
for package_root in package_roots
for lock in ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb")
):
findings.append(Finding(code="dependency.no-lockfile", category="Dependencies", severity=Severity.WARNING, message="JavaScript package has no recognized lockfile", path=relative, deduction=8))
requirement_paths = [
path
for name, values in files_by_name.items()
if name.casefold().startswith("requirements") and name.casefold().endswith(".txt")
for path in values
]
included_requirements: list[str] = []
processed_requirements: set[Path] = set()
pending_requirements = list(requirement_paths)
while pending_requirements:
requirements = pending_requirements.pop(0)
try:
requirements = requirements.resolve(strict=True)
except OSError:
continue
if requirements in processed_requirements or requirements != root and root not in requirements.parents:
continue
processed_requirements.add(requirements)
relative = str(requirements.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
lines = requirements.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
findings.append(
Finding(
code="dependency.unreadable-requirements",
category="Dependencies",
severity=Severity.WARNING,
message="Python requirements file could not be read",
path=relative,
deduction=8,
)
)
continue
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith(("-r ", "--requirement ")):
include = stripped.split(maxsplit=1)[1].strip()
candidate = (requirements.parent / include).resolve()
if candidate == root or root in candidate.parents:
pending_requirements.append(candidate)
included_requirements.append(str(candidate.relative_to(root)).replace("\\", "/"))
continue
if stripped.startswith("-"):
continue
declared += 1
name = _python_dependency_name(stripped)
if name:
dependency_origins[name.casefold()].add(relative)
finding = _python_dependency_finding(stripped, relative)
if finding:
unbounded += 1
findings.append(finding)
for pyproject in files_by_name.get("pyproject.toml", []):
relative = str(pyproject.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
if pyproject.stat().st_size > MANIFEST_MAX_BYTES:
raise ValueError("pyproject.toml is too large to parse")
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise TypeError("pyproject.toml root must be a table")
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, TypeError):
findings.append(
Finding(
code="dependency.invalid-pyproject",
category="Dependencies",
severity=Severity.WARNING,
message="pyproject.toml could not be parsed safely",
path=relative,
deduction=8,
)
)
continue
project = data.get("project", {})
if not isinstance(project, dict):
findings.append(
Finding(
code="dependency.invalid-pyproject",
category="Dependencies",
severity=Severity.WARNING,
message="pyproject.toml project section is not a table",
path=relative,
deduction=8,
)
)
project = {}
project_deps = project.get("dependencies", [])
if isinstance(project_deps, list):
declared += len(project_deps)
for dependency in project_deps:
declaration = str(dependency)
name = _python_dependency_name(declaration)
if name:
dependency_origins[name.casefold()].add(relative)
finding = _python_dependency_finding(declaration, relative)
if finding:
unbounded += 1
findings.append(finding)
elif project_deps is not None:
findings.append(
Finding(
code="dependency.invalid-pyproject",
category="Dependencies",
severity=Severity.WARNING,
message="pyproject.toml project.dependencies is not a list",
path=relative,
deduction=8,
)
)
optional_deps = project.get("optional-dependencies", {})
if isinstance(optional_deps, dict):
for dependencies in optional_deps.values():
if not isinstance(dependencies, list):
continue
declared += len(dependencies)
for dependency in dependencies:
declaration = str(dependency)
name = _python_dependency_name(declaration)
if name:
dependency_origins[name.casefold()].add(relative)
finding = _python_dependency_finding(declaration, relative)
if finding:
unbounded += 1
findings.append(finding)
elif optional_deps is not None:
findings.append(
Finding(
code="dependency.invalid-pyproject",
category="Dependencies",
severity=Severity.WARNING,
message="pyproject.toml project.optional-dependencies is not a table",
path=relative,
deduction=8,
)
)
for go_mod in files_by_name.get("go.mod", []):
relative = str(go_mod.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
text = go_mod.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
findings.append(
Finding(
code="dependency.unreadable-go-mod",
category="Dependencies",
severity=Severity.WARNING,
message="go.mod could not be read",
path=relative,
deduction=8,
)
)
continue
matches = list(re.finditer(r"(?m)^\s*([\w./-]+)\s+v\d[^\s]*", text))
if "require" in text and not matches:
findings.append(
Finding(
code="dependency.invalid-go-mod",
category="Dependencies",
severity=Severity.WARNING,
message="go.mod contains no parseable versioned dependencies",
path=relative,
deduction=8,
)
)
for match in matches:
declared += 1
dependency_origins[match.group(1).casefold()].add(relative)
for cargo in files_by_name.get("Cargo.toml", []):
relative = str(cargo.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
data = tomllib.loads(cargo.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
findings.append(
Finding(
code="dependency.invalid-cargo-toml",
category="Dependencies",
severity=Severity.WARNING,
message="Cargo.toml could not be parsed safely",
path=relative,
deduction=8,
)
)
continue
cargo_deps = data.get("dependencies", {})
if isinstance(cargo_deps, dict):
declared += len(cargo_deps)
for name in cargo_deps:
dependency_origins[str(name).casefold()].add(relative)
for pom in files_by_name.get("pom.xml", []):
relative = str(pom.relative_to(root)).replace("\\", "/")
manifests.append(relative)
try:
xml_root = ET.parse(pom).getroot()
except (OSError, UnicodeError, ET.ParseError):
findings.append(
Finding(
code="dependency.invalid-pom",
category="Dependencies",
severity=Severity.WARNING,
message="pom.xml could not be parsed safely",
path=relative,
deduction=8,
)
)
continue
dependencies = [element for element in xml_root.iter() if element.tag.split("}")[-1] == "dependency"]
declared += len(dependencies)
for dependency in dependencies:
artifact = next((child.text for child in dependency if child.tag.split("}")[-1] == "artifactId"), None)
if artifact:
dependency_origins[artifact.casefold()].add(relative)
duplicates = sorted(name for name, origins in dependency_origins.items() if len(origins) > 1)
for name in duplicates[:25]:
origins = sorted(dependency_origins[name])
findings.append(Finding(code="dependency.duplicate-manifest", category="Dependencies", severity=Severity.INFO, message=f"Dependency {name} is declared across multiple manifests", path=origins[0], deduction=1))
return (
DependencySignals(
manifests=sorted(set(manifests)),
lockfiles=sorted(set(lockfiles)),
declared_dependencies=declared,
unbounded_dependencies=unbounded,
unpinned_dependencies=unpinned,
duplicate_dependencies=duplicates[:50],
included_requirements=sorted(set(included_requirements))[:50],
),
findings,
)
+1527
View File
File diff suppressed because it is too large. Load diff