Repository navigation
feat(integrations): fan out Linear webhooks across orgs sharing a workspace - #4604
Conversation
|
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. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis 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. ChangesIntegration Multi-Connection Support
Sequence DiagramsequenceDiagram
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
🎯 4 (Complex) | ⏱️ ~45 minutes
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add 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. Comment |
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
341ed36 to
aa8c913
Compare
Greptile SummaryThis PR replaces the global
Confidence Score: 4/5The 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.
|
| 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
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
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thanks for the feedback! I've saved this as a new learning to improve future reviews.
| const anyFailed = results.some((r) => r.outcome === "failed"); | ||
| return Response.json( | ||
| { success: !anyFailed, results }, | ||
| { status: anyFailed ? 500 : 200 }, | ||
| ); | ||
| } |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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}.
|
Ready to review this PR? Stage has broken it down into 4 individual chapters for you:
Chapters generated by Stage for commit 4417356 on May 20, 2026 12:54am UTC. |
aa8c913 to
fb7cf7f
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/api/src/app/api/integrations/linear/webhook/route.ts (1)
90-96: 💤 Low valueMinor:
retryCountincrements twice per retry-then-fail cycle.On retry delivery:
onConflictDoUpdateincrements 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
📒 Files selected for processing (7)
apps/api/src/app/api/integrations/linear/callback/route.tsapps/api/src/app/api/integrations/linear/webhook/route.tsapps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsxpackages/db/drizzle/0054_scope_workspace_unique_to_slack.sqlpackages/db/drizzle/meta/0054_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/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
fb7cf7f to
4a07cb5
Compare
eb8fc1c to
d605809
Compare
|
Temporarily closing to reset the stale Neon preview branch after rebasing migrations; will reopen once cleanup completes. |
| 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 }, | ||
| ); |
There was a problem hiding this 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.
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.
d605809 to
4417356
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
apps/api/src/app/api/integrations/linear/callback/route.tsapps/api/src/app/api/integrations/linear/webhook/route.tsapps/api/src/app/api/integrations/slack/callback/route.tsapps/web/src/app/(dashboard-legacy)/integrations/linear/components/ErrorHandler/ErrorHandler.tsxpackages/db/drizzle/0055_set_unique_slack_only.sqlpackages/db/drizzle/meta/0055_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/schema/schema.ts
✅ Files skipped from review due to trivial changes (1)
- packages/db/drizzle/meta/_journal.json
| 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 }, | ||
| ); |
There was a problem hiding this comment.
🧩 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.
| 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.
| // 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}`; |
There was a problem hiding this comment.
🧩 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.tsRepository: superset-sh/superset
Length of output: 9476
🏁 Script executed:
rg -A 5 -B 5 "EntityWebhookPayloadWithIssueData" apps/api/src/app/api/integrations/linear/webhook/route.tsRepository: 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 3Repository: superset-sh/superset
Length of output: 92
🏁 Script executed:
# Check if webhookId is used anywhere in the codebase
rg "webhookId" --type ts --type tsxRepository: superset-sh/superset
Length of output: 92
🏁 Script executed:
rg "webhookId" --type tsRepository: 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 -20Repository: 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 tsRepository: superset-sh/superset
Length of output: 46
🏁 Script executed:
# Check package.json for Linear SDK version
rg "`@linear/sdk`" package.json -A 1 -B 1Repository: 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 -oRepository: 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.jsonRepository: 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 -uRepository: 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 -5Repository: 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 -20Repository: 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 -50Repository: 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 -40Repository: 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/nullRepository: 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 tsRepository: 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 tsRepository: 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 -5Repository: 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 tsRepository: 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:
- 1: https://linear.app/developers/webhooks
- 2: linear/linear@3fcb764
- 3: linear/linear@a8f03ca
- 4: https://linear.app/developers/sdk-webhooks
🏁 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 -uRepository: 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 tsRepository: 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.
| 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), |
There was a problem hiding this comment.
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.
| 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.
…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.
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.
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.
(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.findManyover active connections for the workspace, dispatchprocessIssueEventper connection, keywebhook_eventsas${connection.id}-${linearOrgId}-${timestamp}so each tenant's processing is independently retryable.Issue.remove: filterorganizationIdso a Linear deletion only soft-deletes the recipient org's task row.workspace_already_linkedredirect 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:
findFirstpicked a random connection.eventId = ${linearOrgId}-${timestamp}with a unique constraint collapsed N → 1 before dispatch.Both produce identical symptoms. To settle which, I queried
webhook_eventsdirectly. 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 distinctwebhookIdacross 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 lintandbun run typecheckcleanwebhook_events.payload->>'webhookId'(above)workspace_already_linkedtoast and both orgs sync independentlytasksrows update and twowebhook_eventsrows land (one per connection id) both reachingprocessedFollow-ups (not in this PR)
OAuthApprevoked webhook: gracefully disconnect specific installations on Linear-side de-auth.Summary by CodeRabbit
Bug Fixes
Chores