Five weeks of forge evidence per week rather than a rolling window, so a finding survives under one key and "closed after three weeks" is computable. Rules run deterministically; agent.py hands the packet to whoever writes the prose and never calls a model itself. Model choice fails closed — no default, no substitute, and the exact named model must be served or the pass refuses to run.
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""Shared pieces for the project-state pass: forge client, weeks, dimensions.
|
|
|
|
See docs/tasks/79-project-state.md for what this is for and why it is shaped
|
|
this way.
|
|
"""
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import urllib.parse
|
|
import urllib.request
|
|
from zoneinfo import ZoneInfo
|
|
|
|
LOCAL = ZoneInfo("America/Toronto")
|
|
OWNER = "Fimeg"
|
|
PROJECTS = os.path.expanduser("~/Projects")
|
|
|
|
# Listed, not discovered. A repo joining Souveraine is a deliberate addition
|
|
# here, and one that goes quiet should be visible as a quiet row rather than
|
|
# silently stop being read. Same reasoning as tools/ci-status.sh.
|
|
REPOS = [
|
|
"souveraine", "SouveraineOS", "Pixel3Arch", "souveraine-viewtop",
|
|
"souveraine-updater", "souveraine-lens", "souveraine-speech",
|
|
"souveraine-player", "souveraine-usb", "culver", "linux-blueline",
|
|
"hexagonrpc", "smithay", "pocketboot", "libcmatrix", "upower",
|
|
]
|
|
|
|
# The SAF sections, plus the two the pipeline owns and SAF has no page for.
|
|
DIMENSIONS = [
|
|
"memory", "runtime", "reflection", "identity", "surface",
|
|
"sensorium", "release", "reliability", "federation", "adoption",
|
|
]
|
|
|
|
UNASSIGNED = "unassigned"
|
|
|
|
# Longer keywords score higher, so "device state" beats a bare "state" and a
|
|
# path full of a subsystem's name beats one incidental word in a subject.
|
|
KEYWORDS = {
|
|
"memory": ["memory", "memfs", "compaction", "archivist", "recall", "vault", "memoir"],
|
|
"runtime": ["server", "handler", "runtime", "substrate", "provider", "tool call",
|
|
"stream", "sse", "prompt", "context", "turn", "inference", "token"],
|
|
"reflection": ["reflection", "subconscious", "cadence", "n+1", "aster", "introspect"],
|
|
"identity": ["identity", "authority", "sessiond", "principal", "secrets", "machined",
|
|
"trust", "login", "credential", "permission", "polkit", "device state",
|
|
"device_state", "admission"],
|
|
"surface": ["shell", "panel", "tui", "surface", "qml", "dock", "dial", "osk",
|
|
"keyboard", "island", "overview", "compositor", "viewtop", "window",
|
|
"gesture", "lockscreen", "card", "render"],
|
|
"sensorium": ["sensor", "grip", "battery", "charge", "audio", "microphone", "camera",
|
|
"haptic", "bluetooth", "airpods", "librepods", "modem", "bearer",
|
|
"brightness", "fingerprint", "touch", "squeeze"],
|
|
"release": ["release", "package", "pkgbuild", "pacman", "repo-add", "distribution",
|
|
"artifact", "signing", "manifest"],
|
|
"reliability": ["crash", "stall", "deadlock", "leak", "regression", "revert",
|
|
"hotfix", "clippy", "flake", "panic", "timeout", "retry", "workflow"],
|
|
"federation": ["federation", "enrol", "enroll", "peer", "mirror", "distributed",
|
|
"multi-device", "household", "sync"],
|
|
"adoption": ["license", "licence", "install", "provision", "onboard",
|
|
"quickstart", "getting started", "contributor"],
|
|
}
|
|
|
|
# Two path shapes that no keyword scan should have to guess at.
|
|
PATH_RULES = [
|
|
("reliability", r"(^|/)\.gitea/"),
|
|
("release", r"(^|/)(packaging|pkgs?)/|PKGBUILD"),
|
|
]
|
|
|
|
# Only for a commit that neither its paths nor its subject place. A repo whose
|
|
# whole purpose is one dimension answers for it.
|
|
REPO_DEFAULT = {
|
|
"souveraine": "runtime",
|
|
"Pixel3Arch": "sensorium",
|
|
"linux-blueline": "sensorium",
|
|
"souveraine-viewtop": "surface",
|
|
"souveraine-lens": "adoption",
|
|
"souveraine-updater": "release",
|
|
"souveraine-player": "surface",
|
|
"souveraine-speech": "sensorium",
|
|
"souveraine-usb": "federation",
|
|
"culver": "surface",
|
|
"hexagonrpc": "sensorium",
|
|
"smithay": "surface",
|
|
"pocketboot": "release",
|
|
"libcmatrix": "surface",
|
|
"upower": "sensorium",
|
|
}
|
|
|
|
# Commit subjects are written for the man who owns the machines. Evidence stays
|
|
# raw because it never leaves the forge; this is here for whatever gets promoted
|
|
# to a public surface later. Same patterns as the ledger's collector.
|
|
REDACTIONS = [
|
|
(re.compile(r'\b(?:10|127)(?:\.\d{1,3}){2,3}(?::\d+)?\b'), "[host]"),
|
|
(re.compile(r'\b192\.168(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
|
|
(re.compile(r'\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){1,2}(?::\d+)?\b'), "[host]"),
|
|
(re.compile(r'\b(?:WIUF[-\w]*|wiufph|archdev)\b', re.I), "[host]"),
|
|
(re.compile(r'/home/casey|/root\b'), "~"),
|
|
]
|
|
|
|
|
|
def redact(text):
|
|
for pattern, mask in REDACTIONS:
|
|
text = pattern.sub(mask, text)
|
|
return text
|
|
|
|
|
|
def score(text):
|
|
"""Dimension scores for any text; ties go to the earlier dimension."""
|
|
low = (text or "").lower()
|
|
hits = {d: sum(len(k) for k in words if k in low) for d, words in KEYWORDS.items()}
|
|
hits = {d: s for d, s in hits.items() if s}
|
|
if not hits:
|
|
return None, {}
|
|
best = max(hits, key=lambda d: (hits[d], -DIMENSIONS.index(d)))
|
|
return best, hits
|
|
|
|
|
|
def classify_text(text):
|
|
"""For a task row or a document: what is this about, or nothing."""
|
|
best, _ = score(text)
|
|
return (best, "text") if best else (UNASSIGNED, "none")
|
|
|
|
|
|
def classify(repo, subject, paths=None):
|
|
"""One dimension for a commit, and the basis that chose it.
|
|
|
|
Paths and subject are both read and weighted alike. Paths alone put
|
|
`compaction: target the active conversation` under the server module it
|
|
edits; the subject is the author saying which subsystem he thinks he moved.
|
|
"""
|
|
joined = " ".join(paths or [])
|
|
_, from_paths = score(joined)
|
|
_, from_subject = score(subject)
|
|
total = {d: from_paths.get(d, 0) + from_subject.get(d, 0)
|
|
for d in set(from_paths) | set(from_subject)}
|
|
if total:
|
|
best = max(total, key=lambda d: (total[d], -DIMENSIONS.index(d)))
|
|
return best, "paths" if from_paths.get(best, 0) else "subject"
|
|
for dim, pattern in PATH_RULES:
|
|
if joined and re.search(pattern, joined, re.I):
|
|
return dim, "paths"
|
|
if repo in REPO_DEFAULT:
|
|
return REPO_DEFAULT[repo], "repo"
|
|
return UNASSIGNED, "none"
|
|
|
|
|
|
# ---- weeks -----------------------------------------------------------------
|
|
|
|
def parse_week(spec, today=None):
|
|
"""'2026-W33', 'current', 'last' -> (label, monday, sunday) as local dates."""
|
|
today = today or dt.datetime.now(LOCAL).date()
|
|
if spec in ("current", "last", None):
|
|
monday = today - dt.timedelta(days=today.isoweekday() - 1)
|
|
if spec == "last":
|
|
monday -= dt.timedelta(days=7)
|
|
else:
|
|
m = re.fullmatch(r"(\d{4})-?W(\d{1,2})", spec.upper())
|
|
if not m:
|
|
raise ValueError(f"week must look like 2026-W33, got {spec!r}")
|
|
monday = dt.date.fromisocalendar(int(m.group(1)), int(m.group(2)), 1)
|
|
year, week, _ = monday.isocalendar()
|
|
return f"{year}-W{week:02d}", monday, monday + dt.timedelta(days=6)
|
|
|
|
|
|
def week_bounds_utc(monday, sunday):
|
|
"""Local week -> UTC instants, so a Sunday-evening commit stays in its week."""
|
|
start = dt.datetime.combine(monday, dt.time.min, LOCAL).astimezone(dt.timezone.utc)
|
|
end = dt.datetime.combine(sunday + dt.timedelta(days=1), dt.time.min, LOCAL).astimezone(dt.timezone.utc)
|
|
return start, end
|
|
|
|
|
|
def week_monday(label):
|
|
year, week = label.split("-W")
|
|
return dt.date.fromisocalendar(int(year), int(week), 1)
|
|
|
|
|
|
def label_for(monday):
|
|
y, w, _ = monday.isocalendar()
|
|
return f"{y}-W{w:02d}"
|
|
|
|
|
|
def previous_week(label):
|
|
return label_for(week_monday(label) - dt.timedelta(days=7))
|
|
|
|
|
|
def week_span(first, last):
|
|
"""Every ISO week label from first to last inclusive, in order."""
|
|
monday, end, out = week_monday(first), week_monday(last), []
|
|
while monday <= end:
|
|
out.append(label_for(monday))
|
|
monday += dt.timedelta(days=7)
|
|
return out
|
|
|
|
|
|
def iso_z(when):
|
|
return when.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
def parse_iso(value):
|
|
if not value:
|
|
return None
|
|
try:
|
|
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
# ---- forge -----------------------------------------------------------------
|
|
|
|
class Forge:
|
|
# Gitea clamps a page to MAX_RESPONSE_ITEMS (50) and ignores a larger limit
|
|
# without saying so. The ledger lost 24 of 74 repos to this for months.
|
|
PAGE = 50
|
|
|
|
def __init__(self, base=None, token=None):
|
|
self.base = (base or os.environ.get("GITEA_URL") or "http://10.10.20.120:4455").rstrip("/")
|
|
self.token = token or os.environ.get("GITEA_TOKEN") or self._stored_token()
|
|
self.calls = 0
|
|
|
|
def _stored_token(self):
|
|
path = os.path.expanduser("~/.git-credentials")
|
|
host = urllib.parse.urlsplit(self.base).hostname or ""
|
|
try:
|
|
for line in open(path):
|
|
parsed = urllib.parse.urlsplit(line.strip())
|
|
if parsed.hostname and parsed.hostname.split("%")[0] == host:
|
|
return parsed.password or ""
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
def get(self, path):
|
|
req = urllib.request.Request(f"{self.base}/api/v1/{path}")
|
|
if self.token:
|
|
req.add_header("Authorization", f"token {self.token}")
|
|
self.calls += 1
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
return json.load(r)
|
|
|
|
def pages(self, path, max_pages=40):
|
|
sep = "&" if "?" in path else "?"
|
|
out = []
|
|
for page in range(1, max_pages + 1):
|
|
chunk = self.get(f"{path}{sep}limit={self.PAGE}&page={page}")
|
|
if isinstance(chunk, dict):
|
|
chunk = chunk.get("workflow_runs") or chunk.get("data") or []
|
|
if not chunk:
|
|
return out
|
|
out += chunk
|
|
if len(chunk) < self.PAGE:
|
|
return out
|
|
raise RuntimeError(f"{path}: {max_pages} pages and still going")
|
|
|
|
def version(self):
|
|
return self.get("version").get("version", "?")
|
|
|
|
|
|
# ---- local checkouts -------------------------------------------------------
|
|
|
|
def checkout(repo):
|
|
path = os.path.join(PROJECTS, repo)
|
|
return path if os.path.isdir(os.path.join(path, ".git")) else None
|
|
|
|
|
|
def git(path, *args):
|
|
proc = subprocess.run(["git", "-C", path, *args], capture_output=True, text=True)
|
|
return proc.stdout if proc.returncode == 0 else ""
|
|
|
|
|
|
def commit_paths(repo, sha):
|
|
"""Changed paths from the local clone, empty when it doesn't have the SHA."""
|
|
path = checkout(repo)
|
|
if not path:
|
|
return []
|
|
out = git(path, "show", "--name-only", "--format=", "--no-renames", sha)
|
|
return [line for line in out.splitlines() if line.strip()]
|