blob: c69cf9112da86d4bdafa68c874a015b2821624b2 [file] [log] [blame]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import os
import shlex
from pathlib import Path
from functools import partial
import tempfile
import click
import github
from .utils.git import git
from .utils.logger import logger
from .crossbow import Repo, Queue, Config, Target, Job, CommentReport
class EventError(Exception):
pass
class CommandError(Exception):
def __init__(self, message):
self.message = message
class _CommandMixin:
def get_help_option(self, ctx):
def show_help(ctx, param, value):
if value and not ctx.resilient_parsing:
raise click.UsageError(ctx.get_help())
option = super().get_help_option(ctx)
option.callback = show_help
return option
def __call__(self, message, **kwargs):
args = shlex.split(message)
try:
with self.make_context(self.name, args=args, obj=kwargs) as ctx:
return self.invoke(ctx)
except click.ClickException as e:
raise CommandError(e.format_message())
class Command(_CommandMixin, click.Command):
pass
class Group(_CommandMixin, click.Group):
def command(self, *args, **kwargs):
kwargs.setdefault('cls', Command)
return super().command(*args, **kwargs)
def group(self, *args, **kwargs):
kwargs.setdefault('cls', Group)
return super().group(*args, **kwargs)
def parse_args(self, ctx, args):
if not args and self.no_args_is_help and not ctx.resilient_parsing:
raise click.UsageError(ctx.get_help())
return super().parse_args(ctx, args)
command = partial(click.command, cls=Command)
group = partial(click.group, cls=Group)
class CommentBot:
def __init__(self, name, handler, token=None):
# TODO(kszucs): validate
assert isinstance(name, str)
assert callable(handler)
self.name = name
self.handler = handler
self.github = github.Github(token)
def parse_command(self, payload):
# only allow users of apache org to submit commands, for more see
# https://developer.github.com/v4/enum/commentauthorassociation/
allowed_roles = {'OWNER', 'MEMBER', 'CONTRIBUTOR'}
mention = '@{}'.format(self.name)
comment = payload['comment']
if payload['sender']['login'] == self.name:
raise EventError("Don't respond to itself")
elif payload['action'] not in {'created', 'edited'}:
raise EventError("Don't respond to comment deletion")
elif comment['author_association'] not in allowed_roles:
raise EventError(
"Don't respond to comments from non-authorized users"
)
elif not comment['body'].lstrip().startswith(mention):
raise EventError("The bot is not mentioned")
return payload['comment']['body'].split(mention)[-1].strip()
def handle(self, event, payload):
try:
command = self.parse_command(payload)
except EventError as e:
logger.error(e)
# see the possible reasons in the validate method
return
if event == 'issue_comment':
return self.handle_issue_comment(command, payload)
elif event == 'pull_request_review_comment':
return self.handle_review_comment(command, payload)
else:
raise ValueError("Unexpected event type {}".format(event))
def handle_issue_comment(self, command, payload):
repo = self.github.get_repo(payload['repository']['id'], lazy=True)
issue = repo.get_issue(payload['issue']['number'])
try:
pull = issue.as_pull_request()
except github.GithubException:
return issue.create_comment(
"The comment bot only listens to pull request comments!"
)
comment = pull.get_issue_comment(payload['comment']['id'])
try:
self.handler(command, issue=issue, pull_request=pull,
comment=comment)
except CommandError as e:
logger.error(e)
pull.create_issue_comment("```\n{}\n```".format(e.message))
except Exception as e:
logger.exception(e)
comment.create_reaction('-1')
else:
comment.create_reaction('+1')
def handle_review_comment(self, payload):
raise NotImplementedError()
@group(name='@github-actions')
@click.pass_context
def actions(ctx):
"""Ursabot"""
ctx.ensure_object(dict)
@actions.group()
@click.option('--crossbow', '-c', default='ursacomputing/crossbow',
help='Crossbow repository on github to use')
@click.pass_obj
def crossbow(obj, crossbow):
"""
Trigger crossbow builds for this pull request
"""
obj['crossbow_repo'] = crossbow
def _clone_arrow_and_crossbow(dest, crossbow_repo, pull_request):
"""
Clone the repositories and initialize crossbow objects.
Parameters
----------
dest : Path
Filesystem path to clone the repositories to.
crossbow_repo : str
Github repository name, like kszucs/crossbow.
pull_request : pygithub.PullRequest
Object containing information about the pull request the comment bot
was triggered from.
"""
arrow_path = dest / 'arrow'
queue_path = dest / 'crossbow'
# clone arrow and checkout the pull request's branch
pull_request_ref = 'pull/{}/head:{}'.format(
pull_request.number, pull_request.head.ref
)
git.clone(pull_request.base.repo.clone_url, str(arrow_path))
git.fetch('origin', pull_request_ref, git_dir=arrow_path)
git.checkout(pull_request.head.ref, git_dir=arrow_path)
# clone crossbow repository
crossbow_url = 'https://github.com/{}'.format(crossbow_repo)
git.clone(crossbow_url, str(queue_path))
# initialize crossbow objects
github_token = os.environ['CROSSBOW_GITHUB_TOKEN']
arrow = Repo(arrow_path)
queue = Queue(queue_path, github_token=github_token, require_https=True)
return (arrow, queue)
@crossbow.command()
@click.argument('tasks', nargs=-1, required=False)
@click.option('--group', '-g', 'groups', multiple=True,
help='Submit task groups as defined in tests.yml')
@click.option('--param', '-p', 'params', multiple=True,
help='Additional task parameters for rendering the CI templates')
@click.option('--arrow-version', '-v', default=None,
help='Set target version explicitly.')
@click.pass_obj
def submit(obj, tasks, groups, params, arrow_version):
"""
Submit crossbow testing tasks.
See groups defined in arrow/dev/tasks/tests.yml
"""
crossbow_repo = obj['crossbow_repo']
pull_request = obj['pull_request']
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
arrow, queue = _clone_arrow_and_crossbow(
dest=Path(tmpdir),
crossbow_repo=crossbow_repo,
pull_request=pull_request,
)
# load available tasks configuration and groups from yaml
config = Config.load_yaml(arrow.path / "dev" / "tasks" / "tasks.yml")
config.validate()
# initialize the crossbow build's target repository
target = Target.from_repo(arrow, version=arrow_version,
remote=pull_request.head.repo.clone_url,
branch=pull_request.head.ref)
# parse additional job parameters
params = dict([p.split("=") for p in params])
# instantiate the job object
job = Job.from_config(config=config, target=target, tasks=tasks,
groups=groups, params=params)
# add the job to the crossbow queue and push to the remote repository
queue.put(job, prefix="actions")
queue.push()
# render the response comment's content
report = CommentReport(job, crossbow_repo=crossbow_repo)
# send the response
pull_request.create_issue_comment(report.show())