#! /usr/bin/python

import subprocess
import json
import os
import sys
import errno
import shutil
import traceback
import glob
import ast
import pprint
import time
import datetime
import dateutil.parser
import fnmatch

dry_run = False
verbose = False
tail_log = False

new_style = False

is_remote = False

keep_work_dirs = 30
work = "%s/hubbot/" % os.environ['HOME']
work_url = "http://files.cockpit-project.org/hubbot"

test_data = "%s/cockpit-data/" % os.environ['HOME']

stats_filename = os.path.join(work, "hubbot.persistent")
stats_filename_remote = os.path.join(work_url, "hubbot.persistent")

current_master_filename = os.path.join(work, "hubbot.current_master")

# store when we pulled master info (sha)
timestamp_master_fetched = None

status_images_path = os.path.join(work, "status-images")

config = ast.literal_eval(open("%s/.hubbotrc" % os.environ['HOME']).read())

github_token = config['github_token']
user_whitelist = config['user_whitelist']

master_repo = 'cockpit-project/cockpit'
if 'master_repo' in config:
    master_repo = config['master_repo']

if 'dry_run' in config:
    dry_run = config['dry_run']

if 'remote' in config:
    is_remote = config['remote']

if 'keep_work_dirs' in config:
    keep_work_dirs = config['keep_work_dirs']

priority_default = 0
priority_needs_attention = -1
priority_work_in_progress = -2

# this is the priority for pull requests when master has changed
# tags and other modifiers will be ignored
priority_master_changed = 1

# if an essential check has failed, other checks get this new priority
# only affects pull requests
# tags and other modifiers will be ignored
priority_essential_failed = 2

if 'priority_default' in config:
    priority_default = config['priority_default']
if 'priority_needs_attention' in config:
    priority_needs_attention = config['priority_needs_attention']
if 'priority_work_in_progress' in config:
    priority_work_in_progress = config['priority_work_in_progress']
if 'priority_master_changed' in config:
    priority_master_changed = config['priority_master_changed']
if 'priority_essential_failed' in config:
    priority_essential_failed = config['priority_essential_failed']

""" priority:
  <=  0: disabled, pull requests: only with special tag
         with special tag: same as essential
  >= 10: non-essential, pull requests: status only visible once started, run when hubbot is idle
  >= 20: essential
"""
priority_threshold_disabled = 0
priority_threshold_non_essential = 10
priority_threshold_essential = 20

priority_triggered = priority_threshold_essential

os_arch_configurations = [{'os': 'fedora-22',
                           'short': 'f22',
                           'arch': 'x86_64',
                           'offset_master': 20,
                           'offset_pull': 20,
                           'publish_images': True
                          },
                          {'os': 'rhel-7',
                           'short': 'r7',
                           'arch': 'x86_64',
                           'offset_master': 20,
                           'offset_pull': 20,
                           'publish_images': False
                          },
                          {'os': 'fedora-rawhide',
                           'short': 'fraw',
                           'arch': 'x86_64',
                           'offset_master': 10,
                           'offset_pull': 0,
                           'publish_images': True
                          },
                          {'os': 'fedora-atomic-22',
                           'short': 'f22-atomic',
                           'arch': 'x86_64',
                           'offset_master': 10,
                           'offset_pull': 0,
                           'publish_images': True
                          },
                          {'os': 'fedora-22-testing',
                           'short': 'f22-t',
                           'arch': 'x86_64',
                           'offset_master': 10,
                           'offset_pull': 0,
                           'publish_images': True
                          }
                         ]
default_arch_config = os_arch_configurations[0]
if 'os_arch' in config and config['os_arch']:
    os_arch_configurations = config['os_arch']
    if os_arch_configurations and len(os_arch_configurations) > 0:
        default_arch_config = os_arch_configurations[0]

default_os = default_arch_config['os']
default_short = default_arch_config['short']
default_arch = default_arch_config['arch']

pull_status_skip = 'skip'
pull_status_pending = 'pending'
pull_status_running = 'running'
pull_status_finished = 'finished'

status_message_running = 'Hubbot is busy'
status_message_master_changed = 'Hubbot was happy but will do this again because master has changed'

"""
 These variables are loaded from and stored in persistent storage
 It's triggered manually via load_persistent() and store_persistent()
"""

"""
master is stored via 'config' keys
  last_successful_master[config] should contain ['sha', 'status', 'comment', 'checked-timestamp', 'committed-timestamp', 'hubbot_link'] or None
e.g. last_successful_master['hubbot/r7/x86-64'] = {
        'sha': 'c89a9e1eb5611671258dbff950d463bd8e39cf99',
        'status': 'finished',
        'comment': 'passed',
        'checked-timestamp': '2015-04-01T16:30:52Z',
        'committed-timestamp': '2015-04-01T16:00:49Z',
        'hubbot_link': 'http://files.cockpit-project.org/hubbot/c89a9e1eb5611671258dbff950d463bd8e39cf99/hubbot.html'
    }
"""
last_successful_master = { }
last_master = { }

"""
currently running jobs
 running_job should contain ['config', 'is_master', 'sha', 'hubbot_link']
 e.g. running_jobs[0] = {
        'config': 'hubbot/r7/x86-64',
        'is_master': True,
        'sha': 'c89a9e1eb5611671258dbff950d463bd8e39cf99',
        'hubbot_link': 'http://files.cockpit-project.org/hubbot/c89a9e1eb5611671258dbff950d463bd8e39cf99/hubbot.html'
    }
"""
running_jobs = [ ]

"""
Cache for user html pages
 user_pages[login] = url
 e.g. user_pages['octocat'] = 'https://github.com/octocat/'
 Note that these are usually only updated during scanning, which in turn doesn't write persistent data
"""
user_pages = { }

# we don't use any locking here
# stats might become confused when there is a partial file
# but that will right itself during the next run
# only verification hubbot writes to the file, and that is a singleton
def load_persistent():
    global last_successful_master
    global last_master
    global running_jobs
    global user_pages
    last_successful_master = {}
    last_master = {}
    running_jobs = []
    user_pages = {}
    data = None
    if os.path.isfile(stats_filename):
        with open(stats_filename, "r") as stats_file:
            try:
                data = json.loads(stats_file.read())
            except:
                data = None
    elif is_remote:
        data = subprocess.check_output ([ "curl", "-s", stats_filename_remote ])
        if "404 Not Found" in data:
            return
        else:
            try:
                data = json.loads(data)
            except:
                data = None
    else:
        return
    if data is None:
        return
    trace("loaded persistent data: %s" % data)
    if 'last_successful_master' in data:
        last_successful_master = {k: v for k, v in data['last_successful_master'].iteritems() if type(v) is dict}
    if 'last_master' in data:
        last_master = {k: v for k, v in data['last_master'].iteritems() if type(v) is dict}
    if 'running_jobs' in data:
        running_jobs = filter(lambda e: type(e) is dict, data['running_jobs'])
    if 'user_pages' in data:
        user_pages = data['user_pages']

def store_persistent():
    global last_successful_master
    global last_master
    global running_jobs
    global user_pages
    data = json.dumps({ 'last_successful_master': last_successful_master,
             'last_master': last_master,
             'running_jobs': running_jobs,
             'user_pages': user_pages
           })
    trace("writing persistent data: %s" % data)
    if not dry_run:
        # no option for remote here since we can only store locally
        with open(stats_filename, "w") as stats_file:
            stats_file.write(data)

def store_current_master(sha):
    with open(current_master_filename, "w") as master_file:
        master_file.write(sha)

def load_current_master():
    if os.path.isfile(current_master_filename):
        with open(current_master_filename, "r") as master_file:
            dt = datetime.datetime.utcfromtimestamp(os.path.getmtime(current_master_filename))
            return (master_file.read().strip(), dt)
    else:
        if is_remote:
            path = os.path.join(work_url, "hubbot.current_master")
            sha = subprocess.check_output ([ "curl", "-s", path ]).strip()
            if len(sha) > 40 or "404 Not Found" in sha:
                return ("", None)
            else:
                # get timestamp
                try:
                    dt = subprocess.check_output (["curl", "-s", "-v", "-X", "HEAD", path, "2>&1",
                                                   "|", "grep",  "'^< Last-Modified:'",
                                                   "|", "cut", "-c", "18-"
                                                  ])
                    dt = datetime.datetime.utcfromtimestamp(dateutil.parser.parse(dt))
                    return (sha, dt)
                except:
                    # if we don't get a good time from the server, consider the info to be current
                    return (sha, datetime.datetime.utcnow())
        else:
            return ("", datetime.datetime.utcnow())

def status_context_from_config(config):
    if config is None:
        config = default_arch_config
    n_suffix = ""
    if "HUBBOT_NEW_STYLE" in os.environ and os.environ["HUBBOT_NEW_STYLE"]:
        n_suffix = "/new"
    return "hubbot/%s/%s%s" % (config['short'], config['arch'].replace('_', '-'), n_suffix)

def status_display_name_from_config(config):
    if config is None:
        config = default_arch_config
    n_suffix = ""
    if "HUBBOT_NEW_STYLE" in os.environ and os.environ["HUBBOT_NEW_STYLE"]:
        n_suffix = "/new"
    return "%s/%s%s" % (config['os'], config['arch'].replace('_', '-'), n_suffix)

def status_image_from_config(config, status):
    if "HUBBOT_NEW_STYLE" in os.environ and os.environ["HUBBOT_NEW_STYLE"]:
        return os.path.join(status_images_path, "%s-%s-new-%s.svg" % (config['short'], config['arch'], status))
    return os.path.join(status_images_path, "%s-%s-%s.svg" % (config['short'], config['arch'], status))

def status_filename_from_config(config):
    if "HUBBOT_NEW_STYLE" in os.environ and os.environ["HUBBOT_NEW_STYLE"]:
        return os.path.join(work, "status-%s-%s-new.svg" % (config['short'], config['arch']))
    return os.path.join(work, "status-%s-%s.svg" % (config['short'], config['arch']))

def set_build_status(status_context, status):
    for config in os_arch_configurations:
        if status_context == status_context_from_config(config):
            img_file = status_image_from_config(config, status)
            status_file = status_filename_from_config(config)
            cmd("ln", "-f", "-s", os.path.join("status-images", os.path.basename(img_file)), status_file)

def trace(msg):
    if verbose:
        print msg

def mk_exclusive_dir(path):
    try:
        os.mkdir(path)
        return True
    except OSError as e:
        if e.errno == errno.EEXIST:
            return False
        else:
            raise e

class WorkDir(object):
    def __init__(self, name):
        name = name.replace('hubbot_', '')
        i = 0
        n = name
        while not mk_exclusive_dir (os.path.join (work, n)):
            i = i + 1
            n = name + "." + str(i)
        self.path = os.path.join (work, n)
        if work_url:
            self.url = os.path.join (work_url, n, "hubbot.html")

    def __enter__(self):
        trace("Entering %s" % self.path)
        if not dry_run:
            self.oldcwd = os.getcwd()
            os.chdir(self.path)
            self.log = subprocess.Popen("hublog2html >hubbot.html", stdin=subprocess.PIPE, shell=True, bufsize=0)
            if tail_log:
                self.tail = subprocess.Popen([ "tail", "-f", "hubbot.log" ])
            else:
                self.tail = None
            self.old_stdout, self.old_stderr = sys.stdout, sys.stderr
            self.old_stdout.flush(); self.old_stderr.flush()
            sys.stdout = self.log.stdin
            sys.stderr = self.log.stdin
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if not dry_run:
            sys.stdout = self.old_stdout
            sys.stderr = self.old_stderr
            self.log.stdin.close()
            self.log.wait()
            if self.tail:
                self.tail.kill()
                self.tail.wait()
        trace("Leaving %s" % self.path)

def list_work_dirs():
    def is_work_dir(dirname):
        if dirname in ["data", "status-images"] or not os.path.isdir(os.path.join(work,dirname)):
            return False
        return True
    return map(lambda xy: xy[1],
               sorted (map (lambda e: (os.stat(e).st_mtime,e),
                            map (lambda e: os.path.join(work,e),
                                 filter (is_work_dir, os.listdir(work)))),
                       lambda x,y: cmp(x[0],y[0])))

def expire_work_dirs(github_visible_commits):
    # if we don't have info on what's visible, don't delete anything
    if not github_visible_commits:
        return
    # make sure we don't look at too many commits
    github_visible_commits = list(set(github_visible_commits))
    def is_redundant_directory(dirname):
        for c in github_visible_commits:
            if c in dirname:
                return False
        return True
    dirs = filter (is_redundant_directory, list_work_dirs())
    if len(dirs) > keep_work_dirs:
        for d in dirs[0:len(dirs)-keep_work_dirs]:
            trace("Expiring %s" % d)
            if not dry_run:
                shutil.rmtree(d)

def expire_images():
    keep = { }

    def keep_image(flavor, os_, tag):
        keep["%s-%s-*-%s-checksum" % (flavor, os_, tag)] = True
        keep["%s-%s-*-%s.*" % (flavor, os_, tag)] = True

    for d in list_work_dirs():
        t = os.path.join(d, "test")
        if os.path.exists(t):
            for c in filter(lambda e: e.endswith(".conf"), os.listdir(t)):
                flavor = c[:-5]
                try:
                    conf = ast.literal_eval(open(os.path.join(t, c)).read())
                    if 'tags' in conf:
                        tags = conf['tags']
                        for os_ in tags:
                            keep_image (flavor, os_, tags[os_])
                except:
                    print "error reading %s" % c
                    pass

    images_dir = os.path.join(test_data, "images")
    expired_dir = os.path.join(test_data, "images.expired")
    for f in os.listdir(images_dir):
        remove = True
        for k in keep:
            if fnmatch.fnmatch(f, k):
                remove = False
                break
        if remove:
            trace("Expiring %s" % f)
            if not dry_run:
                shutil.move(os.path.join(images_dir, f), expired_dir)

def cmd(*args):
    print "+", " ".join(list(args))
    subprocess.check_call(list(args), stdout=sys.stdout, stderr=sys.stderr)

def cmd_output(*args):
    return subprocess.check_output(list(args))

def git(*args):
    cmd("git", *args)

def git_output(*args):
    return cmd_output("git", *args)

def curl_authenticated(url, data=None):
    github_username = 'user = "%s:x-oauth-basic"' % (github_token)
    cmd = ["curl", "--silent", "--fail"]
    if data != None:
        cmd += ["-d", data]
    cmd += ["--config", "-", url]
    # pass token via stdin so it doesn't show up in traces or the process list
    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None)
    stdoutdata = p.communicate(input=github_username)[0]
    if p.returncode != 0:
        raise OSError("curl failed for url '%s' (%d)" % (url, p.returncode))
    return stdoutdata

def github_get_authenticated(url):
    return json.loads(curl_authenticated(url))

def github_get(url):
    # we use our token even for this due to rate limitation in github API for non authenticated requests
    return github_get_authenticated("https://api.github.com/repos/%s/%s" % (master_repo, url))

def add_github_comment(issue, message):
    curl_authenticated("https://api.github.com/repos/%s/issues/%s/comments" % (master_repo, issue),
                       data = json.dumps({"body": message}))

def set_github_status(repo, sha, status):
    if dry_run:
        print "Status %s: %s %s" % (sha, status['state'], status['description'])
    else:
        curl_authenticated("https://api.github.com/repos/%s/statuses/%s" % (repo, sha),
                           data = json.dumps(status))

def add_comment(issue, message):
    if issue:
        add_github_comment(issue, message)
    else:
        print "+++ %s" % message

def verify(head, config, is_master=False, clean=False):
    status_context = status_context_from_config(config)

    if not is_master:
        pull_repo = head['repo']['full_name']
        ref = head['ref']
        sha = head['sha']
        rebase_done = False
    else:
        sha = head['commit']['sha']
        commit_date = ""
        if 'committer' in head['commit']['commit'] and 'date' in head['commit']['commit']['committer']:
            commit_date = head['commit']['commit']['committer']['date']
    with WorkDir("%s_%s" % (sha, status_context.replace('/', '_'))) as wd:
        load_persistent()
        global last_master
        global last_successful_master
        global running_jobs
        global timestamp_master_fetched
        run_entry = {
                      'config': status_context,
                      'is_master': is_master,
                      'sha': sha,
                      'hubbot_link': wd.url
                    }
        running_jobs.append(run_entry)
        store_persistent()
        try:
            set_github_status(master_repo, sha, { "state": "pending" if not new_style else "success",
                                                  "description": status_message_running,
                                                  "target_url": wd.url,
                                                  "context": status_context })
            # update status after setting github status - this might help syncing with scan
            if is_master:
                set_build_status(status_context, 'running')
            if not dry_run:
                git("init")
                if not is_master:
                    git("fetch", "https://github.com/%s.git" % pull_repo, ref)
                    branch = git_output("rev-parse", "FETCH_HEAD").strip()
                    git("checkout", "FETCH_HEAD")
                    git("fetch", "https://github.com/%s.git" % master_repo, "master")
                    master = git_output("rev-parse", "FETCH_HEAD").strip()
                    with open("hubbot.master", "w") as master_file:
                        master_file.write("%s\n" % master)
                    git("rebase", "FETCH_HEAD")
                    rebase_done = True
                else:
                    git("fetch", "https://github.com/%s.git" % master_repo, "master")
                    git("checkout", "FETCH_HEAD")
                    # make sure we test exactly the specified revision, maybe someone pushed a new commit in the interim
                    git("reset", "--hard", sha)

                if new_style:
                    os.putenv("GUESTOS", "fedora22-cockpit")
                    os.putenv("GUEST_NUMBER", "22")
                    cmd("./VERIFY.new")
                else:
                    os.putenv("TEST_OS", default_os)
                    os.putenv("TEST_ARCH", default_arch)
                    for config in os_arch_configurations:
                        if status_context == status_context_from_config(config):
                            os.putenv("TEST_OS", config['os'])
                            os.putenv("TEST_ARCH", config['arch'])
                            break
                    os.putenv("TEST_DATA", test_data)
                    os.putenv("TEST_JOBS", "4")
                    if clean:
                        cmd("./VERIFY", "--clean")
                    else:
                        cmd("./VERIFY")
            set_github_status(master_repo, sha, { "state": "success",
                                                  "description": "Hubbot is happy",
                                                  "target_url": wd.url,
                                                  "context": status_context })
            if is_master:
                # reload from disk in case someone else wrote
                load_persistent()
                last_master[status_context] = { 'sha': sha,
                                                'status': 'finished',
                                                'comment': 'passed',
                                                'checked-timestamp': datetime.datetime.now().isoformat(),
                                                'committed-timestamp': commit_date,
                                                'hubbot_link': wd.url
                                              }
                last_successful_master[status_context] = last_master[status_context]
                # only set build status if scan didn't detect a new master in the meantime
                # unless info in the file is outdated
                (n_sha, modified) = load_current_master()
                info_is_current = modified and (modified < timestamp_master_fetched)
                sha_in_file_matches = n_sha and (n_sha == sha)
                if (info_is_current and sha_in_file_matches) or not info_is_current:
                    set_build_status(status_context, 'passed')
        except:
            traceback.print_exc()
            if is_master:
                description = "Hubbot is sad"
            else:
                if rebase_done:
                    description = "Hubbot is sad"
                else:
                    description = "Hubbot needs help with rebasing"
            set_github_status(master_repo, sha, { "state": "failure" if not new_style else "success",
                                                  "description": description,
                                                  "target_url": wd.url,
                                                  "context": status_context })
            if is_master and not dry_run:
                # reload from disk in case someone else wrote
                load_persistent()
                last_master[status_context] = { 'sha': sha,
                                                'status': 'finished',
                                                'comment': 'failed',
                                                'checked-timestamp': datetime.datetime.now().isoformat(),
                                                'committed-timestamp': commit_date,
                                                'hubbot_link': wd.url
                                              }
                (n_sha, modified) = load_current_master()
                info_is_current = modified and (modified < timestamp_master_fetched)
                sha_in_file_matches = n_sha and (n_sha == sha)
                if (info_is_current and sha_in_file_matches) or not info_is_current:
                    set_build_status(status_context, 'failed')
        if 'publish_images' in config and config['publish_images']:
            subprocess.check_call([ "vm-publish-image" ])
        if not is_master:
            load_persistent()
        # mark as not running
        while run_entry in running_jobs:
            running_jobs.remove(run_entry)
        store_persistent()

def master_from_status(status):
    path = "%s/%s/hubbot.master" % (work, os.path.basename(os.path.dirname(status['target_url'])))
    if os.path.isfile(path):
        with open(path, "r") as master_file:
            return master_file.readline().strip()
    else:
        if is_remote:
            path = "%s/hubbot.master" % os.path.dirname(status['target_url'])
            sha = subprocess.check_output ([ "curl", "-s", path ]).strip()
            if len(sha) > 40 or "404 Not Found" in sha:
                return ""
            else:
                return sha
        else:
            return ""

def status_is_success(status):
    return status['state'] == 'success' and status['description'] == "Hubbot is happy"

def status_is_pending(status):
    return status['state'] == 'pending' or status['description'] == status_message_master_changed or status['description'] == "Hubbot will get to this eventually"

def status_is_failed(status):
    return status['state'] == 'error'

# returns (pull_status, 'comment', priority)
# the priority change only describes the changes during this call (e.g. marked a pull as pending)
def consider_head(head, master, status_context, status_data, priority):
    for s in status_data['statuses']:
        if s['context'] == status_context:
            if status_is_success(s):
                old_master = master_from_status(s)
                if old_master != master:
                    trace("%s needs verification (master changed)" % head['label'])
                    set_github_status(master_repo, head['sha'], { "state": "success",
                                                                  "description": status_message_master_changed,
                                                                  "target_url": s['target_url'],
                                                                  "context": status_context })
                    return (pull_status_pending, 'already passed, but master changed', priority_master_changed)
                else:
                    trace("%s already verified" % head['label'])
                    return (pull_status_finished, 'passed', priority)
            elif s['description'] == status_message_master_changed:
                return (pull_status_pending, 'already passed, but master changed', priority_master_changed)
            elif s['state'] == 'error':
                trace("%s needs verification (error)" % head['label'])
                return (pull_status_pending, '', priority)
            elif status_is_pending(s):
                trace("%s needs verification (pending)" % head['label'])
                if (s['description'] == status_message_running):
                    return (pull_status_running, '', priority)
                return (pull_status_pending, '', priority)
            elif s['description'] == "Hubbot needs help with rebasing":
                return (pull_status_finished, 'failed, needs rebase', priority)
            else:
                trace("%s already failed" % head['label'])
                return (pull_status_finished, 'failed', priority)
    trace("%s needs verification (new)" % head['label'])
    set_github_status(master_repo, head['sha'], { "state": "pending" if not new_style else "success",
                                                  "description": "Hubbot will get to this eventually",
                                                  "context": status_context })
    return (pull_status_pending, '', priority)

def get_user_html(url):
    user_info = github_get_authenticated(url)
    return user_info['html_url']

def determine_job_class(pull_status, comment):
    if pull_status == pull_status_running:
        return 'warning'
    elif pull_status == pull_status_finished:
        if 'passed' in comment:
            return 'success'
        elif 'failed' in comment:
            return 'danger'
    elif pull_status == pull_status_pending and 'passed' in comment:
        return 'success'
    elif comment == 'rejected user':
        return 'info'
    return ''

def print_jobs_table(title, jobs):
    global user_pages
    if not jobs:
        return """
      <div class="container">
      <h2>%s</h2>
      <p>No entries.</p>
      </div><!-- /.container -->
""" % (title)
    text = """
    <div class="container">

      <h2>%s</h2>
      <table class="table">
        <thead>
          <tr>
            <th>Title</th>
            <th>User</th>
            <th>Status</th>
            <th>Priority</th>
            <th>Configuration</th>
            <th>Comment</th>
          </tr>
        </thead>
        <tbody>
""" % (title)
    for job in jobs:
        job_class = determine_job_class(job['pull_status'], job['comment'])
        is_pull_request = job['is_pull']

        if is_pull_request:
            p = job['data']
            head = p['head']
            job_desc = "<a href=\"%s\">%s</a>" % (p['html_url'], p['title'])
            user_login = head['user']['login']
            if user_login in user_pages:
                user_html = user_pages[user_login]
            else:
                user_html = get_user_html(head['user']['url'])
                user_pages[user_login] = user_html
            if user_html:
                user_login = "<a href=\"%s\">%s</a>" % (user_html, user_login)
            sha = head['sha']
        else:
            m = job['data']
            if m['commit']:
                c = m['commit']
                if c['sha']:
                    sha = m['commit']['sha']
                if c['author'] and c['author']['login']:
                    user_login = c['author']['login']
                    if user_login in user_pages:
                        user_html = user_pages[user_login]
                    else:
                        user_html = get_user_html(c['author']['url'])
                    if user_html:
                        user_login = "<a href=\"%s\">%s</a>" % (user_html, user_login)
                    else:
                        user_login = c['author']['login']
                if c['committer'] and c['committer']['login']:
                    committer_login = c['committer']['login']
                    if committer_login in user_pages:
                        user_html = user_pages[committer_login]
                    else:
                        user_html = get_user_html(c['committer']['url'])
                    if user_html:
                        user_login += " (committed by <a href=\"%s\">%s</a>)" % (user_html, committer_login)
                    else:
                        user_login += " (committed by %s)" % (committer_login)
                if m['_links'] and m['_links']['html']:
                    job_desc = "<a href=\"%s\">master</a>" % (m['_links']['html'])
                else:
                    job_desc = 'master'
            pass

        status_context = job['config']
        comment = job['comment']
        # get status, filtered by entries that concern us
        if 'status_data' in job:
            status = job['status_data']
        else:
            status = github_get("commits/%s/status" % (sha))
        if status and status['statuses']:
            status = filter(lambda s: s['context'] == status_context and
                                      'hubbot' in "%s" % (s['target_url']),
                            status['statuses'])
        if status:
            # most recent entry will be first
            hubbot_link = "<a href=\"%s\">hubbot output</a>" % (status[0]['target_url'])
            if len(comment) > 0:
                comment += " (%s)" % (hubbot_link)
            else:
                comment = hubbot_link

        text = text + """
          <tr class="%s">
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
          </tr>""" % (job_class, job_desc, user_login, job['pull_status'], job['priority'], status_context, comment)
    text = text + """
        </tbody>
      </table>
    </div><!-- /.container -->
"""
    return text

def print_master_details(extended_info):
    master_jobs = filter(lambda p: not p['is_pull'], extended_info)
    if not master_jobs:
        return """
    <div class="container">
      <h2>master</h2>
      <p>No information available.</p>
    </div><!-- /.container -->
"""
    text = """
    <div class="container">

      <h2>master</h2>
      <table class="table">
        <thead>
          <tr>
            <th>Configuration</th>
            <th>Commit</th>
            <th>Commit date</th>
            <th>Status</th>
            <th>Comment</th>
          </tr>
        </thead>
        <tbody>
"""
    entries = []
    for config in os_arch_configurations:
        status_context = status_context_from_config(config)
        # we always want info on current master
        # if that is broken, we want info on last working
        config_job = filter(lambda p: p['config'] == status_context, master_jobs)
        if len(config_job) < 1:
            # some kind of error, we should always have a job info for master
            entries.append(('', status_context, 'unknown', 'unknown', 'unknown', 'current master'))
        else:
            config_job = config_job[0]
            job_sha = config_job['data']['commit']['sha']
            job_class = determine_job_class(config_job['pull_status'], config_job['comment'])
            commit_link = "<a href=\"%s\">%s</a>" % (
                os.path.join("https://github.com", master_repo, "commit", job_sha),
                job_sha
                )
            commit_date = ""
            commit_data = config_job['data']['commit']['commit']
            if 'committer' in commit_data and 'date' in commit_data['committer']:
                commit_date = commit_data['committer']['date']
            commit_comment = "current master"
            if config_job['comment']:
                commit_comment += ": " + config_job['comment']

            # get status, filtered by entries that concern us
            if 'status_data' in config_job:
                status = config_job['status_data']
            else:
                status = github_get("commits/%s/status" % (job_sha))
            if status and status['statuses']:
                status = filter(lambda s: s['context'] == status_context and
                                          'hubbot' in "%s" % (s['target_url']),
                                status['statuses'])
            if status:
                # most recent entry will be first
                hubbot_link = "<a href=\"%s\">hubbot output</a>" % (status[0]['target_url'])
                commit_comment += " (%s)" % (hubbot_link)

            entries.append((job_class, status_context, commit_link, commit_date, config_job['pull_status'], commit_comment))

            # if current master hasn't passed or is still pending, get info on the last checked master
            if config_job['comment'] != "passed" or config_job['pull_status'] == pull_status_pending:
                if not status_context in last_master or not last_master[status_context]:
                    entries.append(('', status_context, 'unknown', 'unknown', 'unknown', 'last successful master'))
                else:
                    last_info = last_master[status_context]
                    # print this job if it's not the same as head
                    if last_info['sha'] != job_sha:
                        job_class = determine_job_class(last_info['status'], last_info['comment'])
                        commit_link = "<a href=\"%s\">%s</a>" % (
                            os.path.join("https://github.com", master_repo, "commit", last_info['sha']),
                            last_info['sha']
                            )
                        commit_comment = "last checked master"
                        if last_info['comment']:
                            commit_comment += ": " + last_info['comment']
                        if last_info['hubbot_link']:
                            commit_comment += " (<a href=\"%s\">hubbot output</a>)" % last_info['hubbot_link']
                        entries.append((job_class, status_context, commit_link, last_info['committed-timestamp'], last_info['status'], commit_comment))
                    # if last check wasn't successful, try to show last successful run
                    if last_info['comment'] != "passed":
                        if not status_context in last_successful_master or not last_successful_master[status_context]:
                            entries.append(('', status_context, 'unknown', 'unknown', 'unknown', 'last successful master'))
                        else:
                            last_info = last_successful_master[status_context]
                            job_class = determine_job_class(last_info['status'], last_info['comment'])
                            commit_link = "<a href=\"%s\">%s</a>" % (
                                os.path.join("https://github.com", master_repo, "commit", last_info['sha']),
                                last_info['sha']
                                )
                            commit_comment = "last successful master"
                            if last_info['comment']:
                                commit_comment += ": " + last_info['comment']
                            if last_info['hubbot_link']:
                                commit_comment += " (<a href=\"%s\">hubbot output</a>)" % last_info['hubbot_link']
                            entries.append((job_class, status_context, commit_link, last_info['committed-timestamp'], last_info['status'], commit_comment))

    for entry in entries:
        text = text + """
          <tr class="%s">
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
            <td>%s</td>
          </tr>""" % entry

    return text + """
        </tbody>
      </table>
    </div><!-- /.container -->
"""

# Publish scan info in html format
def publish_status(extended_info):
    load_persistent()
    header = """<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
    <meta name="description" content="">
    <meta name="author" content="Cockpit Project">
    <!--<link rel="icon" href="../../favicon.ico">-->

    <title>Cockpit - Hubbot Status</title>

    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">

    <!-- Optional theme -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap-theme.min.css">

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>

  <body>
    <h1>Status at: %s UTC</h1>
    <a href="bug_status.html">(Bug status)</a>
""" % (datetime.datetime.utcnow())
    footer = """
    <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Latest compiled and minified JavaScript -->
    <!--<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>-->
  </body>
</html>
"""
    # print infos we have on master
    master_details = print_master_details(extended_info)

    # print all elements in their respective category
    tables = print_jobs_table('pending jobs',
                              filter(lambda p: p['pull_status'] in [pull_status_pending, pull_status_running],
                                     extended_info)
                             )
    # don't show master checks in finished/skipped jobs
    tables += print_jobs_table('finished jobs',
                               filter(lambda p: p['is_pull'] and p['pull_status'] == pull_status_finished,
                                      extended_info)
                              )
    tables += print_jobs_table('skipped jobs',
                               filter(lambda p: p['is_pull'] and p['pull_status'] == pull_status_skip,
                                      extended_info)
                              )

    # write finished html file
    with open("%sindex.html" % (work), "w") as out_file:
        out_file.write(header + master_details + tables + footer)

def get_sorted_pulls():
    pulls = github_get("pulls")
    # Pulls with a higher priority will be checked first
    viable_pulls = [ ];
    for p in pulls:
        priority = priority_default
        p_labels = None

        # apply wip/needsattention modifiers first
        if p['title'].lower().startswith('wip'):
            priority = priority_work_in_progress
        else:
            # check labels
            p_labels = github_get("issues/%s/labels" % p['number'])
            for l in p_labels:
                if l['name'] in ['needswork', 'needsdesign']:
                    priority = priority_needs_attention

        # get status data for the pull
        status = github_get("commits/%s/status" % p['head']['sha'])

        # first check to see if an essential check has already failed
        essential_failed = False
        for config in os_arch_configurations:
            if essential_failed:
                break
            status_context = status_context_from_config(config)
            priority_offset = config['offset_pull']
            # base priority must be high enough
            if priority_offset < priority_threshold_essential:
                continue

            for s in status['statuses']:
                if s['context'] == status_context and status_is_failed(s):
                    essential_failed = True
                    priority = priority_essential_failed
                    priority_offset = 0
                    break

        for config in os_arch_configurations:
            status_context = status_context_from_config(config)
            config_priority = priority
            priority_offset = config['offset_pull']

            triggered = False

            for s in status['statuses']:
                if s['context'] == status_context:
                    if not p_labels:
                        p_labels = github_get("issues/%s/labels" % p['number'])
                    for l in p_labels:
                        if l['name'] == ('hubbot:%s:check' % config['short']):
                            triggered = True
                            if not essential_failed:
                                config_priority += priority_triggered
                                priority_offset = 0
                            break

                    if status_is_pending(s) and priority_essential_failed:
                        config_priority = priority_essential_failed
                        priority_offset = 0
                    elif s['description'] == status_message_master_changed:
                        config_priority = priority_master_changed
                        priority_offset = 0
                    break

            viable_pulls.append({'data': p,
                                 'priority': config_priority+priority_offset,
                                 'config': status_context,
                                 'config_data': config,
                                 'status_data': status,
                                 'triggered': triggered
                                 })

    # sort by priority
    return sorted(viable_pulls, key=lambda x: x['priority'], reverse=True)

def consider_master(master, get_only=False):
    global last_master
    if not last_master:
        load_persistent()
    master_sha = master['commit']['sha']
    status = github_get("commits/%s/status" % (master_sha))

    # consider all configurations
    viable_checks = []
    for config in os_arch_configurations:
        status_context = status_context_from_config(config)
        priority = config['offset_master'] + priority_default
        entry = {'data': master,
                 'priority': priority,
                 'pull_status': pull_status_pending,
                 'comment': 'daily check',
                 'is_pull': False,
                 'config': status_context,
                 'config_data': config,
                 'status_data': status
                }
        # see if we already checked master for this config on this day
        if status_context in last_master:
            last_check = last_master[status_context]
            if 'checked-timestamp' in last_check:
                check_timestamp = dateutil.parser.parse(last_check['checked-timestamp'])
                if check_timestamp.date() == datetime.datetime.now().date():
                    entry['pull_status'] = pull_status_skip
                    entry['comment'] = 'master already checked today'

        if priority < priority_threshold_disabled:
            entry['comment'] = 'skipped due to low priority'
            entry['pull_status'] = pull_status_skip
        if entry['pull_status'] == pull_status_pending:
            set_build_status(status_context, 'pending')
        viable_checks.append(entry)

    # sort by priority
    return sorted(viable_checks, key=lambda x: x['priority'], reverse=True)

def scan_pulls():
    global timestamp_master_fetched
    master = github_get('branches/master')
    timestamp_master_fetched = datetime.datetime.utcnow()
    master_sha = master['commit']['sha']
    # since we are in scan mode, store current master
    # only do this if verify hasn't touched master before we scanned
    global last_master
    global running_jobs
    load_persistent()
    if running_jobs:
        count_running_on_master = len(filter(lambda r: 'sha' in r and r['sha'] == master_sha, running_jobs))
    else:
        count_running_on_master = 0
    if last_master:
        count_last_master_with_sha = len(filter(lambda j: 'sha' in j and j['sha'] == master_sha, last_master))
    else:
        count_last_master_with_sha = 0
    if count_running_on_master > 0 or count_last_master_with_sha > 0:
        store_current_master(master_sha)
    pulls = get_sorted_pulls()
    extended_info = []
    for pull_info in pulls:
        p = pull_info['data']
        if p['head']['user']['login'] not in user_whitelist:
            trace("%s not in whitelist" % p['head']['user']['login'])
            extended_info.append({'data': p,
                                  'priority': pull_info['priority'],
                                  'pull_status': pull_status_skip,
                                  'comment': 'rejected user',
                                  'is_pull': True,
                                  'config': pull_info['config'],
                                  'status_data': pull_info['status_data']
                                 })
            continue
        if pull_info['priority'] <= priority_threshold_disabled:
            trace("%s skipped for %s (low priority)" % (pull_info['config'], p['head']['label']))
            extended_info.append({'data': p,
                                  'priority': pull_info['priority'],
                                  'pull_status': pull_status_skip,
                                  'comment': 'skipped due to low priority',
                                  'is_pull': True,
                                  'config': pull_info['config'],
                                  'status_data': pull_info['status_data']
                                 })
            continue
        (pull_status, pull_comment, pull_info['priority']) = consider_head(p['head'], master_sha, pull_info['config'], pull_info['status_data'], pull_info['priority'])
        extended_info.append({'data': p,
                              'priority': pull_info['priority'],
                              'pull_status': pull_status,
                              'comment': pull_comment,
                              'is_pull': True,
                              'config': pull_info['config'],
                              'status_data': pull_info['status_data']
                             })
    master_info = consider_master(master)
    if master_info:
        extended_info.extend(master_info)
        # sort by priority
        extended_info = sorted(extended_info, key=lambda x: x['priority'], reverse=True)
    publish_status(extended_info)

def head_is_pending(head, status_context):
    status = github_get("commits/%s/status" % head['sha'])
    for s in status['statuses']:
        if s['context'] == status_context and status_is_pending(s):
            return True
    return False

def verify_pull(pull):
    global timestamp_master_fetched
    data = None
    is_master_check = False
    config = default_arch_config
    github_visible_commits = None
    if pull == "-":
        # initialize priority to lowest number (lowest priority)
        priority = -sys.maxint-1
        pulls = get_sorted_pulls()
        github_visible_commits = map(lambda p: p['data']['head']['sha'], pulls)
        # use first pending pull in sorted list
        for pull_info in pulls:
            p = pull_info['data']
            if head_is_pending(p['head'], pull_info['config']):
                data = p
                priority = pull_info['priority']
                config = pull_info['config_data']
                break

        # consider checking master
        master = github_get('branches/master')
        timestamp_master_fetched = datetime.datetime.utcnow()
        master_sha = master['commit']['sha']
        github_visible_commits.append(master_sha)
        master_info = consider_master(master, True)
        for info in master_info:
            if info['pull_status'] != pull_status_pending:
                continue
            # we need to be of a higher priority than the pull request
            if info['data'] is None or info['priority'] <= priority:
                continue
            data = info['data']
            priority = info['priority']
            is_master_check = True
            config = info['config_data']

        if data is None:
            trace("No verifications pending")
            return

    else:
        data = github_get("pulls/%s" % pull)

    expire_work_dirs(github_visible_commits)
    expire_images()
    clean = False

    if is_master_check:
        clean = True
        trace("Verifying master for config %s%s" % (status_context_from_config(config), " (cleanly)" if clean else ""))
        verify(data, config, True, clean)
    else:
        labels = github_get("issues/%s/labels" % data['number'])
        for l in labels:
            if l['name'] == ("hubbot:%s:clean" % config['short']):
                clean = True
                break
        trace("Verifying %s%s" % (data['head']['label'], " (cleanly)" if clean else ""))
        verify(data['head'], config, False, clean)

def generate_status_images():
    status_color_combos = { 'passed': 'brightgreen',
                            'failed': 'red',
                            'pending': 'yellow',
                            'running': 'blue',
                            'unknown': 'lightgrey'
                          }
    if not os.path.exists(status_images_path):
        cmd("mkdir", "-p", status_images_path)
    for config in os_arch_configurations:
        for status in status_color_combos:
            o_file = status_image_from_config(config, status)
            # download if it doesn't exist
            if not os.path.isfile(o_file):
                src = "https://img.shields.io/badge/%s-%s-%s.svg" % (
                    status_display_name_from_config(config).replace('/', '%2F').replace('-', '--').replace('_', '__'),
                    status,
                    status_color_combos[status]
                    )
                try:
                    cmd("wget", "-t", "0", "-T", "5", "-O", o_file, "-q", src)
                except:
                    pass
        # initialize to unknown if link doesn't exist
        if not os.path.lexists(status_filename_from_config(config)):
            set_build_status(status_context_from_config(config), 'unknown')

if "HUBBOT_NEW_STYLE" in os.environ and os.environ["HUBBOT_NEW_STYLE"]:
    new_style = True
    work = "%s/hubbot.new/" % os.environ['HOME']
    work_url = "http://files.cockpit-project.org/hubbot.new"

def print_usage():
    print "Usage: hubbot verify [ [REPO] PULL ]"
    print "       hubbot scan [REPO]"

if dry_run:
    print "hubbot dry run"

# make sure we have status images
generate_status_images()

if len(sys.argv) < 2:
    print_usage()
    exit(1)

elif sys.argv[1] == 'verify' and len(sys.argv) <= 4:
    if len(sys.argv) == 4:
        master_repo = sys.argv[2]
        pull = sys.argv[3]
    elif len(sys.argv) == 3:
        pull = sys.argv[2]
    else:
        pull = "-"

    if not "/" in master_repo:
        master_repo = master_repo + "/cockpit"
    verify_pull(pull)

elif sys.argv[1] == 'scan' and len(sys.argv) <= 3:
    if len(sys.argv) == 3:
        master_repo = sys.argv[2]

    if not "/" in master_repo:
        master_repo = master_repo + "/cockpit"
    scan_pulls()

else:
    print_usage()
    exit(1)
