generated from coulomb/repo-seed
Some checks failed
ci / validate-registry (push) Has been cancelled
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>
214 lines
6.3 KiB
Python
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,
|
|
) |