diff --git a/bot.py b/bot.py index 70d9829..2e294a5 100644 --- a/bot.py +++ b/bot.py @@ -88,6 +88,10 @@ class Bot: self._load_static_webhooks() self._load_static_rss() + @property + def _commit_limit(self) -> int: + return self._cfg.get("commit_limit", 3) + # ── Static config loading ───────────────────────────────────────────────── def _load_static_webhooks(self): @@ -99,6 +103,7 @@ class Bot: db.webhook_add( self._database, net, ch, hook["repo"], + hook.get("forge"), hook.get("events", list(DEFAULT_EVENTS)), hook.get("branches", []), ) @@ -245,16 +250,21 @@ class Bot: primary = events[0] if events else "" targets = db.webhook_targets( - self._database, full_name, repo_user, organisation) + self._database, forge, full_name, repo_user, organisation) if not targets: log.debug("[%s] No targets for %s", forge, full_name) return - outputs = parser.parse(full_name, primary, data, headers) + outputs = parser.parse(full_name, primary, data, headers, + commit_limit=self._commit_limit) if not outputs: return + forge_tag = fmt.bold(f"[{forge.capitalize()}]") + source = fmt.color(full_name or organisation or repo_name or forge, + fmt.COLOR_REPO) + for target in targets: if branch and target["branches"] and branch not in target["branches"]: continue @@ -265,12 +275,8 @@ class Bot: if not set(events) & allowed: continue - source = fmt.color( - full_name or organisation or repo_name or forge, - fmt.COLOR_REPO) - for message, url in outputs: - line = f"({source}) {message}" + line = f"{forge_tag} ({source}) {message}" if url: line = f"{line} - {url}" await self._deliver_irc(target["network"], target["channel"], line) diff --git a/commands.py b/commands.py index 2e37684..f9255aa 100644 --- a/commands.py +++ b/commands.py @@ -184,14 +184,26 @@ async def _passwd(args, database, reply): # ── !webhook ────────────────────────────────────────────────────────────────── +FORGES = {"github", "gitea", "gitlab"} + WEBHOOK_HELP = ( "!webhook list | " - "!webhook add | " - "!webhook remove | " - "!webhook events [event …] | " - "!webhook branches [branch …]" + "!webhook add [forge] | " + "!webhook remove [forge] | " + "!webhook events [forge] [event …] | " + "!webhook branches [forge] [branch …]" ) + +def _parse_repo_forge(args, offset=0): + """Parse [forge] from args at offset. forge=None if not given.""" + repo = args[offset] if len(args) > offset else None + forge = None + if len(args) > offset + 1 and args[offset + 1].lower() in FORGES: + forge = args[offset + 1].lower() + return repo, forge + + async def _webhook(network, channel, args, database, reply): if not args: await reply(WEBHOOK_HELP) @@ -205,56 +217,72 @@ async def _webhook(network, channel, args, database, reply): await reply("No webhooks registered for this channel.") else: for h in hooks: - branches = ", ".join(h["branches"]) or "all" - events = ", ".join(h["events"]) - await reply(f" {h['repo']} events={events} branches={branches}") + branches = ", ".join(h["branches"]) or "all" + events = ", ".join(h["events"]) + forge_str = f" forge={h['forge']}" if h["forge"] else "" + await reply(f" {h['repo']}{forge_str} events={events} branches={branches}") elif sub == "add": if len(args) < 2: - await reply("Usage: !webhook add ") + await reply("Usage: !webhook add [forge]") return - db.webhook_add(database, network, channel, args[1]) - await reply(f"Webhook added for {args[1]}") + repo, forge = _parse_repo_forge(args, offset=1) + db.webhook_add(database, network, channel, repo, forge) + forge_str = f" ({forge})" if forge else "" + await reply(f"Webhook added for {repo}{forge_str}") elif sub == "remove": if len(args) < 2: - await reply("Usage: !webhook remove ") + await reply("Usage: !webhook remove [forge]") return - db.webhook_remove(database, network, channel, args[1]) - await reply(f"Webhook removed for {args[1]}") + repo, forge = _parse_repo_forge(args, offset=1) + db.webhook_remove(database, network, channel, repo, forge) + forge_str = f" ({forge})" if forge else "" + await reply(f"Webhook removed for {repo}{forge_str}") elif sub == "events": if len(args) < 2: - await reply("Usage: !webhook events [event …]") + await reply("Usage: !webhook events [forge] [event …]") return - repo = args[1] - if len(args) == 2: + repo, forge = _parse_repo_forge(args, offset=1) + event_start = 3 if forge else 2 + if len(args) < event_start + 1: + # Show current hooks = db.webhook_list(database, network, channel) - hook = next((h for h in hooks if h["repo"].lower() == repo.lower()), None) + hook = next((h for h in hooks + if h["repo"].lower() == repo.lower() + and h["forge"] == forge), None) if not hook: - await reply(f"No webhook found for {repo}") + forge_str = f" ({forge})" if forge else "" + await reply(f"No webhook found for {repo}{forge_str}") else: await reply(f"{repo} events: {', '.join(hook['events'])}") else: - events = [e.lower() for e in args[2:]] - db.webhook_set_events(database, network, channel, repo, events) + events = [e.lower() for e in args[event_start:]] + db.webhook_set_events(database, network, channel, repo, events, forge) await reply(f"Updated events for {repo}: {', '.join(events)}") elif sub == "branches": if len(args) < 2: - await reply("Usage: !webhook branches [branch …]") + await reply("Usage: !webhook branches [forge] [branch …]") return - repo = args[1] - if len(args) == 2: + repo, forge = _parse_repo_forge(args, offset=1) + branch_start = 3 if forge else 2 + if len(args) < branch_start + 1: + # Show current hooks = db.webhook_list(database, network, channel) - hook = next((h for h in hooks if h["repo"].lower() == repo.lower()), None) + hook = next((h for h in hooks + if h["repo"].lower() == repo.lower() + and h["forge"] == forge), None) if not hook: - await reply(f"No webhook found for {repo}") + forge_str = f" ({forge})" if forge else "" + await reply(f"No webhook found for {repo}{forge_str}") else: await reply(f"{repo} branches: {', '.join(hook['branches']) or 'all'}") else: - db.webhook_set_branches(database, network, channel, repo, args[2:]) - await reply(f"Updated branches for {repo}: {', '.join(args[2:])}") + branches = args[branch_start:] + db.webhook_set_branches(database, network, channel, repo, branches, forge) + await reply(f"Updated branches for {repo}: {', '.join(branches)}") else: await reply(WEBHOOK_HELP) diff --git a/db.py b/db.py index f1b4691..0c07517 100644 --- a/db.py +++ b/db.py @@ -2,7 +2,6 @@ import json import sqlite3 -from pathlib import Path def connect(path: str) -> sqlite3.Connection: @@ -17,22 +16,23 @@ def connect(path: str) -> sqlite3.Connection: def _migrate(db: sqlite3.Connection): db.executescript(""" CREATE TABLE IF NOT EXISTS webhook_routes ( - id INTEGER PRIMARY KEY, - network TEXT NOT NULL, - channel TEXT NOT NULL, - repo TEXT NOT NULL, - events TEXT NOT NULL DEFAULT '["ping","code","pr","issue","repo"]', - branches TEXT NOT NULL DEFAULT '[]' - ); - CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook - ON webhook_routes(network, channel, repo); - - CREATE TABLE IF NOT EXISTS rss_feeds ( id INTEGER PRIMARY KEY, network TEXT NOT NULL, channel TEXT NOT NULL, - url TEXT NOT NULL, - format TEXT NOT NULL DEFAULT '$feed_name: $title <$link>' + repo TEXT NOT NULL, + forge TEXT, + events TEXT NOT NULL DEFAULT '["ping","code","pr","issue","repo"]', + branches TEXT NOT NULL DEFAULT '[]' + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook + ON webhook_routes(network, channel, repo, forge); + + CREATE TABLE IF NOT EXISTS rss_feeds ( + id INTEGER PRIMARY KEY, + network TEXT NOT NULL, + channel TEXT NOT NULL, + url TEXT NOT NULL, + format TEXT NOT NULL DEFAULT '$feed_name: $title <$link>' ); CREATE UNIQUE INDEX IF NOT EXISTS uq_rss ON rss_feeds(network, channel, url); @@ -44,7 +44,25 @@ def _migrate(db: sqlite3.Connection): ); """) db.commit() - # Migrate existing databases that predate the format column + _migrate_webhook_forge(db) + _migrate_rss_format(db) + + +def _migrate_webhook_forge(db): + """Add forge column and rebuild unique index to include it.""" + cols = [r[1] for r in db.execute("PRAGMA table_info(webhook_routes)").fetchall()] + if "forge" in cols: + return + db.executescript(""" + ALTER TABLE webhook_routes ADD COLUMN forge TEXT; + DROP INDEX IF EXISTS uq_webhook; + CREATE UNIQUE INDEX uq_webhook + ON webhook_routes(network, channel, repo, forge); + """) + db.commit() + + +def _migrate_rss_format(db): cols = [r[1] for r in db.execute("PRAGMA table_info(rss_feeds)").fetchall()] if "format" not in cols: db.execute(""" @@ -54,6 +72,8 @@ def _migrate(db: sqlite3.Connection): db.commit() +# ── Purge helpers (used by reload) ──────────────────────────────────────────── + def purge_network(db, network: str): """Remove all webhook routes and RSS feeds for a network.""" db.execute("DELETE FROM webhook_routes WHERE network=?", (network,)) @@ -72,37 +92,39 @@ def purge_channel(db, network: str, channel: str): # ── Webhook routes ──────────────────────────────────────────────────────────── -def webhook_add(db, network, channel, repo, +def webhook_add(db, network, channel, repo, forge=None, events=None, branches=None): events = events or ["ping", "code", "pr", "issue", "repo"] branches = branches or [] db.execute(""" - INSERT INTO webhook_routes(network, channel, repo, events, branches) - VALUES (?,?,?,?,?) - ON CONFLICT(network, channel, repo) DO UPDATE + INSERT INTO webhook_routes(network, channel, repo, forge, events, branches) + VALUES (?,?,?,?,?,?) + ON CONFLICT(network, channel, repo, forge) DO UPDATE SET events=excluded.events, branches=excluded.branches - """, (network, channel, repo, + """, (network, channel, repo, forge, json.dumps(events), json.dumps(branches))) db.commit() -def webhook_remove(db, network, channel, repo): +def webhook_remove(db, network, channel, repo, forge=None): db.execute(""" DELETE FROM webhook_routes WHERE network=? AND channel=? AND repo=? - """, (network, channel, repo)) + AND (forge IS ? OR (forge IS NULL AND ? IS NULL)) + """, (network, channel, repo, forge, forge)) db.commit() def webhook_list(db, network, channel): rows = db.execute(""" - SELECT repo, events, branches FROM webhook_routes + SELECT repo, forge, events, branches FROM webhook_routes WHERE network=? AND channel=? - ORDER BY repo + ORDER BY repo, forge """, (network, channel)).fetchall() return [ { "repo": r["repo"], + "forge": r["forge"], "events": json.loads(r["events"]), "branches": json.loads(r["branches"]), } @@ -110,27 +132,35 @@ def webhook_list(db, network, channel): ] -def webhook_set_events(db, network, channel, repo, events): +def webhook_set_events(db, network, channel, repo, events, forge=None): db.execute(""" UPDATE webhook_routes SET events=? WHERE network=? AND channel=? AND repo=? - """, (json.dumps(events), network, channel, repo)) + AND (forge IS ? OR (forge IS NULL AND ? IS NULL)) + """, (json.dumps(events), network, channel, repo, forge, forge)) db.commit() -def webhook_set_branches(db, network, channel, repo, branches): +def webhook_set_branches(db, network, channel, repo, branches, forge=None): db.execute(""" UPDATE webhook_routes SET branches=? WHERE network=? AND channel=? AND repo=? - """, (json.dumps(branches), network, channel, repo)) + AND (forge IS ? OR (forge IS NULL AND ? IS NULL)) + """, (json.dumps(branches), network, channel, repo, forge, forge)) db.commit() -def webhook_targets(db, full_name, repo_user, organisation): - """Return all (network, channel, events, branches) matching this repo.""" +def webhook_targets(db, forge, full_name, repo_user, organisation): + """ + Return routes matching this repo+forge. + Routes with forge=NULL match any forge; specific forge routes only match + that forge. + """ rows = db.execute(""" - SELECT network, channel, repo, events, branches FROM webhook_routes - """).fetchall() + SELECT network, channel, repo, forge, events, branches + FROM webhook_routes + WHERE forge IS NULL OR forge=? + """, (forge,)).fetchall() results = [] candidates = {x.lower() for x in [full_name, repo_user, organisation] if x} for r in rows: @@ -147,7 +177,7 @@ def webhook_targets(db, full_name, repo_user, organisation): # ── RSS feeds ───────────────────────────────────────────────────────────────── def rss_add(db, network, channel, url): - """Insert feed. Returns (id, created) — created=False if it already existed.""" + """Insert feed. Returns (id, created) — created=False if already existed.""" existing = db.execute(""" SELECT id FROM rss_feeds WHERE network=? AND channel=? AND url=? """, (network, channel, url)).fetchone() @@ -179,7 +209,6 @@ def rss_list(db, network, channel): def rss_all_feeds(db): - """Return all feeds: list of {id, network, channel, url, format}.""" rows = db.execute(""" SELECT id, network, channel, url, format FROM rss_feeds """).fetchall() @@ -187,7 +216,6 @@ def rss_all_feeds(db): def rss_set_format(db, network, channel, url, fmt): - """Update format template for a feed. Returns True if the feed was found.""" db.execute(""" UPDATE rss_feeds SET format=? WHERE network=? AND channel=? AND url=? """, (fmt, network, channel, url)) @@ -206,7 +234,6 @@ def rss_mark_seen(db, feed_id, entry_ids): db.executemany(""" INSERT OR IGNORE INTO rss_seen(feed_id, entry_id) VALUES (?,?) """, [(feed_id, eid) for eid in entry_ids]) - # Keep only the most recent 500 per feed to avoid unbounded growth db.execute(""" DELETE FROM rss_seen WHERE feed_id=? AND entry_id NOT IN ( SELECT entry_id FROM rss_seen WHERE feed_id=? diff --git a/webhook_gitea.py b/webhook_gitea.py index 9435287..56b7a7b 100644 --- a/webhook_gitea.py +++ b/webhook_gitea.py @@ -77,9 +77,9 @@ def event_categories(ev): return EVENT_CATEGORIES.get(ev, [ev]) -def parse(full_name, ev, data, headers): +def parse(full_name, ev, data, headers, commit_limit=3): dispatch = { - "push": _push, + "push": lambda fn, d: _push(fn, d, commit_limit), "pull_request": _pull_request, "issues": _issues, "issue_comment": _issue_comment, @@ -96,19 +96,32 @@ def parse(full_name, ev, data, headers): return [] -def _push(full_name, data): +def _push(full_name, data, commit_limit=3): branch_str = color(data["ref"].rpartition("/")[2], COLOR_BRANCH) - author = bold(data["pusher"]["login"]) - commits = data.get("commits", []) - outputs = [] - if len(commits) <= 3: - for c in commits: - h = color(_short(c["id"]), COLOR_ID) - msg = c["message"].split("\n")[0].strip() - outputs.append((f"{author} pushed {h} to {branch_str}: {msg}", c["url"])) - else: - url = data.get("compare_url") - outputs.append((f"{author} pushed {len(commits)} commits to {branch_str}", url)) + author = bold(data["pusher"]["login"]) + commits = data.get("commits", []) + range_url = data.get("compare_url") + n = len(commits) + + if not commits: + return [(f"{author} pushed to {branch_str}", None)] + + # Single commit: one clean line + if n == 1: + c = commits[0] + h = color(_short(c["id"]), COLOR_ID) + msg = c["message"].split("\n")[0].strip() + return [(f"{author} pushed {h} to {branch_str}: {msg}", c.get("url"))] + + # Multiple commits + outputs = [(f"{author} pushed {n} commits to {branch_str}", range_url)] + shown = commits[:commit_limit] + for c in shown: + msg = c["message"].split("\n")[0].strip() + outputs.append((f"{author} {_short(c['id'])} - {msg}", None)) + hidden = n - len(shown) + if hidden > 0: + outputs.append((f"(+{hidden} hidden commit{'s' if hidden != 1 else ''})", None)) return outputs diff --git a/webhook_github.py b/webhook_github.py index 7806b3d..1a691db 100644 --- a/webhook_github.py +++ b/webhook_github.py @@ -104,10 +104,10 @@ def event_categories(ev): return EVENT_CATEGORIES.get(ev, [ev]) -def parse(full_name, ev, data, headers): +def parse(full_name, ev, data, headers, commit_limit=3): """Return list of (message, url) tuples.""" dispatch = { - "push": _push, + "push": lambda fn, d: _push(fn, d, commit_limit), "commit_comment": _commit_comment, "pull_request": _pull_request, "pull_request_review": _pr_review, @@ -128,33 +128,40 @@ def parse(full_name, ev, data, headers): return [] -def _format_push(branch_str, author, commits, forced, single_url, range_url): - outputs = [] +def _push(full_name, data, commit_limit=3): + branch_str = color(data["ref"].split("/", 2)[2], COLOR_BRANCH) + author = bold(data["pusher"]["name"]) + forced = data.get("forced", False) + commits = data.get("commits", []) forced_str = f"{color('force', RED)} " if forced else "" + if not commits and forced: return [(f"{author} {forced_str}pushed to {branch_str}", None)] - if len(commits) <= 3: - for c in commits: - h = color(_short(c["id"]), COLOR_ID) - msg = c["message"].split("\n")[0].strip() - url = single_url % c["id"] - outputs.append((f"{author} {forced_str}pushed {h} to {branch_str}: {msg}", url)) - else: - url = range_url - outputs.append((f"{author} {forced_str}pushed {len(commits)} commits to {branch_str}", url)) - return outputs - -def _push(full_name, data): - branch_str = color(data["ref"].split("/", 2)[2], COLOR_BRANCH) - author = bold(data["pusher"]["name"]) - forced = data.get("forced", False) - commits = data.get("commits", []) range_url = None if commits: range_url = COMMIT_RANGE_URL % (full_name, data["before"], commits[-1]["id"]) - single_url = COMMIT_URL % (full_name, "%s") - return _format_push(branch_str, author, commits, forced, single_url, range_url) + + n = len(commits) + + # Single commit: one clean line with hash, branch and message + if n == 1: + c = commits[0] + h = color(_short(c["id"]), COLOR_ID) + msg = c["message"].split("\n")[0].strip() + url = COMMIT_URL % (full_name, c["id"]) + return [(f"{author} {forced_str}pushed {h} to {branch_str}: {msg}", url)] + + # Multiple commits: summary line + individual lines + optional hidden count + outputs = [(f"{author} {forced_str}pushed {n} commits to {branch_str}", range_url)] + shown = commits[:commit_limit] + for c in shown: + msg = c["message"].split("\n")[0].strip() + outputs.append((f"{author} {_short(c['id'])} - {msg}", None)) + hidden = n - len(shown) + if hidden > 0: + outputs.append((f"(+{hidden} hidden commit{'s' if hidden != 1 else ''})", None)) + return outputs def _commit_comment(full_name, data): diff --git a/webhook_gitlab.py b/webhook_gitlab.py index b6d0496..02aab5d 100644 --- a/webhook_gitlab.py +++ b/webhook_gitlab.py @@ -95,9 +95,9 @@ def event_categories(ev): return EVENT_CATEGORIES.get(ev, [ev]) -def parse(full_name, ev, data, headers): +def parse(full_name, ev, data, headers, commit_limit=3): dispatch = { - "push": _push, + "push": lambda fn, d: _push(fn, d, commit_limit), "tag_push": _tag_push, "merge_request": _merge_request, "issue": _issues, @@ -112,18 +112,31 @@ def parse(full_name, ev, data, headers): return [] -def _push(full_name, data): +def _push(full_name, data, commit_limit=3): branch_str = color(data["ref"].rpartition("/")[2], COLOR_BRANCH) - author = bold(data["user_username"]) - commits = data.get("commits", []) - outputs = [] - if len(commits) <= 3: - for c in commits: - h = color(_short(c["id"]), COLOR_ID) - msg = c["message"].split("\n")[0].strip() - outputs.append((f"{author} pushed {h} to {branch_str}: {msg}", c["url"])) - else: - outputs.append((f"{author} pushed {len(commits)} commits to {branch_str}", None)) + author = bold(data["user_username"]) + commits = data.get("commits", []) + n = len(commits) + + if not commits: + return [(f"{author} pushed to {branch_str}", None)] + + # Single commit: one clean line + if n == 1: + c = commits[0] + h = color(_short(c["id"]), COLOR_ID) + msg = c["message"].split("\n")[0].strip() + return [(f"{author} pushed {h} to {branch_str}: {msg}", c.get("url"))] + + # Multiple commits (GitLab has no compare URL in push payloads) + outputs = [(f"{author} pushed {n} commits to {branch_str}", None)] + shown = commits[:commit_limit] + for c in shown: + msg = c["message"].split("\n")[0].strip() + outputs.append((f"{author} {_short(c['id'])} - {msg}", c.get("url"))) + hidden = n - len(shown) + if hidden > 0: + outputs.append((f"(+{hidden} hidden commit{'s' if hidden != 1 else ''})", None)) return outputs