import os from datetime import datetime, timedelta from stat import S_IREAD, S_IWRITE, S_IEXEC from subprocess import DEVNULL from unittest.mock import Mock, call, mock_open, patch import pytest from github import GithubException, UnknownObjectException from github.Requester import requests from tagbot.action import TAGBOT_WEB, Abort, InvalidProject from tagbot.action.repo import Repo RequestException = requests.RequestException def _repo( *, repo="", registry="", github="", github_api="", token="", changelog="", ignore=[], ssh=False, gpg=False, lookback=3, ): return Repo( repo=repo, registry=registry, github=github, github_api=github_api, token=token, changelog=changelog, changelog_ignore=ignore, ssh=ssh, gpg=gpg, lookback=lookback, ) def test_constuctor(): r = _repo(github="github.com", github_api="api.github.com") assert r._gh_url == "https://github.com" assert r._gh_api == "https://api.github.com" assert r._git._github == "github.com" r = _repo(github="https://github.com", github_api="https://api.github.com") assert r._gh_url == "https://github.com" assert r._gh_api == "https://api.github.com" assert r._git._github == "github.com" def test_project(): r = _repo() r._repo.get_contents = Mock( return_value=Mock(decoded_content=b"""name = "FooBar"\nuuid="abc-def"\n""") ) assert r._project("name") == "FooBar" assert r._project("uuid") == "abc-def" assert r._project("name") == "FooBar" r._repo.get_contents.assert_called_once_with("Project.toml") r._repo.get_contents.side_effect = UnknownObjectException(404, "???") r._Repo__project = None with pytest.raises(InvalidProject): r._project("name") def test_registry_path(): r = _repo() r._registry = Mock() r._registry.get_contents.return_value.decoded_content = b""" [packages] abc-def = { path = "B/Bar" } """ r._project = lambda _k: "abc-ddd" assert r._registry_path is None r._project = lambda _k: "abc-def" assert r._registry_path == "B/Bar" assert r._registry_path == "B/Bar" assert r._registry.get_contents.call_count == 2 def test_only(): r = _repo() assert r._only(1) == 1 assert r._only([1]) == 1 assert r._only([[1]]) == [1] def test_maybe_b64(): r = _repo() assert r._maybe_b64("foo bar") == "foo bar" assert r._maybe_b64("Zm9v") == "foo" def test_create_release_branch_pr(): r = _repo() r._repo = Mock(default_branch="default") r._create_release_branch_pr("v1.2.3", "branch") r._repo.create_pull.assert_called_once_with( title="Merge release branch for v1.2.3", body="", head="branch", base="default", ) def test_commit_sha_of_tree_from_branch(): r = _repo() since = datetime.now() r._repo.get_commits = Mock(return_value=[Mock(sha="abc"), Mock(sha="sha")]) r._repo.get_commits.return_value[1].commit.tree.sha = "tree" assert r._commit_sha_of_tree_from_branch("master", "tree", since) == "sha" r._repo.get_commits.assert_called_with(sha="master", since=since) r._repo.get_commits.return_value.pop() assert r._commit_sha_of_tree_from_branch("master", "tree", since) is None def test_commit_sha_of_tree(): r = _repo() now = datetime.now() r._repo = Mock(default_branch="master",) branches = r._repo.get_branches.return_value = [Mock(), Mock()] branches[0].name = "foo" branches[1].name = "master" r._lookback = Mock(__rsub__=lambda x, y: now) r._commit_sha_of_tree_from_branch = Mock(side_effect=["sha1", None, "sha2"]) assert r._commit_sha_of_tree("tree") == "sha1" r._repo.get_branches.assert_not_called() r._commit_sha_of_tree_from_branch.assert_called_once_with("master", "tree", now) assert r._commit_sha_of_tree("tree") == "sha2" r._commit_sha_of_tree_from_branch.assert_called_with("foo", "tree", now) r._commit_sha_of_tree_from_branch.side_effect = None r._commit_sha_of_tree_from_branch.return_value = None r._git.commit_sha_of_tree = Mock(side_effect=["sha", None]) assert r._commit_sha_of_tree("tree") == "sha" assert r._commit_sha_of_tree("tree") is None def test_commit_sha_of_tag(): r = _repo() r._repo.get_git_ref = Mock() r._repo.get_git_ref.return_value.object.type = "commit" r._repo.get_git_ref.return_value.object.sha = "c" assert r._commit_sha_of_tag("v1.2.3") == "c" r._repo.get_git_ref.assert_called_with("tags/v1.2.3") r._repo.get_git_ref.return_value.object.type = "tag" r._repo.get_git_tag = Mock() r._repo.get_git_tag.return_value.object.sha = "t" assert r._commit_sha_of_tag("v2.3.4") == "t" r._repo.get_git_tag.assert_called_with("c") r._repo.get_git_ref.return_value.object = None assert r._commit_sha_of_tag("v3.4.5") is None r._repo.get_git_ref.side_effect = UnknownObjectException(404, "???") assert r._commit_sha_of_tag("v4.5.6") is None @patch("tagbot.action.repo.logger") def test_filter_map_versions(logger): r = _repo() r._commit_sha_of_tree = Mock(return_value=None) assert not r._filter_map_versions({"1.2.3": "tree1"}) logger.warning.assert_called_with( "No matching commit was found for version v1.2.3 (tree1)" ) r._commit_sha_of_tree.return_value = "sha" r._commit_sha_of_tag = Mock(return_value="sha") assert not r._filter_map_versions({"2.3.4": "tree2"}) logger.info.assert_called_with("Tag v2.3.4 already exists") r._commit_sha_of_tag.return_value = "abc" assert not r._filter_map_versions({"3.4.5": "tree3"}) logger.error.assert_called_with( "Existing tag v3.4.5 points at the wrong commit (expected sha)" ) r._commit_sha_of_tag.return_value = None assert r._filter_map_versions({"4.5.6": "tree4"}) == {"v4.5.6": "sha"} @patch("tagbot.action.repo.logger") def test_versions(logger): r = _repo() r._Repo__registry_path = "path" r._registry = Mock() r._registry.get_contents.return_value.decoded_content = b""" ["1.2.3"] git-tree-sha1 = "abc" ["2.3.4"] git-tree-sha1 = "bcd" """ assert r._versions() == {"1.2.3": "abc", "2.3.4": "bcd"} r._registry.get_contents.assert_called_with("path/Versions.toml") logger.debug.assert_not_called() commit = Mock() commit.commit.sha = "abcdef" r._registry.get_commits.return_value = [commit] delta = timedelta(days=3) assert r._versions(min_age=delta) == {"1.2.3": "abc", "2.3.4": "bcd"} r._registry.get_commits.assert_called_once() assert len(r._registry.get_commits.mock_calls) == 1 [c] = r._registry.get_commits.mock_calls assert not c.args and len(c.kwargs) == 1 and "until" in c.kwargs assert isinstance(c.kwargs["until"], datetime) r._registry.get_contents.assert_called_with("path/Versions.toml", ref="abcdef") logger.debug.assert_not_called() r._registry.get_commits.return_value = [] assert r._versions(min_age=delta) == {} logger.debug.assert_called_with("No registry commits were found") r._registry.get_contents.side_effect = UnknownObjectException(404, "???") assert r._versions() == {} logger.debug.assert_called_with("Versions.toml was not found ({})") r._Repo__registry_path = Mock(__bool__=lambda self: False) assert r._versions() == {} logger.debug.assert_called_with("Package is not registered") def test_run_url(): r = _repo() r._repo = Mock(html_url="https://github.com/Foo/Bar") with patch.dict(os.environ, {"GITHUB_RUN_ID": "123"}): assert r._run_url() == "https://github.com/Foo/Bar/actions/runs/123" with patch.dict(os.environ, clear=True): assert r._run_url() == "https://github.com/Foo/Bar/actions" @patch("tagbot.action.repo.logger") @patch("docker.from_env") def test_image_id(from_env, logger): r = _repo() from_env.return_value.containers.get.return_value.image.id = "sha" with patch.dict(os.environ, {"HOSTNAME": "foo"}): assert r._image_id() == "sha" with patch.dict(os.environ, clear=True): assert r._image_id() == "Unknown" logger.warning.assert_called_with("HOSTNAME is not set") @patch("requests.post") def test_report_error(post): post.return_value.json.return_value = {"status": "ok"} r = _repo(token="x") r._repo = Mock(full_name="Foo/Bar", private=True) r._image_id = Mock(return_value="id") r._run_url = Mock(return_value="url") r._report_error("ahh") post.assert_not_called() r._repo.private = False with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}): r._report_error("ahh") post.assert_not_called() with patch.dict(os.environ, {}, clear=True): r._report_error("ahh") post.assert_not_called() with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}): r._report_error("ahh") post.assert_called_with( f"{TAGBOT_WEB}/report", json={"image": "id", "repo": "Foo/Bar", "run": "url", "stacktrace": "ahh"}, ) def test_is_registered(): r = _repo(github="gh.com") r._repo = Mock(full_name="Foo/Bar.jl") r._Repo__registry_path = Mock(__bool__=lambda self: False) r._registry.get_contents = Mock() contents = r._registry.get_contents.return_value contents.decoded_content = b"""repo = "https://gh.com/Foo/Bar.jl.git"\n""" assert not r.is_registered() r._registry.get_contents.assert_not_called() r._Repo__registry_path = "path" assert r.is_registered() r._registry.get_contents.assert_called_with("path/Package.toml") contents.decoded_content = b"""repo = "https://gh.com/Foo/Bar.jl"\n""" assert r.is_registered() contents.decoded_content = b"""repo = "https://gitlab.com/Foo/Bar.jl.git"\n""" assert not r.is_registered() contents.decoded_content = b"""repo = "git@gh.com:Foo/Bar.jl.git"\n""" assert r.is_registered() contents.decoded_content = b"""repo = "git@github.com:Foo/Bar.jl.git"\n""" assert not r.is_registered() # TODO: We should test for the InvalidProject behaviour, # but I'm not really sure how it's possible. def test_new_versions(): r = _repo() r._versions = ( lambda min_age=None: {"1.2.3": "abc"} if min_age else {"1.2.3": "abc", "3.4.5": "cde", "2.3.4": "bcd"} ) r._filter_map_versions = lambda vs: vs assert list(r.new_versions().items()) == [("2.3.4", "bcd"), ("3.4.5", "cde")] def test_create_dispatch_event(): r = _repo() r._repo = Mock(full_name="Foo/Bar") r.create_dispatch_event({"a": "b", "c": "d"}) r._repo.create_repository_dispatch.assert_called_once_with( "TagBot", {"a": "b", "c": "d"} ) @patch("tagbot.action.repo.mkstemp", side_effect=[(0, "abc"), (0, "xyz")] * 3) @patch("os.chmod") @patch("subprocess.run") @patch("pexpect.spawn") def test_configure_ssh(spawn, run, chmod, mkstemp): r = _repo(github="gh.com", repo="foo") r._repo = Mock(ssh_url="sshurl") r._git.set_remote_url = Mock() r._git.config = Mock() open = mock_open() with patch("builtins.open", open): r.configure_ssh(" sshkey ", None) r._git.set_remote_url.assert_called_with("sshurl") open.assert_has_calls( [call("abc", "w"), call("xyz", "w")], any_order=True, ) open.return_value.write.assert_called_with("sshkey\n") run.assert_called_with( ["ssh-keyscan", "-t", "rsa", "gh.com"], check=True, stdout=open.return_value, stderr=DEVNULL, ) chmod.assert_called_with("abc", S_IREAD) r._git.config.assert_called_with( "core.sshCommand", "ssh -i abc -o UserKnownHostsFile=xyz", ) with patch("builtins.open", open): r.configure_ssh("Zm9v", None) open.return_value.write.assert_any_call("foo\n") spawn.assert_not_called() run.return_value.stdout = """ VAR1=value; export VAR1; VAR2=123; export VAR2; echo Agent pid 123; """ with patch("builtins.open", open): r.configure_ssh(" key ", "mypassword") run.assert_called_with(["ssh-agent"], check=True, text=True, capture_output=True) assert os.getenv("VAR1") == "value" assert os.getenv("VAR2") == "123" spawn.assert_called_with("ssh-add abc") calls = [ call.expect("Enter passphrase"), call.sendline("mypassword"), call.expect("Identity added"), ] spawn.return_value.assert_has_calls(calls) @patch("tagbot.action.repo.GPG") @patch("tagbot.action.repo.mkdtemp", return_value="gpgdir") @patch("os.chmod") def test_configure_gpg(chmod, mkdtemp, GPG): r = _repo() r._git.config = Mock() gpg = GPG.return_value gpg.import_keys.return_value = Mock(sec_imported=1, fingerprints=["k"], stderr="e") r.configure_gpg("foo bar", None) assert os.getenv("GNUPGHOME") == "gpgdir" chmod.assert_called_with("gpgdir", S_IREAD | S_IWRITE | S_IEXEC) GPG.assert_called_with(gnupghome="gpgdir", use_agent=True) gpg.import_keys.assert_called_with("foo bar") calls = [call("user.signingKey", "k"), call("tag.gpgSign", "true")] r._git.config.assert_has_calls(calls) r.configure_gpg("Zm9v", None) gpg.import_keys.assert_called_with("foo") gpg.sign.return_value = Mock(status="signature created") r.configure_gpg("foo bar", "mypassword") gpg.sign.assert_called_with("test", passphrase="mypassword") gpg.sign.return_value = Mock(status=None, stderr="e") with pytest.raises(Abort): r.configure_gpg("foo bar", "mypassword") gpg.import_keys.return_value.sec_imported = 0 with pytest.raises(Abort): r.configure_gpg("foo bar", None) def test_handle_release_branch(): r = _repo() r._create_release_branch_pr = Mock() r._git = Mock( fetch_branch=Mock(side_effect=[False, True, True, True, True]), is_merged=Mock(side_effect=[True, False, False, False]), can_fast_forward=Mock(side_effect=[True, False, False]), ) r._pr_exists = Mock(side_effect=[True, False]) r.handle_release_branch("v1") r._git.fetch_branch.assert_called_with("release-1") r._git.is_merged.assert_not_called() r.handle_release_branch("v2") r._git.is_merged.assert_called_with("release-2") r._git.can_fast_forward.assert_not_called() r.handle_release_branch("v3") r._git.merge_and_delete_branch.assert_called_with("release-3") r._pr_exists.assert_not_called() r.handle_release_branch("v4") r._pr_exists.assert_called_with("release-4") r._create_release_branch_pr.assert_not_called() r.handle_release_branch("v5") r._create_release_branch_pr.assert_called_with("v5", "release-5") def test_create_release(): r = _repo() r._git.commit_sha_of_default = Mock(return_value="a") r._git.create_tag = Mock() r._repo = Mock(default_branch="default") r._repo.create_git_tag.return_value.sha = "t" r._changelog.get = Mock(return_value="l") r.create_release("v1", "a") r._git.create_tag.assert_not_called() r._repo.create_git_tag.assert_called_with("v1", "l", "a", "commit") r._repo.create_git_ref.assert_called_with("refs/tags/v1", "t") r._repo.create_git_release.assert_called_with( "v1", "v1", "l", target_commitish="default" ) r.create_release("v1", "b") r._repo.create_git_release.assert_called_with("v1", "v1", "l", target_commitish="b") r._ssh = True r.create_release("v1", "c") r._git.create_tag.assert_called_with("v1", "c", "l") @patch("traceback.format_exc", return_value="ahh") @patch("tagbot.action.repo.logger") def test_handle_error(logger, format_exc): r = _repo() r._report_error = Mock(side_effect=[None, RuntimeError("!")]) r.handle_error(RequestException()) r._report_error.assert_not_called() r.handle_error(GithubException(502, "oops")) r._report_error.assert_not_called() r.handle_error(GithubException(404, "???")) r._report_error.assert_called_with("ahh") r.handle_error(RuntimeError("?")) r._report_error.assert_called_with("ahh") logger.error.assert_called_with("Issue reporting failed") def test_commit_sha_of_version(): r = _repo() r._Repo__registry_path = "" r._registry.get_contents = Mock( return_value=Mock(decoded_content=b"""["3.4.5"]\ngit-tree-sha1 = "abc"\n""") ) r._commit_sha_of_tree = Mock(return_value="def") assert r.commit_sha_of_version("v1.2.3") is None r._registry.get_contents.assert_not_called() r._Repo__registry_path = "path" assert r.commit_sha_of_version("v2.3.4") is None r._registry.get_contents.assert_called_with("path/Versions.toml") r._commit_sha_of_tree.assert_not_called() assert r.commit_sha_of_version("v3.4.5") == "def" r._commit_sha_of_tree.assert_called_with("abc")