From 4a0fdfb75d471edbbb66fb4594f872d3999620a3 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 10:12:07 -0800 Subject: [PATCH 1/9] Add tests --- src/fastapi_cloud_cli/cli.py | 2 + src/fastapi_cloud_cli/commands/setup_ci.py | 350 +++++++++++++ tests/test_cli_setup_ci.py | 546 +++++++++++++++++++++ 3 files changed, 898 insertions(+) create mode 100644 src/fastapi_cloud_cli/commands/setup_ci.py create mode 100644 tests/test_cli_setup_ci.py 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..79f9763 --- /dev/null +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -0,0 +1,350 @@ +import json +import logging +import re +import subprocess +from pathlib import Path +from typing import Annotated, Optional + +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_NAME_PREFIX = "GitHub Actions" +TOKEN_EXPIRES_DAYS = 365 +DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml") + + +def _get_origin() -> str: + try: + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + logger.error( + "Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set." + ) + raise typer.Exit(1) from None + + +def _repo_slug_from_origin(origin: str) -> Optional[str]: + """Extract 'owner/repo' from a GitHub remote URL.""" + match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", origin) + return match.group(1) if match else None + + +def _check_gh_cli_installed() -> bool: + try: + subprocess.run(["gh", "--version"], capture_output=True, text=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def _token_name(repo_slug: str) -> str: + return f"{TOKEN_NAME_PREFIX} — {repo_slug}" + + +def _find_existing_token(client: object, app_id: str, token_name: str) -> Optional[str]: + """Return the token ID if a token with the given name already exists.""" + response = client.get(f"/apps/{app_id}/tokens") + response.raise_for_status() + for token in response.json()["data"]: + if token["name"] == token_name: + return token["id"] + return None + + +def _create_or_regenerate_token(app_id: str, token_name: str) -> tuple[dict[str, str], bool]: + """Create a new deploy token, or regenerate if one already exists. + + Returns (token_data, regenerated). + """ + with APIClient() as client: + existing_id = _find_existing_token(client, app_id, token_name) + + if existing_id: + response = client.post( + f"/apps/{app_id}/tokens/{existing_id}/regenerate", + json={"expires_in_days": TOKEN_EXPIRES_DAYS}, + ) + else: + 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"]}, + existing_id is not None, + ) + + +def _get_default_branch() -> str: + if not _check_gh_cli_installed(): + return "main" + try: + result = subprocess.run( + ["gh", "repo", "view", "--json", "defaultBranchRef"], + capture_output=True, + text=True, + check=True, + ) + + repo_info = json.loads(result.stdout) + return repo_info["defaultBranchRef"]["name"] + except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError): + return "main" + + +def _set_github_secret(secret_name: str, secret_value: str) -> None: + try: + subprocess.run( + ["gh", "secret", "set", secret_name, "--body", secret_value], + capture_output=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + logger.error(f"Error setting GitHub secret: {e}") + + +def _write_workflow_file( + branch: str, workflow_path: Path = DEFAULT_WORKFLOW_PATH +) -> str: + 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) + return str(workflow_path) + + +def setup_ci( + path: Annotated[ + Optional[Path], + typer.Argument( + help="Path to the folder containing the app (defaults to current directory)" + ), + ] = None, + branch: str = typer.Option( + "main", + "--branch", + "-b", + help="Branch that triggers deploys", + show_default=True, + ), + secrets_only: bool = typer.Option( + False, + "--secrets-only", + "-s", + help="Provisions token and sets secrets, skip 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: Optional[str] = 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) + + origin = _get_origin() + if "github.com" not in origin: + 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 + + default_branch = _get_default_branch() + if branch == "main" and default_branch != "main": + branch = default_branch + + # -- header -- + 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'Detected repo: [bold]{repo_slug}[/bold] (from git remote "origin")', + tag="repo", + ) + toolkit.print_line() + toolkit.print( + f"Deploy branch: [bold]{branch}[/bold]", + tag="info", + ) + toolkit.print_line() + toolkit.print( + f"Detected app: [bold]{app_config.app_id}[/bold]", + tag="app", + ) + toolkit.print_line() + + msg_token = "Created deploy token" + msg_secret_token = f"Set [bold]FASTAPI_CLOUD_TOKEN[/bold] on {repo_slug}" + msg_secret_app = f"Set [bold]FASTAPI_CLOUD_APP_ID[/bold] on {repo_slug}" + 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, tag="cloud") + toolkit.print_line() + toolkit.print(msg_secret_token, tag="secret") + toolkit.print_line() + toolkit.print(msg_secret_app, tag="secret") + if not secrets_only: + toolkit.print_line() + toolkit.print(msg_workflow, tag="workflow") + return + + # -- create deploy token -- + token_name = _token_name(repo_slug) + toolkit.print("Creating deploy token...", tag="cloud") + with ( + toolkit.progress(title="Creating deploy token...") as progress, + handle_http_errors(progress, message="Error creating deploy token."), + ): + token_data, regenerated = _create_or_regenerate_token(app_config.app_id, token_name) + progress.log("Regenerated deploy token" if regenerated else msg_token) + + toolkit.print_line() + + # -- set github secrets -- + has_gh = _check_gh_cli_installed() + if has_gh: + toolkit.print("Setting GitHub secrets...", tag="secret") + with toolkit.progress(title="Setting FASTAPI_CLOUD_TOKEN...") as progress: + _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) + progress.log(msg_secret_token) + + with toolkit.progress(title="Setting FASTAPI_CLOUD_APP_ID...") as progress: + _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id) + progress.log(msg_secret_app) + else: + secrets_url = f"https://github.com/{repo_slug}/settings/secrets/actions" + toolkit.print( + "[yellow]gh CLI not found. Set these secrets manually:[/yellow]", + tag="secret", + ) + 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() + + # -- write workflow file -- + if not secrets_only: + if file: + workflow_path = Path(f".github/workflows/{file}") + else: + workflow_path = DEFAULT_WORKFLOW_PATH + + 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 = typer.prompt( + "Enter a new filename (or press Enter to skip)", + default="", + show_default=False, + ) + if new_name: + workflow_path = Path(f".github/workflows/{new_name}") + else: + toolkit.print("Skipped writing workflow file.") + toolkit.print_line() + workflow_path = None + toolkit.print_line() + else: + toolkit.print("Writing workflow file...", tag="workflow") + + if workflow_path is not None: + 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, tag="cloud") + toolkit.print_line() + 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.", + tag="info", + ) diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py new file mode 100644 index 0000000..23f6142 --- /dev/null +++ b/tests/test_cli_setup_ci.py @@ -0,0 +1,546 @@ +import json +import subprocess +from pathlib import Path +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.config import Settings +from tests.conftest import ConfiguredApp +from tests.utils import Keys, changing_dir + +runner = CliRunner() +settings = Settings.get() + +GITHUB_ORIGIN = "git@github.com:owner/repo.git" +GITLAB_ORIGIN = "git@gitlab.com:owner/repo.git" + + +def _mock_subprocess_run( + *, + origin: str = GITHUB_ORIGIN, + gh_installed: bool = True, + default_branch: str = "main", + gh_view_error: bool = False, + gh_secret_error: bool = False, +): + """Create a side_effect for setup_ci.subprocess.run.""" + + def side_effect(cmd, **kwargs): + if cmd[:3] == ["git", "config", "--get"]: + return subprocess.CompletedProcess(cmd, 0, stdout=f"{origin}\n", stderr="") + if cmd[:2] == ["gh", "--version"]: + if not gh_installed: + raise FileNotFoundError + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["gh", "repo", "view"]: + if gh_view_error: + raise subprocess.CalledProcessError(1, "gh") + stdout = json.dumps({"defaultBranchRef": {"name": default_branch}}) + return subprocess.CompletedProcess(cmd, 0, stdout=stdout, stderr="") + if cmd[:3] == ["gh", "secret", "set"]: + if gh_secret_error: + raise subprocess.CalledProcessError(1, "gh") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise ValueError(f"Unexpected command: {cmd}") # pragma: no cover + + return side_effect + + +def _mock_token_api(respx_mock: respx.MockRouter, app_id: str) -> None: + """Set up token API mocks for tests that create tokens.""" + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response(200, json={"data": []}) + ) + respx_mock.post(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 201, + json={"value": "test-token", "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_error_when_app_not_configured( + logged_in_cli: None, tmp_path: Path +) -> None: + with changing_dir(tmp_path): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + assert "No app linked to this directory" in result.output + + +def test_exits_with_error_when_no_remote_origin( + logged_in_cli: None, configured_app: ConfiguredApp +) -> None: + subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) + + with changing_dir(configured_app.path): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 1 + + +def test_shows_error_when_origin_is_not_github( + logged_in_cli: None, configured_app: ConfiguredApp +) -> None: + subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", GITLAB_ORIGIN], + cwd=configured_app.path, + capture_output=True, + ) + + with ( + changing_dir(configured_app.path), + 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 == 1 + assert "Remote origin is not a GitHub repository" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +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) + + subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", GITHUB_ORIGIN], + cwd=configured_app.path, + capture_output=True, + ) + + with ( + changing_dir(configured_app.path), + 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(base_url=settings.base_api_url) +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) + + subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", GITHUB_ORIGIN], + cwd=configured_app.path, + capture_output=True, + ) + + with ( + changing_dir(configured_app.path), + 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 "develop" in result.output + + +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_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", "--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_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", "--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(base_url=settings.base_api_url) +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 + + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response(200, json={"data": []}) + ) + respx_mock.post(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 201, + json={ + "value": "test-token-value", + "expired_at": "2027-02-18T00:00:00Z", + }, + ) + ) + + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci.subprocess.run", + side_effect=_mock_subprocess_run(), + ) as mock_subprocess, + ): + 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 + + # Verify secrets were set via gh CLI + mock_subprocess.assert_any_call( + ["gh", "secret", "set", "FASTAPI_CLOUD_TOKEN", "--body", "test-token-value"], + capture_output=True, + check=True, + ) + mock_subprocess.assert_any_call( + ["gh", "secret", "set", "FASTAPI_CLOUD_APP_ID", "--body", app_id], + capture_output=True, + check=True, + ) + + +@pytest.mark.respx(base_url=settings.base_api_url) +def test_regenerates_existing_token( + logged_in_cli: None, + configured_app: ConfiguredApp, + respx_mock: respx.MockRouter, +) -> None: + app_id = configured_app.app_id + + respx_mock.get(f"/apps/{app_id}/tokens").mock( + return_value=Response( + 200, + json={ + "data": [ + {"id": "token-123", "name": "GitHub Actions \u2014 owner/repo"} + ] + }, + ) + ) + respx_mock.post(f"/apps/{app_id}/tokens/token-123/regenerate").mock( + return_value=Response( + 200, + json={ + "value": "regenerated-token", + "expired_at": "2027-02-18T00:00:00Z", + }, + ) + ) + + with ( + changing_dir(configured_app.path), + patch( + "fastapi_cloud_cli.commands.setup_ci._get_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 "Regenerated deploy token" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +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.subprocess.run", + side_effect=_mock_subprocess_run(gh_installed=False), + ), + ): + 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(base_url=settings.base_api_url) +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.subprocess.run", + side_effect=_mock_subprocess_run( + gh_view_error=True, gh_secret_error=True + ), + ), + ): + result = runner.invoke(app, ["setup-ci"]) + + assert result.exit_code == 0 + assert "Done" in result.output + + +@pytest.mark.respx(base_url=settings.base_api_url) +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_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(base_url=settings.base_api_url) +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_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(base_url=settings.base_api_url) +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_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, + patch( + "fastapi_cloud_cli.commands.setup_ci.typer.prompt", + return_value="", + ), + ): + mock_getchar.side_effect = [Keys.RIGHT_ARROW, 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(base_url=settings.base_api_url) +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_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, + patch( + "fastapi_cloud_cli.commands.setup_ci.typer.prompt", + return_value="ci-deploy.yml", + ), + ): + mock_getchar.side_effect = [Keys.RIGHT_ARROW, 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() From 93988cab6d7bbe5d4b621e87e21ecb8bf3a21185 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 10:12:17 -0800 Subject: [PATCH 2/9] Add tests --- src/fastapi_cloud_cli/commands/setup_ci.py | 8 ++++++-- tests/test_cli_setup_ci.py | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 79f9763..78a3488 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -63,7 +63,9 @@ def _find_existing_token(client: object, app_id: str, token_name: str) -> Option return None -def _create_or_regenerate_token(app_id: str, token_name: str) -> tuple[dict[str, str], bool]: +def _create_or_regenerate_token( + app_id: str, token_name: str +) -> tuple[dict[str, str], bool]: """Create a new deploy token, or regenerate if one already exists. Returns (token_data, regenerated). @@ -274,7 +276,9 @@ def setup_ci( toolkit.progress(title="Creating deploy token...") as progress, handle_http_errors(progress, message="Error creating deploy token."), ): - token_data, regenerated = _create_or_regenerate_token(app_config.app_id, token_name) + token_data, regenerated = _create_or_regenerate_token( + app_config.app_id, token_name + ) progress.log("Regenerated deploy token" if regenerated else msg_token) toolkit.print_line() diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 23f6142..29b7e18 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -384,9 +384,7 @@ def test_handles_gh_command_errors_gracefully( changing_dir(configured_app.path), patch( "fastapi_cloud_cli.commands.setup_ci.subprocess.run", - side_effect=_mock_subprocess_run( - gh_view_error=True, gh_secret_error=True - ), + side_effect=_mock_subprocess_run(gh_view_error=True, gh_secret_error=True), ), ): result = runner.invoke(app, ["setup-ci"]) From 5ba00becd7d32ef2d8a74bba2c308c53056cfbed Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 10:23:27 -0800 Subject: [PATCH 3/9] Update test --- src/fastapi_cloud_cli/commands/setup_ci.py | 20 ++++++++-------- tests/test_cli_setup_ci.py | 27 +++++++++++----------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 78a3488..e854d1c 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -3,7 +3,7 @@ import re import subprocess from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer @@ -35,7 +35,7 @@ def _get_origin() -> str: raise typer.Exit(1) from None -def _repo_slug_from_origin(origin: str) -> Optional[str]: +def _repo_slug_from_origin(origin: str) -> str | None: """Extract 'owner/repo' from a GitHub remote URL.""" match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", origin) return match.group(1) if match else None @@ -53,13 +53,13 @@ def _token_name(repo_slug: str) -> str: return f"{TOKEN_NAME_PREFIX} — {repo_slug}" -def _find_existing_token(client: object, app_id: str, token_name: str) -> Optional[str]: +def _find_existing_token(client: APIClient, app_id: str, token_name: str) -> str | None: """Return the token ID if a token with the given name already exists.""" response = client.get(f"/apps/{app_id}/tokens") response.raise_for_status() for token in response.json()["data"]: if token["name"] == token_name: - return token["id"] + return str(token["id"]) return None @@ -104,7 +104,7 @@ def _get_default_branch() -> str: ) repo_info = json.loads(result.stdout) - return repo_info["defaultBranchRef"]["name"] + return str(repo_info["defaultBranchRef"]["name"]) except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError): return "main" @@ -145,7 +145,7 @@ def _write_workflow_file( def setup_ci( path: Annotated[ - Optional[Path], + Path | None, typer.Argument( help="Path to the folder containing the app (defaults to current directory)" ), @@ -171,7 +171,7 @@ def setup_ci( help="Prints steps that would be taken without actually performing them", show_default=True, ), - file: Optional[str] = typer.Option( + file: str | None = typer.Option( None, "--file", "-f", @@ -274,7 +274,9 @@ def setup_ci( toolkit.print("Creating deploy token...", tag="cloud") with ( toolkit.progress(title="Creating deploy token...") as progress, - handle_http_errors(progress, message="Error creating deploy token."), + handle_http_errors( + progress, default_message="Error creating deploy token." + ), ): token_data, regenerated = _create_or_regenerate_token( app_config.app_id, token_name @@ -332,7 +334,7 @@ def setup_ci( else: toolkit.print("Skipped writing workflow file.") toolkit.print_line() - workflow_path = None + workflow_path = None # type: ignore[assignment] toolkit.print_line() else: toolkit.print("Writing workflow file...", tag="workflow") diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 29b7e18..eb5f73b 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -1,6 +1,7 @@ import json import subprocess from pathlib import Path +from typing import Any from unittest.mock import patch import pytest @@ -9,12 +10,10 @@ from typer.testing import CliRunner from fastapi_cloud_cli.cli import cloud_app as app -from fastapi_cloud_cli.config import Settings from tests.conftest import ConfiguredApp from tests.utils import Keys, changing_dir runner = CliRunner() -settings = Settings.get() GITHUB_ORIGIN = "git@github.com:owner/repo.git" GITLAB_ORIGIN = "git@gitlab.com:owner/repo.git" @@ -27,10 +26,10 @@ def _mock_subprocess_run( default_branch: str = "main", gh_view_error: bool = False, gh_secret_error: bool = False, -): +) -> Any: """Create a side_effect for setup_ci.subprocess.run.""" - def side_effect(cmd, **kwargs): + def side_effect(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: if cmd[:3] == ["git", "config", "--get"]: return subprocess.CompletedProcess(cmd, 0, stdout=f"{origin}\n", stderr="") if cmd[:2] == ["gh", "--version"]: @@ -120,7 +119,7 @@ def test_shows_error_when_origin_is_not_github( assert "Remote origin is not a GitHub repository" in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_detects_github_origin_and_completes_successfully( logged_in_cli: None, configured_app: ConfiguredApp, @@ -154,7 +153,7 @@ def test_detects_github_origin_and_completes_successfully( assert "Done" in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_detects_non_main_default_branch( logged_in_cli: None, configured_app: ConfiguredApp, @@ -242,7 +241,7 @@ def test_dry_run_secrets_only_skips_workflow( assert "deploy.yml" not in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_creates_token_sets_secrets_and_writes_workflow( logged_in_cli: None, configured_app: ConfiguredApp, @@ -293,7 +292,7 @@ def test_creates_token_sets_secrets_and_writes_workflow( ) -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_regenerates_existing_token( logged_in_cli: None, configured_app: ConfiguredApp, @@ -343,7 +342,7 @@ def test_regenerates_existing_token( assert "Regenerated deploy token" in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_shows_manual_instructions_when_gh_not_installed( logged_in_cli: None, configured_app: ConfiguredApp, @@ -372,7 +371,7 @@ def test_shows_manual_instructions_when_gh_not_installed( assert "Done" in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_handles_gh_command_errors_gracefully( logged_in_cli: None, configured_app: ConfiguredApp, @@ -393,7 +392,7 @@ def test_handles_gh_command_errors_gracefully( assert "Done" in result.output -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_file_flag_uses_custom_filename( logged_in_cli: None, configured_app: ConfiguredApp, @@ -424,7 +423,7 @@ def test_file_flag_uses_custom_filename( assert (configured_app.path / ".github" / "workflows" / "ci.yml").exists() -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_overwrites_existing_workflow_when_confirmed( logged_in_cli: None, configured_app: ConfiguredApp, @@ -461,7 +460,7 @@ def test_overwrites_existing_workflow_when_confirmed( assert "Deploy to FastAPI Cloud" in (workflow_dir / "deploy.yml").read_text() -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_skips_writing_workflow_when_declined( logged_in_cli: None, configured_app: ConfiguredApp, @@ -502,7 +501,7 @@ def test_skips_writing_workflow_when_declined( assert (workflow_dir / "deploy.yml").read_text() == "old content" -@pytest.mark.respx(base_url=settings.base_api_url) +@pytest.mark.respx def test_renames_workflow_when_declined_and_new_name_given( logged_in_cli: None, configured_app: ConfiguredApp, From 086a870d323302137f8ec1c7575993f5bb12fdb6 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 10:24:12 -0800 Subject: [PATCH 4/9] Improve annotation --- tests/test_cli_setup_ci.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index eb5f73b..7a197e4 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -1,5 +1,6 @@ import json import subprocess +from collections.abc import Callable from pathlib import Path from typing import Any from unittest.mock import patch @@ -26,7 +27,7 @@ def _mock_subprocess_run( default_branch: str = "main", gh_view_error: bool = False, gh_secret_error: bool = False, -) -> Any: +) -> Callable[..., subprocess.CompletedProcess[str]]: """Create a side_effect for setup_ci.subprocess.run.""" def side_effect(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: From 297f5efc7e1077f7a3f79cbd79eff65f0f060cfd Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 12:08:22 -0800 Subject: [PATCH 5/9] Clean up tags --- src/fastapi_cloud_cli/commands/setup_ci.py | 52 +++++++--------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index e854d1c..70d051c 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -230,28 +230,15 @@ def setup_ci( toolkit.print_line() toolkit.print_title("Configuring CI", tag="FastAPI") - toolkit.print_line() - toolkit.print( - f'Detected repo: [bold]{repo_slug}[/bold] (from git remote "origin")', - tag="repo", - ) - toolkit.print_line() - toolkit.print( - f"Deploy branch: [bold]{branch}[/bold]", - tag="info", - ) - toolkit.print_line() - toolkit.print( - f"Detected app: [bold]{app_config.app_id}[/bold]", - tag="app", - ) + toolkit.print(f"Setting up CI for [bold]{repo_slug}[/bold] (branch: {branch})") toolkit.print_line() msg_token = "Created deploy token" - msg_secret_token = f"Set [bold]FASTAPI_CLOUD_TOKEN[/bold] on {repo_slug}" - msg_secret_app = f"Set [bold]FASTAPI_CLOUD_APP_ID[/bold] on {repo_slug}" + 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})" @@ -259,21 +246,18 @@ def setup_ci( msg_done = "Done — commit and push to start deploying." if dry_run: - toolkit.print(msg_token, tag="cloud") - toolkit.print_line() - toolkit.print(msg_secret_token, tag="secret") - toolkit.print_line() - toolkit.print(msg_secret_app, tag="secret") + toolkit.print(msg_token) + toolkit.print(msg_secrets) if not secrets_only: - toolkit.print_line() - toolkit.print(msg_workflow, tag="workflow") + toolkit.print(msg_workflow) return # -- create deploy token -- token_name = _token_name(repo_slug) - toolkit.print("Creating deploy token...", tag="cloud") + toolkit.print("Generating deploy token...") + toolkit.print_line() with ( - toolkit.progress(title="Creating deploy token...") as progress, + toolkit.progress(title="Generating deploy token...") as progress, handle_http_errors( progress, default_message="Error creating deploy token." ), @@ -288,14 +272,12 @@ def setup_ci( # -- set github secrets -- has_gh = _check_gh_cli_installed() if has_gh: - toolkit.print("Setting GitHub secrets...", tag="secret") - with toolkit.progress(title="Setting FASTAPI_CLOUD_TOKEN...") as progress: + toolkit.print(f"Setting repo secrets on [bold]{repo_slug}[/bold]") + toolkit.print_line() + with toolkit.progress(title="Setting repo secrets...") as progress: _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) - progress.log(msg_secret_token) - - with toolkit.progress(title="Setting FASTAPI_CLOUD_APP_ID...") as progress: _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id) - progress.log(msg_secret_app) + progress.log(msg_secrets) else: secrets_url = f"https://github.com/{repo_slug}/settings/secrets/actions" toolkit.print( @@ -336,9 +318,6 @@ def setup_ci( toolkit.print_line() workflow_path = None # type: ignore[assignment] toolkit.print_line() - else: - toolkit.print("Writing workflow file...", tag="workflow") - if workflow_path is not None: msg_workflow = f"Wrote [bold]{workflow_path}[/bold] (branch: {branch})" with toolkit.progress(title="Writing workflow file...") as progress: @@ -347,10 +326,9 @@ def setup_ci( toolkit.print_line() - toolkit.print(msg_done, tag="cloud") + toolkit.print(msg_done) toolkit.print_line() 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.", - tag="info", ) From 85a6d99b33893e26a2544692cb4b2e11abc19cbb Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 13:09:50 -0800 Subject: [PATCH 6/9] Cleanup --- src/fastapi_cloud_cli/commands/setup_ci.py | 134 ++++---- tests/test_cli_setup_ci.py | 336 +++++++++++++-------- 2 files changed, 276 insertions(+), 194 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 70d051c..1ebe092 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -1,4 +1,3 @@ -import json import logging import re import subprocess @@ -14,34 +13,19 @@ logger = logging.getLogger(__name__) -TOKEN_NAME_PREFIX = "GitHub Actions" TOKEN_EXPIRES_DAYS = 365 DEFAULT_WORKFLOW_PATH = Path(".github/workflows/deploy.yml") -def _get_origin() -> str: - try: - result = subprocess.run( - ["git", "config", "--get", "remote.origin.url"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - except subprocess.CalledProcessError: - logger.error( - "Error retrieving git remote origin URL. Make sure you're in a git repository with a remote origin set." - ) - raise typer.Exit(1) from None - - 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 match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", origin) return match.group(1) if match else None def _check_gh_cli_installed() -> bool: + """Check if the GitHub CLI (gh) is installed and available.""" try: subprocess.run(["gh", "--version"], capture_output=True, text=True, check=True) return True @@ -49,18 +33,24 @@ def _check_gh_cli_installed() -> bool: return False -def _token_name(repo_slug: str) -> str: - return f"{TOKEN_NAME_PREFIX} — {repo_slug}" +def _get_remote_origin() -> str: + """Get the remote origin URL of the Git repository.""" + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() -def _find_existing_token(client: APIClient, app_id: str, token_name: str) -> str | None: - """Return the token ID if a token with the given name already exists.""" - response = client.get(f"/apps/{app_id}/tokens") - response.raise_for_status() - for token in response.json()["data"]: - if token["name"] == token_name: - return str(token["id"]) - return None +def _set_github_secret(name: str, value: str) -> None: + """Set a GitHub Actions secret via the gh CLI.""" + subprocess.run( + ["gh", "secret", "set", name, "--body", value], + capture_output=True, + check=True, + ) def _create_or_regenerate_token( @@ -71,7 +61,14 @@ def _create_or_regenerate_token( Returns (token_data, regenerated). """ with APIClient() as client: - existing_id = _find_existing_token(client, app_id, token_name) + existing_id = None + + response = client.get(f"/apps/{app_id}/tokens") + response.raise_for_status() + for token in response.json()["data"]: + if token["name"] == token_name: + existing_id = token["id"] + break if existing_id: response = client.post( @@ -93,36 +90,20 @@ def _create_or_regenerate_token( def _get_default_branch() -> str: - if not _check_gh_cli_installed(): - return "main" + """Get the default branch of the Git repository.""" try: result = subprocess.run( - ["gh", "repo", "view", "--json", "defaultBranchRef"], + ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], capture_output=True, text=True, check=True, ) - - repo_info = json.loads(result.stdout) - return str(repo_info["defaultBranchRef"]["name"]) - except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError): + return result.stdout.strip().split("/")[-1] + except subprocess.CalledProcessError: return "main" -def _set_github_secret(secret_name: str, secret_value: str) -> None: - try: - subprocess.run( - ["gh", "secret", "set", secret_name, "--body", secret_value], - capture_output=True, - check=True, - ) - except (subprocess.CalledProcessError, FileNotFoundError) as e: - logger.error(f"Error setting GitHub secret: {e}") - - -def _write_workflow_file( - branch: str, workflow_path: Path = DEFAULT_WORKFLOW_PATH -) -> str: +def _write_workflow_file(branch: str, workflow_path: Path) -> None: workflow_content = f"""name: Deploy to FastAPI Cloud on: push: @@ -140,7 +121,6 @@ def _write_workflow_file( """ workflow_path.parent.mkdir(parents=True, exist_ok=True) workflow_path.write_text(workflow_content) - return str(workflow_path) def setup_ci( @@ -150,12 +130,11 @@ def setup_ci( help="Path to the folder containing the app (defaults to current directory)" ), ] = None, - branch: str = typer.Option( - "main", + branch: str | None = typer.Option( + None, "--branch", "-b", - help="Branch that triggers deploys", - show_default=True, + help="Branch that triggers deploys (defaults to the repo's default branch)", ), secrets_only: bool = typer.Option( False, @@ -208,7 +187,15 @@ def setup_ci( ) raise typer.Exit(1) - origin = _get_origin() + 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 + if "github.com" not in origin: toolkit.print( "Remote origin is not a GitHub repository. Please set up a GitHub repo and add it as the remote origin.", @@ -217,12 +204,11 @@ def setup_ci( raise typer.Exit(1) repo_slug = _repo_slug_from_origin(origin) or origin + has_gh = _check_gh_cli_installed() - default_branch = _get_default_branch() - if branch == "main" and default_branch != "main": - branch = default_branch + if not branch: + branch = _get_default_branch() - # -- header -- if dry_run: toolkit.print( "[yellow]This is a dry run — no changes will be made[/yellow]" @@ -252,8 +238,7 @@ def setup_ci( toolkit.print(msg_workflow) return - # -- create deploy token -- - token_name = _token_name(repo_slug) + token_name = f"GitHub Actions — {repo_slug}" toolkit.print("Generating deploy token...") toolkit.print_line() with ( @@ -269,14 +254,16 @@ def setup_ci( toolkit.print_line() - # -- set github secrets -- - has_gh = _check_gh_cli_installed() if has_gh: toolkit.print(f"Setting repo secrets on [bold]{repo_slug}[/bold]") toolkit.print_line() with toolkit.progress(title="Setting repo secrets...") as progress: - _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) - _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id) + try: + _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) + _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id) + except (subprocess.CalledProcessError, FileNotFoundError): + 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.com/{repo_slug}/settings/secrets/actions" @@ -292,13 +279,13 @@ def setup_ci( toolkit.print_line() - # -- write workflow file -- 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?", @@ -306,19 +293,18 @@ def setup_ci( default=False, ) if not overwrite: - new_name = typer.prompt( - "Enter a new filename (or press Enter to skip)", - default="", - show_default=False, - ) + 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() - workflow_path = None # type: ignore[assignment] + write_workflow = False toolkit.print_line() - if workflow_path is not None: + 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) diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 7a197e4..2db45ac 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -1,8 +1,5 @@ -import json import subprocess -from collections.abc import Callable from pathlib import Path -from typing import Any from unittest.mock import patch import pytest @@ -20,38 +17,9 @@ GITLAB_ORIGIN = "git@gitlab.com:owner/repo.git" -def _mock_subprocess_run( - *, - origin: str = GITHUB_ORIGIN, - gh_installed: bool = True, - default_branch: str = "main", - gh_view_error: bool = False, - gh_secret_error: bool = False, -) -> Callable[..., subprocess.CompletedProcess[str]]: - """Create a side_effect for setup_ci.subprocess.run.""" - - def side_effect(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: - if cmd[:3] == ["git", "config", "--get"]: - return subprocess.CompletedProcess(cmd, 0, stdout=f"{origin}\n", stderr="") - if cmd[:2] == ["gh", "--version"]: - if not gh_installed: - raise FileNotFoundError - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:3] == ["gh", "repo", "view"]: - if gh_view_error: - raise subprocess.CalledProcessError(1, "gh") - stdout = json.dumps({"defaultBranchRef": {"name": default_branch}}) - return subprocess.CompletedProcess(cmd, 0, stdout=stdout, stderr="") - if cmd[:3] == ["gh", "secret", "set"]: - if gh_secret_error: - raise subprocess.CalledProcessError(1, "gh") - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - raise ValueError(f"Unexpected command: {cmd}") # pragma: no cover - - return side_effect - - -def _mock_token_api(respx_mock: respx.MockRouter, app_id: str) -> None: +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.get(f"/apps/{app_id}/tokens").mock( return_value=Response(200, json={"data": []}) @@ -59,7 +27,7 @@ def _mock_token_api(respx_mock: respx.MockRouter, app_id: str) -> None: respx_mock.post(f"/apps/{app_id}/tokens").mock( return_value=Response( 201, - json={"value": "test-token", "expired_at": "2027-02-18T00:00:00Z"}, + json={"value": token_value, "expired_at": "2027-02-18T00:00:00Z"}, ) ) @@ -84,35 +52,28 @@ def test_shows_error_when_app_not_configured( def test_exits_with_error_when_no_remote_origin( logged_in_cli: None, configured_app: ConfiguredApp ) -> None: - subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) - - with changing_dir(configured_app.path): + 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: - subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) - subprocess.run( - ["git", "remote", "add", "origin", GITLAB_ORIGIN], - cwd=configured_app.path, - capture_output=True, - ) - with ( changing_dir(configured_app.path), 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", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", + return_value=GITLAB_ORIGIN, ), - patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), ): result = runner.invoke(app, ["setup-ci"]) @@ -128,15 +89,12 @@ def test_detects_github_origin_and_completes_successfully( ) -> None: _mock_token_api(respx_mock, configured_app.app_id) - subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) - subprocess.run( - ["git", "remote", "add", "origin", GITHUB_ORIGIN], - cwd=configured_app.path, - capture_output=True, - ) - 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, @@ -162,15 +120,12 @@ def test_detects_non_main_default_branch( ) -> None: _mock_token_api(respx_mock, configured_app.app_id) - subprocess.run(["git", "init"], cwd=configured_app.path, capture_output=True) - subprocess.run( - ["git", "remote", "add", "origin", GITHUB_ORIGIN], - cwd=configured_app.path, - capture_output=True, - ) - 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, @@ -184,7 +139,71 @@ def test_detects_non_main_default_branch( result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 - assert "develop" in result.output + 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"), + ): + from fastapi_cloud_cli.commands.setup_ci import _get_default_branch + + 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="refs/remotes/origin/develop\n" + ), + ): + from fastapi_cloud_cli.commands.setup_ci import _get_default_branch + + assert _get_default_branch() == "develop" + + +def test_check_gh_cli_installed_returns_true() -> None: + with patch("fastapi_cloud_cli.commands.setup_ci.subprocess.run"): + from fastapi_cloud_cli.commands.setup_ci import _check_gh_cli_installed + + 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.subprocess.run", + side_effect=FileNotFoundError, + ): + from fastapi_cloud_cli.commands.setup_ci import _check_gh_cli_installed + + 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" + ), + ): + from fastapi_cloud_cli.commands.setup_ci import _get_remote_origin + + 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: + from fastapi_cloud_cli.commands.setup_ci import _set_github_secret + + _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_dry_run_shows_planned_steps( @@ -193,7 +212,7 @@ def test_dry_run_shows_planned_steps( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -204,7 +223,6 @@ def test_dry_run_shows_planned_steps( "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", "--dry-run"]) @@ -222,7 +240,7 @@ def test_dry_run_secrets_only_skips_workflow( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -233,7 +251,6 @@ def test_dry_run_secrets_only_skips_workflow( "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", "--dry-run", "--secrets-only"]) @@ -242,6 +259,72 @@ def test_dry_run_secrets_only_skips_workflow( 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, @@ -249,26 +332,23 @@ def test_creates_token_sets_secrets_and_writes_workflow( respx_mock: respx.MockRouter, ) -> None: app_id = configured_app.app_id - - respx_mock.get(f"/apps/{app_id}/tokens").mock( - return_value=Response(200, json={"data": []}) - ) - respx_mock.post(f"/apps/{app_id}/tokens").mock( - return_value=Response( - 201, - json={ - "value": "test-token-value", - "expired_at": "2027-02-18T00:00:00Z", - }, - ) - ) + _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.subprocess.run", - side_effect=_mock_subprocess_run(), - ) as mock_subprocess, + "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"]) @@ -280,17 +360,16 @@ def test_creates_token_sets_secrets_and_writes_workflow( assert "Done" in result.output assert "2027-02-18" in result.output - # Verify secrets were set via gh CLI - mock_subprocess.assert_any_call( - ["gh", "secret", "set", "FASTAPI_CLOUD_TOKEN", "--body", "test-token-value"], - capture_output=True, - check=True, - ) - mock_subprocess.assert_any_call( - ["gh", "secret", "set", "FASTAPI_CLOUD_APP_ID", "--body", app_id], - capture_output=True, - check=True, - ) + # Verify secrets were set + mock_secret.assert_any_call("FASTAPI_CLOUD_TOKEN", "test-token-value") + mock_secret.assert_any_call("FASTAPI_CLOUD_APP_ID", app_id) + + # Verify workflow file was written with correct content + 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 @@ -324,7 +403,7 @@ def test_regenerates_existing_token( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -356,8 +435,16 @@ def test_shows_manual_instructions_when_gh_not_installed( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci.subprocess.run", - side_effect=_mock_subprocess_run(gh_installed=False), + "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"]) @@ -383,14 +470,26 @@ def test_handles_gh_command_errors_gracefully( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci.subprocess.run", - side_effect=_mock_subprocess_run(gh_view_error=True, gh_secret_error=True), + "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=subprocess.CalledProcessError(1, "gh"), ), ): result = runner.invoke(app, ["setup-ci"]) - assert result.exit_code == 0 - assert "Done" in result.output + assert result.exit_code == 1 + assert "Failed to set GitHub secrets" in result.output @pytest.mark.respx @@ -404,7 +503,7 @@ def test_file_flag_uses_custom_filename( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -439,7 +538,7 @@ def test_overwrites_existing_workflow_when_confirmed( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -476,7 +575,7 @@ def test_skips_writing_workflow_when_declined( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -489,12 +588,8 @@ 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, - patch( - "fastapi_cloud_cli.commands.setup_ci.typer.prompt", - return_value="", - ), ): - mock_getchar.side_effect = [Keys.RIGHT_ARROW, Keys.ENTER] + mock_getchar.side_effect = [Keys.RIGHT_ARROW, Keys.ENTER, Keys.ENTER] result = runner.invoke(app, ["setup-ci"]) assert result.exit_code == 0 @@ -517,7 +612,7 @@ def test_renames_workflow_when_declined_and_new_name_given( with ( changing_dir(configured_app.path), patch( - "fastapi_cloud_cli.commands.setup_ci._get_origin", + "fastapi_cloud_cli.commands.setup_ci._get_remote_origin", return_value=GITHUB_ORIGIN, ), patch( @@ -530,12 +625,13 @@ def test_renames_workflow_when_declined_and_new_name_given( ), patch("fastapi_cloud_cli.commands.setup_ci._set_github_secret"), patch("rich_toolkit.container.getchar") as mock_getchar, - patch( - "fastapi_cloud_cli.commands.setup_ci.typer.prompt", - return_value="ci-deploy.yml", - ), ): - mock_getchar.side_effect = [Keys.RIGHT_ARROW, Keys.ENTER] + 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 From beca456d8f2194b719f5ec8379ea68f8fe4a1850 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 13:18:07 -0800 Subject: [PATCH 7/9] Simplify test --- tests/test_cli_setup_ci.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 2db45ac..d8edc73 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -1,5 +1,4 @@ import subprocess -from pathlib import Path from unittest.mock import patch import pytest @@ -39,11 +38,8 @@ def test_shows_login_message_when_not_logged_in(logged_out_cli: None) -> None: assert "No credentials found" in result.output -def test_shows_error_when_app_not_configured( - logged_in_cli: None, tmp_path: Path -) -> None: - with changing_dir(tmp_path): - result = runner.invoke(app, ["setup-ci"]) +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 @@ -360,11 +356,9 @@ def test_creates_token_sets_secrets_and_writes_workflow( assert "Done" in result.output assert "2027-02-18" in result.output - # Verify secrets were set mock_secret.assert_any_call("FASTAPI_CLOUD_TOKEN", "test-token-value") mock_secret.assert_any_call("FASTAPI_CLOUD_APP_ID", app_id) - # Verify workflow file was written with correct content workflow_file = configured_app.path / ".github" / "workflows" / "deploy.yml" assert workflow_file.exists() content = workflow_file.read_text() From edeb1b29811379ec7574339d8a3995578227f82e Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 18 Feb 2026 13:21:56 -0800 Subject: [PATCH 8/9] Clean up dry run --- src/fastapi_cloud_cli/commands/setup_ci.py | 14 ++++++++------ tests/test_cli_setup_ci.py | 2 ++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index 1ebe092..aaade04 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -140,7 +140,7 @@ def setup_ci( False, "--secrets-only", "-s", - help="Provisions token and sets secrets, skip writing the workflow file", + help="Provisions token and sets secrets, skips writing the workflow file", show_default=True, ), dry_run: bool = typer.Option( @@ -236,11 +236,14 @@ def setup_ci( toolkit.print(msg_secrets) if not secrets_only: toolkit.print(msg_workflow) + toolkit.print_line() + toolkit.print( + "[dim]Note: Dry run shows planned actions. " + "Actual run may show 'Regenerated' if token already exists.[/dim]" + ) return token_name = f"GitHub Actions — {repo_slug}" - toolkit.print("Generating deploy token...") - toolkit.print_line() with ( toolkit.progress(title="Generating deploy token...") as progress, handle_http_errors( @@ -255,8 +258,6 @@ def setup_ci( toolkit.print_line() if has_gh: - toolkit.print(f"Setting repo secrets on [bold]{repo_slug}[/bold]") - toolkit.print_line() with toolkit.progress(title="Setting repo secrets...") as progress: try: _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) @@ -269,7 +270,7 @@ def setup_ci( secrets_url = f"https://github.com/{repo_slug}/settings/secrets/actions" toolkit.print( "[yellow]gh CLI not found. Set these secrets manually:[/yellow]", - tag="secret", + tag="info", ) toolkit.print_line() toolkit.print(f" Repository: [blue]{secrets_url}[/]") @@ -314,6 +315,7 @@ def setup_ci( 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 index d8edc73..3a4b7cf 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -228,6 +228,7 @@ def test_dry_run_shows_planned_steps( assert "FASTAPI_CLOUD_TOKEN" in result.output assert "FASTAPI_CLOUD_APP_ID" in result.output assert "deploy.yml" in result.output + assert "Regenerated" in result.output # Disclaimer about regeneration def test_dry_run_secrets_only_skips_workflow( @@ -253,6 +254,7 @@ def test_dry_run_secrets_only_skips_workflow( assert result.exit_code == 0 assert "FASTAPI_CLOUD_TOKEN" in result.output assert "deploy.yml" not in result.output + assert "Regenerated" in result.output # Disclaimer about regeneration @pytest.mark.respx From 647230ddc13fac17dea10073153f4a82b9d6d449 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 19 Feb 2026 10:32:13 -0800 Subject: [PATCH 9/9] Address PR comments --- src/fastapi_cloud_cli/commands/setup_ci.py | 168 +++++++++------- tests/test_cli_setup_ci.py | 212 ++++++++++++++------- 2 files changed, 241 insertions(+), 139 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/setup_ci.py b/src/fastapi_cloud_cli/commands/setup_ci.py index aaade04..562adc2 100644 --- a/src/fastapi_cloud_cli/commands/setup_ci.py +++ b/src/fastapi_cloud_cli/commands/setup_ci.py @@ -1,5 +1,6 @@ import logging import re +import shutil import subprocess from pathlib import Path from typing import Annotated @@ -17,94 +18,124 @@ 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 - match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", origin) + # 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.""" - try: - subprocess.run(["gh", "--version"], capture_output=True, text=True, check=True) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False + return shutil.which("gh") is not None def _get_remote_origin() -> str: """Get the remote origin URL of the Git repository.""" - result = subprocess.run( - ["git", "config", "--get", "remote.origin.url"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() + 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.""" - subprocess.run( - ["gh", "secret", "set", name, "--body", value], - capture_output=True, - check=True, - ) + """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_or_regenerate_token( - app_id: str, token_name: str -) -> tuple[dict[str, str], bool]: - """Create a new deploy token, or regenerate if one already exists. +def _create_token(app_id: str, token_name: str) -> dict[str, str]: + """Create a new deploy token. - Returns (token_data, regenerated). + Returns token_data dict with 'value' and 'expired_at' keys. """ with APIClient() as client: - existing_id = None - - response = client.get(f"/apps/{app_id}/tokens") - response.raise_for_status() - for token in response.json()["data"]: - if token["name"] == token_name: - existing_id = token["id"] - break - - if existing_id: - response = client.post( - f"/apps/{app_id}/tokens/{existing_id}/regenerate", - json={"expires_in_days": TOKEN_EXPIRES_DAYS}, - ) - else: - response = client.post( - f"/apps/{app_id}/tokens", - json={"name": token_name, "expires_in_days": TOKEN_EXPIRES_DAYS}, - ) - + 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"]}, - existing_id is not None, - ) + 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( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], + [ + "gh", + "repo", + "view", + "--json", + "defaultBranchRef", + "-q", + ".defaultBranchRef.name", + ], capture_output=True, text=True, check=True, ) - return result.stdout.strip().split("/")[-1] - except subprocess.CalledProcessError: + 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 + workflow_content = f"""\ +name: Deploy to FastAPI Cloud on: push: branches: [{branch}] @@ -187,6 +218,13 @@ def setup_ci( ) 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: @@ -196,7 +234,8 @@ def setup_ci( ) raise typer.Exit(1) from None - if "github.com" not in origin: + # 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", @@ -204,6 +243,7 @@ def setup_ci( 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: @@ -236,24 +276,22 @@ def setup_ci( toolkit.print(msg_secrets) if not secrets_only: toolkit.print(msg_workflow) - toolkit.print_line() - toolkit.print( - "[dim]Note: Dry run shows planned actions. " - "Actual run may show 'Regenerated' if token already exists.[/dim]" - ) return - token_name = f"GitHub Actions — {repo_slug}" + 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, regenerated = _create_or_regenerate_token( - app_config.app_id, token_name - ) - progress.log("Regenerated deploy token" if regenerated else msg_token) + token_data = _create_token(app_config.app_id, token_name) + progress.log(msg_token) toolkit.print_line() @@ -262,12 +300,12 @@ def setup_ci( try: _set_github_secret("FASTAPI_CLOUD_TOKEN", token_data["value"]) _set_github_secret("FASTAPI_CLOUD_APP_ID", app_config.app_id) - except (subprocess.CalledProcessError, FileNotFoundError): + 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.com/{repo_slug}/settings/secrets/actions" + 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", diff --git a/tests/test_cli_setup_ci.py b/tests/test_cli_setup_ci.py index 3a4b7cf..a53febd 100644 --- a/tests/test_cli_setup_ci.py +++ b/tests/test_cli_setup_ci.py @@ -7,6 +7,14 @@ 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 @@ -20,9 +28,6 @@ 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.get(f"/apps/{app_id}/tokens").mock( - return_value=Response(200, json={"data": []}) - ) respx_mock.post(f"/apps/{app_id}/tokens").mock( return_value=Response( 201, @@ -45,6 +50,22 @@ def test_shows_message_if_app_not_configured(logged_in_cli: None) -> None: 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: @@ -143,37 +164,38 @@ def test_get_default_branch_falls_back_to_main() -> None: "fastapi_cloud_cli.commands.setup_ci.subprocess.run", side_effect=subprocess.CalledProcessError(1, "git"), ): - from fastapi_cloud_cli.commands.setup_ci import _get_default_branch - 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="refs/remotes/origin/develop\n" - ), + return_value=subprocess.CompletedProcess([], 0, stdout="develop\n"), ): - from fastapi_cloud_cli.commands.setup_ci import _get_default_branch - assert _get_default_branch() == "develop" -def test_check_gh_cli_installed_returns_true() -> None: - with patch("fastapi_cloud_cli.commands.setup_ci.subprocess.run"): - from fastapi_cloud_cli.commands.setup_ci import _check_gh_cli_installed +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 - assert _check_gh_cli_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_false_when_missing() -> None: + +def test_check_gh_cli_installed_returns_true() -> None: with patch( - "fastapi_cloud_cli.commands.setup_ci.subprocess.run", - side_effect=FileNotFoundError, + "fastapi_cloud_cli.commands.setup_ci.shutil.which", return_value="/usr/bin/gh" ): - from fastapi_cloud_cli.commands.setup_ci import _check_gh_cli_installed + 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 @@ -184,15 +206,24 @@ def test_get_remote_origin_returns_url() -> None: [], 0, stdout="git@github.com:owner/repo.git\n" ), ): - from fastapi_cloud_cli.commands.setup_ci import _get_remote_origin + 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: - from fastapi_cloud_cli.commands.setup_ci import _set_github_secret - _set_github_secret("MY_SECRET", "my-value") mock_run.assert_called_once_with( @@ -202,6 +233,24 @@ def test_set_github_secret_calls_gh_cli() -> None: ) +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: @@ -228,7 +277,6 @@ def test_dry_run_shows_planned_steps( assert "FASTAPI_CLOUD_TOKEN" in result.output assert "FASTAPI_CLOUD_APP_ID" in result.output assert "deploy.yml" in result.output - assert "Regenerated" in result.output # Disclaimer about regeneration def test_dry_run_secrets_only_skips_workflow( @@ -254,7 +302,6 @@ def test_dry_run_secrets_only_skips_workflow( assert result.exit_code == 0 assert "FASTAPI_CLOUD_TOKEN" in result.output assert "deploy.yml" not in result.output - assert "Regenerated" in result.output # Disclaimer about regeneration @pytest.mark.respx @@ -368,56 +415,6 @@ def test_creates_token_sets_secrets_and_writes_workflow( assert "branches: [main]" in content -@pytest.mark.respx -def test_regenerates_existing_token( - logged_in_cli: None, - configured_app: ConfiguredApp, - respx_mock: respx.MockRouter, -) -> None: - app_id = configured_app.app_id - - respx_mock.get(f"/apps/{app_id}/tokens").mock( - return_value=Response( - 200, - json={ - "data": [ - {"id": "token-123", "name": "GitHub Actions \u2014 owner/repo"} - ] - }, - ) - ) - respx_mock.post(f"/apps/{app_id}/tokens/token-123/regenerate").mock( - return_value=Response( - 200, - json={ - "value": "regenerated-token", - "expired_at": "2027-02-18T00:00:00Z", - }, - ) - ) - - 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 "Regenerated deploy token" in result.output - - @pytest.mark.respx def test_shows_manual_instructions_when_gh_not_installed( logged_in_cli: None, @@ -479,7 +476,9 @@ def test_handles_gh_command_errors_gracefully( ), patch( "fastapi_cloud_cli.commands.setup_ci._set_github_secret", - side_effect=subprocess.CalledProcessError(1, "gh"), + side_effect=GitHubSecretError( + "Failed to set GitHub secret 'FASTAPI_CLOUD_TOKEN'" + ), ), ): result = runner.invoke(app, ["setup-ci"]) @@ -634,3 +633,68 @@ def test_renames_workflow_when_declined_and_new_name_given( 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