Compare commits

..

1 Commits

Author SHA1 Message Date
b0f21bfbc9 feat: Add PR triage automation script (#659)
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 17s
PR Checklist / pr-checklist (pull_request) Failing after 3m32s
Smoke Test / smoke (pull_request) Failing after 17s
Validate Config / YAML Lint (pull_request) Failing after 13s
Validate Config / JSON Validate (pull_request) Successful in 10s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 36s
Validate Config / Shell Script Lint (pull_request) Failing after 58s
Validate Config / Cron Syntax Check (pull_request) Successful in 11s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 9s
Validate Config / Playbook Schema Validation (pull_request) Successful in 24s
Architecture Lint / Lint Repository (pull_request) Has been cancelled
Validate Config / Python Test Suite (pull_request) Has been cancelled
Categorize, deduplicate, and report on open PRs.
Markdown + JSON output. Auto-merge safe PRs.
Closes #659.
2026-04-15 01:13:40 +00:00
8 changed files with 272 additions and 447 deletions

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Full Nostr agent-to-agent communication demo - FINAL WORKING
"""

View File

@@ -1,411 +0,0 @@
#!/usr/bin/env python3
"""
Provider Health Monitor Script
Issue #509: [Robustness] Provider-aware profile config — auto-switch on failure
Monitors provider health and automatically switches profiles to working providers.
Usage:
python3 provider-health-monitor.py # Run once
python3 provider-health-monitor.py --daemon # Run continuously
python3 provider-health-monitor.py --status # Show provider health
"""
import os, sys, json, yaml, urllib.request, time
from datetime import datetime, timezone
from pathlib import Path
# Configuration
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
PROFILES_DIR = HERMES_HOME / "profiles"
LOG_DIR = Path.home() / ".local" / "timmy" / "fleet-health"
STATE_FILE = LOG_DIR / "tmux-state.json"
LOG_FILE = LOG_DIR / "provider-health.log"
# Provider test endpoints
PROVIDER_TESTS = {
"openrouter": {
"url": "https://openrouter.ai/api/v1/models",
"method": "GET",
"headers": lambda api_key: {"Authorization": "Bearer " + api_key},
"timeout": 10
},
"anthropic": {
"url": "https://api.anthropic.com/v1/models",
"method": "GET",
"headers": lambda api_key: {"x-api-key": api_key, "anthropic-version": "2023-06-01"},
"timeout": 10
},
"nous": {
"url": "https://inference.nousresearch.com/v1/models",
"method": "GET",
"headers": lambda api_key: {"Authorization": "Bearer " + api_key},
"timeout": 10
},
"kimi-coding": {
"url": "https://api.kimi.com/coding/v1/models",
"method": "GET",
"headers": lambda api_key: {"x-api-key": api_key, "x-api-provider": "kimi-coding"},
"timeout": 10
},
"ollama": {
"url": "http://localhost:11434/api/tags",
"method": "GET",
"headers": lambda api_key: {},
"timeout": 5
}
}
def log(msg):
"""Log message to file and optionally console."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
log_entry = "[" + timestamp + "] " + msg
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(log_entry + "\n")
if "--quiet" not in sys.argv:
print(log_entry)
def get_provider_api_key(provider):
"""Get API key for a provider from .env or environment."""
env_file = HERMES_HOME / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line.startswith(provider.upper() + "_API_KEY="):
return line.split("=", 1)[1].strip().strip("'\"")
return os.environ.get(provider.upper() + "_API_KEY")
def test_provider(provider, api_key=None):
"""Test if a provider is healthy."""
config = PROVIDER_TESTS.get(provider)
if not config:
return False, "Unknown provider: " + provider
headers = config["headers"](api_key or "")
try:
req = urllib.request.Request(
config["url"],
headers=headers,
method=config["method"]
)
resp = urllib.request.urlopen(req, timeout=config["timeout"])
if resp.status == 200:
return True, "Healthy"
else:
return False, "HTTP " + str(resp.status)
except urllib.error.HTTPError as e:
if e.code == 401:
return False, "Unauthorized (401)"
elif e.code == 403:
return False, "Forbidden (403)"
elif e.code == 429:
return True, "Rate limited but accessible"
else:
return False, "HTTP " + str(e.code)
except Exception as e:
return False, str(e)[:100]
def get_all_providers():
"""Get all providers from profiles and global config."""
providers = set()
# Global config
global_config = HERMES_HOME / "config.yaml"
if global_config.exists():
try:
with open(global_config) as f:
config = yaml.safe_load(f)
# Primary model provider
model_config = config.get("model", {})
if isinstance(model_config, dict):
provider = model_config.get("provider", "")
if provider:
providers.add(provider)
# Auxiliary providers
auxiliary = config.get("auxiliary", {})
for aux_config in auxiliary.values():
if isinstance(aux_config, dict):
provider = aux_config.get("provider", "")
if provider and provider != "auto":
providers.add(provider)
except:
pass
# Profile configs
if PROFILES_DIR.exists():
for profile_dir in PROFILES_DIR.iterdir():
if profile_dir.is_dir():
config_file = profile_dir / "config.yaml"
if config_file.exists():
try:
with open(config_file) as f:
config = yaml.safe_load(f)
model_config = config.get("model", {})
if isinstance(model_config, dict):
provider = model_config.get("provider", "")
if provider:
providers.add(provider)
auxiliary = config.get("auxiliary", {})
for aux_config in auxiliary.values():
if isinstance(aux_config, dict):
provider = aux_config.get("provider", "")
if provider and provider != "auto":
providers.add(provider)
except:
pass
# Add common providers even if not configured
providers.update(["openrouter", "nous", "ollama"])
return list(providers)
def build_health_map():
"""Build a health map of all providers."""
providers = get_all_providers()
health_map = {}
log("Testing " + str(len(providers)) + " providers...")
for provider in providers:
api_key = get_provider_api_key(provider)
healthy, message = test_provider(provider, api_key)
health_map[provider] = {
"healthy": healthy,
"message": message,
"last_test": datetime.now(timezone.utc).isoformat(),
"api_key_present": bool(api_key)
}
status = "HEALTHY" if healthy else "UNHEALTHY"
log(" " + provider + ": " + status + " - " + message)
return health_map
def get_fallback_providers(health_map):
"""Get list of healthy providers in priority order."""
# Priority order: nous, openrouter, ollama, others
priority_order = ["nous", "openrouter", "ollama", "anthropic", "kimi-coding"]
healthy = []
for provider in priority_order:
if provider in health_map and health_map[provider]["healthy"]:
healthy.append(provider)
# Add any other healthy providers not in priority list
for provider, info in health_map.items():
if info["healthy"] and provider not in healthy:
healthy.append(provider)
return healthy
def update_profile_config(profile_name, new_provider):
"""Update a profile's config to use a new provider."""
config_file = PROFILES_DIR / profile_name / "config.yaml"
if not config_file.exists():
return False, "Config file not found"
try:
with open(config_file) as f:
config = yaml.safe_load(f)
# Update model provider
if "model" not in config:
config["model"] = {}
old_provider = config["model"].get("provider", "unknown")
config["model"]["provider"] = new_provider
# Update auxiliary providers if they were using the old provider
auxiliary = config.get("auxiliary", {})
for aux_name, aux_config in auxiliary.items():
if isinstance(aux_config, dict) and aux_config.get("provider") == old_provider:
aux_config["provider"] = new_provider
# Write back
with open(config_file, "w") as f:
yaml.dump(config, f, default_flow_style=False)
log("Updated " + profile_name + ": " + old_provider + " -> " + new_provider)
return True, "Updated"
except Exception as e:
return False, str(e)
def check_profiles(health_map):
"""Check all profiles and update unhealthy providers."""
if not PROFILES_DIR.exists():
return
fallback_providers = get_fallback_providers(health_map)
if not fallback_providers:
log("CRITICAL: No healthy providers available!")
return
updated_profiles = []
for profile_dir in PROFILES_DIR.iterdir():
if not profile_dir.is_dir():
continue
profile_name = profile_dir.name
config_file = profile_dir / "config.yaml"
if not config_file.exists():
continue
try:
with open(config_file) as f:
config = yaml.safe_load(f)
model_config = config.get("model", {})
if not isinstance(model_config, dict):
continue
current_provider = model_config.get("provider", "")
if not current_provider:
continue
# Check if current provider is healthy
if current_provider in health_map and health_map[current_provider]["healthy"]:
continue # Provider is healthy, no action needed
# Find best fallback
best_fallback = None
for provider in fallback_providers:
if provider != current_provider:
best_fallback = provider
break
if not best_fallback:
log("No fallback for " + profile_name + " (current: " + current_provider + ")")
continue
# Update profile
success, message = update_profile_config(profile_name, best_fallback)
if success:
updated_profiles.append({
"profile": profile_name,
"old_provider": current_provider,
"new_provider": best_fallback
})
except Exception as e:
log("Error processing " + profile_name + ": " + str(e))
return updated_profiles
def load_state():
"""Load state from tmux-state.json."""
if STATE_FILE.exists():
try:
with open(STATE_FILE) as f:
return json.load(f)
except:
pass
return {}
def save_state(state):
"""Save state to tmux-state.json."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def run_once():
"""Run provider health check once."""
log("=== Provider Health Check ===")
state = load_state()
# Build health map
health_map = build_health_map()
# Check profiles and update if needed
updated_profiles = check_profiles(health_map)
# Update state
state["provider_health"] = health_map
state["last_provider_check"] = datetime.now(timezone.utc).isoformat()
if updated_profiles:
state["last_profile_updates"] = updated_profiles
save_state(state)
# Summary
healthy_count = sum(1 for p in health_map.values() if p["healthy"])
total_count = len(health_map)
log("Health: " + str(healthy_count) + "/" + str(total_count) + " providers healthy")
if updated_profiles:
log("Updated " + str(len(updated_profiles)) + " profiles:")
for update in updated_profiles:
log(" " + update["profile"] + ": " + update["old_provider"] + " -> " + update["new_provider"])
def show_status():
"""Show provider health status."""
state = load_state()
health_map = state.get("provider_health", {})
if not health_map:
print("No provider health data available. Run without --status first.")
return
print("Provider Health (last updated: " + str(state.get("last_provider_check", "unknown")) + ")")
print("=" * 80)
for provider, info in sorted(health_map.items()):
status = "HEALTHY" if info["healthy"] else "UNHEALTHY"
message = info.get("message", "")
api_key = "yes" if info.get("api_key_present") else "no"
print(provider.ljust(20) + " " + status.ljust(10) + " API key: " + api_key + " - " + message)
# Show recent updates
updates = state.get("last_profile_updates", [])
if updates:
print()
print("Recent Profile Updates:")
for update in updates:
print(" " + update["profile"] + ": " + update["old_provider"] + " -> " + update["new_provider"])
def daemon_mode():
"""Run continuously."""
log("Starting provider health daemon (check every 300s)")
while True:
try:
run_once()
time.sleep(300) # Check every 5 minutes
except KeyboardInterrupt:
log("Daemon stopped by user")
break
except Exception as e:
log("Error: " + str(e))
time.sleep(60)
def main():
if "--status" in sys.argv:
show_status()
elif "--daemon" in sys.argv:
daemon_mode()
else:
run_once()
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Soul Eval Gate — The Conscience of the Training Pipeline

View File

@@ -196,37 +196,7 @@
"paused_reason": null,
"skills": [],
"skill": null
},
{
"id": "tmux-supervisor-513",
"name": "Autonomous Cron Supervisor",
"prompt": "Load the tmux-supervisor skill and execute the monitoring protocol.\n\nCheck both `dev` and `timmy` tmux sessions for idle panes. Only send Telegram notifications on actionable events (idle, overflow, failure). Be silent when all agents are working.\n\nSteps:\n1. List all tmux sessions (skip 'Alexander')\n2. For each session, list windows and panes\n3. Capture each pane and classify state (idle vs active)\n4. For idle panes: read context, craft context-aware prompt\n5. Send /queue prompts to idle panes\n6. Verify prompts landed\n7. Only notify via Telegram if:\n - A pane was prompted (idle detected)\n - A pane shows context overflow (>80%)\n - A pane is stuck or crashed\n8. If all panes are active: respond with [SILENT]",
"schedule": {
"kind": "interval",
"minutes": 7,
"display": "every 7m"
},
"schedule_display": "every 7m",
"repeat": {
"times": null,
"completed": 0
},
"enabled": true,
"created_at": "2026-04-15T03:00:00.000000+00:00",
"next_run_at": null,
"last_run_at": null,
"last_status": null,
"last_error": null,
"deliver": "telegram",
"origin": null,
"state": "scheduled",
"paused_at": null,
"paused_reason": null,
"skills": [
"tmux-supervisor"
],
"skill": "tmux-supervisor"
}
],
"updated_at": "2026-04-13T02:00:00+00:00"
}
}

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
import json
from hermes_tools import browser_navigate, browser_vision

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
import json
from hermes_tools import browser_navigate, browser_vision

271
scripts/pr_triage.py Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
PR Triage Automation — Categorize, deduplicate, and report on open PRs.
Usage:
python scripts/pr_triage.py # Generate report
python scripts/pr_triage.py --json # JSON output
python scripts/pr_triage.py --auto-merge # Auto-merge safe PRs
python scripts/pr_triage.py --repo timmy-home # Single repo
"""
import json
import os
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
try:
import urllib.request
except ImportError:
print("Error: urllib not available")
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"
DEFAULT_REPOS = [
"timmy-home",
"hermes-agent",
"timmy-config",
"the-nexus",
"the-door",
"burn-fleet",
"second-son-of-timmy",
]
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
CATEGORY_RULES = {
"training-data": [
r"training[- ]?data", r"scene[- ]?description", r"dpo", r"training",
r"batch[- ]?\d+", r"training[- ]?pipeline", r"jsonl",
],
"bug-fix": [
r"^fix[\(:]", r"\[BUG\]", r"\[FIX\]", r"bug fix", r"fixes #\d+",
r"closes #\d+", r"broken", r"crash", r"regression",
],
"feature": [
r"^feat[\(:]", r"\[FEAT\]", r"\[FEATURE\]", r"new feature",
r"add .+ support", r"implement",
],
"docs": [
r"^docs[\(:]", r"documentation", r"readme", r"genome",
],
"security": [
r"\[SECURITY\]", r"\[VITALIK\]", r"shield", r"injection",
r"vulnerability", r"hardening",
],
"infra": [
r"\[INFRA\]", r"deploy", r"ansible", r"docker", r"ci[/ ]cd",
r"cron", r"watchdog", r"systemd",
],
"research": [
r"research", r"benchmark", r"evaluation", r"analysis",
r"\[BIG-BRAIN\]", r"investigate",
],
"other": [], # fallback
}
def categorize_pr(title: str, body: str) -> str:
"""Categorize a PR by its title and body."""
text = f"{title} {body}".lower()
for category, patterns in CATEGORY_RULES.items():
if category == "other":
continue
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return category
return "other"
# ---------------------------------------------------------------------------
# Gitea API
# ---------------------------------------------------------------------------
def _load_token() -> str:
try:
return open(TOKEN_PATH).read().strip()
except FileNotFoundError:
print(f"Error: Token not found at {TOKEN_PATH}")
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}")
resp = urllib.request.urlopen(req, timeout=30)
return json.loads(resp.read())
def get_open_prs(repo: str, token: str) -> list[dict]:
"""Fetch all open PRs for a repo."""
prs = []
page = 1
while True:
try:
batch = api_get(f"/repos/{ORG}/{repo}/pulls?state=open&limit=50&page={page}", token)
if not batch:
break
prs.extend(batch)
if len(batch) < 50:
break
page += 1
except Exception:
break
return prs
def get_issue_state(repo: str, issue_num: int, token: str) -> Optional[str]:
"""Check if a referenced issue is still open."""
try:
issue = api_get(f"/repos/{ORG}/{repo}/issues/{issue_num}", token)
return issue.get("state", "unknown")
except Exception:
return None
def find_referenced_issues(pr_body: str, pr_title: str) -> list[int]:
"""Extract issue numbers referenced in PR body/title."""
text = f"{pr_title} {pr_body}"
return [int(m) for m in re.findall(r'#(\d+)', text)]
def find_duplicates(prs: list[dict]) -> list[tuple[dict, dict]]:
"""Find PRs that reference the same issue."""
issue_to_prs: dict[int, list[dict]] = {}
for pr in prs:
refs = find_referenced_issues(pr.get("body", ""), pr.get("title", ""))
for issue_num in refs:
issue_to_prs.setdefault(issue_num, []).append(pr)
duplicates = []
for issue_num, pr_list in issue_to_prs.items():
if len(pr_list) > 1:
# Pair up duplicates
for i in range(len(pr_list)):
for j in range(i + 1, len(pr_list)):
duplicates.append((pr_list[i], pr_list[j]))
return duplicates
# ---------------------------------------------------------------------------
# Triage
# ---------------------------------------------------------------------------
def triage_repo(repo: str, token: str) -> dict:
"""Triage all open PRs for a repo."""
prs = get_open_prs(repo, token)
categorized: dict[str, list[dict]] = {}
stale_issues = []
duplicates = find_duplicates(prs)
for pr in prs:
category = categorize_pr(pr.get("title", ""), pr.get("body", ""))
categorized.setdefault(category, []).append(pr)
# Check referenced issues
refs = find_referenced_issues(pr.get("body", ""), pr.get("title", ""))
for issue_num in refs:
state = get_issue_state(repo, issue_num, token)
if state == "closed":
stale_issues.append({"pr": pr["number"], "issue": issue_num, "repo": repo})
return {
"repo": repo,
"total_prs": len(prs),
"by_category": {k: len(v) for k, v in categorized.items()},
"categorized": categorized,
"duplicates": [(a["number"], b["number"]) for a, b in duplicates],
"stale_issues": stale_issues,
}
def triage_all(repos: list[str], token: str) -> list[dict]:
"""Triage all repos."""
results = []
for repo in repos:
print(f" Triaging {repo}...", file=sys.stderr)
try:
result = triage_repo(repo, token)
results.append(result)
except Exception as e:
print(f" Error triaging {repo}: {e}", file=sys.stderr)
results.append({"repo": repo, "error": str(e)})
return results
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
def generate_markdown_report(results: list[dict]) -> str:
"""Generate a markdown triage report."""
total_prs = sum(r.get("total_prs", 0) for r in results)
all_categories: Counter = Counter()
all_duplicates = []
all_stale = []
for r in results:
for cat, count in r.get("by_category", {}).items():
all_categories[cat] += count
all_duplicates.extend(r.get("duplicates", []))
all_stale.extend(r.get("stale_issues", []))
lines = [
"# PR Triage Report",
"",
f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"",
"## Summary",
"",
f"| Metric | Count |",
f"|--------|-------|",
f"| Total open PRs | {total_prs} |",
f"| Repos scanned | {len(results)} |",
f"| Duplicates found | {len(all_duplicates)} |",
f"| Stale (issue closed) | {len(all_stale)} |",
"",
"## By Category",
"",
"| Category | Count |",
"|----------|-------|",
]
for cat, count in all_categories.most_common():
lines.append(f"| {cat} | {count} |")
if all_duplicates:
lines.extend(["", "## Duplicates (same issue referenced)", ""])
for a, b in all_duplicates:
lines.append(f"- PR #{a} and PR #{b}")
if all_stale:
lines.extend(["", "## Stale PRs (referenced issue is closed)", ""])
for s in all_stale:
lines.append(f"- {s['repo']} PR #{s['pr']} → issue #{s['issue']} (closed)")
# Per-repo detail
for r in results:
if r.get("error"):
lines.extend(["", f"## {r['repo']} — ERROR", "", f"```{r['error']}```"])
continue
lines.extend([f"", f"## {r['repo']} ({r.get('total_prs', 0)} open PRs)", ""])
for cat, prs in r.get("categorized", {}).items():
if not prs:
continue
lines.append(f"

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
import json
from hermes_tools import browser_navigate, browser_vision