mirror of
https://github.com/0xrsydn/nix-hermes-agent.git
synced 2026-08-07 00:53:52 +00:00
feat: quarantine upstream update candidates
This commit is contained in:
parent
35817ccbf4
commit
5b0336a493
15 changed files with 660 additions and 230 deletions
160
scripts/render-update-report.py
Executable file
160
scripts/render-update-report.py
Executable file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render a small, deterministic compatibility report for an upstream update."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTRACT_PATHS = (
|
||||
"hermes_cli",
|
||||
"hermes_cli/config.py",
|
||||
"hermes_constants.py",
|
||||
"skills",
|
||||
"optional-skills",
|
||||
"hermes-tui",
|
||||
"mini-swe-agent",
|
||||
)
|
||||
|
||||
|
||||
def metadata(source: Path | None) -> dict[str, object]:
|
||||
result: dict[str, object] = {
|
||||
"dependencies": set(),
|
||||
"dependency_specs": {},
|
||||
"optional_groups": set(),
|
||||
"scripts": {},
|
||||
"python": "unknown",
|
||||
"config_version": "unknown",
|
||||
"paths": set(),
|
||||
}
|
||||
if source is None or not source.is_dir():
|
||||
return result
|
||||
|
||||
pyproject = source / "pyproject.toml"
|
||||
if pyproject.is_file():
|
||||
try:
|
||||
with pyproject.open("rb") as handle:
|
||||
project = tomllib.load(handle).get("project", {})
|
||||
specs = {
|
||||
dependency_name(item): item for item in project.get("dependencies", [])
|
||||
}
|
||||
result["dependencies"] = set(specs)
|
||||
result["dependency_specs"] = specs
|
||||
result["optional_groups"] = set(project.get("optional-dependencies", {}))
|
||||
result["scripts"] = dict(project.get("scripts", {}))
|
||||
result["python"] = project.get("requires-python", "unknown")
|
||||
except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
for config_path in (source / "hermes_cli/config.py", source / "hermes_constants.py"):
|
||||
if not config_path.is_file():
|
||||
continue
|
||||
contents = config_path.read_text(encoding="utf-8", errors="replace")
|
||||
matches = re.findall(
|
||||
r'(?:CURRENT_CONFIG_VERSION|["\']_config_version["\'])\s*(?::|=)\s*(\d+)',
|
||||
contents,
|
||||
)
|
||||
if matches:
|
||||
result["config_version"] = matches[-1]
|
||||
break
|
||||
|
||||
result["paths"] = {path for path in CONTRACT_PATHS if (source / path).exists()}
|
||||
return result
|
||||
|
||||
|
||||
def dependency_name(requirement: str) -> str:
|
||||
return re.split(r"[<>=!~;\[ @]", requirement, maxsplit=1)[0].strip().lower()
|
||||
|
||||
|
||||
def changes(before: set[str], after: set[str]) -> str:
|
||||
added = sorted(after - before)
|
||||
removed = sorted(before - after)
|
||||
parts = []
|
||||
if added:
|
||||
parts.append("added: " + ", ".join(f"`{item}`" for item in added))
|
||||
if removed:
|
||||
parts.append("removed: " + ", ".join(f"`{item}`" for item in removed))
|
||||
return "; ".join(parts) if parts else "none"
|
||||
|
||||
|
||||
def dependency_changes(before: dict[str, str], after: dict[str, str]) -> str:
|
||||
summary = changes(set(before), set(after))
|
||||
changed = sorted(name for name in before.keys() & after.keys() if before[name] != after[name])
|
||||
if changed:
|
||||
detail = ", ".join(
|
||||
f"`{name}` (`{before[name]}` → `{after[name]}`)" for name in changed
|
||||
)
|
||||
summary = (summary + "; " if summary != "none" else "") + "changed: " + detail
|
||||
return summary
|
||||
|
||||
|
||||
def render(args: argparse.Namespace) -> str:
|
||||
before = metadata(args.before_source)
|
||||
after = metadata(args.after_source)
|
||||
status_icon = "✅" if args.build_status == "passed" else "❌"
|
||||
lines = [
|
||||
f"## Hermes Agent {args.channel} candidate",
|
||||
"",
|
||||
"This PR is a quarantined upstream candidate. Merge it only after required checks pass.",
|
||||
"",
|
||||
"| Contract | Current | Candidate |",
|
||||
"| --- | --- | --- |",
|
||||
f"| Version | `{args.current_version}` | `{args.candidate_version}` |",
|
||||
f"| Revision | `{args.current_rev}` | `{args.candidate_rev}` |",
|
||||
f"| Python | `{before['python']}` | `{after['python']}` |",
|
||||
f"| Config schema | `{before['config_version']}` | `{after['config_version']}` |",
|
||||
f"| Build validation | — | {status_icon} {args.build_status} |",
|
||||
"",
|
||||
"### Detected interface changes",
|
||||
"",
|
||||
f"- Direct dependencies: {dependency_changes(before['dependency_specs'], after['dependency_specs'])}",
|
||||
f"- Optional dependency groups: {changes(before['optional_groups'], after['optional_groups'])}",
|
||||
f"- CLI entry points: {changes(set(before['scripts']), set(after['scripts']))}",
|
||||
f"- Package/module asset paths: {changes(before['paths'], after['paths'])}",
|
||||
"",
|
||||
]
|
||||
if args.upstream_url:
|
||||
lines.extend((f"Upstream: {args.upstream_url}", ""))
|
||||
if args.build_log and args.build_log.is_file() and args.build_status != "passed":
|
||||
tail = args.build_log.read_text(encoding="utf-8", errors="replace").splitlines()[-80:]
|
||||
lines.extend(
|
||||
(
|
||||
"<details><summary>Validation failure (last 80 lines)</summary>",
|
||||
"",
|
||||
"```text",
|
||||
"\n".join(tail).replace("```", "'''"),
|
||||
"```",
|
||||
"</details>",
|
||||
"",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--channel", required=True)
|
||||
parser.add_argument("--current-version", required=True)
|
||||
parser.add_argument("--candidate-version", required=True)
|
||||
parser.add_argument("--current-rev", required=True)
|
||||
parser.add_argument("--candidate-rev", required=True)
|
||||
parser.add_argument("--before-source", type=Path)
|
||||
parser.add_argument("--after-source", type=Path)
|
||||
parser.add_argument("--build-status", choices=("passed", "failed"), required=True)
|
||||
parser.add_argument("--build-log", type=Path)
|
||||
parser.add_argument("--upstream-url")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(render(args), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue