Initial commit: GitHub Release Monitor CLI tool

CLI tool that tracks GitHub releases or tags from user-defined repositories
and exposes them as a subscribable RSS 2.0 feed.

Features:
- Track releases or tags per repository
- SQLite storage with automatic deduplication
- RSS 2.0 feed with markdown-stripped release notes
- Built-in HTTP server for local feed access
- Rate-limit-aware GitHub API client
- 64 passing tests
This commit is contained in:
2026-07-28 11:56:10 +02:00
commit 2a5f11bd9b
18 changed files with 2117 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
"""CLI entry point."""
import argparse
import re
import sys
from datetime import datetime, timedelta, timezone
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
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_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 HTTPServer
handler = create_handler(db_path)
try:
server = HTTPServer((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")
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,
}
cmd = commands.get(args.command)
if cmd:
cmd(args, db_path)
else:
parser.print_help()
sys.exit(2)
if __name__ == "__main__":
main()