diff --git a/CLAUDE.md b/CLAUDE.md index 80bd1804..a1ffd3cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,8 @@ src/keboola_agent_cli/ constants.py # Shared constants + dynamic APP_NAME resolution (retry params, timeouts, defaults) json_utils.py # Deep-merge, set_nested_value, compute_diff utilities models.py # Pydantic models shared across layers + effective_branch.py # resolve_branch(): the ONLY code that applies the `branch use` active branch; + # records project + branch for the `Target:` line / `targets` key (#766) output.py # OutputFormatter: JSON vs Rich dual-mode output errors.py # KeboolaApiError, ConfigError, ErrorCode enum, mask_token() config_store.py # JSON persistence for config.json (0600 permissions) @@ -293,6 +295,14 @@ Full author checklist: see `CONTRIBUTING.md` > "Releasing a beta (pre-release) v `auth register-projects` disclose and every doc surface defers to. Run `python scripts/check_sentinel_guards.py --list` to see the inventory. +19. **Only `effective_branch.resolve_branch()` applies the active branch.** + Commands and services get the branch ID from it and never read + `ProjectConfig.active_branch_id` themselves: the function records the + project and branch, and `OutputFormatter` reports them (a `Target:` line + on stderr, `targets` in the `--json` envelope, also for `--dry-run`). + `tests/test_effective_branch.py` fails on a new direct read. A read that + only shows or manages the active branch must be on its list, with a reason. + ## Claude Code Plugin The plugin lives here in `plugins/kbagent/` and is **published through `keboola/ai-kit`**. It exposes: a CLI (`kbagent`), three skills (`kbagent`, `kbagent-cicd-migration`, `kbagent-promotion-pipeline`), three slash commands (`/kbagent:setup`, `/keboola`, `/kbagent:review`), and two specialist subagents (`keboola-expert`, `kbagent-pr-reviewer`). All are namespaced under `kbagent:`. `/kbagent:setup` is the documented one-command first-run path (install CLI -> connect project -> `doctor`); it runs in the main context and spawns no subagent. @@ -760,6 +770,8 @@ kbagent permissions check OPERATION kbagent branch list [--project NAME] kbagent branch create --project ALIAS --name "..." [--description "..."] kbagent branch use --project ALIAS --branch ID +# branch use: every command that then picks a branch names it -- `Target:` on stderr, `targets` in +# --json (branch_source explicit|active_branch|git_mapping|manifest|merge_request|production). Version gate in gotchas.md (#766). kbagent branch reset --project ALIAS kbagent branch delete --project ALIAS --branch ID kbagent branch merge --project ALIAS [--branch ID] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 909cec8a..10ecf7e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -353,6 +353,7 @@ When adding a new command (e.g., `kbagent storage create-foo`), you must update - [ ] **Service method** in `services/` -- business logic, validation, orchestration - [ ] **Command function** in `commands/` -- Typer options, formatter, error handling - [ ] **Permission registration** in `permissions.py` (`OPERATION_REGISTRY` dict) +- [ ] **Branch choice** through `resolve_branch()` in `effective_branch.py` when the command takes `--branch` or uses the active branch -- never read `ProjectConfig.active_branch_id` directly. The function reports the branch (`Target:` line, `targets` in `--json`); `tests/test_effective_branch.py` fails on a new direct read. - [ ] **Service wiring** in `cli.py` if adding a new service class - [ ] **HTTP API endpoint** in `src/keboola_agent_cli/server/routers/.py` -- `kbagent serve` exposes the CLI as a REST API so external applications (Web UI, scheduled AI agents, Slack bots, Streamlit dashboards, CI pipelines) can call the platform without forking CLI subprocesses. The current convention is **1:1**: every command in a group has a matching endpoint in that group's router (e.g. `commands/flow.py` has 8 commands, `server/routers/flows.py` has 8 routes). If you add a new command, add the corresponding route. **Skip allowed** only for genuinely terminal-only commands (interactive prompts, Rich-rendered output that has no useful JSON shape, `doctor`/`init`/`update`-style infrastructure that manages kbagent itself rather than Keboola). Document any skip in the PR description with a one-line reason so reviewers don't flag it. diff --git a/docs/merge-requests-layer1.md b/docs/merge-requests-layer1.md index 0dd26703..d43bd161 100644 --- a/docs/merge-requests-layer1.md +++ b/docs/merge-requests-layer1.md @@ -143,7 +143,7 @@ decisions, derive facts. Every implicit resolution is reported, on stderr in human mode and in the payload always: ``` -Info: Using active branch (ID: 123) for project 'acme' +Source: project 'acme', branch 123 (from 'kbagent branch use') Info: Resolved merge request #7 from branch 123 ``` diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index f9d06484..2e08d1e6 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -200,6 +200,15 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6). or run `project use`. On <= 0.90.1 the same commands silently used the FIRST registered project and ignored the pin (issue #684). gotchas.md. +**Which branch did a command use? (vNEXT+)** +- Every command that picks a branch names it: `Target: project 'P', branch ID + (from 'kbagent branch use')` on stderr, `targets` in `--json` + (`branch_source` `active_branch` = chosen by `branch use`). Read it before + you report a write as done on production. No `targets` key = no branch was + chosen, NOT production. Below vNEXT, `workspace create` and most config / + flow writes applied the active branch silently: check `branch list` (Active + column) first. gotchas.md (#766). + **Recurring `ext.keboola.cli.` events in a project's own event log (0.93.0+)** - kbagent posts one best-effort usage event per command to the acting project's Storage events. An event audit then shows one `ext.keboola.cli.` (CLI/REPL) or diff --git a/plugins/kbagent/skills/kbagent/references/branch-workflow.md b/plugins/kbagent/skills/kbagent/references/branch-workflow.md index 04c6383d..315a9a6a 100644 --- a/plugins/kbagent/skills/kbagent/references/branch-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/branch-workflow.md @@ -37,7 +37,8 @@ kbagent --json branch merge --project ALIAS - **Async operations**: `branch create` and `branch delete` are async on the API. kbagent waits for completion (typically 1-3s). No need to poll. - **Merge from the CLI needs the merge-request group** *(since 0.94.0)*: `branch merge` only returns a URL for the Keboola UI (and is deprecated). On a project with the `branches-merge-requests` feature, `kbagent merge-request create` + `merge-request merge` merge via the API with review and conflict resolution -- see [merge-request-workflow.md](merge-request-workflow.md). - **Active branch persistence**: stored in kbagent config. Survives between sessions. +- **See which branch a command used** (since vNEXT, #766): every command that picks a branch prints `Target: project 'P', branch ID 'NAME' (from 'kbagent branch use')` on stderr, and `--json` output carries `targets` (`branch_source`: `explicit`, `active_branch`, `git_mapping`, `manifest`, `merge_request` or `production`). `--dry-run` reports the same target as the real run. Check it before a write. - **Config commands respect active branch**: `config list`, `config detail`, and `config search` auto-scope to the active branch. Use `--branch ID` to override. -- **Workspaces respect active branch**: `workspace create` and `workspace delete` operate in the active branch context. +- **Workspaces respect active branch**: `workspace create`, `workspace list`, `workspace detail` and `workspace delete` operate in the active branch context. - **Sync respects active branch**: `sync pull` writes dev branch configs into a separate directory (e.g. `fix-etl/` instead of `main/`). `sync diff` and `sync push` also auto-scope to the active branch. See [sync-workflow.md](sync-workflow.md) for details. - **`branch reset` alone does not re-target the sync manifest** *(since v0.89.0)*: a dev-branch `sync pull` re-points every `manifest.configurations` entry at that branch, so after resetting to production the `main/` tree is an orphan the manifest no longer tracks. A production `sync diff` / `sync push` reports those configs under `orphaned` and excludes them (they are never pushed as new configs — issue #649); run `kbagent sync pull --project ALIAS` to re-target the manifest to production first. Configs that exist only on the dev branch are promoted with `branch merge`, never by pushing them to production. diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index b995b832..594af309 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -271,7 +271,7 @@ Non-SOX Branches 2.0: merge a dev branch into production with review. Alias `mr` ## Workspaces (SQL Debugging) - `workspace create --project ALIAS [--name NAME] [--ui] [--read-only]` -- create workspace (headless ~1s, `--ui` ~15s). Since v0.47.1: Snowflake headless workspaces return a `private_key` PEM field; `password` is empty. BigQuery workspaces keep the default password credential shape. -- `workspace list [--project NAME ...] [--orphaned] [--branch ID] [--qs-compatible]` -- list workspaces. `--project` repeatable; `--orphaned` filters to workspaces whose backing `keboola.sandboxes` config is missing. **Since v0.42.0 (#304)**: each entry carries `login_type`, `read_only`, `qs_compatible`, `database`, `warehouse`. New `Login Type` / `RO` / `QS` columns in human mode. `--qs-compatible` pre-filters to RO + whitelisted-loginType workspaces (the canonical data-app shape). **Updated v0.58.0**: `qs_compatible` is keyed by `(backend, loginType)` -- BigQuery workspaces (loginType `default`) now report `qs_compatible: true` and pass `--qs-compatible`; pre-0.58.0 every BigQuery workspace was wrongly excluded (Snowflake's own legacy `default` stays `false`). `--branch` requires exactly one `--project`; without `--branch`, the command behaves like `storage buckets` and uses production with an `Info: Using production branch for read (active dev branch X ignored; pass --branch X to override)` banner when an alias is pinned to a dev branch +- `workspace list [--project NAME ...] [--orphaned] [--branch ID] [--qs-compatible]` -- list workspaces. `--project` repeatable; `--orphaned` filters to workspaces whose backing `keboola.sandboxes` config is missing. **Since v0.42.0 (#304)**: each entry carries `login_type`, `read_only`, `qs_compatible`, `database`, `warehouse`. New `Login Type` / `RO` / `QS` columns in human mode. `--qs-compatible` pre-filters to RO + whitelisted-loginType workspaces (the canonical data-app shape). **Updated v0.58.0**: `qs_compatible` is keyed by `(backend, loginType)` -- BigQuery workspaces (loginType `default`) now report `qs_compatible: true` and pass `--qs-compatible`; pre-0.58.0 every BigQuery workspace was wrongly excluded (Snowflake's own legacy `default` stays `false`). `--branch` requires exactly one `--project`; without `--branch`, it uses each alias's active branch (`branch use`), else production. **Since vNEXT (#766)** the `Target:` line / `targets` key names that branch; the earlier `Info: Using production branch for read` line was wrong - `workspace detail --project ALIAS --workspace-id ID [--branch ID]` -- show connection details. **Since v0.42.0 (#304)**: response carries `login_type`, `read_only`, `qs_compatible`; human mode adds `Login type:` / `Read-only:` / `Query Service compatible:` rows. **Updated v0.58.0**: BigQuery `default` workspaces now report `qs_compatible: true` (was `false`). `--branch` opt-in mirrors `workspace list` - `workspace delete --project ALIAS --workspace-id ID` -- delete workspace - `workspace password --project ALIAS --workspace-id ID` -- reset and return new password diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index ed6e74fa..1a1a8628 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -11,6 +11,73 @@ Versioning convention: behavior; the inline `(updated vX.Y.Z)` records when the refinement landed. --> +## Every command that picks a branch names it: `Target:` line and `targets` key + +*(since vNEXT, #766)* + +- **`kbagent branch use` sets an active branch per project, and commands + apply it when `--branch` is omitted.** Before, some commands printed an + `Info:` line in human mode only, and others applied the active branch with + no notice (for example `workspace create`). A write could land on a + development branch and the caller did not know. +- **Human mode prints one line on stderr** before the first API call to the + project and before any confirmation prompt: + - `Target: project 'prod', branch 456 'feature-x' (from 'kbagent branch use')` + - `Target: project 'prod', branch 789 (from the command line)` + - `Target: project 'prod', production (active branch 456 'feature-x' not + used; pass --branch 456 to use it)` -- storage reads, Data Streams, + data apps, branch metadata and git-branching `sync` do not use the + active branch. + - `Target: project 'prod', branch 388 (from .keboola/branch-mapping.json)` + or `(from .keboola/manifest.json)` -- `sync` without an active branch. + - `Source:` names the branch that a command reads from or merges from: + the source project of `config clone`, the branch of a merge request + (`merge-request *`, `branch merge`), the branch notification commands + read config names from, and the source table branch of `storage + create-table --source-branch-id` / `storage clone-table` (production). + `merge-request merge` and an armed `merge-request auto-merge` also print + `Target: project 'prod', production`. + - A command that uses two branches prints a line for each: + - `workspace create --ui` creates the config in the active branch but + runs its job on production. + - `workspace from-transformation` reads the transformation from + production. + - `sync clone` creates the buckets in production. +- **`--json` adds `targets` to the success AND the error envelope**, before + `data` / `error`, which do not change. Each entry is + `{role, project_alias, branch_id, branch_name, branch_source, active_branch}`: + - `branch_source`: `explicit` (`--branch` or `--target-branch`), + `active_branch` (`branch use`), `git_mapping` + (`.keboola/branch-mapping.json`), `manifest` (the first branch of + `.keboola/manifest.json`), `merge_request` (the branch of the merge + request given by `--merge-request-id`) or `production`. + - `production` can carry a numeric `branch_id` when the command read the + default branch ID from the API (workspaces, config metadata). + - `active_branch` is the project's `branch use` choice + (`{branch_id, branch_name}` or null), also when the command did not use it. + - `role` is `source` for the `Source:` cases above, else `target`. +- **No `targets` key means the command chose no branch**: it has no + `--branch` and never uses the active branch (`project list`, `token list`, + storage listings without `--project`), it failed on an unknown alias, or it + refused to run because it needs a branch and got none. It does not mean + production. +- **`--branch 0` means production.** The API clients always sent 0 to the + production endpoint. Before vNEXT the config, flow, schedule and + notification commands used the active branch for `--branch 0` instead. +- **`--dry-run` reports the same target as the real run.** `flow delete + --dry-run` and `flow schedule-remove --dry-run` now put the resolved branch + in `would_delete.branch_id` (before: the raw `--branch` value, so null under + an active branch). +- **`workspace list` / `workspace detail` use the active branch.** The + `Info: Using production branch for read` line they printed was wrong: the + workspace service has used the active branch since v0.42.0. They now print + `Target:` with the branch they use. +- **`branch use` and `branch create` save the branch name** next to the ID + (`active_branch_name` in `config.json`). It shows in `Target:` and in + `branch_name`. A branch renamed later keeps the saved name until the next + `branch use`. An active branch set by an older version has no name. +- `kbagent serve` responses do not carry `targets` (REST reporting: #791). + ## A semantic-layer dataset `fqn` is the table's real warehouse location, not `"KEBOOLA"` *(since 0.95.0, #761)* @@ -1130,17 +1197,16 @@ kbagent --json workspace list --project prod --qs-compatible # returns only workspaces with login_type ∈ whitelist AND read_only=true ``` -**Branch behaviour (read-command parity with `storage buckets`):** - -`workspace list` / `workspace detail` now follow the same pattern as -`storage buckets` / `storage tables` / `config list`: when an alias is -pinned to a dev branch via `branch use`, the production endpoint is used -with an `Info: Using production branch for read (active dev branch X -ignored; pass --branch X to override)` banner. Before v0.42.0 these -commands silently scoped to the pinned branch, returning a different -workspace set than the same alias one shell ago. Pass `--branch ID` to -opt back into the dev-branch endpoint. `--branch` requires exactly one -`--project`. +**Branch behaviour:** + +`workspace list` / `workspace detail` use the alias's active branch +(`branch use`) when `--branch` is omitted, like `config list`. Up to vNEXT +they printed `Info: Using production branch for read (active dev branch X +ignored; pass --branch X to override)`, but the workspace service used the +active branch: the line was wrong, the listing was not. Since vNEXT they +print `Target:` with the branch they use (see the #766 entry at the top). +`storage buckets` / `storage tables` are the reads that use production +under an active branch. `--branch` requires exactly one `--project`. ## `config detail --component-id keboola.sandboxes` now annotates the misleading `parameters.id` (since v0.42.0, closes #304) diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 72c9b05e..1f12636e 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -48,6 +48,7 @@ from .commands.workspace import workspace_app from .config_store import ConfigStore, resolve_config_dir from .constants import EXIT_PERMISSION_DENIED +from .effective_branch import record_targets from .errors import ErrorCode, PermissionDeniedError from .output import OutputFormatter, force_utf8_when_redirected @@ -300,6 +301,11 @@ def main( no_color=effective_no_color, verbose=verbose, ) + # Record the project and branch of this command for the output (#766). Not + # for the REPL shell (each line records on its own) nor for `serve`, whose + # request threads would all add to one record for the server's lifetime. + if ctx.invoked_subcommand not in (None, "repl", "serve"): + ctx.with_resource(record_targets(formatter.report_target)) resolved_dir, source = resolve_config_dir(cli_config_dir=config_dir) config_store = ConfigStore(config_dir=resolved_dir, source=source) diff --git a/src/keboola_agent_cli/commands/_data_app_runtime.py b/src/keboola_agent_cli/commands/_data_app_runtime.py index c35dfb1d..4762aae3 100644 --- a/src/keboola_agent_cli/commands/_data_app_runtime.py +++ b/src/keboola_agent_cli/commands/_data_app_runtime.py @@ -20,6 +20,7 @@ import typer +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import get_formatter, get_service, map_error_to_exit_code @@ -139,6 +140,7 @@ def data_app_secrets_set( """ formatter = get_formatter(ctx) + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") if secret and secrets_file: @@ -256,6 +258,7 @@ def data_app_secrets_list( """ formatter = get_formatter(ctx) + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") try: result = service.list_data_app_secrets( @@ -321,6 +324,7 @@ def data_app_secrets_get( """ formatter = get_formatter(ctx) + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") try: result = service.get_data_app_secret( @@ -396,6 +400,7 @@ def data_app_secrets_remove( """ formatter = get_formatter(ctx) + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") if ( diff --git a/src/keboola_agent_cli/commands/_flow_triggers.py b/src/keboola_agent_cli/commands/_flow_triggers.py index 8bbed22e..758e3425 100644 --- a/src/keboola_agent_cli/commands/_flow_triggers.py +++ b/src/keboola_agent_cli/commands/_flow_triggers.py @@ -28,12 +28,12 @@ from rich.markup import escape from rich.table import Table +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) NOT_COVERED_NOTE = ( @@ -119,7 +119,7 @@ def flow_triggers( formatter = get_formatter(ctx) service = get_service(ctx, "flow_service") config_store = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.get_flow_triggers( diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index e72a7241..175af9fa 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -313,88 +313,6 @@ def validate_branch_requires_project( raise typer.Exit(code=2) from None -def resolve_branch( - config_store: ConfigStore, - formatter: OutputFormatter, - project: str | None, - branch: int | None, - *, - ignore_active_branch: bool = False, -) -> tuple[str | None, int | None]: - """Resolve the effective branch and project. - - Resolution order: - 1. Explicit --branch always wins (no change) - 2. If no --branch, check active_branch_id from config for the resolved project - 3. If active branch found, use it and print info message in human mode - - When an active branch is resolved from config, --project is also set - to the project alias (branch is per-project). - - Args: - config_store: Config store for looking up project configs. - formatter: Output formatter for info messages. - project: Explicit --project alias or None. - branch: Explicit --branch integer or None. - ignore_active_branch: When True, the implicit active_branch_id from - config is ignored and the production endpoint (branch_id=None) is - used unless --branch was passed explicitly. An info message is - printed so the user can see the active dev branch was skipped. - Intended for storage READ commands -- the Storage API - branch-scoped endpoint returns only locally-modified resources, - which for a freshly created dev branch is an empty set. Explicit - --branch still overrides. - - Returns: - Tuple of (effective_project, effective_branch_id). - """ - if branch is not None: - return project, branch - - if project is not None: - proj_config = config_store.get_project(project) - if proj_config and proj_config.active_branch_id is not None: - if ignore_active_branch: - if not formatter.json_mode: - formatter.err_console.print( - f"[bold blue]Info:[/bold blue] Using production branch for read " - f"(active dev branch '{proj_config.active_branch_id}' ignored; " - f"pass --branch {proj_config.active_branch_id} to override)" - ) - return project, None - if not formatter.json_mode: - formatter.err_console.print( - f"[bold blue]Info:[/bold blue] Using active branch " - f"(ID: {proj_config.active_branch_id}) for project '{project}'" - ) - return project, proj_config.active_branch_id - else: - config = config_store.load() - active_projects = [ - (alias, proj) - for alias, proj in config.projects.items() - if proj.active_branch_id is not None - ] - if len(active_projects) == 1: - alias, proj = active_projects[0] - if ignore_active_branch: - if not formatter.json_mode: - formatter.err_console.print( - f"[bold blue]Info:[/bold blue] Using production branch for read " - f"(active dev branch '{proj.active_branch_id}' on project '{alias}' " - f"ignored; pass --branch {proj.active_branch_id} to override)" - ) - return alias, None - if not formatter.json_mode: - formatter.err_console.print( - f"[bold blue]Info:[/bold blue] Using active branch " - f"(ID: {proj.active_branch_id}) for project '{alias}'" - ) - return alias, proj.active_branch_id - - return project, None - - _CONFIRM_CODE_LENGTH = 4 diff --git a/src/keboola_agent_cli/commands/_merge_request_common.py b/src/keboola_agent_cli/commands/_merge_request_common.py index 7fee3dbf..62ba9170 100644 --- a/src/keboola_agent_cli/commands/_merge_request_common.py +++ b/src/keboola_agent_cli/commands/_merge_request_common.py @@ -21,11 +21,11 @@ import typer from rich.markup import escape +from ..effective_branch import record_branch, resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( get_service, map_error_to_exit_code, - resolve_branch, resolve_project_alias, ) from ._merge_request_render import next_step_hints @@ -195,8 +195,13 @@ def _resolve_target( alias = resolve_project_alias(ctx, formatter, project) service = get_service(ctx, "merge_request_service") + config_store = get_service(ctx, "config_store") if merge_request_id is not None: row = service.get_merge_request_row(alias, merge_request_id) if need_row else None + if row is not None: + record_branch( + config_store, alias, _branch_from_row(row), "merge_request", role="source" + ) return _Target( alias=alias, merge_request_id=merge_request_id, @@ -205,8 +210,7 @@ def _resolve_target( resolved_from_branch=False, ) - config_store = get_service(ctx, "config_store") - _, branch_id = resolve_branch(config_store, formatter, alias, branch) + branch_id = resolve_branch(config_store, alias, branch, required=True, role="source") if branch_id is None: formatter.error( message=( diff --git a/src/keboola_agent_cli/commands/_merge_request_writes.py b/src/keboola_agent_cli/commands/_merge_request_writes.py index 73b36780..8bedc474 100644 --- a/src/keboola_agent_cli/commands/_merge_request_writes.py +++ b/src/keboola_agent_cli/commands/_merge_request_writes.py @@ -32,6 +32,7 @@ from rich.markup import escape from ..constants import MERGE_REQUEST_EXTERNAL_ID_MAX_LENGTH, MERGE_REQUEST_REASON_MAX_LENGTH +from ..effective_branch import record_branch, resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.merge_request_service import ( AUTO_MERGE_DISARMED, @@ -44,7 +45,6 @@ get_formatter, get_service, parse_json_arg, - resolve_branch, resolve_project_alias, ) from ._merge_request_common import ( @@ -82,6 +82,11 @@ ) +def _record_production(ctx: typer.Context, alias: str) -> None: + """`merge` and armed `auto-merge` write production; the MR's branch is their source.""" + record_branch(get_service(ctx, "config_store"), alias, None, "production", fixed=True) + + def _check_external_id(formatter: Any, external_id: str | None) -> None: """Exit 2 on an over-cap --external-id. The service validates the cap (one constant, one rule); this pre-check exists only so the flag error carries @@ -158,7 +163,7 @@ def merge_request_create( try: alias = resolve_project_alias(ctx, formatter, project) config_store = get_service(ctx, "config_store") - _, branch_id = resolve_branch(config_store, formatter, alias, branch) + branch_id = resolve_branch(config_store, alias, branch, required=True, role="source") if branch_id is None: formatter.error( message=( @@ -434,6 +439,7 @@ def merge_request_auto_merge( need_row=False, ) if arms_auto_merge(strategy): + _record_production(ctx, target.alias) when = f" at {at}" if at else "" _confirm_or_abort( formatter, @@ -503,6 +509,7 @@ def merge_request_merge( branch=branch, need_row=will_prompt, ) + _record_production(ctx, target.alias) title = (target.row or {}).get("title") or "" _confirm_or_abort( formatter, diff --git a/src/keboola_agent_cli/commands/_storage_describe.py b/src/keboola_agent_cli/commands/_storage_describe.py index 7819f704..d5921c33 100644 --- a/src/keboola_agent_cli/commands/_storage_describe.py +++ b/src/keboola_agent_cli/commands/_storage_describe.py @@ -18,12 +18,12 @@ from rich.markup import escape from ..config_store import ConfigStore +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) _DESCRIBE = "Descriptions" @@ -74,7 +74,7 @@ def storage_describe_bucket( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) from ._metadata_input import resolve_text_input @@ -146,7 +146,7 @@ def storage_describe_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) from ._metadata_input import resolve_text_input @@ -224,7 +224,7 @@ def storage_describe_column( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) parsed: dict[str, str] = {} for entry in column: @@ -327,7 +327,7 @@ def storage_describe_batch( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) # In human mode, show a live progress indicator so that large batches # (100+ items) do not look frozen. JSON mode must remain silent on stderr @@ -480,7 +480,7 @@ def storage_describe_migrate( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if table_id and bucket_id: formatter.error( diff --git a/src/keboola_agent_cli/commands/_storage_snapshots.py b/src/keboola_agent_cli/commands/_storage_snapshots.py index 54fbf877..7769213b 100644 --- a/src/keboola_agent_cli/commands/_storage_snapshots.py +++ b/src/keboola_agent_cli/commands/_storage_snapshots.py @@ -20,12 +20,12 @@ from rich.markup import escape from ..config_store import ConfigStore +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) _SNAPSHOTS = "Snapshots" @@ -72,9 +72,7 @@ def storage_snapshots( service = get_service(ctx, "snapshot_service") config_store: ConfigStore = ctx.obj["config_store"] # Read command: ignore implicit active dev branch (empty listing trap). - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) + effective_branch = resolve_branch(config_store, project, branch, ignore_active_branch=True) try: result = service.list_snapshots( @@ -134,7 +132,7 @@ def storage_snapshot_create( formatter = get_formatter(ctx) service = get_service(ctx, "snapshot_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.create_snapshot( @@ -269,7 +267,7 @@ def storage_table_from_snapshot( formatter = get_formatter(ctx) service = get_service(ctx, "snapshot_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.create_table_from_snapshot( diff --git a/src/keboola_agent_cli/commands/branch.py b/src/keboola_agent_cli/commands/branch.py index 866d2c71..ff472956 100644 --- a/src/keboola_agent_cli/commands/branch.py +++ b/src/keboola_agent_cli/commands/branch.py @@ -8,6 +8,7 @@ import typer +from ..effective_branch import report_branch_ref from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_branch_metadata_table, format_branches_table from ._helpers import ( @@ -285,6 +286,7 @@ def branch_metadata_list( arbitrary strings like ``KBC.projectDescription``. """ formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "branch_service") try: @@ -319,6 +321,7 @@ def branch_metadata_get( Exits with code 1 (NOT_FOUND) if the key is not present on the branch. """ formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "branch_service") try: @@ -365,6 +368,7 @@ def branch_metadata_set( The value is taken from exactly one of --text, --file, or --stdin. """ formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) try: value = resolve_text_input(text=text, file=file, stdin=stdin) @@ -412,6 +416,7 @@ def branch_metadata_delete( ) -> None: """Delete a branch metadata entry by its numeric ID.""" formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "branch_service") if ( diff --git a/src/keboola_agent_cli/commands/component.py b/src/keboola_agent_cli/commands/component.py index da4e54e5..d79cefb0 100644 --- a/src/keboola_agent_cli/commands/component.py +++ b/src/keboola_agent_cli/commands/component.py @@ -14,6 +14,7 @@ from ..config_store import ConfigStore from ..constants import VALID_COMPONENT_TYPES +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.component_service import DOCUMENTATION_SOURCE_STORAGE_CATALOG from ._helpers import ( @@ -22,7 +23,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) from .config import _parse_json_input @@ -347,7 +347,7 @@ def component_sync_action( ) raise typer.Exit(code=2) from None - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.run_sync_action( diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index b6b4da8b..0f9fb61d 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -17,6 +17,7 @@ from ..config_store import ConfigStore from ..constants import KEBOOLA_DIR_NAME, MANIFEST_FILENAME, VALID_COMPONENT_TYPES +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import format_config_detail, format_configs_table, format_search_results from ..services.component_service import materialize_pushed_config, stamp_scaffold_config_id @@ -28,7 +29,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) logger = logging.getLogger(__name__) @@ -92,7 +92,6 @@ def config_list( """ formatter = get_formatter(ctx) service = get_service(ctx, "config_service") - config_store: ConfigStore = ctx.obj["config_store"] # --branch requires --project (branch ID is per-project) # For list with multiple projects, only validate if explicit --branch given @@ -103,12 +102,6 @@ def config_list( ) raise typer.Exit(code=2) - # Resolve active branch (only for single-project queries) - effective_branch: int | None = branch - effective_project = project - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - # Validate component_type if provided if component_type and component_type not in VALID_COMPONENT_TYPES: formatter.error( @@ -120,10 +113,10 @@ def config_list( try: result = service.list_configs( - aliases=effective_project, + aliases=project, component_type=component_type, component_id=component_id, - branch_id=effective_branch, + branch_id=branch, include_rows=include_rows, ) except ConfigError as exc: @@ -206,7 +199,6 @@ def config_detail( """ formatter = get_formatter(ctx) service = get_service(ctx, "config_service") - config_store: ConfigStore = ctx.obj["config_store"] # --project is required (zero, one, or many) if not project: @@ -246,11 +238,6 @@ def config_detail( ) raise typer.Exit(code=2) - # Resolve active branch when only one --project was passed - effective_branch: int | None = branch - if branch is None and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - try: if config_id is not None: # Single-config mode: shape unchanged for backward compat, @@ -262,7 +249,7 @@ def config_detail( alias=project[0], component_id=component_id, config_id=config_id, - branch_id=effective_branch, + branch_id=branch, with_state=with_state, include_sandbox_annotation=True, ) @@ -276,7 +263,7 @@ def config_detail( alias=project[0], component_id=component_id, config_id=None, - branch_id=effective_branch, + branch_id=branch, with_state=with_state, aliases=project, ) @@ -504,7 +491,6 @@ def config_search( """ formatter = get_formatter(ctx) service = get_service(ctx, "config_service") - config_store: ConfigStore = ctx.obj["config_store"] # --branch requires exactly one --project if branch is not None and (not project or len(project) != 1): @@ -514,11 +500,6 @@ def config_search( ) raise typer.Exit(code=2) - # Resolve active branch (only for single-project queries) - effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - # Validate component_type if component_type and component_type not in VALID_COMPONENT_TYPES: formatter.error( @@ -547,7 +528,7 @@ def config_search( component_id=component_id, ignore_case=ignore_case, use_regex=use_regex, - branch_id=effective_branch, + branch_id=branch, ) except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) @@ -1636,7 +1617,7 @@ def config_metadata_list( """List all metadata entries on a configuration.""" formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "config_service") try: result = service.list_config_metadata( @@ -1678,7 +1659,7 @@ def config_get_metadata( """ formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "config_service") try: result = service.get_config_metadata_value( @@ -1708,7 +1689,7 @@ def config_set_metadata( """Set a metadata key/value on a configuration (upsert).""" formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "config_service") try: result = service.set_config_metadata( @@ -1741,7 +1722,7 @@ def config_delete_metadata( """Delete a configuration metadata entry by its numeric ID.""" formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if ( not yes @@ -1785,7 +1766,7 @@ def config_set_folder( """ formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "config_service") try: result = service.set_config_folder( @@ -1882,7 +1863,7 @@ def config_variables_set( raise typer.Exit(code=2) from None variables_dict[key] = value - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "variables_service") @@ -1970,7 +1951,7 @@ def config_variables_get( """Read the current variable values attached to a config.""" formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "variables_service") try: @@ -2018,7 +1999,7 @@ def config_variables_clear( """Unlink variables from a config (does NOT delete the underlying keboola.variables).""" formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not yes and not formatter.json_mode: confirmed = typer.confirm( @@ -2506,6 +2487,8 @@ def config_row_delete( kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW --yes """ formatter = get_formatter(ctx) + config_store = ctx.obj["config_store"] + branch = resolve_branch(config_store, project, branch) if ( not yes diff --git a/src/keboola_agent_cli/commands/config_state.py b/src/keboola_agent_cli/commands/config_state.py index 5193a5ae..26f4453c 100644 --- a/src/keboola_agent_cli/commands/config_state.py +++ b/src/keboola_agent_cli/commands/config_state.py @@ -22,8 +22,9 @@ from rich.syntax import Syntax from ..config_store import ConfigStore +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError -from ._helpers import get_formatter, get_service, resolve_branch +from ._helpers import get_formatter, get_service from .config import _handle_config_service_error, _parse_json_input, config_app @@ -54,7 +55,7 @@ def config_state_get( """ formatter = get_formatter(ctx) config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) service = get_service(ctx, "config_service") try: @@ -119,7 +120,7 @@ def config_state_set( ) raise typer.Exit(code=2) from None - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not dry_run and not yes and not formatter.json_mode: target = f"{component_id}/{config_id}" + (f" row [{row_id}]" if row_id else "") diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 1b83c96d..a2368e45 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1232,7 +1232,9 @@ Create dev branch and auto-activate it. Async, CLI waits for completion. kbagent branch use --project ALIAS --branch ID - Set existing branch as active for subsequent commands. + Set existing branch as active for subsequent commands. Every command that + then picks a branch names it: `Target: project 'P', branch ID 'NAME' (from + 'kbagent branch use')` on stderr, `targets` in --json (see Tips 2). kbagent branch reset --project ALIAS Reset to main/production branch. @@ -1334,14 +1336,14 @@ Since 0.47.1, Snowflake headless creates return private_key and an empty password field; use key-pair auth. kbagent workspace list [--project NAME] [--orphaned] [--branch ID] [--qs-compatible] - List workspaces. Read command: ignores active dev branch (production endpoint) with an Info banner; - pass --branch to opt in. Each entry carries login_type, read_only, qs_compatible so callers can pick a + List workspaces. Without --branch uses each alias's active branch (`branch use`), else + production. The Target line names it. Each entry carries login_type, read_only, qs_compatible so callers can pick a Query-Service-compatible workspace without firing a probe query. --qs-compatible filters to RO + confirmed-whitelist loginType (canonical data-app shape). --orphaned shows orphaned workspaces. kbagent workspace detail --project ALIAS --workspace-id ID [--branch ID] Workspace connection details (no password). Includes login_type, read_only, qs_compatible. - Read command: ignores active dev branch with an Info banner; pass --branch to opt in. + Without --branch uses the alias's active branch (`branch use`), else production. kbagent workspace delete --project ALIAS --workspace-id ID Delete workspace. They also expire automatically. @@ -2161,6 +2163,16 @@ Success: {{"status": "ok", "data": ...}} Error: {{"status": "error", "error": {{"code": "...", "message": "...", "retryable": true/false}}}} Check "retryable" -- if true, retry the operation. + A command that picked a branch also carries "targets" (success AND error): + [{{"role": "target"|"source", "project_alias", "branch_id", "branch_name", + "branch_source": "explicit"|"active_branch"|"git_mapping"|"manifest"|"merge_request" + |"production", + "active_branch": {{"branch_id", "branch_name"}}|null}}]. Read it before you + trust a write target: "active_branch" means `branch use` chose the branch. + "source" = the branch read or merged FROM (config clone origin, a merge + request's branch); merge-request merge and an armed auto-merge also list + production as "target". + No "targets" key = the command chose no branch (it does NOT mean production). 3. Multi-project: most read commands accept repeatable --project flag. Omit --project to query ALL connected projects in parallel. diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index b81700e2..3ecda7af 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -19,6 +19,7 @@ from rich.markup import escape from ..constants import DEFAULT_JOB_RUN_TIMEOUT +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._data_app_git import register_git_commands from ._data_app_runtime import register_secrets_commands @@ -108,6 +109,13 @@ def data_app_list( formatter = get_formatter(ctx) service = get_service(ctx, "data_app_service") try: + if project and len(project) == 1: + branch = resolve_branch( + ctx.obj["config_store"], + project[0], + branch, + ignore_active_branch=True, + ) result = service.list_data_apps(aliases=project, branch_id=branch) except KeboolaApiError as exc: formatter.error( @@ -148,6 +156,7 @@ def data_app_detail( formatter = get_formatter(ctx) service = get_service(ctx, "data_app_service") try: + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) result = service.get_data_app(alias=project, app_id=app_id, branch_id=branch) except KeboolaApiError as exc: formatter.error( @@ -319,6 +328,7 @@ def data_app_create( ) -> None: """Create a Keboola data app end-to-end (POST + encrypt + PUT + deploy).""" formatter = get_formatter(ctx) + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) service = get_service(ctx, "data_app_service") # Mutual exclusion: --description vs --description-file @@ -512,6 +522,7 @@ def data_app_deploy( ), ) -> None: """Deploy the latest Storage config (the §9 redeploy contract).""" + branch = resolve_branch(ctx.obj["config_store"], project, branch, ignore_active_branch=True) _run_lifecycle( ctx, "deploy_data_app", diff --git a/src/keboola_agent_cli/commands/flow.py b/src/keboola_agent_cli/commands/flow.py index 4cec2e13..00ade7e5 100644 --- a/src/keboola_agent_cli/commands/flow.py +++ b/src/keboola_agent_cli/commands/flow.py @@ -18,6 +18,7 @@ from rich.syntax import Syntax from rich.table import Table +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.flow_service import ( FLOW_COMPONENT_ID, @@ -31,7 +32,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) logger = logging.getLogger(__name__) @@ -173,7 +173,6 @@ def flow_list( """ formatter = get_formatter(ctx) service = get_service(ctx, "flow_service") - config_store = ctx.obj["config_store"] if branch is not None and (not project or len(project) != 1): formatter.error( @@ -182,14 +181,10 @@ def flow_list( ) raise typer.Exit(code=2) - effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - try: result = service.list_flows( aliases=project, - branch_id=effective_branch, + branch_id=branch, with_schedules=with_schedules, ) except ConfigError as exc: @@ -271,7 +266,7 @@ def flow_detail( formatter = get_formatter(ctx) service = get_service(ctx, "flow_service") config_store = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.get_flow_detail( @@ -802,6 +797,8 @@ def flow_delete( """ formatter = get_formatter(ctx) service = get_service(ctx, "flow_service") + config_store = ctx.obj["config_store"] + branch = resolve_branch(config_store, project, branch) if dry_run: result = { @@ -957,6 +954,8 @@ def flow_schedule_remove( """ formatter = get_formatter(ctx) service = get_service(ctx, "flow_service") + config_store = ctx.obj["config_store"] + branch = resolve_branch(config_store, project, branch) if dry_run: try: diff --git a/src/keboola_agent_cli/commands/job.py b/src/keboola_agent_cli/commands/job.py index 1dc34c57..1f45aed0 100644 --- a/src/keboola_agent_cli/commands/job.py +++ b/src/keboola_agent_cli/commands/job.py @@ -25,6 +25,7 @@ MAX_LOG_TAIL_LINES, VALID_STATUSES, ) +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import OutputFormatter, format_job_detail, format_jobs_table from ._helpers import ( @@ -33,7 +34,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, validate_branch_requires_project, ) @@ -396,7 +396,7 @@ def job_run( _validate_log_tail_lines(formatter, log_tail_lines) validate_branch_requires_project(formatter, branch, project) - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not formatter.json_mode: msg = f"Running [cyan]{component_id}[/cyan] / [cyan]{config_id}[/cyan]" @@ -600,7 +600,6 @@ def job_terminate( raise typer.Exit(code=2) validate_branch_requires_project(formatter, branch, project) - _, effective_branch = resolve_branch(config_store, formatter, project, branch) # Resolve job IDs resolved_ids: list[str] @@ -608,6 +607,8 @@ def job_terminate( if job_id: resolved_ids = list(job_id) else: + # The branch only narrows the --status filter; --job-id kills by ID. + effective_branch = resolve_branch(config_store, project, branch) # "any" means: list without status filter, then keep only killable states client-side list_status = None if status == "any" else status try: diff --git a/src/keboola_agent_cli/commands/notification.py b/src/keboola_agent_cli/commands/notification.py index 8116e5dc..bb97b7f8 100644 --- a/src/keboola_agent_cli/commands/notification.py +++ b/src/keboola_agent_cli/commands/notification.py @@ -29,6 +29,7 @@ from rich.markup import escape from rich.table import Table +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.notification_service import KNOWN_EVENTS, SCOPE_PROJECT_WIDE, VALID_CHANNELS from ._helpers import ( @@ -432,6 +433,8 @@ def notification_replace_recipient( raise typer.Exit(code=2) from None service = get_service(ctx, "notification_service") + # Name the branch the config names are read from before the prompt. + resolve_branch(ctx.obj["config_store"], project, None, role="source") if not yes and not formatter.json_mode: confirmed = typer.confirm( diff --git a/src/keboola_agent_cli/commands/schedule.py b/src/keboola_agent_cli/commands/schedule.py index e2237be5..1b578922 100644 --- a/src/keboola_agent_cli/commands/schedule.py +++ b/src/keboola_agent_cli/commands/schedule.py @@ -18,13 +18,13 @@ from rich.markup import escape from rich.table import Table +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) logger = logging.getLogger(__name__) @@ -150,7 +150,6 @@ def schedule_list( """ formatter = get_formatter(ctx) service = get_service(ctx, "schedule_service") - config_store = ctx.obj["config_store"] if branch is not None and (not project or len(project) != 1): formatter.error( @@ -159,15 +158,11 @@ def schedule_list( ) raise typer.Exit(code=2) from None - effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - try: result = service.list_schedules( aliases=project, enabled_only=enabled_only, - branch_id=effective_branch, + branch_id=branch, ) except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) @@ -207,7 +202,7 @@ def schedule_detail( formatter = get_formatter(ctx) service = get_service(ctx, "schedule_service") config_store = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.get_schedule_detail( @@ -312,7 +307,6 @@ def schedule_find( """ formatter = get_formatter(ctx) service = get_service(ctx, "schedule_service") - config_store = ctx.obj["config_store"] if branch is not None and (not project or len(project) != 1): formatter.error( @@ -321,16 +315,12 @@ def schedule_find( ) raise typer.Exit(code=2) from None - effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch(config_store, formatter, project[0], None) - try: result = service.find_schedules( aliases=project, cron_window=cron_window, not_run_since_days=not_run_since, - branch_id=effective_branch, + branch_id=branch, ) except ConfigError as exc: formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) diff --git a/src/keboola_agent_cli/commands/storage.py b/src/keboola_agent_cli/commands/storage.py index f69faf04..0a5ad4a9 100644 --- a/src/keboola_agent_cli/commands/storage.py +++ b/src/keboola_agent_cli/commands/storage.py @@ -10,6 +10,7 @@ from rich.markup import escape from ..config_store import ConfigStore +from ..effective_branch import record_branch, resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, @@ -17,7 +18,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) from ._storage_table_detail import ( format_range_partitioning, @@ -98,9 +98,9 @@ def storage_buckets( # buckets, which for a freshly created dev branch is an empty set. # Explicit --branch still wins. effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch( - config_store, formatter, project[0], None, ignore_active_branch=True + if project and len(project) == 1: + effective_branch = resolve_branch( + config_store, project[0], branch, ignore_active_branch=True ) try: @@ -185,9 +185,7 @@ def storage_bucket_detail( service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] # Read command: ignore implicit active dev branch (empty listing trap). - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) + effective_branch = resolve_branch(config_store, project, branch, ignore_active_branch=True) try: result = service.get_bucket_detail( @@ -319,9 +317,9 @@ def storage_tables( # Storage API branch endpoint only returns locally modified tables, so # auto-scoping to the active branch traps users into an empty listing. effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch( - config_store, formatter, project[0], None, ignore_active_branch=True + if project and len(project) == 1: + effective_branch = resolve_branch( + config_store, project[0], branch, ignore_active_branch=True ) try: @@ -381,9 +379,7 @@ def storage_table_detail( service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] # Read command: ignore implicit active dev branch (empty listing trap). - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) + effective_branch = resolve_branch(config_store, project, branch, ignore_active_branch=True) try: result = service.get_table_detail( @@ -453,7 +449,7 @@ def storage_create_bucket( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.create_bucket( @@ -638,7 +634,9 @@ def storage_create_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) + if source_table_id and source_branch_id: + record_branch(config_store, project, source_branch_id, "explicit", role="source") try: result = service.create_table( @@ -776,7 +774,7 @@ def storage_upload_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) p = Path(file) if not p.is_file(): @@ -916,7 +914,7 @@ def storage_download_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not formatter.json_mode: msg = f"Exporting [cyan]{table_id}[/cyan]" @@ -1014,7 +1012,7 @@ def storage_delete_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if dry_run: try: @@ -1118,7 +1116,7 @@ def storage_truncate_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if dry_run: try: @@ -1223,7 +1221,7 @@ def storage_add_column( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.add_column( @@ -1302,7 +1300,7 @@ def storage_delete_column( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if dry_run: try: @@ -1427,7 +1425,7 @@ def storage_swap_tables( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch, required=True) if dry_run: try: @@ -1537,7 +1535,9 @@ def storage_clone_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch, required=True) + if effective_branch is not None: # the table is pulled from production into it + record_branch(config_store, project, None, "production", fixed=True, role="source") try: result = service.clone_table( @@ -1622,7 +1622,7 @@ def storage_delete_bucket( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) try: result = service.delete_buckets( @@ -1717,9 +1717,7 @@ def storage_file_list( service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] # Read command: ignore implicit active dev branch (empty listing trap). - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) + effective_branch = resolve_branch(config_store, project, branch, ignore_active_branch=True) try: result = service.list_files( @@ -1860,7 +1858,7 @@ def storage_file_upload( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) p = Path(file) if not p.is_file(): @@ -2150,7 +2148,7 @@ def storage_load_file( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not formatter.json_mode: formatter.console.print( @@ -2271,7 +2269,7 @@ def storage_unload_table( formatter = get_formatter(ctx) service = get_service(ctx, "storage_service") config_store: ConfigStore = ctx.obj["config_store"] - _, effective_branch = resolve_branch(config_store, formatter, project, branch) + effective_branch = resolve_branch(config_store, project, branch) if not formatter.json_mode: msg = f"Exporting [cyan]{table_id}[/cyan] to Storage File" diff --git a/src/keboola_agent_cli/commands/stream.py b/src/keboola_agent_cli/commands/stream.py index 531c3ff5..c1fb6b8d 100644 --- a/src/keboola_agent_cli/commands/stream.py +++ b/src/keboola_agent_cli/commands/stream.py @@ -20,6 +20,7 @@ from rich.panel import Panel from rich.table import Table +from ..effective_branch import report_branch_ref from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._helpers import ( check_cli_permission, @@ -153,6 +154,7 @@ def stream_list( ) -> None: """List Data Streams sources in a project.""" formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "stream_service") try: result = service.list_sources(alias=project, branch_id=branch) @@ -189,6 +191,7 @@ def stream_create_source( --no-sinks to create a bare source without them. """ formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "stream_service") try: result = service.create_source( @@ -218,6 +221,7 @@ def stream_detail( ) -> None: """Show a source's endpoints, protocol, and destination tables.""" formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) service = get_service(ctx, "stream_service") try: result = service.get_source_detail( @@ -246,6 +250,7 @@ def stream_delete( ) -> None: """Delete a Data Streams source (destructive).""" formatter = get_formatter(ctx) + report_branch_ref(ctx.obj["config_store"], project, branch) if ( not dry_run and not formatter.json_mode diff --git a/src/keboola_agent_cli/commands/transformation.py b/src/keboola_agent_cli/commands/transformation.py index d5d1bcfb..74f5ffd8 100644 --- a/src/keboola_agent_cli/commands/transformation.py +++ b/src/keboola_agent_cli/commands/transformation.py @@ -17,6 +17,7 @@ from rich.markup import escape from ..config_store import ConfigStore +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..services.transformation_service import TransformationService from ._helpers import ( @@ -24,7 +25,6 @@ get_formatter, map_error_to_exit_code, parse_json_arg, - resolve_branch, resolve_project_alias, ) @@ -168,7 +168,7 @@ def transformation_create( sql = sql_file.read_text(encoding="utf-8") alias = resolve_project_alias(ctx, formatter, project) - _, branch_id = resolve_branch(config_store, formatter, alias, branch) + branch_id = resolve_branch(config_store, alias, branch) try: result = service.create( @@ -239,7 +239,7 @@ def transformation_show( config_store: ConfigStore = ctx.obj["config_store"] alias = resolve_project_alias(ctx, formatter, project) - _, branch_id = resolve_branch(config_store, formatter, alias, branch) + branch_id = resolve_branch(config_store, alias, branch) try: result = service.show( @@ -360,7 +360,7 @@ def transformation_edit( raise typer.Exit(code=2) from None alias = resolve_project_alias(ctx, formatter, project) - _, branch_id = resolve_branch(config_store, formatter, alias, branch) + branch_id = resolve_branch(config_store, alias, branch) try: result = service.edit( diff --git a/src/keboola_agent_cli/commands/workspace.py b/src/keboola_agent_cli/commands/workspace.py index b9066af1..2d6ba6e1 100644 --- a/src/keboola_agent_cli/commands/workspace.py +++ b/src/keboola_agent_cli/commands/workspace.py @@ -11,12 +11,12 @@ from rich.console import Console from rich.markup import escape -from ..config_store import ConfigStore from ..constants import ( QUERY_RESULTS_DEFAULT_LIMIT, WORKSPACE_LOAD_JOB_MAX_WAIT, WORKSPACE_LOAD_TYPES, ) +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..output import OutputFormatter, format_query_results, format_workspaces_table from ..services._workspace_load_plan import LoadTablePlan @@ -26,7 +26,6 @@ get_formatter, get_service, map_error_to_exit_code, - resolve_branch, ) workspace_app = typer.Typer(help="Workspace lifecycle for SQL debugging") @@ -166,9 +165,8 @@ def workspace_list( branch: int | None = typer.Option( None, "--branch", - help="Dev branch ID. Read-only command -- ignores the alias's active branch " - "by default (mirrors `storage buckets`); pass --branch to opt in. " - "Requires exactly one --project.", + help="Dev branch ID. Defaults to each project's active branch " + "(`kbagent branch use`), else production. Requires exactly one --project.", ), qs_compatible: bool = typer.Option( False, @@ -179,11 +177,9 @@ def workspace_list( ) -> None: """List workspaces from connected projects. - Branch handling: this is a read command and follows the same pattern as - `storage buckets` / `config list` -- when an alias is pinned to a dev - branch via `branch use`, the production endpoint is used (with a visible - `Info: ...` banner) instead of silently scoping the listing to the - pinned branch. Pass `--branch ID` to query a specific dev branch. + Branch handling: without `--branch`, each project's active branch (`branch + use`) is used, else production. The `Target:` line on stderr (`targets` in + --json) names the branch of each project. Each workspace entry exposes `login_type`, `read_only` and `qs_compatible` so data-app developers can pick a Query-Service-compatible @@ -191,7 +187,6 @@ def workspace_list( """ formatter = get_formatter(ctx) service = get_service(ctx, "workspace_service") - config_store: ConfigStore = ctx.obj["config_store"] if branch is not None and (not project or len(project) != 1): formatter.error( @@ -200,17 +195,11 @@ def workspace_list( ) raise typer.Exit(code=2) - effective_branch: int | None = branch - if branch is None and project and len(project) == 1: - _, effective_branch = resolve_branch( - config_store, formatter, project[0], None, ignore_active_branch=True - ) - try: result = service.list_workspaces( aliases=project, orphaned_only=orphaned, - branch_id=effective_branch, + branch_id=branch, qs_compatible_only=qs_compatible, ) except KeboolaApiError as exc: @@ -244,8 +233,8 @@ def workspace_detail( branch: int | None = typer.Option( None, "--branch", - help="Dev branch ID. Read-only command -- ignores the alias's active branch " - "by default (mirrors `storage bucket-detail`); pass --branch to opt in.", + help="Dev branch ID. Defaults to the alias's active branch " + "(`kbagent branch use`), else production.", ), ) -> None: """Show workspace details (password NOT included). @@ -256,16 +245,9 @@ def workspace_detail( """ formatter = get_formatter(ctx) service = get_service(ctx, "workspace_service") - config_store: ConfigStore = ctx.obj["config_store"] - - _, effective_branch = resolve_branch( - config_store, formatter, project, branch, ignore_active_branch=True - ) try: - result = service.get_workspace( - alias=project, workspace_id=workspace_id, branch_id=effective_branch - ) + result = service.get_workspace(alias=project, workspace_id=workspace_id, branch_id=branch) formatter.output( result, lambda c, d: ( @@ -619,6 +601,14 @@ def workspace_gc( formatter = get_formatter(ctx) service = get_service(ctx, "workspace_service") + # Name each project's branch before the prompt; the service resolves the same one. + try: + selected = service.resolve_projects(project) + except ConfigError: + selected = {} # gc_workspaces below reports the unknown alias + for alias in selected: + resolve_branch(ctx.obj["config_store"], alias, None) + if ( not dry_run and not yes diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index 4ffebebb..77f2883b 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -647,12 +647,15 @@ def get_project(self, alias: str) -> ProjectConfig | None: config = self.load() return config.projects.get(alias) - def set_project_branch(self, alias: str, branch_id: int | None) -> None: + def set_project_branch( + self, alias: str, branch_id: int | None, branch_name: str | None = None + ) -> None: """Set or clear the active development branch for a project. Args: alias: The project alias. branch_id: Branch ID to activate, or None to reset to main. + branch_name: The branch name, saved to show next to the ID. Raises: ConfigError: If the alias does not exist. @@ -663,6 +666,7 @@ def set_project_branch(self, alias: str, branch_id: int | None) -> None: raise self.project_not_found_error(alias) self._reject_ephemeral_mutation(config, alias, "modified") config.projects[alias].active_branch_id = branch_id + config.projects[alias].active_branch_name = branch_name or None self.save(config) @staticmethod diff --git a/src/keboola_agent_cli/effective_branch.py b/src/keboola_agent_cli/effective_branch.py new file mode 100644 index 00000000..08143c13 --- /dev/null +++ b/src/keboola_agent_cli/effective_branch.py @@ -0,0 +1,208 @@ +"""Choose the branch a command uses, and record the choice for the output. + +``kbagent branch use`` saves an active branch per project +(``ProjectConfig.active_branch_id``). :func:`resolve_branch` is the only code +that applies it: commands and services call it for the branch ID, and it +records which project and branch the command used and why (issue #766). +``OutputFormatter`` reports the records: a ``Target:`` line on stderr in human +mode and ``targets`` in the ``--json`` envelope, also for ``--dry-run``. +``tests/test_effective_branch.py`` fails on a new read of ``active_branch_id`` +outside this module. + +Only a CLI command opens the record (see :func:`record_targets`). ``kbagent +serve`` and the SDK do not, so there :func:`resolve_branch` only returns the ID. +""" + +import threading +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from .config_store import ConfigStore + +BranchSource = Literal[ + "explicit", "active_branch", "git_mapping", "manifest", "merge_request", "production" +] +TargetRole = Literal["target", "source"] + + +@dataclass(frozen=True) +class BranchTarget: + """One project and branch that a command used.""" + + role: TargetRole + project_alias: str + branch_id: int | None # None = the production endpoint + branch_name: str | None + branch_source: BranchSource + active_id: int | None # the project's active branch (`branch use`), applied or not + active_name: str | None + + def to_dict(self) -> dict[str, Any]: + active = None + if self.active_id is not None: + active = {"branch_id": self.active_id, "branch_name": self.active_name} + return { + "role": self.role, + "project_alias": self.project_alias, + "branch_id": self.branch_id, + "branch_name": self.branch_name, + "branch_source": self.branch_source, + "active_branch": active, + } + + +def _keep(targets: list[BranchTarget], target: BranchTarget) -> bool: + """Add ``target`` unless the command already recorded it; True when added. + + A command often resolves the branch and passes the ID to a service, which + resolves it again as ``explicit``: the first record wins. Two production + records are one target, and the one with the looked-up ID wins. A different + branch for the same project is a second record. + """ + for index, seen in enumerate(targets): + if (seen.role, seen.project_alias) != (target.role, target.project_alias): + continue + if seen.branch_id == target.branch_id: + return False + if seen.branch_source == target.branch_source == "production": + if seen.branch_id is None: + targets[index] = target + return False + targets.append(target) + return True + + +class _Recorder: + """The targets of the running command. Workers of a fan-out add to it concurrently.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._targets: list[BranchTarget] | None = None + self._on_record: Callable[[BranchTarget], None] | None = None + + @contextmanager + def open(self, on_record: Callable[[BranchTarget], None]) -> Iterator[None]: + with self._lock: + previous = (self._targets, self._on_record) + self._targets, self._on_record = [], on_record + try: + yield + finally: + with self._lock: + self._targets, self._on_record = previous + + def add(self, target: BranchTarget) -> None: + with self._lock: + if self._targets is None or not _keep(self._targets, target): + return + on_record = self._on_record + if on_record is not None: + on_record(target) + + def targets(self) -> list[BranchTarget]: + with self._lock: + found = list(self._targets or []) + return sorted(found, key=lambda t: (t.role != "source", t.project_alias, t.branch_id or 0)) + + +_RECORDER = _Recorder() + + +def record_targets(on_record: Callable[[BranchTarget], None]) -> AbstractContextManager[None]: + """Record the targets of one CLI command; ``on_record`` gets each new one.""" + return _RECORDER.open(on_record) + + +def recorded_targets() -> list[BranchTarget]: + """The targets of the running command, sorted; empty when no command records.""" + return _RECORDER.targets() + + +def record_branch( + config_store: "ConfigStore", + alias: str, + branch_id: int | None, + source: BranchSource, + *, + branch_name: str | None = None, + fixed: bool = False, + role: TargetRole = "target", +) -> int | None: + """Record a branch that was chosen without :func:`resolve_branch`; return ``branch_id``. + + An unknown project records nothing: the command fails on the alias. 0 is the + production endpoint (the API clients treat it so) and is returned as None. + ``fixed``: the command always uses this branch, so the record names no + active branch (and the human line gives no `--branch` hint). + """ + branch_id = branch_id or None + project = config_store.get_project(alias) + if project is None: + return branch_id + active_id, active_name = project.active_branch_id, project.active_branch_name + if fixed: + active_id = active_name = None + if branch_name is None and branch_id is not None and branch_id == active_id: + branch_name = active_name + _RECORDER.add( + BranchTarget( + role=role, + project_alias=alias, + branch_id=branch_id, + branch_name=branch_name, + branch_source=source if branch_id is not None else "production", + active_id=active_id, + active_name=active_name, + ) + ) + return branch_id + + +def resolve_branch( + config_store: "ConfigStore", + alias: str, + branch: int | None, + *, + ignore_active_branch: bool = False, + manifest_branch_id: int | None = None, + required: bool = False, + role: TargetRole = "target", +) -> int | None: + """Return the branch ID a command uses on project ``alias``, and record it. + + ``branch`` (``--branch``) wins, then the active branch from ``kbagent branch + use`` unless ``ignore_active_branch``, then ``manifest_branch_id`` (the first + branch of a synced tree). ``None`` means the production endpoint. + ``--branch 0`` is production too: it is recorded so and returned as 0, which + the API clients send to the production endpoint and a second call here + keeps as production. ``required``: the command refuses to run without a + branch, so a missing one is not recorded. A caller that then reads the + numeric ID of the default branch from the API records it with + :func:`record_branch` as ``production``. + """ + if branch == 0: + if required: + return None + record_branch(config_store, alias, None, "production", role=role) + return 0 + if branch is not None: + return record_branch(config_store, alias, branch, "explicit", role=role) + project = config_store.get_project(alias) + active_id = project.active_branch_id if project is not None else None + if active_id is not None and not ignore_active_branch: + return record_branch(config_store, alias, active_id, "active_branch", role=role) + if manifest_branch_id is not None: + return record_branch(config_store, alias, manifest_branch_id, "manifest", role=role) + if required: + return None + return record_branch(config_store, alias, None, "production", role=role) + + +def report_branch_ref(config_store: "ConfigStore", alias: str, ref: int | str | None) -> None: + """Record a branch given as an ID or ``"default"`` by a command that never applies + the active branch (Data Streams, branch metadata).""" + branch = int(ref) if ref is not None and str(ref).isdecimal() else None + resolve_branch(config_store, alias, branch, ignore_active_branch=True) diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 57d489db..61a0e85b 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -80,6 +80,10 @@ class ProjectConfig(BaseModel): default=None, description="Active development branch ID (None = main/production branch)", ) + active_branch_name: str | None = Field( + default=None, + description="Name of the active branch when it was set (shown next to its ID)", + ) org_id: int | None = Field( default=None, description="Organization ID (populated via `org setup` or when verify_token returns it)", @@ -384,6 +388,9 @@ class SuccessResponse(BaseModel): """Structured success response for JSON output mode.""" status: str = Field(default="ok", description="Always 'ok' for success responses") + targets: list[dict[str, Any]] | None = Field( + default=None, description="Projects and branches the command used (issue #766)" + ) data: Any = Field(default=None, description="Response payload") diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index 6fcbbda7..756e24cf 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -11,6 +11,7 @@ from rich.table import Table from rich.text import Text +from .effective_branch import BranchTarget, recorded_targets from .models import ErrorResponse, SuccessResponse @@ -66,6 +67,42 @@ def force_utf8_when_redirected() -> None: continue +def success_json(data: Any) -> str: + """The ``--json`` success envelope; ``targets`` only when the command recorded one.""" + targets = [target.to_dict() for target in recorded_targets()] + response = SuccessResponse(status="ok", targets=targets or None, data=data) + return response.model_dump_json(indent=2, exclude=None if targets else {"targets"}) + + +_TARGET_REASONS = { + "explicit": "from the command line", + "active_branch": "from 'kbagent branch use'", + "git_mapping": "from .keboola/branch-mapping.json", + "manifest": "from .keboola/manifest.json", + "merge_request": "from the merge request", + "production": "", +} + + +def format_target_line(target: BranchTarget) -> str: + """One human line that names the project and branch a command uses.""" + if target.branch_source == "production": + where = ( + "production" if target.branch_id is None else f"production branch {target.branch_id}" + ) + else: + name = f" '{target.branch_name}'" if target.branch_name else "" + where = f"branch {target.branch_id}{name}" + notes = [_TARGET_REASONS[target.branch_source]] if _TARGET_REASONS[target.branch_source] else [] + active = target.active_id + if active is not None and active != target.branch_id and target.branch_source != "explicit": + name = f" '{target.active_name}'" if target.active_name else "" + notes.append(f"active branch {active}{name} not used; pass --branch {active} to use it") + suffix = f" ({'; '.join(notes)})" if notes else "" + label = "Source" if target.role == "source" else "Target" + return f"{label}: project '{target.project_alias}', {where}{suffix}" + + def write_machine_output(text: str) -> None: """Write a machine-readable line to stdout as UTF-8, whatever the console is. @@ -132,8 +169,7 @@ def output( human-friendly output. If None in human mode, prints repr. """ if self.json_mode: - response = SuccessResponse(status="ok", data=data) - write_machine_output(response.model_dump_json(indent=2)) + write_machine_output(success_json(data)) else: if human_formatter is not None: human_formatter(self.console, data) @@ -174,10 +210,11 @@ def error( retryable=retryable, details=details if details else None, ) - error_envelope = { - "status": "error", - "error": err.model_dump(exclude_none=True), - } + error_envelope: dict[str, Any] = {"status": "error"} + targets = [target.to_dict() for target in recorded_targets()] + if targets: + error_envelope["targets"] = targets + error_envelope["error"] = err.model_dump(exclude_none=True) write_machine_output(json.dumps(error_envelope, indent=2)) else: self.err_console.print(f"[bold red]Error:[/bold red] {message}") @@ -189,11 +226,21 @@ def success(self, message: str) -> None: message: The success message to display. """ if self.json_mode: - response = SuccessResponse(status="ok", data={"message": message}) - write_machine_output(response.model_dump_json(indent=2)) + write_machine_output(success_json({"message": message})) else: self.console.print(f"[bold green]Success:[/bold green] {message}") + def report_target(self, target: BranchTarget) -> None: + """Print the project and branch a command uses, on stderr (human mode only).""" + if not self.json_mode: + self.err_console.print( + format_target_line(target), + style="dim", + markup=False, + highlight=False, + soft_wrap=True, + ) + def warning(self, message: str) -> None: """Output a warning message to stderr (human mode only). diff --git a/src/keboola_agent_cli/server/routers/workspaces.py b/src/keboola_agent_cli/server/routers/workspaces.py index 168a7ae3..563e5c41 100644 --- a/src/keboola_agent_cli/server/routers/workspaces.py +++ b/src/keboola_agent_cli/server/routers/workspaces.py @@ -86,9 +86,8 @@ def list_workspaces( branch: int | None = Query( None, description=( - "Dev branch ID. Requires exactly one project. Without branch, the " - "production endpoint is used regardless of any pinned active branch " - "(read-command convention, mirrors `storage buckets`)." + "Dev branch ID. Requires exactly one project. Without branch, each " + "project's active branch (`kbagent branch use`) is used, else production." ), ), qs_compatible: bool = Query( @@ -135,8 +134,8 @@ def detail( branch: int | None = Query( None, description=( - "Dev branch ID. Without branch, the production endpoint is used " - "regardless of any pinned active branch (read-command convention)." + "Dev branch ID. Without branch, the project's active branch " + "(`kbagent branch use`) is used, else production." ), ), registry: ServiceRegistry = Depends(get_registry), diff --git a/src/keboola_agent_cli/services/_config_clone.py b/src/keboola_agent_cli/services/_config_clone.py index 0e8c54eb..1382d36d 100644 --- a/src/keboola_agent_cli/services/_config_clone.py +++ b/src/keboola_agent_cli/services/_config_clone.py @@ -36,6 +36,7 @@ import re from typing import Any, Protocol +from ..effective_branch import resolve_branch from ..errors import ConfigError, KeboolaApiError from ..json_utils import set_nested_value from ..models import ProjectConfig @@ -514,11 +515,20 @@ def clone_config_method( # reusing the source client for what the flow treats as a # cross-project write would create the configuration in the source # project while reporting the target. + same_project = is_same_project(source_project, target_project) target_client = ( source_client - if is_same_project(source_project, target_project) + if same_project else self._client_factory(target_project.stack_url, target_project.token) ) + source_branch_id = resolve_branch(self._config_store, alias, branch_id, role="source") + if target_alias in (None, alias) and target_branch_id is None: + # One alias: the copy lands in the source's branch, not in the active one again. + resolve_branch(self._config_store, alias, branch_id) + else: + target_branch_id = resolve_branch( + self._config_store, target_alias or alias, target_branch_id + ) try: return clone_config( source_client=source_client, @@ -533,8 +543,8 @@ def clone_config_method( description=description, set_overrides=set_overrides, secret_overrides=secret_overrides, - branch_id=branch_id or source_project.active_branch_id, - target_branch_id=target_branch_id or target_project.active_branch_id, + branch_id=source_branch_id, + target_branch_id=target_branch_id, dry_run=dry_run, allow_plaintext_fallback=allow_plaintext_fallback, encrypt_fn=self._encrypt_secrets_before_write, diff --git a/src/keboola_agent_cli/services/_sync_clone.py b/src/keboola_agent_cli/services/_sync_clone.py index c89ad28b..eab707ef 100644 --- a/src/keboola_agent_cli/services/_sync_clone.py +++ b/src/keboola_agent_cli/services/_sync_clone.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +from ..effective_branch import record_branch from ..errors import ConfigError from ..sync.clone import ( apply_bucket_map, @@ -155,7 +156,7 @@ def clone_project( # clone (resolves to None -> 0), a plain clone (the numeric default), # and --branch (the override) -- keeping gotchas.md's promise (CLI-9). push_branch_id = service._resolve_branch_id( - target_project, manifest, target_path, branch_override=branch_override + target_alias, manifest, target_path, branch_override=branch_override ) repoint_default_branch_configs( manifest, @@ -203,6 +204,8 @@ def clone_project( bucket_result = None if create_buckets: bucket_client = service._client_factory(target_project.stack_url, target_project.token) + # The buckets are created without a branch: in production. + record_branch(service._config_store, target_alias, None, "production", fixed=True) with bucket_client: bucket_result = create_buckets_from_export(bucket_client, target_path, bucket_map) diff --git a/src/keboola_agent_cli/services/branch_service.py b/src/keboola_agent_cli/services/branch_service.py index df602a6c..717fff56 100644 --- a/src/keboola_agent_cli/services/branch_service.py +++ b/src/keboola_agent_cli/services/branch_service.py @@ -10,6 +10,7 @@ from typing import Any from ..constants import METADATA_NOT_FOUND +from ..effective_branch import resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -165,7 +166,7 @@ def create_branch( branch_id = int(branch_data["id"]) # Auto-activate the created branch - self._config_store.set_project_branch(alias, branch_id) + self._config_store.set_project_branch(alias, branch_id, branch_data.get("name", name)) return { "project_alias": alias, @@ -197,6 +198,7 @@ def set_active_branch(self, alias: str, branch_id: int) -> dict[str, Any]: """ projects = self.resolve_projects([alias]) project = projects[alias] + resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -217,9 +219,9 @@ def set_active_branch(self, alias: str, branch_id: int) -> dict[str, Any]: f"Use 'kbagent branch list --project {alias}' to see available branches." ) - self._config_store.set_project_branch(alias, branch_id) - branch_name = target_branch.get("name", "") + self._config_store.set_project_branch(alias, branch_id, branch_name) + return { "project_alias": alias, "branch_id": branch_id, @@ -275,6 +277,7 @@ def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]: projects = self.resolve_projects([alias]) project = projects[alias] + resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -326,7 +329,9 @@ def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, A projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id if branch_id is not None else project.active_branch_id + effective_branch_id = resolve_branch( + self._config_store, alias, branch_id, required=True, role="source" + ) if effective_branch_id is None: raise ConfigError( f"No branch specified and no active branch set for project '{alias}'. " diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 043abf1f..5daba8c5 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -20,6 +20,7 @@ CONFIG_STATE_MAX_BYTES, ROOT_LEVEL_CONFIG_COMPONENTS, ) +from ..effective_branch import record_branch, resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..json_utils import compute_diff, deep_merge, find_matches_in_json, set_nested_value from ..models import ComponentDetail, ProjectConfig @@ -147,7 +148,7 @@ def _fetch_project_configs( """ client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) if include_rows: components = client.list_components_with_configs( @@ -411,7 +412,7 @@ def get_config_detail( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -521,7 +522,7 @@ def _fetch_project_component_configs( """ client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) # Pre-filter to the matching component bucket when the # component_id prefix encodes a known type (keboola.ex-*, # keboola.wr-*, keboola.*-transformation, keboola.app-*). The @@ -723,7 +724,7 @@ def update_config( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -919,7 +920,7 @@ def set_default_bucket( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -1063,7 +1064,7 @@ def get_config_state( ConfigError: When the alias is unknown. """ project = self.resolve_projects([alias])[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) base = { "project_alias": alias, @@ -1136,7 +1137,7 @@ def set_config_state( raise _bad_state(f"--state is {size} bytes; cap is {CONFIG_STATE_MAX_BYTES}.") project = self.resolve_projects([alias])[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) base = { "project_alias": alias, @@ -1212,7 +1213,7 @@ def delete_config( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: return trash.execute_delete( @@ -1233,7 +1234,7 @@ def restore_config( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: return trash.execute_restore( @@ -1253,7 +1254,7 @@ def list_config_trash( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: return trash.execute_trash_list(client, alias, component_id, effective_branch_id) @@ -1291,7 +1292,7 @@ def rename_config( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -1457,9 +1458,7 @@ def _find_sync_branch_dir(manifest: Manifest, project_root: Path) -> Path | None branch_dir = project_root / branch_path return branch_dir if branch_dir.exists() else None - def _resolve_metadata_branch_id( - self, project: ProjectConfig, client: Any, branch_id: int | None - ) -> int: + def _metadata_branch_id(self, alias: str, client: Any, branch_id: int | None) -> int: """Resolve the branch ID required by the config metadata API. Config metadata endpoints only support the branch-aware route @@ -1468,9 +1467,9 @@ def _resolve_metadata_branch_id( Raises ConfigError if no default branch can be found. """ - effective = branch_id or project.active_branch_id - if effective: - return int(effective) + effective = resolve_branch(self._config_store, alias, branch_id) + if effective: # 0 = production: read its numeric ID below + return effective try: branches = client.list_dev_branches() except KeboolaApiError as exc: @@ -1485,6 +1484,7 @@ def _resolve_metadata_branch_id( ) from exc default_branch_id = find_default_branch_id(branches) if default_branch_id is not None: + record_branch(self._config_store, alias, default_branch_id, "production") return default_branch_id raise ConfigError( "Could not determine a branch for config metadata. " @@ -1508,7 +1508,7 @@ def list_config_metadata( project = projects[alias] client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = self._resolve_metadata_branch_id(project, client, branch_id) + effective_branch_id = self._metadata_branch_id(alias, client, branch_id) entries = client.list_config_metadata( component_id, config_id, branch_id=effective_branch_id ) @@ -1567,7 +1567,7 @@ def set_config_metadata( project = projects[alias] client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = self._resolve_metadata_branch_id(project, client, branch_id) + effective_branch_id = self._metadata_branch_id(alias, client, branch_id) result = client.set_config_metadata( component_id, config_id, entries=[(key, value)], branch_id=effective_branch_id ) @@ -1599,7 +1599,7 @@ def delete_config_metadata( project = projects[alias] client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = self._resolve_metadata_branch_id(project, client, branch_id) + effective_branch_id = self._metadata_branch_id(alias, client, branch_id) client.delete_config_metadata( component_id, config_id, metadata_id, branch_id=effective_branch_id ) @@ -1729,7 +1729,7 @@ def _search_project_configs( """ client = self._client_factory(project.stack_url, project.token) try: - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) components = client.list_components_with_configs( branch_id=effective_branch_id, component_type=component_type, @@ -1825,7 +1825,7 @@ def create_config_row( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: # Encrypt #-prefixed secrets before they reach Storage (issue #378). @@ -1909,7 +1909,7 @@ def create_config( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) body_was_explicit = configuration is not None effective_config: dict[str, Any] = configuration if body_was_explicit else {} @@ -2177,7 +2177,7 @@ def update_config_row( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -2335,7 +2335,7 @@ def delete_config_row( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch_id = branch_id or project.active_branch_id + effective_branch_id = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: client.delete_config_row( diff --git a/src/keboola_agent_cli/services/flow_service.py b/src/keboola_agent_cli/services/flow_service.py index 77dfe03c..20483672 100644 --- a/src/keboola_agent_cli/services/flow_service.py +++ b/src/keboola_agent_cli/services/flow_service.py @@ -25,6 +25,7 @@ from ..ai_client import AiServiceClient from ..config_store import ConfigStore +from ..effective_branch import resolve_branch from ..errors import ErrorCode, KeboolaApiError from ..models import ComponentDetail, ProjectConfig from ..scheduler_client import SchedulerClient @@ -359,8 +360,8 @@ def list_flows( projects = self.resolve_projects(aliases) def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) - effective_branch = branch_id or project.active_branch_id try: flows: list[dict[str, Any]] = [] try: @@ -447,7 +448,7 @@ def get_flow_detail( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -500,7 +501,7 @@ def create_flow( projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) fetch = self._fetch_flow_schema(project) warnings: list[str] = [] @@ -565,7 +566,7 @@ def update_flow( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) warnings: list[str] = [] client = self._client_factory(project.stack_url, project.token) @@ -635,7 +636,7 @@ def delete_flow( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -670,7 +671,7 @@ def list_flow_schedules( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -738,7 +739,7 @@ def get_flow_triggers( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) schedules = self.list_flow_schedules(alias, config_id, branch_id=branch_id)["schedules"] @@ -816,7 +817,7 @@ def set_flow_schedule( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -943,7 +944,7 @@ def remove_flow_schedule( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py index 63164507..771b1e86 100644 --- a/src/keboola_agent_cli/services/merge_request_service.py +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -32,6 +32,7 @@ MERGE_REQUEST_REASON_MAX_LENGTH, PROTECTED_DEFAULT_BRANCH_FEATURE, ) +from ..effective_branch import record_branch from ..errors import ConfigError, ErrorCode, FeatureNotEnabledError, KeboolaApiError from ..json_utils import DiffEntry, compute_diff_entries from ..models import ProjectConfig @@ -792,6 +793,7 @@ def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: client.merge_requests.get(merge_request_id).get("branches") or {} ).get("branchFromId") branch_from_id = _coerce_branch_id(raw_branch_from) + record_branch(self._config_store, alias, branch_from_id, "merge_request", role="source") try: job = client.merge_requests.merge(merge_request_id) except KeboolaApiError as exc: @@ -991,6 +993,7 @@ def get_config_diff( client = self._client_factory(project.stack_url, project.token) try: branch_id = self._branch_from_id_of(client, merge_request_id) + record_branch(self._config_store, alias, branch_id, "merge_request", role="source") diff = client.get_config_diff(component_id, config_id, branch_id) finally: client.close() @@ -1210,6 +1213,7 @@ def resolve_conflict( try: self._require_merge_requests_feature(client) branch_id = self._branch_from_id_of(client, merge_request_id) + record_branch(self._config_store, alias, branch_id, "merge_request", role="source") self._require_in_conflict_set(client, merge_request_id, component_id, config_id) diff = client.get_config_diff(component_id, config_id, branch_id) theirs = diff.get("theirs") or {} diff --git a/src/keboola_agent_cli/services/notification_service.py b/src/keboola_agent_cli/services/notification_service.py index bf9384ca..ae7f6dad 100644 --- a/src/keboola_agent_cli/services/notification_service.py +++ b/src/keboola_agent_cli/services/notification_service.py @@ -34,6 +34,7 @@ from dataclasses import dataclass, field from typing import Any +from ..effective_branch import resolve_branch from ..errors import ConfigError, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -354,7 +355,7 @@ def get_subscription_detail( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id, role="source") client = self._client_factory(project.stack_url, project.token) try: @@ -406,7 +407,7 @@ def create_subscription( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id, role="source") recipient = _build_recipient(channel, address) filters = _build_filters(component_id, config_id, branch_id) @@ -511,7 +512,7 @@ def replace_subscription_recipient( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, None, role="source") client = self._client_factory(project.stack_url, project.token) try: @@ -597,7 +598,7 @@ def _fetch_project_subscriptions( the discriminator -- ``_run_parallel`` sorts on ``len(result) == 2`` -- so neither shape may grow or shrink independently of the other. """ - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id, role="source") client = self._client_factory(project.stack_url, project.token) try: diff --git a/src/keboola_agent_cli/services/schedule_service.py b/src/keboola_agent_cli/services/schedule_service.py index 5cb9028c..9fd0c2ed 100644 --- a/src/keboola_agent_cli/services/schedule_service.py +++ b/src/keboola_agent_cli/services/schedule_service.py @@ -29,6 +29,7 @@ from datetime import UTC, datetime, timedelta from typing import Any +from ..effective_branch import resolve_branch from ..errors import ConfigError, KeboolaApiError from ..models import ProjectConfig from .base import BaseService @@ -357,7 +358,7 @@ def get_schedule_detail( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -514,7 +515,7 @@ def _fetch_project_schedules( size**, not the schedule count. See ``schedule-workflow.md`` for the trade-off rationale. """ - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -578,7 +579,7 @@ def _find_in_project( tuple, the client is always closed, and the caller sees the uniform ``(alias, payload, True?)`` shape. """ - effective_branch = branch_id or project.active_branch_id + effective_branch = resolve_branch(self._config_store, alias, branch_id) client = self._client_factory(project.stack_url, project.token) try: diff --git a/src/keboola_agent_cli/services/sync_service.py b/src/keboola_agent_cli/services/sync_service.py index 710de01b..63ab0f18 100644 --- a/src/keboola_agent_cli/services/sync_service.py +++ b/src/keboola_agent_cli/services/sync_service.py @@ -526,7 +526,7 @@ def pull( # Determine branch to pull from (git-branching aware) branch_id = self._resolve_branch_id( - project, manifest, project_root, branch_override=branch_override + alias, manifest, project_root, branch_override=branch_override ) # Fetch all components with configs from API (+ storage metadata + jobs) @@ -1254,7 +1254,7 @@ def diff( manifest = load_manifest(project_root) branch_id = self._resolve_branch_id( - project, manifest, project_root, branch_override=branch_override + alias, manifest, project_root, branch_override=branch_override ) # Fetch remote state @@ -1657,7 +1657,7 @@ def push( manifest = load_manifest(project_root) branch_id = self._resolve_branch_id( - project, manifest, project_root, branch_override=branch_override + alias, manifest, project_root, branch_override=branch_override ) # Detect name drift: local dir name doesn't match config name @@ -2160,9 +2160,9 @@ def _fetch_config_folders(client: Any, branch_id: int | None) -> dict[str, str] logger.warning("config-folder metadata lookup failed", exc_info=True) return None - @staticmethod def _resolve_branch_id( - project: Any, + self, + alias: str, manifest: "Manifest", project_root: Path, branch_override: int | None = None, @@ -2183,6 +2183,7 @@ def _resolve_branch_id( guarantees there is always a recovery path when the mapping file is lost (issue #267, Bug E). """ + from ..effective_branch import record_branch, resolve_branch from ..sync.branch_mapping import load_branch_mapping from ..sync.git_utils import get_current_branch @@ -2190,7 +2191,7 @@ def _resolve_branch_id( # dev branch from a clean git workspace without first running # `branch use` or `branch-link`. if branch_override is not None: - return branch_override + return record_branch(self._config_store, alias, branch_override, "explicit") if manifest.git_branching.enabled: git_branch = get_current_branch(project_root) @@ -2203,7 +2204,7 @@ def _resolve_branch_id( # Mapping missing -- auto-recover for the default branch # so the user is never locked out of production. if is_default: - return None + return record_branch(self._config_store, alias, None, "production") raise ConfigError( f"Git branch '{git_branch}' is not linked to a Keboola " f"branch (branch-mapping.json missing). " @@ -2212,20 +2213,18 @@ def _resolve_branch_id( entry = mapping.get(git_branch) if entry is not None: # entry.keboola_id is None for production (default branch) - return entry.keboola_id + return record_branch(self._config_store, alias, entry.keboola_id, "git_mapping") # No entry for current branch -- default branch is always production if is_default: - return None + return record_branch(self._config_store, alias, None, "production") raise ConfigError( f"Git branch '{git_branch}' is not linked to a Keboola branch. " f"Run 'kbagent sync branch-link --project ALIAS' first." ) # Non git-branching: use active_branch_id or manifest fallback - branch_id = project.active_branch_id if project is not None else None - if not branch_id and manifest.branches: - branch_id = manifest.branches[0].id - return branch_id + fallback = manifest.branches[0].id if manifest.branches else None + return resolve_branch(self._config_store, alias, None, manifest_branch_id=fallback) # ------------------------------------------------------------------ # Storage metadata / jobs / samples helpers diff --git a/src/keboola_agent_cli/services/workspace_service.py b/src/keboola_agent_cli/services/workspace_service.py index 0212020e..b25c4888 100644 --- a/src/keboola_agent_cli/services/workspace_service.py +++ b/src/keboola_agent_cli/services/workspace_service.py @@ -28,6 +28,7 @@ WORKSPACE_LOAD_JOB_MAX_WAIT, WORKSPACE_LOAD_TYPES, ) +from ..effective_branch import record_branch, resolve_branch from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ProjectConfig from ._workspace_load_plan import ( @@ -219,21 +220,26 @@ class WorkspaceService(BaseService): Uses dependency injection for config_store and client_factory. """ - def _resolve_branch_id(self, alias: str, project: ProjectConfig) -> int: + def _resolve_branch_id( + self, alias: str, project: ProjectConfig, branch_id: int | None = None + ) -> int: """Resolve the effective branch ID for a project. - Uses active_branch_id if set, otherwise fetches main branch from API. + Uses ``branch_id`` (``--branch``), then the active branch, otherwise + fetches the default branch ID from the API. Returns: Branch ID (int). """ - if project.active_branch_id is not None: - return project.active_branch_id + effective = resolve_branch(self._config_store, alias, branch_id) + if effective is not None: + return effective client = self._client_factory(project.stack_url, project.token) try: default_branch_id = find_default_branch_id(client.list_dev_branches()) if default_branch_id is not None: + record_branch(self._config_store, alias, default_branch_id, "production") return default_branch_id raise ConfigError( f"No default branch found for project '{alias}'. " @@ -316,6 +322,8 @@ def create_workspace( config_id = sandbox_config.get("id", "") if ui_mode: + # The sandbox job is created without a branch: it runs on production. + record_branch(self._config_store, alias, None, "production", fixed=True) return self._create_workspace_via_job( client, alias, @@ -484,9 +492,7 @@ def resolve_sandbox_workspace_id( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = ( - branch_id if branch_id is not None else self._resolve_branch_id(alias, project) - ) + effective_branch = self._resolve_branch_id(alias, project, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -510,10 +516,8 @@ def list_workspaces( orphaned_only: If True, return only orphaned workspaces — those whose keboola.sandboxes config no longer exists. branch_id: When set, list workspaces from this specific dev branch - (`/v2/storage/branch/{ID}/workspaces`). When None, the - production endpoint is used; callers wanting to honour the - alias's pinned branch should resolve it via - ``resolve_branch()`` in the command layer before calling. + (`/v2/storage/branch/{ID}/workspaces`). When None, each + project's active branch is used, else its default branch. Only valid with a single alias (mirrors storage commands). qs_compatible_only: If True, return only workspaces whose ``login_type`` is in ``QUERY_SERVICE_COMPATIBLE_LOGIN_TYPES`` @@ -539,9 +543,7 @@ def worker( ) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]: client = self._client_factory(project.stack_url, project.token) try: - effective_branch = ( - branch_id if branch_id is not None else self._resolve_branch_id(alias, project) - ) + effective_branch = self._resolve_branch_id(alias, project, branch_id) raw_workspaces = client.list_workspaces(branch_id=effective_branch) # Fetch sandbox configs to resolve user-given names @@ -698,10 +700,7 @@ def get_workspace( workspace_id: Workspace ID. branch_id: When set, query the branch-scoped endpoint ``/v2/storage/branch/{ID}/workspaces/{WS}``. When None, falls - back to the project's active branch (or the production - endpoint if no active branch is pinned). Explicit None vs. - resolved value lets the command layer surface a "production - branch used" notice without changing the service signature. + back to the project's active branch, else its default branch. Returns: Dict with workspace details including ``login_type``, @@ -711,9 +710,7 @@ def get_workspace( """ projects = self.resolve_projects([alias]) project = projects[alias] - effective_branch = ( - branch_id if branch_id is not None else self._resolve_branch_id(alias, project) - ) + effective_branch = self._resolve_branch_id(alias, project, branch_id) client = self._client_factory(project.stack_url, project.token) try: @@ -1219,7 +1216,8 @@ def create_from_transformation( try: effective_backend = backend or self._detect_backend(client) - # Read the transformation config + # Read the transformation config (from production, without a branch) + record_branch(self._config_store, alias, None, "production", fixed=True, role="source") config_data = client.get_config_detail(component_id, config_id) # Extract input mapping from configuration diff --git a/tests/test_config_clone_service.py b/tests/test_config_clone_service.py index 97528028..77966e6f 100644 --- a/tests/test_config_clone_service.py +++ b/tests/test_config_clone_service.py @@ -151,6 +151,49 @@ def test_set_overrides_are_applied_after_the_copy(self, tmp_config_dir: Path) -> # The sibling that #587 is about survives the override step. assert patched["runtime"] == {"parallelism": "20"} + def test_branch_flag_under_an_active_branch_copies_into_that_branch( + self, tmp_config_dir: Path + ) -> None: + """Within one project the copy lands in the source's branch (#766). + + The target branch used to fall back to the active branch, so `--branch 777` + under `branch use 456` was refused as a --target-branch the caller never gave. + """ + service, client = _make_service(tmp_config_dir) + service._config_store.set_project_branch("prod", 456) + + result = service.clone_config( + alias="prod", + component_id="keboola.wr-db-snowflake", + config_id="src-1", + name="copy", + branch_id=777, + ) + + assert result["mode"] == "same-project" + assert client.create_config_copy.call_args.kwargs["branch_id"] == 777 + + def test_a_second_alias_of_the_project_keeps_its_own_active_branch( + self, tmp_config_dir: Path + ) -> None: + """Only one alias copies into the source branch; a second alias resolves its own.""" + service, client = _make_service(tmp_config_dir) + store = service._config_store + source = store.get_project("prod") + assert source is not None + store.add_project("prod2", source.model_copy()) + store.set_project_branch("prod2", 888) + + with pytest.raises(ConfigError, match="--target-branch 888 cannot be honoured"): + service.clone_config( + alias="prod", + target_alias="prod2", + component_id="keboola.wr-db-snowflake", + config_id="src-1", + name="copy", + ) + client.create_config_copy.assert_not_called() + def test_no_overrides_means_no_update_call(self, tmp_config_dir: Path) -> None: """Without --set the copy is already final; no pointless second write.""" service, client = _make_service(tmp_config_dir) diff --git a/tests/test_config_metadata.py b/tests/test_config_metadata.py index 9cb85392..f2dd9a4d 100644 --- a/tests/test_config_metadata.py +++ b/tests/test_config_metadata.py @@ -583,7 +583,7 @@ def _make_service(self, tmp_path: Path) -> tuple[ConfigService, MagicMock]: store = ConfigStore(config_dir=config_dir) store.add_project( "prod", - # active_branch_id set so _resolve_metadata_branch_id skips the API call + # active_branch_id set so _metadata_branch_id skips the API call ProjectConfig( stack_url=TEST_URL, token=TEST_TOKEN, diff --git a/tests/test_effective_branch.py b/tests/test_effective_branch.py new file mode 100644 index 00000000..4d70dfb3 --- /dev/null +++ b/tests/test_effective_branch.py @@ -0,0 +1,444 @@ +"""Which branch a command uses, and how the CLI reports it (issue #766).""" + +import ast +import json +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from keboola_agent_cli import cli +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.effective_branch import ( + BranchTarget, + record_branch, + record_targets, + recorded_targets, + resolve_branch, +) +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.output import OutputFormatter, format_target_line, success_json +from keboola_agent_cli.services.flow_service import FlowService +from keboola_agent_cli.services.schedule_service import ScheduleService +from keboola_agent_cli.services.sync_service import SyncService +from keboola_agent_cli.services.workspace_service import WorkspaceService +from keboola_agent_cli.sync.manifest import Manifest, ManifestBranch, ManifestProject + +SRC = Path(__file__).parent.parent / "src" / "keboola_agent_cli" +TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +runner = CliRunner() + +# Reads of `active_branch_id` outside effective_branch.py that do not choose the +# branch of an API call: they only show the active branch or reset it. +READS_THAT_DO_NOT_CHOOSE_A_BRANCH = { + "services/branch_service.py::list_branches", + "services/branch_service.py::reset_branch", + "services/branch_service.py::delete_branch", + "services/merge_request_service.py::merge", + "services/project_service.py::list_projects", + "services/project_service.py::_check_project_status", +} + + +def _project(active_id: int | None = None, active_name: str | None = None) -> ProjectConfig: + return ProjectConfig( + stack_url="https://connection.keboola.com", + token=TOKEN, + project_id=1, + active_branch_id=active_id, + active_branch_name=active_name, + ) + + +def _store(tmp_path: Path, **projects: ProjectConfig) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + for alias, project in projects.items(): + store.add_project(alias, project) + return store + + +def _target(**changes: object) -> BranchTarget: + values: dict = { + "role": "target", + "project_alias": "prod", + "branch_id": None, + "branch_name": None, + "branch_source": "production", + "active_id": None, + "active_name": None, + } + values.update(changes) + return BranchTarget(**values) + + +def _active_branch_reads(root: Path) -> set[str]: + """``::`` for every attribute read of ``active_branch_id``.""" + found: set[str] = set() + for path in root.rglob("*.py"): + rel = path.relative_to(root).as_posix() + if rel == "effective_branch.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Attribute) + and node.attr == "active_branch_id" + and isinstance(node.ctx, ast.Load) + ): + continue + names = [] + scope = parents.get(node) + while scope is not None: + if isinstance(scope, ast.FunctionDef | ast.AsyncFunctionDef): + names.append(scope.name) + scope = parents.get(scope) + found.add(f"{rel}::{'.'.join(reversed(names))}") + return found + + +@pytest.fixture +def store(tmp_path: Path) -> ConfigStore: + """`prod` and `p0`..`p7` have active branch 456; `dev`, `a`, `b`, `z` have none.""" + fan_out = {f"p{i}": _project(456, "feature-x") for i in range(8)} + none = {alias: _project() for alias in ("dev", "a", "b", "z")} + return _store(tmp_path, prod=_project(456, "feature-x"), **fan_out, **none) + + +@pytest.fixture +def printed() -> Iterator[list[BranchTarget]]: + """Open a record the way the CLI root callback does; collect what it prints.""" + lines: list[BranchTarget] = [] + with record_targets(lines.append): + yield lines + + +class TestResolveBranch: + def test_explicit_branch_wins_over_the_active_branch(self, store, printed) -> None: + assert resolve_branch(store, "prod", 789) == 789 + assert recorded_targets()[0].branch_source == "explicit" + + def test_active_branch_applies_with_its_saved_name(self, store, printed) -> None: + assert resolve_branch(store, "prod", None) == 456 + target = recorded_targets()[0] + assert (target.branch_source, target.branch_name) == ("active_branch", "feature-x") + + def test_ignore_active_branch_uses_production_and_keeps_the_active_branch( + self, store, printed + ) -> None: + assert resolve_branch(store, "prod", None, ignore_active_branch=True) is None + target = recorded_targets()[0] + assert (target.branch_source, target.active_id) == ("production", 456) + + def test_branch_zero_is_production(self, store, printed) -> None: + # The API clients always sent 0 to the production endpoint. + assert resolve_branch(store, "prod", 0) == 0 + # A service that resolves the command's result again keeps production. + assert resolve_branch(store, "prod", 0) == 0 + assert [t.branch_source for t in recorded_targets()] == ["production"] + # It is no explicit target for a command that needs a branch. + assert resolve_branch(store, "dev", 0, required=True) is None + assert len(recorded_targets()) == 1 + + def test_manifest_branch_comes_after_the_active_branch(self, store, printed) -> None: + assert resolve_branch(store, "prod", None, manifest_branch_id=388) == 456 + assert resolve_branch(store, "dev", None, manifest_branch_id=388) == 388 + assert [t.branch_source for t in recorded_targets()] == ["manifest", "active_branch"] + + def test_a_required_branch_that_is_missing_records_nothing(self, store, printed) -> None: + # The command refuses to run; it has no target. + assert resolve_branch(store, "dev", None, required=True) is None + assert recorded_targets() == [] + + def test_unknown_project_records_nothing(self, store, printed) -> None: + # The command fails on the alias; it has no target. + assert resolve_branch(store, "gone", None) is None + assert recorded_targets() == [] + + def test_without_a_command_record_it_only_returns_the_id(self, store) -> None: + # `kbagent serve` and the SDK open no record. + assert resolve_branch(store, "prod", None) == 456 + assert recorded_targets() == [] + + +class TestRecord: + def test_a_service_resolving_the_id_again_adds_nothing(self, store, printed) -> None: + branch = resolve_branch(store, "prod", None) # command layer + resolve_branch(store, "prod", branch) # the service gets it as --branch + assert [t.branch_source for t in recorded_targets()] == ["active_branch"] + assert len(printed) == 1 + + def test_the_looked_up_production_id_replaces_the_unknown_one(self, store, printed) -> None: + resolve_branch(store, "dev", None) + record_branch(store, "dev", 3001, "production") + assert [(t.branch_id, t.branch_source) for t in recorded_targets()] == [ + (3001, "production") + ] + assert len(printed) == 1 + + def test_a_second_branch_of_one_project_is_a_second_target(self, store, printed) -> None: + resolve_branch(store, "prod", None, ignore_active_branch=True) + resolve_branch(store, "prod", None) + assert [t.branch_id for t in recorded_targets()] == [None, 456] + + def test_sources_come_first_then_aliases_in_order(self, store, printed) -> None: + resolve_branch(store, "b", None) + resolve_branch(store, "a", None) + resolve_branch(store, "z", None, role="source") + assert [(t.role, t.project_alias) for t in recorded_targets()] == [ + ("source", "z"), + ("target", "a"), + ("target", "b"), + ] + + def test_fan_out_workers_add_to_the_command_record(self, store, printed) -> None: + aliases = [f"p{i}" for i in range(8)] + with ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(lambda alias: resolve_branch(store, alias, None), aliases)) + assert [t.project_alias for t in recorded_targets()] == aliases + + def test_the_record_closes_with_the_command(self, store) -> None: + with record_targets(lambda target: None): + resolve_branch(store, "dev", None) + assert recorded_targets() == [] + + +class TestResolvers: + def test_workspace_resolver_records_the_active_branch(self, tmp_path, printed) -> None: + project = _project(456) + service = WorkspaceService(config_store=_store(tmp_path, prod=project)) + assert service._resolve_branch_id("prod", project) == 456 + assert recorded_targets()[0].branch_source == "active_branch" + + def test_workspace_resolver_records_the_default_branch_id_as_production( + self, tmp_path, printed + ) -> None: + project = _project() + client = MagicMock() + client.list_dev_branches.return_value = [{"id": 3001, "isDefault": True}] + service = WorkspaceService( + config_store=_store(tmp_path, prod=project), client_factory=lambda url, token: client + ) + assert service._resolve_branch_id("prod", project) == 3001 + assert [(t.branch_id, t.branch_source) for t in recorded_targets()] == [ + (3001, "production") + ] + + def test_sync_manifest_fallback_is_one_manifest_record(self, store, tmp_path, printed) -> None: + # `sync clone --branch 388` writes 388 as the first manifest branch. + manifest = Manifest.model_construct( + project=ManifestProject(id=1, apiHost="connection.keboola.com"), + branches=[ManifestBranch(id=388, path="main", metadata={})], + ) + service = SyncService(config_store=store) + assert service._resolve_branch_id("dev", manifest, tmp_path) == 388 + assert [(t.branch_id, t.branch_source) for t in printed] == [(388, "manifest")] + + +class TestFanOutServices: + """These list commands leave the branch to the service, per project.""" + + def _client(self) -> MagicMock: + client = MagicMock() + client.list_component_configs.return_value = [] + client.list_components_with_configs.return_value = [] + return client + + def test_flow_list(self, store, printed) -> None: + client = self._client() + FlowService(config_store=store, client_factory=lambda url, token: client).list_flows( + aliases=["prod", "dev"] + ) + branches = {c.kwargs["branch_id"] for c in client.list_component_configs.call_args_list} + assert branches == {456, None} + assert [(t.project_alias, t.branch_source) for t in recorded_targets()] == [ + ("dev", "production"), + ("prod", "active_branch"), + ] + + @pytest.mark.parametrize("method", ["list_schedules", "find_schedules"]) + def test_schedule_list_and_find(self, store, printed, method) -> None: + client = self._client() + service = ScheduleService(config_store=store, client_factory=lambda url, token: client) + getattr(service, method)(aliases=["prod"]) + assert client.list_components_with_configs.call_args.kwargs["branch_id"] == 456 + assert recorded_targets()[0].branch_source == "active_branch" + + +class TestOutput: + @pytest.mark.parametrize( + ("changes", "line"), + [ + ({}, "Target: project 'prod', production"), + ({"branch_id": 3001}, "Target: project 'prod', production branch 3001"), + ( + { + "branch_id": 456, + "branch_name": "feature-x", + "branch_source": "active_branch", + "active_id": 456, + "active_name": "feature-x", + }, + "Target: project 'prod', branch 456 'feature-x' (from 'kbagent branch use')", + ), + ( + {"branch_id": 789, "branch_source": "explicit", "active_id": 456}, + "Target: project 'prod', branch 789 (from the command line)", + ), + ( + {"branch_id": 388, "branch_source": "manifest"}, + "Target: project 'prod', branch 388 (from .keboola/manifest.json)", + ), + ( + {"active_id": 456, "active_name": "feature-x"}, + ( + "Target: project 'prod', production " + "(active branch 456 'feature-x' not used; pass --branch 456 to use it)" + ), + ), + ( + {"branch_id": 388, "branch_source": "git_mapping"}, + "Target: project 'prod', branch 388 (from .keboola/branch-mapping.json)", + ), + ({"role": "source"}, "Source: project 'prod', production"), + ( + {"role": "source", "branch_id": 999, "branch_source": "merge_request"}, + "Source: project 'prod', branch 999 (from the merge request)", + ), + ], + ) + def test_target_line(self, changes, line) -> None: + assert format_target_line(_target(**changes)) == line + + def test_fixed_production_gives_no_active_branch_hint(self, store, printed) -> None: + record_branch(store, "prod", None, "production", fixed=True) + assert format_target_line(recorded_targets()[0]) == "Target: project 'prod', production" + + def test_success_envelope_without_a_target_is_unchanged(self) -> None: + assert json.loads(success_json({"value": None})) == { + "status": "ok", + "data": {"value": None}, + } + + def test_success_envelope_lists_the_targets(self, store, printed) -> None: + resolve_branch(store, "prod", None) + assert json.loads(success_json({}))["targets"] == [ + { + "role": "target", + "project_alias": "prod", + "branch_id": 456, + "branch_name": "feature-x", + "branch_source": "active_branch", + "active_branch": {"branch_id": 456, "branch_name": "feature-x"}, + } + ] + + def test_error_envelope_lists_the_targets(self, store, printed, capsys) -> None: + resolve_branch(store, "prod", None) + OutputFormatter(json_mode=True).error("boom", error_code="API_ERROR") + body = json.loads(capsys.readouterr().out) + assert body["targets"][0]["branch_id"] == 456 + assert body["error"]["message"] == "boom" + + +class TestCommands: + """The CLI root callback opens the record; the formatter reports it.""" + + def _workspace_detail(self, tmp_path: Path, *args: str): + store = _store(tmp_path, prod=_project(456, "feature-x")) + client = MagicMock() + client.get_workspace.return_value = {"id": 7, "connection": {}} + service = WorkspaceService(config_store=store, client_factory=lambda url, token: client) + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.WorkspaceService", return_value=service), + ): + result = runner.invoke( + app, [*args, "workspace", "detail", "--project", "prod", "--workspace-id", "7"] + ) + assert result.exit_code == 0, result.output + assert client.get_workspace.call_args.kwargs["branch_id"] == 456 + return result + + def test_json_reports_the_active_branch_a_service_applied(self, tmp_path) -> None: + # Issue #766: the workspace service applied the active branch silently. + body = json.loads(self._workspace_detail(tmp_path, "--json").output) + assert body["targets"][0]["branch_source"] == "active_branch" + + def test_human_output_prints_the_target_line(self, tmp_path) -> None: + result = self._workspace_detail(tmp_path) + expected = "Target: project 'prod', branch 456 'feature-x' (from 'kbagent branch use')" + assert expected in result.output + + def test_dry_run_reports_the_branch_the_real_run_uses(self, tmp_path) -> None: + store = _store(tmp_path, prod=_project(456)) + with patch("keboola_agent_cli.cli.ConfigStore", return_value=store): + result = runner.invoke( + app, + ["--json", "flow", "delete", "--project", "prod", "--flow-id", "9", "--dry-run"], + ) + body = json.loads(result.output) + assert body["data"]["would_delete"]["branch_id"] == 456 + assert body["targets"][0]["branch_id"] == 456 + + def test_the_target_line_comes_before_the_confirmation(self, tmp_path) -> None: + store = _store(tmp_path, prod=_project(456)) + with patch("keboola_agent_cli.cli.ConfigStore", return_value=store): + result = runner.invoke( + app, ["flow", "delete", "--project", "prod", "--flow-id", "9"], input="n\n" + ) + assert result.output.index("Target: project 'prod', branch 456") < result.output.index( + "Delete flow" + ) + + def test_merge_names_its_source_branch_and_production_before_the_prompt(self, tmp_path) -> None: + store = _store(tmp_path, prod=_project(456, "feature-x")) + service = MagicMock() + service.find_merge_request_for_branch.return_value = {"id": 7, "title": "t"} + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.MergeRequestService", return_value=service), + ): + result = runner.invoke( + app, ["merge-request", "merge", "--project", "prod"], input="n\n" + ) + source = result.output.index("Source: project 'prod', branch 456 'feature-x'") + target = result.output.index("Target: project 'prod', production\n") + assert source < target < result.output.index("will be merged into production") + service.merge.assert_not_called() + + def test_a_command_without_a_branch_has_no_targets_key(self, tmp_path) -> None: + store = _store(tmp_path, prod=_project(456)) + with patch("keboola_agent_cli.cli.ConfigStore", return_value=store): + result = runner.invoke(app, ["--json", "project", "list"]) + assert result.exit_code == 0, result.output + assert "targets" not in json.loads(result.output) + + @pytest.mark.parametrize( + ("argv", "opened"), [(["serve", "--help"], False), (["config", "list", "--help"], True)] + ) + def test_serve_opens_no_record(self, argv, opened) -> None: + # uvicorn serves each request in a thread that inherits the caller's + # state, so a record opened for `serve` would collect every request. + with patch.object(cli, "record_targets", wraps=record_targets) as spy: + runner.invoke(app, argv) + assert spy.called is opened + + +class TestOnlyThisModuleAppliesTheActiveBranch: + def test_no_new_read_of_active_branch_id(self) -> None: + unexpected = _active_branch_reads(SRC) - READS_THAT_DO_NOT_CHOOSE_A_BRANCH + assert not unexpected, ( + f"{sorted(unexpected)} read ProjectConfig.active_branch_id. Call resolve_branch() " + "from effective_branch.py, which reports the branch. A read that only shows or " + "manages the active branch goes in READS_THAT_DO_NOT_CHOOSE_A_BRANCH." + ) + + def test_every_listed_function_still_reads_it(self) -> None: + assert _active_branch_reads(SRC) >= READS_THAT_DO_NOT_CHOOSE_A_BRANCH diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fb62dd88..3529e47b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -199,231 +199,6 @@ def test_validate_branch_requires_project_passes_when_neither_set(self) -> None: validate_branch_requires_project(formatter, branch=None, project=None) -class TestResolveBranch: - """Tests for resolve_branch.""" - - def test_resolve_branch_explicit_branch_wins(self, tmp_config_dir) -> None: - """Explicit --branch value is returned as-is, regardless of config.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=999, - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch(store, formatter, "prod", 123) - assert project == "prod" - assert branch_id == 123 - - def test_resolve_branch_uses_active_branch(self, tmp_config_dir) -> None: - """When no explicit --branch, active_branch_id from config is used.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=555, - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch(store, formatter, "prod", None) - assert project == "prod" - assert branch_id == 555 - # Should print info message in human mode - formatter.err_console.print.assert_called_once() - - def test_resolve_branch_no_branch_returns_none(self, tmp_config_dir) -> None: - """When no explicit --branch and no active branch, returns None.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch(store, formatter, "prod", None) - assert project == "prod" - assert branch_id is None - - def test_ignore_active_branch_returns_none_when_active_set(self, tmp_config_dir) -> None: - """With ignore_active_branch=True, implicit active_branch_id is skipped. - - Used by storage read commands so users with an active dev branch - still see production tables/buckets by default. - """ - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=15931, - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch( - store, formatter, "prod", None, ignore_active_branch=True - ) - assert project == "prod" - assert branch_id is None - # User must be told production is being used despite active dev branch. - formatter.err_console.print.assert_called_once() - msg = formatter.err_console.print.call_args.args[0] - assert "production" in msg.lower() - assert "15931" in msg - - def test_ignore_active_branch_does_not_override_explicit_branch(self, tmp_config_dir) -> None: - """Explicit --branch wins even when ignore_active_branch=True.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=15931, - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch(store, formatter, "prod", 99, ignore_active_branch=True) - assert project == "prod" - assert branch_id == 99 - - def test_ignore_active_branch_no_config_returns_none(self, tmp_config_dir) -> None: - """With ignore_active_branch=True and no active branch, still returns None.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch( - store, formatter, "prod", None, ignore_active_branch=True - ) - assert project == "prod" - assert branch_id is None - # No info message needed -- there was no active branch to ignore. - formatter.err_console.print.assert_not_called() - - def test_ignore_active_branch_json_mode_silent(self, tmp_config_dir) -> None: - """In --json mode, ignore_active_branch still works but prints nothing.""" - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=15931, - ), - ) - - formatter = MagicMock(json_mode=True) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch( - store, formatter, "prod", None, ignore_active_branch=True - ) - assert project == "prod" - assert branch_id is None - formatter.err_console.print.assert_not_called() - - def test_ignore_active_branch_single_project_inferred(self, tmp_config_dir) -> None: - """Without --project, if a single project has an active branch and - ignore_active_branch=True, the project is still returned but branch_id is None. - """ - from unittest.mock import MagicMock - - from keboola_agent_cli.commands._helpers import resolve_branch - from keboola_agent_cli.config_store import ConfigStore - from keboola_agent_cli.models import ProjectConfig - - store = ConfigStore(config_dir=tmp_config_dir) - store.add_project( - "prod", - ProjectConfig( - stack_url="https://connection.keboola.com", - token="tok-123", - active_branch_id=15931, - ), - ) - - formatter = MagicMock(json_mode=False) - formatter.err_console = MagicMock() - - project, branch_id = resolve_branch(store, formatter, None, None, ignore_active_branch=True) - assert project == "prod" - assert branch_id is None - formatter.err_console.print.assert_called_once() - - class TestResolveProjectAlias: """Tests for resolve_project_alias() (write-op precedence).""" diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index acc3f9c7..625ce59e 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -2718,12 +2718,13 @@ def test_resolve_branch_id_default_branch_without_mapping( (project_root / KEBOOLA_DIR_NAME / BRANCH_MAPPING_FILENAME).unlink() manifest = load_manifest(project_root) - project = store.get_project("prod") with patch( "keboola_agent_cli.sync.git_utils.get_current_branch", return_value="main", ): - resolved = SyncService._resolve_branch_id(project, manifest, project_root) + resolved = SyncService(config_store=store)._resolve_branch_id( + "prod", manifest, project_root + ) assert resolved is None def test_resolve_branch_id_dev_branch_without_mapping_still_errors( @@ -2739,7 +2740,6 @@ def test_resolve_branch_id_dev_branch_without_mapping_still_errors( (project_root / KEBOOLA_DIR_NAME / BRANCH_MAPPING_FILENAME).unlink() manifest = load_manifest(project_root) - project = store.get_project("prod") with ( patch( "keboola_agent_cli.sync.git_utils.get_current_branch", @@ -2747,7 +2747,7 @@ def test_resolve_branch_id_dev_branch_without_mapping_still_errors( ), pytest.raises(ConfigError, match="not linked"), ): - SyncService._resolve_branch_id(project, manifest, project_root) + SyncService(config_store=store)._resolve_branch_id("prod", manifest, project_root) # =================================================================== @@ -3818,15 +3818,16 @@ class TestBranchOverrideAndNameDriftFlag: """Cover the `--branch` override (push / pull / diff) and the `--no-name-drift-warnings` opt-out at the service boundary.""" - def test_resolve_branch_id_override_wins(self, tmp_path: Path) -> None: + def test_resolve_branch_id_override_wins(self, tmp_config_dir: Path, tmp_path: Path) -> None: from keboola_agent_cli.sync.manifest import ( ManifestBranch, ManifestNaming, ManifestProject, ) - project = MagicMock() - project.active_branch_id = 12345 + store = setup_single_project(tmp_config_dir) + store.set_project_branch("prod", 12345) + service = SyncService(config_store=store) manifest = Manifest.model_construct( project=ManifestProject(id=1, apiHost="connection.keboola.com"), naming=ManifestNaming(), @@ -3834,14 +3835,10 @@ def test_resolve_branch_id_override_wins(self, tmp_path: Path) -> None: ) # Without override -> falls back to active_branch_id (priority 2). - assert ( - SyncService._resolve_branch_id(project, manifest, tmp_path, branch_override=None) - == 12345 - ) + assert service._resolve_branch_id("prod", manifest, tmp_path, branch_override=None) == 12345 # Override wins (priority 0). assert ( - SyncService._resolve_branch_id(project, manifest, tmp_path, branch_override=388071) - == 388071 + service._resolve_branch_id("prod", manifest, tmp_path, branch_override=388071) == 388071 ) def test_push_branch_override_reaches_client( diff --git a/tests/test_workspace_cli.py b/tests/test_workspace_cli.py index 5581b332..4cc5cf0d 100644 --- a/tests/test_workspace_cli.py +++ b/tests/test_workspace_cli.py @@ -1659,8 +1659,8 @@ class TestWorkspaceListIssue304: - ``--branch`` flag parity with ``storage buckets`` / ``config list`` - ``--qs-compatible`` filter that pre-selects data-app-ready workspaces - ``--branch`` validation (rejects multi-project usage) - - ``Info: Using production branch for read ...`` banner when an alias is - pinned to a dev branch (read commands ignore the implicit branch) + - no "production branch for read" notice: the service applies the active + branch (#766) """ def test_workspace_list_branch_flag_propagates_to_service(self, tmp_path: Path) -> None: @@ -1772,14 +1772,12 @@ def test_workspace_list_qs_compatible_filter_propagates(self, tmp_path: Path) -> kwargs = mock_ws.list_workspaces.call_args.kwargs assert kwargs["qs_compatible_only"] is True - def test_workspace_list_ignores_active_branch_with_banner(self, tmp_path: Path) -> None: - """`workspace list` with an alias pinned to a dev branch behaves like `storage buckets`. + def test_workspace_list_leaves_the_active_branch_to_the_service(self, tmp_path: Path) -> None: + """`workspace list` passes ``--branch`` unchanged; the service applies the active branch. - The implicit ``active_branch_id`` is ignored (read endpoint uses - production), and an ``Info: ...`` banner explains the override. - Before issue #304 the command silently scoped to the pinned branch - without notifying the caller, returning a different workspace set - than ``workspace list`` against the same alias one shell ago. + Until #766 the command printed "Using production branch for read" while + ``WorkspaceService`` still listed the active branch: both halves came in + with #304 (v0.42.0). The service now reports the branch it used. """ config_dir = tmp_path / "config" config_dir.mkdir() @@ -1818,10 +1816,7 @@ def test_workspace_list_ignores_active_branch_with_banner(self, tmp_path: Path) ) assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" - # Banner goes to stderr in real use; CliRunner mixes streams. - assert "production branch for read" in result.output - assert "99999" in result.output - # branch_id must NOT be propagated -- read commands target production + assert "production branch for read" not in result.output kwargs = mock_ws.list_workspaces.call_args.kwargs assert kwargs["branch_id"] is None