parent
044b8da55a
commit
d5a822884f
288 changed files with 13040 additions and 1 deletions
513
bots/task/__init__.py
Normal file
513
bots/task/__init__.py
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Shared GitHub code. When run as a script, we print out info about
|
||||
# our GitHub interacition.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import urllib.parse
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from . import github
|
||||
from . import sink
|
||||
|
||||
__all__ = (
|
||||
"api",
|
||||
"main",
|
||||
"run",
|
||||
"pull",
|
||||
"comment",
|
||||
"label",
|
||||
"issue",
|
||||
"verbose",
|
||||
"stale",
|
||||
"redhat_network",
|
||||
"REDHAT_STORE",
|
||||
)
|
||||
|
||||
# Server which has the private RHEL/Windows images
|
||||
REDHAT_STORE = "https://cockpit-11.e2e.bos.redhat.com:8493"
|
||||
|
||||
api = github.GitHub()
|
||||
verbose = False
|
||||
|
||||
BOTS = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
BASE = os.path.normpath(os.path.join(BOTS, ".."))
|
||||
|
||||
#
|
||||
# The main function takes a list of tasks, each of wihch has the following
|
||||
# fields, some of which have defaults:
|
||||
#
|
||||
# title: The title for the task
|
||||
# function: The function to call for the task
|
||||
# options=[]: A list of string options to pass to the function argument
|
||||
#
|
||||
# The function for the task will be called with all the context for the task.
|
||||
# In addition it will be called with named arguments for all other task fields
|
||||
# and additional fields such as |verbose|. It should return a zero or None value
|
||||
# if successful, and a string or non-zero value if unsuccessful.
|
||||
#
|
||||
# def run(context, verbose=False, **kwargs):
|
||||
# if verbose:
|
||||
# sys.stderr.write(image + "\n")
|
||||
# return 0
|
||||
#
|
||||
# Call the task.main() as the entry point of your script in one of these ways:
|
||||
#
|
||||
# # As a single task
|
||||
# task.main(title="My title", function=run)
|
||||
#
|
||||
|
||||
def main(**kwargs):
|
||||
global verbose
|
||||
|
||||
task = kwargs.copy()
|
||||
|
||||
# Figure out a descriptoin for the --help
|
||||
task["name"] = named(task)
|
||||
|
||||
parser = argparse.ArgumentParser(description=task.get("title", task["name"]))
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
parser.add_argument("--issue", dest="issue", action="store",
|
||||
help="Act on an already created task issue")
|
||||
parser.add_argument("--publish", dest="publish", default=os.environ.get("TEST_PUBLISH", ""),
|
||||
action="store", help="Publish results centrally to a sink")
|
||||
parser.add_argument("--dry", dest="dry", action="store_true",
|
||||
help="Dry run to validate this task if supported")
|
||||
parser.add_argument("context", nargs="?")
|
||||
|
||||
opts = parser.parse_args()
|
||||
verbose = opts.verbose
|
||||
|
||||
ret = 0
|
||||
|
||||
if "verbose" not in task:
|
||||
task["verbose"] = opts.verbose
|
||||
task["issue"] = opts.issue
|
||||
task["publish"] = opts.publish
|
||||
task["dry"] = opts.dry
|
||||
|
||||
ret = run(opts.context, **task)
|
||||
|
||||
if ret:
|
||||
sys.stderr.write("{0}: {1}\n".format(task["name"], ret))
|
||||
|
||||
sys.exit(ret and 1 or 0)
|
||||
|
||||
def named(task):
|
||||
if "name" in task:
|
||||
return task["name"]
|
||||
else:
|
||||
return os.path.basename(os.path.realpath(sys.argv[0]))
|
||||
|
||||
def begin(publish, name, context, issue):
|
||||
if not publish:
|
||||
return None
|
||||
|
||||
hostname = socket.gethostname().split(".")[0]
|
||||
current = time.strftime('%Y%m%d-%H%M%M')
|
||||
|
||||
# Update the body for an existing issue
|
||||
if issue:
|
||||
number = issue["number"]
|
||||
identifier = "{0}-{1}-{2}".format(name, number, current)
|
||||
title = issue["title"]
|
||||
wip = "WIP: {0}: [no-test] {1}".format(hostname, title)
|
||||
requests = [ {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}".format(number)),
|
||||
"data": { "title": wip }
|
||||
}, {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}/comments".format(number)),
|
||||
"data": { "body": "{0} in progress on {1}.\nLog: :link".format(name, hostname) }
|
||||
} ]
|
||||
watches = [ {
|
||||
"resource": api.qualify("issues/{0}".format(number)),
|
||||
"result": { "title": wip }
|
||||
} ]
|
||||
aborted = [ {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}".format(number)),
|
||||
"data": { "title": title }
|
||||
}, {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}/comments".format(number)),
|
||||
"data": { "body": "Task aborted." }
|
||||
} ]
|
||||
else:
|
||||
identifier = "{0}-{1}".format(name, current)
|
||||
requests = [ ]
|
||||
watches = [ ]
|
||||
aborted = [ ]
|
||||
|
||||
status = {
|
||||
"github": {
|
||||
"token": api.token,
|
||||
"requests": requests,
|
||||
"watches": watches
|
||||
},
|
||||
|
||||
"onaborted": {
|
||||
"github": {
|
||||
"token": api.token,
|
||||
"requests": aborted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publishing = sink.Sink(publish, identifier, status)
|
||||
sys.stderr.write("# Task: {0} {1}\n# Host: {2}\n\n".format(name, context or "", hostname))
|
||||
|
||||
# For statistics
|
||||
publishing.start = time.time()
|
||||
|
||||
return publishing
|
||||
|
||||
def finish(publishing, ret, name, context, issue):
|
||||
if not publishing:
|
||||
return
|
||||
|
||||
if not ret:
|
||||
comment = None
|
||||
result = "Completed"
|
||||
elif isinstance(ret, str):
|
||||
comment = "{0}: :link".format(ret)
|
||||
result = ret
|
||||
else:
|
||||
comment = "Task failed: :link"
|
||||
result = "Failed"
|
||||
|
||||
duration = int(time.time() - publishing.start)
|
||||
sys.stderr.write("\n# Result: {0}\n# Duration: {1}s\n".format(result, duration))
|
||||
|
||||
if issue:
|
||||
# Note that we check whether pass or fail ... this is because
|
||||
# the task is considered "done" until a human comes through and
|
||||
# triggers it again by unchecking the box.
|
||||
item = "{0} {1}".format(name, context or "").strip()
|
||||
checklist = github.Checklist(issue["body"])
|
||||
checklist.check(item, ret and "FAIL" or True)
|
||||
|
||||
number = issue["number"]
|
||||
|
||||
# The sink wants us to escape colons :S
|
||||
body = checklist.body.replace(':', '::')
|
||||
|
||||
requests = [ {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}".format(number)),
|
||||
"data": { "title": "{0}".format(issue["title"]), "body": body }
|
||||
} ]
|
||||
|
||||
# Close the issue if it's not a pull request, successful, and all tasks done
|
||||
if "pull_request" not in issue and not ret and len(checklist.items) == len(checklist.checked()):
|
||||
requests[0]["data"]["state"] = "closed"
|
||||
|
||||
# Comment if there was a failure
|
||||
if comment:
|
||||
requests.insert(0, {
|
||||
"method": "POST",
|
||||
"resource": api.qualify("issues/{0}/comments".format(number)),
|
||||
"data": { "body": comment }
|
||||
})
|
||||
|
||||
else:
|
||||
requests = [ ]
|
||||
|
||||
publishing.status['github']['requests'] = requests
|
||||
publishing.status['github']['watches'] = None
|
||||
publishing.status['github']['onaborted'] = None
|
||||
publishing.flush()
|
||||
|
||||
def run(context, function, **kwargs):
|
||||
number = kwargs.get("issue", None)
|
||||
publish = kwargs.get("publish", "")
|
||||
name = kwargs["name"]
|
||||
|
||||
issue = None
|
||||
if number:
|
||||
issue = api.get("issues/{0}".format(number))
|
||||
if not issue:
|
||||
return "No such issue: {0}".format(number)
|
||||
elif issue["title"].startswith("WIP:"):
|
||||
return "Issue is work in progress: {0}: {1}\n".format(number, issue["title"])
|
||||
kwargs["issue"] = issue
|
||||
kwargs["title"] = issue["title"]
|
||||
|
||||
publishing = begin(publish, name, context, issue=issue)
|
||||
|
||||
ret = "Task threw an exception"
|
||||
try:
|
||||
if issue and "pull_request" in issue:
|
||||
kwargs["pull"] = api.get(issue["pull_request"]["url"])
|
||||
|
||||
ret = function(context, **kwargs)
|
||||
except (RuntimeError, subprocess.CalledProcessError) as ex:
|
||||
ret = str(ex)
|
||||
except (AssertionError, KeyboardInterrupt):
|
||||
raise
|
||||
except:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
finish(publishing, ret, name, context, issue)
|
||||
return ret or 0
|
||||
|
||||
# Check if the given files that match @pathspec are stale
|
||||
# and haven't been updated in @days.
|
||||
def stale(days, pathspec, ref="HEAD"):
|
||||
global verbose
|
||||
|
||||
def execute(*args):
|
||||
if verbose:
|
||||
sys.stderr.write("+ " + " ".join(args) + "\n")
|
||||
output = subprocess.check_output(args, cwd=BASE, universal_newlines=True)
|
||||
if verbose:
|
||||
sys.stderr.write("> " + output + "\n")
|
||||
return output
|
||||
|
||||
timestamp = execute("git", "log", "--max-count=1", "--pretty=format:%ct", ref, "--", pathspec)
|
||||
try:
|
||||
timestamp = int(timestamp)
|
||||
except ValueError:
|
||||
timestamp = 0
|
||||
|
||||
# We randomize when we think this should happen over a day
|
||||
offset = days * 86400
|
||||
due = time.time() - random.randint(offset - 43200, offset + 43200)
|
||||
|
||||
return timestamp < due
|
||||
|
||||
def issue(title, body, item, context=None, items=[], state="open", since=None):
|
||||
if context:
|
||||
item = "{0} {1}".format(item, context).strip()
|
||||
|
||||
if since:
|
||||
# don't let all bots pass the deadline in the same second, to avoid many duplicates
|
||||
since += random.randint(-3600, 3600)
|
||||
|
||||
for issue in api.issues(state=state, since=since):
|
||||
checklist = github.Checklist(issue["body"])
|
||||
if item in checklist.items:
|
||||
return issue
|
||||
|
||||
if not items:
|
||||
items = [ item ]
|
||||
checklist = github.Checklist(body)
|
||||
for x in items:
|
||||
checklist.add(x)
|
||||
data = {
|
||||
"title": title,
|
||||
"body": checklist.body,
|
||||
"labels": [ "bot" ]
|
||||
}
|
||||
return api.post("issues", data)
|
||||
|
||||
def execute(*args):
|
||||
global verbose
|
||||
if verbose:
|
||||
sys.stderr.write("+ " + " ".join(args) + "\n")
|
||||
|
||||
# Make double sure that the token does not appear anywhere in the output
|
||||
def censored(text):
|
||||
return text.replace(api.token, "CENSORED")
|
||||
|
||||
env = os.environ.copy()
|
||||
# No prompting for passwords
|
||||
if "GIT_ASKPASS" not in env:
|
||||
env["GIT_ASKPASS"] = "/bin/true"
|
||||
output = subprocess.check_output(args, cwd=BASE, stderr=subprocess.STDOUT, env=env, universal_newlines=True)
|
||||
sys.stderr.write(censored(output))
|
||||
return output
|
||||
|
||||
def find_our_fork(user):
|
||||
repos = api.get("/users/{0}/repos".format(user))
|
||||
for r in repos:
|
||||
if r["full_name"] == api.repo:
|
||||
# We actually own the origin repo, so use that.
|
||||
return api.repo
|
||||
if r["fork"]:
|
||||
full = api.get("/repos/{0}/{1}".format(user, r["name"]))
|
||||
if full["parent"]["full_name"] == api.repo:
|
||||
return full["full_name"]
|
||||
raise RuntimeError("%s doesn't have a fork of %s" % (user, api.repo))
|
||||
|
||||
def push_branch(user, branch, force=False):
|
||||
fork_repo = find_our_fork(user)
|
||||
|
||||
url = "https://github.com/{0}".format(fork_repo)
|
||||
cmd = ["git", "push", url, "+HEAD:refs/heads/{0}".format(branch)]
|
||||
if force:
|
||||
cmd.insert(2, "-f")
|
||||
execute(*cmd)
|
||||
|
||||
def branch(context, message, pathspec=".", issue=None, branch=None, push=True, **kwargs):
|
||||
name = named(kwargs)
|
||||
if not branch:
|
||||
execute("git", "checkout", "--detach")
|
||||
current = time.strftime('%Y%m%d-%H%M%M')
|
||||
branch = "{0} {1} {2}".format(name, context or "", current).strip()
|
||||
branch = branch.replace(" ", "-").replace("--", "-")
|
||||
|
||||
# Tell git about our github token as a user name
|
||||
try:
|
||||
subprocess.check_call(["git", "config", "credential.https://github.com.username", api.token])
|
||||
except subprocess.CalledProcessError:
|
||||
raise RuntimeError("Couldn't configure git config with our API token")
|
||||
|
||||
user = api.get("/user")['login']
|
||||
fork_repo = find_our_fork(user)
|
||||
|
||||
clean = "https://github.com/{0}".format(fork_repo)
|
||||
|
||||
if pathspec is not None:
|
||||
execute("git", "add", "--", pathspec)
|
||||
|
||||
# If there's nothing to add at that pathspec return None
|
||||
try:
|
||||
execute("git", "commit", "-m", message)
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
# No need to push if we want to add another commits into the same branch
|
||||
if push:
|
||||
push_branch(user, branch)
|
||||
|
||||
# Comment on the issue if present and we pushed the branch
|
||||
if issue and push:
|
||||
comment_done(issue, name, clean, branch, context)
|
||||
|
||||
return "{0}:{1}".format(user, branch)
|
||||
|
||||
def pull(branch, body=None, issue=None, base="master", labels=['bot'], run_tests=True, **kwargs):
|
||||
if "pull" in kwargs:
|
||||
return kwargs["pull"]
|
||||
|
||||
data = {
|
||||
"head": branch,
|
||||
"base": base,
|
||||
"maintainer_can_modify": True
|
||||
}
|
||||
if issue:
|
||||
try:
|
||||
data["issue"] = issue["number"]
|
||||
except TypeError:
|
||||
data["issue"] = int(issue)
|
||||
else:
|
||||
data["title"] = "[no-test] " + kwargs["title"]
|
||||
if body:
|
||||
data["body"] = body
|
||||
|
||||
pull = api.post("pulls", data, accept=[ 422 ])
|
||||
|
||||
# If we were refused to grant maintainer_can_modify, then try without
|
||||
if "errors" in pull:
|
||||
if pull["errors"][0]["field"] == "fork_collab":
|
||||
data["maintainer_can_modify"] = False
|
||||
pull = api.post("pulls", data)
|
||||
|
||||
# Update the pull request
|
||||
label(pull, labels)
|
||||
|
||||
# Update the issue if it is a dict
|
||||
if issue:
|
||||
try:
|
||||
issue["title"] = kwargs["title"]
|
||||
issue["pull_request"] = { "url": pull["url"] }
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if pull["number"]:
|
||||
# If we want to run tests automatically, drop [no-test] from title before force push
|
||||
if run_tests:
|
||||
pull = api.post("pulls/" + str(pull["number"]), {"title": kwargs["title"]}, accept=[ 422 ])
|
||||
|
||||
# Force push
|
||||
last_commit_m = execute("git", "show", "--no-patch", "--format=%B")
|
||||
last_commit_m += "Closes #" + str(pull["number"])
|
||||
execute("git", "commit", "--amend", "-m", last_commit_m)
|
||||
(user, branch) = branch.split(":")
|
||||
push_branch(user, branch, True)
|
||||
|
||||
# Make sure we return the updated pull data
|
||||
for retry in range(20):
|
||||
new_data = api.get("pulls/{}".format(pull["number"]))
|
||||
if pull["head"]["sha"] != new_data["head"]["sha"]:
|
||||
pull = new_data
|
||||
break
|
||||
time.sleep(6)
|
||||
else:
|
||||
raise RuntimeError("Failed to retrieve updated pull data after force pushing")
|
||||
|
||||
return pull
|
||||
|
||||
def label(issue, labels=['bot']):
|
||||
try:
|
||||
resource = "issues/{0}/labels".format(issue["number"])
|
||||
except TypeError:
|
||||
resource = "issues/{0}/labels".format(issue)
|
||||
return api.post(resource, labels)
|
||||
|
||||
def labels_of_pull(pull):
|
||||
if "labels" not in pull:
|
||||
pull["labels"] = api.get("issues/{0}/labels".format(pull["number"]))
|
||||
return list(map(lambda label: label["name"], pull["labels"]))
|
||||
|
||||
def comment(issue, comment):
|
||||
try:
|
||||
number = issue["number"]
|
||||
except TypeError:
|
||||
number = issue
|
||||
return api.post("issues/{0}/comments".format(number), { "body": comment })
|
||||
|
||||
def comment_done(issue, name, clean, branch, context=None):
|
||||
message = "{0} {1} done: {2}/commits/{3}".format(name, context or "", clean, branch)
|
||||
comment(issue, message)
|
||||
|
||||
def attach(filename):
|
||||
if "TEST_ATTACHMENTS" in os.environ:
|
||||
shutil.copy(filename, os.environ["TEST_ATTACHMENTS"])
|
||||
|
||||
def redhat_network():
|
||||
'''Check if we can access the Red Hat network
|
||||
|
||||
This checks if the image server can be accessed. The result gets cached,
|
||||
so this can be called several times.
|
||||
'''
|
||||
if redhat_network.result is None:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
store = urllib.parse.urlparse(REDHAT_STORE)
|
||||
try:
|
||||
s.connect((store.hostname, store.port))
|
||||
redhat_network.result = True
|
||||
except OSError:
|
||||
redhat_network.result = False
|
||||
|
||||
return redhat_network.result
|
||||
|
||||
redhat_network.result = None
|
||||
100
bots/task/cache.py
Normal file
100
bots/task/cache.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2015 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Shared GitHub code. When run as a script, we print out info about
|
||||
# our GitHub interacition.
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
|
||||
__all__ = (
|
||||
'Cache',
|
||||
)
|
||||
|
||||
class Cache(object):
|
||||
def __init__(self, directory, lag=None):
|
||||
self.directory = directory
|
||||
self.pruned = False
|
||||
|
||||
# Default to zero lag when command on command line
|
||||
if lag is None:
|
||||
if os.isatty(0):
|
||||
lag = 0
|
||||
else:
|
||||
lag = 60
|
||||
|
||||
# The lag tells us how long to assume cached data is "current"
|
||||
self.lag = lag
|
||||
|
||||
# The mark tells us that stuff before this time is not "current"
|
||||
self.marked = 0
|
||||
|
||||
# Prune old expired data from the cache directory
|
||||
def prune(self):
|
||||
if not os.path.exists(self.directory):
|
||||
return
|
||||
now = time.time()
|
||||
try:
|
||||
for filename in os.listdir(self.directory):
|
||||
path = os.path.join(self.directory, filename)
|
||||
if os.path.isfile(path) and os.stat(path).st_mtime < now - 7 * 86400:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Read a resource from the cache or return None
|
||||
def read(self, resource):
|
||||
path = os.path.join(self.directory, urllib.parse.quote(resource, safe=''))
|
||||
try:
|
||||
with open(path, 'r') as fp:
|
||||
return json.load(fp)
|
||||
except (IOError, ValueError):
|
||||
return None
|
||||
|
||||
# Write a resource to the cache in an atomic way
|
||||
def write(self, resource, contents):
|
||||
path = os.path.join(self.directory, urllib.parse.quote(resource, safe=''))
|
||||
os.makedirs(self.directory, exist_ok=True)
|
||||
(fd, temp) = tempfile.mkstemp(dir=self.directory)
|
||||
with os.fdopen(fd, 'w') as fp:
|
||||
json.dump(contents, fp)
|
||||
os.chmod(temp, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
os.rename(temp, path)
|
||||
if not self.pruned:
|
||||
self.pruned = True
|
||||
self.prune()
|
||||
|
||||
# Tell the cache that stuff before this time is not "current"
|
||||
def mark(self, mtime=None):
|
||||
if not mtime:
|
||||
mtime = time.time()
|
||||
self.marked = mtime
|
||||
|
||||
# Check if a given resource in the cache is "current" or not
|
||||
def current(self, resource):
|
||||
path = os.path.join(self.directory, urllib.parse.quote(resource, safe=''))
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
return mtime > self.marked and mtime > (time.time() - self.lag)
|
||||
except OSError:
|
||||
return False
|
||||
103
bots/task/distributed_queue.py
Normal file
103
bots/task/distributed_queue.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2019 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Shared GitHub code. When run as a script, we print out info about
|
||||
# our GitHub interacition.
|
||||
|
||||
import ssl
|
||||
import logging
|
||||
|
||||
no_amqp = False
|
||||
try:
|
||||
import pika
|
||||
except ImportError:
|
||||
no_amqp = True
|
||||
|
||||
logging.getLogger("pika").propagate = False
|
||||
|
||||
__all__ = (
|
||||
'DistributedQueue',
|
||||
'BASELINE_PRIORITY',
|
||||
'MAX_PRIORITY',
|
||||
'no_amqp',
|
||||
)
|
||||
|
||||
BASELINE_PRIORITY = 5
|
||||
MAX_PRIORITY = 9
|
||||
|
||||
arguments = {
|
||||
'rhel': {
|
||||
"x-max-priority": MAX_PRIORITY
|
||||
},
|
||||
'public': {
|
||||
"x-max-priority": MAX_PRIORITY
|
||||
},
|
||||
}
|
||||
|
||||
class DistributedQueue(object):
|
||||
def __init__(self, amqp_server, queues, **kwargs):
|
||||
"""connect to some AMQP queues
|
||||
|
||||
amqp_server should be formatted as host:port
|
||||
|
||||
queues should be a list of strings with the names of queues, each queue
|
||||
will be declared and usable
|
||||
|
||||
any extra arguments in **kwargs will be passed to queue_declare()
|
||||
|
||||
the results of the result declarations are stored in
|
||||
DistributedQueue.declare_results, a dict mapping queue name to result
|
||||
|
||||
when passive=True is passed to queue_declare() and the queue does not
|
||||
exist, the declare result will be None
|
||||
"""
|
||||
if no_amqp:
|
||||
raise ImportError('pika is not available')
|
||||
|
||||
try:
|
||||
host, port = amqp_server.split(':')
|
||||
except ValueError:
|
||||
raise ValueError('Please format amqp_server as host:port')
|
||||
context = ssl.create_default_context(cafile='/run/secrets/webhook/ca.pem')
|
||||
context.load_cert_chain(keyfile='/run/secrets/webhook/amqp-client.key',
|
||||
certfile='/run/secrets/webhook/amqp-client.pem')
|
||||
context.check_hostname = False
|
||||
self.connection = pika.BlockingConnection(pika.ConnectionParameters(
|
||||
host=host,
|
||||
port=int(port),
|
||||
ssl_options=pika.SSLOptions(context, server_hostname=host),
|
||||
credentials=pika.credentials.ExternalCredentials()))
|
||||
self.channel = self.connection.channel()
|
||||
self.declare_results = {}
|
||||
|
||||
for queue in queues:
|
||||
try:
|
||||
result = self.channel.queue_declare(queue=queue, arguments=arguments.get(queue, None), **kwargs)
|
||||
self.declare_results[queue] = result
|
||||
except pika.exceptions.ChannelClosedByBroker as e:
|
||||
# unknown error
|
||||
if e.reply_code != 404:
|
||||
raise e
|
||||
# queue does not exist
|
||||
self.declare_results[queue] = None
|
||||
self.channel = self.connection.channel()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
self.connection.close()
|
||||
448
bots/task/github.py
Normal file
448
bots/task/github.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2015 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Shared GitHub code. When run as a script, we print out info about
|
||||
# our GitHub interacition.
|
||||
|
||||
import errno
|
||||
import http.client
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
from . import cache, testmap
|
||||
|
||||
__all__ = (
|
||||
'GitHub',
|
||||
'Checklist',
|
||||
'TESTING',
|
||||
'NO_TESTING',
|
||||
'NOT_TESTED'
|
||||
)
|
||||
|
||||
TESTING = "Testing in progress"
|
||||
NO_TESTING = "Manual testing required"
|
||||
|
||||
# if the webhook receives a pull request event, it will create a status for each
|
||||
# context with NOT_TESTED as description
|
||||
# the subsequent status events caused by the webhook creating the statuses, will
|
||||
# be ignored by the webhook as it only handles NOT_TESTED_DIRECT as described
|
||||
# below
|
||||
NOT_TESTED = "Not yet tested"
|
||||
# if the webhook receives a status event with NOT_TESTED_DIRECT as description,
|
||||
# it will publish a test task to the queue (used to trigger specific contexts)
|
||||
NOT_TESTED_DIRECT = "Not yet tested (direct trigger)"
|
||||
|
||||
ISSUE_TITLE_IMAGE_REFRESH = "Image refresh for {0}"
|
||||
|
||||
BASE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
TOKEN = "~/.config/github-token"
|
||||
|
||||
TEAM_CONTRIBUTORS = "Contributors"
|
||||
|
||||
def known_context(context):
|
||||
context = context.split("@")[0]
|
||||
for project in testmap.projects():
|
||||
for branch_tests in testmap.tests_for_project(project).values():
|
||||
if context in branch_tests:
|
||||
return True
|
||||
return False
|
||||
|
||||
class Logger(object):
|
||||
def __init__(self, directory):
|
||||
hostname = socket.gethostname().split(".")[0]
|
||||
month = time.strftime("%Y%m")
|
||||
self.path = os.path.join(directory, "{0}-{1}.log".format(hostname, month))
|
||||
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
# Yes, we open the file each time
|
||||
def write(self, value):
|
||||
with open(self.path, 'a') as f:
|
||||
f.write(value)
|
||||
|
||||
class GitHubError(RuntimeError):
|
||||
"""Raise when getting an error from the GitHub API
|
||||
|
||||
We used to raise `RuntimeError` before. Subclass from that, so that client
|
||||
code depending on it continues to work.
|
||||
"""
|
||||
|
||||
def __init__(self, url, response):
|
||||
self.url = url
|
||||
self.data = response.get('data')
|
||||
self.status = response.get('status')
|
||||
self.reason = response.get('reason')
|
||||
|
||||
def __str__(self):
|
||||
return ('Error accessing {0}\n'
|
||||
' Status: {1}\n'
|
||||
' Reason: {2}\n'
|
||||
' Response: {3}'.format(self.url, self.status, self.reason, self.data))
|
||||
|
||||
def get_repo():
|
||||
res = subprocess.check_output(['git', 'config', '--default=', 'cockpit.bots.github-repo'])
|
||||
return res.decode('utf-8').strip() or None
|
||||
|
||||
def get_origin_repo():
|
||||
try:
|
||||
res = subprocess.check_output([ "git", "remote", "get-url", "origin" ])
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
url = res.decode('utf-8').strip()
|
||||
m = re.fullmatch("(git@github.com:|https://github.com/)(.*?)(\\.git)?", url)
|
||||
if m:
|
||||
return m.group(2).rstrip("/")
|
||||
raise RuntimeError("Not a GitHub repo: %s" % url)
|
||||
|
||||
class GitHub(object):
|
||||
def __init__(self, base=None, cacher=None, repo=None):
|
||||
self._repo = repo
|
||||
self._base = base
|
||||
self._url = None
|
||||
|
||||
self.conn = None
|
||||
self.token = None
|
||||
self.debug = False
|
||||
try:
|
||||
gt = open(os.path.expanduser(TOKEN), "r")
|
||||
self.token = gt.read().strip()
|
||||
gt.close()
|
||||
except IOError as exc:
|
||||
if exc.errno == errno.ENOENT:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
self.available = self.token and True or False
|
||||
|
||||
# The cache directory is $TEST_DATA/github ~/.cache/github
|
||||
if not cacher:
|
||||
data = os.environ.get("TEST_DATA", os.path.expanduser("~/.cache"))
|
||||
cacher = cache.Cache(os.path.join(data, "github"))
|
||||
self.cache = cacher
|
||||
|
||||
# Create a log for debugging our GitHub access
|
||||
self.log = Logger(self.cache.directory)
|
||||
self.log.write("")
|
||||
|
||||
@property
|
||||
def repo(self):
|
||||
if not self._repo:
|
||||
self._repo = os.environ.get("GITHUB_BASE", None) or get_repo() or get_origin_repo()
|
||||
if not self._repo:
|
||||
raise RuntimeError('Could not determine the github repository:\n'
|
||||
' - some commands accept a --repo argument\n'
|
||||
' - you can set the GITHUB_BASE environment variable\n'
|
||||
' - you can set git config cockpit.bots.github-repo\n'
|
||||
' - otherwise, the "origin" remote from the current checkout is used')
|
||||
|
||||
return self._repo
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
if not self._url:
|
||||
if not self._base:
|
||||
netloc = os.environ.get("GITHUB_API", "https://api.github.com")
|
||||
self._base = "{0}/repos/{1}/".format(netloc, self.repo)
|
||||
|
||||
self._url = urllib.parse.urlparse(self._base)
|
||||
|
||||
return self._url
|
||||
|
||||
def qualify(self, resource):
|
||||
return urllib.parse.urljoin(self.url.path, resource)
|
||||
|
||||
def request(self, method, resource, data="", headers=None):
|
||||
if headers is None:
|
||||
headers = { }
|
||||
headers["User-Agent"] = "Cockpit Tests"
|
||||
if self.token:
|
||||
headers["Authorization"] = "token " + self.token
|
||||
connected = False
|
||||
bad_gateway_errors = 0
|
||||
while not connected and bad_gateway_errors < 5:
|
||||
if not self.conn:
|
||||
if self.url.scheme == 'http':
|
||||
self.conn = http.client.HTTPConnection(self.url.netloc)
|
||||
else:
|
||||
self.conn = http.client.HTTPSConnection(self.url.netloc)
|
||||
connected = True
|
||||
self.conn.set_debuglevel(self.debug and 1 or 0)
|
||||
try:
|
||||
self.conn.request(method, self.qualify(resource), data, headers)
|
||||
response = self.conn.getresponse()
|
||||
if response.status == HTTPStatus.BAD_GATEWAY:
|
||||
bad_gateway_errors += 1
|
||||
self.conn = None
|
||||
connected = False
|
||||
time.sleep(bad_gateway_errors * 2)
|
||||
continue
|
||||
break
|
||||
# This happens when GitHub disconnects in python3
|
||||
except ConnectionResetError:
|
||||
if connected:
|
||||
raise
|
||||
self.conn = None
|
||||
# This happens when GitHub disconnects a keep-alive connection
|
||||
except http.client.BadStatusLine:
|
||||
if connected:
|
||||
raise
|
||||
self.conn = None
|
||||
# This happens when TLS is the source of a disconnection
|
||||
except socket.error as ex:
|
||||
if connected or ex.errno != errno.EPIPE:
|
||||
raise
|
||||
self.conn = None
|
||||
heads = { }
|
||||
for (header, value) in response.getheaders():
|
||||
heads[header.lower()] = value
|
||||
self.log.write('{0} - - [{1}] "{2} {3} HTTP/1.1" {4} -\n'.format(
|
||||
self.url.netloc,
|
||||
time.asctime(),
|
||||
method,
|
||||
resource,
|
||||
response.status
|
||||
))
|
||||
return {
|
||||
"status": response.status,
|
||||
"reason": response.reason,
|
||||
"headers": heads,
|
||||
"data": response.read().decode('utf-8')
|
||||
}
|
||||
|
||||
def get(self, resource):
|
||||
headers = { }
|
||||
qualified = self.qualify(resource)
|
||||
cached = self.cache.read(qualified)
|
||||
if cached:
|
||||
if self.cache.current(qualified):
|
||||
return json.loads(cached['data'] or "null")
|
||||
etag = cached['headers'].get("etag", None)
|
||||
modified = cached['headers'].get("last-modified", None)
|
||||
if etag:
|
||||
headers['If-None-Match'] = etag
|
||||
elif modified:
|
||||
headers['If-Modified-Since'] = modified
|
||||
response = self.request("GET", resource, "", headers)
|
||||
if response['status'] == 404:
|
||||
return None
|
||||
elif cached and response['status'] == 304: # Not modified
|
||||
self.cache.write(qualified, cached)
|
||||
return json.loads(cached['data'] or "null")
|
||||
elif response['status'] < 200 or response['status'] >= 300:
|
||||
raise GitHubError(self.qualify(resource), response)
|
||||
else:
|
||||
self.cache.write(qualified, response)
|
||||
return json.loads(response['data'] or "null")
|
||||
|
||||
def post(self, resource, data, accept=[]):
|
||||
response = self.request("POST", resource, json.dumps(data), { "Content-Type": "application/json" })
|
||||
status = response['status']
|
||||
if (status < 200 or status >= 300) and status not in accept:
|
||||
raise GitHubError(self.qualify(resource), response)
|
||||
self.cache.mark()
|
||||
return json.loads(response['data'])
|
||||
|
||||
def delete(self, resource, accept=[]):
|
||||
response = self.request("DELETE", resource, "", { "Content-Type": "application/json" })
|
||||
status = response['status']
|
||||
if (status < 200 or status >= 300) and status not in accept:
|
||||
raise GitHubError(self.qualify(resource), response)
|
||||
self.cache.mark()
|
||||
return json.loads(response['data'])
|
||||
|
||||
def patch(self, resource, data, accept=[]):
|
||||
response = self.request("PATCH", resource, json.dumps(data), { "Content-Type": "application/json" })
|
||||
status = response['status']
|
||||
if (status < 200 or status >= 300) and status not in accept:
|
||||
raise GitHubError(self.qualify(resource), response)
|
||||
self.cache.mark()
|
||||
return json.loads(response['data'])
|
||||
|
||||
def statuses(self, revision):
|
||||
result = { }
|
||||
page = 1
|
||||
count = 100
|
||||
while count == 100:
|
||||
data = self.get("commits/{0}/status?page={1}&per_page={2}".format(revision, page, count))
|
||||
count = 0
|
||||
page += 1
|
||||
if "statuses" in data:
|
||||
for status in data["statuses"]:
|
||||
if known_context(status["context"]) and status["context"] not in result:
|
||||
result[status["context"]] = status
|
||||
count = len(data["statuses"])
|
||||
return result
|
||||
|
||||
def pulls(self, state='open', since=None):
|
||||
result = [ ]
|
||||
page = 1
|
||||
count = 100
|
||||
while count == 100:
|
||||
pulls = self.get("pulls?page={0}&per_page={1}&state={2}&sort=created&direction=desc".format(page, count, state))
|
||||
count = 0
|
||||
page += 1
|
||||
for pull in pulls or []:
|
||||
# Check that the pulls are past the expected date
|
||||
if since:
|
||||
closed = pull.get("closed_at", None)
|
||||
if closed and since > time.mktime(time.strptime(closed, "%Y-%m-%dT%H:%M:%SZ")):
|
||||
continue
|
||||
created = pull.get("created_at", None)
|
||||
if not closed and created and since > time.mktime(time.strptime(created, "%Y-%m-%dT%H:%M:%SZ")):
|
||||
continue
|
||||
|
||||
result.append(pull)
|
||||
count += 1
|
||||
return result
|
||||
|
||||
# The since argument is seconds since the issue was either
|
||||
# created (for open issues) or closed (for closed issues)
|
||||
def issues(self, labels=[ "bot" ], state="open", since=None):
|
||||
result = [ ]
|
||||
page = 1
|
||||
count = 100
|
||||
opened = True
|
||||
label = ",".join(labels)
|
||||
while count == 100 and opened:
|
||||
req = "issues?labels={0}&state=all&page={1}&per_page={2}".format(label, page, count)
|
||||
issues = self.get(req)
|
||||
count = 0
|
||||
page += 1
|
||||
opened = False
|
||||
for issue in issues:
|
||||
count += 1
|
||||
|
||||
# On each loop of 100 issues we must encounter at least 1 open issue
|
||||
if issue["state"] == "open":
|
||||
opened = True
|
||||
|
||||
# Make sure the state matches
|
||||
if state != "all" and issue["state"] != state:
|
||||
continue
|
||||
|
||||
# Check that the issues are past the expected date
|
||||
if since:
|
||||
closed = issue.get("closed_at", None)
|
||||
if closed and since > time.mktime(time.strptime(closed, "%Y-%m-%dT%H:%M:%SZ")):
|
||||
continue
|
||||
created = issue.get("created_at", None)
|
||||
if not closed and created and since > time.mktime(time.strptime(created, "%Y-%m-%dT%H:%M:%SZ")):
|
||||
continue
|
||||
|
||||
result.append(issue)
|
||||
return result
|
||||
|
||||
def commits(self, branch='master', since=None):
|
||||
page = 1
|
||||
count = 100
|
||||
if since:
|
||||
since = "&since={0}".format(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(since)))
|
||||
else:
|
||||
since = ""
|
||||
while count == 100:
|
||||
commits = self.get("commits?page={0}&per_page={1}&sha={2}{3}".format(page, count, branch, since))
|
||||
count = 0
|
||||
page += 1
|
||||
for commit in commits or []:
|
||||
yield commit
|
||||
count += 1
|
||||
|
||||
def whitelist(self):
|
||||
users = set()
|
||||
teamId = self.teamIdFromName(TEAM_CONTRIBUTORS)
|
||||
page = 1
|
||||
count = 100
|
||||
while count == 100:
|
||||
data = self.get("/teams/{0}/members?page={1}&per_page={2}".format(teamId, page, count)) or []
|
||||
users.update(user.get("login") for user in data)
|
||||
count = len(data)
|
||||
page += 1
|
||||
return users
|
||||
|
||||
def teamIdFromName(self, name):
|
||||
for team in self.get("/orgs/cockpit-project/teams") or []:
|
||||
if team.get("name") == name:
|
||||
return team["id"]
|
||||
else:
|
||||
raise KeyError("Team {0} not found".format(name))
|
||||
|
||||
|
||||
class Checklist(object):
|
||||
def __init__(self, body=None):
|
||||
self.process(body or "")
|
||||
|
||||
@staticmethod
|
||||
def format_line(item, check):
|
||||
status = ""
|
||||
if isinstance(check, str):
|
||||
status = check + ": "
|
||||
check = False
|
||||
return " * [{0}] {1}{2}".format(check and "x" or " ", status, item)
|
||||
|
||||
@staticmethod
|
||||
def parse_line(line):
|
||||
check = item = None
|
||||
stripped = line.strip()
|
||||
if stripped[:6] in ["* [ ] ", "- [ ] ", "* [x] ", "- [x] ", "* [X] ", "- [X] "]:
|
||||
status, unused, item = stripped[6:].strip().partition(": ")
|
||||
if not item:
|
||||
item = status
|
||||
status = None
|
||||
if status:
|
||||
check = status
|
||||
else:
|
||||
check = stripped[3] in ["x", "X"]
|
||||
return (item, check)
|
||||
|
||||
def process(self, body, items={ }):
|
||||
self.items = { }
|
||||
lines = [ ]
|
||||
items = items.copy()
|
||||
for line in body.splitlines():
|
||||
(item, check) = self.parse_line(line)
|
||||
if item:
|
||||
if item in items:
|
||||
check = items[item]
|
||||
del items[item]
|
||||
line = self.format_line(item, check)
|
||||
self.items[item] = check
|
||||
lines.append(line)
|
||||
for item, check in items.items():
|
||||
lines.append(self.format_line(item, check))
|
||||
self.items[item] = check
|
||||
self.body = "\n".join(lines)
|
||||
|
||||
def check(self, item, checked=True):
|
||||
self.process(self.body, { item: checked })
|
||||
|
||||
def add(self, item):
|
||||
self.process(self.body, { item: False })
|
||||
|
||||
def checked(self):
|
||||
result = { }
|
||||
for item, check in self.items.items():
|
||||
if check:
|
||||
result[item] = check
|
||||
return result
|
||||
379
bots/task/log.html
Normal file
379
bots/task/log.html
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Cockpit Integration Tests</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js" type="text/javascript"></script>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
|
||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.2.1/mustache.min.js"></script>
|
||||
<!-- nicer arrows for the collapsible panels and preformatted text-->
|
||||
<style>
|
||||
* {
|
||||
font-family: "Open Sans";
|
||||
}
|
||||
body {
|
||||
margin: 10px;
|
||||
}
|
||||
.panel-heading:last-child:after {
|
||||
font-family:'Glyphicons Halflings';
|
||||
content:"\e114";
|
||||
float: right;
|
||||
color: grey;
|
||||
}
|
||||
.panel-heading:last-child.collapsed:after {
|
||||
content:"\e080";
|
||||
}
|
||||
.panel-heading.failed {
|
||||
color: #A94442;
|
||||
background-color: #F2DEDE;
|
||||
border-color: #EBCCD1;
|
||||
}
|
||||
.panel-heading.retried {
|
||||
background-color: #f7bd7f;
|
||||
border-color: #b35c00;
|
||||
}
|
||||
.panel-heading.skipped {
|
||||
color: #8A6D3B;
|
||||
background-color: #FCF8E3;
|
||||
border-color: #FAEBCC;
|
||||
}
|
||||
li.failed {
|
||||
color: #A94442;
|
||||
background-color: #F2DEDE;
|
||||
border-color: #EBCCD1;
|
||||
}
|
||||
</style>
|
||||
<script id="Tests" type="text/template">
|
||||
<div class="panel-group" id="accordion">
|
||||
{{#tests}} {{{html}}} {{/tests}}
|
||||
</div>
|
||||
</script>
|
||||
<script id="ScreenshotLink" type="text/template">
|
||||
<a href="./{{screenshot}}" title="{{screenshot}}">
|
||||
<span class="glyphicon glyphicon-camera" aria-hidden="true"></span>
|
||||
screenshot
|
||||
</a>
|
||||
</script>
|
||||
<script id="JournalLink" type="text/template">
|
||||
<a href="./{{journal}}" title="{{journal}}">
|
||||
<span class="glyphicon glyphicon-list-alt" aria-hidden="true"></span>
|
||||
journal
|
||||
</a>
|
||||
</script>
|
||||
<script id="TestEntry" type="text/template">
|
||||
<div class="panel panel-default" id="{{id}}">
|
||||
<div class="panel-heading
|
||||
{{#collapsed}}collapsed{{/collapsed}}
|
||||
{{^passed}}failed{{/passed}}
|
||||
{{#retried}}retried{{/retried}}
|
||||
{{#skipped}}skipped{{/skipped}}" data-toggle="collapse" data-target="#collapse{{id}}"
|
||||
style="cursor: pointer">
|
||||
<h4 class="panel-title">
|
||||
{{#failed}}
|
||||
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
|
||||
{{/failed}}
|
||||
{{#retried}}
|
||||
<span class="glyphicon glyphicon-question-sign" aria-hidden="true"></span>
|
||||
{{/retried}}
|
||||
<span>
|
||||
{{title}}
|
||||
</span>
|
||||
{{#reason}}<span>-- skipped: {{reason}}</span>{{/reason}}
|
||||
{{#screenshots}}
|
||||
{{{screenshot_html}}}
|
||||
{{/screenshots}}
|
||||
{{#journals}}
|
||||
{{{journal_html}}}
|
||||
{{/journals}}
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapse{{id}}" class="panel-collapse collapse {{^collapsed}}in{{/collapsed}}">
|
||||
<pre class="panel-body">{{text}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script id="TextOnly" type="text/template">
|
||||
<pre class="panel-body">{{text}}</pre>
|
||||
</script>
|
||||
<script id="TestProgress" type="text/template">
|
||||
<div class="progress" style="width: 40%">
|
||||
<div class="progress-bar progress-bar-success" style="width: {{percentage_passed}}%">
|
||||
{{num_passed}}
|
||||
<span class="sr-only">{{percentage_passed}}% Passed</span>
|
||||
</div>
|
||||
<div class="progress-bar progress-bar-warning" style="width: {{percentage_skipped}}%">
|
||||
{{num_skipped}}
|
||||
<span class="sr-only">{{percentage_skipped}}% Skipped</span>
|
||||
</div>
|
||||
<div class="progress-bar progress-bar-danger" style="width: {{percentage_failed}}%">
|
||||
{{num_failed}}
|
||||
<span class="sr-only">{{percentage_failed}}% Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script id="TestingOverview" type="text/template">
|
||||
<div id="testing">
|
||||
{{total}} tests, {{passed}} passed, {{failed}} failed,
|
||||
{{skipped}} skipped, {{left}} to go ({{retries}} retries).<br>
|
||||
|
||||
<span>Failed and retried tests:</span>
|
||||
<ul>
|
||||
{{#tests}}
|
||||
{{#entry.interesting}}
|
||||
<li
|
||||
{{^entry.retried}}
|
||||
class="failed"
|
||||
{{/entry.retried}}
|
||||
>
|
||||
<a href="#{{entry.id}}">
|
||||
{{entry.title}}
|
||||
</a>
|
||||
{{#entry.screenshots}}
|
||||
{{{screenshot_html}}}
|
||||
{{/entry.screenshots}}
|
||||
{{#entry.journals}}
|
||||
{{{journal_html}}}
|
||||
{{/entry.journals}}
|
||||
{{#entry.reason}}<span>-- skipped: {{entry.reason}}</span>{{/entry.reason}}
|
||||
</li>
|
||||
{{/entry.interesting}}
|
||||
{{/tests}}
|
||||
</ul>
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
var tap_range = /^([0-9]+)\.\.([0-9]+)$/m;
|
||||
var tap_result = /^(ok|not ok) ([0-9]+) (.*)(?: # duration: ([0-9]+s))?(?: # SKIP .*)?$$/gm;
|
||||
var tap_skipped = /^ok [0-9]+ ([^#].*\))(?: #? ?duration: ([^#]*))? # SKIP (.*$)/gm;
|
||||
var image_file = /([A-Za-z0-9\-\.]+)\.(png)$/gm;
|
||||
var journal_file = /(Journal extracted to )([A-Za-z0-9\-\.]+)\.(log)$/gm;
|
||||
var test_header_start = "# ----------------------------------------------------------------------"
|
||||
|
||||
var entry_template = $("#TestEntry").html();
|
||||
Mustache.parse(entry_template);
|
||||
var tests_template = $("#Tests").html();
|
||||
Mustache.parse(tests_template);
|
||||
var text_only_template = $("#TextOnly").html();
|
||||
Mustache.parse(text_only_template);
|
||||
var progress_template = $("#TestProgress").html();
|
||||
Mustache.parse(progress_template);
|
||||
var overview_template = $("#TestingOverview").html();
|
||||
Mustache.parse(overview_template);
|
||||
var screenshot_template = $("#ScreenshotLink").html();
|
||||
Mustache.parse(screenshot_template);
|
||||
var journal_template = $("#JournalLink").html();
|
||||
Mustache.parse(journal_template);
|
||||
|
||||
function extract(text) {
|
||||
var m, s;
|
||||
var first, last, total, passed, failed, skipped;
|
||||
/* default is to show the text we have, unless we find actual results */
|
||||
var altered_text = Mustache.render(text_only_template, {
|
||||
text: text
|
||||
});
|
||||
var entries = [];
|
||||
var indices = {};
|
||||
if (m = tap_range.exec(text)) {
|
||||
first = parseInt(m[1], 10);
|
||||
last = parseInt(m[2], 10);
|
||||
total = last-first+1;
|
||||
|
||||
passed = 0;
|
||||
failed = 0;
|
||||
skipped = 0;
|
||||
retries = 0;
|
||||
|
||||
var segments = text.split(test_header_start);
|
||||
$('#test-info').text(text.slice(0, text.indexOf('\n')));
|
||||
|
||||
var test_links = {};
|
||||
var ids = { };
|
||||
segments.forEach(function (segment, segmentIndex) {
|
||||
tap_range.lastIndex = 0;
|
||||
tap_result.lastIndex = 0;
|
||||
tap_skipped.lastIndex = 0;
|
||||
image_file.lastIndex = 0;
|
||||
journal_file.lastIndex = 0;
|
||||
var entry = { passed: true,
|
||||
skipped: false,
|
||||
retried: false,
|
||||
interesting: false,
|
||||
screenshots: [],
|
||||
journals: [],
|
||||
text: segment};
|
||||
if (m = tap_range.exec(segment)) {
|
||||
entry.idx = 0;
|
||||
entry.id = "initialization"
|
||||
entry.title = entry.id;
|
||||
// hide this by default
|
||||
// maybe we can have better criteria?
|
||||
entry.passed = true;
|
||||
} else if (m = tap_result.exec(segment)) {
|
||||
entry.idx = m[2];
|
||||
entry.id = m[2];
|
||||
r = 0;
|
||||
while (ids[entry.id]) {
|
||||
r += 1;
|
||||
entry.id = m[2] + "-" + r;
|
||||
}
|
||||
ids[entry.id] = true;
|
||||
entry.title = entry.id + ": " + m[3];
|
||||
if (m[4])
|
||||
entry.title += ", duration: " + m[4];
|
||||
|
||||
if(m[1] == "ok") {
|
||||
if (m = tap_skipped.exec(segment)) {
|
||||
entry.title = entry.id + ": " + m[1];
|
||||
entry.reason = m[3];
|
||||
entry.skipped = true;
|
||||
entry.passed = false;
|
||||
skipped += 1;
|
||||
} else {
|
||||
passed += 1;
|
||||
}
|
||||
} else if (segment.indexOf("# RETRY") !== -1) {
|
||||
retries += 1;
|
||||
entry.passed = true;
|
||||
entry.retried = true;
|
||||
entry.interesting = true;
|
||||
} else {
|
||||
entry.passed = false;
|
||||
entry.interesting = true;
|
||||
failed += 1;
|
||||
}
|
||||
} else {
|
||||
// if this isn't the last segment and we don't have a result, treat it as failed
|
||||
if (segmentIndex+1 < segments.length) {
|
||||
entry.idx = 8000;
|
||||
entry.id = segment.split("\n")[1].slice(2);
|
||||
entry.title = entry.id;
|
||||
entry.passed = false;
|
||||
failed += 1;
|
||||
} else {
|
||||
entry.idx = 10000;
|
||||
entry.id = "in-progress";
|
||||
entry.title = "in progress";
|
||||
entry.passed = true;
|
||||
}
|
||||
}
|
||||
while (m = image_file.exec(segment)) {
|
||||
// we have an image link
|
||||
entry.screenshots.push({screenshot_html: Mustache.render(screenshot_template,
|
||||
{ screenshot: m[1] + "." + m[2] })
|
||||
});
|
||||
}
|
||||
while (m = journal_file.exec(segment)) {
|
||||
entry.journals.push({journal_html: Mustache.render(journal_template,
|
||||
{ journal: m[2] + "." + m[3] })
|
||||
});
|
||||
}
|
||||
entry.failed = !entry.passed && !entry.skipped;
|
||||
entry.collapsed = !entry.failed;
|
||||
entries.push({ idx: entry.idx, entry: entry, html: Mustache.render(entry_template, entry) });
|
||||
});
|
||||
entries.sort(function(a, b) {
|
||||
a = isNaN(parseInt(a.idx), 10) ? a.idx : parseInt(a.idx, 10);
|
||||
b = isNaN(parseInt(b.idx), 10) ? b.idx : parseInt(b.idx, 10);
|
||||
return a < b ? -1 : (a > b ? 1 : 0);
|
||||
});
|
||||
altered_text = Mustache.render(tests_template, { tests: entries });
|
||||
// for the overview list, put the failed entries first
|
||||
entries.sort(function(a, b) {
|
||||
var a_idx = isNaN(parseInt(a.idx, 10)) ? a.idx : parseInt(a.idx, 10);
|
||||
var b_idx = isNaN(parseInt(b.idx, 10)) ? b.idx : parseInt(b.idx, 10);
|
||||
if (a.entry.skipped == b.entry.skipped)
|
||||
return a_idx < b_idx ? -1 : (a_idx > b_idx ? 1 : 0);
|
||||
else if (!a.entry.skipped)
|
||||
return -1;
|
||||
else
|
||||
return 1;
|
||||
});
|
||||
$('#testing').html(Mustache.render(overview_template, { tests: entries,
|
||||
passed: passed,
|
||||
failed: failed,
|
||||
skipped: skipped,
|
||||
retries: retries,
|
||||
total: total,
|
||||
left: total - passed - failed - skipped
|
||||
})
|
||||
);
|
||||
$('#testing-progress').html(Mustache.render(progress_template,
|
||||
{ percentage_passed: 100*passed/total,
|
||||
percentage_skipped: 100*skipped/total,
|
||||
percentage_failed: 100*failed/total,
|
||||
num_passed: passed,
|
||||
num_skipped: skipped,
|
||||
num_failed: failed
|
||||
})
|
||||
);
|
||||
} else {
|
||||
$('#testing').empty();
|
||||
$('#testing-progress').empty();
|
||||
}
|
||||
|
||||
return altered_text;
|
||||
}
|
||||
|
||||
var interval_id;
|
||||
|
||||
function poll() {
|
||||
$.ajax({
|
||||
mimeType: 'text/plain; charset=x-user-defined',
|
||||
url: 'log',
|
||||
type: 'GET',
|
||||
dataType: 'text',
|
||||
cache: false,
|
||||
}).done(function (text) {
|
||||
var amended_text = extract(text);
|
||||
$('#log').html(amended_text);
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
mimeType: 'application/json; charset=x-user-defined',
|
||||
url: 'status',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
cache: false,
|
||||
}).done(function (status) {
|
||||
$('#message').text(status.message);
|
||||
if ((status.message == "Install failed") ||
|
||||
(status.message == "Rebase failed")) {
|
||||
$('#testing-progress').html(Mustache.render(progress_template,
|
||||
{ percentage_passed: 0,
|
||||
percentage_skipped: 0,
|
||||
percentage_failed: 100,
|
||||
num_passed: 0,
|
||||
num_skipped: 0,
|
||||
num_failed: status.message
|
||||
})
|
||||
);
|
||||
}
|
||||
$('#status').show();
|
||||
clearInterval(interval_id);
|
||||
});
|
||||
}
|
||||
|
||||
$(function () {
|
||||
interval_id = setInterval(poll, 30000);
|
||||
poll();
|
||||
});
|
||||
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h3 id="test-info">Logs</h3>
|
||||
<p>
|
||||
<a href=".">Result directory</a><br>
|
||||
<a href="./log">Raw log</a>
|
||||
</p>
|
||||
<div id="status" style="display:none">
|
||||
Done: <span id="message"></span>.
|
||||
</div>
|
||||
<div id="testing-progress"></div>
|
||||
<div id="testing"></div>
|
||||
<div id="log"></div>
|
||||
</body>
|
||||
</html>
|
||||
99
bots/task/sink.py
Normal file
99
bots/task/sink.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2015 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
__all__ = (
|
||||
'Sink',
|
||||
)
|
||||
|
||||
BOTS = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
class Sink(object):
|
||||
def __init__(self, host, identifier, status=None):
|
||||
self.attachments = tempfile.mkdtemp(prefix="attachments.", dir="/var/tmp")
|
||||
os.environ["TEST_ATTACHMENTS"] = self.attachments
|
||||
self.status = status
|
||||
|
||||
# Start a gzip and cat processes
|
||||
self.ssh = subprocess.Popen([
|
||||
"ssh", "-o", "ServerAliveInterval=30", host, "--",
|
||||
"python", "sink", identifier
|
||||
], stdin=subprocess.PIPE)
|
||||
|
||||
# Send the status line
|
||||
self.ssh.stdin.write(json.dumps(status).encode('utf-8') + b"\n")
|
||||
self.ssh.stdin.flush()
|
||||
|
||||
# Now dup our own output and errors into the pipeline
|
||||
sys.stdout.flush()
|
||||
self.fout = os.dup(1)
|
||||
os.dup2(self.ssh.stdin.fileno(), 1)
|
||||
sys.stderr.flush()
|
||||
self.ferr = os.dup(2)
|
||||
os.dup2(self.ssh.stdin.fileno(), 2)
|
||||
|
||||
def attach(self, filename):
|
||||
shutil.copy(filename, self.attachments)
|
||||
|
||||
def flush(self, status=None):
|
||||
assert self.ssh is not None
|
||||
|
||||
# Reset stdout back
|
||||
sys.stdout.flush()
|
||||
os.dup2(self.fout, 1)
|
||||
os.close(self.fout)
|
||||
self.fout = -1
|
||||
|
||||
# Reset stderr back
|
||||
sys.stderr.flush()
|
||||
os.dup2(self.ferr, 2)
|
||||
os.close(self.ferr)
|
||||
self.ferr = -1
|
||||
|
||||
# Splice in the github status
|
||||
if status is None:
|
||||
status = self.status
|
||||
if status is not None:
|
||||
self.ssh.stdin.write(b"\n" + json.dumps(status).encode('utf-8'))
|
||||
|
||||
# Send a zero character and send the attachments
|
||||
files = os.listdir(self.attachments)
|
||||
if len(files):
|
||||
self.ssh.stdin.write(b'\x00')
|
||||
self.ssh.stdin.flush()
|
||||
with tarfile.open(name="attachments.tgz", mode="w:gz", fileobj=self.ssh.stdin) as tar:
|
||||
for filename in files:
|
||||
tar.add(os.path.join(self.attachments, filename), arcname=filename, recursive=True)
|
||||
shutil.rmtree(self.attachments)
|
||||
|
||||
# All done sending output
|
||||
self.ssh.stdin.close()
|
||||
|
||||
# SSH should terminate by itself
|
||||
ret = self.ssh.wait()
|
||||
if ret != 0:
|
||||
raise subprocess.CalledProcessError(ret, "ssh")
|
||||
self.ssh = None
|
||||
88
bots/task/test-cache
Executable file
88
bots/task/test-cache
Executable file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import cache
|
||||
|
||||
class TestCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
for name in os.listdir(self.directory):
|
||||
os.unlink(os.path.join(self.directory, name))
|
||||
os.rmdir(self.directory)
|
||||
|
||||
def testReadWrite(self):
|
||||
value = { "blah": 1 }
|
||||
|
||||
c = cache.Cache(self.directory)
|
||||
result = c.read("pa+t\%h")
|
||||
self.assertIsNone(result)
|
||||
|
||||
c.write("pa+t\%h", value)
|
||||
result = c.read("pa+t\%h")
|
||||
self.assertEqual(result, value)
|
||||
|
||||
other = "other"
|
||||
c.write("pa+t\%h", other)
|
||||
result = c.read("pa+t\%h")
|
||||
self.assertEqual(result, other)
|
||||
|
||||
c.write("second", value)
|
||||
result = c.read("pa+t\%h")
|
||||
self.assertEqual(result, other)
|
||||
|
||||
def testCurrent(self):
|
||||
c = cache.Cache(self.directory, lag=3)
|
||||
|
||||
c.write("resource2", { "value": 2 })
|
||||
self.assertTrue(c.current("resource2"))
|
||||
|
||||
time.sleep(2)
|
||||
self.assertTrue(c.current("resource2"))
|
||||
|
||||
time.sleep(2)
|
||||
self.assertFalse(c.current("resource2"))
|
||||
|
||||
def testCurrentMark(self):
|
||||
c = cache.Cache(self.directory, lag=3)
|
||||
|
||||
self.assertFalse(c.current("resource"))
|
||||
|
||||
c.write("resource", { "value": 1 })
|
||||
self.assertTrue(c.current("resource"))
|
||||
|
||||
time.sleep(2)
|
||||
self.assertTrue(c.current("resource"))
|
||||
|
||||
c.mark()
|
||||
self.assertFalse(c.current("resource"))
|
||||
|
||||
def testCurrentZero(self):
|
||||
c = cache.Cache(self.directory, lag=0)
|
||||
c.write("resource", { "value": 1 })
|
||||
self.assertFalse(c.current("resource"))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
94
bots/task/test-checklist
Executable file
94
bots/task/test-checklist
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
BASE = os.path.dirname(__file__)
|
||||
sys.path.insert(1, os.path.join(BASE, ".."))
|
||||
|
||||
from task import github
|
||||
|
||||
class TestChecklist(unittest.TestCase):
|
||||
def testParse(self):
|
||||
parse_line = github.Checklist.parse_line
|
||||
self.assertEqual(parse_line("blah"), (None, None))
|
||||
self.assertEqual(parse_line(""), (None, None))
|
||||
self.assertEqual(parse_line(""), (None, None))
|
||||
self.assertEqual(parse_line("* [ ] test two"), ("test two", False))
|
||||
self.assertEqual(parse_line("- [ ] test two"), ("test two", False))
|
||||
self.assertEqual(parse_line(" * [ ] test two "), ("test two", False))
|
||||
self.assertEqual(parse_line(" - [ ] test two "), ("test two", False))
|
||||
self.assertEqual(parse_line(" - [x] test two "), ("test two", True))
|
||||
self.assertEqual(parse_line(" * [x] test two"), ("test two", True))
|
||||
self.assertEqual(parse_line(" * [x] FAIL: test two"), ("test two", "FAIL"))
|
||||
self.assertEqual(parse_line(" * [x] FAIL: test FAIL: two"), ("test FAIL: two", "FAIL"))
|
||||
self.assertEqual(parse_line(" * [x]test three"), (None, None))
|
||||
self.assertEqual(parse_line(" - [X] test two "), ("test two", True))
|
||||
self.assertEqual(parse_line(" * [X] test four"), ("test four", True))
|
||||
self.assertEqual(parse_line(" * [X] FAIL: test four"), ("test four", "FAIL"))
|
||||
self.assertEqual(parse_line(" * [X] FAIL: test FAIL: four"), ("test FAIL: four", "FAIL"))
|
||||
self.assertEqual(parse_line(" * [X]test five"), (None, None))
|
||||
|
||||
def testFormat(self):
|
||||
format_line = github.Checklist.format_line
|
||||
self.assertEqual(format_line("blah", True), " * [x] blah")
|
||||
self.assertEqual(format_line("blah", False), " * [ ] blah")
|
||||
self.assertEqual(format_line("blah", "FAIL"), " * [ ] FAIL: blah")
|
||||
|
||||
def testProcess(self):
|
||||
body = "This is a description\n- [ ] item1\n * [x] Item two\n * [X] Item three\n\nMore lines"
|
||||
checklist = github.Checklist(body)
|
||||
self.assertEqual(checklist.body, body)
|
||||
self.assertEqual(checklist.items, { "item1": False, "Item two": True, "Item three": True })
|
||||
|
||||
def testCheck(self):
|
||||
body = "This is a description\n- [ ] item1\n * [x] Item two\n * [X] Item three\n\nMore lines"
|
||||
checklist = github.Checklist(body)
|
||||
checklist.check("item1", True)
|
||||
checklist.check("Item three", False)
|
||||
self.assertEqual(checklist.body, "This is a description\n * [x] item1\n * [x] Item two\n * [ ] Item three\n\nMore lines")
|
||||
self.assertEqual(checklist.items, { "item1": True, "Item two": True, "Item three": False })
|
||||
|
||||
def testDisable(self):
|
||||
body = "This is a description\n- [ ] item1\n * [x] Item two\n\nMore lines"
|
||||
checklist = github.Checklist(body)
|
||||
checklist.check("item1", "Status")
|
||||
self.assertEqual(checklist.body, "This is a description\n * [ ] Status: item1\n * [x] Item two\n\nMore lines")
|
||||
self.assertEqual(checklist.items, { "item1": "Status", "Item two": True })
|
||||
|
||||
def testAdd(self):
|
||||
body = "This is a description\n- [ ] item1\n * [x] Item two\n\nMore lines"
|
||||
checklist = github.Checklist(body)
|
||||
checklist.add("Item three")
|
||||
self.assertEqual(checklist.body, "This is a description\n- [ ] item1\n * [x] Item two\n\nMore lines\n * [ ] Item three")
|
||||
self.assertEqual(checklist.items, { "item1": False, "Item two": True, "Item three": False })
|
||||
|
||||
def testChecked(self):
|
||||
body = "This is a description\n- [ ] item1\n * [x] Item two\n * [X] Item three\n\nMore lines"
|
||||
checklist = github.Checklist(body)
|
||||
checklist.check("item1", True)
|
||||
checklist.check("Item three", False)
|
||||
checked = checklist.checked()
|
||||
self.assertEqual(checklist.items, { "item1": True, "Item two": True, "Item three": False })
|
||||
self.assertEqual(checked, { "item1": True, "Item two": True })
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
197
bots/task/test-github
Executable file
197
bots/task/test-github
Executable file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
ADDRESS = ("127.0.0.8", 9898)
|
||||
|
||||
import ctypes
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
BASE = os.path.dirname(__file__)
|
||||
sys.path.insert(1, os.path.join(BASE, ".."))
|
||||
|
||||
from task import cache
|
||||
from task import github
|
||||
|
||||
def mockServer():
|
||||
# Data used by the above handler
|
||||
data = {
|
||||
"count": 0,
|
||||
}
|
||||
|
||||
issues =[{ "number": "5", "state": "open", "created_at": "2011-04-22T13:33:48Z" },
|
||||
{ "number": "6", "state": "closed", "closed_at": "2011-04-21T13:33:48Z" },
|
||||
{ "number": "7", "state": "open" }]
|
||||
|
||||
import http.server
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def replyData(self, value, headers={ }, status=200):
|
||||
self.send_response(status)
|
||||
for name, content in headers.items():
|
||||
self.send_header(name, content)
|
||||
self.end_headers()
|
||||
self.wfile.write(value.encode('utf-8'))
|
||||
self.wfile.flush()
|
||||
|
||||
def replyJson(self, value, headers={ }, status=200):
|
||||
headers["Content-type"] = "application/json"
|
||||
self.server.data["count"] += 1
|
||||
self.replyData(json.dumps(value), headers=headers, status=status)
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path == "/count":
|
||||
self.replyJson(self.server.data["count"])
|
||||
elif parsed.path == "/issues":
|
||||
self.replyJson(issues)
|
||||
elif parsed.path == "/test/user":
|
||||
if self.headers.get("If-None-Match") == "blah":
|
||||
self.replyData("", status=304)
|
||||
else:
|
||||
self.replyJson({ "user": "blah" }, headers={ "ETag": "blah" })
|
||||
elif parsed.path == "/test/user/modified":
|
||||
if self.headers.get("If-Modified-Since") == "Thu, 05 Jul 2012 15:31:30 GMT":
|
||||
self.replyData("", status=304)
|
||||
else:
|
||||
self.replyJson({ "user": "blah" }, headers={ "Last-Modified": "Thu, 05 Jul 2012 15:31:30 GMT" })
|
||||
elif parsed.path == "/orgs/cockpit-project/teams":
|
||||
self.replyJson([
|
||||
{ "name": github.TEAM_CONTRIBUTORS, "id": 1 },
|
||||
{ "name": "Other team", "id": 2 }
|
||||
])
|
||||
elif parsed.path == "/teams/1/members":
|
||||
self.replyJson([
|
||||
{ "login": "one" },
|
||||
{ "login": "two" },
|
||||
{ "login": "three" }
|
||||
])
|
||||
else:
|
||||
self.send_error(404, 'Mock Not Found: ' + parsed.path)
|
||||
|
||||
def do_DELETE(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path == '/issues/7':
|
||||
del issues[-1]
|
||||
self.replyJson({})
|
||||
else:
|
||||
self.send_error(404, 'Mock Not Found: ' + parsed.path)
|
||||
|
||||
httpd = http.server.HTTPServer(ADDRESS, Handler)
|
||||
httpd.data = data
|
||||
|
||||
child = os.fork()
|
||||
if child != 0:
|
||||
httpd.server_close()
|
||||
return child
|
||||
|
||||
# prctl(PR_SET_PDEATHSIG, SIGTERM)
|
||||
try:
|
||||
libc = ctypes.CDLL('libc.so.6')
|
||||
libc.prctl(1, 15)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
httpd.serve_forever()
|
||||
os._exit(1)
|
||||
|
||||
def mockKill(child):
|
||||
os.kill(child, signal.SIGTERM)
|
||||
os.waitpid(child, 0)
|
||||
|
||||
class TestLogger(object):
|
||||
def __init__(self):
|
||||
self.data = ""
|
||||
|
||||
# Yes, we open the file each time
|
||||
def write(self, value):
|
||||
self.data = self.data + value
|
||||
|
||||
class TestGitHub(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.child = mockServer()
|
||||
self.temp = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
mockKill(self.child)
|
||||
shutil.rmtree(self.temp)
|
||||
|
||||
def testCache(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898", cacher=cache.Cache(self.temp))
|
||||
|
||||
values = api.get("/test/user")
|
||||
cached = api.get("/test/user")
|
||||
self.assertEqual(json.dumps(values), json.dumps(cached))
|
||||
|
||||
count = api.get("/count")
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def testLog(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898", cacher=cache.Cache(self.temp))
|
||||
api.log = TestLogger()
|
||||
|
||||
api.get("/test/user")
|
||||
api.cache.mark(time.time() + 1)
|
||||
api.get("/test/user")
|
||||
|
||||
expect = '127.0.0.8:9898 - - * "GET /test/user HTTP/1.1" 200 -\n' + \
|
||||
'127.0.0.8:9898 - - * "GET /test/user HTTP/1.1" 304 -\n'
|
||||
|
||||
match = fnmatch.fnmatch(api.log.data, expect)
|
||||
if not match:
|
||||
self.fail("'{0}' did not match '{1}'".format(api.log.data, expect))
|
||||
|
||||
def testIssuesSince(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898/")
|
||||
issues = api.issues(since=1499838499)
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["number"], "7")
|
||||
|
||||
def testWhitelist(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898/")
|
||||
whitelist = api.whitelist()
|
||||
self.assertTrue(len(whitelist) > 0)
|
||||
self.assertEqual(whitelist, set(["one", "two", "three"]))
|
||||
self.assertNotIn("four", whitelist)
|
||||
self.assertNotIn("", whitelist)
|
||||
|
||||
def testTeamIdFromName(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898/")
|
||||
self.assertEqual(api.teamIdFromName(github.TEAM_CONTRIBUTORS), 1)
|
||||
self.assertEqual(api.teamIdFromName("Other team"), 2)
|
||||
self.assertRaises(KeyError, api.teamIdFromName, "team that doesn't exist")
|
||||
|
||||
def testLastIssueDelete(self):
|
||||
api = github.GitHub("http://127.0.0.8:9898/")
|
||||
self.assertEqual(len(api.issues()), 2)
|
||||
api.delete("/issues/7")
|
||||
issues = api.issues()
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["number"], "5")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
105
bots/task/test-policy
Executable file
105
bots/task/test-policy
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
BASE = os.path.dirname(__file__)
|
||||
BOTS = os.path.normpath(os.path.join(BASE, ".."))
|
||||
sys.path.insert(1, BOTS)
|
||||
|
||||
PCP_CRASH = """
|
||||
# ----------------------------------------------------------------------
|
||||
# testFrameNavigation (check_multi_machine.TestMultiMachine)
|
||||
#
|
||||
Unexpected journal message '/usr/libexec/cockpit-pcp: bridge was killed: 11'
|
||||
not ok 110 testFrameNavigation (check_multi_machine.TestMultiMachine)
|
||||
Traceback (most recent call last):
|
||||
File "test/verify/check-multi-machine", line 202, in tearDown
|
||||
MachineCase.tearDown(self)
|
||||
File "/home/martin/upstream/cockpit/test/common/testlib.py", line 533, in tearDown
|
||||
self.check_journal_messages()
|
||||
File "/home/martin/upstream/cockpit/test/common/testlib.py", line 689, in check_journal_messages
|
||||
raise Error(first)
|
||||
Error: /usr/libexec/cockpit-pcp: bridge was killed: 11
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.png
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.html
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.js.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2503-FAIL.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2502-FAIL.log
|
||||
"""
|
||||
|
||||
PCP_KNOWN = """
|
||||
# ----------------------------------------------------------------------
|
||||
# testFrameNavigation (check_multi_machine.TestMultiMachine)
|
||||
#
|
||||
Unexpected journal message '/usr/libexec/cockpit-pcp: bridge was killed: 11'
|
||||
ok 110 testFrameNavigation (check_multi_machine.TestMultiMachine) # SKIP Known issue #9876
|
||||
Traceback (most recent call last):
|
||||
File "test/verify/check-multi-machine", line 202, in tearDown
|
||||
MachineCase.tearDown(self)
|
||||
File "/home/martin/upstream/cockpit/test/common/testlib.py", line 533, in tearDown
|
||||
self.check_journal_messages()
|
||||
File "/home/martin/upstream/cockpit/test/common/testlib.py", line 689, in check_journal_messages
|
||||
raise Error(first)
|
||||
Error: /usr/libexec/cockpit-pcp: bridge was killed: 11
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.png
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.html
|
||||
Wrote TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.js.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2501-FAIL.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2503-FAIL.log
|
||||
Journal extracted to TestMultiMachine-testFrameNavigation-fedora-i386-127.0.0.2-2502-FAIL.log
|
||||
"""
|
||||
|
||||
class TestPolicy(unittest.TestCase):
|
||||
def testNaughtyNumber(self):
|
||||
script = os.path.join(BOTS, "image-naughty")
|
||||
cmd = [ script, "--offline", "verify/example" ]
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
|
||||
(output, unused) = proc.communicate(PCP_CRASH)
|
||||
self.assertEqual(output, "9876\n")
|
||||
|
||||
def testSimpleNumber(self):
|
||||
script = os.path.join(BOTS, "tests-policy")
|
||||
cmd = [ script, "--simple", "--offline", "verify/example" ]
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
|
||||
(output, unused) = proc.communicate(PCP_CRASH)
|
||||
self.assertEqual(output, "9876\n")
|
||||
|
||||
def testScenario(self):
|
||||
script = os.path.join(BOTS, "tests-policy")
|
||||
cmd = [ script, "--simple", "--offline", "verify/example/scen-one" ]
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
|
||||
(output, unused) = proc.communicate(PCP_CRASH)
|
||||
self.assertEqual(output, "9876\n")
|
||||
|
||||
def testKnownIssue(self):
|
||||
script = os.path.join(BOTS, "tests-policy")
|
||||
cmd = [ script, "--offline", "verify/example" ]
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
|
||||
(output, unused) = proc.communicate(PCP_CRASH)
|
||||
self.assertEqual(output, PCP_KNOWN)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
171
bots/task/test-task
Executable file
171
bots/task/test-task
Executable file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2017 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
ADDRESS = ("127.0.0.9", 9898)
|
||||
|
||||
import ctypes
|
||||
import imp
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
BASE = os.path.dirname(__file__)
|
||||
os.environ["GITHUB_API"] = "http://127.0.0.9:9898"
|
||||
os.environ["GITHUB_BASE"] = "project/repo"
|
||||
|
||||
task = imp.load_source("task", os.path.join(BASE, "__init__.py"))
|
||||
|
||||
DATA = {
|
||||
"/repos/project/repo/issues/3333": {
|
||||
"title": "The issue title"
|
||||
},
|
||||
"/repos/project/repo/pulls/1234": {
|
||||
"title": "Task title",
|
||||
"number": 1234,
|
||||
"body": "This is the body",
|
||||
"head": {"sha": "abcdef"},
|
||||
},
|
||||
"/users/user/repos": [{"full_name": "project/repo"}]
|
||||
}
|
||||
|
||||
def mockServer():
|
||||
# Data used by below handler
|
||||
data = { }
|
||||
|
||||
import http.server
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def replyData(self, value, headers={ }, status=200):
|
||||
self.send_response(status)
|
||||
for name, content in headers.items():
|
||||
self.send_header(name, content)
|
||||
self.end_headers()
|
||||
self.wfile.write(value)
|
||||
self.wfile.flush()
|
||||
|
||||
def replyJson(self, value, headers={ }, status=200):
|
||||
headers["Content-type"] = "application/json"
|
||||
self.replyData(json.dumps(value).encode('utf-8'), headers=headers, status=status)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in DATA:
|
||||
self.replyJson(DATA[self.path])
|
||||
else:
|
||||
self.send_error(404, 'Mock Not Found: ' + self.path)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/repos/project/repo/pulls":
|
||||
content_len = int(self.headers.get('content-length'))
|
||||
data = json.loads(self.rfile.read(content_len).decode('utf-8'))
|
||||
assert(data['title'] == "[no-test] Task title")
|
||||
data["number"] = 1234
|
||||
self.replyJson(data)
|
||||
elif self.path == "/repos/project/repo/pulls/1234":
|
||||
content_len = int(self.headers.get('content-length'))
|
||||
data = json.loads(self.rfile.read(content_len).decode('utf-8'))
|
||||
data["number"] = 1234
|
||||
data["body"] = "This is the body"
|
||||
data["head"] = {"sha": "abcde"}
|
||||
self.replyJson(data)
|
||||
elif self.path == "/repos/project/repo/issues/1234/comments":
|
||||
content_len = int(self.headers.get('content-length'))
|
||||
data = json.loads(self.rfile.read(content_len).decode('utf-8'))
|
||||
self.replyJson(data)
|
||||
elif self.path == "/repos/project/repo/issues/1234/labels":
|
||||
content_len = int(self.headers.get('content-length'))
|
||||
data = json.loads(self.rfile.read(content_len).decode('utf-8'))
|
||||
self.replyJson(data)
|
||||
else:
|
||||
self.send_error(405, 'Method not allowed: ' + self.path)
|
||||
|
||||
httpd = http.server.HTTPServer(ADDRESS, Handler)
|
||||
httpd.data = data
|
||||
|
||||
child = os.fork()
|
||||
if child != 0:
|
||||
return child
|
||||
|
||||
# prctl(PR_SET_PDEATHSIG, SIGTERM)
|
||||
try:
|
||||
libc = ctypes.CDLL('libc.so.6')
|
||||
libc.prctl(1, 15)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
httpd.serve_forever()
|
||||
os._exit(1)
|
||||
|
||||
def mock_execute(*args):
|
||||
assert(args[0] == 'git')
|
||||
if args[1] == "show":
|
||||
return "Task title\n"
|
||||
elif args[1] == "commit" and args[2] == "--amend":
|
||||
if args[4] != "Task title\nCloses #1234":
|
||||
raise Exception("Incorrect commit message")
|
||||
elif args[1] == "push":
|
||||
assert(args[2] == "-f")
|
||||
else:
|
||||
raise Exception("Mocking unsupported git command")
|
||||
|
||||
def mockKill(child):
|
||||
os.kill(child, signal.SIGTERM)
|
||||
os.waitpid(child, 0)
|
||||
|
||||
class TestTask(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.child = mockServer()
|
||||
self.temp = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
mockKill(self.child)
|
||||
shutil.rmtree(self.temp)
|
||||
|
||||
def testRunArguments(self):
|
||||
status = { "ran": False }
|
||||
|
||||
def function(context, **kwargs):
|
||||
self.assertEqual(context, "my-context")
|
||||
self.assertEqual(kwargs["title"], "The issue title")
|
||||
status["ran"] = True
|
||||
|
||||
ret = task.run("my-context", function, name="blah", title="Task title", issue=3333)
|
||||
self.assertEqual(ret, 0)
|
||||
self.assertTrue(status["ran"])
|
||||
|
||||
def testComment(self):
|
||||
comment = task.comment(1234, "This is the comment")
|
||||
self.assertEqual(comment["body"], "This is the comment")
|
||||
|
||||
def testLabel(self):
|
||||
label = task.label(1234, ['xxx'])
|
||||
self.assertEqual(label, ['xxx'])
|
||||
|
||||
@patch('task.execute', mock_execute)
|
||||
def testPullBody(self):
|
||||
args = { "title": "Task title" }
|
||||
pull = task.pull("user:branch", body="This is the body", **args)
|
||||
self.assertEqual(pull["title"], "Task title")
|
||||
self.assertEqual(pull["body"], "This is the body")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
123
bots/task/testmap.py
Normal file
123
bots/task/testmap.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# This file is part of Cockpit.
|
||||
#
|
||||
# Copyright (C) 2019 Red Hat, Inc.
|
||||
#
|
||||
# Cockpit is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Cockpit is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
REPO_BRANCH_CONTEXT = {
|
||||
'cockpit-project/cockpit': {
|
||||
'master': ['fedora-30/container-bastion',
|
||||
'fedora-30/selenium-firefox', 'fedora-30/selenium-chrome', 'fedora-30/selenium-edge',
|
||||
'debian-stable', 'debian-testing',
|
||||
'ubuntu-1804', 'ubuntu-stable',
|
||||
'fedora-30', 'fedora-atomic',
|
||||
'rhel-8-0-distropkg', 'rhel-8-1',
|
||||
],
|
||||
'rhel-7.7': ['fedora-30/container-kubernetes',
|
||||
'fedora-30/container-bastion', 'fedora-30/selenium-firefox', 'fedora-30/selenium-chrome', 'rhel-7-7',
|
||||
],
|
||||
'rhel-8.0': ['fedora-30/container-bastion',
|
||||
'fedora-30/selenium-firefox', 'fedora-30/selenium-chrome',
|
||||
'rhel-8-0',
|
||||
],
|
||||
'rhel-8-appstream': ['fedora-30/container-bastion',
|
||||
'fedora-30/selenium-firefox', 'fedora-30/selenium-chrome', 'rhel-8-0-distropkg', 'rhel-8-1',
|
||||
],
|
||||
'rhel-8.1': ['fedora-30/container-bastion',
|
||||
'fedora-30/selenium-firefox', 'fedora-30/selenium-chrome', 'rhel-8-1',
|
||||
],
|
||||
# These can be triggered manually with bots/tests-trigger
|
||||
'_manual': [
|
||||
'fedora-i386',
|
||||
'fedora-testing',
|
||||
],
|
||||
},
|
||||
'cockpit-project/starter-kit': {
|
||||
'master': [
|
||||
'centos-7',
|
||||
'fedora-30',
|
||||
],
|
||||
},
|
||||
'cockpit-project/cockpit-ostree': {
|
||||
'master': [
|
||||
'fedora-atomic',
|
||||
'continuous-atomic',
|
||||
'rhel-atomic',
|
||||
],
|
||||
},
|
||||
'cockpit-project/cockpit-podman': {
|
||||
'master': [
|
||||
'fedora-29',
|
||||
'fedora-30',
|
||||
'rhel-8-1',
|
||||
],
|
||||
},
|
||||
'weldr/lorax': {
|
||||
'master': [
|
||||
'fedora-30',
|
||||
'fedora-30/tar',
|
||||
'fedora-30/live-iso',
|
||||
'fedora-30/qcow2',
|
||||
'fedora-30/aws',
|
||||
'fedora-30/openstack',
|
||||
'fedora-30/vmware',
|
||||
],
|
||||
'_manual': [
|
||||
'fedora-30/azure',
|
||||
'rhel-8-1',
|
||||
'rhel-8-1/live-iso',
|
||||
'rhel-8-1/qcow2',
|
||||
'rhel-8-1/aws',
|
||||
'rhel-8-1/azure',
|
||||
'rhel-8-1/openstack',
|
||||
'rhel-8-1/vmware',
|
||||
],
|
||||
'rhel7-extras': [
|
||||
'rhel-7-7',
|
||||
'rhel-7-7/live-iso',
|
||||
'rhel-7-7/qcow2',
|
||||
'rhel-7-7/aws',
|
||||
'rhel-7-7/azure',
|
||||
'rhel-7-7/openstack',
|
||||
'rhel-7-7/vmware',
|
||||
],
|
||||
},
|
||||
'weldr/cockpit-composer': {
|
||||
'master': [
|
||||
'fedora-30/chrome',
|
||||
'fedora-30/firefox',
|
||||
'fedora-30/edge',
|
||||
'rhel-7-7/firefox',
|
||||
'rhel-8-1/chrome',
|
||||
],
|
||||
'rhel-8.0': [
|
||||
'rhel-8-0/chrome',
|
||||
'rhel-8-0/firefox',
|
||||
'rhel-8-0/edge',
|
||||
],
|
||||
},
|
||||
'mvollmer/subscription-manager': {
|
||||
'master': [
|
||||
'rhel-8-1',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def projects():
|
||||
"""Return all projects for which we run tests."""
|
||||
return REPO_BRANCH_CONTEXT.keys()
|
||||
|
||||
def tests_for_project(project):
|
||||
"""Return branch -> contexts map."""
|
||||
return REPO_BRANCH_CONTEXT.get(project, {})
|
||||
Loading…
Add table
Add a link
Reference in a new issue