#!/usr/bin/env python import git, os, sys, time, pyinotify, netrc 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('-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') (options, args) = parser.parse_args() if not options.host or not options.dir or not options.channel or not options.nick: 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 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[-1] msg = '\002%s%s\002 pushed to \002%s\002 by \002%s\002 (%s) %s' % ( reponame, ('/'+branch) if branch else '', commit.sha[: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.sha} 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 = '%s/refs/heads' % event.pathname i = 0 # Wait max 10 seconds for refs/heads/ to appear. while not os.access(heads, 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/ (repo NOT added).' % os.path.splitext(event.name)[0]) return wdd = self.bot.wm.add_watch(heads, flags['IN_MODIFY'] | flags['IN_CREATE']) repos[event.pathname] = [None, None, wdd] self.bot.gitmsg('New repo: %s' % os.path.splitext(event.name)[0]) def updated_repo(self, event): pathname = event.pathname while len(pathname) > 1 and not pathname in repos: pathname = os.path.dirname(pathname) if len(pathname) == 1 or not pathname in repos: return l = repos[pathname] if not l[0]: l[0] = git.Repo(pathname) repo = l[0] if not repo.heads: # No branches return if not l[1]: l[1] = dict([(h.name, h.commit.sha) for h in repo.heads]) for h in repo.heads: last = l[1][h.name] if h.name in l[1] else None if h.commit.sha != last: commits = list(repo.iter_commits('%s..%s' % (last, h.name))) if not len(commits): # No commits continue msg = repo_commit_msg(repo, h.name, commits) self.bot.gitmsg(msg) l[1][h.name] = h.commit.sha def process_IN_CREATE(self, event): if os.path.dirname(event.pathname) == root: self.new_repo(event) else: self.updated_repo(event) def process_IN_DELETE(self, event): if event.pathname in repos: self.bot.wm.rm_watch(repos[event.pathname][2].values()) del repos[event.pathname] self.bot.gitmsg('Removed repo: %s' % os.path.splitext(event.name)[0]) def process_IN_MODIFY(self, event): self.updated_repo(event) def check_notifies(bot): bot.notifier.process_events() while bot.notifier.check_events(): bot.notifier.read_events() bot.notifier.process_events() 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')): try: r = git.Repo('%s/%s' % (root, path)) except: continue flags = pyinotify.EventsCodes.ALL_FLAGS wdd = self.wm.add_watch('%s/%s/refs/heads' % (root, path), flags['IN_MODIFY'] | flags['IN_CREATE']) repos['%s/%s' % (root, path)] = [r, dict([(h.name, h.commit.sha) for h in r.heads]), wdd] def gitmsg(self, msg): self.say(options.channel, msg) 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) def get_repo(self, name, target): repo = [(k, v) for k, v in repos.iteritems() if os.path.basename(k).startswith(name)] if len(repo) == 1: path = repo[0][0] repo = repo[0][1] r = repo[0] or git.Repo(path) return r elif len(repo) == 0: self.msg(target, 'No repo found.') else: self.msg(target, 'Ambiguous name: %s' % (', '.join([os.path.basename(x[0]) for x in repo]))) return None def get_commits(self, repo, name): try: if '..' in name: frm, to = name.split('..') commits = list(repo.iter_commits('%s..%s' % (frm, to))) else: commits = [repo.commit(name)] except ValueError: return [] except git.errors.GitCommandError: return [] return commits def get_branch(self, repo, branch, target): heads = sorted((h for h in repo.heads if h.name.startswith(branch)), cmp = lambda a, b: cmp(a.name, b.name)) if not len(heads): self.msg(target, 'No branches matches "%s".' % branch) if heads[0].name == branch: return heads[0] heads = [h for h in repo.heads if h.name.startswith(branch)] if not len(heads): self.msg(target, 'No branches matches "%s".' % branch) elif len(heads) > 1: self.msg(target, 'Ambiguous name: %s' % (', '.join([h.name for h in heads]))) else: return heads[0] 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([os.path.splitext(os.path.basename(x))[0] for x in repos.keys()]) self.msg(target, s) elif cmd == 'last': if not len(messagelist) >= 3: self.msg(target, 'Usage: %s last REPO [BRANCH]' % self.nickname) return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return branch = self.get_branch(repo, messagelist[3] if len(messagelist) == 4 else 'master', target) if not branch: return msg = repo_commit_msg(repo, branch.name, [branch.commit]) if repo.heads else ('No commits found for %s' % os.path.splitext(os.path.basename(repo.working_dir))[0]) self.msg(target, msg) elif cmd == 'show': if not len(messagelist) == 4: self.msg(target, 'Usage: %s show REPO COMMIT' % self.nickname) return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return commits = self.get_commits(repo, messagelist[3]) if not commits: self.msg(target, 'No commits found.') return msg = repo_commit_msg(repo, None, commits) self.msg(target, msg) elif cmd == 'branches': if not len(messagelist) == 3: self.msg(target, 'Usage: %s branches REPO') return repo = self.get_repo(messagelist[2].lower(), target) if not repo: return reponame = os.path.splitext(os.path.basename(repo.working_dir))[0] self.msg(target, '\002%s\002 has branches %s' % (reponame, ', '.join(['\002%s\002' % h.name for h in repo.heads]))) class BotFactory(protocol.ReconnectingClientFactory): protocol = Bot if __name__ == '__main__': f = BotFactory() reactor.connectTCP(options.host, 6667, f) reactor.run()