178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""
|
|
Morning Report Generator — runs at 0600 to compile overnight activity.
|
|
Gathers: cycles executed, issues closed, PRs merged, commits pushed.
|
|
Outputs a structured report for delivery to the main channel.
|
|
|
|
Includes a HEARTBEAT PANEL that checks all cron job heartbeats via
|
|
bezalel_heartbeat_check.py (poka-yoke #1096). Any stale jobs surface
|
|
as blockers in the report.
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
def generate_morning_report():
|
|
"""Generate the morning report for the last 24h."""
|
|
now = datetime.now(timezone.utc)
|
|
since = now - timedelta(hours=24)
|
|
since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
repos = [
|
|
"Timmy_Foundation/timmy-home",
|
|
"Timmy_Foundation/timmy-config",
|
|
"Timmy_Foundation/the-nexus",
|
|
"Timmy_Foundation/hermes-agent",
|
|
]
|
|
|
|
report = {
|
|
"generated_at": now.strftime("%Y-%m-%d %H:%M UTC"),
|
|
"period": f"Last 24h since {since_str}",
|
|
"highlights": [],
|
|
"blockers": [],
|
|
"repos": {},
|
|
}
|
|
|
|
token = open(os.path.expanduser("~/.config/gitea/token")).read().strip()
|
|
|
|
from urllib.request import Request, urlopen
|
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
|
|
|
for repo in repos:
|
|
repo_data = {"closed_issues": 0, "merged_prs": 0, "recent_commits": 0}
|
|
|
|
# Closed issues in last 24h
|
|
url = f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues?state=closed&since={since_str}"
|
|
try:
|
|
resp = urlopen(Request(url, headers=headers), timeout=10)
|
|
issues = json.loads(resp.read())
|
|
repo_data["closed_issues"] = len(issues)
|
|
for i in issues[:5]:
|
|
report["highlights"].append(f"Closed {repo.split('/')[-1]}#{i['number']}: {i['title']}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Merged PRs
|
|
url = f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/pulls?state=closed"
|
|
try:
|
|
resp = urlopen(Request(url, headers=headers), timeout=10)
|
|
prs = json.loads(resp.read())
|
|
merged = [p for p in prs if p.get("merged")]
|
|
repo_data["merged_prs"] = len(merged)
|
|
except Exception:
|
|
pass
|
|
|
|
report["repos"][repo.split("/")[-1]] = repo_data
|
|
|
|
# Check for stuck workers (blockers)
|
|
worker_logs = list(Path("/tmp").glob("codeclaw-qwen-worker-*.log"))
|
|
stuck = 0
|
|
for wf in worker_logs:
|
|
try:
|
|
data = json.loads(wf.read_text().strip())
|
|
if data.get("exit") != 0 and not data.get("has_work"):
|
|
stuck += 1
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
if stuck > 0:
|
|
report["blockers"].append(f"{stuck} worker(s) failed without producing work")
|
|
|
|
# Check dead letter queue
|
|
dlq_path = Path(os.path.expanduser("~/.local/timmy/burn-state/dead-letter.json"))
|
|
if dlq_path.exists():
|
|
try:
|
|
dlq = json.loads(dlq_path.read_text())
|
|
if dlq:
|
|
report["blockers"].append(f"{len(dlq)} action(s) in dead letter queue")
|
|
except Exception:
|
|
pass
|
|
|
|
# Checkpoint status
|
|
cp_path = Path(os.path.expanduser("~/.local/timmy/burn-state/cycle-state.json"))
|
|
if cp_path.exists():
|
|
try:
|
|
cp = json.loads(cp_path.read_text())
|
|
if cp.get("status") == "in-progress":
|
|
ts = cp.get("timestamp", "")
|
|
if ts and datetime.fromisoformat(ts) < since:
|
|
report["blockers"].append(f"Stale checkpoint: {cp.get('action')} since {ts}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Summary
|
|
total_closed = sum(r["closed_issues"] for r in report["repos"].values())
|
|
total_merged = sum(r["merged_prs"] for r in report["repos"].values())
|
|
|
|
print(f"=== MORNING REPORT {report['generated_at']} ===")
|
|
print(f"Period: {report['period']}")
|
|
print(f"Issues closed: {total_closed}")
|
|
print(f"PRs merged: {total_merged}")
|
|
print("")
|
|
if report["highlights"]:
|
|
print("HIGHLIGHTS:")
|
|
for h in report["highlights"]:
|
|
print(f" + {h}")
|
|
if report["blockers"]:
|
|
print("BLOCKERS:")
|
|
for b in report["blockers"]:
|
|
print(f" - {b}")
|
|
if not report["highlights"] and not report["blockers"]:
|
|
print("No significant activity or blockers detected.")
|
|
print("")
|
|
|
|
# ── Heartbeat panel (poka-yoke #1096) ────────────────────────────────────
|
|
# Import bezalel_heartbeat_check via importlib so we don't need __init__.py
|
|
# or a sys.path hack. If the module is missing or the dir doesn't exist,
|
|
# we print a "not provisioned" notice and continue — never crash the report.
|
|
_hb_result = None
|
|
try:
|
|
_project_root = Path(__file__).parent.parent
|
|
_hb_spec = importlib.util.spec_from_file_location(
|
|
"bezalel_heartbeat_check",
|
|
_project_root / "bin" / "bezalel_heartbeat_check.py",
|
|
)
|
|
if _hb_spec is not None:
|
|
_hb_mod = importlib.util.module_from_spec(_hb_spec)
|
|
sys.modules.setdefault("bezalel_heartbeat_check", _hb_mod)
|
|
_hb_spec.loader.exec_module(_hb_mod) # type: ignore[union-attr]
|
|
_hb_result = _hb_mod.check_cron_heartbeats()
|
|
except Exception:
|
|
_hb_result = None
|
|
|
|
print("HEARTBEAT PANEL:")
|
|
if _hb_result is None or not _hb_result.get("jobs"):
|
|
print(" HEARTBEAT PANEL: no data (bezalel not provisioned)")
|
|
report["heartbeat_panel"] = {"status": "not_provisioned"}
|
|
else:
|
|
for _job in _hb_result["jobs"]:
|
|
_prefix = "+" if _job["healthy"] else "-"
|
|
print(f" {_prefix} {_job['job']}: {_job['message']}")
|
|
if not _job["healthy"]:
|
|
report["blockers"].append(
|
|
f"Stale heartbeat: {_job['job']} — {_job['message']}"
|
|
)
|
|
print("")
|
|
report["heartbeat_panel"] = {
|
|
"checked_at": _hb_result.get("checked_at"),
|
|
"healthy_count": _hb_result.get("healthy_count", 0),
|
|
"stale_count": _hb_result.get("stale_count", 0),
|
|
"jobs": _hb_result.get("jobs", []),
|
|
}
|
|
|
|
# Save report
|
|
report_dir = Path(os.path.expanduser("~/.local/timmy/reports"))
|
|
report_dir.mkdir(parents=True, exist_ok=True)
|
|
report_file = report_dir / f"morning-{now.strftime('%Y-%m-%d')}.json"
|
|
report_file.write_text(json.dumps(report, indent=2))
|
|
print(f"Report saved: {report_file}")
|
|
return report
|
|
|
|
|
|
if __name__ == "__main__":
|
|
generate_morning_report()
|