diff --git a/tasks/build.py b/tasks/build.py index b458ceb6..0e1b9788 100644 --- a/tasks/build.py +++ b/tasks/build.py @@ -21,6 +21,7 @@ from connections import github from datetime import datetime, timezone from pyghee.utils import log, error +from retry.api import retry_call from tools import config, run_cmd AWAITS_RELEASE = "awaits_release" @@ -518,36 +519,41 @@ def submit_job(job, submitted_jobs, build_env_cfg, ym, pr_id): return job_id, symlink -def create_metadata(job, repo_name, pr, job_id): - """Create metadata file in submission dir +def create_metadata_file(job, job_id, repo_name, pr_number, pr_comment_id): + """Create metadata file in submission dir. Args: - job (list): jobs to be submitted + job (named tuple): key data about job that has been submitted + job_id (string): id of submitted job repo_name (string): pr base repository name - pr (object): data of pr - job_id (string): job id after parsing + pr_number (int): number of pr + pr_comment_id (int): id of PR comment """ + fn = sys._getframe().f_code.co_name + # create _bot_job.metadata file in submission directory bot_jobfile = configparser.ConfigParser() - bot_jobfile['PR'] = {'repo': repo_name, 'pr_number': pr.number} + bot_jobfile['PR'] = {'repo': repo_name, 'pr_number': pr_number, 'pr_comment_id': pr_comment_id} bot_jobfile_path = os.path.join(job.working_dir, f'_bot_job{job_id}.metadata') with open(bot_jobfile_path, 'w') as bjf: bot_jobfile.write(bjf) + log(f"{fn}(): created job metadata file {bot_jobfile_path}") -def create_pr_comments(job, job_id, app_name, job_comment, pr, repo_name, gh, symlink): - """create comments for pr +def create_pr_comment(job, job_id, app_name, pr_number, repo_name, gh, symlink): + """create pr comment for newly submitted jobpr Args: - job (list): jobs to be submitted - job_id (string): job id after parsing + job (named tuple): key data about job that has been submitted + job_id (string): id of submitted job app_name (string): name of the app - job_comment (string): comments for jobs status and job release - pr (object): pr data + pr_number (int): number of the pr repo_name (string): pr base repo name - gh (object):github instance - symlink(string): symlink from main pr_ dir to job dir + gh (object): github instance + symlink (string): symlink from main pr_ dir to job dir """ + fn = sys._getframe().f_code.co_name + # obtain arch from job.arch_target which has the format OS/ARCH arch_name = '-'.join(job.arch_target.split('/')[1:]) @@ -569,8 +575,15 @@ def create_pr_comments(job, job_id, app_name, job_comment, pr, repo_name, gh, sy # create comment to pull request repo = gh.get_repo(repo_name) - pull_request = repo.get_pull(pr.number) - pull_request.create_issue_comment(job_comment) + 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) + if issue_comment: + log(f"{fn}(): created PR issue comment with id {issue_comment.id}") + return issue_comment.id + else: + log(f"{fn}(): failed to create PR issue comment for job {job_id}") + return -1 def submit_build_jobs(pr, event_info): @@ -604,16 +617,16 @@ def submit_build_jobs(pr, event_info): # Run jobs with the build job submission script submitted_jobs = [] - job_comment = '' repo_name = pr.base.repo.full_name for job in jobs: # TODO make local_tmp specific to job? to isolate jobs if multiple ones can run on a single node job_id, symlink = submit_job(job, submitted_jobs, build_env_cfg, ym, pr_id) - # create _bot_job.metadata file in submission directory - create_metadata(job, repo_name, pr, job_id) # report submitted jobs (incl architecture, ...) - create_pr_comments(job, job_id, app_name, job_comment, pr, repo_name, gh, symlink) + pr_comment_id = create_pr_comment(job, job_id, app_name, int(pr.number), repo_name, gh, symlink) + + # create _bot_job.metadata file in submission directory + create_metadata_file(job, job_id, repo_name, int(pr.number), pr_comment_id) def check_build_permission(pr, event_info): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..5c7aa6c4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +# Configuration of pytest settings for 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: Thomas Roeblitz (@trz42) +# +# license: GPLv2 +# + + +def pytest_configure(config): + # register custom markers + config.addinivalue_line( + "markers", "repo_name(name): parametrize test function with a repo name" + ) + config.addinivalue_line( + "markers", "pr_number(num): parametrize test function with a PR number" + ) + config.addinivalue_line( + "markers", "create_raises(string): define function behaviour" + ) + config.addinivalue_line( + "markers", "create_fails(bool): let function create_issue_comment return None" + ) diff --git a/tests/test_app.cfg b/tests/test_app.cfg index 4f2fe022..dbf2a9ba 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -1,3 +1,24 @@ -# simplistic config file for tests (some functions run config.read_config() +# sample config file for tests (some functions run config.read_config() # which reads app.cfg by default) [job_manager] + +# variable 'comment' under 'submitted_job_comments' should not be changed as there are regular expression patterns matching it +[submitted_job_comments] +initial_comment = New job on instance `{app_name}` for architecture `{arch_name}` for repository `{repo_id}` in job dir `{symlink}` +awaits_release = job id `{job_id}` awaits release by job manager + +[new_job_comments] +awaits_lauch = job awaits launch by Slurm scheduler + +[running_job_comments] +running_job = job `{job_id}` is running + +[finished_job_comments] +success = :grin: SUCCESS tarball `{tarball_name}` ({tarball_size} GiB) in job dir +failure = :cry: FAILURE +no_slurm_out = No slurm output `{slurm_out}` in job dir +slurm_out = Found slurm output `{slurm_out}` in job dir +missing_modules = Slurm output lacks message "No missing modules!". +no_tarball_message = Slurm output lacks message about created tarball. +no_matching_tarball = No tarball matching `{tarball_pattern}` found in job dir. +multiple_tarballs = Found {num_tarballs} tarballs in job dir - only 1 matching `{tarball_pattern}` expected. diff --git a/tests/test_bot_job123.metadata b/tests/test_bot_job123.metadata new file mode 100644 index 00000000..29f8965d --- /dev/null +++ b/tests/test_bot_job123.metadata @@ -0,0 +1,5 @@ +[PR] +repo = test_repo +pr_number = 999 +pr_comment_id = 77 + diff --git a/tests/test_task_build.py b/tests/test_task_build.py index 6169e2f3..7e0669da 100644 --- a/tests/test_task_build.py +++ b/tests/test_task_build.py @@ -12,10 +12,23 @@ # license: GPLv2 # +# Standard library imports +import filecmp import os +import re +import shutil +from unittest.mock import patch + +# Third party imports (anything installed into the local Python environment) import pytest +# Local application imports (anything from EESSI/eessi-bot-software-layer) +from tasks.build import Job, create_metadata_file, create_pr_comment from tools import run_cmd, run_subprocess +from tools.pr_comments import get_submitted_job_comment + +# Local tests imports (reusing code from other tests) +from tests.test_tools_pr_comments import MockIssueComment def test_run_cmd(tmpdir): @@ -92,3 +105,331 @@ def test_run_subprocess(tmpdir): output, err, exit_code = run_subprocess("echo hello", "test in file", tmpdir, log_file=log_file) with open(log_file, "r") as fp: assert "test in file" in fp.read() + + +class CreateIssueCommentException(Exception): + "Raised when pr.create_issue_comment fails in a test." + pass + + +# cases for testing create_pr_comment (essentially testing create_issue_comment) +# - create_issue_comment succeeds immediately +# - returns !None --> create_pr_comment returns 1 +# - returns None --> create_pr_comment returns -1 +# - create_issue_comment fails once, then succeeds +# - returns !None --> create_pr_comment returns 1 +# - create_issue_comment always fails +# - create_issue_comment fails 3 times +# - symptoms of failure: exception raised or return value of tested func -1 + +# overall course of creating mocked objects +# patch gh.get_repo(repo_name) --> returns a MockRepository +# MockRepository provides repo.get_pull(pr_number) --> returns a MockPullRequest +# MockPullRequest provides pull_request.create_issue_comment + +class CreateRepositoryException(Exception): + "Raised when gh.create_repo fails in a test, i.e., if repository already exists." + pass + + +class CreatePullRequestException(Exception): + "Raised when repo.create_pr fails in a test, i.e., if pull request already exists." + pass + + +class MockGitHub: + def __init__(self): + self.repos = {} + + def create_repo(self, repo_name): + if repo_name in self.repos: + raise CreateRepositoryException + else: + self.repos[repo_name] = MockRepository(repo_name) + return self.repos[repo_name] + + def get_repo(self, repo_name): + repo = self.repos[repo_name] + return repo + + +class MockRepository: + def __init__(self, repo_name): + self.repo_name = repo_name + self.pull_requests = {} + + def create_pr(self, pr_number, create_raises='0', create_exception=Exception, create_fails=False): + if pr_number in self.pull_requests: + raise CreatePullRequestException + else: + self.pull_requests[pr_number] = MockPullRequest(pr_number, create_raises, + CreateIssueCommentException, create_fails) + return self.pull_requests[pr_number] + + def get_pull(self, pr_number): + pr = self.pull_requests[pr_number] + return pr + + +class MockPullRequest: + def __init__(self, pr_number, create_raises='0', create_exception=Exception, create_fails=False): + self.pr_number = pr_number + self.issue_comments = [] + self.create_fails = create_fails + self.create_raises = create_raises + self.create_exception = create_exception + self.create_call_count = 0 + + def create_issue_comment(self, body): + def should_raise_exception(): + """ + Determine whether or not an exception should be raised, based on value + of $TEST_RAISE_EXCEPTION + 0: don't raise exception, return value as expected (call succeeds) + >0: decrease value by one, raise exception (call fails, retry may succeed) + always_raise: raise exception (call fails always) + create_issue_comment -> CreateIssueCommentException + """ + should_raise = False + + count_regex = re.compile('^[0-9]+$') + + if self.create_raises == 'always_raise': + should_raise = True + # if self.create_raises is a number, raise exception when > 0 and + # decrement with 1 + elif count_regex.match(self.create_raises): + if int(self.create_raises) > 0: + should_raise = True + self.create_raises = str(int(self.create_raises) - 1) + + return should_raise + + def no_sleep_after_create(delay): + print(f"pr.create_issue_comment failed - sleeping {delay} s (mocked)") + + self.create_call_count = self.create_call_count + 1 + with patch('retry.api.time.sleep') as mock_sleep: + mock_sleep.side_effect = no_sleep_after_create + + if should_raise_exception(): + raise self.create_exception + + if self.create_fails: + return None + self.issue_comments.append(MockIssueComment(body)) + return self.issue_comments[-1] + + def get_issue_comments(self): + return self.issue_comments + + +@pytest.fixture +def mocked_github(request): + def no_sleep_after_create(delay): + print(f"pr.create_issue_comment failed - sleeping {delay} s (mocked)") + + with patch('retry.api.time.sleep') as mock_sleep: + mock_sleep.side_effect = no_sleep_after_create + mock_gh = MockGitHub() + + repo_name = "e2s2i/no_name" + marker1 = request.node.get_closest_marker("repo_name") + if marker1: + repo_name = marker1.args[0] + mock_repo = mock_gh.create_repo(repo_name) + + pr_number = 1 + marker2 = request.node.get_closest_marker("pr_number") + if marker2: + pr_number = marker2.args[0] + create_raises = '0' + marker3 = request.node.get_closest_marker("create_raises") + if marker3: + create_raises = marker3.args[0] + create_exception = CreateIssueCommentException + create_fails = False + marker5 = request.node.get_closest_marker("create_fails") + if marker5: + create_fails = marker5.args[0] + mock_repo.create_pr(pr_number, create_raises=create_raises, + create_exception=create_exception, create_fails=create_fails) + + yield mock_gh + + +# case 1: create_issue_comment succeeds immediately +# returns !None --> create_pr_comment returns 1 +@pytest.mark.repo_name("EESSI/software-layer") +@pytest.mark.pr_number(1) +def test_create_pr_comment_succeeds(mocked_github, tmpdir): + """Tests for function create_pr_comment.""" + shutil.copyfile("tests/test_app.cfg", "app.cfg") + # creating a PR comment + print("CREATING PR COMMENT") + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed-up") + job_id = "123" + app_name = "pytest" + pr_number = 1 + repo_name = "EESSI/software-layer" + symlink = "/symlink" + comment_id = create_pr_comment(job, job_id, app_name, pr_number, repo_name, mocked_github, symlink) + assert comment_id == 1 + # check if created comment includes jobid? + print("VERIFYING PR COMMENT") + repo = mocked_github.get_repo(repo_name) + pr = repo.get_pull(pr_number) + comment = get_submitted_job_comment(pr, job_id) + assert job_id in comment.body + + +# case 2: create_issue_comment succeeds immediately +# returns None --> create_pr_comment returns -1 +@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): + """Tests for function create_pr_comment.""" + shutil.copyfile("tests/test_app.cfg", "app.cfg") + # creating a PR comment + print("CREATING PR COMMENT") + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed-up") + job_id = "123" + app_name = "pytest" + pr_number = 1 + repo_name = "EESSI/software-layer" + symlink = "/symlink" + comment_id = create_pr_comment(job, job_id, app_name, pr_number, repo_name, mocked_github, symlink) + assert comment_id == -1 + + +# case 3: create_issue_comment fails once, then succeeds +# returns !None --> create_pr_comment returns 1 +@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): + """Tests for function create_pr_comment.""" + shutil.copyfile("tests/test_app.cfg", "app.cfg") + # creating a PR comment + print("CREATING PR COMMENT") + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed-up") + job_id = "123" + app_name = "pytest" + pr_number = 1 + repo_name = "EESSI/software-layer" + symlink = "/symlink" + comment_id = create_pr_comment(job, job_id, app_name, pr_number, repo_name, mocked_github, symlink) + assert comment_id == 1 + repo = mocked_github.get_repo(repo_name) + pr = repo.get_pull(pr_number) + assert pr.create_call_count == 2 + + +# case 4: create_issue_comment always fails +@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): + """Tests for function create_pr_comment.""" + shutil.copyfile("tests/test_app.cfg", "app.cfg") + # creating a PR comment + print("CREATING PR COMMENT") + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed-up") + job_id = "123" + app_name = "pytest" + pr_number = 1 + repo_name = "EESSI/software-layer" + symlink = "/symlink" + with pytest.raises(Exception) as err: + create_pr_comment(job, job_id, app_name, pr_number, repo_name, mocked_github, symlink) + assert err.type == CreateIssueCommentException + repo = mocked_github.get_repo(repo_name) + pr = repo.get_pull(pr_number) + assert pr.create_call_count == 3 + + +# case 5: create_issue_comment fails 3 times +@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): + """Tests for function create_pr_comment.""" + shutil.copyfile("tests/test_app.cfg", "app.cfg") + # creating a PR comment + print("CREATING PR COMMENT") + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed-up") + job_id = "123" + app_name = "pytest" + pr_number = 1 + repo_name = "EESSI/software-layer" + symlink = "/symlink" + with pytest.raises(Exception) as err: + create_pr_comment(job, job_id, app_name, pr_number, repo_name, mocked_github, symlink) + assert err.type == CreateIssueCommentException + repo = mocked_github.get_repo(repo_name) + pr = repo.get_pull(pr_number) + assert pr.create_call_count == 3 + + +def test_create_metadata_file(tmpdir): + """Tests for function create_metadata_file.""" + # create some test data + job = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed_up_job") + job_id = "123" + repo_name = "test_repo" + pr_number = 999 + pr_comment_id = 77 + create_metadata_file(job, job_id, repo_name, pr_number, pr_comment_id) + + expected_file = f"_bot_job{job_id}.metadata" + expected_file_path = os.path.join(tmpdir, expected_file) + # assert expected_file exists + assert os.path.exists(expected_file_path) + + # assert file contents = + # [PR] + # repo = test_repo + # pr_number = 999 + # pr_comment_id = 77 + test_file = "tests/test_bot_job123.metadata" + assert filecmp.cmp(expected_file_path, test_file, shallow=False) + + # use directory that does not exist + dir_does_not_exist = os.path.join(tmpdir, "dir_does_not_exist") + job2 = Job(dir_does_not_exist, "test/architecture", "EESSI-pilot", "--speed_up_job") + job_id2 = "222" + with pytest.raises(FileNotFoundError): + create_metadata_file(job2, job_id2, repo_name, pr_number, pr_comment_id) + + # use directory without write permission + dir_without_write_perm = os.path.join("/") + job3 = Job(dir_without_write_perm, "test/architecture", "EESSI-pilot", "--speed_up_job") + job_id3 = "333" + with pytest.raises(OSError): + create_metadata_file(job3, job_id3, repo_name, pr_number, pr_comment_id) + + # disk quota exceeded (difficult to create and unlikely to happen because + # partition where file is stored is usually very large) + + # use undefined values for parameters + # job_id = None + job4 = Job(tmpdir, "test/architecture", "EESSI-pilot", "--speed_up_job") + job_id4 = None + create_metadata_file(job4, job_id4, repo_name, pr_number, pr_comment_id) + + expected_file4 = f"_bot_job{job_id}.metadata" + expected_file_path4 = os.path.join(tmpdir, expected_file4) + # assert expected_file exists + assert os.path.exists(expected_file_path4) + + # assert file contents = + test_file = "tests/test_bot_job123.metadata" + assert filecmp.cmp(expected_file_path4, test_file, shallow=False) + + # use undefined values for parameters + # job.working_dir = None + job5 = Job(None, "test/architecture", "EESSI-pilot", "--speed_up_job") + job_id5 = "555" + with pytest.raises(TypeError): + create_metadata_file(job5, job_id5, repo_name, pr_number, pr_comment_id) diff --git a/tests/test_tools_pr_comments.py b/tests/test_tools_pr_comments.py index aa661530..0caee578 100644 --- a/tests/test_tools_pr_comments.py +++ b/tests/test_tools_pr_comments.py @@ -29,6 +29,7 @@ def __init__(self, body, edit_raises='0', edit_exception=Exception): self.edit_raises = edit_raises self.edit_exception = edit_exception self.edit_call_count = 0 + self.id = 1 def edit(self, body): def should_raise_exception():