From 3ea9bc15e48c7ddb861c017b8c64cc897a4bff34 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Jul 2026 15:02:14 -0500 Subject: [PATCH 1/2] Enable Linear app mentions and delegation --- .../[connectionId]/__tests__/route.test.ts | 11 +++- .../components/settings/Integrations.test.tsx | 37 ++++++++++++- .../src/components/settings/Integrations.tsx | 53 +++++++++++++------ .../src/trpc/commands/linear/index.test.ts | 49 ++++++++++++++++- apps/web/src/trpc/commands/linear/index.ts | 5 ++ .../types/src/__tests__/mcp-oauth.test.ts | 18 +++++++ packages/types/src/mcp-oauth.ts | 11 +++- 7 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 packages/types/src/__tests__/mcp-oauth.test.ts diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index ff75e25e7..ffb4d0c8d 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -186,7 +186,12 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { authorizationEndpoint: 'https://linear.app/oauth/authorize', tokenEndpoint: 'https://api.linear.app/oauth/token', }); - getMcpIntegrationOauthScopesMock.mockReturnValue(['read', 'write']); + getMcpIntegrationOauthScopesMock.mockReturnValue([ + 'read', + 'write', + 'app:assignable', + 'app:mentionable', + ]); getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(','); getMcpIntegrationAuthorizationParametersMock.mockReturnValue([ { name: 'actor', value: 'app' }, @@ -204,7 +209,9 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { const authUrl = new URL(response.headers.get('location')!); expect(authUrl.origin).toBe('https://linear.app'); expect(authUrl.pathname).toBe('/oauth/authorize'); - expect(authUrl.searchParams.get('scope')).toBe('read,write'); + expect(authUrl.searchParams.get('scope')).toBe( + 'read,write,app:assignable,app:mentionable', + ); expect(authUrl.searchParams.get('actor')).toBe('app'); expect(authUrl.searchParams.get('redirect_uri')).toBe(PUBLIC_CALLBACK); expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 6ebbe083e..4767515f2 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -56,7 +56,11 @@ const state = vi.hoisted(() => ({ }, linearInstallation: { linearOrganizationName: 'Roomote', - } as null | { linearOrganizationName?: string }, + requiresReconnect: false, + } as null | { + linearOrganizationName?: string; + requiresReconnect: boolean; + }, linearOauthSetup: { callbackUrl: 'https://roomote.example/api/mcp-oauth/callback', webhookUrl: 'https://roomote.example/api/webhooks/linear', @@ -417,6 +421,7 @@ describe('Integrations settings', () => { state.mcpToolsError = null; state.linearInstallation = { linearOrganizationName: 'Roomote', + requiresReconnect: false, }; state.linearRedirectPath = ''; state.asanaConnection = null; @@ -516,7 +521,10 @@ describe('Integrations settings', () => { }); it('offers setup for a legacy workspace when deployment credentials are missing', () => { - state.linearInstallation = { linearOrganizationName: 'Legacy workspace' }; + state.linearInstallation = { + linearOrganizationName: 'Legacy workspace', + requiresReconnect: false, + }; state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; render(); @@ -529,6 +537,31 @@ describe('Integrations settings', () => { ).not.toBeInTheDocument(); }); + it('asks administrators to reconnect Linear when agent scopes are missing', () => { + state.linearInstallation = { + linearOrganizationName: 'Legacy workspace', + requiresReconnect: true, + }; + + render(); + + expect( + screen.getByText( + 'Reconnect Linear to enable mentions and issue delegation.', + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reconnect Linear' })); + + expect(mutations.connectLinear).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('surfaces the server error when starting Linear fails', () => { state.linearInstallation = null; mutations.connectLinear.mockImplementation((_variables, options) => { diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index a6640085e..1c287c574 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -1145,6 +1145,9 @@ export function Integrations() { )?.status; const linearOauthUnavailable = linearOauthStatus === 'missing' || linearOauthStatus === 'partial'; + const linearRequiresReconnect = Boolean( + linearInstallation.data?.requiresReconnect, + ); const linearOauthSetup = useLinearOauthSetup( isAdmin && linearOauthUnavailable, ); @@ -1290,6 +1293,20 @@ export function Integrations() { (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); const canSetUpLinearOauth = isAdmin && linearOauthUnavailable; + const canReconnectLinear = isAdmin && linearRequiresReconnect; + const startLinearConnection = () => { + connectLinear.mutate(undefined, { + onSuccess: (url) => { + window.location.href = url; + }, + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : 'Failed to enable Linear. Please try again.', + ), + }); + }; const openMcpToolDialog = (integration: McpIntegrationDefinition) => setToolDialogState({ mcpId: integration.id, @@ -1333,10 +1350,15 @@ export function Integrations() { disconnectLinear.isPending, status: linearOauthUnavailable ? getLinearOauthSetupStatus(linearOauthStatus, isAdmin) - : undefined, - statusIcon: linearOauthUnavailable ? ( - - ) : undefined, + : linearRequiresReconnect + ? isAdmin + ? 'Reconnect Linear to enable mentions and issue delegation.' + : 'Linear must be reconnected by an administrator.' + : undefined, + statusIcon: + linearOauthUnavailable || linearRequiresReconnect ? ( + + ) : undefined, headerAction: canSetUpLinearOauth ? { label: 'Set it up', @@ -1346,7 +1368,15 @@ export function Integrations() { linearOauthSetup.isPending || linearOauthSetup.data == null, icon: , } - : undefined, + : canReconnectLinear + ? { + label: 'Reconnect', + ariaLabel: 'Reconnect Linear', + onAction: startLinearConnection, + isPending: connectLinear.isPending, + icon: , + } + : undefined, onAction: linearOauthUnavailable ? undefined : () => { @@ -1364,17 +1394,7 @@ export function Integrations() { return; } - connectLinear.mutate(undefined, { - onSuccess: (url) => { - window.location.href = url; - }, - onError: (error) => - toast.error( - error instanceof Error - ? error.message - : 'Failed to enable Linear. Please try again.', - ), - }); + startLinearConnection(); }, }, ...visibleMcpIntegrations @@ -1616,6 +1636,7 @@ export function Integrations() { linearOauthSetup.isPending, linearOauthStatus, linearOauthUnavailable, + linearRequiresReconnect, oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index 98eec1925..04f29bbe7 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -1,11 +1,15 @@ const { envState, deletedConnectionsState, + findLinearDeploymentMcpConnectionMock, + getLinearDeploymentMetadataMock, resolveDeploymentEnvVarMock, upsertDeploymentEnvironmentVariablesMock, } = vi.hoisted(() => ({ envState: {} as Record, deletedConnectionsState: [] as Array<{ id: string }>, + findLinearDeploymentMcpConnectionMock: vi.fn(), + getLinearDeploymentMetadataMock: vi.fn(), resolveDeploymentEnvVarMock: vi.fn(), upsertDeploymentEnvironmentVariablesMock: vi.fn(), })); @@ -47,12 +51,13 @@ vi.mock('@roomote/db/server', () => ({ deploymentMcpEnablements: { mcpId: 'mcpId' }, })); vi.mock('@roomote/sdk/server', () => ({ - findLinearDeploymentMcpConnection: vi.fn(), - getLinearDeploymentMetadata: vi.fn(), + findLinearDeploymentMcpConnection: findLinearDeploymentMcpConnectionMock, + getLinearDeploymentMetadata: getLinearDeploymentMetadataMock, LINEAR_ORG_CONNECTION_ROLE: 'linear_org_install', })); import { + getLinearInstallationCommand, getLinearOauthSetupCommand, saveLinearOauthSetupCommand, } from './index'; @@ -69,9 +74,49 @@ describe('Linear OAuth setup', () => { delete envState[key]; } deletedConnectionsState.splice(0); + findLinearDeploymentMcpConnectionMock.mockResolvedValue(undefined); + getLinearDeploymentMetadataMock.mockReturnValue(null); resolveDeploymentEnvVarMock.mockResolvedValue(null); }); + it('requires older Linear app installations to reconnect for agent scopes', async () => { + findLinearDeploymentMcpConnectionMock.mockResolvedValue({ + id: 'linear-connection', + authStatus: 'authenticated', + authConfig: {}, + scopes: ['read', 'write'], + }); + getLinearDeploymentMetadataMock.mockReturnValue({ + linearOrganizationId: 'linear-org', + linearOrganizationName: 'Roomote', + linearOrganizationUrlKey: 'roomote', + appUserId: 'linear-app-user', + }); + + await expect(getLinearInstallationCommand(ADMIN)).resolves.toMatchObject({ + requiresReconnect: true, + }); + }); + + it('accepts Linear app installations with mention and assignment scopes', async () => { + findLinearDeploymentMcpConnectionMock.mockResolvedValue({ + id: 'linear-connection', + authStatus: 'authenticated', + authConfig: {}, + scopes: ['read', 'write', 'app:assignable', 'app:mentionable'], + }); + getLinearDeploymentMetadataMock.mockReturnValue({ + linearOrganizationId: 'linear-org', + linearOrganizationName: 'Roomote', + linearOrganizationUrlKey: 'roomote', + appUserId: 'linear-app-user', + }); + + await expect(getLinearInstallationCommand(ADMIN)).resolves.toMatchObject({ + requiresReconnect: false, + }); + }); + it('builds a private Linear app manifest for this deployment', async () => { const setup = await getLinearOauthSetupCommand(ADMIN); const setupUrl = new URL(setup.manifestUrl); diff --git a/apps/web/src/trpc/commands/linear/index.ts b/apps/web/src/trpc/commands/linear/index.ts index ae1a763c5..317d4bc8d 100644 --- a/apps/web/src/trpc/commands/linear/index.ts +++ b/apps/web/src/trpc/commands/linear/index.ts @@ -1,4 +1,5 @@ import { db } from '@roomote/db/server'; +import { LINEAR_APP_OAUTH_SCOPES } from '@roomote/types'; import { findLinearDeploymentMcpConnection, getLinearDeploymentMetadata, @@ -20,6 +21,7 @@ type LinearInstallationSummary = { linearOrganizationName: string | null; linearOrganizationUrlKey: string | null; appUserId: string | null; + requiresReconnect: boolean; }; export async function getLinearInstallationCommand( @@ -40,6 +42,9 @@ export async function getLinearInstallationCommand( linearOrganizationName: metadata.linearOrganizationName, linearOrganizationUrlKey: metadata.linearOrganizationUrlKey, appUserId: metadata.appUserId, + requiresReconnect: !LINEAR_APP_OAUTH_SCOPES.every((scope) => + connection.scopes?.includes(scope), + ), }; } diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts new file mode 100644 index 000000000..11c004fc6 --- /dev/null +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -0,0 +1,18 @@ +import { + getMcpIntegrationOauthScopes, + LINEAR_APP_OAUTH_SCOPES, +} from '../mcp-oauth'; + +describe('Linear OAuth scopes', () => { + it('makes deployment app actors assignable and mentionable', () => { + expect( + getMcpIntegrationOauthScopes('linear', 'linear_org_install'), + ).toEqual(LINEAR_APP_OAUTH_SCOPES); + }); + + it('keeps personal account links read-only', () => { + expect(getMcpIntegrationOauthScopes('linear', 'linear_user_link')).toEqual([ + 'read', + ]); + }); +}); diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 8ea0d4134..4ead8447a 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -166,6 +166,13 @@ export type McpConnectionRole = | 'linear_org_install' | 'linear_user_link'; +export const LINEAR_APP_OAUTH_SCOPES = [ + 'read', + 'write', + 'app:assignable', + 'app:mentionable', +] as const; + /** * MCP Server Configuration * This is what gets written to .roomote/mcp.json in the workspace @@ -633,7 +640,9 @@ export function getMcpIntegrationOauthScopes( } if (integration.id === 'linear') { - return role === 'linear_user_link' ? ['read'] : ['read', 'write']; + return role === 'linear_user_link' + ? ['read'] + : [...LINEAR_APP_OAUTH_SCOPES]; } return integration.oauthScopes; From de7276ee975a42d51af9fa05c6b69d86244a7767 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Jul 2026 15:11:57 -0500 Subject: [PATCH 2/2] Simplify Linear reconnection --- .../components/settings/Integrations.test.tsx | 25 ++-------- .../src/components/settings/Integrations.tsx | 20 +++----- .../src/trpc/commands/linear/index.test.ts | 49 +------------------ apps/web/src/trpc/commands/linear/index.ts | 5 -- 4 files changed, 11 insertions(+), 88 deletions(-) diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 4767515f2..c0ea6e074 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -56,11 +56,7 @@ const state = vi.hoisted(() => ({ }, linearInstallation: { linearOrganizationName: 'Roomote', - requiresReconnect: false, - } as null | { - linearOrganizationName?: string; - requiresReconnect: boolean; - }, + } as null | { linearOrganizationName?: string }, linearOauthSetup: { callbackUrl: 'https://roomote.example/api/mcp-oauth/callback', webhookUrl: 'https://roomote.example/api/webhooks/linear', @@ -421,7 +417,6 @@ describe('Integrations settings', () => { state.mcpToolsError = null; state.linearInstallation = { linearOrganizationName: 'Roomote', - requiresReconnect: false, }; state.linearRedirectPath = ''; state.asanaConnection = null; @@ -521,10 +516,7 @@ describe('Integrations settings', () => { }); it('offers setup for a legacy workspace when deployment credentials are missing', () => { - state.linearInstallation = { - linearOrganizationName: 'Legacy workspace', - requiresReconnect: false, - }; + state.linearInstallation = { linearOrganizationName: 'Legacy workspace' }; state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; render(); @@ -537,20 +529,9 @@ describe('Integrations settings', () => { ).not.toBeInTheDocument(); }); - it('asks administrators to reconnect Linear when agent scopes are missing', () => { - state.linearInstallation = { - linearOrganizationName: 'Legacy workspace', - requiresReconnect: true, - }; - + it('lets administrators reconnect a configured Linear workspace', () => { render(); - expect( - screen.getByText( - 'Reconnect Linear to enable mentions and issue delegation.', - ), - ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Reconnect Linear' })); expect(mutations.connectLinear).toHaveBeenCalledWith( diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 1c287c574..b2e39708b 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -1145,9 +1145,6 @@ export function Integrations() { )?.status; const linearOauthUnavailable = linearOauthStatus === 'missing' || linearOauthStatus === 'partial'; - const linearRequiresReconnect = Boolean( - linearInstallation.data?.requiresReconnect, - ); const linearOauthSetup = useLinearOauthSetup( isAdmin && linearOauthUnavailable, ); @@ -1293,7 +1290,8 @@ export function Integrations() { (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); const canSetUpLinearOauth = isAdmin && linearOauthUnavailable; - const canReconnectLinear = isAdmin && linearRequiresReconnect; + const canReconnectLinear = + isAdmin && Boolean(linearInstallation.data) && !linearOauthUnavailable; const startLinearConnection = () => { connectLinear.mutate(undefined, { onSuccess: (url) => { @@ -1350,15 +1348,10 @@ export function Integrations() { disconnectLinear.isPending, status: linearOauthUnavailable ? getLinearOauthSetupStatus(linearOauthStatus, isAdmin) - : linearRequiresReconnect - ? isAdmin - ? 'Reconnect Linear to enable mentions and issue delegation.' - : 'Linear must be reconnected by an administrator.' - : undefined, - statusIcon: - linearOauthUnavailable || linearRequiresReconnect ? ( - - ) : undefined, + : undefined, + statusIcon: linearOauthUnavailable ? ( + + ) : undefined, headerAction: canSetUpLinearOauth ? { label: 'Set it up', @@ -1636,7 +1629,6 @@ export function Integrations() { linearOauthSetup.isPending, linearOauthStatus, linearOauthUnavailable, - linearRequiresReconnect, oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index 04f29bbe7..98eec1925 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -1,15 +1,11 @@ const { envState, deletedConnectionsState, - findLinearDeploymentMcpConnectionMock, - getLinearDeploymentMetadataMock, resolveDeploymentEnvVarMock, upsertDeploymentEnvironmentVariablesMock, } = vi.hoisted(() => ({ envState: {} as Record, deletedConnectionsState: [] as Array<{ id: string }>, - findLinearDeploymentMcpConnectionMock: vi.fn(), - getLinearDeploymentMetadataMock: vi.fn(), resolveDeploymentEnvVarMock: vi.fn(), upsertDeploymentEnvironmentVariablesMock: vi.fn(), })); @@ -51,13 +47,12 @@ vi.mock('@roomote/db/server', () => ({ deploymentMcpEnablements: { mcpId: 'mcpId' }, })); vi.mock('@roomote/sdk/server', () => ({ - findLinearDeploymentMcpConnection: findLinearDeploymentMcpConnectionMock, - getLinearDeploymentMetadata: getLinearDeploymentMetadataMock, + findLinearDeploymentMcpConnection: vi.fn(), + getLinearDeploymentMetadata: vi.fn(), LINEAR_ORG_CONNECTION_ROLE: 'linear_org_install', })); import { - getLinearInstallationCommand, getLinearOauthSetupCommand, saveLinearOauthSetupCommand, } from './index'; @@ -74,49 +69,9 @@ describe('Linear OAuth setup', () => { delete envState[key]; } deletedConnectionsState.splice(0); - findLinearDeploymentMcpConnectionMock.mockResolvedValue(undefined); - getLinearDeploymentMetadataMock.mockReturnValue(null); resolveDeploymentEnvVarMock.mockResolvedValue(null); }); - it('requires older Linear app installations to reconnect for agent scopes', async () => { - findLinearDeploymentMcpConnectionMock.mockResolvedValue({ - id: 'linear-connection', - authStatus: 'authenticated', - authConfig: {}, - scopes: ['read', 'write'], - }); - getLinearDeploymentMetadataMock.mockReturnValue({ - linearOrganizationId: 'linear-org', - linearOrganizationName: 'Roomote', - linearOrganizationUrlKey: 'roomote', - appUserId: 'linear-app-user', - }); - - await expect(getLinearInstallationCommand(ADMIN)).resolves.toMatchObject({ - requiresReconnect: true, - }); - }); - - it('accepts Linear app installations with mention and assignment scopes', async () => { - findLinearDeploymentMcpConnectionMock.mockResolvedValue({ - id: 'linear-connection', - authStatus: 'authenticated', - authConfig: {}, - scopes: ['read', 'write', 'app:assignable', 'app:mentionable'], - }); - getLinearDeploymentMetadataMock.mockReturnValue({ - linearOrganizationId: 'linear-org', - linearOrganizationName: 'Roomote', - linearOrganizationUrlKey: 'roomote', - appUserId: 'linear-app-user', - }); - - await expect(getLinearInstallationCommand(ADMIN)).resolves.toMatchObject({ - requiresReconnect: false, - }); - }); - it('builds a private Linear app manifest for this deployment', async () => { const setup = await getLinearOauthSetupCommand(ADMIN); const setupUrl = new URL(setup.manifestUrl); diff --git a/apps/web/src/trpc/commands/linear/index.ts b/apps/web/src/trpc/commands/linear/index.ts index 317d4bc8d..ae1a763c5 100644 --- a/apps/web/src/trpc/commands/linear/index.ts +++ b/apps/web/src/trpc/commands/linear/index.ts @@ -1,5 +1,4 @@ import { db } from '@roomote/db/server'; -import { LINEAR_APP_OAUTH_SCOPES } from '@roomote/types'; import { findLinearDeploymentMcpConnection, getLinearDeploymentMetadata, @@ -21,7 +20,6 @@ type LinearInstallationSummary = { linearOrganizationName: string | null; linearOrganizationUrlKey: string | null; appUserId: string | null; - requiresReconnect: boolean; }; export async function getLinearInstallationCommand( @@ -42,9 +40,6 @@ export async function getLinearInstallationCommand( linearOrganizationName: metadata.linearOrganizationName, linearOrganizationUrlKey: metadata.linearOrganizationUrlKey, appUserId: metadata.appUserId, - requiresReconnect: !LINEAR_APP_OAUTH_SCOPES.every((scope) => - connection.scopes?.includes(scope), - ), }; }