-
Notifications
You must be signed in to change notification settings - Fork 18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
support for sending commands to bot instances via PR comments #172
Changes from all commits
759f75d
0c7df9e
f04e622
6630509
afa6560
17d22a4
64c3d2f
c949767
c8dee13
39c7d42
f73eaa3
2702bf9
0a2c678
ff19606
e2a5782
3d9cab1
716b91f
984dbd1
931954f
e72e6a1
b44e298
7d13d7e
5988b60
3ec8b95
267a687
ddf933f
d7aeecb
79e5b57
5517e33
95beb14
7cf7f07
4801e10
b2296e4
4199d03
6e4a5d8
e1bce70
f44c311
a3e7cd3
4d84482
a744423
b042c7d
a47097f
3ab752c
0d076f7
927924c
85198eb
ac359ac
ed9f4fd
dc56784
4d40b85
d082128
975a8bc
e91ae06
a794cfd
a3c305e
236e326
d2c62e6
f8f19e4
05c6243
36851d1
13b7a27
de62337
e6c527b
1b42d77
f189f6b
684820c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,15 +19,26 @@ | |
import tasks.deploy as deploy | ||
|
||
from connections import github | ||
from tasks.build import check_build_permission, get_architecture_targets, get_repo_cfg, submit_build_jobs | ||
from tasks.deploy import deploy_built_artefacts | ||
from tools import config | ||
from tools.args import event_handler_parse | ||
from tasks.build import check_build_permission, submit_build_jobs, get_repo_cfg | ||
from tasks.deploy import deploy_built_artefacts | ||
from tools.commands import EESSIBotCommand, EESSIBotCommandError, get_bot_command | ||
from tools.filter import EESSIBotActionFilter | ||
from tools.permissions import check_command_permission | ||
from tools.pr_comments import create_comment | ||
|
||
from pyghee.lib import PyGHee, create_app, get_event_info, read_event_from_json | ||
from pyghee.utils import log | ||
|
||
|
||
APP_NAME = "app_name" | ||
BOT_CONTROL = "bot_control" | ||
COMMAND_RESPONSE_FMT = "command_response_fmt" | ||
GITHUB = "github" | ||
REPO_TARGET_MAP = "repo_target_map" | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
class EESSIBotSoftwareLayer(PyGHee): | ||
|
||
def __init__(self, *args, **kwargs): | ||
|
@@ -60,10 +71,151 @@ def handle_issue_comment_event(self, event_info, log_file=None): | |
""" | ||
request_body = event_info['raw_request_body'] | ||
issue_url = request_body['issue']['url'] | ||
comment_author = request_body['comment']['user']['login'] | ||
comment_txt = request_body['comment']['body'] | ||
self.log("Comment posted in %s by @%s: %s", issue_url, comment_author, comment_txt) | ||
self.log("issue_comment event handled!") | ||
action = request_body['action'] | ||
sender = request_body['sender']['login'] | ||
owner = request_body['comment']['user']['login'] | ||
repo_name = request_body['repository']['full_name'] | ||
pr_number = request_body['issue']['number'] | ||
|
||
# FIXME add request body text (['comment']['body']) to log message when | ||
# log level is set to debug | ||
self.log(f"Comment in {issue_url} (owned by @{owner}) {action} by @{sender}") | ||
|
||
# obtain app name and format for reporting about received & processed | ||
# commands | ||
app_name = self.cfg[GITHUB][APP_NAME] | ||
command_response_fmt = self.cfg[BOT_CONTROL][COMMAND_RESPONSE_FMT] | ||
|
||
# currently, only commands in new comments are supported | ||
# - commands have the syntax 'bot: COMMAND [ARGS*]' | ||
|
||
# first check if sender is authorized to send any command | ||
# - this serves a double purpose: | ||
# 1. check permission | ||
# 2. skip any comment updates that were done by the bot itself | ||
# --> thus we prevent the bot from entering an endless loop | ||
# where it reacts on updates to comments it made itself | ||
# NOTE this assumes that the sender of the event is corresponding to | ||
# the bot if the bot updates comments itself and that the bot is not | ||
# given permission in the configuration setting 'command_permission' | ||
# ... in order to prevent surprises we should be careful what the bot | ||
# adds to comments, for example, before updating a comment it could | ||
# run the update through the function checking for a bot command. | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if check_command_permission(sender) is False: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is triggered for every issue comment being made, it's done too early, since for accounts that don't have permission to send commands to the bot this will immediately trigger a That should only be done if the comment effectively includes bot commands. Let's try and make this the first thing we fix after merging this PR... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opened #184 to follow up on this |
||
self.log(f"account `{sender}` has NO permission to send commands to bot") | ||
# need to ensure that the bot is not responding on its own comments | ||
# as a quick implementation we check if the sender name contains '[bot]' | ||
# FIXME improve this by querying (and caching) information about the sender of | ||
# an event ALTERNATIVELY we could postpone this test a bit until we | ||
# have parsed the comment and know if it contains any bot command | ||
if not sender.endswith('[bot]'): | ||
comment_response = f"\n- account `{sender}` has NO permission to send commands to the bot" | ||
comment_body = command_response_fmt.format( | ||
app_name=app_name, | ||
comment_response=comment_response, | ||
comment_result='' | ||
) | ||
issue_comment = create_comment(repo_name, pr_number, comment_body) | ||
else: | ||
self.log(f"account `{sender}` seems to be a bot instance itself, hence not creating a new PR comment") | ||
return | ||
else: | ||
self.log(f"account `{sender}` has permission to send commands to bot") | ||
|
||
# only scan for commands in newly created comments | ||
if action == 'created': | ||
comment_received = request_body['comment']['body'] | ||
self.log(f"comment action '{action}' is handled") | ||
else: | ||
# NOTE we do not report this with a new PR comment, because otherwise any update to a comment would | ||
# let the bot add a response (would be very noisy); we might add a debug mode later | ||
self.log(f"comment action '{action}' not handled") | ||
return | ||
|
||
# search for commands in comment | ||
comment_response = '' | ||
commands = [] | ||
# process non-empty (if x) lines (split) in comment | ||
for line in [x for x in [y.strip() for y in comment_received.split('\n')] if x]: | ||
# FIXME add processed line(s) to log when log level is set to debug | ||
bot_command = get_bot_command(line) | ||
if bot_command: | ||
try: | ||
ebc = EESSIBotCommand(bot_command) | ||
except EESSIBotCommandError as bce: | ||
self.log(f"ERROR: parsing bot command '{bot_command}' failed with {bce.args}") | ||
# FIXME possibly add more information to log when log level is set to debug | ||
comment_response += f"\n- parsing the bot command `{bot_command}`, received" | ||
comment_response += f" from sender `{sender}`, failed" | ||
continue | ||
commands.append(ebc) | ||
self.log(f"found bot command: '{bot_command}'") | ||
comment_response += f"\n- received bot command `{bot_command}`" | ||
comment_response += f" from `{sender}`" | ||
comment_response += f"\n - expanded format: `{ebc.to_string()}`" | ||
# FIXME add an else branch that logs information for comments not | ||
# including a bot command; the logging should only be done when log | ||
# level is set to debug | ||
|
||
if comment_response == '': | ||
# no update to be added, just log and return | ||
self.log("comment response is empty") | ||
return | ||
else: | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.log(f"comment response: '{comment_response}'") | ||
|
||
if not any(map(get_bot_command, comment_response.split('\n'))): | ||
# the 'not any()' ensures that the response would not be considered a bot command itself | ||
# ... together with checking the sender of a comment update this aims | ||
# at preventing the bot to enter an endless loop in commenting on its own | ||
# comments | ||
comment_body = command_response_fmt.format( | ||
app_name=app_name, | ||
comment_response=comment_response, | ||
comment_result='' | ||
) | ||
issue_comment = create_comment(repo_name, pr_number, comment_body) | ||
else: | ||
self.log(f"update '{comment_response}' is considered to contain bot command ... not creating PR comment") | ||
# FIXME we may want to report this back to the PR on GitHub, e.g., | ||
# "Oops response message seems to contain a bot command. It is not | ||
# displayed here to prevent the bot from entering an endless loop | ||
# of commands. Please, check the logs at the bot instance for more | ||
# information." | ||
|
||
# process commands | ||
comment_result = '' | ||
for cmd in commands: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The implementation of How about introducing two methods there are called in commands = self.extract_bot_commands(...)
self.log("Found %d bot commands in comment ..." % len(commands))
self.process_bot_commands(commands) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, indeed. However, the data handed over from extracting commands to processing them is not just a list of I agree it's long. Tend to leave it as is and rather open an issue for refactoring this later. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, let's open the issue. |
||
try: | ||
update = self.handle_bot_command(event_info, cmd) | ||
comment_result += f"\n- handling command `{cmd.to_string()}` resulted in: " | ||
comment_result += update | ||
self.log(f"handling command '{cmd.to_string()}' resulted in '{update}'") | ||
|
||
except EESSIBotCommandError as err: | ||
self.log(f"ERROR: handling command {cmd.command} failed with {err.args[0]}") | ||
comment_result += f"\n- handling command `{cmd.command}` failed with message" | ||
comment_result += f"\n _{err.args[0]}_" | ||
continue | ||
except Exception as err: | ||
log(f"Unexpected err={err}, type(err)={type(err)}") | ||
if comment_result: | ||
comment_body = command_response_fmt.format( | ||
app_name=app_name, | ||
comment_response=comment_response, | ||
comment_result=comment_result | ||
) | ||
issue_comment.edit(comment_body) | ||
raise | ||
# only update PR comment once | ||
comment_body = command_response_fmt.format( | ||
app_name=app_name, | ||
comment_response=comment_response, | ||
comment_result=comment_result | ||
) | ||
issue_comment.edit(comment_body) | ||
|
||
self.log(f"issue_comment event (url {issue_url}) handled!") | ||
|
||
def handle_installation_event(self, event_info, log_file=None): | ||
""" | ||
|
@@ -88,7 +240,8 @@ def handle_pull_request_labeled_event(self, event_info, pr): | |
if label == "bot:build": | ||
# run function to build software stack | ||
if check_build_permission(pr, event_info): | ||
submit_build_jobs(pr, event_info) | ||
# use an empty filter | ||
submit_build_jobs(pr, event_info, EESSIBotActionFilter("")) | ||
|
||
elif label == "bot:deploy": | ||
# run function to deploy built artefacts | ||
|
@@ -101,13 +254,20 @@ def handle_pull_request_opened_event(self, event_info, pr): | |
Handle opening of a pull request. | ||
""" | ||
self.log("PR opened: waiting for label bot:build") | ||
app_name = self.cfg['github']['app_name'] | ||
app_name = self.cfg[GITHUB][APP_NAME] | ||
# TODO check if PR already has a comment with arch targets and | ||
# repositories | ||
arch_map = get_architecture_targets(self.cfg) | ||
repo_cfg = get_repo_cfg(self.cfg) | ||
|
||
comment = f"Instance `{app_name}` is configured to build:" | ||
for arch in repo_cfg['repo_target_map'].keys(): | ||
for repo_id in repo_cfg['repo_target_map'][arch]: | ||
|
||
for arch in arch_map.keys(): | ||
# check if repo_target_map contains an entry for {arch} | ||
if arch not in repo_cfg[REPO_TARGET_MAP]: | ||
self.log(f"skipping arch {arch} because repo target map does not define repositories to build for") | ||
continue | ||
for repo_id in repo_cfg[REPO_TARGET_MAP][arch]: | ||
comment += f"\n- arch `{'/'.join(arch.split('/')[1:])}` for repo `{repo_id}`" | ||
|
||
self.log(f"PR opened: comment '{comment}'") | ||
|
@@ -117,7 +277,8 @@ def handle_pull_request_opened_event(self, event_info, pr): | |
gh = github.get_instance() | ||
repo = gh.get_repo(repo_name) | ||
pull_request = repo.get_pull(pr.number) | ||
pull_request.create_issue_comment(comment) | ||
issue_comment = pull_request.create_issue_comment(comment) | ||
return issue_comment | ||
|
||
def handle_pull_request_event(self, event_info, log_file=None): | ||
""" | ||
|
@@ -138,6 +299,67 @@ def handle_pull_request_event(self, event_info, log_file=None): | |
else: | ||
self.log("No handler for PR action '%s'", action) | ||
|
||
def handle_bot_command(self, event_info, bot_command, log_file=None): | ||
""" | ||
Handle bot command | ||
|
||
Args: | ||
event_info (dict): object containing all information of the event | ||
bot_command (EESSIBotCommand): command to be handled | ||
""" | ||
cmd = bot_command.command | ||
handler_name = f"handle_bot_command_{cmd}" | ||
if hasattr(self, handler_name): | ||
handler = getattr(self, handler_name) | ||
self.log(f"Handling bot command {cmd}") | ||
return handler(event_info, bot_command) | ||
else: | ||
self.log(f"No handler for command '{cmd}'") | ||
raise EESSIBotCommandError(f"unknown command `{cmd}`; use `bot: help` for usage information") | ||
|
||
def handle_bot_command_help(self, event_info, bot_command): | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""handles command 'bot: help' with a simple usage info""" | ||
help_msg = "\n **How to send commands to bot instances**" | ||
help_msg += "\n - Commands must be sent with a **new** comment (edits of existing comments are ignored)." | ||
help_msg += "\n - A comment may contain multiple commands, one per line." | ||
help_msg += "\n - Every command begins at the start of a line and has the syntax `bot: COMMAND [ARGUMENTS]*`" | ||
help_msg += "\n - Currently supported COMMANDs are: `help`, `build`, `show_config`" | ||
return help_msg | ||
|
||
def handle_bot_command_build(self, event_info, bot_command): | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""handles command 'bot: build [ARGS*]' by parsing arguments and submitting jobs""" | ||
gh = github.get_instance() | ||
boegel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.log("repository: '%s'", event_info['raw_request_body']['repository']['full_name']) | ||
repo_name = event_info['raw_request_body']['repository']['full_name'] | ||
pr_number = event_info['raw_request_body']['issue']['number'] | ||
pr = gh.get_repo(repo_name).get_pull(pr_number) | ||
build_msg = '' | ||
if check_build_permission(pr, event_info): | ||
# use filter from command | ||
submitted_jobs = submit_build_jobs(pr, event_info, bot_command.action_filters) | ||
if submitted_jobs is None or len(submitted_jobs) == 0: | ||
build_msg = "\n - no jobs were submitted" | ||
else: | ||
for job_id, issue_comment in submitted_jobs.items(): | ||
build_msg += f"\n - submitted job `{job_id}`" | ||
if issue_comment: | ||
build_msg += f", for details & status see {issue_comment.html_url}" | ||
else: | ||
request_body = event_info['raw_request_body'] | ||
sender = request_body['sender']['login'] | ||
build_msg = f"\n - account `{sender}` has NO permission to submit build jobs" | ||
return build_msg | ||
|
||
def handle_bot_command_show_config(self, event_info, bot_command): | ||
"""handles command 'bot: show_config' by printing a list of configured build targets""" | ||
self.log("processing bot command 'show_config'") | ||
gh = github.get_instance() | ||
repo_name = event_info['raw_request_body']['repository']['full_name'] | ||
pr_number = event_info['raw_request_body']['issue']['number'] | ||
pr = gh.get_repo(repo_name).get_pull(pr_number) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't it make more sense to move this stuff to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In addition to |
||
issue_comment = self.handle_pull_request_opened_event(event_info, pr) | ||
return f"\n - added comment {issue_comment.html_url} to show configuration" | ||
|
||
def start(self, app, port=3000): | ||
"""starts the app and log information in the log file | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are also defined in
task/build.py
, which suggests there should be a commonconstants
module to import these from?Can be done in a follow-up PR of course