1#!/usr/bin/env python 2# 3# Copyright (C) Nathaniel Smith <njs@pobox.com> 4# Licensed under the MIT license: 5# http://www.opensource.org/licenses/mit-license.html 6# I.e., do what you like, but keep copyright and there's NO WARRANTY. 7# 8# CIA bot client script for Monotone repositories, written in python. This 9# generates commit messages using CIA's XML commit format, and can deliver 10# them using either XML-RPC or email. Based on the script 'ciabot_svn.py' by 11# Micah Dowty <micah@navi.cx>. 12 13# This script is normally run from a cron job. It periodically does a 'pull' 14# from a given server, finds new revisions, filters them for "interesting" 15# ones, and reports them to CIA. 16 17# It needs a working directory, where it will store the database and some 18# state of its own. 19 20# To use: 21# -- make a copy of it somewhere 22# -- edit the configuration values below 23# -- set up a cron job to run every ten minutes (or whatever), running the 24# command "ciabot_monotone.py <path to scratch dir>". The scratch dir is 25# used to store state between runs. It will be automatically created, 26# but do not delete it. 27 28class config: 29 def project_for_branch(self, branchname): 30 # Customize this to return your project name(s). If changes to the 31 # given branch are uninteresting -- i.e., changes to them should be 32 # ignored entirely -- then return the python constant None (which is 33 # distinct from the string "None", a valid but poor project name!). 34 #if branchname.startswith("net.venge.monotone-viz"): 35 # return "monotone-viz" 36 #elif branchname.startswith("net.venge.monotone.contrib.monotree"): 37 # return "monotree" 38 #else: 39 # return "monotone" 40 return "FIXME" 41 42 # Add entries of the form ("server address", "pattern") to get 43 # this script to watch the given collections at the given monotone 44 # servers. 45 watch_list = [ 46 #("monotone.ca", "net.venge.monotone"), 47 ] 48 49 # If this is non-None, then the web interface will make any file 'foo' a 50 # link to 'repository_uri/foo'. 51 repository_uri = None 52 53 # The server to deliver XML-RPC messages to, if using XML-RPC delivery. 54 xmlrpc_server = "http://cia.navi.cx" 55 56 # The email address to deliver messages to, if using email delivery. 57 smtp_address = "cia@cia.navi.cx" 58 59 # The SMTP server to connect to, if using email delivery. 60 smtp_server = "localhost" 61 62 # The 'from' address to put on email, if using email delivery. 63 from_address = "cia-user@FIXME" 64 65 # Set to one of "xmlrpc", "email", "debug". 66 delivery = "debug" 67 68 # Path to monotone executable. 69 monotone_exec = "monotone" 70 71################################################################################ 72 73import sys, os, os.path 74 75class Monotone: 76 def __init__(self, bin, db): 77 self.bin = bin 78 self.db = db 79 80 def _run_monotone(self, args): 81 args_str = " ".join(args) 82 # Yay lack of quoting 83 fd = os.popen("%s --db=%s --quiet %s" % (self.bin, self.db, args_str)) 84 output = fd.read() 85 if fd.close(): 86 sys.exit("monotone exited with error") 87 return output 88 89 def _split_revs(self, output): 90 if output: 91 return output.strip().split("\n") 92 else: 93 return [] 94 95 def get_interface_version(self): 96 iv_str = self._run_monotone(["automate", "interface_version"]) 97 return tuple(map(int, iv_str.strip().split("."))) 98 99 def db_init(self): 100 self._run_monotone(["db", "init"]) 101 102 def db_migrate(self): 103 self._run_monotone(["db", "migrate"]) 104 105 def ensure_db_exists(self): 106 if not os.path.exists(self.db): 107 self.db_init() 108 109 def pull(self, server, collection): 110 self._run_monotone(["pull", server, collection]) 111 112 def leaves(self): 113 return self._split_revs(self._run_monotone(["automate", "leaves"])) 114 115 def ancestry_difference(self, new_rev, old_revs): 116 args = ["automate", "ancestry_difference", new_rev] + old_revs 117 return self._split_revs(self._run_monotone(args)) 118 119 def log(self, rev, xlast=None): 120 if xlast is not None: 121 last_arg = ["--last=%i" % (xlast,)] 122 else: 123 last_arg = [] 124 return self._run_monotone(["log", "-r", rev] + last_arg) 125 126 def toposort(self, revs): 127 args = ["automate", "toposort"] + revs 128 return self._split_revs(self._run_monotone(args)) 129 130 def get_revision(self, rid): 131 return self._run_monotone(["automate", "get_revision", rid]) 132 133class LeafFile: 134 def __init__(self, path): 135 self.path = path 136 137 def get_leaves(self): 138 if os.path.exists(self.path): 139 f = open(self.path, "r") 140 lines = [] 141 for line in f: 142 lines.append(line.strip()) 143 return lines 144 else: 145 return [] 146 147 def set_leaves(self, leaves): 148 f = open(self.path + ".new", "w") 149 for leaf in leaves: 150 f.write(leaf + "\n") 151 f.close() 152 os.rename(self.path + ".new", self.path) 153 154def escape_for_xml(text, is_attrib=0): 155 text = text.replace("&", "&") 156 text = text.replace("<", "<") 157 text = text.replace(">", ">") 158 if is_attrib: 159 text = text.replace("'", "'") 160 text = text.replace("\"", """) 161 return text 162 163def send_message(message, c): 164 if c.delivery == "debug": 165 print message 166 elif c.delivery == "xmlrpc": 167 import xmlrpclib 168 xmlrpclib.ServerProxy(c.xmlrpc_server).hub.deliver(message) 169 elif c.delivery == "email": 170 import smtplib 171 smtp = smtplib.SMTP(c.smtp_server) 172 smtp.sendmail(c.from_address, c.smtp_address, 173 "From: %s\r\nTo: %s\r\n" 174 "Subject: DeliverXML\r\n\r\n%s" 175 % (c.from_address, c.smtp_address, message)) 176 else: 177 sys.exit("delivery option must be one of 'debug', 'xmlrpc', 'email'") 178 179def send_change_for(rid, m, c): 180 message_tmpl = """<message> 181 <generator> 182 <name>Monotone CIA Bot client python script</name> 183 <version>0.1</version> 184 </generator> 185 <source> 186 <project>%(project)s</project> 187 <branch>%(branch)s</branch> 188 </source> 189 <body> 190 <commit> 191 <revision>%(rid)s</revision> 192 <author>%(author)s</author> 193 <files>%(files)s</files> 194 <log>%(log)s</log> 195 </commit> 196 </body> 197</message>""" 198 199 substs = {} 200 201 log = m.log(rid, 1) 202 rev = m.get_revision(rid) 203 # Stupid way to pull out everything inside quotes (which currently 204 # uniquely identifies filenames inside a changeset). 205 pieces = rev.split('"') 206 files = [] 207 for i in range(len(pieces)): 208 if (i % 2) == 1: 209 if pieces[i] not in files: 210 files.append(pieces[i]) 211 substs["files"] = "\n".join(["<file>%s</file>" % escape_for_xml(f) for f in files]) 212 branch = None 213 author = None 214 changelog_pieces = [] 215 started_changelog = 0 216 pieces = log.split("\n") 217 for p in pieces: 218 if p.startswith("Author:"): 219 author = p.split(None, 1)[1].strip() 220 if p.startswith("Branch:"): 221 branch = p.split()[1] 222 if p.startswith("ChangeLog:"): 223 started_changelog = 1 224 elif started_changelog: 225 changelog_pieces.append(p) 226 changelog = "\n".join(changelog_pieces).strip() 227 if branch is None: 228 return 229 project = c.project_for_branch(branch) 230 if project is None: 231 return 232 substs["author"] = escape_for_xml(author or "(unknown author)") 233 substs["project"] = escape_for_xml(project) 234 substs["branch"] = escape_for_xml(branch) 235 substs["rid"] = escape_for_xml(rid) 236 substs["log"] = escape_for_xml(changelog) 237 238 message = message_tmpl % substs 239 send_message(message, c) 240 241def send_changes_between(old_leaves, new_leaves, m, c): 242 if not old_leaves: 243 # Special case for initial setup -- don't push thousands of old 244 # revisions down CIA's throat! 245 return 246 new_revs = {} 247 for leaf in new_leaves: 248 if leaf in old_leaves: 249 continue 250 for new_rev in m.ancestry_difference(leaf, old_leaves): 251 new_revs[new_rev] = None 252 new_revs_sorted = m.toposort(new_revs.keys()) 253 for new_rev in new_revs_sorted: 254 send_change_for(new_rev, m, c) 255 256def main(progname, args): 257 if len(args) != 1: 258 sys.exit("Usage: %s STATE-DIR" % (progname,)) 259 (state_dir,) = args 260 if not os.path.isdir(state_dir): 261 os.makedirs(state_dir) 262 lockfile = os.path.join(state_dir, "lock") 263 # Small race condition, oh well. 264 if os.path.exists(lockfile): 265 sys.exit("script already running, exiting") 266 try: 267 open(lockfile, "w").close() 268 c = config() 269 m = Monotone(c.monotone_exec, os.path.join(state_dir, "database.db")) 270 m.ensure_db_exists() 271 m.db_migrate() 272 for server, collection in c.watch_list: 273 m.pull(server, collection) 274 lf = LeafFile(os.path.join(state_dir, "leaves")) 275 new_leaves = m.leaves() 276 send_changes_between(lf.get_leaves(), new_leaves, m, c) 277 lf.set_leaves(new_leaves) 278 finally: 279 os.unlink(lockfile) 280 281if __name__ == "__main__": 282 main(sys.argv[0], sys.argv[1:]) 283