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
15 KiB
GitHub Release Monitor — Project Plan
1. Overview
A CLI tool that tracks GitHub releases or tags from user-defined repositories and exposes them as a subscribable RSS feed. Some repos (e.g., Zammad) only use git tags without creating GitHub releases — both modes are supported.
- Language: Python 3.12
- Storage: SQLite (single file, embedded via
sqlite3stdlib) - API: GitHub REST API via
httpx - Output: RSS 2.0 XML feed served over HTTP (stdlib
http.server)
2. Architecture
┌──────────────────────────────────────────────────┐
│ CLI Interface │
│ (add, remove, update, list, check, serve) │
├──────────────────────────────────────────────────┤
│ Core Logic │
│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │
│ │ config.py │ │ db.py │ │github.py│ │
│ │ (defaults, │ │ (sqlite3) │ │(httpx) │ │
│ │ env vars) │ │ │ │ │ │
│ └──────────────┘ └──────┬───────┘ └───┬────┘ │
│ │ │ │
│ ┌────────▼──────────────▼────┐ │
│ │ rss.py │ │
│ │ (xml.etree + markdown) │ │
│ └──────────────┬─────────────┘ │
│ │ │
│ ┌──────────────▼─────────────┐ │
│ │ server.py │ │
│ │ (http.server, stdlib) │ │
│ │ (/feed.xml endpoint) │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────────────────┘
3. File Structure
github_release_monitor/
├── plan.md # This file
├── pyproject.toml # Project metadata, dependencies
├── README.md
├── src/
│ ├── __init__.py
│ ├── cli.py # CLI entry point (argparse)
│ ├── config.py # Centralized config (defaults, env vars, paths)
│ ├── db.py # SQLite schema & queries
│ ├── github.py # GitHub API interaction
│ ├── rss.py # RSS feed generation
│ └── server.py # HTTP server for feed
├── tests/
│ ├── __init__.py
│ ├── test_db.py
│ ├── test_github.py
│ ├── test_rss.py
│ ├── test_server.py
│ └── test_integration.py # End-to-end tests
└── .gitignore
4. Database Schema
repos table
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTO | Internal identifier |
| owner_repo | TEXT | NOT NULL UNIQUE | GitHub repo identifier (owner/repo) |
| mode | TEXT | NOT NULL DEFAULT 'release' CHECK(mode IN ('release', 'tag')) | Track releases or tags |
| created_at | DATETIME | DEFAULT NOW() | When the repo was added |
The owner_repo field serves as both the unique identifier and the display name. The mode field determines whether to fetch GitHub releases (release) or git tags (tag). Default is release.
entries table
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTO | Internal identifier |
| repo_id | INTEGER | FK → repos.id | Which repository this entry belongs to |
| kind | TEXT | NOT NULL CHECK(kind IN ('release', 'tag')) | Whether this is a release or tag |
| tag_name | TEXT | NOT NULL | Git tag / release tag |
| title | TEXT | NOT NULL | Release title or tag name |
| body | TEXT | DEFAULT '' | Release notes (empty for tags) |
| published_at | DATETIME | NOT NULL | Release date or commit date |
| html_url | TEXT | NOT NULL | Link to release or commit on GitHub |
| fetched_at | DATETIME | DEFAULT NOW() | When we fetched this entry |
Unique constraint: (repo_id, tag_name) to avoid duplicates.
5. CLI Commands
ghrel # Main command
├── --version # Show version
├── --verbose / -v # Enable verbose/debug output
├── --quiet / -q # Suppress non-essential output
├── --db-path <path> # Override database path (default: ~/.config/ghrel/repos.db)
├── add <owner/repo> [--tags] # Add a repository to track
├── remove <owner/repo> # Remove a repository
├── update <owner/repo> [--tags] [--release] # Update tracking mode
├── list # List all tracked repositories
├── check [--since N] # Fetch latest releases/tags for all repos
└── serve [--port 8080] [--host 127.0.0.1] # Start HTTP server
add <owner/repo> [--tags]
- Accepts a single
owner/repoargument (e.g.,golang/go) --tagsflag: track git tags instead of releases (default: releases)- Validates the format with a regex:
^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ - Pings the GitHub API to verify the repo exists (with token validation)
- Inserts into
repostable; errors on duplicateowner_repo - On success:
Added golang/go (releases)orAdded zammad/zammad (tags)
remove <owner/repo>
- Deletes from
repostable byowner_repo - Cascading delete removes associated entries
- On success:
Removed golang/go
update <owner/repo> [--tags] [--release]
- Updates the tracking mode for an existing repo
--tags: switch to tracking tags--release: switch to tracking releases- On success:
Updated zammad/zammad to track tags
list
- Displays a table of tracked repos:
REPO MODE ENTRIES LAST CHECKED golang/go release 12 2025-01-15 10:30 zammad/zammad tag 8 2025-01-15 10:30
check [--since N]
- Iterates over all repos in the database
- For each repo, fetches latest releases or tags depending on the repo's
mode - If
--since Nis provided (e.g.,--since 24h), only fetches entries published in the last N hours - Upserts into
entriestable (insert if new, skip if already known bytag_name) - Reports results:
Checked 5 repos: 3 new entries found - Global rate-limit-aware queue: if one repo hits the rate limit, remaining repos wait
serve [--port 8080] [--host 127.0.0.1]
- Starts an HTTP server using stdlib
http.server - Default host:
127.0.0.1(localhost only, not exposed to network) - Serves RSS feed at
/feed.xml - Serves a small index page at
/with a link to the feed - Health check endpoint at
/healthreturns200 OK
Error format
All errors go to stderr with non-zero exit code:
Error: <message>
6. GitHub API Integration
Approach: Direct HTTP calls via httpx
- Authentication: Read
GH_TOKENfrom environment variable. If set, use it for authenticated requests (5000/h vs 60/h). If not set, fall back to unauthenticated. - Token validation: On first API call after startup, check
GET /rate_limitto verify the token is valid. If401, print a clear error:Error: Invalid or missing GH_TOKEN. Set it with: export GH_TOKEN=your_token - Pagination: Fetch first page only (30 items). Sufficient for tracking recent entries.
- Rate limiting: Track
X-RateLimit-Remainingheader globally across all repos. If rate limited, pause all requests untilX-RateLimit-Resettime. No per-repo retries — a single global wait prevents cascading 403s.
Two API endpoints depending on mode
Releases (mode = 'release'):
GET https://api.github.com/repos/{owner}/{repo}/releases?per_page=30- Data extracted:
tag_name,name(title),body(markdown),published_at,html_url
Tags (mode = 'tag'):
GET https://api.github.com/repos/{owner}/{repo}/tags?per_page=30- Data extracted:
name(used as bothtag_nameandtitle),zipball_url→ derivehtml_urlashttps://github.com/{owner}/{repo}/tags/{name} - Tags have no
body— store empty string - Tags have no
published_at— fetch commit date viaGET /repos/{owner}/{repo}/git/ref/tags/{name}and useobject.commit.author.datefrom the resolved commit object
7. RSS Feed Specification
RSS 2.0 generated with stdlib xml.etree.ElementTree. RSS 2.0 is simple enough that feedgen is unnecessary.
Feed-level
- title: "GitHub Release Monitor"
- description: "Latest releases from tracked GitHub repositories"
- link:
http://{host}:{port}/(base URL of the serving instance) - lastBuildDate: Current time in RFC 822 format
Per-item
- title:
[{owner/repo}] {title} - description: First 300 characters of
body, with markdown stripped to plain text. Append…if truncated. For tags (no body), use:Tag {tag_name} - link:
html_urlfrom the entry - pubDate:
published_atin RFC 822 format - guid:
github.com/{owner}/{repo}/releases/tag/{tag_name}(unique identifier, same format for both releases and tags)
Items sorted by published_at descending (newest first). Capped at 50 items to prevent feed bloat.
Markdown stripping
Use markdown library to convert markdown to HTML, then bleach to strip all HTML tags and produce plain text.
8. Configuration (config.py)
Centralized configuration module:
| Setting | Default | Override |
|---|---|---|
db_path |
~/.config/ghrel/repos.db |
--db-path flag |
gh_token |
GH_TOKEN env var |
— |
server_host |
127.0.0.1 |
--host flag |
server_port |
8080 |
--port flag |
rss_max_items |
50 |
— |
api_per_page |
30 |
— |
On database creation, set file permissions to 0600 (owner read/write only) to avoid leaking tracked repo list.
9. Error Handling
- GitHub API errors (404, 403, etc.): Log warning, skip the repo, continue with others
- Network errors: Retry with exponential backoff (3 attempts, 1s / 2s / 4s), then skip
- Rate limit exceeded: Log warning with reset time, pause all requests globally
- Invalid repo URL: Validate format on
add, show clear error:Error: Invalid repo format 'foo'. Expected 'owner/repo' - Invalid token: Clear error on first API call:
Error: GH_TOKEN is invalid (401 Unauthorized) - Database errors: Wrap in user-friendly messages:
Error: Failed to open database: <reason> - Port in use: Clear error:
Error: Port 8080 is already in use
All errors go to stderr. Exit codes: 0 success, 1 general error, 2 usage error.
10. Testing Strategy
Write tests alongside implementation, not after. Each module gets tests before moving to the next.
| Test file | What it covers |
|---|---|
test_db.py |
Schema creation, CRUD, constraints, mode CHECK, 0600 permissions |
test_github.py |
API mocking for both releases and tags, parsing, rate limiting, token validation |
test_rss.py |
Feed generation, markdown stripping, truncation, XML escaping, item cap, tag entries (no body) |
test_server.py |
HTTP server, feed endpoint, health check, response headers |
test_integration.py |
Full flow: add → check → serve → fetch feed → verify XML (both modes) |
test_cli.py |
CLI smoke tests: exit codes, stderr output, flag parsing, --tags flag |
Use pytest with respx for mocking httpx. In-memory SQLite for DB tests. pytest-httpserver or http.server in a thread for integration tests.
11. Implementation Order
- Project scaffolding:
pyproject.toml, directory structure,.gitignore - Config module (
config.py): Defaults, env vars, DB path management - Database layer (
db.py): Schema, connection, CRUD functions +test_db.py - CLI foundation (
cli.py):add,remove,listcommands +test_cli.py - GitHub fetcher (
github.py): API calls for releases and tags, parsing, rate limiting, token validation +test_github.py - CLI
checkcommand: Integration of fetcher with DB upsert - RSS generator (
rss.py): xml.etree generation, markdown stripping +test_rss.py - HTTP server (
server.py): stdlib http.server,/feed.xml,/health+test_server.py - CLI
servecommand: Wire up server - Integration tests (
test_integration.py): End-to-end flow - README: Usage documentation
12. Dependencies
| Package | Purpose |
|---|---|
httpx |
HTTP client for GitHub API |
markdown |
Convert markdown to HTML |
bleach |
Strip HTML tags for plain text |
pytest |
Testing framework |
respx |
Mocking HTTP requests for tests |
No heavy frameworks. http.server and xml.etree.ElementTree are stdlib.
13. Future Considerations (Out of Scope for MVP)
- Webhook-based real-time updates instead of polling
- Docker container for easy deployment
- Configuration file (
ghrel.toml) instead of CLI-only management - Support for GitLab releases
- Email notifications for new releases
- Web UI for managing tracked repos
- Scheduled background checks (cron / systemd timer integration)