Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "kbagent",
"version": "0.30.3",
"version": "0.30.4",
"source": "./plugins/kbagent",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"category": "development"
Expand Down
2 changes: 1 addition & 1 deletion plugins/kbagent/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kbagent",
"version": "0.30.3",
"version": "0.30.4",
"description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces",
"author": {
"name": "Keboola",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keboola-agent-cli"
version = "0.30.3"
version = "0.30.4"
description = "AI-friendly CLI for managing Keboola projects"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
9 changes: 9 additions & 0 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@

# Ordered newest-first. Each value is a list of brief one-line descriptions.
CHANGELOG: dict[str, list[str]] = {
"0.30.4": [
"Fix: `kbagent sync pull` against a linked dev branch now writes files under the linked branch's directory (`branch-<id>/...` or its sanitized name), not under `main/`. Pre-fix, `branch_link` persisted Keboola branch IDs as **strings** in `.keboola/branch-mapping.json` (`kbc_branch_id = str(branch_info['id'])` at five call-sites in `services/sync_service.py`), but every comparison against the manifest read those IDs as the **int** they're typed as in `ManifestBranch.id: int` and on the Storage API. Cross-type `int == str` is always False in Python, so `_find_branch_path` fell back to the default branch (`manifest.branches[0].path == 'main'`) and `_ensure_branch_registered` re-registered the 'unknown' branch on every pull, appending a duplicate `branches[]` entry with a mangled `branch-<id>-<id>` path until the manifest was hand-cleaned. The same comparison failed in `_ensure_branch_registered`'s `b.get('id') == branch_id` API-name lookup, so the branch's human-readable name from the API was never used and the path always fell through to the numeric `branch-<id>` fallback (the 'side observation' from issue #267). Fix is end-to-end `int`: `branch_link` writes `int(branch_info['id'])`, `BranchMappingEntry.keboola_id: int | None` (was `str | None`), and `from_dict` silently coerces legacy string IDs on load so existing user workspaces upgrade without manual editing. Bug A from issue #267, reported externally on v0.27.0 and reproduced on v0.30.3.",
"Fix: `kbagent sync pull` no longer re-writes every previously-tracked config on every invocation in git-branching mode. The `branch_switched` guard at `services/sync_service.py:489-491` compared `existing_branch_ids[lookup_key]` (int from manifest) against the polluted str return of `_resolve_branch_id`; cross-type `!=` was always True, so the idempotency check was completely defeated and `files_written` ticked up on every pull even when nothing changed. The Bug A end-to-end int fix automatically restores correct behaviour here -- this is Bug C from issue #267, fixed transitively. Regression test pins `pull-pull-pull` against an unchanged remote and asserts manifest stability.",
"Fix: `kbagent sync diff` and `kbagent sync push --dry-run` now surface scaffolded local config directories on git-branching workspaces with empty `manifest.configurations[]`. Pre-fix, `_find_untracked_configs` (`services/sync_service.py:2612`) built its scope set exclusively from already-tracked configs (`active_branch_ids.add(cfg.branch_id)`); when configurations were empty, the scope set was empty, the walker `continue`d past every branch and returned `[]`, silently dropping the documented `ADDED -> push creates it` flow. Fix widens the scope to `tracked U {default} U {resolved}`: branches with tracked configs (today's protection against orphaned dirs), the default branch (push-to-main scaffold is legitimate), and the branch the caller resolved for this op (the linked feature branch the user is actively working on). `diff()` now passes `branch_id` into the walker so the resolved branch is in scope. Phantom-add protection for unrelated dev-branch dirs is preserved. Bug B from issue #267.",
"Fix: `kbagent branch delete` and `kbagent branch merge` now clean up matching entries from `.keboola/branch-mapping.json` in the nearest enclosing sync workspace. Pre-fix, deleting a Keboola dev branch left the local mapping pointing at a now-non-existent branch, and every subsequent `sync pull/push` from the linked git branch hit a 404 from the Storage API -- a non-recoverable state until the user manually ran `sync branch-unlink`. New helper `cleanup_branch_id_from_mapping()` in `sync/branch_mapping.py` walks upward from cwd to find the workspace, removes every entry whose `keboola_id` equals the deleted/merged branch ID, and is wired into `BranchService.delete_branch` and `BranchService.get_merge_url`. Both surface a `mapping_cleanup` field plus an additive message line listing the unlinked git branches. Bug D from issue #267.",
"Fix: `_resolve_branch_id` no longer raises `ConfigError` for the default git branch when `.keboola/branch-mapping.json` is missing. Pre-fix, an accidentally deleted (or `.gitignore`d) mapping file blocked even `sync pull` on the default branch with `Git branch 'main' is not linked to a Keboola branch` and there was no recovery path because `branch_link` explicitly forbids linking the default branch (`services/sync_service.py:1918`). Fix: when the mapping is missing or has no entry for the current branch AND the current branch is `manifest.git_branching.default_branch`, return `None` (production). Non-default branches with no mapping still raise `ConfigError` (intentionally narrow recovery: production is always reachable, dev branches still require explicit linking). Bug E from issue #267.",
'Tests: 7 new regression tests under `TestIssue267Regressions` (covering branch_link int persistence, repeated-pull manifest stability, pull routing to feature dir, walker untracked-detection on empty configurations, walker phantom-add protection still holds, default-branch recovery on missing mapping, dev-branch error path on missing mapping) plus 6 new tests in `tests/test_sync_branch_mapping.py` covering legacy str-id migration, `find_sync_workspace` upward search, and `cleanup_branch_id_from_mapping` cases (matching id removal, unmatched no-op, no-workspace no-op). Existing tests in `TestBranchLink`, `TestBranchUnlink`, `TestBranchStatus`, and `test_sync_branch_mapping.py` updated from `assert keboola_id == "99999"` (the assertion that locked Bug A in) to `assert keboola_id == 99999`.',
"Refactor: `_find_untracked_configs(project_root, manifest)` is now `_find_untracked_configs(project_root, manifest, resolved_branch_id=None)`. The scope-widening param defaults to `None` (keeps `status()` callsite at `services/sync_service.py:791` semantics-compatible). `diff()` callsite at line 905 now passes the resolved branch ID so the walker covers the linked feature branch dir. No behaviour change for non-git-branching workspaces.",
],
"0.30.3": [
"Fix: `_perform_mcp_update` for `uvx`-cache installs now promotes to `uv tool install --upgrade keboola-mcp-server` instead of running the broken `uvx --refresh --from <pkg> <bin> --version` chain. The trailing `--version` arg was rejected by the upstream MCP binary (no such flag), so the upgrade subprocess always exited non-zero and the user-facing banner reported failure even when the cache refresh itself worked. Promoting to `uv tool install --upgrade` does the equivalent refresh AND moves the binary to PATH so subsequent runs use the faster `uv_tool` detection path. Bug B fix from issue #263.",
"Fix: `_maybe_update_mcp` now skips the upgrade attempt when the local-version probe returns `None`. Pre-fix, probe-`None` left `up_to_date == None` (not `True`), the short-circuit was bypassed, and the function fell through to a broken upgrade subprocess every TTL window. The user saw an `Updating ... vunknown -> v1.59.1` banner once per kbagent invocation. Post-fix, probe-`None` opts out of the upgrade for this TTL window; the next fresh-cache pass will retry detection. Cache TTL still ticks. Bug C fix from issue #263.",
Expand Down
39 changes: 33 additions & 6 deletions src/keboola_agent_cli/services/branch_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ def reset_branch(self, alias: str) -> dict[str, Any]:
def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]:
"""Delete a development branch via API. Auto-resets if it was active.

Also cleans up any matching entry in the nearest enclosing sync
workspace's ``branch-mapping.json`` (issue #267, Bug D), so the
user is not left with a stale mapping pointing at a deleted
branch.

Args:
alias: Project alias.
branch_id: Branch ID to delete.
Expand All @@ -266,6 +271,8 @@ def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]:
ConfigError: If the project alias is not found.
KeboolaApiError: If the API call fails.
"""
from ..sync.branch_mapping import cleanup_branch_id_from_mapping

projects = self.resolve_projects([alias])
project = projects[alias]

Expand All @@ -280,15 +287,24 @@ def delete_branch(self, alias: str, branch_id: int) -> dict[str, Any]:
if was_active:
self._config_store.set_project_branch(alias, None)

return {
cleanup = cleanup_branch_id_from_mapping(branch_id)

message_parts = [f"Branch ID {branch_id} deleted from project '{alias}'."]
if was_active:
message_parts.append("Active branch reset to main.")
if cleanup:
unlinked = ", ".join(cleanup["git_branches_unlinked"])
message_parts.append(f"Unlinked git branch(es): {unlinked}.")

result: dict[str, Any] = {
"project_alias": alias,
"branch_id": branch_id,
"was_active": was_active,
"message": (
f"Branch ID {branch_id} deleted from project '{alias}'."
+ (" Active branch reset to main." if was_active else "")
),
"message": " ".join(message_parts),
}
if cleanup:
result["mapping_cleanup"] = cleanup
return result

def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, Any]:
"""Generate KBC UI merge URL for a development branch.
Expand Down Expand Up @@ -332,7 +348,13 @@ def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, A
# Reset active branch to main after generating merge URL
self._config_store.set_project_branch(alias, None)

return {
# Best-effort cleanup of any matching sync-workspace mapping so the
# user is not left referencing a soon-to-be-merged branch (Bug D).
from ..sync.branch_mapping import cleanup_branch_id_from_mapping

cleanup = cleanup_branch_id_from_mapping(effective_branch_id)

result: dict[str, Any] = {
"project_alias": alias,
"branch_id": effective_branch_id,
"url": merge_url,
Expand All @@ -341,6 +363,11 @@ def get_merge_url(self, alias: str, branch_id: int | None = None) -> dict[str, A
f"in project '{alias}'. Active branch has been reset to main."
),
}
if cleanup:
unlinked = ", ".join(cleanup["git_branches_unlinked"])
result["message"] += f" Unlinked git branch(es): {unlinked}."
result["mapping_cleanup"] = cleanup
return result

# ── Branch metadata ────────────────────────────────────────────────

Expand Down
81 changes: 58 additions & 23 deletions src/keboola_agent_cli/services/sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ def diff(
)

# Also add untracked local configs (new files)
for added_cfg in self._find_untracked_configs(project_root, manifest):
for added_cfg in self._find_untracked_configs(project_root, manifest, branch_id):
branch_path = self._find_branch_path(manifest, branch_id)
config_dir = project_root / branch_path / added_cfg["path"]
local_data = self._read_config_file(config_dir)
Expand Down Expand Up @@ -1954,7 +1954,7 @@ def branch_link(
)
if branch_info is None:
raise ConfigError(f"Keboola branch {branch_id} not found.")
kbc_branch_id = str(branch_info["id"])
kbc_branch_id = int(branch_info["id"])
kbc_branch_name = branch_info.get("name", "")
elif branch_name:
# Search by name or create
Expand All @@ -1964,11 +1964,11 @@ def branch_link(
None,
)
if branch_info:
kbc_branch_id = str(branch_info["id"])
kbc_branch_id = int(branch_info["id"])
kbc_branch_name = branch_info.get("name", "")
else:
result = client.create_dev_branch(name=branch_name)
kbc_branch_id = str(result["id"])
kbc_branch_id = int(result["id"])
kbc_branch_name = branch_name
else:
# Default: use git branch name to search/create
Expand All @@ -1978,11 +1978,11 @@ def branch_link(
None,
)
if branch_info:
kbc_branch_id = str(branch_info["id"])
kbc_branch_id = int(branch_info["id"])
kbc_branch_name = branch_info.get("name", "")
else:
result = client.create_dev_branch(name=git_branch)
kbc_branch_id = str(result["id"])
kbc_branch_id = int(result["id"])
kbc_branch_name = git_branch

mapping.set(git_branch, kbc_branch_id, kbc_branch_name)
Expand Down Expand Up @@ -2095,30 +2095,47 @@ def _resolve_branch_id(
3. First branch in manifest (production fallback)

Raises ``ConfigError`` if git-branching is enabled but the current
branch is not linked (prevents accidental production writes).
branch is not the default and is not linked.

The default git branch always resolves to ``None`` (production), even
if ``branch-mapping.json`` is missing or has no entry for it. This
guarantees there is always a recovery path when the mapping file is
lost (issue #267, Bug E).
"""
from ..sync.branch_mapping import load_branch_mapping
from ..sync.git_utils import get_current_branch

if manifest.git_branching.enabled:
git_branch = get_current_branch(project_root)
if git_branch:
default_branch = manifest.git_branching.default_branch
is_default = git_branch == default_branch
try:
mapping = load_branch_mapping(project_root)
entry = mapping.get(git_branch)
if entry is not None:
# entry.keboola_id is None for production (default branch)
return entry.keboola_id
except FileNotFoundError:
pass
# Branch not linked -- block operation
# Mapping missing -- auto-recover for the default branch
# so the user is never locked out of production.
if is_default:
return None
raise ConfigError(
f"Git branch '{git_branch}' is not linked to a Keboola "
f"branch (branch-mapping.json missing). "
f"Run 'kbagent sync branch-link --project ALIAS' first."
) from None
entry = mapping.get(git_branch)
if entry is not None:
# entry.keboola_id is None for production (default branch)
return entry.keboola_id
# No entry for current branch -- default branch is always production
if is_default:
return None
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
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
Expand Down Expand Up @@ -2610,26 +2627,44 @@ def _find_branch_path(self, manifest: Manifest, branch_id: int | None) -> str:
return manifest.branches[0].path if manifest.branches else "main"

def _find_untracked_configs(
self, project_root: Path, manifest: Manifest
self,
project_root: Path,
manifest: Manifest,
resolved_branch_id: int | None = None,
) -> list[dict[str, str]]:
"""Scan for _config.yml files that are not tracked in the manifest.

Only scans branch directories that have at least one tracked
configuration. This prevents phantom "added" configs from
inactive/old branch directories left over from a previous pull.
Scans branch directories that the user is actively working with:
branches that already have tracked configs, the default branch
(production), and the branch the caller resolved for the current
operation (when provided). This supports the documented
"scaffold locally then push" workflow on git-branching workspaces
with empty ``manifest.configurations`` (issue #267, Bug B).

Branches outside this scope are skipped to avoid phantom "added"
configs from orphaned dev-branch directories left over from
previous work.
"""
tracked_paths: set[str] = set()
active_branch_ids: set[int] = set()
in_scope_branch_ids: set[int] = set()
for cfg in manifest.configurations:
branch_path = self._find_branch_path(manifest, cfg.branch_id)
tracked_paths.add(str(project_root / branch_path / cfg.path))
active_branch_ids.add(cfg.branch_id)
in_scope_branch_ids.add(cfg.branch_id)

# Default branch is always in scope -- pushing a brand-new config
# against production with empty configurations[] is a legitimate flow.
if manifest.branches:
in_scope_branch_ids.add(manifest.branches[0].id)

# The branch the caller resolved is in scope (linked feature branch
# the user explicitly switched to via git checkout + branch-link).
if resolved_branch_id is not None:
in_scope_branch_ids.add(resolved_branch_id)

added: list[dict[str, str]] = []
for branch in manifest.branches:
# Only scan branches that have tracked configs — skip inactive
# branch directories to avoid phantom "added" configs.
if branch.id not in active_branch_ids:
if branch.id not in in_scope_branch_ids:
continue
branch_dir = project_root / branch.path
if not branch_dir.exists():
Expand Down
Loading
Loading