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:
+268
@@ -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()
|
||||
Reference in New Issue
Block a user