Skip to content

feat(notification): write path — create/delete/replace-recipient subscriptions (#690) - #695

Merged
padak merged 12 commits into
mainfrom
claude/issue-690-pr-d250e8
Aug 25, 2026
Merged

feat(notification): write path — create/delete/replace-recipient subscriptions (#690)#695
padak merged 12 commits into
mainfrom
claude/issue-690-pr-d250e8

Conversation

@padak

@padak padak commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes #690.

What

Adds the write path to the kbagent notification group so the audit workflow from #600 can also fix what it finds:

kbagent notification create --project ALIAS --event NAME --channel email|webhook --address ADDR [--component-id ID] [--config-id ID] [--branch ID] [--expires-at TS]
kbagent notification delete --project ALIAS --subscription-id ID [--yes]
kbagent notification replace-recipient --project ALIAS --subscription-id ID --address NEW_ADDR [--channel email|webhook] [--yes]

Extends the exact layers #615 built — no new abstraction:

  • Client (client/notifications.py): create_project_subscription (POST /project-subscriptions, expiresAt camelCase, filters omitted when empty) and delete_project_subscription (DELETE with a path-quoted id), mirroring the existing read methods.
  • Service (services/notification_service.py): create_subscription builds filters from friendly component_id/config_id/branch_id params (same dotted-path constants the read path parses) and returns the canonical audit row; delete_subscription is a thin wrapper; replace_subscription_recipient implements the motivating "fix a stale recipient" case.
  • CLI (commands/notification.py): three thin commands; the detail rendering is extracted into a shared _print_subscription helper (detail output unchanged). Invalid --channel is a structured INVALID_ARGUMENT, exit 2. delete/replace-recipient prompt for confirmation unless --yes (or --json).
  • Permissions: notification.create = write, notification.replace-recipient = write, notification.delete = destructive — so --deny-writes blocks all three and --deny-destructive blocks only delete, matching the gate the issue asked for.
  • Serve: POST /notifications/{project}, DELETE /notifications/{project}/{subscription_id}, POST /notifications/{project}/{subscription_id}/replace-recipient (read routes already existed; body convention mirrors routers/flows.py; no permission-engine wiring, consistent with every router except /auth/*). docs/web-server-endpoints.md regenerated. Note: channel validation lives in the CLI command layer, so over REST an invalid channel surfaces as the service's ConfigError (HTTP 400 CONFIG_ERROR) rather than INVALID_ARGUMENT — the docs scope the INVALID_ARGUMENT/exit-2 claim to the CLI.
  • Docs (convention v0.6.0: Branch lifecycle management + security hardening #17 surfaces): CLAUDE.md command block, context.py AGENT_CONTEXT, commands-reference.md, gotchas.md, keboola-expert.md, SKILL.md decision table — all tagged (since vNEXT). No version bump, no changelog entry (feature PR per docs: version bumps move out of feature PRs into dedicated release PRs #648 process).

One deliberate deviation from the issue sketch

The issue sketches replace-recipient as get → delete → recreate. This PR orders it create-new-first, then delete-old: if the second step fails, the caller is left with a recoverable duplicate (surfaced as old_deleted: false plus a warning naming the old id) instead of a silently lost alert. A new subscription_id is always minted either way — the output carries both old_subscription_id and new_subscription_id so scripts never cache the stale one. The old subscription's event/filters/expiresAt are passed through verbatim; only the recipient changes.

Testing

  • Unit/CLI/router tests across all layers (client request bodies, filter building + call ordering via mock_calls, real permission-engine exit-6 tests, serve route kwarg passthrough). make check green.
  • tests/test_e2e.py: create → detail → replace-recipient → delete → list round trip added to the existing notification E2E class (env-guarded, self-cleaning).
  • Live verification: a harness script driving this branch's build via the CLI (~/kbagent/e2e/notification_write_e2e.py, config-dir pattern, no token handling) ran the full round trip against the dedicated E2E project 5946 — all steps passed, including leftover-subscription sweeps before and after.

Non-goals (per the issue)

  • POST /notifications (push a one-off notification) — different endpoint, needs a Manage application token with notifications:push-event scope; untouched.
  • No fake update command — the API has no update primitive, and the CLI does not pretend otherwise.

Open in Devin Review

padak added 11 commits August 25, 2026 13:33
Add create_subscription / delete_subscription / replace_subscription_recipient
to NotificationService, building on the Task 1 client write methods.

replace_subscription_recipient creates the new subscription before deleting
the old one (reverse of the issue's delete-then-recreate sketch): a failed
delete leaves a recoverable duplicate instead of risking a lost alert, so it
is reported via old_deleted=False plus a warning rather than raised. Old
event/filters/expiresAt are carried over verbatim; only the recipient
changes.
The delete-old cleanup in replace_subscription_recipient only caught
KeboolaApiError, but _do_request lets raw transport failures (httpx
ReadError/WriteError/RemoteProtocolError/ProxyError) propagate as-is. After a
successful create, such an exception would escape the method, drop
new_subscription_id from the response, and risk a retry minting a third
subscription. Widen the catch to Exception, log at warning, and keep
reporting via old_deleted=False + a warning instead of raising.

Also guard a non-dict old recipient the same way the read path does (avoids
an AttributeError in favor of the intended ConfigError), and add test
coverage for a positive expiresAt pass-through and the non-dict-recipient
path that was previously untested.
Adds POST /notifications/{project}, DELETE /notifications/{project}/{subscription_id},
and POST /notifications/{project}/{subscription_id}/replace-recipient, wired to the
notification service's create_subscription/delete_subscription/replace_subscription_recipient
methods (Task 2). Request bodies follow the flows.py pydantic BaseModel convention.
No permission-engine wiring, matching every other non-/auth/* router.
create_subscription's config_id parameter is str | None (the CLI and serve
both only ever pass a string). The filter-order test violated that by
passing config_id=98765 as an int, which ty flagged as invalid-argument-type.
Switch it to config_id="98765" and keep branch_id=4242 as a real int (its
declared type), so the assertion still exercises _build_filters's
str(branch_id) stringification.
- Update the OpenAPI tag description for the notifications router to
  reflect the write path (create/delete/replace-recipient), not just
  the original read-only audit, and regenerate docs/web-server-endpoints.md
- Add a test covering the invalid --channel branch of
  `notification replace-recipient`
- Add a pre-flight sweep to the E2E write-path round-trip test so a
  killed run doesn't leak a project-wide job-failed subscription
- Clarify the historical CLAUDE.md notification comment block as
  describing the read path only, since a write-path block now sits
  above it

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@padak padak left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review of #695 — feat(notification): write path — create/delete/replace-recipient subscriptions (#690)

Generated by kbagent-pr-reviewer subagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed via make check, not duplicated here.

Summary

This PR adds the write path (create / delete / replace-recipient) to the kbagent notification group across all four layers (client, service, CLI, serve router), closes issue #690, and threads every documented silent-drift surface (context.py, CLAUDE.md, commands-reference.md, gotchas.md, keboola-expert.md matrix row, SKILL.md table, permissions.py OPERATION_REGISTRY) with (since vNEXT) tags. It is unusually thorough: the replace-recipient create-before-delete ordering rationale is documented in three places (service docstring, gotchas.md, CLAUDE.md) and matches the actual implementation; the E2E test performs a full self-cleaning create→detail→replace-recipient→delete round trip with a pre-flight leak-sweep. make check passes (6198 tests), the layer boundaries are clean, and I verified live that the permission engine and the --channel validation behave exactly as documented. Verdict: APPROVE — no blocking findings, one minor non-blocking test-coverage nit.

Verdict

  • Verdict: APPROVE
  • Blocking findings: 0
  • Non-blocking findings: 1
  • Nits: 1

Blocking findings

(none)

Non-blocking findings

[NB-1] tests/test_notification_service.py:596-771 — new service tests never assert client.close()

TestCreateSubscription, TestDeleteSubscription, and TestReplaceSubscriptionRecipient all mock client = MagicMock() and exercise the try/finally: client.close() pattern in the service (confirmed present at src/keboola_agent_cli/services/notification_service.py in all three new methods), but none of the ~15 new test methods calls client.close.assert_called_once(). CONTRIBUTING.md > Testing Guidelines explicitly lists this as a required assertion. The implementation is correct (I read the finally blocks), so this is a coverage gap, not a bug — but it means a future regression that drops the finally would go undetected by these new tests.

Nits

  • [NIT-1] src/keboola_agent_cli/services/notification_service.py:365-576create_subscription / delete_subscription / replace_subscription_recipient all return a bare dict[str, Any] rather than a typed model/dataclass. This mirrors the pre-existing list_subscriptions / get_subscription_detail shape in the same file (grandfathered), so not a new-tuple violation and not something this PR should be asked to fix alone — flagging only because replace_subscription_recipient's return shape (old_subscription_id / new_subscription_id / old_address / old_deleted / warnings / **row) is dense enough that a @dataclass would read better at call sites; author's call whether it's worth a follow-up.

Verification log

  • gh pr view 695 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state → OPEN, base main, 18 files changed, +1970/-67, title uses feat(notification): prefix matching the change (new commands = new feature) ✓
  • Read CONTRIBUTING.md (Checklist: Adding a New CLI Command, Plugin synchronization map, Releasing a new version) and CLAUDE.md convention #17 + ## All CLI Commands before reviewing ✓
  • git rev-parse --abbrev-ref HEADclaude/issue-690-pr-d250e8, matches <branch> ✓ (no checkout needed)
  • Layer-violation greps (typer/formatter in services, httpx in commands, formatter/typer in clients) → all empty ✓
  • git diff main...HEAD -- commands/notification.py → 3 new @notification_app.command(...) decorators: create, delete, replace-recipient
  • Silent-drift surface check (all present, correctly (since vNEXT) tagged where applicable):
    • permissions.py OPERATION_REGISTRYnotification.create: write, notification.delete: destructive, notification.replace-recipient: write
    • commands/context.py AGENT_CONTEXT → full section added under "Notification Subscriptions" ✓
    • CLAUDE.md ## All CLI Commands → 3 new command signatures + explanatory block ✓
    • plugins/kbagent/agents/keboola-expert.md §2 → one new matrix row for the group ("Add / remove / re-point a notification recipient"), correctly a single row not per-command; byte size 51570 / 70000 budget ✓
    • plugins/kbagent/skills/kbagent/references/commands-reference.md → 3 new bullets ✓
    • plugins/kbagent/skills/kbagent/references/gotchas.md → new section "Notification subscriptions can now be written..." tagged (since vNEXT)
    • plugins/kbagent/skills/kbagent/SKILL.md decision table → 3 new rows (CI-checked, make skill-check passed) ✓
    • docs/web-server-endpoints.md → regenerated (231→234 operations), make endpoints-check passed via make check
    • server/routers/notifications.py → 3 new routes (POST /notifications/{project}, DELETE .../{subscription_id}, POST .../{subscription_id}/replace-recipient) matching the CLI 1:1 ✓
  • grep -E '\-> ?tuple\[' on new diff hunks → none found (no new bare-tuple returns) ✓
  • Convention checks (magic numbers, raw error_code="..." in src/, bare except:, print(), token leakage) → all clean; the 3 raw error_code="..." hits found are inside test files constructing mock KeboolaApiError objects, not production code, and make check-error-codes (part of make check) passed ✓
  • make check → exit 0, 6198 passed, 12 skipped (unit test job), lint/format/typecheck/skill/version/command-sync/endpoints/changelog/error-codes/sentinel-guards/file-size all green ✓
  • Test coverage by layer: service 19 new def test_, CLI 16 new, client 4 new, test_server_router_calls.py 5 new, tests/test_e2e.py 1 new (test_create_replace_recipient_delete_round_trip, a full write-path round trip) ✓ — all four checklist layers covered
  • Live behavior verification (against a throwaway local --config-dir, no real project needed since these are argument-validation / permission-engine paths):
    • kbagent --json notification create --project doesnotexist --event job-failed --channel bogus --address a@b.com{"error":{"code":"INVALID_ARGUMENT",...}}, exit 2 — matches PR's documented INVALID_ARGUMENT claim for the CLI layer ✓
    • kbagent --deny-writes --json notification create --project doesnotexist --event job-failed --channel email --address a@b.com{"error":{"code":"PERMISSION_DENIED",...}}, exit 6 — confirms notification.create is correctly gated as write and blocked ✓
    • kbagent notification create/delete/replace-recipient --help → option lists match CLAUDE.md's documented signatures exactly ✓
    • Could not exercise the real create→delete→replace-recipient round trip against a live Keboola project (no credentials in this environment); the author's PR description reports a live run against E2E project 5946 with all steps passing, and tests/test_e2e.py::TestE2ENotificationSubscriptions::test_create_replace_recipient_delete_round_trip encodes the same flow with self-cleanup — treating this as sufficiently verified rather than re-running it myself
  • Reviewed the 3 self-review fixup commits already on the branch (af9731da widen delete-guard to Exception, cd593cf0 fix an int/str type mismatch in a test, 1215c62a close review findings incl. OpenAPI tag description + E2E pre-flight sweep) — evidence the author already ran a self-review pass before this posting, consistent with CONTRIBUTING.md > "Self-review before tagging a human reviewer" ✓

Open questions for the author

(none)

CONTRIBUTING.md's Testing Guidelines require verifying client.close() for
the try/finally client-close pattern, but the new create/delete/replace
tests never asserted it -- a future regression dropping the finally would
go undetected.

Add the assertion where it is real signal: one happy-path test per method
(create_subscription, delete_subscription, replace_subscription_recipient),
delete_subscription's own propagated-404 path (the client must still close
even though the exception escapes the method), and
replace_subscription_recipient's three try-block error paths where the
client was already open when the error hit -- the blank/unknown-channel
ConfigError raised before the replacement is created, both non-KeboolaApiError
and KeboolaApiError delete-failure branches, and the non-dict-recipient guard.
Left the resolve_projects-before-open ConfigError tests (invalid channel,
unknown alias) untouched: the client factory is never invoked on those
paths, so a close() assertion there would be trivially true rather than
real coverage.
@padak

padak commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Review findings addressed:

  • [NB-1] Fixed in 45db0aeclient.close.assert_called_once() added to 8 write-path service tests (create happy path, both delete tests, and all replace paths including the delete-failure and validation-error branches), mirroring the existing TestListSubscriptions::test_client_is_closed_even_on_failure idiom. The four ConfigError-before-client-open tests were deliberately left without the assertion — no client is ever constructed there, so it would be trivially true rather than real coverage.
  • [NIT-1] Leaving the bare-dict returns as-is in this PR: they mirror the pre-existing list_subscriptions / get_subscription_detail shape in the same file, and switching only the three new methods to dataclasses would split the module into two conventions. Happy to do a follow-up that converts the whole service if wanted.

@padak
padak merged commit eb62fa0 into main Aug 25, 2026
3 checks passed
@padak
padak deleted the claude/issue-690-pr-d250e8 branch August 25, 2026 13:01
padak added a commit that referenced this pull request Aug 25, 2026
padak added a commit that referenced this pull request Aug 25, 2026
@padak padak mentioned this pull request Aug 25, 2026
13 tasks
padak added a commit that referenced this pull request Aug 25, 2026
Bump pyproject to 0.91.0, add the changelog entry covering every PR merged since v0.90.1 (#627, #681, #691, #692, #693, #694, #695, #696, #697, #698), resolve all 54 vNEXT version-gate placeholders, and run version-sync + skill-gen.

No web/frontend changes in this batch, so no whatsnew.ts entry.
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.

kbagent notification: write path (create/delete subscriptions) to fix what audit finds

1 participant