From 6afa1fb06129b8c1ba5b059c6baa8eae226e7e8a Mon Sep 17 00:00:00 2001 From: Brian Read Date: Thu, 30 Oct 2025 07:58:45 +0100 Subject: [PATCH] Integrate all feeds into one parameterised program --- FeedToRocket.py | 1208 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1208 insertions(+) create mode 100644 FeedToRocket.py diff --git a/FeedToRocket.py b/FeedToRocket.py new file mode 100644 index 0000000..416710a --- /dev/null +++ b/FeedToRocket.py @@ -0,0 +1,1208 @@ +#!/usr/bin/env python3 +""" +FeedToRocket.py + +Unified feeder to post Bugzilla, Koji, Wiki and Gitea events to Rocket.Chat webhooks. + +Usage examples: + python FeedToRocket.py + python FeedToRocket.py --sleep 5 --log-level DEBUG + python FeedToRocket.py --one-off --feeds wiki,gitea + python FeedToRocket.py --empty-db +""" + +import argparse +import logging +import os +import sqlite3 +import socket +import time +import json +import re +from typing import Optional, Dict, Any, List + +import requests +import feedparser +import xml.etree.ElementTree as ET +from bs4 import BeautifulSoup +from dotenv import load_dotenv + +# --------------------------- +# Load .env variables +# --------------------------- +load_dotenv() + +# --------------------------- +# Configuration (from .env) +# --------------------------- +FEED_CONFIG = { + "bugzilla": { + "enabled": True, + "type": "bugzilla", + "domain": "bugs.koozali.org", + "feed_path": "/buglist.cgi?chfield=%5BBug%20creation%5D&chfieldfrom=7d&ctype=atom&title=Bugs%20reported%20in%20the%20last%207%20days", + "chat_url": os.getenv("TEST_CHAT_URL"), + #chat_url": os.getenv("BUGZILLA_CHAT_URL"), + "filter_field": "status", + "filter_value": "open", + "bypass_filter": True, + }, + "koji": { + "enabled": True, + "type": "koji", + "domain": "koji.koozali.org", + "feed_path": "/koji/recentbuilds?feed=rss", + #"chat_url": os.getenv("KOJI_CHAT_URL") + "chat_url": os.getenv("TEST_CHAT_URL") + }, + "wiki": { + "enabled": True, + "type": "wiki", + "domain": "wiki.koozali.org", + "feed_path": "/api.php?hidebots=1&urlversion=2&days=7&limit=50&action=feedrecentchanges&feedformat=rss", + "chat_url": os.getenv("TEST_CHAT_URL") + #"chat_url": os.getenv("WIKI_CHAT_URL") + } +} + +GITEA_ORGS = ["smecontribs", "smeserver"] +for org in GITEA_ORGS: + FEED_CONFIG[f"gitea_{org}"] = { + "enabled": True, + "type": "gitea", + "feed_url": f"https://src.koozali.org/{org}.atom", + "org": org, # ✅ this line ensures process_gitea knows the org name + "chat_url": os.getenv("TEST_CHAT_URL"), + # "chat_url": os.getenv("GITEA_CHAT_URL") + } + +# --------------------------- +# Logging configuration +# --------------------------- +DEFAULT_LOG_FILENAME = "FeedToRocket.log" +DEFAULT_LOG_DIR = "/var/log" +LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" + +def init_logging(log_level_str: str) -> str: + log_level = getattr(logging, log_level_str.upper(), logging.INFO) + preferred_path = os.path.join(DEFAULT_LOG_DIR, DEFAULT_LOG_FILENAME) + fallback_path = os.path.join(".", DEFAULT_LOG_FILENAME) + log_file = preferred_path + try: + os.makedirs(os.path.dirname(preferred_path), exist_ok=True) + logging.basicConfig(filename=preferred_path, level=log_level, format=LOG_FORMAT) + logging.getLogger().info("Logging initialized at %s", preferred_path) + except PermissionError: + log_file = fallback_path + logging.basicConfig(filename=fallback_path, level=log_level, format=LOG_FORMAT) + logging.getLogger().info("Permission denied for %s. Logging initialized at %s", preferred_path, fallback_path) + console = logging.StreamHandler() + console.setLevel(log_level) + console.setFormatter(logging.Formatter(LOG_FORMAT)) + logging.getLogger().addHandler(console) + return log_file + +# --------------------------- +# Database helpers +# --------------------------- +DB_PATH = "sent_items.db" + +def setup_database(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sent_items ( + feed_name TEXT, + item_id TEXT, + PRIMARY KEY(feed_name, item_id) + ) + ''') + conn.commit() + conn.close() + +def clear_database(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute('DELETE FROM sent_items') + conn.commit() + conn.close() + logging.getLogger().info("Cleared the sent_items database") + +def has_been_sent(feed_name: str, item_id: str) -> bool: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute('SELECT 1 FROM sent_items WHERE feed_name = ? AND item_id = ?', (feed_name, item_id)) + exists = cursor.fetchone() is not None + conn.close() + return exists + +def mark_as_sent(feed_name: str, item_id: str): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute('INSERT OR IGNORE INTO sent_items (feed_name, item_id) VALUES (?, ?)', (feed_name, item_id)) + conn.commit() + conn.close() + +# --------------------------- +# Utilities +# --------------------------- +def get_ip_address(domain: str, retries: int = 10, delay: int = 1) -> str: + for attempt in range(1, retries + 1): + try: + ip_address = socket.gethostbyname(domain) + logging.info("Resolved %s -> %s", domain, ip_address) + return ip_address + except socket.gaierror: + logging.warning("Attempt %d failed to resolve %s, retrying...", attempt, domain) + time.sleep(delay) + raise RuntimeError(f"Unable to resolve domain '{domain}' after {retries} attempts.") + +def send_to_rocket_chat(alias: str, text: str, attachments: Optional[List[Dict[str, Any]]], chat_url: str): + if not chat_url: + logging.warning("No chat URL for alias %s; skipping message.", alias) + return + payload = {"alias": alias, "text": text, "attachments": attachments or []} + try: + r = requests.post(chat_url, json=payload, timeout=10) + if r.status_code == 200: + logging.info("%s: message sent to Rocket.Chat", alias) + else: + logging.error("%s: Rocket.Chat returned %d - %s", alias, r.status_code, r.text) + except Exception as e: + logging.exception("%s: failed to send to Rocket.Chat: %s", alias, e) + +def send_startup_message(feed_name: str, chat_url: str): + send_to_rocket_chat(feed_name.capitalize(), f"{feed_name.capitalize()} integration started successfully.", None, chat_url) + +# --------------------------- +# Feed Processors +# --------------------------- + +# Bugzilla +def parse_bugzilla_summary(summary: str): + summary = summary.replace("<", "<").replace(">", ">").replace("&", "&") + try: + root = ET.fromstring(summary) + except Exception as e: + logging.warning("Bugzilla parse error: %s", e) + return "", "", "", "", "" + status = reported_by = last_changed = product = component = "" + for row in root.findall('.//tr'): + if len(row) < 2: continue + field = (row[0].text or "").strip() + value = (row[1].text or "").strip() + if field == "Status": status = value + elif field == "ReportedByName": reported_by = value + elif field == "Last changed date": last_changed = value + elif field == "Product": product = value + elif field == "Component": component = value + return status, reported_by, last_changed, product, component + +def process_bugzilla(conf, one_shot, domain_cache): + domain = conf["domain"] + if domain not in domain_cache: + domain_cache[domain] = get_ip_address(domain) + feed_url = f"http://{domain_cache[domain]}{conf['feed_path']}" + headers = {"Host": domain, "Referer": f"https://{domain}/"} + feed = feedparser.parse(requests.get(feed_url, headers=headers, timeout=10).content) + for entry in feed.entries: + summary = getattr(entry, "summary", "") + status, reported_by, last_changed, product, component = parse_bugzilla_summary(summary) + bug_id = str(entry.id).split("=")[-1] + status_key = status.lower() if status else "unknown" + if conf.get("bypass_filter") or (getattr(entry, conf.get("filter_field", ""), "").lower() == conf.get("filter_value", "")): + key = f"{bug_id}:{status_key}" + if not has_been_sent("bugzilla", key): + text = f"*Bug ID:* {bug_id} | *Status:* {status} | *Reporter:* {reported_by}\nProduct: {product}, Component: {component}, Last Changed: {last_changed}" + send_to_rocket_chat("Bugzilla", text, [{"title": entry.title, "title_link": entry.link, "color": "#764FA5"}], conf["chat_url"]) + mark_as_sent("bugzilla", key) + +# Koji +def extract_koji_changelog(link, host): + try: + r = requests.get(link, headers={"Host": host}, timeout=10) + soup = BeautifulSoup(r.text, "html.parser") + td = soup.find("td", class_="changelog") + if not td: + return "" + lines = [ln.strip() for ln in td.get_text().splitlines() if ln.strip()] + return "\n".join(lines[:5]) + except Exception as e: + logging.warning("Koji changelog fetch failed: %s", e) + return "" + +def process_koji(conf, one_shot, domain_cache): + domain = conf["domain"] + if domain not in domain_cache: + domain_cache[domain] = get_ip_address(domain) + url = f"http://{domain_cache[domain]}{conf['feed_path']}" + feed = feedparser.parse(requests.get(url, headers={"Host": domain}, timeout=10).content) + for entry in feed.entries: + title = entry.title.strip() + build_id = title.split(":")[1].split(",")[0].strip() if ":" in title else entry.id + if not has_been_sent("koji", build_id): + link = entry.link #.replace(domain, domain_cache[domain]) + changelog = extract_koji_changelog(link, domain) + text = f"Build Notification - ID: {build_id}\n{changelog}" + send_to_rocket_chat("Koji", text, [{"title": title, "title_link": entry.link, "color": "#764FA5"}], conf["chat_url"]) + mark_as_sent("koji", build_id) + +# Wiki +def process_wiki(conf, one_shot, domain_cache): + domain = conf["domain"] + if domain not in domain_cache: + domain_cache[domain] = get_ip_address(domain) + url = f"http://{domain_cache[domain]}{conf['feed_path']}" + feed = feedparser.parse(requests.get(url, headers={"Host": domain}, timeout=10).content) + logging.debug(f"Wiki feed:{feed}") + for entry in feed.entries: + wiki_id = entry.id + if not has_been_sent("wiki", wiki_id): + text = f"*Title:* {entry.title}\n*Date:* {entry.published}\n*Author:* {entry.author}\nLink: {entry.link}" + send_to_rocket_chat("Wiki", text, [{"title": entry.title, "title_link": entry.link, "color": "#764FA5"}], conf["chat_url"]) + mark_as_sent("wiki", wiki_id) + +# Gitea (Atom feed) +# --- Gitea helpers (shared by processor + selftest) --- + +def _gitea_clean_author(a: str) -> str: + if not a: + return "" + a = re.sub(r"\S*noreply\S*", "", a) # drop noreply + a = re.sub(r"mailto:\S+", "", a) # drop mailto + a = re.sub(r"<.*?>", "", a) # drop angle-bracketed emails + a = re.sub(r"\S+@\S+", "", a) # drop raw emails that might slip in + return a.strip() + +def _gitea_text_only(html_snippet: str) -> str: + return BeautifulSoup(html_snippet or "", "html.parser").get_text(" ", strip=True) + +def _gitea_first_two_links(html_snippet: str, base_host: str): + """ + Return (diff_link, diff_text, repo_link, repo_text) from an HTML snippet. + Only consider anchors pointing to src.koozali.org, make them absolute, ensure distinct. + """ + from urllib.parse import urljoin + diff_link = diff_text = repo_link = repo_text = None + soup = BeautifulSoup(html_snippet or "", "html.parser") + anchors = [] + for a in soup.find_all("a"): + href = (a.get("href") or "").strip() + if "src.koozali.org" in href: + anchors.append((urljoin(base_host, href), a.get_text(strip=True))) + if anchors: + diff_link, diff_text = anchors[0] + if len(anchors) > 1: + repo_link, repo_text = anchors[-1] + else: + repo_link, repo_text = diff_link, diff_text + return diff_link, diff_text, repo_link, repo_text + +def _gitea_build_attachment(org_name: str, + title_html: str, + summary_html: str, + content_html: str, + entry_link: str, + updated: str, + author_raw: str, + base_host: str) -> dict: + """ + Build a single Rocket.Chat attachment dict: + title: org_name + title_link: diff (or repo/entry_link) + text: compact body with Date, Author, and 'Diff | Repo' (each once) + """ + import html + from urllib.parse import urljoin + + author = _gitea_clean_author(author_raw) + + # Prefer links from , then fallback to summary/content + diff_link, diff_text, repo_link, repo_text = _gitea_first_two_links(title_html, base_host) + if not diff_link or not repo_link: + alt_diff, alt_dt, alt_repo, alt_rt = _gitea_first_two_links(summary_html or content_html, base_host) + diff_link = diff_link or alt_diff + diff_text = diff_text or alt_dt + repo_link = repo_link or alt_repo + repo_text = repo_text or alt_rt + + # If still no repo_link, try to derive from entry_link + if not repo_link and entry_link: + entry_link = urljoin(base_host, entry_link) + m = re.search(r"^(https?://[^/]+/[^/]+/[^/]+)", entry_link) + if m: + repo_link = m.group(1) + repo_text = "/".join(repo_link.rstrip("/").split("/")[-2:]) + + # Guard against any accidental email capture in repo_text + if repo_text and "@" in repo_text: + repo_text = None + if (not repo_text) and repo_link: + repo_text = "/".join(repo_link.rstrip("/").split("/")[-2:]) + + # Compact action line: clean readable text from <title> + title_text = html.unescape(_gitea_text_only(title_html)) + action_line = f"{org_name}: {title_text}" + + # Links shown once; both only if distinct + link_parts = [] + if diff_link: + link_parts.append(f"[Diff]({diff_link})") + if repo_link and (repo_link != diff_link): + link_parts.append(f"[Repo]({repo_link})") + + lines = [action_line] + if updated: + lines.append(f"Date: {updated}") + author = author.strip() + if author: + lines.append(f"Author: {author}") + if link_parts: + lines.append(" | ".join(link_parts)) + + text = "\n\n".join(lines) + title_link = diff_link or repo_link or urljoin(base_host, entry_link or "") + + return { + "title": f"{org_name}", + "title_link": title_link, + "text": text, + "color": "#764FA5", + "collapsed": False + } + + +def process_gitea(conf, one_shot, domain_cache): + """ + Gitea Atom -> Rocket.Chat (compact, using attachment fields): + Fields shown: + - Action: <org>: <human-readable title> + - Message: <one-line commit message> (if available) + - Date: <updated> (if available) + - Author: <author> (if available) + - Links: [Diff](...) | [Repo](...) (Diff is compare/commit/branch/tag; Repo is repo home) + + Diff priority: compare > commit > branch > tag + For tag-only entries, resolves tag -> commit (and previous tag) via Gitea API + to generate a commit or compare diff link. + + Expects helpers elsewhere: + - has_been_sent(key, id) + - mark_as_sent(key, id) + - send_to_rocket_chat(username, text, attachments, webhook_url) + """ + import re + import html + import json + import logging + from urllib.parse import urljoin + + import requests + import feedparser + from bs4 import BeautifulSoup + + # ---- Constants / config -------------------------------------------------------- + base_host = "https://src.koozali.org" + GITEA_API_BASE = f"{base_host}/api/v1" + + feed_url = conf.get("feed_url") + chat_url = conf.get("chat_url") + org_name = conf.get("org") or ( + re.search(r"src\.koozali\.org/([^/.]+)", conf.get("feed_url", "")) or [None] + )[1] or "unknown" + + log = logging.getLogger(f"gitea.{org_name}") + + if not feed_url or not chat_url: + log.warning("Skipping Gitea: feed URL or chat URL missing (org=%s)", org_name) + return + + # ---- Helpers ------------------------------------------------------------------ + + def clean_author(a: str) -> str: + if not a: + return "" + a = re.sub(r"\S*noreply\S*", "", a) # drop noreply + a = re.sub(r"mailto:\S+", "", a) # drop mailto + a = re.sub(r"<.*?>", "", a) # drop angle-brackets + a = re.sub(r"\S+@\S+", "", a) # drop raw emails + return a.strip() + + def text_only(html_snippet: str) -> str: + return html.unescape(BeautifulSoup(html_snippet or "", "html.parser").get_text(" ", strip=True)) + + def _sanitize_url(u: str) -> str: + u = (u or "").strip() + # Trim artifacts from scraping + u = re.sub(r'[">\')\]]+$', '', u) # closing quotes/brackets/parens + u = re.sub(r'[.,;:]+$', '', u) # trailing punctuation + return u + + def _normalize_url(u: str) -> str: + return urljoin(base_host, _sanitize_url(u)) + + def _collect_links(entry): + """ + Return absolute src.koozali.org URLs with texts from: + - title (anchors) + - summary or content (anchors) + - entry.link (raw) + - entry.id (raw; after colon) + - PLUS: any raw URLs in summary/content (non-anchors) inc. relative paths + De-dupes by URL. + """ + links = [] + + def add_from_anchors(snippet): + if not snippet: + return + soup = BeautifulSoup(snippet, "html.parser") + for a in soup.find_all("a"): + href = _sanitize_url(a.get("href") or "") + if "src.koozali.org" in href: + links.append((_normalize_url(href), a.get_text(strip=True))) + + def add_from_raw(text): + if not text: + return + # absolute URLs + for m in re.finditer(r"https?://src\.koozali\.org[^\s)>\]\"']+", text): + links.append((_normalize_url(m.group(0)), "")) + # relative (common org prefixes or any /compare/) + for m in re.finditer(r"(?:^|\s)(/[^ \t\n\r)>\]\"']+)", text): + candidate = m.group(1) + if candidate.startswith(("/smeserver/", "/smecontribs/", "/common/")) or "/compare/" in candidate: + links.append((_normalize_url(candidate), "")) + + title_html = getattr(entry, "title", "") + summary_html = getattr(entry, "summary", "") + content_html = "" + if getattr(entry, "content", None): + content_html = entry.content[0].get("value", "") + + # Anchors + add_from_anchors(title_html) + add_from_anchors(summary_html or content_html) + + # entry.link + elink = getattr(entry, "link", "") + if "src.koozali.org" in elink: + links.append((_normalize_url(elink), "")) + + # URL inside id (e.g., "123: https://...") + eid = getattr(entry, "id", "") + m = re.search(r"https?://[^ \t\n\r]+", eid) + if m and "src.koozali.org" in m.group(0): + links.append((_normalize_url(m.group(0)), "")) + + # Raw text scan + add_from_raw(summary_html) + add_from_raw(content_html) + + # De-dupe by URL + seen = set() + out = [] + for u, t in links: + if "src.koozali.org" not in u: + continue + if u not in seen: + seen.add(u) + out.append((u, t)) + return out + + def _classify(links): + """Split into compare/commit/branch/tag and detect repo homes.""" + compares, commits, branches, tags, repos = [], [], [], [], [] + for url, txt in links: + if "/compare/" in url: + compares.append((url, txt or "compare")) + elif "/commit/" in url: + sha = url.rsplit("/", 1)[-1] + commits.append((url, txt or (sha[:7] if sha else "commit"))) + elif "/src/branch/" in url: + br = url.rsplit("/", 1)[-1] + branches.append((url, txt or br)) + elif "/src/tag/" in url: + tg = url.rsplit("/", 1)[-1] + tags.append((url, txt or tg)) + + # Repo home: https://host/org/repo + m = re.match(r"^https?://[^/]+/[^/]+/[^/]+/?$", url) + if m: + home = m.group(0).rstrip("/") + repos.append((home, "/".join(home.split("/")[-2:]))) + return compares, commits, branches, tags, repos + + def _extract_commit_message(summary_html: str, content_html: str) -> str: + """ + Pull a compact one-line commit message from summary/content. + - Convert to text with newlines preserved + - Drop a leading line that is just a SHA + - Return first meaningful non-empty line (trimmed to ~200 chars) + """ + soup = BeautifulSoup((summary_html or content_html) or "", "html.parser") + txt = soup.get_text("\n", strip=True) + if not txt: + return "" + lines = [ln.strip() for ln in txt.splitlines()] + if not lines: + return "" + + sha_like = re.compile(r"^[0-9a-f]{7,40}$", re.I) + if lines and sha_like.match(lines[0]): + lines = lines[1:] + + for ln in lines: + if not ln: + continue + # Skip pure mailto lines + if "mailto:" in ln.lower(): + continue + # Compact whitespace and trim length + msg = re.sub(r"\s+", " ", ln).strip() + if msg: + return (msg[:200] + "…") if len(msg) > 200 else msg + return "" + + # ---- Gitea API helpers (for tag-only upgrade) --------------------------------- + + def _gitea_api_get_tags(owner: str, repo: str, timeout=10): + """ + Return list of tags (dicts) from Gitea: + [{"name": "v1.2.3", "commit": {"sha": "...", "url": "..."}}, ...] + On error, return []. + """ + url = f"{GITEA_API_BASE}/repos/{owner}/{repo}/tags" + try: + r = requests.get(url, timeout=timeout) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + return data + except Exception as e: + log.warning("Gitea API tags fetch failed for %s/%s: %s", owner, repo, e) + return [] + + def _resolve_tag_to_commit(owner: str, repo: str, tag_name: str, cache: dict): + """ + Map tag_name -> commit SHA via cache/api: + cache['owner/repo'][tag_name] = sha + """ + repo_key = f"{owner}/{repo}" + cache.setdefault(repo_key, {}) + if tag_name in cache[repo_key]: + return cache[repo_key][tag_name] + tags = _gitea_api_get_tags(owner, repo) + for t in tags: + if t.get("name") == tag_name: + sha = (t.get("commit") or {}).get("sha") + if sha: + cache[repo_key][tag_name] = sha + return sha + return None + + def _find_previous_tag(owner: str, repo: str, tag_name: str): + """ + Best-effort previous tag: + - Use /tags list order (typically newest-first). If current at i, pick i+1 if exists. + - Else first different tag. + """ + tags = _gitea_api_get_tags(owner, repo) + names = [t.get("name") for t in tags if t.get("name")] + if not names: + return None + try: + i = names.index(tag_name) + if i + 1 < len(names): + return names[i + 1] + except ValueError: + pass + for n in names: + if n != tag_name: + return n + return None + + # ---- Fetch + parse feed ------------------------------------------------------- + + try: + log.info("Fetching Atom from %s", feed_url) + resp = requests.get(feed_url, timeout=15) + resp.raise_for_status() + feed = feedparser.parse(resp.content) + except Exception as e: + log.warning("Fetch/parse failed: %s", e) + return + + sent_key = f"gitea_{org_name}" + + # Cache for tag->sha resolution across entries + tag_sha_cache = domain_cache.setdefault("gitea_tag_sha_cache", {}) + + for entry in feed.entries: + try: + entry_id = getattr(entry, "id", None) + if not entry_id: + continue + if has_been_sent(sent_key, entry_id): + continue + + title_html = getattr(entry, "title", "") + summary_html = getattr(entry, "summary", "") + content_html = "" + if getattr(entry, "content", None): + content_html = entry.content[0].get("value", "") + updated = getattr(entry, "updated", "") + author_raw = getattr(entry, "author", "") + + # Collect + classify URLs + all_links = _collect_links(entry) + compares, commits, branches, tags, repos = _classify(all_links) + + log.debug( + "Entry id=%s title='%s' links: compares=%s commits=%s branches=%s tags=%s repos=%s", + entry_id, + text_only(title_html)[:120], + [u for u, _ in compares], + [u for u, _ in commits], + [u for u, _ in branches], + [u for u, _ in tags], + [u for u, _ in repos], + ) + + # Repo: explicit home if present, else derive from any URL + if repos: + repo_link, _repo_text = repos[0] + else: + repo_link = None + for url, _ in (compares + commits + branches + tags): + m = re.match(r"^(https?://[^/]+/[^/]+/[^/]+)", url) + if m: + repo_link = m.group(1) + break + + # Diff: compare > commit > branch > tag + diff_link = None + diff_kind = "-" + if compares: + diff_link, _ = compares[0] + diff_kind = "compare" + elif commits: + diff_link, _ = commits[0] + diff_kind = "commit" + elif branches: + diff_link, _ = branches[0] + diff_kind = "branch" + elif tags: + diff_link, _ = tags[0] + diff_kind = "tag" + + # Tag-only upgrade: try to turn tag page into commit or compare + if diff_kind == "tag" and diff_link: + try: + m = re.match(r"^https?://[^/]+/([^/]+)/([^/]+)/src/tag/([^/]+)$", diff_link) + if m: + owner, repo, tag_name = m.group(1), m.group(2), m.group(3) + sha = _resolve_tag_to_commit(owner, repo, tag_name, tag_sha_cache) + if sha: + prev_tag = _find_previous_tag(owner, repo, tag_name) + if prev_tag: + diff_link = f"{base_host}/{owner}/{repo}/compare/{prev_tag}...{tag_name}" + diff_kind = "compare" + else: + diff_link = f"{base_host}/{owner}/{repo}/commit/{sha}" + diff_kind = "commit" + log.debug("Upgraded tag-only diff to %s: %s", diff_kind, diff_link) + else: + log.debug("Could not resolve tag '%s' for %s/%s; keeping tag URL", tag_name, owner, repo) + except Exception: + log.exception("Error upgrading tag-only entry to a commit/compare diff") + + # Clean author & title; extract commit message + author = clean_author(author_raw) + title_text = text_only(title_html) + commit_msg = _extract_commit_message(summary_html, content_html) + + # ---- Build fields ----------------------------------------------------- + fields = [] + fields.append({"title": "*Action*", "value": f"{org_name}: {title_text}", "short": False}) + if commit_msg: + fields.append({"title": "*Message*", "value": commit_msg, "short": False}) + if updated: + fields.append({"title": "*Date*", "value": updated, "short": True}) + if author: + fields.append({"title": "*Author*", "value": author, "short": True}) + + link_parts = [] + if diff_link: + link_parts.append(f"[Diff]({diff_link})") + if repo_link and (repo_link != diff_link): + link_parts.append(f"[Repo]({repo_link})") + if link_parts: + fields.append({"title": "*Links*", "value": " | ".join(link_parts), "short": False}) + + # Title click-through prefers diff, else repo + title_link = diff_link or repo_link + + attachment = { + "title": f"{org_name}/{repo}", + "title_link": title_link, + "text": "", # we use fields for layout + "fields": fields, # <<<<<<<<<<<<<<<<<<<<<<<< + "color": "#764FA5", + "collapsed": False, + } + + # Send & mark + send_to_rocket_chat(f"Gitea-{org_name}", "", [attachment], chat_url) + mark_as_sent(sent_key, entry_id) + log.info( + "Sent: diffKind=%s diff=%s repo=%s title='%s'", + diff_kind, (diff_link or "-"), (repo_link or "-"), + title_text[:160], + ) + + except Exception: + log.exception("Error processing Gitea entry for org '%s'", org_name) + + +# --- Self-test: runs against embedded sample Atom and prints attachments (no sending) --- + +SMESERVER_SAMPLE_ATOM = r"""<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"> + <title>Feed of "smeserver" + https://src.koozali.org/smeserver + 2025-10-29T12:41:45+01:00 + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-124_el8_sme">11_0_0-124_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-24T13:41:59+02:00 + 84593: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-124_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-24T13:41:58+02:00 + 84586: https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676 + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676" rel="nofollow">8e270ef3fd973ef27d0087fcfa02f614c1e13676</a> * Fri Oct 24 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-124.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676">8e270ef3fd973ef27d0087fcfa02f614c1e13676</a> * Fri Oct 24 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-124.sme + + brianr + brianr@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-123_el8_sme">11_0_0-123_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-24T11:50:58+02:00 + 84579: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-123_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-24T11:50:56+02:00 + 84572: https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad" rel="nofollow">a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad</a> * Fri Oct 24 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-123.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad">a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad</a> * Fri Oct 24 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-123.sme + + brianr + brianr@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/tag/11_0_0-11_el8_sme">11_0_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery">smeserver/smeserver-manager-jsquery</a> + 2025-10-24T11:41:20+02:00 + 84565: https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/tag/11_0_0-11_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery">smeserver/smeserver-manager-jsquery</a> + 2025-10-24T11:41:20+02:00 + 84558: https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a + <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a" rel="nofollow">e026aa17369953a482be3ecb1338f39cada6d03a</a> * Thu Oct 23 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-11.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a">e026aa17369953a482be3ecb1338f39cada6d03a</a> * Thu Oct 23 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-11.sme + + brianr + brianr@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/common/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/common">smeserver/common</a> + 2025-10-23T18:20:23+02:00 + 83886: /smeserver/common/compare/507cc753ec53612b622047344a527314158808a3...8d5535b58b89c2ab8757842325a687c73022f317 + <a href="https://src.koozali.org/smeserver/common/commit/8d5535b58b89c2ab8757842325a687c73022f317" rel="nofollow">8d5535b58b89c2ab8757842325a687c73022f317</a> filter ARCHIVEFILE to only get archives ending with z <a href="https://src.koozali.org/smeserver/common/commit/63f19e99973fe9d254394b3fbd08a57f14c8d7f4" rel="nofollow">63f19e99973fe9d254394b3fbd08a57f14c8d7f4</a> add info + + <a href="https://src.koozali.org/smeserver/common/commit/8d5535b58b89c2ab8757842325a687c73022f317">8d5535b58b89c2ab8757842325a687c73022f317</a> filter ARCHIVEFILE to only get archives ending with z <a href="https://src.koozali.org/smeserver/common/commit/63f19e99973fe9d254394b3fbd08a57f14c8d7f4">63f19e99973fe9d254394b3fbd08a57f14c8d7f4</a> add info + + jpp + jpp@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-22T10:44:34+02:00 + 83707: https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c" rel="nofollow">9437dd792a2117ba41c433881fef4a915acfcc2c</a> html comment closure leaks onto panel + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c">9437dd792a2117ba41c433881fef4a915acfcc2c</a> html comment closure leaks onto panel + + brianr + brianr@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-122_el8_sme">11_0_0-122_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-21T20:28:14+02:00 + 83700: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-122_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-10-21T20:28:12+02:00 + 83693: https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb" rel="nofollow">f03d82ebf746e38a7678f1ee82ab754bb20da9eb</a> * Tue Oct 21 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-122.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb">f03d82ebf746e38a7678f1ee82ab754bb20da9eb</a> * Tue Oct 21 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-122.sme + + brianr + brianr@noreply.koozali.org + + + + jcrisp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-certificates/src/tag/11_0-11_el8_sme">11_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-certificates">smeserver/smeserver-certificates</a> + 2025-10-15T17:10:17+02:00 + 83634: https://src.koozali.org/smeserver/smeserver-certificates/src/tag/11_0-11_el8_sme + + + jcrisp + jcrisp@noreply.koozali.org + + + + jcrisp pushed to <a href="https://src.koozali.org/smeserver/smeserver-certificates/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-certificates">smeserver/smeserver-certificates</a> + 2025-10-15T17:05:40+02:00 + 83627: https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4 + <a href="https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4" rel="nofollow">73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4</a> Fix typo + + <a href="https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4">73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4</a> Fix typo + + jcrisp + jcrisp@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-31_el8_sme">11_0_0-31_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-06T19:34:20+02:00 + 81653: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-31_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-update/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-06T19:34:15+02:00 + 81646: https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87 + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87" rel="nofollow">27485c3952d2aa55de468a70cc5f69939580bb87</a> * Mon Oct 06 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-31.sme + + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87">27485c3952d2aa55de468a70cc5f69939580bb87</a> * Mon Oct 06 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-31.sme + + brianr + brianr@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/smeserver-ntp/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a> + 2025-10-03T21:50:22+02:00 + 81202: https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e + <a href="https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e" rel="nofollow">8879d29ca50e750e0890f11d4c0d405954ae7c2e</a> typo in changelog + + <a href="https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e">8879d29ca50e750e0890f11d4c0d405954ae7c2e</a> typo in changelog + + jpp + jpp@noreply.koozali.org + + + + jpp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-ntp/src/tag/11_0_0-8_el8_sme">11_0_0-8_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a> + 2025-10-03T21:48:25+02:00 + 81195: https://src.koozali.org/smeserver/smeserver-ntp/src/tag/11_0_0-8_el8_sme + + + jpp + jpp@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/smeserver-ntp/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a> + 2025-10-03T21:47:56+02:00 + 81188: https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233 + <a href="https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233" rel="nofollow">6d07479bf668b0f0be4ae705841b3208fa9ee233</a> * Fri Oct 03 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="" rel="nofollow">jpp@koozali.org</a>&gt; 11.0.0-8.sme + + <a href="https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233">6d07479bf668b0f0be4ae705841b3208fa9ee233</a> * Fri Oct 03 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="">jpp@koozali.org</a>&gt; 11.0.0-8.sme + + jpp + jpp@noreply.koozali.org + + + + jpp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-30_el8_sme">11_0_0-30_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-03T15:17:43+02:00 + 81125: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-30_el8_sme + + + jpp + jpp@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/smeserver-update/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-03T15:17:15+02:00 + 81118: https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a" rel="nofollow">8e29af1670c27e91ff9846f7c677b8e1e943e94a</a> * Thu Oct 02 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="" rel="nofollow">jpp@koozali.org</a>&gt; 11.0.0-30.sme + + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a">8e29af1670c27e91ff9846f7c677b8e1e943e94a</a> * Thu Oct 02 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="">jpp@koozali.org</a>&gt; 11.0.0-30.sme + + jpp + jpp@noreply.koozali.org + + + + jpp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-29_el8_sme">11_0_0-29_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-02T15:51:27+02:00 + 81083: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-29_el8_sme + + + jpp + jpp@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/smeserver-update/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a> + 2025-10-02T15:49:58+02:00 + 81076: https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598 + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598" rel="nofollow">37f6399569c303a9a64de84bd39b836b1de78598</a> * Thu Oct 02 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="" rel="nofollow">jpp@koozali.org</a>&gt; 11.0.0-29.sme + + <a href="https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598">37f6399569c303a9a64de84bd39b836b1de78598</a> * Thu Oct 02 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="">jpp@koozali.org</a>&gt; 11.0.0-29.sme + + jpp + jpp@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-121_el8_sme">11_0_0-121_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-27T13:30:07+02:00 + 80913: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-121_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-27T13:30:05+02:00 + 80906: https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac" rel="nofollow">de2f78a0892214b8c1fdd2de7b2eed177d398aac</a> * Sat Sep 27 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-121.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac">de2f78a0892214b8c1fdd2de7b2eed177d398aac</a> * Sat Sep 27 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-121.sme + + brianr + brianr@noreply.koozali.org + + + + jpp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-12_el8_sme">11_0_0-12_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a> + 2025-09-26T18:49:52+02:00 + 80885: https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-12_el8_sme + + + jpp + jpp@noreply.koozali.org + + + + jpp pushed to <a href="https://src.koozali.org/smeserver/smeserver-proftpd/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a> + 2025-09-26T18:49:43+02:00 + 80878: https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8 + <a href="https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8" rel="nofollow">ed837ffb760943d12a6462c35c7c9f48176b91d8</a> * Fri Sep 26 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="" rel="nofollow">jpp@koozali.org</a>&gt; 11.0.0-12.sme + + <a href="https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8">ed837ffb760943d12a6462c35c7c9f48176b91d8</a> * Fri Sep 26 2025 Jean-Philippe Pialasse &lt;<a href="mailto:jpp@koozali.org" data-markdown-generated-content="">jpp@koozali.org</a>&gt; 11.0.0-12.sme + + jpp + jpp@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-120_el8_sme">11_0_0-120_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-25T19:45:32+02:00 + 80752: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-120_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-25T19:45:30+02:00 + 80745: https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7 + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7" rel="nofollow">b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7</a> * Thu Sep 25 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-120.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7">b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7</a> * Thu Sep 25 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-120.sme + + brianr + brianr@noreply.koozali.org + + + + brianr pushed tag <a href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-119_el8_sme">11_0_0-119_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-25T16:42:44+02:00 + 80708: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-119_el8_sme + + + brianr + brianr@noreply.koozali.org + + + + brianr pushed to <a href="https://src.koozali.org/smeserver/smeserver-manager/src/branch/master">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a> + 2025-09-25T16:42:39+02:00 + 80701: https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b" rel="nofollow">9c9ab9186966b5ba893528a79531bc608c42ac7b</a> * Thu Sep 25 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="" rel="nofollow">brianr@koozali.org</a>&gt; 11.0.0-119.sme + + <a href="https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b">9c9ab9186966b5ba893528a79531bc608c42ac7b</a> * Thu Sep 25 2025 Brian Read &lt;<a href="mailto:brianr@koozali.org" data-markdown-generated-content="">brianr@koozali.org</a>&gt; 11.0.0-119.sme + + brianr + brianr@noreply.koozali.org + + + + jpp pushed tag <a href="https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-11_el8_sme">11_0_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a> + 2025-09-25T16:32:37+02:00 + 80694: https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-11_el8_sme + + + jpp + jpp@noreply.koozali.org + + +""" + +def selftest_gitea_sample(org_name="smeserver", log_level="DEBUG"): + """ + Parse the embedded sample Atom and print the Rocket.Chat attachment JSON + that would be sent, one per entry. Does NOT call the webhook. + """ + logger = logging.getLogger("gitea_selftest") + logger.setLevel(getattr(logging, log_level.upper(), logging.DEBUG)) + + base_host = "https://src.koozali.org" + feed = feedparser.parse(SMESERVER_SAMPLE_ATOM) + + out = [] + for entry in feed.entries: + title_html = getattr(entry, "title", "") + summary_html = getattr(entry, "summary", "") + content_html = "" + if getattr(entry, "content", None): + content_html = entry.content[0].get("value", "") + updated = getattr(entry, "updated", "") + author_raw = getattr(entry, "author", "") + entry_link = getattr(entry, "link", "") + + att = _gitea_build_attachment( + org_name=org_name, + title_html=title_html, + summary_html=summary_html, + content_html=content_html, + entry_link=entry_link, + updated=updated, + author_raw=author_raw, + base_host=base_host, + ) + out.append(att) + + # Pretty print to console & log + for i, att in enumerate(out, 1): + logger.debug("Attachment %d: %s", i, json.dumps(att, ensure_ascii=False)) + print(f"\n--- Attachment {i} ---") + print(json.dumps(att, ensure_ascii=False, indent=2)) + + +# --------------------------- +# Main loop +# --------------------------- +def main(): + parser = argparse.ArgumentParser(description="Unified Feed -> Rocket.Chat notifier") + parser.add_argument("--sleep", type=int, default=1, help="Minutes to sleep between polls") + parser.add_argument("--one-off", action="store_true", help="Run once then exit") + parser.add_argument("--empty-db", action="store_true", help="Clear DB before start") + parser.add_argument("--feeds", type=str, default="", help="Comma-separated subset of feeds") + parser.add_argument("--log-level", type=str, default="INFO", help="Logging level") + parser.add_argument("--selftest-gitea", action="store_true", + help="Run built-in Gitea parser selftest using the embedded sample Atom (no network, no send)") + + args = parser.parse_args() + + log_path = init_logging(args.log_level) + logging.info("FeedToRocket starting (log: %s)", log_path) + + logging.debug("Loaded feeds: %s", FEED_CONFIG) + + if args.selftest_gitea: + logging.info("Running Gitea selftest against embedded sample Atom…") + selftest_gitea_sample(org_name="smeserver", log_level=args.log_level) + return + + + setup_database() + if args.empty_db: + clear_database() + + selected = {f.strip() for f in args.feeds.split(",") if f.strip()} if args.feeds else set() + domain_cache = {} + + for name, conf in FEED_CONFIG.items(): + if not conf.get("enabled"): continue + if selected and name not in selected: continue + send_startup_message(name, conf.get("chat_url")) + + processors = {"bugzilla": process_bugzilla, "koji": process_koji, "wiki": process_wiki, "gitea": process_gitea} + sleep_sec = max(1, args.sleep) * 60 + + while True: + start = time.time() + for name, conf in FEED_CONFIG.items(): + if not conf.get("enabled"): continue + if selected and name not in selected: continue + try: + proc = processors[conf["type"]] + proc(conf, args.one_off, domain_cache) + except Exception as e: + logging.exception("Feed %s failed: %s", name, e) + if args.one_off: + logging.info("One-off mode complete; exiting.") + break + elapsed = time.time() - start + time.sleep(max(1, sleep_sec - elapsed)) + +if __name__ == "__main__": + main()