diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index ca198a0..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,45 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- `repo list --all` fetches every page by following GitHub `Link` headers. -- `repo list --page` selects the page number (default `1`). - -### Changed - -- `repo list --page` no longer means page size. Use `--per-page` (default `20`) instead. -- `repo get` table output is a curated key/value detail view instead of the list summary columns. -- Split output formatting into projection helpers and generic renderers (`format_repo_get` / `format_repo_list`). - -### Documentation - -- Clarified `repo get` vs `repo list` table/JSON field behavior in the CLI guide. -- Documented `repo list` pagination flags (`--per-page`, `--page`, `--all`). -- Linked each CLI command to its GitHub REST API docs in the [CLI guide](docs/cli.md), plus a Related APIs section for endpoints not wrapped yet. - -## [2.0.0] - 2026-07-21 - -### Changed - -- Nested CLI groups: `repo`, `dependabot`, and `environment` with subcommands. -- `repo create` visibility via `--public` / `--private` / `--internal` (default public). -- Moved argparse construction and command handlers into `parser.py`; `main.py` is a thin entrypoint. -- Migrated uv development dependencies to PEP 735 `[dependency-groups]`. - -### Documentation - -- Added [CLI guide](docs/cli.md). -- Added [Authentication](docs/authentication.md) guide (PAT scopes and token setup). -- Expanded [configuration](docs/configuration.md) with `settings.toml` / `.secrets.toml` credential examples. -- Documented the release process in [CONTRIBUTING.md](CONTRIBUTING.md). - -## [1.0.3] - 2025-08-22 - -Previous release on PyPI. See Git history and GitHub Releases for earlier notes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d50741d..77c04bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,10 +109,9 @@ Publishing to PyPI is triggered by creating a GitHub Release. The workflow is [` 1. Ensure `main` is green (CI) and docs are up to date ([CLI guide](docs/cli.md), [configuration](docs/configuration.md)). 2. Agree the SemVer bump (breaking CLI changes usually mean a major or an intentional minor while still Beta). 3. Update `version` in [`pyproject.toml`](pyproject.toml). -4. Move `[Unreleased]` notes in [`CHANGELOG.md`](CHANGELOG.md) into a new version section with today’s date. -5. Open a PR for the version bump + changelog, merge to `main`. -6. Create a GitHub Release for the new tag (for example `v2.0.0`) whose target is the merge commit on `main`. -7. Confirm the **Python Publish Release** workflow succeeds on [PyPI](https://pypi.org/project/github-rest-cli/). +4. Open a PR for the version bump, merge to `main`. +5. Create a GitHub Release for the new tag (for example `v2.0.0`) whose target is the merge commit on `main`. Put the release notes in the GitHub Release description (drawn from merged PRs as needed). +6. Confirm the **Python Publish Release** workflow succeeds on [PyPI](https://pypi.org/project/github-rest-cli/). Do not tag a release until the version in `pyproject.toml` matches the tag. diff --git a/README.md b/README.md index 945b555..80546b2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python CI](https://github.com/lbrealdev/github-rest-cli/actions/workflows/python-ci.yml/badge.svg)](https://github.com/lbrealdev/github-rest-cli/actions/workflows/python-ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -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. +A Python CLI for common [GitHub REST API](https://docs.github.com/en/rest) operations—list and inspect repositories, create, update, or delete them, manage Dependabot security settings, and create deployment environments. ## Installation @@ -39,6 +39,10 @@ github-rest-cli --help github-rest-cli repo list github-rest-cli repo get --name my-repo github-rest-cli repo create --name my-new-repo --private +github-rest-cli repo create --name my-app --template owner/template-repo +github-rest-cli repo update --name my-repo --description "Updated" +github-rest-cli repo update --name my-repo --new-name renamed-repo +github-rest-cli repo update --name my-repo --as-template ``` ## Commands diff --git a/docs/cli.md b/docs/cli.md index 4cdbc2d..8da6ded 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ github-rest-cli --version | Group | Purpose | | --- | --- | -| `repo` | Get, list, create, and delete repositories | +| `repo` | Get, list, create, update, and delete repositories | | `dependabot` | Enable or disable Dependabot security updates | | `environment` | Create deployment environments | @@ -42,6 +42,8 @@ github-rest-cli environment --help | `repo list` | `GET /user/repos` | [List repositories for the authenticated user](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-repositories-for-the-authenticated-user) | | `repo create` (user) | `POST /user/repos` | [Create a repository for the authenticated user](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-a-repository-for-the-authenticated-user) | | `repo create` (org) | `POST /orgs/{org}/repos` | [Create an organization repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-an-organization-repository) | +| `repo create` (template) | `POST /repos/{template_owner}/{template_repo}/generate` | [Create a repository using a template](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-a-repository-using-a-template) | +| `repo update` | `PATCH /repos/{owner}/{repo}` | [Update a repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#update-a-repository) | | `repo delete` | `DELETE /repos/{owner}/{repo}` | [Delete a repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#delete-a-repository) | | `dependabot enable` | Dependabot security updates | [Enable Dependabot security updates](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#enable-dependabot-security-updates) | | `dependabot disable` | Dependabot security updates | [Disable Dependabot security updates](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#disable-dependabot-security-updates) | @@ -67,7 +69,7 @@ github-rest-cli repo get --name my-repo --format json | `-o` / `--org` | No | authenticated user | Organization owner | | `-f` / `--format` | No | `table` | Output format: `table` or `json` | -Table mode shows a key/value detail view (`Field` | `Value`) with curated fields: `name`, `full_name`, `owner`, `description`, `visibility`, `default_branch`, `language`, `topics`, `html_url`, `created_at`, `updated_at`, `pushed_at`, `fork`, `archived`, `disabled`. +Table mode shows a key/value detail view (`Field` | `Value`) with curated fields: `name`, `full_name`, `owner`, `description`, `visibility`, `default_branch`, `language`, `topics`, `html_url`, `created_at`, `updated_at`, `pushed_at`, `fork`, `archived`, `disabled`, `is_template`. JSON mode returns the full raw GitHub repository object from the API. @@ -102,10 +104,13 @@ github-rest-cli repo list --role owner --format json Create a repository. Visibility defaults to **public** when none of the visibility flags is passed. +With `--template OWNER/REPO`, the CLI uses the template generate endpoint instead of the normal create APIs. `--template` cannot be combined with `--empty`. Template create supports `--public` / `--private` only (not `--internal`). + **API:** - User: `POST /user/repos` — [Create a repository for the authenticated user](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-a-repository-for-the-authenticated-user) - Organization: `POST /orgs/{org}/repos` — [Create an organization repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-an-organization-repository) +- Template: `POST /repos/{template_owner}/{template_repo}/generate` — [Create a repository using a template](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-a-repository-using-a-template) ```shell github-rest-cli repo create --name my-new-repo @@ -114,6 +119,9 @@ github-rest-cli repo create --name my-new-repo --public github-rest-cli repo create --name my-new-repo --internal github-rest-cli repo create --name my-new-repo --org my-org github-rest-cli repo create --name my-new-repo --empty +github-rest-cli repo create --name my-app --template owner/template-repo +github-rest-cli repo create --name my-app --template owner/template-repo --private --include-all-branches +github-rest-cli repo create --name my-app --template owner/template-repo --org my-org ``` | Flag | Required | Default | Description | @@ -122,10 +130,47 @@ github-rest-cli repo create --name my-new-repo --empty | `-o` / `--org` | No | authenticated user | Create under an organization | | `--public` | No | default when omitted | Public repository | | `--private` | No | — | Private repository | -| `--internal` | No | — | Internal repository (org) | +| `--internal` | No | — | Internal repository (org; not supported with `--template`) | | `-e` / `--empty` | No | off | Create without an initial commit / README | +| `--template` | No | unset | Template repository as `OWNER/REPO` | +| `--include-all-branches` | No | off | Include all branches from the template (requires `--template`) | + +`--public`, `--private`, and `--internal` are mutually exclusive. `--template` and `--empty` cannot be used together. + +### `repo update` -`--public`, `--private`, and `--internal` are mutually exclusive. +Update settings on an existing repository. Pass at least one change option. + +**API:** `PATCH /repos/{owner}/{repo}` — [Update a repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#update-a-repository) + +```shell +github-rest-cli repo update --name my-repo --description "Updated description" +github-rest-cli repo update --name my-repo --new-name renamed-repo +github-rest-cli repo update --name my-repo --private +github-rest-cli repo update --name my-repo --homepage https://example.com --default-branch main +github-rest-cli repo update --name my-repo --org my-org --archived +github-rest-cli repo update --name my-repo --unarchived +github-rest-cli repo update --name my-repo --as-template +github-rest-cli repo update --name my-repo --no-template +``` + +| Flag | Required | Default | Description | +| --- | --- | --- | --- | +| `-n` / `--name` | Yes | — | Current repository name | +| `-o` / `--org` | No | authenticated user | Organization owner | +| `--new-name` | No | unset | Rename the repository | +| `--description` | No | unset | Short description | +| `--homepage` | No | unset | Homepage URL | +| `--public` | No | unset | Make the repository public | +| `--private` | No | unset | Make the repository private | +| `--internal` | No | unset | Make the repository internal | +| `--default-branch` | No | unset | Default branch name | +| `--archived` | No | unset | Archive the repository | +| `--unarchived` | No | unset | Unarchive the repository | +| `--as-template` | No | unset | Mark the repository as a template | +| `--no-template` | No | unset | Unmark the repository as a template | + +`--public`, `--private`, and `--internal` are mutually exclusive. `--archived` and `--unarchived` are mutually exclusive. `--as-template` and `--no-template` are mutually exclusive. ### `repo delete` @@ -207,8 +252,6 @@ These GitHub REST endpoints are not exposed by the CLI today, but are candidates | Capability | GitHub docs | | --- | --- | -| Create from template | [Create a repository using a template](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#create-a-repository-using-a-template) | -| Update repository | [Update a repository](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#update-a-repository) | | List org repositories | [List organization repositories](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-organization-repositories) | | List environments | [List environments](https://docs.github.com/en/rest/deployments/environments?apiVersion=2026-03-10#list-environments) | | Get environment | [Get an environment](https://docs.github.com/en/rest/deployments/environments?apiVersion=2026-03-10#get-an-environment) | diff --git a/src/github_rest_cli/api.py b/src/github_rest_cli/api.py index 89df4a5..2f22a3e 100644 --- a/src/github_rest_cli/api.py +++ b/src/github_rest_cli/api.py @@ -1,10 +1,11 @@ import requests + from github_rest_cli.globals import get_api_url, get_headers -from github_rest_cli.utils import rich_output, format_repo_get, format_repo_list +from github_rest_cli.utils import format_repo_get, format_repo_list, rich_output def request_with_handling( - method, url, success_msg: str = None, error_msg: str = None, **kwargs + method, url, success_msg: str | None = None, error_msg: str | None = None, **kwargs ): try: response = requests.request(method, url, **kwargs) @@ -50,7 +51,7 @@ def fetch_user() -> str: return None -def get_repository(name: str, org: str = None, output_format: str = "table"): +def get_repository(name: str, org: str | None = None, output_format: str = "table"): owner = org if org else fetch_user() headers = get_headers() url = build_url("repos", owner, name) @@ -124,7 +125,47 @@ def list_repositories( return format_repo_list(repos, output_format) -def create_repository(name: str, visibility: str, org: str = None, empty: bool = False): +def _parse_template_ref(template: str) -> tuple[str, str] | None: + """Parse OWNER/REPO template reference. Returns None if invalid.""" + if not template or "/" not in template: + return None + template_owner, template_repo = template.split("/", 1) + if not template_owner or not template_repo or "/" in template_repo: + return None + return template_owner, template_repo + + +def create_repository( + name: str, + visibility: str, + org: str | None = None, + empty: bool = False, + template: str | None = None, + include_all_branches: bool = False, +): + if template and empty: + rich_output( + "Cannot use --template together with --empty.", + format_str="bold red", + ) + return None + + if include_all_branches and not template: + rich_output( + "--include-all-branches requires --template.", + format_str="bold red", + ) + return None + + if template: + return _create_repository_from_template( + name, + visibility, + org=org, + template=template, + include_all_branches=include_all_branches, + ) + payload = { "name": name, "visibility": visibility, @@ -154,7 +195,123 @@ def create_repository(name: str, visibility: str, org: str = None, empty: bool = ) -def delete_repository(name: str, org: str = None): +def _create_repository_from_template( + name: str, + visibility: str, + *, + org: str | None = None, + template: str, + include_all_branches: bool = False, +): + if visibility == "internal": + rich_output( + "Template create does not support --internal; use --public or --private.", + format_str="bold red", + ) + return None + + parsed = _parse_template_ref(template) + if not parsed: + rich_output( + "--template must be in OWNER/REPO format.", + format_str="bold red", + ) + return None + + template_owner, template_repo = parsed + owner = org if org else fetch_user() + if not owner: + return None + + payload = { + "name": name, + "owner": owner, + "private": visibility == "private", + "include_all_branches": include_all_branches, + } + + headers = get_headers() + url = build_url("repos", template_owner, template_repo, "generate") + + return request_with_handling( + "POST", + url, + headers=headers, + json=payload, + success_msg=( + f"Repository successfully created in {owner}/{name} " + f"from template {template_owner}/{template_repo}." + ), + error_msg={ + 401: "Unauthorized access. Please check your token or credentials.", + 404: "Template repository not found or is not marked as a template.", + 422: "Repository name already exists or template generate failed.", + }, + ) + + +def update_repository( + name: str, + org: str | None = None, + *, + new_name: str | None = None, + description: str | None = None, + homepage: str | None = None, + visibility: str | None = None, + default_branch: str | None = None, + archived: bool | None = None, + is_template: bool | None = None, +): + payload = {} + if new_name is not None: + payload["name"] = new_name + if description is not None: + payload["description"] = description + if homepage is not None: + payload["homepage"] = homepage + if visibility is not None: + payload["visibility"] = visibility + if visibility == "private": + payload["private"] = True + elif visibility == "public": + payload["private"] = False + if default_branch is not None: + payload["default_branch"] = default_branch + if archived is not None: + payload["archived"] = archived + if is_template is not None: + payload["is_template"] = is_template + + if not payload: + rich_output( + "No updates specified. Pass at least one option to change.", + format_str="bold red", + ) + return None + + owner = org if org else fetch_user() + if not owner: + return None + + headers = get_headers() + url = build_url("repos", owner, name) + result_name = new_name if new_name is not None else name + + return request_with_handling( + "PATCH", + url, + headers=headers, + json=payload, + success_msg=f"Repository successfully updated in {owner}/{result_name}.", + error_msg={ + 401: "Unauthorized access. Please check your token or credentials.", + 404: "The requested repository does not exist.", + 422: "Invalid repository update request.", + }, + ) + + +def delete_repository(name: str, org: str | None = None): owner = org if org else fetch_user() headers = get_headers() url = build_url("repos", owner, name) @@ -171,7 +328,7 @@ def delete_repository(name: str, org: str = None): ) -def dependabot_security(name: str, enabled: bool, org: str = None): +def dependabot_security(name: str, enabled: bool, org: str | None = None): is_enabled = bool(enabled) owner = org if org else fetch_user() @@ -202,7 +359,7 @@ def dependabot_security(name: str, enabled: bool, org: str = None): ) -def deployment_environment(name: str, env: str, org: str = None): +def deployment_environment(name: str, env: str, org: str | None = None): owner = org if org else fetch_user() headers = get_headers() url = build_url("repos", owner, name, "environments", env) diff --git a/src/github_rest_cli/globals.py b/src/github_rest_cli/globals.py index 30cfba7..c62e6b4 100644 --- a/src/github_rest_cli/globals.py +++ b/src/github_rest_cli/globals.py @@ -1,7 +1,8 @@ -from github_rest_cli.config import settings, AUTH_TOKEN_VALIDATOR, DEFAULT_API_URL -from dynaconf.base import ValidationError import logging +from dynaconf.base import ValidationError + +from github_rest_cli.config import AUTH_TOKEN_VALIDATOR, DEFAULT_API_URL, settings logger = logging.getLogger(__name__) diff --git a/src/github_rest_cli/main.py b/src/github_rest_cli/main.py index 7f1084b..16b7175 100644 --- a/src/github_rest_cli/main.py +++ b/src/github_rest_cli/main.py @@ -1,9 +1,8 @@ -from importlib.metadata import version import logging +from importlib.metadata import version from github_rest_cli.parser import build_parser - __version__ = version("github-rest-cli") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") diff --git a/src/github_rest_cli/parser.py b/src/github_rest_cli/parser.py index c27dde6..c727883 100644 --- a/src/github_rest_cli/parser.py +++ b/src/github_rest_cli/parser.py @@ -1,13 +1,15 @@ 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, + get_repository, + list_repositories, + update_repository, ) @@ -66,7 +68,28 @@ def run_list_repo(args: Namespace) -> None: def run_create_repo(args: Namespace) -> None: - create_repository(args.name, args.visibility, args.org, args.empty) + create_repository( + args.name, + args.visibility, + args.org, + empty=args.empty, + template=args.template, + include_all_branches=args.include_all_branches, + ) + + +def run_update_repo(args: Namespace) -> None: + update_repository( + args.name, + args.org, + new_name=args.new_name, + description=args.description, + homepage=args.homepage, + visibility=args.visibility, + default_branch=args.default_branch, + archived=args.archived, + is_template=args.is_template, + ) def run_delete_repo(args: Namespace) -> None: @@ -123,7 +146,7 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(help="GitHub REST API commands", dest="command") - # repo {get,list,create,delete} + # repo {get,list,create,update,delete} repo_parser = _subcommand(subparsers, "repo", help="Manage repositories") repo_subparsers = repo_parser.add_subparsers( help="Repository commands", dest="repo_command" @@ -219,8 +242,113 @@ def build_parser() -> argparse.ArgumentParser: dest="empty", help="Create an empty repository", ) + create_repo_parser.add_argument( + "--template", + metavar="OWNER/REPO", + required=False, + default=None, + dest="template", + help="Create from a template repository (OWNER/REPO)", + ) + create_repo_parser.add_argument( + "--include-all-branches", + required=False, + action="store_true", + dest="include_all_branches", + help="Include all branches from the template repository", + ) create_repo_parser.set_defaults(visibility="public", func=run_create_repo) + update_repo_parser = _subcommand( + repo_subparsers, + "update", + help="Update an existing repository", + ) + _add_repo_name_args(update_repo_parser) + update_repo_parser.add_argument( + "--new-name", + required=False, + default=None, + dest="new_name", + help="Rename the repository", + ) + update_repo_parser.add_argument( + "--description", + required=False, + default=None, + dest="description", + help="Short description of the repository", + ) + update_repo_parser.add_argument( + "--homepage", + required=False, + default=None, + dest="homepage", + help="Homepage URL for the repository", + ) + update_visibility = update_repo_parser.add_mutually_exclusive_group() + update_visibility.add_argument( + "--public", + action="store_const", + const="public", + dest="visibility", + help="Make the repository public", + ) + update_visibility.add_argument( + "--private", + action="store_const", + const="private", + dest="visibility", + help="Make the repository private", + ) + update_visibility.add_argument( + "--internal", + action="store_const", + const="internal", + dest="visibility", + help="Make the repository internal", + ) + update_repo_parser.add_argument( + "--default-branch", + required=False, + default=None, + dest="default_branch", + help="Default branch name", + ) + update_archived = update_repo_parser.add_mutually_exclusive_group() + update_archived.add_argument( + "--archived", + action="store_const", + const=True, + dest="archived", + help="Archive the repository", + ) + update_archived.add_argument( + "--unarchived", + action="store_const", + const=False, + dest="archived", + help="Unarchive the repository", + ) + update_template = update_repo_parser.add_mutually_exclusive_group() + update_template.add_argument( + "--as-template", + action="store_const", + const=True, + dest="is_template", + help="Mark the repository as a template", + ) + update_template.add_argument( + "--no-template", + action="store_const", + const=False, + dest="is_template", + help="Unmark the repository as a template", + ) + update_repo_parser.set_defaults( + visibility=None, archived=None, is_template=None, func=run_update_repo + ) + delete_repo_parser = _subcommand( repo_subparsers, "delete", diff --git a/src/github_rest_cli/utils.py b/src/github_rest_cli/utils.py index 67acc8f..3b3d19b 100644 --- a/src/github_rest_cli/utils.py +++ b/src/github_rest_cli/utils.py @@ -1,6 +1,7 @@ -from rich import print as rprint import json +from rich import print as rprint + REPO_SUMMARY_COLUMNS = ["name", "owner", "url", "visibility"] REPO_DETAIL_FIELDS = [ @@ -19,6 +20,7 @@ "fork", "archived", "disabled", + "is_template", ] @@ -80,6 +82,7 @@ def project_repo_detail(repo: dict) -> list[tuple[str, str]]: "fork": repo.get("fork"), "archived": repo.get("archived"), "disabled": repo.get("disabled"), + "is_template": repo.get("is_template"), } return [(field, _stringify(values[field])) for field in REPO_DETAIL_FIELDS] diff --git a/tests/test_api.py b/tests/test_api.py index cb0b87b..05db5ae 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,5 @@ from github_rest_cli import api - GET_HEADERS_FUNCTION = "github_rest_cli.api.get_headers" FETCH_USER_FUNCTION = "github_rest_cli.api.fetch_user" REQUEST_HANDLER_FUNCTION = "github_rest_cli.api.request_with_handling" @@ -60,6 +59,7 @@ def test_create_repository_org(mocker): "fork": False, "archived": False, "disabled": False, + "is_template": False, } @@ -98,6 +98,7 @@ def test_get_repository_table_format(mocker): assert "test-user" in table_text assert "default_branch" in table_text assert "main" in table_text + assert "is_template" in table_text assert not table_text.strip().startswith("{") @@ -174,7 +175,7 @@ def test_list_repositories_fetch_all_follows_link_headers(mocker): result = api.list_repositories(20, 5, "pushed", None, "json", fetch_all=True) assert request_mock.call_count == 2 - first_args, first_kwargs = request_mock.call_args_list[0] + _, first_kwargs = request_mock.call_args_list[0] assert first_kwargs["params"]["page"] == 1 assert first_kwargs["params"]["per_page"] == 20 diff --git a/tests/test_api_commands.py b/tests/test_api_commands.py index 3baaa88..df6618b 100644 --- a/tests/test_api_commands.py +++ b/tests/test_api_commands.py @@ -1,6 +1,5 @@ from github_rest_cli import api - GET_HEADERS_FUNCTION = "github_rest_cli.api.get_headers" FETCH_USER_FUNCTION = "github_rest_cli.api.fetch_user" REQUEST_HANDLER_FUNCTION = "github_rest_cli.api.request_with_handling" @@ -72,3 +71,189 @@ def test_deployment_environment_org(mocker): assert request_mock.call_args.args[1].endswith( "/repos/my-org/my-repo/environments/staging" ) + + +def test_update_repository(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.update_repository( + "my-repo", + description="Updated", + homepage="https://example.com", + visibility="private", + default_branch="main", + archived=True, + ) + + request_mock.assert_called_once() + assert request_mock.call_args.args[0] == "PATCH" + assert request_mock.call_args.args[1].endswith("/repos/test-user/my-repo") + assert request_mock.call_args.kwargs["json"] == { + "description": "Updated", + "homepage": "https://example.com", + "visibility": "private", + "private": True, + "default_branch": "main", + "archived": True, + } + + +def test_update_repository_org(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.update_repository("my-repo", org="my-org", visibility="public", archived=False) + + assert request_mock.call_args.args[1].endswith("/repos/my-org/my-repo") + assert request_mock.call_args.kwargs["json"] == { + "visibility": "public", + "private": False, + "archived": False, + } + + +def test_update_repository_requires_changes(mocker): + output = mocker.patch("github_rest_cli.api.rich_output") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION) + + result = api.update_repository("my-repo") + + assert result is None + request_mock.assert_not_called() + output.assert_called_once() + assert "No updates specified" in output.call_args.args[0] + + +def test_update_repository_rename(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.update_repository("old-repo", new_name="new-repo") + + request_mock.assert_called_once() + assert request_mock.call_args.args[0] == "PATCH" + assert request_mock.call_args.args[1].endswith("/repos/test-user/old-repo") + assert request_mock.call_args.kwargs["json"] == {"name": "new-repo"} + assert ( + request_mock.call_args.kwargs["success_msg"] + == "Repository successfully updated in test-user/new-repo." + ) + + +def test_update_repository_as_template(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.update_repository("my-repo", is_template=True) + + assert request_mock.call_args.kwargs["json"] == {"is_template": True} + + +def test_update_repository_no_template(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.update_repository("my-repo", is_template=False) + + assert request_mock.call_args.kwargs["json"] == {"is_template": False} + + +def test_create_repository_from_template(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mocker.patch(FETCH_USER_FUNCTION, return_value="test-user") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.create_repository( + "my-app", + "private", + template="octo/template", + include_all_branches=True, + ) + + request_mock.assert_called_once() + assert request_mock.call_args.args[0] == "POST" + assert request_mock.call_args.args[1].endswith("/repos/octo/template/generate") + assert request_mock.call_args.kwargs["json"] == { + "name": "my-app", + "owner": "test-user", + "private": True, + "include_all_branches": True, + } + + +def test_create_repository_from_template_org(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=None) + + api.create_repository( + "my-app", + "public", + org="my-org", + template="octo/template", + ) + + assert request_mock.call_args.kwargs["json"]["owner"] == "my-org" + assert request_mock.call_args.kwargs["json"]["private"] is False + + +def test_create_repository_template_rejects_empty(mocker): + output = mocker.patch("github_rest_cli.api.rich_output") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION) + + result = api.create_repository( + "my-app", + "public", + empty=True, + template="octo/template", + ) + + assert result is None + request_mock.assert_not_called() + assert "--template" in output.call_args.args[0] + assert "--empty" in output.call_args.args[0] + + +def test_create_repository_template_rejects_internal(mocker): + output = mocker.patch("github_rest_cli.api.rich_output") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION) + + result = api.create_repository( + "my-app", + "internal", + template="octo/template", + ) + + assert result is None + request_mock.assert_not_called() + assert "--internal" in output.call_args.args[0] + + +def test_create_repository_template_invalid_ref(mocker): + output = mocker.patch("github_rest_cli.api.rich_output") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION) + + result = api.create_repository("my-app", "public", template="not-a-ref") + + assert result is None + request_mock.assert_not_called() + assert "OWNER/REPO" in output.call_args.args[0] + + +def test_create_repository_include_all_branches_requires_template(mocker): + output = mocker.patch("github_rest_cli.api.rich_output") + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION) + + result = api.create_repository( + "my-app", + "public", + include_all_branches=True, + ) + + assert result is None + request_mock.assert_not_called() + assert "--include-all-branches" in output.call_args.args[0] diff --git a/tests/test_cli_dependabot.py b/tests/test_cli_dependabot.py index c176f40..3476c51 100644 --- a/tests/test_cli_dependabot.py +++ b/tests/test_cli_dependabot.py @@ -1,7 +1,7 @@ import pytest -from github_rest_cli.parser import build_parser -from github_rest_cli import api +from github_rest_cli import api +from github_rest_cli.parser import build_parser GET_HEADERS_FUNCTION = "github_rest_cli.api.get_headers" FETCH_USER_FUNCTION = "github_rest_cli.api.fetch_user" diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 6d5c145..c864bc2 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 cli, __version__ + +from github_rest_cli.main import __version__, cli from github_rest_cli.parser import build_parser @@ -46,6 +47,7 @@ def test_repo_help_lists_subcommands(capsys): assert "get" in out assert "list" in out assert "create" in out + assert "update" in out assert "delete" in out @@ -64,6 +66,8 @@ def test_repo_create_defaults_to_public(): args = parser.parse_args(["repo", "create", "--name", "my-repo"]) assert args.visibility == "public" + assert args.template is None + assert args.include_all_branches is False def test_repo_create_private_flag(): @@ -73,6 +77,113 @@ def test_repo_create_private_flag(): assert args.visibility == "private" +def test_repo_create_template_flags(): + parser = build_parser() + args = parser.parse_args( + [ + "repo", + "create", + "--name", + "my-app", + "--template", + "owner/template", + "--include-all-branches", + "--private", + ] + ) + + assert args.template == "owner/template" + assert args.include_all_branches is True + assert args.visibility == "private" + + +def test_repo_update_parses_options(): + parser = build_parser() + args = parser.parse_args( + [ + "repo", + "update", + "--name", + "my-repo", + "--new-name", + "renamed-repo", + "--description", + "Updated", + "--homepage", + "https://example.com", + "--private", + "--default-branch", + "main", + "--archived", + ] + ) + + assert args.repo_command == "update" + assert args.new_name == "renamed-repo" + assert args.description == "Updated" + assert args.homepage == "https://example.com" + assert args.visibility == "private" + assert args.default_branch == "main" + assert args.archived is True + + +def test_repo_update_defaults_leave_fields_unset(): + parser = build_parser() + args = parser.parse_args(["repo", "update", "--name", "my-repo"]) + + assert args.visibility is None + assert args.archived is None + assert args.description is None + assert args.new_name is None + assert args.is_template is None + + +def test_repo_update_new_name_flag(): + parser = build_parser() + args = parser.parse_args( + ["repo", "update", "--name", "old-repo", "--new-name", "new-repo"] + ) + + assert args.name == "old-repo" + assert args.new_name == "new-repo" + + +def test_repo_update_as_template_flag(): + parser = build_parser() + args = parser.parse_args(["repo", "update", "--name", "my-repo", "--as-template"]) + + assert args.is_template is True + + +def test_repo_update_no_template_flag(): + parser = build_parser() + args = parser.parse_args(["repo", "update", "--name", "my-repo", "--no-template"]) + + assert args.is_template is False + + +def test_repo_update_archived_conflict(capsys): + parser = build_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args( + ["repo", "update", "--name", "my-repo", "--archived", "--unarchived"] + ) + + assert exc_info.value.code == 2 + + +def test_repo_update_template_conflict(capsys): + parser = build_parser() + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args( + ["repo", "update", "--name", "my-repo", "--as-template", "--no-template"] + ) + + assert exc_info.value.code == 2 + + def test_repo_create_public_and_private_conflict(capsys): parser = build_parser() diff --git a/tests/test_config.py b/tests/test_config.py index e7e4c7f..ff934c4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,9 @@ import inspect +from github_rest_cli import api from github_rest_cli import config as config_module -from github_rest_cli.config import settings, DEFAULT_API_URL +from github_rest_cli.config import DEFAULT_API_URL, settings from github_rest_cli.globals import get_api_url -from github_rest_cli import api def test_settings_files_are_basenames(): diff --git a/tests/test_delete_repo.py b/tests/test_delete_repo.py index 9d298bf..f5e191c 100644 --- a/tests/test_delete_repo.py +++ b/tests/test_delete_repo.py @@ -1,7 +1,6 @@ +from github_rest_cli import api from github_rest_cli.main import cli from github_rest_cli.parser import build_parser, confirm_delete_repository -from github_rest_cli import api - GET_HEADERS_FUNCTION = "github_rest_cli.api.get_headers" FETCH_USER_FUNCTION = "github_rest_cli.api.fetch_user" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2e67b20..cc70e1b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,6 @@ project_repo_summary, ) - SAMPLE_REPO = { "name": "test-repo", "full_name": "test-user/test-repo", @@ -22,6 +21,7 @@ "fork": False, "archived": False, "disabled": False, + "is_template": True, } @@ -54,9 +54,11 @@ def test_project_repo_detail_ordered_fields(): "fork", "archived", "disabled", + "is_template", ] assert dict(pairs)["topics"] == "cli, github" assert dict(pairs)["fork"] == "false" + assert dict(pairs)["is_template"] == "true" def test_project_repo_detail_null_and_missing_fields():