From 88c77e551264bd0dd6f713604d8b38b9effad6b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:25:41 +0000 Subject: [PATCH 1/5] Refactor CLI to nested command and subcommand groups Replace flat verb-noun commands (get-repo, list-repo, ...) with resource-oriented groups: repo, dependabot, and environment. Dispatch via args.func(args) and update docs/tests accordingly. Co-authored-by: akae --- CONTRIBUTING.md | 2 +- README.md | 60 +++++---- src/github_rest_cli/main.py | 247 ++++++++++++++++------------------- tests/test_cli_dependabot.py | 25 +--- tests/test_cli_smoke.py | 24 +++- tests/test_delete_repo.py | 11 +- 6 files changed, 177 insertions(+), 192 deletions(-) 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..285e15f 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ A Python CLI for common [GitHub REST API](https://docs.github.com/en/rest) opera ## 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`) +- Get repository details (`repo get`) +- List repositories for the authenticated user (`repo list`) +- Create and delete repositories (`repo create`, `repo delete`) +- Enable or disable Dependabot security updates (`dependabot enable|disable`) +- Create deployment environments (`environment create`) - Table or JSON output for repository listings (`--format`) ## Installation @@ -60,20 +60,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 +94,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 --visibility 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 +105,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..4ab343f 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -1,4 +1,5 @@ import argparse +from argparse import Namespace from github_rest_cli.api import ( get_repository, create_repository, @@ -15,6 +16,62 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +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", + required=False, + default="table", + choices=["table", "json"], + dest="format", + help="Output format", + ) + + +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 build_parser() -> argparse.ArgumentParser: """ Create parsers and subparsers for CLI arguments @@ -29,34 +86,22 @@ def build_parser() -> argparse.ArgumentParser: 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" + # repo {get,list,create,delete} + repo_parser = subparsers.add_parser("repo", help="Manage repositories") + repo_subparsers = repo_parser.add_subparsers( + help="Repository commands", dest="repo_command" ) - 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", + repo_subparsers.required = True + + get_repo_parser = repo_subparsers.add_parser( + "get", help="Get a repository's details" ) - get_repo_parser.set_defaults(func=get_repository) + _add_repo_name_args(get_repo_parser) + _add_format_arg(get_repo_parser) + get_repo_parser.set_defaults(func=run_get_repo) - # Subparser for "list-repo" function - list_repo_parser = subparsers.add_parser( - "list-repo", + list_repo_parser = repo_subparsers.add_parser( + "list", help="List your repositories", ) list_repo_parser.add_argument( @@ -83,29 +128,14 @@ def build_parser() -> argparse.ArgumentParser: 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) + _add_format_arg(list_repo_parser) + list_repo_parser.set_defaults(func=run_list_repo) - # Subparser for "create-repository" function - create_repo_parser = subparsers.add_parser( - "create-repo", + create_repo_parser = repo_subparsers.add_parser( + "create", help="Create a new repository", ) - create_repo_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", - ) + _add_repo_name_args(create_repo_parser) create_repo_parser.add_argument( "-v", "--visibility", @@ -114,13 +144,6 @@ def build_parser() -> argparse.ArgumentParser: 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", @@ -129,27 +152,13 @@ def build_parser() -> argparse.ArgumentParser: dest="empty", help="Create an empty repository", ) - create_repo_parser.set_defaults(func=create_repository) + create_repo_parser.set_defaults(func=run_create_repo) - # Subparser for "delete-repository" function - delete_repo_parser = subparsers.add_parser( - "delete-repo", + delete_repo_parser = repo_subparsers.add_parser( + "delete", 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", - ) + _add_repo_name_args(delete_repo_parser) delete_repo_parser.add_argument( "-y", "--yes", @@ -157,70 +166,55 @@ def build_parser() -> argparse.ArgumentParser: dest="yes", help="Skip the confirmation prompt and delete immediately", ) - delete_repo_parser.set_defaults(func=delete_repository) + delete_repo_parser.set_defaults(func=run_delete_repo) - # Subparser for "dependabot" function + # dependabot {enable,disable} 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", + dependabot_subparsers = dependabot_parser.add_subparsers( + help="Dependabot commands", dest="dependabot_command" ) - 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", + dependabot_subparsers.required = True + + dependabot_enable_parser = dependabot_subparsers.add_parser( + "enable", + help="Enable Dependabot security updates", ) - control_group.add_argument( - "--disable", - action="store_const", - const=False, - dest="control", - help="Disable dependabot security updates", + _add_repo_name_args(dependabot_enable_parser) + dependabot_enable_parser.set_defaults(func=run_dependabot, control=True) + + dependabot_disable_parser = dependabot_subparsers.add_parser( + "disable", + help="Disable Dependabot security updates", ) - dependabot_parser.set_defaults(func=dependabot_security) + _add_repo_name_args(dependabot_disable_parser) + dependabot_disable_parser.set_defaults(func=run_dependabot, control=False) - # Subparser for "deployment-environments" function - deploy_env_parser = subparsers.add_parser( + # environment create + environment_parser = subparsers.add_parser( "environment", help="Manage deployment environments", ) - deploy_env_parser.add_argument( - "-n", - "--name", - required=True, - dest="name", - help="The repository name", + environment_subparsers = environment_parser.add_subparsers( + help="Environment commands", dest="environment_command" + ) + environment_subparsers.required = True + + environment_create_parser = environment_subparsers.add_parser( + "create", + help="Create a deployment environment", ) - deploy_env_parser.add_argument( + _add_repo_name_args(environment_create_parser) + environment_create_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) + environment_create_parser.set_defaults(func=run_environment_create) return parser @@ -240,30 +234,9 @@ def confirm_delete_repository( 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/tests/test_cli_dependabot.py b/tests/test_cli_dependabot.py index 6959a87..5e64696 100644 --- a/tests/test_cli_dependabot.py +++ b/tests/test_cli_dependabot.py @@ -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..e345c64 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -20,9 +20,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 +34,25 @@ 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 diff --git a/tests/test_delete_repo.py b/tests/test_delete_repo.py index b4b5972..4041d64 100644 --- a/tests/test_delete_repo.py +++ b/tests/test_delete_repo.py @@ -9,16 +9,17 @@ 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 @@ -50,7 +51,7 @@ def test_cli_delete_repo_aborts_without_confirmation(mocker, capsys): delete_mock = mocker.patch("github_rest_cli.main.delete_repository") mocker.patch( "sys.argv", - ["github-rest-cli", "delete-repo", "--name", "my-repo"], + ["github-rest-cli", "repo", "delete", "--name", "my-repo"], ) cli() @@ -64,7 +65,7 @@ def test_cli_delete_repo_proceeds_with_yes(mocker): prompt = mocker.patch("github_rest_cli.main.input") mocker.patch( "sys.argv", - ["github-rest-cli", "delete-repo", "--name", "my-repo", "--yes"], + ["github-rest-cli", "repo", "delete", "--name", "my-repo", "--yes"], ) cli() From 170ff9c92da50a59c47dda75f3d27655b6d870f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:31:40 +0000 Subject: [PATCH 2/5] style: format main.py with ruff Co-authored-by: akae --- src/github_rest_cli/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index 4ab343f..b5f2361 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -16,7 +16,9 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") -def _add_repo_name_args(parser: argparse.ArgumentParser, *, name_required: bool = True) -> None: +def _add_repo_name_args( + parser: argparse.ArgumentParser, *, name_required: bool = True +) -> None: parser.add_argument( "-n", "--name", From 0f8b8c7fb9322052b775b4dd65e06a25b46bf280 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:37:47 +0000 Subject: [PATCH 3/5] Align argparse option help columns Use a short FORMAT metavar and a wider HelpFormatter so repo list/get flag descriptions stay on one line. Co-authored-by: akae --- src/github_rest_cli/main.py | 53 ++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index b5f2361..0eaa56e 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -16,6 +16,13 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +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: @@ -35,11 +42,12 @@ 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", + help="Output format (table or json)", ) @@ -74,12 +82,22 @@ def run_environment_create(args: Namespace) -> None: deployment_environment(args.name, args.env, args.org) +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( @@ -89,20 +107,21 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(help="GitHub REST API commands", dest="command") # repo {get,list,create,delete} - repo_parser = subparsers.add_parser("repo", help="Manage repositories") + 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 = repo_subparsers.add_parser( - "get", help="Get a repository's details" + 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 = repo_subparsers.add_parser( + list_repo_parser = _subcommand( + repo_subparsers, "list", help="List your repositories", ) @@ -133,7 +152,8 @@ def build_parser() -> argparse.ArgumentParser: _add_format_arg(list_repo_parser) list_repo_parser.set_defaults(func=run_list_repo) - create_repo_parser = repo_subparsers.add_parser( + create_repo_parser = _subcommand( + repo_subparsers, "create", help="Create a new repository", ) @@ -141,10 +161,11 @@ def build_parser() -> argparse.ArgumentParser: create_repo_parser.add_argument( "-v", "--visibility", + metavar="VISIBILITY", required=False, default="public", dest="visibility", - help="Whether the repository is private", + help="Repository visibility (e.g. public or private)", ) create_repo_parser.add_argument( "-e", @@ -156,7 +177,8 @@ def build_parser() -> argparse.ArgumentParser: ) create_repo_parser.set_defaults(func=run_create_repo) - delete_repo_parser = repo_subparsers.add_parser( + delete_repo_parser = _subcommand( + repo_subparsers, "delete", help="Delete an existing repository", ) @@ -171,7 +193,8 @@ def build_parser() -> argparse.ArgumentParser: delete_repo_parser.set_defaults(func=run_delete_repo) # dependabot {enable,disable} - dependabot_parser = subparsers.add_parser( + dependabot_parser = _subcommand( + subparsers, "dependabot", help="Manage Dependabot settings", ) @@ -180,14 +203,16 @@ def build_parser() -> argparse.ArgumentParser: ) dependabot_subparsers.required = True - dependabot_enable_parser = dependabot_subparsers.add_parser( + 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 = dependabot_subparsers.add_parser( + dependabot_disable_parser = _subcommand( + dependabot_subparsers, "disable", help="Disable Dependabot security updates", ) @@ -195,7 +220,8 @@ def build_parser() -> argparse.ArgumentParser: dependabot_disable_parser.set_defaults(func=run_dependabot, control=False) # environment create - environment_parser = subparsers.add_parser( + environment_parser = _subcommand( + subparsers, "environment", help="Manage deployment environments", ) @@ -204,7 +230,8 @@ def build_parser() -> argparse.ArgumentParser: ) environment_subparsers.required = True - environment_create_parser = environment_subparsers.add_parser( + environment_create_parser = _subcommand( + environment_subparsers, "create", help="Create a deployment environment", ) From 6667ac234139b74293ebf365d7ce022ff7453c0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 23:57:32 +0000 Subject: [PATCH 4/5] Extract CLI parser and use visibility-state flags Move argparse builders and command handlers into parser.py so main.py stays a thin entrypoint. Replace repo create --visibility with mutually exclusive --public/--private/--internal flags. Co-authored-by: akae --- README.md | 2 +- src/github_rest_cli/main.py | 256 +------------------------------- src/github_rest_cli/parser.py | 271 ++++++++++++++++++++++++++++++++++ tests/test_cli_dependabot.py | 2 +- tests/test_cli_smoke.py | 28 +++- tests/test_delete_repo.py | 17 ++- 6 files changed, 311 insertions(+), 265 deletions(-) create mode 100644 src/github_rest_cli/parser.py diff --git a/README.md b/README.md index 285e15f..3d0fbfb 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ github-rest-cli repo list --role owner --format json ```shell github-rest-cli repo create --name my-new-repo -github-rest-cli repo create --name my-new-repo --visibility private +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 ``` diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index 0eaa56e..7f1084b 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -1,265 +1,13 @@ -import argparse -from argparse import Namespace -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") -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 _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__}" - ) - - 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) - create_repo_parser.add_argument( - "-v", - "--visibility", - metavar="VISIBILITY", - required=False, - default="public", - dest="visibility", - help="Repository visibility (e.g. public or private)", - ) - 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=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 - - -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() 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 5e64696..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 diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index e345c64..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): @@ -56,3 +57,28 @@ def test_repo_get_subcommand_help(capsys): 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 4041d64..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 @@ -25,30 +26,30 @@ def test_delete_repo_short_yes_flag_parses(): 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", "repo", "delete", "--name", "my-repo"], @@ -61,8 +62,8 @@ 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", "repo", "delete", "--name", "my-repo", "--yes"], From c7c4a85fd279813acfdf732f615ad61bdc323f2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 00:00:45 +0000 Subject: [PATCH 5/5] docs: remove Features section from README The intro paragraph already covers what the CLI does. Co-authored-by: akae --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 3d0fbfb..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 (`repo get`) -- List repositories for the authenticated user (`repo list`) -- Create and delete repositories (`repo create`, `repo delete`) -- Enable or disable Dependabot security updates (`dependabot enable|disable`) -- Create deployment environments (`environment create`) -- Table or JSON output for repository listings (`--format`) - ## Installation With `pip`: