diff --git a/src/fastapi_cloud_cli/cli.py b/src/fastapi_cloud_cli/cli.py index fdb8929..3fb7080 100644 --- a/src/fastapi_cloud_cli/cli.py +++ b/src/fastapi_cloud_cli/cli.py @@ -6,6 +6,7 @@ from .commands.login import login from .commands.logout import logout from .commands.logs import logs +from .commands.setup_ci import setup_ci from .commands.unlink import unlink from .commands.whoami import whoami from .logging import setup_logging @@ -32,6 +33,7 @@ cloud_app.command()(logout) cloud_app.command()(whoami) cloud_app.command()(unlink) +cloud_app.command()(setup_ci) cloud_app.add_typer(env_app, name="env") diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py new file mode 100644 index 0000000..562adc2 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -0,0 +1,360 @@ +import logging +import re +import shutil +import subprocess +from pathlib import Path +from typing import Annotated + +import typer + +from fastapi_cloud_cli.utils.api import APIClient +from fastapi_cloud_cli.utils.apps import get_app_config +from fastapi_cloud_cli.utils.auth import Identity +from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors + +logger = logging.getLogger(__name__) + +TOKEN_EXPIRES_DAYS = 365 +DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml") + + +class GitHubSecretError(Exception): + """Raised when setting a GitHub Actions secret fails.""" + + pass + + +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" + + +def _repo_slug_from_origin(origin: str) -> str | None: + """Extract 'owner/repo' from a GitHub remote URL.""" + # Handles URLs like: git@github.com:owner/repo.git or https://github.com/owner/repo.git + # Also supports GitHub Enterprise hosts like git@github.enterprise.com:owner/repo.git + # Match the part after the last : or / (which is owner/repo) + match = re.search(r"[:/]([^:/]+/[^/]+?)(?:\.git)?$", origin) + return match.group(1) if match else None + + +def _check_git_installed() -> bool: + """Check if git is installed and available.""" + return shutil.which("git") is not None + + +def _check_gh_cli_installed() -> bool: + """Check if the GitHub CLI (gh) is installed and available.""" + return shutil.which("gh") is not None + + +def _get_remote_origin() -> str: + """Get the remote origin URL of the Git repository.""" + try: + # Try gh first (to respect gh repo set-default) + result = subprocess.run( + ["gh", "repo", "view", "--json", "url", "-q", ".url"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + # CalledProcessError if gh command fails, FileNotFoundError if gh is not installed + except (subprocess.CalledProcessError, FileNotFoundError): + # Fallback to git command + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def _set_github_secret(name: str, value: str) -> None: + """Set a GitHub Actions secret via the gh CLI. + + Raises: + GitHubSecretError: If setting the secret fails. + """ + try: + subprocess.run( + ["gh", "secret", "set", name, "--body", value], + capture_output=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise GitHubSecretError(f"Failed to set GitHub secret '{name}'") from e + + +def _create_token(app_id: str, token_name: str) -> dict[str, str]: + """Create a new deploy token. + + Returns token_data dict with 'value' and 'expired_at' keys. + """ + with APIClient() as client: + response = client.post( + f"/apps/{app_id}/tokens", + json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS}, + ) + response.raise_for_status() + data = response.json() + return {"value": data["value"], "expired_at": data["expired_at"]} + + +def _get_default_branch() -> str: + """Get the default branch of the Git repository.""" + try: + result = subprocess.run( + [ + "gh", + "repo", + "view", + "--json", + "defaultBranchRef", + "-q", + ".defaultBranchRef.name", + ], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "main" + + +def _write_workflow_file(branch: str, workflow_path: Path) -> None: + workflow_content = f"""\ +name: Deploy to FastAPI Cloud +on: + push: + branches: [{branch}] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - run: uv run fastapi deploy + env: + FASTAPI_CLOUD_TOKEN: ${{{{ secrets.FASTAPI_CLOUD_TOKEN }}}} + FASTAPI_CLOUD_APP_ID: ${{{{ secrets.FASTAPI_CLOUD_APP_ID }}}} +""" + workflow_path.parent.mkdir(parents=True, exist_ok=True) + workflow_path.write_text(workflow_content) + + +def setup_ci( + path: Annotated[ + Path | None, + typer.Argument( + help="Path to the folder containing the app (defaults to current directory)" + ), + ] = None, + branch: str | None = typer.Option( + None, + "--branch", + "-b", + help="Branch that triggers deploys (defaults to the repo's default branch)", + ), + secrets_only: bool = typer.Option( + False, + "--secrets-only", + "-s", + help="Provisions token and sets secrets, skips writing the workflow file", + show_default=True, + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + "-d", + help="Prints steps that would be taken without actually performing them", + show_default=True, + ), + file: str | None = typer.Option( + None, + "--file", + "-f", + help="Custom workflow filename (written to .github/workflows/)", + ), +) -> None: + """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 --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: + if not identity.is_logged_in(): + toolkit.print( + "No credentials found. Use [blue]`fastapi login`[/] to login.", + tag="auth", + ) + raise typer.Exit(1) + + app_path = path or Path.cwd() + app_config = get_app_config(app_path) + + if not app_config: + toolkit.print( + "No app linked to this directory. Run [blue]`fastapi deploy`[/] first.", + tag="error", + ) + raise typer.Exit(1) + + if not _check_git_installed(): + toolkit.print( + "git is not installed. Please install git to use this command.", + tag="error", + ) + raise typer.Exit(1) + + try: + origin = _get_remote_origin() + except subprocess.CalledProcessError: + toolkit.print( + "Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set.", + tag="error", + ) + raise typer.Exit(1) from None + + # Check if it's a GitHub host (github.com or GitHub Enterprise) + if "github" not in origin.lower(): + toolkit.print( + "Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.", + tag="error", + ) + raise typer.Exit(1) + + 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() + + if dry_run: + toolkit.print( + "[yellow]This is a dry run — no changes will be made[/yellow]" + ) + toolkit.print_line() + + toolkit.print_title("Configuring CI", tag="FastAPI") + toolkit.print_line() + + toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})") + toolkit.print_line() + + msg_token = "Created deploy token" + msg_secrets = ( + "Set [bold]FASTAPI_CLOUD_TOKEN[/bold] and [bold]FASTAPI_CLOUD_APP_ID[/bold]" + ) + 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." + + 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 + + # 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 ( + toolkit.progress(title="Generating deploy token...") as progress, + handle_http_errors( + progress, default_message="Error creating deploy token." + ), + ): + token_data = _create_token(app_config.app_id, token_name) + progress.log(msg_token) + + toolkit.print_line() + + if has_gh: + with toolkit.progress(title="Setting repo secrets...") as progress: + try: + _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) + _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.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]", + tag="info", + ) + 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] = {app_config.app_id}") + + toolkit.print_line() + + if not secrets_only: + if file: + workflow_path = Path(f".github/workflows/{file}") + else: + workflow_path = DEFAULT_WORKFLOW_PATH + + write_workflow = True + if not file and workflow_path.exists(): + overwrite = toolkit.confirm( + f"Workflow file [bold]{workflow_path}[/bold] already exists. Overwrite?", + tag="workflow", + default=False, + ) + if not overwrite: + new_name = toolkit.input( + "Enter a new filename (without path) or leave blank to skip writing the workflow file:", + tag="workflow", + ).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: + msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})" + with toolkit.progress(title="Writing workflow file...") as progress: + _write_workflow_file(branch, workflow_path) + progress.log(msg_workflow) + + toolkit.print_line() + + toolkit.print(msg_done) + 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.", + ) diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py new file mode 100644 index 0000000..a53febd --- /dev/null +++ b/tests/test_cli_setup_ci.py @@ -0,0 +1,700 @@ +import subprocess +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 ( + GitHubSecretError, + _check_gh_cli_installed, + _check_git_installed, + _get_default_branch, + _get_remote_origin, + _set_github_secret, +) +from tests.conftest import ConfiguredApp +from tests.utils import Keys, changing_dir + +runner = CliRunner() + +GITHUB_ORIGIN = "git@github.com:owner/repo.git" +GITLAB_ORIGIN = "git@gitlab.com:owner/repo.git" + + +def _mock_token_api( + respx_mock: respx.MockRouter, app_id: str, *, token_value: str = "test-token" +) -> None: + """Set up token API mocks for tests that create tokens.""" + respx_mock.post(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 201, + json={"value": token_value, "expired_at": "2027-02-18T00:00:00Z"}, + ) + ) + + +def test_shows_login_message_when_not_logged_in(logged_out_cli: None) -> None: + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "No credentials found" in result.output + + +def test_shows_message_if_app_not_configured(logged_in_cli: None) -> None: + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "No app linked to this directory" in result.output + + +def test_shows_error_when_git_not_installed( + logged_in_cli: None, configured_app: ConfiguredApp +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_git_installed", + return_value=False, + ), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "git is not installed" in result.output + + +def test_exits_with_error_when_no_remote_origin( + logged_in_cli: None, configured_app: ConfiguredApp +) -> None: + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + side_effect=subprocess.CalledProcessError(1, "git"), + ), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "Error retrieving git remote origin URL" in result.output + + +def test_shows_error_when_origin_is_not_github( + 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=GITLAB_ORIGIN, + ), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "Remote origin is not a GitHub repository" in result.output + + +@pytest.mark.respx +def test_detects_github_origin_and_completes_successfully( + 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), + 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("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "owner/repo" in result.output + assert "Done" in result.output + + +@pytest.mark.respx +def test_detects_non_main_default_branch( + 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), + 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="develop", + ), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "(branch: develop)" in result.output + + +def test_get_default_branch_falls_back_to_main() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "git"), + ): + assert _get_default_branch() == "main" + + +def test_get_default_branch_returns_branch_name() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, stdout="develop\n"), + ): + assert _get_default_branch() == "develop" + + +def test_check_git_installed_returns_true() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value="/usr/bin/git" + ): + assert _check_git_installed() is True + + +def test_check_git_installed_returns_false_when_missing() -> None: + with patch("fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value=None): + assert _check_git_installed() is False + + +def test_check_gh_cli_installed_returns_true() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value="/usr/bin/gh" + ): + assert _check_gh_cli_installed() is True + + +def test_check_gh_cli_installed_returns_false_when_missing() -> None: + with patch("fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value=None): + assert _check_gh_cli_installed() is False + + +def test_get_remote_origin_returns_url() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + return_value=subprocess.CompletedProcess( + [], 0, stdout="git@github.com:owner/repo.git\n" + ), + ): + assert _get_remote_origin() == "git@github.com:owner/repo.git" + + +def test_get_remote_origin_falls_back_to_git_when_gh_fails() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + side_effect=[ + subprocess.CalledProcessError(1, "gh"), # gh fails + subprocess.CompletedProcess( + [], 0, stdout="git@github.com:owner/repo.git\n" + ), + ], + ): + assert _get_remote_origin() == "git@github.com:owner/repo.git" + + +def test_set_github_secret_calls_gh_cli() -> None: + with patch("fastapi_cloud_cli.commands.setup_ci.subprocess.run") as mock_run: + _set_github_secret("MY_SECRET", "my-value") + + mock_run.assert_called_once_with( + ["gh", "secret", "set", "MY_SECRET", "--body", "my-value"], + capture_output=True, + check=True, + ) + + +def test_set_github_secret_raises_custom_exception_on_command_error() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "gh"), + ): + with pytest.raises(GitHubSecretError, match="Failed to set GitHub secret"): + _set_github_secret("MY_SECRET", "my-value") + + +def test_set_github_secret_raises_custom_exception_when_gh_not_found() -> None: + with patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + side_effect=FileNotFoundError("gh not found"), + ): + with pytest.raises(GitHubSecretError, match="Failed to set GitHub secret"): + _set_github_secret("MY_SECRET", "my-value") + + +def test_dry_run_shows_planned_steps( + 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", + ), + ): + result = runner.invoke(app, ["setup-ci", "--dry-run"]) + + assert result.exit_code == 0 + assert "dry run" in result.output.lower() + assert "Created deploy token" in result.output + assert "FASTAPI_CLOUD_TOKEN" in result.output + assert "FASTAPI_CLOUD_APP_ID" in result.output + assert "deploy.yml" in result.output + + +def test_dry_run_secrets_only_skips_workflow( + 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", + ), + ): + result = runner.invoke(app, ["setup-ci", "--dry-run", "--secrets-only"]) + + assert result.exit_code == 0 + assert "FASTAPI_CLOUD_TOKEN" in result.output + assert "deploy.yml" not in result.output + + +@pytest.mark.respx +def test_secrets_only_skips_workflow_file( + 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), + 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("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + ): + result = runner.invoke(app, ["setup-ci", "--secrets-only"]) + + assert result.exit_code == 0 + assert "FASTAPI_CLOUD_TOKEN" in result.output + assert "Done" in result.output + assert not (configured_app.path / ".github" / "workflows" / "deploy.yml").exists() + + +@pytest.mark.respx +def test_branch_flag_overrides_detected_branch( + 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), + 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("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + ): + result = runner.invoke(app, ["setup-ci", "--branch", "production"]) + + assert result.exit_code == 0 + assert "(branch: production)" in result.output + + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + content = workflow_file.read_text() + assert "branches: [production]" in content + + +@pytest.mark.respx +def test_creates_token_sets_secrets_and_writes_workflow( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + app_id = configured_app.app_id + _mock_token_api(respx_mock, app_id, token_value="test-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._get_default_branch", + return_value="main", + ), + 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 "FASTAPI_CLOUD_TOKEN" in result.output + assert "FASTAPI_CLOUD_APP_ID" in result.output + assert "deploy.yml" in result.output + assert "Done" in result.output + assert "2027-02-18" 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) + + workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" + assert workflow_file.exists() + content = workflow_file.read_text() + assert "Deploy to FastAPI Cloud" in content + assert "branches: [main]" in content + + +@pytest.mark.respx +def test_shows_manual_instructions_when_gh_not_installed( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + app_id = 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=False, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_default_branch", + return_value="main", + ), + ): + 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 "Done" in result.output + + +@pytest.mark.respx +def test_handles_gh_command_errors_gracefully( + 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), + 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( + "fastapi_cloud_cli.commands.setup_ci._set_github_secret", + side_effect=GitHubSecretError( + "Failed to set GitHub secret 'FASTAPI_CLOUD_TOKEN'" + ), + ), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "Failed to set GitHub secrets" in result.output + + +@pytest.mark.respx +def test_file_flag_uses_custom_filename( + 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), + 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("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + ): + result = runner.invoke(app, ["setup-ci", "--file", "ci.yml"]) + + assert result.exit_code == 0 + assert "ci.yml" in result.output + assert (configured_app.path / ".github" / "workflows" / "ci.yml").exists() + + +@pytest.mark.respx +def test_overwrites_existing_workflow_when_confirmed( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_token_api(respx_mock, configured_app.app_id) + + workflow_dir = configured_app.path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "deploy.yml").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._get_default_branch", + return_value="main", + ), + patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), + patch("rich_toolkit.container.getchar") as mock_getchar, + ): + mock_getchar.side_effect = [Keys.ENTER] + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "already exists" in result.output + assert "Deploy to FastAPI Cloud" in (workflow_dir / "deploy.yml").read_text() + + +@pytest.mark.respx +def test_skips_writing_workflow_when_declined( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_token_api(respx_mock, configured_app.app_id) + + workflow_dir = configured_app.path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "deploy.yml").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._get_default_branch", + return_value="main", + ), + 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] + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "Skipped writing workflow file" in result.output + assert (workflow_dir / "deploy.yml").read_text() == "old content" + + +@pytest.mark.respx +def test_renames_workflow_when_declined_and_new_name_given( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + _mock_token_api(respx_mock, configured_app.app_id) + + workflow_dir = configured_app.path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "deploy.yml").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._get_default_branch", + return_value="main", + ), + 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, + *"ci-deploy.yml", + Keys.ENTER, + ] + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "ci-deploy.yml" in result.output + assert (workflow_dir / "deploy.yml").read_text() == "old content" + assert (workflow_dir / "ci-deploy.yml").exists() + + +def test_get_github_host_extracts_from_ssh_url() -> None: + from fastapi_cloud_cli.commands.setup_ci import _get_github_host + + assert _get_github_host("git@github.com:owner/repo.git") == "github.com" + + +def test_get_github_host_extracts_from_https_url() -> None: + from fastapi_cloud_cli.commands.setup_ci import _get_github_host + + assert _get_github_host("https://github.com/owner/repo.git") == "github.com" + + +def test_get_github_host_extracts_from_enterprise_ssh_url() -> None: + from fastapi_cloud_cli.commands.setup_ci import _get_github_host + + assert ( + _get_github_host("git@github.enterprise.com:owner/repo.git") + == "github.enterprise.com" + ) + + +def test_get_github_host_extracts_from_enterprise_https_url() -> None: + from fastapi_cloud_cli.commands.setup_ci import _get_github_host + + assert ( + _get_github_host("https://github.enterprise.com/owner/repo.git") + == "github.enterprise.com" + ) + + +@pytest.mark.respx +def test_shows_enterprise_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 ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=enterprise_origin, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._check_gh_cli_installed", + return_value=False, + ), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_default_branch", + return_value="main", + ), + ): + 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 "github.com/owner/repo" not in result.output