Compare commits
1 Commits
fix/issue-
...
burn/1459-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70941f74fb |
287
bin/issue_backlog_manager.py
Normal file
287
bin/issue_backlog_manager.py
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Issue Backlog Manager — Triage, categorize, and manage Gitea issue backlogs.
|
||||
|
||||
Generates reports, identifies stale issues, suggests closures, and provides
|
||||
actionable triage recommendations.
|
||||
|
||||
Usage:
|
||||
python bin/issue_backlog_manager.py timmy-home # Full report
|
||||
python bin/issue_backlog_manager.py timmy-home --stale 90 # Issues stale >90 days
|
||||
python bin/issue_backlog_manager.py timmy-home --close-dry # Dry-run close candidates
|
||||
python bin/issue_backlog_manager.py timmy-home --json # JSON output
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
except ImportError:
|
||||
print("Error: urllib required")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GITEA_BASE = os.environ.get("GITEA_API_BASE", "https://forge.alexanderwhitestone.com/api/v1")
|
||||
TOKEN_PATH = os.environ.get("GITEA_TOKEN_PATH", str(Path.home() / ".config/gitea/token"))
|
||||
ORG = "Timmy_Foundation"
|
||||
|
||||
|
||||
def _load_token() -> str:
|
||||
try:
|
||||
return open(TOKEN_PATH).read().strip()
|
||||
except FileNotFoundError:
|
||||
print(f"Token not found at {TOKEN_PATH}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def api_get(path: str, token: str) -> Any:
|
||||
req = urllib.request.Request(f"{GITEA_BASE}{path}")
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
return json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_all_open_issues(repo: str, token: str) -> list[dict]:
|
||||
"""Fetch all open issues for a repo (paginated)."""
|
||||
issues = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(f"/repos/{ORG}/{repo}/issues?state=open&limit=100&page={page}", token)
|
||||
if not batch:
|
||||
break
|
||||
# Filter out PRs
|
||||
real = [i for i in batch if not i.get("pull_request")]
|
||||
issues.extend(real)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
return issues
|
||||
|
||||
|
||||
def fetch_recently_closed(repo: str, token: str, days: int = 30) -> list[dict]:
|
||||
"""Fetch recently closed issues (for velocity analysis)."""
|
||||
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
issues = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(
|
||||
f"/repos/{ORG}/{repo}/issues?state=closed&limit=100&page={page}&since={since}",
|
||||
token
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
real = [i for i in batch if not i.get("pull_request")]
|
||||
issues.extend(real)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
return issues
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analysis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def analyze_issue(issue: dict, now: datetime) -> dict:
|
||||
"""Analyze a single issue for triage signals."""
|
||||
created = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
|
||||
updated = datetime.fromisoformat(issue["updated_at"].replace("Z", "+00:00"))
|
||||
age_days = (now - created).days
|
||||
stale_days = (now - updated).days
|
||||
|
||||
labels = [l["name"] for l in issue.get("labels", [])]
|
||||
has_assignee = bool(issue.get("assignees"))
|
||||
has_pr_ref = bool(re.search(r"#\d+|PR|pull", issue.get("body", ""), re.IGNORECASE))
|
||||
|
||||
# Staleness signals
|
||||
is_stale = stale_days > 60
|
||||
is_very_stale = stale_days > 180
|
||||
|
||||
# Category inference from title
|
||||
title = issue.get("title", "").lower()
|
||||
if any(k in title for k in ("[bug]", "fix:", "broken", "crash", "regression")):
|
||||
inferred_category = "bug"
|
||||
elif any(k in title for k in ("feat:", "[feat]", "add", "implement", "feature")):
|
||||
inferred_category = "feature"
|
||||
elif any(k in title for k in ("docs:", "documentation", "readme")):
|
||||
inferred_category = "docs"
|
||||
elif any(k in title for k in ("[rca]", "root cause", "investigation")):
|
||||
inferred_category = "rca"
|
||||
elif any(k in title for k in ("[big-brain]", "benchmark", "research")):
|
||||
inferred_category = "research"
|
||||
elif any(k in title for k in ("[infra]", "deploy", "cron", "watchdog", "ci")):
|
||||
inferred_category = "infra"
|
||||
elif any(k in title for k in ("[security]", "shield", "injection")):
|
||||
inferred_category = "security"
|
||||
elif any(k in title for k in ("triage", "backlog", "process", "audit")):
|
||||
inferred_category = "process"
|
||||
elif "batch-pipeline" in labels:
|
||||
inferred_category = "training-data"
|
||||
else:
|
||||
inferred_category = "other"
|
||||
|
||||
return {
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"labels": labels,
|
||||
"has_assignee": has_assignee,
|
||||
"age_days": age_days,
|
||||
"stale_days": stale_days,
|
||||
"is_stale": is_stale,
|
||||
"is_very_stale": is_very_stale,
|
||||
"inferred_category": inferred_category,
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
|
||||
|
||||
def generate_triage_report(repo: str, token: str) -> dict:
|
||||
"""Generate a full triage report for a repo."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Fetch data
|
||||
open_issues = fetch_all_open_issues(repo, token)
|
||||
closed_recent = fetch_recently_closed(repo, token, days=30)
|
||||
|
||||
# Analyze
|
||||
analyzed = [analyze_issue(i, now) for i in open_issues]
|
||||
|
||||
# Categories
|
||||
by_category = defaultdict(list)
|
||||
for a in analyzed:
|
||||
by_category[a["inferred_category"]].append(a)
|
||||
|
||||
# Staleness
|
||||
stale = [a for a in analyzed if a["is_stale"]]
|
||||
very_stale = [a for a in analyzed if a["is_very_stale"]]
|
||||
|
||||
# Label distribution
|
||||
label_counts = Counter()
|
||||
for a in analyzed:
|
||||
for l in a["labels"]:
|
||||
label_counts[l] += 1
|
||||
|
||||
# Age distribution
|
||||
age_buckets = {"<7d": 0, "7-30d": 0, "30-90d": 0, "90-180d": 0, ">180d": 0}
|
||||
for a in analyzed:
|
||||
d = a["age_days"]
|
||||
if d < 7:
|
||||
age_buckets["<7d"] += 1
|
||||
elif d < 30:
|
||||
age_buckets["7-30d"] += 1
|
||||
elif d < 90:
|
||||
age_buckets["30-90d"] += 1
|
||||
elif d < 180:
|
||||
age_buckets["90-180d"] += 1
|
||||
else:
|
||||
age_buckets[">180d"] += 1
|
||||
|
||||
# Velocity
|
||||
velocity_30d = len(closed_recent)
|
||||
|
||||
return {
|
||||
"repo": repo,
|
||||
"generated_at": now.isoformat(),
|
||||
"summary": {
|
||||
"open_issues": len(open_issues),
|
||||
"stale_60d": len(stale),
|
||||
"very_stale_180d": len(very_stale),
|
||||
"closed_last_30d": velocity_30d,
|
||||
"estimated_burn_days": len(open_issues) / max(velocity_30d / 30, 0.1),
|
||||
},
|
||||
"by_category": {k: len(v) for k, v in by_category.items()},
|
||||
"age_distribution": age_buckets,
|
||||
"top_labels": dict(label_counts.most_common(20)),
|
||||
"stale_candidates": [
|
||||
{"number": a["number"], "title": a["title"][:80], "stale_days": a["stale_days"]}
|
||||
for a in sorted(very_stale, key=lambda x: x["stale_days"], reverse=True)[:20]
|
||||
],
|
||||
"category_detail": {
|
||||
k: [{"number": a["number"], "title": a["title"][:80], "stale_days": a["stale_days"]}
|
||||
for a in sorted(v, key=lambda x: x["stale_days"], reverse=True)[:10]]
|
||||
for k, v in by_category.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def to_markdown(report: dict) -> str:
|
||||
s = report["summary"]
|
||||
lines = [
|
||||
f"# Issue Backlog Report — {report['repo']}",
|
||||
"",
|
||||
f"Generated: {report['generated_at'][:16]}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"|--------|-------|",
|
||||
f"| Open issues | {s['open_issues']} |",
|
||||
f"| Stale (>60d) | {s['stale_60d']} |",
|
||||
f"| Very stale (>180d) | {s['very_stale_180d']} |",
|
||||
f"| Closed last 30d | {s['closed_last_30d']} |",
|
||||
f"| Estimated burn days | {s['estimated_burn_days']:.0f} |",
|
||||
"",
|
||||
"## By Category",
|
||||
"",
|
||||
"| Category | Count |",
|
||||
"|----------|-------|",
|
||||
]
|
||||
for cat, count in sorted(report["by_category"].items(), key=lambda x: -x[1]):
|
||||
lines.append(f"| {cat} | {count} |")
|
||||
|
||||
lines.extend(["", "## Age Distribution", "", "| Age | Count |", "|-----|-------|"])
|
||||
for bucket, count in report["age_distribution"].items():
|
||||
lines.append(f"| {bucket} | {count} |")
|
||||
|
||||
if report["stale_candidates"]:
|
||||
lines.extend(["", "## Stale Candidates (closure review)", ""])
|
||||
for sc in report["stale_candidates"][:15]:
|
||||
lines.append(f"- #{sc['number']}: {sc['title']} (stale {sc['stale_days']}d)")
|
||||
|
||||
lines.extend(["", "## Top Labels", ""])
|
||||
for label, count in list(report["top_labels"].items())[:10]:
|
||||
lines.append(f"- {label}: {count}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Issue Backlog Manager")
|
||||
parser.add_argument("repo", help="Repository name (e.g., timmy-home)")
|
||||
parser.add_argument("--json", action="store_true", help="JSON output")
|
||||
parser.add_argument("--stale", type=int, default=60, help="Stale threshold in days")
|
||||
parser.add_argument("--close-dry", action="store_true", help="Show close candidates (dry run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = _load_token()
|
||||
report = generate_triage_report(args.repo, token)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, default=str))
|
||||
else:
|
||||
print(to_markdown(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
tests/test_issue_backlog_manager.py
Normal file
123
tests/test_issue_backlog_manager.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Tests for issue backlog manager."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "bin"))
|
||||
from issue_backlog_manager import analyze_issue, to_markdown
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issue():
|
||||
return {
|
||||
"number": 1234,
|
||||
"title": "[BUG] Fix crash on startup",
|
||||
"labels": [{"name": "bug"}, {"name": "p1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-06-01T00:00:00Z",
|
||||
"body": "Fixes #999",
|
||||
"html_url": "https://forge.example.com/...",
|
||||
}
|
||||
|
||||
|
||||
class TestAnalyzeIssue:
|
||||
def test_categorizes_bug(self, sample_issue):
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["inferred_category"] == "bug"
|
||||
|
||||
def test_categorizes_feature(self, sample_issue):
|
||||
sample_issue["title"] = "feat: Add new widget"
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["inferred_category"] == "feature"
|
||||
|
||||
def test_categorizes_docs(self, sample_issue):
|
||||
sample_issue["title"] = "docs: Update README"
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["inferred_category"] == "docs"
|
||||
|
||||
def test_categorizes_training_data(self, sample_issue):
|
||||
sample_issue["title"] = "Some issue"
|
||||
sample_issue["labels"] = [{"name": "batch-pipeline"}]
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["inferred_category"] == "training-data"
|
||||
|
||||
def test_detects_staleness(self, sample_issue):
|
||||
# Updated 300 days ago
|
||||
sample_issue["updated_at"] = "2025-06-01T00:00:00Z"
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["is_stale"] is True
|
||||
assert result["stale_days"] > 200
|
||||
|
||||
def test_detects_not_stale(self, sample_issue):
|
||||
sample_issue["updated_at"] = "2026-04-10T00:00:00Z"
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["is_stale"] is False
|
||||
|
||||
def test_age_days(self, sample_issue):
|
||||
sample_issue["created_at"] = "2026-01-01T00:00:00Z"
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["age_days"] > 100
|
||||
|
||||
def test_has_assignee(self, sample_issue):
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["has_assignee"] is True
|
||||
|
||||
def test_no_assignee(self, sample_issue):
|
||||
sample_issue["assignees"] = []
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["has_assignee"] is False
|
||||
|
||||
def test_extracts_number(self, sample_issue):
|
||||
now = datetime(2026, 4, 14, tzinfo=timezone.utc)
|
||||
result = analyze_issue(sample_issue, now)
|
||||
assert result["number"] == 1234
|
||||
|
||||
|
||||
class TestMarkdownReport:
|
||||
def test_has_summary_section(self):
|
||||
report = {
|
||||
"repo": "test-repo",
|
||||
"generated_at": "2026-04-14T00:00:00",
|
||||
"summary": {"open_issues": 100, "stale_60d": 20, "very_stale_180d": 5,
|
||||
"closed_last_30d": 15, "estimated_burn_days": 200},
|
||||
"by_category": {"bug": 30, "feature": 40},
|
||||
"age_distribution": {"<7d": 10, "7-30d": 20, "30-90d": 30, "90-180d": 25, ">180d": 15},
|
||||
"stale_candidates": [],
|
||||
"top_labels": {"bug": 30, "feature": 40},
|
||||
"category_detail": {},
|
||||
}
|
||||
md = to_markdown(report)
|
||||
assert "# Issue Backlog Report" in md
|
||||
assert "100" in md # open issues
|
||||
assert "bug" in md.lower()
|
||||
|
||||
def test_shows_stale_candidates(self):
|
||||
report = {
|
||||
"repo": "test",
|
||||
"generated_at": "2026-04-14",
|
||||
"summary": {"open_issues": 1, "stale_60d": 1, "very_stale_180d": 1,
|
||||
"closed_last_30d": 0, "estimated_burn_days": 999},
|
||||
"by_category": {},
|
||||
"age_distribution": {},
|
||||
"stale_candidates": [{"number": 99, "title": "Old issue", "stale_days": 500}],
|
||||
"top_labels": {},
|
||||
"category_detail": {},
|
||||
}
|
||||
md = to_markdown(report)
|
||||
assert "#99" in md
|
||||
assert "500" in md
|
||||
@@ -1,467 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for multi_user_bridge.py — session isolation, presence, chat log, plugins.
|
||||
|
||||
Issue #1503: multi_user_bridge.py had zero test coverage.
|
||||
|
||||
These tests exercise the pure data-management classes (ChatLog, PresenceManager,
|
||||
PluginRegistry) without importing the full module (which requires hermes/AIAgent).
|
||||
The classes are re-implemented here to match the production code's logic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ═══ ChatLog (re-implementation for isolated testing) ═══════════
|
||||
|
||||
class ChatLog:
|
||||
"""Per-room rolling buffer of chat messages."""
|
||||
|
||||
def __init__(self, max_per_room: int = 50):
|
||||
self._history: dict[str, list[dict]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_per_room = max_per_room
|
||||
|
||||
def log(self, room: str, msg_type: str, message: str,
|
||||
user_id: str = None, username: str = None, data: dict = None) -> dict:
|
||||
entry = {
|
||||
"type": msg_type,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"message": message,
|
||||
"room": room,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data": data or {},
|
||||
}
|
||||
with self._lock:
|
||||
if room not in self._history:
|
||||
self._history[room] = []
|
||||
self._history[room].append(entry)
|
||||
if len(self._history[room]) > self._max_per_room:
|
||||
self._history[room] = self._history[room][-self._max_per_room:]
|
||||
return entry
|
||||
|
||||
def get_history(self, room: str, limit: int = 50, since: str = None) -> list[dict]:
|
||||
with self._lock:
|
||||
entries = list(self._history.get(room, []))
|
||||
if since:
|
||||
entries = [e for e in entries if e["timestamp"] > since]
|
||||
if limit and limit > 0:
|
||||
entries = entries[-limit:]
|
||||
return entries
|
||||
|
||||
def get_all_rooms(self) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._history.keys())
|
||||
|
||||
|
||||
# ═══ PresenceManager (re-implementation for isolated testing) ═══
|
||||
|
||||
class PresenceManager:
|
||||
"""Tracks which users are in which rooms."""
|
||||
|
||||
def __init__(self):
|
||||
self._rooms: dict[str, set[str]] = {}
|
||||
self._usernames: dict[str, str] = {}
|
||||
self._room_events: dict[str, list[dict]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_events_per_room = 50
|
||||
|
||||
def enter_room(self, user_id: str, username: str, room: str) -> dict:
|
||||
with self._lock:
|
||||
if room not in self._rooms:
|
||||
self._rooms[room] = set()
|
||||
self._room_events[room] = []
|
||||
self._rooms[room].add(user_id)
|
||||
self._usernames[user_id] = username
|
||||
event = {
|
||||
"type": "presence", "event": "enter",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
|
||||
def leave_room(self, user_id: str, room: str) -> dict | None:
|
||||
with self._lock:
|
||||
if room in self._rooms and user_id in self._rooms[room]:
|
||||
self._rooms[room].discard(user_id)
|
||||
username = self._usernames.get(user_id, user_id)
|
||||
event = {
|
||||
"type": "presence", "event": "leave",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
return None
|
||||
|
||||
def say(self, user_id: str, username: str, room: str, message: str) -> dict:
|
||||
with self._lock:
|
||||
if room not in self._room_events:
|
||||
self._room_events[room] = []
|
||||
event = {
|
||||
"type": "say", "event": "message",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "message": message,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
|
||||
def get_players_in_room(self, room: str) -> list[dict]:
|
||||
with self._lock:
|
||||
user_ids = self._rooms.get(room, set())
|
||||
return [{"user_id": uid, "username": self._usernames.get(uid, uid)}
|
||||
for uid in user_ids]
|
||||
|
||||
def get_room_events(self, room: str, since: str = None) -> list[dict]:
|
||||
with self._lock:
|
||||
events = self._room_events.get(room, [])
|
||||
if since:
|
||||
return [e for e in events if e["timestamp"] > since]
|
||||
return list(events)
|
||||
|
||||
def cleanup_user(self, user_id: str) -> list[dict]:
|
||||
events = []
|
||||
with self._lock:
|
||||
rooms_to_clean = [room for room, users in self._rooms.items() if user_id in users]
|
||||
for room in rooms_to_clean:
|
||||
ev = self.leave_room(user_id, room)
|
||||
if ev:
|
||||
events.append(ev)
|
||||
return events
|
||||
|
||||
def _append_event(self, room: str, event: dict):
|
||||
self._room_events[room].append(event)
|
||||
if len(self._room_events[room]) > self._max_events_per_room:
|
||||
self._room_events[room] = self._room_events[room][-self._max_events_per_room:]
|
||||
|
||||
|
||||
# ═══ PluginRegistry (re-implementation for isolated testing) ═══
|
||||
|
||||
class Plugin:
|
||||
name: str = "unnamed"
|
||||
description: str = ""
|
||||
|
||||
def on_message(self, user_id, message, room):
|
||||
return None
|
||||
|
||||
def on_join(self, user_id, room):
|
||||
return None
|
||||
|
||||
def on_leave(self, user_id, room):
|
||||
return None
|
||||
|
||||
def on_command(self, user_id, command, args, room):
|
||||
return None
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
def __init__(self):
|
||||
self._plugins: dict[str, Plugin] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, plugin: Plugin):
|
||||
with self._lock:
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
if name in self._plugins:
|
||||
del self._plugins[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Plugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_plugins(self) -> list[dict]:
|
||||
return [{"name": p.name, "description": p.description} for p in self._plugins.values()]
|
||||
|
||||
def fire_on_message(self, user_id, message, room):
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_message(user_id, message, room)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
def fire_on_join(self, user_id, room):
|
||||
messages = []
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_join(user_id, room)
|
||||
if result is not None:
|
||||
messages.append(result)
|
||||
return "\n".join(messages) if messages else None
|
||||
|
||||
def fire_on_leave(self, user_id, room):
|
||||
messages = []
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_leave(user_id, room)
|
||||
if result is not None:
|
||||
messages.append(result)
|
||||
return "\n".join(messages) if messages else None
|
||||
|
||||
def fire_on_command(self, user_id, command, args, room):
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_command(user_id, command, args, room)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
# ═══ Tests ═══════════════════════════════════════════════════════
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestChatLog(unittest.TestCase):
|
||||
|
||||
def test_log_and_retrieve(self):
|
||||
log = ChatLog()
|
||||
entry = log.log("room1", "say", "hello", user_id="u1", username="Alice")
|
||||
self.assertEqual(entry["message"], "hello")
|
||||
self.assertEqual(entry["room"], "room1")
|
||||
history = log.get_history("room1")
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["message"], "hello")
|
||||
|
||||
def test_multiple_rooms(self):
|
||||
log = ChatLog()
|
||||
log.log("room1", "say", "hello")
|
||||
log.log("room2", "ask", "what?")
|
||||
self.assertEqual(set(log.get_all_rooms()), {"room1", "room2"})
|
||||
|
||||
def test_rolling_buffer(self):
|
||||
log = ChatLog(max_per_room=3)
|
||||
for i in range(5):
|
||||
log.log("room1", "say", f"msg{i}")
|
||||
history = log.get_history("room1")
|
||||
self.assertEqual(len(history), 3)
|
||||
self.assertEqual(history[0]["message"], "msg2")
|
||||
self.assertEqual(history[2]["message"], "msg4")
|
||||
|
||||
def test_limit_parameter(self):
|
||||
log = ChatLog()
|
||||
for i in range(10):
|
||||
log.log("room1", "say", f"msg{i}")
|
||||
history = log.get_history("room1", limit=3)
|
||||
self.assertEqual(len(history), 3)
|
||||
|
||||
def test_since_filter(self):
|
||||
log = ChatLog()
|
||||
log.log("room1", "say", "old")
|
||||
time.sleep(0.01)
|
||||
cutoff = datetime.now().isoformat()
|
||||
time.sleep(0.01)
|
||||
log.log("room1", "say", "new")
|
||||
history = log.get_history("room1", since=cutoff)
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["message"], "new")
|
||||
|
||||
def test_empty_room(self):
|
||||
log = ChatLog()
|
||||
self.assertEqual(log.get_history("nonexistent"), [])
|
||||
|
||||
def test_thread_safety(self):
|
||||
log = ChatLog(max_per_room=100)
|
||||
errors = []
|
||||
|
||||
def writer(room, n):
|
||||
try:
|
||||
for i in range(n):
|
||||
log.log(room, "say", f"{room}-{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(f"room{t}", 50)) for t in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(len(errors), 0)
|
||||
total = sum(len(log.get_history(f"room{t}")) for t in range(4))
|
||||
self.assertEqual(total, 200)
|
||||
|
||||
|
||||
class TestPresenceManager(unittest.TestCase):
|
||||
|
||||
def test_enter_room(self):
|
||||
pm = PresenceManager()
|
||||
event = pm.enter_room("u1", "Alice", "lobby")
|
||||
self.assertEqual(event["event"], "enter")
|
||||
self.assertEqual(event["username"], "Alice")
|
||||
players = pm.get_players_in_room("lobby")
|
||||
self.assertEqual(len(players), 1)
|
||||
|
||||
def test_leave_room(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
event = pm.leave_room("u1", "lobby")
|
||||
self.assertEqual(event["event"], "leave")
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 0)
|
||||
|
||||
def test_leave_nonexistent(self):
|
||||
pm = PresenceManager()
|
||||
result = pm.leave_room("u1", "lobby")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_multiple_users(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u2", "Bob", "lobby")
|
||||
players = pm.get_players_in_room("lobby")
|
||||
self.assertEqual(len(players), 2)
|
||||
|
||||
def test_say_event(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
event = pm.say("u1", "Alice", "lobby", "hello world")
|
||||
self.assertEqual(event["type"], "say")
|
||||
self.assertEqual(event["message"], "hello world")
|
||||
events = pm.get_room_events("lobby")
|
||||
self.assertEqual(len(events), 2) # enter + say
|
||||
|
||||
def test_cleanup_user(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u1", "Alice", "tavern")
|
||||
events = pm.cleanup_user("u1")
|
||||
self.assertEqual(len(events), 2) # left both rooms
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 0)
|
||||
self.assertEqual(len(pm.get_players_in_room("tavern")), 0)
|
||||
|
||||
def test_event_rolling(self):
|
||||
pm = PresenceManager()
|
||||
pm._max_events_per_room = 3
|
||||
for i in range(5):
|
||||
pm.say("u1", "Alice", "lobby", f"msg{i}")
|
||||
events = pm.get_room_events("lobby")
|
||||
self.assertEqual(len(events), 3)
|
||||
|
||||
def test_room_isolation(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u2", "Bob", "tavern")
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 1)
|
||||
self.assertEqual(len(pm.get_players_in_room("tavern")), 1)
|
||||
|
||||
|
||||
class TestPluginRegistry(unittest.TestCase):
|
||||
|
||||
def test_register_and_get(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "test"
|
||||
p.description = "A test plugin"
|
||||
reg.register(p)
|
||||
self.assertEqual(reg.get("test"), p)
|
||||
|
||||
def test_unregister(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "test"
|
||||
reg.register(p)
|
||||
self.assertTrue(reg.unregister("test"))
|
||||
self.assertIsNone(reg.get("test"))
|
||||
|
||||
def test_unregister_missing(self):
|
||||
reg = PluginRegistry()
|
||||
self.assertFalse(reg.unregister("nonexistent"))
|
||||
|
||||
def test_list_plugins(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"; p1.description = "A"
|
||||
p2 = Plugin(); p2.name = "b"; p2.description = "B"
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
names = [p["name"] for p in reg.list_plugins()]
|
||||
self.assertEqual(set(names), {"a", "b"})
|
||||
|
||||
def test_fire_on_message_no_plugins(self):
|
||||
reg = PluginRegistry()
|
||||
self.assertIsNone(reg.fire_on_message("u1", "hello", "lobby"))
|
||||
|
||||
def test_fire_on_message_returns_override(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "greeter"
|
||||
p.on_message = lambda uid, msg, room: "Welcome!"
|
||||
reg.register(p)
|
||||
result = reg.fire_on_message("u1", "hello", "lobby")
|
||||
self.assertEqual(result, "Welcome!")
|
||||
|
||||
def test_fire_on_join_collects(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"
|
||||
p1.on_join = lambda uid, room: "Hello from A"
|
||||
p2 = Plugin(); p2.name = "b"
|
||||
p2.on_join = lambda uid, room: "Hello from B"
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
result = reg.fire_on_join("u1", "lobby")
|
||||
self.assertIn("Hello from A", result)
|
||||
self.assertIn("Hello from B", result)
|
||||
|
||||
def test_fire_on_command_first_wins(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"
|
||||
p1.on_command = lambda uid, cmd, args, room: {"result": "from A"}
|
||||
p2 = Plugin(); p2.name = "b"
|
||||
p2.on_command = lambda uid, cmd, args, room: {"result": "from B"}
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
result = reg.fire_on_command("u1", "look", "", "lobby")
|
||||
self.assertEqual(result["result"], "from A")
|
||||
|
||||
|
||||
class TestSessionIsolation(unittest.TestCase):
|
||||
"""Test that session data doesn't leak between users."""
|
||||
|
||||
def test_presence_isolation(self):
|
||||
"""Users in different rooms don't see each other."""
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "room-a")
|
||||
pm.enter_room("u2", "Bob", "room-b")
|
||||
self.assertEqual(len(pm.get_players_in_room("room-a")), 1)
|
||||
self.assertEqual(len(pm.get_players_in_room("room-b")), 1)
|
||||
self.assertEqual(pm.get_players_in_room("room-a")[0]["username"], "Alice")
|
||||
self.assertEqual(pm.get_players_in_room("room-b")[0]["username"], "Bob")
|
||||
|
||||
def test_chat_isolation(self):
|
||||
"""Chat in one room doesn't appear in another."""
|
||||
log = ChatLog()
|
||||
log.log("room-a", "say", "secret", user_id="u1")
|
||||
log.log("room-b", "say", "public", user_id="u2")
|
||||
self.assertEqual(len(log.get_history("room-a")), 1)
|
||||
self.assertEqual(len(log.get_history("room-b")), 1)
|
||||
self.assertEqual(log.get_history("room-a")[0]["message"], "secret")
|
||||
self.assertEqual(log.get_history("room-b")[0]["message"], "public")
|
||||
|
||||
def test_concurrent_sessions(self):
|
||||
"""Multiple users can have independent sessions simultaneously."""
|
||||
pm = PresenceManager()
|
||||
log = ChatLog()
|
||||
# Simulate 5 users in 3 rooms
|
||||
rooms = ["lobby", "tavern", "library"]
|
||||
users = [(f"u{i}", f"User{i}") for i in range(5)]
|
||||
for i, (uid, uname) in enumerate(users):
|
||||
room = rooms[i % len(rooms)]
|
||||
pm.enter_room(uid, uname, room)
|
||||
log.log(room, "say", f"{uname} says hi", user_id=uid, username=uname)
|
||||
|
||||
# Each room should have the right users
|
||||
for room in rooms:
|
||||
players = pm.get_players_in_room(room)
|
||||
self.assertGreater(len(players), 0)
|
||||
history = log.get_history(room)
|
||||
self.assertEqual(len(history), len(players))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user