generated from coulomb/repo-seed
WP-0016 finished: interactive registry maintain with llm-connect automation
Some checks failed
ci / validate-registry (push) Has been cancelled
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>
This commit is contained in:
214
reuse_surface/maintain.py
Normal file
214
reuse_surface/maintain.py
Normal file
@@ -0,0 +1,214 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user