diff --git a/app.cfg.example b/app.cfg.example index f9b296f6..f0a4ec49 100644 --- a/app.cfg.example +++ b/app.cfg.example @@ -61,6 +61,8 @@ command_response_fmt = {comment_result} +# chattiness level of the bot in terms of writing comments into PRs (minimal, basic, or chatty) +chatlevel = basic [buildenv] # name of the job script used for building an EESSI stack diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index 5895fbfb..84128305 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -38,13 +38,14 @@ from tools.commands import EESSIBotCommand, EESSIBotCommandError, \ contains_any_bot_command, get_bot_command from tools.permissions import check_command_permission -from tools.pr_comments import create_comment +from tools.pr_comments import ChatLevels, create_comment REQUIRED_CONFIG = { config.SECTION_ARCHITECTURETARGETS: [ config.ARCHITECTURETARGETS_SETTING_ARCH_TARGET_MAP], # required config.SECTION_BOT_CONTROL: [ + # config.BOT_CONTROL_SETTING_CHATLEVEL, # optional config.BOT_CONTROL_SETTING_COMMAND_PERMISSION, # required config.BOT_CONTROL_SETTING_COMMAND_RESPONSE_FMT], # required config.SECTION_BUILDENV: [ @@ -193,6 +194,8 @@ def handle_issue_comment_event(self, event_info, log_file=None): return # at this point we know that we are handling a new comment + issue_comment = None + # check if comment does not contain a bot command if not contains_any_bot_command(comment_received): self.log("comment does not contain a bot comment; not processing it further") @@ -229,7 +232,7 @@ def handle_issue_comment_event(self, event_info, log_file=None): comment_response=comment_response, comment_result='' ) - issue_comment = create_comment(repo_name, pr_number, comment_body) + issue_comment = create_comment(repo_name, pr_number, comment_body, ChatLevels.CHATTY) else: self.log(f"account `{sender}` seems to be a bot instance itself, hence not creating a new PR comment") return @@ -263,6 +266,11 @@ def handle_issue_comment_event(self, event_info, log_file=None): # including a bot command; the logging should only be done when log # level is set to debug + if 'help' in (x.command for x in commands): + req_chatlevel = ChatLevels.MINIMAL + else: + req_chatlevel = ChatLevels.CHATTY + if comment_response == '': # no update to be added, just log and return self.log("comment response is empty") @@ -281,7 +289,7 @@ def handle_issue_comment_event(self, event_info, log_file=None): comment_response=comment_response, comment_result='' ) - issue_comment = create_comment(repo_name, pr_number, comment_body) + issue_comment = create_comment(repo_name, pr_number, comment_body, req_chatlevel) else: self.log(f"update '{comment_response}' is considered to contain bot command ... not creating PR comment") # TODO we may want to report this back to the PR on GitHub, e.g., @@ -306,7 +314,7 @@ def handle_issue_comment_event(self, event_info, log_file=None): continue except Exception as err: log(f"Unexpected err={err}, type(err)={type(err)}") - if comment_result: + if comment_result and issue_comment: comment_body = command_response_fmt.format( app_name=app_name, comment_response=comment_response, @@ -314,16 +322,18 @@ def handle_issue_comment_event(self, event_info, log_file=None): ) issue_comment.edit(comment_body) raise - # only update PR comment once, that is, a single call to - # issue_comment.edit is made in the entire function - 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!") + if issue_comment: + # only update PR comment once, that is, a single call to + # issue_comment.edit is made in the entire function + 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): """ @@ -373,14 +383,14 @@ def handle_pull_request_labeled_event(self, event_info, pr): comment_response=msg, comment_result='' ) - create_comment(repo_name, pr_number, comment_body) + create_comment(repo_name, pr_number, comment_body, ChatLevels.BASIC) elif label == "bot:deploy": # run function to deploy built artefacts deploy_built_artefacts(pr, event_info) else: self.log("handle_pull_request_labeled_event: no handler for label '%s'", label) - def handle_pull_request_opened_event(self, event_info, pr): + def handle_pull_request_opened_event(self, event_info, pr, req_chatlevel=ChatLevels.CHATTY): """ Handle events of type pull_request with the action opened. Main action is to report for which architectures and repositories a bot instance is @@ -420,10 +430,7 @@ def handle_pull_request_opened_event(self, event_info, pr): # create comment to pull request repo_name = pr.base.repo.full_name - gh = github.get_instance() - repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr.number) - issue_comment = pull_request.create_issue_comment(comment) + issue_comment = create_comment(repo_name, pr.number, comment, req_chatlevel) return issue_comment def handle_pull_request_event(self, event_info, log_file=None): @@ -554,8 +561,9 @@ def handle_bot_command_show_config(self, event_info, bot_command): 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) - issue_comment = self.handle_pull_request_opened_event(event_info, pr) - return f"\n - added comment {issue_comment.html_url} to show configuration" + issue_comment = self.handle_pull_request_opened_event(event_info, pr, req_chatlevel=ChatLevels.MINIMAL) + if issue_comment: + return f"\n - added comment {issue_comment.html_url} to show configuration" def handle_bot_command_status(self, event_info, bot_command): """ @@ -571,7 +579,6 @@ def handle_bot_command_status(self, event_info, bot_command): PyGithub, not the github from the internal connections module) """ self.log("processing bot command 'status'") - gh = github.get_instance() repo_name = event_info['raw_request_body']['repository']['full_name'] pr_number = event_info['raw_request_body']['issue']['number'] status_table = request_bot_build_issue_comments(repo_name, pr_number) @@ -588,9 +595,7 @@ def handle_bot_command_status(self, event_info, bot_command): comment_status += f"{status_table['url'][x]}|" self.log(f"Overview of finished builds: comment '{comment_status}'") - repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr_number) - issue_comment = pull_request.create_issue_comment(comment_status) + issue_comment = create_comment(repo_name, pr_number, comment_status, ChatLevels.MINIMAL) return issue_comment def start(self, app, port=3000): @@ -669,12 +674,9 @@ def handle_pull_request_closed_event(self, event_info, pr): # 4) report move to pull request repo_name = pr.base.repo.full_name - gh = github.get_instance() - repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr.number) clean_up_comment = self.cfg[config.SECTION_CLEAN_UP][config.CLEAN_UP_SETTING_MOVED_JOB_DIRS_COMMENT] moved_comment = clean_up_comment.format(job_dirs=job_dirs, trash_bin_dir=trash_bin_dir) - issue_comment = pull_request.create_issue_comment(moved_comment) + issue_comment = create_comment(repo_name, pr.number, moved_comment, ChatLevels.CHATTY) return issue_comment diff --git a/tasks/build.py b/tasks/build.py index 7a0b1c83..517c4077 100644 --- a/tasks/build.py +++ b/tasks/build.py @@ -28,12 +28,11 @@ # Third party imports (anything installed into the local Python environment) from pyghee.utils import error, log -from retry.api import retry_call # Local application imports (anything from EESSI/eessi-bot-software-layer) -from connections import github from tools import config, cvmfs_repository, job_metadata, pr_comments, run_cmd import tools.filter as tools_filter +from tools.pr_comments import ChatLevels, create_comment # defaults (used if not specified via, eg, 'app.cfg') @@ -484,7 +483,7 @@ def comment_download_pr(base_repo_name, pr, download_pr_exit_code, download_pr_e f"\n{download_pr_comments_cfg[config.DOWNLOAD_PR_COMMENTS_SETTING_GIT_APPLY_TIP]}") download_comment = pr_comments.create_comment( - repo_name=base_repo_name, pr_number=pr.number, comment=download_comment + repo_name=base_repo_name, pr_number=pr.number, comment=download_comment, req_chatlevel=ChatLevels.MINIMAL ) if download_comment: log(f"{fn}(): created PR issue comment with id {download_comment.id}") @@ -887,7 +886,7 @@ def submit_job(job, cfg): return job_id, symlink -def create_pr_comment(job, job_id, app_name, pr, gh, symlink): +def create_pr_comment(job, job_id, app_name, pr, symlink): """ Create a comment to the pull request for a newly submitted job @@ -896,7 +895,6 @@ def create_pr_comment(job, job_id, app_name, pr, gh, symlink): job_id (string): id of the submitted job app_name (string): name of the app pr (github.PullRequest.PullRequest): instance representing the pull request - gh (object): github instance symlink (string): symlink from main pr_ dir to job dir Returns: @@ -961,10 +959,7 @@ def create_pr_comment(job, job_id, app_name, pr, gh, symlink): # create comment to pull request repo_name = pr.base.repo.full_name - repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr.number) - issue_comment = retry_call(pull_request.create_issue_comment, fargs=[job_comment], - exceptions=Exception, tries=3, delay=1, backoff=2, max_delay=10) + issue_comment = create_comment(repo_name, pr.number, job_comment, ChatLevels.MINIMAL) if issue_comment: log(f"{fn}(): created PR issue comment with id {issue_comment.id}") return issue_comment @@ -1002,9 +997,6 @@ def submit_build_jobs(pr, event_info, action_filter): log(f"{fn}(): no jobs ({len(jobs)}) to be submitted") return {} - # obtain handle to GitHub - gh = github.get_instance() - # process prepared jobs: submit, create metadata file and add comment to pull # request on GitHub job_id_to_comment_map = {} @@ -1013,7 +1005,7 @@ def submit_build_jobs(pr, event_info, action_filter): job_id, symlink = submit_job(job, cfg) # create pull request comment to report about the submitted job - pr_comment = create_pr_comment(job, job_id, app_name, pr, gh, symlink) + pr_comment = create_pr_comment(job, job_id, app_name, pr, symlink) job_id_to_comment_map[job_id] = pr_comment pr_comment = pr_comments.PRComment(pr.base.repo.full_name, pr.number, pr_comment.id) @@ -1056,7 +1048,8 @@ def check_build_permission(pr, event_info): repo_name = event_info["raw_request_body"]["repository"]["full_name"] pr_comments.create_comment(repo_name, pr.number, - no_build_permission_comment.format(build_labeler=build_labeler)) + no_build_permission_comment.format(build_labeler=build_labeler), + ChatLevels.MINIMAL) return False else: log(f"{fn}(): GH account '{build_labeler}' is authorized to build") diff --git a/tasks/deploy.py b/tasks/deploy.py index 37ad3adf..f137b068 100644 --- a/tasks/deploy.py +++ b/tasks/deploy.py @@ -28,6 +28,7 @@ from connections import github from tasks.build import get_build_env_cfg from tools import config, job_metadata, pr_comments, run_cmd +from tools.pr_comments import ChatLevels def determine_job_dirs(pr_number): @@ -617,7 +618,8 @@ def deploy_built_artefacts(pr, event_info): repo_name = event_info["raw_request_body"]["repository"]["full_name"] pr_comments.create_comment(repo_name, pr.number, - no_deploy_permission_comment.format(deploy_labeler=labeler)) + no_deploy_permission_comment.format(deploy_labeler=labeler), + ChatLevels.CHATTY) return else: log(f"{funcname}(): GH account '{labeler}' is authorized to deploy") diff --git a/tests/test_app.cfg b/tests/test_app.cfg index 84161ba0..4f833bbf 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -31,3 +31,5 @@ awaits_lauch = job awaits launch by Slurm scheduler running_job = job `{job_id}` is running [finished_job_comments] + +[bot_control] diff --git a/tests/test_task_build.py b/tests/test_task_build.py index f432d768..1c289947 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -157,6 +157,9 @@ def get_repo(self, repo_name): repo = self.repos[repo_name] return repo + def get_instance(self): + return self + MockBase = namedtuple('MockBase', ['repo']) @@ -275,8 +278,9 @@ def no_sleep_after_create(delay): # returns !None --> create_pr_comment returns comment (with id == 1) @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) -def test_create_pr_comment_succeeds(mocked_github, tmpdir): +def test_create_pr_comment_succeeds(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" + monkeypatch.setattr('tools.pr_comments.github', mocked_github) shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") @@ -291,7 +295,7 @@ def test_create_pr_comment_succeeds(mocked_github, tmpdir): repo = mocked_github.get_repo(repo_name) pr = repo.get_pull(pr_number) symlink = "/symlink" - comment = create_pr_comment(job, job_id, app_name, pr, mocked_github, symlink) + comment = create_pr_comment(job, job_id, app_name, pr, symlink) assert comment.id == 1 # check if created comment includes jobid? print("VERIFYING PR COMMENT") @@ -304,8 +308,9 @@ def test_create_pr_comment_succeeds(mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_fails(True) -def test_create_pr_comment_succeeds_none(mocked_github, tmpdir): +def test_create_pr_comment_succeeds_none(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" + monkeypatch.setattr('tools.pr_comments.github', mocked_github) shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") @@ -320,7 +325,7 @@ def test_create_pr_comment_succeeds_none(mocked_github, tmpdir): repo = mocked_github.get_repo(repo_name) pr = repo.get_pull(pr_number) symlink = "/symlink" - comment = create_pr_comment(job, job_id, app_name, pr, mocked_github, symlink) + comment = create_pr_comment(job, job_id, app_name, pr, symlink) assert comment is None @@ -329,8 +334,9 @@ def test_create_pr_comment_succeeds_none(mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("1") -def test_create_pr_comment_raises_once_then_succeeds(mocked_github, tmpdir): +def test_create_pr_comment_raises_once_then_succeeds(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" + monkeypatch.setattr('tools.pr_comments.github', mocked_github) shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") @@ -345,7 +351,7 @@ def test_create_pr_comment_raises_once_then_succeeds(mocked_github, tmpdir): repo = mocked_github.get_repo(repo_name) pr = repo.get_pull(pr_number) symlink = "/symlink" - comment = create_pr_comment(job, job_id, app_name, pr, mocked_github, symlink) + comment = create_pr_comment(job, job_id, app_name, pr, symlink) assert comment.id == 1 assert pr.create_call_count == 2 @@ -354,8 +360,9 @@ def test_create_pr_comment_raises_once_then_succeeds(mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("always_raise") -def test_create_pr_comment_always_raises(mocked_github, tmpdir): +def test_create_pr_comment_always_raises(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" + monkeypatch.setattr('tools.pr_comments.github', mocked_github) shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") @@ -371,7 +378,7 @@ def test_create_pr_comment_always_raises(mocked_github, tmpdir): pr = repo.get_pull(pr_number) symlink = "/symlink" with pytest.raises(Exception) as err: - create_pr_comment(job, job_id, app_name, pr, mocked_github, symlink) + create_pr_comment(job, job_id, app_name, pr, symlink) assert err.type == CreateIssueCommentException assert pr.create_call_count == 3 @@ -380,8 +387,9 @@ def test_create_pr_comment_always_raises(mocked_github, tmpdir): @pytest.mark.repo_name("EESSI/software-layer") @pytest.mark.pr_number(1) @pytest.mark.create_raises("3") -def test_create_pr_comment_three_raises(mocked_github, tmpdir): +def test_create_pr_comment_three_raises(monkeypatch, mocked_github, tmpdir): """Tests for function create_pr_comment.""" + monkeypatch.setattr('tools.pr_comments.github', mocked_github) shutil.copyfile("tests/test_app.cfg", "app.cfg") # creating a PR comment print("CREATING PR COMMENT") @@ -397,7 +405,7 @@ def test_create_pr_comment_three_raises(mocked_github, tmpdir): pr = repo.get_pull(pr_number) symlink = "/symlink" with pytest.raises(Exception) as err: - create_pr_comment(job, job_id, app_name, pr, mocked_github, symlink) + create_pr_comment(job, job_id, app_name, pr, symlink) assert err.type == CreateIssueCommentException assert pr.create_call_count == 3 diff --git a/tools/config.py b/tools/config.py index 5d0c6a7e..6fe5982c 100644 --- a/tools/config.py +++ b/tools/config.py @@ -35,6 +35,7 @@ SECTION_BOT_CONTROL = 'bot_control' BOT_CONTROL_SETTING_COMMAND_PERMISSION = 'command_permission' BOT_CONTROL_SETTING_COMMAND_RESPONSE_FMT = 'command_response_fmt' +BOT_CONTROL_SETTING_CHATLEVEL = 'chatlevel' SECTION_BUILDENV = 'buildenv' BUILDENV_SETTING_ALLOWED_EXPORTVARS = 'allowed_exportvars' diff --git a/tools/pr_comments.py b/tools/pr_comments.py index f74bbbf2..0585887f 100644 --- a/tools/pr_comments.py +++ b/tools/pr_comments.py @@ -9,13 +9,16 @@ # author: Hafsa Naeem (@hafsa-naeem) # author: Jonas Qvigstad (@jonas-lq) # author: Thomas Roeblitz (@trz42) +# author: Sam Moors (@smoors) # # license: GPLv2 # # Standard library imports from collections import namedtuple +from enum import Enum import re +import sys # Third party imports (anything installed into the local Python environment) from pyghee.utils import log @@ -24,12 +27,21 @@ # Local application imports (anything from EESSI/eessi-bot-software-layer) from connections import github +from tools import config PRComment = namedtuple('PRComment', ('repo_name', 'pr_number', 'pr_comment_id')) -def create_comment(repo_name, pr_number, comment): +class ChatLevels(Enum): + "chattiness levels" + INCOGNITO = 0 + MINIMAL = 1 + BASIC = 2 + CHATTY = 3 + + +def create_comment(repo_name, pr_number, comment, req_chatlevel): """ Create a comment to a pull request on GitHub @@ -37,15 +49,31 @@ def create_comment(repo_name, pr_number, comment): repo_name (string): name of the repository pr_number (int): number of the pull request within the repository comment (string): comment body + req_chatlevel (member of ChatLevels Enum): minimum required chattiness level for creating the PR comment Returns: github.IssueComment.IssueComment instance or None (note, github refers to PyGithub, not the github from the internal connections module) """ - gh = github.get_instance() - repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr_number) - return pull_request.create_issue_comment(comment) + fn = sys._getframe().f_code.co_name + + cfg = config.read_config() + chatlevel = cfg[config.SECTION_BOT_CONTROL].get( + config.BOT_CONTROL_SETTING_CHATLEVEL, ChatLevels.BASIC.name).upper() + + if ChatLevels[chatlevel].value >= req_chatlevel.value: + gh = github.get_instance() + repo = gh.get_repo(repo_name) + pull_request = repo.get_pull(pr_number) + issue_comment = retry_call(pull_request.create_issue_comment, fargs=[comment], + exceptions=Exception, tries=3, delay=1, backoff=2, max_delay=10) + return issue_comment + + else: + log(f"{fn}(): not creating PR comment: " + f"chatlevel {ChatLevels[chatlevel].value} < required chatlevel {req_chatlevel.value}") + + return None def determine_issue_comment(pull_request, pr_comment_id, search_pattern=None):