| #!/usr/bin/env python3 |
| # -*- coding: utf-8 -*- |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| import asyncio |
| import yaml |
| import fnmatch |
| import os |
| import sys |
| import asfpy.pubsub |
| import irc.client |
| import irc.client_aio |
| import base64 |
| |
| MAX_LOG_LEN = 200 |
| INTERNAL_TIMEOUT = 30 # Internal 30 second timeout for monitoring the pubsub loop. |
| |
| |
| def files_touched(files): |
| """Finds the root path of files touched by this commit, as well as returns a short summary of what was touched""" |
| if isinstance(files, dict): # svn returns a dict, we only care about the filenames, so convert to list |
| files = list(files.keys()) |
| if not files: # No files touched, likely a branch or tag was created/deleted |
| return "", "No files touched" |
| if len(files) == 1: # Just one file, return that |
| return files[0], files[0] |
| paths = files[0].split("/") |
| commit_files = 0 |
| commit_dirs = set() |
| for file in files: |
| commit_files += 1 |
| commit_dirs.add(os.path.dirname(file)) |
| while not file.startswith("/".join(paths)): |
| paths.pop() |
| if len(paths) == 0: |
| break |
| root_path = "/".join(paths) |
| commit_dirs = len(commit_dirs) == 1 and "1 directory" or f"{len(commit_dirs)} directories" |
| commit_files = commit_files == 1 and "1 file" or f"{commit_files} files" |
| return root_path, f"{root_path} ({commit_files} in {commit_dirs})" |
| |
| |
| def format_message(payload): |
| """Formats a commit payload into an IRC message""" |
| commit = payload.get("commit") |
| if not commit: # Probably a still-alive ping, ignore |
| return "", "" |
| commit_type = commit.get("repository") |
| commit_root, commit_files = files_touched(commit.get("files") or commit.get("changed", {})) |
| if commit_type == "git": |
| commit_subject = commit.get("subject") |
| author = commit.get("email", "unknown@apache.org") |
| commit_repo = commit.get("project") |
| tag = commit.get("ref", "main").replace("refs/heads/", "").replace("refs/tags/", "") # Strip refs/*/ away |
| sha = commit.get("hash", "0000000") |
| url = f"https://gitbox.apache.org/repos/asf?p={commit_repo}.git;h={sha}" |
| return ( |
| f"git:{commit_repo}", |
| f"\x033 {author}\x03 \x02{tag} * {sha}\x0f ({commit_files}) {url}: {commit_subject}", |
| ) |
| else: # if not git, then svn |
| author = commit.get("committer", "unknown") + "@apache.org" |
| commit_subject = commit.get("log", "No log provided") |
| commit_subject = " ".join(commit_subject.split("\n")) |
| if len(commit_subject) > MAX_LOG_LEN: |
| commit_subject = commit_subject[: MAX_LOG_LEN - 3] + "..." |
| revision = commit.get("id", "1") |
| url = f"https://svn.apache.org/r{revision}" |
| return f"svn:{commit_root}", f"\x033 {author}\x03 \x02r{revision}\x0f ({commit_files}) {url}: {commit_subject}" |
| |
| |
| class SASLMixin(irc.client_aio.AioSimpleIRCClient): |
| """Mixin for the IRC client, adding simple SASL capabilities""" |
| |
| def __init__(self): |
| super(irc.client_aio.AioSimpleIRCClient, self).__init__() |
| self.reactor._on_connect = self._on_connect |
| for event in ["cap", "authenticate", "903", "908"]: |
| self.connection.add_global_handler(event, getattr(self, f"_on_{event}")) |
| |
| def _on_connect(self, sock, event): |
| """Send CAP REQ :sasl on connect.""" |
| self.connection.cap("REQ", "sasl") |
| |
| def _on_cap(self, conn, event): |
| """Handle CAP responses.""" |
| if event.arguments and event.arguments[0] == "ACK": |
| conn.send_raw("AUTHENTICATE PLAIN") |
| else: |
| print("Unexpected CAP response: %s", event) |
| conn.disconnect() |
| |
| def _on_authenticate(self, conn, event): |
| """Handle AUTHENTICATE responses.""" |
| if event.target == "+": |
| creds = "{username}\0{username}\0{password}".format( |
| username=self.config["client"]["nick"], password=self.password |
| ) |
| conn.send_raw("AUTHENTICATE {}".format(base64.b64encode(creds.encode("utf8")).decode("utf8"))) |
| else: |
| print("Unexpcted AUTHENTICATE response: %s", event) |
| conn.disconnect() |
| |
| def _on_903(self, conn, event): |
| """903: RPL_SASLSUCCESS""" |
| self.connection.cap("END") |
| |
| def _on_908(self, conn, event): |
| """908: RPL_SASLMECHS (PLAIN SASL not supported)""" |
| print("SASL PLAIN not supported: %s", event) |
| self.die() |
| |
| |
| class CommitbotClient(SASLMixin): |
| def __init__(self, config: dict): |
| super(CommitbotClient, self).__init__() |
| self.config = config |
| self.future = None |
| self.password = None |
| |
| def run(self): |
| self.password = open(self.config["client"]["password"]).read().strip() |
| self.connect( |
| self.config["server"]["host"], |
| self.config["server"]["port"], |
| self.config["client"]["nick"], |
| ircname=self.config["client"]["realname"], |
| ) |
| try: |
| self.start() |
| finally: |
| self.connection.disconnect() |
| self.reactor.loop.close() |
| |
| def on_welcome(self, connection, event): |
| for channel in self.config["channels"]: |
| self.connection.join(channel) |
| print(f"Joined {channel}") |
| |
| self.future = asyncio.ensure_future(self.pubsub_poll(), loop=connection.reactor.loop) |
| |
| def on_disconnect(self, connection, event): |
| if self.future: |
| self.future.cancel() |
| sys.exit(0) |
| |
| |
| async def pubsub_poll(self): |
| while True: |
| async for payload in asfpy.pubsub.listen(self.config["pubsub_host"]): |
| root, msg = format_message(payload) |
| if msg: |
| to_channels = [] |
| for channel, data in self.config["channels"].items(): |
| for tag in data.get("tags", []): |
| if fnmatch.fnmatch(root, tag): |
| to_channels.append(channel) |
| break |
| if to_channels: |
| self.connection.privmsg_many(to_channels, msg) |
| await asyncio.sleep(1) # Don't flood too quickly |
| self.connection.quit("Bye!") |
| |
| |
| def main(): |
| print("Starting CommitBot") |
| config = yaml.safe_load(open("config.yaml")) |
| bot = CommitbotClient(config) |
| bot.run() |
| |
| |
| if __name__ == "__main__": |
| main() |