Skip to content

feat(integrations): fan out Linear webhooks across orgs sharing a workspace - #4604

Merged
saddlepaddle merged 1 commit into
mainfrom
linear-multi-connection-r
May 20, 2026
Merged

saddlepaddle merged 1 commit into
mainfrom
linear-multi-connection-r

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented May 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

PR #4386 added a partial unique index forcing one Superset org per Linear workspace. The block has been a real adoption friction (agencies, parent/sub orgs, contractors all hit it), and on inspection it turns out Linear's webhook model doesn't actually need it — it just needs receive-side fan-out.

  • Replace the global (provider, external_org_id) partial unique with a Slack-scoped one. Linear workspaces may now be shared across orgs; Slack keeps its atomic one-org-per-workspace guard at the DB level.
  • Fan out the Linear webhook handler: findMany over active connections for the workspace, dispatch processIssueEvent per connection, key webhook_events as ${connection.id}-${linearOrgId}-${timestamp} so each tenant's processing is independently retryable.
  • Fix a latent cross-org bug in Issue.remove: filter organizationId so a Linear deletion only soft-deletes the recipient org's task row.
  • Acknowledge webhooks for workspaces with zero active connections with a 2xx (not 404) so Linear stops retrying an unrecoverable condition.
  • Stop echoing internal connection ids / raw error strings back to Linear in the failure response — logged server-side instead.
  • Drop the OAuth callback's workspace_already_linked redirect and the matching UI error branch.

Why the original guard turned out to be unnecessary

The #4386 incident was 153 orgs / 167 connection rows misrouting in prod. That symptom is consistent with two theories:

  • (A) Linear delivers once per (OAuth app, workspace); our pre-fix findFirst picked a random connection.
  • (B) Linear delivers N times for N installations; our eventId = ${linearOrgId}-${timestamp} with a unique constraint collapsed N → 1 before dispatch.

Both produce identical symptoms. To settle which, I queried webhook_events directly. The 당근 workspace had 14 historical Superset connection rows from 14 distinct end-users in 14 distinct customer orgs (mostly simultaneously active before #4386's cleanup migration) — and produced exactly one distinct webhookId across all stored events. Theory A is confirmed; the original guard was a product choice (force a single tenant) masked as a technical constraint (avoid misrouting).

Linear delivers one stream per workspace and leaves tenant routing to us. Fan-out on receive is the right shape.

Echo loop note for reviewers

When org A pushes a task change → Linear → Linear webhooks the change back, both A and B receive it. A's row is upserted with content A just sent (idempotent no-op). B's row picks up A's change (intended). No suppression logic needed.

Test plan

  • bun run lint and bun run typecheck clean
  • Migration applied locally; index confirmed dropped in DB
  • Empirical confirmation via historical webhook_events.payload->>'webhookId' (above)
  • Manual OAuth flow from two test Superset orgs into the same Linear workspace; confirm no workspace_already_linked toast and both orgs sync independently
  • Edit an issue in Linear; confirm both orgs' tasks rows update and two webhook_events rows land (one per connection id) both reaching processed

Follow-ups (not in this PR)

  • Disclosure UI: when multiple orgs share a workspace, surface a small banner naming the others.
  • OAuthApp revoked webhook: gracefully disconnect specific installations on Linear-side de-auth.
  • Same shape for Slack if/when product calls for multi-tenant Slack — the Slack-scoped DB index would be the thing to relax.

Summary by CodeRabbit

  • Bug Fixes

    • More reliable Linear OAuth linking via upsert to prevent duplicate links.
    • Improved Linear webhook handling: concurrent per-connection processing, per-connection idempotency, and clearer partial/failure reporting.
    • Broader Slack OAuth conflict detection to better surface workspace-linking errors.
    • Simplified integration error toast messaging for clearer user feedback.
  • Chores

    • Database constraint and migration updates to enforce revised uniqueness rules for Slack and Linear integrations.

Review Change Stack

@capy-ai

capy-ai Bot commented May 15, 2026

Copy link
Copy Markdown

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@coderabbitai

coderabbitai Bot commented May 15, 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
📝 Walkthrough

Walkthrough

This PR refactors Linear and Slack integration connection handling to support multiple active connections per organization. The database schema is updated with organization-scoped uniqueness constraints, the Linear OAuth callback simplifies to an upsert operation, the Linear webhook handler processes all active connections concurrently with per-connection event idempotency, and related error and constraint detection logic is updated throughout.

Changes

Integration Multi-Connection Support

Layer / File(s) Summary
Database Schema - Organization-Scoped Uniqueness
packages/db/src/schema/schema.ts, packages/db/drizzle/0055_set_unique_slack_only.sql, packages/db/drizzle/meta/_journal.json
integrationConnections constraint changed from provider/externalOrgId active-only unique to organization-scoped (organizationId, provider, externalOrgId) unique; new Slack-specific active-only external_org_id index added; migration and journal updated.
Linear Callback - Simplified Upsert
apps/api/src/app/api/integrations/linear/callback/route.ts
Removes workspace-already-linked detection and unique-constraint exception mapping; replaces with single insert...onConflictDoUpdate(...) upsert on (organizationId, provider) to set tokens, external org identifiers, clear disconnect metadata, set connectedByUserId, and refresh updatedAt.
Linear Webhook - Multi-Connection Fan-out
apps/api/src/app/api/integrations/linear/webhook/route.ts
Queries all active Linear connections for the organization and processes them concurrently via Promise.all; establishes per-connection event idempotency using ${connection.id}-${payload.organizationId}-${payload.webhookTimestamp}; refactors per-connection handler to return structured outcomes; conditionally re-arms failed webhook events for retry; constrains task deletion by connection.organizationId.
Slack Callback - Expanded Linkage Detection
apps/api/src/app/api/integrations/slack/callback/route.ts
Replaces single ACTIVE_LINKAGE_INDEX constant with ACTIVE_LINKAGE_INDEXES Set to detect multiple unique-constraint violations as workspace_already_linked errors.
UI Error Handler - Simplified Error Message Lookup
apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx
Removes special-case handling for workspace_already_linked and owner parameter dependency; derives toast message via ERROR_MESSAGES[error] lookup with fallback.

Sequence Diagram

sequenceDiagram
  participant Client as Webhook Client
  participant Handler as LinearWebhookHandler
  participant DB as Database
  participant Processor as processForConnection
  participant Events as webhookEvents
  participant Worker as processIssueEvent

  Client->>Handler: POST payload
  Handler->>DB: Query active integrationConnections for payload.organizationId
  DB-->>Handler: [connection...]
  alt no connections
    Handler-->>Client: 200 { success: true, status: "no_subscribers" }
  else has connections
    loop per connection (concurrent)
      Handler->>Processor: processForConnection(payload, connection)
      Processor->>Events: insert/upsert eventId scoped to connection.id
      alt event pending
        Processor->>Worker: processIssueEvent(...)
        Worker-->>Processor: result
        Processor->>Events: update status processed/skipped, processedAt
      else non-pending or processed
        Processor-->>Handler: outcome skipped/processed
      else exception
        Processor->>Events: update status failed, error, retryCount++
        Processor-->>Handler: outcome failed
      end
    end
    Handler-->>Client: 200/500 based on aggregated outcomes
  end
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 The connections now dance in tune,
Each webhook hops through its own cartoon,
One schema to rule them with org in sight,
While Slack and Linear organize just right—
No more conflicts, just multi-synced delight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 and specifically describes the main change: enabling Linear webhooks to be fanned out across multiple orgs sharing a workspace, which is the core objective of this PR.
Description check ✅ Passed The description comprehensively covers all required sections: a detailed summary of changes, context/rationale, type of changes (refactor/feature), testing performed, and follow-ups. It exceeds template requirements.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch linear-multi-connection-r

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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 and usage tips.

@github-actions

github-actions Bot commented May 15, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@greptile-apps

greptile-apps Bot commented May 15, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the global (provider, external_org_id) partial unique index with a Slack-scoped one and rewrites the Linear webhook handler to fan out across all active connections for a workspace, enabling multiple Superset orgs to share a single Linear workspace. It also fixes a latent cross-org bug in Issue.remove (missing organizationId filter), acknowledges zero-subscriber deliveries with 2xx, and removes the workspace_already_linked OAuth redirect + UI branch.

  • DB migration drops the global partial unique and creates integration_connections_slack_external_org_active_unique scoped to provider='slack'.
  • Webhook fan-out keys webhook_events as ${connection.id}-${linearOrgId}-${timestamp} for independent per-tenant idempotency.
  • OAuth callback removes the conflict pre-check and relies solely on the existing (organizationId, provider) upsert.

Confidence Score: 4/5

The fan-out design is correct but a persistently failing connection for one org can cause Linear to disable the webhook endpoint for all orgs sharing that workspace.

The cross-org organizationId filter fix in Issue.remove, the per-connection eventId keying, and the Slack-scoped DB index are all correct. The concern is that anyFailed returning HTTP 500 ties all orgs' webhook liveness to the health of the least-stable connection — if one org's processing throws persistently, Linear will eventually back off and stop delivering to the endpoint entirely, silencing every co-tenant.

apps/api/src/app/api/integrations/linear/webhook/route.ts — specifically the HTTP 500 response path when any connection fails.

Important Files Changed

Filename Overview
apps/api/src/app/api/integrations/linear/webhook/route.ts Fan-out logic and per-connection idempotency are well-structured, but returning HTTP 500 on any partial failure means a single persistently failing connection can cause Linear to eventually disable the entire webhook endpoint for all tenants sharing the workspace.
apps/api/src/app/api/integrations/linear/callback/route.ts Correctly removes the workspace-already-linked guard and simplifies the upsert; conflict target (organizationId, provider) matches the schema's integration_connections_unique constraint.
apps/api/src/app/api/integrations/slack/callback/route.ts ACTIVE_LINKAGE_INDEXES now holds both old and new index names — correct defensive approach for rolling deployments where the migration may not yet be applied.
packages/db/drizzle/0055_set_unique_slack_only.sql Drops the global partial unique index and replaces it with a Slack-scoped partial unique index; straightforward and correct.
packages/db/src/schema/schema.ts Schema updated with integration_connections_slack_external_org_active_unique scoped to provider='slack'; existing (organizationId, provider) plain unique remains intact.
apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx Removes the workspace_already_linked error branch; remaining error handling falls through to the generic ERROR_MESSAGES map.

Sequence Diagram

sequenceDiagram
    participant L as Linear
    participant W as Webhook Handler
    participant DB as Database
    participant P as processForConnection x N

    L->>W: POST webhook
    W->>DB: findMany active connections for orgId
    DB-->>W: conn_A, conn_B, conn_N
    alt no connections
        W-->>L: 200 no_subscribers
    else N connections
        W->>P: Promise.all fan-out
        loop each connection
            P->>DB: INSERT webhookEvents ON CONFLICT
            DB-->>P: row status
            alt processed
                P-->>W: outcome processed
            else not pending
                P-->>W: outcome skipped
            else pending
                P->>DB: upsert tasks
                P-->>W: outcome processed or failed
            end
        end
        alt anyFailed
            W-->>L: 500
        else
            W-->>L: 200
        end
    end
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/api/src/app/api/integrations/linear/webhook/route.ts:59-66
**Partial failure poisons the full fan-out — one bad connection can silence all tenants**

`anyFailed = true` returns HTTP 500, which tells Linear to retry the entire webhook delivery. That retry is correctly idempotent for connections that already succeeded (their `webhookEvents` row is `"processed"`, so `onConflictDoUpdate` leaves it alone and the function returns early). However, if one connection has a **persistent** processing error (e.g., a `processIssueEvent` exception caused by bad data in `tasks` / `taskStatuses` for that org), every delivery will keep returning 500. Linear's retry budget is finite: after N consecutive failures it backs off exponentially and eventually disables the webhook endpoint entirely — at which point **all** orgs sharing the workspace stop receiving webhook events, not just the one with the broken connection.

Before this PR, one broken connection meant one org lost webhook delivery. Now it can deny service to every co-tenant. The PR description says each tenant's processing is "independently retryable," but the HTTP status code is still global. Making the endpoint return 200 when at least one connection succeeds (and relying on the `webhookEvents` "failed" row + a separate poller/queue for per-connection retries) would contain the blast radius.

Reviews (2): Last reviewed commit: "feat(integrations): fan out Linear webho..." | Re-trigger Greptile

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/db/drizzle/0054_drop_linear_global_unique_index.sql">

<violation number="1" location="packages/db/drizzle/0054_drop_linear_global_unique_index.sql:1">
P1: Dropping this index removes Slack’s atomic workspace-uniqueness guard and reintroduces a race where the same Slack workspace can be linked to multiple orgs.</violation>
</file>

<file name="apps/api/src/app/api/integrations/linear/webhook/route.ts">

<violation number="1" location="apps/api/src/app/api/integrations/linear/webhook/route.ts:42">
P2: The no-active-connection path returns 404 for an unrecoverable condition, which keeps treating the webhook as failed on every redelivery. Acknowledge and ignore this case with a 2xx response.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

@@ -0,0 +1 @@
DROP INDEX "integration_connections_provider_external_org_active_unique"; No newline at end of file

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.

P1: Dropping this index removes Slack’s atomic workspace-uniqueness guard and reintroduces a race where the same Slack workspace can be linked to multiple orgs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/drizzle/0054_drop_linear_global_unique_index.sql, line 1:

<comment>Dropping this index removes Slack’s atomic workspace-uniqueness guard and reintroduces a race where the same Slack workspace can be linked to multiple orgs.</comment>

<file context>
@@ -0,0 +1 @@
+DROP INDEX "integration_connections_provider_external_org_active_unique";
\ No newline at end of file
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fb7cf7f — replaced the dropped global index with a Slack-scoped partial unique (integration_connections_slack_external_org_active_unique on external_org_id WHERE provider = 'slack' AND disconnected_at IS NULL). Slack keeps its atomic one-org-per-workspace guard; only Linear is relaxed.

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.

Thanks for the feedback! I've saved this as a new learning to improve future reviews.

Comment on lines +55 to +60
const anyFailed = results.some((r) => r.outcome === "failed");
return Response.json(
{ success: !anyFailed, results },
{ status: anyFailed ? 500 : 200 },
);
}

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.

P2 Internal connection UUIDs and raw error strings forwarded to Linear

The 500 response body includes the full results array, which contains each connectionId (an internal DB UUID) and the raw error message string from whatever threw. Linear's webhook infrastructure is a trusted caller, but sending internal identifiers and unredacted error messages across that trust boundary leaks DB schema shape and could expose constraint names or query fragments if a DB error surfaces. Returning { success: false, failedCount: N } (or stripping connectionId/error from the response) is safer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/api/src/app/api/integrations/linear/webhook/route.ts
Line: 55-60

Comment:
**Internal connection UUIDs and raw error strings forwarded to Linear**

The 500 response body includes the full `results` array, which contains each `connectionId` (an internal DB UUID) and the raw `error` message string from whatever threw. Linear's webhook infrastructure is a trusted caller, but sending internal identifiers and unredacted error messages across that trust boundary leaks DB schema shape and could expose constraint names or query fragments if a DB error surfaces. Returning `{ success: false, failedCount: N }` (or stripping `connectionId`/`error` from the response) is safer.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fb7cf7f — the failure response no longer includes the results array. Connection ids and raw error strings are logged server-side via console.error; Linear only receives {success: false}.

@ghost

ghost commented May 15, 2026 •

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 4 individual chapters for you:

Title
1 Scope unique workspace constraint to Slack only
2 Update Linear OAuth flow for multi-org support
3 Fan out Linear webhooks to multiple organizations
4 Fix cross-org task deletion bug
Open in Stage

Chapters generated by Stage for commit 4417356 on May 20, 2026 12:54am UTC.

@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.

🧹 Nitpick comments (1)
apps/api/src/app/api/integrations/linear/webhook/route.ts (1)

90-96: 💤 Low value

Minor: retryCount increments twice per retry-then-fail cycle.

On retry delivery: onConflictDoUpdate increments retryCount (line 94). If processing fails again, the catch block increments it a second time (line 138). After 2 webhook deliveries that both fail, retryCount = 3 rather than 2.

If this is intentional (tracking both "retry transitions" and "failure events"), consider adding a brief comment. Otherwise, remove one increment.

Also applies to: 136-139

🤖 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 `@apps/api/src/app/api/integrations/linear/webhook/route.ts` around lines 90 -
96, The retryCount is being incremented twice: once in the upsert SQL inside
onConflictDoUpdate (using webhookEvents.retryCount + 1) and again in the error
handler catch block; remove the duplicate by choosing one place to increment —
e.g., keep the DB upsert increment in the onConflictDoUpdate block (the CASE
expression for retryCount on webhookEvents) and delete the manual increment in
the catch block (or vice versa if you prefer). Update the catch block around the
function handling webhook processing (referencing the same catch that currently
mutates webhookEvents.retryCount and sets status/error) so it no longer
increments retryCount, but still updates status/error appropriately to match the
upsert logic.
🤖 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.

Nitpick comments:
In `@apps/api/src/app/api/integrations/linear/webhook/route.ts`:
- Around line 90-96: The retryCount is being incremented twice: once in the
upsert SQL inside onConflictDoUpdate (using webhookEvents.retryCount + 1) and
again in the error handler catch block; remove the duplicate by choosing one
place to increment — e.g., keep the DB upsert increment in the
onConflictDoUpdate block (the CASE expression for retryCount on webhookEvents)
and delete the manual increment in the catch block (or vice versa if you
prefer). Update the catch block around the function handling webhook processing
(referencing the same catch that currently mutates webhookEvents.retryCount and
sets status/error) so it no longer increments retryCount, but still updates
status/error appropriately to match the upsert logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a8ee62c-6225-4c04-ad0b-05aedb0905c0

📥 Commits

Reviewing files that changed from the base of the PR and between aa8c913 and fb7cf7f.

📒 Files selected for processing (7)
  • apps/api/src/app/api/integrations/linear/callback/route.ts
  • apps/api/src/app/api/integrations/linear/webhook/route.ts
  • apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx
  • packages/db/drizzle/0054_scope_workspace_unique_to_slack.sql
  • packages/db/drizzle/meta/0054_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/schema.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/db/drizzle/0054_scope_workspace_unique_to_slack.sql
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx
  • packages/db/src/schema/schema.ts
  • apps/api/src/app/api/integrations/linear/callback/route.ts

@saddlepaddle

Copy link
Copy Markdown
Collaborator Author

Temporarily closing to reset the stale Neon preview branch after rebasing migrations; will reopen once cleanup completes.

Comment on lines +59 to +66
const anyFailed = results.some((r) => r.outcome === "failed");
if (anyFailed) {
console.error("[linear/webhook] processing failures:", results);
}
return Response.json(
{ success: !anyFailed },
{ status: anyFailed ? 500 : 200 },
);

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.

P1 Partial failure poisons the full fan-out — one bad connection can silence all tenants

anyFailed = true returns HTTP 500, which tells Linear to retry the entire webhook delivery. That retry is correctly idempotent for connections that already succeeded (their webhookEvents row is "processed", so onConflictDoUpdate leaves it alone and the function returns early). However, if one connection has a persistent processing error (e.g., a processIssueEvent exception caused by bad data in tasks / taskStatuses for that org), every delivery will keep returning 500. Linear's retry budget is finite: after N consecutive failures it backs off exponentially and eventually disables the webhook endpoint entirely — at which point all orgs sharing the workspace stop receiving webhook events, not just the one with the broken connection.

Before this PR, one broken connection meant one org lost webhook delivery. Now it can deny service to every co-tenant. The PR description says each tenant's processing is "independently retryable," but the HTTP status code is still global. Making the endpoint return 200 when at least one connection succeeds (and relying on the webhookEvents "failed" row + a separate poller/queue for per-connection retries) would contain the blast radius.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/api/src/app/api/integrations/linear/webhook/route.ts
Line: 59-66

Comment:
**Partial failure poisons the full fan-out — one bad connection can silence all tenants**

`anyFailed = true` returns HTTP 500, which tells Linear to retry the entire webhook delivery. That retry is correctly idempotent for connections that already succeeded (their `webhookEvents` row is `"processed"`, so `onConflictDoUpdate` leaves it alone and the function returns early). However, if one connection has a **persistent** processing error (e.g., a `processIssueEvent` exception caused by bad data in `tasks` / `taskStatuses` for that org), every delivery will keep returning 500. Linear's retry budget is finite: after N consecutive failures it backs off exponentially and eventually disables the webhook endpoint entirely — at which point **all** orgs sharing the workspace stop receiving webhook events, not just the one with the broken connection.

Before this PR, one broken connection meant one org lost webhook delivery. Now it can deny service to every co-tenant. The PR description says each tenant's processing is "independently retryable," but the HTTP status code is still global. Making the endpoint return 200 when at least one connection succeeds (and relying on the `webhookEvents` "failed" row + a separate poller/queue for per-connection retries) would contain the blast radius.

How can I resolve this? If you propose a fix, please make it concise.

…kspace

PR #4386 added a partial unique index forcing one Superset org per Linear
workspace because the webhook handler picked a winner non-deterministically.
Empirical check across 6.87M historical webhook_events confirms Linear
delivers one webhook per (OAuth app, workspace) regardless of how many
installations exist — one workspace with 14 installations produced exactly
one webhookId. Multi-tenant support is therefore a receive-side problem.

- Replace the global (provider, external_org_id) partial unique with a
  Slack-scoped one. Linear workspaces may now be shared across orgs;
  Slack keeps its atomic one-org-per-workspace guard.
- Linear webhook handler: findMany over active connections, dispatch
  processIssueEvent per connection, key webhook_events as
  `${connection.id}-${linearOrgId}-${timestamp}` so each tenant's
  processing is independently retryable.
- Fix latent cross-org bug in the Issue.remove path: filter organizationId
  so a Linear deletion only soft-deletes the recipient org's task row.
- Webhook with no active connections returns 2xx (not 404) so Linear
  stops retrying an unrecoverable condition.
- Webhook failure response no longer echoes internal connection ids or
  raw error strings back to Linear; details are logged server-side.
- Drop the OAuth callback's pre-insert workspace_already_linked redirect
  and the UI error branch.
@saddlepaddle
saddlepaddle force-pushed the linear-multi-connection-r branch from d605809 to 4417356 Compare May 20, 2026 00:53

@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: 3

🤖 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 `@apps/api/src/app/api/integrations/linear/webhook/route.ts`:
- Around line 59-74: The current logic sets HTTP 200 for partial failures in the
block using results, anyFailed and allFailed and returns Response.json(..., {
status: allFailed ? 500 : 200 }); change this so anyFailed (not just allFailed)
yields a non-200 status: return HTTP 500 whenever anyFailed is true so Linear
will retry; also adjust the success field to be false when anyFailed (e.g.,
success: !anyFailed) to reflect that a failure occurred. Locate the code around
anyFailed, allFailed, results and Response.json to implement this change.
- Around line 85-87: Replace the use of payload.webhookTimestamp when building
the unique eventId with payload.webhookId to avoid deduping distinct deliveries
that share the same millisecond; update the eventId construction (the variable
named eventId created from connection.id and payload.organizationId and
payload.webhookTimestamp) to concatenate connection.id, payload.organizationId,
and payload.webhookId instead.

In `@packages/db/src/schema/schema.ts`:
- Around line 220-225: The schema removed the general active (provider,
external_org_id) lookup and only left a Slack-specific unique index, causing
full-table scans for Linear webhook lookups; add a non-unique partial index for
Linear similar to the Slack index — e.g. call uniqueIndex/index builder
(matching the style used:
uniqueIndex("integration_connections_slack_external_org_active_unique") /
index("integration_connections_org_idx")) to create an index on
table.externalOrgId with a WHERE clause sql`${table.provider} = 'linear' AND
${table.disconnectedAt} IS NULL` (name it something like
"integration_connections_linear_external_org_active_idx") so the POST
/api/integrations/linear/webhook query is supported without a full scan.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: be933b70-8542-450b-b8f4-c5529b0f475f

📥 Commits

Reviewing files that changed from the base of the PR and between d605809 and 4417356.

📒 Files selected for processing (8)
  • apps/api/src/app/api/integrations/linear/callback/route.ts
  • apps/api/src/app/api/integrations/linear/webhook/route.ts
  • apps/api/src/app/api/integrations/slack/callback/route.ts
  • apps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsx
  • packages/db/drizzle/0055_set_unique_slack_only.sql
  • packages/db/drizzle/meta/0055_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/schema.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/db/drizzle/meta/_journal.json

Comment on lines +59 to +74
const anyFailed = results.some((r) => r.outcome === "failed");
const allFailed = results.every((r) => r.outcome === "failed");
if (anyFailed) {
console.error("[linear/webhook] processing failures:", results);
}
return Response.json(
{
success: !allFailed,
status: allFailed
? "failed"
: anyFailed
? "partial_failure"
: "processed",
},
{ status: allFailed ? 500 : 200 },
);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Linear webhooks retry non-200 response HTTP 200 official docs

💡 Result:

Linear webhooks: if your endpoint returns a non-200 HTTP status (or doesn’t respond in time / is unavailable), Linear retries the webhook up to 3 times with a backoff schedule of 1 minute, 1 hour, then 6 hours; if the webhook URL remains unresponsive, it may be disabled and must be re-enabled manually. Linear’s docs also instruct that your webhook consumer must respond with HTTP 200 (“OK”) to a Linear Webhook push within 5 seconds (timeout mentioned alongside the retry conditions). [1] If you’re trying to intentionally prevent retries, make sure your webhook handler returns HTTP 200 for successfully received deliveries—even if your internal processing fails asynchronously—because the retry logic is triggered by non-200 responses or server timeout/unavailability, per Linear’s stated retry conditions. [1]

Citations:


Return non-200 status for partial failures to ensure Linear retries failed connections.

Linear retries webhook deliveries only when your endpoint returns a non-200 HTTP status. Returning 200 for "partial_failure" means the failed connections will never be retried, even though this handler is already connection-idempotent by design. Return HTTP 500 whenever any connection fails.

Suggested change
 	return Response.json(
 		{
-			success: !allFailed,
+			success: !anyFailed,
 			status: allFailed
 				? "failed"
 				: anyFailed
 					? "partial_failure"
 					: "processed",
 		},
-		{ status: allFailed ? 500 : 200 },
+		{ status: anyFailed ? 500 : 200 },
 	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const anyFailed = results.some((r) => r.outcome === "failed");
const allFailed = results.every((r) => r.outcome === "failed");
if (anyFailed) {
console.error("[linear/webhook] processing failures:", results);
}
return Response.json(
{
success: !allFailed,
status: allFailed
? "failed"
: anyFailed
? "partial_failure"
: "processed",
},
{ status: allFailed ? 500 : 200 },
);
const anyFailed = results.some((r) => r.outcome === "failed");
const allFailed = results.every((r) => r.outcome === "failed");
if (anyFailed) {
console.error("[linear/webhook] processing failures:", results);
}
return Response.json(
{
success: !anyFailed,
status: allFailed
? "failed"
: anyFailed
? "partial_failure"
: "processed",
},
{ status: anyFailed ? 500 : 200 },
);
🤖 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 `@apps/api/src/app/api/integrations/linear/webhook/route.ts` around lines 59 -
74, The current logic sets HTTP 200 for partial failures in the block using
results, anyFailed and allFailed and returns Response.json(..., { status:
allFailed ? 500 : 200 }); change this so anyFailed (not just allFailed) yields a
non-200 status: return HTTP 500 whenever anyFailed is true so Linear will retry;
also adjust the success field to be false when anyFailed (e.g., success:
!anyFailed) to reflect that a failure occurred. Locate the code around
anyFailed, allFailed, results and Response.json to implement this change.

Comment on lines +85 to +87
// One webhookEvents row per (Linear event × Superset connection) so each
// tenant's processing status is independently retryable.
const eventId = `${connection.id}-${payload.organizationId}-${payload.webhookTimestamp}`;

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Linear webhook payload webhookId Linear-Delivery unique identifier official docs

💡 Result:

Linear webhooks provide a unique delivery identifier in the HTTP header Linear-Delivery, described in Linear’s developer docs as “An UUID (v4) uniquely identifying this payload.” [1] The webhook payload body also includes a webhookId field (distinct from the Linear-Delivery header), described as “ID uniquely identifying this webhook.” [1] So, depending on where you’re looking: - Header: Linear-Delivery = UUIDv4 unique delivery ID for the payload [1] - Body field: webhookId = unique identifier for the webhook event [1]

Citations:


🏁 Script executed:

cat -n apps/api/src/app/api/integrations/linear/webhook/route.ts

Repository: superset-sh/superset

Length of output: 9476


🏁 Script executed:

rg -A 5 -B 5 "EntityWebhookPayloadWithIssueData" apps/api/src/app/api/integrations/linear/webhook/route.ts

Repository: superset-sh/superset

Length of output: 848


🏁 Script executed:

# Check the LinearWebhookClient import to understand the payload type
rg "LinearWebhookClient|EntityWebhookPayload" --type ts --type tsx -A 3

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

# Check if webhookId is used anywhere in the codebase
rg "webhookId" --type ts --type tsx

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

rg "webhookId" --type ts

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check if there's documentation or type info about Linear webhook payload
fd "linear" --type f | head -20

Repository: superset-sh/superset

Length of output: 658


🏁 Script executed:

# Look for any references to Linear-Delivery header or webhook ID handling
rg "Linear-Delivery|webhookId" --type ts

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check package.json for Linear SDK version
rg "`@linear/sdk`" package.json -A 1 -B 1

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Try to find any webhook payload examples or documentation in the codebase
rg "payload\." apps/api/src/app/api/integrations/linear/webhook/route.ts -o

Repository: superset-sh/superset

Length of output: 163


🏁 Script executed:

# Check if there's type information available for the Linear SDK
node -e "
const pkg = require('./package.json');
console.log(JSON.stringify(pkg.dependencies['`@linear/sdk`'], null, 2));
" 2>/dev/null || echo "package.json not in root"

Repository: superset-sh/superset

Length of output: 89


🏁 Script executed:

# Find package.json files
fd package.json

Repository: superset-sh/superset

Length of output: 1055


🏁 Script executed:

# Search for all payload field accesses to understand available fields
rg "payload\.[a-zA-Z]+" -o apps/api/src/app/api/integrations/linear/webhook/route.ts | sort -u

Repository: superset-sh/superset

Length of output: 153


🏁 Script executed:

# Check if there's a node_modules directory with Linear SDK types
fd "`@linear`" --type d | head -5

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Check apps/api package.json for Linear SDK version
cat apps/api/package.json | grep -A 2 -B 2 "`@linear/sdk`"

Repository: superset-sh/superset

Length of output: 240


🏁 Script executed:

# Search for any webhook-related files or additional context
find apps/api -name "*webhook*" -o -name "*linear*" | grep -v node_modules | head -20

Repository: superset-sh/superset

Length of output: 278


🏁 Script executed:

# Check if webhookId is mentioned in any TypeScript/JavaScript files, including tests
rg "webhookId" . --type ts --type js 2>/dev/null || echo "No webhookId found"

Repository: superset-sh/superset

Length of output: 83


🏁 Script executed:

# Look at the Linear SDK package to understand the payload structure
# First, check if there's a node_modules directory
ls -la apps/api/node_modules/@linear/sdk 2>/dev/null | head -20 || echo "node_modules not available in sandbox"

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Search online documentation for Linear SDK webhook payload structure
# The web search already confirmed webhookId exists, let me verify by checking
# if the request headers are accessible and could include Linear-Delivery
cat -n apps/api/src/app/api/integrations/linear/webhook/route.ts | sed -n '20,30p'

Repository: superset-sh/superset

Length of output: 541


🏁 Script executed:

# Check if there's any documentation or comments about the Linear webhook payload
rg "webhook" apps/api/src/app/api/integrations/linear -A 3 -B 3 --type ts | head -50

Repository: superset-sh/superset

Length of output: 4142


🏁 Script executed:

# Look at the database schema to understand how eventId is used and if it can be changed
cat packages/db/drizzle/*.sql | grep -A 10 -B 5 "webhookEvents" | head -40

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Find the webhookEvents schema definition
fd "*.sql" packages/db/drizzle | xargs grep -l "webhookEvents" 2>/dev/null

Repository: superset-sh/superset

Length of output: 351


🏁 Script executed:

# Also check the TypeScript schema files
rg "webhookEvents|export const webhookEvents" packages/db/src -A 10 -B 2 --type ts

Repository: superset-sh/superset

Length of output: 1079


🏁 Script executed:

# Get the full webhookEvents schema definition
rg "export const webhookEvents" packages/db/src -A 30 --type ts

Repository: superset-sh/superset

Length of output: 1946


🏁 Script executed:

# Check if there's a unique constraint on eventId
rg "webhookEvents" packages/db/drizzle -l | head -5

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

# Try to get Linear SDK type definitions or documentation
# Check if there's any example or test using the Linear SDK
rg "parseData|LinearWebhookClient" apps/api -A 5 -B 5 --type ts

Repository: superset-sh/superset

Length of output: 3846


🏁 Script executed:

# Search for any comments or documentation about Linear webhook payload fields
rg "webhookId|Linear-Delivery" . --type ts --type js 2>/dev/null || echo "Not found in codebase"

Repository: superset-sh/superset

Length of output: 86


🏁 Script executed:

# Check if the Linear SDK is documented anywhere in the project
find . -name "*.md" -o -name "*.txt" | xargs grep -l "webhookId\|Linear-Delivery" 2>/dev/null | head -5 || echo "Not found in documentation"

Repository: superset-sh/superset

Length of output: 46


🌐 Web query:

Linear SDK @linear/sdk webhooks payload webhookId field structure parseData

💡 Result:

Linear SDK (@linear/sdk) webhook payload structure and the webhookId field, plus how the SDK parses it: 1) Webhook HTTP payload shape (what the server receives) - The webhook HTTP request body includes an object that has top-level fields like action, type, actor, createdAt, url, updatedFrom (for update actions), webhookTimestamp (UNIX ms), and webhookId (ID uniquely identifying this webhook) [1]. - The serialized “entity” that changed is under the top-level data field [1]. Example (from Linear docs) showing webhookId and webhookTimestamp at the top level: { "type": "Comment", "action": "create",..., "webhookTimestamp": 1676056940508, "webhookId": "000042e3-d123-4980-b49f-8e140eef9329" } [1] 2) Does the SDK have a “parseData” function? - Yes, @linear/sdk exposes parsing via a parseData method in its webhook utilities: parseData(rawBody, signature, timestamp?) returning the typed webhook payload (with different union variants depending on event/entity) [2]. - Also, LinearWebhookClient.createHandler used to parse payloads in a handler and had a redundant parseData call removed in a later refactor; the commit diff explicitly mentions removing a redundant parseData call from handlers returned by LinearWebhookClient.createHandler [3]. 3) How to parse/verify in the SDK (signature + timestamp) - The SDK provides a LinearWebhookClient in @linear/sdk/webhooks for creating typed, signature-verified handlers [4]. - If you want to handle manually, the SDK also provides a verify helper and exposes constants for the signature header name and the timestamp field in the JSON body [4]. The docs show usage: - read the linear signature from req.headers[LINEAR_WEBHOOK_SIGNATURE_HEADER] - parse JSON and read JSON.parse(buf.toString)[LINEAR_WEBHOOK_TS_FIELD] - call webhookClient.verify(buf, signature, timestamp) [4] 4) Where webhookId sits relative to data - webhookId is a top-level field that identifies the webhook delivery/event instance [1]. - The changed GraphQL entity is in the top-level data field [1]. So in practice: - payload.webhookId: identifies this webhook delivery instance [1] - payload.data: contains the “serialized value of the subject entity” (e.g., Issue, Comment, Label, etc.) [1] 5) Typed parsing: “parseData” returns specific webhook payload types - The SDK’s parseData signature is implemented to return one of several payload variants (e.g., EntityWebhookPayloadWithEntityData, IssueSlaWebhookPayload, OAuthAppWebhookPayload, etc.) [2]. If you share the exact event type you’re handling (e.g., Issue, Comment, IssueSla), I can map payload.type/action to the corresponding parseData return type shape you should expect.

Citations:


🏁 Script executed:

# Check if webhookId would be accessible in the payload by searching for all payload field references
rg "payload\.[a-zA-Z]+" -o apps/api/src --type ts | sort -u

Repository: superset-sh/superset

Length of output: 2680


🏁 Script executed:

# Check all usages of webhookEvents.eventId to ensure changing its format won't break anything
rg "eventId|webhook_events" packages/db/src packages/trpc/src apps/api/src --type ts

Repository: superset-sh/superset

Length of output: 3477


Use payload.webhookId instead of payload.webhookTimestamp for the eventId.

Linear provides webhookId (a UUIDv4) as the unique identifier for each webhook delivery. Using webhookTimestamp can cause distinct events occurring in the same millisecond to be treated as duplicates and incorrectly skipped. Replace line 87 with:

const eventId = `${connection.id}-${payload.organizationId}-${payload.webhookId}`;
🤖 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 `@apps/api/src/app/api/integrations/linear/webhook/route.ts` around lines 85 -
87, Replace the use of payload.webhookTimestamp when building the unique eventId
with payload.webhookId to avoid deduping distinct deliveries that share the same
millisecond; update the eventId construction (the variable named eventId created
from connection.id and payload.organizationId and payload.webhookTimestamp) to
concatenate connection.id, payload.organizationId, and payload.webhookId
instead.

Comment on lines +220 to 225
uniqueIndex("integration_connections_slack_external_org_active_unique")
.on(table.externalOrgId)
.where(
sql`${table.provider} = 'slack' AND ${table.disconnectedAt} IS NULL`,
),
index("integration_connections_org_idx").on(table.organizationId),

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a non-unique active Linear lookup index.

Replacing the old active (provider, external_org_id) index with a Slack-only index leaves POST /api/integrations/linear/webhook without a supporting index for provider = 'linear' AND external_org_id = ? AND disconnected_at IS NULL. That puts a full scan of integration_connections on the webhook request path.

Suggested schema change
 		uniqueIndex("integration_connections_slack_external_org_active_unique")
 			.on(table.externalOrgId)
 			.where(
 				sql`${table.provider} = 'slack' AND ${table.disconnectedAt} IS NULL`,
 			),
+		index("integration_connections_linear_external_org_active_idx")
+			.on(table.externalOrgId)
+			.where(
+				sql`${table.provider} = 'linear' AND ${table.disconnectedAt} IS NULL`,
+			),
 		index("integration_connections_org_idx").on(table.organizationId),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uniqueIndex("integration_connections_slack_external_org_active_unique")
.on(table.externalOrgId)
.where(
sql`${table.provider} = 'slack' AND ${table.disconnectedAt} IS NULL`,
),
index("integration_connections_org_idx").on(table.organizationId),
uniqueIndex("integration_connections_slack_external_org_active_unique")
.on(table.externalOrgId)
.where(
sql`${table.provider} = 'slack' AND ${table.disconnectedAt} IS NULL`,
),
index("integration_connections_linear_external_org_active_idx")
.on(table.externalOrgId)
.where(
sql`${table.provider} = 'linear' AND ${table.disconnectedAt} IS NULL`,
),
index("integration_connections_org_idx").on(table.organizationId),
🤖 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 `@packages/db/src/schema/schema.ts` around lines 220 - 225, The schema removed
the general active (provider, external_org_id) lookup and only left a
Slack-specific unique index, causing full-table scans for Linear webhook
lookups; add a non-unique partial index for Linear similar to the Slack index —
e.g. call uniqueIndex/index builder (matching the style used:
uniqueIndex("integration_connections_slack_external_org_active_unique") /
index("integration_connections_org_idx")) to create an index on
table.externalOrgId with a WHERE clause sql`${table.provider} = 'linear' AND
${table.disconnectedAt} IS NULL` (name it something like
"integration_connections_linear_external_org_active_idx") so the POST
/api/integrations/linear/webhook query is supported without a full scan.

@saddlepaddle
saddlepaddle merged commit 250596a into main May 20, 2026
16 checks passed
sazabi Bot pushed a commit that referenced this pull request May 20, 2026
…kspace (#4604)

PR #4386 added a partial unique index forcing one Superset org per Linear
workspace because the webhook handler picked a winner non-deterministically.
Empirical check across 6.87M historical webhook_events confirms Linear
delivers one webhook per (OAuth app, workspace) regardless of how many
installations exist — one workspace with 14 installations produced exactly
one webhookId. Multi-tenant support is therefore a receive-side problem.

- Replace the global (provider, external_org_id) partial unique with a
  Slack-scoped one. Linear workspaces may now be shared across orgs;
  Slack keeps its atomic one-org-per-workspace guard.
- Linear webhook handler: findMany over active connections, dispatch
  processIssueEvent per connection, key webhook_events as
  `${connection.id}-${linearOrgId}-${timestamp}` so each tenant's
  processing is independently retryable.
- Fix latent cross-org bug in the Issue.remove path: filter organizationId
  so a Linear deletion only soft-deletes the recipient org's task row.
- Webhook with no active connections returns 2xx (not 404) so Linear
  stops retrying an unrecoverable condition.
- Webhook failure response no longer echoes internal connection ids or
  raw error strings back to Linear; details are logged server-side.
- Drop the OAuth callback's pre-insert workspace_already_linked redirect
  and the UI error branch.
MocA-Love added a commit to MocA-Love/superset that referenced this pull request May 29, 2026
Allow multiple Superset organizations to connect the same Linear workspace, process each Linear webhook for every active matching connection, and keep task deletion scoped to the connection organization.

Keep Slack's active workspace uniqueness via the new slack-only schema index source. Drizzle migration files were not edited; generate/apply the migration on a Neon branch before release.

This branch was previously deployed

1 inactive deployment
preview — 4417356b Deployed May 20, 2026 by saddlepaddle via Deploy Admin #5575
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.

1 participant