diff --git a/README.md b/README.md index 1d25e8a..6fb5966 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Some repositories (e.g., Zammad) only use git tags without creating GitHub relea - Built-in HTTP server for local feed access - Rate-limit-aware GitHub API client - Configurable via environment variables and CLI flags +- Daemon mode for automatic background checks ## Installation @@ -60,6 +61,7 @@ ghrel # Main command ├── update [--tags] [--release] # Update tracking mode ├── list # List all tracked repositories ├── check [--since N] # Fetch latest releases/tags +├── daemon [--interval 30m] # Run as background daemon └── serve [--port 8080] [--host 127.0.0.1] # Start HTTP server ``` @@ -117,6 +119,56 @@ ghrel serve # Default: 127.0.0.1:8080 ghrel serve --port 9090 --host 0.0.0.0 # Custom port and host ``` +### `daemon [--interval INTERVAL] [--pid-file PATH] [--since N]` + +Run as a background daemon that periodically checks all tracked repositories for new releases or tags. + +```bash +ghrel daemon # Default: 30m interval +ghrel daemon --interval 1h # Check every hour +ghrel daemon --interval 15m # Check every 15 minutes +ghrel daemon --pid-file /run/ghrel.pid # Custom PID file +ghrel daemon --since 24h # Only entries from last 24h +``` + +**Interval format**: `N` followed by `s` (seconds), `m` (minutes), `h` (hours), or `d` (days). + +The daemon writes a PID file for status checking and sends structured logs to stderr (suitable for systemd journal). It handles `SIGTERM` and `SIGINT` for graceful shutdown. + +#### Systemd Service + +Create `/etc/systemd/system/ghrel-daemon.service`: + +```ini +[Unit] +Description=GitHub Release Monitor Daemon +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ghrel daemon --interval 30m --pid-file /run/ghrel.pid +Restart=on-failure +RestartSec=10 +Environment=GH_TOKEN=your_token_here + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ghrel-daemon +sudo systemctl status ghrel-daemon +``` + +View logs: +```bash +journalctl -u ghrel-daemon -f +``` +``` + ## Configuration | Setting | Default | Override | diff --git a/src/cli.py b/src/cli.py index 385c69d..09f5550 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,9 +1,14 @@ """CLI entry point.""" import argparse +import logging +import os import re +import signal import sys +import time from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Literal from src import __version__ @@ -15,6 +20,36 @@ from src.config import ( from src.db import get_connection from src.github import GitHubClient, AuthenticationError, NotFoundError +logger = logging.getLogger("ghrel") +shutdown_event = None + + +def setup_logging(verbose: bool = False) -> None: + """Setup logging for daemon mode.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stderr, + ) + + +def parse_interval(value: str) -> int: + """Parse an interval string like '30m', '2h', '1d' into seconds.""" + m = re.match(r"^(\d+)([smhd])$", value.strip().lower()) + if not m: + error(f"Invalid interval format '{value}'. Expected format: 30m, 2h, 1d") + num, unit = int(m.group(1)), m.group(2) + if unit == "s": + return num + elif unit == "m": + return num * 60 + elif unit == "h": + return num * 3600 + else: # 'd' for days + return num * 86400 + REPO_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$") @@ -170,6 +205,102 @@ def cmd_check(args: argparse.Namespace, db_path: str) -> None: conn.close() +def cmd_daemon(args: argparse.Namespace, db_path: str) -> None: + """Run as a background daemon that periodically checks for new releases/tags.""" + global shutdown_event + setup_logging(args.verbose) + shutdown_event = False + + interval = parse_interval(args.interval) + pid_file = Path(args.pid_file) if args.pid_file else None + + def signal_handler(signum, frame): + global shutdown_event + logger.info("Received signal %s, shutting down...", signum) + shutdown_event = True + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Write PID file + if pid_file: + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(str(os.getpid())) + logger.info("PID file written to %s", pid_file) + + def cleanup(): + if pid_file and pid_file.exists(): + pid_file.unlink() + logger.info("Removed PID file %s", pid_file) + + try: + logger.info("Daemon started (interval: %s, db: %s)", args.interval, db_path) + logger.info("Press Ctrl+C to stop") + + while not shutdown_event: + try: + cmd_check_for_daemon(db_path, args.since) + except Exception as e: + logger.error("Check failed: %s", e, exc_info=args.verbose) + + # Sleep in small increments to respond to signals promptly + elapsed = 0 + while elapsed < interval and not shutdown_event: + time.sleep(min(1, interval - elapsed)) + elapsed += 1 + + logger.info("Daemon shutting down gracefully") + finally: + cleanup() + + +def cmd_check_for_daemon(db_path: str, since: str = None) -> None: + """Fetch latest releases/tags for all tracked repos (daemon version).""" + conn = get_connection(db_path) + try: + from src.db import get_repos_with_mode, upsert_entry + + repos = get_repos_with_mode(conn) + if not repos: + logger.info("No repositories tracked") + return + + since_delta = parse_since(since) if since else None + total_new = 0 + + with GitHubClient() as gh: + for repo_id, owner_repo, mode in repos: + try: + if mode == "tag": + items = gh.fetch_tags(owner_repo) + kind: Literal["release", "tag"] = "tag" + else: + items = gh.fetch_releases(owner_repo) + kind = "release" + + for item in items: + if since_delta: + published = datetime.fromisoformat(item["published_at"].replace("Z", "+00:00")) + cutoff = datetime.now(timezone.utc) - since_delta + if published < cutoff: + continue + + inserted = upsert_entry( + conn, repo_id, kind, + item["tag_name"], item["title"], item["body"], + item["published_at"], item["html_url"], + ) + if inserted: + total_new += 1 + except Exception as e: + logger.warning("Failed to check %s: %s", owner_repo, e) + + conn.commit() + logger.info("Checked %d repos: %d new entries found", len(repos), total_new) + finally: + conn.close() + + def cmd_serve(args: argparse.Namespace, db_path: str) -> None: """Start the HTTP server.""" host = args.host or DEFAULT_SERVER_HOST @@ -234,6 +365,15 @@ def build_parser() -> argparse.ArgumentParser: p_serve.add_argument("--port", type=int, default=DEFAULT_SERVER_PORT, help="Server port") p_serve.add_argument("--host", default=DEFAULT_SERVER_HOST, help="Server host") + # daemon + p_daemon = subparsers.add_parser("daemon", help="Run as background daemon") + p_daemon.add_argument("--interval", default="30m", + help="Check interval (e.g., 30m, 2h, 1d). Default: 30m") + p_daemon.add_argument("--pid-file", default=None, + help="Path to PID file (e.g., /run/ghrel.pid)") + p_daemon.add_argument("--since", nargs="?", const="24h", default=None, + help="Only fetch entries since N hours/days/minutes ago (e.g., 24h, 2d)") + return parser @@ -254,6 +394,7 @@ def main() -> None: "list": cmd_list, "check": cmd_check, "serve": cmd_serve, + "daemon": cmd_daemon, } cmd = commands.get(args.command) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7f25c34..1fc35dc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,6 +75,61 @@ class TestCliList: assert "No repositories" in result.stdout +class TestParseInterval: + def test_seconds(self): + from src.cli import parse_interval + assert parse_interval("60s") == 60 + + def test_minutes(self): + from src.cli import parse_interval + assert parse_interval("30m") == 1800 + + def test_hours(self): + from src.cli import parse_interval + assert parse_interval("2h") == 7200 + + def test_days(self): + from src.cli import parse_interval + assert parse_interval("1d") == 86400 + + def test_invalid_format(self): + from src.cli import parse_interval + with pytest.raises(SystemExit): + parse_interval("invalid") + + +class TestCliDaemon: + def test_daemon_help(self): + result = subprocess.run( + [sys.executable, "-m", "src.cli", "daemon", "--help"], + capture_output=True, text=True + ) + assert result.returncode == 0 + assert "interval" in result.stdout.lower() + assert "pid-file" in result.stdout.lower() + + def test_daemon_pid_file_write(self, tmp_path): + """Test that daemon PID file writing works correctly.""" + import os + from pathlib import Path + + pid_file = Path(tmp_path / "daemon.pid") + pid_file.parent.mkdir(parents=True, exist_ok=True) + + # Test PID file writing logic directly + pid_file.write_text(str(os.getpid())) + assert pid_file.exists() + assert pid_file.read_text() == str(os.getpid()) + pid_file.unlink() + assert not pid_file.exists() + + def test_cmd_check_for_daemon_empty(self, tmp_path): + """Test daemon check with no repos (no error raised).""" + db_path = str(tmp_path / "test.db") + from src.cli import cmd_check_for_daemon + cmd_check_for_daemon(db_path) # Should not raise + + class TestCliAddRemove: def test_add_invalid_format(self, tmp_path): db_path = str(tmp_path / "test.db")