"""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__ from src.config import ( DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT, get_db_path, ) 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._-]+$") def error(msg: str, code: int = 1) -> None: print(f"Error: {msg}", file=sys.stderr) sys.exit(code) def parse_since(value: str) -> timedelta: """Parse a duration string like '24h', '2h', '1d' into a timedelta.""" m = re.match(r"^(\d+)([hdm])$", value.strip().lower()) if not m: return timedelta(hours=24) # default num, unit = int(m.group(1)), m.group(2) if unit == "h": return timedelta(hours=num) elif unit == "d": return timedelta(days=num) else: # 'm' for minutes return timedelta(minutes=num) def cmd_add(args: argparse.Namespace, db_path: str) -> None: """Add a repository to track.""" owner_repo = args.repo if not REPO_PATTERN.match(owner_repo): error(f"Invalid repo format '{owner_repo}'. Expected 'owner/repo'") mode: Literal["release", "tag"] = "tag" if args.tags else "release" conn = get_connection(db_path) try: # Validate repo exists via GitHub API with GitHubClient() as gh: if not gh.repo_exists(owner_repo): error(f"Repository '{owner_repo}' not found on GitHub") from src.db import add_repo add_repo(conn, owner_repo, mode) conn.commit() mode_label = "tags" if mode == "tag" else "releases" print(f"Added {owner_repo} ({mode_label})") finally: conn.close() def cmd_remove(args: argparse.Namespace, db_path: str) -> None: """Remove a repository.""" owner_repo = args.repo conn = get_connection(db_path) try: from src.db import remove_repo if remove_repo(conn, owner_repo): print(f"Removed {owner_repo}") else: error(f"Repository '{owner_repo}' not found") finally: conn.close() def cmd_update(args: argparse.Namespace, db_path: str) -> None: """Update the tracking mode for a repository.""" owner_repo = args.repo if args.tags and args.release: error("Cannot specify both --tags and --release") if args.tags: mode: Literal["release", "tag"] = "tag" elif args.release: mode = "release" else: error("Specify --tags or --release") conn = get_connection(db_path) try: from src.db import update_repo_mode if update_repo_mode(conn, owner_repo, mode): mode_label = "tags" if mode == "tag" else "releases" print(f"Updated {owner_repo} to track {mode_label}") else: error(f"Repository '{owner_repo}' not found") finally: conn.close() def cmd_list(args: argparse.Namespace, db_path: str) -> None: """List all tracked repositories.""" conn = get_connection(db_path) try: from src.db import list_repos repos = list_repos(conn) if not repos: print("No repositories tracked.") return # Format table header = f"{'REPO':<20} {'MODE':<10} {'ENTRIES':<8} {'LAST CHECKED'}" print(header) print("-" * len(header)) for r in repos: last = r["last_checked"] or "never" if "T" in str(last): last = str(last)[:16].replace("T", " ") print(f"{r['owner_repo']:<20} {r['mode']:<10} {r['entries']:<8} {last}") finally: conn.close() def cmd_check(args: argparse.Namespace, db_path: str) -> None: """Fetch latest releases/tags for all tracked repos.""" conn = get_connection(db_path) try: from src.db import get_repos_with_mode, upsert_entry, get_repo_id repos = get_repos_with_mode(conn) if not repos: print("No repositories tracked. Use 'ghrel add ' first.") return since = getattr(args, "since", None) since_delta = parse_since(since) if since else None with GitHubClient() as gh: total_new = 0 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: print(f"Warning: Failed to check {owner_repo}: {e}", file=sys.stderr) print(f"Checked {len(repos)} repos: {total_new} new entries found") finally: 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 port = args.port or DEFAULT_SERVER_PORT from src.server import create_handler from http.server import ThreadingHTTPServer handler = create_handler(db_path) try: server = ThreadingHTTPServer((host, port), handler) except OSError as e: if e.errno == 98: # Address already in use error(f"Port {port} is already in use") raise print(f"Serving RSS feed at http://{host}:{port}/feed.xml") try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down server.") server.server_close() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="ghrel", description="Track GitHub releases and tags as an RSS feed", ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output") parser.add_argument("--quiet", "-q", action="store_true", help="Suppress non-essential output") parser.add_argument("--db-path", default=None, help="Override database path") subparsers = parser.add_subparsers(dest="command") # add p_add = subparsers.add_parser("add", help="Add a repository to track") p_add.add_argument("repo", help="Repository in 'owner/repo' format") p_add.add_argument("--tags", action="store_true", help="Track git tags instead of releases") # remove p_remove = subparsers.add_parser("remove", help="Remove a repository") p_remove.add_argument("repo", help="Repository in 'owner/repo' format") # update p_update = subparsers.add_parser("update", help="Update tracking mode") p_update.add_argument("repo", help="Repository in 'owner/repo' format") p_update.add_argument("--tags", action="store_true", help="Switch to tracking tags") p_update.add_argument("--release", action="store_true", help="Switch to tracking releases") # list subparsers.add_parser("list", help="List tracked repositories") # check p_check = subparsers.add_parser("check", help="Fetch latest releases/tags") p_check.add_argument("--since", nargs="?", const="24h", default=None, help="Only fetch entries since N hours/days/minutes ago (e.g., 24h, 2d)") # serve p_serve = subparsers.add_parser("serve", help="Start HTTP server") 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 def main() -> None: parser = build_parser() args = parser.parse_args() if not args.command: parser.print_help() sys.exit(0) db_path = args.db_path or str(get_db_path()) commands = { "add": cmd_add, "remove": cmd_remove, "update": cmd_update, "list": cmd_list, "check": cmd_check, "serve": cmd_serve, "daemon": cmd_daemon, } cmd = commands.get(args.command) if cmd: cmd(args, db_path) else: parser.print_help() sys.exit(2) if __name__ == "__main__": main()