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:
@@ -0,0 +1,3 @@
|
||||
"""GitHub Release Monitor - track GitHub releases and tags as an RSS feed."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
+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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
"""
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user