#!/usr/bin/env python2 # gitnotipy - An IRC bot which announces changes to a collection of git repositories # Copyright (C) 2009-2010 Jon Bergli Heier # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . import git, os, sys, time, pyinotify, netrc, SocketServer from optparse import OptionParser from twisted.words.protocols import irc from twisted.internet import reactor, protocol from twisted.internet.task import LoopingCall have_bitly = False try: import bitly have_bitly = True except: pass parser = OptionParser() parser.add_option('-s', '--host') parser.add_option('-p', '--port', type = 'int', default = 6667) parser.add_option('-n', '--nick', default = 'git') parser.add_option('-d', '--dir') parser.add_option('-c', '--channel') parser.add_option('-i', '--interval', type = 'int', default = 10) parser.add_option('-u', '--url') parser.add_option('-l', '--socket') (options, args) = parser.parse_args() if not options.host or not options.dir or not options.channel or not options.nick or not options.socket: parser.print_help() sys.exit(1) if have_bitly and not netrc.netrc().authenticators('bitly'): have_bitly = False root = options.dir if root[-1] == '/': root = root[:-1] repos = None class GitnotipyAmbiguousException(Exception): pass class NotifyRepo(object): def __init__(self, bot, path): self.path = path self.bot = bot self.repo = git.Repo(path) self.heads = dict([(h.name, h.commit.hexsha) for h in self.repo.heads]) self.tags = set([t.name for t in self.repo.tags]) def updated(self): reponame = os.path.splitext(os.path.basename(self.path))[0] oldheads = set(self.heads.keys()) newheads = set([h.name for h in self.repo.heads]) removed_heads = oldheads.difference(newheads) for h in removed_heads: del self.heads[h] self.bot.gitmsg('Branch \002%s\002 from repo \002%s\002 was deleted.' % (h, reponame)) if not self.repo.heads: # No branches return for h in self.repo.heads: last = self.heads[h.name] if h.name in self.heads else None if h.commit.hexsha != last: if last: # Check against last commit commits = list(self.repo.iter_commits('%s..%s' % (last, h.name))) elif len(h.commit.parents): # Check against parent commit commits = list(self.repo.iter_commits('%s..%s' % (h.commit.parents[0], h.name))) else: # Initial commit or orphan branch was pushed commits = list(self.repo.iter_commits(h.name)) if not len(commits): # No commits continue msg = repo_commit_msg(self.repo, h.name, commits) self.bot.gitmsg(msg) self.heads[h.name] = h.commit.hexsha newtags = set([t.name for t in self.repo.tags]) removed_tags = self.tags.difference(newtags) for t in removed_tags: self.tags.remove(t) self.bot.gitmsg('Tag \002%s\002 from repo \002%s\002 was deleted.' % (t, reponame)) for t in (t for t in self.repo.tags if t.name not in self.tags): self.bot.gitmsg('New tag \002%s\002 for repo \002%s\002 points to \002%s\002' % (t.name, reponame, t.commit.hexsha[:7])) self.tags.add(t.name) def repo_commit_msg(repo, branch, commits): reponame = os.path.splitext(os.path.basename(repo.working_dir))[0] deletions = insertions = 0 files = [] for c in commits: deletions += c.stats.total['deletions'] insertions += c.stats.total['insertions'] files.extend((f for f in c.stats.files.iterkeys() if not f in files)) stat = '%d files,' % len(files) if len(commits) > 1: stat = '%d commits, %s' % (len(commits), stat) stat += ' \00305--%d\017' % deletions stat += ' \00303++%d\017' % insertions commit = commits[0] msg = '\002%s%s\002 pushed to \002%s\002 by \002%s\002 (%s) %s' % ( reponame, ('/'+branch) if branch else '', commit.hexsha[:7], commit.committer.name if commit.author.name == commit.committer.name else '%s/%s' % (commit.committer.name, commit.author.name), stat, commit.summary ) if options.url: msg += ' | ' url = options.url % {'repo': reponame, 'commit': commit.hexsha} if have_bitly: short_url = None try: bitly_auth = netrc.netrc().authenticators('bitly') bitly_api = bitly.Api(login = bitly_auth[0], apikey = bitly_auth[2]) short_url = bitly_api.shorten(url) except: short_url = None if short_url: url = short_url msg += url return msg class ReposNotifyEvent(pyinotify.ProcessEvent): def new_repo(self, event): flags = pyinotify.EventsCodes.ALL_FLAGS heads = os.path.join(event.pathname, 'refs/heads') tags = os.path.join(event.pathname, 'refs/tags') i = 0 # Wait max 10 seconds for refs/heads/ and refs/tags/ to appear. while not os.access(heads, os.F_OK) and not os.access(tags, os.F_OK) and i < 10: time.sleep(1) i += 1 if not os.access(heads, os.F_OK): self.bot.gitmsg('Repo %s was found but couldn''t locate refs/heads/ or refs/tags/ (repo NOT added).' % os.path.splitext(event.name)[0]) return repos[event.pathname] = NotifyRepo(self.bot, event.pathname) self.bot.gitmsg('New repo: %s' % os.path.splitext(event.name)[0]) def process_IN_CREATE(self, event): # Ignore directories outside of, and files in the root directory if os.path.dirname(event.pathname) == root and os.path.isdir(event.pathname): self.new_repo(event) def process_IN_DELETE(self, event): if event.pathname in repos: del repos[event.pathname] self.bot.gitmsg('Removed repo: %s' % os.path.splitext(event.name)[0]) def check_notifies(bot): bot.notifier.process_events() while bot.notifier.check_events(): bot.notifier.read_events() bot.notifier.process_events() # holds the current active Bot instance bot = None class Bot(irc.IRCClient): nickname = options.nick def initial_add(self): global repos if not repos: repos = {} for path in (x for x in os.listdir(root) if x.endswith('.git')): fullpath = os.path.join(root, path) repos[fullpath] = NotifyRepo(self, fullpath) def gitmsg(self, msg): self.msg(options.channel, msg.encode('utf-8')) def signedOn(self): self.join(options.channel) self.wm = pyinotify.WatchManager() flags = pyinotify.EventsCodes.ALL_FLAGS self.wm.add_watch(root, flags['IN_DELETE'] | flags['IN_CREATE'] | flags['IN_ONLYDIR']) self.rne = ReposNotifyEvent() self.rne.bot = self self.notifier = pyinotify.Notifier(self.wm, self.rne, timeout = options.interval) self.initial_add() self.repeater = LoopingCall(check_notifies, self) self.repeater.start(options.interval) global bot bot = self def connectionLost(self, reason): global bot bot = None irc.IRCClient.connectionLost(self, reason) def get_repo(self, name, target): repo = [v for k, v in repos.iteritems() if os.path.basename(k).startswith(name)] if len(repo) == 1: return repo[0].repo elif len(repo) == 0: self.msg(target, 'No repo found.') else: s = [r for r in repo if os.path.splitext(os.path.basename(r.path))[0] == name] # Check for equal match if we're giving an ambiguous name if len(s) == 1: return s[0].repo self.msg(target, 'Ambiguous name: %s' % (', '.join([os.path.splitext(os.path.basename(x.path))[0] for x in repo]))) return None def get_commits(self, repo, name): branch = None try: if '..' in name: commits = list(repo.iter_commits(name)) else: try: commits = [repo.commit(name)] branches = [b.name for b in repo.heads if b.name == name] if len(branches) == 1: branch = branches[0] except git.exc.BadObject: commits = [] if not len(commits): branches = self.get_branch(repo, name) tags = self.get_tags(repo, name) if len(branches) and len(tags): raise GitnotipyAmbiguousException('Ambiguous name: %s (\002branches\002 %s tags \002%s\002' % ( name, ', '.join(sorted([x.name for x in branches])), ', '.join(sorted([x.name for x in tags])))) elif len(branches) > 1: raise GitnotipyAmbiguousException('Ambiguous name: %s' % (', '.join(sorted([x.name for x in branches])))) elif len(tags) > 1: raise GitnotipyAmbiguousException('Ambiguous name: %s' % (', '.join(sorted([x.name for x in tags])))) elif len(branches): commits, branch = [branches[0].commit], branches[0].name elif len(tags): commits = [tags[0].commit] except ValueError: return [], None except git.exc.BadObject: return [], None except git.exc.GitCommandError: return [], None return commits, branch def get_branch(self, repo, branch): return sorted((h for h in repo.heads if h.name.startswith(branch)), cmp = lambda a, b: cmp(a.name, b.name)) def get_tags(self, repo, tag): return sorted((t for t in repo.tags if t.name.startswith(tag)), cmp = lambda a, b: cmp(a.name, b.name)) def privmsg(self, user, channel, message): private = channel == self.nickname nick = user.split('!')[0] target = nick if private else channel messagelist = message.split() if len(messagelist) < 2: return if messagelist[0].startswith(self.nickname): cmd = messagelist[1].lower() if cmd == 'list': s = 'Repos: %s' % ', '.join(sorted([os.path.splitext(os.path.basename(x))[0] for x in repos.keys()])) self.msg(target, s) elif cmd == 'show': if len(messagelist) < 3: self.msg(target, 'Usage: %s show REPO [COMMIT|BRANCH|TAG|RANGE]' % self.nickname) return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return if len(messagelist) > 3: try: commits, branch = self.get_commits(repo, messagelist[3]) except GitnotipyAmbiguousException as e: self.msg(target, e.message) return else: commits, branch = self.get_commits(repo, 'master') if not commits: self.msg(target, 'No commits found.') return msg = repo_commit_msg(repo, branch, commits) self.msg(target, msg.encode('utf-8')) elif cmd == 'branches': if not len(messagelist) == 3: self.msg(target, 'Usage: %s branches REPO' % self.nickname) return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return reponame = os.path.splitext(os.path.basename(repo.working_dir))[0] msg = '\002%s\002 has branches %s' % (reponame, ', '.join(['\002%s\002' % h.name for h in repo.heads])) self.msg(target, msg.encode('utf-8')) elif cmd == 'tags': if not len(messagelist) == 3: self.msg(target, 'Usage: %s tags REPO' % self.nickname) return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return reponame = os.path.splitext(os.path.basename(repo.working_dir))[0] if not len(repo.tags): msg = '\002%s\002 has no tags.' % reponame else: msg = '\002%s\002 has tags %s' % (reponame, ', '.join(['\002%s\002' % t.name for t in repo.tags])) self.msg(target, msg.encode('utf-8')) class BotFactory(protocol.ReconnectingClientFactory): protocol = Bot class UnixDatagramServer(protocol.DatagramProtocol): def datagramReceived(self, datagram, addr): name = datagram.strip() if not name in repos: return repo = repos[name] repo.updated() if __name__ == '__main__': f = BotFactory() reactor.connectTCP(options.host, options.port, f) if os.access(options.socket, os.F_OK): os.unlink(options.socket) reactor.listenUNIXDatagram(options.socket, UnixDatagramServer()) reactor.run()