Compare commits
1 Commits
refactor/n
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4cf6a6196 |
BIN
bin/__pycache__/nexus_watchdog.cpython-311.pyc
Normal file
BIN
bin/__pycache__/nexus_watchdog.cpython-311.pyc
Normal file
Binary file not shown.
487
bin/ezra_weekly_report.py
Normal file
487
bin/ezra_weekly_report.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user