Commit | Line | Data |
---|---|---|
6a488035 TO |
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) |