From fc543fa79bff2e687c399c81b6dee9efe8aaf788 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Tue, 23 Jun 2026 11:40:48 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20cloud=20ci=20setup=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcake-Parent: main --- src/fastapi_cloud_cli/commands/ci/__init__.py | 2 + src/fastapi_cloud_cli/commands/setup_ci.py | 370 +++++++++++++----- src/fastapi_cloud_cli/utils/errors.py | 1 + tests/test_cli_ci.py | 255 ++++++++++++ tests/test_cli_setup_ci.py | 194 ++++++++- 5 files changed, 703 insertions(+), 119 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/ci/__init__.py b/src/fastapi_cloud_cli/commands/ci/__init__.py index 6c3c0103..7c7cf8c2 100644 --- a/src/fastapi_cloud_cli/commands/ci/__init__.py +++ b/src/fastapi_cloud_cli/commands/ci/__init__.py @@ -3,11 +3,13 @@ from fastapi_cloud_cli.commands.ci.print_workflow import ( print_workflow as print_workflow_command, ) +from fastapi_cloud_cli.commands.setup_ci import setup_ci ci_app = typer.Typer( no_args_is_help=True, help="Manage CI integration helpers.", ) ci_app.command("print-workflow")(print_workflow_command) +ci_app.command("setup")(setup_ci) __all__ = ["ci_app"] diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 4cfb480c..6f5fe6ba 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -2,15 +2,18 @@ import re import shutil import subprocess -from pathlib import Path -from typing import Annotated +from datetime import datetime, timezone +from pathlib import Path, PurePath +from typing import Annotated, Any import typer +from pydantic import BaseModel from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.apps import resolve_app_id_or_fail from fastapi_cloud_cli.utils.auth import Identity -from fastapi_cloud_cli.utils.cli import get_rich_toolkit +from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit +from fastapi_cloud_cli.utils.execution import JsonOutputOption logger = logging.getLogger(__name__) @@ -18,6 +21,38 @@ DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml") +class CISetupOutput(BaseModel): + app_id: str + repo: str + branch: str + workflow_path: str + created_token: bool + set_github_secrets: bool + wrote_workflow: bool + token_expired_at: str | None = None + + +def _render_ci_setup_output(data: CISetupOutput, toolkit: FastAPIRichToolkit) -> None: + if data.wrote_workflow and data.set_github_secrets: + toolkit.print("Done! Commit and push to start deploying.", emoji="✅") + elif data.wrote_workflow: + toolkit.print( + "Done — workflow file is ready, but GitHub secrets were not set.", + emoji="✅", + ) + elif data.set_github_secrets: + toolkit.print("Done! GitHub Actions secrets are configured.", emoji="✅") + else: + toolkit.print("Done!", emoji="✅") + + if data.token_expired_at: + toolkit.print_line() + toolkit.print( + f"Your deploy token expires on [bold]{data.token_expired_at[:10]}[/bold]. " + "Regenerate it from the dashboard or re-run this command before then.", + ) + + class GitHubSecretError(Exception): """Raised when setting a GitHub Actions secret fails.""" @@ -25,15 +60,6 @@ class GitHubSecretError(Exception): def _get_github_host(origin: str) -> str: - """Extract the GitHub host from a git remote URL. - - Supports both github.com and GitHub Enterprise hosts. - Examples: - git@github.com:owner/repo.git -> github.com - https://github.com/owner/repo.git -> github.com - git@enterprise.github.com:owner/repo.git -> enterprise.github.com - """ - # Match git@HOST:owner/repo or https://HOST/owner/repo match = re.search(r"(?:git@|https://)([^:/]+)", origin) return match.group(1) if match else "github.com" @@ -157,6 +183,46 @@ def _write_workflow_file(branch: str, workflow_path: Path) -> None: workflow_path.write_text(workflow_content) +def _get_workflow_path(file: str | None) -> Path: + if file: + return Path(f".github/workflows/{file}") + + return DEFAULT_WORKFLOW_PATH + + +def _format_workflow_path(workflow_path: PurePath) -> str: + return workflow_path.as_posix() + + +def _resolve_existing_workflow_path( + toolkit: FastAPIRichToolkit, workflow_path: Path +) -> Path | None: + if toolkit.confirm( + f"Workflow file [bold]{_format_workflow_path(workflow_path)}[/bold] already exists. Overwrite?", + default=False, + emoji="🗂️", + ): + toolkit.print_line() + + return workflow_path + + toolkit.print_line() + + if new_name := toolkit.input( + "Enter a new filename (without path) or leave blank to skip writing the workflow file:", + emoji="✏️", + ).strip(): + toolkit.print_line() + + return Path(f".github/workflows/{new_name}") + + toolkit.print_line() + toolkit.print("Skipped writing workflow file.", emoji="⏭️") + toolkit.print_line() + + return None + + def setup_ci( path: Annotated[ Path | None, @@ -185,6 +251,12 @@ def setup_ci( help="Provisions token and sets secrets, skips writing the workflow file", show_default=True, ), + workflow_only: bool = typer.Option( + False, + "--workflow-only", + help="Writes the workflow file without creating a token or setting secrets", + show_default=True, + ), dry_run: bool = typer.Option( False, "--dry-run", @@ -198,25 +270,34 @@ def setup_ci( "-f", help="Custom workflow filename (written to .github/workflows/)", ), -) -> None: + json_output: JsonOutputOption = False, +) -> Any: """Configures a GitHub Actions workflow for deploying the app on push to the specified branch. Examples: fastapi cloud setup-ci # Provisions token, sets secrets, and writes workflow file for the 'main' branch fastapi cloud setup-ci --branch develop # Same as above but for the 'develop' branch fastapi cloud setup-ci --secrets-only # Only provisions token and sets secrets, does not write workflow file + fastapi cloud setup-ci --workflow-only # Only writes the workflow file fastapi cloud setup-ci --dry-run # Prints the steps that would be taken without performing them fastapi cloud setup-ci --file ci.yml # Writes workflow to .github/workflows/ci.yml """ identity = Identity() - with get_rich_toolkit() as toolkit: + with get_rich_toolkit(json_output=json_output) as toolkit: if not identity.is_logged_in(): - toolkit.print( - "No credentials found. Use [blue]`fastapi login`[/] to login.", + toolkit.fail( + "not_logged_in", + "No credentials found.", + hint="Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.", + ) + + if secrets_only and workflow_only: + toolkit.fail( + "invalid_input", + "--secrets-only and --workflow-only cannot be used together.", ) - raise typer.Exit(1) target_app_id = resolve_app_id_or_fail( toolkit, @@ -247,12 +328,35 @@ def setup_ci( ) repo_slug = _repo_slug_from_origin(origin) or origin - github_host = _get_github_host(origin) - has_gh = _check_gh_cli_installed() if not branch: branch = _get_default_branch() + workflow_path = _get_workflow_path(file) + needs_secrets = not workflow_only + needs_workflow = not secrets_only + has_gh = _check_gh_cli_installed() if needs_secrets and not dry_run else True + + if ( + toolkit.mode == "json" + and needs_workflow + and not dry_run + and not file + and workflow_path.exists() + ): + toolkit.fail( + "invalid_input", + f"Workflow file {_format_workflow_path(workflow_path)} already exists.", + hint="Pass --file to choose another workflow file or remove the existing file.", + ) + + if needs_secrets and not dry_run and toolkit.mode == "json" and not has_gh: + toolkit.fail( + "dependency_missing", + "GitHub CLI (`gh`) is required to set GitHub Actions secrets.", + hint="Install gh or use --workflow-only to write only the workflow file.", + ) + if dry_run: toolkit.print( "[yellow]This is a dry run — no changes will be made[/yellow]" @@ -262,109 +366,171 @@ def setup_ci( toolkit.print_title("Configuring CI") toolkit.print_line() - toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})") + toolkit.print( + f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})", + emoji="⚙️", + ) toolkit.print_line() msg_token = "Created deploy token" msg_secrets = ( - "Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]" + "Set GitHub Actions secrets [bold blue]FASTAPI_CLOUD_TOKEN[/] " + "and [bold blue]FASTAPI_CLOUD_APP_ID[/]" ) - workflow_file = file or DEFAULT_WORKFLOW_PATH.name - msg_workflow = ( - f"Wrote [bold].github/workflows/{workflow_file}[/bold] (branch: {branch})" - ) - msg_done = "Done — commit and push to start deploying." + msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})" if dry_run: - toolkit.print(msg_token) - toolkit.print(msg_secrets) - if not secrets_only: - toolkit.print(msg_workflow) - return - - from datetime import datetime, timezone + if needs_secrets: + toolkit.print(msg_token) + toolkit.print(msg_secrets) - # Create unique token name with timestamp to avoid duplicates - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - token_name = f"GitHub Actions — {repo_slug} ({timestamp})" + if needs_workflow: + toolkit.print(msg_workflow) - with ( - APIClient() as client, - toolkit.progress( - title="Generating deploy token...", done_emoji="🔑" - ) as progress, - client.handle_http_errors( - progress, default_message="Error creating deploy token." - ), - ): - token_data = _create_token( - client=client, app_id=target_app_id, token_name=token_name + toolkit.success( + CISetupOutput( + app_id=target_app_id, + repo=repo_slug, + branch=branch, + workflow_path=_format_workflow_path(workflow_path), + created_token=False, + set_github_secrets=False, + wrote_workflow=False, + ), + render_output=lambda _data, _toolkit: None, ) - progress.log(msg_token) - - toolkit.print_line() + return - if has_gh: - with toolkit.progress( - title="Setting repo secrets...", done_emoji="🔒" - ) as progress: - try: - _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) - _set_github_secret("FASTAPI_CLOUD_APP_ID", target_app_id) - except GitHubSecretError: - progress.set_error("Failed to set GitHub secrets via gh CLI.") - raise typer.Exit(1) from None - progress.log(msg_secrets) - else: - secrets_url = f"https://{github_host}/{repo_slug}/settings/secrets/actions" - toolkit.print( - "[yellow]gh CLI not found. Set these secrets manually:[/yellow]", + token_expired_at: str | None = None + created_token = False + set_github_secrets = False + wrote_workflow = False + + if needs_secrets: + should_create_token = ( + True + if toolkit.mode == "json" + else toolkit.confirm( + "Create a FastAPI Cloud deploy token for GitHub Actions?", + default=True, + ) ) - toolkit.print_line() - toolkit.print(f" Repository: [blue]{secrets_url}[/]") - toolkit.print_line() - toolkit.print(f" [bold]FASTAPI_CLOUD_TOKEN[/bold] = {token_data['value']}") - toolkit.print(f" [bold]FASTAPI_CLOUD_APP_ID[/bold] = {target_app_id}") + if toolkit.mode != "json": + toolkit.print_line() - toolkit.print_line() + if should_create_token: + # Create unique token name with timestamp to avoid duplicates + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + token_name = f"GitHub Actions — {repo_slug} ({timestamp})" + + with ( + APIClient() as client, + toolkit.progress( + title="Generating deploy token...", done_emoji="🔑" + ) as progress, + client.handle_http_errors( + progress, default_message="Error creating deploy token." + ), + ): + token_data = _create_token( + client=client, app_id=target_app_id, token_name=token_name + ) + token_expired_at = token_data["expired_at"] + created_token = True + progress.log(msg_token) + + toolkit.print_line() - if not secrets_only: - if file: - workflow_path = Path(f".github/workflows/{file}") + if has_gh: + should_set_secrets = ( + True + if toolkit.mode == "json" + else toolkit.confirm( + "Set GitHub Actions secrets " + "[bold blue]FASTAPI_CLOUD_TOKEN[/] and " + "[bold blue]FASTAPI_CLOUD_APP_ID[/] via gh?", + default=True, + ) + ) + if toolkit.mode != "json": + toolkit.print_line() + else: + should_set_secrets = False + secrets_url = ( + f"https://{_get_github_host(origin)}/{repo_slug}" + "/settings/secrets/actions" + ) + toolkit.print( + "[yellow]gh CLI not found. Set these secrets manually:[/yellow]", + ) + toolkit.print_line() + toolkit.print(f"Repository: [blue]{secrets_url}[/]") + toolkit.print_line() + toolkit.print( + f"[bold blue]FASTAPI_CLOUD_TOKEN[/] = {token_data['value']}" + ) + toolkit.print( + f"[bold blue]FASTAPI_CLOUD_APP_ID[/] = {target_app_id}" + ) + + if should_set_secrets: + with toolkit.progress( + title="Setting repo secrets...", done_emoji="🔒" + ) as progress: + try: + _set_github_secret( + "FASTAPI_CLOUD_TOKEN", token_data["value"] + ) + _set_github_secret("FASTAPI_CLOUD_APP_ID", target_app_id) + + progress.log(msg_secrets) + except GitHubSecretError: + progress.set_error( + "Failed to set GitHub secrets via gh CLI." + ) + toolkit.fail( + "api_error", + "Failed to set GitHub secrets via gh CLI.", + ) + set_github_secrets = True + else: + toolkit.print("Skipped setting GitHub Actions secrets.", emoji="⏭️") else: - workflow_path = DEFAULT_WORKFLOW_PATH + toolkit.print( + "Skipped creating deploy token and GitHub secrets.", emoji="⏭️" + ) - write_workflow = True + toolkit.print_line() + + if needs_workflow: if not file and workflow_path.exists(): - overwrite = toolkit.confirm( - f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?", - default=False, + resolved_workflow_path = _resolve_existing_workflow_path( + toolkit, workflow_path ) - if not overwrite: - new_name = toolkit.input( - "Enter a new filename (without path) or leave blank to skip writing the workflow file:", - ).strip() - if new_name: - workflow_path = Path(f".github/workflows/{new_name}") - else: - toolkit.print("Skipped writing workflow file.") - toolkit.print_line() - write_workflow = False - toolkit.print_line() - if write_workflow: + + if resolved_workflow_path is None: + needs_workflow = False + else: + workflow_path = resolved_workflow_path + + if needs_workflow: msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})" - with toolkit.progress( - title="Writing workflow file...", done_emoji="📄" - ) as progress: - _write_workflow_file(branch, workflow_path) - progress.log(msg_workflow) + _write_workflow_file(branch, workflow_path) + wrote_workflow = True + + toolkit.print(msg_workflow) toolkit.print_line() - toolkit.print(msg_done, emoji="✅") - toolkit.print_line() - # Token expiration date is in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ), extract date portion - toolkit.print( - f"Your deploy token expires on [bold]{token_data['expired_at'][:10]}[/bold]. " - "Regenerate it from the dashboard or re-run this command before then.", + output = CISetupOutput( + app_id=target_app_id, + repo=repo_slug, + branch=branch, + workflow_path=_format_workflow_path(workflow_path), + created_token=created_token, + set_github_secrets=set_github_secrets, + wrote_workflow=wrote_workflow, + token_expired_at=token_expired_at, ) + + toolkit.success(output, render_output=_render_ci_setup_output) diff --git a/src/fastapi_cloud_cli/utils/errors.py b/src/fastapi_cloud_cli/utils/errors.py index 690ce7e3..06260fea 100644 --- a/src/fastapi_cloud_cli/utils/errors.py +++ b/src/fastapi_cloud_cli/utils/errors.py @@ -4,6 +4,7 @@ "already_linked", "api_error", "cancelled", + "dependency_missing", "invalid_token", "invalid_input", "missing_required_input", diff --git a/tests/test_cli_ci.py b/tests/test_cli_ci.py index 519cc5d9..10cff5a4 100644 --- a/tests/test_cli_ci.py +++ b/tests/test_cli_ci.py @@ -1,13 +1,34 @@ import json from unittest.mock import patch +import pytest +import respx +from httpx import Response from typer.testing import CliRunner from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.commands.setup_ci import _get_workflow_content +from tests.conftest import ConfiguredApp +from tests.utils import changing_dir runner = CliRunner() +GITHUB_ORIGIN = "git@github.com:owner/repo.git" + + +def _mock_token_api( + respx_mock: respx.MockRouter, + app_id: str, + *, + token_value: str = "test-token", +) -> None: + respx_mock.post(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 201, + json={"value": token_value, "expired_at": "2027-05-22T10:00:00Z"}, + ) + ) + def test_print_workflow_uses_default_branch() -> None: with patch( @@ -40,3 +61,237 @@ def test_print_workflow_json_outputs_envelope() -> None: } } assert result.stderr == "" + + +@pytest.mark.respx +def test_ci_setup_json_outputs_envelope( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_token_api( + respx_mock, + configured_app.app_id, + token_value="secret-token-value", + ) + + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=True, + ), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke( + app, + [ + "ci", + "setup", + "--app-id", + configured_app.app_id, + "--branch", + "main", + "--file", + "deploy.yml", + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": configured_app.app_id, + "repo": "owner/repo", + "branch": "main", + "workflow_path": ".github/workflows/deploy.yml", + "created_token": True, + "set_github_secrets": True, + "wrote_workflow": True, + "token_expired_at": "2027-05-22T10:00:00Z", + } + } + assert "secret-token-value" not in result.stdout + assert result.stderr == "" + mock_secret.assert_any_call("FASTAPI_CLOUD_TOKEN", "secret-token-value") + mock_secret.assert_any_call("FASTAPI_CLOUD_APP_ID", configured_app.app_id) + assert (configured_app.path / ".github" / "workflows" / "deploy.yml").exists() + + +def test_ci_setup_json_returns_dependency_missing_when_gh_is_missing( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=False, + ), + patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, + ): + result = runner.invoke( + app, + [ + "ci", + "setup", + "--app-id", + configured_app.app_id, + "--branch", + "main", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "dependency_missing", + "message": "GitHub CLI (`gh`) is required to set GitHub Actions secrets.", + "hint": "Install gh or use --workflow-only to write only the workflow file.", + } + } + assert result.stderr == "" + mock_create_token.assert_not_called() + + +def test_ci_setup_workflow_only_skips_token_and_secret_creation( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke( + app, + [ + "ci", + "setup", + "--app-id", + configured_app.app_id, + "--branch", + "main", + "--workflow-only", + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "data": { + "app_id": configured_app.app_id, + "repo": "owner/repo", + "branch": "main", + "workflow_path": ".github/workflows/deploy.yml", + "created_token": False, + "set_github_secrets": False, + "wrote_workflow": True, + "token_expired_at": None, + } + } + assert result.stderr == "" + mock_create_token.assert_not_called() + mock_secret.assert_not_called() + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + assert workflow_file.read_text() == _get_workflow_content("main") + + +def test_ci_setup_json_existing_workflow_fails_without_prompt( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + workflow_file.parent.mkdir(parents=True) + workflow_file.write_text("old content") + + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + ): + result = runner.invoke( + app, + [ + "ci", + "setup", + "--app-id", + configured_app.app_id, + "--branch", + "main", + "--workflow-only", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": "Workflow file .github/workflows/deploy.yml already exists.", + "hint": "Pass --file to choose another workflow file or remove the existing file.", + } + } + assert workflow_file.read_text() == "old content" + + +def test_ci_setup_json_existing_workflow_fails_before_side_effects( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + workflow_file.parent.mkdir(parents=True) + workflow_file.write_text("old content") + + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=True, + ), + patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke( + app, + [ + "ci", + "setup", + "--app-id", + configured_app.app_id, + "--branch", + "main", + "--json", + ], + ) + + assert result.exit_code == 1 + assert json.loads(result.stdout) == { + "error": { + "code": "invalid_input", + "message": "Workflow file .github/workflows/deploy.yml already exists.", + "hint": "Pass --file to choose another workflow file or remove the existing file.", + } + } + assert workflow_file.read_text() == "old content" + mock_create_token.assert_not_called() + mock_secret.assert_not_called() diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 1adb2c00..c8f4befd 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -1,5 +1,6 @@ import subprocess -from pathlib import Path +from pathlib import Path, PureWindowsPath +from typing import cast from unittest.mock import patch import pytest @@ -12,10 +13,13 @@ GitHubSecretError, _check_gh_cli_installed, _check_git_installed, + _format_workflow_path, _get_default_branch, _get_remote_origin, + _resolve_existing_workflow_path, _set_github_secret, ) +from fastapi_cloud_cli.utils.cli import FastAPIRichToolkit, get_rich_toolkit from tests.conftest import ConfiguredApp from tests.utils import Keys, changing_dir @@ -51,6 +55,15 @@ def test_shows_message_if_app_not_configured(logged_in_cli: None) -> None: assert "App ID is required." in result.output +def test_shows_error_when_secrets_only_and_workflow_only_conflict( + logged_in_cli: None, +) -> None: + result = runner.invoke(app, ["setup-ci", "--secrets-only", "--workflow-only"]) + + assert result.exit_code == 1 + assert "--secrets-only and --workflow-only cannot be used together" in result.output + + def test_shows_error_when_git_not_installed( logged_in_cli: None, configured_app: ConfiguredApp ) -> None: @@ -122,6 +135,7 @@ def test_detects_github_origin_and_completes_successfully( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci"]) @@ -154,6 +168,7 @@ def test_app_id_option_works_without_linked_directory( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci", "--app-id", app_id]) @@ -184,6 +199,7 @@ def test_detects_non_main_default_branch( return_value="develop", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci"]) @@ -231,6 +247,33 @@ def test_check_gh_cli_installed_returns_false_when_missing() -> None: assert _check_gh_cli_installed() is False +def test_format_workflow_path_uses_forward_slashes() -> None: + assert ( + _format_workflow_path(PureWindowsPath(".github/workflows/deploy.yml")) + == ".github/workflows/deploy.yml" + ) + + +def test_existing_workflow_prompt_uses_forward_slashes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + labels: list[str] = [] + workflow_path = cast(Path, PureWindowsPath(".github/workflows/deploy.yml")) + + def confirm(label: str, **metadata: object) -> bool: + labels.append(label) + return True + + with get_rich_toolkit(minimal=True) as toolkit: + monkeypatch.setattr(toolkit, "confirm", confirm) + + assert _resolve_existing_workflow_path(toolkit, workflow_path) == workflow_path + + assert labels == [ + "Workflow file [bold].github/workflows/deploy.yml[/bold] already exists. Overwrite?" + ] + + def test_get_remote_origin_returns_url() -> None: with patch( "fastapi_cloud_cli.commands.setup_ci.subprocess.run", @@ -359,11 +402,13 @@ def test_secrets_only_skips_workflow_file( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci", "--secrets-only"]) assert result.exit_code == 0 assert "FASTAPI_CLOUD_TOKEN" in result.output + assert "FASTAPI_CLOUD_APP_ID" in result.output assert "Done" in result.output assert not (configured_app.path / ".github" / "workflows" / "deploy.yml").exists() @@ -391,6 +436,7 @@ def test_branch_flag_overrides_detected_branch( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci", "--branch", "production"]) @@ -426,6 +472,7 @@ def test_creates_token_sets_secrets_and_writes_workflow( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci"]) @@ -436,6 +483,7 @@ def test_creates_token_sets_secrets_and_writes_workflow( assert "deploy.yml" in result.output assert "Done" in result.output assert "2027-02-18" in result.output + assert "test-token-value" not in result.output mock_secret.assert_any_call("FASTAPI_CLOUD_TOKEN", "test-token-value") mock_secret.assert_any_call("FASTAPI_CLOUD_APP_ID", app_id) @@ -447,15 +495,112 @@ def test_creates_token_sets_secrets_and_writes_workflow( assert "branches: [main]" in content +def test_declining_token_creation_skips_token_and_secret_setup( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=True, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_default_branch", + return_value="main", + ), + patch.object(FastAPIRichToolkit, "confirm", return_value=False), + patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "Skipped creating deploy token and GitHub secrets" in result.output + mock_create_token.assert_not_called() + mock_secret.assert_not_called() + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + assert workflow_file.exists() + + +def test_declining_token_creation_for_secrets_only_finishes_without_setup( + logged_in_cli: None, + configured_app: ConfiguredApp, +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=True, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_default_branch", + return_value="main", + ), + patch.object(FastAPIRichToolkit, "confirm", return_value=False), + patch("fastapi_cloud_cli.commands.setup_ci._create_token") as mock_create_token, + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke(app, ["setup-ci", "--secrets-only"]) + + assert result.exit_code == 0 + assert "Skipped creating deploy token and GitHub secrets" in result.output + assert "Done" in result.output + mock_create_token.assert_not_called() + mock_secret.assert_not_called() + + @pytest.mark.respx -def test_shows_manual_instructions_when_gh_not_installed( +def test_declining_github_secret_setup_keeps_created_token( logged_in_cli: None, configured_app: ConfiguredApp, respx_mock: respx.MockRouter, ) -> None: - app_id = configured_app.app_id + _mock_token_api(respx_mock, configured_app.app_id) - _mock_token_api(respx_mock, app_id) + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITHUB_ORIGIN, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=True, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_default_branch", + return_value="main", + ), + patch.object(FastAPIRichToolkit, "confirm", side_effect=[True, False]), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "Created deploy token" in result.output + assert "Skipped setting GitHub Actions secrets" in result.output + assert "2027-02-18" in result.output + mock_secret.assert_not_called() + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + assert workflow_file.exists() + + +@pytest.mark.respx +def test_shows_manual_instructions_when_gh_not_installed( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_token_api(respx_mock, configured_app.app_id) with ( changing_dir(configured_app.path), @@ -471,17 +616,19 @@ def test_shows_manual_instructions_when_gh_not_installed( "fastapi_cloud_cli.commands.setup_ci._get_default_branch", return_value="main", ), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, ): result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 assert "gh CLI not found" in result.output - assert "github.com/owner/repo/settings/secrets/actions" in result.output - assert "FASTAPI_CLOUD_TOKEN" in result.output - assert "test-token" in result.output - assert "FASTAPI_CLOUD_APP_ID" in result.output - assert app_id in result.output + assert "https://github.com/owner/repo/settings/secrets/actions" in result.output + assert "FASTAPI_CLOUD_TOKEN = test-token" in result.output + assert f"FASTAPI_CLOUD_APP_ID = {configured_app.app_id}" in result.output assert "Done" in result.output + assert "2027-02-18" in result.output + mock_secret.assert_not_called() @pytest.mark.respx @@ -512,6 +659,7 @@ def test_handles_gh_command_errors_gracefully( "Failed to set GitHub secret 'FASTAPI_CLOUD_TOKEN'" ), ), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci"]) @@ -542,6 +690,7 @@ def test_file_flag_uses_custom_filename( return_value="main", ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), ): result = runner.invoke(app, ["setup-ci", "--file", "ci.yml"]) @@ -579,7 +728,7 @@ def test_overwrites_existing_workflow_when_confirmed( patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), patch("rich_toolkit.container.getchar") as mock_getchar, ): - mock_getchar.side_effect = [Keys.ENTER] + mock_getchar.side_effect = [Keys.ENTER, Keys.ENTER, Keys.ENTER] result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 @@ -616,7 +765,13 @@ def test_skips_writing_workflow_when_declined( patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), patch("rich_toolkit.container.getchar") as mock_getchar, ): - mock_getchar.side_effect = [Keys.RIGHT_ARROW, Keys.ENTER, Keys.ENTER] + mock_getchar.side_effect = [ + Keys.ENTER, + Keys.ENTER, + Keys.RIGHT_ARROW, + Keys.ENTER, + Keys.ENTER, + ] result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 @@ -654,6 +809,8 @@ def test_renames_workflow_when_declined_and_new_name_given( patch("rich_toolkit.container.getchar") as mock_getchar, ): mock_getchar.side_effect = [ + Keys.ENTER, + Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"ci-deploy.yml", @@ -698,14 +855,12 @@ def test_get_github_host_extracts_from_enterprise_https_url() -> None: @pytest.mark.respx -def test_shows_enterprise_secrets_url_when_gh_not_installed( +def test_shows_enterprise_manual_secrets_url_when_gh_not_installed( logged_in_cli: None, configured_app: ConfiguredApp, respx_mock: respx.MockRouter, ) -> None: - """Verify that GitHub Enterprise URLs are built correctly for manual setup.""" _mock_token_api(respx_mock, configured_app.app_id) - enterprise_origin = "git@github.enterprise.com:owner/repo.git" with ( @@ -722,11 +877,16 @@ def test_shows_enterprise_secrets_url_when_gh_not_installed( "fastapi_cloud_cli.commands.setup_ci._get_default_branch", return_value="main", ), + patch.object(FastAPIRichToolkit, "confirm", return_value=True), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret") as mock_secret, ): result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 - assert "gh CLI not found" in result.output - # Should use enterprise host, not github.com - assert "github.enterprise.com/owner/repo/settings/secrets/actions" in result.output + assert ( + "https://github.enterprise.com/owner/repo/settings/secrets/actions" + in result.output + ) + assert "FASTAPI_CLOUD_TOKEN = test-token" in result.output assert "github.com/owner/repo" not in result.output + mock_secret.assert_not_called()