├── requirements.txt ├── .gitattributes ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── .env.example ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md └── app.py /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | .env 4 | .idea/ 5 | .vscode/ 6 | *.log 7 | 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | github-notifications-rss: 3 | build: . 4 | restart: unless-stopped 5 | env_file: 6 | - .env 7 | ports: 8 | - "8083:8000" 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY app.py . 9 | 10 | ENV BIND_ADDR=0.0.0.0 11 | ENV BIND_PORT=8000 12 | 13 | EXPOSE 8000 14 | 15 | CMD ["python", "app.py"] 16 | 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file to ".env" and fill in your values. 2 | 3 | GITHUB_TOKEN=ghp_your_token_here 4 | 5 | # Optional filters 6 | GITHUB_NOTIF_PARTICIPATING_ONLY=true 7 | GITHUB_NOTIF_INCLUDE_READ=false 8 | GITHUB_NOTIF_REASONS_INCLUDE= 9 | GITHUB_NOTIF_REASONS_EXCLUDE=subscribed,ci_activity 10 | GITHUB_NOTIF_REPOS_INCLUDE= 11 | GITHUB_NOTIF_REPOS_EXCLUDE= 12 | 13 | # RSS metadata 14 | RSS_TITLE=GitHub notifications RSS 15 | RSS_LINK=https://github.com/notifications 16 | RSS_DESCRIPTION=Custom feed built from your GitHub notifications 17 | 18 | # Cache duration (seconds). 0 = disable caching. 19 | CACHE_TTL_SECONDS=60 20 | 21 | # Use HTML inside (most readers support this nicely) 22 | RSS_HTML_DESCRIPTION=true 23 | 24 | 25 | # Server 26 | BIND_ADDR=0.0.0.0 27 | BIND_PORT=8000 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | python-checks: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | pip install flake8 27 | 28 | - name: Syntax check 29 | run: python -m compileall . 30 | 31 | - name: Lint with flake8 (only real errors) 32 | run: flake8 app.py --select=E9,F63,F7,F82 33 | 34 | docker-build: 35 | runs-on: ubuntu-latest 36 | needs: python-checks 37 | 38 | steps: 39 | - name: Check out repository 40 | uses: actions/checkout@v4 41 | 42 | - name: Build Docker image 43 | run: docker build . --tag github-notifications-rss:test 44 | 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 timkicker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Notifications RSS 2 | 3 | This is a small service that turns your GitHub notifications into an RSS feed. 4 | 5 | You give it a GitHub personal access token. 6 | It calls the `/notifications` API, filters the data, and serves an RSS 2.0 feed that your reader can subscribe to. 7 | 8 | ## What it does 9 | 10 | - Fetches GitHub notifications using the official API 11 | - Can limit to threads where you are actually involved (`participating_only`) 12 | - Can filter by reason (mention, assign, state_change, ci_activity, ...) 13 | - Can include or exclude specific repositories 14 | - Caches results for a short time so it does not hammer the GitHub API 15 | - Exposes two endpoints: 16 | - `/feed` for the RSS feed 17 | - `/health` for a simple JSON status 18 | 19 | Descriptions can be HTML or plain text, depending on config. 20 | 21 | ## Example feed item 22 | 23 | In a typical RSS reader a single item might look like this: 24 | 25 | **Title** 26 | 27 | `[timkicker/podliner] Fix MPV logging path on Linux` 28 | 29 | **Body** 30 | 31 | ```bash 32 | [mention] [Pull request] 🔔 33 | Fix MPV logging path on Linux 34 | 35 | Repo: timkicker/podliner 36 | Reason: mention 37 | Type: Pull request 38 | Unread: yes 39 | Repo link: https://github.com/timkicker/podliner 40 | Updated: 2025-11-14T06:56:00+00:00 41 | ``` 42 | 43 | ## Quick start with Docker 44 | 45 | Clone the repo and copy the example env file: 46 | 47 | ```bash 48 | cp .env.example .env 49 | ``` 50 | 51 | Edit `.env` and set at least: 52 | 53 | ```bash 54 | GITHUB_TOKEN=ghp_your_token_here 55 | ``` 56 | 57 | Then build and start: 58 | 59 | ```bash 60 | docker compose up --build -d 61 | ``` 62 | 63 | If your compose file maps port `8083:8000`, the feed is available at: 64 | 65 | - Feed: `http://localhost:8083/feed` 66 | - Health: `http://localhost:8083/health` 67 | 68 | Add `http://localhost:8083/feed` to your RSS reader and you are done. 69 | 70 | ## Token and scopes 71 | 72 | You need a GitHub Personal Access Token (classic). 73 | 74 | - Only public repositories: 75 | - `public_repo` and `read:user` are usually enough 76 | - With private repositories: 77 | - include `repo` 78 | 79 | ## Basic configuration 80 | 81 | Most options are set through environment variables. There is a `.env.example` with all of them. The most useful ones: 82 | 83 | ```bash 84 | # GitHub access 85 | GITHUB_TOKEN=ghp_your_token_here 86 | GITHUB_API_URL=https://api.github.com 87 | 88 | # Query behaviour 89 | GITHUB_NOTIF_PARTICIPATING_ONLY=true 90 | GITHUB_NOTIF_INCLUDE_READ=false 91 | 92 | # Optional filters 93 | GITHUB_NOTIF_REASONS_INCLUDE= 94 | GITHUB_NOTIF_REASONS_EXCLUDE=subscribed,ci_activity 95 | GITHUB_NOTIF_REPOS_INCLUDE= 96 | GITHUB_NOTIF_REPOS_EXCLUDE= 97 | 98 | # RSS output 99 | RSS_TITLE=GitHub notifications RSS 100 | RSS_LINK=https://github.com/notifications 101 | RSS_DESCRIPTION=Custom feed built from your GitHub notifications 102 | RSS_HTML_DESCRIPTION=true 103 | 104 | # Cache 105 | CACHE_TTL_SECONDS=60 106 | 107 | # Server 108 | BIND_ADDR=0.0.0.0 109 | BIND_PORT=8000 110 | ``` 111 | 112 | You can adjust this later when you know what kind of notifications you want to see or hide. For many setups the defaults should be fine. 113 | 114 | ## Status endpoint 115 | 116 | The `/health` endpoint returns a small JSON payload, for example: 117 | 118 | ```json 119 | { 120 | "status": "ok", 121 | "last_fetch": "2025-11-14T06:56:00+00:00", 122 | "last_error": null, 123 | "cache_ttl_seconds": 60 124 | } 125 | ``` 126 | 127 | - `ok` means the last fetch worked 128 | - `degraded` means GitHub failed but an older cached feed is still served 129 | - `error` means there is no valid cache and the last fetch failed 130 | 131 | ## License 132 | 133 | This project is licensed under the MIT License. See `LICENSE` for details. 134 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple GitHub notifications -> RSS bridge. 4 | 5 | - Polls the GitHub REST API /notifications endpoint 6 | - Filters threads by reason and repository 7 | - Exposes a tiny HTTP endpoint that returns an RSS 2.0 feed 8 | 9 | Configure via environment variables (see load_config()). 10 | """ 11 | 12 | import os 13 | import sys 14 | import logging 15 | from datetime import datetime, timezone, timedelta 16 | from email.utils import format_datetime 17 | 18 | import requests 19 | from flask import Flask, Response, jsonify 20 | 21 | 22 | # ----------------------------------------------------------------------------- 23 | # Logging 24 | # ----------------------------------------------------------------------------- 25 | 26 | logging.basicConfig( 27 | level=logging.INFO, 28 | format="%(asctime)s [%(levelname)s] %(message)s", 29 | ) 30 | 31 | logger = logging.getLogger("github-notifications-rss") 32 | 33 | app = Flask(__name__) 34 | 35 | 36 | # ----------------------------------------------------------------------------- 37 | # Helper functions 38 | # ----------------------------------------------------------------------------- 39 | 40 | 41 | def getenv_bool(name: str, default: bool = False) -> bool: 42 | value = os.getenv(name) 43 | if value is None: 44 | return default 45 | return value.strip().lower() in ("1", "true", "yes", "y", "on") 46 | 47 | 48 | def getenv_list(name: str): 49 | value = os.getenv(name, "") 50 | if not value: 51 | return [] 52 | return [v.strip() for v in value.split(",") if v.strip()] 53 | 54 | 55 | def load_config(): 56 | token = os.getenv("GITHUB_TOKEN") 57 | 58 | if not token: 59 | logger.warning( 60 | "GITHUB_TOKEN is not set. /feed will return 503 until you configure it." 61 | ) 62 | 63 | cfg = { 64 | # GitHub API 65 | "token": token, 66 | "api_url": os.getenv("GITHUB_API_URL", "https://api.github.com").rstrip("/"), 67 | 68 | # Notification query behaviour 69 | "participating_only": getenv_bool("GITHUB_NOTIF_PARTICIPATING_ONLY", True), 70 | "include_read": getenv_bool("GITHUB_NOTIF_INCLUDE_READ", False), 71 | "per_page": int(os.getenv("GITHUB_NOTIF_PER_PAGE", "50")), # max 50 72 | "max_pages": int(os.getenv("GITHUB_NOTIF_MAX_PAGES", "3")), 73 | 74 | # Filtering 75 | "include_reasons": set(getenv_list("GITHUB_NOTIF_REASONS_INCLUDE")), 76 | "exclude_reasons": set(getenv_list("GITHUB_NOTIF_REASONS_EXCLUDE")), 77 | "include_repos": set(getenv_list("GITHUB_NOTIF_REPOS_INCLUDE")), 78 | "exclude_repos": set(getenv_list("GITHUB_NOTIF_REPOS_EXCLUDE")), 79 | 80 | # RSS metadata 81 | "rss_title": os.getenv("RSS_TITLE", "GitHub notifications RSS"), 82 | "rss_link": os.getenv("RSS_LINK", "https://github.com/notifications"), 83 | "rss_description": os.getenv( 84 | "RSS_DESCRIPTION", 85 | "Custom feed built from your GitHub notifications", 86 | ), 87 | 88 | # Cache 89 | "cache_ttl_seconds": int(os.getenv("CACHE_TTL_SECONDS", "60")), 90 | # HTML descriptions 91 | "rss_html_description": getenv_bool("RSS_HTML_DESCRIPTION", True), 92 | } 93 | 94 | # Sanity bounds 95 | cfg["per_page"] = max(1, min(cfg["per_page"], 50)) 96 | cfg["max_pages"] = max(1, cfg["max_pages"]) 97 | cfg["cache_ttl_seconds"] = max(0, cfg["cache_ttl_seconds"]) 98 | 99 | logger.info("Config loaded:") 100 | logger.info(" api_url = %s", cfg["api_url"]) 101 | logger.info(" participating_only = %s", cfg["participating_only"]) 102 | logger.info(" include_read = %s", cfg["include_read"]) 103 | logger.info(" per_page = %d, max_pages = %d", cfg["per_page"], cfg["max_pages"]) 104 | logger.info( 105 | " include_reasons = %s, exclude_reasons = %s", 106 | cfg["include_reasons"] or "(none)", 107 | cfg["exclude_reasons"] or "(none)", 108 | ) 109 | logger.info( 110 | " include_repos = %s, exclude_repos = %s", 111 | cfg["include_repos"] or "(none)", 112 | cfg["exclude_repos"] or "(none)", 113 | ) 114 | logger.info(" cache_ttl_seconds = %d", cfg["cache_ttl_seconds"]) 115 | logger.info(" rss_html_description = %s", cfg["rss_html_description"]) 116 | 117 | return cfg 118 | 119 | 120 | CONFIG = load_config() 121 | 122 | 123 | def github_headers(): 124 | return { 125 | "Authorization": f"Bearer {CONFIG['token']}", 126 | "Accept": "application/vnd.github+json", 127 | "X-GitHub-Api-Version": "2022-11-28", 128 | "User-Agent": "github-notifications-rss", 129 | } 130 | 131 | 132 | # ----------------------------------------------------------------------------- 133 | # GitHub API + filtering 134 | # ----------------------------------------------------------------------------- 135 | 136 | 137 | def fetch_notifications(): 138 | """ 139 | Fetch notifications from GitHub, following pagination up to max_pages. 140 | Raises requests.RequestException on network/HTTP problems. 141 | """ 142 | url = f"{CONFIG['api_url']}/notifications" 143 | 144 | params = { 145 | "per_page": CONFIG["per_page"], 146 | "all": "true" if CONFIG["include_read"] else "false", 147 | "participating": "true" if CONFIG["participating_only"] else "false", 148 | } 149 | 150 | notifications = [] 151 | page = 1 152 | 153 | while page <= CONFIG["max_pages"]: 154 | params["page"] = page 155 | logger.info("Requesting notifications page %d", page) 156 | 157 | resp = requests.get( 158 | url, 159 | headers=github_headers(), 160 | params=params, 161 | timeout=10, 162 | ) 163 | 164 | if resp.status_code == 304: 165 | logger.info("GitHub returned 304 Not Modified") 166 | break 167 | 168 | resp.raise_for_status() 169 | 170 | page_items = resp.json() 171 | if not page_items: 172 | logger.info("No further notifications on page %d", page) 173 | break 174 | 175 | notifications.extend(page_items) 176 | 177 | link_header = resp.headers.get("Link", "") 178 | if 'rel="next"' not in link_header: 179 | break 180 | 181 | page += 1 182 | 183 | logger.info("Fetched %d notifications from GitHub", len(notifications)) 184 | return notifications 185 | 186 | 187 | def filter_notifications(notifications): 188 | """ 189 | Apply simple in-Python filters: 190 | - include / exclude reasons 191 | - include / exclude repos (full_name: owner/repo) 192 | """ 193 | inc_reasons = CONFIG["include_reasons"] 194 | exc_reasons = CONFIG["exclude_reasons"] 195 | inc_repos = CONFIG["include_repos"] 196 | exc_repos = CONFIG["exclude_repos"] 197 | 198 | filtered = [] 199 | 200 | for n in notifications: 201 | reason = n.get("reason") 202 | repo = n.get("repository") or {} 203 | repo_full_name = repo.get("full_name") 204 | 205 | if inc_reasons and reason not in inc_reasons: 206 | continue 207 | if exc_reasons and reason in exc_reasons: 208 | continue 209 | 210 | if inc_repos and repo_full_name not in inc_repos: 211 | continue 212 | if exc_repos and repo_full_name in exc_repos: 213 | continue 214 | 215 | filtered.append(n) 216 | 217 | logger.info("Filtered down to %d notifications", len(filtered)) 218 | return filtered 219 | 220 | 221 | def subject_html_url(notification): 222 | """ 223 | Try to turn the API subject URL into a human-facing HTML URL. 224 | 225 | Example: 226 | API: https://api.github.com/repos/owner/repo/issues/123 227 | HTML: https://github.com/owner/repo/issues/123 228 | """ 229 | subject = notification.get("subject") or {} 230 | api_url = subject.get("url") or "" 231 | repo = notification.get("repository") or {} 232 | repo_html = repo.get("html_url") or "" 233 | 234 | if api_url.startswith("https://api.github.com/repos/") and repo_html: 235 | rest = api_url.split("/repos/", 1)[1] 236 | parts = rest.split("/", 2) 237 | 238 | if len(parts) >= 3: 239 | tail = parts[2] 240 | else: 241 | tail = "" 242 | 243 | if tail.startswith("commits/"): 244 | sha = tail.split("/", 1)[1] 245 | return f"{repo_html}/commit/{sha}" 246 | elif tail: 247 | return f"{repo_html}/{tail}" 248 | 249 | if repo_html: 250 | return repo_html 251 | 252 | return CONFIG["rss_link"] 253 | 254 | 255 | # ----------------------------------------------------------------------------- 256 | # RSS generation + caching 257 | # ----------------------------------------------------------------------------- 258 | 259 | 260 | def build_rss(notifications): 261 | """ 262 | Build an RSS 2.0 XML string from the notification list. 263 | 264 | If CONFIG['rss_html_description'] is True, descriptions will contain a small HTML block 265 | with tags and metadata. Otherwise a plain text description is used. 266 | """ 267 | from xml.sax.saxutils import escape 268 | 269 | now = format_datetime(datetime.now(timezone.utc)) 270 | use_html_description = CONFIG["rss_html_description"] 271 | 272 | def format_reason(reason): 273 | if not reason: 274 | return "other" 275 | mapping = { 276 | "mention": "mention", 277 | "author": "author", 278 | "assign": "assigned", 279 | "review_requested": "review requested", 280 | "approval_requested": "approval requested", 281 | "comment": "comment", 282 | "state_change": "state change", 283 | "subscribed": "subscribed", 284 | "ci_activity": "CI", 285 | "team_mention": "team mention", 286 | "security_alert": "security alert", 287 | "manual": "manual", 288 | "invitation": "invitation", 289 | } 290 | return mapping.get(reason, reason) 291 | 292 | def format_subject_type(subject_type): 293 | if not subject_type: 294 | return "Other" 295 | mapping = { 296 | "Issue": "Issue", 297 | "PullRequest": "Pull request", 298 | "Commit": "Commit", 299 | "Release": "Release", 300 | } 301 | return mapping.get(subject_type, subject_type) 302 | 303 | item_xml_chunks = [] 304 | 305 | for n in notifications: 306 | subject = n.get("subject") or {} 307 | repo = n.get("repository") or {} 308 | 309 | repo_full_name = repo.get("full_name", "unknown/repo") 310 | subject_title = subject.get("title", "(no title)") 311 | subject_type_raw = subject.get("type") 312 | subject_type_label = format_subject_type(subject_type_raw) 313 | 314 | title = f"[{repo_full_name}] {subject_title}" 315 | link = subject_html_url(n) 316 | guid = n.get("id") 317 | 318 | updated_at = n.get("updated_at") 319 | if updated_at: 320 | try: 321 | dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) 322 | except ValueError: 323 | dt = None 324 | else: 325 | dt = None 326 | 327 | pub_date = format_datetime(dt) if dt else now 328 | 329 | reason_raw = n.get("reason") 330 | reason_label = format_reason(reason_raw) 331 | unread = bool(n.get("unread")) 332 | unread_tag = "🔔" if unread else "" 333 | repo_html = repo.get("html_url") 334 | 335 | if use_html_description: 336 | html_desc = f""" 337 |

338 | [{escape(reason_label)}] 339 | [{escape(subject_type_label)}] 340 | {escape(unread_tag)} 341 |

342 |

{escape(subject_title)}

343 |

344 | Repo: {escape(repo_full_name)}
345 | Reason: {escape(reason_raw or "unknown")}
346 | Type: {escape(subject_type_label)}
347 | Unread: {"yes" if unread else "no"}
348 | """ 349 | if repo_html: 350 | html_desc += f' Repo link: {escape(repo_full_name)}
\n' 351 | if dt: 352 | html_desc += f" Updated: {escape(dt.isoformat())}
\n" 353 | 354 | html_desc += "

" 355 | 356 | description_content = html_desc 357 | else: 358 | tags = f"[{reason_label}] [{subject_type_label}]" 359 | if unread: 360 | tags += " 🔔" 361 | description_content = ( 362 | f"{tags}\n" 363 | f"Title: {subject_title}\n" 364 | f"Repo: {repo_full_name}\n" 365 | f"Reason: {reason_raw or 'unknown'}\n" 366 | f"Type: {subject_type_label}\n" 367 | f"Unread: {'yes' if unread else 'no'}" 368 | ) 369 | if repo_html: 370 | description_content += f"\nRepo link: {repo_html}" 371 | if dt: 372 | description_content += f"\nUpdated: {dt.isoformat()}" 373 | 374 | description_escaped = escape(description_content) 375 | 376 | item_xml = f""" 377 | 378 | {escape(title)} 379 | {escape(link)} 380 | {escape(str(guid))} 381 | {pub_date} 382 | {description_escaped} 383 | """ 384 | item_xml_chunks.append(item_xml) 385 | 386 | items_str = "\n".join(item_xml_chunks) 387 | 388 | channel_image = f""" 389 | 390 | https://github.githubassets.com/favicons/favicon.png 391 | {escape(CONFIG['rss_title'])} 392 | {escape(CONFIG['rss_link'])} 393 | 394 | """ 395 | 396 | rss = f""" 397 | 398 | 399 | {CONFIG['rss_title']} 400 | {CONFIG['rss_link']} 401 | {CONFIG['rss_description']} 402 | {now}{channel_image} 403 | {items_str} 404 | 405 | 406 | """ 407 | return rss 408 | 409 | 410 | CACHE = { 411 | "rss": None, 412 | "last_fetch": None, # datetime in UTC 413 | "last_error": None, # str or None 414 | } 415 | 416 | 417 | def cache_expired(): 418 | ttl = CONFIG["cache_ttl_seconds"] 419 | if ttl <= 0: 420 | return True 421 | if CACHE["last_fetch"] is None: 422 | return True 423 | return datetime.now(timezone.utc) - CACHE["last_fetch"] > timedelta(seconds=ttl) 424 | 425 | 426 | def get_rss_with_cache(): 427 | """ 428 | Fetch RSS from GitHub with caching and error handling. 429 | 430 | - Uses in-memory cache to avoid hammering the API on every request. 431 | - On GitHub/network error, serves stale cache if available. 432 | """ 433 | if not CONFIG["token"]: 434 | raise RuntimeError("GITHUB_TOKEN is not configured") 435 | 436 | if not cache_expired() and CACHE["rss"] is not None and CACHE["last_error"] is None: 437 | logger.info("Serving RSS from cache") 438 | return CACHE["rss"] 439 | 440 | logger.info("Cache expired or empty, querying GitHub") 441 | 442 | try: 443 | notifications = fetch_notifications() 444 | notifications = filter_notifications(notifications) 445 | rss_xml = build_rss(notifications) 446 | 447 | CACHE["rss"] = rss_xml 448 | CACHE["last_fetch"] = datetime.now(timezone.utc) 449 | CACHE["last_error"] = None 450 | 451 | return rss_xml 452 | 453 | except requests.RequestException as e: 454 | logger.error("GitHub API request failed: %s", e) 455 | CACHE["last_error"] = str(e) 456 | 457 | if CACHE["rss"] is not None: 458 | logger.warning("Serving stale RSS from cache due to error") 459 | return CACHE["rss"] 460 | 461 | raise 462 | 463 | except Exception as e: 464 | logger.exception("Unexpected error while generating RSS: %s", e) 465 | CACHE["last_error"] = str(e) 466 | 467 | if CACHE["rss"] is not None: 468 | logger.warning("Serving stale RSS from cache due to unexpected error") 469 | return CACHE["rss"] 470 | 471 | raise 472 | 473 | 474 | # ----------------------------------------------------------------------------- 475 | # HTTP endpoints 476 | # ----------------------------------------------------------------------------- 477 | 478 | 479 | @app.route("/") 480 | @app.route("/feed") 481 | def feed(): 482 | if not CONFIG["token"]: 483 | return Response( 484 | "GITHUB_TOKEN is not configured on the server\n", 485 | status=503, 486 | mimetype="text/plain", 487 | ) 488 | 489 | try: 490 | rss_xml = get_rss_with_cache() 491 | return Response(rss_xml, mimetype="application/rss+xml") 492 | except RuntimeError as e: 493 | logger.error("Runtime error in /feed: %s", e) 494 | return Response(str(e) + "\n", status=503, mimetype="text/plain") 495 | except requests.RequestException: 496 | return Response( 497 | "Failed to fetch notifications from GitHub\n", 498 | status=502, 499 | mimetype="text/plain", 500 | ) 501 | except Exception: 502 | return Response( 503 | "Internal server error\n", 504 | status=500, 505 | mimetype="text/plain", 506 | ) 507 | 508 | 509 | @app.route("/health") 510 | def health(): 511 | last_fetch = CACHE["last_fetch"] 512 | last_error = CACHE["last_error"] 513 | 514 | if last_error is None: 515 | status = "ok" 516 | elif CACHE["rss"] is not None: 517 | status = "degraded" 518 | else: 519 | status = "error" 520 | 521 | return jsonify( 522 | { 523 | "status": status, 524 | "last_fetch": last_fetch.isoformat() if last_fetch else None, 525 | "last_error": last_error, 526 | "cache_ttl_seconds": CONFIG["cache_ttl_seconds"], 527 | } 528 | ) 529 | 530 | 531 | def main(): 532 | host = os.getenv("BIND_ADDR", "0.0.0.0") 533 | port = int(os.getenv("BIND_PORT", "8000")) 534 | debug = getenv_bool("FLASK_DEBUG", False) 535 | logger.info("Starting server on %s:%d (debug=%s)", host, port, debug) 536 | app.run(host=host, port=port, debug=debug) 537 | 538 | 539 | if __name__ == "__main__": 540 | main() 541 | 542 | --------------------------------------------------------------------------------