diff --git a/RELEASE_NOTES b/RELEASE_NOTES index 1707f3a5..b8d09e8c 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -1,6 +1,19 @@ This file contains a description of the major changes to the EESSI build-and-deploy bot. For more detailed information, please see the git log. +v0.3.0 (30 January 2024) +-------------------------- + +This is a minor release of the EESSI build-and-deploy bot. + +Bug fixes: +* refreshes the token to access GitHub well before it expires (#238) + +Improvements: +* adds a new bot command 'status' which provides an overview (table) of all + finished builds (#237) + + v0.2.0 (26 November 2023) -------------------------- diff --git a/connections/github.py b/connections/github.py index 2204fe89..3037c0fe 100644 --- a/connections/github.py +++ b/connections/github.py @@ -11,7 +11,7 @@ # # Standard library imports -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import time # Third party imports (anything installed into the local Python environment) @@ -99,8 +99,6 @@ def get_instance(): Instance of Github """ global _gh, _token - # TODO Possibly renew token already if expiry date is soon, not only - # after it has expired. # Check if PyGithub version is < 1.56 if hasattr(github, 'GithubRetry'): @@ -110,7 +108,10 @@ def get_instance(): # Pygithub 1.x time_now = datetime.utcnow() - if not _gh or (_token and time_now > _token.expires_at): + # Renew token already if expiry date is less then 30 min away. + refresh_time = timedelta(minutes=30) + + if not _gh or (_token and time_now > (_token.expires_at - refresh_time)): _gh = connect() return _gh diff --git a/eessi_bot_event_handler.py b/eessi_bot_event_handler.py index e750f5e5..024615ce 100644 --- a/eessi_bot_event_handler.py +++ b/eessi_bot_event_handler.py @@ -25,7 +25,8 @@ # Local application imports (anything from EESSI/eessi-bot-software-layer) from connections import github import tasks.build as build -from tasks.build import check_build_permission, get_architecture_targets, get_repo_cfg, submit_build_jobs +from tasks.build import check_build_permission, get_architecture_targets, get_repo_cfg, \ + request_bot_build_issue_comments, submit_build_jobs import tasks.deploy as deploy from tasks.deploy import deploy_built_artefacts from tools import config @@ -416,7 +417,7 @@ def handle_bot_command_help(self, event_info, bot_command): 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`" + help_msg += "\n - Currently supported COMMANDs are: `help`, `build`, `show_config`, `status`" help_msg += "\n" help_msg += "\n For more information, see https://www.eessi.io/docs/bot" return help_msg @@ -476,6 +477,42 @@ def handle_bot_command_show_config(self, event_info, bot_command): issue_comment = self.handle_pull_request_opened_event(event_info, pr) return f"\n - added comment {issue_comment.html_url} to show configuration" + def handle_bot_command_status(self, event_info, bot_command): + """ + Handles bot command 'status' by querying the github API + for the comments in a pr. + + Args: + event_info (dict): event received by event_handler + bot_command (EESSIBotCommand): command to be handled + + Returns: + github.IssueComment.IssueComment (note, github refers to + 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) + + comment_status = '' + comment_status += "\nThis is the status of all the `bot: build` commands:" + comment_status += "\n|arch|result|date|status|url|" + comment_status += "\n|----|------|----|------|---|" + for x in range(0, len(status_table['date'])): + comment_status += f"\n|{status_table['arch'][x]}|" + comment_status += f"{status_table['result'][x]}|" + comment_status += f"{status_table['date'][x]}|" + comment_status += f"{status_table['status'][x]}|" + 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) + return issue_comment + def start(self, app, port=3000): """ Logs startup information to shell and log file and starts the app using diff --git a/tasks/build.py b/tasks/build.py index e3f0bc8c..1ce8e956 100644 --- a/tasks/build.py +++ b/tasks/build.py @@ -760,3 +760,78 @@ def check_build_permission(pr, event_info): else: log(f"{fn}(): GH account '{build_labeler}' is authorized to build") return True + + +def request_bot_build_issue_comments(repo_name, pr_number): + """ + Query the github API for the issue_comments in a pr. + + Archs: + repo_name (string): name of the repository (format USER_OR_ORGANISATION/REPOSITORY) + pr_number (int): number og the pr + + Returns: + status_table (dict): dictionary with 'arch', 'date', 'status', 'url' and 'result' + for all the finished builds; + """ + status_table = {'arch': [], 'date': [], 'status': [], 'url': [], 'result': []} + cfg = config.read_config() + + # for loop because github has max 100 items per request. + # if the pr has more than 100 comments we need to use per_page + # argument at the moment the for loop is for a max of 400 comments could bump this up + for x in range(1, 5): + curl_cmd = f'curl -L https://api.github.com/repos/{repo_name}/issues/{pr_number}/comments?per_page=100&page={x}' + curl_output, curl_error, curl_exit_code = run_cmd(curl_cmd, "fetch all comments") + + comments = json.loads(curl_output) + + for comment in comments: + # iterate through the comments to find the one where the status of the build was in + if config.read_config()["submitted_job_comments"]['initial_comment'][:20] in comment['body']: + + # get archictecture from comment['body'] + first_line = comment['body'].split('\n')[0] + arch_map = get_architecture_targets(cfg) + for arch in arch_map.keys(): + target_arch = '/'.join(arch.split('/')[1:]) + if target_arch in first_line: + status_table['arch'].append(target_arch) + + # get date, status, url and result from the markdown table + comment_table = comment['body'][comment['body'].find('|'):comment['body'].rfind('|')+1] + + # Convert markdown table to a dictionary + lines = comment_table.split('\n') + rows = [] + keys = [] + for i, row in enumerate(lines): + values = {} + if i == 0: + for key in row.split('|'): + keys.append(key.strip()) + elif i == 1: + continue + else: + for j, value in enumerate(row.split('|')): + if j > 0 and j < len(keys) - 1: + values[keys[j]] = value.strip() + rows.append(values) + + # add date, status, url to status_table if + for row in rows: + if row['job status'] == 'finished': + status_table['date'].append(row['date']) + status_table['status'].append(row['job status']) + status_table['url'].append(comment['html_url']) + if 'FAILURE' in row['comment']: + status_table['result'].append(':cry: FAILURE') + elif 'SUCCESS' in value['comment']: + status_table['result'].append(':grin: SUCCESS') + elif 'UNKNOWN' in row['comment']: + status_table['result'].append(':shrug: UNKNOWN') + else: + status_table['result'].append(row['comment']) + if len(comments) != 100: + break + return status_table