from __future__ import annotations import json import os from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable from reuse_surface.establish import format_publish_check_markdown, publish_check from reuse_surface.interactive import NonInteractiveError, prompt_batch from reuse_surface.maintain_llm import request_maintain_patches from reuse_surface.patches import ( apply_patches_atomic, filter_auto_patches, patches_from_suggestions, ) from reuse_surface.registry import load_index_at, registry_paths from reuse_surface.registry_update import collect_deterministic_suggestions @dataclass class MaintainResult: selected_count: int = 0 applied: list[str] = field(default_factory=list) skipped: int = 0 notes: list[str] = field(default_factory=list) publish: dict[str, Any] | None = None exit_code: int = 0 def collect_capability_ids( repo_root: Path, *, capability_id: str | None, all_capabilities: bool, ) -> list[str]: index = load_index_at(registry_paths(repo_root)["index"]) ids = [row["id"] for row in index.get("capabilities", [])] if capability_id: if capability_id not in ids: raise ValueError(f"capability not in index: {capability_id}") return [capability_id] if all_capabilities: return ids raise ValueError("specify --capability or --all") def gather_patches( repo_root: Path, *, capability_ids: list[str], git_since: str | None, llm_url: str | None, no_llm: bool, ) -> tuple[list[dict[str, Any]], list[str]]: patches: list[dict[str, Any]] = [] notes: list[str] = [] scope_id = capability_ids[0] if len(capability_ids) == 1 else None suggestions = collect_deterministic_suggestions( repo_root, capability_id=scope_id, git_since=git_since, ) patches = patches_from_suggestions(suggestions) if scope_id: patches = [ patch for patch in patches if patch["capability_id"] == scope_id or patch.get("kind") == "index_updated_bump" ] if no_llm: return patches, notes try: for cap_id in capability_ids: payload = request_maintain_patches( repo_root, cap_id, git_since=git_since, llm_url=llm_url, ) patches.extend(payload.get("patches", [])) notes.extend(payload.get("notes", [])) except ValueError as exc: if "LLM backend not configured" in str(exc): notes.append("LLM phase skipped: LLM_CONNECT_URL not set") else: notes.append(f"LLM phase skipped: {exc}") return patches, notes def run_maintain( repo_root: Path, *, capability_id: str | None = None, all_capabilities: bool = False, git_since: str | None = None, llm_url: str | None = None, no_llm: bool = False, auto: bool = False, yes: bool = False, auto_confidence: str = "high", auto_max_delta: int = 1, publish: bool = False, raw_url: str | None = None, output_format: str = "markdown", validate_fn: Callable[[], tuple[int, list[str], list[str]]] | None = None, ) -> MaintainResult: if publish and not raw_url: raw_url = os.environ.get("REUSE_SURFACE_RAW_URL") if publish and not raw_url: raise ValueError("--publish requires --raw-url or REUSE_SURFACE_RAW_URL") cap_ids = collect_capability_ids( repo_root, capability_id=capability_id, all_capabilities=all_capabilities, ) patches, notes = gather_patches( repo_root, capability_ids=cap_ids, git_since=git_since, llm_url=llm_url, no_llm=no_llm, ) result = MaintainResult(notes=notes) if not patches: result.exit_code = 0 if publish and raw_url: result.publish = publish_check(repo_root, raw_url=raw_url) if not result.publish["ok"]: result.exit_code = 1 return result if auto or yes: selected = filter_auto_patches( patches, repo_root, auto_confidence=auto_confidence, auto_max_delta=auto_max_delta, ) result.skipped = len(patches) - len(selected) else: try: selected = prompt_batch( patches, assume_yes=yes, auto_mode=False, emit_json=output_format == "json", ) result.skipped = len(patches) - len(selected) except NonInteractiveError as exc: raise ValueError(str(exc)) from exc result.selected_count = len(selected) if not selected: result.exit_code = 2 return result if validate_fn is None: raise ValueError("validate_fn is required") applied, code = apply_patches_atomic(repo_root, selected, validate=validate_fn) result.applied = applied result.exit_code = code if code == 0 and publish and raw_url: result.publish = publish_check(repo_root, raw_url=raw_url) if not result.publish["ok"]: result.exit_code = 1 return result def format_maintain_markdown(result: MaintainResult) -> str: lines = ["# Registry maintain session", ""] lines.append(f"**Selected:** {result.selected_count} patch(es)") lines.append(f"**Skipped:** {result.skipped}") lines.append(f"**Exit:** {result.exit_code}") if result.applied: lines.append("") lines.append("## Applied") for item in result.applied: lines.append(f"- {item}") if result.notes: lines.append("") lines.append("## Notes") for note in result.notes: lines.append(f"- {note}") if result.publish: lines.append("") lines.append(format_publish_check_markdown(result.publish).rstrip()) return "\n".join(lines) + "\n" def format_maintain_json(result: MaintainResult) -> str: return json.dumps( { "selected_count": result.selected_count, "skipped": result.skipped, "applied": result.applied, "notes": result.notes, "publish": result.publish, "exit_code": result.exit_code, }, indent=2, sort_keys=True, )