diff --git a/src/git_portfolio/__main__.py b/src/git_portfolio/__main__.py index 525735c5..46b3f9f8 100644 --- a/src/git_portfolio/__main__.py +++ b/src/git_portfolio/__main__.py @@ -1,11 +1,21 @@ """Command-line interface.""" +import sys from typing import Tuple import click import git_portfolio.config_manager as cm +import git_portfolio.github_manager as ghm import git_portfolio.local_manager as lm -import git_portfolio.portfolio_manager as pm + + +try: + CONFIG_MANAGER = cm.ConfigManager() +except TypeError as type_error: + click.secho( + f"Error: {type_error}", fg="red", err=True, + ) + sys.exit() @click.group("cli") @@ -19,9 +29,7 @@ def main() -> None: def checkout(args: Tuple[str]) -> None: """CLI `git checkout BRANCH` command.""" # TODO add -b option - config_manager = cm.ConfigManager() - configs = config_manager.load_configs() - if not configs.github_selected_repos: + if not CONFIG_MANAGER.config.github_selected_repos: click.secho( "Error: no repos selected. Please run `gitp config init`.", fg="red", ) @@ -34,7 +42,11 @@ def checkout(args: Tuple[str]) -> None: fg="red", ) else: - click.secho(lm.LocalManager().checkout(configs.github_selected_repos, args)) + click.secho( + lm.LocalManager().checkout( + CONFIG_MANAGER.config.github_selected_repos, args + ) + ) @click.group("config") @@ -68,40 +80,52 @@ def delete() -> None: pass +def _save_config(config: cm.Config) -> None: + """Save config with ConfigManager.""" + CONFIG_MANAGER.config = config + CONFIG_MANAGER.save_config() + click.secho("gitp successfully configured.", fg="blue") + + @configure.command("init") def config_init() -> None: """Config init command.""" - pm.PortfolioManager() + github_manager = ghm.GithubManager(CONFIG_MANAGER.config) + _save_config(github_manager.config) @configure.command("repos") def config_repos() -> None: """Config repos command.""" - pm.PortfolioManager().config_repos() + config = ghm.GithubManager(CONFIG_MANAGER.config).config_repos() + if config: + _save_config(config) + else: + click.secho("Error: no config found, please run `gitp config init`.", fg="red") @create.command("issues") def create_issues() -> None: """Create issues command.""" - pm.PortfolioManager().create_issues() + ghm.GithubManager(CONFIG_MANAGER.config).create_issues() @create.command("prs") def create_prs() -> None: """Create prs command.""" - pm.PortfolioManager().create_pull_requests() + ghm.GithubManager(CONFIG_MANAGER.config).create_pull_requests() @merge.command("prs") def merge_prs() -> None: """Merge prs command.""" - pm.PortfolioManager().merge_pull_requests() + ghm.GithubManager(CONFIG_MANAGER.config).merge_pull_requests() @delete.command("branches") def delete_branches() -> None: """Delete branches command.""" - pm.PortfolioManager().delete_branches() + ghm.GithubManager(CONFIG_MANAGER.config).delete_branches() main.add_command(configure) diff --git a/src/git_portfolio/config_manager.py b/src/git_portfolio/config_manager.py index f7bfa1a6..18f8e113 100644 --- a/src/git_portfolio/config_manager.py +++ b/src/git_portfolio/config_manager.py @@ -24,25 +24,37 @@ def __init__( class ConfigManager: """Configuration manager class.""" - CONFIG_FOLDER = os.path.join(os.path.expanduser("~"), ".gitp") - CONFIG_FILE = "config.yaml" - - def load_configs(self) -> Config: - """Get configs if it exists.""" - if os.path.exists(os.path.join(self.CONFIG_FOLDER, self.CONFIG_FILE)): - print("Loading previous configs\n") - with open( - os.path.join(self.CONFIG_FOLDER, self.CONFIG_FILE) - ) as config_file: - data = yaml.safe_load(config_file) - return Config(**data) - return Config("", "", []) - - def save_configs(self, configs: Config) -> None: + def __init__(self, config_filename: str = "config.yaml") -> None: + """Load config if it exists.""" + self.config_folder = os.path.join(os.path.expanduser("~"), ".gitp") + self.config_path = os.path.join(self.config_folder, config_filename) + + if os.path.exists(self.config_path): + print("Loading previous config...\n") + with open(self.config_path) as config_file: + try: + data = yaml.safe_load(config_file) + except yaml.scanner.ScannerError: + raise TypeError("Invalid YAML file.") + if data: + try: + self.config = Config(**data) + return + except TypeError: + raise TypeError("Invalid config file.") + self.config = Config("", "", []) + + def _config_is_empty(self) -> bool: + if self.config.github_selected_repos and self.config.github_access_token: + return False + return True + + def save_config(self) -> None: """Write config to YAML file.""" - pathlib.Path(self.CONFIG_FOLDER).mkdir(parents=True, exist_ok=True) - configs_dict = vars(configs) - with open( - os.path.join(self.CONFIG_FOLDER, self.CONFIG_FILE), "w" - ) as config_file: - yaml.dump(configs_dict, config_file) + if not self._config_is_empty(): + pathlib.Path(self.config_folder).mkdir(parents=True, exist_ok=True) + config_dict = vars(self.config) + with open(self.config_path, "w") as config_file: + yaml.dump(config_dict, config_file) + else: + raise AttributeError diff --git a/src/git_portfolio/portfolio_manager.py b/src/git_portfolio/github_manager.py similarity index 79% rename from src/git_portfolio/portfolio_manager.py rename to src/git_portfolio/github_manager.py index 7f9f20c6..701e9ee0 100644 --- a/src/git_portfolio/portfolio_manager.py +++ b/src/git_portfolio/github_manager.py @@ -10,7 +10,7 @@ import github import requests -import git_portfolio.config_manager as config_manager +import git_portfolio.config_manager as cm import git_portfolio.prompt as prompt # starting log @@ -20,15 +20,14 @@ LOGGER = logging.getLogger(__name__) -class PortfolioManager: - """Portfolio manager class.""" +class GithubManager: + """Github manager class.""" - def __init__(self) -> None: + def __init__(self, config: cm.Config) -> None: """Constructor.""" - self.config_manager = config_manager.ConfigManager() - self.configs = self.config_manager.load_configs() - if self.configs.github_access_token: - self.github_setup() + self.config = config + if config.github_access_token: + self._github_setup() else: self.config_ini() @@ -37,7 +36,7 @@ def create_issues( ) -> None: """Create issues.""" if not issue: - issue = prompt.create_issues(self.configs.github_selected_repos) + issue = prompt.create_issues(self.config.github_selected_repos) labels = ( [label.strip() for label in issue.labels.split(",")] if issue.labels else [] ) @@ -45,7 +44,7 @@ def create_issues( if github_repo: self._create_issue_from_repo(github_repo, issue, labels) else: - for github_repo in self.configs.github_selected_repos: + for github_repo in self.config.github_selected_repos: self._create_issue_from_repo(github_repo, issue, labels) def _create_issue_from_repo( @@ -72,12 +71,12 @@ def create_pull_requests( ) -> None: """Create pull requests.""" if not pr: - pr = prompt.create_pull_requests(self.configs.github_selected_repos) + pr = prompt.create_pull_requests(self.config.github_selected_repos) if github_repo: self._create_pull_request_from_repo(github_repo, pr) else: - for github_repo in self.configs.github_selected_repos: + for github_repo in self.config.github_selected_repos: self._create_pull_request_from_repo(github_repo, pr) def _create_pull_request_from_repo(self, github_repo: str, pr: Any) -> None: @@ -127,7 +126,7 @@ def merge_pull_requests( """Merge pull requests.""" if not pr_merge: pr_merge = prompt.merge_pull_requests( - self.github_username, self.configs.github_selected_repos + self.github_username, self.config.github_selected_repos ) # Important note: base and head arguments have different import formats. # https://developer.github.com/v3/pulls/#list-pull-requests @@ -138,7 +137,7 @@ def merge_pull_requests( if github_repo: self._merge_pull_request_from_repo(github_repo, head, pr_merge, state) else: - for github_repo in self.configs.github_selected_repos: + for github_repo in self.config.github_selected_repos: self._merge_pull_request_from_repo(github_repo, head, pr_merge, state) def _merge_pull_request_from_repo( @@ -175,12 +174,12 @@ def _merge_pull_request_from_repo( def delete_branches(self, branch: str = "", github_repo: str = "") -> None: """Delete branches.""" if not branch: - branch = prompt.delete_branches(self.configs.github_selected_repos) + branch = prompt.delete_branches(self.config.github_selected_repos) if github_repo: self._delete_branch_from_repo(github_repo, branch) else: - for github_repo in self.configs.github_selected_repos: + for github_repo in self.config.github_selected_repos: self._delete_branch_from_repo(github_repo, branch) def _delete_branch_from_repo(self, github_repo: str, branch: str) -> None: @@ -196,13 +195,13 @@ def _delete_branch_from_repo(self, github_repo: str, branch: str) -> None: def get_github_connection(self) -> github.Github: """Get Github connection.""" # GitHub Enterprise - if self.configs.github_hostname: - base_url = f"https://{self.configs.github_hostname}/api/v3" + if self.config.github_hostname: + base_url = f"https://{self.config.github_hostname}/api/v3" return github.Github( - base_url=base_url, login_or_token=self.configs.github_access_token + base_url=base_url, login_or_token=self.config.github_access_token ) # GitHub.com - return github.Github(self.configs.github_access_token) + return github.Github(self.config.github_access_token) def get_github_username( self, @@ -216,9 +215,14 @@ def get_github_username( except (github.BadCredentialsException, github.GithubException): return "" except requests.exceptions.ConnectionError: - sys.exit("Unable to reach server. Please check you network.") + sys.exit( + ( + "Unable to reach server. Please check you network and credentials " + "and try again." + ) + ) - def get_github_repos( + def _get_github_repos( self, user: Union[ github.AuthenticatedUser.AuthenticatedUser, github.NamedUser.NamedUser @@ -227,42 +231,36 @@ def get_github_repos( """Get Github repos from user.""" return user.get_repos() - def config_repos(self) -> None: + def config_ini(self) -> None: + """Initialize app configuration.""" + # only config if gets a valid connection + valid = False + while not valid: + answers = prompt.connect_github(self.config.github_access_token) + self.config.github_access_token = answers.github_access_token.strip() + self.config.github_hostname = answers.github_hostname + valid = self._github_setup() + if not valid: + print("Wrong GitHub token/permissions. Please try again.") + self.config_repos() + + def config_repos(self) -> Optional[cm.Config]: """Configure repos in use.""" - if self.configs.github_selected_repos: - print("\nThe configured repos will be used:") - for repo in self.configs.github_selected_repos: - print(" *", repo) - new_repos = prompt.new_repos() + if self.config.github_selected_repos: + new_repos = prompt.new_repos(self.config.github_selected_repos) if not new_repos: - print("gitp successfully configured.") - return + return None repo_names = [repo.full_name for repo in self.github_repos] + self.config.github_selected_repos = prompt.select_repos(repo_names) + return self.config - self.configs.github_selected_repos = prompt.select_repos(repo_names) - self.config_manager.save_configs(self.configs) - print("gitp successfully configured.") - - def github_setup(self) -> bool: + def _github_setup(self) -> bool: """Setup Github connection properties.""" self.github_connection = self.get_github_connection() user = self.github_connection.get_user() self.github_username = self.get_github_username(user) if not self.github_username: return False - self.github_repos = self.get_github_repos(user) + self.github_repos = self._get_github_repos(user) return True - - def config_ini(self) -> None: - """Initialize app configuration.""" - # only config if gets a valid connection - valid = False - while not valid: - answers = prompt.connect_github(self.configs.github_access_token) - self.configs.github_access_token = answers.github_access_token.strip() - self.configs.github_hostname = answers.github_hostname - valid = self.github_setup() - if not valid: - print("Wrong GitHub token/permissions. Please try again.") - self.config_repos() diff --git a/src/git_portfolio/prompt.py b/src/git_portfolio/prompt.py index 7e123c61..450e41cb 100644 --- a/src/git_portfolio/prompt.py +++ b/src/git_portfolio/prompt.py @@ -233,11 +233,13 @@ def connect_github(github_access_token: str) -> ConnectGithub: return ConnectGithub(answers["github_access_token"], answers["github_hostname"]) -def new_repos() -> Any: +def new_repos(github_selected_repos: List[str]) -> Any: """Prompt question to know if you want to select new repositories.""" - answer = inquirer.prompt( - [inquirer.Confirm("", message="Do you want to select new repositories?")] - )[""] + message = "\nThe configured repos will be used:" + for repo in github_selected_repos: + message += f" * {repo}\n" + message += "Do you want to select new repositories?" + answer = inquirer.prompt([inquirer.Confirm("", message=message)])[""] return answer diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 00000000..12371d7f --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,77 @@ +"""Test cases for the config manager module.""" +from unittest.mock import Mock +from unittest.mock import patch + +import pytest + +from git_portfolio import config_manager as cm + + +@patch("os.path.join") +class TestConfigManager: + """ConfigManager test class.""" + + def test_init_invalid_config(self, os_join_path: Mock, tmp_path: Mock) -> None: + """It raises TypeError.""" + filename = "config1.yaml" + d = tmp_path + p = d / filename + p.write_text("in:valid") + os_join_path.side_effect = [str(d), str(p)] + with pytest.raises(TypeError): + cm.ConfigManager() + + def test_save_invalid_yaml(self, os_join_path: Mock, tmp_path: Mock) -> None: + """It raises yaml.ScannerError.""" + filename = "config.yaml" + content = ( + "github_access_token: aaaaabbbbbccccc12345" + "github_hostname: ''" + "github_selected_repos:" + " - staticdev/test" + ) + d = tmp_path + p = d / filename + p.write_text(content) + os_join_path.side_effect = [str(d), str(p)] + with pytest.raises(TypeError): + cm.ConfigManager() + + def test_save_config_no_file(self, os_join_path: Mock, tmp_path: Mock) -> None: + """It raises AttributeError.""" + d = tmp_path + os_join_path.side_effect = [str(d), str(d / "config.yaml")] + manager = cm.ConfigManager() + with pytest.raises(AttributeError): + manager.save_config() + + def test_save_config_empty_file(self, os_join_path: Mock, tmp_path: Mock) -> None: + """It raises AttributeError.""" + filename = "config2.yaml" + d = tmp_path + p = d / filename + p.write_text("") + os_join_path.side_effect = [str(d), str(p)] + manager = cm.ConfigManager(filename) + with pytest.raises(AttributeError): + manager.save_config() + + @patch("yaml.dump") + def test_save_config_success( + self, yaml_dump: Mock, os_join_path: Mock, tmp_path: Mock + ) -> None: + """It dumps yaml config file.""" + filename = "config.yaml" + content = ( + "github_access_token: aaaaabbbbbccccc12345\n" + "github_hostname: ''\n" + "github_selected_repos:\n" + " - staticdev/test\n" + ) + d = tmp_path + p = d / filename + p.write_text(content) + os_join_path.side_effect = [str(d), str(p)] + manager = cm.ConfigManager() + manager.save_config() + yaml_dump.assert_called_once() diff --git a/tests/test_github_manager.py b/tests/test_github_manager.py new file mode 100644 index 00000000..741497ee --- /dev/null +++ b/tests/test_github_manager.py @@ -0,0 +1,16 @@ +"""Test cases for the Github manager module.""" +# from unittest.mock import Mock +# from unittest.mock import patch +# import git_portfolio.github_manager as ghm +# from git_portfolio import config_manager as cm + + +class TestGithubManager: + """GithubManager test class.""" + + def test_config_repos_dont_select(self) -> None: + """It does nothing.""" + # config = Mock() + # github_manager = ghm.GithubManager(config) + # github_manager.config_repos(["staticdev/omg"]) + pass diff --git a/tests/test_local_manager.py b/tests/test_local_manager.py index 9af0650b..babaf30a 100644 --- a/tests/test_local_manager.py +++ b/tests/test_local_manager.py @@ -1,4 +1,4 @@ -"""Test cases for the local_manager module.""" +"""Test cases for the local manager module.""" from unittest.mock import Mock from unittest.mock import patch diff --git a/tests/test_main.py b/tests/test_main.py index e694160b..390240d5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,7 @@ import pytest from click.testing import CliRunner -from git_portfolio import __main__ +import git_portfolio.__main__ @pytest.fixture @@ -14,85 +14,108 @@ def runner() -> CliRunner: return CliRunner() -@patch("git_portfolio.config_manager.ConfigManager", autospec=True) +@patch("git_portfolio.__main__.CONFIG_MANAGER") @patch("git_portfolio.local_manager.LocalManager", autospec=True) def test_checkout_success( - mock_lm_localmanager: Mock, mock_cm_configmanager: Mock, runner: CliRunner + mock_lm_localmanager: Mock, mock_configmanager: Mock, runner: CliRunner ) -> None: """It calls checkout with master.""" - mock_cm_configmanager.return_value.load_configs.return_value = Mock( - github_selected_repos=["staticdev/omg"] - ) - runner.invoke(__main__.main, ["checkout", "master"], prog_name="gitp") + mock_configmanager.config.github_selected_repos = ["staticdev/omg"] + runner.invoke(git_portfolio.__main__.main, ["checkout", "master"], prog_name="gitp") mock_lm_localmanager.return_value.checkout.assert_called_once_with( ["staticdev/omg"], ("master",) ) -@patch("git_portfolio.config_manager.ConfigManager", autospec=True) -def test_checkout_no_repos(mock_cm_configmanager: Mock, runner: CliRunner) -> None: +@patch("git_portfolio.__main__.CONFIG_MANAGER") +def test_checkout_no_repos(mock_configmanager: Mock, runner: CliRunner) -> None: """It calls checkout with master.""" - mock_cm_configmanager.return_value.load_configs.return_value = Mock( - github_selected_repos=[] + mock_configmanager.config.github_selected_repos = [] + result = runner.invoke( + git_portfolio.__main__.main, ["checkout", "master"], prog_name="gitp" ) - result = runner.invoke(__main__.main, ["checkout", "master"], prog_name="gitp") assert result.output.startswith("Error: no repos selected.") -@patch("git_portfolio.config_manager.ConfigManager", autospec=True) +@patch("git_portfolio.__main__.CONFIG_MANAGER") @patch("git_portfolio.local_manager.LocalManager", autospec=True) def test_checkout_two_arguments( - mock_lm_localmanager: Mock, mock_cm_configmanager: Mock, runner: CliRunner + mock_lm_localmanager: Mock, mock_configmanager: Mock, runner: CliRunner ) -> None: """It outputs error.""" + mock_configmanager.config.github_selected_repos = ["staticdev/omg"] result = runner.invoke( - __main__.main, ["checkout", "master", "master"], prog_name="gitp" + git_portfolio.__main__.main, ["checkout", "master", "master"], prog_name="gitp" ) assert "Error" in result.output -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_config_init(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It creates pm.PortfolioManager.""" - runner.invoke(__main__.configure, ["init"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() +@patch("git_portfolio.__main__.CONFIG_MANAGER") +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_config_init( + mock_github_manager: Mock, mock_configmanager: Mock, runner: CliRunner +) -> None: + """It creates pm.GithubManager.""" + runner.invoke(git_portfolio.__main__.configure, ["init"], prog_name="gitp") + mock_github_manager.assert_called_once() + + +@patch("git_portfolio.__main__.CONFIG_MANAGER") +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_config_repos( + mock_github_manager: Mock, mock_configmanager: Mock, runner: CliRunner +) -> None: + """It call config_repos from pm.GithubManager.""" + result = runner.invoke( + git_portfolio.__main__.configure, ["repos"], prog_name="gitp" + ) + mock_github_manager.assert_called_once() + mock_github_manager.return_value.config_repos.assert_called_once() + assert result.output == "gitp successfully configured.\n" -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_config_repos(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It call config_repos from pm.PortfolioManager.""" - runner.invoke(__main__.configure, ["repos"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() - mock_pm_portfoliomanager.return_value.config_repos.assert_called_once() +@patch("git_portfolio.__main__.CONFIG_MANAGER") +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_config_repos_no_config( + mock_github_manager: Mock, mock_configmanager: Mock, runner: CliRunner +) -> None: + """It returns error message.""" + mock_github_manager.return_value.config_repos.return_value = None + result = runner.invoke( + git_portfolio.__main__.configure, ["repos"], prog_name="gitp" + ) + mock_github_manager.assert_called_once() + mock_github_manager.return_value.config_repos.assert_called_once() + assert "Error" in result.output -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_create_issues(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It call create_issues from pm.PortfolioManager.""" - runner.invoke(__main__.create, ["issues"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() - mock_pm_portfoliomanager.return_value.create_issues.assert_called_once() +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_create_issues(mock_github_manager: Mock, runner: CliRunner) -> None: + """It call create_issues from pm.GithubManager.""" + runner.invoke(git_portfolio.__main__.create, ["issues"], prog_name="gitp") + mock_github_manager.assert_called_once() + mock_github_manager.return_value.create_issues.assert_called_once() -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_create_prs(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It call create_pull_requests from pm.PortfolioManager.""" - runner.invoke(__main__.create, ["prs"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() - mock_pm_portfoliomanager.return_value.create_pull_requests.assert_called_once() +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_create_prs(mock_github_manager: Mock, runner: CliRunner) -> None: + """It call create_pull_requests from pm.GithubManager.""" + runner.invoke(git_portfolio.__main__.create, ["prs"], prog_name="gitp") + mock_github_manager.assert_called_once() + mock_github_manager.return_value.create_pull_requests.assert_called_once() -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_merge_prs(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It call merge_pull_requests from pm.PortfolioManager.""" - runner.invoke(__main__.merge, ["prs"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() - mock_pm_portfoliomanager.return_value.merge_pull_requests.assert_called_once() +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_merge_prs(mock_github_manager: Mock, runner: CliRunner) -> None: + """It call merge_pull_requests from pm.GithubManager.""" + runner.invoke(git_portfolio.__main__.merge, ["prs"], prog_name="gitp") + mock_github_manager.assert_called_once() + mock_github_manager.return_value.merge_pull_requests.assert_called_once() -@patch("git_portfolio.portfolio_manager.PortfolioManager", autospec=True) -def test_delete_branches(mock_pm_portfoliomanager: Mock, runner: CliRunner) -> None: - """It call delete_branches from pm.PortfolioManager.""" - runner.invoke(__main__.delete, ["branches"], prog_name="gitp") - mock_pm_portfoliomanager.assert_called_once() - mock_pm_portfoliomanager.return_value.delete_branches.assert_called_once() +@patch("git_portfolio.github_manager.GithubManager", autospec=True) +def test_delete_branches(mock_github_manager: Mock, runner: CliRunner) -> None: + """It call delete_branches from pm.GithubManager.""" + runner.invoke(git_portfolio.__main__.delete, ["branches"], prog_name="gitp") + mock_github_manager.assert_called_once() + mock_github_manager.return_value.delete_branches.assert_called_once()