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
+3
View File
@@ -0,0 +1,3 @@
"""GitHub Release Monitor - track GitHub releases and tags as an RSS feed."""
__version__ = "0.1.0"
+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()
+29
View File
@@ -0,0 +1,29 @@
"""Centralized configuration."""
import os
from pathlib import Path
def get_db_path(override: str | None = None) -> Path:
"""Return the database file path.
Priority: override argument > env var GHREL_DB_PATH > default.
"""
if override:
return Path(override)
env_path = os.environ.get("GHREL_DB_PATH")
if env_path:
return Path(env_path)
return Path.home() / ".config" / "ghrel" / "repos.db"
def get_gh_token() -> str | None:
"""Return the GitHub token from the environment, or None."""
return os.environ.get("GH_TOKEN")
# Defaults
DEFAULT_SERVER_HOST = "127.0.0.1"
DEFAULT_SERVER_PORT = 8080
RSS_MAX_ITEMS = 50
API_PER_PAGE = 30
+177
View File
@@ -0,0 +1,177 @@
"""SQLite database layer."""
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Literal
from src.config import get_db_path
def get_connection(db_path: str | Path | None = None) -> sqlite3.Connection:
"""Open a database connection, creating the DB and schema if needed."""
path = Path(db_path) if db_path else get_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
_create_tables(conn)
# Set restrictive permissions on the database file
try:
path.chmod(0o600)
except OSError:
pass
return conn
def _create_tables(conn: sqlite3.Connection) -> None:
"""Create the database tables if they don't exist."""
conn.executescript("""
CREATE TABLE IF NOT EXISTS repos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_repo TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'release' CHECK(mode IN ('release', 'tag')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('release', 'tag')),
tag_name TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT DEFAULT '',
published_at DATETIME NOT NULL,
html_url TEXT NOT NULL,
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE,
UNIQUE(repo_id, tag_name)
);
""")
conn.commit()
# ── Repos ──
def add_repo(conn: sqlite3.Connection, owner_repo: str, mode: Literal["release", "tag"] = "release") -> int:
"""Add a repository to track. Returns the repo id."""
cursor = conn.execute(
"INSERT INTO repos (owner_repo, mode) VALUES (?, ?)", (owner_repo, mode)
)
conn.commit()
return cursor.lastrowid
def remove_repo(conn: sqlite3.Connection, owner_repo: str) -> bool:
"""Remove a repository. Returns True if a row was deleted."""
cursor = conn.execute("DELETE FROM repos WHERE owner_repo = ?", (owner_repo,))
conn.commit()
return cursor.rowcount > 0
def update_repo_mode(conn: sqlite3.Connection, owner_repo: str, mode: Literal["release", "tag"]) -> bool:
"""Update the tracking mode for a repo. Returns True if a row was updated."""
cursor = conn.execute(
"UPDATE repos SET mode = ? WHERE owner_repo = ?", (mode, owner_repo)
)
conn.commit()
return cursor.rowcount > 0
def list_repos(conn: sqlite3.Connection) -> list[dict]:
"""List all tracked repositories with entry counts and last checked time."""
rows = conn.execute("""
SELECT
r.owner_repo,
r.mode,
COUNT(e.id) AS entries,
MAX(e.fetched_at) AS last_checked
FROM repos r
LEFT JOIN entries e ON e.repo_id = r.id
GROUP BY r.id
ORDER BY r.owner_repo
""").fetchall()
return [
{
"owner_repo": row[0],
"mode": row[1],
"entries": row[2],
"last_checked": row[3],
}
for row in rows
]
def get_repo_id(conn: sqlite3.Connection, owner_repo: str) -> int | None:
"""Get the internal id for a repo, or None if not found."""
row = conn.execute("SELECT id FROM repos WHERE owner_repo = ?", (owner_repo,)).fetchone()
return row[0] if row else None
def get_repos_with_mode(conn: sqlite3.Connection) -> list[tuple[int, str, str]]:
"""Return list of (id, owner_repo, mode) for all repos."""
return conn.execute("SELECT id, owner_repo, mode FROM repos").fetchall()
# ── Entries ──
def upsert_entry(
conn: sqlite3.Connection,
repo_id: int,
kind: Literal["release", "tag"],
tag_name: str,
title: str,
body: str,
published_at: str,
html_url: str,
) -> bool:
"""Insert an entry, skip if already exists (by repo_id + tag_name).
Returns True if a new entry was inserted.
"""
try:
conn.execute(
"""
INSERT INTO entries (repo_id, kind, tag_name, title, body, published_at, html_url)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(repo_id, kind, tag_name, title, body, published_at, html_url),
)
conn.commit()
return True
except sqlite3.IntegrityError:
return False
def get_all_entries(conn: sqlite3.Connection, limit: int = 50) -> list[dict]:
"""Get all entries sorted by published_at descending, capped at *limit*."""
rows = conn.execute("""
SELECT e.tag_name, e.title, e.body, e.published_at, e.html_url,
e.kind, r.owner_repo
FROM entries e
JOIN repos r ON r.id = e.repo_id
ORDER BY e.published_at DESC
LIMIT ?
""", (limit,)).fetchall()
return [
{
"tag_name": row[0],
"title": row[1],
"body": row[2],
"published_at": row[3],
"html_url": row[4],
"kind": row[5],
"owner_repo": row[6],
}
for row in rows
]
def get_entry_count(conn: sqlite3.Connection, repo_id: int) -> int:
"""Get the number of entries for a repo."""
row = conn.execute(
"SELECT COUNT(*) FROM entries WHERE repo_id = ?", (repo_id,)
).fetchone()
return row[0]
+172
View File
@@ -0,0 +1,172 @@
"""GitHub API interaction."""
import time
from datetime import datetime, timezone
from typing import Literal
import httpx
from src.config import API_PER_PAGE, get_gh_token
class GitHubClient:
"""Client for the GitHub REST API with rate-limit awareness."""
BASE_URL = "https://api.github.com"
def __init__(self, token: str | None = None):
self._client = httpx.Client()
headers = {}
self._token = token or get_gh_token()
if self._token:
headers["Authorization"] = f"token {self._token}"
self._client.headers.update(headers)
self._rate_limit_remaining = None
self._rate_limit_reset = None
def close(self):
self._client.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def _check_rate_limit(self, response: httpx.Response) -> None:
"""Update rate limit state from response headers."""
remaining = response.headers.get("X-RateLimit-Remaining")
reset = response.headers.get("X-RateLimit-Reset")
if remaining is not None:
self._rate_limit_remaining = int(remaining)
if reset is not None:
self._rate_limit_reset = int(reset)
def _handle_rate_limit(self, response: httpx.Response) -> None:
"""Wait if rate limited."""
if response.status_code == 403 and self._rate_limit_remaining is not None and self._rate_limit_remaining == 0:
if self._rate_limit_reset:
wait = max(1, self._rate_limit_reset - int(time.time()))
import sys
print(f"Warning: Rate limited. Waiting {wait}s...", file=sys.stderr)
time.sleep(wait)
def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with retry and rate-limit handling."""
last_error = None
for attempt in range(3):
try:
response = self._client.request(method, url, **kwargs)
self._check_rate_limit(response)
if response.status_code == 401:
raise AuthenticationError("GH_TOKEN is invalid (401 Unauthorized)")
if response.status_code == 404:
raise NotFoundError(f"Resource not found: {url}")
self._handle_rate_limit(response)
response.raise_for_status()
return response
except (NotFoundError, AuthenticationError):
raise
except httpx.HTTPStatusError:
raise
except httpx.TransportError as e:
last_error = e
if attempt < 2:
time.sleep(2 ** attempt)
raise last_error # type: ignore[misc]
def validate_token(self) -> None:
"""Check that the token is valid by hitting the rate_limit endpoint."""
if not self._token:
return # No token, allow unauthenticated
resp = self._request("GET", f"{self.BASE_URL}/rate_limit")
def fetch_releases(self, owner_repo: str) -> list[dict]:
"""Fetch the latest releases for a repository.
Returns a list of dicts with keys: tag_name, title, body, published_at, html_url.
"""
resp = self._request(
"GET",
f"{self.BASE_URL}/repos/{owner_repo}/releases",
params={"per_page": API_PER_PAGE},
)
data = resp.json()
result = []
for release in data:
result.append({
"tag_name": release["tag_name"],
"title": release.get("name") or release["tag_name"],
"body": release.get("body") or "",
"published_at": release.get("published_at") or datetime.now(timezone.utc).isoformat(),
"html_url": release.get("html_url") or f"https://github.com/{owner_repo}/releases/tag/{release['tag_name']}",
})
return result
def fetch_tags(self, owner_repo: str) -> list[dict]:
"""Fetch the latest tags for a repository.
Returns a list of dicts with keys: tag_name, title, body, published_at, html_url.
"""
resp = self._request(
"GET",
f"{self.BASE_URL}/repos/{owner_repo}/tags",
params={"per_page": API_PER_PAGE},
)
data = resp.json()
result = []
for tag in data:
tag_name = tag["name"]
published_at = self._get_tag_date(owner_repo, tag_name)
result.append({
"tag_name": tag_name,
"title": tag_name,
"body": "",
"published_at": published_at,
"html_url": f"https://github.com/{owner_repo}/tags/{tag_name}",
})
return result
def _get_tag_date(self, owner_repo: str, tag_name: str) -> str:
"""Get the commit date for a tag by resolving the git ref."""
try:
resp = self._request(
"GET",
f"{self.BASE_URL}/repos/{owner_repo}/git/ref/tags/{tag_name}",
)
data = resp.json()
# The ref may point to a commit directly or to a tag object
obj = data.get("object", {})
obj_type = obj.get("type")
if obj_type == "commit":
author_date = obj.get("author", {}).get("date")
if author_date:
return author_date
elif obj_type == "tag":
# Follow the tag object to get the commit
commit_sha = obj.get("sha")
if commit_sha:
commit_resp = self._request("GET", f"{self.BASE_URL}/repos/{owner_repo}/git/commits/{commit_sha}")
commit_data = commit_resp.json()
author_date = commit_data.get("author", {}).get("date")
if author_date:
return author_date
except Exception:
pass
return datetime.now(timezone.utc).isoformat()
def repo_exists(self, owner_repo: str) -> bool:
"""Check if a repository exists on GitHub."""
try:
resp = self._request("GET", f"{self.BASE_URL}/repos/{owner_repo}")
return resp.status_code == 200
except (NotFoundError, httpx.HTTPStatusError):
return False
class AuthenticationError(Exception):
pass
class NotFoundError(Exception):
pass
+79
View File
@@ -0,0 +1,79 @@
"""RSS feed generation."""
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
import bleach
import markdown
from src.config import RSS_MAX_ITEMS
def strip_markdown(text: str) -> str:
"""Convert markdown to HTML, then strip tags to produce plain text."""
if not text:
return ""
html = markdown.markdown(text)
plain = bleach.clean(html, tags=[], strip=True)
return plain.strip()
def format_rfc822(dt_str: str) -> str:
"""Convert an ISO 8601 datetime string to RFC 822 format for RSS."""
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
def generate_feed(entries: list[dict], base_url: str = "http://127.0.0.1:8080") -> str:
"""Generate an RSS 2.0 XML feed from a list of entry dicts.
Each entry should have: tag_name, title, body, published_at, html_url, owner_repo.
"""
root = ET.Element("rss")
root.set("version", "2.0")
channel = ET.SubElement(root, "channel")
now = datetime.now(timezone.utc)
ET.SubElement(channel, "title").text = "GitHub Release Monitor"
ET.SubElement(channel, "description").text = "Latest releases from tracked GitHub repositories"
ET.SubElement(channel, "link").text = base_url.rstrip("/") + "/"
ET.SubElement(channel, "lastBuildDate").text = now.strftime("%a, %d %b %Y %H:%M:%S %z")
for entry in entries[:RSS_MAX_ITEMS]:
item = ET.SubElement(channel, "item")
owner_repo = entry.get("owner_repo", "")
ET.SubElement(item, "title").text = f"[{owner_repo}] {entry['title']}"
body = entry.get("body", "")
if body:
plain = strip_markdown(body)
if len(plain) > 300:
plain = plain[:300] + "\u2026"
ET.SubElement(item, "description").text = plain
else:
ET.SubElement(item, "description").text = f"Tag {entry['tag_name']}"
ET.SubElement(item, "link").text = entry["html_url"]
ET.SubElement(item, "pubDate").text = format_rfc822(entry["published_at"])
ET.SubElement(item, "guid").text = (
f"github.com/{owner_repo}/releases/tag/{entry['tag_name']}"
)
return ET.tostring(root, encoding="unicode", xml_declaration=False)
def generate_index_html(base_url: str = "http://127.0.0.1:8080") -> str:
"""Generate a simple index page with a link to the RSS feed."""
return f"""<!DOCTYPE html>
<html>
<head><title>GitHub Release Monitor</title></head>
<body>
<h1>GitHub Release Monitor</h1>
<p><a href="{base_url.rstrip('/')}/feed.xml">RSS Feed</a></p>
<p><a href="{base_url.rstrip('/')}/health">Health Check</a></p>
</body>
</html>
"""
+66
View File
@@ -0,0 +1,66 @@
"""HTTP server for serving the RSS feed."""
from http.server import BaseHTTPRequestHandler
from urllib.parse import urlparse
from src.db import get_connection
from src.rss import generate_feed, generate_index_html
def create_handler(db_path: str):
"""Factory that returns an HTTP request handler class bound to a DB path."""
class FeedHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
if path == "/feed.xml":
self._serve_feed()
elif path == "/health":
self._serve_health()
elif path == "/":
self._serve_index()
else:
self.send_response(404)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Not Found")
def _serve_feed(self):
conn = get_connection(db_path)
try:
from src.db import get_all_entries
entries = get_all_entries(conn)
feed_xml = generate_feed(entries, self._base_url())
finally:
conn.close()
self.send_response(200)
self.send_header("Content-Type", "application/rss+xml; charset=utf-8")
self.end_headers()
self.wfile.write(feed_xml.encode("utf-8"))
def _serve_index(self):
html = generate_index_html(self._base_url())
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(html.encode("utf-8"))
def _serve_health(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
def _base_url(self) -> str:
host = self.server.server_address[0]
port = self.server.server_address[1]
return f"http://{host}:{port}"
def log_message(self, format, *args):
"""Suppress default request logging."""
pass
return FeedHandler