From 4a3aa49dfaf385535d930b5df6e4a90ee2b8d2f7 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:47:36 +0100 Subject: [PATCH 01/19] Update requirements for GitLab support --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index faecf277..17402463 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,11 +7,13 @@ # author: Bob Droege (@bedroge) # author: Kenneth Hoste (@boegel) # author: Thomas Roeblitz (@trz42) +# author: Sondre Bergsvaag Risanger (@sondrebr) # # license: GPLv2 # PyGithub +python-gitlab Waitress>=3.0.1 # required to fix vulnerabilities detected by scorecards cryptography>=44.0.1 # required to fix vulnerabilities detected by scorecards -PyGHee>=0.0.3 +PyGHee @ git+https://github.com/boegel/PyGHee.git@bd4ab122206c446bd4fe554e1344d26a64b232d3 # Pin commit adding GL support retry From b74d264a0d72a5fd866a1159df0e613cfe716396 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:21:11 +0200 Subject: [PATCH 02/19] Add Git and GitLab config sections --- tools/config.py | 12 ++++++++++++ tools/git.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tools/git.py diff --git a/tools/config.py b/tools/config.py index 10a7590d..5c4e730a 100644 --- a/tools/config.py +++ b/tools/config.py @@ -23,6 +23,7 @@ # (none yet) # Local application imports (anything from EESSI/eessi-bot-software-layer) +from .git import get_hosting_platform, SUPPORTED_HOSTS from .logging import error # define configuration constants @@ -95,6 +96,9 @@ FINISHED_JOB_COMMENTS_SETTING_JOB_RESULT_UNKNOWN_FMT = 'job_result_unknown_fmt' FINISHED_JOB_COMMENTS_SETTING_JOB_TEST_UNKNOWN_FMT = 'job_test_unknown_fmt' +SECTION_GIT = 'git' +GIT_SETTING_HOSTING_PLATFORM = 'hosting_platform' + SECTION_GITHUB = 'github' GITHUB_SETTING_API_TIMEOUT = 'api_timeout' GITHUB_SETTING_APP_ID = 'app_id' @@ -102,6 +106,11 @@ GITHUB_SETTING_INSTALLATION_ID = 'installation_id' GITHUB_SETTING_PRIVATE_KEY = 'private_key' +SECTION_GITLAB = 'gitlab' +GITLAB_SETTING_API_TIMEOUT = 'api_timeout' +GITLAB_SETTING_BOT_NAME = 'bot_name' +GITLAB_SETTING_INSTANCE_URL = 'instance_url' + SECTION_JOB_MANAGER = 'job_manager' JOB_MANAGER_SETTING_LOG_PATH = 'log_path' JOB_MANAGER_SETTING_JOB_IDS_DIR = 'job_ids_dir' @@ -207,9 +216,12 @@ def check_cfg_settings(req_settings, path="app.cfg"): """ # TODO argument path is not being used cfg = read_config() + git_host = get_hosting_platform() # iterate over keys in req_settings which correspond to sections ([name]) # in the configuration file (.ini format) for section in req_settings.keys(): + if git_host and (section in SUPPORTED_HOSTS) and (section != git_host): + continue if section not in cfg: error(f'Missing section "{section}" in configuration file {path}.') # iterate over list elements required for the current section diff --git a/tools/git.py b/tools/git.py new file mode 100644 index 00000000..8eac620e --- /dev/null +++ b/tools/git.py @@ -0,0 +1,51 @@ +# This file is part of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +# (none) + +# Third party imports (anything installed into the local Python environment) +# (none) + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from connections import github, gitlab +from tools import config, logging + + +GITHUB = "github" +GITLAB = "gitlab" + +SUPPORTED_HOSTS = { + GITHUB, + GITLAB, +} + +_git_host = None + + +def get_hosting_platform(): + """ + Read the config and get the Git hosting platform the bot is configured for. + Exit if the setting is invalid or not set. + + Args: + No arguments + + Returns: + (str): The configured Git hosting platform + """ + global _git_host + if not _git_host: + cfg = config.read_config() + _git_host = cfg.get(config.SECTION_GIT, config.GIT_SETTING_HOSTING_PLATFORM, fallback=None) + if _git_host not in SUPPORTED_HOSTS: + logging.error(f"Invalid Git host configured: '{_git_host}'") + return _git_host From 61790e1be303cb68e1336c1a7b51376e0ff65a9e Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:07:09 +0200 Subject: [PATCH 03/19] Connect event handler to GitLab if configured --- connections/gitlab.py | 87 ++++++++++++++++++++++++++++++++++++++ eessi_bot_event_handler.py | 24 ++++++++--- tools/git.py | 10 +++++ 3 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 connections/gitlab.py diff --git a/connections/gitlab.py b/connections/gitlab.py new file mode 100644 index 00000000..a6af90b5 --- /dev/null +++ b/connections/gitlab.py @@ -0,0 +1,87 @@ +# This file is part of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +import os + +# Third party imports (anything installed into the local Python environment) +import gitlab + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tools import config, logging + + +_gl = None + + +def verify_connection(gl): + """ + Verifies connection to GitLab. Exits if verification fails. + + Args: + Instance of Gitlab + + Returns: + None (implicit) + """ + try: + # auth tests the instance's credentials by retrieving the access token user + gl.auth() + if type(gl.user) is not gl._objects.CurrentUser: + raise Exception("'user' attribute of Gitlab instance is not of type 'CurrentUser'.") + except Exception as err: + logging.error(f"Failed to verify GitLab connection: {err}") + + +def connect(): + """ + Creates a Gitlab instance, then verifies the connection. + + Args: + No arguments + + Returns: + None (implicit) + """ + global _gl + cfg = config.read_config() + gitlab_cfg = cfg[config.SECTION_GITLAB] + timeout = int(gitlab_cfg.get(config.GITLAB_SETTING_API_TIMEOUT, 10)) + url = gitlab_cfg.get(config.GITLAB_SETTING_INSTANCE_URL) + + access_token = os.getenv('GITLAB_PROJECT_ACCESS_TOKEN') + if access_token is None: + logging.error("GitLab token is not available via $GITLAB_PROJECT_ACCESS_TOKEN!") + else: + del os.environ['GITLAB_PROJECT_ACCESS_TOKEN'] + + _gl = gitlab.Gitlab(url, access_token) + _gl.timeout = timeout + _gl.retry_transient_errors = True + verify_connection(_gl) + + +def get_instance(): + """ + Returns a Gitlab instance. Creates an instance if one does not exist, + otherwise verifies the existing instance. + + Args: + No arguments + + Returns: + Instance of Gitlab + """ + if not _gl: + connect() + else: + verify_connection(_gl) + return _gl diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index 41787e1b..fe4f146b 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -37,6 +37,7 @@ from tools.args import event_handler_parse from tools.commands import EESSIBotCommand, EESSIBotCommandError, \ contains_any_bot_command, get_bot_command +from tools.git import connect_to_host, get_hosting_platform from tools.permissions import check_command_permission from tools.pr_comments import ChatLevels, create_comment @@ -95,12 +96,18 @@ config.DOWNLOAD_PR_COMMENTS_SETTING_PR_DIFF_TIP], # required config.SECTION_EVENT_HANDLER: [ config.EVENT_HANDLER_SETTING_LOG_PATH], # required + config.SECTION_GIT: [ + config.GIT_SETTING_HOSTING_PLATFORM], # required config.SECTION_GITHUB: [ - config.GITHUB_SETTING_API_TIMEOUT, # required - config.GITHUB_SETTING_APP_ID, # required - config.GITHUB_SETTING_APP_NAME, # required - config.GITHUB_SETTING_INSTALLATION_ID, # required - config.GITHUB_SETTING_PRIVATE_KEY], # required + config.GITHUB_SETTING_API_TIMEOUT, # required for github + config.GITHUB_SETTING_APP_ID, # required for github + config.GITHUB_SETTING_APP_NAME, # required for github + config.GITHUB_SETTING_INSTALLATION_ID, # required for github + config.GITHUB_SETTING_PRIVATE_KEY], # required for github + config.SECTION_GITLAB: [ + config.GITLAB_SETTING_API_TIMEOUT, # required for gitlab + config.GITLAB_SETTING_BOT_NAME, # required for gitlab + config.GITLAB_SETTING_INSTANCE_URL], # required for gitlab # the poll interval setting is required for the alternative job handover # protocol (delayed_begin) config.SECTION_JOB_MANAGER: [ @@ -133,7 +140,8 @@ def __init__(self, *args, **kwargs): EESSIBotSoftwareLayer constructor. Calls constructor of PyGHee and initializes some configuration settings. """ - super(EESSIBotSoftwareLayer, self).__init__(*args, **kwargs) + event_source = get_hosting_platform() + super(EESSIBotSoftwareLayer, self).__init__(event_source, *args, **kwargs) self.cfg = config.read_config() event_handler_cfg = self.cfg[config.SECTION_EVENT_HANDLER] @@ -833,7 +841,9 @@ def main(): else: print("Configuration check: FAILED") sys.exit(1) - github.connect() + + # Connect to Git hosting platform + connect_to_host() if opts.file: app = create_app(klass=EESSIBotSoftwareLayer) diff --git a/tools/git.py b/tools/git.py index 8eac620e..a0b62ae0 100644 --- a/tools/git.py +++ b/tools/git.py @@ -49,3 +49,13 @@ def get_hosting_platform(): if _git_host not in SUPPORTED_HOSTS: logging.error(f"Invalid Git host configured: '{_git_host}'") return _git_host + + +def connect_to_host(): + git_host = get_hosting_platform() + if git_host == GITHUB: + github.connect() + elif git_host == GITLAB: + gitlab.connect() + else: + logging.error(f"Git host not supported: '{git_host}'") From 776ed152521836c999048b8092c2b328e69d7b54 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:33:57 +0200 Subject: [PATCH 04/19] Add optional cfg parameter to get_hosting_platform --- tools/config.py | 2 +- tools/git.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/config.py b/tools/config.py index 5c4e730a..dd317f1b 100644 --- a/tools/config.py +++ b/tools/config.py @@ -216,7 +216,7 @@ def check_cfg_settings(req_settings, path="app.cfg"): """ # TODO argument path is not being used cfg = read_config() - git_host = get_hosting_platform() + git_host = get_hosting_platform(cfg) # iterate over keys in req_settings which correspond to sections ([name]) # in the configuration file (.ini format) for section in req_settings.keys(): diff --git a/tools/git.py b/tools/git.py index a0b62ae0..d28b8481 100644 --- a/tools/git.py +++ b/tools/git.py @@ -31,20 +31,22 @@ _git_host = None -def get_hosting_platform(): +def get_hosting_platform(cfg=None): """ Read the config and get the Git hosting platform the bot is configured for. Exit if the setting is invalid or not set. Args: - No arguments + cfg (ConfigParser): Instance of ConfigParser containing the configuration. + May be passed by caller to avoid re-reading the configuration file. Returns: (str): The configured Git hosting platform """ global _git_host if not _git_host: - cfg = config.read_config() + if not cfg: + cfg = config.read_config() _git_host = cfg.get(config.SECTION_GIT, config.GIT_SETTING_HOSTING_PLATFORM, fallback=None) if _git_host not in SUPPORTED_HOSTS: logging.error(f"Invalid Git host configured: '{_git_host}'") From a99faaaa20f6bf4bc2608e5a97ffe6152f51a7e4 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:35:46 +0200 Subject: [PATCH 05/19] Add docstring to connect_to_host --- tools/git.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/git.py b/tools/git.py index d28b8481..f73491b1 100644 --- a/tools/git.py +++ b/tools/git.py @@ -54,6 +54,16 @@ def get_hosting_platform(cfg=None): def connect_to_host(): + """ + Establish connection to Git hosting platform. Exit if the configured hosting + platform is not supported by the bot. + + Args: + No arguments + + Returns: + None (implicit) + """ git_host = get_hosting_platform() if git_host == GITHUB: github.connect() From d29362917d614d7f7788ccf8fa61a70eb783c862 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:38:11 +0200 Subject: [PATCH 06/19] Update PyGHee requirement --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 17402463..57003fd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,5 +15,5 @@ PyGithub python-gitlab 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@bd4ab122206c446bd4fe554e1344d26a64b232d3 # Pin commit adding GL support +PyGHee @ git+https://github.com/boegel/PyGHee.git@c5e10632a45db5ca94f5cbf87ac7a90a2064e8fd # Pin commit with GL support retry From 1f5011b25e14b4f3cc7e710b5f8d0d9284ca4b2b Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:38:32 +0200 Subject: [PATCH 07/19] Add tools/event_info.py Contains the BaseEventInfo class, the GitHubEventInfo and GitLabEventInfo classes implementing it, and the function create_event_info_instance. --- tools/event_info.py | 298 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 tools/event_info.py diff --git a/tools/event_info.py b/tools/event_info.py new file mode 100644 index 00000000..c1a7020e --- /dev/null +++ b/tools/event_info.py @@ -0,0 +1,298 @@ +# This file is part of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Sondre Bergsvaag Risanger (@sondrebr) +# +# license: GPLv2 +# + +# Standard library imports +from functools import cached_property + +# Third party imports (anything installed into the local Python environment) +# (none) + +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from connections import gitlab +from tools.git import get_hosting_platform, GITHUB, GITLAB + + +class BaseEventInfo(): + """ + Base class to use for handling event info, which works differently + for GitHub vs. GitLab. Subscripting is implemented for compatibility. + If a new field needs to be accessed, add a new property to + retrieve it instead of subscripting/using the event_info dict. + """ + def __init__(self, event_info): + if self.__class__ is BaseEventInfo: + err_msg = "Do not use this base class directly. " + err_msg += "Please use one of its subclasses instead." + raise NotImplementedError(err_msg) + self.event_info = event_info + + # Do not override - implements subscripting for compatibility + def __getitem__(self, key): + return self.event_info[key] + + @cached_property + def action(self): + raise NotImplementedError() + + @cached_property + def comment_id(self): + raise NotImplementedError() + + @cached_property + def comment_body(self): + raise NotImplementedError() + + @cached_property + def comment_created_by(self): + raise NotImplementedError() + + @cached_property + def comment_updated_by(self): + raise NotImplementedError() + + @cached_property + def discussion_id(self): + raise NotImplementedError() + + @cached_property + def event_id(self): + raise NotImplementedError() + + @cached_property + def event_type(self): + raise NotImplementedError() + + @cached_property + def label_name(self): + raise NotImplementedError() + + @cached_property + def pr_number(self): + raise NotImplementedError() + + @cached_property + def pr_merged_status(self): + raise NotImplementedError() + + @cached_property + def pr_url(self): + raise NotImplementedError() + + @cached_property + def repo_name(self): + raise NotImplementedError() + + +class GitHubEventInfo(BaseEventInfo): + """ + EventInfo class for use with GitHub webhooks. + """ + def __init__(self, event_info): + super().__init__(event_info) + self._request_body = event_info["raw_request_body"] + + @cached_property + def action(self): + return self.event_info["action"] + + @cached_property + def comment_id(self): + return self._request_body["comment"]["id"] + + @cached_property + def comment_body(self): + return self._request_body["comment"]["body"] + + @cached_property + def comment_created_by(self): + return self._request_body["comment"]["user"]["login"] + + @cached_property + def comment_updated_by(self): + return self._request_body["sender"]["login"] + + @cached_property + def discussion_id(self): + # Not applicable for GitHub + return None + + @cached_property + def event_id(self): + return self.event_info["id"] + + @cached_property + def event_type(self): + return self.event_info["type"] + + @cached_property + def label_name(self): + return self._request_body["label"]["name"] + + @cached_property + def pr_number(self): + if self.event_type == "pull_request": + pr_num = self._request_body["pull_request"]["number"] + else: + pr_num = self._request_body["issue"]["number"] + return pr_num + + @cached_property + def pr_merged_status(self): + return self._request_body["pull_request"]["merged"] + + @cached_property + def pr_url(self): + if self.event_type == "pull_request": + url = self._request_body["pull_request"]["html_url"] + else: + url = self._request_body["issue"]["html_url"] + return url + + @cached_property + def repo_name(self): + return self._request_body["repository"]["full_name"] + + +class GitLabEventInfo(BaseEventInfo): + """ + EventInfo class for use with GitLab webhooks. Converts GL terminology to + GH equivalents where needed, e.g. event type 'note' becomes 'issue_comment'. + """ + def __init__(self, event_info): + super().__init__(event_info) + self._request_body = event_info["raw_request_body"] + self._object_attributes = self._request_body.get("object_attributes", {}) + + # Map GitLab actions to GitHub actions + _ACTION_MAP = { + # Note -> comment actions + "create": "created", + "update": "edited", + "delete": "deleted", + + # MR -> PR actions + # MR 'update' handled separately + "open": "opened", + "merge": "closed", + "close": "closed", + } + _UNKNOWN = "UNKNOWN" + + @cached_property + def action(self): + gl_action = self._object_attributes["action"] + # GL uses a single 'update' action for MRs + # Need to check changes to find exact action, e.g. 'labeled' + if self.event_type == "pull_request" and gl_action == "update": + changes = self._request_body["changes"] + if "labels" in changes: + action = "labeled" + else: + action = self._UNKNOWN + else: + action = self._ACTION_MAP.get(gl_action, self._UNKNOWN) + return action + + @cached_property + def comment_id(self): + return self._object_attributes["id"] + + @cached_property + def comment_body(self): + return self._object_attributes["note"] + + @cached_property + def comment_created_by(self): + created_by_id = self._object_attributes["author_id"] + triggered_by_id = self._request_body["user"]["id"] + # GL events only include the username of the user who triggered the event + if triggered_by_id == created_by_id: + created_by = self._request_body["user"]["username"] + else: + gl = gitlab.get_instance() + user = gl.users.get(created_by_id) + created_by = user.username + return created_by + + @cached_property + def comment_updated_by(self): + return self._request_body["user"]["username"] + + @cached_property + def discussion_id(self): + return self._object_attributes["discussion_id"] + + @cached_property + def event_id(self): + return self.event_info["id"] + + # Map (relevant) GitLab events to GitHub events + _EVENT_TYPE_MAP = { + "note": "issue_comment", + "merge_request": "pull_request", + } + + @cached_property + def event_type(self): + gl_event_type = self.event_info["type"] + return self._EVENT_TYPE_MAP.get(gl_event_type, self._UNKNOWN) + + @cached_property + def label_name(self): + # GH sends one event per label while GL sends one event with all label changes + # Set manually and call handle_pull_request_labeled_event once per label? + raise NotImplementedError() + + @cached_property + def pr_number(self): + if self.event_type == "pull_request": + pr_iid = self._object_attributes["iid"] + else: + pr_iid = self._request_body["merge_request"]["iid"] + return pr_iid + + @cached_property + def pr_merged_status(self): + if self.event_type == "pull_request": + state = self._object_attributes["state"] + else: + state = self._request_body["merge_request"]["state"] + return state == "merged" + + @cached_property + def pr_url(self): + if self.event_type == "pull_request": + url = self._object_attributes["url"] + else: + url = self._request_body["merge_request"]["url"] + return url + + @cached_property + def repo_name(self): + return self._request_body["project"]["path_with_namespace"] + + +def create_event_info_instance(event_info): + """ + Creates an EventInfo instance for the configured Git hosting platform. + + Args: + event_info (dict): The event info dictionary created by PyGHee + + Returns: + Instance of BaseEventInfo subclass + """ + git_host = get_hosting_platform() + if git_host == GITHUB: + new_event_info = GitHubEventInfo(event_info) + elif git_host == GITLAB: + new_event_info = GitLabEventInfo(event_info) + return new_event_info From 22bfaedb237e79bf47348202c6d765d44e2e817c Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:02:17 +0200 Subject: [PATCH 08/19] Make event handler use EventInfo classes --- eessi_bot_event_handler.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index fe4f146b..957cd4a7 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -37,6 +37,7 @@ from tools.args import event_handler_parse from tools.commands import EESSIBotCommand, EESSIBotCommandError, \ contains_any_bot_command, get_bot_command +from tools.event_info import create_event_info_instance from tools.git import connect_to_host, get_hosting_platform from tools.permissions import check_command_permission from tools.pr_comments import ChatLevels, create_comment @@ -165,6 +166,21 @@ def log(self, msg, *args): msg = "[%s]: %s" % (funcname, msg) log(msg, log_file=self.logfile) + def handle_event(self, event_info, log_file=None): + """ + Create EventInfo instance using event_info, + then pass that to PyGHee's handle_event method. + + Args: + event_info (dict): event received by event_handler + log_file (string): path to log messages to + + Returns: + None (implicit) + """ + event_info_object = create_event_info_instance(event_info) + super().handle_event(event_info_object, log_file) + def handle_issue_comment_event(self, event_info, log_file=None): """ Handle events of type issue_comment. Main action is to parse new issue From c06b98dbc7f0786e3b9a01c7d7e2f0739b65d07c Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:15:08 +0200 Subject: [PATCH 09/19] Update app.cfg.example to add git, gitlab sections --- app.cfg.example | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app.cfg.example b/app.cfg.example index be8e2198..374f209f 100644 --- a/app.cfg.example +++ b/app.cfg.example @@ -17,6 +17,10 @@ # Also see documentation at https://github.com/EESSI/eessi-bot-software-layer/blob/main/README.md#step5.5 +[git] +# Name of the Git hosting platform (supported values are 'github' and 'gitlab') +hosting_platform = github + [github] # API timeout, time limit for requests to GitHub's REST API api_timeout = 10 @@ -43,6 +47,20 @@ installation_id = 12345678 # path to the private key that was generated when the GitHub App was registered private_key = PATH_TO_PRIVATE_KEY +[gitlab] +# NOTE: Access token required for GitLab: +# https://docs.gitlab.com/user/project/settings/project_access_tokens/#bot-users-for-projects +# Must be stored in environment variable 'GITLAB_PROJECT_ACCESS_TOKEN'. + +# API timeout, time limit for requests to GitLab's REST API +api_timeout = 10 + +# Name used to refer to your bot instance - see comment for github.app_name config +bot_name = MY-bot + +# The base URL of your GitLab instance +instance_url = https://gitlab.com + [bot_control] # which GH accounts have the permission to send commands to the bot From ab02d4dd35190b742213a0af0612fcd33f4b3dc8 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Mon, 4 May 2026 13:13:30 +0200 Subject: [PATCH 10/19] Pin python-gitlab Python 3.9 needs its own pin as python-gitlab dropped support with version 7.0.0. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 57003fd3..35c6ecf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,8 @@ # license: GPLv2 # PyGithub -python-gitlab +python-gitlab==6.5.0;python_version=="3.9" # Last version with Python 3.9 support +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 From 5e5259fad22514527f044ae108a112598e6fe5f1 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 5 May 2026 15:42:16 +0200 Subject: [PATCH 11/19] Prevent overriding `BaseEventInfo.__getitem__` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Thomas Röblitz --- tools/event_info.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/event_info.py b/tools/event_info.py index c1a7020e..e1bc4949 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -34,6 +34,12 @@ def __init__(self, event_info): raise NotImplementedError(err_msg) self.event_info = event_info + # Prevents subclasses from overriding __getitem__ + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "__getitem__" in cls.__dict__: + raise Exception(f"{cls.__name__} must not override __getitem__") + # Do not override - implements subscripting for compatibility def __getitem__(self, key): return self.event_info[key] From 9e7f6eb27426c704d3cbf93858c6283ed6ca661f Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Tue, 5 May 2026 16:06:32 +0200 Subject: [PATCH 12/19] Use more descriptive names in tools/git.py --- eessi_bot_event_handler.py | 8 ++++---- tools/config.py | 6 +++--- tools/event_info.py | 4 ++-- tools/git.py | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index 957cd4a7..7417f1b0 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -38,7 +38,7 @@ from tools.commands import EESSIBotCommand, EESSIBotCommandError, \ contains_any_bot_command, get_bot_command from tools.event_info import create_event_info_instance -from tools.git import connect_to_host, get_hosting_platform +from tools.git import connect_to_git_hosting_platform, get_git_hosting_platform from tools.permissions import check_command_permission from tools.pr_comments import ChatLevels, create_comment @@ -141,7 +141,7 @@ def __init__(self, *args, **kwargs): EESSIBotSoftwareLayer constructor. Calls constructor of PyGHee and initializes some configuration settings. """ - event_source = get_hosting_platform() + event_source = get_git_hosting_platform() super(EESSIBotSoftwareLayer, self).__init__(event_source, *args, **kwargs) self.cfg = config.read_config() @@ -858,8 +858,8 @@ def main(): print("Configuration check: FAILED") sys.exit(1) - # Connect to Git hosting platform - connect_to_host() + # Verify that the event handler is able to connect to the Git hosting platform + connect_to_git_hosting_platform() if opts.file: app = create_app(klass=EESSIBotSoftwareLayer) diff --git a/tools/config.py b/tools/config.py index dd317f1b..249370ab 100644 --- a/tools/config.py +++ b/tools/config.py @@ -23,7 +23,7 @@ # (none yet) # Local application imports (anything from EESSI/eessi-bot-software-layer) -from .git import get_hosting_platform, SUPPORTED_HOSTS +from .git import get_git_hosting_platform, SUPPORTED_GIT_HOSTS from .logging import error # define configuration constants @@ -216,11 +216,11 @@ def check_cfg_settings(req_settings, path="app.cfg"): """ # TODO argument path is not being used cfg = read_config() - git_host = get_hosting_platform(cfg) + git_host = get_git_hosting_platform(cfg) # iterate over keys in req_settings which correspond to sections ([name]) # in the configuration file (.ini format) for section in req_settings.keys(): - if git_host and (section in SUPPORTED_HOSTS) and (section != git_host): + if git_host and (section in SUPPORTED_GIT_HOSTS) and (section != git_host): continue if section not in cfg: error(f'Missing section "{section}" in configuration file {path}.') diff --git a/tools/event_info.py b/tools/event_info.py index e1bc4949..cfc8d903 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -17,7 +17,7 @@ # Local application imports (anything from EESSI/eessi-bot-software-layer) from connections import gitlab -from tools.git import get_hosting_platform, GITHUB, GITLAB +from tools.git import get_git_hosting_platform, GITHUB, GITLAB class BaseEventInfo(): @@ -296,7 +296,7 @@ def create_event_info_instance(event_info): Returns: Instance of BaseEventInfo subclass """ - git_host = get_hosting_platform() + git_host = get_git_hosting_platform() if git_host == GITHUB: new_event_info = GitHubEventInfo(event_info) elif git_host == GITLAB: diff --git a/tools/git.py b/tools/git.py index f73491b1..25a61a57 100644 --- a/tools/git.py +++ b/tools/git.py @@ -23,7 +23,7 @@ GITHUB = "github" GITLAB = "gitlab" -SUPPORTED_HOSTS = { +SUPPORTED_GIT_HOSTS = { GITHUB, GITLAB, } @@ -31,7 +31,7 @@ _git_host = None -def get_hosting_platform(cfg=None): +def get_git_hosting_platform(cfg=None): """ Read the config and get the Git hosting platform the bot is configured for. Exit if the setting is invalid or not set. @@ -48,12 +48,12 @@ def get_hosting_platform(cfg=None): if not cfg: cfg = config.read_config() _git_host = cfg.get(config.SECTION_GIT, config.GIT_SETTING_HOSTING_PLATFORM, fallback=None) - if _git_host not in SUPPORTED_HOSTS: + if _git_host not in SUPPORTED_GIT_HOSTS: logging.error(f"Invalid Git host configured: '{_git_host}'") return _git_host -def connect_to_host(): +def connect_to_git_hosting_platform(): """ Establish connection to Git hosting platform. Exit if the configured hosting platform is not supported by the bot. @@ -64,7 +64,7 @@ def connect_to_host(): Returns: None (implicit) """ - git_host = get_hosting_platform() + git_host = get_git_hosting_platform() if git_host == GITHUB: github.connect() elif git_host == GITLAB: From 611022a7173723a8092949846b2b3c8995084cd3 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 12:34:32 +0200 Subject: [PATCH 13/19] Add a few clarifying comments --- eessi_bot_event_handler.py | 1 + tools/config.py | 1 + tools/event_info.py | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index 7417f1b0..2967ad5d 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -168,6 +168,7 @@ def log(self, msg, *args): def handle_event(self, event_info, log_file=None): """ + Override of PyGHee's handle_event method. Create EventInfo instance using event_info, then pass that to PyGHee's handle_event method. diff --git a/tools/config.py b/tools/config.py index 249370ab..fbe7e820 100644 --- a/tools/config.py +++ b/tools/config.py @@ -220,6 +220,7 @@ def check_cfg_settings(req_settings, path="app.cfg"): # iterate over keys in req_settings which correspond to sections ([name]) # in the configuration file (.ini format) for section in req_settings.keys(): + # Skip checking the GitLab section if the bot is configured for GitHub and vice versa if git_host and (section in SUPPORTED_GIT_HOSTS) and (section != git_host): continue if section not in cfg: diff --git a/tools/event_info.py b/tools/event_info.py index cfc8d903..f61ff8bc 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -219,7 +219,9 @@ def comment_body(self): def comment_created_by(self): created_by_id = self._object_attributes["author_id"] triggered_by_id = self._request_body["user"]["id"] - # GL events only include the username of the user who triggered the event + # GL events only include the username of the user who triggered the event. + # E.g., if a comment was updated by someone other than the original author, + # we need to retrieve the name of the author from the server. if triggered_by_id == created_by_id: created_by = self._request_body["user"]["username"] else: @@ -257,6 +259,9 @@ def label_name(self): # Set manually and call handle_pull_request_labeled_event once per label? raise NotImplementedError() + # GL uses the 'object_attributes' field to store data about the event object. + # For example, MR events store information about the MR in 'object_attributes', while + # events from comments on MRs store information about the MR in the 'merge_request' field. @cached_property def pr_number(self): if self.event_type == "pull_request": From 4155c30e9d16d725e2b7a99b67f4cc5d55801ce9 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 12:56:01 +0200 Subject: [PATCH 14/19] `Gitlab` refers to a class in `python-gitlab` --- connections/gitlab.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/connections/gitlab.py b/connections/gitlab.py index a6af90b5..30da0858 100644 --- a/connections/gitlab.py +++ b/connections/gitlab.py @@ -27,7 +27,7 @@ def verify_connection(gl): Verifies connection to GitLab. Exits if verification fails. Args: - Instance of Gitlab + Instance of gitlab.Gitlab (from python-gitlab) Returns: None (implicit) @@ -36,14 +36,14 @@ def verify_connection(gl): # auth tests the instance's credentials by retrieving the access token user gl.auth() if type(gl.user) is not gl._objects.CurrentUser: - raise Exception("'user' attribute of Gitlab instance is not of type 'CurrentUser'.") + raise Exception("'user' attribute of Gitlab class instance is not of type 'CurrentUser'.") except Exception as err: logging.error(f"Failed to verify GitLab connection: {err}") def connect(): """ - Creates a Gitlab instance, then verifies the connection. + Creates a gitlab.Gitlab instance (from python-gitlab), then verifies the connection to GitLab. Args: No arguments @@ -71,14 +71,14 @@ def connect(): def get_instance(): """ - Returns a Gitlab instance. Creates an instance if one does not exist, + Returns a gitlab.Gitlab instance. Creates an instance if one does not exist, otherwise verifies the existing instance. Args: No arguments Returns: - Instance of Gitlab + Instance of gitlab.Gitlab (from python-gitlab) """ if not _gl: connect() From 91c4860e1c570b96cd26feb48e384e723ac06ed7 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 13:23:41 +0200 Subject: [PATCH 15/19] Remove EventInfo.discussion_id Currently unused - may be reintroduced later if needed --- tools/event_info.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tools/event_info.py b/tools/event_info.py index f61ff8bc..75bf999d 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -64,10 +64,6 @@ def comment_created_by(self): def comment_updated_by(self): raise NotImplementedError() - @cached_property - def discussion_id(self): - raise NotImplementedError() - @cached_property def event_id(self): raise NotImplementedError() @@ -125,11 +121,6 @@ def comment_created_by(self): def comment_updated_by(self): return self._request_body["sender"]["login"] - @cached_property - def discussion_id(self): - # Not applicable for GitHub - return None - @cached_property def event_id(self): return self.event_info["id"] @@ -234,10 +225,6 @@ def comment_created_by(self): def comment_updated_by(self): return self._request_body["user"]["username"] - @cached_property - def discussion_id(self): - return self._object_attributes["discussion_id"] - @cached_property def event_id(self): return self.event_info["id"] From f8391b0f0abcd56b424318915e1749fd73e64f10 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 13:39:41 +0200 Subject: [PATCH 16/19] Implement GitLabEventInfo.label_name --- tools/event_info.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/event_info.py b/tools/event_info.py index 75bf999d..26767eeb 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -242,9 +242,15 @@ def event_type(self): @cached_property def label_name(self): - # GH sends one event per label while GL sends one event with all label changes - # Set manually and call handle_pull_request_labeled_event once per label? - raise NotImplementedError() + # GL sends a single event containing all previous and current labels. + # Since we currently only use one label, 'bot:deploy', we can check just for that. + label_changes = self._request_body["changes"]["labels"] + # The difference between the sets will yield all newly added labels + added_labels = set(label_changes["current"]) - set(label_changes["previous"]) + if "bot:deploy" in added_labels: + return "bot:deploy" + else: + return None # GL uses the 'object_attributes' field to store data about the event object. # For example, MR events store information about the MR in 'object_attributes', while From 8502631c5001cb9ed76fd4e044386407b16cbe14 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 15:30:07 +0200 Subject: [PATCH 17/19] Add issue_number and issue_url properties pr_number and pr_url would previously also be used to get issue numbers/URLs --- tools/event_info.py | 54 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/tools/event_info.py b/tools/event_info.py index 26767eeb..ff4637f5 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -72,6 +72,14 @@ def event_id(self): def event_type(self): raise NotImplementedError() + @cached_property + def issue_number(self): + raise NotImplementedError() + + @cached_property + def issue_url(self): + raise NotImplementedError() + @cached_property def label_name(self): raise NotImplementedError() @@ -129,17 +137,21 @@ def event_id(self): def event_type(self): return self.event_info["type"] + @cached_property + def issue_number(self): + return self._request_body["issue"]["number"] + + @cached_property + def issue_url(self): + return self._request_body["issue"]["html_url"] + @cached_property def label_name(self): return self._request_body["label"]["name"] @cached_property def pr_number(self): - if self.event_type == "pull_request": - pr_num = self._request_body["pull_request"]["number"] - else: - pr_num = self._request_body["issue"]["number"] - return pr_num + return self._request_body["pull_request"]["number"] @cached_property def pr_merged_status(self): @@ -147,11 +159,7 @@ def pr_merged_status(self): @cached_property def pr_url(self): - if self.event_type == "pull_request": - url = self._request_body["pull_request"]["html_url"] - else: - url = self._request_body["issue"]["html_url"] - return url + return self._request_body["pull_request"]["html_url"] @cached_property def repo_name(self): @@ -240,6 +248,32 @@ def event_type(self): gl_event_type = self.event_info["type"] return self._EVENT_TYPE_MAP.get(gl_event_type, self._UNKNOWN) + # The bot does not handle issue events, but comment events can come from both issue and MR comments. + # 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": + issue_iid = self._request_body["merge_request"]["iid"] + elif notable_type == "Issue": + issue_iid = self._request_body["issue"]["iid"] + else: + # Comments may also come from commits etc. - default to -1 + issue_iid = -1 + return issue_iid + + @cached_property + def issue_url(self): + notable_type = self._object_attributes["notable_type"] + if notable_type == "MergeRequest": + issue_url = self._request_body["merge_request"]["url"] + elif notable_type == "Issue": + issue_url = self._request_body["issue"]["url"] + else: + # Comments may also come from commits etc. - default to empty string + issue_url = "" + return issue_url + @cached_property def label_name(self): # GL sends a single event containing all previous and current labels. From 6996b9ea4bd605154c6d443f142436b96d019351 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 15:32:52 +0200 Subject: [PATCH 18/19] Rename comment_updated_by to event_triggered_by The new name more accurately describes the property --- tools/event_info.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tools/event_info.py b/tools/event_info.py index ff4637f5..00ed7a9d 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -61,11 +61,11 @@ def comment_created_by(self): raise NotImplementedError() @cached_property - def comment_updated_by(self): + def event_id(self): raise NotImplementedError() @cached_property - def event_id(self): + def event_triggered_by(self): raise NotImplementedError() @cached_property @@ -125,14 +125,14 @@ def comment_body(self): def comment_created_by(self): return self._request_body["comment"]["user"]["login"] - @cached_property - def comment_updated_by(self): - return self._request_body["sender"]["login"] - @cached_property def event_id(self): return self.event_info["id"] + @cached_property + def event_triggered_by(self): + return self._request_body["sender"]["login"] + @cached_property def event_type(self): return self.event_info["type"] @@ -229,14 +229,14 @@ def comment_created_by(self): created_by = user.username return created_by - @cached_property - def comment_updated_by(self): - return self._request_body["user"]["username"] - @cached_property def event_id(self): return self.event_info["id"] + @cached_property + def event_triggered_by(self): + return self._request_body["user"]["username"] + # Map (relevant) GitLab events to GitHub events _EVENT_TYPE_MAP = { "note": "issue_comment", From 373e6db708b31f85216faca6f392672e20c6abc9 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Thu, 7 May 2026 16:53:18 +0200 Subject: [PATCH 19/19] Add fallback to `create_event_info_instance` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Thomas Röblitz --- tools/event_info.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/event_info.py b/tools/event_info.py index 00ed7a9d..2d1a902c 100644 --- a/tools/event_info.py +++ b/tools/event_info.py @@ -330,7 +330,7 @@ def create_event_info_instance(event_info): """ git_host = get_git_hosting_platform() if git_host == GITHUB: - new_event_info = GitHubEventInfo(event_info) + return GitHubEventInfo(event_info) elif git_host == GITLAB: - new_event_info = GitLabEventInfo(event_info) - return new_event_info + return GitLabEventInfo(event_info) + return None