the-custodian/scripts/sync_resolved_decisions.py
2026-10-21 08:08:41 +02:00

70 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""Resolve open hub decisions that a repo decision log already records as resolved.
Repo files are authoritative (ADR-001); the hub is a read model. This scans
~/*/decisions/*.md for fenced yaml blocks with `id`, `title`, `status: resolved`
and resolves the open hub decision whose title matches exactly (whitespace-
normalized, case-insensitive). Uses the sanctioned POST /decisions/{id}/resolve.
Usage: sync_resolved_decisions.py [--apply] [--hub http://127.0.0.1:8000]
"""
import argparse, glob, json, os, re, time, urllib.error, urllib.request
def get_json(url, attempts=3, delay=5):
# The workstation hub forward occasionally drops a first request; retry reads only.
for i in range(attempts):
try:
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)
except (ConnectionError, urllib.error.URLError):
if i == attempts - 1:
raise
time.sleep(delay * (i + 1))
def norm(s):
return re.sub(r"\s+", " ", s.strip().strip("'\"")).lower()
def repo_resolved(root):
out = {}
for f in glob.glob(os.path.join(root, "*", "decisions", "*.md")):
text = open(f, encoding="utf-8", errors="ignore").read()
for b in re.findall(r"```yaml\n(.*?)```", text, re.S):
g = lambda k: (re.search(rf"^{k}:\s*(.*)$", b, re.M) or [None, ""])[1]
if g("status").strip() in ("resolved", "accepted") and g("title"):
out[norm(g("title"))] = (g("id").strip(), os.path.relpath(f, root))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--hub", default="http://127.0.0.1:8000")
ap.add_argument("--root", default=os.path.expanduser("~"))
ap.add_argument("--apply", action="store_true")
a = ap.parse_args()
known = repo_resolved(a.root)
data = get_json(f"{a.hub}/decisions/?status=open&limit=500")
data = data.get("items", data) if isinstance(data, dict) else data
hits = 0
for d in data:
t = norm(d["title"])
m = known.get(t) or next((v for k, v in known.items()
if min(len(k), len(t)) >= 40 and (k.startswith(t) or t.startswith(k))), None)
if not m:
continue
hits += 1
rid, path = m
print(f"{d['id'][:8]} {rid} {path} | {d['title'][:70]}")
if a.apply:
body = json.dumps({"rationale": f"Resolved in repo decision log {path} ({rid}); hub synced to repo state.",
"decided_by": "bernd"}).encode()
req = urllib.request.Request(f"{a.hub}/decisions/{d['id']}/resolve", data=body,
headers={"Content-Type": "application/json"}, method="POST")
urllib.request.urlopen(req).read()
print(f"{hits} open hub decisions match resolved repo records" + ("" if a.apply else " (dry run)"))
if __name__ == "__main__":
main()