blob: b81ebd5af1bb712a773f52b54f76a2c1957f5313 [file] [log] [blame]
#!/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()