diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fb43dc..e29f8fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ You can also profile a command: ```shell just profile --help # or with arguments, e.g.: -just profile list-repo --format json +just profile repo list --format json ``` ## Configuration diff --git a/README.md b/README.md index e3bd4df..9bf8322 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,6 @@ A Python CLI for common [GitHub REST API](https://docs.github.com/en/rest) operations—list and inspect repositories, create or delete them, manage Dependabot security settings, and create deployment environments. -## Features - -- Get repository details (`get-repo`) -- List repositories for the authenticated user (`list-repo`) -- Create and delete repositories (`create-repo`, `delete-repo`) -- Enable or disable Dependabot security updates (`dependabot`) -- Create deployment environments (`environment`) -- Table or JSON output for repository listings (`--format`) - ## Installation With `pip`: @@ -60,20 +51,28 @@ github-rest-cli --help ## Commands +Top-level groups: + +```shell +github-rest-cli repo --help +github-rest-cli dependabot --help +github-rest-cli environment --help +``` + ### Get a repository ```shell -github-rest-cli get-repo --name my-repo -github-rest-cli get-repo --name my-repo --org my-org -github-rest-cli get-repo --name my-repo --format json +github-rest-cli repo get --name my-repo +github-rest-cli repo get --name my-repo --org my-org +github-rest-cli repo get --name my-repo --format json ``` ### List repositories ```shell -github-rest-cli list-repo -github-rest-cli list-repo --page 50 --sort pushed -github-rest-cli list-repo --role owner --format json +github-rest-cli repo list +github-rest-cli repo list --page 50 --sort pushed +github-rest-cli repo list --role owner --format json ``` | Flag | Description | Default | @@ -86,10 +85,10 @@ github-rest-cli list-repo --role owner --format json ### Create a repository ```shell -github-rest-cli create-repo --name my-new-repo -github-rest-cli create-repo --name my-new-repo --visibility private -github-rest-cli create-repo --name my-new-repo --org my-org -github-rest-cli create-repo --name my-new-repo --empty +github-rest-cli repo create --name my-new-repo +github-rest-cli repo create --name my-new-repo --private +github-rest-cli repo create --name my-new-repo --org my-org +github-rest-cli repo create --name my-new-repo --empty ``` ### Delete a repository @@ -97,36 +96,36 @@ github-rest-cli create-repo --name my-new-repo --empty Prompts for confirmation unless `--yes` / `-y` is passed: ```shell -github-rest-cli delete-repo --name my-repo -github-rest-cli delete-repo --name my-repo --org my-org -github-rest-cli delete-repo --name my-repo --yes +github-rest-cli repo delete --name my-repo +github-rest-cli repo delete --name my-repo --org my-org +github-rest-cli repo delete --name my-repo --yes ``` ### Dependabot security updates ```shell -github-rest-cli dependabot --name my-repo --enable -github-rest-cli dependabot --name my-repo --disable -github-rest-cli dependabot --name my-repo --org my-org --enable +github-rest-cli dependabot enable --name my-repo +github-rest-cli dependabot disable --name my-repo +github-rest-cli dependabot enable --name my-repo --org my-org ``` ### Deployment environments ```shell -github-rest-cli environment --name my-repo --env production -github-rest-cli environment --name my-repo --env staging --org my-org +github-rest-cli environment create --name my-repo --env production +github-rest-cli environment create --name my-repo --env staging --org my-org ``` ## Output format -`get-repo` and `list-repo` support: +`repo get` and `repo list` support: - `table` (default) — PrettyTable display - `json` — JSON string suitable for piping or scripting ```shell -github-rest-cli list-repo --format json -github-rest-cli get-repo --name my-repo --format table +github-rest-cli repo list --format json +github-rest-cli repo get --name my-repo --format table ``` ## Contributing diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index a19e153..7f1084b 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -1,269 +1,19 @@ -import argparse -from github_rest_cli.api import ( - get_repository, - create_repository, - delete_repository, - list_repositories, - dependabot_security, - deployment_environment, -) from importlib.metadata import version import logging +from github_rest_cli.parser import build_parser + __version__ = version("github-rest-cli") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") -def build_parser() -> argparse.ArgumentParser: - """ - Create parsers and subparsers for CLI arguments - """ - parser = argparse.ArgumentParser( - description="Python CLI for GitHub REST API", - ) - - parser.add_argument( - "-v", "--version", action="version", version=f"%(prog)s {__version__}" - ) - - subparsers = parser.add_subparsers(help="GitHub REST API commands", dest="command") - - # Subparser for "get-repo" function - get_repo_parser = subparsers.add_parser( - "get-repo", help="Get a repository's details" - ) - get_repo_parser.add_argument( - "-n", - "--name", - help="The repository name", - required=True, - dest="name", - ) - get_repo_parser.add_argument( - "-o", "--org", help="The organization name", required=False, dest="org" - ) - get_repo_parser.add_argument( - "-f", - "--format", - required=False, - default="table", - choices=["table", "json"], - dest="format", - help="Format to display the repository in", - ) - get_repo_parser.set_defaults(func=get_repository) - - # Subparser for "list-repo" function - list_repo_parser = subparsers.add_parser( - "list-repo", - help="List your repositories", - ) - list_repo_parser.add_argument( - "-r", - "--role", - required=False, - dest="role", - help="List repositories by role", - ) - list_repo_parser.add_argument( - "-p", - "--page", - required=False, - default=20, - type=int, - dest="page", - help="The number of results", - ) - list_repo_parser.add_argument( - "-s", - "--sort", - required=False, - default="pushed", - dest="sort", - help="List repositories sorted by", - ) - list_repo_parser.add_argument( - "-f", - "--format", - required=False, - default="table", - choices=["table", "json"], - dest="format", - help="Format to display the list of repositories in", - ) - list_repo_parser.set_defaults(func=list_repositories) - - # Subparser for "create-repository" function - create_repo_parser = subparsers.add_parser( - "create-repo", - help="Create a new repository", - ) - create_repo_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", - ) - create_repo_parser.add_argument( - "-v", - "--visibility", - required=False, - default="public", - dest="visibility", - help="Whether the repository is private", - ) - create_repo_parser.add_argument( - "-o", - "--org", - required=False, - dest="org", - help="The organization name", - ) - create_repo_parser.add_argument( - "-e", - "--empty", - required=False, - action="store_true", - dest="empty", - help="Create an empty repository", - ) - create_repo_parser.set_defaults(func=create_repository) - - # Subparser for "delete-repository" function - delete_repo_parser = subparsers.add_parser( - "delete-repo", - help="Delete an existing repository", - ) - delete_repo_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", - ) - delete_repo_parser.add_argument( - "-o", - "--org", - required=False, - dest="org", - help="The organization name", - ) - delete_repo_parser.add_argument( - "-y", - "--yes", - action="store_true", - dest="yes", - help="Skip the confirmation prompt and delete immediately", - ) - delete_repo_parser.set_defaults(func=delete_repository) - - # Subparser for "dependabot" function - dependabot_parser = subparsers.add_parser( - "dependabot", - help="Manage Dependabot settings", - ) - dependabot_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", - ) - dependabot_parser.add_argument( - "-o", - "--org", - dest="org", - help="The organization name", - ) - control_group = dependabot_parser.add_mutually_exclusive_group(required=True) - control_group.add_argument( - "--enable", - action="store_const", - const=True, - dest="control", - help="Enable dependabot security updates", - ) - control_group.add_argument( - "--disable", - action="store_const", - const=False, - dest="control", - help="Disable dependabot security updates", - ) - dependabot_parser.set_defaults(func=dependabot_security) - - # Subparser for "deployment-environments" function - deploy_env_parser = subparsers.add_parser( - "environment", - help="Manage deployment environments", - ) - deploy_env_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", - ) - deploy_env_parser.add_argument( - "-e", - "--env", - required=True, - dest="env", - help="Deployment environment name", - ) - deploy_env_parser.add_argument( - "-o", - "--org", - required=False, - dest="org", - help="The organization name", - ) - deploy_env_parser.set_defaults(func=deployment_environment) - - return parser - - -def confirm_delete_repository( - name: str, org: str | None = None, *, yes: bool = False -) -> bool: - """Return True if deletion should proceed.""" - if yes: - return True - - target = f"{org}/{name}" if org else name - answer = input(f"Delete repository '{target}'? This cannot be undone. [y/N] ") - return answer.strip().lower() in {"y", "yes"} - - def cli(): parser = build_parser() args = parser.parse_args() - command = args.command if hasattr(args, "func"): - if command == "get-repo": - repo = args.func(args.name, args.org, args.format) - if repo is not None: - print(repo) # noqa: T201 - elif command == "list-repo": - repos = args.func(args.page, args.sort, args.role, args.format) - if repos is not None: - print(repos) # noqa: T201 - elif command == "create-repo": - args.func(args.name, args.visibility, args.org, args.empty) - elif command == "delete-repo": - if not confirm_delete_repository(args.name, args.org, yes=args.yes): - print("Aborted.") # noqa: T201 - return - args.func(args.name, args.org) - elif command == "dependabot": - args.func(args.name, args.control, args.org) - elif command == "environment": - args.func(args.name, args.env, args.org) - else: - return False + args.func(args) else: parser.print_help() diff --git a/src/github_rest_cli/parser.py b/src/github_rest_cli/parser.py new file mode 100644 index 0000000..6bb4151 --- /dev/null +++ b/src/github_rest_cli/parser.py @@ -0,0 +1,271 @@ +import argparse +from argparse import Namespace +from importlib.metadata import version +from github_rest_cli.api import ( + get_repository, + create_repository, + delete_repository, + list_repositories, + dependabot_security, + deployment_environment, +) + + +class _HelpFormatter(argparse.HelpFormatter): + """Widen the option column so flag help stays on one line.""" + + def __init__(self, prog: str) -> None: + super().__init__(prog, max_help_position=40) + + +def _add_repo_name_args( + parser: argparse.ArgumentParser, *, name_required: bool = True +) -> None: + parser.add_argument( + "-n", + "--name", + help="The repository name", + required=name_required, + dest="name", + ) + parser.add_argument( + "-o", "--org", help="The organization name", required=False, dest="org" + ) + + +def _add_format_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-f", + "--format", + metavar="FORMAT", + required=False, + default="table", + choices=["table", "json"], + dest="format", + help="Output format (table or json)", + ) + + +def run_get_repo(args: Namespace) -> None: + repo = get_repository(args.name, args.org, args.format) + if repo is not None: + print(repo) # noqa: T201 + + +def run_list_repo(args: Namespace) -> None: + repos = list_repositories(args.page, args.sort, args.role, args.format) + if repos is not None: + print(repos) # noqa: T201 + + +def run_create_repo(args: Namespace) -> None: + create_repository(args.name, args.visibility, args.org, args.empty) + + +def run_delete_repo(args: Namespace) -> None: + if not confirm_delete_repository(args.name, args.org, yes=args.yes): + print("Aborted.") # noqa: T201 + return + delete_repository(args.name, args.org) + + +def run_dependabot(args: Namespace) -> None: + dependabot_security(args.name, args.control, args.org) + + +def run_environment_create(args: Namespace) -> None: + deployment_environment(args.name, args.env, args.org) + + +def confirm_delete_repository( + name: str, org: str | None = None, *, yes: bool = False +) -> bool: + """Return True if deletion should proceed.""" + if yes: + return True + + target = f"{org}/{name}" if org else name + answer = input(f"Delete repository '{target}'? This cannot be undone. [y/N] ") + return answer.strip().lower() in {"y", "yes"} + + +def _subcommand( + subparsers: argparse._SubParsersAction, + name: str, + *, + help: str, +) -> argparse.ArgumentParser: + return subparsers.add_parser(name, help=help, formatter_class=_HelpFormatter) + + +def build_parser() -> argparse.ArgumentParser: + """ + Create parsers and subparsers for CLI arguments + """ + parser = argparse.ArgumentParser( + description="Python CLI for GitHub REST API", + formatter_class=_HelpFormatter, + ) + + parser.add_argument( + "-v", + "--version", + action="version", + version=f"%(prog)s {version('github-rest-cli')}", + ) + + subparsers = parser.add_subparsers(help="GitHub REST API commands", dest="command") + + # repo {get,list,create,delete} + repo_parser = _subcommand(subparsers, "repo", help="Manage repositories") + repo_subparsers = repo_parser.add_subparsers( + help="Repository commands", dest="repo_command" + ) + repo_subparsers.required = True + + get_repo_parser = _subcommand( + repo_subparsers, "get", help="Get a repository's details" + ) + _add_repo_name_args(get_repo_parser) + _add_format_arg(get_repo_parser) + get_repo_parser.set_defaults(func=run_get_repo) + + list_repo_parser = _subcommand( + repo_subparsers, + "list", + help="List your repositories", + ) + list_repo_parser.add_argument( + "-r", + "--role", + required=False, + dest="role", + help="List repositories by role", + ) + list_repo_parser.add_argument( + "-p", + "--page", + required=False, + default=20, + type=int, + dest="page", + help="The number of results", + ) + list_repo_parser.add_argument( + "-s", + "--sort", + required=False, + default="pushed", + dest="sort", + help="List repositories sorted by", + ) + _add_format_arg(list_repo_parser) + list_repo_parser.set_defaults(func=run_list_repo) + + create_repo_parser = _subcommand( + repo_subparsers, + "create", + help="Create a new repository", + ) + _add_repo_name_args(create_repo_parser) + visibility = create_repo_parser.add_mutually_exclusive_group() + visibility.add_argument( + "--public", + action="store_const", + const="public", + dest="visibility", + help="Make the repository public (default)", + ) + visibility.add_argument( + "--private", + action="store_const", + const="private", + dest="visibility", + help="Make the repository private", + ) + visibility.add_argument( + "--internal", + action="store_const", + const="internal", + dest="visibility", + help="Make the repository internal", + ) + create_repo_parser.add_argument( + "-e", + "--empty", + required=False, + action="store_true", + dest="empty", + help="Create an empty repository", + ) + create_repo_parser.set_defaults(visibility="public", func=run_create_repo) + + delete_repo_parser = _subcommand( + repo_subparsers, + "delete", + help="Delete an existing repository", + ) + _add_repo_name_args(delete_repo_parser) + delete_repo_parser.add_argument( + "-y", + "--yes", + action="store_true", + dest="yes", + help="Skip the confirmation prompt and delete immediately", + ) + delete_repo_parser.set_defaults(func=run_delete_repo) + + # dependabot {enable,disable} + dependabot_parser = _subcommand( + subparsers, + "dependabot", + help="Manage Dependabot settings", + ) + dependabot_subparsers = dependabot_parser.add_subparsers( + help="Dependabot commands", dest="dependabot_command" + ) + dependabot_subparsers.required = True + + dependabot_enable_parser = _subcommand( + dependabot_subparsers, + "enable", + help="Enable Dependabot security updates", + ) + _add_repo_name_args(dependabot_enable_parser) + dependabot_enable_parser.set_defaults(func=run_dependabot, control=True) + + dependabot_disable_parser = _subcommand( + dependabot_subparsers, + "disable", + help="Disable Dependabot security updates", + ) + _add_repo_name_args(dependabot_disable_parser) + dependabot_disable_parser.set_defaults(func=run_dependabot, control=False) + + # environment create + environment_parser = _subcommand( + subparsers, + "environment", + help="Manage deployment environments", + ) + environment_subparsers = environment_parser.add_subparsers( + help="Environment commands", dest="environment_command" + ) + environment_subparsers.required = True + + environment_create_parser = _subcommand( + environment_subparsers, + "create", + help="Create a deployment environment", + ) + _add_repo_name_args(environment_create_parser) + environment_create_parser.add_argument( + "-e", + "--env", + required=True, + dest="env", + help="Deployment environment name", + ) + environment_create_parser.set_defaults(func=run_environment_create) + + return parser diff --git a/tests/test_cli_dependabot.py b/tests/test_cli_dependabot.py index 6959a87..c176f40 100644 --- a/tests/test_cli_dependabot.py +++ b/tests/test_cli_dependabot.py @@ -1,5 +1,5 @@ import pytest -from github_rest_cli.main import build_parser +from github_rest_cli.parser import build_parser from github_rest_cli import api @@ -8,48 +8,37 @@ REQUEST_HANDLER_FUNCTION = "github_rest_cli.api.request_with_handling" -def test_dependabot_enable_flag(): +def test_dependabot_enable_subcommand(): parser = build_parser() - args = parser.parse_args(["dependabot", "--name", "my-repo", "--enable"]) + args = parser.parse_args(["dependabot", "enable", "--name", "my-repo"]) assert args.command == "dependabot" + assert args.dependabot_command == "enable" assert args.name == "my-repo" assert args.control is True assert args.org is None -def test_dependabot_disable_flag(): +def test_dependabot_disable_subcommand(): parser = build_parser() args = parser.parse_args( - ["dependabot", "--name", "my-repo", "--org", "my-org", "--disable"] + ["dependabot", "disable", "--name", "my-repo", "--org", "my-org"] ) assert args.command == "dependabot" + assert args.dependabot_command == "disable" assert args.name == "my-repo" assert args.org == "my-org" assert args.control is False -def test_dependabot_missing_flag_errors(capsys): +def test_dependabot_missing_subcommand_errors(capsys): parser = build_parser() with pytest.raises(SystemExit) as exc_info: parser.parse_args(["dependabot", "--name", "my-repo"]) assert exc_info.value.code == 2 - err = capsys.readouterr().err - assert "--enable" in err or "--disable" in err - - -def test_dependabot_both_flags_error(capsys): - parser = build_parser() - - with pytest.raises(SystemExit) as exc_info: - parser.parse_args(["dependabot", "--name", "my-repo", "--enable", "--disable"]) - - assert exc_info.value.code == 2 - err = capsys.readouterr().err - assert "not allowed with" in err or "mutually exclusive" in err.lower() def test_dependabot_security_enable(mocker): diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index c85d440..4e19eff 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -1,5 +1,6 @@ import pytest -from github_rest_cli.main import build_parser, cli, __version__ +from github_rest_cli.main import cli, __version__ +from github_rest_cli.parser import build_parser def test_version_flag(capsys): @@ -20,9 +21,9 @@ def test_help_flag(capsys): assert exc_info.value.code == 0 out = capsys.readouterr().out - assert "get-repo" in out - assert "list-repo" in out - assert "delete-repo" in out + assert "repo" in out + assert "dependabot" in out + assert "environment" in out def test_no_command_prints_help(mocker, capsys): @@ -34,11 +35,50 @@ def test_no_command_prints_help(mocker, capsys): assert "GitHub REST API" in out or "usage:" in out.lower() -def test_get_repo_subcommand_help(capsys): +def test_repo_help_lists_subcommands(capsys): parser = build_parser() with pytest.raises(SystemExit) as exc_info: - parser.parse_args(["get-repo", "--help"]) + parser.parse_args(["repo", "--help"]) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "get" in out + assert "list" in out + assert "create" in out + assert "delete" in out + + +def test_repo_get_subcommand_help(capsys): + parser = build_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(["repo", "get", "--help"]) assert exc_info.value.code == 0 assert "--name" in capsys.readouterr().out + + +def test_repo_create_defaults_to_public(): + parser = build_parser() + args = parser.parse_args(["repo", "create", "--name", "my-repo"]) + + assert args.visibility == "public" + + +def test_repo_create_private_flag(): + parser = build_parser() + args = parser.parse_args(["repo", "create", "--name", "my-repo", "--private"]) + + assert args.visibility == "private" + + +def test_repo_create_public_and_private_conflict(capsys): + parser = build_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args( + ["repo", "create", "--name", "my-repo", "--public", "--private"] + ) + + assert exc_info.value.code == 2 diff --git a/tests/test_delete_repo.py b/tests/test_delete_repo.py index b4b5972..9d298bf 100644 --- a/tests/test_delete_repo.py +++ b/tests/test_delete_repo.py @@ -1,4 +1,5 @@ -from github_rest_cli.main import build_parser, confirm_delete_repository, cli +from github_rest_cli.main import cli +from github_rest_cli.parser import build_parser, confirm_delete_repository from github_rest_cli import api @@ -9,48 +10,49 @@ def test_delete_repo_yes_flag_parses(): parser = build_parser() - args = parser.parse_args(["delete-repo", "--name", "my-repo", "--yes"]) + args = parser.parse_args(["repo", "delete", "--name", "my-repo", "--yes"]) - assert args.command == "delete-repo" + assert args.command == "repo" + assert args.repo_command == "delete" assert args.name == "my-repo" assert args.yes is True def test_delete_repo_short_yes_flag_parses(): parser = build_parser() - args = parser.parse_args(["delete-repo", "-n", "my-repo", "-y"]) + args = parser.parse_args(["repo", "delete", "-n", "my-repo", "-y"]) assert args.yes is True def test_confirm_delete_skips_prompt_with_yes(mocker): - prompt = mocker.patch("github_rest_cli.main.input") + prompt = mocker.patch("github_rest_cli.parser.input") assert confirm_delete_repository("my-repo", yes=True) is True prompt.assert_not_called() def test_confirm_delete_accepts_yes(mocker): - mocker.patch("github_rest_cli.main.input", return_value="y") + mocker.patch("github_rest_cli.parser.input", return_value="y") assert confirm_delete_repository("my-repo", org="my-org") is True def test_confirm_delete_rejects_other_answers(mocker): - mocker.patch("github_rest_cli.main.input", return_value="n") + mocker.patch("github_rest_cli.parser.input", return_value="n") assert confirm_delete_repository("my-repo") is False def test_cli_delete_repo_aborts_without_confirmation(mocker, capsys): mocker.patch( - "github_rest_cli.main.confirm_delete_repository", + "github_rest_cli.parser.confirm_delete_repository", return_value=False, ) - delete_mock = mocker.patch("github_rest_cli.main.delete_repository") + delete_mock = mocker.patch("github_rest_cli.parser.delete_repository") mocker.patch( "sys.argv", - ["github-rest-cli", "delete-repo", "--name", "my-repo"], + ["github-rest-cli", "repo", "delete", "--name", "my-repo"], ) cli() @@ -60,11 +62,11 @@ def test_cli_delete_repo_aborts_without_confirmation(mocker, capsys): def test_cli_delete_repo_proceeds_with_yes(mocker): - delete_mock = mocker.patch("github_rest_cli.main.delete_repository") - prompt = mocker.patch("github_rest_cli.main.input") + delete_mock = mocker.patch("github_rest_cli.parser.delete_repository") + prompt = mocker.patch("github_rest_cli.parser.input") mocker.patch( "sys.argv", - ["github-rest-cli", "delete-repo", "--name", "my-repo", "--yes"], + ["github-rest-cli", "repo", "delete", "--name", "my-repo", "--yes"], ) cli()