| 1 | #!/usr/bin/env python |
| 2 | ''' |
| 3 | Push feed and vcs activities to an IRC channel. Configured with the ".slander" rc file, or another yaml file specified on the cmd line. |
| 4 | |
| 5 | CREDITS |
| 6 | Miki Tebeka, http://pythonwise.blogspot.com/2009/05/subversion-irc-bot.html |
| 7 | Eloff, http://stackoverflow.com/a/925630 |
| 8 | rewritten by Adam Wight, |
| 9 | project homepage is https://github.com/adamwight/slander |
| 10 | |
| 11 | EXAMPLE |
| 12 | This is the configuration file used for the CiviCRM project: |
| 13 | jobs: |
| 14 | svn: |
| 15 | changeset_url_format: |
| 16 | https://fisheye2.atlassian.com/changelog/CiviCRM?cs=%s |
| 17 | root: http://svn.civicrm.org/civicrm |
| 18 | args: --username SVN_USER --password SVN_PASSS |
| 19 | jira: |
| 20 | base_url: |
| 21 | http://issues.civicrm.org/jira |
| 22 | source: |
| 23 | http://issues.civicrm.org/jira/activity?maxResults=20&streams=key+IS+CRM&title=undefined |
| 24 | |
| 25 | irc: |
| 26 | host: irc.freenode.net |
| 27 | port: 6667 |
| 28 | nick: civi-activity |
| 29 | realname: CiviCRM svn and jira notification bot |
| 30 | channel: "#civicrm" #note that quotes are necessary here |
| 31 | maxlen: 200 |
| 32 | |
| 33 | poll_interval: 60 |
| 34 | |
| 35 | sourceURL: https://svn.civicrm.org/tools/trunk/bin/scripts/ircbot-civi.py |
| 36 | ''' |
| 37 | |
| 38 | import sys |
| 39 | import os |
| 40 | import re |
| 41 | |
| 42 | import yaml |
| 43 | |
| 44 | from twisted.words.protocols import irc |
| 45 | from twisted.internet.protocol import ReconnectingClientFactory |
| 46 | from twisted.internet import reactor |
| 47 | from twisted.internet.task import LoopingCall |
| 48 | |
| 49 | from xml.etree.cElementTree import parse as xmlparse |
| 50 | from cStringIO import StringIO |
| 51 | from subprocess import Popen, PIPE |
| 52 | |
| 53 | import feedparser |
| 54 | from HTMLParser import HTMLParser |
| 55 | |
| 56 | |
| 57 | class RelayToIRC(irc.IRCClient): |
| 58 | def connectionMade(self): |
| 59 | self.config = self.factory.config |
| 60 | self.jobs = create_jobs(self.config["jobs"]) |
| 61 | self.nickname = self.config["irc"]["nick"] |
| 62 | self.realname = self.config["irc"]["realname"] |
| 63 | self.channel = self.config["irc"]["channel"] |
| 64 | self.sourceURL = "https://github.com/adamwight/slander" |
| 65 | if "sourceURL" in self.config: |
| 66 | self.sourceURL = self.config["sourceURL"] |
| 67 | |
| 68 | irc.IRCClient.connectionMade(self) |
| 69 | |
| 70 | def signedOn(self): |
| 71 | self.join(self.channel) |
| 72 | |
| 73 | def joined(self, channel): |
| 74 | print "Joined channel %s as %s" % (channel, self.nickname) |
| 75 | task = LoopingCall(self.check) |
| 76 | task.start(self.config["poll_interval"]) |
| 77 | print "Started polling jobs, every %d seconds." % (self.config["poll_interval"], ) |
| 78 | |
| 79 | def privmsg(self, user, channel, message): |
| 80 | if message.find(self.nickname) >= 0: |
| 81 | # TODO surely there are useful ways to interact? |
| 82 | if re.search(r'\bhelp\b', message): |
| 83 | self.say(self.channel, "If I only had a brain: %s" % (self.sourceURL, )) |
| 84 | else: |
| 85 | print "Failed to handle incoming command: %s said %s" % (user, message) |
| 86 | |
| 87 | def check(self): |
| 88 | for job in self.jobs: |
| 89 | for line in job.check(): |
| 90 | self.say(self.channel, str(line)) |
| 91 | print(line) |
| 92 | |
| 93 | @staticmethod |
| 94 | def run(config): |
| 95 | factory = ReconnectingClientFactory() |
| 96 | factory.protocol = RelayToIRC |
| 97 | factory.config = config |
| 98 | reactor.connectTCP(config["irc"]["host"], config["irc"]["port"], factory) |
| 99 | reactor.run() |
| 100 | |
| 101 | class SvnPoller(object): |
| 102 | def __init__(self, root=None, args=None, changeset_url_format=None): |
| 103 | self.pre = ["svn", "--xml"] + args.split() |
| 104 | self.root = root |
| 105 | self.changeset_url_format = changeset_url_format |
| 106 | print "Initializing SVN poller: %s" % (" ".join(self.pre)+" "+root, ) |
| 107 | |
| 108 | def svn(self, *cmd): |
| 109 | pipe = Popen(self.pre + list(cmd) + [self.root], stdout=PIPE) |
| 110 | try: |
| 111 | data = pipe.communicate()[0] |
| 112 | except IOError: |
| 113 | data = "" |
| 114 | return xmlparse(StringIO(data)) |
| 115 | |
| 116 | def revision(self): |
| 117 | tree = self.svn("info") |
| 118 | revision = tree.find(".//commit").get("revision") |
| 119 | return int(revision) |
| 120 | |
| 121 | def revision_info(self, revision): |
| 122 | revision = str(revision) |
| 123 | tree = self.svn("log", "-r", revision) |
| 124 | author = tree.find(".//author").text |
| 125 | comment = truncate(strip(tree.find(".//msg").text), self.config["irc"]["maxlen"]) |
| 126 | url = self.changeset_url(revision) |
| 127 | |
| 128 | return (revision, author, comment, url) |
| 129 | |
| 130 | def changeset_url(self, revision): |
| 131 | return self.changeset_url_format % (revision, ) |
| 132 | |
| 133 | previous_revision = None |
| 134 | def check(self): |
| 135 | try: |
| 136 | latest = self.revision() |
| 137 | if self.previous_revision and latest != self.previous_revision: |
| 138 | for rev in range(self.previous_revision + 1, latest + 1): |
| 139 | yield "r%s by %s: %s [%s]" % self.revision_info(rev) |
| 140 | self.previous_revision = latest |
| 141 | except Exception, e: |
| 142 | print "ERROR: %s" % e |
| 143 | |
| 144 | |
| 145 | class FeedPoller(object): |
| 146 | last_seen_id = None |
| 147 | |
| 148 | def __init__(self, **config): |
| 149 | print "Initializing feed poller: %s" % (config["source"], ) |
| 150 | self.config = config |
| 151 | |
| 152 | def check(self): |
| 153 | result = feedparser.parse(self.config["source"]) |
| 154 | for entry in result.entries: |
| 155 | if (not self.last_seen_id) or (self.last_seen_id == entry.id): |
| 156 | break |
| 157 | yield self.parse(entry) |
| 158 | |
| 159 | if result.entries: |
| 160 | self.last_seen_id = result.entries[0].id |
| 161 | |
| 162 | |
| 163 | class JiraPoller(FeedPoller): |
| 164 | def parse(self, entry): |
| 165 | m = re.search(r'(CRM-[0-9]+)$', entry.link) |
| 166 | if (not m) or (entry.generator_detail.href != self.config["base_url"]): |
| 167 | return |
| 168 | issue = m.group(1) |
| 169 | summary = truncate(strip(entry.summary), self.config["irc"]["maxlen"]) |
| 170 | url = self.config["base_url"]+"/browse/%s" % (issue, ) |
| 171 | |
| 172 | return "%s: %s %s [%s]" % (entry.author_detail.name, issue, summary, url) |
| 173 | |
| 174 | class MinglePoller(FeedPoller): |
| 175 | def parse(self, entry): |
| 176 | m = re.search(r'^(.*/([0-9]+))', entry.id) |
| 177 | url = m.group(1) |
| 178 | issue = int(m.group(2)) |
| 179 | summary = truncate(strip(entry.summary), self.config["irc"]["maxlen"]) |
| 180 | author = abbrevs(entry.author_detail.name) |
| 181 | |
| 182 | return "#%d: (%s) %s [%s]" % (issue, author, summary, url) |
| 183 | |
| 184 | def strip(text, html=True, space=True): |
| 185 | class MLStripper(HTMLParser): |
| 186 | def __init__(self): |
| 187 | self.reset() |
| 188 | self.fed = [] |
| 189 | def handle_data(self, d): |
| 190 | self.fed.append(d) |
| 191 | def get_data(self): |
| 192 | return ''.join(self.fed) |
| 193 | |
| 194 | if html: |
| 195 | stripper = MLStripper() |
| 196 | stripper.feed(text) |
| 197 | text = stripper.get_data() |
| 198 | if space: |
| 199 | text = text.strip().replace("\n", " ") |
| 200 | return text |
| 201 | |
| 202 | def abbrevs(name): |
| 203 | return "".join([w[:1] for w in name.split()]) |
| 204 | |
| 205 | def truncate(message, length): |
| 206 | if len(message) > length: |
| 207 | return (message[:(length-3)] + "...") |
| 208 | else: |
| 209 | return message |
| 210 | |
| 211 | |
| 212 | def create_jobs(d): |
| 213 | for type, options in d.items(): |
| 214 | classname = type.capitalize() + "Poller" |
| 215 | klass = globals()[classname] |
| 216 | yield klass(**options) |
| 217 | |
| 218 | if __name__ == "__main__": |
| 219 | if len(sys.argv) == 2: |
| 220 | dotfile = sys.argv[1] |
| 221 | else: |
| 222 | dotfile = os.path.expanduser("~/.slander") |
| 223 | print "Reading config from %s" % (dotfile, ) |
| 224 | config = yaml.load(file(dotfile)) |
| 225 | RelayToIRC.run(config) |