Files
Harbor/harbor/logbook_auto.py
T
KeelrLeek b7e09cfbd1 Logbook lives in Harbor and keeps itself current
The board's Logbook view replaces the published page: statistics, the chart
of the executable, where the code stands (with the function tiers), the
trend, milestones and the ship's log, stored as JSON in the Harbor home.
A server thread follows main: each new commit becomes a log entry and the
statistics, chart and trend point are rebuilt with the open-seas progress
tools on a scratch copy. 'harbor logbook add|stats|map|milestone|refresh'.
2026-10-05 16:30:21 -05:00

145 lines
6.5 KiB
Python

"""Keeps the Logbook current without anyone writing to it: when the decomp repository's main branch moves,
1. each new commit on main becomes a ship's log entry (its subject and the first lines of its message), and
2. the statistics, the chart of the executable and a trend point are rebuilt with the open-seas progress tools,
run on a scratch copy of that repository (the real one stays untouched).
Configured per project in a [logbook] table:
auto = true
decomp = 'R:/.../open-seas-decomp' the repository whose main branch is followed
stats_repo = 'R:/.../stats-work/repo' a scratch copy of open-seas holding tools/progress/*.py
interval_minutes = 10 how often the server looks (default 10)
Milestones and narrative entries stay with people: they need judgement.
State (the last commit seen) is kept in the logbook folder as auto.json."""
import json
import re
import subprocess
import sys
import tempfile
import time
import traceback
from pathlib import Path
from . import logbook
MAX_ENTRIES = 25
def _git(repo, *args):
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()[:300]}")
return result.stdout
def _state(project):
path = logbook.folder(project) / "auto.json"
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
def _save_state(project, state):
path = logbook.folder(project) / "auto.json"
path.write_text(json.dumps(state, indent=1), encoding="utf-8")
def _utc(iso):
"""git's %cI (local time with an offset) as UTC, the Logbook's format."""
import datetime
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _kind(files):
if files and all(f.startswith("annotations/") for f in files):
return "naming"
if any(f.startswith("src/") for f in files):
return "progress"
return "note"
def log_commits(project, repo, since, head):
"""One ship's log entry per commit on main after `since` (oldest first); a summary when there are many."""
raw = _git(repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%cI%x1f%s%x1f%b%x1e", f"{since}..{head}")
commits = [c.strip().split("\x1f") for c in raw.split("\x1e") if c.strip()]
added = []
if len(commits) > MAX_ENTRIES:
subjects = "\n".join(f"- {c[2]} ({c[0][:8]})" for c in commits[-40:])
added.append(logbook.add_entry(project, f"{len(commits)} commits landed on main",
subjects, kind="progress", at=_utc(commits[-1][1])))
return added
for sha, when, subject, body in commits:
files = [f for f in _git(repo, "show", "--name-only", "--format=", sha).splitlines() if f.strip()]
lines = [l for l in body.strip().splitlines() if l.strip() and not l.lower().startswith("co-authored-by")]
detail = "\n".join(lines[:8]) if lines else subject
detail += f"\n(main {sha[:8]}, {len(files)} file{'s' if len(files) != 1 else ''})"
added.append(logbook.add_entry(project, subject, detail, kind=_kind(files), at=_utc(when)))
return added
def rebuild_stats(project, repo, stats_repo, head):
"""Run the open-seas progress tools on the scratch copy and store their statistics and chart."""
tools = Path(stats_repo) / "tools" / "progress"
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo),
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
sync = run(tools / "sync_decomp.py", "--decomp", repo)
if sync.returncode != 0:
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
report = run(tools / "progress.py")
if report.returncode != 0:
raise RuntimeError("progress: " + (report.stdout + report.stderr).strip()[-400:])
with tempfile.TemporaryDirectory() as out:
payload = run(tools / "dashboard_payload.py", out)
if payload.returncode != 0:
raise RuntimeError("dashboard_payload: " + (payload.stdout + payload.stderr).strip()[-400:])
stats = json.loads((Path(out) / "stats_current.json").read_text(encoding="utf-8"))
chart = json.loads((Path(out) / "stats_map.json").read_text(encoding="utf-8"))
stats["commit"] = head[:7]
stats["updated_at"] = logbook.now()
logbook.set_stats(project, stats)
logbook.set_map(project, chart)
return {"classified": stats.get("classified_percent"), "weighted": stats.get("weighted_percent")}
def refresh(project, force=False):
"""Bring the Logbook up to main. Returns what changed."""
config = getattr(project, "logbook", None) or {}
repo, stats_repo = config.get("decomp"), config.get("stats_repo")
if not repo:
return {"changed": False, "reason": "no [logbook] decomp"}
head = _git(repo, "rev-parse", "HEAD").strip()
state = _state(project)
if state.get("last_head") == head and not force:
return {"changed": False, "head": head[:8]}
out = {"changed": True, "head": head[:8]}
if state.get("last_head"):
try:
out["entries"] = len(log_commits(project, repo, state["last_head"], head))
except RuntimeError as error: # e.g. the old commit vanished after a rewrite
out["log_error"] = str(error)
if stats_repo:
try:
out["stats"] = rebuild_stats(project, repo, stats_repo, head)
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
out["stats_error"] = str(error)
_save_state(project, {**state, "last_head": head, "last_refresh": logbook.now(),
**({"last_error": out.get("stats_error") or out.get("log_error")}
if out.get("stats_error") or out.get("log_error") else {"last_error": None})})
return out
def loop(registry):
"""Server thread: refresh every project with [logbook] auto = true."""
while True:
wait = 600
for name in registry.names():
try:
h, _ = registry.get(name)
config = getattr(h.project, "logbook", None) or {}
if config.get("auto"):
wait = min(wait, int(config.get("interval_minutes", 10)) * 60)
refresh(h.project)
except Exception:
traceback.print_exc()
time.sleep(max(60, wait))