diff --git a/docs/error-codes.md b/docs/error-codes.md index e107253c4..3238d55d4 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -55,6 +55,7 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | `CONFIG_ERROR` | kbagent config problem (e.g. unknown project alias) | | `NOT_INITIALIZED` | `.keboola/manifest.json` not found; run `sync init` first | | `INIT_ERROR` | Error during `sync init` auto-init path | +| `FEATURE_NOT_ENABLED` | A client-side pre-flight found the project lacks a required feature flag (e.g. `branches-merge-requests` for `merge-request` writes); the server would answer an opaque 403/404 | ### Jobs @@ -186,3 +187,10 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | Code | Description | |---|---| | `PAYG_NOT_AVAILABLE` | The project does not have the `pay-as-you-go` feature, so it has no credit balance; the billing host may not even resolve on this stack | + +### Merge requests + +| Code | Description | +|---|---| +| `MR_NOT_READY_TO_MERGE` | Merge answered 409 with `storage.mergeRequests.notReadyToMerge`: a project merge lock is held, the MR is in a state that cannot merge, or another MR in the project is already processing. Transient -- retryable | +| `MR_MERGE_CONFLICT` | Merge answered 409 with `storage.mergeRequests.validation` (or, on older stacks, no code): configurations changed on both branches; `details.api_error_params.errors` lists them. Not retryable -- resolve the conflicts and merge again | diff --git a/src/keboola_agent_cli/client/merge_requests.py b/src/keboola_agent_cli/client/merge_requests.py index 4cf0fb025..dad159c34 100644 --- a/src/keboola_agent_cli/client/merge_requests.py +++ b/src/keboola_agent_cli/client/merge_requests.py @@ -129,7 +129,7 @@ class MergeRequests: branch-prefixed; all bodies are JSON (module docstring). Returns are raw parsed JSON, as everywhere in ``client/``. - The pre-flight feature check (``has_feature(FEATURE_BRANCHES_MERGE_REQUESTS)``) + The pre-flight feature check (``has_feature(BRANCHES_MERGE_REQUESTS_FEATURE)``) is deliberately NOT done here -- a missing feature surfaces as a 403 byte-for-byte identical to a role denial, so only a Layer 2 pre-flight can word the error. @@ -313,7 +313,9 @@ def merge(self, merge_request_id: int) -> dict[str, Any]: The merge 409 has four causes in two response shapes (three "not ready" cases carry ``storage.mergeRequests.notReadyToMerge``, a - conflict does not); mapping them is Layer 2's concern. + conflict carries ``storage.mergeRequests.validation`` plus the + conflicting configurations in ``params.errors``); mapping them is + Layer 2's concern. """ response = self._requester.request("PUT", f"{_BASE}/{merge_request_id}/merge") return self._requester.wait_for_storage_job(response.json(), max_wait=MERGE_JOB_MAX_WAIT) diff --git a/src/keboola_agent_cli/client/tokens.py b/src/keboola_agent_cli/client/tokens.py index 9c3548575..6128ed4f3 100644 --- a/src/keboola_agent_cli/client/tokens.py +++ b/src/keboola_agent_cli/client/tokens.py @@ -41,6 +41,15 @@ def verify_token(self) -> TokenVerifyResponse: org_id = int(org_id_raw) if org_id_raw is not None else None except (TypeError, ValueError): org_id = None + # Top-level `admin` block: present for admin tokens only (a scoped + # token has no admin identity behind it). Its id is the caller's + # user id -- the anchor for viewer-relative MR derivations. + admin = data.get("admin") or {} + admin_id_raw = admin.get("id") + try: + admin_id = int(admin_id_raw) if admin_id_raw is not None else None + except (TypeError, ValueError): + admin_id = None response = TokenVerifyResponse( token_id=str(data.get("id", "")), token_description=data.get("description", ""), @@ -55,6 +64,8 @@ def verify_token(self) -> TokenVerifyResponse: # the id (e.g. "#73") as a fallback until `org setup` fills # in the human-readable name. org_name=None, + admin_id=admin_id, + admin_name=admin.get("name"), ) # Refresh the features cache on every successful verify so explicit # callers stay consistent with the cached view used by has_feature(). diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 9851c8b10..ef40d1634 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -110,6 +110,17 @@ def _resolve_app_name() -> str: "sign in", ) +# Ceiling for the server-supplied `params` context copied into +# KeboolaApiError.details["api_error_params"] (serialized JSON length). +# The message above is capped, so this structured sibling must be too -- +# a merge 409's params.errors carries one entry per conflicting config and +# an agent-consumed --json envelope must not grow unbounded with it. When +# over the cap, top-level lists are truncated to +# MAX_API_ERROR_PARAMS_LIST_ITEMS entries; if still over, params is dropped +# (details carries api_error_params_truncated: true either way). +MAX_API_ERROR_PARAMS_LENGTH: int = 8192 +MAX_API_ERROR_PARAMS_LIST_ITEMS: int = 20 + # --- Developer Portal MFA --- # Challenge type sent on the second `/auth/login` step (after the first call # returns a `session` token). The apiary spec documents `SOFTWARE_TOKEN_MFA` @@ -516,12 +527,23 @@ def _resolve_app_name() -> str: # --- Merge Requests (Branches 2.0) --- # Feature flag gating the non-SOX merge-request flow. Layer 3 # (client/merge_requests.py) does no feature check itself -- a missing -# feature is a 403 identical to a role denial -- so the Part 2 service layer -# must call has_feature() with this constant before writes and word the error. It -# also doubles as the SOX fence: server-side, `protected-default-branch` -# passes the same gate, so checking for this flag specifically keeps SOX -# projects out of a flow whose approvals semantics kbagent does not cover. -FEATURE_BRANCHES_MERGE_REQUESTS: str = "branches-merge-requests" +# feature is a 403 identical to a role denial -- so the service layer calls +# has_feature() with this constant before writes and words the error. It +# also doubles as the SOX fence: server-side, the six MR writes accept +# `protected-default-branch` OR `branches-merge-requests` (only /rebase +# requires this flag specifically), so checking for this flag keeps SOX +# projects out of a flow whose approvals semantics kbagent does not cover -- +# but the fence holds ONLY as long as a SOX project never also carries +# `branches-merge-requests`. The pre-flight is thus deliberately stricter +# than the server for a project with only `protected-default-branch`. +BRANCHES_MERGE_REQUESTS_FEATURE: str = "branches-merge-requests" + +# The SOX flavour of protected branches. kbagent does NOT support its +# approvals flow; the constant exists so the merge-request pre-flight can +# tell "this is a SOX project (unsupported)" from "merge requests are simply +# not enabled" when wording its refusal -- the features cache is already +# loaded at that point, so the distinction is free. +PROTECTED_DEFAULT_BRANCH_FEATURE: str = "protected-default-branch" # --- Global Search --- # Feature flag that gates the Storage API ``GET /v2/storage/global-search`` diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 064d8c4e8..e9619dd14 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -52,6 +52,11 @@ class ErrorCode(StrEnum): CONFIG_ERROR = "CONFIG_ERROR" NOT_INITIALIZED = "NOT_INITIALIZED" INIT_ERROR = "INIT_ERROR" + # A client-side pre-flight found the project lacks a required feature + # flag (raised via FeatureNotEnabledError). The value matches the string + # SearchService already emits in its per-project error envelopes, so the + # two surfaces agree. + FEATURE_NOT_ENABLED = "FEATURE_NOT_ENABLED" # Jobs QUEUE_JOB_FAILED = "QUEUE_JOB_FAILED" @@ -148,6 +153,15 @@ class ErrorCode(StrEnum): # Billing / Pay-As-You-Go (since #594) PAYG_NOT_AVAILABLE = "PAYG_NOT_AVAILABLE" + # Merge requests (DMD-1899). The merge 409 has four causes in two wire + # shapes; these split exactly where the backend does (wire-truth notes on + # branch ms/merge-requests-rfcs): storage.mergeRequests.notReadyToMerge + # vs storage.mergeRequests.validation, both in the body's top-level + # `code`. Mapped in MergeRequestService.merge() -- only the service + # knows the 409 came from the merge endpoint. + MR_NOT_READY_TO_MERGE = "MR_NOT_READY_TO_MERGE" + MR_MERGE_CONFLICT = "MR_MERGE_CONFLICT" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -208,6 +222,23 @@ def __init__(self, message: str) -> None: self.message = message +class FeatureNotEnabledError(ConfigError): + """Raised by a client-side pre-flight when the project lacks a feature flag. + + A missing feature often surfaces server-side as an opaque 403 (or 404) + indistinguishable from a role denial -- only a pre-flight can word the + real error. Carries ``error_code`` so a ``--json`` consumer can tell + "feature not enabled" from every other :class:`ConfigError` shape + (precedent: ``PAYG_NOT_AVAILABLE`` -- a missing project feature is a + configuration problem, and ``SessionAuthUnsupportedError`` for the + ConfigError-with-a-code pattern). + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.error_code = ErrorCode.FEATURE_NOT_ENABLED + + class SyncConflictError(Exception): """Raised when ``sync pull --force`` would overwrite locally-modified configs whose remote **also** changed since the last pull -- a true 3-way @@ -338,9 +369,12 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: # it inherits the right category instead of silently taking the "api" # default -- a missing project feature is a configuration problem. ErrorCode.PAYG_NOT_AVAILABLE: "configuration", + ErrorCode.FEATURE_NOT_ENABLED: "configuration", # A refused-by-us safety guard, not an upstream fault: nothing was sent to # the API, and the caller fixes it by re-issuing the request with --force. ErrorCode.WORKSPACE_LOAD_COPY_TOO_LARGE: "validation", + ErrorCode.MR_NOT_READY_TO_MERGE: "conflict", + ErrorCode.MR_MERGE_CONFLICT: "conflict", } diff --git a/src/keboola_agent_cli/http_base.py b/src/keboola_agent_cli/http_base.py index 6d16fbe80..4090ea471 100644 --- a/src/keboola_agent_cli/http_base.py +++ b/src/keboola_agent_cli/http_base.py @@ -21,6 +21,8 @@ BACKOFF_BASE, ENV_CONVERSATION_ID, MAX_API_ERROR_LENGTH, + MAX_API_ERROR_PARAMS_LENGTH, + MAX_API_ERROR_PARAMS_LIST_ITEMS, MAX_EXCEPTION_ID_LENGTH, MAX_RETRIES, MAX_RETRY_AFTER_SECONDS, @@ -40,6 +42,32 @@ _EXCEPTION_ID_DISALLOWED = re.compile(r"[^A-Za-z0-9._:-]+") +def _bound_error_params(params: dict) -> tuple[dict | None, bool]: + """Cap the server-supplied ``params`` context like the message is capped. + + Returns ``(bounded_params_or_None, truncated)``. Small payloads pass + through untouched. Over MAX_API_ERROR_PARAMS_LENGTH (serialized), every + top-level list is truncated to MAX_API_ERROR_PARAMS_LIST_ITEMS entries + (a merge 409's ``params.errors`` keeps its first N conflicting configs); + if the result still exceeds the cap, params is dropped entirely and only + the truncation marker survives. + """ + try: + if len(json.dumps(params, default=str)) <= MAX_API_ERROR_PARAMS_LENGTH: + return params, False + slimmed = { + key: value[:MAX_API_ERROR_PARAMS_LIST_ITEMS] if isinstance(value, list) else value + for key, value in params.items() + } + if len(json.dumps(slimmed, default=str)) <= MAX_API_ERROR_PARAMS_LENGTH: + return slimmed, True + return None, True + except (TypeError, ValueError): + # Unserializable server payload -- drop it rather than crash the + # error path itself. + return None, True + + def build_user_agent() -> str: """Build the User-Agent that signs every Keboola API call. @@ -437,6 +465,7 @@ def _raise_api_error( url_label = base_url or self._base_url exception_id = "" + details: dict = {} try: body = response.json() # Keboola answers a 5xx with a generic `error` ("Application @@ -445,6 +474,31 @@ def _raise_api_error( # left the operator with nothing to escalate (issue #599). if isinstance(body, dict): exception_id = self._safe_exception_id(body.get("exceptionId")) + # Keboola user errors also carry a machine-readable string + # `code` (e.g. `storage.mergeRequests.notReadyToMerge`). + # Surface it in details so a service can branch on it -- the + # message alone holds only the human `error` text (DMD-1899; + # the merge 409's two shapes differ exactly by this field). + api_error_code = body.get("code") + if isinstance(api_error_code, str) and api_error_code: + details["api_error_code"] = api_error_code + # A Package HttpException additionally serializes its context + # as `params` (ExceptionConverter) -- e.g. the merge-conflict + # 409 carries the conflicting configurations in + # `params.errors`. Surface it so a caller does not have to + # re-fetch data the error already delivered -- BOUNDED, like + # the message below: this rides every 4xx/5xx from every + # BaseHttpClient subclass into agent-consumed --json output. + # Passed through UNMASKED by decision: it is server-authored + # error context (ids, names, version identifiers), not + # credentials -- the same trust the error message text gets. + api_error_params = body.get("params") + if isinstance(api_error_params, dict) and api_error_params: + bounded, truncated = _bound_error_params(api_error_params) + if bounded is not None: + details["api_error_params"] = bounded + if truncated: + details["api_error_params_truncated"] = True # Real Keboola APIs answer with one of these keys in priority # order. Two caveats: # 1. Keboola Metastore puts the HTTP status code into `error` @@ -499,6 +553,7 @@ def _raise_api_error( status_code=status, error_code=ErrorCode.INVALID_TOKEN, retryable=False, + details=details, ) raise KeboolaApiError( message=( @@ -512,6 +567,7 @@ def _raise_api_error( status_code=status, error_code=ErrorCode.AUTH_REJECTED, retryable=False, + details=details, ) if status == 403: @@ -520,6 +576,7 @@ def _raise_api_error( status_code=status, error_code=ErrorCode.ACCESS_DENIED, retryable=False, + details=details, ) if status == 404: @@ -528,6 +585,7 @@ def _raise_api_error( status_code=status, error_code=ErrorCode.NOT_FOUND, retryable=False, + details=details, ) # Appended AFTER the truncation above so they always survive into the @@ -547,4 +605,5 @@ def _raise_api_error( status_code=status, error_code=ErrorCode.API_ERROR, retryable=status in RETRYABLE_STATUS_CODES if retryable is None else retryable, + details=details, ) diff --git a/src/keboola_agent_cli/json_utils.py b/src/keboola_agent_cli/json_utils.py index 5829ee82e..50dd0d89c 100644 --- a/src/keboola_agent_cli/json_utils.py +++ b/src/keboola_agent_cli/json_utils.py @@ -8,8 +8,13 @@ from __future__ import annotations import copy +from dataclasses import dataclass from typing import Any +# Sentinel marking "the key does not exist on this side" in a DiffEntry -- +# distinct from an explicit ``None`` value, which is a legal JSON value. +_ABSENT: Any = object() + def deep_merge(target: dict[str, Any], source: dict[str, Any]) -> dict[str, Any]: """Recursively merge *source* into *target* (non-mutating). @@ -95,19 +100,46 @@ def set_nested_value(obj: dict[str, Any], path: str, value: Any) -> dict[str, An return result -def compute_diff( +@dataclass(frozen=True) +class DiffEntry: + """One changed dot-separated path between two nested dicts. + + ``old`` / ``new`` hold the value on each side, or the module-private + ``_ABSENT`` sentinel when the key does not exist there -- distinct from an + explicit ``None``, which is a legal JSON value. Callers read the + ``old_present`` / ``new_present`` properties instead of comparing against + the sentinel. + """ + + path: str + old: Any + new: Any + + @property + def old_present(self) -> bool: + return self.old is not _ABSENT + + @property + def new_present(self) -> bool: + return self.new is not _ABSENT + + +def compute_diff_entries( old: dict[str, Any], new: dict[str, Any], path: str = "", -) -> list[str]: - """Produce a human-readable list of changes between two dicts. +) -> list[DiffEntry]: + """Compute changed paths between two dicts as structured entries. - Each entry looks like: - ``"parameters.tables.count: 5 -> 10"`` - ``"parameters.newKey: (absent) -> 'hello'"`` - ``"parameters.removed: 42 -> (absent)"`` + The recursive walk behind :func:`compute_diff` (which formats these + entries for humans), exposed as data so callers can post-process paths -- + e.g. intersect two pairwise diffs into a three-way ``ours``/``theirs``/ + ``both`` classification (merge-request conflict presentation, DMD-1899). + + Nested dicts recurse; any other type mismatch or value change yields one + entry for the whole path. Keys are visited in sorted order. """ - changes: list[str] = [] + entries: list[DiffEntry] = [] all_keys = sorted(set(list(old.keys()) + list(new.keys()))) for key in all_keys: @@ -119,14 +151,34 @@ def compute_diff( old_val = old[key] new_val = new[key] if isinstance(old_val, dict) and isinstance(new_val, dict): - changes.extend(compute_diff(old_val, new_val, full_path)) + entries.extend(compute_diff_entries(old_val, new_val, full_path)) elif old_val != new_val: - changes.append(f"{full_path}: {_fmt(old_val)} -> {_fmt(new_val)}") + entries.append(DiffEntry(path=full_path, old=old_val, new=new_val)) elif in_old and not in_new: - changes.append(f"{full_path}: {_fmt(old[key])} -> (absent)") + entries.append(DiffEntry(path=full_path, old=old[key], new=_ABSENT)) else: - changes.append(f"{full_path}: (absent) -> {_fmt(new[key])}") + entries.append(DiffEntry(path=full_path, old=_ABSENT, new=new[key])) + + return entries + +def compute_diff( + old: dict[str, Any], + new: dict[str, Any], + path: str = "", +) -> list[str]: + """Produce a human-readable list of changes between two dicts. + + A formatter over :func:`compute_diff_entries`. Each entry looks like: + ``"parameters.tables.count: 5 -> 10"`` + ``"parameters.newKey: (absent) -> 'hello'"`` + ``"parameters.removed: 42 -> (absent)"`` + """ + changes: list[str] = [] + for entry in compute_diff_entries(old, new, path): + old_s = _fmt(entry.old) if entry.old_present else "(absent)" + new_s = _fmt(entry.new) if entry.new_present else "(absent)" + changes.append(f"{entry.path}: {old_s} -> {new_s}") return changes diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index d48f91b81..57d489db8 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -279,6 +279,18 @@ class TokenVerifyResponse(BaseModel): default=None, description="Organization name parsed from owner.organization (when present)", ) + admin_id: int | None = Field( + default=None, + description=( + "Admin (user) ID behind the token, from the verify response's top-level " + "`admin` block. Present only for admin tokens; scoped tokens have no admin " + "identity. Used for viewer-relative merge-request derivations (DMD-1899)." + ), + ) + admin_name: str | None = Field( + default=None, + description="Admin (user) display name, when the `admin` block is present", + ) class ComponentDetail(BaseModel): diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index eb70c1a6d..34cb48130 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -118,6 +118,27 @@ def project_error_entry( } +def find_default_branch_id(branches: list[dict[str, Any]]) -> int | None: + """The id of the ``isDefault`` branch in a ``list_dev_branches()`` result. + + The one home for the ``isDefault`` scan shared by the config, sync, + workspace and merge-request services. Returns ``None`` when no flagged + branch carries a usable (int-coercible) id -- an entry that cannot be + coerced is skipped, never raised on, so a degenerate payload cannot + crash a caller mid-operation. What ``None`` means (error vs. fallback) + stays the caller's decision. ``lib.py`` keeps its own loop deliberately: + the SDK facade does not import the services layer. + """ + for branch in branches: + if not branch.get("isDefault"): + continue + try: + return int(branch["id"]) + except (KeyError, TypeError, ValueError): + continue + return None + + def default_client_factory(stack_url: str, token: str) -> KeboolaClient: """Create a KeboolaClient with the given stack URL and token. diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 7f4416725..5b49764c7 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -29,7 +29,7 @@ from ._config_clone import clone_config_method from ._config_set_guard import validate_set_paths from ._encryption import collect_secrets, encrypt_secrets_in_config, find_plaintext_secret_keys -from .base import BaseService, ClientFactory, sanitize_unexpected_error +from .base import BaseService, ClientFactory, find_default_branch_id, sanitize_unexpected_error from .workspace_service import find_storage_workspace_for_sandbox_config AiClientFactory = Callable[[str, str], AiServiceClient] @@ -168,10 +168,7 @@ def _fetch_project_configs( folder_branch_id = effective_branch_id if not folder_branch_id: # Fetch default branch ID from dev-branches endpoint - branches = client.list_dev_branches() - default = next((b for b in branches if b.get("isDefault")), None) - if default: - folder_branch_id = default["id"] + folder_branch_id = find_default_branch_id(client.list_dev_branches()) if folder_branch_id: result = client.list_config_folder_metadata(branch_id=folder_branch_id) folder_map = result if isinstance(result, dict) else {} @@ -1487,9 +1484,9 @@ def _resolve_metadata_branch_id( f"Unexpected error listing branches for metadata route: {exc}. " "Pass --branch explicitly." ) from exc - default = next((b for b in branches if b.get("isDefault")), None) - if default: - return int(default["id"]) + default_branch_id = find_default_branch_id(branches) + if default_branch_id is not None: + return default_branch_id raise ConfigError( "Could not determine a branch for config metadata. " "Set an active branch with 'kbagent branch use' or pass --branch." diff --git a/src/keboola_agent_cli/services/merge_request_service.py b/src/keboola_agent_cli/services/merge_request_service.py new file mode 100644 index 000000000..32e371396 --- /dev/null +++ b/src/keboola_agent_cli/services/merge_request_service.py @@ -0,0 +1,1211 @@ +"""Merge-request service -- MR lifecycle, status derivation, error mapping (DMD-1899). + +Business logic for the future ``kbagent merge-request`` command group, over the +Layer 3 namespace ``client.merge_requests`` (shipped in #556) and the config +diff/rebase endpoints in ``client/configs.py``. Scope is the **non-SOX** flow +(``branches-merge-requests``). Design record: the merge-request RFC set -- +the L2 RFC and the wire-truth notes referenced throughout this module -- lives +on branch ``ms/merge-requests-rfcs`` (kept out of main deliberately) and in +DMD-1899. + +The module-level functions below are the **status-derivation polyfill**: the +derived vocabulary (``derived_state`` / ``merge_blockers`` / ``allowed_actions`` +/ ``viewer``) belongs on the backend so the UI, this CLI, and the MCP tools +consume one evaluation instead of re-deriving it three times (the way GitHub +serializes ``mergeable_state`` / ``reviewDecision`` / ``viewer*``). Connection +tracks that as DMD-1988; until it lands, every function here reads the future +serialized field FIRST and only falls back to the local decision table -- +delete the fallbacks when DMD-1988 ships. The canonical decision tables live +in the L2 RFC and in DMD-1988; the local logic is a port of the UI list badge +(``kbc-ui .../merge-requests/components/MergeRequestRow.tsx`` + ``helpers.ts``). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..client import KeboolaClient +from ..constants import BRANCHES_MERGE_REQUESTS_FEATURE, PROTECTED_DEFAULT_BRANCH_FEATURE +from ..errors import ConfigError, ErrorCode, FeatureNotEnabledError, KeboolaApiError +from ..json_utils import DiffEntry, compute_diff_entries +from ..models import ProjectConfig +from .base import BaseService, find_default_branch_id + +logger = logging.getLogger(__name__) + +# Raw lifecycle state -> derived_state, before the reviewer-based overrides. +# `published`/`canceled` get client-facing names matching the UI list badge +# ("Merged"/"Closed"); `in_merge` is named even though the UI badge omits it. +_DERIVED_STATE_BY_RAW: dict[str, str] = { + "development": "in_development", + "in_review": "in_review", + "approved": "approved", + "in_merge": "in_merge", + "published": "merged", + "canceled": "closed", +} + +# Mechanical action availability per raw state, verified against the Symfony +# workflow (MergeRequestLifecycleStateMachine + its guards): +# - `approve` exists ONLY in in_review (the transition's sole `from` place); +# from `approved` the backend answers 422. Even in in_review it is further +# gated by AddApprovalGuard (not the creator, not already approved, +# required count not yet reached) -- which no state-only table can express. +# With the non-SOX default of 0 required approvals, approve is 422 in every +# state and in_review itself is unreachable (request-review jumps straight +# to approved via skip_review). +# - `merge` appears for `development` because a non-SOX merge from there +# succeeds when approvals suffice (the backend auto-applies skip_review). +# - `update` is blocked server-side only in the terminal states +# (published/canceled) -- an in_merge MR is still updatable. +# - `resolve_conflicts` (diff+rebase) is allowed while the MR is open; the +# in_merge branch lock is enforced on the SOX path only, but rebasing +# mid-merge is pointless, so the table deliberately omits it there. +# Deliberately state-only: roles and features are the pre-flight's job +# client-side, and the backend can honor them once it serializes +# `allowedActions` (DMD-1988). +_ALLOWED_ACTIONS_BY_STATE: dict[str, tuple[str, ...]] = { + "development": ("request_review", "merge", "update", "resolve_conflicts"), + "in_review": ("approve", "request_changes", "update", "resolve_conflicts"), + "approved": ("request_changes", "merge", "update", "resolve_conflicts"), + "in_merge": ("update",), + "published": (), + "canceled": (), +} + + +# Everything `list_merge_requests`' --state filter accepts: the derived +# vocabulary plus the raw lifecycle states (the two overlap on purpose). +# Public: Layer 1 enumerates it in --state help text and pre-validates to +# exit 2 (house precedent: notification_service's KNOWN_EVENTS). +STATE_FILTER_VOCABULARY: frozenset[str] = ( + frozenset(_DERIVED_STATE_BY_RAW) | frozenset(_DERIVED_STATE_BY_RAW.values()) | {"rejected"} +) + +# The resolve_conflict take modes. Public for the same reason. +TAKE_MODES: tuple[str, ...] = ("ours", "theirs", "delete") + + +def _same_id(a: Any, b: Any) -> bool: + """Compare two ids that may arrive as int or str (approverId is a string + on the wire while creator.id and reviewer.id are numbers).""" + return a is not None and b is not None and str(a) == str(b) + + +def _coerce_branch_id(value: Any) -> int | None: + """A wire branch id as int, or None when absent or not numeric. + + The payload mixes int and str ids (the reason _same_id exists); a + non-numeric value must degrade to "no usable branch id", never to an + unhandled ValueError on the error path. + """ + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _creator_id(mr: dict[str, Any]) -> Any: + return (mr.get("creator") or {}).get("id") + + +def derive_state(mr: dict[str, Any]) -> str: + """Derive the client-facing lifecycle state of a merge request. + + Server-first: prefers a serialized ``derivedState`` when Connection ships + it (DMD-1988); the local fallback is the UI list badge's decision table, + evaluated in order: + + - ``rejected``: ``development`` + a non-creator reviewer with + ``status=rejected``. + - ``closed``: ``canceled``, or ``development`` + the creator's + self-rejection -- the UI "cancel" action reuses request-changes under + the hood, so a self-closed MR keeps ``state=development``. + - otherwise the raw state mapped through ``_DERIVED_STATE_BY_RAW`` + (an unknown raw state passes through unchanged, defensively). + + Reliability caveat (verified against Connection): ``reviewers[].status`` is populated only within a review + round anchored by an actual ``request_review`` event, and a non-reviewer's + decision (the creator's included -- the creator can never BE a reviewer) + is dropped whenever explicit reviewers exist. ``skip_review`` writes no + activity event, so in a project with the non-SOX default of 0 required + approvals every status is ``null`` and the ``rejected`` / self-``closed`` + overrides never fire -- the same blind spot the UI badge has, since this + table is its port. The truth lives in the MR's activity log + (``changes_requested`` events, un-shadowed and un-anchored); serializing a + reliable ``derivedState`` from it is exactly what DMD-1988 asks Connection + to do. The fallback here stays best-effort by design. + """ + server = mr.get("derivedState") + if isinstance(server, str) and server: + return server + + state = mr.get("state") or "" + creator_id = _creator_id(mr) + non_creator_rejected = False + creator_self_rejected = False + for reviewer in mr.get("reviewers") or []: + if reviewer.get("status") != "rejected": + continue + if _same_id(reviewer.get("id"), creator_id): + creator_self_rejected = True + else: + non_creator_rejected = True + + if state == "development" and non_creator_rejected: + return "rejected" + if state == "canceled" or (state == "development" and creator_self_rejected): + return "closed" + return _DERIVED_STATE_BY_RAW.get(state, state) + + +def derive_merge_blockers(mr: dict[str, Any], conflicts: list[dict[str, Any]] | None) -> list[str]: + """Derive what currently blocks ``merge`` -- a list, so concurrent + blockers don't mask each other (unlike GitHub's single-valued + ``mergeable_state``). + + Server-first (``mergeBlockers``, DMD-1988). Local fallback, in + deterministic order: + + - ``conflicts``: the live conflicts list is non-empty. Pass ``None`` when + conflicts were not fetched (list rows) -- absence of data is not + absence of conflicts, so ``None`` simply skips the check. + - ``approvals``: ``state == in_review`` -- the state machine collapses + the requirement (insufficient approvals is the only way to sit there). + No count is reported until Connection serializes it (DMD-1969). + - ``state``: ``in_merge`` / ``published`` / ``canceled`` -- merge is not + applicable. + + Purely informational, NOT a guard: the backend stays the authority via + the merge 409 (conflicts are validated live on every attempt). Note a + ``rejected`` MR has no blocker -- it sits in ``development`` and a + non-SOX merge from there succeeds (auto skip_review); the story is told + by ``derived_state``. + """ + server = mr.get("mergeBlockers") + if isinstance(server, list): + return [str(blocker) for blocker in server] + + state = mr.get("state") or "" + blockers: list[str] = [] + if conflicts: + blockers.append("conflicts") + if state == "in_review": + blockers.append("approvals") + if state in ("in_merge", "published", "canceled"): + blockers.append("state") + return blockers + + +def derive_allowed_actions(mr: dict[str, Any]) -> list[str]: + """Derive which MR actions the current state mechanically allows. + + Server-first (``allowedActions``, DMD-1988). The local fallback is + state-only (see ``_ALLOWED_ACTIONS_BY_STATE``); an unknown state yields + an empty list rather than guessing. + """ + server = mr.get("allowedActions") + if isinstance(server, list): + return [str(action) for action in server] + return list(_ALLOWED_ACTIONS_BY_STATE.get(mr.get("state") or "", ())) + + +def _server_viewer(mr: dict[str, Any]) -> dict[str, Any] | None: + """The server-serialized ``viewer`` block, or None when absent/unusable. + + THE single predicate for "did DMD-1988 land": both ``derive_viewer`` and + ``get_merge_request``'s verify_token skip use it, so they can never + disagree on what counts as a usable server field (a bare ``viewer: {}`` + or one with foreign keys must fall back to the local derivation, not + silently yield None flags). + """ + server = mr.get("viewer") + if isinstance(server, dict) and ("isCreator" in server or "hasApproved" in server): + return server + return None + + +def derive_viewer(mr: dict[str, Any], admin_id: int | None) -> dict[str, bool | None]: + """Derive the caller-relative flags: am I the creator, did I approve. + + ``admin_id`` is the caller's user id from ``verify_token`` (the response's + ``admin`` block); when it is ``None`` both flags are ``None`` -- honest + "unknown", not ``False``. In practice that branch is defense in depth + rather than a reachable path: a scoped token (no admin identity) is + denied by ``MergeRequestVoter`` on the detail/conflicts endpoints before + this function ever runs. Server-first (``viewer`` with + ``isCreator``/``hasApproved``, DMD-1988). These flags are what turns a + blocker into a next step: ``approvals`` + ``has_approved=True`` means + "wait for the other reviewers", not "approve it". + """ + server = _server_viewer(mr) + if server is not None: + return { + "is_creator": server.get("isCreator"), + "has_approved": server.get("hasApproved"), + } + + if admin_id is None: + return {"is_creator": None, "has_approved": None} + is_creator = _same_id(_creator_id(mr), admin_id) + has_approved = any( + _same_id(approval.get("approverId"), admin_id) for approval in mr.get("approvals") or [] + ) + return {"is_creator": is_creator, "has_approved": has_approved} + + +def _enrich_row(mr: dict[str, Any]) -> dict[str, Any]: + """Row-level enrichment applied to every MR the service returns: raw MR + + derived_state + allowed_actions (both are free -- state-only). Conflicts + are not fetched here, so no blockers -- detail-level enrichment does that. + A --json consumer of create/update/transitions can answer "what can I do + next" without a second call. + + State-only also means FEATURE-BLIND: on a project that once had merge + requests and lost the feature, rows still advertise write actions the + pre-flight will refuse. Deliberate -- closing it would cost a + verify_token GET on every non-empty list for a rare configuration, and + the pre-flight answers any attempted write with the precise error; the + real fix is the server serializing ``allowedActions`` (DMD-1988), which + can honor features.""" + return { + **mr, + "derived_state": derive_state(mr), + "allowed_actions": derive_allowed_actions(mr), + } + + +class MergeRequestService(BaseService): + """Business logic for the non-SOX merge-request lifecycle. + + Single-project operations over ``client.merge_requests`` (Layer 3, #556). + Reads pass through with derived-status enrichment; writes run the + ``branches-merge-requests`` pre-flight first, because a missing feature + surfaces as a 403 byte-for-byte identical to a role denial and only a + client-side check can word the real error. + + Uses dependency injection for config_store and client_factory. + """ + + # States whose source branch still exists and whose conflicts endpoint is + # meaningful; for published/canceled MRs the branch is deleted and + # branchFromId is null. + _OPEN_STATES = ("development", "in_review", "approved") + + def _project(self, alias: str) -> ProjectConfig: + return self.resolve_projects([alias])[alias] + + def _require_merge_requests_feature(self, client: KeboolaClient) -> None: + """Refuse a write early when the project lacks the non-SOX MR feature. + + Server-side, the six MR writes accept `protected-default-branch` OR + `branches-merge-requests` (only /rebase requires the latter), so this + pre-flight fences off SOX projects ONLY as long as a SOX project + never also carries `branches-merge-requests` -- and it is deliberately + stricter than the server for a project with only + `protected-default-branch` (SOX approvals semantics are out of + kbagent's scope). See the L2 RFC (module docstring says where). + """ + if client.has_feature(BRANCHES_MERGE_REQUESTS_FEATURE): + return + # The features cache is already loaded by the has_feature call above, + # so telling a SOX project apart from a plain "not enabled" is free -- + # and the two need different advice: a SOX refusal is deliberate CLI + # policy, a missing feature is something to enable. + if client.has_feature(PROTECTED_DEFAULT_BRANCH_FEATURE): + raise FeatureNotEnabledError( + "This is a SOX project ('protected-default-branch'): kbagent " + "deliberately does not support its merge-request approvals flow, " + "although the server would accept some writes. Use the Keboola UI " + "for SOX merge requests." + ) + raise FeatureNotEnabledError( + "Merge requests are not enabled on this project: the " + f"'{BRANCHES_MERGE_REQUESTS_FEATURE}' feature is missing. Without this " + "pre-flight the API would answer an unexplained 403 (identical to a " + "role denial). Ask Keboola support to enable the feature." + ) + + # -- Reads ---------------------------------------------------------------- + + def list_merge_requests(self, alias: str, state: str | None = None) -> dict[str, Any]: + """List the project's merge requests, each with ``derived_state``. + + ``state`` filters client-side (the endpoint declares no query + parameters): a value matching either the derived vocabulary + (``rejected``, ``merged``, ``closed``, ...) or a raw lifecycle state + (``published``, ...) keeps the row; matching is case-insensitive. An + unknown value is refused with the accepted list -- the vocabulary is + closed and known here, and a typo returning a silent ``count: 0`` + would read as "no MRs". + """ + if state is not None and state.lower() not in STATE_FILTER_VOCABULARY: + raise ConfigError( + f"Unknown --state value {state!r}. Accepted values: " + f"{', '.join(sorted(STATE_FILTER_VOCABULARY))}." + ) + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + rows = [_enrich_row(mr) for mr in client.merge_requests.list()] + # The list endpoint is ungated, so a project without the feature + # answers 200 + [] -- indistinguishable from a genuinely empty + # project, while every subsequent write would fail. Disambiguate + # only for the empty result (the has_feature call costs one + # verify_token GET, so it is not spent on non-empty lists). + feature_enabled = ( + client.has_feature(BRANCHES_MERGE_REQUESTS_FEATURE) if not rows else None + ) + finally: + client.close() + + if state is not None: + wanted = state.lower() + rows = [ + mr + for mr in rows + if wanted in ((mr.get("state") or "").lower(), mr["derived_state"].lower()) + ] + + result: dict[str, Any] = { + "alias": alias, + "count": len(rows), + "merge_requests": rows, + } + if feature_enabled is not None: + result["feature_enabled"] = feature_enabled + if state is not None: + result["state_filter"] = state + return result + + def find_merge_request_for_branch(self, alias: str, branch_id: int) -> dict[str, Any]: + """Resolve a dev branch to its merge request (a branch has at most + one MR, ever -- the backend's existence check has no state filter). + + The list endpoint cannot filter server-side, so this lists and + matches ``branches.branchFromId`` client-side. Raises ``NOT_FOUND`` + when the branch has no MR, with the create command as the next step + -- but first checks the feature on the no-match path: the list + endpoint is ungated, so a project without ``branches-merge-requests`` + also answers ``200 + []``, and a NOT_FOUND prescribing ``create`` + would send the user into a guaranteed FEATURE_NOT_ENABLED loop. + The feature check costs one verify_token GET + and is spent only when nothing matched. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + for mr in client.merge_requests.list(): + if _same_id((mr.get("branches") or {}).get("branchFromId"), branch_id): + return {"alias": alias, **_enrich_row(mr)} + # No match: tell "feature off" apart from "branch has no MR" + # before prescribing a next step that could not succeed. + self._require_merge_requests_feature(client) + finally: + client.close() + raise KeboolaApiError( + message=( + f"Branch {branch_id} has no merge request in project '{alias}'. " + "Create one with `kbagent merge-request create`." + ), + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + + def get_merge_request( + self, + alias: str, + merge_request_id: int, + include_activity_log: bool = False, + ) -> dict[str, Any]: + """Get an MR's detail with the full derived status. + + On top of the raw payload: ``derived_state``, ``merge_blockers`` + + ``mergeable``, ``allowed_actions``, ``viewer`` and -- for open MRs -- + the live ``conflicts`` list (skipped for published/canceled/in_merge, + where the source branch is gone or locked and the readiness question + is moot). The derivations are informational; the merge 409 stays the + authority. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + mr = client.merge_requests.get( + merge_request_id, include_activity_log=include_activity_log + ) + conflicts: list[dict[str, Any]] | None = None + if (mr.get("state") or "") in self._OPEN_STATES: + conflicts = client.merge_requests.conflicts(merge_request_id) + # The verify_token call exists only to anchor the viewer + # polyfill (its admin block is the caller's identity). Note the + # detail and conflicts endpoints themselves require an admin + # token (MergeRequestVoter denies a token with no admin) -- a + # scoped token fails above before viewer is ever derived; the + # None-flags path stays as defense in depth. Once the + # server serializes `viewer` (DMD-1988), derive_viewer never + # reads admin_id -- so skip the call and its cost with it. A + # scoped token has no admin identity -> flags are None, not + # False. + admin_id: int | None = None + if _server_viewer(mr) is None: + admin_id = client.verify_token().admin_id + finally: + client.close() + + blockers = derive_merge_blockers(mr, conflicts) + detail: dict[str, Any] = { + "alias": alias, + **mr, + "derived_state": derive_state(mr), + "merge_blockers": blockers, + "mergeable": not blockers and conflicts is not None, + "allowed_actions": derive_allowed_actions(mr), + "viewer": derive_viewer(mr, admin_id), + } + if conflicts is not None: + detail["conflicts"] = conflicts + detail["conflicts_count"] = len(conflicts) + return detail + + # -- Writes: create / update / review transitions -------------------------- + + def create_merge_request( + self, + alias: str, + branch_from_id: int, + title: str, + description: str | None = None, + reviewer_ids: list[int] | None = None, + auto_merge_strategy: str | None = None, + auto_merge_at: str | None = None, + external_id: str | None = None, + ) -> dict[str, Any]: + """Create a merge request from ``branch_from_id`` into the default branch. + + The target is always the default branch (the backend rejects any + other), so the service resolves its id itself. The source branch is + an explicit parameter -- Layer 1 resolves it via the house + ``resolve_branch()`` idiom (``--branch`` -> ``active_branch_id`` -> + readable error) and the output must state which branch the MR was + created from. A source branch can have at most one MR, ever; a + second create answers 404 server-side. + + ``auto_merge_strategy="immediately"`` is NOT metadata: a background + backend tick merges any *approved* MR carrying it (same + MergeProcessor as the merge endpoint, under a system token) -- and on + non-SOX defaults ``request_review`` lands straight in approved, so + create+submit alone can end in a production merge and the source + branch's deletion. See the notes doc, *Auto-merge*. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + default_branch_id = self._default_branch_id(client, alias) + if branch_from_id == default_branch_id: + raise ConfigError( + f"Branch {branch_from_id} is the default (production) branch of " + f"project '{alias}'. A merge request merges a development branch " + "into it -- create one with `kbagent branch create`, or pass " + "--branch with a dev branch id." + ) + mr = client.merge_requests.create( + branch_from_id=branch_from_id, + branch_into_id=default_branch_id, + title=title, + description=description, + reviewer_ids=reviewer_ids, + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ) + finally: + client.close() + return { + "alias": alias, + "branch_from_id": branch_from_id, + "branch_into_id": default_branch_id, + **_enrich_row(mr), + } + + def update_merge_request( + self, + alias: str, + merge_request_id: int, + title: str | None = None, + description: str | None = None, + reviewer_ids: list[int] | None = None, + auto_merge_strategy: str | None = None, + auto_merge_at: str | None = None, + external_id: str | None = None, + ) -> dict[str, Any]: + """Update an MR's metadata. ``None`` = leave unchanged (the API cannot + clear a field to null; an empty string clears description/externalId + server-side). + + ``auto_merge_strategy="immediately"`` on an already-approved MR is + enough for the backend's auto-merge tick to merge it -- no ``merge()`` + call involved (see the notes doc, *Auto-merge*). Not just metadata. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + mr = client.merge_requests.update( + merge_request_id, + title=title, + description=description, + reviewer_ids=reviewer_ids, + auto_merge_strategy=auto_merge_strategy, + auto_merge_at=auto_merge_at, + external_id=external_id, + ) + finally: + client.close() + return {"alias": alias, **_enrich_row(mr)} + + def request_review(self, alias: str, merge_request_id: int) -> dict[str, Any]: + """Move the MR from ``development`` to review. + + With the non-SOX default of 0 required approvals the MR lands + straight in ``approved`` (the backend auto-applies finish_review) -- + and a merge from ``development`` skips this step entirely, so the + happy path never needs it. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + mr = client.merge_requests.request_review(merge_request_id) + finally: + client.close() + return {"alias": alias, **_enrich_row(mr)} + + def approve(self, alias: str, merge_request_id: int) -> dict[str, Any]: + """Add the caller's approval.""" + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + mr = client.merge_requests.approve(merge_request_id) + finally: + client.close() + return {"alias": alias, **_enrich_row(mr)} + + def request_changes( + self, alias: str, merge_request_id: int, reason: str | None = None + ) -> dict[str, Any]: + """Send the MR back to ``development`` (approvals are deleted). + + Also the closest thing to closing an MR: the backend has no cancel + endpoint, and the UI's "cancel" is exactly this call made by the + creator on their own MR (rendered as Closed; derived_state mirrors + that). ``reason`` is capped at 1000 characters server-side. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + mr = client.merge_requests.request_changes(merge_request_id, reason=reason) + finally: + client.close() + return {"alias": alias, **_enrich_row(mr)} + + def _default_branch_id(self, client: KeboolaClient, alias: str) -> int: + """Resolve the project's default branch id (the only legal MR target).""" + branch_id = find_default_branch_id(client.list_dev_branches()) + if branch_id is not None: + return branch_id + raise KeboolaApiError( + message=f"Project '{alias}' reports no default branch -- cannot target a merge request.", + status_code=0, + error_code=ErrorCode.API_ERROR, + retryable=False, + ) + + # -- Merge ------------------------------------------------------------------ + + _NOT_READY_CODE = "storage.mergeRequests.notReadyToMerge" + # MergeValidationException's string code (ExceptionConverter serializes + # it top-level as `code`, with the conflicting configs in `params.errors`). + _CONFLICT_CODE = "storage.mergeRequests.validation" + + def merge(self, alias: str, merge_request_id: int) -> dict[str, Any]: + """Merge the MR into the default branch and clean local references. + + Waits for the merge Storage job (Layer 3 awaits with + MERGE_JOB_MAX_WAIT; a failed merge raises STORAGE_JOB_FAILED and the + MR rolls back to ``approved``). Works straight from ``development`` + when approvals are satisfied -- the backend auto-applies skip_review. + + The merge 409 is remapped onto its two wire shapes (the RFC's + decision; docs/error-codes.md). Both carry a machine string code + (ExceptionConverter serializes it top-level as ``code``): + + - ``storage.mergeRequests.notReadyToMerge`` (project merge lock / + wrong state / another MR processing) -> ``MR_NOT_READY_TO_MERGE``, + retryable -- all three causes are transient. + - ``storage.mergeRequests.validation`` is the conflict validation -> + ``MR_MERGE_CONFLICT``, not retryable; the conflicting + configurations arrive in the 409's own ``params.errors`` and are + passed through in details. A code-less 409 is treated as a + conflict too (older stacks), but a 409 with any OTHER code passes + through unmapped -- never mislabeled as a conflict. + + A successful merge always also deletes the source branch -- as a + second async job with no handle, so the result says the branch "is + being deleted", never that it is gone. Local cleanup mirrors + ``BranchService.delete_branch``: reset ``active_branch_id`` only if + it pointed at the merged branch, and unlink any sync + ``branch-mapping.json`` entry. Cleanup is best-effort -- the merge + already happened, so a cleanup failure degrades to a warning in the + result instead of failing the command. + """ + from ..sync.branch_mapping import cleanup_branch_id_from_mapping + + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + # branchFromId is nullable once the MR is published -- capture it + # from the pre-merge payload, not the post-merge one. Coerced to + # int so the was_active comparison and the mapping cleanup below + # cannot be defeated by a string-serialized wire id (the payload + # mixes int and str ids -- the reason _same_id exists). + raw_branch_from = ( + client.merge_requests.get(merge_request_id).get("branches") or {} + ).get("branchFromId") + branch_from_id = _coerce_branch_id(raw_branch_from) + try: + job = client.merge_requests.merge(merge_request_id) + except KeboolaApiError as exc: + self._remap_merge_conflict(exc) + raise + finally: + client.close() + + was_active = branch_from_id is not None and project.active_branch_id == branch_from_id + mapping_cleanup: dict[str, Any] | None = None + cleanup_warnings: list[str] = [] + if raw_branch_from is not None and branch_from_id is None: + # "Absent" and "present but not numeric" must not collapse + # silently: with no usable id the local cleanup below is skipped, + # leaving active_branch_id and the sync mapping pointing at the + # branch the merge just doomed -- and the caller must hear that. + logger.warning("branchFromId %r is not a numeric branch id", raw_branch_from) + cleanup_warnings.append( + f"branchFromId {raw_branch_from!r} is not a numeric branch id -- local " + "cleanup (active-branch reset, sync-mapping unlink) was skipped. If this " + "project's active branch pointed at the merged branch, run " + "`kbagent branch reset` and `kbagent sync branch-unlink` yourself." + ) + # The merge already happened: a failed local cleanup must not turn it + # into a failed command -- and the two cleanups are independent, so a + # failed config write must not skip the mapping unlink (or vice versa). + branch_reset_done = False + try: + if was_active: + self._config_store.set_project_branch(alias, None) + branch_reset_done = True + except Exception as exc: + logger.warning("Post-merge active-branch reset failed: %s", exc) + cleanup_warnings.append( + f"Post-merge active-branch reset failed: {exc}. The active branch " + "still points at the deleted branch -- run `kbagent branch reset " + f"--project {alias}`." + ) + try: + if branch_from_id is not None: + mapping_cleanup = cleanup_branch_id_from_mapping(branch_from_id) + except Exception as exc: + logger.warning("Post-merge sync-mapping cleanup failed: %s", exc) + cleanup_warnings.append(f"Post-merge sync-mapping cleanup failed: {exc}") + + results = job.get("results") + mr_after: dict[str, Any] = results if isinstance(results, dict) else {} + + message_parts = [f"Merge request {merge_request_id} merged into production."] + if branch_from_id is not None: + message_parts.append( + f"Source branch {branch_from_id} is being deleted (a separate async " + "job -- it may still briefly exist)." + ) + if branch_reset_done: + message_parts.append("Active branch reset to main.") + if mapping_cleanup: + unlinked = ", ".join(mapping_cleanup["git_branches_unlinked"]) + message_parts.append(f"Unlinked git branch(es): {unlinked}.") + + result: dict[str, Any] = { + "alias": alias, + "merge_request_id": merge_request_id, + "branch_from_id": branch_from_id, + "was_active": was_active, + "job": job, + "message": " ".join(message_parts), + } + if mr_after.get("state"): + result["state"] = mr_after["state"] + result["derived_state"] = derive_state(mr_after) + result["allowed_actions"] = derive_allowed_actions(mr_after) + if mapping_cleanup: + result["mapping_cleanup"] = mapping_cleanup + if cleanup_warnings: + result["cleanup_warnings"] = cleanup_warnings + return result + + def _remap_merge_conflict(self, exc: KeboolaApiError) -> None: + """Raise the RFC's dedicated error for a known merge 409; return for others. + + Only this call site knows the 409 came from the merge endpoint -- + which is why the mapping cannot live in http_base (see the L2 + RFC). Both shapes match on the top-level + ``code`` of the error body (surfaced as ``details.api_error_code``); + a code-less 409 falls back to the conflict interpretation for older + stacks, but a 409 carrying any *other* code passes through unmapped + rather than being confidently mislabeled a conflict. + """ + if exc.status_code != 409: + return + code = exc.details.get("api_error_code") + if code == self._NOT_READY_CODE: + raise KeboolaApiError( + message=( + f"{exc.message} The merge lock, MR state or a concurrently " + "processing merge request blocks the merge -- these are " + "transient; retry once it clears." + ), + status_code=409, + error_code=ErrorCode.MR_NOT_READY_TO_MERGE, + retryable=True, + details=exc.details, + ) from exc + if code == self._CONFLICT_CODE or code is None: + # The 409 already carries the conflicting configurations in + # params.errors (surfaced as details.api_error_params) -- keep + # them so the caller does not need a second round trip; the + # conflicts command remains the way to re-inspect later. + raise KeboolaApiError( + message=( + f"{exc.message} Configurations changed on both branches. Inspect " + "them with `kbagent merge-request conflicts`, resolve each one, " + "then merge again (conflicts are re-validated live on every " + "attempt)." + ), + status_code=409, + error_code=ErrorCode.MR_MERGE_CONFLICT, + retryable=False, + details=exc.details, + ) from exc + + # -- Conflicts / diff / resolution -------------------------------------------- + + # Content-bearing keys of a diff side's ``diff`` envelope -- what a + # three-way comparison is about. Wire truth (ConfigurationDiffData, + # verified against connection): each side serializes as + # ``{version, isDeleted, diff: {name, description, changeDescription, + # isDisabled, configuration, rows}}`` -- content nested under ``diff``, + # version/deletion as side metadata. ``changeDescription`` is excluded: + # it is a per-version commit message, not content to resolve (the rebase + # takes its own ``change_description``). + _DIFF_CONTENT_KEYS = ("name", "description", "configuration", "isDisabled", "rows") + + def list_conflicts(self, alias: str, merge_request_id: int) -> dict[str, Any]: + """List the configurations conflicting between the MR's branches. + + Conflicts are computed live by the backend on every call (and on + every merge attempt), so rebasing each listed config is sufficient -- + there is no MR-level re-validate step. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + conflicts = client.merge_requests.conflicts(merge_request_id) + finally: + client.close() + return { + "alias": alias, + "merge_request_id": merge_request_id, + "count": len(conflicts), + "conflicts": conflicts, + } + + def get_config_diff( + self, alias: str, merge_request_id: int, component_id: str, config_id: str + ) -> dict[str, Any]: + """Three-way diff of one config, flattened to a per-path classification. + + The branch is derived from the merge request (``branches.branchFromId``) + for the same reason ``resolve_conflict`` derives it: a caller-supplied + branch id has no relation to the MR, so the diff could silently show an + unrelated branch (findings doc, DMD-1899/DMD-1900). One GET buys the + symmetry; the resolved ``branch_id`` is echoed in the result so Layer 1 + never re-derives the branch->MR relation itself. + + No three panes: each touched path is tagged ``changed_by`` -- + ``ours`` (only the dev branch changed it), ``theirs`` (only + production), or ``both``. A ``both`` row where the two sides agree on + the identical value additionally carries ``agreed: true`` -- both + sides moved, but there is nothing to decide; the actual conflict + hotspots are the ``both`` rows without it. ``rows`` compares + wholesale (row-level three-way diffing is not attempted). + + Deletions do not show up as paths: a soft-deleted side is flagged by + the top-level ``ours_deleted`` / ``theirs_deleted`` booleans instead + (``None`` = the side does not exist at all). ``onto_version`` is the + default-branch version a rebase re-anchors onto -- the + ``theirs.version`` trap spelled out once, here. + """ + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + branch_id = self._branch_from_id_of(client, merge_request_id) + diff = client.get_config_diff(component_id, config_id, branch_id) + finally: + client.close() + theirs = diff.get("theirs") or {} + ours = diff.get("ours") + return { + "alias": alias, + "merge_request_id": merge_request_id, + "component_id": component_id, + "config_id": config_id, + "branch_id": branch_id, + "onto_version": theirs.get("version"), + "ours_deleted": bool(ours.get("isDeleted")) if ours is not None else None, + "theirs_deleted": ( + bool(theirs.get("isDeleted")) if diff.get("theirs") is not None else None + ), + "changes": self._classify_three_way(diff), + "diff": diff, + } + + def _classify_three_way(self, diff: dict[str, Any]) -> list[dict[str, Any]]: + """Intersect the two pairwise diffs (base->ours, base->theirs) per path. + + Only meaningful when BOTH sides carry comparable content. A null + side (the config never existed there), a tombstoned one + (``isDeleted`` -- the criterion ``resolve_conflict`` already treats + as "no content to take") and an empty envelope are all side-level + facts carried by the ``ours_deleted`` / ``theirs_deleted`` flags; + fabricating per-path rows against an empty stand-in would emit + contradictions -- every base key rendered as "that side removed it" + next to a flag saying the side does not exist. Any of them yields no + ``changes`` at all. + """ + + def classifiable(side: dict[str, Any] | None) -> bool: + return side is not None and not side.get("isDeleted") and bool(side.get("diff")) + + if not classifiable(diff.get("ours")) or not classifiable(diff.get("theirs")): + return [] + + def content(side: dict[str, Any] | None) -> dict[str, Any]: + # The side's content lives in its nested ``diff`` envelope (wire + # truth above); a null base (config created on both sides) + # contributes nothing. + envelope = (side or {}).get("diff") or {} + return {key: envelope[key] for key in self._DIFF_CONTENT_KEYS if key in envelope} + + base = content(diff.get("base")) + ours_entries = {e.path: e for e in compute_diff_entries(base, content(diff.get("ours")))} + theirs_entries = { + e.path: e for e in compute_diff_entries(base, content(diff.get("theirs"))) + } + + def side_value(entry: DiffEntry | None, base_val: Any) -> Any: + # A side that changed the path shows its own value (None when + # it REMOVED the key -- never the base value); a side with no + # entry did not touch the path and still holds the base. + if entry is not None: + return entry.new if entry.new_present else None + return base_val + + changes: list[dict[str, Any]] = [] + for path in sorted(set(ours_entries) | set(theirs_entries)): + ours_entry = ours_entries.get(path) + theirs_entry = theirs_entries.get(path) + reference = ours_entry or theirs_entry + assert reference is not None # path came from one of the two maps + changed_by = ( + "both" if ours_entry and theirs_entry else ("ours" if ours_entry else "theirs") + ) + base_value = reference.old if reference.old_present else None + change: dict[str, Any] = { + "path": path, + "changed_by": changed_by, + "base": base_value, + "ours": side_value(ours_entry, base_value), + "theirs": side_value(theirs_entry, base_value), + } + if changed_by == "both": + # Identical independent changes are agreement, not a + # conflict hotspot -- flag them so renderers can demote them. + assert ours_entry is not None and theirs_entry is not None + change["agreed"] = ( + ours_entry.new_present == theirs_entry.new_present + and side_value(ours_entry, base_value) == side_value(theirs_entry, base_value) + ) + changes.append(change) + return changes + + def resolve_conflict( + self, + alias: str, + merge_request_id: int, + component_id: str, + config_id: str, + take: str | None = None, + resolved: dict[str, Any] | None = None, + change_description: str | None = None, + ) -> dict[str, Any]: + """Resolve one conflicting config by rebasing it (uniformly -- every + mode goes through the rebase endpoint, per the RFC decision; the UI's + reset-to-default alternative for take=theirs is DMD-1987). + + The branch is NOT a parameter: it is derived from the merge request + itself (``branches.branchFromId``), so the conflict-set guard and the + branch being written to can never disagree -- a caller-supplied + branch id could point the rebase at an unrelated dev branch that the + guard never checked (rebase REPLACES; that would be silent data + loss). + + Modes (exactly one of ``take`` / ``resolved``): + + - ``take="ours"``: keep the dev-branch content, re-anchored onto the + production version. + - ``take="theirs"``: adopt the production content (the config stays + in the MR's changeset; the merge then writes a content-no-op). + - ``take="delete"``: the ``{"version": N, "diff": {}}`` tombstone. + A take of a side whose ``isDeleted`` is true collapses to this + resolution too -- "production deleted it, dev changed it" and its + mirror are live conflict shapes. + - ``resolved={...}``: a caller-authored three-way merge -- a FLAT + body that must carry ALL FIVE replaced fields explicitly: + ``name``, ``rows``, ``configuration``, ``isDisabled`` and + ``description`` (nullable, but must be present). Rebase REPLACES; + a missing key would silently wipe data -- re-enable a disabled + config, drop a description -- so it is refused instead of + defaulted. + + The config must be in the MR's live conflict set; ``version`` is + taken from the diff's ``theirs.version`` (the default-branch version + being re-anchored onto). Rebasing every conflicting config makes the + MR mergeable -- no re-validate step exists or is needed. + """ + if (take is None) == (resolved is None): + raise ConfigError("Pass exactly one of take=ours|theirs|delete or a resolved body.") + if take is not None and take not in TAKE_MODES: + raise ConfigError(f"Unknown take mode {take!r}: use {', '.join(TAKE_MODES)}.") + + project = self._project(alias) + client = self._client_factory(project.stack_url, project.token) + try: + self._require_merge_requests_feature(client) + branch_id = self._branch_from_id_of(client, merge_request_id) + 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 {} + onto_version = theirs.get("version") + if onto_version is None: + raise KeboolaApiError( + message=( + f"The diff of {component_id}/{config_id} has no theirs side -- " + "cannot determine the default-branch version to rebase onto." + ), + status_code=0, + error_code=ErrorCode.VALIDATION_ERROR, + retryable=False, + ) + + # Normalize the three sources of a replace body onto one flat + # shape: a take side contributes its nested ``diff`` envelope, a + # caller-authored body is already flat. + body: dict[str, Any] | None = resolved + resolution = "custom" + if take == "delete": + body, resolution = None, "delete" + elif take is not None: + side = diff.get("ours") if take == "ours" else diff.get("theirs") + if side is None or side.get("isDeleted"): + # Taking a deleted (or never-existing) side IS the delete + # resolution -- symmetric for ours and theirs. + body, resolution = None, "delete" + else: + body, resolution = side.get("diff") or {}, take + + warnings: list[str] = [] + if body is None: + configuration = client.rebase_config_delete( + component_id, config_id, branch_id, version=onto_version + ) + if change_description is not None: + # The delete envelope is exactly {"version": N, "diff": {}} + # -- changeDescription lives INSIDE the diff envelope, so + # the wire cannot carry one and the server writes its + # default message. Say so instead of silently ignoring it. + warnings.append( + "change_description ignored: the delete resolution's wire " + "envelope cannot carry one; the server records its default " + "message." + ) + else: + # Present-but-null defeats a bare presence check for every + # key, not just `name`: the backend's + # `?? new stdClass()` fires on null as well as on absence, so + # a `configuration: null` would REPLACE the config body with + # {} and return 200 -- silent data loss, the exact thing this + # guard exists to refuse. A null `rows` is equally ambiguous + # (`[]` means delete-all-rows and must stay expressible, null + # means nothing). The same replace semantics cover the other + # two fields: a defaulted `isDisabled` would silently + # re-enable a disabled config, a defaulted `description` + # would wipe one -- so ALL FIVE replaced-body keys are + # required. And `name` must additionally be non-empty: the + # diff envelope declares it nullable, but the rebase + # validator requires a non-empty trimmed string. + missing = [ + key + for key in ("name", "rows", "configuration", "isDisabled") + if key not in body or body[key] is None + ] + # description is the one genuinely nullable replaced field, + # and the two paths differ: a CALLER-authored body must spell + # it out (an explicit null is a decision, an omission is not), + # but a take side is the server's own envelope with no intent + # to elicit -- and rebase_config omits the key for None + # anyway, so absent and null are wire-identical there. + # Refusing the take side would prescribe hand-authoring the + # very body this code just declined to compose. + if take is None and "description" not in body: + missing.append("description") + if "name" not in missing and not str(body.get("name") or "").strip(): + missing.insert(0, "name") + if "isDisabled" not in missing and not isinstance(body["isDisabled"], bool): + # bool("false") is True: a string boolean from a hand-edited + # or tool-generated file would DISABLE the config on a + # replace and return 200. Refuse, per the guard's + # refuse-don't-default policy -- blaming the right party: + # on a take side the envelope is server-produced. + detail = ( + "isDisabled must be a JSON boolean (true/false), got " + f"{type(body['isDisabled']).__name__} {body['isDisabled']!r}." + ) + if take is not None: + raise KeboolaApiError( + message=( + f"The diff's {take} side carries a non-boolean " + f"isDisabled -- {detail} Author the resolution " + "manually and pass it as a resolved body instead." + ), + status_code=0, + error_code=ErrorCode.VALIDATION_ERROR, + retryable=False, + ) + raise ConfigError(detail) + if missing: + if take is not None: + # The envelope is server-produced with these keys + # required by its schema -- a hole is a backend + # contract violation, not caller error. Point at the + # manual path as the workaround. + raise KeboolaApiError( + message=( + f"The diff's {take} side carries no " + f"{', '.join(missing)} -- cannot compose a replace " + "body from it. Author the resolution manually and " + "pass it as a resolved body instead." + ), + status_code=0, + error_code=ErrorCode.VALIDATION_ERROR, + retryable=False, + ) + raise ConfigError( + "A resolved body must spell out the full replaced content " + f"(rebase REPLACES): missing {', '.join(missing)}." + ) + configuration = client.rebase_config( + component_id, + config_id, + branch_id, + version=onto_version, + name=body["name"], + rows=body["rows"], + configuration=body["configuration"], + is_disabled=body["isDisabled"], + description=body.get("description"), + change_description=change_description, + ) + finally: + client.close() + + result: dict[str, Any] = { + "alias": alias, + "merge_request_id": merge_request_id, + "component_id": component_id, + "config_id": config_id, + "branch_id": branch_id, + "resolution": resolution, + "onto_version": onto_version, + "configuration": configuration, + } + if warnings: + result["warnings"] = warnings + return result + + def _branch_from_id_of(self, client: KeboolaClient, merge_request_id: int) -> int: + """The MR's source branch id -- the only branch a resolution may write to. + + The null check is best-effort, not airtight: `branchFromId` is nulled + by the FK's ON DELETE SET NULL when the source branch row is deleted, + and that deletion is a separate async job -- a freshly published MR + can still carry the id for a while. Harmless: the rebase then fails + server-side (the MR is no longer open), it just fails later. + """ + mr = client.merge_requests.get(merge_request_id) + raw = (mr.get("branches") or {}).get("branchFromId") + branch_from_id = _coerce_branch_id(raw) + if branch_from_id is None: + # "Absent" and "present but not numeric" are different faults -- + # one points at the MR lifecycle, the other at the payload. + message = ( + f"Merge request {merge_request_id} has no source branch (state: " + f"{mr.get('state', 'unknown')}) -- a published or canceled MR " + "cannot be resolved." + if raw is None + else ( + f"Merge request {merge_request_id}'s branchFromId is {raw!r} -- " + "not a numeric branch id; cannot resolve against it." + ) + ) + raise KeboolaApiError( + message=message, + status_code=0, + error_code=ErrorCode.VALIDATION_ERROR, + retryable=False, + ) + return branch_from_id + + def _require_in_conflict_set( + self, client: KeboolaClient, merge_request_id: int, component_id: str, config_id: str + ) -> None: + """Refuse to rebase a config the MR does not list as conflicting.""" + for conflict in client.merge_requests.conflicts(merge_request_id): + if conflict.get("componentId") == component_id and str( + conflict.get("configurationId") + ) == str(config_id): + return + raise KeboolaApiError( + message=( + f"{component_id}/{config_id} is not in merge request " + f"{merge_request_id}'s conflict set -- nothing to resolve. " + "See `kbagent merge-request conflicts` for the current set." + ), + status_code=0, + error_code=ErrorCode.VALIDATION_ERROR, + retryable=False, + ) diff --git a/src/keboola_agent_cli/services/search_service.py b/src/keboola_agent_cli/services/search_service.py index 89b9619ce..d25ea1b7f 100644 --- a/src/keboola_agent_cli/services/search_service.py +++ b/src/keboola_agent_cli/services/search_service.py @@ -16,7 +16,7 @@ from typing import Any from ..constants import GLOBAL_SEARCH_FEATURE -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ProjectConfig from .base import BaseService, sanitize_unexpected_error from .config_service import ConfigService @@ -202,7 +202,7 @@ def _search_project_textual( alias, { "project_alias": alias, - "error_code": "FEATURE_NOT_ENABLED", + "error_code": str(ErrorCode.FEATURE_NOT_ENABLED), "message": ( f"Project does not have the '{GLOBAL_SEARCH_FEATURE}' feature " "enabled. Use --search-type config-based for body scanning, or " diff --git a/src/keboola_agent_cli/services/sync_service.py b/src/keboola_agent_cli/services/sync_service.py index 746a3ecc5..57fc02bd1 100644 --- a/src/keboola_agent_cli/services/sync_service.py +++ b/src/keboola_agent_cli/services/sync_service.py @@ -110,7 +110,7 @@ stamp_created_config, stamp_updated_config, ) -from .base import BaseService +from .base import BaseService, find_default_branch_id logger = logging.getLogger(__name__) @@ -308,11 +308,7 @@ def init_sync( if project_id is None: raise ConfigError("Token verification returned no project ID; cannot build manifest.") api_host = project.stack_url.replace("https://", "").rstrip("/") - default_branch_info = next( - (b for b in branches if b.get("isDefault")), - None, - ) - default_branch_id = default_branch_info["id"] if default_branch_info else None + default_branch_id = find_default_branch_id(branches) default_branch_name = "main" # Git branching setup @@ -339,7 +335,7 @@ def init_sync( path=default_branch_name, ) ] - if default_branch_id + if default_branch_id is not None else [], configurations=[], ) diff --git a/src/keboola_agent_cli/services/workspace_service.py b/src/keboola_agent_cli/services/workspace_service.py index f41cd48fd..0212020ed 100644 --- a/src/keboola_agent_cli/services/workspace_service.py +++ b/src/keboola_agent_cli/services/workspace_service.py @@ -38,7 +38,7 @@ coerce_data_size_bytes, plan_auto_load_type, ) -from .base import BaseService +from .base import BaseService, find_default_branch_id logger = logging.getLogger(__name__) @@ -232,10 +232,9 @@ def _resolve_branch_id(self, alias: str, project: ProjectConfig) -> int: client = self._client_factory(project.stack_url, project.token) try: - branches = client.list_dev_branches() - for branch in branches: - if branch.get("isDefault", False): - return int(branch["id"]) + default_branch_id = find_default_branch_id(client.list_dev_branches()) + if default_branch_id is not None: + return default_branch_id raise ConfigError( f"No default branch found for project '{alias}'. " "Set an active branch with 'kbagent branch use'." diff --git a/tests/test_http_base.py b/tests/test_http_base.py index 5b1da5056..0391738c4 100644 --- a/tests/test_http_base.py +++ b/tests/test_http_base.py @@ -10,6 +10,7 @@ from keboola_agent_cli.constants import ( APP_NAME, MAX_API_ERROR_LENGTH, + MAX_API_ERROR_PARAMS_LIST_ITEMS, MAX_EXCEPTION_ID_LENGTH, MAX_RETRIES, ) @@ -1240,3 +1241,153 @@ def test_401_with_no_message_at_all_stays_invalid_token(self, httpx_mock) -> Non assert exc_info.value.error_code == ErrorCode.INVALID_TOKEN finally: client.close() + + +class TestApiErrorCodeDetails: + """The body's machine string `code` must survive into KeboolaApiError.details.""" + + def _client(self) -> BaseHttpClient: + return BaseHttpClient( + base_url=STACK_URL, + token=TOKEN, + headers={"X-StorageApi-Token": TOKEN}, + ) + + def test_body_code_lands_in_details(self, httpx_mock) -> None: + # The merge 409's "not ready" shape -- the message carries only the + # human `error` text, so `code` in details is the ONLY machine handle + # (MergeRequestService.merge() branches on it, DMD-1899). + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={ + "error": "Cannot merge, another merge request is processing.", + "code": "storage.mergeRequests.notReadyToMerge", + }, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert ( + exc_info.value.details["api_error_code"] == "storage.mergeRequests.notReadyToMerge" + ) + finally: + client.close() + + def test_no_code_means_no_details_key(self, httpx_mock) -> None: + # The merge 409's conflict shape carries no string code -- details + # must not invent one. + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={"error": "Configuration was changed in the default branch."}, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert "api_error_code" not in exc_info.value.details + finally: + client.close() + + def test_non_string_code_ignored(self, httpx_mock) -> None: + # Keboola Metastore puts an int HTTP status into `error`; guard the + # same way against a non-string `code`. + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=404, + json={"error": "not found", "code": 404}, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert "api_error_code" not in exc_info.value.details + finally: + client.close() + + def test_params_context_lands_in_details(self, httpx_mock) -> None: + # The merge-conflict 409 delivers the conflicting configurations in + # params.errors (ExceptionConverter serializes HttpException context) + # -- surface it so callers don't re-fetch data the error carried. + params = {"errors": [{"componentId": "c", "configurationId": "1"}]} + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={ + "error": "Merge request 7 cannot be merged.", + "code": "storage.mergeRequests.validation", + "params": params, + }, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert exc_info.value.details["api_error_params"] == params + finally: + client.close() + + def test_small_params_pass_through_unbounded(self, httpx_mock) -> None: + params = {"errors": [{"componentId": "c", "configurationId": str(i)} for i in range(3)]} + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={ + "error": "conflict", + "code": "storage.mergeRequests.validation", + "params": params, + }, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + assert exc_info.value.details["api_error_params"] == params + assert "api_error_params_truncated" not in exc_info.value.details + finally: + client.close() + + def test_oversized_params_lists_are_truncated_with_a_marker(self, httpx_mock) -> None: + # A merge over hundreds of conflicting configs must not flood the + # agent-consumed --json envelope: lists cap at + # MAX_API_ERROR_PARAMS_LIST_ITEMS, and the truncation is flagged. + errors = [ + {"componentId": "keboola.ex-db", "configurationId": str(i), "message": "x" * 100} + for i in range(500) + ] + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={"error": "conflict", "params": {"errors": errors}}, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + details = exc_info.value.details + assert details["api_error_params_truncated"] is True + assert len(details["api_error_params"]["errors"]) == MAX_API_ERROR_PARAMS_LIST_ITEMS + # the kept prefix is verbatim + assert details["api_error_params"]["errors"][0]["configurationId"] == "0" + finally: + client.close() + + def test_untruncatable_oversized_params_are_dropped_with_the_marker(self, httpx_mock) -> None: + # No top-level list to slim -- one huge scalar blob. Drop params, keep + # the marker, never crash the error path. + httpx_mock.add_response( + url=f"{STACK_URL}/test-path", + status_code=409, + json={"error": "conflict", "params": {"blob": "x" * 50000}}, + ) + client = self._client() + try: + with pytest.raises(KeboolaApiError) as exc_info: + client._do_request("GET", "/test-path") + details = exc_info.value.details + assert "api_error_params" not in details + assert details["api_error_params_truncated"] is True + finally: + client.close() diff --git a/tests/test_json_utils.py b/tests/test_json_utils.py index 6dffa3c1f..2df2b45da 100644 --- a/tests/test_json_utils.py +++ b/tests/test_json_utils.py @@ -4,6 +4,7 @@ from keboola_agent_cli.json_utils import ( compute_diff, + compute_diff_entries, deep_merge, get_nested_value, set_nested_value, @@ -177,3 +178,67 @@ def test_nested_changes(self) -> None: changes = compute_diff(old, new) assert len(changes) == 1 assert "db.host:" in changes[0] + + +class TestComputeDiffEntries: + """Tests for compute_diff_entries() -- the structured walk under compute_diff().""" + + def test_identical_dicts_yield_no_entries(self) -> None: + d = {"a": 1, "nested": {"b": 2}} + assert compute_diff_entries(d, d) == [] + + def test_value_change_carries_both_sides(self) -> None: + entries = compute_diff_entries({"host": "old"}, {"host": "new"}) + assert len(entries) == 1 + entry = entries[0] + assert entry.path == "host" + assert entry.old == "old" + assert entry.new == "new" + assert entry.old_present and entry.new_present + + def test_added_key_is_absent_on_old_side(self) -> None: + entries = compute_diff_entries({"a": 1}, {"a": 1, "b": 2}) + assert len(entries) == 1 + entry = entries[0] + assert entry.path == "b" + assert not entry.old_present + assert entry.new_present + assert entry.new == 2 + + def test_removed_key_is_absent_on_new_side(self) -> None: + entries = compute_diff_entries({"a": 1, "b": 2}, {"a": 1}) + assert len(entries) == 1 + entry = entries[0] + assert entry.path == "b" + assert entry.old_present + assert not entry.new_present + + def test_explicit_none_is_present_not_absent(self) -> None: + # None is a legal JSON value -- it must not be conflated with absence. + entries = compute_diff_entries({"a": None}, {"a": 1}) + assert len(entries) == 1 + assert entries[0].old_present + assert entries[0].old is None + + def test_nested_paths_are_dotted(self) -> None: + entries = compute_diff_entries( + {"db": {"host": "old", "port": 5432}}, {"db": {"host": "new", "port": 5432}} + ) + assert [e.path for e in entries] == ["db.host"] + + def test_dict_vs_scalar_is_one_whole_path_entry(self) -> None: + entries = compute_diff_entries({"a": {"x": 1}}, {"a": 2}) + assert len(entries) == 1 + assert entries[0].path == "a" + assert entries[0].old == {"x": 1} + assert entries[0].new == 2 + + def test_compute_diff_formats_entries_identically(self) -> None: + # compute_diff is now a formatter over compute_diff_entries; its + # output format is pinned by TestComputeDiff above -- this pins the + # delegation (same paths, same order). + old = {"a": 1, "b": {"c": 2}, "gone": 3} + new = {"a": 9, "b": {"c": 2, "d": 4}} + strings = compute_diff(old, new) + entries = compute_diff_entries(old, new) + assert [s.split(":")[0] for s in strings] == [e.path for e in entries] diff --git a/tests/test_merge_request_service.py b/tests/test_merge_request_service.py new file mode 100644 index 000000000..506cdb31f --- /dev/null +++ b/tests/test_merge_request_service.py @@ -0,0 +1,1494 @@ +"""Tests for MergeRequestService and the status-derivation polyfill (DMD-1899). + +The derivation tables are the canonical spec from the L2 RFC ("Derived +status" section; branch ms/merge-requests-rfcs) and DMD-1988 -- a port of +the UI list badge. When Connection serializes the fields, the server-first +tests keep passing and the fallback tests get deleted with the fallbacks. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.client.merge_requests import MergeRequests +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ( + ConfigError, + ErrorCode, + FeatureNotEnabledError, + KeboolaApiError, +) +from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.services.merge_request_service import ( + MergeRequestService, + derive_allowed_actions, + derive_merge_blockers, + derive_state, + derive_viewer, +) + +CREATOR = {"id": 42, "name": "Martin"} + + +def _mr( + state: str = "development", + reviewers: list[dict[str, Any]] | None = None, + approvals: list[dict[str, Any]] | None = None, + **extra: Any, +) -> dict[str, Any]: + return { + "id": 7, + "state": state, + "creator": CREATOR, + "reviewers": reviewers or [], + "approvals": approvals or [], + **extra, + } + + +class TestDeriveState: + def test_plain_states_map_to_client_vocabulary(self) -> None: + assert derive_state(_mr("development")) == "in_development" + assert derive_state(_mr("in_review")) == "in_review" + assert derive_state(_mr("approved")) == "approved" + assert derive_state(_mr("in_merge")) == "in_merge" + assert derive_state(_mr("published")) == "merged" + assert derive_state(_mr("canceled")) == "closed" + + def test_rejected_needs_a_non_creator_rejection_in_development(self) -> None: + mr = _mr("development", reviewers=[{"id": 99, "name": "R", "status": "rejected"}]) + assert derive_state(mr) == "rejected" + + def test_creator_self_rejection_is_closed_not_rejected(self) -> None: + # The UI "cancel" action reuses request-changes: the creator rejects + # their own MR and it stays in development. + mr = _mr("development", reviewers=[{"id": 42, "name": "Martin", "status": "rejected"}]) + assert derive_state(mr) == "closed" + + def test_rejection_outside_development_does_not_override(self) -> None: + # A stale rejected reviewer entry must not relabel a re-submitted MR. + mr = _mr("in_review", reviewers=[{"id": 99, "status": "rejected"}]) + assert derive_state(mr) == "in_review" + + def test_non_creator_rejection_wins_over_self_rejection(self) -> None: + # RFC order: rejected is evaluated before closed. + mr = _mr( + "development", + reviewers=[ + {"id": 42, "status": "rejected"}, + {"id": 99, "status": "rejected"}, + ], + ) + assert derive_state(mr) == "rejected" + + def test_unknown_raw_state_passes_through(self) -> None: + assert derive_state(_mr("some_future_state")) == "some_future_state" + + def test_server_field_wins_over_local_derivation(self) -> None: + # Polyfill contract: once DMD-1988 serializes derivedState, the local + # table must never contradict it. + mr = _mr("development", derivedState="rejected") + assert derive_state(mr) == "rejected" + + def test_reviewer_without_status_is_pending_not_rejection(self) -> None: + mr = _mr("development", reviewers=[{"id": 99, "name": "R", "status": None}]) + assert derive_state(mr) == "in_development" + + +class TestDeriveMergeBlockers: + def test_open_mr_without_conflicts_is_mergeable(self) -> None: + assert derive_merge_blockers(_mr("development"), conflicts=[]) == [] + assert derive_merge_blockers(_mr("approved"), conflicts=[]) == [] + + def test_conflicts_block(self) -> None: + conflicts = [{"componentId": "keboola.snowflake-transformation", "configurationId": "1"}] + assert derive_merge_blockers(_mr("development"), conflicts) == ["conflicts"] + + def test_in_review_blocks_on_approvals(self) -> None: + assert derive_merge_blockers(_mr("in_review"), conflicts=[]) == ["approvals"] + + def test_concurrent_blockers_do_not_mask_each_other(self) -> None: + conflicts = [{"componentId": "c", "configurationId": "1"}] + assert derive_merge_blockers(_mr("in_review"), conflicts) == ["conflicts", "approvals"] + + def test_terminal_and_transient_states_block_on_state(self) -> None: + for state in ("in_merge", "published", "canceled"): + assert derive_merge_blockers(_mr(state), conflicts=[]) == ["state"] + + def test_none_conflicts_means_not_fetched_not_conflict_free(self) -> None: + # List rows don't fetch conflicts; absence of data must not assert + # "no conflicts". + assert derive_merge_blockers(_mr("development"), conflicts=None) == [] + + def test_rejected_mr_has_no_blocker(self) -> None: + # It sits in development; a non-SOX merge from there succeeds + # (auto skip_review). derived_state tells the story instead. + mr = _mr("development", reviewers=[{"id": 99, "status": "rejected"}]) + assert derive_merge_blockers(mr, conflicts=[]) == [] + + def test_server_field_wins(self) -> None: + mr = _mr("development", mergeBlockers=["approvals"]) + assert derive_merge_blockers(mr, conflicts=[]) == ["approvals"] + + +class TestDeriveAllowedActions: + def test_development_offers_submit_and_direct_merge(self) -> None: + assert derive_allowed_actions(_mr("development")) == [ + "request_review", + "merge", + "update", + "resolve_conflicts", + ] + + def test_approve_exists_only_in_in_review(self) -> None: + # The approve transition's sole `from` place is in_review; from + # `approved` the backend answers 422 (Opus wire review 2026-08-27). + assert "approve" in derive_allowed_actions(_mr("in_review")) + assert "approve" not in derive_allowed_actions(_mr("approved")) + assert "approve" not in derive_allowed_actions(_mr("development")) + + def test_request_changes_in_review_and_approved(self) -> None: + assert "request_changes" in derive_allowed_actions(_mr("in_review")) + assert "request_changes" in derive_allowed_actions(_mr("approved")) + + def test_merge_not_offered_from_in_review(self) -> None: + # in_review means approvals are insufficient by definition; the + # moment they suffice the backend auto-transitions to approved. + assert "merge" not in derive_allowed_actions(_mr("in_review")) + + def test_in_merge_offers_only_update(self) -> None: + # The server blocks update only in the terminal states; an in_merge + # MR is still updatable (metadata), nothing else is sensible. + assert derive_allowed_actions(_mr("in_merge")) == ["update"] + + def test_terminal_states_offer_nothing(self) -> None: + for state in ("published", "canceled"): + assert derive_allowed_actions(_mr(state)) == [] + + def test_unknown_state_offers_nothing(self) -> None: + assert derive_allowed_actions(_mr("some_future_state")) == [] + + def test_server_field_wins(self) -> None: + mr = _mr("published", allowedActions=["merge"]) + assert derive_allowed_actions(mr) == ["merge"] + + +class TestDeriveViewer: + def test_creator_is_flagged(self) -> None: + viewer = derive_viewer(_mr(), admin_id=42) + assert viewer == {"is_creator": True, "has_approved": False} + + def test_approver_is_flagged(self) -> None: + # approverId is a string on the wire while admin ids are ints -- the + # comparison must not care (the UI does String(id) for the same + # reason). + mr = _mr("in_review", approvals=[{"approverId": "77", "approverName": "R"}]) + viewer = derive_viewer(mr, admin_id=77) + assert viewer == {"is_creator": False, "has_approved": True} + + def test_no_admin_identity_means_unknown_not_false(self) -> None: + # A scoped token has no admin block; None is honest, False would lie. + viewer = derive_viewer(_mr(), admin_id=None) + assert viewer == {"is_creator": None, "has_approved": None} + + def test_approval_without_approver_id_is_ignored(self) -> None: + mr = _mr("in_review", approvals=[{"approverId": None, "approverName": "gone"}]) + assert derive_viewer(mr, admin_id=42)["has_approved"] is False + + def test_server_field_wins(self) -> None: + mr = _mr(viewer={"isCreator": False, "hasApproved": True}) + assert derive_viewer(mr, admin_id=42) == {"is_creator": False, "has_approved": True} + + +# -- Service-level tests ------------------------------------------------------ + +STACK_URL = "https://connection.keboola.com" +TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +ALIAS = "prod" + + +def _verify_response(admin_id: int | None = 42) -> TokenVerifyResponse: + return TokenVerifyResponse( + token_id="901", + token_description="test", + project_id=10, + project_name="Prod", + owner_name="Prod", + admin_id=admin_id, + ) + + +@pytest.fixture +def store(tmp_config_dir: Path) -> ConfigStore: + s = ConfigStore(config_dir=tmp_config_dir) + s.add_project( + ALIAS, + ProjectConfig(stack_url=STACK_URL, token=TOKEN, project_name="Prod", project_id=10), + ) + return s + + +@pytest.fixture +def client_factory() -> tuple[MagicMock, MagicMock]: + # spec'd at the L3 seam this PR builds on (#556): a renamed or removed + # client/namespace method fails these tests instead of silently keeping + # them green against an interface that no longer exists. + mock = MagicMock(spec=KeboolaClient) + mock.merge_requests = MagicMock(spec=MergeRequests) + mock.verify_token.return_value = _verify_response() + mock.has_feature.return_value = True + factory = MagicMock(return_value=mock) + return factory, mock + + +def _svc(store: ConfigStore, factory: MagicMock) -> MergeRequestService: + return MergeRequestService(store, client_factory=factory) + + +def _wire_mr( + mr_id: int = 7, + state: str = "development", + branch_from: int | None = 123, + **extra: Any, +) -> dict[str, Any]: + return { + "id": mr_id, + "state": state, + "title": "My MR", + "creator": CREATOR, + "reviewers": [], + "approvals": [], + "branches": {"branchFromId": branch_from, "branchIntoId": 1}, + **extra, + } + + +class TestListMergeRequests: + def test_rows_carry_derived_state(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [ + _wire_mr(1, "published"), + _wire_mr(2, "development"), + ] + result = _svc(store, factory).list_merge_requests(ALIAS) + assert result["count"] == 2 + assert [mr["derived_state"] for mr in result["merge_requests"]] == [ + "merged", + "in_development", + ] + mock.close.assert_called_once() + + def test_state_filter_matches_derived_vocabulary(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [ + _wire_mr(1, "published"), + _wire_mr(2, "development"), + ] + result = _svc(store, factory).list_merge_requests(ALIAS, state="merged") + assert result["count"] == 1 + assert result["merge_requests"][0]["id"] == 1 + assert result["state_filter"] == "merged" + + def test_state_filter_matches_raw_state_too(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [_wire_mr(1, "published")] + result = _svc(store, factory).list_merge_requests(ALIAS, state="published") + assert result["count"] == 1 + + def test_rejected_filter_uses_reviewer_derivation(self, store, client_factory) -> None: + factory, mock = client_factory + rejected = _wire_mr(1, "development", reviewers=[{"id": 99, "status": "rejected"}]) + mock.merge_requests.list.return_value = [rejected, _wire_mr(2, "development")] + result = _svc(store, factory).list_merge_requests(ALIAS, state="rejected") + assert [mr["id"] for mr in result["merge_requests"]] == [1] + + +class TestFindMergeRequestForBranch: + def test_finds_by_branch_from_id(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [ + _wire_mr(1, branch_from=111), + _wire_mr(2, branch_from=222), + ] + result = _svc(store, factory).find_merge_request_for_branch(ALIAS, 222) + assert result["id"] == 2 + assert result["alias"] == ALIAS + assert result["derived_state"] == "in_development" + + def test_branch_without_mr_raises_not_found_with_next_step(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [_wire_mr(1, branch_from=111)] + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).find_merge_request_for_branch(ALIAS, 999) + assert exc_info.value.error_code == ErrorCode.NOT_FOUND + assert "merge-request create" in exc_info.value.message + + +class TestGetMergeRequest: + def test_open_mr_fetches_conflicts_and_derives_everything(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "in_review") + mock.merge_requests.conflicts.return_value = [{"componentId": "c", "configurationId": "1"}] + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + assert detail["derived_state"] == "in_review" + assert detail["merge_blockers"] == ["conflicts", "approvals"] + assert detail["mergeable"] is False + assert detail["allowed_actions"] == [ + "approve", + "request_changes", + "update", + "resolve_conflicts", + ] + assert detail["viewer"] == {"is_creator": True, "has_approved": False} + assert detail["conflicts_count"] == 1 + mock.merge_requests.conflicts.assert_called_once_with(7) + + def test_mergeable_open_mr(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.conflicts.return_value = [] + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + assert detail["merge_blockers"] == [] + assert detail["mergeable"] is True + assert detail["conflicts"] == [] + + def test_closed_mr_skips_conflicts_and_is_not_mergeable(self, store, client_factory) -> None: + # The source branch of a published/canceled MR is deleted; the + # conflicts endpoint is moot there and must not be called. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + mock.merge_requests.conflicts.assert_not_called() + assert detail["merge_blockers"] == ["state"] + assert detail["mergeable"] is False + assert "conflicts" not in detail + + def test_activity_log_flag_passes_through(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) + _svc(store, factory).get_merge_request(ALIAS, 7, include_activity_log=True) + mock.merge_requests.get.assert_called_once_with(7, include_activity_log=True) + + def test_scoped_token_yields_unknown_viewer(self, store, client_factory) -> None: + factory, mock = client_factory + mock.verify_token.return_value = _verify_response(admin_id=None) + mock.merge_requests.get.return_value = _wire_mr(7, "development") + mock.merge_requests.conflicts.return_value = [] + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + assert detail["viewer"] == {"is_creator": None, "has_approved": None} + + +DEFAULT_BRANCH = {"id": 1, "name": "Main", "isDefault": True} +DEV_BRANCH = {"id": 123, "name": "feature", "isDefault": False} + + +class TestCreateMergeRequest: + def test_targets_the_default_branch_automatically(self, store, client_factory) -> None: + factory, mock = client_factory + mock.list_dev_branches.return_value = [DEFAULT_BRANCH, DEV_BRANCH] + mock.merge_requests.create.return_value = _wire_mr(9, "development", branch_from=123) + result = _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + kwargs = mock.merge_requests.create.call_args.kwargs + assert kwargs["branch_from_id"] == 123 + assert kwargs["branch_into_id"] == 1 + assert result["branch_from_id"] == 123 + assert result["derived_state"] == "in_development" + + def test_refuses_the_default_branch_as_source(self, store, client_factory) -> None: + factory, mock = client_factory + mock.list_dev_branches.return_value = [DEFAULT_BRANCH, DEV_BRANCH] + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).create_merge_request(ALIAS, 1, "My MR") + assert "default" in str(exc_info.value) + mock.merge_requests.create.assert_not_called() + + def test_preflight_blocks_without_the_feature(self, store, client_factory) -> None: + factory, mock = client_factory + mock.has_feature.return_value = False + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + assert "branches-merge-requests" in str(exc_info.value) + mock.merge_requests.create.assert_not_called() + + def test_optional_fields_pass_through(self, store, client_factory) -> None: + factory, mock = client_factory + mock.list_dev_branches.return_value = [DEFAULT_BRANCH] + mock.merge_requests.create.return_value = _wire_mr(9) + _svc(store, factory).create_merge_request( + ALIAS, + 123, + "My MR", + description="d", + reviewer_ids=[5, 6], + external_id="TICKET-1", + ) + kwargs = mock.merge_requests.create.call_args.kwargs + assert kwargs["reviewer_ids"] == [5, 6] + assert kwargs["external_id"] == "TICKET-1" + + +class TestUpdateAndTransitions: + def test_update_passes_fields_and_enriches(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.update.return_value = _wire_mr(7, "in_review") + result = _svc(store, factory).update_merge_request(ALIAS, 7, title="New") + assert mock.merge_requests.update.call_args.kwargs["title"] == "New" + assert result["derived_state"] == "in_review" + + def test_request_review(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.request_review.return_value = _wire_mr(7, "approved") + result = _svc(store, factory).request_review(ALIAS, 7) + # Non-SOX default of 0 required approvals: lands straight in approved. + assert result["derived_state"] == "approved" + + def test_approve(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.approve.return_value = _wire_mr(7, "approved") + result = _svc(store, factory).approve(ALIAS, 7) + assert result["derived_state"] == "approved" + + def test_request_changes_carries_reason(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.request_changes.return_value = _wire_mr(7, "development") + _svc(store, factory).request_changes(ALIAS, 7, reason="fix the mapping") + assert mock.merge_requests.request_changes.call_args.kwargs["reason"] == "fix the mapping" + + def test_every_write_runs_the_preflight(self, store, client_factory) -> None: + factory, mock = client_factory + mock.has_feature.return_value = False + svc = _svc(store, factory) + for call in ( + lambda: svc.update_merge_request(ALIAS, 7, title="x"), + lambda: svc.request_review(ALIAS, 7), + lambda: svc.approve(ALIAS, 7), + lambda: svc.request_changes(ALIAS, 7), + ): + with pytest.raises(ConfigError): + call() + + +class TestMerge: + def _arm_merge(self, mock: MagicMock, branch_from: int | None = 123) -> None: + mock.merge_requests.get.return_value = _wire_mr(7, "approved", branch_from=branch_from) + mock.merge_requests.merge.return_value = { + "id": 555, + "status": "success", + "results": {**_wire_mr(7, "published", branch_from=None)}, + } + + def test_happy_path_says_being_deleted_never_deleted( + self, store, client_factory, monkeypatch + ) -> None: + factory, mock = client_factory + self._arm_merge(mock) + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: None, + ) + result = _svc(store, factory).merge(ALIAS, 7) + assert "is being deleted" in result["message"] + assert "is deleted" not in result["message"].replace("is being deleted", "") + assert result["branch_from_id"] == 123 + assert result["state"] == "published" + assert result["derived_state"] == "merged" + + def test_active_branch_reset_only_when_it_was_the_merged_one( + self, store, client_factory, monkeypatch + ) -> None: + factory, mock = client_factory + self._arm_merge(mock) + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: None, + ) + store.set_project_branch(ALIAS, 999) # a DIFFERENT branch is active + result = _svc(store, factory).merge(ALIAS, 7) + assert result["was_active"] is False + assert store.get_project(ALIAS).active_branch_id == 999 # untouched + + store.set_project_branch(ALIAS, 123) # the merged branch is active + result = _svc(store, factory).merge(ALIAS, 7) + assert result["was_active"] is True + assert store.get_project(ALIAS).active_branch_id is None + + def test_sync_mapping_cleanup_is_reported(self, store, client_factory, monkeypatch) -> None: + factory, mock = client_factory + self._arm_merge(mock) + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: {"project_root": "/x", "git_branches_unlinked": ["feat/a"]}, + ) + result = _svc(store, factory).merge(ALIAS, 7) + assert result["mapping_cleanup"]["git_branches_unlinked"] == ["feat/a"] + assert "feat/a" in result["message"] + + def test_cleanup_failure_degrades_to_warning_not_error( + self, store, client_factory, monkeypatch + ) -> None: + factory, mock = client_factory + self._arm_merge(mock) + + def boom(branch_id: int) -> None: + raise OSError("disk full") + + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", boom + ) + result = _svc(store, factory).merge(ALIAS, 7) # must NOT raise + assert any("disk full" in w for w in result["cleanup_warnings"]) + + def test_409_with_code_maps_to_not_ready_retryable(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="API error 409: Cannot merge, another merge request is processing.", + status_code=409, + error_code=ErrorCode.API_ERROR, + details={"api_error_code": "storage.mergeRequests.notReadyToMerge"}, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).merge(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.MR_NOT_READY_TO_MERGE + assert exc_info.value.retryable is True + + def test_409_without_code_maps_to_conflict_with_next_step(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="API error 409: Configuration was changed in the default branch.", + status_code=409, + error_code=ErrorCode.API_ERROR, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).merge(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.MR_MERGE_CONFLICT + assert exc_info.value.retryable is False + assert "merge-request conflicts" in exc_info.value.message + + def test_non_409_errors_pass_through_unmapped(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="job failed", + status_code=0, + error_code=ErrorCode.STORAGE_JOB_FAILED, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).merge(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.STORAGE_JOB_FAILED + + def test_failed_merge_does_no_cleanup(self, store, client_factory, monkeypatch) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="conflict", status_code=409, error_code=ErrorCode.API_ERROR + ) + called: list[int] = [] + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: called.append(branch_id), + ) + store.set_project_branch(ALIAS, 123) + with pytest.raises(KeboolaApiError): + _svc(store, factory).merge(ALIAS, 7) + assert called == [] + assert store.get_project(ALIAS).active_branch_id == 123 + + def test_preflight_blocks_without_the_feature(self, store, client_factory) -> None: + factory, mock = client_factory + mock.has_feature.return_value = False + with pytest.raises(ConfigError): + _svc(store, factory).merge(ALIAS, 7) + mock.merge_requests.merge.assert_not_called() + + +def _diff( + base: dict[str, Any] | None, + ours: dict[str, Any] | None, + theirs: dict[str, Any] | None, +) -> dict[str, Any]: + return {"base": base, "ours": ours, "theirs": theirs} + + +def _side( + configuration: dict[str, Any], + version: int = 5, + is_deleted: bool = False, + rows: list[dict[str, Any]] | None = None, + **diff_extra: Any, +) -> dict[str, Any]: + """One diff side in the verified wire shape (ConfigurationVersionResponse): + version/isDeleted as side metadata, content nested under ``diff``.""" + return { + "version": version, + "isDeleted": is_deleted, + "diff": { + "name": "My config", + "description": None, + "changeDescription": "edited", + "isDisabled": False, + "configuration": configuration, + "rows": rows if rows is not None else [], + **diff_extra, + }, + } + + +CONFLICT_ENTRY = {"componentId": "keboola.ex-db", "configurationId": "111"} + + +class TestGetConfigDiff: + def test_classifies_ours_theirs_both(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100, "timeout": 30, "flag": True}, version=3), + ours=_side({"limit": 500, "timeout": 30, "flag": False}, version=4), + theirs=_side({"limit": 250, "timeout": 60, "flag": True}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + # The branch is derived from the MR, never caller-supplied -- the L3 + # diff call must receive branchFromId (123), and the result echoes it. + mock.get_config_diff.assert_called_once_with("keboola.ex-db", "111", 123) + assert result["branch_id"] == 123 + assert result["merge_request_id"] == 7 + by_path = {c["path"]: c for c in result["changes"]} + assert by_path["configuration.limit"] == { + "path": "configuration.limit", + "changed_by": "both", + "agreed": False, + "base": 100, + "ours": 500, + "theirs": 250, + } + assert by_path["configuration.timeout"]["changed_by"] == "theirs" + # The side that did NOT touch the path still holds the base value. + assert by_path["configuration.timeout"]["ours"] == 30 + assert by_path["configuration.flag"]["changed_by"] == "ours" + assert "agreed" not in by_path["configuration.flag"] # only on `both` rows + assert result["onto_version"] == 7 + assert result["ours_deleted"] is False + assert result["theirs_deleted"] is False + + def test_identical_change_on_both_sides_is_agreed(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 500}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + (change,) = result["changes"] + assert change["changed_by"] == "both" + assert change["agreed"] is True + + def test_removed_key_shows_none_not_base(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"secret": "old"}, version=3), + ours=_side({}, version=4), # ours REMOVED the key + theirs=_side({"secret": "new"}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + (change,) = result["changes"] + assert change["changed_by"] == "both" + assert change["agreed"] is False + assert change["ours"] is None # removed, never the base value + assert change["theirs"] == "new" + + def test_deleted_side_is_flagged_not_a_path(self, store, client_factory) -> None: + # Deletion is side metadata (isDeleted, top-level on the wire), not a + # content path -- it must surface as a boolean, or the "Only you / + # Only production" rendering would hide the most consequential + # difference. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 100}, version=4), + theirs=_side({"limit": 100}, version=7, is_deleted=True), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["theirs_deleted"] is True + assert result["ours_deleted"] is False + assert result["changes"] == [] # identical content, no paths + + def test_null_side_reports_none_deleted_flag_and_no_paths(self, store, client_factory) -> None: + # A null side is a side-level fact (the config never existed there); + # per-path rows against an empty stand-in would contradict the + # ours_deleted/theirs_deleted flags, so none are emitted. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=None, + ours=None, # never existed on this side + theirs=_side({"a": 2}, version=7), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["ours_deleted"] is None + assert result["changes"] == [] + + def test_change_description_is_not_content(self, store, client_factory) -> None: + # changeDescription is a per-version commit message; two sides always + # differ there and it is not something a resolution decides. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({"a": 1}, version=3, changeDescription="base msg"), + ours=_side({"a": 1}, version=4, changeDescription="ours msg"), + theirs=_side({"a": 1}, version=7, changeDescription="theirs msg"), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["changes"] == [] + + def test_rows_compare_wholesale(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.get_config_diff.return_value = _diff( + base=_side({}, version=3, rows=[{"id": "r1"}]), + ours=_side({}, version=4, rows=[{"id": "r1"}, {"id": "r2"}]), + theirs=_side({}, version=7, rows=[{"id": "r1"}]), + ) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + (change,) = result["changes"] + assert change["path"] == "rows" + assert change["changed_by"] == "ours" + + +class TestResolveConflict: + # Sentinel: "keep the fixture's default side" -- distinct from an + # explicit None, which means "the side does not exist". + _DEFAULT_SIDE: Any = object() + + def _arm( + self, + mock: MagicMock, + ours: Any = _DEFAULT_SIDE, + theirs: Any = _DEFAULT_SIDE, + ) -> None: + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4) if ours is self._DEFAULT_SIDE else ours, + theirs=(_side({"limit": 250}, version=7) if theirs is self._DEFAULT_SIDE else theirs), + ) + mock.rebase_config.return_value = {"id": "111", "version": 8} + mock.rebase_config_delete.return_value = {"id": "111", "version": 8, "isDeleted": True} + + def test_branch_is_derived_from_the_mr_never_supplied(self, store, client_factory) -> None: + # Finding #1 of the PR review: a caller-supplied branch could point + # the rebase at a branch the conflict-set guard never checked. + factory, mock = client_factory + self._arm(mock) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="ours" + ) + mock.get_config_diff.assert_called_once_with("keboola.ex-db", "111", 123) + args = mock.rebase_config.call_args.args + assert args == ("keboola.ex-db", "111", 123) + assert result["branch_id"] == 123 + + def test_closed_mr_cannot_be_resolved(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="ours") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + mock.rebase_config.assert_not_called() + + def test_take_ours_rebases_dev_content_onto_theirs_version(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="ours" + ) + kwargs = mock.rebase_config.call_args.kwargs + assert kwargs["version"] == 7 # theirs.version, never ours' + assert kwargs["configuration"] == {"limit": 500} + assert kwargs["name"] == "My config" # from the side's diff envelope + assert result["resolution"] == "ours" + assert result["onto_version"] == 7 + + def test_take_theirs_rebases_production_content(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + kwargs = mock.rebase_config.call_args.kwargs + assert kwargs["configuration"] == {"limit": 250} + assert kwargs["version"] == 7 + + def test_take_delete_sends_the_tombstone(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="delete" + ) + mock.rebase_config_delete.assert_called_once_with("keboola.ex-db", "111", 123, version=7) + assert result["resolution"] == "delete" + + def test_take_ours_of_a_deleted_side_becomes_delete(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock, ours=_side({}, version=4, is_deleted=True)) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="ours" + ) + assert result["resolution"] == "delete" + mock.rebase_config.assert_not_called() + + def test_take_theirs_of_a_deleted_side_becomes_delete(self, store, client_factory) -> None: + # "Production deleted it, dev changed it" is a live conflict shape -- + # symmetric with the ours mirror (review finding #3). + factory, mock = client_factory + self._arm(mock, theirs=_side({}, version=7, is_deleted=True)) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="theirs" + ) + assert result["resolution"] == "delete" + mock.rebase_config_delete.assert_called_once_with("keboola.ex-db", "111", 123, version=7) + + def test_take_side_without_content_keys_blames_the_server_not_the_caller( + self, store, client_factory + ) -> None: + # The side's diff envelope is server-produced with all content keys + # required -- a hole is a backend contract violation and the message + # must point at the manual path, not lecture the caller about a body + # they never supplied (review finding #2). + factory, mock = client_factory + self._arm(mock, theirs={"version": 7, "isDeleted": False, "diff": {"name": "x"}}) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert "theirs side carries no" in exc_info.value.message + assert "resolved body" in exc_info.value.message # names the workaround + + def test_missing_theirs_version_is_refused(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock, theirs=None) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="ours") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert "theirs side" in exc_info.value.message + + def test_custom_body_must_spell_out_replaced_content(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", resolved={"name": "n"} + ) + assert "rows" in str(exc_info.value) and "configuration" in str(exc_info.value) + mock.rebase_config.assert_not_called() + + def test_custom_body_rebases_verbatim(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + body = { + "name": "merged", + "rows": [], + "configuration": {"limit": 300}, + "isDisabled": True, + "description": None, # nullable, but must be spelled out + } + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", resolved=body, change_description="3-way" + ) + kwargs = mock.rebase_config.call_args.kwargs + assert kwargs["configuration"] == {"limit": 300} + assert kwargs["is_disabled"] is True + assert kwargs["description"] is None + assert kwargs["change_description"] == "3-way" + assert result["resolution"] == "custom" + + def test_exactly_one_mode_required(self, store, client_factory) -> None: + factory, _ = client_factory + svc = _svc(store, factory) + with pytest.raises(ConfigError): + svc.resolve_conflict(ALIAS, 7, "c", "1") + with pytest.raises(ConfigError): + svc.resolve_conflict(ALIAS, 7, "c", "1", take="ours", resolved={}) + with pytest.raises(ConfigError): + svc.resolve_conflict(ALIAS, 7, "c", "1", take="mine") + + def test_config_outside_conflict_set_is_refused(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.other", "999", take="ours") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + mock.rebase_config.assert_not_called() + + +class TestListConflicts: + def test_returns_count_and_raw_entries(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + result = _svc(store, factory).list_conflicts(ALIAS, 7) + assert result["count"] == 1 + assert result["conflicts"] == [CONFLICT_ENTRY] + + +class TestReviewFollowUps: + """Regression tests for the PR #703 review findings.""" + + def test_string_wire_branch_id_still_matches_find(self, store, client_factory) -> None: + # Finding #6: MR payload ids mix int and str; a string-serialized + # branchFromId must not defeat the branch->MR resolver. + factory, mock = client_factory + mr = _wire_mr(1) + mr["branches"]["branchFromId"] = "123" + mock.merge_requests.list.return_value = [mr] + result = _svc(store, factory).find_merge_request_for_branch(ALIAS, 123) + assert result["id"] == 1 + + def test_string_wire_branch_id_still_resets_active_branch( + self, store, client_factory, monkeypatch + ) -> None: + factory, mock = client_factory + mr = _wire_mr(7, "approved") + mr["branches"]["branchFromId"] = "123" + mock.merge_requests.get.return_value = mr + mock.merge_requests.merge.return_value = {"id": 5, "status": "success", "results": {}} + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: None, + ) + store.set_project_branch(ALIAS, 123) + result = _svc(store, factory).merge(ALIAS, 7) + assert result["was_active"] is True + assert result["branch_from_id"] == 123 # coerced to int + assert store.get_project(ALIAS).active_branch_id is None + + def test_unknown_state_filter_is_refused_with_the_vocabulary( + self, store, client_factory + ) -> None: + factory, mock = client_factory + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).list_merge_requests(ALIAS, state="mereged") + assert "merged" in str(exc_info.value) # names the accepted values + mock.merge_requests.list.assert_not_called() + + def test_no_default_branch_is_a_readable_error(self, store, client_factory) -> None: + factory, mock = client_factory + mock.list_dev_branches.return_value = [DEV_BRANCH] # no isDefault anywhere + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + assert "no default branch" in exc_info.value.message + mock.merge_requests.create.assert_not_called() + + def test_server_viewer_skips_the_verify_token_call(self, store, client_factory) -> None: + # Once DMD-1988 serializes `viewer`, the polyfill's cost (one + # verify_token round-trip per detail) must disappear with it. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr( + 7, "development", viewer={"isCreator": True, "hasApproved": False} + ) + mock.merge_requests.conflicts.return_value = [] + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + mock.verify_token.assert_not_called() + assert detail["viewer"] == {"is_creator": True, "has_approved": False} + + +class TestOpusWireReviewFollowUps: + """Regression tests for the Opus wire-truth review (2026-08-27).""" + + def test_conflict_409_matches_the_validation_code(self, store, client_factory) -> None: + # The conflict 409 DOES carry a machine code + # (storage.mergeRequests.validation) plus the conflicting configs in + # params.errors -- both must survive into the remapped error. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + conflict_params = { + "errors": [{"componentId": "c", "configurationId": "1", "isDeleted": False}] + } + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="Merge request 7 cannot be merged.", + status_code=409, + error_code=ErrorCode.API_ERROR, + details={ + "api_error_code": "storage.mergeRequests.validation", + "api_error_params": conflict_params, + }, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).merge(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.MR_MERGE_CONFLICT + assert exc_info.value.details["api_error_params"] == conflict_params + + def test_unknown_409_code_passes_through_unmapped(self, store, client_factory) -> None: + # A future backend 409 with a different code must NOT be confidently + # mislabeled as a merge conflict. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved") + mock.merge_requests.merge.side_effect = KeboolaApiError( + message="something else entirely", + status_code=409, + error_code=ErrorCode.API_ERROR, + details={"api_error_code": "storage.somethingElse.entirely"}, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).merge(ALIAS, 7) + assert exc_info.value.error_code == ErrorCode.API_ERROR + + def test_null_name_on_a_take_side_is_a_contract_violation(self, store, client_factory) -> None: + # The diff envelope declares name nullable, but the rebase validator + # requires a non-empty string -- a null must not sail through the + # presence check into a server 400. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + side = _side({"limit": 250}, version=7) + side["diff"]["name"] = None + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=side, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert "name" in exc_info.value.message + mock.rebase_config.assert_not_called() + + def test_empty_server_viewer_falls_back_to_local_derivation( + self, store, client_factory + ) -> None: + # Copilot review: a `viewer: {}` (or one with foreign keys) must NOT + # skip verify_token -- the skip predicate and derive_viewer's + # server-field predicate are one function, so they cannot disagree. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", viewer={}) + mock.merge_requests.conflicts.return_value = [] + detail = _svc(store, factory).get_merge_request(ALIAS, 7) + mock.verify_token.assert_called_once() + # admin_id=42 == creator -> locally derived, not None/None + assert detail["viewer"] == {"is_creator": True, "has_approved": False} + + def test_sox_project_gets_the_sox_refusal_not_a_generic_one( + self, store, client_factory + ) -> None: + # A SOX project (protected-default-branch, no branches-merge-requests) + # is refused as deliberate CLI policy -- the message must say so, not + # suggest enabling a feature the project deliberately does not have. + factory, mock = client_factory + mock.has_feature.side_effect = lambda f: f == "protected-default-branch" + with pytest.raises(FeatureNotEnabledError) as exc_info: + _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + assert "SOX" in str(exc_info.value) + assert exc_info.value.error_code == ErrorCode.FEATURE_NOT_ENABLED + mock.merge_requests.create.assert_not_called() + + def test_plain_project_gets_the_enable_hint(self, store, client_factory) -> None: + factory, mock = client_factory + mock.has_feature.return_value = False + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + assert "not enabled" in str(exc_info.value) + assert "SOX" not in str(exc_info.value) + + +class TestLayer1FindingsFollowUps: + """Regression tests for tasks/dmd-1899-findings-from-layer1.md.""" + + def test_every_enriched_return_carries_allowed_actions(self, store, client_factory) -> None: + # Finding #2: a --json consumer of create/transitions must be able to + # answer "what can I do next" without a second call. + factory, mock = client_factory + mock.list_dev_branches.return_value = [{"id": 1, "isDefault": True}] + mock.merge_requests.create.return_value = _wire_mr(9, "development") + created = _svc(store, factory).create_merge_request(ALIAS, 123, "My MR") + assert created["allowed_actions"] == [ + "request_review", + "merge", + "update", + "resolve_conflicts", + ] + + mock.merge_requests.request_review.return_value = _wire_mr(9, "approved") + submitted = _svc(store, factory).request_review(ALIAS, 9) + assert "merge" in submitted["allowed_actions"] + + mock.merge_requests.list.return_value = [_wire_mr(9, "published")] + rows = _svc(store, factory).list_merge_requests(ALIAS)["merge_requests"] + assert rows[0]["allowed_actions"] == [] + + def test_empty_list_reports_feature_enabled_flag(self, store, client_factory) -> None: + # Finding #6: 200 + [] on a project without the feature must be + # tellable from a genuinely empty project. + factory, mock = client_factory + mock.merge_requests.list.return_value = [] + mock.has_feature.return_value = False + result = _svc(store, factory).list_merge_requests(ALIAS) + assert result["count"] == 0 + assert result["feature_enabled"] is False + + def test_non_empty_list_does_not_spend_the_feature_call(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [_wire_mr(1)] + result = _svc(store, factory).list_merge_requests(ALIAS) + assert "feature_enabled" not in result + mock.has_feature.assert_not_called() + + def test_diff_on_a_closed_mr_is_refused(self, store, client_factory) -> None: + # Finding #1: the branch comes from the MR; a published/canceled MR + # has none (FK nulled it), so the diff is refused readably. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "published", branch_from=None) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + mock.get_config_diff.assert_not_called() + + +class TestZajcaReviewFollowUps: + """Regression tests for Zajca's PR #703 review (2026-08-28).""" + + def test_null_configuration_in_resolved_body_is_refused(self, store, client_factory) -> None: + # PHP's `?? new stdClass()` fires on null as well as absence: a + # configuration: null would silently REPLACE the config body with {}. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 250}, version=7), + ) + body = {"name": "My config", "rows": [], "configuration": None} + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert "configuration" in str(exc_info.value) + mock.rebase_config.assert_not_called() + + def test_null_rows_on_a_take_side_is_a_contract_violation(self, store, client_factory) -> None: + # rows: null is ambiguous ([] means delete-all and must stay + # expressible) -- refuse instead of guessing. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + side = _side({"limit": 250}, version=7) + side["diff"]["rows"] = None + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=side, + ) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert "rows" in exc_info.value.message + mock.rebase_config.assert_not_called() + + def test_empty_rows_list_stays_a_legal_resolution(self, store, client_factory) -> None: + # The null guard must NOT swallow the legitimate [] (delete all rows). + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 250}, version=7), + ) + mock.rebase_config.return_value = {"id": "111", "version": 8} + body = { + "name": "My config", + "rows": [], + "configuration": {"limit": 300}, + "isDisabled": False, + "description": None, + } + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", resolved=body + ) + assert result["resolution"] == "custom" + assert mock.rebase_config.call_args.kwargs["rows"] == [] + + def test_find_on_a_featureless_project_says_so_not_not_found( + self, store, client_factory + ) -> None: + # 200 + [] from the ungated list on a project without the feature must + # not become NOT_FOUND prescribing a create that is guaranteed to fail. + factory, mock = client_factory + mock.merge_requests.list.return_value = [] + mock.has_feature.return_value = False + with pytest.raises(FeatureNotEnabledError): + _svc(store, factory).find_merge_request_for_branch(ALIAS, 123) + + def test_find_no_match_with_feature_stays_not_found(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [] + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).find_merge_request_for_branch(ALIAS, 123) + assert exc_info.value.error_code == ErrorCode.NOT_FOUND + + def test_find_happy_path_does_not_spend_the_feature_call(self, store, client_factory) -> None: + factory, mock = client_factory + mock.merge_requests.list.return_value = [_wire_mr(1, branch_from=123)] + _svc(store, factory).find_merge_request_for_branch(ALIAS, 123) + mock.has_feature.assert_not_called() + + +class TestZajcaSecondReviewFollowUps: + """Regression tests for Zajca's second PR #703 review (2026-09-02).""" + + def _arm(self, mock: MagicMock) -> None: + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=_side({"limit": 250}, version=7), + ) + mock.rebase_config.return_value = {"id": "111", "version": 8} + mock.rebase_config_delete.return_value = {"id": "111", "version": 8, "isDeleted": True} + + def test_missing_is_disabled_is_refused_not_defaulted(self, store, client_factory) -> None: + # BLOCKING finding: a defaulted isDisabled=False silently re-enables + # a disabled config -- rebase REPLACES, so the key is required. + factory, mock = client_factory + self._arm(mock) + body = {"name": "n", "rows": [], "configuration": {}, "description": None} + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert "isDisabled" in str(exc_info.value) + mock.rebase_config.assert_not_called() + + def test_missing_description_is_refused_but_explicit_null_is_legal( + self, store, client_factory + ) -> None: + factory, mock = client_factory + self._arm(mock) + body = {"name": "n", "rows": [], "configuration": {}, "isDisabled": False} + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert "description" in str(exc_info.value) + # ... while an explicit null is a decision, not an omission: + body["description"] = None + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", resolved=body + ) + assert result["resolution"] == "custom" + + def test_take_side_forwards_is_disabled_verbatim(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + theirs = _side({"limit": 250}, version=7) + theirs["diff"]["isDisabled"] = True + mock.get_config_diff.return_value = _diff( + base=_side({"limit": 100}, version=3), + ours=_side({"limit": 500}, version=4), + theirs=theirs, + ) + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + assert mock.rebase_config.call_args.kwargs["is_disabled"] is True + + def test_delete_with_change_description_warns_about_the_drop( + self, store, client_factory + ) -> None: + # The delete tombstone's wire envelope cannot carry a + # changeDescription -- say so instead of silently ignoring it. + factory, mock = client_factory + self._arm(mock) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="delete", change_description="bye" + ) + assert any("change_description ignored" in w for w in result["warnings"]) + + def test_delete_without_change_description_has_no_warning(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="delete" + ) + assert "warnings" not in result + + def test_non_numeric_wire_branch_id_degrades_instead_of_raising( + self, store, client_factory, monkeypatch + ) -> None: + # merge(): a garbage branchFromId must not crash the success path -- + # it degrades to "no usable branch id" (cleanup skipped). + factory, mock = client_factory + mr = _wire_mr(7, "approved") + mr["branches"]["branchFromId"] = "not-a-number" + mock.merge_requests.get.return_value = mr + mock.merge_requests.merge.return_value = {"id": 5, "status": "success", "results": {}} + called: list[int] = [] + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: called.append(branch_id), + ) + result = _svc(store, factory).merge(ALIAS, 7) + assert result["branch_from_id"] is None + assert called == [] + # The degradation is RECORDED: cleanup was skipped, the caller hears + # it and gets the manual recovery steps. + assert any("not-a-number" in w and "branch reset" in w for w in result["cleanup_warnings"]) + + def test_failed_branch_reset_does_not_skip_mapping_cleanup( + self, store, client_factory, monkeypatch + ) -> None: + # The two post-merge cleanups are independent try blocks. + factory, mock = client_factory + mock.merge_requests.get.return_value = _wire_mr(7, "approved", branch_from=123) + mock.merge_requests.merge.return_value = {"id": 5, "status": "success", "results": {}} + store.set_project_branch(ALIAS, 123) + + def boom(alias: str, branch_id: object) -> None: + raise OSError("config write failed") + + monkeypatch.setattr(store, "set_project_branch", boom) + cleaned: list[int] = [] + monkeypatch.setattr( + "keboola_agent_cli.sync.branch_mapping.cleanup_branch_id_from_mapping", + lambda branch_id: cleaned.append(branch_id) or None, + ) + result = _svc(store, factory).merge(ALIAS, 7) + assert any( + "active-branch reset failed" in w and "branch reset" in w + for w in result["cleanup_warnings"] + ) + # The message reports the OUTCOME, not the precondition: no claim of + # a reset that did not happen. + assert "Active branch reset to main" not in result["message"] + assert cleaned == [123] # mapping cleanup still ran + + +class TestZajcaThirdReviewFollowUps: + """Regression tests for Zajca's third PR #703 review (2026-09-03).""" + + def _arm(self, mock: MagicMock, **diff_sides: Any) -> None: + mock.merge_requests.get.return_value = _wire_mr(7, "development", branch_from=123) + mock.merge_requests.conflicts.return_value = [CONFLICT_ENTRY] + sides = { + "base": _side({"limit": 100}, version=3), + "ours": _side({"limit": 500}, version=4), + "theirs": _side({"limit": 250}, version=7), + } + sides.update(diff_sides) + mock.get_config_diff.return_value = _diff(sides["base"], sides["ours"], sides["theirs"]) + mock.rebase_config.return_value = {"id": "111", "version": 8} + mock.rebase_config_delete.return_value = {"id": "111", "version": 8, "isDeleted": True} + + def test_string_boolean_is_disabled_is_refused_in_resolved_body( + self, store, client_factory + ) -> None: + # bool("false") is True -- a string boolean from a hand-edited file + # would DISABLE the config on a replace and return 200. + factory, mock = client_factory + self._arm(mock) + body = { + "name": "n", + "rows": [], + "configuration": {}, + "isDisabled": "false", + "description": None, + } + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert "JSON boolean" in str(exc_info.value) + mock.rebase_config.assert_not_called() + + def test_non_boolean_is_disabled_on_a_take_side_blames_the_server( + self, store, client_factory + ) -> None: + factory, mock = client_factory + theirs = _side({"limit": 250}, version=7) + theirs["diff"]["isDisabled"] = "false" + self._arm(mock, theirs=theirs) + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="theirs") + assert exc_info.value.error_code == ErrorCode.VALIDATION_ERROR + assert "resolved body" in exc_info.value.message # names the workaround + mock.rebase_config.assert_not_called() + + def test_is_disabled_forwards_verbatim_not_coerced(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + body = { + "name": "n", + "rows": [], + "configuration": {}, + "isDisabled": False, + "description": None, + } + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert mock.rebase_config.call_args.kwargs["is_disabled"] is False + + def test_take_side_with_absent_description_composes_none(self, store, client_factory) -> None: + # rebase_config omits the key for None and the server treats null and + # absent identically -- refusing a wire-equivalent input would + # prescribe hand-authoring the very body this call composes. + factory, mock = client_factory + theirs = _side({"limit": 250}, version=7) + del theirs["diff"]["description"] + self._arm(mock, theirs=theirs) + result = _svc(store, factory).resolve_conflict( + ALIAS, 7, "keboola.ex-db", "111", take="theirs" + ) + assert result["resolution"] == "theirs" + assert mock.rebase_config.call_args.kwargs["description"] is None + + def test_resolved_body_still_requires_description_presence(self, store, client_factory) -> None: + factory, mock = client_factory + self._arm(mock) + body = {"name": "n", "rows": [], "configuration": {}, "isDisabled": False} + with pytest.raises(ConfigError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", resolved=body) + assert "description" in str(exc_info.value) + + def test_tombstoned_side_yields_no_classification_rows(self, store, client_factory) -> None: + # isDeleted is the same "no content to take" criterion + # resolve_conflict uses; the flags tell the story, not fabricated + # per-path rows. + factory, mock = client_factory + tombstone = {"version": 4, "isDeleted": True, "diff": {}} + self._arm(mock, ours=tombstone) + result = _svc(store, factory).get_config_diff(ALIAS, 7, "keboola.ex-db", "111") + assert result["ours_deleted"] is True + assert result["changes"] == [] + + def test_non_numeric_branch_from_id_names_the_payload_not_the_lifecycle( + self, store, client_factory + ) -> None: + factory, mock = client_factory + mr = _wire_mr(7, "in_review", branch_from=123) + mr["branches"]["branchFromId"] = "b-123" + mock.merge_requests.get.return_value = mr + with pytest.raises(KeboolaApiError) as exc_info: + _svc(store, factory).resolve_conflict(ALIAS, 7, "keboola.ex-db", "111", take="ours") + assert "'b-123'" in exc_info.value.message + assert "published or canceled" not in exc_info.value.message + + +class TestFindDefaultBranchId: + def test_coerces_numeric_ids(self) -> None: + from keboola_agent_cli.services.base import find_default_branch_id + + assert find_default_branch_id([{"isDefault": True, "id": 9}]) == 9 + assert find_default_branch_id([{"isDefault": True, "id": "42"}]) == 42 + + def test_skips_uncoercible_entries_instead_of_raising(self) -> None: + from keboola_agent_cli.services.base import find_default_branch_id + + assert find_default_branch_id([{"isDefault": True, "id": "main"}]) is None + assert find_default_branch_id([{"isDefault": True, "id": None}]) is None + assert find_default_branch_id([{"isDefault": True}]) is None + assert ( + find_default_branch_id([{"isDefault": True, "id": None}, {"isDefault": True, "id": 9}]) + == 9 + ) + + def test_no_default_branch_is_none(self) -> None: + from keboola_agent_cli.services.base import find_default_branch_id + + assert find_default_branch_id([]) is None + assert find_default_branch_id([{"isDefault": False, "id": 1}]) is None