diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index 2967ad5d..c747474a 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -36,9 +36,9 @@ from tools import config from tools.args import event_handler_parse from tools.commands import EESSIBotCommand, EESSIBotCommandError, \ - contains_any_bot_command, get_bot_command + contains_any_bot_command, get_bot_command, get_supported_commands, ALL_COMMANDS from tools.event_info import create_event_info_instance -from tools.git import connect_to_git_hosting_platform, get_git_hosting_platform +from tools.git import connect_to_git_hosting_platform, get_app_name, get_git_hosting_platform from tools.permissions import check_command_permission from tools.pr_comments import ChatLevels, create_comment @@ -188,7 +188,7 @@ def handle_issue_comment_event(self, event_info, log_file=None): comments for any bot command and execute it if one is found. Args: - event_info (dict): event received by event_handler + event_info (EventInfo): event received by event_handler log_file (string): path to log messages to Returns: @@ -197,19 +197,18 @@ def handle_issue_comment_event(self, event_info, log_file=None): Raises: Exception: raises any exception that is not of type EESSIBotCommandError """ - request_body = event_info['raw_request_body'] - issue_url = request_body['issue']['url'] - 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'] + issue_url = event_info.issue_url + action = event_info.action + sender = event_info.event_triggered_by + owner = event_info.comment_created_by + repo_name = event_info.repo_name + pr_number = event_info.issue_number # TODO 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}") - app_name = self.cfg[config.SECTION_GITHUB][config.GITHUB_SETTING_APP_NAME] + app_name = get_app_name(self.cfg) command_response_fmt = self.cfg[config.SECTION_BOT_CONTROL][config.BOT_CONTROL_SETTING_COMMAND_RESPONSE_FMT] # currently, only commands in new comments are supported @@ -217,7 +216,7 @@ def handle_issue_comment_event(self, event_info, log_file=None): # only scan for commands in newly created comments if action == 'created': - comment_received = request_body['comment']['body'] + comment_received = event_info.comment_body self.log(f"comment action '{action}' is handled") else: # NOTE we do not respond to an updated PR comment with yet another @@ -368,6 +367,9 @@ def handle_issue_comment_event(self, event_info, log_file=None): self.log(f"issue_comment event (url {issue_url}) handled!") + # PyGHee gets the event type by subscripting event_info, i.e., it gets 'note' for GL comment events + handle_note_event = handle_issue_comment_event + def handle_installation_event(self, event_info, log_file=None): """ Handle events of type installation. Main action is to log the event. @@ -497,7 +499,7 @@ def handle_bot_command(self, event_info, bot_command, log_file=None): specific bot_command given. Args: - event_info (dict): event received by event_handler + event_info (EventInfo): event received by event_handler bot_command (EESSIBotCommand): command to be handled log_file (string): path to log messages to @@ -512,9 +514,13 @@ def handle_bot_command(self, event_info, bot_command, log_file=None): 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) + if cmd in get_supported_commands(self.cfg): + handler = getattr(self, handler_name) + self.log(f"Handling bot command {cmd}") + return handler(event_info, bot_command) + else: + self.log(f"Command '{cmd}' is not supported on the configured Git hosting platform.") + raise EESSIBotCommandError(f"Unsupported command `{cmd}`; use `bot: help` for usage information") else: self.log(f"No handler for command '{cmd}'") raise EESSIBotCommandError(f"unknown command `{cmd}`; use `bot: help` for usage information") @@ -525,17 +531,25 @@ def handle_bot_command_help(self, event_info, bot_command): commands. Args: - event_info (dict): event received by event_handler + event_info (EventInfo): event received by event_handler bot_command (EESSIBotCommand): command to be handled Returns: (string): basic information about sending commands to the bot """ + # Create comma-separated lists of supported and unsupported commands + supported_commands = get_supported_commands(self.cfg) + unsupported_commands = [cmd for cmd in ALL_COMMANDS if cmd not in supported_commands] + supported_commands_str = ", ".join([f"`{cmd}`" for cmd in supported_commands]) + unsupported_commands_str = ", ".join([f"`{cmd}`" for cmd in unsupported_commands]) + 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`, `status`, `cancel`" + help_msg += "\n - Currently supported COMMANDs are: " + supported_commands_str + if unsupported_commands_str: + help_msg += "\n - The following COMMANDs are not yet supported: " + unsupported_commands_str help_msg += "\n" help_msg += "\n For more information, see https://www.eessi.io/docs/bot" return help_msg @@ -572,7 +586,7 @@ def handle_bot_command_build(self, event_info, bot_command): else: for job_id, issue_comment in submitted_jobs.items(): build_msg += f"\n - submitted job `{job_id}`" - if issue_comment: + if issue_comment and issue_comment.html_url: build_msg += f", for details & status see {issue_comment.html_url}" else: request_body = event_info['raw_request_body'] diff --git a/requirements.txt b/requirements.txt index 35c6ecf4..ad11ebcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,5 @@ python-gitlab==6.5.0;python_version=="3.9" # Last version with Python 3.9 suppor python-gitlab==8.3.0;python_version>="3.10" # Most recent version on 2026-05-04 Waitress>=3.0.1 # required to fix vulnerabilities detected by scorecards cryptography>=44.0.1 # required to fix vulnerabilities detected by scorecards -PyGHee @ git+https://github.com/boegel/PyGHee.git@c5e10632a45db5ca94f5cbf87ac7a90a2064e8fd # Pin commit with GL support +PyGHee @ git+https://github.com/boegel/PyGHee.git@514bdf6b7db1ed2a965ccdabaf45f9e9b1d825b7 # Pin commit with GL support retry diff --git a/tasks/build.py b/tasks/build.py index 6c191013..a33cff2d 100644 --- a/tasks/build.py +++ b/tasks/build.py @@ -1078,7 +1078,7 @@ def submit_build_jobs(pr, event_info, action_filter, build_params): pr_comment = create_pr_comment(job, job_id, app_name, pr, symlink, build_params) job_id_to_comment_map[job_id] = pr_comment - pr_comment = pr_comments.PRComment(pr.base.repo.full_name, pr.number, pr_comment.id) + pr_comment = pr_comments.PRCommentInfo(pr.base.repo.full_name, pr.number, pr_comment.id) # create _bot_job.metadata file in the job's working directory job_metadata.create_metadata_file(job, job_id, pr_comment) diff --git a/tests/test_app.cfg b/tests/test_app.cfg index d0fff239..db1e1982 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -11,6 +11,9 @@ # sample config file for tests (some functions run config.read_config() # which reads app.cfg by default) +[git] +hosting_platform = github + [buildenv] job_handover_protocol = hold_release diff --git a/tests/test_task_build.py b/tests/test_task_build.py index cab91a35..4de5a2f0 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -30,7 +30,7 @@ from tools import run_cmd, run_subprocess from tools.build_params import EESSIBotBuildParams from tools.job_metadata import create_metadata_file, read_metadata_file -from tools.pr_comments import PRComment, get_submitted_job_comment +from tools.pr_comments import PRCommentInfo, get_submitted_job_comment # Local tests imports (reusing code from other tests) from tests.test_tools_pr_comments import MockIssueComment @@ -462,7 +462,7 @@ def test_create_read_metadata_file(mocked_github, tmp_path): job_id = "123" repo_name = "test_repo" - pr_comment = PRComment(repo_name, pr_number, 77) + pr_comment = PRCommentInfo(repo_name, pr_number, 77) create_metadata_file(job, job_id, pr_comment) expected_file = f"_bot_job{job_id}.metadata" diff --git a/tools/commands.py b/tools/commands.py index bd80e339..7fd9a4da 100644 --- a/tools/commands.py +++ b/tools/commands.py @@ -19,6 +19,29 @@ # Local application imports (anything from EESSI/eessi-bot-software-layer) from tools.filter import EESSIBotActionFilter, EESSIBotActionFilterError from tools.build_params import EESSIBotBuildParams +from tools.git import get_git_hosting_platform, GITHUB, GITLAB + + +ALL_COMMANDS = ["help", "build", "show_config", "status", "cancel"] +SUPPORTED_COMMANDS_PER_GIT_HOST = { + GITHUB: ["help", "build", "show_config", "status", "cancel"], + GITLAB: ["help"], +} + + +def get_supported_commands(cfg=None): + """ + Returns the supported commands for the configured Git hosting platform. + + Args: + cfg (ConfigParser): Instance of ConfigParser containing the configuration. + May be passed by caller to avoid re-reading the configuration file. + + Returns: + supported_commands (list of strings): The supported commands + """ + git_host = get_git_hosting_platform(cfg) + return SUPPORTED_COMMANDS_PER_GIT_HOST[git_host] def contains_any_bot_command(body): diff --git a/tools/event_info.py b/tools/event_info.py index 2d1a902c..1b6d1bb2 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -11,6 +11,7 @@ # Standard library imports from functools import cached_property +from typing import Union # Third party imports (anything installed into the local Python environment) # (none) @@ -252,10 +253,10 @@ def event_type(self): # We therefore need to check what type of comment it is to get the issue numbers and URLs. @cached_property def issue_number(self): - notable_type = self._object_attributes["notable_type"] - if notable_type == "MergeRequest": + noteable_type = self._object_attributes["noteable_type"] + if noteable_type == "MergeRequest": issue_iid = self._request_body["merge_request"]["iid"] - elif notable_type == "Issue": + elif noteable_type == "Issue": issue_iid = self._request_body["issue"]["iid"] else: # Comments may also come from commits etc. - default to -1 @@ -264,10 +265,10 @@ def issue_number(self): @cached_property def issue_url(self): - notable_type = self._object_attributes["notable_type"] - if notable_type == "MergeRequest": + noteable_type = self._object_attributes["noteable_type"] + if noteable_type == "MergeRequest": issue_url = self._request_body["merge_request"]["url"] - elif notable_type == "Issue": + elif noteable_type == "Issue": issue_url = self._request_body["issue"]["url"] else: # Comments may also come from commits etc. - default to empty string @@ -318,6 +319,10 @@ def repo_name(self): return self._request_body["project"]["path_with_namespace"] +# Type for subclasses of BaseEventInfo +EventInfo = Union[GitHubEventInfo, GitLabEventInfo] + + def create_event_info_instance(event_info): """ Creates an EventInfo instance for the configured Git hosting platform. @@ -326,7 +331,7 @@ def create_event_info_instance(event_info): event_info (dict): The event info dictionary created by PyGHee Returns: - Instance of BaseEventInfo subclass + EventInfo instance or None """ git_host = get_git_hosting_platform() if git_host == GITHUB: diff --git a/tools/git.py b/tools/git.py index 25a61a57..f6af96a7 100644 --- a/tools/git.py +++ b/tools/git.py @@ -71,3 +71,25 @@ def connect_to_git_hosting_platform(): gitlab.connect() else: logging.error(f"Git host not supported: '{git_host}'") + + +# TODO: We might consider merging these settings later, for example as an 'app_name' setting in the 'git' section +def get_app_name(cfg=None): + """ + Get the configured app/bot name. + + Args: + cfg (ConfigParser): Instance of ConfigParser containing the configuration. + May be passed by caller to avoid re-reading the configuration file. + + Returns: + (str): The configured app/bot name or None + """ + if not cfg: + cfg = config.read_config() + git_host = get_git_hosting_platform(cfg) + if git_host == GITHUB: + return cfg.get(config.SECTION_GITHUB, config.GITHUB_SETTING_APP_NAME) + elif git_host == GITLAB: + return cfg.get(config.SECTION_GITLAB, config.GITLAB_SETTING_BOT_NAME) + return None diff --git a/tools/job_metadata.py b/tools/job_metadata.py index e4031faf..9ecda4a7 100644 --- a/tools/job_metadata.py +++ b/tools/job_metadata.py @@ -90,7 +90,7 @@ def create_metadata_file(job, job_id, pr_comment): Args: job (named tuple): key data about job that has been submitted job_id (string): id of submitted job - pr_comment (PRComment): contains repo_name, pr_number and pr_comment_id + pr_comment (PRCommentInfo): contains repo_name, pr_number and pr_comment_id Returns: None (implicitly) diff --git a/tools/pr_comments.py b/tools/pr_comments.py index 0585887f..5732cb39 100644 --- a/tools/pr_comments.py +++ b/tools/pr_comments.py @@ -10,6 +10,7 @@ # author: Jonas Qvigstad (@jonas-lq) # author: Thomas Roeblitz (@trz42) # author: Sam Moors (@smoors) +# author: Sondre Bergsvaag Risanger (@sondrebr) # # license: GPLv2 # @@ -19,6 +20,7 @@ from enum import Enum import re import sys +from typing import Union # Third party imports (anything installed into the local Python environment) from pyghee.utils import log @@ -26,11 +28,12 @@ from retry.api import retry_call # Local application imports (anything from EESSI/eessi-bot-software-layer) -from connections import github +from connections import github, gitlab from tools import config +from tools.git import get_git_hosting_platform, GITHUB, GITLAB -PRComment = namedtuple('PRComment', ('repo_name', 'pr_number', 'pr_comment_id')) +PRCommentInfo = namedtuple('PRCommentInfo', ('repo_name', 'pr_number', 'pr_comment_id')) class ChatLevels(Enum): @@ -43,7 +46,7 @@ class ChatLevels(Enum): def create_comment(repo_name, pr_number, comment, req_chatlevel): """ - Create a comment to a pull request on GitHub + Create a comment to a pull request Args: repo_name (string): name of the repository @@ -52,8 +55,7 @@ def create_comment(repo_name, pr_number, comment, req_chatlevel): 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) + PRComment instance or None """ fn = sys._getframe().f_code.co_name @@ -62,12 +64,12 @@ def create_comment(repo_name, pr_number, comment, req_chatlevel): 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 + pr_comment = create_pr_comment_instance(repo_name, pr_number, body=comment) + pr_comment.create() + # If 'id' is not set, something went wrong + if not pr_comment.id: + return None + return pr_comment else: log(f"{fn}(): not creating PR comment: " @@ -192,3 +194,175 @@ def update_pr_comment(event_info, update): pull_request = repo.get_pull(pr_number) issue_comment = pull_request.get_issue_comment(issue_id) issue_comment.edit(comment_new + update) + + +class BasePRComment(): + """ + Base class to use for handling PR comments, which works differently for GitHub vs. GitLab. + """ + def __init__(self, repo_name, pr_number, body=None, id=None): + if self.__class__ is BasePRComment: + err_msg = "Do not use this base class directly. " + err_msg += "Please use one of its subclasses instead." + raise NotImplementedError(err_msg) + + # 'body' should be provided when creating a new comment + # 'id' should be provided when dealing with an existing comment + if (body and id) or not (body or id): + err_msg = "Exactly one of 'body' and 'id' must be " + err_msg += "set when initializing a comment class." + raise Exception(err_msg) + + self.body = body + self.id = id + self.repo_name = repo_name + self.pr_number = pr_number + self._pr_obj = None + self._comment_obj = None + + @property + def html_url(self): + raise NotImplementedError() + + def get(self): + raise NotImplementedError() + + def create(self): + raise NotImplementedError() + + def edit(self): + raise NotImplementedError() + + def append(self): + raise NotImplementedError() + + +class GitHubPRComment(BasePRComment): + """ + PRComment class for use with GitHub. + """ + def __init__(self, repo_name, pr_number, body=None, id=None): + super().__init__(repo_name, pr_number, body, id) + gh = github.get_instance() + repo = gh.get_repo(self.repo_name) + self._pr_obj = repo.get_pull(self.pr_number) + + @property + def html_url(self): + if self._comment_obj: + return self._comment_obj.html_url + return None + + def get(self): + if not self.id: + raise Exception("'id' must be set to get a comment.") + self._comment_obj = retry_call(self._pr_obj.get_issue_comment, fargs=[self.id], + exceptions=Exception, tries=5, delay=1, backoff=2, max_delay=30) + if self._comment_obj: + self.body = self._comment_obj.body + + def create(self): + if not self.body: + raise Exception("'body' must be set to create a comment.") + if self.id: + # Return early if 'id' is set to avoid creating duplicate comments + return + self._comment_obj = retry_call(self._pr_obj.create_issue_comment, fargs=[self.body], + exceptions=Exception, tries=3, delay=1, backoff=2, max_delay=10) + if self._comment_obj: + self.id = self._comment_obj.id + + def edit(self, new_body): + if not self.id: + raise Exception("'id' must be set to edit a comment.") + # Ensure comment object is present + if not self._comment_obj: + self.get() + self.body = new_body + retry_call(self._comment_obj.edit, fargs=[self.body], exceptions=Exception, + tries=5, delay=1, backoff=2, max_delay=30) + + def append(self, text_to_append): + if not self.id: + raise Exception("'id' must be set to append to a comment.") + # Ensure comment object is present and up to date + self.get() + self.edit(self.body + text_to_append) + + +class GitLabPRComment(BasePRComment): + """ + PRComment class for use with GitLab. + """ + def __init__(self, repo_name, pr_number, body=None, id=None): + super().__init__(repo_name, pr_number, body, id) + gl = gitlab.get_instance() + proj = gl.projects.get(self.repo_name) + self._pr_obj = proj.mergerequests.get(self.pr_number) + + @property + def html_url(self): + if self._comment_obj: + # GitLab comment object does not include a comment URL + return f"{self._pr_obj.web_url}#note_{self._comment_obj.id}" + return None + + def get(self): + if not self.id: + raise Exception("'id' must be set to get a comment.") + self._comment_obj = self._pr_obj.notes.get(self.id) + self.body = self._comment_obj.body + + def create(self): + if not self.body: + raise Exception("'body' must be set to create a comment.") + if self.id: + # Return early if 'id' is set to avoid creating duplicate comments + return + self._comment_obj = self._pr_obj.notes.create({"body": self.body}) + if self._comment_obj: + self.id = self._comment_obj.id + + def edit(self, new_body): + if not self.id: + raise Exception("'id' must be set to edit a comment.") + # Ensure comment object is present + if not self._comment_obj: + self.get() + self.body = new_body + self._comment_obj.body = self.body + self._comment_obj.save() + + def append(self, text_to_append): + if not self.id: + raise Exception("'id' must be set to append to a comment.") + # Ensure comment object and body are present and up to date + self.get() + self.edit(self.body + text_to_append) + + +# Type for subclasses of BasePRComment +PRComment = Union[GitHubPRComment, GitLabPRComment] + + +def create_pr_comment_instance(repo_name, pr_number, body=None, id=None): + """ + Creates a PRComment instance for the configured Git hosting platform. + + Args: + repo_name (string): The name of the repository + pr_number (int): The number of the pull request in the repository + body (string): The comment body. Required when creating a new comment. + Cannot be set at the same time as 'id'. + id (int): The ID of the comment. Required when getting and/or updating + an existing comment. Cannot be set at the same time as 'body'. + + Returns: + PRComment instance or None + """ + git_host = get_git_hosting_platform() + if git_host == GITHUB: + return GitHubPRComment(repo_name=repo_name, pr_number=pr_number, body=body, id=id) + elif git_host == GITLAB: + return GitLabPRComment(repo_name=repo_name, pr_number=pr_number, body=body, id=id) + return None