Skip to content

feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) - #703

Open
martinsifra wants to merge 15 commits into
mainfrom
ms/dmd-1899/cli-layer-2
Open

feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899)#703
martinsifra wants to merge 15 commits into
mainfrom
ms/dmd-1899/cli-layer-2

Conversation

@martinsifra

@martinsifra martinsifra commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Layer 2 of the merge-request stack (DMD-1899): services/merge_request_service.py over the Layer 3 namespace shipped in #556. No Layer 1 commands yet — those are DMD-1900, so none of the convention-#17 doc surfaces or E2E tests apply to this PR; unit tests only (tests/test_merge_request_service.py, 94 tests).

The design record is docs/merge-requests-layer2.md (first commit) — every decision below is written down there. A full self-review pass (2026-08-27) was applied in the final commit; see "Review fixes" below.

What's in

  • Status-derivation polyfill (pure module-level functions): derived_state (the UI list badge's decision table incl. the rejected / closed-by-creator reviewer overrides), merge_blockers + mergeable (a list, so concurrent blockers don't mask each other), allowed_actions (state-machine table), viewer (is_creator/has_approved, anchored on verify_token's new admin_id). Every function reads the future server-serialized field first (DMD-1988) and falls back to the local table — the fallbacks get deleted when Connection serializes.
  • Reads: list_merge_requests (client-side state filter over the closed derived + raw vocabulary, typos refused), find_merge_request_for_branch (the resolver behind L1's optional --mr-id; a branch has at most one MR ever), get_merge_request (detail with full derived status; conflicts fetched only for open MRs).
  • Writes: create_merge_request (targets the default branch itself; refuses the default as source with a readable error instead of the backend's 404), update_merge_request, request_review / approve / request_changes — all behind the branches-merge-requests pre-flight raising FEATURE_NOT_ENABLED (a missing feature is otherwise a 403 byte-identical to a role denial; SOX-fence assumption in the comment).
  • merge: 409 remapped onto its two wire shapes — MR_NOT_READY_TO_MERGE (retryable; carries storage.mergeRequests.notReadyToMerge) vs MR_MERGE_CONFLICT (not retryable, names merge-request conflicts as the next step); backend counterpart asking for codes on every MR error is DMD-1984. Post-merge cleanup mirrors BranchService.delete_branch (conditional active_branch_id reset, sync-mapping unlink, best-effort with warnings); output says the source branch "is being deleted", never that it's gone.
  • Conflict resolution: list_conflicts, get_config_diff flattened to a per-path changed_by: ours|theirs|both classification (+ agreed: true when both sides made the identical change; ours_deleted/theirs_deleted flags for tombstoned sides), and resolve_conflict where every mode goes through the rebase endpoint: take=ours|theirs composes the full replace body from the side's diff envelope with version=theirs.version, a deleted side collapses to the delete resolution (both directions), a custom body must spell out name/rows/configuration (rebase REPLACES — a defaulted key would be silent data loss). The branch is derived from the MR itself, never caller-supplied. The UI's reset-to-default alternative for take=theirs is tracked as DMD-1987.
  • Infra: http_base now surfaces a Keboola user error's machine string code as KeboolaApiError.details["api_error_code"] (additive, all four raise sites); TokenVerifyResponse gains admin_id/admin_name; FEATURE_BRANCHES_MERGE_REQUESTS renamed to BRANCHES_MERGE_REQUESTS_FEATURE; the isDefault scan hoisted to services.base.find_default_branch_id and the config/sync/workspace copies migrated; json_utils.compute_diff split into structured compute_diff_entries + a byte-identical formatter.

Review fixes (final commit, tasks/pr-703-review.md)

The self-review found one critical wire-shape error: the diff sides were assumed flat, but the verified shape (connection ConfigurationVersionResponse + ConfigurationDiffData) nests all content under a diff envelope with version/isDeleted as side metadata — the original take/classify code would have been dead on arrival against the live API, invisibly, because the test fixtures encoded the same wrong assumption. Now recorded in docs/merge-requests-notes.md's wire-truth table. Also fixed: resolve_conflict could rebase into an unrelated branch (branch now derived from the MR), deleted-side asymmetry, _same_id on branch ids, FEATURE_NOT_ENABLED, spec'd mocks at the L3 seam, and regression tests for every finding.

Second review: wire truth vs. Connection (2026-08-27)

An adversarial Opus review verified every wire assumption against the Connection source (report: tasks/pr-703-opus-wire-review.md): 7 CONFIRMED, 3 MISMATCH, all fixed in the final commit:

  • The conflict 409 is not code-lessExceptionConverter serializes storage.mergeRequests.validation top-level plus the conflicting configs in params.errors. The remap now matches both codes explicitly (unknown 409 codes pass through unmapped), and http_base surfaces params as details.api_error_params — the conflict list travels with the error.
  • approve exists only in in_review (from approved the backend answers 422 — the UI button offering it there is wrong); allowed_actions corrected, update added to in_merge. With the non-SOX default of 0 required approvals, approve is 422 everywhere and in_review is unreachable.
  • derive_state's rejected/self-closed rows are best-effort by wire design: reviewers[].status needs a review_requested anchor that skip_review never writes, and explicit reviewers shadow non-reviewer decisions — so on a default non-SOX project those states are underivable from reviewers[]. The UI badge has the identical blind spot (our table is its port). Documented, not re-derived client-side — the reliable fix is server-side and is now recorded on DMD-1988 (derive from the activity log).

Layer 1 RFC findings applied (2026-08-28, commit 07daa50)

Writing the Layer 1 command RFC (DMD-1900, PR #708) surfaced seven Layer 2 findings (tasks/dmd-1899-findings-from-layer1.md); all verified against Connection and applied:

  • get_config_diff derives the branch from the MR (signature now alias, merge_request_id, component_id, config_id) — the same make-the-wrong-call-unrepresentable reasoning resolve_conflict already had; the resolved branch_id is echoed, a published/canceled MR is refused readably. The old tests passed shifted positional args straight into MagicMock — rewritten to pin the wiring.
  • Every enriched return carries allowed_actions (list rows, find, create, update, transitions, merge's post-merge state) — a --json consumer answers "what can I do next" without a second call.
  • Safety fact recorded: autoMergeStrategy=immediately makes a background backend tick merge any approved MR through the same MergeProcessor under a system token (AutoMergeCandidateRepository.php:38-47, AutoMergeTickHandler.php:86) — create + request-review can end in a production merge with merge() never called. New notes section + both docstrings.
  • Docs: full MergeRequestResponse wire shape in the notes table (merge{} is nested, createdAt top-level, autoMerge* are response fields); MergeRequestVoter cited (scoped token → 403 on detail/conflicts, a different axis than role whitelisting) + 403 added to the two read rows in the layer3 doc.
  • Empty list now carries feature_enabled — 200 + [] on a project without the feature stops being indistinguishable from a genuinely empty project (the extra verify_token GET is spent only on the empty case).
  • STATE_FILTER_VOCABULARY and TAKE_MODES are public so Layer 1 enumerates them in help text and pre-validates to exit 2 (precedent: notification_service.KNOWN_EVENTS).

Known CI caveats

  • Rebased onto current main (0.91.0) — make changelog-check is green again. No version bump here: this lands in the DMD-1899/1900 stack and the bump PR owns the changelog entry (the feat(token): add token list, stop retrying non-idempotent writes (#599) #616 precedent).
  • tests/test_changelog_render.py fails (2 tests) whenever FORCE_COLOR is set in the environment (Warp exports FORCE_COLOR=3): Rich then emits ANSI inside the asserted substrings (New: renders bold, splitting "New: alpha thing."). Pre-existing main-branch test fragility, fully reproducible and unrelated to this PR; worth a tiny follow-up fix on main.

Review pointers

  • The derivation tables are 1:1 with the RFC section "Derived status" and DMD-1988 — that's the contract; if a table looks wrong here, it's wrong there first.
  • resolve_conflict's conflict-set check, the MR-derived branch, and onto_version = theirs.version are the service-level guards Layer 3 deliberately does not have.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 26, 2026

Copy link
Copy Markdown

DMD-1899

martinsifra and others added 9 commits August 27, 2026 01:21
…) [DMD-1899]

L2 RFC decisions from the 2026-08-26 brainstorm: service shape (house pattern,
verb_noun methods), derived status (4 derivates - derived_state, merge_blockers,
allowed_actions, viewer; polyfill until DMD-1988), create source-branch via
resolve_branch, merge-409 error codes mapped in the service (backend counterpart
DMD-1984), conflict resolution uniformly via rebase (take ours/theirs; UI
reset-to-default discrepancy filed as DMD-1987), per-path 3-way diff presentation,
post-merge cleanup mirroring delete_branch, feature-constant rename; mcp_parity
section marked obsolete (map removed in 0.85.0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
compute_diff returned pre-formatted strings, so a caller needing the
changed paths as data (the MR three-way conflict classification, DMD-1899,
intersects two pairwise diffs per path) had nothing to build on short of
parsing the strings back. Split the recursive walk into
compute_diff_entries() returning frozen DiffEntry dataclasses (path +
old/new with an _ABSENT sentinel distinct from an explicit None), and keep
compute_diff() as a formatter over it -- byte-identical output, pinned by
a delegation test against the existing format tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etails [DMD-1899]

Three pieces of error plumbing the merge-request service needs:

- ErrorCode.MR_NOT_READY_TO_MERGE / MR_MERGE_CONFLICT (+ docs/error-codes.md,
  both categorized 'conflict'). The merge 409 has four causes in two wire
  shapes; the split follows the backend's own line (the three transient
  causes carry storage.mergeRequests.notReadyToMerge, a conflict carries no
  code). Mapping happens in the service -- only it knows the 409 came from
  the merge endpoint.
- http_base: a Keboola user error's machine string `code` now survives into
  KeboolaApiError.details['api_error_code'] (all four raise sites). The
  message holds only the human `error` text, so without this no caller can
  branch on the code. Additive; no behavior change when absent.
- constants: FEATURE_BRANCHES_MERGE_REQUESTS renamed to
  BRANCHES_MERGE_REQUESTS_FEATURE (the file's dominant suffix convention;
  the constant was unused until now). SOX-fence assumption spelled out in
  the comment per the L2 RFC.

Backend counterpart asking for codes on every MR error: DMD-1984.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fy [DMD-1899]

The four derivates from the L2 RFC ('Derived status'), as pure module-level
functions in the new services/merge_request_service.py: derive_state (the UI
list badge's decision table incl. the rejected / closed-by-creator reviewer
overrides), derive_merge_blockers (a list, so concurrent blockers don't mask
each other; conflicts=None means not-fetched, not conflict-free),
derive_allowed_actions (state-only; roles/features stay with the pre-flight),
derive_viewer (is_creator / has_approved relative to the caller).

All four are a POLYFILL: they read the future server-serialized field first
(derivedState / mergeBlockers / allowedActions / viewer -- Connection issue
DMD-1988) and fall back to the local tables; the fallbacks get deleted when
DMD-1988 lands. Same defensive-read pattern as changeLog and the DMD-1969
approvals count.

verify_token now also parses the response's top-level admin block into
TokenVerifyResponse.admin_id/admin_name (additive; absent for scoped
tokens) -- the anchor derive_viewer compares creator.id and approverId
against. approverId is a string on the wire, ids are ints; comparisons
normalize via str() like the UI does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ail [DMD-1899]

The service class (BaseService DI: ConfigStore + client_factory) and its
three read methods:

- list_merge_requests: rows enriched with derived_state; --state filters
  client-side (the endpoint has no query params) and matches the derived
  vocabulary (rejected/merged/closed/...) as well as raw states.
- find_merge_request_for_branch: the branch->MR resolver behind L1's
  optional --mr-id (a branch has at most one MR ever, so the match is
  unambiguous); no MR -> NOT_FOUND naming merge-request create as the next
  step.
- get_merge_request: detail with the full derived status -- merge_blockers/
  mergeable/allowed_actions/viewer + the live conflicts list, fetched only
  for open MRs (a published/canceled MR's source branch is deleted, the
  conflicts endpoint is moot). viewer anchors on verify_token's admin id;
  a scoped token yields honest None flags. Derivations stay informational:
  the merge 409 remains the authority.

Also the write pre-flight helper (_require_merge_requests_feature) with the
SOX-fence assumption spelled out, used by the write methods that follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- create_merge_request: resolves the target itself (always the default
  branch -- the backend rejects any other) and refuses the default branch
  as source with a readable error instead of the backend's confusing 404.
  The source branch arrives explicit; L1 resolves it via the house
  resolve_branch() idiom per the RFC decision.
- update_merge_request: None = leave unchanged (the API cannot clear to
  null; empty string clears description/externalId server-side).
- request_review / approve / request_changes: thin transitions with the
  feature pre-flight; request_changes doubles as the close mechanism (no
  cancel endpoint exists -- the UI's cancel is this call by the creator,
  and derived_state renders it as closed).

Every write runs _require_merge_requests_feature first; returns are the raw
MR enriched with derived_state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…899]

merge() waits for the Storage job (Layer 3, MERGE_JOB_MAX_WAIT) and adds
the two service-level concerns from the RFC:

409 mapping (_remap_merge_conflict): the shape carrying
storage.mergeRequests.notReadyToMerge (read from details.api_error_code,
plumbed by the earlier http_base commit) becomes MR_NOT_READY_TO_MERGE,
retryable -- all three causes are transient; a 409 without the code is the
conflict validation and becomes MR_MERGE_CONFLICT, not retryable, with
'kbagent merge-request conflicts' named as the next step. Mapped here and
not in http_base because only this call site knows the 409 came from the
merge endpoint. Non-409 errors (STORAGE_JOB_FAILED rollback included) pass
through unmapped.

Post-merge cleanup mirrors BranchService.delete_branch: branchFromId is
captured from the PRE-merge payload (nullable once published),
active_branch_id resets only if it pointed at the merged branch (the
was_active logic -- deliberately not get_merge_url's unconditional reset),
and the sync branch-mapping entry is unlinked. The whole cleanup block is
best-effort: the merge already happened, so a failure degrades to
cleanup_warnings in the result instead of a failed command. The message
says the source branch 'is being deleted' -- never that it is gone (second
async job, no handle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…D-1899]

The conflict-resolution half of the service:

- list_conflicts: pass-through with count (conflicts are computed live by
  the backend on every call/merge attempt -- rebasing each listed config is
  sufficient, no re-validate step exists).
- get_config_diff: flattens the nested base/ours/theirs (each nullable)
  into a per-path classification -- changed_by: ours | theirs | both, built
  by intersecting two compute_diff_entries() pairwise diffs. A side that
  removed a key shows None (never the base value); an untouched side shows
  the base. rows compare wholesale. onto_version carries theirs.version --
  the rebase version trap spelled out once, in data.
- resolve_conflict: every mode goes through the rebase endpoint (RFC
  decision; the UI's reset-to-default for take=theirs is DMD-1987).
  take=ours|theirs composes the full replace body from the diff side with
  version=theirs.version; take=ours of a deleted side becomes the delete
  resolution; take=delete sends the tombstone; a caller-authored resolved
  body must spell out name/rows/configuration explicitly (rebase REPLACES --
  a defaulted key would be silent data loss). The config must be in the
  MR's live conflict set, and rebase runs the feature pre-flight (the one
  endpoint gated on branches-merge-requests specifically).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The big one (#2, upgraded): the diff sides' wire shape was WRONG in the
implementation and in the test fixtures that defined it. Verified against
connection (ConfigurationVersionResponse + ConfigurationDiffData OA
schemas, now recorded in the notes wire-truth table): each side is
{version, isDeleted, diff: {name, description, changeDescription,
isDisabled, configuration, rows}} -- content NESTED under diff,
version/deletion as side metadata. The classification and both take modes
now read the envelope; the flat-side code would have been dead on arrival
against the live API.

#1: resolve_conflict no longer takes branch_id -- it derives the branch
from the MR itself (branches.branchFromId), so the conflict-set guard and
the branch being written to can never disagree; a caller-supplied id could
point the REPLACing rebase at an unrelated dev branch the guard never
checked. A published/canceled MR (null branchFromId) is refused readably.

#3: take=theirs of a deleted side collapses to the delete resolution,
symmetric with ours ('production deleted it, dev changed it' is a live
conflict shape).
#4: deletion surfaces as top-level ours_deleted/theirs_deleted booleans on
get_config_diff (None = side never existed) -- it is side metadata, not a
content path.
#5: a 'both' row where the sides agree on the identical value carries
agreed: true -- agreement, not a conflict hotspot.
#2 (message half): a take side missing required envelope keys is reported
as a backend contract violation pointing at the resolved-body workaround,
not as caller error.
#6: wire ids compared via _same_id / int-coerced (find_merge_request_for_
branch, merge()'s was_active) -- a string-serialized branchFromId can no
longer silently defeat the post-merge cleanup.
#7: the feature pre-flight raises FeatureNotEnabledError carrying the new
ErrorCode.FEATURE_NOT_ENABLED (value matches the string SearchService
already emits; categorized 'configuration' like PAYG_NOT_AVAILABLE).
#8: ConfigError imported from ..errors like every other service; the
isDefault scan hoisted to services.base.find_default_branch_id and the
copies in config/sync/workspace services migrated (lib.py keeps its own
loop -- the SDK facade does not import the services layer); verify_token
is skipped when the server already serialized viewer (the polyfill's cost
dies with the polyfill); list --state validates against the closed
vocabulary instead of returning a silent count: 0 on a typo.
#9: test imports hoisted (no mid-file noqa), mocks spec'd at the L3 seam
(KeboolaClient + MergeRequests -- a renamed L3 method now fails the tests),
and regression tests added for every finding (82 tests total).

Doc drift: layer2/layer3 references updated to BRANCHES_MERGE_REQUESTS_
FEATURE, the layer3 open nit closed, tokens.py line ref fixed.

Review: tasks/pr-703-review.md (2026-08-27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra force-pushed the ms/dmd-1899/cli-layer-2 branch from 0e7e87f to 51eaa2c Compare August 26, 2026 23:40
martinsifra and others added 2 commits August 27, 2026 02:02
…actions, derive_state honesty [DMD-1899]

Adversarial review of every wire assumption against Connection source
(tasks/pr-703-opus-wire-review.md): 7 CONFIRMED, 3 MISMATCH. The fixes:

409 mapping (finding #4): the conflict 409 is NOT code-less -- an earlier
reading missed ExceptionConverter, which serializes
MergeValidationException's own string code storage.mergeRequests.validation
top-level as `code`, plus the conflicting configurations in
`params.errors`. _remap_merge_conflict now matches BOTH codes explicitly
(code-less 409 falls back to conflict for older stacks; a 409 with any
OTHER code passes through unmapped instead of being confidently mislabeled
a conflict), and http_base surfaces `params` as
details.api_error_params so the conflict list travels with the error
instead of demanding a second round trip. Stale 'carries no code' claims
corrected in errors.py, the L3 docstring, error-codes.md, the notes
wire-truth table and the RFC.

allowed_actions (finding #7): `approve` removed from the `approved`
tuple -- the transition's sole `from` place is in_review; from approved
the backend answers 422 (the UI button offering it there is wrong).
`update` added to in_merge (the server blocks update only in terminal
states). Docstring records the AddApprovalGuard gating and that with the
non-SOX default of 0 required approvals, approve is 422 in every state and
in_review itself is unreachable.

derive_state honesty (finding #10, the significant one): reviewers[].status
is populated only within a review round anchored by a review_requested
activity event -- which skip_review never writes -- and explicit reviewers
shadow every non-reviewer's decision (the creator can never BE a reviewer).
So in a default non-SOX project the rejected / self-closed derivations
never fire; the UI badge has the identical blind spot, since our table is
its port. Documented in the docstring and the RFC rather than re-derived
from the activity log client-side: the reliable fix is server-side
(commented on DMD-1988 -- derive from the activity log, not reviewers[]).

Sharp edges from confirmed items: a take side with a null/empty `name`
(nullable in the diff envelope, required non-empty by the rebase validator)
is refused as a contract violation instead of sailing into a server 400;
_branch_from_id_of documents that its null check is racy (branchFromId is
nulled by the FK when the async branch delete lands, not by the state
change); the viewer docstring notes detail/conflicts require an admin token
anyway (MergeRequestVoter).

Regression tests for each fix; 87 service tests total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… [DMD-1899]

Two quality nits from the pre-review self-audit: the side_value helper was
re-defined on every classification loop iteration (it captures nothing
loop-scoped -- hoisted above the loop); the test _arm fixture abused
Ellipsis as a keep-default sentinel with a type: ignore -- replaced with a
typed module object sentinel, which also unbreaks `make typecheck` over
tests (ty rejects an EllipsisType default on a dict|None parameter).

Also pinned down the test_changelog_render 'environmental' failures for
good: they reproduce exactly when FORCE_COLOR is set (Warp exports
FORCE_COLOR=3), which makes Rich emit ANSI inside the asserted substrings
('New: ' renders bold, splitting 'New: alpha thing.'). A main-branch test
robustness issue, unrelated to this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@martinsifra
martinsifra marked this pull request as ready for review August 27, 2026 00:25
@martinsifra martinsifra changed the title feat(service): merge-request Layer 2 -- lifecycle, derived status, conflict resolution (DMD-1899) feat(service): merge-request Layer 2 - lifecycle, derived status, conflict resolution (DMD-1899) Aug 27, 2026
@martinsifra
martinsifra requested review from padak and a lite review from Copilot August 27, 2026 00:25
@martinsifra martinsifra self-assigned this Aug 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Layer 2 (services) support for Keboola “merge requests” (Branches 2.0), including client-side derived-status polyfills, merge lifecycle orchestration, and conflict resolution built on the existing Layer 3 client namespace.

Changes:

  • Adds MergeRequestService with list/get/create/update/review transitions, merge error remapping, and conflict diff/resolve workflows.
  • Introduces derived-status polyfill helpers (derived_state, merge_blockers, allowed_actions, viewer) with “server-first, fallback” behavior.
  • Extends shared infrastructure: structured diff entries, richer API error details (code/params), verify-token viewer anchoring (admin_id), and a shared default-branch resolver.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_merge_request_service.py New unit tests covering MR service behavior, derived status, merge 409 remapping, and conflict resolution flows.
tests/test_json_utils.py Adds coverage for new compute_diff_entries() structured diff walker and its formatting parity with compute_diff().
tests/test_http_base.py Adds tests ensuring API error body code and params are surfaced into KeboolaApiError.details.
src/keboola_agent_cli/services/workspace_service.py Switches default-branch detection to shared find_default_branch_id().
src/keboola_agent_cli/services/sync_service.py Uses shared find_default_branch_id() for sync init default branch resolution.
src/keboola_agent_cli/services/merge_request_service.py New Layer 2 service: lifecycle ops, derived status, merge cleanup, conflict diff/resolve.
src/keboola_agent_cli/services/config_service.py Replaces duplicated default-branch scan with find_default_branch_id().
src/keboola_agent_cli/services/base.py Adds shared helper find_default_branch_id().
src/keboola_agent_cli/models.py Extends TokenVerifyResponse with admin_id/admin_name used for viewer-relative derivation.
src/keboola_agent_cli/json_utils.py Adds DiffEntry + compute_diff_entries(); refactors compute_diff() into a formatter over entries.
src/keboola_agent_cli/http_base.py Surfaces API error code/params into KeboolaApiError.details for higher-layer branching.
src/keboola_agent_cli/errors.py Adds FEATURE_NOT_ENABLED, MR-specific error codes, and FeatureNotEnabledError.
src/keboola_agent_cli/constants.py Renames merge-request feature flag constant to BRANCHES_MERGE_REQUESTS_FEATURE.
src/keboola_agent_cli/client/tokens.py Parses verify-token admin block into TokenVerifyResponse.admin_id/admin_name.
src/keboola_agent_cli/client/merge_requests.py Updates docs/comments to new constant name and clarifies merge 409 conflict shape.
docs/merge-requests-notes.md Adds verified backend “wire truth” notes, including 409 shapes and diff/rebase envelopes.
docs/merge-requests-layer3.md Documents shipped Layer 3 surface and contracts; updated with verified conflict 409 code/params.
docs/merge-requests-layer2.md Design/decision record for Layer 2 service behavior, derivations, preflight, merge cleanup, and conflict resolution.
docs/merge-requests-layer1.md Working notes for future CLI commands and UX wording (Layer 1).
docs/error-codes.md Documents newly introduced error codes for feature gating and MR merge conflict/not-ready cases.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +371 to +373
admin_id: int | None = None
if not isinstance(mr.get("viewer"), dict):
admin_id = client.verify_token().admin_id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid catch — the skip predicate was looser than derive_viewer's server-field predicate, so a bare viewer: {} (or one with foreign keys) would skip verify_token and yield {None, None} instead of the local derivation. Fixed in cbdf1d1: both sites now share a single _server_viewer() predicate (dict AND has isCreator/hasApproved), so they cannot disagree; regression test added (test_empty_server_viewer_falls_back_to_local_derivation).

martinsifra and others added 3 commits August 27, 2026 02:44
…ew) [DMD-1899]

get_merge_request's verify_token skip accepted ANY dict as a server viewer,
while derive_viewer required isCreator/hasApproved keys -- a bare
`viewer: {}` (or one with foreign keys) would skip the call and yield
{None, None} instead of the local derivation. Both sites now share
_server_viewer(), the single predicate for 'did DMD-1988 land', so they
cannot disagree. Regression test included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…899]

The pre-flight refusal said the same thing to two very different projects:
a SOX project (protected-default-branch -- refused as deliberate CLI
policy, nothing to enable) and a plain project that simply does not have
merge requests turned on (something to ask support for). The features
cache is already loaded by the first has_feature call, so the distinction
is free: a new PROTECTED_DEFAULT_BRANCH_FEATURE constant + a dedicated SOX
message pointing at the Keboola UI, vs. the enable-the-feature hint.

Both paths stay FeatureNotEnabledError / FEATURE_NOT_ENABLED -- the split
is in the wording, not the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turns the working notes into an implementation-ready RFC for the
`kbagent merge-request` group over MergeRequestService (DMD-1899, #703).

Decided in this pass:

- `--mr-id` is optional everywhere, resolved `--mr-id` -> `resolve_branch()`
  -> `find_merge_request_for_branch()`; `merge` is not exempt.
- `merge` is classified `destructive`, so `--deny-destructive` lets an agent
  run the whole flow and hands only the last step to a human.
- No `--wait`/`--timeout` on merge in v1 (L3 always awaits, 600 s) and no
  `resolve --all` (rebase replaces; conflicts are meant to be walked).
- A full `server/routers/merge_requests.py` ships with the commands, plus a
  serve-only `by-branch` route; routers are not gated by CI, so a skip would
  reach users as an HTTP 404 with nothing red.

Facts the analysis surfaced that shape the commands:

- The MR serializer emits no timestamps, so no date column is possible and
  the renderer must preserve the server's `createdAt DESC` order.
- `FeatureNotEnabledError` carries `FEATURE_NOT_ENABLED`; the common
  `except ConfigError` idiom would flatten it to `CONFIG_ERROR`.
- An empty `--reviewer-id` list is sent as `reviewerIds: []` and clears the
  reviewer set -- it must be normalised to None.
- `detail`/`conflicts` 403 on a scoped token while `list` works.
- `approve` answers 422 in every state on a 0-approval project, and
  `request-review` lands directly in `approved` -- neither has a happy path
  to assert, and there is no `close` command for the same reason.

E2E is deliberately left open: no project carries the feature, kbagent
cannot provision one, and the happy path necessarily merges into
production. The RFC records the proposed path and marks it unsettled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven findings surfaced while writing the Layer 1 command RFC (DMD-1900),
all verified against Connection (tasks/dmd-1899-findings-from-layer1.md):

1. get_config_diff now derives the branch from the merge request
   (signature: alias, merge_request_id, component_id, config_id) -- the
   same make-the-wrong-call-unrepresentable reasoning resolve_conflict
   already carries; a caller-supplied branch id had no relation to any MR.
   The resolved branch_id is echoed in the result; a published/canceled MR
   is refused readably. The old tests passed the shifted positional args
   straight into MagicMock without failing -- rewritten to pin the wiring
   (the L3 diff call must receive branchFromId).
2. _enrich_row now adds allowed_actions alongside derived_state, so every
   return (list rows, find, create, update, transitions, merge's post-merge
   state) answers 'what can I do next' without a second call.
3. SAFETY FACT recorded: autoMergeStrategy=immediately makes a background
   backend tick merge any approved MR through the same MergeProcessor under
   a system token (AutoMergeCandidateRepository.php:38-47,
   AutoMergeTickHandler.php:86) -- create+request_review can end in a
   production merge with merge() never called. New notes section + one
   sentence in both create/update docstrings.
4. Notes wire-truth table gains the full MergeRequestResponse shape:
   merge{mergedAt,mergerId,mergerName} is NESTED, createdAt is top-level,
   autoMergeStrategy/autoMergeAt are response fields too
   (MergeRequestResponseProvider.php:86-117; list==detail item shape).
5. MergeRequestVoter recorded in the notes (admin-identity axis vs. the
   role axis -- not a contradiction), 403 added to the detail/conflicts
   rows in the layer3 doc, derive_viewer docstring softened (the None-flags
   branch is defense in depth, not a reachable path via get_merge_request).
6. An empty list result now carries feature_enabled from has_feature, so
   'no merge requests' and 'feature not enabled' stop being the same 200 +
   []; the extra verify_token GET is spent only on the empty case.
7. STATE_FILTER_VOCABULARY and TAKE_MODES are public for Layer 1 to
   enumerate in help text and pre-validate to exit 2 (precedent:
   notification_service's KNOWN_EVENTS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants