Add per-repo cover image support for RSS feed items

Add image_url column to repos table with migration, CLI image add/remove
commands, and Media RSS <media:content> elements in generated feed output.

New CLI commands: ghrel image add <repo> <url>, ghrel image remove <repo>
This commit is contained in:
2026-08-05 11:42:32 +02:00
parent 5c1d745ed3
commit 5cd809930e
6 changed files with 316 additions and 1 deletions
+66
View File
@@ -301,6 +301,44 @@ def cmd_check_for_daemon(db_path: str, since: str = None) -> None:
conn.close()
def cmd_image_add(args: argparse.Namespace, db_path: str) -> None:
"""Add an image URL for a repository."""
owner_repo = args.repo
if not REPO_PATTERN.match(owner_repo):
error(f"Invalid repo format '{owner_repo}'. Expected 'owner/repo'")
image_url = args.url
if not re.match(r"^https?://", image_url):
error("Image URL must start with http:// or https://")
conn = get_connection(db_path)
try:
from src.db import set_repo_image
if set_repo_image(conn, owner_repo, image_url):
print(f"Set image for {owner_repo}: {image_url}")
else:
error(f"Repository '{owner_repo}' not found")
finally:
conn.close()
def cmd_image_remove(args: argparse.Namespace, db_path: str) -> None:
"""Remove the image URL for a repository."""
owner_repo = args.repo
if not REPO_PATTERN.match(owner_repo):
error(f"Invalid repo format '{owner_repo}'. Expected 'owner/repo'")
conn = get_connection(db_path)
try:
from src.db import remove_repo_image
if remove_repo_image(conn, owner_repo):
print(f"Removed image for {owner_repo}")
else:
error(f"No image set for '{owner_repo}' (or repository not 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
@@ -367,6 +405,17 @@ def build_parser() -> argparse.ArgumentParser:
p_serve.add_argument("--host", default=DEFAULT_SERVER_HOST, help="Server host")
p_serve.add_argument("--base-url", default=None, help="Base URL for feed links. Port is auto-appended (e.g., http://192.168.1.100)")
# image
p_image = subparsers.add_parser("image", help="Manage repository cover images")
image_sub = p_image.add_subparsers(dest="image_command")
p_image_add = image_sub.add_parser("add", help="Set a cover image for a repository")
p_image_add.add_argument("repo", help="Repository in 'owner/repo' format")
p_image_add.add_argument("url", help="Image URL (http:// or https://)")
p_image_remove = image_sub.add_parser("remove", help="Remove the cover image for a repository")
p_image_remove.add_argument("repo", help="Repository in 'owner/repo' format")
# daemon
p_daemon = subparsers.add_parser("daemon", help="Run as background daemon")
p_daemon.add_argument("--interval", default="30m",
@@ -399,6 +448,23 @@ def main() -> None:
"daemon": cmd_daemon,
}
# Handle image subcommand
if args.command == "image":
image_commands = {
"add": cmd_image_add,
"remove": cmd_image_remove,
}
if not getattr(args, "image_command", None):
parser.parse_args(["image", "--help"])
sys.exit(2)
cmd = image_commands.get(args.image_command)
if cmd:
cmd(args, db_path)
else:
parser.parse_args(["image", "--help"])
sys.exit(2)
return
cmd = commands.get(args.command)
if cmd:
cmd(args, db_path)
+33 -1
View File
@@ -31,6 +31,7 @@ def _create_tables(conn: sqlite3.Connection) -> None:
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_repo TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'release' CHECK(mode IN ('release', 'tag')),
image_url TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -48,9 +49,20 @@ def _create_tables(conn: sqlite3.Connection) -> None:
UNIQUE(repo_id, tag_name)
);
""")
_migrate_add_image_url(conn)
conn.commit()
def _migrate_add_image_url(conn: sqlite3.Connection) -> None:
"""Add image_url column to repos if it doesn't exist (migration)."""
cursor = conn.execute(
"PRAGMA table_info(repos)"
).fetchall()
column_names = [col[1] for col in cursor]
if "image_url" not in column_names:
conn.execute("ALTER TABLE repos ADD COLUMN image_url TEXT DEFAULT NULL")
# ── Repos ──
@@ -114,6 +126,25 @@ def get_repos_with_mode(conn: sqlite3.Connection) -> list[tuple[int, str, str]]:
return conn.execute("SELECT id, owner_repo, mode FROM repos").fetchall()
def set_repo_image(conn: sqlite3.Connection, owner_repo: str, image_url: str) -> bool:
"""Set an image URL for a repo. Returns True if a row was updated."""
cursor = conn.execute(
"UPDATE repos SET image_url = ? WHERE owner_repo = ?", (image_url, owner_repo)
)
conn.commit()
return cursor.rowcount > 0
def remove_repo_image(conn: sqlite3.Connection, owner_repo: str) -> bool:
"""Remove the image URL for a repo. Returns True if a row was updated."""
cursor = conn.execute(
"UPDATE repos SET image_url = NULL WHERE owner_repo = ? AND image_url IS NOT NULL",
(owner_repo,),
)
conn.commit()
return cursor.rowcount > 0
# ── Entries ──
@@ -149,7 +180,7 @@ 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
e.kind, r.owner_repo, r.image_url
FROM entries e
JOIN repos r ON r.id = e.repo_id
ORDER BY e.published_at DESC
@@ -164,6 +195,7 @@ def get_all_entries(conn: sqlite3.Connection, limit: int = 50) -> list[dict]:
"html_url": row[4],
"kind": row[5],
"owner_repo": row[6],
"image_url": row[7],
}
for row in rows
]
+9
View File
@@ -9,6 +9,8 @@ import markdown
from src.config import RSS_MAX_ITEMS
ET.register_namespace("media", "http://search.yahoo.com/mrss/")
def strip_markdown(text: str) -> str:
"""Convert markdown to HTML, then strip tags to produce plain text."""
@@ -29,6 +31,7 @@ def generate_feed(entries: list[dict], base_url: str = "http://127.0.0.1:8080")
"""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.
Optionally: image_url (for Media RSS cover image).
"""
root = ET.Element("rss")
root.set("version", "2.0")
@@ -62,6 +65,12 @@ def generate_feed(entries: list[dict], base_url: str = "http://127.0.0.1:8080")
f"github.com/{owner_repo}/releases/tag/{entry['tag_name']}"
)
image_url = entry.get("image_url")
if image_url:
media_content = ET.SubElement(item, "{http://search.yahoo.com/mrss/}content")
media_content.set("url", image_url)
media_content.set("medium", "image")
return ET.tostring(root, encoding="unicode", xml_declaration=False)