Files
github_release_monitor/src/cli.py
T
ARIA e1bd1ad9eb Fix server threading, tag URLs, and tag date resolution
- Switch to ThreadingHTTPServer to handle concurrent requests
- Catch BrokenPipeError on response writes to suppress noisy tracebacks
- Change tag URL from /tags/ to /releases/tag/
- Resolve tag dates via commit SHA from tags API + commits endpoint (works for public repos without auth)
- Update tests to match new tag date resolution approach
2026-07-28 12:25:04 +02:00

410 lines
14 KiB
Python

"""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 <owner/repo>' 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()