logbook: statistics run on a clean detached snapshot of main ([logbook] snapshot), not the integrator's live checkout, which is often mid-batch; a failed run retries on the next tick instead of waiting for main to move
This commit is contained in:
1 parent
d7464aaa40
commit
a3fc48802c
1 file changed
+169
-152
+169
-152
@@ -1,152 +1,169 @@
|
|||||||
"""Keeps the Logbook current without anyone writing to it: when the decomp repository's main branch moves,
|
"""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
|
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,
|
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).
|
run on a scratch copy of that repository (the real one stays untouched).
|
||||||
|
|
||||||
Configured per project in a [logbook] table:
|
Configured per project in a [logbook] table:
|
||||||
auto = true
|
auto = true
|
||||||
decomp = 'R:/.../open-seas-decomp' the repository whose main branch is followed
|
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
|
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)
|
interval_minutes = 10 how often the server looks (default 10)
|
||||||
Milestones and narrative entries stay with people: they need judgement.
|
Milestones and narrative entries stay with people: they need judgement.
|
||||||
|
|
||||||
State (the last commit seen) is kept in the logbook folder as auto.json."""
|
State (the last commit seen) is kept in the logbook folder as auto.json."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import logbook
|
from . import logbook
|
||||||
|
|
||||||
MAX_ENTRIES = 25
|
MAX_ENTRIES = 25
|
||||||
|
|
||||||
|
|
||||||
def _git(repo, *args):
|
def _git(repo, *args):
|
||||||
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
|
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
|
||||||
errors="replace", timeout=60)
|
errors="replace", timeout=60)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()[:300]}")
|
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()[:300]}")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
def _state(project):
|
def _state(project):
|
||||||
path = logbook.folder(project) / "auto.json"
|
path = logbook.folder(project) / "auto.json"
|
||||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||||
|
|
||||||
|
|
||||||
def _save_state(project, state):
|
def _save_state(project, state):
|
||||||
path = logbook.folder(project) / "auto.json"
|
path = logbook.folder(project) / "auto.json"
|
||||||
path.write_text(json.dumps(state, indent=1), encoding="utf-8")
|
path.write_text(json.dumps(state, indent=1), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _utc(iso):
|
def _utc(iso):
|
||||||
"""git's %cI (local time with an offset) as UTC, the Logbook's format."""
|
"""git's %cI (local time with an offset) as UTC, the Logbook's format."""
|
||||||
import datetime
|
import datetime
|
||||||
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
def _kind(files):
|
def _kind(files):
|
||||||
if files and all(f.startswith("annotations/") for f in files):
|
if files and all(f.startswith("annotations/") for f in files):
|
||||||
return "naming"
|
return "naming"
|
||||||
if any(f.startswith("src/") for f in files):
|
if any(f.startswith("src/") for f in files):
|
||||||
return "progress"
|
return "progress"
|
||||||
return "note"
|
return "note"
|
||||||
|
|
||||||
|
|
||||||
def log_commits(project, repo, since, head):
|
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."""
|
"""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}")
|
raw = _git(repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%cI%x1f%s%x1f%b%x1e", f"{since}..{head}")
|
||||||
# str.strip() counts \x1f as whitespace and would eat an empty body's field, so trim newlines only and pad
|
# str.strip() counts \x1f as whitespace and would eat an empty body's field, so trim newlines only and pad
|
||||||
commits = [(c.strip("\r\n").split("\x1f") + [""] * 4)[:4] for c in raw.split("\x1e") if c.strip("\r\n")]
|
commits = [(c.strip("\r\n").split("\x1f") + [""] * 4)[:4] for c in raw.split("\x1e") if c.strip("\r\n")]
|
||||||
added = []
|
added = []
|
||||||
if len(commits) > MAX_ENTRIES:
|
if len(commits) > MAX_ENTRIES:
|
||||||
subjects = "\n".join(f"- {c[2]} ({c[0][:8]})" for c in commits[-40:])
|
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",
|
added.append(logbook.add_entry(project, f"{len(commits)} commits landed on main",
|
||||||
subjects, kind="progress", at=_utc(commits[-1][1])))
|
subjects, kind="progress", at=_utc(commits[-1][1])))
|
||||||
return added
|
return added
|
||||||
for sha, when, subject, body in commits:
|
for sha, when, subject, body in commits:
|
||||||
files = [f for f in _git(repo, "show", "--name-only", "--format=", sha).splitlines() if f.strip()]
|
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")]
|
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 = "\n".join(lines[:8]) if lines else subject
|
||||||
detail += f"\n(main {sha[:8]}, {len(files)} file{'s' if len(files) != 1 else ''})"
|
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)))
|
added.append(logbook.add_entry(project, subject, detail, kind=_kind(files), at=_utc(when)))
|
||||||
return added
|
return added
|
||||||
|
|
||||||
|
|
||||||
def rebuild_stats(project, repo, stats_repo, head):
|
def rebuild_stats(project, repo, stats_repo, head):
|
||||||
"""Run the open-seas progress tools on the scratch copy and store their statistics and chart."""
|
"""Run the open-seas progress tools on the scratch copy and store their statistics and chart."""
|
||||||
tools = Path(stats_repo) / "tools" / "progress"
|
tools = Path(stats_repo) / "tools" / "progress"
|
||||||
# its own data dir: the agents' compiles hold the shared decomp-build\pirates.h open
|
# its own data dir: the agents' compiles hold the shared decomp-build\pirates.h open
|
||||||
env = dict(os.environ, OPENSEAS_DATA=os.environ.get("HARBOR_LOGBOOK_DATA", r"R:\Workspace\Data\openseas-logbook"))
|
env = dict(os.environ, OPENSEAS_DATA=os.environ.get("HARBOR_LOGBOOK_DATA", r"R:\Workspace\Data\openseas-logbook"))
|
||||||
# the readable build finds the compiler through vswhere, which a server started without a shell's PATH lacks
|
# the readable build finds the compiler through vswhere, which a server started without a shell's PATH lacks
|
||||||
installer = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer"
|
installer = r"C:\Program Files (x86)\Microsoft Visual Studio\Installer"
|
||||||
if os.path.isdir(installer) and installer.lower() not in env.get("PATH", "").lower():
|
if os.path.isdir(installer) and installer.lower() not in env.get("PATH", "").lower():
|
||||||
env["PATH"] = installer + os.pathsep + env.get("PATH", "")
|
env["PATH"] = installer + os.pathsep + env.get("PATH", "")
|
||||||
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo), env=env,
|
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo), env=env,
|
||||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
|
||||||
sync = run(tools / "sync_decomp.py", "--decomp", repo)
|
sync = run(tools / "sync_decomp.py", "--decomp", repo)
|
||||||
if sync.returncode != 0:
|
if sync.returncode != 0:
|
||||||
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
|
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
|
||||||
report = run(tools / "progress.py")
|
report = run(tools / "progress.py")
|
||||||
if report.returncode != 0:
|
if report.returncode != 0:
|
||||||
raise RuntimeError("progress: " + (report.stdout + report.stderr).strip()[-400:])
|
raise RuntimeError("progress: " + (report.stdout + report.stderr).strip()[-400:])
|
||||||
with tempfile.TemporaryDirectory() as out:
|
with tempfile.TemporaryDirectory() as out:
|
||||||
payload = run(tools / "dashboard_payload.py", out)
|
payload = run(tools / "dashboard_payload.py", out)
|
||||||
if payload.returncode != 0:
|
if payload.returncode != 0:
|
||||||
raise RuntimeError("dashboard_payload: " + (payload.stdout + payload.stderr).strip()[-400:])
|
raise RuntimeError("dashboard_payload: " + (payload.stdout + payload.stderr).strip()[-400:])
|
||||||
stats = json.loads((Path(out) / "stats_current.json").read_text(encoding="utf-8"))
|
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"))
|
chart = json.loads((Path(out) / "stats_map.json").read_text(encoding="utf-8"))
|
||||||
stats["commit"] = head[:7]
|
stats["commit"] = head[:7]
|
||||||
stats["updated_at"] = logbook.now()
|
stats["updated_at"] = logbook.now()
|
||||||
logbook.set_stats(project, stats)
|
logbook.set_stats(project, stats)
|
||||||
logbook.set_map(project, chart)
|
logbook.set_map(project, chart)
|
||||||
return {"classified": stats.get("classified_percent"), "weighted": stats.get("weighted_percent")}
|
return {"classified": stats.get("classified_percent"), "weighted": stats.get("weighted_percent")}
|
||||||
|
|
||||||
|
|
||||||
def refresh(project, force=False):
|
def snapshot_at(config, repo, head):
|
||||||
"""Bring the Logbook up to main. Returns what changed."""
|
"""A clean checkout of `head` for the statistics: [logbook] snapshot, a detached worktree of the decomp repository
|
||||||
config = getattr(project, "logbook", None) or {}
|
that only this refresher uses. The live checkout is the integrator's and is often mid-batch with uncommitted
|
||||||
repo, stats_repo = config.get("decomp"), config.get("stats_repo")
|
files, which the statistics tools rightly refuse. Without a snapshot setting, the live checkout is used."""
|
||||||
if not repo:
|
snapshot = config.get("snapshot")
|
||||||
return {"changed": False, "reason": "no [logbook] decomp"}
|
if not snapshot:
|
||||||
head = _git(repo, "rev-parse", "HEAD").strip()
|
return repo
|
||||||
state = _state(project)
|
path = Path(snapshot)
|
||||||
if state.get("last_head") == head and not force:
|
if not (path / ".git").exists():
|
||||||
return {"changed": False, "head": head[:8]}
|
_git(repo, "worktree", "add", "--detach", str(path), head)
|
||||||
out = {"changed": True, "head": head[:8]}
|
else:
|
||||||
if state.get("last_head"):
|
_git(str(path), "checkout", "--detach", "--force", head)
|
||||||
try:
|
return str(path)
|
||||||
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)
|
def refresh(project, force=False):
|
||||||
if stats_repo:
|
"""Bring the Logbook up to main. Returns what changed."""
|
||||||
try:
|
config = getattr(project, "logbook", None) or {}
|
||||||
out["stats"] = rebuild_stats(project, repo, stats_repo, head)
|
repo, stats_repo = config.get("decomp"), config.get("stats_repo")
|
||||||
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
if not repo:
|
||||||
out["stats_error"] = str(error)
|
return {"changed": False, "reason": "no [logbook] decomp"}
|
||||||
_save_state(project, {**state, "last_head": head, "last_refresh": logbook.now(),
|
head = _git(repo, "rev-parse", "HEAD").strip()
|
||||||
**({"last_error": out.get("stats_error") or out.get("log_error")}
|
state = _state(project)
|
||||||
if out.get("stats_error") or out.get("log_error") else {"last_error": None})})
|
if state.get("last_head") == head and not force:
|
||||||
return out
|
return {"changed": False, "head": head[:8]}
|
||||||
|
out = {"changed": True, "head": head[:8]}
|
||||||
|
if state.get("last_head"):
|
||||||
def loop(registry):
|
try:
|
||||||
"""Server thread: refresh every project with [logbook] auto = true."""
|
out["entries"] = len(log_commits(project, repo, state["last_head"], head))
|
||||||
while True:
|
except RuntimeError as error: # e.g. the old commit vanished after a rewrite
|
||||||
wait = 600
|
out["log_error"] = str(error)
|
||||||
for name in registry.names():
|
if stats_repo:
|
||||||
try:
|
try:
|
||||||
h, _ = registry.get(name)
|
out["stats"] = rebuild_stats(project, snapshot_at(config, repo, head), stats_repo, head)
|
||||||
config = getattr(h.project, "logbook", None) or {}
|
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
|
||||||
if config.get("auto"):
|
out["stats_error"] = str(error)
|
||||||
wait = min(wait, int(config.get("interval_minutes", 10)) * 60)
|
# a failed statistics run is retried next time, instead of waiting for main to move again
|
||||||
refresh(h.project)
|
done_head = state.get("last_head") if out.get("stats_error") else head
|
||||||
except Exception:
|
_save_state(project, {**state, "last_head": done_head, "last_refresh": logbook.now(),
|
||||||
traceback.print_exc()
|
**({"last_error": out.get("stats_error") or out.get("log_error")}
|
||||||
time.sleep(max(60, wait))
|
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