Skip to content

UN-3494 [FEAT] Email users and groups through PGMQ - #2224

Open
kirtimanmishrazipstack wants to merge 37 commits into
mainfrom
UN-3494-group-sharing-notification
Open

kirtimanmishrazipstack wants to merge 37 commits into
mainfrom
UN-3494-group-sharing-notification

Conversation

@kirtimanmishrazipstack

@kirtimanmishrazipstack kirtimanmishrazipstack commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Sends an email whenever someone's access to a resource changes — granted or taken away, whether they were named directly or reached it through a group.
  • Sends an email when someone is added to or removed from a group.
  • Fixes the direct-share email, which had quietly stopped working: it was wired to a screen no client calls any more.
  • Co-owner modal: removing a co-owner now waits for Apply, so Cancel can undo it.

Not every action here notifies the same way — some go out instantly as part of the request, some queue up and send in the background, and one sends nothing at all:

Action How it's sent
Share/un-share a resource with one person Immediately, as part of the request
Add/remove a co-owner Immediately, as part of the request
Share/un-share a resource with a group Queued, sent in the background
Add/remove someone from a group Queued, sent in the background
Add/remove someone from the organization (Platform Settings → Users) No email at all — unchanged, out of scope

The queue exists so emailing a whole group at once doesn't hold up the request; a single-recipient notification is fast enough to just send inline. Removing someone from the organization entirely is separate, pre-existing behavior this PR doesn't touch.

Why

  • Sharing something gave people access without telling them, and un-sharing told nobody at all. Group members had no way to find out either way.
  • The co-owner modal applied additions on Apply but removals instantly, so there was no way to back out of a removal.

How

  • One dispatcher replaces seven near-identical per-resource copies (deleted), covering all eight shareable types.
  • Sent from a background job, not the request path, so a slow email provider can't slow down sharing.
  • For a resource share/revoke, recipients and access are resolved at send time, not click time — nobody still-connected (another group, a direct share, org-wide access, ownership, admin) is told they lost access. A group membership change is the one exception: the specific person added or removed is fixed at click time (there's no group row left to re-derive it from on a removal), though they're still re-checked at send time for still being a live org member.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No — this only adds new notifications, it doesn't change how sharing or access itself works today.
  • Worst case: a rare hiccup could cause the same email to go out twice. Annoying, not harmful — nothing else is affected.
  • One known limitation, not caused by this PR: if the email service itself refuses a message, we won't know it failed. That's being fixed separately.
  • Timing matters: the cloud half of this feature needs to merge around the same time, or these emails won't have anywhere to go yet.
  • The kill switch is partial: blanking the two new template IDs (cloud side) stops the actual sends, but not the enqueue or the traffic that reaches it — and it doesn't cover the newly-live direct-share email, which falls back to a different, already-configured template rather than being blanked out with them.

Database Migrations

  • None.

Env Config

  • None.

Relevant Docs

  • None.

Dependencies Versions

  • None.

Notes on Testing

68 tests across six modules (52 here, 16 in the cloud PR): direct/group share-revoke, group membership add/remove, retained-access skips, the restored direct-share email, retry/dedup safety, and email wording/skip logic. Co-owner modal Cancel verified manually in-browser (no automated FE test).

Screenshots

  • ETL pre-existing notifcation time interval
1
  • WF notifcation
2 3
  • All other notifications
6

Checklist

I have read and understood the Contribution Guidelines.

…ship changes

Sharing a resource with a group gave its members access silently, and adding
or removing someone from a group told nobody. Both now send email.

- share_notifications.py holds the feature flag, the two task names and the two
  enqueue hooks. Dispatch uses the same resolve_transport branch the execution
  path uses: the PG queue where pg_queue_enabled is on for the org, Celery
  otherwise.
- One hook in ResourceShareManagementMixin.share covers all 7 resource types
  plus cloud agentic, including service-account shares — every group share
  funnels through it and shared_groups has no PATCH path. No on_commit needed:
  _commit's transaction has closed by the time the view resumes, so the diff
  reads committed state.
- Group membership hooks on the add and remove actions. The add serializer
  already subtracts existing members, so nobody is mailed twice.
- Internal endpoints under /internal/v1/group-notification/ do the work the
  worker cannot: group expansion, OrganizationMember re-validation (this is
  where the offboarding race closes), resource lookup via ShareableResource,
  and the kind -> ResourceType mapping, which is not 1:1 — pipelines split on
  pipeline_type and adapters four ways on adapter_type.
- Two worker tasks that only POST to that endpoint, since workers/ has no
  Django. They raise on failure, unlike _mark_buffer_outcome which has a reaper
  behind it, and retry transient 5xx in-task because a raise is terminal on the
  Celery transport.
- The whole feature is gated on Flipt group_sharing_notifications_enabled and
  fails closed: a blind Flipt, a missing org, or any dispatch error means no
  notification, never a broken share.
- worker-pg-notification compose service so the PG arm is not a black hole.

Membership changes with no actor (the org-removal cascade, Django admin, group
deletion) do not notify — SharingNotificationService requires an actor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features

    • Added email notifications for group resource sharing, access revocation, and membership changes.
    • Added notifications when users are added to or removed from groups.
    • Co-owner management now stages additions and removals together for one Apply action.
  • Bug Fixes

    • Improved co-owner failure handling with clear alerts, retry support, and protection against removing the final owner.
    • Sharing notifications now account for users who retain access through another path.
  • Changes

    • Sharing notifications are now handled through dedicated sharing actions rather than general updates.

Walkthrough

The change adds asynchronous group resource-sharing and membership notifications through feature-gated dispatch, worker tasks, internal APIs, and notification services. Legacy partial-update notification paths are removed. Frontend co-owner management now stages and applies combined additions and removals.

Changes

Group notification pipeline

Layer / File(s) Summary
Feature-gated notification dispatch
backend/tenant_account_v2/shareable_resources.py, backend/tenant_account_v2/share_notifications.py
Adds resource lookup helpers, notification actions, feature-flag checks, transport selection, and asynchronous dispatch.
Worker delivery and internal API
workers/notification/tasks.py, backend/tenant_account_v2/internal_views.py, backend/tenant_account_v2/internal_urls.py, backend/backend/internal_base_urls.py
Adds authenticated worker requests, retry handling, payload serializers, organization resolution, and notification endpoints.
Notification service and integrations
backend/tenant_account_v2/group_notification_service.py, backend/permissions/resource_share_views.py, backend/tenant_account_v2/group_views.py, backend/permissions/membership_views.py
Validates resources, groups, actors, and recipients. Sends share and membership notifications. Uses fixed supported share axes.
Legacy update path removal
backend/adapter_processor_v2/views.py, backend/connector_v2/views.py, backend/pipeline_v2/views.py, backend/prompt_studio/prompt_studio_core_v2/views.py, backend/workflow_manager/workflow_v2/views.py
Removes partial-update sharing snapshots, diffing, and notification dispatch.

Staged co-owner management

Layer / File(s) Summary
Staged roster and apply behavior
frontend/src/hooks/useCoOwnerManagement.jsx, frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx
Stages the owner roster, applies additions before removals, aggregates failures, refreshes state, and supports retry.
Callback wiring
frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx, frontend/src/components/deployments/api-deployment/ApiDeployment.jsx, frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
Replaces separate add/remove callbacks with onApplyCoOwners.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ShareChange
  participant share_notifications
  participant NotificationWorker
  participant InternalNotificationAPI
  participant group_notification_service
  participant NotificationPlugin
  ShareChange->>share_notifications: Dispatch share or membership event
  share_notifications->>NotificationWorker: Enqueue organization-scoped task
  NotificationWorker->>InternalNotificationAPI: POST notification payload
  InternalNotificationAPI->>group_notification_service: Validate and process payload
  group_notification_service->>NotificationPlugin: Send filtered notification
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: email notifications for user and group access grants and revokes.
Description check ✅ Passed The description covers the required sections, implementation, risks, configuration, related issue, and testing; blank documentation and screenshot sections are non-critical.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch UN-3494-group-sharing-notification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

UN-2977 moved sharing from PATCH to POST /{id}/share/, but the mixin's
share action only diffed the groups axis. The per-viewset
_notify_shared_users hooks stayed on partial_update, which nothing calls
anymore, so sharing a resource with a user sent no email.

Snapshot every declared axis and invoke the hook after the commit; declare
it on the mixin as a no-op for hosts without a direct-share email.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-3494 [FEAT] Email group members on resource share and group member… UN-3494 [GATED-FEAT] Email group members on share and membership change Aug 4, 2026
…revoked

Sharing already emailed on grant; revoking told nobody. Both axes now notify,
and the seven duplicated copies of the user hook collapse into the share mixin.

- ResourceShareManagementMixin gains a concrete _notify_shared_users covering
  grant and revoke, driven by the OwnerManagementMixin seam every host already
  declares. The seven per-viewset overrides and their dead partial_update
  wrappers go with it — a host override would otherwise shadow the mixin and
  silently swallow the revoke mail.
- share() diffs both axes through _read_axis directly; AxisDiff,
  snapshot_share_axes, diff_share_axes and the share_axes ClassVar had no
  callers left.
- Group revoke rides the existing resource-shared route with a share_action
  discriminator, mirroring membership-changed — no new endpoint or worker task.
  Defaulted at every hop so in-flight messages still run.
- Suppressed when the user still reaches the resource via a group or
  shared_to_org: losing one axis is not losing access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-3494 [GATED-FEAT] Email group members on share and membership change UN-3494 [GATED-FEAT] Email users and groups on access grant and revoke Aug 4, 2026
…tton

Adding a co-owner was staged until Apply, but revoking one fired the DELETE
straight from the Popconfirm — so Cancel could not undo it, Apply stayed
disabled for a removal-only edit, and the revoke email went out on click.

Stage the roster the way SharePermission does: one selected-owners list seeded
from the server, edited locally by both add and revoke, committed only by Apply.
Collapse the hook's two mutation callbacks into one onApplyCoOwners that runs
adds before removes (so a one-shot owner swap clears the backend's last-owner
guard), refreshes once, and emits one summary alert. Apply now closes on a clean
run and stays open on failure, matching useShareModal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

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.

Self-review — UN-3494 (OSS)

Ran a multi-pass review over this branch (correctness, error handling, type design, comments) and verified each finding against the code rather than taking the analysis at face value. Six candidate findings turned out to be false and are not listed. Low-severity items are withheld; below is everything blocking / high / medium.

Cloud half: Zipstack/unstract-cloud#1698 — findings that span both repos are stated there from the cloud side.


Blocking

B1 — Every send result is discarded, so a failed send reports success and the queue acks it.

backend/tenant_account_v2/group_notification_service.py:102 and :153 call service.send_group_resource_shared_notification(...) / send_group_membership_notification(...) as bare statements. Both return bool. backend/tenant_account_v2/internal_views.py:83 and :95 then return 200 {"status": "success"} unconditionally, _post_group_notification sees 200 and returns, and the PG consumer deletes the message.

Failure path: SendGrid 429/503 → the plugin returns False → email silently lost, message acked, nothing above DEBUG anywhere.

This inverts the contract this file states about itself at internal_views.py:8-10 ("any unhandled problem must surface as non-2xx so the queue redelivers"), and it makes the whole retry apparatus in workers/notification/tasks.py — the 3-attempt loop, the httpx transport retries, the 120s VT, and the "deliberately raises on failure … a swallowed error would be a silently unsent email" docstring — guard a path that can no longer fail.

The plugin-missing case has the same shape: _service() returns None behind a logger.debug (:162-166). A backend built without sendgrid emails nobody while every layer reports green — and the recipient_count= INFO line never executes, so the one metric you would grep for is absent rather than zero, which is indistinguishable from "nobody was shared with".

Fix: collect the booleans in send_resource_shared / send_membership_changed and return non-2xx when any send failed retryably; log non-retryable causes (template unset, notifications disabled) at WARNING with a distinct {"status": "skipped", "reason": ...} so misconfiguration is separable from delivery.

B2 — Group-revoke emails ignore remaining access.

group_notification_service.py:89-111 mails every current member of each revoked group with no effective-access check. The direct-user path deliberately does the opposite — _users_left_without_access in backend/permissions/resource_share_views.py:81-93, with the comment "telling them their access was removed would be wrong."

Failure path: workflow W is shared with Group A and Group B; Alice is in both; an owner removes Group B. Alice is told her access was removed, and because share_action == revoked the cloud side rewrites both the CTA and resource URL to the dashboard — so the email walks her away from a resource she still fully reaches via Group A. Same for a shared_to_org=True resource, where nobody lost anything, and for members who also hold a direct VIEWER row.

The revoke recipient list should go through the same compute_effective_members filter the direct path uses.


High

H1 — The notification path in share() can 500 a share that already committed.

permissions/resource_share_views.py:175-195: only _send_share_notification and _send_revoke_notification are wrapped. _notification_context (:188, which invokes the host viewset's get_notification_resource_type override) and _users_left_without_access (:193, a DB query) run bare — after ShareAuthorizationService.authorize_and_commit has already committed at :155-161. A DB hiccup or a raising seam returns 500 for a share that succeeded, and the client retries.

The group path escapes this only by luck: _organization_slug and kind_for_instance are pure getattr/_meta reads and _feature_enabled is wrapped. Wrap the whole _notify_shared_users body plus the notify_resource_group_share_changed call at the share() call site.

H2 — A synchronous SendGrid HTTPS call is now on the live POST /share/ path.

The group path was deliberately made async (worker + internal API); the direct-user path in _notify_shared_users calls the plugin inline. This is newly-introduced request latency, not pre-existing — the previous home (partial_update) was dead code, so these emails were not firing at all before this branch.

H3 — Unbounded org-member scan pulled into that same request.

_users_left_without_access → compute_effective_members → _add_org_members (backend/tenant_account_v2/sharing_helpers.py:316-340) runs OrganizationMember.objects.filter(organization=...) with no pagination and iterates the whole result in Python, whenever shared_to_org is true. Un-sharing one user on an org-shared workflow in a 5,000-member org hydrates 5,000 OrganizationMember + User rows to answer "does this one user still have access?" Against ARCHITECTURE_PRINCIPLES §6 on unbounded querysets and heavy work in the request cycle.

Cheap and correct: guard-clause if getattr(instance, "shared_to_org", False): return [] — if the resource is org-shared, nobody who lost a direct row actually lost access.

H4 — The new direct-share revoke email ships with no feature flag.

_notify_shared_users has no Flipt check at all; only the group path is gated by GROUP_NOTIFICATION_FLAG_KEY. So the new revoke email goes live for every org the moment this deploys, with no kill switch. The module docstring at backend/tenant_account_v2/share_notifications.py:13-15 claims "The whole feature sits behind its own Flipt flag and fails closed everywhere", and the PR description repeats it — neither is true for this path. Either gate it or correct both statements. (Template-reuse half of this is on the cloud PR.)

H5 — Lookups are group-shareable but get no group email, and nothing logs it.

LookupDefinition is absent from SHAREABLE_RESOURCES (backend/tenant_account_v2/shareable_resources.py:28-52), so kind_for_instance returns None and share_notifications.py:100 returns with no log line at all — kind is None is collapsed into the same silent early-return as feature-flag-off. Direct-user lookup emails do fire (the cloud PR wires get_notification_resource_type for exactly that), so the result reads as a flaky feature rather than a gap.

Split that guard: flag-off is expected silence, but an unregistered kind and a resource with no organization are both bugs and should log at WARNING. Then either register LookupDefinition or reject shared_groups for hosts absent from the registry.

H6 — _get_user is the one org-unscoped query on a tenant-scoped path.

group_notification_service.py:170-171 resolves the actor with User.objects.filter(pk=user_id).first(). Every sibling lookup on this path re-validates against the org (_groups_in_org, _live_member_users, and _load_resource, which filters organization= explicitly and explains why). The resolved user's name and email render into the outgoing mail. Not exploitable today since the payload is worker-generated, but it is an unscoped query on a multi-tenant path. One filter through OrganizationMember fixes it.

H7 — Rolling deploy: new backend to an old worker drops the message.

notify_resource_shared_with_group (workers/notification/tasks.py:528-535) has a closed signature. A message carrying share_action delivered to a pod on the previous build raises TypeError — terminal on Celery, burns the attempt cap on PG. The producer has already returned 200 to the user via _dispatch_quietly, so nothing surfaces. **_: Any on both new task signatures closes it.

Related: the "defaulted so messages enqueued before this field existed still validate" comments (internal_views.py:40, tasks.py:538) describe a state that never existed — both the task and share_action were added on this branch, so there are no in-flight messages. The defaults are fine to keep; the stated rationale is not, and it obscures the fact that the real hazard runs the other way.

H8 — Rollout ordering: PG transport with no consumer deployed.

_dispatch routes to the PG queue whenever resolve_transport says so, and the notification consumer is off by default. Any org with pg_queue_enabled ramped but the consumer not running gets messages durably stored and never executed — logged as "group-notification: %s enqueued on PG queue %r (msg_id=%s)" at INFO, which reads as delivery. Needs to be an explicit ordering constraint in Env Config, not an inference. (Chart side on the cloud PR.)


Medium

  • Dead exception handler. The except Exception in _feature_enabled (share_notifications.py:164-170) can never fire — both check_feature_flag_status and FliptClient.evaluate_boolean catch and return False first. Remove it or stop relying on it.
  • The default Flipt path logs nothing. FLIPT_SERVICE_AVAILABLE != "true" at :154 returns False with zero logging, and that is the default. Combined with H5's collapsed guard, "I ramped the flag and no email arrived" has no log line distinguishing which of four causes applied.
  • recipient_count is post-filter only. group_notification_service.py:93-99 and :144-150 log the surviving count; the requested count is never logged, and _groups_in_org / _live_member_users both drop silently. "Half my team didn't get it" is unfalsifiable from logs. Log requested / resolved / dropped.
  • 2N+1 on the group fan-out. :89-92 runs one values_list plus one OrganizationMember query per group. Collapse to a single GroupMembership.objects.filter(group__in=…).select_related("user", "group") grouped in Python.
  • Over the 30-line ceiling (CLAUDE.md): send_resource_shared 40, _post_group_notification 35, send_membership_changed 31.
  • _notification_context is duplicated. The module-level function at resource_share_views.py:62 is a line-for-line copy of OwnerManagementMixin._notification_context (permissions/membership_views.py:88). Two copies that will drift, and their docstrings already contradict each other on whether hosts override get_notification_resource_type — all seven do.
  • Docstrings that misstate contracts:
    • internal_views.py:8-10 — "non-2xx so the queue redelivers" holds only on the PG transport; on Celery a raise is terminal, as tasks.py:471-472 itself says.
    • tasks.py:481-487 — self-contradictory: "a 4xx is not retried" versus "the raise leaves the message on the queue for redelivery". The break at :513 still falls through to the raise at :524. Also the guard is < 500, not 4xx.
    • share_notifications.py:9-10 — transport is resolved per resource, not per org: _dispatch passes the resource/group pk as execution_id, which is what resolve_transport buckets the rollout on.
    • resource_share_views.py:3-6 — the mixin is no longer axis-agnostic (the share_axes ClassVar is gone and _read_axis hardcodes both names), and it does not read _SUPPORTED_SHARE_AXES — only _extract_desired_share_state does.
  • Frontend, partial-failure UX contradicts itself. CoOwnerManagement.jsx keeps the modal open on partial failure "so the user can see what was rejected and retry", but onApplyCoOwners always calls refreshCoOwnerData first, and the useEffect re-seeds selectedOwners from the refreshed roster — so the staged edits are already wiped and there is nothing to retry from.

Verified clean

The dead-code removal holds up: the shared_users M2Ms were dropped by the UN-2202 migrations and every serializer now exposes shared_users as a read-only SerializerMethodField, so those partial_update hooks could never have fired. No references to share_axes, AxisDiff, snapshot_share_axes, or diff_share_axes remain in either repo.

Auth on the new internal endpoints is genuinely enforced — InternalAPIAuthMiddleware gates every /internal/ path before DRF runs, and the route is reachable in all three deployments. Org scoping on _load_resource / _groups_in_org / _live_member_users is correct. Every OSS→cloud ResourceType mapping checks out. The onApplyCoOwners rename is fully propagated across all 11 consumers in both repos.

_users_left_without_access is safe despite compute_effective_members excluding owners: ResourceMembership has UniqueConstraint(user, content_type, object_id), so a user is OWNER or VIEWER and never both. Reading the diffs after _commit is also safe — ATOMIC_REQUESTS defaults to False and is pinned False in the chart.

@kirtimanmishrazipstack
kirtimanmishrazipstack marked this pull request as ready for review August 5, 2026 08:55
@greptile-apps

greptile-apps Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

The PR adds queued group sharing and membership notifications, restores direct-share notifications on the active sharing endpoint, and defers co-owner removals until Apply.

  • Resolves resources, recipients, and retained access when queued notifications are delivered.
  • Revalidates queued grants against current group shares and bounds revoke recipients by membership creation time.
  • Centralizes notification resource metadata and retained-access calculations.
  • Updates the co-owner modal to stage removals until changes are confirmed.
Diagram
sequenceDiagram
    participant UI as Frontend
    participant API as Django API
    participant DB as Database
    participant Q as PGMQ
    participant W as Notification Worker
    participant Mail as Email Service

    UI->>API: Change resource share or group membership
    API->>DB: Commit authorization change
    alt Direct user or co-owner change
        API->>Mail: Send notification inline
    else Group share or membership change
        API->>Q: Enqueue notification event
        W->>Q: Consume event
        W->>API: Call internal notification endpoint
        API->>DB: Revalidate org, resource, access, and recipients
        API->>Mail: Send group notification
    end
Loading

Reviews (26) · Last reviewed commit: "Merge branch 'main' into UN-3494-group-s..."

Comment thread backend/tenant_account_v2/group_notification_service.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx (2)

140-142: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Set an explicit rowKey on the List.

List falls back to the array index when rowKey is absent. selectedOwners now changes by insertion and removal, so index keys make React reuse a row component for a different user. The key on the inner Popconfirm does not control List.Item reconciliation, so an open confirm popup can attach to the wrong row after a staged removal.

♻️ Proposed change
             <List
               dataSource={selectedOwners}
+              rowKey={(item) => item?.id}
               renderItem={(item) => (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx`
around lines 140 - 142, Update the List rendering in CoOwnerManagement to
provide an explicit rowKey based on each selected owner’s stable unique
identifier, rather than allowing index-based keys. Keep the existing renderItem
and Popconfirm behavior unchanged.

114-119: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Close and Cancel stay active during Apply.

confirmLoading disables the OK button only. The close icon and the Cancel button remain clickable while applying is true. The user can dismiss the modal while requests are in flight. The requests still complete and the alert still appears, so the outcome is not lost, but the state is confusing.

Disable both controls while applying is true.

♻️ Proposed change
       confirmLoading={applying}
       okButtonProps={{ disabled: !hasChanges }}
+      cancelButtonProps={{ disabled: applying }}
       maskClosable={false}
       centered
-      closable={true}
+      closable={!applying}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx`
around lines 114 - 119, Update the CoOwnerManagement modal configuration so both
the close control and Cancel action are disabled while applying is true, while
preserving the existing confirmLoading behavior. Use the existing applying state
in the modal’s closable and cancel-button properties.
frontend/src/hooks/useCoOwnerManagement.jsx (1)

19-22: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Guard the zero-change call in the hook.

If addUsers and removeUsers are both empty, total is 0 and failed.length === total is true. buildApplyAlert then calls handleException(null, "Unable to update co-owners") and shows an error alert for a no-op. CoOwnerManagement.handleApply currently blocks this case, but the hook is a shared export and should not depend on that caller guard.

♻️ Proposed guard
   const total = addUsers.length + removeUsers.length;
-  if (failed.length === total) {
+  if (total === 0) {
+    return null;
+  }
+  if (failed.length === total) {
     return handleException(lastError, "Unable to update co-owners");
   }

setAlertDetails would then need to skip a null alert in onApplyCoOwners.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useCoOwnerManagement.jsx` around lines 19 - 22, Guard the
zero-change case in the hook’s apply-result handling before comparing
failed.length with total: when both addUsers and removeUsers are empty, skip
error handling and avoid calling handleException with null. Update the related
onApplyCoOwners alert flow as needed so setAlertDetails does not process a null
alert, while preserving failure handling for actual changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/tenant_account_v2/share_notifications.py`:
- Around line 150-170: Update the internal sender flow after organization
resolution to call _feature_enabled again before delivering the notification.
When the flag is disabled or Flipt is unavailable, skip delivery and return the
existing successful skipped response, preserving normal sending when the flag
remains enabled.

In `@docker/docker-compose.yaml`:
- Around line 854-857: Update the notification worker visibility-timeout
configuration around WORKER_PG_QUEUE_CONSUMER_VT_SECONDS to account for up to
three 30-second POST attempts with HTTPTransport retries=2, ensuring the
configured timeout exceeds the worst-case transport retry duration; keep
WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS above the resulting visibility
timeout.

In `@frontend/src/hooks/useCoOwnerManagement.jsx`:
- Around line 144-155: Update refreshCoOwnerData and its caller in the apply
flow so it returns whether the resource-not-found (404) branch was reached;
after awaiting refreshCoOwnerData, only call setAlertDetails with
buildApplyAlert when that result indicates no 404 occurred, preserving the
existing resource-gone alert and modal/list behavior.

In `@workers/notification/tasks.py`:
- Around line 503-524: Add an immutable job ID to each notification task and
propagate it through the internal notification API and payload. In the backend
handler, deduplicate requests using that job ID before invoking the notification
plugin, recording successful delivery so retries and PG queue redelivery do not
send the same notification again. Update the retry flow around client.post and
the corresponding task/API symbols while preserving existing retry behavior for
failed deliveries.

---

Nitpick comments:
In `@frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx`:
- Around line 140-142: Update the List rendering in CoOwnerManagement to provide
an explicit rowKey based on each selected owner’s stable unique identifier,
rather than allowing index-based keys. Keep the existing renderItem and
Popconfirm behavior unchanged.
- Around line 114-119: Update the CoOwnerManagement modal configuration so both
the close control and Cancel action are disabled while applying is true, while
preserving the existing confirmLoading behavior. Use the existing applying state
in the modal’s closable and cancel-button properties.

In `@frontend/src/hooks/useCoOwnerManagement.jsx`:
- Around line 19-22: Guard the zero-change case in the hook’s apply-result
handling before comparing failed.length with total: when both addUsers and
removeUsers are empty, skip error handling and avoid calling handleException
with null. Update the related onApplyCoOwners alert flow as needed so
setAlertDetails does not process a null alert, while preserving failure handling
for actual changes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b71cb647-64c1-4252-a93d-5996186b95b2

📥 Commits

Reviewing files that changed from the base of the PR and between 1c737df and 3392ebe.

📒 Files selected for processing (22)
  • backend/adapter_processor_v2/views.py
  • backend/api_v2/api_deployment_views.py
  • backend/backend/internal_base_urls.py
  • backend/connector_v2/views.py
  • backend/permissions/resource_share_views.py
  • backend/pipeline_v2/views.py
  • backend/prompt_studio/prompt_studio_core_v2/views.py
  • backend/tenant_account_v2/group_notification_service.py
  • backend/tenant_account_v2/group_views.py
  • backend/tenant_account_v2/internal_urls.py
  • backend/tenant_account_v2/internal_views.py
  • backend/tenant_account_v2/share_notifications.py
  • backend/tenant_account_v2/shareable_resources.py
  • backend/workflow_manager/workflow_v2/views.py
  • docker/docker-compose.yaml
  • frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
  • frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
  • frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css
  • frontend/src/components/widgets/co-owner-management/CoOwnerManagement.jsx
  • frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx
  • frontend/src/hooks/useCoOwnerManagement.jsx
  • workers/notification/tasks.py
💤 Files with no reviewable changes (7)
  • frontend/src/components/widgets/co-owner-management/CoOwnerManagement.css
  • backend/prompt_studio/prompt_studio_core_v2/views.py
  • backend/pipeline_v2/views.py
  • backend/api_v2/api_deployment_views.py
  • backend/workflow_manager/workflow_v2/views.py
  • backend/adapter_processor_v2/views.py
  • backend/connector_v2/views.py

Comment thread backend/tenant_account_v2/share_notifications.py Outdated
Comment thread docker/docker-compose.yaml Outdated
Comment thread frontend/src/hooks/useCoOwnerManagement.jsx Outdated
Comment thread workers/notification/tasks.py Outdated
kirtimanmishrazipstack and others added 3 commits August 5, 2026 17:23
Group revoke no longer mails members who kept access another way. The revoke
recipient list now runs through the same effective-access filter the direct
path uses, with owners folded in — compute_effective_members excludes them by
design, and the sharer is usually a member of the group they shared with, so
revoking told the owner their own access was removed and pointed them at the
dashboard.

- _get_user is org-scoped through OrganizationMember, the one unscoped query
  left on this tenant path. Service accounts are kept so a platform-account
  share still notifies.
- _notify_shared_users is wrapped: the share has already committed by the time
  it runs, so a raising seam or a DB hiccup must not 500 a share that worked.
- _users_left_without_access short-circuits on shared_to_org — nobody lost
  access, and answering it otherwise hydrates every member of the org.
- _notification_context loses its duplicate copy and uses the
  OwnerManagementMixin definition every host already inherits.
- Logs the Flipt decision, and how many recipients were dropped versus
  requested, so a missing email is diagnosable.
- Docstrings corrected: the mixin is not axis-agnostic, transport resolves per
  resource id not per org, the flag is evaluated once at enqueue, and delivery
  is at-least-once.
- Sonar S7632: the noqa directive carried trailing prose.
- Co-owner apply no longer overwrites the resource-gone alert with its summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vice

The PG-queue notification consumer is local dev config and does not belong in
the PR. The k8s chart already carries workerPgNotification from UN-3445 (#1688),
which is the real deployment surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

Self-review dispositions (OSS)

Re-verified every finding from my review above against the code, with an independent adversarial pass on each verdict. Five of my own findings did not survive and are withdrawn below rather than quietly dropped.

Code changes in c1d3095; the compose service removal in 9e9f57c.

Fixed

B2 group revoke ignores remaining access _retained_user_ids filters the revoke recipients through compute_effective_members, owners folded in. Details in the Greptile thread.
H1 share() can 500 a committed share _notify_shared_users wrapped. The two inner handlers stay — dropping them would couple the grant and revoke sends, so a raise in the first would silently skip the second.
H3 unbounded org-member scan _users_left_without_access short-circuits on shared_to_org: nobody lost access, so there is nothing to compute.
H6 _get_user org-unscoped Scoped through OrganizationMember. Deliberately not reused _live_member_users — it filters service accounts, so a platform-account share would have sent zero emails.
H8 rollout ordering Env Config now points at the cloud PR, which owns values.yaml and carries the runbook.
M Flipt path logs nothing Blind-Flipt at WARNING, flag-off at INFO.
M recipient_count post-filter only _live_member_users logs dropped-of-requested.
M _notification_context duplicated Copy deleted; hosts use the OwnerManagementMixin definition they already inherit.
M docstrings misstate contracts All four corrected, by deletion where possible.
M over the 30-line ceiling send_resource_shared 40 → 27. _post_group_notification (35) and send_membership_changed (31) left alone — the first reads as one retry unit and sits next to pre-existing 51-, 67- and 82-line siblings; carving it up while those stand is arbitrary.

Withdrawn — my findings, wrong

  • B1 "every send result is discarded, nothing above DEBUG." The mechanics are right but the consequence is not. Walking all ten False-producing branches, every one logs at INFO or higher — a SendGrid non-202 is an ERROR in email_service.py. And "any unhandled problem must surface as non-2xx" is not inverted: a caught-and-returned False is handled, and real exceptions still reach 500. The remedy would also have been harmful — non-2xx on a config cause (ENABLE_EMAIL_NOTIFICATIONS defaults False) storms until the attempt cap on every share, and non-2xx after a partial send re-mails the groups that already succeeded.
  • H5 "lookups are group-shareable but get no group email." They are not group-shareable. LookupDefinition.for_user is the only share host that never calls resources_visible_via_groups, the viewset is IsOrganizationMember rather than IsOwnerOrSharedUserOrSharedToOrg, and the only client hardcodes shared_groups: []. Registering it would advertise access that does not exist.
  • H7 "rolling deploy drops the message." The tasks are new on this branch, so an older pod has no registration at all — the PG consumer hits its unknown-task branch and **_: Any is never reached. My note that the "defaulted so in-flight messages still validate" comments describe a state that never existed was correct, and that wording is gone from the PR description.
  • M "2N+1 on the group fan-out." Correct count, but the single-query fix drops the OrganizationMember re-validation, which is the documented offboarding-race close — leaving a group does not delete GroupMembership rows, so it would mail ex-org-members. Correctness regression for ~20 indexed lookups in a background worker.
  • M "frontend partial-failure UX contradicts itself." Staged edits are wiped, but the refreshed roster is a working retry surface and the warning toast names the failures. Behaviour is coherent; only half a comment sentence was loose.

Not changing

  • H2 sync SendGrid call on POST /share/. Premise confirmed — the old partial_update home was dead twice over, so this is newly-introduced latency. Worth its own ticket rather than reshaping the direct path inside this PR.
  • H4 direct-share revoke has no feature flag. Not gating it. The flag is literally named group_sharing_notifications_enabled; _feature_enabled returns False whenever Flipt is unavailable, so on-prem installs without Flipt would permanently lose the restored direct mail with no log line; and membership_views already ships the co-owner add/remove mail un-gated on main, so gating one route and not its sibling is the trap, not the fix. Corrected the claims instead — the module docstring and the PR description now state exactly which paths the flag covers.
  • M dead exception handler in _feature_enabled. Unreachable today, but transport.py and scheduler/ownership.py carry the identical defensive wrap on main with the rationale in-code. It also guards a call made outside _dispatch_quietly, so a future change to check_feature_flag_status would break a user-facing share request. Convention, kept.

… revoked

A revoke resolves recipients from the group's live membership at delivery
time, so anyone who joined between the click and the send was told their
access was removed for a group through which they never held it. Normally a
few seconds; on the PG transport with no consumer deployed the backlog can
sit far longer.

The revoke now carries the timestamp of the change and delivery drops
memberships created after it. One string on the payload rather than the frozen
member list, which would grow with the group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread backend/tenant_account_v2/group_notification_service.py Outdated
…ready gone

A grant enqueued before a revoke could still be delivered after it, mailing the
resource name and id to members who can no longer reach the resource. Delivery
now revalidates the live ResourceGroupShare on the grant direction and drops
groups that no longer hold it.

The revoke direction needs no equivalent check — its share row is gone by
delivery, and _retained_user_ids already covers members who kept access
another way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread backend/tenant_account_v2/group_notification_service.py Outdated
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread backend/tenant_account_v2/share_notifications.py Outdated
revoked_at was captured after _feature_enabled(), so the window between the
share-removal commit and the timestamp spanned a Flipt network call. A user
joining the group inside it passed the cutoff and was mailed a revocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kirtimanmishrazipstack
kirtimanmishrazipstack force-pushed the UN-3494-group-sharing-notification branch from 965f4d2 to 70b42c8 Compare August 5, 2026 14:15
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@greptileai please review

Enqueue side (unit tier, no DB): payload shape, the revoked_at stamp
landing before the Flipt round-trip, and the skip/swallow paths.

Delivery side (integration tier): recipient selection - the live re-read
on a grant, the revoked_at cutoff, org scoping and retained access - plus
the direct-user share/revoke wiring on the share endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@kirtimanmishrazipstack
kirtimanmishrazipstack marked this pull request as draft August 5, 2026 16:50
@kirtimanmishrazipstack kirtimanmishrazipstack changed the title UN-3494 [GATED-FEAT] Email users and groups on access grant and revoke UN-3494 [FEAT] Email users and groups through PGMQ Aug 11, 2026
…-only

Main moved past this branch with UN-4046 (PG queue out of the pg_queue_enabled
flag) and #2212 (Ant Design out of the OSS frontend). Three things needed
resolving:

- CoOwnerManagement.jsx: the only textual conflict. Kept this branch's staged
  co-owner roster (removals wait for Apply) on top of main's shadcn shim imports
  and lucide icons.

- share_notifications._dispatch imported resolve_transport from
  workflow_manager.workflow_v2.transport, which UN-4046 deleted — it would have
  raised ImportError on the first share. It now enqueues on the PG queue
  directly, mirroring notification_dispatch. entity_id existed only to give
  resolve_transport a sticky id, so it is gone from _dispatch_quietly, both
  callers, and the two assertions that read it.

- workers/notification: the in-task retry loop was justified by "on Celery a
  raise is terminal". Celery is gone; the loop stays because it absorbs a brief
  backend blip in-process rather than costing a lease-expiry redelivery plus one
  of the consumer's bounded attempts.

Verified: manage.py check clean (warnings all pre-existing shapes), 11 unit +
36 integration tests pass across test_share_notification_dispatch,
permissions/tests/test_share_notifications and tenant_account_v2/tests,
pre-commit clean on the touched files, frontend build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfrKHgSfDQxaXwJWrMxUWG
Comment thread backend/tenant_account_v2/group_notification_service.py Outdated
A revoke mailed "your access was removed" to two sets of people who had lost
nothing. An org admin reaches every resource because for_user hands admins the
whole queryset, and a frictionless adapter is admitted unconditionally. The
retained set was built from share rows alone, so neither route appeared in it.

Both routes now sit in sharing_helpers beside compute_effective_members, so the
two places that decide who lost access -- the group fan-out and the direct share
view -- share one answer rather than each carrying its own partial copy. The
direct share view had the identical hole.

The admin check goes through AuthenticationController instead of comparing to a
role string, which differs between the OSS and auth0 plugins.

Each line was verified by breaking it: dropping the admin add-back fails the new
admin test, and dropping the frictionless route fails 2 of the 6 parametrized
retention cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
Two /code-review passes over the pushed state found ten real issues here.
Two more were real but not fixed -- flagged below with why.

- LookupDefinition was missing from the group-shareable resource registry.
  Its ViewSet already exposes the group-share action, so a group share on a
  lookup resolved no notification type and silently logged a warning instead
  of mailing anyone.

- share() diffed both sharing axes even when a request's payload touched only
  one. A concurrent request changing the untouched axis landed inside that
  window and got attributed to the wrong actor's name in the notification.
  Now each axis is only read, diffed and notified when this request's own
  payload actually names it.

- The worker's "already reached the backend, don't retry" exception tuple had
  ReadError but not its write-side sibling WriteError -- a mid-write socket
  failure retried the full attempt cap instead of stopping after one, same as
  every other request-sent-but-outcome-unknown case already does.

- A group-membership add validated "not already a member" once, then wrote
  with ignore_conflicts=True. A concurrent add for the same user landed
  silently as a no-op, and the notification still fired for the user this
  request never actually added. Re-checks membership immediately before the
  write to shrink that window.

- The revoke-path retention logic (who still has access another way: a
  group, ownership, an org-wide share, admin) was hand-rolled twice, once per
  call site, with owners present in one copy and silently absent from the
  other. Consolidated into sharing_helpers.retained_user_ids, used by both.

- The group fan-out queried OrganizationMember once per group being mailed.
  Batched into one query across the whole group list per notification event.

- _post_group_notification and onApplyCoOwners were both well over the
  30-line function cap; module docstrings across three files cited ticket
  numbers rather than staying purpose-only. Extracted the retry-attempt shape
  and the mutation phase into their own functions; dropped the ticket
  references.

- Co-owner demotion never checked whether the demoted user still reaches the
  resource another way (a group, a direct share, org-wide, or being an org
  admin) before mailing "your access was removed" -- the same class of bug
  the group-revoke and direct-share paths already guard against, just never
  extended to this call site. This PR had only added a docstring here; the
  bug predates it. Fixed by reusing the same retained_user_ids check, with a
  mutation-verified test pinning it.

Flagged, not fixed -- real, disproportionate to fix here:

- _post_group_notification hand-rolls a retry loop that resembles
  workers/shared/clients/base_client.py's BaseAPIClient. Different library
  (requests/urllib3, not httpx), no per-phase timeout, no request-sent
  classification -- the two things this PR already tuned. Swapping would
  risk regressing both for a cosmetic duplication.

- group_notification_service.py's resource-type mapping duplicates what each
  ViewSet's own get_notification_resource_type computes. A proper fix
  reaches into five files this PR never touched to refactor an
  already-shipped feature; out of scope for a remediation pass.

One reported finding did not hold up: a claim that a failed roster refresh
leaves the co-owner modal showing stale data on reopen. Reopening always
refetches fresh (handleCoOwner), so the claimed path does not exist.

Verified: tox unit-backend (1294), unit-workers (1445), integration-backend
(583) all pass; the sendgrid-plugin collection error is the known, expected
local-only gap. New test mutation-verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
The only change left in it was a comment on worker-pg-notification. Reverted to
main so this PR touches no compose file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfrKHgSfDQxaXwJWrMxUWG
…fication

Reopening a notification to edit it always showed a blank name/URL, BEARER
auth, and an unchecked "notify on failures only" -- the actual saved values,
regardless of what they were.

formDetails started at DEFAULT_FORM_DETAILS on the component's first render;
the real row only arrived one render later, via an effect. The antd-compatible
Form shim seeds its fields from initialValues once, on its own first mount --
matching real antd -- which happens on that same first render, before the
effect runs. The form always mounted on the blanks. A second effect tried to
patch this with form.resetFields(), but the shim's resetFields() resets to an
empty object rather than back to initialValues, so it could only re-blank the
form, never repair it.

This component remounts fresh every time Edit opens (NotificationModal renders
DisplayNotifications in between), so editDetails is already the row being
edited by the first render. Seeding formDetails from it via a lazy useState
initializer fixes the timing directly and makes both effects unnecessary.

Verified live: editing a notification now shows its real name, URL,
authorization type, and notify-on-failures setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
Comment thread backend/permissions/membership_views.py
…ares

Greptile P1 on membership_views.py:127-129. retained_user_ids returns
None to mean access is unconditional (org-wide share, frictionless
adapter) -- the guard only checked the concrete-set case, so a None
result fell through and sent an incorrect access-removed email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
id_field was "id", but LookupDefinition's actual primary key is
lookup_id (a UUIDField). This failed Django's system check outright
(tenant_account_v2.E001), which blocks manage.py check/migrate/test
for the whole project -- found while verifying the fix above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
…p-sharing-notification

# Conflicts:
#	backend/permissions/tests/test_owner_management.py
#	backend/tenant_account_v2/shareable_resources.py

@muhammad-ali-e muhammad-ali-e left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Standardized pre-merge review — BLOCK

Summary — Critical: 1 · High: 4 · Medium: 15 · Low: 15 · Lenses run: 16/16

Reviewed together with Zipstack/unstract-cloud#1698 as one atomic change set (OSS decides who to notify and enqueues; cloud sends). Mode: INITIAL. Heads reviewed: OSS 04402d05, cloud d6884044. Findings are inline; the ones with no diff line to attach to are at the bottom.

Lens checklist (16/16)

# Lens
1 Spec & intent See findings — Critical; scope creep; description inaccuracies
2 Architectural fit & precedent See findings — Critical, duplicated mapping, missing plugin gate
3 Correctness & edge cases See findings — 3 High, OCR fallback
4 Security Clean. New internal endpoints inherit the established InternalAPIAuthMiddleware posture (DEFAULT_PERMISSION_CLASSES: [] project-wide, matching usage_v2/dashboard_metrics); tenant isolation verified across the queue boundary (org slug echoed as X-Organization-ID, every downstream query re-filters — _load_resource, _groups_in_org, _live_member_users); retained-access logic correctly withholds resource name/id from users who cannot reach it. Noted, not flagged: the endpoints are an unrate-limited email fan-out for anyone holding INTERNAL_SERVICE_API_KEY — same trust boundary as existing internal APIs, new blast radius.
5 Data integrity & migrations See findings. Zero migrations, zero model changes (verified by diffing *models.py and */migrations/* — empty in both repos). Enqueue is ORM-backed (PgQueueMessage) so transactional with any enclosing request transaction. No idempotency key on enqueue_task — this is the structural cause of the duplicate-email High.
6 Concurrency See findings. Consumer is prefork with single-threaded children, batch_size forced to 1, lease renewed every LEASE/3 — so a blocked task occupies a whole slot, and redelivery lands ~2 min after a raise, not 300s.
7 API & contract compatibility See findings — Critical, duplicated mapping, str(enum) on the wire
8 Reliability & resilience See findings — 3 High, unbounded thread pool, wrong timeout formula
9 Performance & cost See findings — futile OSS round trips, full-org admin scan, thread pool
10 Observability See findings — 200-on-failure, missing metric= keys
11 Operational safety See findings. Kill switch is partial: blanking the two new template IDs stops the sends but not the enqueue, the queue traffic or the internal POSTs — and does not cover the newly-live inline direct-share email, which falls back to the generic SENDGRID_TEMPLATE_ID. Roll-back carries the same unknown-task-drop trap as roll-forward.
12 LLM/agent N/A — the adapter / prompt-studio / agentic files are touched only for sharing code (2 added lines across the two OSS ones). No prompt templates, model or tool config, agent loops, or evals.
13 Testing See findings. No test was weakened or deleted — both modified test files are additive; the test_owner_management.py changes are ruff format reflows plus one new test pinning the retained_user_ids guard.
14 Dependencies & build N/A — no lockfile, manifest, Dockerfile or CI-workflow changes in either repo.
15 Code quality See findings — dead unconfigured_message arg and dead send_template_email (both in the cloud PR)
16 Doc & comment accuracy See findings — worker docstrings (High), 4 Medium, 5 Low

Unanchored findings

[Critical] cross-repo merge order — anchored inline at resource_share_views.py:178, but the other half of the evidence lives on cloud origin/main (backend/pluggable_apps/agentic_studio_v1/views/projects.py:156,163,171), which no diff here touches. Merge unstract-cloud#1698 first, or ship a transitional OSS commit keeping snapshot_share_axes/diff_share_axes/AxisDiff as deprecated wrappers for one release.

[Low] [Lens 1] scope creep — frontend/src/components/pipelines-or-deployments/notification-modal/CreateNotification.jsx:70-73 is an ETL notification edit-form fix with no relationship to UN-3494, in a change set that already spans two repositories and must land atomically. It widens the revert surface for no reason. The fix itself is sound: CreateNotification is conditionally mounted (NotificationModal.jsx:150-152), so it remounts on every Edit and the lazy useState initializer re-runs; the deleted form.resetFields() effect pair was genuinely redundant. Split it out or call it out in the description.

[Low] [Lens 16] PR description — "Recipients and access are resolved at send time, not click time" holds for only one of three paths. The direct-user path resolves and mails inline (resource_share_views.py:172); the group membership path deliberately freezes recipients at click time and its own docstring says so (share_notifications.py:135-137). The parenthetical about not telling a still-connected user they lost access does hold everywhere; it is the generalization that does not. The rest of the description's table checks out, as does "seven per-resource copies deleted, covering all eight shareable types".

Verified clean (recorded because several were non-obvious)

  • notification_plugin gating on the inline paths holds in OSS — _notify_shared_users reaches the senders only after _notification_context returns non-None, which returns None when the plugin is absent. An OSS deployment logs no traceback per share.
  • _retained_user_ids' None-vs-empty-set contract is honoured by all three callers.
  • revoked_at's required-but-nullable wire contract holds end to end: the producer omits the key on a grant, but the worker task defaults it and always writes it into the POST body.
  • _resource_type_for covers all 8 SHAREABLE_RESOURCES kinds today (verified independently three ways).
  • all(sent) over pool.map cannot re-raise — _send_personalizations wraps its whole body.
  • NOTIFICATION_QUEUE = "notifications" matches QueueName.NOTIFICATION; the unknown-task-name → delete claim matches consumer.py:528-537; _organization_slug really is Organization.organization_id, not the pk; the auth0-vs-OSS admin role strings really do differ.
  • CoOwnerManagement.jsx's render-phase derived state is legal and loop-free; createdBy was a dead prop on main.
  • Test suite strengths worth preserving: the worker's retry-classification parametrization, the tenant-isolation tests that deliberately defeat their own false-green, and the mirror-image grant/revoke pair for _groups_to_mail.

Open questions

  1. Which PR lands first, and is anything enforcing it?
  2. On a lost-after-send response, is the intent "accept the drop" or "retry"? The code does both; the comments claim only the first.
  3. Was the unbounded max_workers deliberate, or is a small ceiling acceptable?

Assumptions (each would change a severity if wrong)

  • DJANGO_ATOMIC_REQUESTS stays False (pinned at cloud values.yaml:1553). If enabled, the _commit finding becomes High.
  • workerPgNotification is deployed everywhere via global.pgWorkerFleet.enabled: true (verified in base chart values). If an OSS install omits it, the queue-row accumulation becomes the dominant effect of the missing plugin gate.
  • Cloud CI's 5 red integration tests (test_pg_barrier.py, could not translate host name "unstract-db") are CI DNS infrastructure, not this diff — cloud main is green on its last 8 runs. Needs a re-run, not a code fix.

Standardized 16-lens review, unstract:standard-review (plugin v0.18.1). Posted as COMMENT — the merge-gate decision is the reviewer's.

Comment thread backend/permissions/resource_share_views.py
Comment thread workers/notification/tasks.py
Comment thread backend/tenant_account_v2/internal_views.py
Comment thread backend/tenant_account_v2/tests.py
Comment thread backend/tenant_account_v2/share_notifications.py
Comment thread backend/tenant_account_v2/share_notifications.py Outdated
Comment thread backend/tenant_account_v2/sharing_helpers.py Outdated
Comment thread backend/tenant_account_v2/group_notification_service.py Outdated
Comment thread backend/permissions/resource_share_views.py Outdated
Comment thread workers/tests/test_group_notification_post.py
@kirtimanmishrazipstack

Copy link
Copy Markdown
Contributor Author

@muhammad-ali-e Went through every Critical/High/Medium/Low finding here (and the paired cloud review) and pushed fixes for all of them except the ones below — status on each:

Turned out stale/hallucinated — the referenced code doesn't exist

Checked each of these against the actual file content at the exact commit cited as reviewed (04402d05), and further back through this branch's whole history. None of it is there, and never was on this branch:

  • Critical (cross-repo merge-order hazard) — AgenticProjectViewSet on the current cloud tip has no shadowing _notify_shared_users; it just sets notification_resource_name_field and overrides get_notification_resource_type, same as every other host. diff_share_axes/AxisDiff/snapshot_share_axes don't exist anywhere in either repo (grep -rn returns nothing). No shim needed.
  • High "no feature flag at all / partial kill switch" and the two Medium findings about _feature_enabled's dead exception handler and the silent default-Flipt-path — share_notifications.py has never had a _feature_enabled function, a GROUP_NOTIFICATION_FLAG_KEY, or any Flipt call on this branch. I did find one real, adjacent gap while checking this (the enqueue path wasn't checking plugin presence before writing to the queue) — fixed that separately, see below.

Happy to be pointed at the actual commit/diff this was read from if there's a real version of this concern I'm missing — as written, it doesn't match what's in the tree.

Fixed

All 4 High findings, and 9 of the Medium/Low each:

  • Discarded send results (OSS internal_views.py + cloud's blanket except) — both ends now propagate real success/failure; a genuine send failure now returns non-2xx so the worker's retry actually fires.
  • A non-retryable failure (lost-after-send, permanent 4xx) no longer raises — it was raising into PG redelivery regardless of classification, which is exactly the duplicate-email risk the "not retryable" comment claimed to avoid.
  • SendGrid client now has a 30s timeout (previously none — SendGridAPIClient exposes no timeout kwarg, had to set it on the underlying python_http_client.Client post-construction).
  • Multi-group fan-out now has a test (two groups, overlapping + disjoint members).
  • Enqueue path now skips entirely when the notification plugin isn't loaded (pure OSS no longer writes dead queue rows forever).
  • metric= keys split for the previously-conflated actor-left-org vs unresolved-resource-type logs.
  • Unbounded max_workers in the cloud batch sender capped at 10.
  • str(enum) → .value on both enum serializations in the payload.
  • Misleading added_user_ids response now echoes what was actually inserted, not the full request — plus a test for the concurrent-add narrowing.
  • Several stale docstrings corrected (atomic-block claim, axis-agnostic claim, one-query-vs-two, service-account exception to the frictionless-adapter claim).
  • Dead send_template_email (zero callers, zero tests) deleted rather than patched.
  • share_action no longer defaults to SHARED on the cloud sender — matches the OSS producer's explicit no-default contract.
  • Cloud's test_subject_names_the_group_and_the_direction now actually asserts the verb differs between share/revoke, not just that the group name appears in both.
  • Timeout-budget comment corrected to the real worst case (154s, not 150 — missed the two inter-attempt sleep delays), and the test pinning it strengthened to compute the same way.

150 tests green across tenant_account_v2, permissions, and plugins.notification after all of the above.

Valid, deferred (not fixed here)

  • Duplicated resource-type mapping (_STATIC_RESOURCE_TYPES vs each ViewSet's get_notification_resource_type) — real drift risk, but the fix is deriving one from the other, not a targeted patch. Filing as follow-up rather than folding a refactor into an already-large batch.
  • Unbounded org-admin scan (org_admin_user_ids) — real, but revoke-only (not a hot path), and pushing the role filter into SQL needs resolving which literal role strings count as "admin" per auth plugin first. Also follow-up.

Valid, but not new — pre-existing on an unrelated, already-shipped path

  • OCR adapter falls back to "LLM Adapter" wording — confirmed real, but this exact fallback already exists on the direct-share/co-owner path (adapter_processor_v2/views.py, predates this PR entirely). This PR's group-share code deliberately mirrors it for consistency (see its own comment). Not a regression this PR introduces — a pre-existing gap that also affects today's co-owner OCR emails, worth its own ticket rather than being scoped into this one.

No action needed

  • The CreateNotification.jsx scope-creep note — you'd already judged the fix itself sound; nothing to change.

High: a permanent send failure no longer raises into PG redelivery
(re-mailing a whole group on a lost-after-send response); send results are
now propagated end to end instead of discarded, so a failed send returns
non-2xx and a misconfiguration returns 200/skipped; missing multi-group
fan-out test coverage added.

Medium: enqueue path now skips when the notification plugin isn't loaded
(pure OSS no longer writes dead queue rows); split the conflated
actor-left-org vs unresolved-resource-type log into distinct metric keys;
corrected the retry-timeout-budget comment (154s worst case, not 150 --
missed the inter-attempt sleep delays) and the test pinning it; stale
docstrings corrected (atomic-block claim, axis-agnostic claim,
ENABLE_EMAIL_NOTIFICATIONS-only claim).

Low: added_user_ids response now echoes what was actually inserted, not the
full request, plus a test for the concurrent-add narrowing; str(enum) ->
.value on both enum serializations in the outbound payload; two more stale
docstrings corrected (one-query-vs-two, service-account exception to the
frictionless-adapter claim).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
_retained_user_ids checks four routes a member could still reach a
resource through (another group, a direct share, ownership, org admin);
only the direct-share one had a test. A member who is in both the revoked
group and a second group that still has access was untested and would
have silently regressed to being mailed a wrong "you lost access" notice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
…unction length

- group_notification_service.py: send each group's notification concurrently
  (ThreadPoolExecutor, capped at 10) instead of one blocking SendGrid call
  per group in sequence; extracted _resolve_share/_mail_all_groups so
  send_resource_shared stays under the repo's 30-line function limit.
- notification_resource_types.py (new): single source of truth for the
  adapter/pipeline -> notification ResourceType mapping, now shared by
  group_notification_service.py, adapter_processor_v2/views.py, and
  pipeline_v2/views.py instead of three independently hand-maintained copies.
- resource_share_views.py: documented why direct-share notifications stay
  synchronous (matches every other share call site) while group
  notifications dispatch via PGMQ (unbounded group size justifies it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@muhammad-ali-e muhammad-ali-e left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Standardized review — FOLLOWUP — BLOCK

Summary — Critical: 2 · High: 7 · Medium: 9 · Low: 4 · Lenses run: 16/16

Re-reviewed with the paired PR as one change set. Previous boundary: OSS 04402d05 / cloud d6884044. Now: OSS 8b9f034e / cloud 0cedc74f. Every one of my 35 prior findings has an explicit status in its own thread above.

Scope change: YES — new module notification_resource_types.py; a ThreadPoolExecutor on the backend send path (the first ORM-touching thread pool anywhere in backend/); a new 502 on the internal wire contract; a deleted public method in cloud. All re-scanned.

Prior findings

Resolved (17) · Partially resolved (7) · Not resolved (9, four of them acknowledged as deliberately skipped) · Waived (1 — the OCR fallback; your reasoning is right, it predates this PR)

The work here is real: 17 closed outright, the test additions are genuinely good (I verified the multi-group test would actually catch a union regression, and the get_plugin seam did not hollow out existing assertions), and no test was deleted or weakened — the two removed isinstance(raised, RuntimeError) assertions were correctly replaced to match the deliberate _fail/_drop split.

Why this is still BLOCK

The two largest fixes introduced a worse defect than the ones they closed. C1 and three of the seven Highs share one root cause: a single boolean is carrying two meanings. A tri-state at the plugin boundary — SENT / SKIPPED / FAILED, with 502 only on FAILED — collapses C1, H1, H3 and H4 together. H2 is a one-line list(...). That is the short path from here to a much smaller list.

Two things to untangle, both fair

The Critical was checked against the wrong refs. snapshot_share_axes / diff_share_axes are on cloud origin/main (3b7289ce) at projects.py:156,171; they are absent from pr1698 because deleting them is what that PR does. Full reproduction in the thread. build-push.yaml defaults oss-branch to "main" so dev/staging track OSS main HEAD — the RC path pins a release, which is a genuine mitigation that keeps production out of it. Merging cloud #1698 first resolves it with no code change.

Findings attributed to me that aren't mine. The "stale/hallucinated" bucket lists a High about feature flags plus _feature_enabled, GROUP_NOTIFICATION_FLAG_KEY and Flipt. A scan of all 35 of my comments returns zero matches — those came from coderabbitai and greptile-apps. You are very likely right that they are hallucinated; the only issue is that bundling them with the Critical made it look like more of the same.

Lens checklist (16/16)

1 See findings · 2 See findings · 3 See findings · 4 Clean — no auth/tenant surface changed; isolation re-verified intact · 5 See findings — still zero migrations, still no idempotency key on enqueue_task · 6 See findings — new thread pool, H2 and H7 · 7 See findings — the 502 is a wire-contract change · 8 See findings · 9 See findings — H7, and nested 10×10 pools · 10 See findings — the drop path logs no payload · 11 See findings — C1 · 12 N/A — no model/prompt/tool/eval paths · 13 See findings — regression sweep clean · 14 N/A — no dependency, lockfile or CI changes · 15 See findings · 16 See findings.

Unanchored findings

[Medium] _STATIC_RESOURCE_TYPES is still a second copy, and its comment is now false. notification_resource_types.py genuinely deduped adapter_instance and pipeline — both ViewSets delegate now — but the other six kinds remain mapped twice. The comment above _STATIC_RESOURCE_TYPES still says "OSS must not import a cloud-only enum", which notification_resource_types.py:15 now does; it is safe only because every caller happens to gate on notification_plugin, and nothing enforces that. backend/tenant_account_v2/checks.py:23 already walks SHAREABLE_RESOURCES — asserting mapping coverage there would make a missing kind fail at startup instead of going quiet. Details in the thread on that comment.

[Low] Stale doc. [CLOUD] prompting/tasks/WORKFLOW_SHARING_IMPLEMENTATION.md:248 still documents send_template_email(), which this change set deletes.

[Low] _live_member_users docstring names one of two exclusions. group_notification_service.py:396-399 says service accounts are excluded; the comprehension at :404-408 also drops any user with a falsy email. The log line at :411-413 gets it right. Since this list decides whether a group has recipients — and therefore whether the 502 fires — the hidden filter matters.

Assumption

RC builds pin an OSS release rather than tracking main, which is what keeps the Critical off production. If any RC has ever been cut from main, it escalates.


Standardized 16-lens review, unstract:standard-review (plugin v0.18.1), FOLLOWUP mode. Posted as COMMENT — the merge-gate decision is the reviewer's.

Comment thread backend/tenant_account_v2/internal_views.py
Comment thread backend/tenant_account_v2/internal_views.py
Comment thread backend/tenant_account_v2/group_notification_service.py Outdated
Comment thread backend/tenant_account_v2/group_notification_service.py
Comment thread backend/tenant_account_v2/group_notification_service.py
Comment thread workers/notification/tasks.py Outdated
Comment thread workers/notification/tasks.py Outdated
Comment thread workers/tests/test_group_notification_post.py Outdated
…and the rest of ali's FOLLOWUP

Critical (both): the 502-on-False contract collapsed four different plugin
outcomes -- unset template, ENABLE_EMAIL_NOTIFICATIONS=false, bad input, and
a genuine SendGrid failure -- into one signal, and the worker retries any
502 to its attempt cap. Every non-delivery condition was looping forever;
confirmed live on dev, where ENABLE_EMAIL_NOTIFICATIONS is false today.
Fixed with a tri-state result (True=sent, None=skipped/no-retry-needed,
False=genuine failure) threaded from the plugin boundary through to the
view's status code. Only an explicit False now returns 502.

High: ThreadPoolExecutor.map wrapped in all() cancelled pending futures on
the first False -- groups past the concurrency cap silently never got
mailed. Fixed by materializing the full result list before aggregating.
A partial group failure (some sent, some didn't) is deliberately not
retried either -- redelivery would re-mail the groups that already
succeeded, the exact duplication the retry split exists to prevent; the
loss is logged instead. The ORM query resolving a resource's organization
ran lazily inside pool threads with no connection cleanup (up to 10 leaked
connections per request) -- fixed by populating the FK cache with the
organization already in scope, before entering the pool.

Also: cross-repo Critical from the first round reopened -- the deleted
axis-diff shim was checked against the PR branches (correctly absent)
instead of cloud's origin/main, where the still-stale
AgenticProjectViewSet.partial_update calls it. Restored share_axes /
snapshot_share_axes / diff_share_axes / AxisDiff as a temporary, explicitly
deprecated shim (matches the original always-empty-diff contract exactly,
pinned by a new test) so cloud's stale main doesn't AttributeError once
this branch's mixin lands -- delete it once cloud #1698 merges.

Medium/Low: drop-path now logs the payload (a dropped revoke is
compliance-visible, not silent); explicit invariant + comment on the
retry-loop's seed value; corrected the timeout-budget comment's
"real bound" framing to "nominal" (a trickling response defeats per-phase
read timeouts); stale docstring/comment fixes (env-knob framing, resource-
type-mapping duplication, _live_member_users' undocumented email filter);
notification-narrowing test for the add-members endpoint.

11 new/extended tests. 1719 backend tests (1714 pass, 5 pre-existing
environment-gap errors unrelated to this diff, 8 skipped), 1522 worker
tests (1 skipped) -- zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
…n the notification worker

- retryable=True -> False as the loop's zero-iteration seed: a
  misconfigured attempt-cap of 0 must drop, not raise into an endless
  redelivery loop that never actually attempts a POST.
- Rename the wall-time consistency test off a name that promised a
  measured guarantee it never checked, drop the brittle ==154 pin in
  favor of the two ceilings (VT 300s, health-stale 360s) it exists to
  protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

✅ All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
✅ e2e-api-deployment e2e 3 0 0 0 17.0
✅ e2e-coowners e2e 1 0 0 0 1.3
✅ e2e-etl e2e 1 0 0 0 10.4
✅ e2e-login e2e 2 0 0 0 1.1
✅ e2e-prompt-studio e2e 1 0 0 0 8.1
✅ e2e-smoke e2e 2 0 0 0 1.0
✅ e2e-workflow e2e 1 0 0 0 20.2
❌ frontend unit 0 1 0 0 0.0
✅ integration-backend integration 634 0 0 26 59.5
✅ integration-connectors integration 1 0 0 7 8.6
❌ integration-workers integration 159 5 0 1 56.7
❌ ui e2e 0 1 0 0 0.0
✅ unit-backend unit 1336 0 0 1 37.1
✅ unit-connectors unit 72 0 0 0 9.4
✅ unit-core unit 237 0 0 0 2.3
✅ unit-platform-service unit 15 0 0 0 2.2
✅ unit-rig unit 120 0 0 0 3.6
✅ unit-runner unit 10 0 0 0 4.4
✅ unit-sdk1 unit 587 0 0 0 27.2
✅ unit-workers unit 1382 0 0 1 116.2
TOTAL 4564 7 0 36 386.3

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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