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("&", "&amp;")
156    text = text.replace("<", "&lt;")
157    text = text.replace(">", "&gt;")
158    if is_attrib:
159        text = text.replace("'", "&apos;")
160        text = text.replace("\"", "&quot;")
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