package.json: Update react package dependency

Closes #213
This commit is contained in:
Cockpituous 2019-08-22 16:05:08 +00:00
parent 044b8da55a
commit d5a822884f
288 changed files with 13040 additions and 1 deletions

513
bots/task/__init__.py Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, {})