Compare commits
1 Commits
queue/583-
...
fix/640-vp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f0f48006 |
116
scripts/vps-gitea-heartbeat-install.sh
Normal file
116
scripts/vps-gitea-heartbeat-install.sh
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# VPS Gitea Heartbeat Installer
|
||||
# ============================================================================
|
||||
#
|
||||
# Installs the Gitea heartbeat poller as a cron job or systemd service.
|
||||
#
|
||||
# Usage:
|
||||
# bash vps-gitea-heartbeat-install.sh [agent] [cron|systemd]
|
||||
#
|
||||
# Examples:
|
||||
# bash vps-gitea-heartbeat-install.sh ezra cron
|
||||
# bash vps-gitea-heartbeat-install.sh bezalel systemd
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AGENT="${1:?Usage: $0 AGENT [cron|systemd]}"
|
||||
MODE="${2:-cron}"
|
||||
SCRIPT_PATH="${HOME}/.hermes/bin/vps-gitea-heartbeat.py"
|
||||
INTERVAL=300 # 5 minutes
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${CYAN}VPS Gitea Heartbeat Installer${NC}"
|
||||
echo -e " Agent: ${AGENT}"
|
||||
echo -e " Mode: ${MODE}"
|
||||
echo ""
|
||||
|
||||
# Ensure script exists
|
||||
if [ ! -f "$SCRIPT_PATH" ]; then
|
||||
echo -e "${RED}Error: ${SCRIPT_PATH} not found${NC}"
|
||||
echo "Copy vps-gitea-heartbeat.py to ~/.hermes/bin/ first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure token exists
|
||||
TOKEN_FILE="${HOME}/.hermes/gitea_token_vps"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
# Try alternate location
|
||||
TOKEN_FILE="${HOME}/.config/gitea/token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo -e "${RED}Error: No Gitea token found${NC}"
|
||||
echo "Create ~/.hermes/gitea_token_vps with your Gitea API token."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓${NC} Token found"
|
||||
|
||||
# Create log directory
|
||||
mkdir -p "${HOME}/.hermes/logs/gitea-heartbeat"
|
||||
|
||||
if [ "$MODE" = "cron" ]; then
|
||||
# ── Cron installation ────────────────────────────────────────────────
|
||||
CRON_LINE="*/5 * * * * /usr/bin/python3 ${SCRIPT_PATH} --agent ${AGENT} --once >> ${HOME}/.hermes/logs/gitea-heartbeat/cron-${AGENT}.log 2>&1"
|
||||
|
||||
# Check if already installed
|
||||
if crontab -l 2>/dev/null | grep -q "vps-gitea-heartbeat.*--agent ${AGENT}"; then
|
||||
echo -e "${CYAN}Cron job already exists for ${AGENT}, updating...${NC}"
|
||||
# Remove old entry
|
||||
crontab -l 2>/dev/null | grep -v "vps-gitea-heartbeat.*--agent ${AGENT}" | crontab -
|
||||
fi
|
||||
|
||||
# Add new entry
|
||||
(crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -
|
||||
echo -e "${GREEN}✓${NC} Cron job installed (every 5 minutes)"
|
||||
echo ""
|
||||
echo "Verify with: crontab -l"
|
||||
|
||||
elif [ "$MODE" = "systemd" ]; then
|
||||
# ── Systemd installation ─────────────────────────────────────────────
|
||||
SERVICE_NAME="gitea-heartbeat-${AGENT}"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
cat > "/tmp/${SERVICE_NAME}.service" << EOF
|
||||
[Unit]
|
||||
Description=Gitea Heartbeat for ${AGENT}
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 ${SCRIPT_PATH} --agent ${AGENT} --daemon --interval ${INTERVAL}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=root
|
||||
Environment=HOME=${HOME}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "Installing systemd service..."
|
||||
echo -e "${CYAN}Run as root:${NC}"
|
||||
echo " sudo cp /tmp/${SERVICE_NAME}.service ${SERVICE_FILE}"
|
||||
echo " sudo systemctl daemon-reload"
|
||||
echo " sudo systemctl enable ${SERVICE_NAME}"
|
||||
echo " sudo systemctl start ${SERVICE_NAME}"
|
||||
echo ""
|
||||
echo "Or copy-paste:"
|
||||
echo " sudo bash -c 'cat > ${SERVICE_FILE}' < /tmp/${SERVICE_NAME}.service && sudo systemctl daemon-reload && sudo systemctl enable --now ${SERVICE_NAME}"
|
||||
|
||||
else
|
||||
echo -e "${RED}Unknown mode: ${MODE}${NC}"
|
||||
echo "Use 'cron' or 'systemd'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Installation complete${NC}"
|
||||
echo ""
|
||||
echo "Test with:"
|
||||
echo " python3 ${SCRIPT_PATH} --agent ${AGENT} --once --verbose"
|
||||
369
scripts/vps-gitea-heartbeat.py
Normal file
369
scripts/vps-gitea-heartbeat.py
Normal file
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python3
|
||||
"""VPS Gitea Heartbeat — Poll Gitea for @mentions and issue assignments.
|
||||
|
||||
Runs on Ezra/Bezalel VPS boxes. Every N minutes:
|
||||
1. Polls Gitea API for issues assigned to this agent
|
||||
2. Scans recent comments for @mentions of this agent
|
||||
3. Dispatches actionable events to local `hermes chat`
|
||||
4. Tracks seen events to avoid duplicates
|
||||
|
||||
Usage:
|
||||
python3 vps-gitea-heartbeat.py --agent ezra --once # Single poll
|
||||
python3 vps-gitea-heartbeat.py --agent bezalel --daemon # Continuous mode
|
||||
|
||||
Ref: #579, #640
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
DEFAULT_INTERVAL = 300 # 5 minutes
|
||||
STATE_DIR = Path.home() / ".hermes"
|
||||
LOG_DIR = STATE_DIR / "logs" / "gitea-heartbeat"
|
||||
|
||||
# Repos to scan for mentions
|
||||
WATCHED_REPOS = [
|
||||
"Timmy_Foundation/timmy-home",
|
||||
"Timmy_Foundation/hermes-agent",
|
||||
"Timmy_Foundation/timmy-config",
|
||||
"Timmy_Foundation/the-beacon",
|
||||
]
|
||||
|
||||
|
||||
def load_gitea_token() -> str:
|
||||
"""Load Gitea API token from standard locations."""
|
||||
token_paths = [
|
||||
Path.home() / ".config" / "gitea" / "token",
|
||||
Path.home() / ".hermes" / "gitea_token_vps",
|
||||
Path.home() / ".hermes" / "gitea_token",
|
||||
]
|
||||
for path in token_paths:
|
||||
if path.exists():
|
||||
return path.read_text().strip()
|
||||
return os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
def gitea_request(path: str, token: str, method: str = "GET", data: Any = None) -> Any:
|
||||
"""Make a Gitea API request."""
|
||||
url = f"{GITEA_BASE}{path}"
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.warning(f"Gitea API error {e.code}: {path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Gitea request failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class HeartbeatState:
|
||||
"""Persistent state for deduplication."""
|
||||
|
||||
def __init__(self, agent: str):
|
||||
self.agent = agent
|
||||
self.state_file = STATE_DIR / f"gitea-heartbeat-{agent}.json"
|
||||
self.seen_comment_ids: Set[str] = set()
|
||||
self.seen_issue_hashes: Set[str] = set()
|
||||
self.last_poll: str = ""
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
data = json.loads(self.state_file.read_text())
|
||||
self.seen_comment_ids = set(data.get("seen_comment_ids", []))
|
||||
self.seen_issue_hashes = set(data.get("seen_issue_hashes", []))
|
||||
self.last_poll = data.get("last_poll", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load state: {e}")
|
||||
|
||||
def save(self):
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"agent": self.agent,
|
||||
"seen_comment_ids": sorted(self.seen_comment_ids),
|
||||
"seen_issue_hashes": sorted(self.seen_issue_hashes),
|
||||
"last_poll": self.last_poll,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.state_file.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def is_comment_seen(self, comment_id: str) -> bool:
|
||||
return comment_id in self.seen_comment_ids
|
||||
|
||||
def mark_comment_seen(self, comment_id: str):
|
||||
self.seen_comment_ids.add(comment_id)
|
||||
# Keep set bounded
|
||||
if len(self.seen_comment_ids) > 10000:
|
||||
self.seen_comment_ids = set(sorted(self.seen_comment_ids)[-5000:])
|
||||
|
||||
def is_issue_seen(self, issue_key: str) -> bool:
|
||||
return issue_key in self.seen_issue_hashes
|
||||
|
||||
def mark_issue_seen(self, issue_key: str):
|
||||
self.seen_issue_hashes.add(issue_key)
|
||||
|
||||
|
||||
def poll_assigned_issues(
|
||||
agent: str, token: str, state: HeartbeatState
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Check for issues assigned to this agent."""
|
||||
events = []
|
||||
mention_tag = f"@{agent}"
|
||||
|
||||
for repo in WATCHED_REPOS:
|
||||
# Get open issues
|
||||
issues = gitea_request(f"/repos/{repo}/issues?state=open&limit=20", token)
|
||||
if not issues:
|
||||
continue
|
||||
|
||||
for issue in issues:
|
||||
# Check if assigned to this agent
|
||||
assignee = issue.get("assignee", {})
|
||||
if assignee and assignee.get("login", "").lower() == agent.lower():
|
||||
issue_key = f"{repo}#{issue['number']}"
|
||||
if not state.is_issue_seen(issue_key):
|
||||
events.append({
|
||||
"type": "assignment",
|
||||
"repo": repo,
|
||||
"issue_number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"issue_key": issue_key,
|
||||
})
|
||||
state.mark_issue_seen(issue_key)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def poll_mentions(
|
||||
agent: str, token: str, state: HeartbeatState
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Scan recent comments for @mentions of this agent."""
|
||||
events = []
|
||||
mention_tag = f"@{agent}"
|
||||
|
||||
for repo in WATCHED_REPOS:
|
||||
# Get recent issues (last 24h activity)
|
||||
issues = gitea_request(
|
||||
f"/repos/{repo}/issues?state=open&limit=10&sort=updated", token
|
||||
)
|
||||
if not issues:
|
||||
continue
|
||||
|
||||
for issue in issues:
|
||||
issue_num = issue["number"]
|
||||
# Get comments
|
||||
comments = gitea_request(
|
||||
f"/repos/{repo}/issues/{issue_num}/comments?limit=10", token
|
||||
)
|
||||
if not comments:
|
||||
continue
|
||||
|
||||
for comment in comments:
|
||||
comment_id = str(comment.get("id", ""))
|
||||
if state.is_comment_seen(comment_id):
|
||||
continue
|
||||
|
||||
body = comment.get("body", "")
|
||||
if mention_tag.lower() in body.lower():
|
||||
events.append({
|
||||
"type": "mention",
|
||||
"repo": repo,
|
||||
"issue_number": issue_num,
|
||||
"comment_id": comment_id,
|
||||
"body_preview": body[:200],
|
||||
"author": comment.get("user", {}).get("login", ""),
|
||||
"url": comment.get("html_url", ""),
|
||||
})
|
||||
|
||||
state.mark_comment_seen(comment_id)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def dispatch_to_hermes(agent: str, event: Dict[str, Any]) -> bool:
|
||||
"""Dispatch an event to local hermes chat."""
|
||||
event_type = event["type"]
|
||||
repo = event["repo"]
|
||||
issue_num = event["issue_number"]
|
||||
title = event.get("title", "")
|
||||
|
||||
if event_type == "mention":
|
||||
prompt = (
|
||||
f"You were @mentioned on {repo}#{issue_num}. "
|
||||
f"Check the issue and respond. URL: {event.get('url', '')}"
|
||||
)
|
||||
elif event_type == "assignment":
|
||||
prompt = (
|
||||
f"You have been assigned {repo}#{issue_num}: {title}. "
|
||||
f"Implement the fix, commit, push, and open a PR. "
|
||||
f"URL: {event.get('url', '')}"
|
||||
)
|
||||
else:
|
||||
prompt = f"New event on {repo}#{issue_num}: {event_type}"
|
||||
|
||||
try:
|
||||
# Dispatch via hermes chat CLI
|
||||
result = subprocess.run(
|
||||
["hermes", "chat", "--quiet", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
env={**os.environ, "HERMES_SESSION_KEY": f"gitea-{agent}-{issue_num}"},
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Dispatched {event_type} for {repo}#{issue_num} to hermes")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"hermes chat failed: {result.stderr[:200]}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
# hermes not in PATH — try direct path
|
||||
logger.warning("hermes CLI not found, trying ~/.hermes/bin/")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"hermes chat timed out for {repo}#{issue_num}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Dispatch failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def log_event(agent: str, event: Dict[str, Any], dispatched: bool):
|
||||
"""Log event to file."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_file = LOG_DIR / f"{agent}-{datetime.now().strftime('%Y%m%d')}.log"
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"agent": agent,
|
||||
"dispatched": dispatched,
|
||||
**event,
|
||||
}
|
||||
|
||||
with open(log_file, "a") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def run_poll(agent: str, token: str) -> int:
|
||||
"""Run a single poll cycle. Returns number of events dispatched."""
|
||||
state = HeartbeatState(agent)
|
||||
state.last_poll = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Collect events
|
||||
assignment_events = poll_assigned_issues(agent, token, state)
|
||||
mention_events = poll_mentions(agent, token, state)
|
||||
|
||||
all_events = assignment_events + mention_events
|
||||
|
||||
if not all_events:
|
||||
logger.debug(f"No new events for @{agent}")
|
||||
state.save()
|
||||
return 0
|
||||
|
||||
logger.info(f"Found {len(all_events)} new event(s) for @{agent}")
|
||||
|
||||
# Dispatch
|
||||
dispatched = 0
|
||||
for event in all_events:
|
||||
success = dispatch_to_hermes(agent, event)
|
||||
log_event(agent, event, success)
|
||||
if success:
|
||||
dispatched += 1
|
||||
|
||||
state.save()
|
||||
return dispatched
|
||||
|
||||
|
||||
def run_daemon(agent: str, token: str, interval: int):
|
||||
"""Run in daemon mode — poll continuously."""
|
||||
logger.info(f"Starting daemon for @{agent} (interval: {interval}s)")
|
||||
while True:
|
||||
try:
|
||||
run_poll(agent, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Poll error: {e}")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="VPS Gitea Heartbeat — Poll for @mentions and assignments"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agent", "-a",
|
||||
required=True,
|
||||
help="Agent name (ezra, bezalel, etc.)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="Run single poll and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--daemon", "-d",
|
||||
action="store_true",
|
||||
help="Run in daemon mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval", "-i",
|
||||
type=int,
|
||||
default=DEFAULT_INTERVAL,
|
||||
help=f"Poll interval in seconds (default: {DEFAULT_INTERVAL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable verbose logging",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
# Load token
|
||||
token = load_gitea_token()
|
||||
if not token:
|
||||
print("Error: No Gitea token found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent = args.agent.lower()
|
||||
|
||||
if args.daemon:
|
||||
run_daemon(agent, token, args.interval)
|
||||
else:
|
||||
dispatched = run_poll(agent, token)
|
||||
print(f"Poll complete: {dispatched} event(s) dispatched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user