Ticket #12486: patchbot-scripts-5.0.patch

File patchbot-scripts-5.0.patch, 81.1 KB (added by ddrake, 9 years ago)

for 5 .0 series, apply to scripts repo

  • new file patchbot/README.txt

    # HG changeset patch
    # User Robert Bradshaw <robertwb@math.washington.edu>
    # Date 1328866496 28800
    # Node ID 49780561b45d9883684a9c74eab707761414c415
    # Parent  7d84a793c1e5aca967f3c22a19ea5c10a9968367
    trac #12486: 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 21001.
  • 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 = 21001
     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('reports.base')
     15tickets.ensure_index('reports.machine')
     16tickets.ensure_index('reports.time')
     17
     18logs = gridfs.GridFS(mongodb, 'logs')
     19
     20def lookup_ticket(ticket_id):
     21    return tickets.find_one({'id': ticket_id})
     22
     23def save_ticket(ticket_data):
     24    old = lookup_ticket(ticket_data['id'])
     25    if old:
     26        old.update(ticket_data)
     27        ticket_data = old
     28    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    try:
     139        print post_multipart("%s/report/%s" % (server, ticket['id']), fields, files)
     140    except:
     141        traceback.print_exc()
     142
     143class TimeOut(Exception):
     144    pass
     145
     146def alarm_handler(signum, frame):
     147    raise Alarm
     148
     149class Tee:
     150    def __init__(self, filepath, time=False, timeout=60*60*24):
     151        self.filepath = filepath
     152        self.time = time
     153        self.timeout = timeout
     154
     155    def __enter__(self):
     156        self._saved = os.dup(sys.stdout.fileno()), os.dup(sys.stderr.fileno())
     157        self.tee = subprocess.Popen(["tee", self.filepath], stdin=subprocess.PIPE)
     158        os.dup2(self.tee.stdin.fileno(), sys.stdout.fileno())
     159        os.dup2(self.tee.stdin.fileno(), sys.stderr.fileno())
     160        if self.time:
     161            print datetime()
     162            self.start_time = time.time()
     163
     164    def __exit__(self, exc_type, exc_val, exc_tb):
     165        if exc_type is not None:
     166            traceback.print_exc()
     167        if self.time:
     168            print datetime()
     169            print int(time.time() - self.start_time), "seconds"
     170        self.tee.stdin.close()
     171        time.sleep(1)
     172        os.dup2(self._saved[0], sys.stdout.fileno())
     173        os.dup2(self._saved[1], sys.stderr.fileno())
     174        time.sleep(1)
     175        try:
     176            signal.signal(signal.SIGALRM, alarm_handler)
     177            signal.alarm(self.timeout)
     178            self.tee.wait()
     179            signal.alarm(0)
     180        except TimeOut:
     181            traceback.print_exc()
     182            raise
     183        return False
     184
     185
     186class Timer:
     187    def __init__(self):
     188        self._starts = {}
     189        self._history = []
     190        self.start()
     191    def start(self, label=None):
     192        self._last_activity = self._starts[label] = time.time()
     193    def finish(self, label=None):
     194        try:
     195            elapsed = time.time() - self._starts[label]
     196        except KeyError:
     197            elapsed = time.time() - self._last_activity
     198        self._last_activity = time.time()
     199        self.print_time(label, elapsed)
     200        self._history.append((label, elapsed))
     201    def print_time(self, label, elapsed):
     202        print label, '--', int(elapsed), 'seconds'
     203    def print_all(self):
     204        for label, elapsed in self._history:
     205            self.print_time(label, elapsed)
     206
     207# The sage test scripts could really use some cleanup...
     208all_test_dirs = ["doc/common", "doc/en", "doc/fr", "sage"]
     209
     210status = {
     211    'started': 'ApplyFailed',
     212    'applied': 'BuildFailed',
     213    'built'  : 'TestsFailed',
     214    'tested' : 'TestsPassed',
     215    'failed_plugin' : 'PluginFailed',
     216}
     217
     218def plugin_boundary(name, end=False):
     219    if end:
     220        name = 'end ' + name
     221    return ' '.join(('='*10, name, '='*10))
     222
     223
     224def test_a_ticket(sage_root, server, ticket=None, nodocs=False):
     225    base = get_base(sage_root)
     226    if ticket is None:
     227        ticket = get_ticket(base=base, server=server, **conf)
     228    else:
     229        ticket = None, scrape(int(ticket))
     230    if not ticket:
     231        print "No more tickets."
     232        if random.random() < 0.01:
     233            cleanup(sage_root, server)
     234        time.sleep(conf['idle'])
     235        return
     236    rating, ticket = ticket
     237    print "\n" * 2
     238    print "=" * 30, ticket['id'], "=" * 30
     239    print ticket['title']
     240    print "score", rating
     241    print "\n" * 2
     242    log_dir = sage_root + "/logs"
     243    if not os.path.exists(log_dir):
     244        os.mkdir(log_dir)
     245    log = '%s/%s-log.txt' % (log_dir, ticket['id'])
     246    report_ticket(server, ticket, status='Pending', base=base, machine=conf['machine'], user=conf['user'], log=None)
     247    plugins_results = []
     248    try:
     249        with Tee(log, time=True, timeout=conf['timeout']):
     250            t = Timer()
     251            start_time = time.time()
     252
     253            state = 'started'
     254            os.environ['MAKE'] = "make -j%s" % conf['parallelism']
     255            os.environ['SAGE_ROOT'] = sage_root
     256            # TODO: Ensure that sage-main is pristine.
     257            pull_from_trac(sage_root, ticket['id'], force=True)
     258            t.finish("Apply")
     259            state = 'applied'
     260
     261            do_or_die('$SAGE_ROOT/sage -b %s' % ticket['id'])
     262            t.finish("Build")
     263            state = 'built'
     264
     265            working_dir = "%s/devel/sage-%s" % (sage_root, ticket['id'])
     266            # Only the ones on this ticket.
     267            patches = os.popen2('hg --cwd %s qapplied' % working_dir)[1].read().strip().split('\n')[-len(ticket['patches']):]
     268            kwds = {
     269                "original_dir": "%s/devel/sage-0" % sage_root,
     270                "patched_dir": working_dir,
     271                "patches": ["%s/devel/sage-%s/.hg/patches/%s" % (sage_root, ticket['id'], p) for p in patches if p],
     272            }
     273            for name, plugin in conf['plugins']:
     274                try:
     275                    print plugin_boundary(name)
     276                    plugin(ticket, **kwds)
     277                    passed = True
     278                except Exception:
     279                    traceback.print_exc()
     280                    passed = False
     281                finally:
     282                    t.finish(name)
     283                    print plugin_boundary(name, end=True)
     284                    plugins_results.append((name, passed))
     285
     286            test_dirs = ["$SAGE_ROOT/devel/sage-%s/%s" % (ticket['id'], dir) for dir in all_test_dirs]
     287            if conf['parallelism'] > 1:
     288                test_cmd = "-tp %s" % conf['parallelism']
     289            else:
     290                test_cmd = "-t"
     291            do_or_die("$SAGE_ROOT/sage %s -sagenb %s" % (test_cmd, ' '.join(test_dirs)))
     292            #do_or_die("$SAGE_ROOT/sage -t $SAGE_ROOT/devel/sage-%s/sage/rings/integer.pyx" % ticket['id'])
     293            #do_or_die('sage -testall')
     294            t.finish("Tests")
     295            state = 'tested'
     296
     297            if not all(passed for name, passed in plugins_results):
     298                state = 'failed_plugin'
     299
     300            print
     301            t.print_all()
     302    except Exception:
     303        traceback.print_exc()
     304    report_ticket(server, ticket, status=status[state], base=base, machine=conf['machine'], user=conf['user'], log=log, plugins=plugins_results)
     305    return status[state]
     306
     307def cleanup(sage_root, server):
     308    print "Looking up closed tickets."
     309    closed_list = urllib2.urlopen(server + "?status=closed").read()
     310    closed = set(m.groups()[0] for m in re.finditer(r"/ticket/(\d+)/", closed_list))
     311    for branch in os.listdir(os.path.join(sage_root, "devel")):
     312        if branch[:5] == "sage-":
     313            if branch[5:] in closed:
     314                to_delete = os.path.join(sage_root, "devel", branch)
     315                print "Deleting closed ticket:", to_delete
     316                shutil.rmtree(to_delete)
     317    print "Done cleaning up."
     318
     319def default_trusted_authors(server):
     320    handle = urllib2.urlopen(server + "/trusted/")
     321    try:
     322        return json.load(handle).keys()
     323    finally:
     324        handle.close()
     325
     326def machine_data():
     327    system, node, release, version, arch = os.uname()
     328    if system.lower() == "linux":
     329        dist_name, dist_version, dist_id = platform.linux_distribution()
     330        if dist_name:
     331            return [dist_name, dist_version, arch, release, node]
     332    return [system, arch, release, node]
     333
     334def parse_time_of_day(s):
     335    def parse_interval(ss):
     336        ss = ss.strip()
     337        if '-' in ss:
     338            start, end = ss.split('-')
     339            return float(start), float(end)
     340        else:
     341            return float(ss), float(ss) + 1
     342    return [parse_interval(ss) for ss in s.split(',')]
     343
     344def check_time_of_day(hours):
     345    from datetime import datetime
     346    now = datetime.now()
     347    hour = now.hour + now.minute / 60.
     348    for start, end in parse_time_of_day(hours):
     349        if start < end:
     350            if start <= hour <= end:
     351                return True
     352        elif hour <= end or start <= hour:
     353            return True
     354    return False
     355
     356def get_conf(path, server, **overrides):
     357    if path is None:
     358        unicode_conf = {}
     359    else:
     360        unicode_conf = json.load(open(path))
     361    # defaults
     362    conf = {
     363        "idle": 300,
     364        "time_of_day": "0-0", # midnight-midnight
     365        "parallelism": 3,
     366        "timeout": 3 * 60 * 60,
     367        "plugins": ["plugins.commit_messages",
     368                    "plugins.coverage",
     369                    "plugins.trailing_whitespace",
     370#                    "plugins.docbuild"
     371                    ],
     372        "bonus": {},
     373        "machine": machine_data(),
     374        "machine_match": 3,
     375        "user": getpass.getuser(),
     376    }
     377    default_bonus = {
     378        "needs_review": 1000,
     379        "positive_review": 500,
     380        "blocker": 100,
     381        "critical": 50,
     382    }
     383    for key, value in unicode_conf.items():
     384        conf[str(key)] = value
     385    for key, value in default_bonus.items():
     386        if key not in conf['bonus']:
     387            conf['bonus'][key] = value
     388    conf.update(overrides)
     389    if "trusted_authors" not in conf:
     390        conf["trusted_authors"] = default_trusted_authors(server)
     391
     392    def locate_plugin(name):
     393        ix = name.rindex('.')
     394        module = name[:ix]
     395        name = name[ix+1:]
     396        plugin = getattr(__import__(module, fromlist=[name]), name)
     397        assert callable(plugin)
     398        return plugin
     399    conf["plugins"] = [(name, locate_plugin(name)) for name in conf["plugins"]]
     400    return conf
     401
     402def main(args):
     403    global conf
     404
     405    # Most configuration is done in the config file, which is reread between
     406    # each ticket for live configuration of the patchbot.
     407    parser = OptionParser()
     408    parser.add_option("--config", dest="config")
     409    parser.add_option("--sage-root", dest="sage_root", default=os.environ.get('SAGE_ROOT'))
     410    parser.add_option("--server", dest="server", default="http://patchbot.sagemath.org/")
     411    parser.add_option("--count", dest="count", default=1000000)
     412    parser.add_option("--ticket", dest="ticket", default=None)
     413    parser.add_option("--list", dest="list", default=False)
     414    parser.add_option("--skip-base", dest="skip_base", default=False)
     415    (options, args) = parser.parse_args(args)
     416
     417    conf_path = options.config and os.path.abspath(options.config)
     418    if options.ticket:
     419        tickets = [int(t) for t in options.ticket.split(',')]
     420        count = len(tickets)
     421    else:
     422        tickets = None
     423        count = int(options.count)
     424
     425    conf = get_conf(conf_path, options.server)
     426    if options.list:
     427        for score, ticket in get_ticket(base=get_base(options.sage_root), server=options.server, return_all=True, **conf):
     428            print score, ticket['id'], ticket['title']
     429            print ticket
     430            print
     431        sys.exit(0)
     432
     433    print "WARNING: Assuming sage-main is pristine."
     434    if options.sage_root == os.environ.get('SAGE_ROOT'):
     435        print "WARNING: Do not use this copy of sage while the patchbot is running."
     436
     437    if not options.skip_base:
     438        clean = lookup_ticket(options.server, 0)
     439        def good(report):
     440            return report['machine'] == conf['machine'] and report['status'] == 'TestsPassed'
     441        if not any(good(report) for report in current_reports(clean, base=get_base(options.sage_root))):
     442            res = test_a_ticket(ticket=0, sage_root=options.sage_root, server=options.server)
     443            if res != 'TestsPassed':
     444                print "\n\n"
     445                while True:
     446                    print "Failing tests in your install: %s. Continue anyways? [y/N] " % res
     447                    ans = sys.stdin.readline().lower().strip()
     448                    if ans == '' or ans[0] == 'n':
     449                        sys.exit(1)
     450                    elif ans[0] == 'y':
     451                        break
     452
     453    for _ in range(count):
     454        if tickets:
     455            ticket = tickets.pop(0)
     456        else:
     457            ticket = None
     458        conf = get_conf(conf_path, options.server)
     459        if check_time_of_day(conf['time_of_day']):
     460            test_a_ticket(ticket=ticket, sage_root=options.sage_root, server=options.server)
     461        else:
     462            print "Idle."
     463            time.sleep(conf['idle'])
     464
     465if __name__ == '__main__':
     466    # allow this script to serve as a single entry point for bots and the server
     467    args = list(sys.argv)
     468    if len(args) > 1 and args[1] == '--serve':
     469        del args[1]
     470        from serve import main
     471    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=21001", "--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
     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    if 'query' in request.args:
     40        query = json.loads(request.args.get('query'))
     41    else:
     42        status = request.args.get('status', 'needs_review')
     43        if status == 'all':
     44            query = {}
     45        elif status in ('new', 'closed'):
     46            query = {'status': {'$regex': status + '.*' }}
     47        elif status in ('open'):
     48            query = {'status': {'$regex': 'needs_.*|positive_review' }}
     49        else:
     50            query = {'status': status}
     51        if 'todo' in request.args:
     52            query['patches'] = {'$not': {'$size': 0}}
     53            query['spkgs'] = {'$size': 0}
     54        if 'authors' in request.args:
     55            authors = request.args.get('authors').split(':')
     56            query['authors'] = {'$in': authors}
     57    if 'order' in request.args:
     58        order = request.args.get('order')
     59    else:
     60        order = 'last_activity'
     61    if 'base' in request.args:
     62        base = request.args.get('base')
     63        if base == 'all':
     64            base = None
     65    else:
     66        base = global_base
     67    if 'author' in request.args:
     68        query['authors'] = request.args.get('author')
     69    if 'participant' in request.args:
     70        query['participants'] = request.args.get('participant')
     71    all = patchbot.filter_on_authors(tickets.find(query).sort(order), authors)
     72    if 'raw' in request.args:
     73        if 'pretty' in request.args:
     74            indent = 4
     75        else:
     76            indent = None
     77        response = make_response(json.dumps(list(all), default=lambda x: None, indent=indent))
     78        response.headers['Content-type'] = 'text/plain'
     79        return response
     80    summary = dict((key, 0) for key in status_order)
     81    def preprocess(all):
     82        for ticket in all:
     83            ticket['report_count'], ticket['report_status'], ticket['report_status_composite'] = get_ticket_status(ticket, base)
     84            if 'reports' in ticket:
     85                ticket['pending'] = len([r for r in ticket['reports'] if r['status'] == 'Pending'])
     86            summary[ticket['report_status']] += 1
     87            yield ticket
     88    ticket0 = db.lookup_ticket(0)
     89    versions = list(set(report['base'] for report in ticket0['reports']))
     90    versions.sort(trac.compare_version)
     91    versions = [(v, get_ticket_status(ticket0, v)) for v in versions if v != '4.7.']
     92    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)
     93
     94def format_patches(ticket, patches, deps=None, required=None):
     95    if deps is None:
     96        deps = []
     97    if required is not None:
     98        required = set(required)
     99    def format_item(item):
     100        if required is None or item in required:
     101            note = ""
     102        else:
     103            note = "<span style='color: red'>(mismatch)</span>"
     104        item = str(item)
     105        if '#' in item:
     106            url = trac.get_patch_url(ticket, item, raw=False)
     107            title = item
     108        elif '.' in item:
     109            url = '/?base=%s' % item
     110            title = 'sage-%s' % item
     111        else:
     112            url = '/ticket/%s' % item
     113            title = '#%s' % item
     114        return "<a href='%s'>%s</a> %s" % (url, title, note)
     115
     116    missing_deps = missing_patches = ''
     117    if required is not None:
     118        required_patches_count = len([p for p in required if '#' in str(p)])
     119        if len(deps) < len(required) - required_patches_count:
     120            missing_deps = "<li><span style='color: red'>(missing deps)</span>\n"
     121        if len(patches) < required_patches_count:
     122            missing_patches = "<li><span style='color: red'>(missing patches)</span>\n"
     123    return ("<ol>"
     124        + missing_deps
     125        + "<li>\n"
     126        + "\n<li>".join(format_item(patch) for patch in (deps + patches))
     127        + missing_patches
     128        + "</ol>")
     129
     130@app.route("/ticket/<int:ticket>/")
     131def render_ticket(ticket):
     132    try:
     133        info = trac.scrape(ticket, db=db, force='force' in request.args)
     134    except:
     135        info = tickets.find_one({'id': ticket})
     136    if info is None:
     137        return "No such ticket."
     138    if 'kick' in request.args:
     139        info['retry'] = True
     140        db.save_ticket(info)
     141    if 'reports' in info:
     142        info['reports'].sort(lambda a, b: -cmp(a['time'], b['time']))
     143    else:
     144        info['reports'] = []
     145
     146    old_reports = list(info['reports'])
     147    patchbot.prune_pending(info)
     148    if old_reports != info['reports']:
     149        db.save_ticket(info)
     150
     151    def format_info(info):
     152        new_info = {}
     153        for key, value in info.items():
     154            if key == 'patches':
     155                new_info['patches'] = format_patches(ticket, value)
     156            elif key == 'reports' or key == 'pending':
     157                pass
     158            elif key == 'depends_on':
     159                new_info[key] = ', '.join("<a href='/ticket/%s'>%s</a>" % (a, a) for a in value)
     160            elif key == 'authors':
     161                new_info[key] = ', '.join("<a href='/ticket/?author=%s'>%s</a>" % (a,a) for a in value)
     162            elif key == 'participants':
     163                new_info[key] = ', '.join("<a href='/ticket/?participant=%s'>%s</a>" % (a,a) for a in value)
     164            elif isinstance(value, list):
     165                new_info[key] = ', '.join(value)
     166            elif key not in ('id', '_id'):
     167                new_info[key] = value
     168        return new_info
     169    def preprocess_reports(all):
     170        for item in all:
     171            if 'patches' in item:
     172                required = info['depends_on'] + info['patches']
     173                item['patch_list'] = format_patches(ticket, item['patches'], item.get('deps'), required)
     174            if item['base'] != base:
     175                item['base'] = "<span style='color: red'>%s</span>" % item['base']
     176            if 'time' in item:
     177                item['log'] = log_name(info['id'], item)
     178            yield item
     179    return render_template("ticket.html", reports=preprocess_reports(info['reports']), ticket=ticket, info=format_info(info), status=get_ticket_status(info, base=base)[2])
     180
     181# The fact that this image is in the trac template lets the patchbot know
     182# when a page gets updated.
     183@app.route("/ticket/<int:ticket>/status.png")
     184def render_ticket_status(ticket):
     185    try:
     186        info = trac.scrape(ticket, db=db)
     187    except:
     188        info = tickets.find_one({'id': ticket})
     189    status = get_ticket_status(info, base=base)[2]
     190    response = make_response(create_status_image(status))
     191    response.headers['Content-type'] = 'image/png'
     192    response.headers['Cache-Control'] = 'no-cache'
     193    return response
     194
     195def get_or_set(ticket, key, default):
     196    if key in ticket:
     197        value = ticket[key]
     198    else:
     199        value = ticket[key] = default
     200    return value
     201
     202@app.route("/report/<int:ticket_id>", methods=['POST'])
     203def post_report(ticket_id):
     204    try:
     205        ticket = db.lookup_ticket(ticket_id)
     206        if ticket is None:
     207            ticket = trac.scrape(ticket_id)
     208        if 'reports' not in ticket:
     209            ticket['reports'] = []
     210        report = json.loads(request.form.get('report'))
     211        assert isinstance(report, dict)
     212        for fld in ['status', 'patches', 'spkgs', 'base', 'machine', 'time']:
     213            assert fld in report
     214        patchbot.prune_pending(ticket, report['machine'])
     215        ticket['reports'].append(report)
     216        if report['status'] != 'Pending':
     217            db.logs.put(request.files.get('log'), _id=log_name(ticket_id, report))
     218        if 'retry' in ticket:
     219            ticket['retry'] = False
     220        ticket['last_activity'] = now_str()
     221        db.save_ticket(ticket)
     222        return "ok"
     223    except:
     224        traceback.print_exc()
     225        return "error"
     226
     227def log_name(ticket_id, report):
     228    return "/log/%s/%s/%s" % (ticket_id, '/'.join(report['machine']), report['time'])
     229
     230
     231def shorten(lines):
     232    timing = re.compile(r'\s*\[\d+\.\d* s\]\s*$')
     233    skip = re.compile(r'(sage -t.*\(skipping\))|(byte-compiling)|(copying)|(\S+: \d+% \(\d+ of \d+\))$')
     234    gcc = re.compile('(gcc)|(g\+\+)')
     235    prev = None
     236    for line in StringIO(lines):
     237        if skip.match(line):
     238            pass
     239        elif prev is None:
     240            prev = line
     241        elif prev.startswith('sage -t') and timing.match(line):
     242            prev = None
     243        elif prev.startswith('python `which cython`') and '-->' in line:
     244            prev = None
     245        elif gcc.match(prev) and (gcc.match(line) or line.startswith('Time to execute')):
     246            prev = line
     247        else:
     248            yield prev
     249            prev = line
     250
     251    if prev is not None:
     252        yield prev
     253
     254def extract_plugin_log(data, plugin):
     255    from patchbot import plugin_boundary
     256    start = plugin_boundary(plugin) + "\n"
     257    end = plugin_boundary(plugin, end=True) + "\n"
     258    all = []
     259    include = False
     260    for line in StringIO(data):
     261        if line == start:
     262            include = True
     263        if include:
     264            all.append(line)
     265        if line == end:
     266            break
     267    return ''.join(all)
     268
     269@app.route("/ticket/<id>/log/<path:log>")
     270def get_ticket_log(id, log):
     271    return get_log(log)
     272
     273@app.route("/log/<path:log>")
     274def get_log(log):
     275    path = "/log/" + log
     276    if not logs.exists(path):
     277        data = "No such log!"
     278    else:
     279        data = bz2.decompress(logs.get(path).read())
     280    if 'plugin' in request.args:
     281        data = extract_plugin_log(data, request.args.get('plugin'))
     282    if 'short' in request.args:
     283        response = Response(shorten(data), direct_passthrough=True)
     284    else:
     285        response = make_response(data)
     286    response.headers['Content-type'] = 'text/plain'
     287    return response
     288
     289status_order = ['New', 'ApplyFailed', 'BuildFailed', 'TestsFailed', 'PluginFailed', 'TestsPassed', 'Pending', 'NoPatch', 'Spkg']
     290# TODO: cleanup old records
     291# status_order += ['started', 'applied', 'built', 'tested']
     292
     293status_colors = {
     294    'New'        : 'white',
     295    'ApplyFailed': 'red',
     296    'BuildFailed': 'red',
     297    'TestsFailed': 'yellow',
     298    'TestsPassed': 'green',
     299    'PluginFailed': 'blue',
     300    'Pending'    : 'white',
     301    'NoPatch'    : 'purple',
     302    'Spkg'       : 'purple',
     303}
     304
     305@app.route("/blob/<status>")
     306def status_image(status):
     307    response = make_response(create_status_image(status))
     308    response.headers['Content-type'] = 'image/png'
     309    response.headers['Cache-Control'] = 'max-age=3600'
     310    return response
     311
     312def create_status_image(status):
     313    if ',' in status:
     314        status_list = status.split(',')
     315        if len(set(status_list)) == 1:
     316            status = status_list[0]
     317        else:
     318            try:
     319                from PIL import Image
     320                import numpy
     321                path = 'images/_cache/' + ','.join(status_list) + '-blob.png'
     322                if not os.path.exists(path):
     323                    composite = numpy.asarray(Image.open(status_image(status_list[0]))).copy()
     324                    height, width, _ = composite.shape
     325                    for ix, status in enumerate(reversed(status_list)):
     326                        slice = numpy.asarray(Image.open(status_image(status)))
     327                        start = ix * width / len(status_list)
     328                        end = (ix + 1) * width / len(status_list)
     329                        composite[:,start:end,:] = slice[:,start:end,:]
     330                    if not os.path.exists('images/_cache'):
     331                        os.mkdir('images/_cache')
     332                    Image.fromarray(composite, 'RGBA').save(path)
     333                return open(path).read()
     334            except ImportError:
     335                print "here"
     336                status = min_status(status_list)
     337    return open(status_image(status)).read()
     338
     339def status_image(status):
     340    return 'images/%s-blob.png' % status_colors[status]
     341
     342def min_status(status_list):
     343    index = min(status_order.index(status) for status in status_list)
     344    return status_order[index]
     345
     346@app.route("/robots.txt")
     347def robots():
     348    return """
     349User-agent: *
     350Disallow: /ticket/1303/status.png
     351Disallow: /blob/
     352Crawl-delay: 5
     353    """.lstrip()
     354
     355@app.route("/favicon.ico")
     356def robots():
     357    response = make_response(open('images/%s-blob.png' % status_colors['TestsPassed']).read())
     358    response.headers['Content-type'] = 'image/png'
     359    return response
     360
     361def get_ticket_status(ticket, base=None):
     362    all = current_reports(ticket, base=base)
     363    if len(all):
     364        status_list = [report['status'] for report in all]
     365        if len(set(status_list)) == 1:
     366            composite = single = status_list[0]
     367        else:
     368            composite = ','.join(status_list)
     369            single = min_status(status_list)
     370        return len(all), single, composite
     371    elif ticket['spkgs']:
     372        return 0, 'Spkg', 'Spkg'
     373    elif not ticket['patches']:
     374        return 0, 'NoPatch', 'NoPatch'
     375    else:
     376        return 0, 'New', 'New'
     377
     378def main(args):
     379    parser = OptionParser()
     380    parser.add_option("-b", "--base", dest="base")
     381    parser.add_option("-p", "--port", dest="port")
     382    parser.add_option("--debug", dest="debug", default=True)
     383    (options, args) = parser.parse_args(args)
     384
     385    global global_base, base
     386    global_base = base = options.base
     387    app.run(debug=options.debug, host="0.0.0.0", port=int(options.port))
     388
     389if __name__ == '__main__':
     390    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="/blob/{{status}}">
     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>{{'/'.join(report.machine)}}</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 current_reports(ticket, base=None, unique=False):
     29    if 'reports' not in ticket:
     30        return []
     31    if unique:
     32        seen = set()
     33        def first(x):
     34            if x in seen:
     35                return False
     36            else:
     37                seen.add(x)
     38                return True
     39    else:
     40        first = lambda x: True
     41    reports = list(ticket['reports'])
     42    reports.sort(lambda a, b: cmp(b['time'], a['time']))
     43    return filter(lambda report: (ticket['patches'] == report['patches'] and
     44                                  ticket['spkgs'] == report['spkgs'] and
     45                                  ticket['depends_on'] == (report.get('deps') or []) and
     46                                  (not base or base == report['base'])) and
     47                                  first('/'.join(report['machine'])),
     48                      reports)
     49
     50def do_or_die(cmd):
     51    print cmd
     52    res = os.system(cmd)
     53    if res:
     54        raise Exception, "%s %s" % (res, cmd)
     55
     56def extract_version(s):
     57    m = re.search(r'\d+(\.\d+)+(\.\w+)', s)
     58    if m:
     59        return m.group(0)
     60
     61def compare_version(a, b):
     62    a = a.replace("beta", "beta.")
     63    b = b.replace("beta", "beta.")
     64    a += '.z'
     65    b += '.z'
     66    def maybe_int(s):
     67        try:
     68            return 1, int(s)
     69        except ValueError:
     70            return 0, s
     71    return cmp([maybe_int(v) for v in a.split('.')],
     72               [maybe_int(v) for v in b.split('.')])
     73
     74def get_base(sage_root):
     75    p = subprocess.Popen([os.path.join(sage_root, 'sage'), '-v'], stdout=subprocess.PIPE)
     76    if p.wait():
     77        raise ValueError, "Invalid sage_root='%s'" % sage_root
     78    version_info = p.stdout.read()
     79    return re.search(r'Sage Version ([\d.]+\w*)', version_info).groups()[0]