From b4cf6a619667ba8174dd0e9a158ec7892a9307b6 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sat, 4 Apr 2026 15:45:05 -0400 Subject: [PATCH] WIP: Claude Code progress on #828 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated salvage commit — agent session ended (exit 1). Work in progress, may need continuation. --- .../nexus_watchdog.cpython-311.pyc | Bin 0 -> 26822 bytes bin/ezra_weekly_report.py | 487 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 bin/__pycache__/nexus_watchdog.cpython-311.pyc create mode 100644 bin/ezra_weekly_report.py diff --git a/bin/__pycache__/nexus_watchdog.cpython-311.pyc b/bin/__pycache__/nexus_watchdog.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c63227ba35ca64b91eb5272aca033a3deda3f13 GIT binary patch literal 26822 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 z zke9PSDoM-ZrMZ2&m`5eo*Nf4MvWtcn44@b6nL{tiqkom57b?TA*^$X$kv3JRa#R`< z=hBJi=AcZR6I-XABh)`d7SePo5D}>H$?h=CaDSMr)e(_ǐnKaKJyDG8FE3PCsh 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 literal 0 HcmV?d00001 diff --git a/bin/ezra_weekly_report.py b/bin/ezra_weekly_report.py new file mode 100644 index 00000000..43836217 --- /dev/null +++ b/bin/ezra_weekly_report.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +""" +Ezra Weekly Wizard Performance Report + +Runs weekly (via cron) and reports wizard fleet performance to the +Timmy Time Telegram group. Surfaces problems before Alexander has to ask. + +Metrics reported: + - Issues opened/closed per wizard (7-day window) + - Unassigned issue count + - Overloaded wizards (>15 open assignments) + - Idle wizards (0 closes in 7 days) + +USAGE +===== + # One-shot report + python bin/ezra_weekly_report.py + + # Dry-run (print to stdout, don't send Telegram) + python bin/ezra_weekly_report.py --dry-run + + # Crontab entry (every Monday at 09:00) + 0 9 * * 1 cd /path/to/the-nexus && python bin/ezra_weekly_report.py + +ENVIRONMENT +=========== + GITEA_URL Gitea base URL (default: http://143.198.27.163:3000) + GITEA_TOKEN Gitea API token + NEXUS_REPO Repository slug (default: Timmy_Foundation/the-nexus) + TELEGRAM_BOT_TOKEN Telegram bot token for delivery + TELEGRAM_CHAT_ID Telegram chat/group ID for delivery + +ZERO DEPENDENCIES +================= +Pure stdlib. No pip installs. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("ezra.weekly_report") + +# ── Configuration ──────────────────────────────────────────────────── + +GITEA_URL = os.environ.get("GITEA_URL", "http://143.198.27.163:3000") +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus") +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") + +OVERLOAD_THRESHOLD = 15 # open assignments above this = overloaded +WINDOW_DAYS = 7 # look-back window for opened/closed counts +PAGE_LIMIT = 50 # Gitea items per page + + +# ── Data types ──────────────────────────────────────────────────────── + +@dataclass +class WizardStats: + """Per-wizard performance data for the reporting window.""" + login: str + opened: int = 0 # issues opened in the window + closed: int = 0 # issues closed in the window + open_assignments: int = 0 # currently open issues assigned to this wizard + + @property + def is_overloaded(self) -> bool: + return self.open_assignments > OVERLOAD_THRESHOLD + + @property + def is_idle(self) -> bool: + return self.closed == 0 + + +@dataclass +class WeeklyReport: + """Aggregate weekly performance report.""" + generated_at: float + window_days: int + wizard_stats: Dict[str, WizardStats] = field(default_factory=dict) + unassigned_count: int = 0 + + @property + def overloaded(self) -> List[WizardStats]: + return [s for s in self.wizard_stats.values() if s.is_overloaded] + + @property + def idle(self) -> List[WizardStats]: + """Wizards with open assignments but zero closes in the window.""" + return [ + s for s in self.wizard_stats.values() + if s.is_idle and s.open_assignments > 0 + ] + + def to_markdown(self) -> str: + """Format the report as Telegram-friendly markdown.""" + ts = datetime.fromtimestamp(self.generated_at, tz=timezone.utc) + ts_str = ts.strftime("%Y-%m-%d %H:%M UTC") + window = self.window_days + + lines = [ + f"📊 *Ezra Weekly Wizard Report* — {ts_str}", + f"_{window}-day window_", + "", + ] + + # ── Per-wizard throughput table ────────────────────────────── + lines.append("*Wizard Throughput*") + lines.append("```") + lines.append(f"{'Wizard':<18} {'Opened':>6} {'Closed':>6} {'Open':>6}") + lines.append("-" * 40) + + sorted_wizards = sorted( + self.wizard_stats.values(), + key=lambda s: s.closed, + reverse=True, + ) + for s in sorted_wizards: + flag = " ⚠️" if s.is_overloaded else (" 💤" if s.is_idle and s.open_assignments > 0 else "") + lines.append( + f"{s.login:<18} {s.opened:>6} {s.closed:>6} {s.open_assignments:>6}{flag}" + ) + + lines.append("```") + lines.append("") + + # ── Summary ────────────────────────────────────────────────── + total_opened = sum(s.opened for s in self.wizard_stats.values()) + total_closed = sum(s.closed for s in self.wizard_stats.values()) + lines.append( + f"*Fleet totals:* {total_opened} opened · {total_closed} closed · " + f"{self.unassigned_count} unassigned" + ) + lines.append("") + + # ── Alerts ─────────────────────────────────────────────────── + alerts = [] + if self.overloaded: + names = ", ".join(s.login for s in self.overloaded) + alerts.append( + f"🔴 *Overloaded* (>{OVERLOAD_THRESHOLD} open): {names}" + ) + if self.idle: + names = ", ".join(s.login for s in self.idle) + alerts.append(f"💤 *Idle* (0 closes in {window}d): {names}") + if self.unassigned_count > 0: + alerts.append( + f"📭 *Unassigned issues:* {self.unassigned_count} waiting for triage" + ) + + if alerts: + lines.append("*Alerts*") + lines.extend(alerts) + else: + lines.append("✅ No alerts — fleet running clean.") + + lines.append("") + lines.append("_— Ezra, archivist-wizard_") + return "\n".join(lines) + + +# ── Gitea API ───────────────────────────────────────────────────────── + +def _gitea_request( + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[dict] = None, +) -> Any: + """Make a Gitea API request. Returns parsed JSON or None on failure.""" + url = f"{GITEA_URL.rstrip('/')}/api/v1{path}" + if params: + url = f"{url}?{urllib.parse.urlencode(params)}" + + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + if GITEA_TOKEN: + req.add_header("Authorization", f"token {GITEA_TOKEN}") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as e: + logger.warning("Gitea HTTP %d at %s: %s", e.code, path, e.read().decode()[:200]) + return None + except Exception as e: + logger.warning("Gitea request failed (%s): %s", path, e) + return None + + +def _fetch_all_issues(state: str = "open", since: Optional[str] = None) -> List[dict]: + """Fetch all issues from the repo, paginating through results. + + Args: + state: "open" or "closed" + since: ISO 8601 timestamp — only issues updated at or after this time + """ + all_issues: List[dict] = [] + page = 1 + + while True: + params: Dict[str, Any] = { + "state": state, + "type": "issues", + "limit": PAGE_LIMIT, + "page": page, + } + if since: + params["since"] = since + + items = _gitea_request("GET", f"/repos/{GITEA_REPO}/issues", params=params) + if not items or not isinstance(items, list): + break + all_issues.extend(items) + if len(items) < PAGE_LIMIT: + break + page += 1 + + return all_issues + + +def _iso_since(days: int) -> str: + """Return an ISO 8601 timestamp for N days ago (UTC).""" + ts = time.time() - days * 86400 + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +# ── Report assembly ─────────────────────────────────────────────────── + +def _collect_opened_in_window(window_days: int) -> Dict[str, int]: + """Count issues opened per wizard in the window.""" + since_str = _iso_since(window_days) + since_ts = time.time() - window_days * 86400 + + # All open issues updated since the window (may have been opened before) + all_open = _fetch_all_issues(state="open", since=since_str) + # All closed issues updated since the window + all_closed = _fetch_all_issues(state="closed", since=since_str) + + counts: Dict[str, int] = {} + + for issue in all_open + all_closed: + created_at = issue.get("created_at", "") + if not created_at: + continue + try: + dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + created_ts = dt.timestamp() + except (ValueError, AttributeError): + continue + + if created_ts < since_ts: + continue # opened before the window + + poster = (issue.get("user") or {}).get("login", "") + if poster: + counts[poster] = counts.get(poster, 0) + 1 + + return counts + + +def _collect_closed_in_window(window_days: int) -> Dict[str, int]: + """Count issues closed per wizard (the assignee at close time).""" + since_str = _iso_since(window_days) + since_ts = time.time() - window_days * 86400 + + closed_issues = _fetch_all_issues(state="closed", since=since_str) + + counts: Dict[str, int] = {} + + for issue in closed_issues: + closed_at = issue.get("closed_at") or issue.get("updated_at", "") + if not closed_at: + continue + try: + dt = datetime.fromisoformat(closed_at.replace("Z", "+00:00")) + closed_ts = dt.timestamp() + except (ValueError, AttributeError): + continue + + if closed_ts < since_ts: + continue # closed before the window + + # Credit the assignee; fall back to issue poster + assignees = issue.get("assignees") or [] + if assignees: + for assignee in assignees: + login = (assignee or {}).get("login", "") + if login: + counts[login] = counts.get(login, 0) + 1 + else: + poster = (issue.get("user") or {}).get("login", "") + if poster: + counts[poster] = counts.get(poster, 0) + 1 + + return counts + + +def _collect_open_assignments() -> Dict[str, int]: + """Count currently open issues per assignee.""" + open_issues = _fetch_all_issues(state="open") + counts: Dict[str, int] = {} + + for issue in open_issues: + assignees = issue.get("assignees") or [] + for assignee in assignees: + login = (assignee or {}).get("login", "") + if login: + counts[login] = counts.get(login, 0) + 1 + + return counts + + +def _count_unassigned() -> int: + """Count open issues with no assignee.""" + open_issues = _fetch_all_issues(state="open") + return sum( + 1 for issue in open_issues + if not (issue.get("assignees") or []) + ) + + +def build_report(window_days: int = WINDOW_DAYS) -> WeeklyReport: + """Fetch data from Gitea and assemble the weekly report.""" + logger.info("Fetching wizard performance data (window: %d days)", window_days) + + opened = _collect_opened_in_window(window_days) + logger.info("Opened counts: %s", opened) + + closed = _collect_closed_in_window(window_days) + logger.info("Closed counts: %s", closed) + + open_assignments = _collect_open_assignments() + logger.info("Open assignments: %s", open_assignments) + + unassigned = _count_unassigned() + logger.info("Unassigned issues: %d", unassigned) + + # Merge all wizard logins into a unified stats dict + all_logins = set(opened) | set(closed) | set(open_assignments) + wizard_stats: Dict[str, WizardStats] = {} + for login in sorted(all_logins): + wizard_stats[login] = WizardStats( + login=login, + opened=opened.get(login, 0), + closed=closed.get(login, 0), + open_assignments=open_assignments.get(login, 0), + ) + + return WeeklyReport( + generated_at=time.time(), + window_days=window_days, + wizard_stats=wizard_stats, + unassigned_count=unassigned, + ) + + +# ── Telegram delivery ───────────────────────────────────────────────── + +def send_telegram(text: str, bot_token: str, chat_id: str) -> bool: + """Send a message to a Telegram chat via the Bot API. + + Returns True on success, False on failure. + """ + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + data = json.dumps({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + }).encode() + + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode() + result = json.loads(raw) + if result.get("ok"): + logger.info("Telegram delivery: OK (message_id=%s)", result.get("result", {}).get("message_id")) + return True + logger.error("Telegram API error: %s", result.get("description", "unknown")) + return False + except urllib.error.HTTPError as e: + logger.error("Telegram HTTP %d: %s", e.code, e.read().decode()[:200]) + return False + except Exception as e: + logger.error("Telegram delivery failed: %s", e) + return False + + +# ── CLI ─────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="Ezra Weekly Wizard Performance Report", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the report to stdout instead of sending to Telegram", + ) + parser.add_argument( + "--window", + type=int, + default=WINDOW_DAYS, + help=f"Look-back window in days (default: {WINDOW_DAYS})", + ) + parser.add_argument( + "--json", + action="store_true", + dest="output_json", + help="Output report data as JSON (for integration with other tools)", + ) + args = parser.parse_args() + + if not GITEA_TOKEN and not args.dry_run: + logger.warning("GITEA_TOKEN not set — Gitea API calls will be unauthenticated") + + report = build_report(window_days=args.window) + markdown = report.to_markdown() + + if args.output_json: + data = { + "generated_at": report.generated_at, + "window_days": report.window_days, + "unassigned_count": report.unassigned_count, + "wizards": { + login: { + "opened": s.opened, + "closed": s.closed, + "open_assignments": s.open_assignments, + "overloaded": s.is_overloaded, + "idle": s.is_idle, + } + for login, s in report.wizard_stats.items() + }, + "alerts": { + "overloaded": [s.login for s in report.overloaded], + "idle": [s.login for s in report.idle], + }, + } + print(json.dumps(data, indent=2)) + return + + if args.dry_run: + print(markdown) + return + + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID: + logger.error( + "TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set for delivery. " + "Use --dry-run to print without sending." + ) + sys.exit(1) + + success = send_telegram(markdown, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID) + if not success: + logger.error("Failed to deliver report to Telegram") + sys.exit(1) + + logger.info("Weekly report delivered successfully") + + +if __name__ == "__main__": + main()