diff --git a/CLAUDE.md b/CLAUDE.md index 10a12e82..a6b0fcde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,7 @@ kbagent config list [--project NAME] [--component-type TYPE] [--component-id ID] kbagent config detail --project NAME --component-id ID --config-id ID [--branch ID] kbagent config search --query PATTERN [--project NAME] [--component-type TYPE] [--ignore-case] [--regex] [--branch ID] kbagent config update --project NAME --component-id ID --config-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--configuration-file PATH] [--set PATH=VALUE ...] [--merge] [--dry-run] [--branch ID] +kbagent config rename --project NAME --component-id ID --config-id ID --name "New Name" [--branch ID] [--directory DIR] kbagent job list [--project NAME] [--component-id ID] [--status STATUS] [--limit N] kbagent job detail --project NAME --job-id ID diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index a7bbf30d..204a79c4 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -68,6 +68,7 @@ If kbagent is not installed or you need the full standalone reference, run `kbag | Show detailed information about a specific configuration | `kbagent config detail --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Search through configuration bodies for a string or pattern | `kbagent config search --query QUERY` | | Update a configuration's metadata and/or content | `kbagent config update --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Rename a configuration (update name via API + rename local sync directory) | `kbagent config rename --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --name NAME` | | Delete a configuration from a project | `kbagent config delete --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Generate boilerplate configuration files for a Keboola component | `kbagent config new --component-id COMPONENT-ID` | | List jobs from connected projects | `kbagent job list` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 269b6fdb..b392982e 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -30,6 +30,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro - `config detail --project NAME --component-id ID --config-id ID [--branch ID]` -- full config with parameters and rows (branch-aware) - `config search --query PATTERN [--project NAME] [-i] [-r] [--branch ID]` -- search config bodies for string/regex (branch-aware) - `config update --project NAME --component-id ID --config-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--configuration-file PATH] [--set PATH=VALUE ...] [--merge] [--dry-run] [--branch ID]` -- update metadata and/or configuration content. `--set` targets a nested key (e.g. `parameters.db.host=new-host`). `--merge` deep-merges into existing config (preserves sibling keys). `--dry-run` previews changes without applying. Paths are relative to the configuration root (unlike MCP's `update_config` which uses paths relative to `parameters`) +- `config rename --project NAME --component-id ID --config-id ID --name "New Name" [--branch ID] [--directory DIR]` -- rename a configuration (API update + local sync directory rename with git mv support) - `config delete --project NAME --component-id ID --config-id ID [--branch ID]` -- delete a configuration - `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR]` -- scaffold new config from component schema diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index 70dcea67..c9cbaf63 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -539,6 +539,117 @@ def config_update( ) +@config_app.command("rename") +def config_rename( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + component_id: str = typer.Option( + ..., + "--component-id", + help="Component ID (e.g. keboola.python-transformation-v2)", + ), + config_id: str = typer.Option( + ..., + "--config-id", + help="Configuration ID to rename", + ), + name: str = typer.Option( + ..., + "--name", + help="New name for the configuration", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Rename in a specific dev branch ID (defaults to active branch)", + ), + directory: Path | None = typer.Option( + None, + "--directory", + "-d", + help="Sync working directory (auto-detects .keboola/manifest.json in CWD if omitted)", + ), +) -> None: + """Rename a configuration (update name via API + rename local sync directory). + + Updates the configuration name in the Keboola project. If a local sync + directory is detected (either via --directory or the current working + directory), the local folder is renamed and the manifest is updated + to match. + + \b + Examples: + # Simple rename + kbagent config rename --project prod --component-id kds-team.app-custom-python \\ + --config-id abc123 --name "Stripe Extractor" + + # Rename with explicit sync directory + kbagent config rename --project prod --component-id kds-team.app-custom-python \\ + --config-id abc123 --name "Stripe Extractor" --directory ./my-project + """ + if should_hint(ctx): + emit_hint( + ctx, + "config.rename", + project=project, + component_id=component_id, + config_id=config_id, + name=name, + branch=branch, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "config_service") + + # Auto-detect sync directory from CWD if not specified + effective_directory = directory + if effective_directory is None: + cwd = Path.cwd() + if (cwd / KEBOOLA_DIR_NAME / MANIFEST_FILENAME).exists(): + effective_directory = cwd + + try: + result = service.rename_config( + alias=project, + component_id=component_id, + config_id=config_id, + name=name, + branch_id=branch, + directory=effective_directory, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + if formatter.json_mode: + formatter.output(result) + else: + branch_info = "" + if result.get("branch_id"): + branch_info = f" (branch {result['branch_id']})" + formatter.success( + f'Renamed "{result["old_name"]}" -> "{result["new_name"]}"' + f" ({component_id}/{config_id}){branch_info}" + ) + sync_info = result.get("sync") + if sync_info: + formatter.console.print( + f" Sync: {sync_info['old_path']}/ -> {sync_info['new_path']}/" + f" ({sync_info['method']})" + ) + + @config_app.command("delete") def config_delete( ctx: typer.Context, diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index f6c25e50..0dbf274e 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -96,6 +96,11 @@ existing config (preserves sibling keys). --dry-run previews changes. Paths are always relative to the configuration root. + kbagent config rename --project NAME --component-id ID --config-id ID --name "New Name" [--branch ID] [--directory DIR] + Rename a configuration. Updates name via API. If a local sync directory + exists (.keboola/manifest.json), renames the directory and updates the + manifest path. Uses git mv when inside a git repo for cleaner history. + kbagent config delete --project NAME --component-id ID --config-id ID [--branch ID] Delete a configuration. Branch-aware. @@ -276,6 +281,7 @@ Download configs as local files. Idempotent, protects local modifications. --job-limit controls max recent jobs per config (default 5). For large projects, automatically falls back to per-config job fetching to ensure all configs get job history. + Auto-detects renamed configs and renames local directories to match (uses git mv in git repos). kbagent sync status [--directory DIR] Show local changes since last pull (SHA256-based). diff --git a/src/keboola_agent_cli/commands/sync.py b/src/keboola_agent_cli/commands/sync.py index 0f00946d..dc560ec5 100644 --- a/src/keboola_agent_cli/commands/sync.py +++ b/src/keboola_agent_cli/commands/sync.py @@ -134,9 +134,10 @@ def _format_pull_result(formatter: Any, result: dict) -> None: new_cfgs = [d for d in details if d["action"] == "new"] updated_cfgs = [d for d in details if d["action"] == "updated"] removed_cfgs = [d for d in details if d["action"] == "removed"] + renamed_cfgs = [d for d in details if d["action"] == "renamed"] skipped_cfgs = [d for d in details if d["action"] == "skipped"] - has_changes = bool(new_cfgs or updated_cfgs or removed_cfgs) + has_changes = bool(new_cfgs or updated_cfgs or removed_cfgs or renamed_cfgs) storage = result.get("storage", {}) jobs_written = result.get("jobs_written", 0) @@ -168,6 +169,12 @@ def _format_pull_result(formatter: Any, result: dict) -> None: if jobs_written: formatter.console.print(f" Jobs: {jobs_written} configs with job history") + if renamed_cfgs: + formatter.console.print(f" [magenta]Renamed ({len(renamed_cfgs)}):[/magenta]") + for d in renamed_cfgs: + formatter.console.print( + f" > {d.get('old_path', '?')} -> {d['component_id']}/{d['config_name']}" + ) if new_cfgs: formatter.console.print(f" [green]New ({len(new_cfgs)}):[/green]") for d in new_cfgs: @@ -241,6 +248,19 @@ def _format_push_result(formatter: Any, result: dict) -> None: f"{result.get('updated', 0)} updated, " f"{result.get('deleted', 0)} deleted" ) + # Show name drift warnings + drift_warnings = result.get("name_drift_warnings", []) + if drift_warnings: + formatter.console.print( + f"\n [yellow]Warning: {len(drift_warnings)} config(s) have " + f"local directory names that don't match their config name:[/yellow]" + ) + for w in drift_warnings: + formatter.console.print( + f" '{w['local_dirname']}' should be " + f"'{w['expected_dirname']}' (config: {w['config_name']})" + ) + formatter.console.print(" Run 'kbagent config rename' or 'kbagent sync pull' to fix.") def _pull_one_liner(result: dict) -> str: @@ -249,10 +269,13 @@ def _pull_one_liner(result: dict) -> str: new_n = sum(1 for d in details if d["action"] == "new") upd_n = sum(1 for d in details if d["action"] == "updated") rem_n = sum(1 for d in details if d["action"] == "removed") + ren_n = sum(1 for d in details if d["action"] == "renamed") skip_n = sum(1 for d in details if d["action"] == "skipped") - if not new_n and not upd_n and not rem_n and not skip_n: + if not new_n and not upd_n and not rem_n and not ren_n and not skip_n: return "[green]up to date[/green]" parts = [] + if ren_n: + parts.append(f"[magenta]>{ren_n} renamed[/magenta]") if new_n: parts.append(f"[green]+{new_n} new[/green]") if upd_n: diff --git a/src/keboola_agent_cli/hints/definitions/config.py b/src/keboola_agent_cli/hints/definitions/config.py index ed1ebe49..b02a713f 100644 --- a/src/keboola_agent_cli/hints/definitions/config.py +++ b/src/keboola_agent_cli/hints/definitions/config.py @@ -1,4 +1,4 @@ -"""Hint definitions for config commands (list, detail, search).""" +"""Hint definitions for config commands (list, detail, search, rename).""" from .. import HintRegistry from ..models import ClientCall, CommandHint, HintStep, ServiceCall @@ -118,3 +118,45 @@ ], ) ) + +# ── config rename ───────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="config.rename", + description="Rename a configuration (update name via API + local sync dir)", + steps=[ + HintStep( + comment="Rename configuration via API", + client=ClientCall( + method="update_config", + args={ + "component_id": "{component_id}", + "config_id": "{config_id}", + "name": "{name}", + "branch_id": "{branch}", + }, + result_var="result", + result_hint="dict", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="rename_config", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "config_id": "{config_id}", + "name": "{name}", + "branch_id": "{branch}", + }, + ), + ), + ], + notes=[ + "Only the name is updated; configuration content is unchanged.", + "If a local sync directory exists, the folder is renamed and " + "manifest.json is updated automatically.", + ], + ) +) diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index ce386ab5..74517335 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -25,6 +25,7 @@ "config.detail": "read", "config.search": "read", "config.update": "write", + "config.rename": "write", "config.delete": "destructive", "config.new": "write", # Job history diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 2fbd12fd..6ab01835 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -5,14 +5,22 @@ """ import json +import logging import re +import shutil +import subprocess +from pathlib import Path from typing import Any from ..errors import KeboolaApiError from ..json_utils import compute_diff, deep_merge, set_nested_value from ..models import ProjectConfig +from ..sync.manifest import Manifest, load_manifest, save_manifest +from ..sync.naming import sanitize_name from .base import BaseService +logger = logging.getLogger(__name__) + def _find_matches_in_json( obj: Any, @@ -437,6 +445,203 @@ def delete_config( "branch_id": effective_branch_id, } + def rename_config( + self, + alias: str, + component_id: str, + config_id: str, + name: str, + branch_id: int | None = None, + directory: Path | None = None, + ) -> dict[str, Any]: + """Rename a configuration (update name via API + rename local sync dir). + + Args: + alias: Project alias. + component_id: The component ID. + config_id: The configuration ID to rename. + name: The new configuration name. + branch_id: If set, rename in a specific dev branch. + If None, uses the project's active branch (if any). + directory: Optional sync working directory. If a manifest exists + here and tracks this config, the local directory is + renamed and the manifest path is updated. + + Returns: + Dict with old name, new name, and optional sync rename details. + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + effective_branch_id = branch_id or project.active_branch_id + + client = self._client_factory(project.stack_url, project.token) + try: + # Fetch current state to get old name + current = client.get_config_detail( + component_id, config_id, branch_id=effective_branch_id + ) + old_name = current.get("name", "") + + # Update name via API + client.update_config( + component_id=component_id, + config_id=config_id, + name=name, + change_description=f"Renamed via kbagent config rename: {old_name} -> {name}", + branch_id=effective_branch_id, + ) + finally: + client.close() + + result: dict[str, Any] = { + "status": "renamed", + "project_alias": alias, + "component_id": component_id, + "config_id": config_id, + "old_name": old_name, + "new_name": name, + "branch_id": effective_branch_id, + } + + # Attempt local sync directory rename if applicable + sync_result = self._rename_sync_directory( + directory=directory, + component_id=component_id, + config_id=config_id, + new_name=name, + ) + if sync_result: + result["sync"] = sync_result + + return result + + def _rename_sync_directory( + self, + directory: Path | None, + component_id: str, + config_id: str, + new_name: str, + ) -> dict[str, str] | None: + """Rename the local sync directory for a config if a manifest tracks it. + + Returns a dict with old_path/new_path on success, or None if no + sync directory was found or rename was not needed. + """ + if directory is None: + return None + + from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME + + manifest_path = directory / KEBOOLA_DIR_NAME / MANIFEST_FILENAME + if not manifest_path.exists(): + return None + + try: + manifest = load_manifest(directory) + except (FileNotFoundError, ValueError): + return None + + # Find the config entry in the manifest + target_cfg = None + for cfg in manifest.configurations: + if cfg.component_id == component_id and cfg.id == config_id: + target_cfg = cfg + break + + if target_cfg is None: + return None + + # Compute new path using the naming template + old_path = target_cfg.path + old_basename = old_path.rsplit("/", 1)[-1] if "/" in old_path else old_path + new_basename = sanitize_name(new_name) + + if old_basename == new_basename: + return None # No rename needed + + # Build new path: replace only the last segment (config name) + if "/" in old_path: + parent = old_path.rsplit("/", 1)[0] + new_path = f"{parent}/{new_basename}" + else: + new_path = new_basename + + # Collision detection: if target already exists, append numeric suffix + branch_dir = self._find_sync_branch_dir(manifest, directory) + if branch_dir is None: + return None + + target_dir = branch_dir / new_path + if target_dir.exists(): + counter = 2 + while (branch_dir / f"{new_path}-{counter}").exists(): + counter += 1 + new_path = f"{new_path}-{counter}" + target_dir = branch_dir / new_path + + # Perform the rename + source_dir = branch_dir / old_path + if not source_dir.exists(): + # Directory doesn't exist locally, just update manifest + target_cfg.path = new_path + target_cfg.metadata.pop("pull_hash", None) + target_cfg.metadata.pop("pull_config_hash", None) + save_manifest(directory, manifest) + return {"old_path": old_path, "new_path": new_path, "method": "manifest_only"} + + # Try git mv first for cleaner history, fall back to shutil.move + method = self._move_directory(source_dir, target_dir) + + # Update manifest + target_cfg.path = new_path + target_cfg.metadata.pop("pull_hash", None) + target_cfg.metadata.pop("pull_config_hash", None) + save_manifest(directory, manifest) + + # Clean up empty parent directories + parent_dir = source_dir.parent + while parent_dir != branch_dir and parent_dir.exists(): + if not any(parent_dir.iterdir()): + parent_dir.rmdir() + parent_dir = parent_dir.parent + else: + break + + return {"old_path": old_path, "new_path": new_path, "method": method} + + @staticmethod + def _move_directory(source: Path, target: Path) -> str: + """Move a directory, using git mv if in a git repo, else shutil.move.""" + target.parent.mkdir(parents=True, exist_ok=True) + try: + result = subprocess.run( + ["git", "mv", str(source), str(target)], + cwd=source.parent, + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return "git_mv" + except FileNotFoundError: + pass # git not installed + shutil.move(str(source), str(target)) + return "shutil_move" + + @staticmethod + def _find_sync_branch_dir(manifest: Manifest, project_root: Path) -> Path | None: + """Find the branch directory within a sync project root.""" + if not manifest.branches: + return None + # Use the first branch (typically "main") + branch_path = manifest.branches[0].path + branch_dir = project_root / branch_path + return branch_dir if branch_dir.exists() else None + def search_configs( self, query: str, diff --git a/src/keboola_agent_cli/services/sync_service.py b/src/keboola_agent_cli/services/sync_service.py index 83951e25..b5725602 100644 --- a/src/keboola_agent_cli/services/sync_service.py +++ b/src/keboola_agent_cli/services/sync_service.py @@ -9,6 +9,7 @@ import json import logging import shutil +import subprocess import threading from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -318,6 +319,40 @@ def pull( is_new = lookup_key not in existing_keys if lookup_key in existing_paths: rel_path = existing_paths[lookup_key] + + # Auto-rename: detect when remote name changed + expected_path = config_path( + manifest.naming.config, + component_type, + component_id, + config_name, + ) + if rel_path != expected_path and not dry_run: + rename_target = expected_path + # Collision: if target already used, add suffix + if rename_target in used_paths: + suffix = config_id[:8] if len(config_id) > 8 else config_id + rename_target = f"{rename_target}-{suffix}" + + old_dir = branch_dir / rel_path + new_dir = branch_dir / rename_target + if old_dir.exists() and not new_dir.exists(): + self._rename_directory(old_dir, new_dir) + pull_details.append( + { + "action": "renamed", + "component_id": component_id, + "config_name": config_name, + "path": rename_target, + "old_path": rel_path, + } + ) + rel_path = rename_target + logger.info( + "Renamed config dir: %s -> %s", + rel_path, + rename_target, + ) else: # Generate new filesystem path with collision detection rel_path = config_path( @@ -889,6 +924,9 @@ def push( branch_id = self._resolve_branch_id(project, manifest, project_root) + # Detect name drift: local dir name doesn't match config name + name_drift_warnings = self._detect_name_drift(manifest, project_root) + client = self._client_factory(project.stack_url, project.token) created = 0 updated = 0 @@ -1027,7 +1065,7 @@ def push( if manifest_dirty: save_manifest(project_root, manifest) - return { + result_data: dict[str, Any] = { "status": "pushed", "created": created, "updated": updated, @@ -1035,6 +1073,9 @@ def push( "errors": errors, "pushed_details": pushed_details, } + if name_drift_warnings: + result_data["name_drift_warnings"] = name_drift_warnings + return result_data @staticmethod def _encrypt_secrets_in_config( @@ -2047,6 +2088,65 @@ def _file_hash(self, file_path: Path) -> str: content = file_path.read_bytes() return hashlib.sha256(content).hexdigest() + def _detect_name_drift(self, manifest: Manifest, project_root: Path) -> list[dict[str, str]]: + """Detect configs where local dir name doesn't match the config name. + + Reads each tracked config's _config.yml to get the current name, + then compares sanitize_name(name) against the directory basename. + + Returns a list of warning dicts with component_id, config_id, + local_dirname, and expected_dirname. + """ + warnings: list[dict[str, str]] = [] + for cfg in manifest.configurations: + path = cfg.path + dirname = path.rsplit("/", 1)[-1] if "/" in path else path + + # Find branch dir and read _config.yml + branch_path = self._find_branch_path(manifest, cfg.branch_id) + config_dir = project_root / branch_path / path + local_data = self._read_config_file(config_dir) + if local_data is None: + continue + + config_name = local_data.get("name", "") + if not config_name: + continue + + expected_dirname = sanitize_name(config_name) + if dirname != expected_dirname: + warnings.append( + { + "component_id": cfg.component_id, + "config_id": cfg.id, + "local_dirname": dirname, + "expected_dirname": expected_dirname, + "config_name": config_name, + } + ) + return warnings + + def _rename_directory(self, source: Path, target: Path) -> str: + """Rename a directory, using git mv if in a git repo, else shutil.move. + + Returns 'git_mv' or 'shutil_move' indicating which method was used. + """ + target.parent.mkdir(parents=True, exist_ok=True) + try: + result = subprocess.run( + ["git", "mv", str(source), str(target)], + cwd=source.parent, + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return "git_mv" + except FileNotFoundError: + pass # git not installed + shutil.move(str(source), str(target)) + return "shutil_move" + def _read_config_file(self, config_dir: Path) -> dict[str, Any] | None: """Read and parse a ``_config.yml`` file, returning None if missing.""" config_file = config_dir / CONFIG_FILENAME diff --git a/tests/test_config_rename.py b/tests/test_config_rename.py new file mode 100644 index 00000000..702d9cc8 --- /dev/null +++ b/tests/test_config_rename.py @@ -0,0 +1,325 @@ +"""Tests for config rename feature (API rename + local sync directory rename). + +Covers ConfigService.rename_config and ConfigService._rename_sync_directory. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from helpers import setup_single_project +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.services.config_service import ConfigService + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +SAMPLE_CONFIG_DETAIL = { + "id": "cfg-001", + "name": "Old Name", + "description": "A test configuration", + "configuration": {"parameters": {"key": "value"}}, +} + + +def _make_service( + tmp_config_dir: Path, +) -> tuple[ConfigService, MagicMock]: + """Create a ConfigService with a mock client for rename tests.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_config_detail.return_value = SAMPLE_CONFIG_DETAIL + mock_client.update_config.return_value = { + "id": "cfg-001", + "name": "New Name", + "componentId": "keboola.ex-http", + } + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + return service, mock_client + + +def _create_sync_directory(tmp_path: Path) -> Path: + """Create a realistic Keboola CLI sync directory structure. + + Returns the project root directory (parent of .keboola/). + """ + keboola_dir = tmp_path / ".keboola" + keboola_dir.mkdir(parents=True) + + manifest_data = { + "version": 2, + "project": {"id": 258, "apiHost": "connection.keboola.com"}, + "allowTargetEnv": True, + "gitBranching": {"enabled": False, "defaultBranch": "main"}, + "sortBy": "id", + "naming": { + "branch": "{branch_name}", + "config": "{component_type}/{component_id}/{config_name}", + "configRow": "rows/{config_row_name}", + }, + "allowedBranches": [], + "ignoredComponents": [], + "branches": [{"id": 12345, "path": "main", "metadata": {}}], + "configurations": [ + { + "branchId": 12345, + "componentId": "keboola.ex-http", + "id": "cfg-001", + "path": "extractor/keboola.ex-http/old-name", + "metadata": {"pull_hash": "abc", "pull_config_hash": "def"}, + "rows": [], + } + ], + } + (keboola_dir / "manifest.json").write_text(json.dumps(manifest_data)) + + # Create the actual config directory with a file inside + config_dir = tmp_path / "main" / "extractor" / "keboola.ex-http" / "old-name" + config_dir.mkdir(parents=True) + (config_dir / "_config.yml").write_text("name: Old Name\n") + + return tmp_path + + +# --------------------------------------------------------------------------- +# rename_config (API-level) tests +# --------------------------------------------------------------------------- + + +class TestRenameConfigApi: + """Tests for ConfigService.rename_config API interaction.""" + + def test_rename_config_basic(self, tmp_config_dir: Path) -> None: + """Rename via API returns old_name and new_name in result.""" + service, client = _make_service(tmp_config_dir) + + result = service.rename_config( + alias="prod", + component_id="keboola.ex-http", + config_id="cfg-001", + name="New Name", + ) + + assert result["status"] == "renamed" + assert result["old_name"] == "Old Name" + assert result["new_name"] == "New Name" + assert result["project_alias"] == "prod" + assert result["component_id"] == "keboola.ex-http" + assert result["config_id"] == "cfg-001" + + client.get_config_detail.assert_called_once_with( + "keboola.ex-http", "cfg-001", branch_id=None + ) + client.update_config.assert_called_once() + call_kwargs = client.update_config.call_args.kwargs + assert call_kwargs["name"] == "New Name" + assert call_kwargs["component_id"] == "keboola.ex-http" + assert call_kwargs["config_id"] == "cfg-001" + + def test_rename_config_with_branch(self, tmp_config_dir: Path) -> None: + """Rename with branch_id passes it through to API calls.""" + service, client = _make_service(tmp_config_dir) + + result = service.rename_config( + alias="prod", + component_id="keboola.ex-http", + config_id="cfg-001", + name="New Name", + branch_id=9999, + ) + + assert result["branch_id"] == 9999 + + client.get_config_detail.assert_called_once_with( + "keboola.ex-http", "cfg-001", branch_id=9999 + ) + call_kwargs = client.update_config.call_args.kwargs + assert call_kwargs["branch_id"] == 9999 + + def test_rename_config_api_error(self, tmp_config_dir: Path) -> None: + """KeboolaApiError from the client propagates to the caller.""" + service, client = _make_service(tmp_config_dir) + client.get_config_detail.side_effect = KeboolaApiError( + status_code=404, + message="Configuration not found", + ) + + with pytest.raises(KeboolaApiError, match="Configuration not found"): + service.rename_config( + alias="prod", + component_id="keboola.ex-http", + config_id="cfg-001", + name="New Name", + ) + + +# --------------------------------------------------------------------------- +# _rename_sync_directory tests +# --------------------------------------------------------------------------- + + +class TestRenameSyncDirectory: + """Tests for ConfigService._rename_sync_directory.""" + + def test_rename_sync_directory_no_directory(self, tmp_config_dir: Path) -> None: + """Returns None when directory is None.""" + service, _ = _make_service(tmp_config_dir) + + result = service._rename_sync_directory( + directory=None, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="New Name", + ) + + assert result is None + + def test_rename_sync_directory_no_manifest(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """Returns None when no manifest.json exists in the directory.""" + service, _ = _make_service(tmp_config_dir) + empty_dir = tmp_path / "no-manifest" + empty_dir.mkdir() + + result = service._rename_sync_directory( + directory=empty_dir, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="New Name", + ) + + assert result is None + + def test_rename_sync_directory_config_not_tracked( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """Returns None when the config is not tracked in the manifest.""" + service, _ = _make_service(tmp_config_dir) + project_root = _create_sync_directory(tmp_path / "sync") + + result = service._rename_sync_directory( + directory=project_root, + component_id="keboola.ex-http", + config_id="cfg-999", # Not in manifest + new_name="New Name", + ) + + assert result is None + + def test_rename_sync_directory_no_change_needed( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """Returns None when old name matches new name after sanitization.""" + service, _ = _make_service(tmp_config_dir) + project_root = _create_sync_directory(tmp_path / "sync") + + # "old-name" sanitized stays "old-name" + result = service._rename_sync_directory( + directory=project_root, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="Old Name", # sanitize_name("Old Name") == "old-name" + ) + + assert result is None + + def test_rename_sync_directory_renames_dir(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """Full rename: moves files, updates manifest path, clears hashes.""" + service, _ = _make_service(tmp_config_dir) + project_root = _create_sync_directory(tmp_path / "sync") + + result = service._rename_sync_directory( + directory=project_root, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="New Name", + ) + + assert result is not None + assert result["old_path"] == "extractor/keboola.ex-http/old-name" + assert result["new_path"] == "extractor/keboola.ex-http/new-name" + assert result["method"] in ("git_mv", "shutil_move") + + # Verify old directory is gone and new one exists + old_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "old-name" + new_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "new-name" + assert not old_dir.exists() + assert new_dir.exists() + assert (new_dir / "_config.yml").read_text() == "name: Old Name\n" + + # Verify manifest was updated + manifest_path = project_root / ".keboola" / "manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + cfg_entry = manifest_data["configurations"][0] + assert cfg_entry["path"] == "extractor/keboola.ex-http/new-name" + # Pull hashes should be cleared + assert "pull_hash" not in cfg_entry.get("metadata", {}) + assert "pull_config_hash" not in cfg_entry.get("metadata", {}) + + def test_rename_sync_directory_collision(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """When target dir already exists, appends a numeric suffix.""" + service, _ = _make_service(tmp_config_dir) + project_root = _create_sync_directory(tmp_path / "sync") + + # Pre-create the target directory to cause a collision + collision_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "new-name" + collision_dir.mkdir(parents=True) + (collision_dir / "_config.yml").write_text("name: Existing\n") + + result = service._rename_sync_directory( + directory=project_root, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="New Name", + ) + + assert result is not None + # Should get a numeric suffix due to collision + assert result["new_path"] == "extractor/keboola.ex-http/new-name-2" + + # Original collision dir is untouched + assert collision_dir.exists() + assert (collision_dir / "_config.yml").read_text() == "name: Existing\n" + + # New suffixed dir exists with moved content + suffixed_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "new-name-2" + assert suffixed_dir.exists() + assert (suffixed_dir / "_config.yml").read_text() == "name: Old Name\n" + + def test_rename_sync_directory_source_missing( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """When source dir doesn't exist, updates manifest only.""" + service, _ = _make_service(tmp_config_dir) + project_root = _create_sync_directory(tmp_path / "sync") + + # Remove the source directory (simulates not-yet-pulled state) + source_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "old-name" + import shutil + + shutil.rmtree(source_dir) + + result = service._rename_sync_directory( + directory=project_root, + component_id="keboola.ex-http", + config_id="cfg-001", + new_name="New Name", + ) + + assert result is not None + assert result["old_path"] == "extractor/keboola.ex-http/old-name" + assert result["new_path"] == "extractor/keboola.ex-http/new-name" + assert result["method"] == "manifest_only" + + # Verify manifest was updated + manifest_path = project_root / ".keboola" / "manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + cfg_entry = manifest_data["configurations"][0] + assert cfg_entry["path"] == "extractor/keboola.ex-http/new-name" + assert "pull_hash" not in cfg_entry.get("metadata", {}) + assert "pull_config_hash" not in cfg_entry.get("metadata", {}) diff --git a/tests/test_config_rename_cli.py b/tests/test_config_rename_cli.py new file mode 100644 index 00000000..c13b5360 --- /dev/null +++ b/tests/test_config_rename_cli.py @@ -0,0 +1,277 @@ +"""Tests for config rename CLI command via CliRunner. + +Tests the `kbagent config rename` subcommand: JSON output, human-readable +output, sync directory info, API error handling, and help text. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from typer.testing import CliRunner + +from helpers import setup_single_project +from keboola_agent_cli.cli import app +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.services.config_service import ConfigService + +runner = CliRunner() + + +class TestConfigRenameCli: + """Tests for `kbagent config rename` command.""" + + def test_config_rename_json_output(self, tmp_config_dir: Path) -> None: + """config rename --json returns structured JSON with rename details.""" + store = setup_single_project(tmp_config_dir) + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = { + "id": "cfg-001", + "name": "Old Name", + "componentId": "keboola.ex-http", + } + mock_client.update_config.return_value = { + "id": "cfg-001", + "name": "New Name", + "componentId": "keboola.ex-http", + } + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ), + ) + + result = runner.invoke( + app, + [ + "--json", + "--config-dir", + str(tmp_config_dir), + "config", + "rename", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "cfg-001", + "--name", + "New Name", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["status"] == "renamed" + assert output["data"]["old_name"] == "Old Name" + assert output["data"]["new_name"] == "New Name" + assert output["data"]["component_id"] == "keboola.ex-http" + assert output["data"]["config_id"] == "cfg-001" + assert output["data"]["project_alias"] == "prod" + + def test_config_rename_human_output(self, tmp_config_dir: Path) -> None: + """config rename in human mode outputs success message with rename info.""" + store = setup_single_project(tmp_config_dir) + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = { + "id": "cfg-001", + "name": "Old Name", + "componentId": "keboola.ex-http", + } + mock_client.update_config.return_value = { + "id": "cfg-001", + "name": "New Name", + "componentId": "keboola.ex-http", + } + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ), + ) + + result = runner.invoke( + app, + [ + "--config-dir", + str(tmp_config_dir), + "config", + "rename", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "cfg-001", + "--name", + "New Name", + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert "Renamed" in result.output + assert "Old Name" in result.output + assert "New Name" in result.output + assert "keboola.ex-http" in result.output + + def test_config_rename_with_sync_info(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """config rename with sync directory shows sync rename details in human output.""" + store = setup_single_project(tmp_config_dir) + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = { + "id": "cfg-001", + "name": "Old Name", + "componentId": "keboola.ex-http", + } + mock_client.update_config.return_value = { + "id": "cfg-001", + "name": "New Name", + "componentId": "keboola.ex-http", + } + + # Set up a mock sync directory with manifest + sync_dir = tmp_path / "sync_project" + sync_dir.mkdir() + keboola_dir = sync_dir / ".keboola" + keboola_dir.mkdir() + manifest = { + "version": 2, + "project": {"id": 258, "apiHost": "connection.keboola.com"}, + "allowTargetEnv": True, + "gitBranching": {"enabled": False, "defaultBranch": "main"}, + "sortBy": "id", + "naming": { + "branch": "{branch_name}", + "config": "{component_type}/{component_id}/{config_name}", + "configRow": "rows/{config_row_name}", + "schedulerConfig": "schedules/{config_name}", + "sharedCodeConfig": "_shared/{target_component_id}", + "sharedCodeConfigRow": "codes/{config_row_name}", + "variablesConfig": "variables", + "variablesValuesRow": "values/{config_row_name}", + "dataAppConfig": "app/{component_id}/{config_name}", + }, + "allowedBranches": [], + "ignoredComponents": [], + "branches": [{"id": 12345, "path": "main", "metadata": {}}], + "configurations": [ + { + "branchId": 12345, + "componentId": "keboola.ex-http", + "id": "cfg-001", + "path": "extractor/keboola.ex-http/old-name", + "metadata": {}, + "rows": [], + } + ], + } + (keboola_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + # Create the old config directory on disk + old_config_dir = sync_dir / "main" / "extractor" / "keboola.ex-http" / "old-name" + old_config_dir.mkdir(parents=True) + (old_config_dir / "_config.yml").write_text("name: Old Name\n", encoding="utf-8") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ), + ) + + result = runner.invoke( + app, + [ + "--config-dir", + str(tmp_config_dir), + "config", + "rename", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "cfg-001", + "--name", + "New Name", + "--directory", + str(sync_dir), + ], + ) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert "Renamed" in result.output + # The sync info line shows old and new paths + assert "Sync:" in result.output + + def test_config_rename_api_error(self, tmp_config_dir: Path) -> None: + """config rename with API error returns appropriate exit code.""" + store = setup_single_project(tmp_config_dir) + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Configuration 'cfg-999' not found", + status_code=404, + error_code="NOT_FOUND", + retryable=False, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ), + ) + + result = runner.invoke( + app, + [ + "--json", + "--config-dir", + str(tmp_config_dir), + "config", + "rename", + "--project", + "prod", + "--component-id", + "keboola.ex-http", + "--config-id", + "cfg-999", + "--name", + "Whatever", + ], + ) + + # NOT_FOUND maps to exit code 1 (general error) + assert result.exit_code == 1 + output = json.loads(result.output) + assert output["status"] == "error" + assert "NOT_FOUND" in output["error"]["code"] + + def test_config_rename_help(self) -> None: + """config rename --help shows usage information.""" + result = runner.invoke(app, ["config", "rename", "--help"]) + + assert result.exit_code == 0 + assert "Rename a configuration" in result.output + assert "--project" in result.output + assert "--component-id" in result.output + assert "--config-id" in result.output + assert "--name" in result.output + assert "--directory" in result.output diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 7fbf8e64..d961153d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -413,6 +413,9 @@ def test_full_cli_e2e(self) -> None: _step(18, "config update --merge", "partial merge without losing keys") self._test_config_merge(config_id) + _step("18b", "config rename", "rename config via API") + self._test_config_rename(config_id) + _step(19, "config new scaffold", "generate boilerplate for component") self._test_config_new_scaffold() @@ -1159,6 +1162,55 @@ def _test_config_merge(self, config_id: str) -> None: assert db_config["port"] == 5439, "Existing 'port' preserved" assert db_config["database"] == "final_db", "Existing 'database' preserved" + def _test_config_rename(self, config_id: str) -> None: + """Test config rename: rename a config via API and verify.""" + # Rename the config + data = self._run_ok( + "config", + "rename", + "--project", + self.alias, + "--component-id", + TEST_COMPONENT_ID, + "--config-id", + config_id, + "--name", + "E2E Renamed Config", + ) + result = data["data"] + assert result["status"] == "renamed" + assert result["new_name"] == "E2E Renamed Config" + assert result["old_name"] # should have the old name + assert result["component_id"] == TEST_COMPONENT_ID + assert result["config_id"] == config_id + + # Verify via config detail that the name actually changed + data = self._run_ok( + "config", + "detail", + "--project", + self.alias, + "--component-id", + TEST_COMPONENT_ID, + "--config-id", + config_id, + ) + assert data["data"]["name"] == "E2E Renamed Config" + + # Rename back so subsequent tests are not affected + self._run_ok( + "config", + "rename", + "--project", + self.alias, + "--component-id", + TEST_COMPONENT_ID, + "--config-id", + config_id, + "--name", + "E2E Test Config", + ) + def _test_config_new_scaffold(self) -> None: """Test config new -- generate scaffold for a component.""" scaffold_dir = self.data_dir / "scaffold" diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 331f1631..ec8230d4 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -614,6 +614,87 @@ def test_pull_dry_run_preserves_directories(self, tmp_config_dir: Path, tmp_path # Directory must still exist after dry-run assert snowflake_dir.exists(), "Dry-run should NOT delete directories" + def test_pull_auto_renames_config_on_remote_name_change( + self, tmp_config_dir: Path, tmp_path: Path + ) -> None: + """pull auto-renames local directory when config name changed on remote.""" + project_root = tmp_path / "project" + project_root.mkdir() + + store = self._init_project(tmp_config_dir, project_root) + + # First pull: download config with original name "My HTTP Extractor" + pull_client = _make_sync_mock_client( + components_response=SAMPLE_COMPONENTS_NO_ROWS, + ) + svc = SyncService( + config_store=store, + client_factory=lambda url, token: pull_client, + ) + result1 = svc.pull(alias="prod", project_root=project_root) + assert result1["configs_pulled"] == 1 + + # Verify original directory exists at expected path + old_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "my-http-extractor" + assert old_dir.exists(), "Original config directory should exist after first pull" + assert (old_dir / CONFIG_FILENAME).exists() + + # Verify manifest tracks the original path + manifest_before = load_manifest(project_root) + assert len(manifest_before.configurations) == 1 + assert ( + manifest_before.configurations[0].path == "extractor/keboola.ex-http/my-http-extractor" + ) + + # Second pull: same config ID but with renamed name + renamed_components = [ + { + "id": "keboola.ex-http", + "type": "extractor", + "configurations": [ + { + "id": "cfg-001", + "name": "Renamed HTTP Extractor", + "description": "Fetches data", + "configuration": { + "parameters": {"baseUrl": "https://api.example.com"}, + }, + "rows": [], + } + ], + }, + ] + pull_client2 = _make_sync_mock_client(components_response=renamed_components) + svc2 = SyncService( + config_store=store, + client_factory=lambda url, token: pull_client2, + ) + result2 = svc2.pull(alias="prod", project_root=project_root) + + # Verify the old directory no longer exists + assert not old_dir.exists(), "Old config directory should be gone after rename" + + # Verify the new directory exists + new_dir = project_root / "main" / "extractor" / "keboola.ex-http" / "renamed-http-extractor" + assert new_dir.exists(), "Renamed config directory should exist" + assert (new_dir / CONFIG_FILENAME).exists() + + # Verify manifest path was updated + manifest_after = load_manifest(project_root) + assert len(manifest_after.configurations) == 1 + assert ( + manifest_after.configurations[0].path + == "extractor/keboola.ex-http/renamed-http-extractor" + ) + + # Verify pull_details contains a "renamed" action + renamed_details = [d for d in result2["details"] if d["action"] == "renamed"] + assert len(renamed_details) == 1 + assert renamed_details[0]["component_id"] == "keboola.ex-http" + assert renamed_details[0]["config_name"] == "Renamed HTTP Extractor" + assert renamed_details[0]["old_path"] == "extractor/keboola.ex-http/my-http-extractor" + assert renamed_details[0]["path"] == "extractor/keboola.ex-http/renamed-http-extractor" + # =================================================================== # status tests