forked from Keelrleek/Harbor
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'.
This commit is contained in:
1 parent
87631bec52
commit
b7e09cfbd1
7 files changed
+497
-6
No files matched your search
@@ -0,0 +1,144 @@
|
||||
"""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))
|
||||
Reference in new issue
Block a user