Files
reuse-surface/reuse_surface/maintain.py
tegwick b24ec507aa
Some checks failed
ci / validate-registry (push) Has been cancelled
WP-0016 finished: interactive registry maintain with llm-connect automation
Closes the registry maintenance loop from inside each domain repo:
interactive prompting for judgment calls, full automation for safe and
high-confidence changes, both backed by the llm-connect HTTP bridge.

- New modules: maintain.py, maintain_llm.py, patches.py, interactive.py
- Schema: schemas/registry-patch.schema.json
- CLI: reuse-surface maintain; establish --scaffold --hook
- Sibling templates: Makefile fragment, pre-commit hook
- Deterministic signal collectors extended; validate cwd auto-detect
- Docs, gap priority 28, SCOPE update
- Tests: test_maintain.py, test_interactive.py (59 pytest total)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:00:39 +02:00

214 lines
6.3 KiB
Python

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,
)