From ebd2d781136296a26c3132f341ccc258060fd6b2 Mon Sep 17 00:00:00 2001 From: Tony Brimeyer Date: Fri, 22 May 2026 13:35:30 -0500 Subject: [PATCH 1/2] feat(projects): add project export configuration options for tags and test cases --- .pre-commit-config.yaml | 132 +- Makefile | 198 +- README.md | 274 +- docs/COMMAND_REFERENCE.md | 262 +- .../cli/commands/projects/command.py | 1105 ++++---- .../cli/commands/projects/project_manager.py | 745 +++--- .../cli/utils/config/models.py | 188 +- .../client/workato_api/models/api_client.py | 4 +- .../workato_api/models/api_collection.py | 14 +- tests/unit/commands/projects/test_command.py | 2363 +++++++++-------- .../commands/projects/test_project_manager.py | 116 + 11 files changed, 2879 insertions(+), 2522 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1bb195..eaed675 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,66 +1,66 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: trailing-whitespace - exclude: ^(client/|src/workato_platform_cli/client/) - - id: end-of-file-fixer - exclude: ^(client/|src/workato_platform_cli/client/) - - id: check-yaml - - id: check-added-large-files - - id: check-json - - id: check-merge-conflict - - id: check-toml - - id: debug-statements - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.0 - hooks: - - id: ruff - exclude: ^(client/|src/workato_platform_cli/client/) - - id: ruff-format - exclude: ^(client/|src/workato_platform_cli/client/) - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.1 - hooks: - - id: mypy - args: [--explicit-package-bases] - additional_dependencies: - [ - types-requests, - types-click, - packaging, - asyncclick>=8.0.0, - pydantic>=2.11.7, - dependency-injector>=4.41.0, - inquirer>=3.1.0, - aiohttp>=3.8.0, - aiohttp-retry>=2.8.0, - python-dateutil>=2.8.0, - typing-extensions>=4.0.0, - pytest>=7.0.0, - pytest-asyncio>=0.21.0, - pytest-mock>=3.10.0, - prompt-toolkit>=3.0.0, - ] - exclude: ^(client/|src/workato_platform_cli/client/) - - # pip-audit for dependency security auditing - - repo: https://github.com/pypa/pip-audit - rev: v2.9.0 - hooks: - - id: pip-audit - # Temporary workaround: ignoring pip vulnerability GHSA-4xh5-x5gv-qwph (pip 25.2). - # Remove this ignore once a patched version of pip is available. - args: [--format=json, --ignore-vuln=GHSA-4xh5-x5gv-qwph] - - # Local hooks for project-specific tasks - - repo: local - hooks: - - id: generate-client - name: Generate OpenAPI client - entry: make generate-client - language: system - always_run: true - pass_filenames: false +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + exclude: ^(client/|src/workato_platform_cli/client/) + - id: end-of-file-fixer + exclude: ^(client/|src/workato_platform_cli/client/) + - id: check-yaml + - id: check-added-large-files + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.0 + hooks: + - id: ruff + exclude: ^(client/|src/workato_platform_cli/client/) + - id: ruff-format + exclude: ^(client/|src/workato_platform_cli/client/) + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.1 + hooks: + - id: mypy + args: [--explicit-package-bases] + additional_dependencies: + [ + types-requests, + types-click, + packaging, + asyncclick>=8.0.0, + pydantic>=2.11.7, + dependency-injector>=4.41.0, + inquirer>=3.1.0, + aiohttp>=3.8.0, + aiohttp-retry>=2.8.0, + python-dateutil>=2.8.0, + typing-extensions>=4.0.0, + pytest>=7.0.0, + pytest-asyncio>=0.21.0, + pytest-mock>=3.10.0, + prompt-toolkit>=3.0.0, + ] + exclude: ^(client/|src/workato_platform_cli/client/) + + # pip-audit for dependency security auditing + - repo: https://github.com/pypa/pip-audit + rev: v2.9.0 + hooks: + - id: pip-audit + # Ensure the hook environment uses a patched pip version. + additional_dependencies: ["pip>=26.1"] + args: [--format=json, --ignore-vuln=GHSA-4xh5-x5gv-qwph] + + # Local hooks for project-specific tasks + - repo: local + hooks: + - id: generate-client + name: Generate OpenAPI client + entry: make generate-client + language: system + always_run: true + pass_filenames: false diff --git a/Makefile b/Makefile index 53dae42..b7140ba 100644 --- a/Makefile +++ b/Makefile @@ -1,99 +1,99 @@ -.PHONY: help install install-dev test lint format clean build upload docs check generate-client - -help: - @echo "Available commands:" - @echo " install Install package" - @echo " install-dev Install package with development dependencies" - @echo " test Run all tests" - @echo " test-unit Run unit tests only" - @echo " test-integration Run integration tests only" - @echo " test-client Run generated client tests only" - @echo " test-cov Run tests with coverage report" - @echo " test-watch Run tests in watch mode" - @echo " lint Run linting checks" - @echo " format Format code with ruff" - @echo " check Run all checks (lint + format check + type check)" - @echo " clean Clean build artifacts" - @echo " build Build distribution packages" - @echo " upload Upload to PyPI (requires credentials)" - @echo " docs Generate documentation" - @echo " generate-client Generate API client from OpenAPI spec" - -install: - @if [ ! -d ".venv" ]; then \ - echo "๐Ÿ”„ Creating virtual environment..."; \ - uv venv; \ - fi - uv sync - uv pip install -e . - @echo "โœ… Installation complete!" - @echo "๐Ÿ’ก To activate the virtual environment, run: source .venv/bin/activate" - @echo " Or use 'uv run workato' to run commands without activation" - -install-dev: - @if [ ! -d ".venv" ]; then \ - echo "๐Ÿ”„ Creating virtual environment..."; \ - uv venv; \ - fi - uv sync --group dev - uv run pre-commit install - -test: - uv run pytest tests/ -v - -test-unit: - uv run pytest tests/unit/ -v - -test-integration: - uv run pytest tests/integration/ -v - -test-client: - uv run pytest src/workato_platform_cli/client/workato_api/test/ -v - -test-cov: - uv run pytest tests/ --cov=src/workato_platform_cli --cov-report=html --cov-report=term --cov-report=xml - -test-watch: - uv run pytest tests/ -v --tb=short -x --lf - -lint: - uv run ruff check src/ tests/ - -format: - uv run ruff format src/ tests/ - -check: - uv run ruff check src/ tests/ - uv run ruff format --check src/ tests/ - uv run mypy --explicit-package-bases src/ tests/ - -clean: - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info/ - rm -rf .coverage - rm -rf htmlcov/ - find . -type d -name __pycache__ -delete - find . -type f -name "*.pyc" -delete - -build: clean - python -m build - -upload: build - twine check dist/* - twine upload dist/* - -docs: - @echo "Documentation generation not yet implemented" - @echo "Consider using sphinx-quickstart to set up docs/" - -generate-client: - @echo "๐Ÿ”„ Generating API client from OpenAPI spec..." - openapi-generator-cli generate -i workato-api-spec.yaml -g python -c openapi-config.yaml -o ./src/ - @echo "โœ… API client generated successfully" - -# Development shortcuts -dev: install-dev format check test - -# CI/CD command -ci: check test +.PHONY: help install install-dev test lint format clean build upload docs check generate-client + +help: + @echo "Available commands:" + @echo " install Install package" + @echo " install-dev Install package with development dependencies" + @echo " test Run all tests" + @echo " test-unit Run unit tests only" + @echo " test-integration Run integration tests only" + @echo " test-client Run generated client tests only" + @echo " test-cov Run tests with coverage report" + @echo " test-watch Run tests in watch mode" + @echo " lint Run linting checks" + @echo " format Format code with ruff" + @echo " check Run all checks (lint + format check + type check)" + @echo " clean Clean build artifacts" + @echo " build Build distribution packages" + @echo " upload Upload to PyPI (requires credentials)" + @echo " docs Generate documentation" + @echo " generate-client Generate API client from OpenAPI spec" + +install: + @if [ ! -d ".venv" ]; then \ + echo "๐Ÿ”„ Creating virtual environment..."; \ + uv venv; \ + fi + uv sync + uv pip install -e . + @echo "โœ… Installation complete!" + @echo "๐Ÿ’ก To activate the virtual environment, run: source .venv/bin/activate" + @echo " Or use 'uv run workato' to run commands without activation" + +install-dev: + @if [ ! -d ".venv" ]; then \ + echo "๐Ÿ”„ Creating virtual environment..."; \ + uv venv; \ + fi + uv sync --group dev + uv run pre-commit install + +test: + uv run pytest tests/ -v + +test-unit: + uv run pytest tests/unit/ -v + +test-integration: + uv run pytest tests/integration/ -v + +test-client: + uv run pytest src/workato_platform_cli/client/workato_api/test/ -v + +test-cov: + uv run pytest tests/ --cov=src/workato_platform_cli --cov-report=html --cov-report=term --cov-report=xml + +test-watch: + uv run pytest tests/ -v --tb=short -x --lf + +lint: + uv run ruff check src/ tests/ + +format: + uv run ruff format src/ tests/ + +check: + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + uv run mypy --explicit-package-bases src/ tests/ + +clean: + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf .coverage + rm -rf htmlcov/ + find . -type f -name "*.pyc" -delete + find . -type d -name __pycache__ -delete + +build: clean + python -m build + +upload: build + twine check dist/* + twine upload dist/* + +docs: + @echo "Documentation generation not yet implemented" + @echo "Consider using sphinx-quickstart to set up docs/" + +generate-client: + @echo "๐Ÿ”„ Generating API client from OpenAPI spec..." + openapi-generator-cli generate -i workato-api-spec.yaml -g python -c openapi-config.yaml -o ./src/ + @echo "โœ… API client generated successfully" + +# Development shortcuts +dev: install-dev format check test + +# CI/CD command +ci: check test diff --git a/README.md b/README.md index ccf08bc..6e928d0 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,140 @@ -# Workato Platform CLI - -A modern, type-safe command-line interface for the Workato API, designed for automation and AI agent interaction. **Perfect for AI agents helping developers build, validate, and manage Workato recipes, connections, and projects.** - -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -[![Type Checked](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy.readthedocs.io/) -[![Code Style](https://img.shields.io/badge/code%20style-ruff-black.svg)](https://docs.astral.sh/ruff/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -## Features - -- **Project Management**: Create, push, pull, and manage Workato projects -- **Recipe Operations**: Validate, start, stop, and manage recipes -- **Connection Management**: Create and manage OAuth connections -- **API Integration**: Manage API clients, collections, and endpoints -- **AI Agent Support**: Built-in documentation and guide system - -# Quick Start Guide - -Get the Workato CLI running in 5 minutes. - -## Prerequisites - -- Python 3.11+ -- Workato account with API token - -### Getting Your API Token - -1. Log into your Workato account -1. Navigate to **Workspace Admin** โ†’ **API clients** -1. Click **Create API client** -1. Fill out information about the client, click **Create client** -1. Copy the generated token (starts with `wrkatrial-` for trial accounts or `wrkprod-` for production) - -## Installation - -### From PyPI (Coming Soon) - -```bash -pip install workato-platform-cli -``` - -### From Source - -```bash -git clone https://github.com/workato-devs/workato-platform-cli.git -cd workato-platform-cli -make install -``` - -Having issues? See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) for troubleshooting. - -## Setup - -```bash -# Initialize CLI (will prompt for API token and region) -workato init - -# Verify your workspace -workato workspace -``` - -## First Commands - -```bash -# List available commands -workato --help - -# List your recipes -workato recipes list - -# List your connections -workato connections list - -# Check project status -workato workspace -``` - -## Next Steps - -- **Need detailed commands?** โ†’ See [COMMAND_REFERENCE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/COMMAND_REFERENCE.md) -- **Want real-world examples?** โ†’ See [USE_CASES.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/USE_CASES.md) -- **Looking for sample recipes?** โ†’ See [examples/](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/examples/) -- **Installation issues?** โ†’ See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) -- **Looking for all documentation?** โ†’ See [INDEX.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/INDEX.md) - -## Quick Recipe Workflow - -```bash -# 1. Validate a recipe file -workato recipes validate --path ./my-recipe.json - -# 2. Push changes to Workato -workato push - -# 3. Pull latest from remote -workato pull -``` - -You're ready to go! - -## Contributing to the CLI - -These commands are for CLI maintainers and contributors, not for developers using the CLI to build Workato integrations. - -### For Development - -```bash -# Setup (with uv - recommended) -make install-dev - -# Run all checks -make check # linting, formatting, type checking -make test # run tests -make test-cov # run tests with coverage - -# Development workflow -make format # auto-format code -make lint # check code quality -make build # build distribution packages -``` - -### Tech Stack - -- **๐Ÿ Python 3.11+** with full type annotations -- **โšก uv** for fast dependency management -- **๐Ÿ” mypy** for static type checking -- **๐Ÿงน ruff** for linting and formatting -- **โœ… pytest** for testing -- **๐Ÿ”ง pre-commit** for git hooks - -## License - -MIT License +# Workato Platform CLI + +A modern, type-safe command-line interface for the Workato API, designed for automation and AI agent interaction. **Perfect for AI agents helping developers build, validate, and manage Workato recipes, connections, and projects.** + +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![Type Checked](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy.readthedocs.io/) +[![Code Style](https://img.shields.io/badge/code%20style-ruff-black.svg)](https://docs.astral.sh/ruff/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Features + +- **Project Management**: Create, push, pull, and manage Workato projects +- **Recipe Operations**: Validate, start, stop, and manage recipes +- **Connection Management**: Create and manage OAuth connections +- **API Integration**: Manage API clients, collections, and endpoints +- **AI Agent Support**: Built-in documentation and guide system + +# Quick Start Guide + +Get the Workato CLI running in 5 minutes. + +## Prerequisites + +- Python 3.11+ +- Workato account with API token + +### Getting Your API Token + +1. Log into your Workato account +1. Navigate to **Workspace Admin** โ†’ **API clients** +1. Click **Create API client** +1. Fill out information about the client, click **Create client** +1. Copy the generated token (starts with `wrkatrial-` for trial accounts or `wrkprod-` for production) + +## Installation + +### From PyPI (Coming Soon) + +```bash +pip install workato-platform-cli +``` + +### From Source + +```bash +git clone https://github.com/workato-devs/workato-platform-cli.git +cd workato-platform-cli +make install +``` + +Having issues? See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) for troubleshooting. + +## Setup + +```bash +# Initialize CLI (will prompt for API token and region) +workato init + +# Verify your workspace +workato workspace +``` + +## First Commands + +```bash +# List available commands +workato --help + +# List your recipes +workato recipes list + +# List your connections +workato connections list + +# Check project status +workato workspace + +# Configure project export defaults +workato projects config --include-tags --no-include-test-cases + +# Show current project export defaults +workato projects config show +``` + +## Next Steps + +- **Need detailed commands?** โ†’ See [COMMAND_REFERENCE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/COMMAND_REFERENCE.md) +- **Want real-world examples?** โ†’ See [USE_CASES.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/USE_CASES.md) +- **Looking for sample recipes?** โ†’ See [examples/](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/examples/) +- **Installation issues?** โ†’ See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) +- **Looking for all documentation?** โ†’ See [INDEX.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/INDEX.md) + +## Quick Recipe Workflow + +```bash +# 1. Validate a recipe file +workato recipes validate --path ./my-recipe.json + +# 2. Push changes to Workato +workato push + +# 3. Pull latest from remote +workato pull +``` + +You're ready to go! + +## Contributing to the CLI + +These commands are for CLI maintainers and contributors, not for developers using the CLI to build Workato integrations. + +### For Development + +```bash +# Setup (with uv - recommended) +make install-dev + +# Run all checks +make check # linting, formatting, type checking +make test # run tests +make test-cov # run tests with coverage + +# Development workflow +make format # auto-format code +make lint # check code quality +make build # build distribution packages +``` + +### Tech Stack + +- **๐Ÿ Python 3.11+** with full type annotations +- **โšก uv** for fast dependency management +- **๐Ÿ” mypy** for static type checking +- **๐Ÿงน ruff** for linting and formatting +- **โœ… pytest** for testing +- **๐Ÿ”ง pre-commit** for git hooks + +## License + +MIT License diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 498f9cb..e571a93 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -1,130 +1,132 @@ -# Workato CLI Command Reference - -Complete reference for all CLI commands and options. - -## Installation & Setup - -```bash -# Install -pip install -e . - -# Initialize -workato init - -# Check status -workato workspace -``` - -## Core Commands - -### Project Management -```bash -workato init # Initialize CLI configuration -workato workspace # Show current workspace info -workato pull # Pull latest from remote -workato push [--restart-recipes] # Push local changes (recipes won't restart by default) -``` - -### Recipe Management -```bash -workato recipes list [--folder-id ID] [--running] [--page N] -workato recipes validate --path FILE -workato recipes start --id ID -workato recipes stop --id ID -workato recipes update-connection RECIPE_ID --adapter-name NAME --connection-id ID -``` - -### Connection Management -```bash -workato connections list [--folder-id ID] -workato connections create --provider PROVIDER --name NAME -workato connections create-oauth --parent-id ID -workato connections update --connection-id ID --name NAME -workato connections get-oauth-url --id ID -``` - -### Connectors -```bash -workato connectors list -workato connectors parameters --provider PROVIDER -``` - -### API Collections -```bash -workato api-collections create --format FORMAT --content PATH --name NAME -# Formats: json, yaml, url -``` - -### Profiles -```bash -workato init # Create new profile interactively -workato profiles list -workato profiles use NAME -workato profiles status -``` - -### Documentation -```bash -workato guide topics # List available topics -workato guide search QUERY # Search documentation -workato guide content TOPIC # Show topic content -``` - -## Common Options - -- `--help` - Show help for any command -- `--profile NAME` - Use specific profile -- `--page N --per-page N` - Pagination for list commands -- `--folder-id ID` - Filter by folder - -## Examples - -### Development Workflow -```bash -# Setup -workato init # Creates profile interactively - -# Development -workato recipes validate --path ./recipe.json -workato push --restart-recipes # Only restarts running recipes that were updated -workato recipes list --running - -# Switch environments -workato profiles use production -workato pull -``` - -### Recipe Management -```bash -# List and filter recipes -workato recipes list --folder-id 123 -workato recipes list --running --per-page 10 - -# Manage recipe lifecycle -workato recipes start --id 456 -workato recipes stop --id 456 -``` - -### Connection Setup -```bash -# Create connections -workato connections create --provider salesforce --name "Production SF" -workato connections create-oauth --parent-id 789 - -# Get OAuth URL for authentication -workato connections get-oauth-url --id 789 -``` - -## Environment Support - -**Trial Accounts:** Use `wrkatrial-` tokens with `https://app.trial.workato.com/api` - -**Production Accounts:** Use `wrkprod-` tokens with `https://www.workato.com/api` - -## Requirements - -- Python 3.11+ -- Valid Workato account and API token -- Network access to Workato API endpoints - -For setup and installation issues, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). +# Workato CLI Command Reference + +Complete reference for all CLI commands and options. + +## Installation & Setup + +```bash +# Install +pip install -e . + +# Initialize +workato init + +# Check status +workato workspace +``` + +## Core Commands + +### Project Management +```bash +workato init # Initialize CLI configuration +workato workspace # Show current workspace info +workato pull # Pull latest from remote +workato push [--restart-recipes] # Push local changes (recipes won't restart by default) +workato projects config --include-tags|--no-include-tags [--include-test-cases|--no-include-test-cases] +workato projects config show [--output-mode table|json] +``` + +### Recipe Management +```bash +workato recipes list [--folder-id ID] [--running] [--page N] +workato recipes validate --path FILE +workato recipes start --id ID +workato recipes stop --id ID +workato recipes update-connection RECIPE_ID --adapter-name NAME --connection-id ID +``` + +### Connection Management +```bash +workato connections list [--folder-id ID] +workato connections create --provider PROVIDER --name NAME +workato connections create-oauth --parent-id ID +workato connections update --connection-id ID --name NAME +workato connections get-oauth-url --id ID +``` + +### Connectors +```bash +workato connectors list +workato connectors parameters --provider PROVIDER +``` + +### API Collections +```bash +workato api-collections create --format FORMAT --content PATH --name NAME +# Formats: json, yaml, url +``` + +### Profiles +```bash +workato init # Create new profile interactively +workato profiles list +workato profiles use NAME +workato profiles status +``` + +### Documentation +```bash +workato guide topics # List available topics +workato guide search QUERY # Search documentation +workato guide content TOPIC # Show topic content +``` + +## Common Options + +- `--help` - Show help for any command +- `--profile NAME` - Use specific profile +- `--page N --per-page N` - Pagination for list commands +- `--folder-id ID` - Filter by folder + +## Examples + +### Development Workflow +```bash +# Setup +workato init # Creates profile interactively + +# Development +workato recipes validate --path ./recipe.json +workato push --restart-recipes # Only restarts running recipes that were updated +workato recipes list --running + +# Switch environments +workato profiles use production +workato pull +``` + +### Recipe Management +```bash +# List and filter recipes +workato recipes list --folder-id 123 +workato recipes list --running --per-page 10 + +# Manage recipe lifecycle +workato recipes start --id 456 +workato recipes stop --id 456 +``` + +### Connection Setup +```bash +# Create connections +workato connections create --provider salesforce --name "Production SF" +workato connections create-oauth --parent-id 789 + +# Get OAuth URL for authentication +workato connections get-oauth-url --id 789 +``` + +## Environment Support + +**Trial Accounts:** Use `wrkatrial-` tokens with `https://app.trial.workato.com/api` + +**Production Accounts:** Use `wrkprod-` tokens with `https://www.workato.com/api` + +## Requirements + +- Python 3.11+ +- Valid Workato account and API token +- Network access to Workato API endpoints + +For setup and installation issues, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). diff --git a/src/workato_platform_cli/cli/commands/projects/command.py b/src/workato_platform_cli/cli/commands/projects/command.py index e08f3f7..5ce7fb0 100644 --- a/src/workato_platform_cli/cli/commands/projects/command.py +++ b/src/workato_platform_cli/cli/commands/projects/command.py @@ -1,513 +1,592 @@ -"""Manage Workato projects""" - -import json - -from typing import Any - -import asyncclick as click - -from dependency_injector.wiring import Provide, inject - -from workato_platform_cli import Workato -from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager -from workato_platform_cli.cli.containers import ( - Container, - create_profile_aware_workato_config, -) -from workato_platform_cli.cli.utils.config import ConfigData, ConfigManager -from workato_platform_cli.cli.utils.exception_handler import ( - handle_api_exceptions, - handle_cli_exceptions, -) -from workato_platform_cli.client.workato_api.models.project import Project - - -@click.group() -def projects() -> None: - """Manage Workato projects""" - pass - - -@projects.command(name="list") -@click.option( - "--profile", - help="Profile to use for authentication and region settings", - default=None, -) -@click.option( - "--source", - type=click.Choice(["local", "remote", "both"]), - default="local", - help="Source of projects to list: local (default), remote (server), or both", -) -@click.option( - "--output-mode", - type=click.Choice(["table", "json"]), - default="table", - help="Output format: table (default) or json", -) -@handle_cli_exceptions -@inject -@handle_api_exceptions -async def list_projects( - profile: str | None = None, - source: str = "local", - output_mode: str = "table", - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """List available projects from local workspace and/or server""" - - # Gather projects based on source - local_projects: list[tuple[Any, str, ConfigData | None]] = [] - remote_projects: list[Project] = [] - - if source in ["local", "both"]: - local_projects = await _get_local_projects(config_manager) - - if source in ["remote", "both"]: - workato_api_configuration = create_profile_aware_workato_config( - config_manager=config_manager, - cli_profile=profile, - ) - workato_api_client = Workato(configuration=workato_api_configuration) - async with workato_api_client as workato_api_client: - project_manager = ProjectManager(workato_api_client=workato_api_client) - remote_projects = await project_manager.get_all_projects() - - # Output based on mode - if output_mode == "json": - await _output_json(source, local_projects, remote_projects, config_manager) - else: - await _output_table(source, local_projects, remote_projects, config_manager) - - -@projects.command() -@click.argument("project_name") -@handle_cli_exceptions -@inject -async def use( - project_name: str, - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Switch to a specific project by name""" - # Find workspace root to search for projects - workspace_root = config_manager.get_workspace_root() - - # Use the new config system to find all projects in workspace - all_projects = config_manager._find_all_projects(workspace_root) - - # Find the project by name - target_project = None - for project_path, discovered_project_name in all_projects: - if discovered_project_name == project_name: - target_project = (project_path, discovered_project_name) - break - - if not target_project: - click.echo(f"โŒ Project '{project_name}' not found") - click.echo("๐Ÿ’ก Use 'workato projects list' to see available projects") - return - - project_path, _ = target_project - - # Load project configuration - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - project_config = project_config_manager.load_config() - except Exception as e: - click.echo(f"โŒ Project '{project_name}' has configuration errors: {e}") - click.echo("๐Ÿ’ก Navigate to the project directory and run 'workato init'") - return - - # Update workspace-level config to point to this project - try: - workspace_config = config_manager.load_config() - - # Calculate relative project path for workspace config - relative_project_path = str(project_path.relative_to(workspace_root)) - - # Copy project-specific data to workspace config - workspace_config.project_id = project_config.project_id - workspace_config.project_name = project_config.project_name - workspace_config.project_path = relative_project_path - workspace_config.folder_id = project_config.folder_id - workspace_config.profile = project_config.profile - - config_manager.save_config(workspace_config) - - click.echo(f"โœ… Switched to project '{project_name}'") - - # Show project details - if project_config.project_name: - click.echo(f" Name: {project_config.project_name}") - if project_config.folder_id: - click.echo(f" Folder ID: {project_config.folder_id}") - if project_config.profile: - click.echo(f" Profile: {project_config.profile}") - click.echo(f" Directory: {relative_project_path}") - - except Exception as e: - click.echo(f"โŒ Failed to switch to project '{project_name}': {e}") - - -@projects.command() -@handle_cli_exceptions -@inject -async def switch( - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Interactively switch to a different project""" - import inquirer - - # Find workspace root to search for projects - workspace_root = config_manager.get_workspace_root() - - # Use the new config system to find all projects in workspace - all_projects = config_manager._find_all_projects(workspace_root) - - if not all_projects: - click.echo("โŒ No projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - # Get current project for context - current_project_name = config_manager.get_current_project_name() - - # Build project choices with configuration - project_choices: list[tuple[str, str, ConfigData | None]] = [] - - for project_path, project_name in all_projects: - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - config_data = project_config_manager.load_config() - - # Create display name - display_name = project_name - if config_data.project_name and config_data.project_name != project_name: - display_name = f"{project_name} ({config_data.project_name})" - - if project_name == current_project_name: - display_name += " (current)" - - project_choices.append((display_name, project_name, config_data)) - except Exception: - # Still include projects with configuration errors - display_name = f"{project_name} (configuration error)" - if project_name == current_project_name: - display_name += " (current)" - project_choices.append((display_name, project_name, None)) - - if not project_choices: - click.echo("โŒ No configured projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - if len(project_choices) == 1 and project_choices[0][1] == current_project_name: - click.echo("โœ… Only one project available and it's already current") - return - - # Create interactive selection - choices = [choice[0] for choice in project_choices] - - questions = [ - inquirer.List( - "project", - message="Select a project to switch to", # noboost - choices=choices, - ) - ] - - answers = inquirer.prompt(questions) - if not answers: - click.echo("โŒ No project selected") - return - - # Find the selected project - selected_project_name: str | None = None - selected_config: ConfigData | None = None - - for display_name, project_name, project_config_data in project_choices: - if display_name == answers["project"]: - selected_project_name = project_name - selected_config = project_config_data - break - - if not selected_project_name: - click.echo("โŒ Failed to identify selected project") - return - - if selected_project_name == current_project_name: - click.echo("โœ… Project is already current") - return - - if not selected_config: - click.echo(f"โŒ Project '{selected_project_name}' has configuration errors") - return - - # Find the project path - selected_project_path = None - for project_path, project_name in all_projects: - if project_name == selected_project_name: - selected_project_path = project_path - break - - if not selected_project_path: - click.echo(f"โŒ Failed to find path for project '{selected_project_name}'") - return - - # Switch to the selected project - try: - workspace_config = config_manager.load_config() - - # Calculate relative project path for workspace config - relative_project_path = str(selected_project_path.relative_to(workspace_root)) - - # Copy project-specific data to workspace config - workspace_config.project_id = selected_config.project_id - workspace_config.project_name = selected_config.project_name - workspace_config.project_path = relative_project_path - workspace_config.folder_id = selected_config.folder_id - workspace_config.profile = selected_config.profile - - config_manager.save_config(workspace_config) - - click.echo(f"โœ… Switched to project '{selected_project_name}'") - - # Show project details - if selected_config.project_name: - click.echo(f" Name: {selected_config.project_name}") - if selected_config.folder_id: - click.echo(f" Folder ID: {selected_config.folder_id}") - if selected_config.profile: - click.echo(f" Profile: {selected_config.profile}") - click.echo(f" Directory: {relative_project_path}") - - except Exception as e: - click.echo(f"โŒ Failed to switch to project '{selected_project_name}': {e}") - - -async def _get_local_projects( - config_manager: ConfigManager, -) -> list[tuple[Any, str, ConfigData | None]]: - """Get local projects with their configurations""" - workspace_root = config_manager.get_workspace_root() - all_projects = config_manager._find_all_projects(workspace_root) - - local_projects: list[tuple[Any, str, ConfigData | None]] = [] - for project_path, project_name in all_projects: - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - config_data = project_config_manager.load_config() - local_projects.append((project_path, project_name, config_data)) - except Exception: - local_projects.append((project_path, project_name, None)) - - return local_projects - - -async def _output_json( - source: str, - local_projects: list[tuple[Any, str, ConfigData | None]], - remote_projects: list[Project], - config_manager: ConfigManager, -) -> None: - """Output projects in JSON format""" - workspace_root = config_manager.get_workspace_root() - current_project_name = config_manager.get_current_project_name() - - output_data: dict[str, Any] = { - "source": source, - "current_project": current_project_name, - "workspace_root": str(workspace_root) if workspace_root else None, - "local_projects": [], - "remote_projects": [], - } - - # Process local projects - if source in ["local", "both"]: - for project_path, project_name, config_data in local_projects: - if config_data: - project_info = { - "name": project_name, - "directory": str(project_path.relative_to(workspace_root)) - if workspace_root - else str(project_path), - "is_current": project_name == current_project_name, - "project_id": config_data.project_id, - "folder_id": config_data.folder_id, - "profile": config_data.profile, - "configured": True, - } - else: - project_info = { - "name": project_name, - "directory": str(project_path.relative_to(workspace_root)) - if workspace_root - else str(project_path), - "is_current": project_name == current_project_name, - "configured": False, - "error": "configuration error", - } - output_data["local_projects"].append(project_info) - - # Process remote projects - if source in ["remote", "both"]: - for remote_project in remote_projects: - # Check if this remote project exists locally - local_match = None - if source == "both": - for _, _, config_data in local_projects: - if config_data and config_data.project_id == remote_project.id: - local_match = config_data - break - - remote_info = { - "name": remote_project.name, - "project_id": remote_project.id, - "folder_id": remote_project.folder_id, - "description": remote_project.description or "", - "has_local_copy": local_match is not None, - } - - if local_match: - remote_info["local_profile"] = local_match.profile - - output_data["remote_projects"].append(remote_info) - - click.echo(json.dumps(output_data)) - - -async def _output_table( - source: str, - local_projects: list[tuple[Any, str, ConfigData | None]], - remote_projects: list[Project], - config_manager: ConfigManager, -) -> None: - """Output projects in table format""" - workspace_root = config_manager.get_workspace_root() - current_project_name = config_manager.get_current_project_name() - - if source == "local": - if not local_projects: - click.echo("๐Ÿ“‹ No local projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - click.echo("๐Ÿ“‹ Local projects:") - for project_path, project_name, config_data in sorted( - local_projects, key=lambda x: x[1] - ): - current_indicator = ( - " (current)" if project_name == current_project_name else "" - ) - - if config_data: - click.echo(f" โ€ข {project_name}{current_indicator}") - if config_data.project_id: - click.echo(f" Project ID: {config_data.project_id}") - if config_data.folder_id: - click.echo(f" Folder ID: {config_data.folder_id}") - if config_data.profile: - click.echo(f" Profile: {config_data.profile}") - if workspace_root: - click.echo( - f" Directory: {project_path.relative_to(workspace_root)}" - ) - else: - click.echo( - f" โ€ข {project_name}{current_indicator} (configuration error)" - ) - click.echo() - - elif source == "remote": - if not remote_projects: - click.echo("๐Ÿ“‹ No remote projects found") - return - - click.echo("๐Ÿ“‹ Remote projects:") - for remote_project in sorted(remote_projects, key=lambda x: x.name): - click.echo(f" โ€ข {remote_project.name}") - click.echo(f" Project ID: {remote_project.id}") - click.echo(f" Folder ID: {remote_project.folder_id}") - if remote_project.description: - click.echo(f" Description: {remote_project.description}") - click.echo() - - else: # both - # Show combined view with sync status - if not local_projects and not remote_projects: - click.echo("๐Ÿ“‹ No projects found locally or remotely") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - click.echo("๐Ÿ“‹ All projects (local + remote):") - - # Create a unified view - all_projects = {} - - # Add local projects - for project_path, project_name, config_data in local_projects: - project_id = config_data.project_id if config_data else None - all_projects[project_id or f"local:{project_name}"] = { - "name": project_name, - "project_id": project_id, - "folder_id": config_data.folder_id if config_data else None, - "profile": config_data.profile if config_data else None, - "local_path": project_path, - "is_local": True, - "is_remote": False, - "is_current": project_name == current_project_name, - "config_error": config_data is None, - } - - # Add/update with remote projects - for remote_project in remote_projects: - key = remote_project.id - if key in all_projects: - # Update existing local project with remote info - all_projects[key]["is_remote"] = True - all_projects[key]["remote_description"] = remote_project.description - else: - # Add remote-only project - all_projects[key] = { - "name": remote_project.name, - "project_id": remote_project.id, - "folder_id": remote_project.folder_id, - "remote_description": remote_project.description, - "is_local": False, - "is_remote": True, - "is_current": False, - "config_error": False, - } - - # Display unified projects - for project_data in sorted(all_projects.values(), key=lambda x: x["name"]): - status_indicators = [] - if project_data["is_current"]: - status_indicators.append("current") - if project_data["is_local"] and project_data["is_remote"]: - status_indicators.append("synced") - elif project_data["is_local"]: - status_indicators.append("local only") - elif project_data["is_remote"]: - status_indicators.append("remote only") - if project_data.get("config_error"): - status_indicators.append("config error") - - status_text = ( - f" ({', '.join(status_indicators)})" if status_indicators else "" - ) - click.echo(f" โ€ข {project_data['name']}{status_text}") - - if project_data["project_id"]: - click.echo(f" Project ID: {project_data['project_id']}") - if project_data["folder_id"]: - click.echo(f" Folder ID: {project_data['folder_id']}") - if project_data.get("profile"): - click.echo(f" Profile: {project_data['profile']}") - if project_data.get("remote_description"): - click.echo(f" Description: {project_data['remote_description']}") - if project_data.get("local_path") and workspace_root: - local_path = project_data["local_path"] - click.echo(f" Directory: {local_path.relative_to(workspace_root)}") - click.echo() +"""Manage Workato projects""" + +import json + +from typing import Any + +import asyncclick as click + +from dependency_injector.wiring import Provide, inject + +from workato_platform_cli import Workato +from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager +from workato_platform_cli.cli.containers import ( + Container, + create_profile_aware_workato_config, +) +from workato_platform_cli.cli.utils.config import ConfigData, ConfigManager +from workato_platform_cli.cli.utils.exception_handler import ( + handle_api_exceptions, + handle_cli_exceptions, +) +from workato_platform_cli.client.workato_api.models.project import Project + + +@click.group() +def projects() -> None: + """Manage Workato projects""" + pass + + +@projects.command(name="list") +@click.option( + "--profile", + help="Profile to use for authentication and region settings", + default=None, +) +@click.option( + "--source", + type=click.Choice(["local", "remote", "both"]), + default="local", + help="Source of projects to list: local (default), remote (server), or both", +) +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@handle_cli_exceptions +@inject +@handle_api_exceptions +async def list_projects( + profile: str | None = None, + source: str = "local", + output_mode: str = "table", + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """List available projects from local workspace and/or server""" + + # Gather projects based on source + local_projects: list[tuple[Any, str, ConfigData | None]] = [] + remote_projects: list[Project] = [] + + if source in ["local", "both"]: + local_projects = await _get_local_projects(config_manager) + + if source in ["remote", "both"]: + workato_api_configuration = create_profile_aware_workato_config( + config_manager=config_manager, + cli_profile=profile, + ) + workato_api_client = Workato(configuration=workato_api_configuration) + async with workato_api_client as workato_api_client: + project_manager = ProjectManager(workato_api_client=workato_api_client) + remote_projects = await project_manager.get_all_projects() + + # Output based on mode + if output_mode == "json": + await _output_json(source, local_projects, remote_projects, config_manager) + else: + await _output_table(source, local_projects, remote_projects, config_manager) + + +@projects.command() +@click.argument("project_name") +@handle_cli_exceptions +@inject +async def use( + project_name: str, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Switch to a specific project by name""" + # Find workspace root to search for projects + workspace_root = config_manager.get_workspace_root() + + # Use the new config system to find all projects in workspace + all_projects = config_manager._find_all_projects(workspace_root) + + # Find the project by name + target_project = None + for project_path, discovered_project_name in all_projects: + if discovered_project_name == project_name: + target_project = (project_path, discovered_project_name) + break + + if not target_project: + click.echo(f"โŒ Project '{project_name}' not found") + click.echo("๐Ÿ’ก Use 'workato projects list' to see available projects") + return + + project_path, _ = target_project + + # Load project configuration + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + project_config = project_config_manager.load_config() + except Exception as e: + click.echo(f"โŒ Project '{project_name}' has configuration errors: {e}") + click.echo("๐Ÿ’ก Navigate to the project directory and run 'workato init'") + return + + # Update workspace-level config to point to this project + try: + workspace_config = config_manager.load_config() + + # Calculate relative project path for workspace config + relative_project_path = str(project_path.relative_to(workspace_root)) + + # Copy project-specific data to workspace config + workspace_config.project_id = project_config.project_id + workspace_config.project_name = project_config.project_name + workspace_config.project_path = relative_project_path + workspace_config.folder_id = project_config.folder_id + workspace_config.profile = project_config.profile + + config_manager.save_config(workspace_config) + + click.echo(f"โœ… Switched to project '{project_name}'") + + # Show project details + if project_config.project_name: + click.echo(f" Name: {project_config.project_name}") + if project_config.folder_id: + click.echo(f" Folder ID: {project_config.folder_id}") + if project_config.profile: + click.echo(f" Profile: {project_config.profile}") + click.echo(f" Directory: {relative_project_path}") + + except Exception as e: + click.echo(f"โŒ Failed to switch to project '{project_name}': {e}") + + +@projects.command() +@handle_cli_exceptions +@inject +async def switch( + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Interactively switch to a different project""" + import inquirer + + # Find workspace root to search for projects + workspace_root = config_manager.get_workspace_root() + + # Use the new config system to find all projects in workspace + all_projects = config_manager._find_all_projects(workspace_root) + + if not all_projects: + click.echo("โŒ No projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + # Get current project for context + current_project_name = config_manager.get_current_project_name() + + # Build project choices with configuration + project_choices: list[tuple[str, str, ConfigData | None]] = [] + + for project_path, project_name in all_projects: + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + config_data = project_config_manager.load_config() + + # Create display name + display_name = project_name + if config_data.project_name and config_data.project_name != project_name: + display_name = f"{project_name} ({config_data.project_name})" + + if project_name == current_project_name: + display_name += " (current)" + + project_choices.append((display_name, project_name, config_data)) + except Exception: + # Still include projects with configuration errors + display_name = f"{project_name} (configuration error)" + if project_name == current_project_name: + display_name += " (current)" + project_choices.append((display_name, project_name, None)) + + if not project_choices: + click.echo("โŒ No configured projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + if len(project_choices) == 1 and project_choices[0][1] == current_project_name: + click.echo("โœ… Only one project available and it's already current") + return + + # Create interactive selection + choices = [choice[0] for choice in project_choices] + + questions = [ + inquirer.List( + "project", + message="Select a project to switch to", # noboost + choices=choices, + ) + ] + + answers = inquirer.prompt(questions) + if not answers: + click.echo("โŒ No project selected") + return + + # Find the selected project + selected_project_name: str | None = None + selected_config: ConfigData | None = None + + for display_name, project_name, project_config_data in project_choices: + if display_name == answers["project"]: + selected_project_name = project_name + selected_config = project_config_data + break + + if not selected_project_name: + click.echo("โŒ Failed to identify selected project") + return + + if selected_project_name == current_project_name: + click.echo("โœ… Project is already current") + return + + if not selected_config: + click.echo(f"โŒ Project '{selected_project_name}' has configuration errors") + return + + # Find the project path + selected_project_path = None + for project_path, project_name in all_projects: + if project_name == selected_project_name: + selected_project_path = project_path + break + + if not selected_project_path: + click.echo(f"โŒ Failed to find path for project '{selected_project_name}'") + return + + # Switch to the selected project + try: + workspace_config = config_manager.load_config() + + # Calculate relative project path for workspace config + relative_project_path = str(selected_project_path.relative_to(workspace_root)) + + # Copy project-specific data to workspace config + workspace_config.project_id = selected_config.project_id + workspace_config.project_name = selected_config.project_name + workspace_config.project_path = relative_project_path + workspace_config.folder_id = selected_config.folder_id + workspace_config.profile = selected_config.profile + + config_manager.save_config(workspace_config) + + click.echo(f"โœ… Switched to project '{selected_project_name}'") + + # Show project details + if selected_config.project_name: + click.echo(f" Name: {selected_config.project_name}") + if selected_config.folder_id: + click.echo(f" Folder ID: {selected_config.folder_id}") + if selected_config.profile: + click.echo(f" Profile: {selected_config.profile}") + click.echo(f" Directory: {relative_project_path}") + + except Exception as e: + click.echo(f"โŒ Failed to switch to project '{selected_project_name}': {e}") + + +@projects.group(name="config", invoke_without_command=True) +@click.option( + "--include-tags/--no-include-tags", + default=None, + help=( + "Set export_include_tags default in .workatoenv for project export manifests" + ), +) +@click.option( + "--include-test-cases/--no-include-test-cases", + default=None, + help=( + "Set export_include_test_cases default in .workatoenv for project " + "export manifests" + ), +) +@handle_cli_exceptions +@inject +async def set_project_config( + include_tags: bool | None = None, + include_test_cases: bool | None = None, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Set project export defaults in local .workatoenv config.""" + + if include_tags is None and include_test_cases is None: + click.echo("โŒ No config values provided") + click.echo( + "๐Ÿ’ก Use --include-tags/--no-include-tags and/or " + "--include-test-cases/--no-include-test-cases" + ) + return + + config_data = config_manager.load_config() + + if include_tags is not None: + config_data.export_include_tags = include_tags + if include_test_cases is not None: + config_data.export_include_test_cases = include_test_cases + + config_manager.save_config(config_data) + + click.echo("โœ… Updated project export defaults") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + +@set_project_config.command(name="show") +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@handle_cli_exceptions +@inject +async def show_project_config( + output_mode: str = "table", + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Show project export defaults from local .workatoenv config.""" + config_data = config_manager.load_config() + + if output_mode == "json": + click.echo( + json.dumps( + { + "export_include_tags": config_data.export_include_tags, + "export_include_test_cases": config_data.export_include_test_cases, + } + ) + ) + return + + click.echo("๐Ÿ“‹ Project export defaults:") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + +async def _get_local_projects( + config_manager: ConfigManager, +) -> list[tuple[Any, str, ConfigData | None]]: + """Get local projects with their configurations""" + workspace_root = config_manager.get_workspace_root() + all_projects = config_manager._find_all_projects(workspace_root) + + local_projects: list[tuple[Any, str, ConfigData | None]] = [] + for project_path, project_name in all_projects: + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + config_data = project_config_manager.load_config() + local_projects.append((project_path, project_name, config_data)) + except Exception: + local_projects.append((project_path, project_name, None)) + + return local_projects + + +async def _output_json( + source: str, + local_projects: list[tuple[Any, str, ConfigData | None]], + remote_projects: list[Project], + config_manager: ConfigManager, +) -> None: + """Output projects in JSON format""" + workspace_root = config_manager.get_workspace_root() + current_project_name = config_manager.get_current_project_name() + + output_data: dict[str, Any] = { + "source": source, + "current_project": current_project_name, + "workspace_root": str(workspace_root) if workspace_root else None, + "local_projects": [], + "remote_projects": [], + } + + # Process local projects + if source in ["local", "both"]: + for project_path, project_name, config_data in local_projects: + if config_data: + project_info = { + "name": project_name, + "directory": str(project_path.relative_to(workspace_root)) + if workspace_root + else str(project_path), + "is_current": project_name == current_project_name, + "project_id": config_data.project_id, + "folder_id": config_data.folder_id, + "profile": config_data.profile, + "configured": True, + } + else: + project_info = { + "name": project_name, + "directory": str(project_path.relative_to(workspace_root)) + if workspace_root + else str(project_path), + "is_current": project_name == current_project_name, + "configured": False, + "error": "configuration error", + } + output_data["local_projects"].append(project_info) + + # Process remote projects + if source in ["remote", "both"]: + for remote_project in remote_projects: + # Check if this remote project exists locally + local_match = None + if source == "both": + for _, _, config_data in local_projects: + if config_data and config_data.project_id == remote_project.id: + local_match = config_data + break + + remote_info = { + "name": remote_project.name, + "project_id": remote_project.id, + "folder_id": remote_project.folder_id, + "description": remote_project.description or "", + "has_local_copy": local_match is not None, + } + + if local_match: + remote_info["local_profile"] = local_match.profile + + output_data["remote_projects"].append(remote_info) + + click.echo(json.dumps(output_data)) + + +async def _output_table( + source: str, + local_projects: list[tuple[Any, str, ConfigData | None]], + remote_projects: list[Project], + config_manager: ConfigManager, +) -> None: + """Output projects in table format""" + workspace_root = config_manager.get_workspace_root() + current_project_name = config_manager.get_current_project_name() + + if source == "local": + if not local_projects: + click.echo("๐Ÿ“‹ No local projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + click.echo("๐Ÿ“‹ Local projects:") + for project_path, project_name, config_data in sorted( + local_projects, key=lambda x: x[1] + ): + current_indicator = ( + " (current)" if project_name == current_project_name else "" + ) + + if config_data: + click.echo(f" โ€ข {project_name}{current_indicator}") + if config_data.project_id: + click.echo(f" Project ID: {config_data.project_id}") + if config_data.folder_id: + click.echo(f" Folder ID: {config_data.folder_id}") + if config_data.profile: + click.echo(f" Profile: {config_data.profile}") + if workspace_root: + click.echo( + f" Directory: {project_path.relative_to(workspace_root)}" + ) + else: + click.echo( + f" โ€ข {project_name}{current_indicator} (configuration error)" + ) + click.echo() + + elif source == "remote": + if not remote_projects: + click.echo("๐Ÿ“‹ No remote projects found") + return + + click.echo("๐Ÿ“‹ Remote projects:") + for remote_project in sorted(remote_projects, key=lambda x: x.name): + click.echo(f" โ€ข {remote_project.name}") + click.echo(f" Project ID: {remote_project.id}") + click.echo(f" Folder ID: {remote_project.folder_id}") + if remote_project.description: + click.echo(f" Description: {remote_project.description}") + click.echo() + + else: # both + # Show combined view with sync status + if not local_projects and not remote_projects: + click.echo("๐Ÿ“‹ No projects found locally or remotely") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + click.echo("๐Ÿ“‹ All projects (local + remote):") + + # Create a unified view + all_projects = {} + + # Add local projects + for project_path, project_name, config_data in local_projects: + project_id = config_data.project_id if config_data else None + all_projects[project_id or f"local:{project_name}"] = { + "name": project_name, + "project_id": project_id, + "folder_id": config_data.folder_id if config_data else None, + "profile": config_data.profile if config_data else None, + "local_path": project_path, + "is_local": True, + "is_remote": False, + "is_current": project_name == current_project_name, + "config_error": config_data is None, + } + + # Add/update with remote projects + for remote_project in remote_projects: + key = remote_project.id + if key in all_projects: + # Update existing local project with remote info + all_projects[key]["is_remote"] = True + all_projects[key]["remote_description"] = remote_project.description + else: + # Add remote-only project + all_projects[key] = { + "name": remote_project.name, + "project_id": remote_project.id, + "folder_id": remote_project.folder_id, + "remote_description": remote_project.description, + "is_local": False, + "is_remote": True, + "is_current": False, + "config_error": False, + } + + # Display unified projects + for project_data in sorted(all_projects.values(), key=lambda x: x["name"]): + status_indicators = [] + if project_data["is_current"]: + status_indicators.append("current") + if project_data["is_local"] and project_data["is_remote"]: + status_indicators.append("synced") + elif project_data["is_local"]: + status_indicators.append("local only") + elif project_data["is_remote"]: + status_indicators.append("remote only") + if project_data.get("config_error"): + status_indicators.append("config error") + + status_text = ( + f" ({', '.join(status_indicators)})" if status_indicators else "" + ) + click.echo(f" โ€ข {project_data['name']}{status_text}") + + if project_data["project_id"]: + click.echo(f" Project ID: {project_data['project_id']}") + if project_data["folder_id"]: + click.echo(f" Folder ID: {project_data['folder_id']}") + if project_data.get("profile"): + click.echo(f" Profile: {project_data['profile']}") + if project_data.get("remote_description"): + click.echo(f" Description: {project_data['remote_description']}") + if project_data.get("local_path") and workspace_root: + local_path = project_data["local_path"] + click.echo(f" Directory: {local_path.relative_to(workspace_root)}") + click.echo() diff --git a/src/workato_platform_cli/cli/commands/projects/project_manager.py b/src/workato_platform_cli/cli/commands/projects/project_manager.py index 2a3e84b..7f0c87f 100644 --- a/src/workato_platform_cli/cli/commands/projects/project_manager.py +++ b/src/workato_platform_cli/cli/commands/projects/project_manager.py @@ -1,338 +1,407 @@ -"""ProjectManager for handling Workato project operations""" - -import os -import shutil -import subprocess # noqa: S404 -import time -import zipfile - -from pathlib import Path - -import asyncclick as click -import inquirer - -from workato_platform_cli import Workato -from workato_platform_cli.cli.utils.spinner import Spinner -from workato_platform_cli.client.workato_api.models.asset import Asset -from workato_platform_cli.client.workato_api.models.create_export_manifest_request import ( # noqa: E501 - CreateExportManifestRequest, -) -from workato_platform_cli.client.workato_api.models.create_folder_request import ( - CreateFolderRequest, -) -from workato_platform_cli.client.workato_api.models.export_manifest_request import ( - ExportManifestRequest, -) -from workato_platform_cli.client.workato_api.models.project import Project - - -class ProjectManager: - """Manages Workato project operations using the new API client""" - - def __init__(self, workato_api_client: Workato): - self.client = workato_api_client - - def _format_project_display(self, project: Project) -> str: - """Format a project object for display - returns formatted string""" - return f"{project.name} (ID: {project.id})" - - def _get_project_by_display_name( - self, - projects: list[Project], - display_name: str, - ) -> Project | None: - """Find a project by its display name - returns project object or None""" - for project in projects: - if self._format_project_display(project) == display_name: - return project - return None - - async def get_projects(self, page: int = 1, per_page: int = 100) -> list[Project]: - """Get list of projects with pagination""" - return await self.client.projects_api.list_projects( - page=page, per_page=per_page - ) - - async def get_all_projects(self) -> list[Project]: - """Get all projects by handling pagination""" - all_projects = [] - page = 1 - per_page = 100 - - while True: - projects = await self.get_projects(page, per_page) - - if not projects: - break - - all_projects.extend(projects) - - # If we got fewer than per_page results, we're on the last page - if len(projects) < per_page: - break - - page += 1 - - return all_projects - - async def create_project(self, project_name: str) -> Project: - """Create a new project folder""" - folder_data = await self.client.folders_api.create_folder( - create_folder_request=CreateFolderRequest(name=project_name) - ) - - for project in await self.get_all_projects(): - if project.folder_id == folder_data.id: - return project - - return Project( - id=folder_data.id, - name=project_name, - folder_id=folder_data.id, - description="", - ) - - async def check_folder_assets(self, folder_id: int) -> list[Asset]: - """Check if a folder has any assets""" - assets_response = await self.client.export_api.list_assets_in_folder( - folder_id=folder_id - ) - assets = ( - assets_response.result.assets - if assets_response.result and assets_response.result.assets - else [] - ) - return assets - - async def export_project( - self, - folder_id: int, - project_name: str, - target_dir: str = "project", - ) -> str | None: - """Export project assets and return project directory path""" - # Check if project has any assets before attempting export - assets = await self.check_folder_assets(folder_id) - - if not assets: - # Empty project - just create the directory structure - click.echo("๐Ÿ“‚ Project is empty, creating directory structure...") - project_dir = Path(target_dir) - project_dir.mkdir(exist_ok=True) - return str(project_dir) - - # Create export manifest with spinner - spinner = Spinner("Creating export manifest") - spinner.start() - try: - manifest_data = await self.client.export_api.create_export_manifest( - create_export_manifest_request=CreateExportManifestRequest( - export_manifest=ExportManifestRequest( - name=project_name, - folder_id=folder_id, - auto_generate_assets=True, - ) - ) - ) - manifest_id = manifest_data.result.id - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Export manifest created: {manifest_id} ({elapsed:.1f}s)") - - # Trigger the export package creation - spinner = Spinner("Triggering export package") - spinner.start() - try: - package_data = await self.client.packages_api.export_package( - id=str(manifest_id) - ) - package_id = package_data.id - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Export package triggered: {package_id} ({elapsed:.1f}s)") - - # Poll for package completion and download - extracted_project_dir = await self.download_and_extract_package( - package_id, - target_dir, - ) - return str(extracted_project_dir) - - async def download_and_extract_package( - self, package_id: int, target_dir: str = "project" - ) -> Path | None: - """Download and extract the package, return project directory path""" - - # Poll package status until completed with dynamic status - spinner = Spinner("Preparing package") - spinner.start() - - max_wait_time = 300 # 5 minutes timeout - start_time = time.time() - - try: - while time.time() - start_time < max_wait_time: - package_response = await self.client.packages_api.get_package( - package_id=package_id - ) - status = package_response.status - - if status == "completed": - break - elif status == "failed": - spinner.stop() - click.echo("โŒ Package export failed") - - # Show error details if available - if package_response.error: - click.echo(f" ๐Ÿ“„ Error: {package_response.error}") - if package_response.recipe_status: - click.echo(" ๐Ÿ“‹ Detailed errors:") - for error in package_response.recipe_status: - click.echo(f" โ€ข {error}") - - return None - else: - # Update spinner message with current status - spinner.update_message(f"Processing package ({status})") - time.sleep(2) # Wait 2 seconds before polling again - - # Check if we timed out - if time.time() - start_time >= max_wait_time: - spinner.stop() - click.echo( - f"โฐ Package still processing after {max_wait_time // 60} minutes" - ) - click.echo(" ๐Ÿ’ก Check status manually or try again later") - click.echo(f" ๐Ÿ“Š Package ID: {package_id}") - return None - - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Package ready for download ({elapsed:.1f}s)") - - # Download the package using the direct download API - spinner = Spinner("Downloading package") - spinner.start() - try: - download_response = await self.client.packages_api.download_package( - package_id=package_id - ) - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Package downloaded ({elapsed:.1f}s)") - - # Create project directory and extract - spinner = Spinner("Extracting files") - spinner.start() - try: - project_dir = Path(target_dir) - project_dir.mkdir(exist_ok=True) - - # Save and extract zip file - zip_path = f"{target_dir}.zip" - with open(zip_path, "wb") as f: - f.write(download_response) - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(project_dir) - - os.remove(zip_path) - finally: - elapsed = spinner.stop() - - # Show clean message - don't expose internal temp paths to the user - click.echo(f"โœ… Project assets extracted ({elapsed:.1f}s)") - return project_dir - - async def handle_post_api_sync( - self, - ) -> None: - """Handle syncing project files after API resource operations""" - # Auto-sync is always enabled - run pull automatically - click.echo() - click.echo("๐Ÿ”„ Auto-syncing project files...") - try: - # Find the workato executable to use full path for security - workato_exe = shutil.which("workato") - if workato_exe is None: - click.echo("โš ๏ธ Could not find workato executable for auto-sync") - return - - # Secure subprocess call: hardcoded command, validated executable path - result = subprocess.run( # noqa: S603 - [workato_exe, "pull"], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - click.echo("โœ… Project synced successfully") - else: - click.echo(f"โš ๏ธ Sync completed with warnings: {result.stderr.strip()}") - except subprocess.TimeoutExpired: - click.echo("โš ๏ธ Sync timed out - please run 'workato pull' manually") - - async def delete_project(self, project_id: int) -> None: - """Delete a project (folder and assets are automatically cleaned up)""" - await self.client.projects_api.delete_project(project_id=project_id) - - def save_project_to_config(self, project: Project) -> None: - """Save project info to config - returns True if successful""" - from workato_platform_cli.cli.utils.config import ConfigManager - - config_manager = ConfigManager() - - meta_data = config_manager.load_config() - meta_data.project_id = project.id - meta_data.project_name = project.name - meta_data.folder_id = project.folder_id - - config_manager.save_config(meta_data) - - async def import_existing_project(self) -> Project | None: - """Import an existing project - returns project info or None if failed""" - projects = await self.client.projects_api.list_projects() - - if not projects: - click.echo("No projects found in your workspace.") - return None - - # Create project choices using utility function - choices = [self._format_project_display(p) for p in projects] - questions = [ - inquirer.List( - "project", # noboost - message="Select a project:", # noboost - choices=choices, - ) - ] - - answers = inquirer.prompt(questions) - if not answers: - return None - - # Find selected project using utility function - selected_project = self._get_project_by_display_name( - projects, answers["project"] - ) - - if not selected_project: - return None - # Save project info using utility function - - self.save_project_to_config(selected_project) - click.echo(f"โœ… Selected project: {selected_project.name}") - - # Export project files - if selected_project.folder_id: - await self.export_project( - folder_id=selected_project.folder_id, - project_name=selected_project.name, - ) - - return selected_project +"""ProjectManager for handling Workato project operations""" + +import os +import shutil +import subprocess # noqa: S404 +import time +import zipfile + +from pathlib import Path + +import asyncclick as click +import inquirer + +from workato_platform_cli import Workato +from workato_platform_cli.cli.utils.spinner import Spinner +from workato_platform_cli.client.workato_api.models.asset import Asset +from workato_platform_cli.client.workato_api.models.create_export_manifest_request import ( # noqa: E501 + CreateExportManifestRequest, +) +from workato_platform_cli.client.workato_api.models.create_folder_request import ( + CreateFolderRequest, +) +from workato_platform_cli.client.workato_api.models.export_manifest_request import ( + ExportManifestRequest, +) +from workato_platform_cli.client.workato_api.models.project import Project + + +class ProjectManager: + """Manages Workato project operations using the new API client""" + + def __init__(self, workato_api_client: Workato): + self.client = workato_api_client + + def _format_project_display(self, project: Project) -> str: + """Format a project object for display - returns formatted string""" + return f"{project.name} (ID: {project.id})" + + def _get_project_by_display_name( + self, + projects: list[Project], + display_name: str, + ) -> Project | None: + """Find a project by its display name - returns project object or None""" + for project in projects: + if self._format_project_display(project) == display_name: + return project + return None + + async def get_projects(self, page: int = 1, per_page: int = 100) -> list[Project]: + """Get list of projects with pagination""" + return await self.client.projects_api.list_projects( + page=page, per_page=per_page + ) + + async def get_all_projects(self) -> list[Project]: + """Get all projects by handling pagination""" + all_projects = [] + page = 1 + per_page = 100 + + while True: + projects = await self.get_projects(page, per_page) + + if not projects: + break + + all_projects.extend(projects) + + # If we got fewer than per_page results, we're on the last page + if len(projects) < per_page: + break + + page += 1 + + return all_projects + + async def create_project(self, project_name: str) -> Project: + """Create a new project folder""" + folder_data = await self.client.folders_api.create_folder( + create_folder_request=CreateFolderRequest(name=project_name) + ) + + for project in await self.get_all_projects(): + if project.folder_id == folder_data.id: + return project + + return Project( + id=folder_data.id, + name=project_name, + folder_id=folder_data.id, + description="", + ) + + async def check_folder_assets(self, folder_id: int) -> list[Asset]: + """Check if a folder has any assets""" + assets_response = await self.client.export_api.list_assets_in_folder( + folder_id=folder_id + ) + assets = ( + assets_response.result.assets + if assets_response.result and assets_response.result.assets + else [] + ) + return assets + + @staticmethod + def _parse_bool_value(value: str | None) -> bool | None: + """Parse common string boolean formats to bool.""" + if value is None: + return None + + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return None + + def _resolve_export_manifest_flags( + self, + include_tags: bool | None, + include_test_cases: bool | None, + ) -> tuple[bool, bool]: + """Resolve export manifest flags.""" + config_include_tags: bool | None = None + config_include_test_cases: bool | None = None + + try: + from workato_platform_cli.cli.utils.config import ConfigManager + + config_data = ConfigManager(skip_validation=True).load_config() + config_include_tags = config_data.export_include_tags + config_include_test_cases = config_data.export_include_test_cases + except Exception: + # Export should still work even if local config cannot be loaded. + click.echo("โš ๏ธ Could not load config, using defaults") + pass + + env_include_tags = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TAGS") + ) + env_include_test_cases = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TEST_CASES") + ) + + resolved_include_tags = ( + include_tags + if include_tags is not None + else config_include_tags + if config_include_tags is not None + else env_include_tags + if env_include_tags is not None + else False + ) + resolved_include_test_cases = ( + include_test_cases + if include_test_cases is not None + else config_include_test_cases + if config_include_test_cases is not None + else env_include_test_cases + if env_include_test_cases is not None + else False + ) + + return resolved_include_tags, resolved_include_test_cases + + async def export_project( + self, + folder_id: int, + project_name: str, + target_dir: str = "project", + include_tags: bool | None = None, + include_test_cases: bool | None = None, + ) -> str | None: + """Export project assets and return project directory path""" + resolved_include_tags, resolved_include_test_cases = ( + self._resolve_export_manifest_flags(include_tags, include_test_cases) + ) + + # Check if project has any assets before attempting export + assets = await self.check_folder_assets(folder_id) + + if not assets: + # Empty project - just create the directory structure + click.echo("๐Ÿ“‚ Project is empty, creating directory structure...") + project_dir = Path(target_dir) + project_dir.mkdir(exist_ok=True) + return str(project_dir) + + # Create export manifest with spinner + spinner = Spinner("Creating export manifest") + spinner.start() + try: + manifest_data = await self.client.export_api.create_export_manifest( + create_export_manifest_request=CreateExportManifestRequest( + export_manifest=ExportManifestRequest( + name=project_name, + folder_id=folder_id, + auto_generate_assets=True, + include_tags=resolved_include_tags, + include_test_cases=resolved_include_test_cases, + ) + ) + ) + manifest_id = manifest_data.result.id + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Export manifest created: {manifest_id} ({elapsed:.1f}s)") + + # Trigger the export package creation + spinner = Spinner("Triggering export package") + spinner.start() + try: + package_data = await self.client.packages_api.export_package( + id=str(manifest_id) + ) + package_id = package_data.id + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Export package triggered: {package_id} ({elapsed:.1f}s)") + + # Poll for package completion and download + extracted_project_dir = await self.download_and_extract_package( + package_id, + target_dir, + ) + return str(extracted_project_dir) + + async def download_and_extract_package( + self, package_id: int, target_dir: str = "project" + ) -> Path | None: + """Download and extract the package, return project directory path""" + + # Poll package status until completed with dynamic status + spinner = Spinner("Preparing package") + spinner.start() + + max_wait_time = 300 # 5 minutes timeout + start_time = time.time() + + try: + while time.time() - start_time < max_wait_time: + package_response = await self.client.packages_api.get_package( + package_id=package_id + ) + status = package_response.status + + if status == "completed": + break + elif status == "failed": + spinner.stop() + click.echo("โŒ Package export failed") + + # Show error details if available + if package_response.error: + click.echo(f" ๐Ÿ“„ Error: {package_response.error}") + if package_response.recipe_status: + click.echo(" ๐Ÿ“‹ Detailed errors:") + for error in package_response.recipe_status: + click.echo(f" โ€ข {error}") + + return None + else: + # Update spinner message with current status + spinner.update_message(f"Processing package ({status})") + time.sleep(2) # Wait 2 seconds before polling again + + # Check if we timed out + if time.time() - start_time >= max_wait_time: + spinner.stop() + click.echo( + f"โฐ Package still processing after {max_wait_time // 60} minutes" + ) + click.echo(" ๐Ÿ’ก Check status manually or try again later") + click.echo(f" ๐Ÿ“Š Package ID: {package_id}") + return None + + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Package ready for download ({elapsed:.1f}s)") + + # Download the package using the direct download API + spinner = Spinner("Downloading package") + spinner.start() + try: + download_response = await self.client.packages_api.download_package( + package_id=package_id + ) + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Package downloaded ({elapsed:.1f}s)") + + # Create project directory and extract + spinner = Spinner("Extracting files") + spinner.start() + try: + project_dir = Path(target_dir) + project_dir.mkdir(exist_ok=True) + + # Save and extract zip file + zip_path = f"{target_dir}.zip" + with open(zip_path, "wb") as f: + f.write(download_response) + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(project_dir) + + os.remove(zip_path) + finally: + elapsed = spinner.stop() + + # Show clean message - don't expose internal temp paths to the user + click.echo(f"โœ… Project assets extracted ({elapsed:.1f}s)") + return project_dir + + async def handle_post_api_sync( + self, + ) -> None: + """Handle syncing project files after API resource operations""" + # Auto-sync is always enabled - run pull automatically + click.echo() + click.echo("๐Ÿ”„ Auto-syncing project files...") + try: + # Find the workato executable to use full path for security + workato_exe = shutil.which("workato") + if workato_exe is None: + click.echo("โš ๏ธ Could not find workato executable for auto-sync") + return + + # Secure subprocess call: hardcoded command, validated executable path + result = subprocess.run( # noqa: S603 + [workato_exe, "pull"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + click.echo("โœ… Project synced successfully") + else: + click.echo(f"โš ๏ธ Sync completed with warnings: {result.stderr.strip()}") + except subprocess.TimeoutExpired: + click.echo("โš ๏ธ Sync timed out - please run 'workato pull' manually") + + async def delete_project(self, project_id: int) -> None: + """Delete a project (folder and assets are automatically cleaned up)""" + await self.client.projects_api.delete_project(project_id=project_id) + + def save_project_to_config(self, project: Project) -> None: + """Save project info to config - returns True if successful""" + from workato_platform_cli.cli.utils.config import ConfigManager + + config_manager = ConfigManager() + + meta_data = config_manager.load_config() + meta_data.project_id = project.id + meta_data.project_name = project.name + meta_data.folder_id = project.folder_id + + config_manager.save_config(meta_data) + + async def import_existing_project(self) -> Project | None: + """Import an existing project - returns project info or None if failed""" + projects = await self.client.projects_api.list_projects() + + if not projects: + click.echo("No projects found in your workspace.") + return None + + # Create project choices using utility function + choices = [self._format_project_display(p) for p in projects] + questions = [ + inquirer.List( + "project", # noboost + message="Select a project:", # noboost + choices=choices, + ) + ] + + answers = inquirer.prompt(questions) + if not answers: + return None + + # Find selected project using utility function + selected_project = self._get_project_by_display_name( + projects, answers["project"] + ) + + if not selected_project: + return None + # Save project info using utility function + + self.save_project_to_config(selected_project) + click.echo(f"โœ… Selected project: {selected_project.name}") + + # Export project files + if selected_project.folder_id: + await self.export_project( + folder_id=selected_project.folder_id, + project_name=selected_project.name, + ) + + return selected_project diff --git a/src/workato_platform_cli/cli/utils/config/models.py b/src/workato_platform_cli/cli/utils/config/models.py index 923ddfb..1691de1 100644 --- a/src/workato_platform_cli/cli/utils/config/models.py +++ b/src/workato_platform_cli/cli/utils/config/models.py @@ -1,89 +1,99 @@ -"""Data models for configuration management.""" - -from pydantic import BaseModel, Field, field_validator - - -class ProjectInfo(BaseModel): - """Data model for project information""" - - id: int = Field(..., description="Project ID") - name: str = Field(..., description="Project name") - folder_id: int | None = Field(None, description="Associated folder ID") - - -class ConfigData(BaseModel): - """Data model for configuration file data""" - - project_id: int | None = Field(None, description="Project ID") - project_name: str | None = Field(None, description="Project name") - project_path: str | None = Field( - None, description="Relative path to project (workspace only)" - ) - folder_id: int | None = Field(None, description="Folder ID") - profile: str | None = Field(None, description="Profile override") - - -class RegionInfo(BaseModel): - """Data model for region information""" - - region: str = Field(..., description="Region code") - name: str = Field(..., description="Human-readable region name") - url: str | None = Field(None, description="Base URL for the region") - - -class ProfileData(BaseModel): - """Data model for a single profile""" - - region: str = Field( - ..., description="Region code (us, eu, jp, sg, au, il, trial, custom)" - ) - region_url: str = Field(..., description="Base URL for the region") - workspace_id: int = Field(..., description="Workspace ID") - - @field_validator("region") - def validate_region(cls, v: str) -> str: # noqa: N805 - """Validate region code""" - valid_regions = {"us", "eu", "jp", "sg", "au", "il", "trial", "custom"} - if v not in valid_regions: - raise ValueError(f"Invalid region code: {v}") - return v - - @property - def region_name(self) -> str: - """Get human-readable region name from region code""" - region_info = AVAILABLE_REGIONS.get(self.region) - return region_info.name if region_info else f"Unknown ({self.region})" - - -class ProfilesConfig(BaseModel): - """Data model for profiles file (~/.workato/profiles)""" - - current_profile: str | None = Field(None, description="Currently active profile") - profiles: dict[str, ProfileData] = Field( - default_factory=dict, description="Profile definitions" - ) - - -# Available Workato regions -AVAILABLE_REGIONS = { - "us": RegionInfo(region="us", name="US Data Center", url="https://www.workato.com"), - "eu": RegionInfo( - region="eu", name="EU Data Center", url="https://app.eu.workato.com" - ), - "jp": RegionInfo( - region="jp", name="JP Data Center", url="https://app.jp.workato.com" - ), - "sg": RegionInfo( - region="sg", name="SG Data Center", url="https://app.sg.workato.com" - ), - "au": RegionInfo( - region="au", name="AU Data Center", url="https://app.au.workato.com" - ), - "il": RegionInfo( - region="il", name="IL Data Center", url="https://app.il.workato.com" - ), - "trial": RegionInfo( - region="trial", name="Developer Sandbox", url="https://app.trial.workato.com" - ), - "custom": RegionInfo(region="custom", name="Custom URL", url=None), -} +"""Data models for configuration management.""" + +from pydantic import BaseModel, Field, field_validator + + +class ProjectInfo(BaseModel): + """Data model for project information""" + + id: int = Field(..., description="Project ID") + name: str = Field(..., description="Project name") + folder_id: int | None = Field(None, description="Associated folder ID") + + +class ConfigData(BaseModel): + """Data model for configuration file data""" + + project_id: int | None = Field(None, description="Project ID") + project_name: str | None = Field(None, description="Project name") + project_path: str | None = Field( + None, description="Relative path to project (workspace only)" + ) + folder_id: int | None = Field(None, description="Folder ID") + profile: str | None = Field(None, description="Profile override") + export_include_tags: bool | None = Field( + None, + description="Default value for include_tags when exporting project manifests", + ) + export_include_test_cases: bool | None = Field( + None, + description=( + "Default value for include_test_cases when exporting project manifests" + ), + ) + + +class RegionInfo(BaseModel): + """Data model for region information""" + + region: str = Field(..., description="Region code") + name: str = Field(..., description="Human-readable region name") + url: str | None = Field(None, description="Base URL for the region") + + +class ProfileData(BaseModel): + """Data model for a single profile""" + + region: str = Field( + ..., description="Region code (us, eu, jp, sg, au, il, trial, custom)" + ) + region_url: str = Field(..., description="Base URL for the region") + workspace_id: int = Field(..., description="Workspace ID") + + @field_validator("region") + def validate_region(cls, v: str) -> str: # noqa: N805 + """Validate region code""" + valid_regions = {"us", "eu", "jp", "sg", "au", "il", "trial", "custom"} + if v not in valid_regions: + raise ValueError(f"Invalid region code: {v}") + return v + + @property + def region_name(self) -> str: + """Get human-readable region name from region code""" + region_info = AVAILABLE_REGIONS.get(self.region) + return region_info.name if region_info else f"Unknown ({self.region})" + + +class ProfilesConfig(BaseModel): + """Data model for profiles file (~/.workato/profiles)""" + + current_profile: str | None = Field(None, description="Currently active profile") + profiles: dict[str, ProfileData] = Field( + default_factory=dict, description="Profile definitions" + ) + + +# Available Workato regions +AVAILABLE_REGIONS = { + "us": RegionInfo(region="us", name="US Data Center", url="https://www.workato.com"), + "eu": RegionInfo( + region="eu", name="EU Data Center", url="https://app.eu.workato.com" + ), + "jp": RegionInfo( + region="jp", name="JP Data Center", url="https://app.jp.workato.com" + ), + "sg": RegionInfo( + region="sg", name="SG Data Center", url="https://app.sg.workato.com" + ), + "au": RegionInfo( + region="au", name="AU Data Center", url="https://app.au.workato.com" + ), + "il": RegionInfo( + region="il", name="IL Data Center", url="https://app.il.workato.com" + ), + "trial": RegionInfo( + region="trial", name="Developer Sandbox", url="https://app.trial.workato.com" + ), + "custom": RegionInfo(region="custom", name="Custom URL", url=None), +} diff --git a/src/workato_platform_cli/client/workato_api/models/api_client.py b/src/workato_platform_cli/client/workato_api/models/api_client.py index 1cbabf4..0cf9cb9 100644 --- a/src/workato_platform_cli/client/workato_api/models/api_client.py +++ b/src/workato_platform_cli/client/workato_api/models/api_client.py @@ -45,8 +45,8 @@ class ApiClient(BaseModel): mtls_enabled: Optional[StrictBool] = None validation_formula: Optional[StrictStr] = None cert_bundle_ids: Optional[List[StrictInt]] = None - api_policies: Optional[List[ApiClientApiPoliciesInner]] = Field(default=None, description="List of API policies associated with the client") - api_collections: Optional[List[ApiClientApiCollectionsInner]] = Field(default=None, description="List of API collections associated with the client") + api_policies: List[ApiClientApiPoliciesInner] = Field(description="List of API policies associated with the client") + api_collections: List[ApiClientApiCollectionsInner] = Field(description="List of API collections associated with the client") __properties: ClassVar[List[str]] = ["id", "name", "description", "active_api_keys_count", "total_api_keys_count", "created_at", "updated_at", "logo", "logo_2x", "is_legacy", "email", "auth_type", "api_token", "mtls_enabled", "validation_formula", "cert_bundle_ids", "api_policies", "api_collections"] @field_validator('auth_type') diff --git a/src/workato_platform_cli/client/workato_api/models/api_collection.py b/src/workato_platform_cli/client/workato_api/models/api_collection.py index bc3a644..0774649 100644 --- a/src/workato_platform_cli/client/workato_api/models/api_collection.py +++ b/src/workato_platform_cli/client/workato_api/models/api_collection.py @@ -18,8 +18,8 @@ import json from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator -from typing import Any, ClassVar, Dict, List, Optional, Union +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from workato_platform_cli.client.workato_api.models.import_results import ImportResults from typing import Optional, Set from typing_extensions import Self @@ -30,7 +30,7 @@ class ApiCollection(BaseModel): """ # noqa: E501 id: StrictInt name: StrictStr - project_id: Optional[Union[StrictStr, StrictInt]] = None + project_id: StrictStr url: StrictStr api_spec_url: StrictStr version: StrictStr @@ -40,14 +40,6 @@ class ApiCollection(BaseModel): import_results: Optional[ImportResults] = None __properties: ClassVar[List[str]] = ["id", "name", "project_id", "url", "api_spec_url", "version", "created_at", "updated_at", "message", "import_results"] - @field_validator('project_id', mode='before') - @classmethod - def coerce_project_id_to_string(cls, v): - """Coerce project_id to string since API may return int or string""" - if v is not None: - return str(v) - return v - model_config = ConfigDict( populate_by_name=True, validate_assignment=True, diff --git a/tests/unit/commands/projects/test_command.py b/tests/unit/commands/projects/test_command.py index 4f81454..9980f64 100644 --- a/tests/unit/commands/projects/test_command.py +++ b/tests/unit/commands/projects/test_command.py @@ -1,1140 +1,1223 @@ -"""Unit tests for the projects CLI command module.""" - -from __future__ import annotations - -import json -import sys - -from collections.abc import Iterator -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from workato_platform_cli.cli.commands.projects import command -from workato_platform_cli.cli.utils.config import ConfigData - - -@pytest.fixture(autouse=True) -def capture_echo(monkeypatch: pytest.MonkeyPatch) -> list[str]: - captured: list[str] = [] - - def _capture(message: str = "") -> None: - captured.append(message) - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.click.echo", - _capture, - ) - return captured - - -@pytest.mark.asyncio -async def test_list_projects_no_directory( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - monkeypatch.chdir(tmp_path) - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] # No projects found - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_list_projects_with_entries( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - projects_dir = workspace / "projects" - alpha_project = projects_dir / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text( - '{"project_id": 5, "project_name": "Alpha", ' - '"folder_id": 9, "profile": "default"}', - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - project_config = ConfigData( - project_id=5, project_name="Alpha", folder_id=9, profile="default" - ) - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return project_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "alpha" in output - assert "Folder ID" in output - - -@pytest.mark.asyncio -async def test_use_project_success( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - workspace_config = ConfigData() - - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - (project_dir / ".workatoenv").write_text( - '{"project_id": 3, "project_name": "Beta", "folder_id": 7, "profile": "p1"}' - ) - - project_config = ConfigData( - project_id=3, project_name="Beta", folder_id=7, profile="p1" - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - config_manager.load_config.return_value = workspace_config - config_manager.save_config = Mock() - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return project_config if self.path == project_dir else workspace_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - saved = config_manager.save_config.call_args.args[0] - assert saved.project_id == 3 - assert saved.project_path == "projects/beta" - assert "Switched to project" in "\n".join(capture_echo) - - -@pytest.mark.asyncio -async def test_use_project_not_found(tmp_path: Path, capture_echo: list[str]) -> None: - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager._find_all_projects.return_value = [] # No projects found - - await command.use.callback(project_name="missing", config_manager=config_manager) # type: ignore[misc] - - assert any("not found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_interactive( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text( - '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (workspace / "alpha", "alpha"), - (beta_project, "beta"), - ] - config_manager.load_config.return_value = ConfigData() - config_manager.save_config = Mock() - - selected_config = ConfigData( - project_id=9, project_name="Beta", folder_id=11, profile="default" - ) - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - if self.path == beta_project: - return selected_config - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - config_manager.save_config.assert_called_once() - assert "Switched to project 'beta'" in "\n".join(capture_echo) - - -@pytest.mark.asyncio -async def test_switch_keeps_current_when_only_one( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: None, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("already current" in line for line in capture_echo) - - -def test_project_group_exists() -> None: - """Test that the project group command exists.""" - assert callable(command.projects) - - # Test that it's a click group - import asyncclick as click - - assert isinstance(command.projects, click.Group) - assert command.projects.callback is not None - assert command.projects.callback() is None - - -@pytest.mark.asyncio -async def test_list_projects_empty_directory( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when projects directory exists but is empty.""" - workspace = tmp_path - projects_dir = workspace / "projects" - projects_dir.mkdir() # Create empty projects directory - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] # Empty directory - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_list_projects_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when project has configuration error.""" - workspace = tmp_path - projects_dir = workspace / "projects" - alpha_project = projects_dir / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - # Mock ConfigManager to raise exception - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "configuration error" in output - - -@pytest.mark.asyncio -async def test_list_projects_json_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """JSON mode should surface configuration errors.""" - - workspace = tmp_path - project_dir = workspace / "projects" / "alpha" - project_dir.mkdir(parents=True) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(project_dir, "alpha")] - - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("broken") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.list_projects.callback( # type: ignore[misc] - output_mode="json", config_manager=config_manager - ) - - assert capture_echo, "Expected JSON output" - data = json.loads("".join(capture_echo)) - assert data["local_projects"][0]["configured"] is False - assert "configuration error" in data["local_projects"][0]["error"] - - -@pytest.mark.asyncio -async def test_list_projects_workspace_root_fallback( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when workspace root is None, falls back to cwd.""" - monkeypatch.chdir(tmp_path) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = Path.cwd() - config_manager._find_all_projects.return_value = [] # Force fallback - config_manager.get_current_project_name.return_value = None - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_use_project_not_configured( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test use project when project exists but is not configured.""" - workspace = tmp_path - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - # No .workatoenv file created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - - # Mock ConfigManager to raise exception for unconfigured project - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - output = "\n".join(capture_echo) - assert "configuration errors" in output - - -@pytest.mark.asyncio -async def test_use_project_exception_handling( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test use project exception handling.""" - workspace = tmp_path - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - (project_dir / ".workatoenv").write_text( - '{"project_id": 3, "project_name": "Beta", "folder_id": 7}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - config_manager.load_config.side_effect = Exception( - "Config error" - ) # Force exception - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - output = "\n".join(capture_echo) - assert "Failed to switch to project" in output - - -@pytest.mark.asyncio -async def test_switch_workspace_root_fallback( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when workspace root is None, falls back to cwd.""" - monkeypatch.chdir(tmp_path) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = Path.cwd() - config_manager._find_all_projects.return_value = [] # Force fallback - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_projects_directory( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when no projects directory exists.""" - workspace = tmp_path - # No projects directory created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [] - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command with configuration error.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - # Mock ConfigManager to raise exception - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (configuration error)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "configuration errors" in output - - -@pytest.mark.asyncio -async def test_switch_config_error_current_project( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Config errors on the current project should report already current.""" - - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = "alpha" - - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (configuration error) (current)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "already current" in output - - -@pytest.mark.asyncio -async def test_switch_no_configured_projects( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when no configured projects found.""" - workspace = tmp_path - projects_dir = workspace / "projects" - projects_dir.mkdir() - # Create directory but no projects with .workatoenv - - project_dir = projects_dir / "unconfigured" - project_dir.mkdir() - # No .workatoenv file created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [] # No configured projects - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_project_choices_after_iteration( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Guard clause should trigger when iteration yields nothing.""" - - class TruthyEmpty: - def __iter__(self) -> Iterator[tuple[Path, str]]: - return iter(()) - - def __bool__(self) -> bool: - return True - - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager._find_all_projects.return_value = TruthyEmpty() - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No configured projects" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_project_selected( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when user cancels selection.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: None, # User cancelled - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No project selected" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_failed_to_identify_project( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when selected project can't be identified.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "nonexistent"}, # Select non-matching project - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("Failed to identify selected project" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_already_current( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when selected project is already current.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text('{"project_name": "beta"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (alpha_project, "alpha"), - (beta_project, "beta"), - ] - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - if self.path == alpha_project: - return ConfigData(project_name="alpha") - return ConfigData(project_name="beta") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (current)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("already current" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_missing_project_path( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """If the project list becomes stale, path lookup should fail gracefully.""" - - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - - class OneShot: - def __init__(self, entry: tuple[Path, str]) -> None: - self.entry = entry - self.iterations = 0 - - def __iter__(self) -> Iterator[tuple[Path, str]]: - if self.iterations == 0: - self.iterations += 1 - return iter([self.entry]) - return iter(()) - - def __bool__(self) -> bool: - return True - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = OneShot((beta_project, "beta")) - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - - def load_config(self) -> ConfigData: - return ConfigData(project_name="Beta Display") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta Display)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("Failed to find path" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_exception_handling( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command exception handling.""" - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text( - '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (workspace / "alpha", "alpha"), - (beta_project, "beta"), - ] - config_manager.load_config.side_effect = Exception( - "Config error" - ) # Force exception - - selected_config = ConfigData(project_id=9, project_name="Beta", folder_id=11) - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - if self.path == beta_project: - return selected_config - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "Failed to switch to project" in output - - -@pytest.mark.asyncio -async def test_list_projects_json_output_mode( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list_projects with JSON output mode.""" - workspace_root = tmp_path / "workspace" - project_path = workspace_root / "test-project" - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace_root - config_manager.get_current_project_name.return_value = "test-project" - config_manager._find_all_projects.return_value = [(project_path, "test-project")] - - # Mock project config manager - project_config = ConfigData( - project_id=123, project_name="Test Project", folder_id=456, profile="dev" - ) - mock_project_config_manager = Mock() - mock_project_config_manager.load_config.return_value = project_config - - with patch( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - return_value=mock_project_config_manager, - ): - assert command.list_projects.callback - await command.list_projects.callback( - output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - - # Parse JSON output - import json - - parsed = json.loads(output) - - assert parsed["current_project"] == "test-project" - assert len(parsed["local_projects"]) == 1 - project = parsed["local_projects"][0] - assert project["name"] == "test-project" - assert project["is_current"] is True - assert project["project_id"] == 123 - assert project["folder_id"] == 456 - assert project["profile"] == "dev" - assert project["configured"] is True - - -@pytest.mark.asyncio -async def test_list_projects_json_output_mode_empty( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list_projects JSON output with no projects.""" - workspace_root = tmp_path / "workspace" - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace_root - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] - - assert command.list_projects.callback - await command.list_projects.callback( - output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - - # Parse JSON output - import json - - parsed = json.loads(output) - - assert parsed["current_project"] is None - assert parsed["local_projects"] == [] - - -@pytest.mark.asyncio -async def test_list_projects_remote_source( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with remote source.""" - config_manager = Mock() - - # Mock create_profile_aware_workato_config and Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - - # Mock remote projects - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project = Project( - id=123, name="Remote Project", folder_id=456, description="A remote project" - ) - mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="remote", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - assert "Remote projects:" in output - assert "Remote Project" in output - assert "Project ID: 123" in output - - -@pytest.mark.asyncio -async def test_list_projects_remote_source_json( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with remote source JSON output.""" - config_manager = Mock() - config_manager.get_workspace_root.return_value = None - config_manager.get_current_project_name.return_value = None - - # Mock create_profile_aware_workato_config and Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - - # Mock remote projects - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project = Project( - id=123, name="Remote Project", folder_id=456, description="A remote project" - ) - mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="remote", output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - parsed = json.loads(output) - - assert parsed["source"] == "remote" - assert len(parsed["remote_projects"]) == 1 - remote = parsed["remote_projects"][0] - assert remote["name"] == "Remote Project" - assert remote["project_id"] == 123 - assert remote["folder_id"] == 456 - assert remote["description"] == "A remote project" - assert remote["has_local_copy"] is False - - -@pytest.mark.asyncio -async def test_list_projects_both_source( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects with both local and remote source.""" - workspace = tmp_path - alpha_project = workspace / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text( - '{"project_id": 123, "project_name": "Alpha", "folder_id": 456}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - # Mock local project config loading - project_config = Mock() - project_config.project_id = 123 - project_config.project_name = "Alpha" - project_config.folder_id = 456 - project_config.profile = "dev" - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - pass - - def load_config(self) -> ConfigData: - return project_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - # Mock remote projects - mock_workato_client = Mock() - mock_project_manager = Mock() - - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project1 = Project( - id=123, name="Alpha", folder_id=456, description="Synced project" - ) - remote_project2 = Project( - id=789, name="Remote Only", folder_id=999, description="Remote only project" - ) - mock_project_manager.get_all_projects = AsyncMock( - return_value=[remote_project1, remote_project2] - ) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="both", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - assert "All projects (local + remote):" in output - assert "Remote Only" in output - assert "synced" in output # Alpha should be marked as synced - assert "remote only" in output # Remote Only should be marked as remote only - # Alpha project should be shown (either as local "alpha" or remote "Alpha") - assert "alpha" in output.lower() or "Alpha" in output - - -@pytest.mark.asyncio -async def test_list_projects_with_profile( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with profile parameter.""" - config_manager = Mock() - config_manager.get_workspace_root.return_value = None - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] - - # Mock profile-aware config creation - mock_config = Mock() - mock_create_config = Mock(return_value=mock_config) - - # Mock Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - mock_project_manager.get_all_projects = AsyncMock(return_value=[]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - mock_create_config, - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - profile="test-profile", source="remote", config_manager=config_manager - ) - - # Verify that create_profile_aware_workato_config was called - # with the correct profile - mock_create_config.assert_called_once_with( - config_manager=config_manager, cli_profile="test-profile" - ) +"""Unit tests for the projects CLI command module.""" + +from __future__ import annotations + +import json +import sys + +from collections.abc import Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from workato_platform_cli.cli.commands.projects import command +from workato_platform_cli.cli.utils.config import ConfigData + + +@pytest.fixture(autouse=True) +def capture_echo(monkeypatch: pytest.MonkeyPatch) -> list[str]: + captured: list[str] = [] + + def _capture(message: str = "") -> None: + captured.append(message) + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.click.echo", + _capture, + ) + return captured + + +@pytest.mark.asyncio +async def test_list_projects_no_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + monkeypatch.chdir(tmp_path) + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] # No projects found + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_list_projects_with_entries( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + projects_dir = workspace / "projects" + alpha_project = projects_dir / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text( + '{"project_id": 5, "project_name": "Alpha", ' + '"folder_id": 9, "profile": "default"}', + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + project_config = ConfigData( + project_id=5, project_name="Alpha", folder_id=9, profile="default" + ) + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return project_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "alpha" in output + assert "Folder ID" in output + + +@pytest.mark.asyncio +async def test_use_project_success( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + workspace_config = ConfigData() + + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + (project_dir / ".workatoenv").write_text( + '{"project_id": 3, "project_name": "Beta", "folder_id": 7, "profile": "p1"}' + ) + + project_config = ConfigData( + project_id=3, project_name="Beta", folder_id=7, profile="p1" + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + config_manager.load_config.return_value = workspace_config + config_manager.save_config = Mock() + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return project_config if self.path == project_dir else workspace_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + saved = config_manager.save_config.call_args.args[0] + assert saved.project_id == 3 + assert saved.project_path == "projects/beta" + assert "Switched to project" in "\n".join(capture_echo) + + +@pytest.mark.asyncio +async def test_use_project_not_found(tmp_path: Path, capture_echo: list[str]) -> None: + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager._find_all_projects.return_value = [] # No projects found + + await command.use.callback(project_name="missing", config_manager=config_manager) # type: ignore[misc] + + assert any("not found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_interactive( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text( + '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (workspace / "alpha", "alpha"), + (beta_project, "beta"), + ] + config_manager.load_config.return_value = ConfigData() + config_manager.save_config = Mock() + + selected_config = ConfigData( + project_id=9, project_name="Beta", folder_id=11, profile="default" + ) + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + if self.path == beta_project: + return selected_config + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + config_manager.save_config.assert_called_once() + assert "Switched to project 'beta'" in "\n".join(capture_echo) + + +@pytest.mark.asyncio +async def test_switch_keeps_current_when_only_one( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: None, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("already current" in line for line in capture_echo) + + +def test_project_group_exists() -> None: + """Test that the project group command exists.""" + assert callable(command.projects) + + # Test that it's a click group + import asyncclick as click + + assert isinstance(command.projects, click.Group) + assert command.projects.callback is not None + assert command.projects.callback() is None + + +@pytest.mark.asyncio +async def test_list_projects_empty_directory( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when projects directory exists but is empty.""" + workspace = tmp_path + projects_dir = workspace / "projects" + projects_dir.mkdir() # Create empty projects directory + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] # Empty directory + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_list_projects_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when project has configuration error.""" + workspace = tmp_path + projects_dir = workspace / "projects" + alpha_project = projects_dir / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + # Mock ConfigManager to raise exception + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "configuration error" in output + + +@pytest.mark.asyncio +async def test_list_projects_json_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """JSON mode should surface configuration errors.""" + + workspace = tmp_path + project_dir = workspace / "projects" / "alpha" + project_dir.mkdir(parents=True) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(project_dir, "alpha")] + + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("broken") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.list_projects.callback( # type: ignore[misc] + output_mode="json", config_manager=config_manager + ) + + assert capture_echo, "Expected JSON output" + data = json.loads("".join(capture_echo)) + assert data["local_projects"][0]["configured"] is False + assert "configuration error" in data["local_projects"][0]["error"] + + +@pytest.mark.asyncio +async def test_list_projects_workspace_root_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when workspace root is None, falls back to cwd.""" + monkeypatch.chdir(tmp_path) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = Path.cwd() + config_manager._find_all_projects.return_value = [] # Force fallback + config_manager.get_current_project_name.return_value = None + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_use_project_not_configured( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test use project when project exists but is not configured.""" + workspace = tmp_path + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + # No .workatoenv file created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + + # Mock ConfigManager to raise exception for unconfigured project + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "configuration errors" in output + + +@pytest.mark.asyncio +async def test_use_project_exception_handling( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test use project exception handling.""" + workspace = tmp_path + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + (project_dir / ".workatoenv").write_text( + '{"project_id": 3, "project_name": "Beta", "folder_id": 7}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + config_manager.load_config.side_effect = Exception( + "Config error" + ) # Force exception + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "Failed to switch to project" in output + + +@pytest.mark.asyncio +async def test_switch_workspace_root_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when workspace root is None, falls back to cwd.""" + monkeypatch.chdir(tmp_path) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = Path.cwd() + config_manager._find_all_projects.return_value = [] # Force fallback + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_projects_directory( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when no projects directory exists.""" + workspace = tmp_path + # No projects directory created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [] + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command with configuration error.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + # Mock ConfigManager to raise exception + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (configuration error)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "configuration errors" in output + + +@pytest.mark.asyncio +async def test_switch_config_error_current_project( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Config errors on the current project should report already current.""" + + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = "alpha" + + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (configuration error) (current)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "already current" in output + + +@pytest.mark.asyncio +async def test_switch_no_configured_projects( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when no configured projects found.""" + workspace = tmp_path + projects_dir = workspace / "projects" + projects_dir.mkdir() + # Create directory but no projects with .workatoenv + + project_dir = projects_dir / "unconfigured" + project_dir.mkdir() + # No .workatoenv file created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [] # No configured projects + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_project_choices_after_iteration( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Guard clause should trigger when iteration yields nothing.""" + + class TruthyEmpty: + def __iter__(self) -> Iterator[tuple[Path, str]]: + return iter(()) + + def __bool__(self) -> bool: + return True + + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager._find_all_projects.return_value = TruthyEmpty() + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No configured projects" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_project_selected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when user cancels selection.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: None, # User cancelled + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No project selected" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_failed_to_identify_project( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when selected project can't be identified.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "nonexistent"}, # Select non-matching project + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("Failed to identify selected project" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_already_current( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when selected project is already current.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text('{"project_name": "beta"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (alpha_project, "alpha"), + (beta_project, "beta"), + ] + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + if self.path == alpha_project: + return ConfigData(project_name="alpha") + return ConfigData(project_name="beta") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (current)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("already current" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_missing_project_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """If the project list becomes stale, path lookup should fail gracefully.""" + + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + + class OneShot: + def __init__(self, entry: tuple[Path, str]) -> None: + self.entry = entry + self.iterations = 0 + + def __iter__(self) -> Iterator[tuple[Path, str]]: + if self.iterations == 0: + self.iterations += 1 + return iter([self.entry]) + return iter(()) + + def __bool__(self) -> bool: + return True + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = OneShot((beta_project, "beta")) + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + + def load_config(self) -> ConfigData: + return ConfigData(project_name="Beta Display") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta Display)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("Failed to find path" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_exception_handling( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command exception handling.""" + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text( + '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (workspace / "alpha", "alpha"), + (beta_project, "beta"), + ] + config_manager.load_config.side_effect = Exception( + "Config error" + ) # Force exception + + selected_config = ConfigData(project_id=9, project_name="Beta", folder_id=11) + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + if self.path == beta_project: + return selected_config + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "Failed to switch to project" in output + + +@pytest.mark.asyncio +async def test_list_projects_json_output_mode( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list_projects with JSON output mode.""" + workspace_root = tmp_path / "workspace" + project_path = workspace_root / "test-project" + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace_root + config_manager.get_current_project_name.return_value = "test-project" + config_manager._find_all_projects.return_value = [(project_path, "test-project")] + + # Mock project config manager + project_config = ConfigData( + project_id=123, project_name="Test Project", folder_id=456, profile="dev" + ) + mock_project_config_manager = Mock() + mock_project_config_manager.load_config.return_value = project_config + + with patch( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + return_value=mock_project_config_manager, + ): + assert command.list_projects.callback + await command.list_projects.callback( + output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + + # Parse JSON output + import json + + parsed = json.loads(output) + + assert parsed["current_project"] == "test-project" + assert len(parsed["local_projects"]) == 1 + project = parsed["local_projects"][0] + assert project["name"] == "test-project" + assert project["is_current"] is True + assert project["project_id"] == 123 + assert project["folder_id"] == 456 + assert project["profile"] == "dev" + assert project["configured"] is True + + +@pytest.mark.asyncio +async def test_list_projects_json_output_mode_empty( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list_projects JSON output with no projects.""" + workspace_root = tmp_path / "workspace" + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace_root + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] + + assert command.list_projects.callback + await command.list_projects.callback( + output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + + # Parse JSON output + import json + + parsed = json.loads(output) + + assert parsed["current_project"] is None + assert parsed["local_projects"] == [] + + +@pytest.mark.asyncio +async def test_list_projects_remote_source( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with remote source.""" + config_manager = Mock() + + # Mock create_profile_aware_workato_config and Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + + # Mock remote projects + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project = Project( + id=123, name="Remote Project", folder_id=456, description="A remote project" + ) + mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="remote", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + assert "Remote projects:" in output + assert "Remote Project" in output + assert "Project ID: 123" in output + + +@pytest.mark.asyncio +async def test_list_projects_remote_source_json( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with remote source JSON output.""" + config_manager = Mock() + config_manager.get_workspace_root.return_value = None + config_manager.get_current_project_name.return_value = None + + # Mock create_profile_aware_workato_config and Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + + # Mock remote projects + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project = Project( + id=123, name="Remote Project", folder_id=456, description="A remote project" + ) + mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="remote", output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + parsed = json.loads(output) + + assert parsed["source"] == "remote" + assert len(parsed["remote_projects"]) == 1 + remote = parsed["remote_projects"][0] + assert remote["name"] == "Remote Project" + assert remote["project_id"] == 123 + assert remote["folder_id"] == 456 + assert remote["description"] == "A remote project" + assert remote["has_local_copy"] is False + + +@pytest.mark.asyncio +async def test_list_projects_both_source( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects with both local and remote source.""" + workspace = tmp_path + alpha_project = workspace / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text( + '{"project_id": 123, "project_name": "Alpha", "folder_id": 456}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + # Mock local project config loading + project_config = Mock() + project_config.project_id = 123 + project_config.project_name = "Alpha" + project_config.folder_id = 456 + project_config.profile = "dev" + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + pass + + def load_config(self) -> ConfigData: + return project_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + # Mock remote projects + mock_workato_client = Mock() + mock_project_manager = Mock() + + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project1 = Project( + id=123, name="Alpha", folder_id=456, description="Synced project" + ) + remote_project2 = Project( + id=789, name="Remote Only", folder_id=999, description="Remote only project" + ) + mock_project_manager.get_all_projects = AsyncMock( + return_value=[remote_project1, remote_project2] + ) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="both", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + assert "All projects (local + remote):" in output + assert "Remote Only" in output + assert "synced" in output # Alpha should be marked as synced + assert "remote only" in output # Remote Only should be marked as remote only + # Alpha project should be shown (either as local "alpha" or remote "Alpha") + assert "alpha" in output.lower() or "Alpha" in output + + +@pytest.mark.asyncio +async def test_list_projects_with_profile( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with profile parameter.""" + config_manager = Mock() + config_manager.get_workspace_root.return_value = None + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] + + # Mock profile-aware config creation + mock_config = Mock() + mock_create_config = Mock(return_value=mock_config) + + # Mock Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + mock_project_manager.get_all_projects = AsyncMock(return_value=[]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + mock_create_config, + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + profile="test-profile", source="remote", config_manager=config_manager + ) + + # Verify that create_profile_aware_workato_config was called + # with the correct profile + mock_create_config.assert_called_once_with( + config_manager=config_manager, cli_profile="test-profile" + ) + + +@pytest.mark.asyncio +async def test_projects_config_sets_include_defaults(capture_echo: list[str]) -> None: + """Projects config command should persist include defaults.""" + config_manager = Mock() + config_data = ConfigData() + config_manager.load_config.return_value = config_data + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=True, + include_test_cases=False, + config_manager=config_manager, + ) + + saved_config = config_manager.save_config.call_args.args[0] + assert saved_config.export_include_tags is True + assert saved_config.export_include_test_cases is False + output = "\n".join(capture_echo) + assert "Updated project export defaults" in output + + +@pytest.mark.asyncio +async def test_projects_config_requires_at_least_one_value( + capture_echo: list[str], +) -> None: + """Projects config command should reject empty updates.""" + config_manager = Mock() + config_manager.load_config = Mock() + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=None, + include_test_cases=None, + config_manager=config_manager, + ) + + config_manager.load_config.assert_not_called() + config_manager.save_config.assert_not_called() + output = "\n".join(capture_echo) + assert "No config values provided" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_table(capture_echo: list[str]) -> None: + """Projects config show should print table output by default.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=True, + export_include_test_cases=False, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="table", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "Project export defaults" in output + assert "export_include_tags: True" in output + assert "export_include_test_cases: False" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_json(capture_echo: list[str]) -> None: + """Projects config show should support JSON output.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=False, + export_include_test_cases=True, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="json", + config_manager=config_manager, + ) + + data = json.loads("".join(capture_echo)) + assert data["export_include_tags"] is False + assert data["export_include_test_cases"] is True diff --git a/tests/unit/commands/projects/test_project_manager.py b/tests/unit/commands/projects/test_project_manager.py index 89fa960..0e1f59e 100644 --- a/tests/unit/commands/projects/test_project_manager.py +++ b/tests/unit/commands/projects/test_project_manager.py @@ -11,6 +11,7 @@ import pytest from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager +from workato_platform_cli.cli.utils.config import ConfigData from workato_platform_cli.client.workato_api.models.project import Project @@ -225,6 +226,121 @@ async def test_export_project_happy_path( assert result == str(project_dir) +@pytest.mark.asyncio +async def test_export_project_uses_config_defaults_for_manifest_flags( + project_manager: ProjectManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Mock(result=Mock(id=88)) + project_dir = tmp_path / "extracted" + + class StubConfigManager: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def load_config(self) -> ConfigData: + return ConfigData( + export_include_tags=True, + export_include_test_cases=False, + ) + + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.ConfigManager", + StubConfigManager, + ) + + with ( + patch.object( + project_manager, "check_folder_assets", AsyncMock(return_value=[Mock()]) + ), + patch.object( + project_manager.client.export_api, + "create_export_manifest", + AsyncMock(return_value=manifest), + ) as mock_create_manifest, + patch.object( + project_manager.client.packages_api, + "export_package", + AsyncMock(return_value=Mock(id=44)), + ), + patch.object( + project_manager, + "download_and_extract_package", + AsyncMock(return_value=project_dir), + ), + ): + await project_manager.export_project( + folder_id=9, + project_name="Demo", + target_dir=str(project_dir), + ) + + assert mock_create_manifest.await_args is not None + request = mock_create_manifest.await_args.kwargs[ + "create_export_manifest_request" + ] + assert request.export_manifest.include_tags is True + assert request.export_manifest.include_test_cases is False + + +@pytest.mark.asyncio +async def test_export_project_defaults_manifest_flags_to_false( + project_manager: ProjectManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Mock(result=Mock(id=88)) + project_dir = tmp_path / "extracted" + + class StubConfigManager: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def load_config(self) -> ConfigData: + return ConfigData() + + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.ConfigManager", + StubConfigManager, + ) + monkeypatch.delenv("WORKATO_INCLUDE_TAGS", raising=False) + monkeypatch.delenv("WORKATO_INCLUDE_TEST_CASES", raising=False) + + with ( + patch.object( + project_manager, "check_folder_assets", AsyncMock(return_value=[Mock()]) + ), + patch.object( + project_manager.client.export_api, + "create_export_manifest", + AsyncMock(return_value=manifest), + ) as mock_create_manifest, + patch.object( + project_manager.client.packages_api, + "export_package", + AsyncMock(return_value=Mock(id=44)), + ), + patch.object( + project_manager, + "download_and_extract_package", + AsyncMock(return_value=project_dir), + ), + ): + await project_manager.export_project( + folder_id=9, + project_name="Demo", + target_dir=str(project_dir), + ) + + assert mock_create_manifest.await_args is not None + request = mock_create_manifest.await_args.kwargs[ + "create_export_manifest_request" + ] + assert request.export_manifest.include_tags is False + assert request.export_manifest.include_test_cases is False + + @pytest.mark.asyncio async def test_download_and_extract_package_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch From c35eff914390b804b1c7b9f5c6645422689ac3a1 Mon Sep 17 00:00:00 2001 From: Tony Brimeyer Date: Fri, 22 May 2026 14:19:05 -0500 Subject: [PATCH 2/2] feat(projects): add project export configuration options for tags and test cases --- .pre-commit-config.yaml | 132 +- Makefile | 198 +- README.md | 280 +- docs/COMMAND_REFERENCE.md | 264 +- .../cli/commands/projects/command.py | 1184 ++++---- .../cli/commands/projects/project_manager.py | 814 +++--- .../cli/utils/config/models.py | 198 +- tests/unit/commands/projects/test_command.py | 2446 ++++++++--------- 8 files changed, 2758 insertions(+), 2758 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaed675..91c4477 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,66 +1,66 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - - id: trailing-whitespace - exclude: ^(client/|src/workato_platform_cli/client/) - - id: end-of-file-fixer - exclude: ^(client/|src/workato_platform_cli/client/) - - id: check-yaml - - id: check-added-large-files - - id: check-json - - id: check-merge-conflict - - id: check-toml - - id: debug-statements - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.0 - hooks: - - id: ruff - exclude: ^(client/|src/workato_platform_cli/client/) - - id: ruff-format - exclude: ^(client/|src/workato_platform_cli/client/) - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.1 - hooks: - - id: mypy - args: [--explicit-package-bases] - additional_dependencies: - [ - types-requests, - types-click, - packaging, - asyncclick>=8.0.0, - pydantic>=2.11.7, - dependency-injector>=4.41.0, - inquirer>=3.1.0, - aiohttp>=3.8.0, - aiohttp-retry>=2.8.0, - python-dateutil>=2.8.0, - typing-extensions>=4.0.0, - pytest>=7.0.0, - pytest-asyncio>=0.21.0, - pytest-mock>=3.10.0, - prompt-toolkit>=3.0.0, - ] - exclude: ^(client/|src/workato_platform_cli/client/) - - # pip-audit for dependency security auditing - - repo: https://github.com/pypa/pip-audit - rev: v2.9.0 - hooks: - - id: pip-audit - # Ensure the hook environment uses a patched pip version. - additional_dependencies: ["pip>=26.1"] - args: [--format=json, --ignore-vuln=GHSA-4xh5-x5gv-qwph] - - # Local hooks for project-specific tasks - - repo: local - hooks: - - id: generate-client - name: Generate OpenAPI client - entry: make generate-client - language: system - always_run: true - pass_filenames: false +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + exclude: ^(client/|src/workato_platform_cli/client/) + - id: end-of-file-fixer + exclude: ^(client/|src/workato_platform_cli/client/) + - id: check-yaml + - id: check-added-large-files + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.13.0 + hooks: + - id: ruff + exclude: ^(client/|src/workato_platform_cli/client/) + - id: ruff-format + exclude: ^(client/|src/workato_platform_cli/client/) + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.1 + hooks: + - id: mypy + args: [--explicit-package-bases] + additional_dependencies: + [ + types-requests, + types-click, + packaging, + asyncclick>=8.0.0, + pydantic>=2.11.7, + dependency-injector>=4.41.0, + inquirer>=3.1.0, + aiohttp>=3.8.0, + aiohttp-retry>=2.8.0, + python-dateutil>=2.8.0, + typing-extensions>=4.0.0, + pytest>=7.0.0, + pytest-asyncio>=0.21.0, + pytest-mock>=3.10.0, + prompt-toolkit>=3.0.0, + ] + exclude: ^(client/|src/workato_platform_cli/client/) + + # pip-audit for dependency security auditing + - repo: https://github.com/pypa/pip-audit + rev: v2.9.0 + hooks: + - id: pip-audit + # Ensure the hook environment uses a patched pip version. + additional_dependencies: ["pip>=26.1"] + args: [--format=json, --ignore-vuln=GHSA-4xh5-x5gv-qwph] + + # Local hooks for project-specific tasks + - repo: local + hooks: + - id: generate-client + name: Generate OpenAPI client + entry: make generate-client + language: system + always_run: true + pass_filenames: false diff --git a/Makefile b/Makefile index b7140ba..1a6c21e 100644 --- a/Makefile +++ b/Makefile @@ -1,99 +1,99 @@ -.PHONY: help install install-dev test lint format clean build upload docs check generate-client - -help: - @echo "Available commands:" - @echo " install Install package" - @echo " install-dev Install package with development dependencies" - @echo " test Run all tests" - @echo " test-unit Run unit tests only" - @echo " test-integration Run integration tests only" - @echo " test-client Run generated client tests only" - @echo " test-cov Run tests with coverage report" - @echo " test-watch Run tests in watch mode" - @echo " lint Run linting checks" - @echo " format Format code with ruff" - @echo " check Run all checks (lint + format check + type check)" - @echo " clean Clean build artifacts" - @echo " build Build distribution packages" - @echo " upload Upload to PyPI (requires credentials)" - @echo " docs Generate documentation" - @echo " generate-client Generate API client from OpenAPI spec" - -install: - @if [ ! -d ".venv" ]; then \ - echo "๐Ÿ”„ Creating virtual environment..."; \ - uv venv; \ - fi - uv sync - uv pip install -e . - @echo "โœ… Installation complete!" - @echo "๐Ÿ’ก To activate the virtual environment, run: source .venv/bin/activate" - @echo " Or use 'uv run workato' to run commands without activation" - -install-dev: - @if [ ! -d ".venv" ]; then \ - echo "๐Ÿ”„ Creating virtual environment..."; \ - uv venv; \ - fi - uv sync --group dev - uv run pre-commit install - -test: - uv run pytest tests/ -v - -test-unit: - uv run pytest tests/unit/ -v - -test-integration: - uv run pytest tests/integration/ -v - -test-client: - uv run pytest src/workato_platform_cli/client/workato_api/test/ -v - -test-cov: - uv run pytest tests/ --cov=src/workato_platform_cli --cov-report=html --cov-report=term --cov-report=xml - -test-watch: - uv run pytest tests/ -v --tb=short -x --lf - -lint: - uv run ruff check src/ tests/ - -format: - uv run ruff format src/ tests/ - -check: - uv run ruff check src/ tests/ - uv run ruff format --check src/ tests/ - uv run mypy --explicit-package-bases src/ tests/ - -clean: - rm -rf build/ - rm -rf dist/ - rm -rf *.egg-info/ - rm -rf .coverage - rm -rf htmlcov/ - find . -type f -name "*.pyc" -delete - find . -type d -name __pycache__ -delete - -build: clean - python -m build - -upload: build - twine check dist/* - twine upload dist/* - -docs: - @echo "Documentation generation not yet implemented" - @echo "Consider using sphinx-quickstart to set up docs/" - -generate-client: - @echo "๐Ÿ”„ Generating API client from OpenAPI spec..." - openapi-generator-cli generate -i workato-api-spec.yaml -g python -c openapi-config.yaml -o ./src/ - @echo "โœ… API client generated successfully" - -# Development shortcuts -dev: install-dev format check test - -# CI/CD command -ci: check test +.PHONY: help install install-dev test lint format clean build upload docs check generate-client + +help: + @echo "Available commands:" + @echo " install Install package" + @echo " install-dev Install package with development dependencies" + @echo " test Run all tests" + @echo " test-unit Run unit tests only" + @echo " test-integration Run integration tests only" + @echo " test-client Run generated client tests only" + @echo " test-cov Run tests with coverage report" + @echo " test-watch Run tests in watch mode" + @echo " lint Run linting checks" + @echo " format Format code with ruff" + @echo " check Run all checks (lint + format check + type check)" + @echo " clean Clean build artifacts" + @echo " build Build distribution packages" + @echo " upload Upload to PyPI (requires credentials)" + @echo " docs Generate documentation" + @echo " generate-client Generate API client from OpenAPI spec" + +install: + @if [ ! -d ".venv" ]; then \ + echo "๐Ÿ”„ Creating virtual environment..."; \ + uv venv; \ + fi + uv sync + uv pip install -e . + @echo "โœ… Installation complete!" + @echo "๐Ÿ’ก To activate the virtual environment, run: source .venv/bin/activate" + @echo " Or use 'uv run workato' to run commands without activation" + +install-dev: + @if [ ! -d ".venv" ]; then \ + echo "๐Ÿ”„ Creating virtual environment..."; \ + uv venv; \ + fi + uv sync --group dev + uv run pre-commit install + +test: + uv run pytest tests/ -v + +test-unit: + uv run pytest tests/unit/ -v + +test-integration: + uv run pytest tests/integration/ -v + +test-client: + uv run pytest src/workato_platform_cli/client/workato_api/test/ -v + +test-cov: + uv run pytest tests/ --cov=src/workato_platform_cli --cov-report=html --cov-report=term --cov-report=xml + +test-watch: + uv run pytest tests/ -v --tb=short -x --lf + +lint: + uv run ruff check src/ tests/ + +format: + uv run ruff format src/ tests/ + +check: + uv run ruff check src/ tests/ + uv run ruff format --check src/ tests/ + uv run mypy --explicit-package-bases src/ tests/ + +clean: + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf .coverage + rm -rf htmlcov/ + find . -type f -name "*.pyc" -delete + find . -type d -name __pycache__ -delete + +build: clean + python -m build + +upload: build + twine check dist/* + twine upload dist/* + +docs: + @echo "Documentation generation not yet implemented" + @echo "Consider using sphinx-quickstart to set up docs/" + +generate-client: + @echo "๐Ÿ”„ Generating API client from OpenAPI spec..." + openapi-generator-cli generate -i workato-api-spec.yaml -g python -c openapi-config.yaml -o ./src/ + @echo "โœ… API client generated successfully" + +# Development shortcuts +dev: install-dev format check test + +# CI/CD command +ci: check test diff --git a/README.md b/README.md index 6e928d0..01fcdc0 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,140 @@ -# Workato Platform CLI - -A modern, type-safe command-line interface for the Workato API, designed for automation and AI agent interaction. **Perfect for AI agents helping developers build, validate, and manage Workato recipes, connections, and projects.** - -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -[![Type Checked](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy.readthedocs.io/) -[![Code Style](https://img.shields.io/badge/code%20style-ruff-black.svg)](https://docs.astral.sh/ruff/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -## Features - -- **Project Management**: Create, push, pull, and manage Workato projects -- **Recipe Operations**: Validate, start, stop, and manage recipes -- **Connection Management**: Create and manage OAuth connections -- **API Integration**: Manage API clients, collections, and endpoints -- **AI Agent Support**: Built-in documentation and guide system - -# Quick Start Guide - -Get the Workato CLI running in 5 minutes. - -## Prerequisites - -- Python 3.11+ -- Workato account with API token - -### Getting Your API Token - -1. Log into your Workato account -1. Navigate to **Workspace Admin** โ†’ **API clients** -1. Click **Create API client** -1. Fill out information about the client, click **Create client** -1. Copy the generated token (starts with `wrkatrial-` for trial accounts or `wrkprod-` for production) - -## Installation - -### From PyPI (Coming Soon) - -```bash -pip install workato-platform-cli -``` - -### From Source - -```bash -git clone https://github.com/workato-devs/workato-platform-cli.git -cd workato-platform-cli -make install -``` - -Having issues? See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) for troubleshooting. - -## Setup - -```bash -# Initialize CLI (will prompt for API token and region) -workato init - -# Verify your workspace -workato workspace -``` - -## First Commands - -```bash -# List available commands -workato --help - -# List your recipes -workato recipes list - -# List your connections -workato connections list - -# Check project status -workato workspace - -# Configure project export defaults -workato projects config --include-tags --no-include-test-cases - -# Show current project export defaults -workato projects config show -``` - -## Next Steps - -- **Need detailed commands?** โ†’ See [COMMAND_REFERENCE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/COMMAND_REFERENCE.md) -- **Want real-world examples?** โ†’ See [USE_CASES.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/USE_CASES.md) -- **Looking for sample recipes?** โ†’ See [examples/](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/examples/) -- **Installation issues?** โ†’ See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) -- **Looking for all documentation?** โ†’ See [INDEX.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/INDEX.md) - -## Quick Recipe Workflow - -```bash -# 1. Validate a recipe file -workato recipes validate --path ./my-recipe.json - -# 2. Push changes to Workato -workato push - -# 3. Pull latest from remote -workato pull -``` - -You're ready to go! - -## Contributing to the CLI - -These commands are for CLI maintainers and contributors, not for developers using the CLI to build Workato integrations. - -### For Development - -```bash -# Setup (with uv - recommended) -make install-dev - -# Run all checks -make check # linting, formatting, type checking -make test # run tests -make test-cov # run tests with coverage - -# Development workflow -make format # auto-format code -make lint # check code quality -make build # build distribution packages -``` - -### Tech Stack - -- **๐Ÿ Python 3.11+** with full type annotations -- **โšก uv** for fast dependency management -- **๐Ÿ” mypy** for static type checking -- **๐Ÿงน ruff** for linting and formatting -- **โœ… pytest** for testing -- **๐Ÿ”ง pre-commit** for git hooks - -## License - -MIT License +# Workato Platform CLI + +A modern, type-safe command-line interface for the Workato API, designed for automation and AI agent interaction. **Perfect for AI agents helping developers build, validate, and manage Workato recipes, connections, and projects.** + +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![Type Checked](https://img.shields.io/badge/type--checked-mypy-blue.svg)](https://mypy.readthedocs.io/) +[![Code Style](https://img.shields.io/badge/code%20style-ruff-black.svg)](https://docs.astral.sh/ruff/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Features + +- **Project Management**: Create, push, pull, and manage Workato projects +- **Recipe Operations**: Validate, start, stop, and manage recipes +- **Connection Management**: Create and manage OAuth connections +- **API Integration**: Manage API clients, collections, and endpoints +- **AI Agent Support**: Built-in documentation and guide system + +# Quick Start Guide + +Get the Workato CLI running in 5 minutes. + +## Prerequisites + +- Python 3.11+ +- Workato account with API token + +### Getting Your API Token + +1. Log into your Workato account +1. Navigate to **Workspace Admin** โ†’ **API clients** +1. Click **Create API client** +1. Fill out information about the client, click **Create client** +1. Copy the generated token (starts with `wrkatrial-` for trial accounts or `wrkprod-` for production) + +## Installation + +### From PyPI (Coming Soon) + +```bash +pip install workato-platform-cli +``` + +### From Source + +```bash +git clone https://github.com/workato-devs/workato-platform-cli.git +cd workato-platform-cli +make install +``` + +Having issues? See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) for troubleshooting. + +## Setup + +```bash +# Initialize CLI (will prompt for API token and region) +workato init + +# Verify your workspace +workato workspace +``` + +## First Commands + +```bash +# List available commands +workato --help + +# List your recipes +workato recipes list + +# List your connections +workato connections list + +# Check project status +workato workspace + +# Configure project export defaults +workato projects config --include-tags --no-include-test-cases + +# Show current project export defaults +workato projects config show +``` + +## Next Steps + +- **Need detailed commands?** โ†’ See [COMMAND_REFERENCE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/COMMAND_REFERENCE.md) +- **Want real-world examples?** โ†’ See [USE_CASES.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/USE_CASES.md) +- **Looking for sample recipes?** โ†’ See [examples/](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/examples/) +- **Installation issues?** โ†’ See [DEVELOPER_GUIDE.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/DEVELOPER_GUIDE.md) +- **Looking for all documentation?** โ†’ See [INDEX.md](https://github.com/workato-devs/workato-platform-cli/blob/main/docs/INDEX.md) + +## Quick Recipe Workflow + +```bash +# 1. Validate a recipe file +workato recipes validate --path ./my-recipe.json + +# 2. Push changes to Workato +workato push + +# 3. Pull latest from remote +workato pull +``` + +You're ready to go! + +## Contributing to the CLI + +These commands are for CLI maintainers and contributors, not for developers using the CLI to build Workato integrations. + +### For Development + +```bash +# Setup (with uv - recommended) +make install-dev + +# Run all checks +make check # linting, formatting, type checking +make test # run tests +make test-cov # run tests with coverage + +# Development workflow +make format # auto-format code +make lint # check code quality +make build # build distribution packages +``` + +### Tech Stack + +- **๐Ÿ Python 3.11+** with full type annotations +- **โšก uv** for fast dependency management +- **๐Ÿ” mypy** for static type checking +- **๐Ÿงน ruff** for linting and formatting +- **โœ… pytest** for testing +- **๐Ÿ”ง pre-commit** for git hooks + +## License + +MIT License diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index e571a93..6865f86 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -1,132 +1,132 @@ -# Workato CLI Command Reference - -Complete reference for all CLI commands and options. - -## Installation & Setup - -```bash -# Install -pip install -e . - -# Initialize -workato init - -# Check status -workato workspace -``` - -## Core Commands - -### Project Management -```bash -workato init # Initialize CLI configuration -workato workspace # Show current workspace info -workato pull # Pull latest from remote -workato push [--restart-recipes] # Push local changes (recipes won't restart by default) -workato projects config --include-tags|--no-include-tags [--include-test-cases|--no-include-test-cases] -workato projects config show [--output-mode table|json] -``` - -### Recipe Management -```bash -workato recipes list [--folder-id ID] [--running] [--page N] -workato recipes validate --path FILE -workato recipes start --id ID -workato recipes stop --id ID -workato recipes update-connection RECIPE_ID --adapter-name NAME --connection-id ID -``` - -### Connection Management -```bash -workato connections list [--folder-id ID] -workato connections create --provider PROVIDER --name NAME -workato connections create-oauth --parent-id ID -workato connections update --connection-id ID --name NAME -workato connections get-oauth-url --id ID -``` - -### Connectors -```bash -workato connectors list -workato connectors parameters --provider PROVIDER -``` - -### API Collections -```bash -workato api-collections create --format FORMAT --content PATH --name NAME -# Formats: json, yaml, url -``` - -### Profiles -```bash -workato init # Create new profile interactively -workato profiles list -workato profiles use NAME -workato profiles status -``` - -### Documentation -```bash -workato guide topics # List available topics -workato guide search QUERY # Search documentation -workato guide content TOPIC # Show topic content -``` - -## Common Options - -- `--help` - Show help for any command -- `--profile NAME` - Use specific profile -- `--page N --per-page N` - Pagination for list commands -- `--folder-id ID` - Filter by folder - -## Examples - -### Development Workflow -```bash -# Setup -workato init # Creates profile interactively - -# Development -workato recipes validate --path ./recipe.json -workato push --restart-recipes # Only restarts running recipes that were updated -workato recipes list --running - -# Switch environments -workato profiles use production -workato pull -``` - -### Recipe Management -```bash -# List and filter recipes -workato recipes list --folder-id 123 -workato recipes list --running --per-page 10 - -# Manage recipe lifecycle -workato recipes start --id 456 -workato recipes stop --id 456 -``` - -### Connection Setup -```bash -# Create connections -workato connections create --provider salesforce --name "Production SF" -workato connections create-oauth --parent-id 789 - -# Get OAuth URL for authentication -workato connections get-oauth-url --id 789 -``` - -## Environment Support - -**Trial Accounts:** Use `wrkatrial-` tokens with `https://app.trial.workato.com/api` - -**Production Accounts:** Use `wrkprod-` tokens with `https://www.workato.com/api` - -## Requirements - -- Python 3.11+ -- Valid Workato account and API token -- Network access to Workato API endpoints - -For setup and installation issues, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). +# Workato CLI Command Reference + +Complete reference for all CLI commands and options. + +## Installation & Setup + +```bash +# Install +pip install -e . + +# Initialize +workato init + +# Check status +workato workspace +``` + +## Core Commands + +### Project Management +```bash +workato init # Initialize CLI configuration +workato workspace # Show current workspace info +workato pull # Pull latest from remote +workato push [--restart-recipes] # Push local changes (recipes won't restart by default) +workato projects config --include-tags|--no-include-tags [--include-test-cases|--no-include-test-cases] +workato projects config show [--output-mode table|json] +``` + +### Recipe Management +```bash +workato recipes list [--folder-id ID] [--running] [--page N] +workato recipes validate --path FILE +workato recipes start --id ID +workato recipes stop --id ID +workato recipes update-connection RECIPE_ID --adapter-name NAME --connection-id ID +``` + +### Connection Management +```bash +workato connections list [--folder-id ID] +workato connections create --provider PROVIDER --name NAME +workato connections create-oauth --parent-id ID +workato connections update --connection-id ID --name NAME +workato connections get-oauth-url --id ID +``` + +### Connectors +```bash +workato connectors list +workato connectors parameters --provider PROVIDER +``` + +### API Collections +```bash +workato api-collections create --format FORMAT --content PATH --name NAME +# Formats: json, yaml, url +``` + +### Profiles +```bash +workato init # Create new profile interactively +workato profiles list +workato profiles use NAME +workato profiles status +``` + +### Documentation +```bash +workato guide topics # List available topics +workato guide search QUERY # Search documentation +workato guide content TOPIC # Show topic content +``` + +## Common Options + +- `--help` - Show help for any command +- `--profile NAME` - Use specific profile +- `--page N --per-page N` - Pagination for list commands +- `--folder-id ID` - Filter by folder + +## Examples + +### Development Workflow +```bash +# Setup +workato init # Creates profile interactively + +# Development +workato recipes validate --path ./recipe.json +workato push --restart-recipes # Only restarts running recipes that were updated +workato recipes list --running + +# Switch environments +workato profiles use production +workato pull +``` + +### Recipe Management +```bash +# List and filter recipes +workato recipes list --folder-id 123 +workato recipes list --running --per-page 10 + +# Manage recipe lifecycle +workato recipes start --id 456 +workato recipes stop --id 456 +``` + +### Connection Setup +```bash +# Create connections +workato connections create --provider salesforce --name "Production SF" +workato connections create-oauth --parent-id 789 + +# Get OAuth URL for authentication +workato connections get-oauth-url --id 789 +``` + +## Environment Support + +**Trial Accounts:** Use `wrkatrial-` tokens with `https://app.trial.workato.com/api` + +**Production Accounts:** Use `wrkprod-` tokens with `https://www.workato.com/api` + +## Requirements + +- Python 3.11+ +- Valid Workato account and API token +- Network access to Workato API endpoints + +For setup and installation issues, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). diff --git a/src/workato_platform_cli/cli/commands/projects/command.py b/src/workato_platform_cli/cli/commands/projects/command.py index 5ce7fb0..7e5d71c 100644 --- a/src/workato_platform_cli/cli/commands/projects/command.py +++ b/src/workato_platform_cli/cli/commands/projects/command.py @@ -1,592 +1,592 @@ -"""Manage Workato projects""" - -import json - -from typing import Any - -import asyncclick as click - -from dependency_injector.wiring import Provide, inject - -from workato_platform_cli import Workato -from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager -from workato_platform_cli.cli.containers import ( - Container, - create_profile_aware_workato_config, -) -from workato_platform_cli.cli.utils.config import ConfigData, ConfigManager -from workato_platform_cli.cli.utils.exception_handler import ( - handle_api_exceptions, - handle_cli_exceptions, -) -from workato_platform_cli.client.workato_api.models.project import Project - - -@click.group() -def projects() -> None: - """Manage Workato projects""" - pass - - -@projects.command(name="list") -@click.option( - "--profile", - help="Profile to use for authentication and region settings", - default=None, -) -@click.option( - "--source", - type=click.Choice(["local", "remote", "both"]), - default="local", - help="Source of projects to list: local (default), remote (server), or both", -) -@click.option( - "--output-mode", - type=click.Choice(["table", "json"]), - default="table", - help="Output format: table (default) or json", -) -@handle_cli_exceptions -@inject -@handle_api_exceptions -async def list_projects( - profile: str | None = None, - source: str = "local", - output_mode: str = "table", - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """List available projects from local workspace and/or server""" - - # Gather projects based on source - local_projects: list[tuple[Any, str, ConfigData | None]] = [] - remote_projects: list[Project] = [] - - if source in ["local", "both"]: - local_projects = await _get_local_projects(config_manager) - - if source in ["remote", "both"]: - workato_api_configuration = create_profile_aware_workato_config( - config_manager=config_manager, - cli_profile=profile, - ) - workato_api_client = Workato(configuration=workato_api_configuration) - async with workato_api_client as workato_api_client: - project_manager = ProjectManager(workato_api_client=workato_api_client) - remote_projects = await project_manager.get_all_projects() - - # Output based on mode - if output_mode == "json": - await _output_json(source, local_projects, remote_projects, config_manager) - else: - await _output_table(source, local_projects, remote_projects, config_manager) - - -@projects.command() -@click.argument("project_name") -@handle_cli_exceptions -@inject -async def use( - project_name: str, - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Switch to a specific project by name""" - # Find workspace root to search for projects - workspace_root = config_manager.get_workspace_root() - - # Use the new config system to find all projects in workspace - all_projects = config_manager._find_all_projects(workspace_root) - - # Find the project by name - target_project = None - for project_path, discovered_project_name in all_projects: - if discovered_project_name == project_name: - target_project = (project_path, discovered_project_name) - break - - if not target_project: - click.echo(f"โŒ Project '{project_name}' not found") - click.echo("๐Ÿ’ก Use 'workato projects list' to see available projects") - return - - project_path, _ = target_project - - # Load project configuration - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - project_config = project_config_manager.load_config() - except Exception as e: - click.echo(f"โŒ Project '{project_name}' has configuration errors: {e}") - click.echo("๐Ÿ’ก Navigate to the project directory and run 'workato init'") - return - - # Update workspace-level config to point to this project - try: - workspace_config = config_manager.load_config() - - # Calculate relative project path for workspace config - relative_project_path = str(project_path.relative_to(workspace_root)) - - # Copy project-specific data to workspace config - workspace_config.project_id = project_config.project_id - workspace_config.project_name = project_config.project_name - workspace_config.project_path = relative_project_path - workspace_config.folder_id = project_config.folder_id - workspace_config.profile = project_config.profile - - config_manager.save_config(workspace_config) - - click.echo(f"โœ… Switched to project '{project_name}'") - - # Show project details - if project_config.project_name: - click.echo(f" Name: {project_config.project_name}") - if project_config.folder_id: - click.echo(f" Folder ID: {project_config.folder_id}") - if project_config.profile: - click.echo(f" Profile: {project_config.profile}") - click.echo(f" Directory: {relative_project_path}") - - except Exception as e: - click.echo(f"โŒ Failed to switch to project '{project_name}': {e}") - - -@projects.command() -@handle_cli_exceptions -@inject -async def switch( - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Interactively switch to a different project""" - import inquirer - - # Find workspace root to search for projects - workspace_root = config_manager.get_workspace_root() - - # Use the new config system to find all projects in workspace - all_projects = config_manager._find_all_projects(workspace_root) - - if not all_projects: - click.echo("โŒ No projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - # Get current project for context - current_project_name = config_manager.get_current_project_name() - - # Build project choices with configuration - project_choices: list[tuple[str, str, ConfigData | None]] = [] - - for project_path, project_name in all_projects: - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - config_data = project_config_manager.load_config() - - # Create display name - display_name = project_name - if config_data.project_name and config_data.project_name != project_name: - display_name = f"{project_name} ({config_data.project_name})" - - if project_name == current_project_name: - display_name += " (current)" - - project_choices.append((display_name, project_name, config_data)) - except Exception: - # Still include projects with configuration errors - display_name = f"{project_name} (configuration error)" - if project_name == current_project_name: - display_name += " (current)" - project_choices.append((display_name, project_name, None)) - - if not project_choices: - click.echo("โŒ No configured projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - if len(project_choices) == 1 and project_choices[0][1] == current_project_name: - click.echo("โœ… Only one project available and it's already current") - return - - # Create interactive selection - choices = [choice[0] for choice in project_choices] - - questions = [ - inquirer.List( - "project", - message="Select a project to switch to", # noboost - choices=choices, - ) - ] - - answers = inquirer.prompt(questions) - if not answers: - click.echo("โŒ No project selected") - return - - # Find the selected project - selected_project_name: str | None = None - selected_config: ConfigData | None = None - - for display_name, project_name, project_config_data in project_choices: - if display_name == answers["project"]: - selected_project_name = project_name - selected_config = project_config_data - break - - if not selected_project_name: - click.echo("โŒ Failed to identify selected project") - return - - if selected_project_name == current_project_name: - click.echo("โœ… Project is already current") - return - - if not selected_config: - click.echo(f"โŒ Project '{selected_project_name}' has configuration errors") - return - - # Find the project path - selected_project_path = None - for project_path, project_name in all_projects: - if project_name == selected_project_name: - selected_project_path = project_path - break - - if not selected_project_path: - click.echo(f"โŒ Failed to find path for project '{selected_project_name}'") - return - - # Switch to the selected project - try: - workspace_config = config_manager.load_config() - - # Calculate relative project path for workspace config - relative_project_path = str(selected_project_path.relative_to(workspace_root)) - - # Copy project-specific data to workspace config - workspace_config.project_id = selected_config.project_id - workspace_config.project_name = selected_config.project_name - workspace_config.project_path = relative_project_path - workspace_config.folder_id = selected_config.folder_id - workspace_config.profile = selected_config.profile - - config_manager.save_config(workspace_config) - - click.echo(f"โœ… Switched to project '{selected_project_name}'") - - # Show project details - if selected_config.project_name: - click.echo(f" Name: {selected_config.project_name}") - if selected_config.folder_id: - click.echo(f" Folder ID: {selected_config.folder_id}") - if selected_config.profile: - click.echo(f" Profile: {selected_config.profile}") - click.echo(f" Directory: {relative_project_path}") - - except Exception as e: - click.echo(f"โŒ Failed to switch to project '{selected_project_name}': {e}") - - -@projects.group(name="config", invoke_without_command=True) -@click.option( - "--include-tags/--no-include-tags", - default=None, - help=( - "Set export_include_tags default in .workatoenv for project export manifests" - ), -) -@click.option( - "--include-test-cases/--no-include-test-cases", - default=None, - help=( - "Set export_include_test_cases default in .workatoenv for project " - "export manifests" - ), -) -@handle_cli_exceptions -@inject -async def set_project_config( - include_tags: bool | None = None, - include_test_cases: bool | None = None, - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Set project export defaults in local .workatoenv config.""" - - if include_tags is None and include_test_cases is None: - click.echo("โŒ No config values provided") - click.echo( - "๐Ÿ’ก Use --include-tags/--no-include-tags and/or " - "--include-test-cases/--no-include-test-cases" - ) - return - - config_data = config_manager.load_config() - - if include_tags is not None: - config_data.export_include_tags = include_tags - if include_test_cases is not None: - config_data.export_include_test_cases = include_test_cases - - config_manager.save_config(config_data) - - click.echo("โœ… Updated project export defaults") - click.echo(f" export_include_tags: {config_data.export_include_tags}") - click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") - - -@set_project_config.command(name="show") -@click.option( - "--output-mode", - type=click.Choice(["table", "json"]), - default="table", - help="Output format: table (default) or json", -) -@handle_cli_exceptions -@inject -async def show_project_config( - output_mode: str = "table", - config_manager: ConfigManager = Provide[Container.config_manager], -) -> None: - """Show project export defaults from local .workatoenv config.""" - config_data = config_manager.load_config() - - if output_mode == "json": - click.echo( - json.dumps( - { - "export_include_tags": config_data.export_include_tags, - "export_include_test_cases": config_data.export_include_test_cases, - } - ) - ) - return - - click.echo("๐Ÿ“‹ Project export defaults:") - click.echo(f" export_include_tags: {config_data.export_include_tags}") - click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") - - -async def _get_local_projects( - config_manager: ConfigManager, -) -> list[tuple[Any, str, ConfigData | None]]: - """Get local projects with their configurations""" - workspace_root = config_manager.get_workspace_root() - all_projects = config_manager._find_all_projects(workspace_root) - - local_projects: list[tuple[Any, str, ConfigData | None]] = [] - for project_path, project_name in all_projects: - try: - project_config_manager = ConfigManager(project_path, skip_validation=True) - config_data = project_config_manager.load_config() - local_projects.append((project_path, project_name, config_data)) - except Exception: - local_projects.append((project_path, project_name, None)) - - return local_projects - - -async def _output_json( - source: str, - local_projects: list[tuple[Any, str, ConfigData | None]], - remote_projects: list[Project], - config_manager: ConfigManager, -) -> None: - """Output projects in JSON format""" - workspace_root = config_manager.get_workspace_root() - current_project_name = config_manager.get_current_project_name() - - output_data: dict[str, Any] = { - "source": source, - "current_project": current_project_name, - "workspace_root": str(workspace_root) if workspace_root else None, - "local_projects": [], - "remote_projects": [], - } - - # Process local projects - if source in ["local", "both"]: - for project_path, project_name, config_data in local_projects: - if config_data: - project_info = { - "name": project_name, - "directory": str(project_path.relative_to(workspace_root)) - if workspace_root - else str(project_path), - "is_current": project_name == current_project_name, - "project_id": config_data.project_id, - "folder_id": config_data.folder_id, - "profile": config_data.profile, - "configured": True, - } - else: - project_info = { - "name": project_name, - "directory": str(project_path.relative_to(workspace_root)) - if workspace_root - else str(project_path), - "is_current": project_name == current_project_name, - "configured": False, - "error": "configuration error", - } - output_data["local_projects"].append(project_info) - - # Process remote projects - if source in ["remote", "both"]: - for remote_project in remote_projects: - # Check if this remote project exists locally - local_match = None - if source == "both": - for _, _, config_data in local_projects: - if config_data and config_data.project_id == remote_project.id: - local_match = config_data - break - - remote_info = { - "name": remote_project.name, - "project_id": remote_project.id, - "folder_id": remote_project.folder_id, - "description": remote_project.description or "", - "has_local_copy": local_match is not None, - } - - if local_match: - remote_info["local_profile"] = local_match.profile - - output_data["remote_projects"].append(remote_info) - - click.echo(json.dumps(output_data)) - - -async def _output_table( - source: str, - local_projects: list[tuple[Any, str, ConfigData | None]], - remote_projects: list[Project], - config_manager: ConfigManager, -) -> None: - """Output projects in table format""" - workspace_root = config_manager.get_workspace_root() - current_project_name = config_manager.get_current_project_name() - - if source == "local": - if not local_projects: - click.echo("๐Ÿ“‹ No local projects found") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - click.echo("๐Ÿ“‹ Local projects:") - for project_path, project_name, config_data in sorted( - local_projects, key=lambda x: x[1] - ): - current_indicator = ( - " (current)" if project_name == current_project_name else "" - ) - - if config_data: - click.echo(f" โ€ข {project_name}{current_indicator}") - if config_data.project_id: - click.echo(f" Project ID: {config_data.project_id}") - if config_data.folder_id: - click.echo(f" Folder ID: {config_data.folder_id}") - if config_data.profile: - click.echo(f" Profile: {config_data.profile}") - if workspace_root: - click.echo( - f" Directory: {project_path.relative_to(workspace_root)}" - ) - else: - click.echo( - f" โ€ข {project_name}{current_indicator} (configuration error)" - ) - click.echo() - - elif source == "remote": - if not remote_projects: - click.echo("๐Ÿ“‹ No remote projects found") - return - - click.echo("๐Ÿ“‹ Remote projects:") - for remote_project in sorted(remote_projects, key=lambda x: x.name): - click.echo(f" โ€ข {remote_project.name}") - click.echo(f" Project ID: {remote_project.id}") - click.echo(f" Folder ID: {remote_project.folder_id}") - if remote_project.description: - click.echo(f" Description: {remote_project.description}") - click.echo() - - else: # both - # Show combined view with sync status - if not local_projects and not remote_projects: - click.echo("๐Ÿ“‹ No projects found locally or remotely") - click.echo("๐Ÿ’ก Run 'workato init' to create your first project") - return - - click.echo("๐Ÿ“‹ All projects (local + remote):") - - # Create a unified view - all_projects = {} - - # Add local projects - for project_path, project_name, config_data in local_projects: - project_id = config_data.project_id if config_data else None - all_projects[project_id or f"local:{project_name}"] = { - "name": project_name, - "project_id": project_id, - "folder_id": config_data.folder_id if config_data else None, - "profile": config_data.profile if config_data else None, - "local_path": project_path, - "is_local": True, - "is_remote": False, - "is_current": project_name == current_project_name, - "config_error": config_data is None, - } - - # Add/update with remote projects - for remote_project in remote_projects: - key = remote_project.id - if key in all_projects: - # Update existing local project with remote info - all_projects[key]["is_remote"] = True - all_projects[key]["remote_description"] = remote_project.description - else: - # Add remote-only project - all_projects[key] = { - "name": remote_project.name, - "project_id": remote_project.id, - "folder_id": remote_project.folder_id, - "remote_description": remote_project.description, - "is_local": False, - "is_remote": True, - "is_current": False, - "config_error": False, - } - - # Display unified projects - for project_data in sorted(all_projects.values(), key=lambda x: x["name"]): - status_indicators = [] - if project_data["is_current"]: - status_indicators.append("current") - if project_data["is_local"] and project_data["is_remote"]: - status_indicators.append("synced") - elif project_data["is_local"]: - status_indicators.append("local only") - elif project_data["is_remote"]: - status_indicators.append("remote only") - if project_data.get("config_error"): - status_indicators.append("config error") - - status_text = ( - f" ({', '.join(status_indicators)})" if status_indicators else "" - ) - click.echo(f" โ€ข {project_data['name']}{status_text}") - - if project_data["project_id"]: - click.echo(f" Project ID: {project_data['project_id']}") - if project_data["folder_id"]: - click.echo(f" Folder ID: {project_data['folder_id']}") - if project_data.get("profile"): - click.echo(f" Profile: {project_data['profile']}") - if project_data.get("remote_description"): - click.echo(f" Description: {project_data['remote_description']}") - if project_data.get("local_path") and workspace_root: - local_path = project_data["local_path"] - click.echo(f" Directory: {local_path.relative_to(workspace_root)}") - click.echo() +"""Manage Workato projects""" + +import json + +from typing import Any + +import asyncclick as click + +from dependency_injector.wiring import Provide, inject + +from workato_platform_cli import Workato +from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager +from workato_platform_cli.cli.containers import ( + Container, + create_profile_aware_workato_config, +) +from workato_platform_cli.cli.utils.config import ConfigData, ConfigManager +from workato_platform_cli.cli.utils.exception_handler import ( + handle_api_exceptions, + handle_cli_exceptions, +) +from workato_platform_cli.client.workato_api.models.project import Project + + +@click.group() +def projects() -> None: + """Manage Workato projects""" + pass + + +@projects.command(name="list") +@click.option( + "--profile", + help="Profile to use for authentication and region settings", + default=None, +) +@click.option( + "--source", + type=click.Choice(["local", "remote", "both"]), + default="local", + help="Source of projects to list: local (default), remote (server), or both", +) +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@handle_cli_exceptions +@inject +@handle_api_exceptions +async def list_projects( + profile: str | None = None, + source: str = "local", + output_mode: str = "table", + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """List available projects from local workspace and/or server""" + + # Gather projects based on source + local_projects: list[tuple[Any, str, ConfigData | None]] = [] + remote_projects: list[Project] = [] + + if source in ["local", "both"]: + local_projects = await _get_local_projects(config_manager) + + if source in ["remote", "both"]: + workato_api_configuration = create_profile_aware_workato_config( + config_manager=config_manager, + cli_profile=profile, + ) + workato_api_client = Workato(configuration=workato_api_configuration) + async with workato_api_client as workato_api_client: + project_manager = ProjectManager(workato_api_client=workato_api_client) + remote_projects = await project_manager.get_all_projects() + + # Output based on mode + if output_mode == "json": + await _output_json(source, local_projects, remote_projects, config_manager) + else: + await _output_table(source, local_projects, remote_projects, config_manager) + + +@projects.command() +@click.argument("project_name") +@handle_cli_exceptions +@inject +async def use( + project_name: str, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Switch to a specific project by name""" + # Find workspace root to search for projects + workspace_root = config_manager.get_workspace_root() + + # Use the new config system to find all projects in workspace + all_projects = config_manager._find_all_projects(workspace_root) + + # Find the project by name + target_project = None + for project_path, discovered_project_name in all_projects: + if discovered_project_name == project_name: + target_project = (project_path, discovered_project_name) + break + + if not target_project: + click.echo(f"โŒ Project '{project_name}' not found") + click.echo("๐Ÿ’ก Use 'workato projects list' to see available projects") + return + + project_path, _ = target_project + + # Load project configuration + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + project_config = project_config_manager.load_config() + except Exception as e: + click.echo(f"โŒ Project '{project_name}' has configuration errors: {e}") + click.echo("๐Ÿ’ก Navigate to the project directory and run 'workato init'") + return + + # Update workspace-level config to point to this project + try: + workspace_config = config_manager.load_config() + + # Calculate relative project path for workspace config + relative_project_path = str(project_path.relative_to(workspace_root)) + + # Copy project-specific data to workspace config + workspace_config.project_id = project_config.project_id + workspace_config.project_name = project_config.project_name + workspace_config.project_path = relative_project_path + workspace_config.folder_id = project_config.folder_id + workspace_config.profile = project_config.profile + + config_manager.save_config(workspace_config) + + click.echo(f"โœ… Switched to project '{project_name}'") + + # Show project details + if project_config.project_name: + click.echo(f" Name: {project_config.project_name}") + if project_config.folder_id: + click.echo(f" Folder ID: {project_config.folder_id}") + if project_config.profile: + click.echo(f" Profile: {project_config.profile}") + click.echo(f" Directory: {relative_project_path}") + + except Exception as e: + click.echo(f"โŒ Failed to switch to project '{project_name}': {e}") + + +@projects.command() +@handle_cli_exceptions +@inject +async def switch( + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Interactively switch to a different project""" + import inquirer + + # Find workspace root to search for projects + workspace_root = config_manager.get_workspace_root() + + # Use the new config system to find all projects in workspace + all_projects = config_manager._find_all_projects(workspace_root) + + if not all_projects: + click.echo("โŒ No projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + # Get current project for context + current_project_name = config_manager.get_current_project_name() + + # Build project choices with configuration + project_choices: list[tuple[str, str, ConfigData | None]] = [] + + for project_path, project_name in all_projects: + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + config_data = project_config_manager.load_config() + + # Create display name + display_name = project_name + if config_data.project_name and config_data.project_name != project_name: + display_name = f"{project_name} ({config_data.project_name})" + + if project_name == current_project_name: + display_name += " (current)" + + project_choices.append((display_name, project_name, config_data)) + except Exception: + # Still include projects with configuration errors + display_name = f"{project_name} (configuration error)" + if project_name == current_project_name: + display_name += " (current)" + project_choices.append((display_name, project_name, None)) + + if not project_choices: + click.echo("โŒ No configured projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + if len(project_choices) == 1 and project_choices[0][1] == current_project_name: + click.echo("โœ… Only one project available and it's already current") + return + + # Create interactive selection + choices = [choice[0] for choice in project_choices] + + questions = [ + inquirer.List( + "project", + message="Select a project to switch to", # noboost + choices=choices, + ) + ] + + answers = inquirer.prompt(questions) + if not answers: + click.echo("โŒ No project selected") + return + + # Find the selected project + selected_project_name: str | None = None + selected_config: ConfigData | None = None + + for display_name, project_name, project_config_data in project_choices: + if display_name == answers["project"]: + selected_project_name = project_name + selected_config = project_config_data + break + + if not selected_project_name: + click.echo("โŒ Failed to identify selected project") + return + + if selected_project_name == current_project_name: + click.echo("โœ… Project is already current") + return + + if not selected_config: + click.echo(f"โŒ Project '{selected_project_name}' has configuration errors") + return + + # Find the project path + selected_project_path = None + for project_path, project_name in all_projects: + if project_name == selected_project_name: + selected_project_path = project_path + break + + if not selected_project_path: + click.echo(f"โŒ Failed to find path for project '{selected_project_name}'") + return + + # Switch to the selected project + try: + workspace_config = config_manager.load_config() + + # Calculate relative project path for workspace config + relative_project_path = str(selected_project_path.relative_to(workspace_root)) + + # Copy project-specific data to workspace config + workspace_config.project_id = selected_config.project_id + workspace_config.project_name = selected_config.project_name + workspace_config.project_path = relative_project_path + workspace_config.folder_id = selected_config.folder_id + workspace_config.profile = selected_config.profile + + config_manager.save_config(workspace_config) + + click.echo(f"โœ… Switched to project '{selected_project_name}'") + + # Show project details + if selected_config.project_name: + click.echo(f" Name: {selected_config.project_name}") + if selected_config.folder_id: + click.echo(f" Folder ID: {selected_config.folder_id}") + if selected_config.profile: + click.echo(f" Profile: {selected_config.profile}") + click.echo(f" Directory: {relative_project_path}") + + except Exception as e: + click.echo(f"โŒ Failed to switch to project '{selected_project_name}': {e}") + + +@projects.group(name="config", invoke_without_command=True) +@click.option( + "--include-tags/--no-include-tags", + default=None, + help=( + "Set export_include_tags default in .workatoenv for project export manifests" + ), +) +@click.option( + "--include-test-cases/--no-include-test-cases", + default=None, + help=( + "Set export_include_test_cases default in .workatoenv for project " + "export manifests" + ), +) +@handle_cli_exceptions +@inject +async def set_project_config( + include_tags: bool | None = None, + include_test_cases: bool | None = None, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Set project export defaults in local .workatoenv config.""" + + if include_tags is None and include_test_cases is None: + click.echo("โŒ No config values provided") + click.echo( + "๐Ÿ’ก Use --include-tags/--no-include-tags and/or " + "--include-test-cases/--no-include-test-cases" + ) + return + + config_data = config_manager.load_config() + + if include_tags is not None: + config_data.export_include_tags = include_tags + if include_test_cases is not None: + config_data.export_include_test_cases = include_test_cases + + config_manager.save_config(config_data) + + click.echo("โœ… Updated project export defaults") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + +@set_project_config.command(name="show") +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@handle_cli_exceptions +@inject +async def show_project_config( + output_mode: str = "table", + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Show project export defaults from local .workatoenv config.""" + config_data = config_manager.load_config() + + if output_mode == "json": + click.echo( + json.dumps( + { + "export_include_tags": config_data.export_include_tags, + "export_include_test_cases": config_data.export_include_test_cases, + } + ) + ) + return + + click.echo("๐Ÿ“‹ Project export defaults:") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + +async def _get_local_projects( + config_manager: ConfigManager, +) -> list[tuple[Any, str, ConfigData | None]]: + """Get local projects with their configurations""" + workspace_root = config_manager.get_workspace_root() + all_projects = config_manager._find_all_projects(workspace_root) + + local_projects: list[tuple[Any, str, ConfigData | None]] = [] + for project_path, project_name in all_projects: + try: + project_config_manager = ConfigManager(project_path, skip_validation=True) + config_data = project_config_manager.load_config() + local_projects.append((project_path, project_name, config_data)) + except Exception: + local_projects.append((project_path, project_name, None)) + + return local_projects + + +async def _output_json( + source: str, + local_projects: list[tuple[Any, str, ConfigData | None]], + remote_projects: list[Project], + config_manager: ConfigManager, +) -> None: + """Output projects in JSON format""" + workspace_root = config_manager.get_workspace_root() + current_project_name = config_manager.get_current_project_name() + + output_data: dict[str, Any] = { + "source": source, + "current_project": current_project_name, + "workspace_root": str(workspace_root) if workspace_root else None, + "local_projects": [], + "remote_projects": [], + } + + # Process local projects + if source in ["local", "both"]: + for project_path, project_name, config_data in local_projects: + if config_data: + project_info = { + "name": project_name, + "directory": str(project_path.relative_to(workspace_root)) + if workspace_root + else str(project_path), + "is_current": project_name == current_project_name, + "project_id": config_data.project_id, + "folder_id": config_data.folder_id, + "profile": config_data.profile, + "configured": True, + } + else: + project_info = { + "name": project_name, + "directory": str(project_path.relative_to(workspace_root)) + if workspace_root + else str(project_path), + "is_current": project_name == current_project_name, + "configured": False, + "error": "configuration error", + } + output_data["local_projects"].append(project_info) + + # Process remote projects + if source in ["remote", "both"]: + for remote_project in remote_projects: + # Check if this remote project exists locally + local_match = None + if source == "both": + for _, _, config_data in local_projects: + if config_data and config_data.project_id == remote_project.id: + local_match = config_data + break + + remote_info = { + "name": remote_project.name, + "project_id": remote_project.id, + "folder_id": remote_project.folder_id, + "description": remote_project.description or "", + "has_local_copy": local_match is not None, + } + + if local_match: + remote_info["local_profile"] = local_match.profile + + output_data["remote_projects"].append(remote_info) + + click.echo(json.dumps(output_data)) + + +async def _output_table( + source: str, + local_projects: list[tuple[Any, str, ConfigData | None]], + remote_projects: list[Project], + config_manager: ConfigManager, +) -> None: + """Output projects in table format""" + workspace_root = config_manager.get_workspace_root() + current_project_name = config_manager.get_current_project_name() + + if source == "local": + if not local_projects: + click.echo("๐Ÿ“‹ No local projects found") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + click.echo("๐Ÿ“‹ Local projects:") + for project_path, project_name, config_data in sorted( + local_projects, key=lambda x: x[1] + ): + current_indicator = ( + " (current)" if project_name == current_project_name else "" + ) + + if config_data: + click.echo(f" โ€ข {project_name}{current_indicator}") + if config_data.project_id: + click.echo(f" Project ID: {config_data.project_id}") + if config_data.folder_id: + click.echo(f" Folder ID: {config_data.folder_id}") + if config_data.profile: + click.echo(f" Profile: {config_data.profile}") + if workspace_root: + click.echo( + f" Directory: {project_path.relative_to(workspace_root)}" + ) + else: + click.echo( + f" โ€ข {project_name}{current_indicator} (configuration error)" + ) + click.echo() + + elif source == "remote": + if not remote_projects: + click.echo("๐Ÿ“‹ No remote projects found") + return + + click.echo("๐Ÿ“‹ Remote projects:") + for remote_project in sorted(remote_projects, key=lambda x: x.name): + click.echo(f" โ€ข {remote_project.name}") + click.echo(f" Project ID: {remote_project.id}") + click.echo(f" Folder ID: {remote_project.folder_id}") + if remote_project.description: + click.echo(f" Description: {remote_project.description}") + click.echo() + + else: # both + # Show combined view with sync status + if not local_projects and not remote_projects: + click.echo("๐Ÿ“‹ No projects found locally or remotely") + click.echo("๐Ÿ’ก Run 'workato init' to create your first project") + return + + click.echo("๐Ÿ“‹ All projects (local + remote):") + + # Create a unified view + all_projects = {} + + # Add local projects + for project_path, project_name, config_data in local_projects: + project_id = config_data.project_id if config_data else None + all_projects[project_id or f"local:{project_name}"] = { + "name": project_name, + "project_id": project_id, + "folder_id": config_data.folder_id if config_data else None, + "profile": config_data.profile if config_data else None, + "local_path": project_path, + "is_local": True, + "is_remote": False, + "is_current": project_name == current_project_name, + "config_error": config_data is None, + } + + # Add/update with remote projects + for remote_project in remote_projects: + key = remote_project.id + if key in all_projects: + # Update existing local project with remote info + all_projects[key]["is_remote"] = True + all_projects[key]["remote_description"] = remote_project.description + else: + # Add remote-only project + all_projects[key] = { + "name": remote_project.name, + "project_id": remote_project.id, + "folder_id": remote_project.folder_id, + "remote_description": remote_project.description, + "is_local": False, + "is_remote": True, + "is_current": False, + "config_error": False, + } + + # Display unified projects + for project_data in sorted(all_projects.values(), key=lambda x: x["name"]): + status_indicators = [] + if project_data["is_current"]: + status_indicators.append("current") + if project_data["is_local"] and project_data["is_remote"]: + status_indicators.append("synced") + elif project_data["is_local"]: + status_indicators.append("local only") + elif project_data["is_remote"]: + status_indicators.append("remote only") + if project_data.get("config_error"): + status_indicators.append("config error") + + status_text = ( + f" ({', '.join(status_indicators)})" if status_indicators else "" + ) + click.echo(f" โ€ข {project_data['name']}{status_text}") + + if project_data["project_id"]: + click.echo(f" Project ID: {project_data['project_id']}") + if project_data["folder_id"]: + click.echo(f" Folder ID: {project_data['folder_id']}") + if project_data.get("profile"): + click.echo(f" Profile: {project_data['profile']}") + if project_data.get("remote_description"): + click.echo(f" Description: {project_data['remote_description']}") + if project_data.get("local_path") and workspace_root: + local_path = project_data["local_path"] + click.echo(f" Directory: {local_path.relative_to(workspace_root)}") + click.echo() diff --git a/src/workato_platform_cli/cli/commands/projects/project_manager.py b/src/workato_platform_cli/cli/commands/projects/project_manager.py index 7f0c87f..33c3dd2 100644 --- a/src/workato_platform_cli/cli/commands/projects/project_manager.py +++ b/src/workato_platform_cli/cli/commands/projects/project_manager.py @@ -1,407 +1,407 @@ -"""ProjectManager for handling Workato project operations""" - -import os -import shutil -import subprocess # noqa: S404 -import time -import zipfile - -from pathlib import Path - -import asyncclick as click -import inquirer - -from workato_platform_cli import Workato -from workato_platform_cli.cli.utils.spinner import Spinner -from workato_platform_cli.client.workato_api.models.asset import Asset -from workato_platform_cli.client.workato_api.models.create_export_manifest_request import ( # noqa: E501 - CreateExportManifestRequest, -) -from workato_platform_cli.client.workato_api.models.create_folder_request import ( - CreateFolderRequest, -) -from workato_platform_cli.client.workato_api.models.export_manifest_request import ( - ExportManifestRequest, -) -from workato_platform_cli.client.workato_api.models.project import Project - - -class ProjectManager: - """Manages Workato project operations using the new API client""" - - def __init__(self, workato_api_client: Workato): - self.client = workato_api_client - - def _format_project_display(self, project: Project) -> str: - """Format a project object for display - returns formatted string""" - return f"{project.name} (ID: {project.id})" - - def _get_project_by_display_name( - self, - projects: list[Project], - display_name: str, - ) -> Project | None: - """Find a project by its display name - returns project object or None""" - for project in projects: - if self._format_project_display(project) == display_name: - return project - return None - - async def get_projects(self, page: int = 1, per_page: int = 100) -> list[Project]: - """Get list of projects with pagination""" - return await self.client.projects_api.list_projects( - page=page, per_page=per_page - ) - - async def get_all_projects(self) -> list[Project]: - """Get all projects by handling pagination""" - all_projects = [] - page = 1 - per_page = 100 - - while True: - projects = await self.get_projects(page, per_page) - - if not projects: - break - - all_projects.extend(projects) - - # If we got fewer than per_page results, we're on the last page - if len(projects) < per_page: - break - - page += 1 - - return all_projects - - async def create_project(self, project_name: str) -> Project: - """Create a new project folder""" - folder_data = await self.client.folders_api.create_folder( - create_folder_request=CreateFolderRequest(name=project_name) - ) - - for project in await self.get_all_projects(): - if project.folder_id == folder_data.id: - return project - - return Project( - id=folder_data.id, - name=project_name, - folder_id=folder_data.id, - description="", - ) - - async def check_folder_assets(self, folder_id: int) -> list[Asset]: - """Check if a folder has any assets""" - assets_response = await self.client.export_api.list_assets_in_folder( - folder_id=folder_id - ) - assets = ( - assets_response.result.assets - if assets_response.result and assets_response.result.assets - else [] - ) - return assets - - @staticmethod - def _parse_bool_value(value: str | None) -> bool | None: - """Parse common string boolean formats to bool.""" - if value is None: - return None - - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "y", "on"}: - return True - if normalized in {"0", "false", "no", "n", "off"}: - return False - return None - - def _resolve_export_manifest_flags( - self, - include_tags: bool | None, - include_test_cases: bool | None, - ) -> tuple[bool, bool]: - """Resolve export manifest flags.""" - config_include_tags: bool | None = None - config_include_test_cases: bool | None = None - - try: - from workato_platform_cli.cli.utils.config import ConfigManager - - config_data = ConfigManager(skip_validation=True).load_config() - config_include_tags = config_data.export_include_tags - config_include_test_cases = config_data.export_include_test_cases - except Exception: - # Export should still work even if local config cannot be loaded. - click.echo("โš ๏ธ Could not load config, using defaults") - pass - - env_include_tags = self._parse_bool_value( - os.environ.get("WORKATO_INCLUDE_TAGS") - ) - env_include_test_cases = self._parse_bool_value( - os.environ.get("WORKATO_INCLUDE_TEST_CASES") - ) - - resolved_include_tags = ( - include_tags - if include_tags is not None - else config_include_tags - if config_include_tags is not None - else env_include_tags - if env_include_tags is not None - else False - ) - resolved_include_test_cases = ( - include_test_cases - if include_test_cases is not None - else config_include_test_cases - if config_include_test_cases is not None - else env_include_test_cases - if env_include_test_cases is not None - else False - ) - - return resolved_include_tags, resolved_include_test_cases - - async def export_project( - self, - folder_id: int, - project_name: str, - target_dir: str = "project", - include_tags: bool | None = None, - include_test_cases: bool | None = None, - ) -> str | None: - """Export project assets and return project directory path""" - resolved_include_tags, resolved_include_test_cases = ( - self._resolve_export_manifest_flags(include_tags, include_test_cases) - ) - - # Check if project has any assets before attempting export - assets = await self.check_folder_assets(folder_id) - - if not assets: - # Empty project - just create the directory structure - click.echo("๐Ÿ“‚ Project is empty, creating directory structure...") - project_dir = Path(target_dir) - project_dir.mkdir(exist_ok=True) - return str(project_dir) - - # Create export manifest with spinner - spinner = Spinner("Creating export manifest") - spinner.start() - try: - manifest_data = await self.client.export_api.create_export_manifest( - create_export_manifest_request=CreateExportManifestRequest( - export_manifest=ExportManifestRequest( - name=project_name, - folder_id=folder_id, - auto_generate_assets=True, - include_tags=resolved_include_tags, - include_test_cases=resolved_include_test_cases, - ) - ) - ) - manifest_id = manifest_data.result.id - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Export manifest created: {manifest_id} ({elapsed:.1f}s)") - - # Trigger the export package creation - spinner = Spinner("Triggering export package") - spinner.start() - try: - package_data = await self.client.packages_api.export_package( - id=str(manifest_id) - ) - package_id = package_data.id - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Export package triggered: {package_id} ({elapsed:.1f}s)") - - # Poll for package completion and download - extracted_project_dir = await self.download_and_extract_package( - package_id, - target_dir, - ) - return str(extracted_project_dir) - - async def download_and_extract_package( - self, package_id: int, target_dir: str = "project" - ) -> Path | None: - """Download and extract the package, return project directory path""" - - # Poll package status until completed with dynamic status - spinner = Spinner("Preparing package") - spinner.start() - - max_wait_time = 300 # 5 minutes timeout - start_time = time.time() - - try: - while time.time() - start_time < max_wait_time: - package_response = await self.client.packages_api.get_package( - package_id=package_id - ) - status = package_response.status - - if status == "completed": - break - elif status == "failed": - spinner.stop() - click.echo("โŒ Package export failed") - - # Show error details if available - if package_response.error: - click.echo(f" ๐Ÿ“„ Error: {package_response.error}") - if package_response.recipe_status: - click.echo(" ๐Ÿ“‹ Detailed errors:") - for error in package_response.recipe_status: - click.echo(f" โ€ข {error}") - - return None - else: - # Update spinner message with current status - spinner.update_message(f"Processing package ({status})") - time.sleep(2) # Wait 2 seconds before polling again - - # Check if we timed out - if time.time() - start_time >= max_wait_time: - spinner.stop() - click.echo( - f"โฐ Package still processing after {max_wait_time // 60} minutes" - ) - click.echo(" ๐Ÿ’ก Check status manually or try again later") - click.echo(f" ๐Ÿ“Š Package ID: {package_id}") - return None - - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Package ready for download ({elapsed:.1f}s)") - - # Download the package using the direct download API - spinner = Spinner("Downloading package") - spinner.start() - try: - download_response = await self.client.packages_api.download_package( - package_id=package_id - ) - finally: - elapsed = spinner.stop() - - click.echo(f"โœ… Package downloaded ({elapsed:.1f}s)") - - # Create project directory and extract - spinner = Spinner("Extracting files") - spinner.start() - try: - project_dir = Path(target_dir) - project_dir.mkdir(exist_ok=True) - - # Save and extract zip file - zip_path = f"{target_dir}.zip" - with open(zip_path, "wb") as f: - f.write(download_response) - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(project_dir) - - os.remove(zip_path) - finally: - elapsed = spinner.stop() - - # Show clean message - don't expose internal temp paths to the user - click.echo(f"โœ… Project assets extracted ({elapsed:.1f}s)") - return project_dir - - async def handle_post_api_sync( - self, - ) -> None: - """Handle syncing project files after API resource operations""" - # Auto-sync is always enabled - run pull automatically - click.echo() - click.echo("๐Ÿ”„ Auto-syncing project files...") - try: - # Find the workato executable to use full path for security - workato_exe = shutil.which("workato") - if workato_exe is None: - click.echo("โš ๏ธ Could not find workato executable for auto-sync") - return - - # Secure subprocess call: hardcoded command, validated executable path - result = subprocess.run( # noqa: S603 - [workato_exe, "pull"], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - click.echo("โœ… Project synced successfully") - else: - click.echo(f"โš ๏ธ Sync completed with warnings: {result.stderr.strip()}") - except subprocess.TimeoutExpired: - click.echo("โš ๏ธ Sync timed out - please run 'workato pull' manually") - - async def delete_project(self, project_id: int) -> None: - """Delete a project (folder and assets are automatically cleaned up)""" - await self.client.projects_api.delete_project(project_id=project_id) - - def save_project_to_config(self, project: Project) -> None: - """Save project info to config - returns True if successful""" - from workato_platform_cli.cli.utils.config import ConfigManager - - config_manager = ConfigManager() - - meta_data = config_manager.load_config() - meta_data.project_id = project.id - meta_data.project_name = project.name - meta_data.folder_id = project.folder_id - - config_manager.save_config(meta_data) - - async def import_existing_project(self) -> Project | None: - """Import an existing project - returns project info or None if failed""" - projects = await self.client.projects_api.list_projects() - - if not projects: - click.echo("No projects found in your workspace.") - return None - - # Create project choices using utility function - choices = [self._format_project_display(p) for p in projects] - questions = [ - inquirer.List( - "project", # noboost - message="Select a project:", # noboost - choices=choices, - ) - ] - - answers = inquirer.prompt(questions) - if not answers: - return None - - # Find selected project using utility function - selected_project = self._get_project_by_display_name( - projects, answers["project"] - ) - - if not selected_project: - return None - # Save project info using utility function - - self.save_project_to_config(selected_project) - click.echo(f"โœ… Selected project: {selected_project.name}") - - # Export project files - if selected_project.folder_id: - await self.export_project( - folder_id=selected_project.folder_id, - project_name=selected_project.name, - ) - - return selected_project +"""ProjectManager for handling Workato project operations""" + +import os +import shutil +import subprocess # noqa: S404 +import time +import zipfile + +from pathlib import Path + +import asyncclick as click +import inquirer + +from workato_platform_cli import Workato +from workato_platform_cli.cli.utils.spinner import Spinner +from workato_platform_cli.client.workato_api.models.asset import Asset +from workato_platform_cli.client.workato_api.models.create_export_manifest_request import ( # noqa: E501 + CreateExportManifestRequest, +) +from workato_platform_cli.client.workato_api.models.create_folder_request import ( + CreateFolderRequest, +) +from workato_platform_cli.client.workato_api.models.export_manifest_request import ( + ExportManifestRequest, +) +from workato_platform_cli.client.workato_api.models.project import Project + + +class ProjectManager: + """Manages Workato project operations using the new API client""" + + def __init__(self, workato_api_client: Workato): + self.client = workato_api_client + + def _format_project_display(self, project: Project) -> str: + """Format a project object for display - returns formatted string""" + return f"{project.name} (ID: {project.id})" + + def _get_project_by_display_name( + self, + projects: list[Project], + display_name: str, + ) -> Project | None: + """Find a project by its display name - returns project object or None""" + for project in projects: + if self._format_project_display(project) == display_name: + return project + return None + + async def get_projects(self, page: int = 1, per_page: int = 100) -> list[Project]: + """Get list of projects with pagination""" + return await self.client.projects_api.list_projects( + page=page, per_page=per_page + ) + + async def get_all_projects(self) -> list[Project]: + """Get all projects by handling pagination""" + all_projects = [] + page = 1 + per_page = 100 + + while True: + projects = await self.get_projects(page, per_page) + + if not projects: + break + + all_projects.extend(projects) + + # If we got fewer than per_page results, we're on the last page + if len(projects) < per_page: + break + + page += 1 + + return all_projects + + async def create_project(self, project_name: str) -> Project: + """Create a new project folder""" + folder_data = await self.client.folders_api.create_folder( + create_folder_request=CreateFolderRequest(name=project_name) + ) + + for project in await self.get_all_projects(): + if project.folder_id == folder_data.id: + return project + + return Project( + id=folder_data.id, + name=project_name, + folder_id=folder_data.id, + description="", + ) + + async def check_folder_assets(self, folder_id: int) -> list[Asset]: + """Check if a folder has any assets""" + assets_response = await self.client.export_api.list_assets_in_folder( + folder_id=folder_id + ) + assets = ( + assets_response.result.assets + if assets_response.result and assets_response.result.assets + else [] + ) + return assets + + @staticmethod + def _parse_bool_value(value: str | None) -> bool | None: + """Parse common string boolean formats to bool.""" + if value is None: + return None + + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return None + + def _resolve_export_manifest_flags( + self, + include_tags: bool | None, + include_test_cases: bool | None, + ) -> tuple[bool, bool]: + """Resolve export manifest flags.""" + config_include_tags: bool | None = None + config_include_test_cases: bool | None = None + + try: + from workato_platform_cli.cli.utils.config import ConfigManager + + config_data = ConfigManager(skip_validation=True).load_config() + config_include_tags = config_data.export_include_tags + config_include_test_cases = config_data.export_include_test_cases + except Exception: + # Export should still work even if local config cannot be loaded. + click.echo("โš ๏ธ Could not load config, using defaults") + pass + + env_include_tags = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TAGS") + ) + env_include_test_cases = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TEST_CASES") + ) + + resolved_include_tags = ( + include_tags + if include_tags is not None + else config_include_tags + if config_include_tags is not None + else env_include_tags + if env_include_tags is not None + else False + ) + resolved_include_test_cases = ( + include_test_cases + if include_test_cases is not None + else config_include_test_cases + if config_include_test_cases is not None + else env_include_test_cases + if env_include_test_cases is not None + else False + ) + + return resolved_include_tags, resolved_include_test_cases + + async def export_project( + self, + folder_id: int, + project_name: str, + target_dir: str = "project", + include_tags: bool | None = None, + include_test_cases: bool | None = None, + ) -> str | None: + """Export project assets and return project directory path""" + resolved_include_tags, resolved_include_test_cases = ( + self._resolve_export_manifest_flags(include_tags, include_test_cases) + ) + + # Check if project has any assets before attempting export + assets = await self.check_folder_assets(folder_id) + + if not assets: + # Empty project - just create the directory structure + click.echo("๐Ÿ“‚ Project is empty, creating directory structure...") + project_dir = Path(target_dir) + project_dir.mkdir(exist_ok=True) + return str(project_dir) + + # Create export manifest with spinner + spinner = Spinner("Creating export manifest") + spinner.start() + try: + manifest_data = await self.client.export_api.create_export_manifest( + create_export_manifest_request=CreateExportManifestRequest( + export_manifest=ExportManifestRequest( + name=project_name, + folder_id=folder_id, + auto_generate_assets=True, + include_tags=resolved_include_tags, + include_test_cases=resolved_include_test_cases, + ) + ) + ) + manifest_id = manifest_data.result.id + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Export manifest created: {manifest_id} ({elapsed:.1f}s)") + + # Trigger the export package creation + spinner = Spinner("Triggering export package") + spinner.start() + try: + package_data = await self.client.packages_api.export_package( + id=str(manifest_id) + ) + package_id = package_data.id + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Export package triggered: {package_id} ({elapsed:.1f}s)") + + # Poll for package completion and download + extracted_project_dir = await self.download_and_extract_package( + package_id, + target_dir, + ) + return str(extracted_project_dir) + + async def download_and_extract_package( + self, package_id: int, target_dir: str = "project" + ) -> Path | None: + """Download and extract the package, return project directory path""" + + # Poll package status until completed with dynamic status + spinner = Spinner("Preparing package") + spinner.start() + + max_wait_time = 300 # 5 minutes timeout + start_time = time.time() + + try: + while time.time() - start_time < max_wait_time: + package_response = await self.client.packages_api.get_package( + package_id=package_id + ) + status = package_response.status + + if status == "completed": + break + elif status == "failed": + spinner.stop() + click.echo("โŒ Package export failed") + + # Show error details if available + if package_response.error: + click.echo(f" ๐Ÿ“„ Error: {package_response.error}") + if package_response.recipe_status: + click.echo(" ๐Ÿ“‹ Detailed errors:") + for error in package_response.recipe_status: + click.echo(f" โ€ข {error}") + + return None + else: + # Update spinner message with current status + spinner.update_message(f"Processing package ({status})") + time.sleep(2) # Wait 2 seconds before polling again + + # Check if we timed out + if time.time() - start_time >= max_wait_time: + spinner.stop() + click.echo( + f"โฐ Package still processing after {max_wait_time // 60} minutes" + ) + click.echo(" ๐Ÿ’ก Check status manually or try again later") + click.echo(f" ๐Ÿ“Š Package ID: {package_id}") + return None + + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Package ready for download ({elapsed:.1f}s)") + + # Download the package using the direct download API + spinner = Spinner("Downloading package") + spinner.start() + try: + download_response = await self.client.packages_api.download_package( + package_id=package_id + ) + finally: + elapsed = spinner.stop() + + click.echo(f"โœ… Package downloaded ({elapsed:.1f}s)") + + # Create project directory and extract + spinner = Spinner("Extracting files") + spinner.start() + try: + project_dir = Path(target_dir) + project_dir.mkdir(exist_ok=True) + + # Save and extract zip file + zip_path = f"{target_dir}.zip" + with open(zip_path, "wb") as f: + f.write(download_response) + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(project_dir) + + os.remove(zip_path) + finally: + elapsed = spinner.stop() + + # Show clean message - don't expose internal temp paths to the user + click.echo(f"โœ… Project assets extracted ({elapsed:.1f}s)") + return project_dir + + async def handle_post_api_sync( + self, + ) -> None: + """Handle syncing project files after API resource operations""" + # Auto-sync is always enabled - run pull automatically + click.echo() + click.echo("๐Ÿ”„ Auto-syncing project files...") + try: + # Find the workato executable to use full path for security + workato_exe = shutil.which("workato") + if workato_exe is None: + click.echo("โš ๏ธ Could not find workato executable for auto-sync") + return + + # Secure subprocess call: hardcoded command, validated executable path + result = subprocess.run( # noqa: S603 + [workato_exe, "pull"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + click.echo("โœ… Project synced successfully") + else: + click.echo(f"โš ๏ธ Sync completed with warnings: {result.stderr.strip()}") + except subprocess.TimeoutExpired: + click.echo("โš ๏ธ Sync timed out - please run 'workato pull' manually") + + async def delete_project(self, project_id: int) -> None: + """Delete a project (folder and assets are automatically cleaned up)""" + await self.client.projects_api.delete_project(project_id=project_id) + + def save_project_to_config(self, project: Project) -> None: + """Save project info to config - returns True if successful""" + from workato_platform_cli.cli.utils.config import ConfigManager + + config_manager = ConfigManager() + + meta_data = config_manager.load_config() + meta_data.project_id = project.id + meta_data.project_name = project.name + meta_data.folder_id = project.folder_id + + config_manager.save_config(meta_data) + + async def import_existing_project(self) -> Project | None: + """Import an existing project - returns project info or None if failed""" + projects = await self.client.projects_api.list_projects() + + if not projects: + click.echo("No projects found in your workspace.") + return None + + # Create project choices using utility function + choices = [self._format_project_display(p) for p in projects] + questions = [ + inquirer.List( + "project", # noboost + message="Select a project:", # noboost + choices=choices, + ) + ] + + answers = inquirer.prompt(questions) + if not answers: + return None + + # Find selected project using utility function + selected_project = self._get_project_by_display_name( + projects, answers["project"] + ) + + if not selected_project: + return None + # Save project info using utility function + + self.save_project_to_config(selected_project) + click.echo(f"โœ… Selected project: {selected_project.name}") + + # Export project files + if selected_project.folder_id: + await self.export_project( + folder_id=selected_project.folder_id, + project_name=selected_project.name, + ) + + return selected_project diff --git a/src/workato_platform_cli/cli/utils/config/models.py b/src/workato_platform_cli/cli/utils/config/models.py index 1691de1..f4420a7 100644 --- a/src/workato_platform_cli/cli/utils/config/models.py +++ b/src/workato_platform_cli/cli/utils/config/models.py @@ -1,99 +1,99 @@ -"""Data models for configuration management.""" - -from pydantic import BaseModel, Field, field_validator - - -class ProjectInfo(BaseModel): - """Data model for project information""" - - id: int = Field(..., description="Project ID") - name: str = Field(..., description="Project name") - folder_id: int | None = Field(None, description="Associated folder ID") - - -class ConfigData(BaseModel): - """Data model for configuration file data""" - - project_id: int | None = Field(None, description="Project ID") - project_name: str | None = Field(None, description="Project name") - project_path: str | None = Field( - None, description="Relative path to project (workspace only)" - ) - folder_id: int | None = Field(None, description="Folder ID") - profile: str | None = Field(None, description="Profile override") - export_include_tags: bool | None = Field( - None, - description="Default value for include_tags when exporting project manifests", - ) - export_include_test_cases: bool | None = Field( - None, - description=( - "Default value for include_test_cases when exporting project manifests" - ), - ) - - -class RegionInfo(BaseModel): - """Data model for region information""" - - region: str = Field(..., description="Region code") - name: str = Field(..., description="Human-readable region name") - url: str | None = Field(None, description="Base URL for the region") - - -class ProfileData(BaseModel): - """Data model for a single profile""" - - region: str = Field( - ..., description="Region code (us, eu, jp, sg, au, il, trial, custom)" - ) - region_url: str = Field(..., description="Base URL for the region") - workspace_id: int = Field(..., description="Workspace ID") - - @field_validator("region") - def validate_region(cls, v: str) -> str: # noqa: N805 - """Validate region code""" - valid_regions = {"us", "eu", "jp", "sg", "au", "il", "trial", "custom"} - if v not in valid_regions: - raise ValueError(f"Invalid region code: {v}") - return v - - @property - def region_name(self) -> str: - """Get human-readable region name from region code""" - region_info = AVAILABLE_REGIONS.get(self.region) - return region_info.name if region_info else f"Unknown ({self.region})" - - -class ProfilesConfig(BaseModel): - """Data model for profiles file (~/.workato/profiles)""" - - current_profile: str | None = Field(None, description="Currently active profile") - profiles: dict[str, ProfileData] = Field( - default_factory=dict, description="Profile definitions" - ) - - -# Available Workato regions -AVAILABLE_REGIONS = { - "us": RegionInfo(region="us", name="US Data Center", url="https://www.workato.com"), - "eu": RegionInfo( - region="eu", name="EU Data Center", url="https://app.eu.workato.com" - ), - "jp": RegionInfo( - region="jp", name="JP Data Center", url="https://app.jp.workato.com" - ), - "sg": RegionInfo( - region="sg", name="SG Data Center", url="https://app.sg.workato.com" - ), - "au": RegionInfo( - region="au", name="AU Data Center", url="https://app.au.workato.com" - ), - "il": RegionInfo( - region="il", name="IL Data Center", url="https://app.il.workato.com" - ), - "trial": RegionInfo( - region="trial", name="Developer Sandbox", url="https://app.trial.workato.com" - ), - "custom": RegionInfo(region="custom", name="Custom URL", url=None), -} +"""Data models for configuration management.""" + +from pydantic import BaseModel, Field, field_validator + + +class ProjectInfo(BaseModel): + """Data model for project information""" + + id: int = Field(..., description="Project ID") + name: str = Field(..., description="Project name") + folder_id: int | None = Field(None, description="Associated folder ID") + + +class ConfigData(BaseModel): + """Data model for configuration file data""" + + project_id: int | None = Field(None, description="Project ID") + project_name: str | None = Field(None, description="Project name") + project_path: str | None = Field( + None, description="Relative path to project (workspace only)" + ) + folder_id: int | None = Field(None, description="Folder ID") + profile: str | None = Field(None, description="Profile override") + export_include_tags: bool | None = Field( + None, + description="Default value for include_tags when exporting project manifests", + ) + export_include_test_cases: bool | None = Field( + None, + description=( + "Default value for include_test_cases when exporting project manifests" + ), + ) + + +class RegionInfo(BaseModel): + """Data model for region information""" + + region: str = Field(..., description="Region code") + name: str = Field(..., description="Human-readable region name") + url: str | None = Field(None, description="Base URL for the region") + + +class ProfileData(BaseModel): + """Data model for a single profile""" + + region: str = Field( + ..., description="Region code (us, eu, jp, sg, au, il, trial, custom)" + ) + region_url: str = Field(..., description="Base URL for the region") + workspace_id: int = Field(..., description="Workspace ID") + + @field_validator("region") + def validate_region(cls, v: str) -> str: # noqa: N805 + """Validate region code""" + valid_regions = {"us", "eu", "jp", "sg", "au", "il", "trial", "custom"} + if v not in valid_regions: + raise ValueError(f"Invalid region code: {v}") + return v + + @property + def region_name(self) -> str: + """Get human-readable region name from region code""" + region_info = AVAILABLE_REGIONS.get(self.region) + return region_info.name if region_info else f"Unknown ({self.region})" + + +class ProfilesConfig(BaseModel): + """Data model for profiles file (~/.workato/profiles)""" + + current_profile: str | None = Field(None, description="Currently active profile") + profiles: dict[str, ProfileData] = Field( + default_factory=dict, description="Profile definitions" + ) + + +# Available Workato regions +AVAILABLE_REGIONS = { + "us": RegionInfo(region="us", name="US Data Center", url="https://www.workato.com"), + "eu": RegionInfo( + region="eu", name="EU Data Center", url="https://app.eu.workato.com" + ), + "jp": RegionInfo( + region="jp", name="JP Data Center", url="https://app.jp.workato.com" + ), + "sg": RegionInfo( + region="sg", name="SG Data Center", url="https://app.sg.workato.com" + ), + "au": RegionInfo( + region="au", name="AU Data Center", url="https://app.au.workato.com" + ), + "il": RegionInfo( + region="il", name="IL Data Center", url="https://app.il.workato.com" + ), + "trial": RegionInfo( + region="trial", name="Developer Sandbox", url="https://app.trial.workato.com" + ), + "custom": RegionInfo(region="custom", name="Custom URL", url=None), +} diff --git a/tests/unit/commands/projects/test_command.py b/tests/unit/commands/projects/test_command.py index 9980f64..883699d 100644 --- a/tests/unit/commands/projects/test_command.py +++ b/tests/unit/commands/projects/test_command.py @@ -1,1223 +1,1223 @@ -"""Unit tests for the projects CLI command module.""" - -from __future__ import annotations - -import json -import sys - -from collections.abc import Iterator -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from workato_platform_cli.cli.commands.projects import command -from workato_platform_cli.cli.utils.config import ConfigData - - -@pytest.fixture(autouse=True) -def capture_echo(monkeypatch: pytest.MonkeyPatch) -> list[str]: - captured: list[str] = [] - - def _capture(message: str = "") -> None: - captured.append(message) - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.click.echo", - _capture, - ) - return captured - - -@pytest.mark.asyncio -async def test_list_projects_no_directory( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - monkeypatch.chdir(tmp_path) - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] # No projects found - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_list_projects_with_entries( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - projects_dir = workspace / "projects" - alpha_project = projects_dir / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text( - '{"project_id": 5, "project_name": "Alpha", ' - '"folder_id": 9, "profile": "default"}', - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - project_config = ConfigData( - project_id=5, project_name="Alpha", folder_id=9, profile="default" - ) - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return project_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "alpha" in output - assert "Folder ID" in output - - -@pytest.mark.asyncio -async def test_use_project_success( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - workspace_config = ConfigData() - - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - (project_dir / ".workatoenv").write_text( - '{"project_id": 3, "project_name": "Beta", "folder_id": 7, "profile": "p1"}' - ) - - project_config = ConfigData( - project_id=3, project_name="Beta", folder_id=7, profile="p1" - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - config_manager.load_config.return_value = workspace_config - config_manager.save_config = Mock() - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return project_config if self.path == project_dir else workspace_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - saved = config_manager.save_config.call_args.args[0] - assert saved.project_id == 3 - assert saved.project_path == "projects/beta" - assert "Switched to project" in "\n".join(capture_echo) - - -@pytest.mark.asyncio -async def test_use_project_not_found(tmp_path: Path, capture_echo: list[str]) -> None: - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager._find_all_projects.return_value = [] # No projects found - - await command.use.callback(project_name="missing", config_manager=config_manager) # type: ignore[misc] - - assert any("not found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_interactive( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text( - '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (workspace / "alpha", "alpha"), - (beta_project, "beta"), - ] - config_manager.load_config.return_value = ConfigData() - config_manager.save_config = Mock() - - selected_config = ConfigData( - project_id=9, project_name="Beta", folder_id=11, profile="default" - ) - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - if self.path == beta_project: - return selected_config - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - config_manager.save_config.assert_called_once() - assert "Switched to project 'beta'" in "\n".join(capture_echo) - - -@pytest.mark.asyncio -async def test_switch_keeps_current_when_only_one( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> ConfigData: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: None, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("already current" in line for line in capture_echo) - - -def test_project_group_exists() -> None: - """Test that the project group command exists.""" - assert callable(command.projects) - - # Test that it's a click group - import asyncclick as click - - assert isinstance(command.projects, click.Group) - assert command.projects.callback is not None - assert command.projects.callback() is None - - -@pytest.mark.asyncio -async def test_list_projects_empty_directory( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when projects directory exists but is empty.""" - workspace = tmp_path - projects_dir = workspace / "projects" - projects_dir.mkdir() # Create empty projects directory - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] # Empty directory - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_list_projects_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when project has configuration error.""" - workspace = tmp_path - projects_dir = workspace / "projects" - alpha_project = projects_dir / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - # Mock ConfigManager to raise exception - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "configuration error" in output - - -@pytest.mark.asyncio -async def test_list_projects_json_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """JSON mode should surface configuration errors.""" - - workspace = tmp_path - project_dir = workspace / "projects" / "alpha" - project_dir.mkdir(parents=True) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(project_dir, "alpha")] - - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("broken") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.list_projects.callback( # type: ignore[misc] - output_mode="json", config_manager=config_manager - ) - - assert capture_echo, "Expected JSON output" - data = json.loads("".join(capture_echo)) - assert data["local_projects"][0]["configured"] is False - assert "configuration error" in data["local_projects"][0]["error"] - - -@pytest.mark.asyncio -async def test_list_projects_workspace_root_fallback( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects when workspace root is None, falls back to cwd.""" - monkeypatch.chdir(tmp_path) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = Path.cwd() - config_manager._find_all_projects.return_value = [] # Force fallback - config_manager.get_current_project_name.return_value = None - - await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No local projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_use_project_not_configured( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test use project when project exists but is not configured.""" - workspace = tmp_path - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - # No .workatoenv file created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - - # Mock ConfigManager to raise exception for unconfigured project - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - output = "\n".join(capture_echo) - assert "configuration errors" in output - - -@pytest.mark.asyncio -async def test_use_project_exception_handling( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test use project exception handling.""" - workspace = tmp_path - project_dir = workspace / "projects" / "beta" - project_dir.mkdir(parents=True) - (project_dir / ".workatoenv").write_text( - '{"project_id": 3, "project_name": "Beta", "folder_id": 7}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(project_dir, "beta")] - config_manager.load_config.side_effect = Exception( - "Config error" - ) # Force exception - - await command.use.callback( # type: ignore[misc] - project_name="beta", - config_manager=config_manager, - ) - - output = "\n".join(capture_echo) - assert "Failed to switch to project" in output - - -@pytest.mark.asyncio -async def test_switch_workspace_root_fallback( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when workspace root is None, falls back to cwd.""" - monkeypatch.chdir(tmp_path) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = Path.cwd() - config_manager._find_all_projects.return_value = [] # Force fallback - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_projects_directory( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when no projects directory exists.""" - workspace = tmp_path - # No projects directory created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [] - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_config_error( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command with configuration error.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - # Mock ConfigManager to raise exception - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (configuration error)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "configuration errors" in output - - -@pytest.mark.asyncio -async def test_switch_config_error_current_project( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Config errors on the current project should report already current.""" - - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = "alpha" - - def failing_config_manager(*_: Any, **__: Any) -> Any: - mock = Mock() - mock.load_config.side_effect = Exception("Configuration error") - return mock - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - failing_config_manager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (configuration error) (current)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "already current" in output - - -@pytest.mark.asyncio -async def test_switch_no_configured_projects( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when no configured projects found.""" - workspace = tmp_path - projects_dir = workspace / "projects" - projects_dir.mkdir() - # Create directory but no projects with .workatoenv - - project_dir = projects_dir / "unconfigured" - project_dir.mkdir() - # No .workatoenv file created - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [] # No configured projects - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No projects found" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_project_choices_after_iteration( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Guard clause should trigger when iteration yields nothing.""" - - class TruthyEmpty: - def __iter__(self) -> Iterator[tuple[Path, str]]: - return iter(()) - - def __bool__(self) -> bool: - return True - - config_manager = Mock() - config_manager.get_workspace_root.return_value = tmp_path - config_manager._find_all_projects.return_value = TruthyEmpty() - config_manager.get_current_project_name.return_value = None - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No configured projects" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_no_project_selected( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when user cancels selection.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: None, # User cancelled - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("No project selected" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_failed_to_identify_project( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when selected project can't be identified.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - config_manager.get_current_project_name.return_value = None - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "nonexistent"}, # Select non-matching project - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("Failed to identify selected project" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_already_current( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command when selected project is already current.""" - workspace = tmp_path - alpha_project = workspace / "projects" / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text('{"project_name": "beta"}') - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (alpha_project, "alpha"), - (beta_project, "beta"), - ] - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - if self.path == alpha_project: - return ConfigData(project_name="alpha") - return ConfigData(project_name="beta") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "alpha (current)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("already current" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_missing_project_path( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """If the project list becomes stale, path lookup should fail gracefully.""" - - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - - class OneShot: - def __init__(self, entry: tuple[Path, str]) -> None: - self.entry = entry - self.iterations = 0 - - def __iter__(self) -> Iterator[tuple[Path, str]]: - if self.iterations == 0: - self.iterations += 1 - return iter([self.entry]) - return iter(()) - - def __bool__(self) -> bool: - return True - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = OneShot((beta_project, "beta")) - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - - def load_config(self) -> ConfigData: - return ConfigData(project_name="Beta Display") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta Display)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - assert any("Failed to find path" in line for line in capture_echo) - - -@pytest.mark.asyncio -async def test_switch_exception_handling( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test switch command exception handling.""" - workspace = tmp_path - beta_project = workspace / "projects" / "beta" - beta_project.mkdir(parents=True) - (beta_project / ".workatoenv").write_text( - '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [ - (workspace / "alpha", "alpha"), - (beta_project, "beta"), - ] - config_manager.load_config.side_effect = Exception( - "Config error" - ) # Force exception - - selected_config = ConfigData(project_id=9, project_name="Beta", folder_id=11) - - class StubConfigManager: - def __init__(self, path: Any, skip_validation: bool = False) -> None: - self.path = path - self.skip_validation = skip_validation - - def load_config(self) -> Any: - if self.path == beta_project: - return selected_config - return ConfigData(project_name="alpha") - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - stub_inquirer = SimpleNamespace( - List=lambda *args, **kwargs: SimpleNamespace(), - prompt=lambda *_: {"project": "beta (Beta)"}, - ) - monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) - - await command.switch.callback(config_manager=config_manager) # type: ignore[misc] - - output = "\n".join(capture_echo) - assert "Failed to switch to project" in output - - -@pytest.mark.asyncio -async def test_list_projects_json_output_mode( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list_projects with JSON output mode.""" - workspace_root = tmp_path / "workspace" - project_path = workspace_root / "test-project" - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace_root - config_manager.get_current_project_name.return_value = "test-project" - config_manager._find_all_projects.return_value = [(project_path, "test-project")] - - # Mock project config manager - project_config = ConfigData( - project_id=123, project_name="Test Project", folder_id=456, profile="dev" - ) - mock_project_config_manager = Mock() - mock_project_config_manager.load_config.return_value = project_config - - with patch( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - return_value=mock_project_config_manager, - ): - assert command.list_projects.callback - await command.list_projects.callback( - output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - - # Parse JSON output - import json - - parsed = json.loads(output) - - assert parsed["current_project"] == "test-project" - assert len(parsed["local_projects"]) == 1 - project = parsed["local_projects"][0] - assert project["name"] == "test-project" - assert project["is_current"] is True - assert project["project_id"] == 123 - assert project["folder_id"] == 456 - assert project["profile"] == "dev" - assert project["configured"] is True - - -@pytest.mark.asyncio -async def test_list_projects_json_output_mode_empty( - tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list_projects JSON output with no projects.""" - workspace_root = tmp_path / "workspace" - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace_root - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] - - assert command.list_projects.callback - await command.list_projects.callback( - output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - - # Parse JSON output - import json - - parsed = json.loads(output) - - assert parsed["current_project"] is None - assert parsed["local_projects"] == [] - - -@pytest.mark.asyncio -async def test_list_projects_remote_source( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with remote source.""" - config_manager = Mock() - - # Mock create_profile_aware_workato_config and Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - - # Mock remote projects - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project = Project( - id=123, name="Remote Project", folder_id=456, description="A remote project" - ) - mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="remote", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - assert "Remote projects:" in output - assert "Remote Project" in output - assert "Project ID: 123" in output - - -@pytest.mark.asyncio -async def test_list_projects_remote_source_json( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with remote source JSON output.""" - config_manager = Mock() - config_manager.get_workspace_root.return_value = None - config_manager.get_current_project_name.return_value = None - - # Mock create_profile_aware_workato_config and Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - - # Mock remote projects - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project = Project( - id=123, name="Remote Project", folder_id=456, description="A remote project" - ) - mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="remote", output_mode="json", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - parsed = json.loads(output) - - assert parsed["source"] == "remote" - assert len(parsed["remote_projects"]) == 1 - remote = parsed["remote_projects"][0] - assert remote["name"] == "Remote Project" - assert remote["project_id"] == 123 - assert remote["folder_id"] == 456 - assert remote["description"] == "A remote project" - assert remote["has_local_copy"] is False - - -@pytest.mark.asyncio -async def test_list_projects_both_source( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] -) -> None: - """Test list projects with both local and remote source.""" - workspace = tmp_path - alpha_project = workspace / "alpha" - alpha_project.mkdir(parents=True) - (alpha_project / ".workatoenv").write_text( - '{"project_id": 123, "project_name": "Alpha", "folder_id": 456}' - ) - - config_manager = Mock() - config_manager.get_workspace_root.return_value = workspace - config_manager.get_current_project_name.return_value = "alpha" - config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] - - # Mock local project config loading - project_config = Mock() - project_config.project_id = 123 - project_config.project_name = "Alpha" - project_config.folder_id = 456 - project_config.profile = "dev" - - class StubConfigManager: - def __init__( - self, path: Path | None = None, skip_validation: bool = False - ) -> None: - pass - - def load_config(self) -> ConfigData: - return project_config - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ConfigManager", - StubConfigManager, - ) - - # Mock remote projects - mock_workato_client = Mock() - mock_project_manager = Mock() - - from workato_platform_cli.client.workato_api.models.project import Project - - remote_project1 = Project( - id=123, name="Alpha", folder_id=456, description="Synced project" - ) - remote_project2 = Project( - id=789, name="Remote Only", folder_id=999, description="Remote only project" - ) - mock_project_manager.get_all_projects = AsyncMock( - return_value=[remote_project1, remote_project2] - ) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - Mock(return_value=Mock()), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - source="both", config_manager=config_manager - ) - - output = "\n".join(capture_echo) - assert "All projects (local + remote):" in output - assert "Remote Only" in output - assert "synced" in output # Alpha should be marked as synced - assert "remote only" in output # Remote Only should be marked as remote only - # Alpha project should be shown (either as local "alpha" or remote "Alpha") - assert "alpha" in output.lower() or "Alpha" in output - - -@pytest.mark.asyncio -async def test_list_projects_with_profile( - monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] -) -> None: - """Test list projects with profile parameter.""" - config_manager = Mock() - config_manager.get_workspace_root.return_value = None - config_manager.get_current_project_name.return_value = None - config_manager._find_all_projects.return_value = [] - - # Mock profile-aware config creation - mock_config = Mock() - mock_create_config = Mock(return_value=mock_config) - - # Mock Workato client - mock_workato_client = Mock() - mock_project_manager = Mock() - mock_project_manager.get_all_projects = AsyncMock(return_value=[]) - - # Mock the context manager for Workato client - async def mock_aenter(_self: Any) -> Mock: - return mock_workato_client - - async def mock_aexit(_self: Any, *_args: Any) -> None: - return None - - mock_workato_client.__aenter__ = mock_aenter - mock_workato_client.__aexit__ = mock_aexit - - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", - mock_create_config, - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.Workato", - Mock(return_value=mock_workato_client), - ) - monkeypatch.setattr( - "workato_platform_cli.cli.commands.projects.command.ProjectManager", - Mock(return_value=mock_project_manager), - ) - - await command.list_projects.callback( # type: ignore[misc] - profile="test-profile", source="remote", config_manager=config_manager - ) - - # Verify that create_profile_aware_workato_config was called - # with the correct profile - mock_create_config.assert_called_once_with( - config_manager=config_manager, cli_profile="test-profile" - ) - - -@pytest.mark.asyncio -async def test_projects_config_sets_include_defaults(capture_echo: list[str]) -> None: - """Projects config command should persist include defaults.""" - config_manager = Mock() - config_data = ConfigData() - config_manager.load_config.return_value = config_data - config_manager.save_config = Mock() - - await command.set_project_config.callback( # type: ignore[misc] - # ctx=Mock(invoked_subcommand=None), - include_tags=True, - include_test_cases=False, - config_manager=config_manager, - ) - - saved_config = config_manager.save_config.call_args.args[0] - assert saved_config.export_include_tags is True - assert saved_config.export_include_test_cases is False - output = "\n".join(capture_echo) - assert "Updated project export defaults" in output - - -@pytest.mark.asyncio -async def test_projects_config_requires_at_least_one_value( - capture_echo: list[str], -) -> None: - """Projects config command should reject empty updates.""" - config_manager = Mock() - config_manager.load_config = Mock() - config_manager.save_config = Mock() - - await command.set_project_config.callback( # type: ignore[misc] - # ctx=Mock(invoked_subcommand=None), - include_tags=None, - include_test_cases=None, - config_manager=config_manager, - ) - - config_manager.load_config.assert_not_called() - config_manager.save_config.assert_not_called() - output = "\n".join(capture_echo) - assert "No config values provided" in output - - -@pytest.mark.asyncio -async def test_projects_config_show_outputs_table(capture_echo: list[str]) -> None: - """Projects config show should print table output by default.""" - config_manager = Mock() - config_manager.load_config.return_value = ConfigData( - export_include_tags=True, - export_include_test_cases=False, - ) - - await command.show_project_config.callback( # type: ignore[misc] - output_mode="table", - config_manager=config_manager, - ) - - output = "\n".join(capture_echo) - assert "Project export defaults" in output - assert "export_include_tags: True" in output - assert "export_include_test_cases: False" in output - - -@pytest.mark.asyncio -async def test_projects_config_show_outputs_json(capture_echo: list[str]) -> None: - """Projects config show should support JSON output.""" - config_manager = Mock() - config_manager.load_config.return_value = ConfigData( - export_include_tags=False, - export_include_test_cases=True, - ) - - await command.show_project_config.callback( # type: ignore[misc] - output_mode="json", - config_manager=config_manager, - ) - - data = json.loads("".join(capture_echo)) - assert data["export_include_tags"] is False - assert data["export_include_test_cases"] is True +"""Unit tests for the projects CLI command module.""" + +from __future__ import annotations + +import json +import sys + +from collections.abc import Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from workato_platform_cli.cli.commands.projects import command +from workato_platform_cli.cli.utils.config import ConfigData + + +@pytest.fixture(autouse=True) +def capture_echo(monkeypatch: pytest.MonkeyPatch) -> list[str]: + captured: list[str] = [] + + def _capture(message: str = "") -> None: + captured.append(message) + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.click.echo", + _capture, + ) + return captured + + +@pytest.mark.asyncio +async def test_list_projects_no_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + monkeypatch.chdir(tmp_path) + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] # No projects found + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_list_projects_with_entries( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + projects_dir = workspace / "projects" + alpha_project = projects_dir / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text( + '{"project_id": 5, "project_name": "Alpha", ' + '"folder_id": 9, "profile": "default"}', + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + project_config = ConfigData( + project_id=5, project_name="Alpha", folder_id=9, profile="default" + ) + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return project_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "alpha" in output + assert "Folder ID" in output + + +@pytest.mark.asyncio +async def test_use_project_success( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + workspace_config = ConfigData() + + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + (project_dir / ".workatoenv").write_text( + '{"project_id": 3, "project_name": "Beta", "folder_id": 7, "profile": "p1"}' + ) + + project_config = ConfigData( + project_id=3, project_name="Beta", folder_id=7, profile="p1" + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + config_manager.load_config.return_value = workspace_config + config_manager.save_config = Mock() + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return project_config if self.path == project_dir else workspace_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + saved = config_manager.save_config.call_args.args[0] + assert saved.project_id == 3 + assert saved.project_path == "projects/beta" + assert "Switched to project" in "\n".join(capture_echo) + + +@pytest.mark.asyncio +async def test_use_project_not_found(tmp_path: Path, capture_echo: list[str]) -> None: + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager._find_all_projects.return_value = [] # No projects found + + await command.use.callback(project_name="missing", config_manager=config_manager) # type: ignore[misc] + + assert any("not found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_interactive( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text( + '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (workspace / "alpha", "alpha"), + (beta_project, "beta"), + ] + config_manager.load_config.return_value = ConfigData() + config_manager.save_config = Mock() + + selected_config = ConfigData( + project_id=9, project_name="Beta", folder_id=11, profile="default" + ) + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + if self.path == beta_project: + return selected_config + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + config_manager.save_config.assert_called_once() + assert "Switched to project 'beta'" in "\n".join(capture_echo) + + +@pytest.mark.asyncio +async def test_switch_keeps_current_when_only_one( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> ConfigData: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: None, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("already current" in line for line in capture_echo) + + +def test_project_group_exists() -> None: + """Test that the project group command exists.""" + assert callable(command.projects) + + # Test that it's a click group + import asyncclick as click + + assert isinstance(command.projects, click.Group) + assert command.projects.callback is not None + assert command.projects.callback() is None + + +@pytest.mark.asyncio +async def test_list_projects_empty_directory( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when projects directory exists but is empty.""" + workspace = tmp_path + projects_dir = workspace / "projects" + projects_dir.mkdir() # Create empty projects directory + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] # Empty directory + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_list_projects_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when project has configuration error.""" + workspace = tmp_path + projects_dir = workspace / "projects" + alpha_project = projects_dir / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + # Mock ConfigManager to raise exception + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "configuration error" in output + + +@pytest.mark.asyncio +async def test_list_projects_json_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """JSON mode should surface configuration errors.""" + + workspace = tmp_path + project_dir = workspace / "projects" / "alpha" + project_dir.mkdir(parents=True) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(project_dir, "alpha")] + + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("broken") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.list_projects.callback( # type: ignore[misc] + output_mode="json", config_manager=config_manager + ) + + assert capture_echo, "Expected JSON output" + data = json.loads("".join(capture_echo)) + assert data["local_projects"][0]["configured"] is False + assert "configuration error" in data["local_projects"][0]["error"] + + +@pytest.mark.asyncio +async def test_list_projects_workspace_root_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects when workspace root is None, falls back to cwd.""" + monkeypatch.chdir(tmp_path) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = Path.cwd() + config_manager._find_all_projects.return_value = [] # Force fallback + config_manager.get_current_project_name.return_value = None + + await command.list_projects.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No local projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_use_project_not_configured( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test use project when project exists but is not configured.""" + workspace = tmp_path + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + # No .workatoenv file created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + + # Mock ConfigManager to raise exception for unconfigured project + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "configuration errors" in output + + +@pytest.mark.asyncio +async def test_use_project_exception_handling( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test use project exception handling.""" + workspace = tmp_path + project_dir = workspace / "projects" / "beta" + project_dir.mkdir(parents=True) + (project_dir / ".workatoenv").write_text( + '{"project_id": 3, "project_name": "Beta", "folder_id": 7}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(project_dir, "beta")] + config_manager.load_config.side_effect = Exception( + "Config error" + ) # Force exception + + await command.use.callback( # type: ignore[misc] + project_name="beta", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "Failed to switch to project" in output + + +@pytest.mark.asyncio +async def test_switch_workspace_root_fallback( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when workspace root is None, falls back to cwd.""" + monkeypatch.chdir(tmp_path) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = Path.cwd() + config_manager._find_all_projects.return_value = [] # Force fallback + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_projects_directory( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when no projects directory exists.""" + workspace = tmp_path + # No projects directory created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [] + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_config_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command with configuration error.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + # Mock ConfigManager to raise exception + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (configuration error)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "configuration errors" in output + + +@pytest.mark.asyncio +async def test_switch_config_error_current_project( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Config errors on the current project should report already current.""" + + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = "alpha" + + def failing_config_manager(*_: Any, **__: Any) -> Any: + mock = Mock() + mock.load_config.side_effect = Exception("Configuration error") + return mock + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + failing_config_manager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (configuration error) (current)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "already current" in output + + +@pytest.mark.asyncio +async def test_switch_no_configured_projects( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when no configured projects found.""" + workspace = tmp_path + projects_dir = workspace / "projects" + projects_dir.mkdir() + # Create directory but no projects with .workatoenv + + project_dir = projects_dir / "unconfigured" + project_dir.mkdir() + # No .workatoenv file created + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [] # No configured projects + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No projects found" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_project_choices_after_iteration( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Guard clause should trigger when iteration yields nothing.""" + + class TruthyEmpty: + def __iter__(self) -> Iterator[tuple[Path, str]]: + return iter(()) + + def __bool__(self) -> bool: + return True + + config_manager = Mock() + config_manager.get_workspace_root.return_value = tmp_path + config_manager._find_all_projects.return_value = TruthyEmpty() + config_manager.get_current_project_name.return_value = None + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No configured projects" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_no_project_selected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when user cancels selection.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: None, # User cancelled + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("No project selected" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_failed_to_identify_project( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when selected project can't be identified.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + config_manager.get_current_project_name.return_value = None + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "nonexistent"}, # Select non-matching project + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("Failed to identify selected project" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_already_current( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command when selected project is already current.""" + workspace = tmp_path + alpha_project = workspace / "projects" / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text('{"project_name": "alpha"}') + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text('{"project_name": "beta"}') + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (alpha_project, "alpha"), + (beta_project, "beta"), + ] + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + if self.path == alpha_project: + return ConfigData(project_name="alpha") + return ConfigData(project_name="beta") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "alpha (current)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("already current" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_missing_project_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """If the project list becomes stale, path lookup should fail gracefully.""" + + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + + class OneShot: + def __init__(self, entry: tuple[Path, str]) -> None: + self.entry = entry + self.iterations = 0 + + def __iter__(self) -> Iterator[tuple[Path, str]]: + if self.iterations == 0: + self.iterations += 1 + return iter([self.entry]) + return iter(()) + + def __bool__(self) -> bool: + return True + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = OneShot((beta_project, "beta")) + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + + def load_config(self) -> ConfigData: + return ConfigData(project_name="Beta Display") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta Display)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + assert any("Failed to find path" in line for line in capture_echo) + + +@pytest.mark.asyncio +async def test_switch_exception_handling( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test switch command exception handling.""" + workspace = tmp_path + beta_project = workspace / "projects" / "beta" + beta_project.mkdir(parents=True) + (beta_project / ".workatoenv").write_text( + '{"project_id": 9, "project_name": "Beta", "folder_id": 11}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [ + (workspace / "alpha", "alpha"), + (beta_project, "beta"), + ] + config_manager.load_config.side_effect = Exception( + "Config error" + ) # Force exception + + selected_config = ConfigData(project_id=9, project_name="Beta", folder_id=11) + + class StubConfigManager: + def __init__(self, path: Any, skip_validation: bool = False) -> None: + self.path = path + self.skip_validation = skip_validation + + def load_config(self) -> Any: + if self.path == beta_project: + return selected_config + return ConfigData(project_name="alpha") + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + stub_inquirer = SimpleNamespace( + List=lambda *args, **kwargs: SimpleNamespace(), + prompt=lambda *_: {"project": "beta (Beta)"}, + ) + monkeypatch.setitem(sys.modules, "inquirer", stub_inquirer) + + await command.switch.callback(config_manager=config_manager) # type: ignore[misc] + + output = "\n".join(capture_echo) + assert "Failed to switch to project" in output + + +@pytest.mark.asyncio +async def test_list_projects_json_output_mode( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list_projects with JSON output mode.""" + workspace_root = tmp_path / "workspace" + project_path = workspace_root / "test-project" + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace_root + config_manager.get_current_project_name.return_value = "test-project" + config_manager._find_all_projects.return_value = [(project_path, "test-project")] + + # Mock project config manager + project_config = ConfigData( + project_id=123, project_name="Test Project", folder_id=456, profile="dev" + ) + mock_project_config_manager = Mock() + mock_project_config_manager.load_config.return_value = project_config + + with patch( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + return_value=mock_project_config_manager, + ): + assert command.list_projects.callback + await command.list_projects.callback( + output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + + # Parse JSON output + import json + + parsed = json.loads(output) + + assert parsed["current_project"] == "test-project" + assert len(parsed["local_projects"]) == 1 + project = parsed["local_projects"][0] + assert project["name"] == "test-project" + assert project["is_current"] is True + assert project["project_id"] == 123 + assert project["folder_id"] == 456 + assert project["profile"] == "dev" + assert project["configured"] is True + + +@pytest.mark.asyncio +async def test_list_projects_json_output_mode_empty( + tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list_projects JSON output with no projects.""" + workspace_root = tmp_path / "workspace" + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace_root + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] + + assert command.list_projects.callback + await command.list_projects.callback( + output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + + # Parse JSON output + import json + + parsed = json.loads(output) + + assert parsed["current_project"] is None + assert parsed["local_projects"] == [] + + +@pytest.mark.asyncio +async def test_list_projects_remote_source( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with remote source.""" + config_manager = Mock() + + # Mock create_profile_aware_workato_config and Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + + # Mock remote projects + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project = Project( + id=123, name="Remote Project", folder_id=456, description="A remote project" + ) + mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="remote", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + assert "Remote projects:" in output + assert "Remote Project" in output + assert "Project ID: 123" in output + + +@pytest.mark.asyncio +async def test_list_projects_remote_source_json( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with remote source JSON output.""" + config_manager = Mock() + config_manager.get_workspace_root.return_value = None + config_manager.get_current_project_name.return_value = None + + # Mock create_profile_aware_workato_config and Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + + # Mock remote projects + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project = Project( + id=123, name="Remote Project", folder_id=456, description="A remote project" + ) + mock_project_manager.get_all_projects = AsyncMock(return_value=[remote_project]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="remote", output_mode="json", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + parsed = json.loads(output) + + assert parsed["source"] == "remote" + assert len(parsed["remote_projects"]) == 1 + remote = parsed["remote_projects"][0] + assert remote["name"] == "Remote Project" + assert remote["project_id"] == 123 + assert remote["folder_id"] == 456 + assert remote["description"] == "A remote project" + assert remote["has_local_copy"] is False + + +@pytest.mark.asyncio +async def test_list_projects_both_source( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capture_echo: list[str] +) -> None: + """Test list projects with both local and remote source.""" + workspace = tmp_path + alpha_project = workspace / "alpha" + alpha_project.mkdir(parents=True) + (alpha_project / ".workatoenv").write_text( + '{"project_id": 123, "project_name": "Alpha", "folder_id": 456}' + ) + + config_manager = Mock() + config_manager.get_workspace_root.return_value = workspace + config_manager.get_current_project_name.return_value = "alpha" + config_manager._find_all_projects.return_value = [(alpha_project, "alpha")] + + # Mock local project config loading + project_config = Mock() + project_config.project_id = 123 + project_config.project_name = "Alpha" + project_config.folder_id = 456 + project_config.profile = "dev" + + class StubConfigManager: + def __init__( + self, path: Path | None = None, skip_validation: bool = False + ) -> None: + pass + + def load_config(self) -> ConfigData: + return project_config + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ConfigManager", + StubConfigManager, + ) + + # Mock remote projects + mock_workato_client = Mock() + mock_project_manager = Mock() + + from workato_platform_cli.client.workato_api.models.project import Project + + remote_project1 = Project( + id=123, name="Alpha", folder_id=456, description="Synced project" + ) + remote_project2 = Project( + id=789, name="Remote Only", folder_id=999, description="Remote only project" + ) + mock_project_manager.get_all_projects = AsyncMock( + return_value=[remote_project1, remote_project2] + ) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + Mock(return_value=Mock()), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + source="both", config_manager=config_manager + ) + + output = "\n".join(capture_echo) + assert "All projects (local + remote):" in output + assert "Remote Only" in output + assert "synced" in output # Alpha should be marked as synced + assert "remote only" in output # Remote Only should be marked as remote only + # Alpha project should be shown (either as local "alpha" or remote "Alpha") + assert "alpha" in output.lower() or "Alpha" in output + + +@pytest.mark.asyncio +async def test_list_projects_with_profile( + monkeypatch: pytest.MonkeyPatch, capture_echo: list[str] +) -> None: + """Test list projects with profile parameter.""" + config_manager = Mock() + config_manager.get_workspace_root.return_value = None + config_manager.get_current_project_name.return_value = None + config_manager._find_all_projects.return_value = [] + + # Mock profile-aware config creation + mock_config = Mock() + mock_create_config = Mock(return_value=mock_config) + + # Mock Workato client + mock_workato_client = Mock() + mock_project_manager = Mock() + mock_project_manager.get_all_projects = AsyncMock(return_value=[]) + + # Mock the context manager for Workato client + async def mock_aenter(_self: Any) -> Mock: + return mock_workato_client + + async def mock_aexit(_self: Any, *_args: Any) -> None: + return None + + mock_workato_client.__aenter__ = mock_aenter + mock_workato_client.__aexit__ = mock_aexit + + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.create_profile_aware_workato_config", + mock_create_config, + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.Workato", + Mock(return_value=mock_workato_client), + ) + monkeypatch.setattr( + "workato_platform_cli.cli.commands.projects.command.ProjectManager", + Mock(return_value=mock_project_manager), + ) + + await command.list_projects.callback( # type: ignore[misc] + profile="test-profile", source="remote", config_manager=config_manager + ) + + # Verify that create_profile_aware_workato_config was called + # with the correct profile + mock_create_config.assert_called_once_with( + config_manager=config_manager, cli_profile="test-profile" + ) + + +@pytest.mark.asyncio +async def test_projects_config_sets_include_defaults(capture_echo: list[str]) -> None: + """Projects config command should persist include defaults.""" + config_manager = Mock() + config_data = ConfigData() + config_manager.load_config.return_value = config_data + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=True, + include_test_cases=False, + config_manager=config_manager, + ) + + saved_config = config_manager.save_config.call_args.args[0] + assert saved_config.export_include_tags is True + assert saved_config.export_include_test_cases is False + output = "\n".join(capture_echo) + assert "Updated project export defaults" in output + + +@pytest.mark.asyncio +async def test_projects_config_requires_at_least_one_value( + capture_echo: list[str], +) -> None: + """Projects config command should reject empty updates.""" + config_manager = Mock() + config_manager.load_config = Mock() + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=None, + include_test_cases=None, + config_manager=config_manager, + ) + + config_manager.load_config.assert_not_called() + config_manager.save_config.assert_not_called() + output = "\n".join(capture_echo) + assert "No config values provided" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_table(capture_echo: list[str]) -> None: + """Projects config show should print table output by default.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=True, + export_include_test_cases=False, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="table", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "Project export defaults" in output + assert "export_include_tags: True" in output + assert "export_include_test_cases: False" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_json(capture_echo: list[str]) -> None: + """Projects config show should support JSON output.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=False, + export_include_test_cases=True, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="json", + config_manager=config_manager, + ) + + data = json.loads("".join(capture_echo)) + assert data["export_include_tags"] is False + assert data["export_include_test_cases"] is True