Files
RSS-to-Rocket/FeedToRocket.py

1209 lines
66 KiB
Python

#!/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","smedev"]
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("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
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 <title>, 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 &#34;smeserver&#34;</title>
<id>https://src.koozali.org/smeserver</id>
<updated>2025-10-29T12:41:45+01:00</updated>
<link href="https://src.koozali.org/smeserver"></link>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-124_el8_sme&#34;&gt;11_0_0-124_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-24T13:41:59+02:00</updated>
<id>84593: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-124_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-124_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-24T13:41:58+02:00</updated>
<id>84586: https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676&#34; rel=&#34;nofollow&#34;&gt;8e270ef3fd973ef27d0087fcfa02f614c1e13676&lt;/a&gt;&#xA;* Fri Oct 24 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-124.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/8e270ef3fd973ef27d0087fcfa02f614c1e13676&#34;&gt;8e270ef3fd973ef27d0087fcfa02f614c1e13676&lt;/a&gt;&#xA;* Fri Oct 24 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-124.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-123_el8_sme&#34;&gt;11_0_0-123_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-24T11:50:58+02:00</updated>
<id>84579: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-123_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-123_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-24T11:50:56+02:00</updated>
<id>84572: https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad&#34; rel=&#34;nofollow&#34;&gt;a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad&lt;/a&gt;&#xA;* Fri Oct 24 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-123.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad&#34;&gt;a04097bf5a2fefe78aa3d324bf4d9d9ce90f31ad&lt;/a&gt;&#xA;* Fri Oct 24 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-123.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/tag/11_0_0-11_el8_sme&#34;&gt;11_0_0-11_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery&#34;&gt;smeserver/smeserver-manager-jsquery&lt;/a&gt;</title>
<updated>2025-10-24T11:41:20+02:00</updated>
<id>84565: https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/tag/11_0_0-11_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/tag/11_0_0-11_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery&#34;&gt;smeserver/smeserver-manager-jsquery&lt;/a&gt;</title>
<updated>2025-10-24T11:41:20+02:00</updated>
<id>84558: https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a&#34; rel=&#34;nofollow&#34;&gt;e026aa17369953a482be3ecb1338f39cada6d03a&lt;/a&gt;&#xA;* Thu Oct 23 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-11.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager-jsquery/commit/e026aa17369953a482be3ecb1338f39cada6d03a&#34;&gt;e026aa17369953a482be3ecb1338f39cada6d03a&lt;/a&gt;&#xA;* Thu Oct 23 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-11.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/common/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/common&#34;&gt;smeserver/common&lt;/a&gt;</title>
<updated>2025-10-23T18:20:23+02:00</updated>
<id>83886: /smeserver/common/compare/507cc753ec53612b622047344a527314158808a3...8d5535b58b89c2ab8757842325a687c73022f317</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/common/commit/8d5535b58b89c2ab8757842325a687c73022f317&#34; rel=&#34;nofollow&#34;&gt;8d5535b58b89c2ab8757842325a687c73022f317&lt;/a&gt;&#xA;filter ARCHIVEFILE to only get archives ending with z&#xA;&#xA;&lt;a href=&#34;https://src.koozali.org/smeserver/common/commit/63f19e99973fe9d254394b3fbd08a57f14c8d7f4&#34; rel=&#34;nofollow&#34;&gt;63f19e99973fe9d254394b3fbd08a57f14c8d7f4&lt;/a&gt;&#xA;add info</content>
<link href="/smeserver/common/compare/507cc753ec53612b622047344a527314158808a3...8d5535b58b89c2ab8757842325a687c73022f317" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/common/commit/8d5535b58b89c2ab8757842325a687c73022f317&#34;&gt;8d5535b58b89c2ab8757842325a687c73022f317&lt;/a&gt;&#xA;filter ARCHIVEFILE to only get archives ending with z&#xA;&#xA;&lt;a href=&#34;https://src.koozali.org/smeserver/common/commit/63f19e99973fe9d254394b3fbd08a57f14c8d7f4&#34;&gt;63f19e99973fe9d254394b3fbd08a57f14c8d7f4&lt;/a&gt;&#xA;add info</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-22T10:44:34+02:00</updated>
<id>83707: https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c&#34; rel=&#34;nofollow&#34;&gt;9437dd792a2117ba41c433881fef4a915acfcc2c&lt;/a&gt;&#xA;html comment closure leaks onto panel</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c&#34;&gt;9437dd792a2117ba41c433881fef4a915acfcc2c&lt;/a&gt;&#xA;html comment closure leaks onto panel</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-122_el8_sme&#34;&gt;11_0_0-122_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-21T20:28:14+02:00</updated>
<id>83700: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-122_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-122_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-10-21T20:28:12+02:00</updated>
<id>83693: https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb&#34; rel=&#34;nofollow&#34;&gt;f03d82ebf746e38a7678f1ee82ab754bb20da9eb&lt;/a&gt;&#xA;* Tue Oct 21 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-122.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/f03d82ebf746e38a7678f1ee82ab754bb20da9eb&#34;&gt;f03d82ebf746e38a7678f1ee82ab754bb20da9eb&lt;/a&gt;&#xA;* Tue Oct 21 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-122.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jcrisp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates/src/tag/11_0-11_el8_sme&#34;&gt;11_0-11_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates&#34;&gt;smeserver/smeserver-certificates&lt;/a&gt;</title>
<updated>2025-10-15T17:10:17+02:00</updated>
<id>83634: https://src.koozali.org/smeserver/smeserver-certificates/src/tag/11_0-11_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-certificates/src/tag/11_0-11_el8_sme" rel="alternate"></link>
<author>
<name>jcrisp</name>
<email>jcrisp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jcrisp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates&#34;&gt;smeserver/smeserver-certificates&lt;/a&gt;</title>
<updated>2025-10-15T17:05:40+02:00</updated>
<id>83627: https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4&#34; rel=&#34;nofollow&#34;&gt;73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4&lt;/a&gt;&#xA;Fix typo</content>
<link href="https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4&#34;&gt;73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4&lt;/a&gt;&#xA;Fix typo</summary>
<author>
<name>jcrisp</name>
<email>jcrisp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-31_el8_sme&#34;&gt;11_0_0-31_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-06T19:34:20+02:00</updated>
<id>81653: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-31_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-31_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-06T19:34:15+02:00</updated>
<id>81646: https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87&#34; rel=&#34;nofollow&#34;&gt;27485c3952d2aa55de468a70cc5f69939580bb87&lt;/a&gt;&#xA;* Mon Oct 06 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-31.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/27485c3952d2aa55de468a70cc5f69939580bb87&#34;&gt;27485c3952d2aa55de468a70cc5f69939580bb87&lt;/a&gt;&#xA;* Mon Oct 06 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-31.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp&#34;&gt;smeserver/smeserver-ntp&lt;/a&gt;</title>
<updated>2025-10-03T21:50:22+02:00</updated>
<id>81202: https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e&#34; rel=&#34;nofollow&#34;&gt;8879d29ca50e750e0890f11d4c0d405954ae7c2e&lt;/a&gt;&#xA;typo in changelog</content>
<link href="https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e&#34;&gt;8879d29ca50e750e0890f11d4c0d405954ae7c2e&lt;/a&gt;&#xA;typo in changelog</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/src/tag/11_0_0-8_el8_sme&#34;&gt;11_0_0-8_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp&#34;&gt;smeserver/smeserver-ntp&lt;/a&gt;</title>
<updated>2025-10-03T21:48:25+02:00</updated>
<id>81195: https://src.koozali.org/smeserver/smeserver-ntp/src/tag/11_0_0-8_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-ntp/src/tag/11_0_0-8_el8_sme" rel="alternate"></link>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp&#34;&gt;smeserver/smeserver-ntp&lt;/a&gt;</title>
<updated>2025-10-03T21:47:56+02:00</updated>
<id>81188: https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233&#34; rel=&#34;nofollow&#34;&gt;6d07479bf668b0f0be4ae705841b3208fa9ee233&lt;/a&gt;&#xA;* Fri Oct 03 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-8.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-ntp/commit/6d07479bf668b0f0be4ae705841b3208fa9ee233&#34;&gt;6d07479bf668b0f0be4ae705841b3208fa9ee233&lt;/a&gt;&#xA;* Fri Oct 03 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-8.sme</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-30_el8_sme&#34;&gt;11_0_0-30_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-03T15:17:43+02:00</updated>
<id>81125: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-30_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-30_el8_sme" rel="alternate"></link>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-03T15:17:15+02:00</updated>
<id>81118: https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a&#34; rel=&#34;nofollow&#34;&gt;8e29af1670c27e91ff9846f7c677b8e1e943e94a&lt;/a&gt;&#xA;* Thu Oct 02 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-30.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/8e29af1670c27e91ff9846f7c677b8e1e943e94a&#34;&gt;8e29af1670c27e91ff9846f7c677b8e1e943e94a&lt;/a&gt;&#xA;* Thu Oct 02 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-30.sme</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-29_el8_sme&#34;&gt;11_0_0-29_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-02T15:51:27+02:00</updated>
<id>81083: https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-29_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-update/src/tag/11_0_0-29_el8_sme" rel="alternate"></link>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update&#34;&gt;smeserver/smeserver-update&lt;/a&gt;</title>
<updated>2025-10-02T15:49:58+02:00</updated>
<id>81076: https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598&#34; rel=&#34;nofollow&#34;&gt;37f6399569c303a9a64de84bd39b836b1de78598&lt;/a&gt;&#xA;* Thu Oct 02 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-29.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-update/commit/37f6399569c303a9a64de84bd39b836b1de78598&#34;&gt;37f6399569c303a9a64de84bd39b836b1de78598&lt;/a&gt;&#xA;* Thu Oct 02 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-29.sme</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-121_el8_sme&#34;&gt;11_0_0-121_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-27T13:30:07+02:00</updated>
<id>80913: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-121_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-121_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-27T13:30:05+02:00</updated>
<id>80906: https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac&#34; rel=&#34;nofollow&#34;&gt;de2f78a0892214b8c1fdd2de7b2eed177d398aac&lt;/a&gt;&#xA;* Sat Sep 27 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-121.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/de2f78a0892214b8c1fdd2de7b2eed177d398aac&#34;&gt;de2f78a0892214b8c1fdd2de7b2eed177d398aac&lt;/a&gt;&#xA;* Sat Sep 27 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-121.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-12_el8_sme&#34;&gt;11_0_0-12_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd&#34;&gt;smeserver/smeserver-proftpd&lt;/a&gt;</title>
<updated>2025-09-26T18:49:52+02:00</updated>
<id>80885: https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-12_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-12_el8_sme" rel="alternate"></link>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd&#34;&gt;smeserver/smeserver-proftpd&lt;/a&gt;</title>
<updated>2025-09-26T18:49:43+02:00</updated>
<id>80878: https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8&#34; rel=&#34;nofollow&#34;&gt;ed837ffb760943d12a6462c35c7c9f48176b91d8&lt;/a&gt;&#xA;* Fri Sep 26 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-12.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd/commit/ed837ffb760943d12a6462c35c7c9f48176b91d8&#34;&gt;ed837ffb760943d12a6462c35c7c9f48176b91d8&lt;/a&gt;&#xA;* Fri Sep 26 2025 Jean-Philippe Pialasse &amp;lt;&lt;a href=&#34;mailto:jpp@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;jpp@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-12.sme</summary>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-120_el8_sme&#34;&gt;11_0_0-120_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-25T19:45:32+02:00</updated>
<id>80752: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-120_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-120_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-25T19:45:30+02:00</updated>
<id>80745: https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7&#34; rel=&#34;nofollow&#34;&gt;b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7&lt;/a&gt;&#xA;* Thu Sep 25 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-120.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7&#34;&gt;b838d9252a7d6fd52e9dc3e9c6fc91f4f8d726d7&lt;/a&gt;&#xA;* Thu Sep 25 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-120.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-119_el8_sme&#34;&gt;11_0_0-119_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-25T16:42:44+02:00</updated>
<id>80708: https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-119_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-manager/src/tag/11_0_0-119_el8_sme" rel="alternate"></link>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>brianr pushed to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/src/branch/master&#34;&gt;master&lt;/a&gt; at &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager&#34;&gt;smeserver/smeserver-manager&lt;/a&gt;</title>
<updated>2025-09-25T16:42:39+02:00</updated>
<id>80701: https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b</id>
<content type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b&#34; rel=&#34;nofollow&#34;&gt;9c9ab9186966b5ba893528a79531bc608c42ac7b&lt;/a&gt;&#xA;* Thu Sep 25 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34; rel=&#34;nofollow&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-119.sme</content>
<link href="https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b" rel="alternate"></link>
<summary type="html">&lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-manager/commit/9c9ab9186966b5ba893528a79531bc608c42ac7b&#34;&gt;9c9ab9186966b5ba893528a79531bc608c42ac7b&lt;/a&gt;&#xA;* Thu Sep 25 2025 Brian Read &amp;lt;&lt;a href=&#34;mailto:brianr@koozali.org&#34; data-markdown-generated-content=&#34;&#34;&gt;brianr@koozali.org&lt;/a&gt;&amp;gt; 11.0.0-119.sme</summary>
<author>
<name>brianr</name>
<email>brianr@noreply.koozali.org</email>
</author>
</entry>
<entry>
<title>jpp pushed tag &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-11_el8_sme&#34;&gt;11_0_0-11_el8_sme&lt;/a&gt; to &lt;a href=&#34;https://src.koozali.org/smeserver/smeserver-proftpd&#34;&gt;smeserver/smeserver-proftpd&lt;/a&gt;</title>
<updated>2025-09-25T16:32:37+02:00</updated>
<id>80694: https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-11_el8_sme</id>
<link href="https://src.koozali.org/smeserver/smeserver-proftpd/src/tag/11_0_0-11_el8_sme" rel="alternate"></link>
<author>
<name>jpp</name>
<email>jpp@noreply.koozali.org</email>
</author>
</entry>
</feed>"""
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()