Ticket #12486: 12486-patchbot-scripts-5.0-updated.patch

File 12486-patchbot-scripts-5.0-updated.patch, 82.7 KB (added by kini, 9 years ago)

apply to $SAGE_LOCAL/bin

  • new file patchbot/README.txt

    # HG changeset patch
    # User Robert Bradshaw <robertwb@math.washington.edu>
    # Date 1328866496 28800
    # Node ID 96d095486210341c4dc6fbba7146e48076b25c52
    # Parent  8bff3bdfa50e2c792fb0de0d64316597861f7fdf
    Add patchbot to Sage. Apply to scripts repo.
    
    diff --git a/patchbot/README.txt b/patchbot/README.txt
    new file mode 100644
    - +  
     1The patchbot only needs a Sage install and is started with
     2
     3    python patchbot.py [options]
     4
     5Type --help for a list of options, though most configuration is done via an
     6optional JSON config file. This is what is invoked by sage --patchbot [...]
     7
     8The server needs a Python with Flask and mongod installed.  Installing numpy
     9and PIL will allow multi-colored blurbs.  Start a monitoring loop with
     10
     11    python run_server.py
     12
     13Currently, the server is set up to run on port 21100, communicating with
     14a mongod instance running on 21002.
  • new file patchbot/db.py

    diff --git a/patchbot/db.py b/patchbot/db.py
    new file mode 100644
    - +  
     1import os
     2
     3# mongod --port=21000 --dbpath=data
     4import pymongo, gridfs
     5from pymongo import Connection
     6mongo_port = 21002
     7
     8mongodb = Connection(port=mongo_port).buildbot
     9tickets = mongodb.tickets
     10tickets.ensure_index('id', unique=True)
     11tickets.ensure_index('status')
     12tickets.ensure_index('authors')
     13tickets.ensure_index('participants')
     14tickets.ensure_index('last_activity')
     15tickets.ensure_index('reports.base')
     16tickets.ensure_index('reports.machine')
     17tickets.ensure_index('reports.time')
     18
     19logs = gridfs.GridFS(mongodb, 'logs')
     20
     21def lookup_ticket(ticket_id):
     22    return tickets.find_one({'id': ticket_id})
     23
     24def save_ticket(ticket_data):
     25    old = lookup_ticket(ticket_data['id'])
     26    if old:
     27        old.update(ticket_data)
     28        ticket_data = old
     29    tickets.save(ticket_data)
  • new file patchbot/http_post_file.py

    diff --git a/patchbot/http_post_file.py b/patchbot/http_post_file.py
    new file mode 100644
    - +  
     1# http://code.activestate.com/recipes/146306-http-client-to-post-using-multipartform-data/
     2
     3import httplib, mimetypes, mimetools, urllib2
     4
     5def post_multipart(url, fields, files):
     6    """
     7    Post fields and files to an http host as multipart/form-data.
     8    fields is a sequence of (name, value) elements for regular form fields.
     9    files is a sequence of (name, filename, value) elements for data to be uploaded as files
     10    Return the server's response page.
     11    """
     12    content_type, body = encode_multipart_formdata(fields, files)
     13    headers = {'Content-Type': content_type,
     14               'Content-Length': str(len(body))}
     15    r = urllib2.Request(url, body, headers)
     16    return urllib2.urlopen(r).read()
     17
     18def encode_multipart_formdata(fields, files):
     19    """
     20    fields is a sequence of (name, value) elements for regular form fields.
     21    files is a sequence of (name, filename, value) elements for data to be uploaded as files
     22    Return (content_type, body) ready for httplib.HTTP instance
     23    """
     24    BOUNDARY = mimetools.choose_boundary()
     25    CRLF = '\r\n'
     26    L = []
     27    if isinstance(fields, dict):
     28        fields = fields.items()
     29    for (key, value) in fields:
     30        L.append('--' + BOUNDARY)
     31        L.append('Content-Disposition: form-data; name="%s"' % key)
     32        L.append('')
     33        L.append(value)
     34    for (key, filename, value) in files:
     35        L.append('--' + BOUNDARY)
     36        L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
     37        L.append('Content-Type: %s' % get_content_type(filename))
     38        L.append('')
     39        L.append(value)
     40    L.append('--' + BOUNDARY + '--')
     41    L.append('')
     42    body = CRLF.join(L)
     43    content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
     44    return content_type, body
     45
     46def get_content_type(filename):
     47    return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
  • new file patchbot/images/blue-blob.png

    diff --git a/patchbot/images/blue-blob.png b/patchbot/images/blue-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..aea190743765fae95f15fc364bc0cf01d226408e
    GIT binary patch
    literal 2654
    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
    M07*qoM6N<$f(gJ5ZU6uP
  • new file patchbot/images/green-blob.png

    diff --git a/patchbot/images/green-blob.png b/patchbot/images/green-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..b610b8f543da98af17d74d5d7616082aa0d0c946
    GIT binary patch
    literal 2617
    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
    b1?7JO^qZY>hVjBs00000NkvXXu0mjf2gVBz
  • new file patchbot/images/purple-blob.png

    diff --git a/patchbot/images/purple-blob.png b/patchbot/images/purple-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..ef2fac14668b358229709db09d381a526eb51b74
    GIT binary patch
    literal 2731
    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
  • new file patchbot/images/red-blob.png

    diff --git a/patchbot/images/red-blob.png b/patchbot/images/red-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..7acaa38454b2014f6f1a7f49788bc59aae6ce8c3
    GIT binary patch
    literal 2851
    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
    Be6Iii
  • new file patchbot/images/white-blob.png

    diff --git a/patchbot/images/white-blob.png b/patchbot/images/white-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..44032e2ce17c5aaaeb0b29b5b819acf67ad90031
    GIT binary patch
    literal 2045
    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
    bemne6xb|riB^Ycf00000NkvXXu0mjfWt7fB
  • new file patchbot/images/yellow-blob.png

    diff --git a/patchbot/images/yellow-blob.png b/patchbot/images/yellow-blob.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..d212160fa672cf5da151313e75f74b6a2d697d42
    GIT binary patch
    literal 3012
    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
    GLSTaGkmXYV
  • new file patchbot/patchbot.py

    diff --git a/patchbot/patchbot.py b/patchbot/patchbot.py
    new file mode 100755
    - +  
     1#!/usr/bin/env python
     2
     3####################################################################
     4#
     5# This is the main script for the patchbot. It pulls patches from
     6# trac, applies them, and publishes the results of the tests to a
     7# server running serve.py.  Configuration is primarily done via an
     8# optional conf.txt file passed in as a command line argument.
     9#
     10#          Author: Robert Bradshaw <robertwb@gmail.com>
     11#
     12#               Copyright 2010-11 (C) Google, Inc.
     13#
     14#  Distributed under the terms of the GNU General Public License (GPL)
     15#  as published by the Free Software Foundation; either version 2 of
     16#  the License, or (at your option) any later version.
     17#                  http://www.gnu.org/licenses/
     18####################################################################
     19
     20
     21import signal
     22import getpass, platform
     23import random, re, os, shutil, sys, subprocess, time, traceback
     24import bz2, urllib2, urllib, json
     25from optparse import OptionParser
     26
     27from http_post_file import post_multipart
     28
     29from trac import scrape, pull_from_trac
     30from util import now_str as datetime, parse_datetime, prune_pending, do_or_die, get_base, compare_version, current_reports
     31
     32def filter_on_authors(tickets, authors):
     33    if authors is not None:
     34        authors = set(authors)
     35    for ticket in tickets:
     36        if authors is None or set(ticket['authors']).issubset(authors):
     37            yield ticket
     38
     39def contains_any(key, values):
     40    clauses = [{'key': value} for value in values]
     41    return {'$or': clauses}
     42
     43def no_unicode(s):
     44    return s.encode('ascii', 'replace').replace(u'\ufffd', '?')
     45
     46def get_ticket(server, return_all=False, **conf):
     47    query = "raw&status=open&todo"
     48    if 'trusted_authors' in conf:
     49        query += "&authors=" + urllib.quote_plus(no_unicode(':'.join(conf['trusted_authors'])), safe=':')
     50    try:
     51        handle = urllib2.urlopen(server + "/ticket/?" + query)
     52        all = json.load(handle)
     53        handle.close()
     54    except:
     55        traceback.print_exc()
     56        return
     57    if 'trusted_authors' in conf:
     58        all = filter_on_authors(all, conf['trusted_authors'])
     59    all = filter(lambda x: x[0], ((rate_ticket(t, **conf), t) for t in all))
     60    all.sort()
     61    if return_all:
     62        return all
     63    if all:
     64        return all[-1]
     65
     66def lookup_ticket(server, id):
     67    url = server + "/ticket/?" + urllib.urlencode({'raw': True, 'query': json.dumps({'id': id})})
     68    res = json.load(urllib2.urlopen(url))
     69    if res:
     70        return res[0]
     71    else:
     72        return scrape(id)
     73
     74def compare_machines(a, b, machine_match=None):
     75    if isinstance(a, dict) or isinstance(b, dict):
     76        # old format, remove
     77        return (1,)
     78    else:
     79        if machine_match is not None:
     80            a = a[:machine_match]
     81            b = b[:machine_match]
     82        diff = [x != y for x, y in zip(a, b)]
     83        if len(a) != len(b):
     84            diff.append(1)
     85        return diff
     86
     87def rate_ticket(ticket, **conf):
     88    rating = 0
     89    if ticket['spkgs']:
     90        return # can't handle these yet
     91    elif not ticket['patches']:
     92        return # nothing to do
     93    for dep in ticket['depends_on']:
     94        if isinstance(dep, basestring) and '.' in dep:
     95            if compare_version(conf['base'], dep) < 0:
     96                # Depends on a newer version of Sage than we're running.
     97                return None
     98    for author in ticket['authors']:
     99        if author not in conf['trusted_authors']:
     100            return
     101        rating += conf['bonus'].get(author, 0)
     102    for participant in ticket['participants']:
     103        rating += conf['bonus'].get(participant, 0) # doubled for authors
     104    rating += len(ticket['participants'])
     105    # TODO: remove condition
     106    if 'component' in ticket:
     107        rating += conf['bonus'].get(ticket['component'], 0)
     108    rating += conf['bonus'].get(ticket['status'], 0)
     109    rating += conf['bonus'].get(ticket['priority'], 0)
     110    rating += conf['bonus'].get(str(ticket['id']), 0)
     111    redundancy = (100,)
     112    prune_pending(ticket)
     113    if not ticket.get('retry'):
     114        for reports in current_reports(ticket, base=conf['base']):
     115            redundancy = min(redundancy, compare_machines(reports['machine'], conf['machine'], conf['machine_match']))
     116    if not redundancy[-1]:
     117        return # already did this one
     118    return redundancy, rating, -int(ticket['id'])
     119
     120def report_ticket(server, ticket, status, base, machine, user, log, plugins=[]):
     121    print ticket['id'], status
     122    report = {
     123        'status': status,
     124        'patches': ticket['patches'],
     125        'deps': ticket['depends_on'],
     126        'spkgs': ticket['spkgs'],
     127        'base': base,
     128        'user': user,
     129        'machine': machine,
     130        'time': datetime(),
     131        'plugins': plugins,
     132    }
     133    fields = {'report': json.dumps(report)}
     134    if status != 'Pending':
     135        files = [('log', 'log', bz2.compress(open(log).read()))]
     136    else:
     137        files = []
     138    print post_multipart("%s/report/%s" % (server, ticket['id']), fields, files)
     139
     140class TimeOut(Exception):
     141    pass
     142
     143def alarm_handler(signum, frame):
     144    raise Alarm
     145
     146class Tee:
     147    def __init__(self, filepath, time=False, timeout=60*60*24):
     148        self.filepath = filepath
     149        self.time = time
     150        self.timeout = timeout
     151
     152    def __enter__(self):
     153        self._saved = os.dup(sys.stdout.fileno()), os.dup(sys.stderr.fileno())
     154        self.tee = subprocess.Popen(["tee", self.filepath], stdin=subprocess.PIPE)
     155        os.dup2(self.tee.stdin.fileno(), sys.stdout.fileno())
     156        os.dup2(self.tee.stdin.fileno(), sys.stderr.fileno())
     157        if self.time:
     158            print datetime()
     159            self.start_time = time.time()
     160
     161    def __exit__(self, exc_type, exc_val, exc_tb):
     162        if exc_type is not None:
     163            traceback.print_exc()
     164        if self.time:
     165            print datetime()
     166            print int(time.time() - self.start_time), "seconds"
     167        self.tee.stdin.close()
     168        time.sleep(1)
     169        os.dup2(self._saved[0], sys.stdout.fileno())
     170        os.dup2(self._saved[1], sys.stderr.fileno())
     171        time.sleep(1)
     172        try:
     173            signal.signal(signal.SIGALRM, alarm_handler)
     174            signal.alarm(self.timeout)
     175            self.tee.wait()
     176            signal.alarm(0)
     177        except TimeOut:
     178            traceback.print_exc()
     179            raise
     180        return False
     181
     182
     183class Timer:
     184    def __init__(self):
     185        self._starts = {}
     186        self._history = []
     187        self.start()
     188    def start(self, label=None):
     189        self._last_activity = self._starts[label] = time.time()
     190    def finish(self, label=None):
     191        try:
     192            elapsed = time.time() - self._starts[label]
     193        except KeyError:
     194            elapsed = time.time() - self._last_activity
     195        self._last_activity = time.time()
     196        self.print_time(label, elapsed)
     197        self._history.append((label, elapsed))
     198    def print_time(self, label, elapsed):
     199        print label, '--', int(elapsed), 'seconds'
     200    def print_all(self):
     201        for label, elapsed in self._history:
     202            self.print_time(label, elapsed)
     203
     204# The sage test scripts could really use some cleanup...
     205all_test_dirs = ["doc/common", "doc/en", "doc/fr", "sage"]
     206
     207status = {
     208    'started': 'ApplyFailed',
     209    'applied': 'BuildFailed',
     210    'built'  : 'TestsFailed',
     211    'tested' : 'TestsPassed',
     212    'failed_plugin' : 'PluginFailed',
     213}
     214
     215def plugin_boundary(name, end=False):
     216    if end:
     217        name = 'end ' + name
     218    return ' '.join(('='*10, name, '='*10))
     219
     220
     221def test_a_ticket(sage_root, server, ticket=None, nodocs=False):
     222    base = get_base(sage_root)
     223    if ticket is None:
     224        ticket = get_ticket(base=base, server=server, **conf)
     225    else:
     226        ticket = None, scrape(int(ticket))
     227    if not ticket:
     228        print "No more tickets."
     229        if random.random() < 0.01:
     230            cleanup(sage_root, server)
     231        time.sleep(conf['idle'])
     232        return
     233    rating, ticket = ticket
     234    print "\n" * 2
     235    print "=" * 30, ticket['id'], "=" * 30
     236    print ticket['title']
     237    print "score", rating
     238    print "\n" * 2
     239    log_dir = sage_root + "/logs"
     240    if not os.path.exists(log_dir):
     241        os.mkdir(log_dir)
     242    log = '%s/%s-log.txt' % (log_dir, ticket['id'])
     243    report_ticket(server, ticket, status='Pending', base=base, machine=conf['machine'], user=conf['user'], log=None)
     244    plugins_results = []
     245    try:
     246        with Tee(log, time=True, timeout=conf['timeout']):
     247            t = Timer()
     248            start_time = time.time()
     249
     250            state = 'started'
     251            os.environ['MAKE'] = "make -j%s" % conf['parallelism']
     252            os.environ['SAGE_ROOT'] = sage_root
     253            # TODO: Ensure that sage-main is pristine.
     254            pull_from_trac(sage_root, ticket['id'], force=True)
     255            t.finish("Apply")
     256            state = 'applied'
     257
     258            do_or_die('$SAGE_ROOT/sage -b %s' % ticket['id'])
     259            t.finish("Build")
     260            state = 'built'
     261
     262            working_dir = "%s/devel/sage-%s" % (sage_root, ticket['id'])
     263            # Only the ones on this ticket.
     264            patches = os.popen2('hg --cwd %s qapplied' % working_dir)[1].read().strip().split('\n')[-len(ticket['patches']):]
     265            kwds = {
     266                "original_dir": "%s/devel/sage-0" % sage_root,
     267                "patched_dir": working_dir,
     268                "patches": ["%s/devel/sage-%s/.hg/patches/%s" % (sage_root, ticket['id'], p) for p in patches if p],
     269            }
     270            for name, plugin in conf['plugins']:
     271                try:
     272                    print plugin_boundary(name)
     273                    plugin(ticket, **kwds)
     274                    passed = True
     275                except Exception:
     276                    traceback.print_exc()
     277                    passed = False
     278                finally:
     279                    t.finish(name)
     280                    print plugin_boundary(name, end=True)
     281                    plugins_results.append((name, passed))
     282
     283            test_dirs = ["$SAGE_ROOT/devel/sage-%s/%s" % (ticket['id'], dir) for dir in all_test_dirs]
     284            if conf['parallelism'] > 1:
     285                test_cmd = "-tp %s" % conf['parallelism']
     286            else:
     287                test_cmd = "-t"
     288            do_or_die("$SAGE_ROOT/sage %s -sagenb %s" % (test_cmd, ' '.join(test_dirs)))
     289            #do_or_die("$SAGE_ROOT/sage -t $SAGE_ROOT/devel/sage-%s/sage/rings/integer.pyx" % ticket['id'])
     290            #do_or_die('sage -testall')
     291            t.finish("Tests")
     292            state = 'tested'
     293
     294            if not all(passed for name, passed in plugins_results):
     295                state = 'failed_plugin'
     296
     297            print
     298            t.print_all()
     299    except Exception:
     300        traceback.print_exc()
     301
     302    for _ in range(5):
     303        try:
     304            print "Reporting", ticket['id'], status[state]
     305            report_ticket(server, ticket, status=status[state], base=base, machine=conf['machine'], user=conf['user'], log=log, plugins=plugins_results)
     306            print "Done reporting", ticket['id']
     307            break
     308        except urllib2.HTTPError:
     309            traceback.print_exc()
     310            time.sleep(conf['idle'])
     311    else:
     312        print "Error reporting", ticket['id']
     313    return status[state]
     314
     315def cleanup(sage_root, server):
     316    print "Looking up closed tickets."
     317    closed_list = urllib2.urlopen(server + "?status=closed").read()
     318    closed = set(m.groups()[0] for m in re.finditer(r"/ticket/(\d+)/", closed_list))
     319    for branch in os.listdir(os.path.join(sage_root, "devel")):
     320        if branch[:5] == "sage-":
     321            if branch[5:] in closed:
     322                to_delete = os.path.join(sage_root, "devel", branch)
     323                print "Deleting closed ticket:", to_delete
     324                shutil.rmtree(to_delete)
     325    print "Done cleaning up."
     326
     327def default_trusted_authors(server):
     328    handle = urllib2.urlopen(server + "/trusted/")
     329    try:
     330        return json.load(handle).keys()
     331    finally:
     332        handle.close()
     333
     334def machine_data():
     335    system, node, release, version, arch = os.uname()
     336    if system.lower() == "linux":
     337        dist_name, dist_version, dist_id = platform.linux_distribution()
     338        if dist_name:
     339            return [dist_name, dist_version, arch, release, node]
     340    return [system, arch, release, node]
     341
     342def parse_time_of_day(s):
     343    def parse_interval(ss):
     344        ss = ss.strip()
     345        if '-' in ss:
     346            start, end = ss.split('-')
     347            return float(start), float(end)
     348        else:
     349            return float(ss), float(ss) + 1
     350    return [parse_interval(ss) for ss in s.split(',')]
     351
     352def check_time_of_day(hours):
     353    from datetime import datetime
     354    now = datetime.now()
     355    hour = now.hour + now.minute / 60.
     356    for start, end in parse_time_of_day(hours):
     357        if start < end:
     358            if start <= hour <= end:
     359                return True
     360        elif hour <= end or start <= hour:
     361            return True
     362    return False
     363
     364def get_conf(path, server, **overrides):
     365    if path is None:
     366        unicode_conf = {}
     367    else:
     368        unicode_conf = json.load(open(path))
     369    # defaults
     370    conf = {
     371        "idle": 300,
     372        "time_of_day": "0-0", # midnight-midnight
     373        "parallelism": 3,
     374        "timeout": 3 * 60 * 60,
     375        "plugins": ["plugins.commit_messages",
     376                    "plugins.coverage",
     377                    "plugins.trailing_whitespace",
     378#                    "plugins.docbuild"
     379                    ],
     380        "bonus": {},
     381        "machine": machine_data(),
     382        "machine_match": 3,
     383        "user": getpass.getuser(),
     384    }
     385    default_bonus = {
     386        "needs_review": 1000,
     387        "positive_review": 500,
     388        "blocker": 100,
     389        "critical": 50,
     390    }
     391    for key, value in unicode_conf.items():
     392        conf[str(key)] = value
     393    for key, value in default_bonus.items():
     394        if key not in conf['bonus']:
     395            conf['bonus'][key] = value
     396    conf.update(overrides)
     397    if "trusted_authors" not in conf:
     398        conf["trusted_authors"] = default_trusted_authors(server)
     399
     400    def locate_plugin(name):
     401        ix = name.rindex('.')
     402        module = name[:ix]
     403        name = name[ix+1:]
     404        plugin = getattr(__import__(module, fromlist=[name]), name)
     405        assert callable(plugin)
     406        return plugin
     407    conf["plugins"] = [(name, locate_plugin(name)) for name in conf["plugins"]]
     408    return conf
     409
     410def main(args):
     411    global conf
     412
     413    # Most configuration is done in the config file, which is reread between
     414    # each ticket for live configuration of the patchbot.
     415    parser = OptionParser()
     416    parser.add_option("--config", dest="config")
     417    parser.add_option("--sage-root", dest="sage_root", default=os.environ.get('SAGE_ROOT'))
     418    parser.add_option("--server", dest="server", default="http://patchbot.sagemath.org/")
     419    parser.add_option("--count", dest="count", default=1000000)
     420    parser.add_option("--ticket", dest="ticket", default=None)
     421    parser.add_option("--list", dest="list", default=False)
     422    parser.add_option("--skip-base", dest="skip_base", default=False)
     423    (options, args) = parser.parse_args(args)
     424
     425    conf_path = options.config and os.path.abspath(options.config)
     426    if options.ticket:
     427        tickets = [int(t) for t in options.ticket.split(',')]
     428        count = len(tickets)
     429    else:
     430        tickets = None
     431        count = int(options.count)
     432
     433    conf = get_conf(conf_path, options.server)
     434    if options.list:
     435        for score, ticket in get_ticket(base=get_base(options.sage_root), server=options.server, return_all=True, **conf):
     436            print score, ticket['id'], ticket['title']
     437            print ticket
     438            print
     439        sys.exit(0)
     440
     441    print "WARNING: Assuming sage-main is pristine."
     442    if options.sage_root == os.environ.get('SAGE_ROOT'):
     443        print "WARNING: Do not use this copy of sage while the patchbot is running."
     444
     445    if not options.skip_base:
     446        clean = lookup_ticket(options.server, 0)
     447        def good(report):
     448            return report['machine'] == conf['machine'] and report['status'] == 'TestsPassed'
     449        if not any(good(report) for report in current_reports(clean, base=get_base(options.sage_root))):
     450            res = test_a_ticket(ticket=0, sage_root=options.sage_root, server=options.server)
     451            if res != 'TestsPassed':
     452                print "\n\n"
     453                while True:
     454                    print "Failing tests in your install: %s. Continue anyways? [y/N] " % res
     455                    ans = sys.stdin.readline().lower().strip()
     456                    if ans == '' or ans[0] == 'n':
     457                        sys.exit(1)
     458                    elif ans[0] == 'y':
     459                        break
     460
     461    for _ in range(count):
     462        try:
     463            if tickets:
     464                ticket = tickets.pop(0)
     465            else:
     466                ticket = None
     467            conf = get_conf(conf_path, options.server)
     468            if check_time_of_day(conf['time_of_day']):
     469                test_a_ticket(ticket=ticket, sage_root=options.sage_root, server=options.server)
     470            else:
     471                print "Idle."
     472                time.sleep(conf['idle'])
     473        except urllib2.HTTPError:
     474                traceback.print_exc()
     475                time.sleep(conf['idle'])
     476
     477if __name__ == '__main__':
     478    # allow this script to serve as a single entry point for bots and the server
     479    args = list(sys.argv)
     480    if len(args) > 1 and args[1] == '--serve':
     481        del args[1]
     482        from serve import main
     483    main(args)
  • new file patchbot/plugins.py

    diff --git a/patchbot/plugins.py b/patchbot/plugins.py
    new file mode 100644
    - +  
     1"""
     2A plugin is any callable.
     3
     4It is called after the ticket has been successfully applied and built,
     5but before tests are run. It should print out any analysis to stdout,
     6raising an exception if anything went wrong.
     7
     8The parameters are as follows:
     9
     10   ticket -- a dictionary of all the ticket informaton
     11   original_dir -- pristine sage-main directory
     12   patched_dir -- patched sage-branch directory for this ticket
     13   patchs -- a list of absolute paths to the patch files for this ticket
     14
     15It is recommended that a plugin ignore extra keywords to be
     16compatible with future options.
     17"""
     18
     19import re, os, sys
     20
     21from trac import do_or_die
     22
     23
     24def coverage(ticket, **kwds):
     25    do_or_die('$SAGE_ROOT/sage -coverageall')
     26
     27def docbuild(ticket, **kwds):
     28    do_or_die('$SAGE_ROOT/sage -docbuild --jsmath reference html')
     29
     30def trailing_whitespace(ticket, patches, **kwds):
     31    ignore_empty = True
     32    bad_lines = 0
     33    trailing = re.compile("\\+.*\\s+$")
     34    for patch_path in patches:
     35        patch = os.path.basename(patch_path)
     36        print patch
     37        for ix, line in enumerate(open(patch_path)):
     38            line = line.strip("\n")
     39            m = trailing.match(line)
     40            if m:
     41                print "    %s:%s %s$" % (patch, ix+1, line)
     42                if line.strip() == '+' and ignore_empty:
     43                    pass
     44                else:
     45                    bad_lines += 1
     46    msg = "Trailing whitespace inserted on %s %slines." % (bad_lines, "non-empty " if ignore_empty else "")
     47    print msg
     48    if bad_lines > 0:
     49        raise ValueError(msg)
     50
     51def commit_messages(ticket, patches, **kwds):
     52    for patch_path in patches:
     53        patch = os.path.basename(patch_path)
     54        print "Looking at", patch
     55        header = []
     56        for line in open(patch_path):
     57            if line.startswith('diff '):
     58                break
     59            header.append(line)
     60        else:
     61            print ''.join(header[:10])
     62            raise ValueError("Not a valid patch file: " + patch)
     63        print ''.join(header)
     64        if header[0].strip() != "# HG changeset patch":
     65            raise ValueError("Not a mercurial patch file: " + patch)
     66        for line in header:
     67            if not line.startswith('# '):
     68                # First description line
     69                if line.startswith('[mq]'):
     70                    raise ValueError("Mercurial queue boilerplate")
     71                elif not re.search(r"\b%s\b" % ticket['id'], line):
     72                    print "Ticket number not in first line of comments: " + patch
     73                break
     74        else:
     75            raise ValueError("No patch comments:" + patch)
     76        print
     77    print "All patches good."
     78
     79if __name__ == '__main__':
     80    plugin = globals()[sys.argv[1]]
     81    plugin(-1, patches=sys.argv[2:])
  • new file patchbot/run_server.py

    diff --git a/patchbot/run_server.py b/patchbot/run_server.py
    new file mode 100755
    - +  
     1#!/usr/bin/env python
     2
     3import os, signal, subprocess, sys, time, traceback, urllib2
     4
     5if not hasattr(subprocess.Popen, 'send_signal'):
     6    def send_signal(self, sig):
     7        os.kill(self.pid, sig)
     8    subprocess.Popen.send_signal = send_signal
     9
     10DATABASE = "../data"
     11
     12# The server hangs while connecting to trac, so we poll it and
     13# restart if needed.
     14
     15HTTP_TIMEOUT = 60
     16POLL_INTERVAL = 180
     17KILL_WAIT = 5
     18
     19p = None
     20try:
     21    # Start mongodb
     22    mongo_process = subprocess.Popen(["mongod", "--port=21002", "--dbpath=" + DATABASE], stderr=subprocess.STDOUT)
     23
     24    # Run the server
     25    while True:
     26
     27        if p is None or p.poll() is not None:
     28            # The subprocess died.
     29            restart = True
     30        else:
     31            try:
     32                print "Testing url..."
     33                urllib2.urlopen("http://patchbot.sagemath.org/", timeout=HTTP_TIMEOUT)
     34                print "    ...good"
     35                restart = False
     36            except urllib2.URLError, e:
     37                print "    ...bad", e
     38                restart = True
     39
     40        if restart:
     41            if p is not None and p.poll() is None:
     42                print "SIGTERM"
     43                p.send_signal(signal.SIGTERM)
     44                time.sleep(KILL_WAIT)
     45                if p.poll() is None:
     46                    print "SIGKILL"
     47                    p.kill()
     48                    time.sleep(KILL_WAIT)
     49
     50            print "Starting server..."
     51            base = open("base.txt").read().strip()
     52            p = subprocess.Popen([sys.executable, "serve.py", "--base=" + base, "--port=21100"])
     53            open("server.pid", "w").write(str(p.pid))
     54            print "    ...done."
     55        time.sleep(POLL_INTERVAL)
     56
     57finally:
     58    traceback.print_exc()
     59    mongo_process.send_signal(signal.SIGTERM)
     60    if p is not None and p.poll() is None:
     61        p.kill()
     62
  • new file patchbot/serve.py

    diff --git a/patchbot/serve.py b/patchbot/serve.py
    new file mode 100644
    - +  
     1import os, sys, bz2, json, traceback, re, collections
     2from cStringIO import StringIO
     3from optparse import OptionParser
     4from flask import Flask, render_template, make_response, request, Response
     5import pymongo
     6import trac
     7import patchbot
     8import db
     9
     10from db import tickets, logs
     11from util import now_str, current_reports, comparable_version, latest_version
     12
     13app = Flask(__name__)
     14
     15@app.route("/reports")
     16def reports():
     17    pass
     18
     19@app.route("/trusted")
     20@app.route("/trusted/")
     21def trusted_authors():
     22    authors = collections.defaultdict(int)
     23    for ticket in tickets.find({'status': 'closed : fixed'}):
     24        for author in ticket["authors"]:
     25            authors[author] += 1
     26    if 'pretty' in request.args:
     27        indent = 4
     28    else:
     29        indent = None
     30    response = make_response(json.dumps(authors, default=lambda x: None, indent=indent))
     31    response.headers['Content-type'] = 'text/plain'
     32    return response
     33
     34@app.route("/")
     35@app.route("/ticket")
     36@app.route("/ticket/")
     37def ticket_list():
     38    authors = None
     39    machine = None
     40    if 'query' in request.args:
     41        query = json.loads(request.args.get('query'))
     42    else:
     43        status = request.args.get('status', 'needs_review')
     44        if status == 'all':
     45            query = {}
     46        elif status in ('new', 'closed'):
     47            query = {'status': {'$regex': status + '.*' }}
     48        elif status in ('open'):
     49            query = {'status': {'$regex': 'needs_.*|positive_review' }}
     50        else:
     51            query = {'status': status}
     52        if 'todo' in request.args:
     53            query['patches'] = {'$not': {'$size': 0}}
     54            query['spkgs'] = {'$size': 0}
     55        if 'authors' in request.args:
     56            authors = request.args.get('authors').split(':')
     57            query['authors'] = {'$in': authors}
     58        if 'machine' in request.args:
     59            machine = request.args.get('machine').split('/')
     60            query['reports.machine'] = machine
     61    if 'order' in request.args:
     62        order = request.args.get('order')
     63    else:
     64        order = 'last_activity'
     65    if 'base' in request.args:
     66        base = request.args.get('base')
     67        if base == 'all':
     68            base = None
     69    else:
     70        base = 'latest'
     71    if 'author' in request.args:
     72        query['authors'] = request.args.get('author')
     73    if 'participant' in request.args:
     74        query['participants'] = request.args.get('participant')
     75    all = patchbot.filter_on_authors(tickets.find(query).sort(order), authors)
     76    if 'raw' in request.args:
     77        if 'pretty' in request.args:
     78            indent = 4
     79        else:
     80            indent = None
     81        response = make_response(json.dumps(list(all), default=lambda x: None, indent=indent))
     82        response.headers['Content-type'] = 'text/plain'
     83        return response
     84    summary = dict((key, 0) for key in status_order)
     85    def preprocess(all):
     86        for ticket in all:
     87            ticket['report_count'], ticket['report_status'], ticket['report_status_composite'] = get_ticket_status(ticket, machine=machine, base=base or 'latest')
     88            if 'reports' in ticket:
     89                ticket['pending'] = len([r for r in ticket['reports'] if r['status'] == 'Pending'])
     90            summary[ticket['report_status']] += 1
     91            yield ticket
     92    ticket0 = db.lookup_ticket(0)
     93    versions = list(set(report['base'] for report in ticket0['reports']))
     94    versions.sort(trac.compare_version)
     95    versions = [(v, get_ticket_status(ticket0, v)) for v in versions if v != '4.7.']
     96    return render_template("ticket_list.html", tickets=preprocess(all), summary=summary, base=base, base_status=get_ticket_status(db.lookup_ticket(0), base), versions=versions, status_order=status_order)
     97
     98def format_patches(ticket, patches, deps=None, required=None):
     99    if deps is None:
     100        deps = []
     101    if required is not None:
     102        required = set(required)
     103    def format_item(item):
     104        if required is None or item in required:
     105            note = ""
     106        else:
     107            note = "<span style='color: red'>(mismatch)</span>"
     108        item = str(item)
     109        if '#' in item:
     110            url = trac.get_patch_url(ticket, item, raw=False)
     111            title = item
     112        elif '.' in item:
     113            url = '/?base=%s' % item
     114            title = 'sage-%s' % item
     115        else:
     116            url = '/ticket/%s' % item
     117            title = '#%s' % item
     118        return "<a href='%s'>%s</a> %s" % (url, title, note)
     119
     120    missing_deps = missing_patches = ''
     121    if required is not None:
     122        required_patches_count = len([p for p in required if '#' in str(p)])
     123        if len(deps) < len(required) - required_patches_count:
     124            missing_deps = "<li><span style='color: red'>(missing deps)</span>\n"
     125        if len(patches) < required_patches_count:
     126            missing_patches = "<li><span style='color: red'>(missing patches)</span>\n"
     127    return ("<ol>"
     128        + missing_deps
     129        + "<li>\n"
     130        + "\n<li>".join(format_item(patch) for patch in (deps + patches))
     131        + missing_patches
     132        + "</ol>")
     133
     134@app.route("/ticket/<int:ticket>/")
     135def render_ticket(ticket):
     136    try:
     137        info = trac.scrape(ticket, db=db, force='force' in request.args)
     138    except:
     139        info = tickets.find_one({'id': ticket})
     140    if info is None:
     141        return "No such ticket."
     142    if 'kick' in request.args:
     143        info['retry'] = True
     144        db.save_ticket(info)
     145    if 'reports' in info:
     146        info['reports'].sort(lambda a, b: -cmp(a['time'], b['time']))
     147    else:
     148        info['reports'] = []
     149
     150    old_reports = list(info['reports'])
     151    patchbot.prune_pending(info)
     152    if old_reports != info['reports']:
     153        db.save_ticket(info)
     154
     155    def format_info(info):
     156        new_info = {}
     157        for key, value in info.items():
     158            if key == 'patches':
     159                new_info['patches'] = format_patches(ticket, value)
     160            elif key == 'reports' or key == 'pending':
     161                pass
     162            elif key == 'depends_on':
     163                new_info[key] = ', '.join("<a href='/ticket/%s'>%s</a>" % (a, a) for a in value)
     164            elif key == 'authors':
     165                new_info[key] = ', '.join("<a href='/ticket/?author=%s'>%s</a>" % (a,a) for a in value)
     166            elif key == 'participants':
     167                new_info[key] = ', '.join("<a href='/ticket/?participant=%s'>%s</a>" % (a,a) for a in value)
     168            elif isinstance(value, list):
     169                new_info[key] = ', '.join(value)
     170            elif key not in ('id', '_id'):
     171                new_info[key] = value
     172        return new_info
     173    def preprocess_reports(all):
     174        for item in all:
     175            if 'patches' in item:
     176                required = info['depends_on'] + info['patches']
     177                item['patch_list'] = format_patches(ticket, item['patches'], item.get('deps'), required)
     178            if item['base'] != base:
     179                item['base'] = "<span style='color: red'>%s</span>" % item['base']
     180            if 'time' in item:
     181                item['log'] = log_name(info['id'], item)
     182            yield item
     183    return render_template("ticket.html", reports=preprocess_reports(info['reports']), ticket=ticket, info=format_info(info), status=get_ticket_status(info, base=base)[2])
     184
     185# The fact that this image is in the trac template lets the patchbot know
     186# when a page gets updated.
     187@app.route("/ticket/<int:ticket>/status.png")
     188def render_ticket_status(ticket):
     189    try:
     190        info = trac.scrape(ticket, db=db)
     191    except:
     192        info = tickets.find_one({'id': ticket})
     193    if 'reports' in info:
     194        base = latest_version(current_reports(info))
     195    else:
     196        base = None
     197    status = get_ticket_status(info, base=base)[2]
     198    response = make_response(create_status_image(status, base=base))
     199    response.headers['Content-type'] = 'image/png'
     200    response.headers['Cache-Control'] = 'no-cache'
     201    return response
     202
     203def get_or_set(ticket, key, default):
     204    if key in ticket:
     205        value = ticket[key]
     206    else:
     207        value = ticket[key] = default
     208    return value
     209
     210@app.route("/report/<int:ticket_id>", methods=['POST'])
     211def post_report(ticket_id):
     212    try:
     213        ticket = db.lookup_ticket(ticket_id)
     214        if ticket is None:
     215            ticket = trac.scrape(ticket_id)
     216        if 'reports' not in ticket:
     217            ticket['reports'] = []
     218        report = json.loads(request.form.get('report'))
     219        assert isinstance(report, dict)
     220        for fld in ['status', 'patches', 'spkgs', 'base', 'machine', 'time']:
     221            assert fld in report
     222        patchbot.prune_pending(ticket, report['machine'])
     223        ticket['reports'].append(report)
     224        if report['status'] != 'Pending':
     225            db.logs.put(request.files.get('log'), _id=log_name(ticket_id, report))
     226        if 'retry' in ticket:
     227            ticket['retry'] = False
     228        ticket['last_activity'] = now_str()
     229        db.save_ticket(ticket)
     230        return "ok"
     231    except:
     232        traceback.print_exc()
     233        return "error"
     234
     235def log_name(ticket_id, report):
     236    return "/log/%s/%s/%s" % (ticket_id, '/'.join(report['machine']), report['time'])
     237
     238
     239def shorten(lines):
     240    timing = re.compile(r'\s*\[\d+\.\d* s\]\s*$')
     241    skip = re.compile(r'(sage -t.*\(skipping\))|(byte-compiling)|(copying)|(\S+: \d+% \(\d+ of \d+\))$')
     242    gcc = re.compile('(gcc)|(g\+\+)')
     243    prev = None
     244    for line in StringIO(lines):
     245        if skip.match(line):
     246            pass
     247        elif prev is None:
     248            prev = line
     249        elif prev.startswith('sage -t') and timing.match(line):
     250            prev = None
     251        elif prev.startswith('python `which cython`') and '-->' in line:
     252            prev = None
     253        elif gcc.match(prev) and (gcc.match(line) or line.startswith('Time to execute')):
     254            prev = line
     255        else:
     256            yield prev
     257            prev = line
     258
     259    if prev is not None:
     260        yield prev
     261
     262def extract_plugin_log(data, plugin):
     263    from patchbot import plugin_boundary
     264    start = plugin_boundary(plugin) + "\n"
     265    end = plugin_boundary(plugin, end=True) + "\n"
     266    all = []
     267    include = False
     268    for line in StringIO(data):
     269        if line == start:
     270            include = True
     271        if include:
     272            all.append(line)
     273        if line == end:
     274            break
     275    return ''.join(all)
     276
     277@app.route("/ticket/<id>/log/<path:log>")
     278def get_ticket_log(id, log):
     279    return get_log(log)
     280
     281@app.route("/log/<path:log>")
     282def get_log(log):
     283    path = "/log/" + log
     284    if not logs.exists(path):
     285        data = "No such log!"
     286    else:
     287        data = bz2.decompress(logs.get(path).read())
     288    if 'plugin' in request.args:
     289        data = extract_plugin_log(data, request.args.get('plugin'))
     290    if 'short' in request.args:
     291        response = Response(shorten(data), direct_passthrough=True)
     292    else:
     293        response = make_response(data)
     294    response.headers['Content-type'] = 'text/plain'
     295    return response
     296
     297status_order = ['New', 'ApplyFailed', 'BuildFailed', 'TestsFailed', 'PluginFailed', 'TestsPassed', 'Pending', 'NoPatch', 'Spkg']
     298# TODO: cleanup old records
     299# status_order += ['started', 'applied', 'built', 'tested']
     300
     301status_colors = {
     302    'New'        : 'white',
     303    'ApplyFailed': 'red',
     304    'BuildFailed': 'red',
     305    'TestsFailed': 'yellow',
     306    'TestsPassed': 'green',
     307    'PluginFailed': 'blue',
     308    'Pending'    : 'white',
     309    'NoPatch'    : 'purple',
     310    'Spkg'       : 'purple',
     311}
     312
     313@app.route("/blob/<status>")
     314def status_image(status):
     315    response = make_response(create_status_image(status))
     316    response.headers['Content-type'] = 'image/png'
     317    response.headers['Cache-Control'] = 'max-age=3600'
     318    return response
     319
     320def create_status_image(status, base=None):
     321    if ',' in status:
     322        status_list = status.split(',')
     323        if len(set(status_list)) == 1:
     324            status = status_list[0]
     325        else:
     326            try:
     327                from PIL import Image
     328                import numpy
     329                path = 'images/_cache/' + ','.join(status_list) + '-blob.png'
     330                if not os.path.exists(path):
     331                    composite = numpy.asarray(Image.open(status_image(status_list[0]))).copy()
     332                    height, width, _ = composite.shape
     333                    for ix, status in enumerate(reversed(status_list)):
     334                        slice = numpy.asarray(Image.open(status_image(status)))
     335                        start = ix * width / len(status_list)
     336                        end = (ix + 1) * width / len(status_list)
     337                        composite[:,start:end,:] = slice[:,start:end,:]
     338                    if not os.path.exists('images/_cache'):
     339                        os.mkdir('images/_cache')
     340                    Image.fromarray(composite, 'RGBA').save(path)
     341            except ImportError:
     342                print "bad import"
     343                status = min_status(status_list)
     344                path = status_image(status)
     345    else:
     346        path = status_image(status)
     347    if base is not None:
     348        from PIL import Image
     349        import ImageDraw
     350        im = Image.open(path)
     351        ImageDraw.Draw(im).text((5, 20), base.replace("alpha", "a").replace("beta", "b"), fill='#FFFFFF')
     352        output = StringIO()
     353        im.save(output, format='png')
     354        return output.getvalue()
     355    else:
     356        return open(path).read()
     357
     358def status_image(status):
     359    return 'images/%s-blob.png' % status_colors[status]
     360
     361def min_status(status_list):
     362    index = min(status_order.index(status) for status in status_list)
     363    return status_order[index]
     364
     365@app.route("/robots.txt")
     366def robots():
     367    return render_template("robots.txt")
     368
     369@app.route("/favicon.ico")
     370def favicon():
     371    response = make_response(open('images/%s-blob.png' % status_colors['TestsPassed']).read())
     372    response.headers['Content-type'] = 'image/png'
     373    return response
     374
     375def get_ticket_status(ticket, base=None, machine=None):
     376    all = current_reports(ticket, base=base)
     377    if machine is not None:
     378        all = [r for r in all if r['machine'] == machine]
     379    if len(all):
     380        status_list = [report['status'] for report in all]
     381        if len(set(status_list)) == 1:
     382            composite = single = status_list[0]
     383        else:
     384            composite = ','.join(status_list)
     385            single = min_status(status_list)
     386        return len(all), single, composite
     387    elif ticket['spkgs']:
     388        return 0, 'Spkg', 'Spkg'
     389    elif not ticket['patches']:
     390        return 0, 'NoPatch', 'NoPatch'
     391    else:
     392        return 0, 'New', 'New'
     393
     394def main(args):
     395    parser = OptionParser()
     396    parser.add_option("-b", "--base", dest="base")
     397    parser.add_option("-p", "--port", dest="port")
     398    parser.add_option("--debug", dest="debug", default=True)
     399    (options, args) = parser.parse_args(args)
     400
     401    global global_base, base
     402    global_base = base = options.base
     403    app.run(debug=options.debug, host="0.0.0.0", port=int(options.port))
     404
     405if __name__ == '__main__':
     406    main(sys.argv)
  • new file patchbot/templates/base.html

    diff --git a/patchbot/templates/base.html b/patchbot/templates/base.html
    new file mode 100644
    - +  
     1<!DOCTYPE HTML>
     2<html>
     3<head>
     4{% block head %}
     5{% endblock head %}
     6</head>
     7<body>
     8
     9{% block body %}
     10
     11{% endblock body %}
     12
     13<script type="text/javascript">
     14
     15  var _gaq = _gaq || [];
     16  _gaq.push(['_setAccount', 'UA-20037535-1']);
     17  _gaq.push(['_trackPageview']);
     18
     19  (function() {
     20    var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
     21    ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
     22    var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
     23  })();
     24
     25</script>
     26</body>
     27</html>
  • new file patchbot/templates/ticket.html

    diff --git a/patchbot/templates/ticket.html b/patchbot/templates/ticket.html
    new file mode 100644
    - +  
     1{% extends 'base.html' %}
     2
     3{% block head %}
     4<title>#{{ticket}} PatchBot Results</title>
     5<link rel="shortcut icon" href="/blob/{{status}}" />
     6{% endblock %}
     7
     8{% block body %}
     9
     10<h2>
     11<img src="/ticket/{{ticket}}/status.png">
     12{{ticket}}
     13</h2>
     14<a href="http://trac.sagemath.org/sage_trac/ticket/{{ticket}}">
     15{{info.title}}
     16</a>
     17<br><br>
     18
     19<table>
     20{% for key, value in info.items(): %}
     21<tr>
     22<td align='right'>{{key}}:</td>
     23<td>{{value|safe}}</td>
     24</tr>
     25{% endfor %}
     26</table>
     27<br>
     28<hr>
     29<table width='100%'>
     30{% for report in reports: %}
     31<tr>
     32<td><img src="/blob/{{report.status}}"></td>
     33<td><b>{{report.status}}</b></td>
     34<td>{{report.base|safe}}</td>
     35<td><a href='/ticket/?machine={{'/'.join(report.machine)}}&status=open'>{{'/'.join(report.machine)}}</a></td>
     36<td>{{report.time}}</td>
     37<td align='center'><a href='{{report.log}}'>log</a> <a href='{{report.log}}?short'>shortlog</a></td>
     38</tr>
     39<tr>
     40<td colspan=2></td>
     41<td colspan=3 valign='top'>
     42{{report.patch_list|safe}}
     43</td>
     44<td valign='top'>
     45<ul style='list-style-type: none'>
     46{% for plugin, status in report.get('plugins', []) %}
     47<li>
     48<img height="16" src="/blob/{{['PluginFailed', 'TestsPassed'][status]}}">
     49<a href='{{report.log}}?plugin={{plugin}}'>{{plugin}}</a>
     50{% endfor %}
     51</ul>
     52</td>
     53</tr>
     54{% endfor %}
     55</table>
     56
     57{% endblock %}
  • new file patchbot/templates/ticket_list.html

    diff --git a/patchbot/templates/ticket_list.html b/patchbot/templates/ticket_list.html
    new file mode 100644
    - +  
     1{% extends 'base.html' %}
     2
     3{% block head %}
     4<title>Sage PatchBot</title>
     5<link rel="shortcut icon" href="/blob/{{base_status[1]}}" />
     6{% endblock %}
     7
     8{% block body %}
     9
     10<h1>Sage Patchbot</h1>
     11<img src='/blob/{{base_status[1]}}' height=16>
     12Results against Sage {{base}}
     13(<a href='/ticket/0'>{{base_status[0]}} reports</a>).<br><br>
     14See results against
     15{% for version, status in versions: %}
     16<img src='/blob/{{status[1]}}' height=16>
     17<a href='?base={{version}}'>{{version}}</a>
     18{% endfor %}
     19
     20<hr>
     21
     22<table>
     23{% for ticket in tickets: %}
     24<tr>
     25<td>{{'*' if ticket.pending else ''}}</td>
     26<td>{{ticket.report_count}}</td>
     27<td><img src='/blob/{{ticket.report_status_composite}}' alt='{{ticket.report_status_composite}}' title='{{ticket.report_status_composite}}' height=16></td>
     28<td align='right'>
     29    <a href="/ticket/{{ticket.id}}/">{{ticket.id}}</a>
     30</td>
     31<td>{{ticket.title}}</td>
     32<td>{{ticket.status}}</td>
     33</tr>
     34{% endfor %}
     35</table>
     36
     37<hr>
     38
     39<table>
     40{% for status in status_order %}
     41<tr>
     42<td><img src='/blob/{{status}}' height=16></td>
     43<td>{{status}}</td>
     44<td>{{summary[status]}}</td>
     45</tr>
     46{% endfor %}
     47</table>
     48
     49{% endblock %}
  • new file patchbot/trac.py

    diff --git a/patchbot/trac.py b/patchbot/trac.py
    new file mode 100644
    - +  
     1TRAC_URL = "http://trac.sagemath.org/sage_trac"
     2
     3import re, hashlib, urllib2, os, sys, traceback, time, subprocess
     4
     5from util import do_or_die, extract_version, compare_version, get_base, now_str
     6
     7def digest(s):
     8    """
     9    Computes a cryptographic hash of the string s.
     10    """
     11    return hashlib.md5(s).hexdigest()
     12
     13def get_url(url):
     14    """
     15    Returns the contents of url as a string.
     16    """
     17    try:
     18        url = url.replace(' ', '%20')
     19        handle = urllib2.urlopen(url, timeout=5)
     20        data = handle.read()
     21        handle.close()
     22        return data
     23    except:
     24        print url
     25        raise
     26
     27def get_patch_url(ticket, patch, raw=True):
     28    if raw:
     29        return "%s/raw-attachment/ticket/%s/%s" % (TRAC_URL, ticket, patch)
     30    else:
     31        return "%s/attachment/ticket/%s/%s" % (TRAC_URL, ticket, patch)
     32
     33def get_patch(ticket, patch):
     34    return get_url(get_patch_url(ticket, patch))
     35
     36def scrape(ticket_id, force=False, db=None):
     37    """
     38    Scrapes the trac page for ticket_id, updating the database if needed.
     39    """
     40    ticket_id = int(ticket_id)
     41    if ticket_id == 0:
     42        if db is not None:
     43            db_info = db.lookup_ticket(ticket_id)
     44            if db_info is not None:
     45                return db_info
     46        return {
     47            'id'            : ticket_id,
     48            'title'         : 'base',
     49            'page_hash'     : '0',
     50            'status'        : 'base',
     51            'priority'      : 'base',
     52            'component'     : 'base',
     53            'depends_on'    : [],
     54            'spkgs'         : [],
     55            'patches'       : [],
     56            'authors'       : [],
     57            'participants'  : [],
     58        }
     59
     60    rss = get_url("%s/ticket/%s?format=rss" % (TRAC_URL, ticket_id))
     61    page_hash = digest(rss) # rss isn't as brittle
     62    if db is not None:
     63        # TODO: perhaps the db caching should be extracted outside of this function...
     64        db_info = db.lookup_ticket(ticket_id)
     65        if not force and db_info is not None and db_info['page_hash'] == page_hash:
     66            return db_info
     67    # TODO: Is there a better format that still has all the information?
     68    html = get_url("%s/ticket/%s" % (TRAC_URL, ticket_id))
     69    authors = set()
     70    patches = []
     71    for patch, who in extract_patches(rss):
     72        authors.add(who)
     73        patches.append(patch + "#" + digest(get_patch(ticket_id, patch)))
     74    authors = list(authors)
     75    data = {
     76        'id'            : ticket_id,
     77        'title'         : extract_title(rss),
     78        'page_hash'     : page_hash,
     79        'status'        : extract_status(html),
     80        'milestone'     : extract_milestone(html),
     81        'merged'        : extract_merged(html),
     82        'priority'      : extract_priority(html),
     83        'component'     : extract_component(html),
     84        'depends_on'    : extract_depends_on(html),
     85        'spkgs'         : extract_spkgs(html),
     86        'patches'       : patches,
     87        'authors'       : authors,
     88        'participants'  : extract_participants(rss),
     89        'last_activity' : now_str(),
     90    }
     91    if db is not None:
     92        db.save_ticket(data)
     93        db_info = db.lookup_ticket(ticket_id)
     94        return db_info
     95    else:
     96        return data
     97
     98def extract_tag(sgml, tag):
     99    """
     100    Find the first occurance of the tag start (including attributes) and
     101    return the contents of that tag (really, up until the next end tag
     102    of that type).
     103
     104    Crude but fast.
     105    """
     106    tag_name = tag[1:-1]
     107    if ' ' in tag_name:
     108        tag_name = tag_name[:tag_name.index(' ')]
     109    end = "</%s>" % tag_name
     110    start_ix = sgml.find(tag)
     111    if start_ix == -1:
     112        return None
     113    end_ix = sgml.find(end, start_ix)
     114    if end_ix == -1:
     115        return None
     116    return sgml[start_ix + len(tag) : end_ix].strip()
     117
     118def extract_status(html):
     119    """
     120    Extracts the status of a ticket from the html page.
     121    """
     122    status = extract_tag(html, '<span class="status">')
     123    if status is None:
     124        return 'unknown'
     125    status = status.strip('()')
     126    status = status.replace('defect', '').replace('enhancement', '').strip()
     127    return status
     128
     129def extract_priority(html):
     130    """
     131    Extracts any spkgs for a ticket from the html page.
     132    """
     133    return extract_tag(html, '<td headers="h_priority">')
     134
     135def extract_milestone(html):
     136    milestone_field = extract_tag(html, '<td headers="h_milestone">')
     137    return extract_version(milestone_field)
     138
     139def extract_merged(html):
     140    merged_field = extract_tag(html, '<td headers="h_merged">')
     141    return extract_version(merged_field)
     142
     143def extract_component(html):
     144    return extract_tag(html, '<td headers="h_component">')
     145
     146def extract_title(rss):
     147    title = extract_tag(rss, '<title>')
     148    return re.sub(r'.*#\d+:', '', title).strip()
     149
     150folded_regex = re.compile('all.*(folded|combined|merged)')
     151subsequent_regex = re.compile('second|third|fourth|next|on top|after')
     152attachment_regex = re.compile(r"<strong>attachment</strong>\s*set to <em>(.*)</em>", re.M)
     153rebased_regex = re.compile(r"([-.]?rebased?)|(-v\d)")
     154def extract_patches(rss):
     155    """
     156    Extracts the list of patches for a ticket from the rss feed.
     157
     158    Tries to deduce the subset of attached patches to apply based on
     159
     160        (1) "Apply ..." in comment text
     161        (2) Mercurial .N naming
     162        (3) "rebased" in name
     163        (3) Chronology
     164    """
     165    all_patches = []
     166    patches = []
     167    authors = {}
     168    for item in rss.split('<item>'):
     169        who = extract_tag(item, '<dc:creator>')
     170        description = extract_tag(item, '<description>').replace('&lt;', '<').replace('&gt;', '>')
     171        m = attachment_regex.search(description)
     172        comments = description[description.find('</ul>') + 1:]
     173        # Look for apply... followed by patch names
     174        for line in comments.lower().split('\n'):
     175            if 'apply' in line:
     176                new_patches = []
     177                for p in line[line.index('apply'):].split(','):
     178                    for pp in p.strip().split():
     179                        if pp in all_patches:
     180                            new_patches.append(pp)
     181                if new_patches or (m and not subsequent_regex.search(line)):
     182                    patches = new_patches
     183            elif m and folded_regex.search(line):
     184                patches = [] # will add this patch below
     185        if m is not None:
     186            attachment = m.group(1)
     187            base, ext = os.path.splitext(attachment)
     188            if '.' in base:
     189                try:
     190                    base2, ext2 = os.path.splitext(base)
     191                    count = int(ext2[1:])
     192                    for i in range(count):
     193                        if i:
     194                            older = "%s.%s%s" % (base2, i, ext)
     195                        else:
     196                            older = "%s%s" % (base2, ext)
     197                        if older in patches:
     198                            patches.remove(older)
     199                except:
     200                    pass
     201            if rebased_regex.search(attachment):
     202                older = rebased_regex.sub('', attachment)
     203                if older in patches:
     204                    patches.remove(older)
     205            if ext in ('.patch', '.diff'):
     206                all_patches.append(attachment)
     207                patches.append(attachment)
     208                authors[attachment] = who
     209    return [(p, authors[p]) for p in patches]
     210
     211participant_regex = re.compile("<strong>attachment</strong>\w*set to <em>(.*)</em>")
     212def extract_participants(rss):
     213    """
     214    Extracts any spkgs for a ticket from the html page.
     215    """
     216    all = set()
     217    for item in rss.split('<item>'):
     218        who = extract_tag(item, '<dc:creator>')
     219        if who:
     220            all.add(who)
     221    return list(all)
     222
     223spkg_url_regex = re.compile(r"(?:(?:http://)|(?:/attachment/)).*?\.spkg")
     224#spkg_url_regex = re.compile(r"http://.*?\.spkg")
     225def extract_spkgs(html):
     226    """
     227    Extracts any spkgs for a ticket from the html page.
     228
     229    Just searches for urls ending in .spkg.
     230    """
     231    return list(set(spkg_url_regex.findall(html)))
     232
     233def min_non_neg(*rest):
     234    non_neg = [a for a in rest if a >= 0]
     235    if len(non_neg) == 0:
     236        return rest[0]
     237    elif len(non_neg) == 1:
     238        return non_neg[0]
     239    else:
     240        return min(*non_neg)
     241
     242ticket_url_regex = re.compile(r"%s/ticket/(\d+)" % TRAC_URL)
     243def extract_depends_on(html):
     244    deps_field = extract_tag(html, '<td headers="h_dependencies">')
     245    deps = []
     246    for dep in re.finditer(r'ticket/(\d+)', deps_field):
     247        deps.append(int(dep.group(1)))
     248    version = re.search(r'sage-\d+(\.\d)+(\.\w+)?', deps_field)
     249    if version:
     250        deps.insert(0, version.group(0))
     251    return deps
     252
     253
     254
     255safe = re.compile('[-+A-Za-z0-9._]*')
     256def ensure_safe(items):
     257    """
     258    Raise an error if item has any spaces in it.
     259    """
     260    if isinstance(items, (str, unicode)):
     261        m = safe.match(items)
     262        if m is None or m.end() != len(items):
     263            raise ValueError, "Unsafe patch name '%s'" % items
     264    else:
     265        for item in items:
     266            ensure_safe(item)
     267
     268
     269def pull_from_trac(sage_root, ticket, branch=None, force=None, interactive=None):
     270    # Should we set/unset SAGE_ROOT and SAGE_BRANCH here? Fork first?
     271    if branch is None:
     272        branch = str(ticket)
     273    if not os.path.exists('%s/devel/sage-%s' % (sage_root, branch)):
     274        do_or_die('%s/sage -b main' % (sage_root,))
     275        do_or_die('%s/sage -clone %s' % (sage_root, branch))
     276    os.chdir('%s/devel/sage-%s' % (sage_root, branch))
     277    if interactive:
     278        raise NotImplementedError
     279    if not os.path.exists('.hg/patches'):
     280        do_or_die('hg qinit')
     281        series = []
     282    elif not os.path.exists('.hg/patches/series'):
     283        series = []
     284    else:
     285        series = open('.hg/patches/series').read().split('\n')
     286
     287    base = get_base(sage_root)
     288    desired_series = []
     289    seen_deps = []
     290    def append_patch_list(ticket, dependency=False):
     291        if ticket in seen_deps:
     292            return
     293        print "Looking at #%s" % ticket
     294        seen_deps.append(ticket)
     295        data = scrape(ticket)
     296        if dependency and 'closed' in data['status']:
     297            merged = data.get('merged')
     298            if merged is None:
     299                merged = data.get('milestone')
     300            if merged is None or compare_version(merged, base) <= 0:
     301                print "#%s already applied (%s <= %s)" % (ticket, merged, base)
     302                return
     303        if data['spkgs']:
     304            raise NotImplementedError, "Spkgs not yet handled."
     305        if data['depends_on']:
     306            for dep in data['depends_on']:
     307                if isinstance(dep, basestring) and '.' in dep:
     308                    if compare_version(base, dep) < 0:
     309                        raise ValueError, "%s < %s for %s" % (base, dep, ticket)
     310                    continue
     311                append_patch_list(dep, dependency=True)
     312        print "Patches for #%s:" % ticket
     313        print "    " + "\n    ".join(data['patches'])
     314        for patch in data['patches']:
     315            patchfile, hash = patch.split('#')
     316            desired_series.append((hash, patchfile, get_patch_url(ticket, patchfile)))
     317    append_patch_list(ticket)
     318
     319    ensure_safe(series)
     320    ensure_safe(patch for hash, patch, url in desired_series)
     321
     322    last_good_patch = '-a'
     323    to_push = list(desired_series)
     324    for series_patch, (hash, patch, url) in zip(series, desired_series):
     325        if not series_patch:
     326            break
     327        next_hash = digest(open('.hg/patches/%s' % series_patch).read())
     328#        print next_hash, hash, series_patch
     329        if next_hash == hash:
     330            to_push.pop(0)
     331            last_good_patch = series_patch
     332        else:
     333            break
     334
     335    try:
     336        if last_good_patch != '-a':
     337            # In case it's not yet pushed...
     338            if last_good_patch not in os.popen2('hg qapplied')[1].read().split('\n'):
     339                do_or_die('hg qpush %s' % last_good_patch)
     340        do_or_die('hg qpop %s' % last_good_patch)
     341        for hash, patch, url in to_push:
     342            if patch in series:
     343                if not force:
     344                    raise Exception, "Duplicate patch: %s" % patch
     345                old_patch = patch
     346                while old_patch in series:
     347                    old_patch += '-old'
     348                do_or_die('hg qrename %s %s' % (patch, old_patch))
     349            do_or_die('hg qimport %s && hg qpush' % url)
     350        do_or_die('hg qapplied')
     351    except:
     352        os.system('hg qpop -a')
     353        raise
     354
     355
     356def push_from_trac(sage_root, ticket, branch=None, force=None, interactive=None):
     357    raise NotImplementedError
     358
     359
     360
     361if __name__ == '__main__':
     362    force = False
     363    apply = False
     364    for ticket in sys.argv[1:]:
     365        if ticket == '-f':
     366            force = True
     367            continue
     368        if ticket == '-a':
     369            apply = True
     370            continue
     371        if '-' in ticket:
     372            start, end = ticket.split('-')
     373            tickets = range(int(start), int(end) + 1)
     374        else:
     375            tickets = [int(ticket)]
     376        for ticket in tickets:
     377            try:
     378                print ticket, scrape(ticket, force=force)
     379                if apply:
     380                    pull_from_trac(os.environ['SAGE_ROOT'], ticket, force=True)
     381                time.sleep(1)
     382            except Exception:
     383                print "Error for", ticket
     384                traceback.print_exc()
     385        force = apply = False
     386#    pull_from_trac('/Users/robertwb/sage/current', ticket, force=True)
  • new file patchbot/util.py

    diff --git a/patchbot/util.py b/patchbot/util.py
    new file mode 100644
    - +  
     1import os, re, subprocess, time
     2
     3
     4DATE_FORMAT = '%Y-%m-%d %H:%M:%S %z'
     5def now_str():
     6    return time.strftime(DATE_FORMAT)
     7
     8def parse_datetime(s):
     9    # The one thing Python can't do is parse dates...
     10    return time.mktime(time.strptime(s[:-5].strip(), DATE_FORMAT[:-3])) + 60*int(s[-5:].strip())
     11
     12def prune_pending(ticket, machine=None, timeout=6*60*60):
     13    if 'reports' in ticket:
     14        reports = ticket['reports']
     15    else:
     16        return []
     17    # TODO: is there a better way to handle time zones?
     18    now = time.time() + 60 * int(time.strftime('%z'))
     19    for report in list(reports):
     20        if report['status'] == 'Pending':
     21            t = parse_datetime(report['time'])
     22            if report['machine'] == machine:
     23                reports.remove(report)
     24            elif now - t > timeout:
     25                reports.remove(report)
     26    return reports
     27
     28def latest_version(reports):
     29    if reports:
     30        return max([r['base'] for r in reports], key=comparable_version)
     31    else:
     32        return None
     33
     34def current_reports(ticket, base=None, unique=False):
     35    if 'reports' not in ticket:
     36        return []
     37    if unique:
     38        seen = set()
     39        def first(x):
     40            if x in seen:
     41                return False
     42            else:
     43                seen.add(x)
     44                return True
     45    else:
     46        first = lambda x: True
     47    reports = list(ticket['reports'])
     48    reports.sort(lambda a, b: cmp(b['time'], a['time']))
     49    if base == 'latest':
     50        base = latest_version(reports)
     51    return filter(lambda report: (ticket['patches'] == report['patches'] and
     52                                  ticket['spkgs'] == report['spkgs'] and
     53                                  ticket['depends_on'] == (report.get('deps') or []) and
     54                                  (not base or base == report['base'])) and
     55                                  first('/'.join(report['machine'])),
     56                      reports)
     57
     58def do_or_die(cmd):
     59    print cmd
     60    res = os.system(cmd)
     61    if res:
     62        raise Exception, "%s %s" % (res, cmd)
     63
     64def extract_version(s):
     65    m = re.search(r'\d+(\.\d+)+(\.\w+)', s)
     66    if m:
     67        return m.group(0)
     68
     69def comparable_version(version):
     70    version = re.sub(r'([^.0-9])(\d+)', r'\1.\2', version) + '.z'
     71    def maybe_int(s):
     72        try:
     73            return 1, int(s)
     74        except:
     75            return 0, s
     76    return [maybe_int(s) for s in version.split('.')]
     77
     78def compare_version(a, b):
     79    return cmp(comparable_version(a), comparable_version(b))
     80
     81def get_base(sage_root):
     82    p = subprocess.Popen([os.path.join(sage_root, 'sage'), '-v'], stdout=subprocess.PIPE)
     83    if p.wait():
     84        raise ValueError, "Invalid sage_root='%s'" % sage_root
     85    version_info = p.stdout.read()
     86    return re.search(r'Sage Version ([\d.]+\w*)', version_info).groups()[0]