2025-10-30 07:58:45 +01:00
#!/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 %20c reation % 5D&chfieldfrom=7d&ctype=atom&title=Bugs %20r eported %20i n % 20the %20la st %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")
}
}
2025-10-30 08:03:09 +01:00
GITEA_ORGS = [ " smecontribs " , " smeserver " , " smedev " ]
2025-10-30 07:58:45 +01:00
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 } \n Product: { 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 } \n Link: { 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"</title>
< id > https : / / src . koozali . org / smeserver < / id >
< updated > 2025 - 10 - 29 T12 : 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">11_0_0-124_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 24 T13 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 24 T13 : 41 : 58 + 02 : 00 < / updated >
< id > 84586 : https : / / src . koozali . org / smeserver / smeserver - manager / commit / 8e270 ef3fd973ef27d0087fcfa02f614c1e13676 < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">11_0_0-123_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 24 T11 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 24 T11 : 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" 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</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">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</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">11_0_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery">smeserver/smeserver-manager-jsquery</a></title>
< updated > 2025 - 10 - 24 T11 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager-jsquery">smeserver/smeserver-manager-jsquery</a></title>
< updated > 2025 - 10 - 24 T11 : 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" 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</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">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</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">master</a> at <a href="https://src.koozali.org/smeserver/common">smeserver/common</a></title>
< updated > 2025 - 10 - 23 T18 : 20 : 23 + 02 : 00 < / updated >
< id > 83886 : / smeserver / common / compare / 507 cc753ec53612b622047344a527314158808a3 . . .8 d5535b58b89c2ab8757842325a687c73022f317 < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 22 T10 : 44 : 34 + 02 : 00 < / updated >
< id > 83707 : https : / / src . koozali . org / smeserver / smeserver - manager / commit / 9437 dd792a2117ba41c433881fef4a915acfcc2c < / id >
< content type = " html " > & lt ; a href = & #34;https://src.koozali.org/smeserver/smeserver-manager/commit/9437dd792a2117ba41c433881fef4a915acfcc2c" rel="nofollow">9437dd792a2117ba41c433881fef4a915acfcc2c</a>
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">9437dd792a2117ba41c433881fef4a915acfcc2c</a>
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">11_0_0-122_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 21 T20 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 10 - 21 T20 : 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" 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</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">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</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">11_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-certificates">smeserver/smeserver-certificates</a></title>
< updated > 2025 - 10 - 15 T17 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-certificates">smeserver/smeserver-certificates</a></title>
< updated > 2025 - 10 - 15 T17 : 05 : 40 + 02 : 00 < / updated >
< id > 83627 : https : / / src . koozali . org / smeserver / smeserver - certificates / commit / 73 ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4 < / id >
< content type = " html " > & lt ; a href = & #34;https://src.koozali.org/smeserver/smeserver-certificates/commit/73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4" rel="nofollow">73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4</a>
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">73ef48ef5f0bfb5e9e990b4f6b0948f662a3f9b4</a>
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">11_0_0-31_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 06 T19 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 06 T19 : 34 : 15 + 02 : 00 < / updated >
< id > 81646 : https : / / src . koozali . org / smeserver / smeserver - update / commit / 27485 c3952d2aa55de468a70cc5f69939580bb87 < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a></title>
< updated > 2025 - 10 - 03 T21 : 50 : 22 + 02 : 00 < / updated >
< id > 81202 : https : / / src . koozali . org / smeserver / smeserver - ntp / commit / 8879 d29ca50e750e0890f11d4c0d405954ae7c2e < / id >
< content type = " html " > & lt ; a href = & #34;https://src.koozali.org/smeserver/smeserver-ntp/commit/8879d29ca50e750e0890f11d4c0d405954ae7c2e" rel="nofollow">8879d29ca50e750e0890f11d4c0d405954ae7c2e</a>
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">8879d29ca50e750e0890f11d4c0d405954ae7c2e</a>
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">11_0_0-8_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a></title>
< updated > 2025 - 10 - 03 T21 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-ntp">smeserver/smeserver-ntp</a></title>
< updated > 2025 - 10 - 03 T21 : 47 : 56 + 02 : 00 < / updated >
< id > 81188 : https : / / src . koozali . org / smeserver / smeserver - ntp / commit / 6 d07479bf668b0f0be4ae705841b3208fa9ee233 < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">11_0_0-30_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 03 T15 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 03 T15 : 17 : 15 + 02 : 00 < / updated >
< id > 81118 : https : / / src . koozali . org / smeserver / smeserver - update / commit / 8e29 af1670c27e91ff9846f7c677b8e1e943e94a < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">11_0_0-29_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 02 T15 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-update">smeserver/smeserver-update</a></title>
< updated > 2025 - 10 - 02 T15 : 49 : 58 + 02 : 00 < / updated >
< id > 81076 : https : / / src . koozali . org / smeserver / smeserver - update / commit / 37 f6399569c303a9a64de84bd39b836b1de78598 < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">11_0_0-121_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 27 T13 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 27 T13 : 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" 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</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">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</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">11_0_0-12_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a></title>
< updated > 2025 - 09 - 26 T18 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a></title>
< updated > 2025 - 09 - 26 T18 : 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" 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</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">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</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">11_0_0-120_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 25 T19 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 25 T19 : 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" 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</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">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</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">11_0_0-119_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 25 T16 : 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">master</a> at <a href="https://src.koozali.org/smeserver/smeserver-manager">smeserver/smeserver-manager</a></title>
< updated > 2025 - 09 - 25 T16 : 42 : 39 + 02 : 00 < / updated >
< id > 80701 : https : / / src . koozali . org / smeserver / smeserver - manager / commit / 9 c9ab9186966b5ba893528a79531bc608c42ac7b < / id >
< content type = " html " > & lt ; a href = & #34;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</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">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</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">11_0_0-11_el8_sme</a> to <a href="https://src.koozali.org/smeserver/smeserver-proftpd">smeserver/smeserver-proftpd</a></title>
< updated > 2025 - 09 - 25 T16 : 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 ( )