Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9980855
refactor(json): expose compute_diff's walk as structured DiffEntry data
martinsifra Aug 26, 2026
2df791b
feat(errors): MR merge-409 codes + surface the API's string code in d…
martinsifra Aug 26, 2026
0349204
feat(service): MR status-derivation polyfill + admin identity on veri…
martinsifra Aug 26, 2026
c66cca9
feat(service): MergeRequestService reads -- list, find-by-branch, det…
martinsifra Aug 26, 2026
760e533
feat(service): MR create, update and review transitions [DMD-1899]
martinsifra Aug 26, 2026
19b3d87
feat(service): merge -- 409 mapping + post-merge local cleanup [DMD-1…
martinsifra Aug 26, 2026
07e3097
feat(service): conflicts, per-path 3-way diff, resolve via rebase [DM…
martinsifra Aug 26, 2026
1c9c9e6
fix(service): apply the PR #703 self-review findings [DMD-1899]
martinsifra Aug 26, 2026
629625a
fix(service): apply the Opus wire-truth review -- 409 codes, allowed_…
martinsifra Aug 27, 2026
f6bc60b
style(service): hoist side_value out of the loop, typed test sentinel…
martinsifra Aug 27, 2026
a51a6f2
fix(service): one predicate for the server viewer block (Copilot revi…
martinsifra Aug 27, 2026
0257675
feat(service): tell a SOX refusal apart from a missing feature [DMD-1…
martinsifra Aug 27, 2026
13ff700
fix(service): apply the Layer 1 RFC findings [DMD-1899]
martinsifra Aug 27, 2026
22274dc
fix(service,http): apply Zajca's PR review -- null-value guards, feat…
martinsifra Aug 31, 2026
565e239
fix(http): restore details= on the 401 INVALID_TOKEN raise lost in th…
martinsifra Aug 31, 2026
175d45b
fix(service): apply Zajca's second review -- full replace-body guard,…
martinsifra Sep 2, 2026
4dbfc00
docs(code): repoint RFC references to the ms/merge-requests-rfcs bran…
martinsifra Sep 2, 2026
7cd1855
fix(service): apply Zajca's third review -- record degradations, hone…
martinsifra Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
6 changes: 4 additions & 2 deletions src/keboola_agent_cli/client/merge_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/keboola_agent_cli/client/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
Expand All @@ -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().
Expand Down
34 changes: 28 additions & 6 deletions src/keboola_agent_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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``
Expand Down
34 changes: 34 additions & 0 deletions src/keboola_agent_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
}


Expand Down
59 changes: 59 additions & 0 deletions src/keboola_agent_cli/http_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -499,6 +553,7 @@ def _raise_api_error(
status_code=status,
error_code=ErrorCode.INVALID_TOKEN,
retryable=False,
details=details,
)
raise KeboolaApiError(
message=(
Expand All @@ -512,6 +567,7 @@ def _raise_api_error(
status_code=status,
error_code=ErrorCode.AUTH_REJECTED,
retryable=False,
details=details,
)

if status == 403:
Expand All @@ -520,6 +576,7 @@ def _raise_api_error(
status_code=status,
error_code=ErrorCode.ACCESS_DENIED,
retryable=False,
details=details,
)

if status == 404:
Expand All @@ -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
Expand All @@ -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,
)
Loading