diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index 1bd5a0830a..4f2bc12234 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -1,4 +1,10 @@ const mocks = vi.hoisted(() => ({ + connectionAdapter: { + getSourceControlReadiness: vi.fn(), + requestSourceControlConnection: vi.fn(), + supersedeSourceControlConnectionRequests: vi.fn(), + }, + connectionEnabled: vi.fn(async () => false), acquireLock: vi.fn(), answerQuestion: vi.fn(), fetchHistory: vi.fn(), @@ -41,6 +47,8 @@ vi.mock('@roomote/cloud-agents/server', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter), + isSourceControlConnectionEnabled: mocks.connectionEnabled, admitFastAgentHumanFollowUp: mocks.admitHumanFollowUp, createFastAgentConversationArtifact: mocks.createConversationArtifact, persistFastAgentInlineHumanTurn: vi.fn(async () => null), @@ -149,133 +157,147 @@ describe('processDiscordFastAgentMessage', () => { }); }); - it('accepts a suggestion on its original conversation and sends replies and delegated work to the canonical target', async () => { - const conversation = { - surface: 'discord', - workspaceId: 'guild-1', - conversationId: 'original-report-event', - sessionId: 'origin-session', - replyTarget: { channelId: 'report-channel', threadId: 'report-thread' }, - }; - const channel = { - channelId: 'report-thread', - parentChannelId: 'report-channel', - channelType: 11, - channelName: 'Report', - guildId: 'guild-1', - isThread: true, - isDirectMessage: false, - }; - mocks.resolveSuggestionConversation.mockResolvedValueOnce(conversation); - mocks.resolveChannel.mockResolvedValueOnce(channel); - mocks.fetchHistory.mockResolvedValueOnce([ - { - id: 'history-1', - user: 'author', - username: 'Author', - text: 'Original report', - }, - ]); - const onAccepted = vi.fn(); - const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; - mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => { - const reply = await adapter.postReply({ message: 'Working on it' }); - await adapter.replaceReply(reply, { message: 'Updated' }); - await adapter.launchTask({ - prompt: 'Fix errors', - environmentId: ALL_REPOSITORIES, - parentSessionId: 'fast-session-1', - postKickoff: async () => {}, - }); - return null; - }); - await expect( - processDiscordFastAgentMessage({ - eventId: 'new-interaction', - originSessionId: 'origin-session', - question: 'Investigate errors', - sender: { id: 'clicker', username: 'Matt' }, - senderUserId: 'acting-user', - provider: provider as never, - applicationId: 'app-1', - channel: { - ...channel, - channelId: 'card-thread', - parentChannelId: 'card-channel', - }, - metadata: { - communicationChannelId: 'card-channel', - communicationThreadId: 'card-thread', - } as never, - conversationId: 'card-thread', - anchorMessageId: 'clicked-card', - interaction: { - interaction: { id: 'new-interaction', token: 'token' } as never, - interactionDeferred: true, + it.each([true, false])( + 'accepts a suggestion on its original conversation with trusted connection rollout %s', + async (enabled) => { + mocks.connectionEnabled.mockResolvedValueOnce(enabled); + const conversation = { + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'original-report-event', + sessionId: 'origin-session', + replyTarget: { channelId: 'report-channel', threadId: 'report-thread' }, + }; + const channel = { + channelId: 'report-thread', + parentChannelId: 'report-channel', + channelType: 11, + channelName: 'Report', + guildId: 'guild-1', + isThread: true, + isDirectMessage: false, + }; + mocks.resolveSuggestionConversation.mockResolvedValueOnce(conversation); + mocks.resolveChannel.mockResolvedValueOnce(channel); + mocks.fetchHistory.mockResolvedValueOnce([ + { + id: 'history-1', + user: 'author', + username: 'Author', + text: 'Original report', }, - onAccepted, - }), - ).resolves.toBe(true); - - expect(onAccepted).toHaveBeenCalledWith(expect.any(Function)); - expect(mocks.resolveSuggestionConversation).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'acting-user', - originSessionId: 'origin-session', - conversation: expect.objectContaining({ + ]); + const onAccepted = vi.fn(); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + mocks.answerQuestion.mockImplementationOnce(async ({ adapter }) => { + const reply = await adapter.postReply({ message: 'Working on it' }); + await adapter.replaceReply(reply, { message: 'Updated' }); + await adapter.launchTask({ + prompt: 'Fix errors', + environmentId: ALL_REPOSITORIES, + parentSessionId: 'fast-session-1', + postKickoff: async () => {}, + }); + return null; + }); + await expect( + processDiscordFastAgentMessage({ + eventId: 'new-interaction', + originSessionId: 'origin-session', + question: 'Investigate errors', + sender: { id: 'clicker', username: 'Matt' }, + senderUserId: 'acting-user', + provider: provider as never, + applicationId: 'app-1', + channel: { + ...channel, + channelId: 'card-thread', + parentChannelId: 'card-channel', + }, + metadata: { + communicationChannelId: 'card-channel', + communicationThreadId: 'card-thread', + } as never, conversationId: 'card-thread', + anchorMessageId: 'clicked-card', + interaction: { + interaction: { id: 'new-interaction', token: 'token' } as never, + interactionDeferred: true, + }, + onAccepted, }), - }), - ); - expect(mocks.acquireLock).toHaveBeenCalledWith({ - conversation, - maxWaitMs: 0, - }); - expect(mocks.getSession).toHaveBeenCalledWith({ - userId: 'acting-user', - conversation, - }); - expect(mocks.fetchHistory).toHaveBeenCalledWith({ - provider, - channelId: 'report-thread', - parentChannelId: 'report-channel', - }); - expect(mocks.answerQuestion).toHaveBeenCalledWith( - expect.objectContaining({ + ).resolves.toBe(true); + + expect(onAccepted).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.connectionEnabled).toHaveBeenCalledWith(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'acting-user', + adapter: expect.objectContaining({ + ...mocks.connectionAdapter, + sourceControlConnectionEnabled: enabled, + }), + }), + ); + expect(mocks.resolveSuggestionConversation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'acting-user', + originSessionId: 'origin-session', + conversation: expect.objectContaining({ + conversationId: 'card-thread', + }), + }), + ); + expect(mocks.acquireLock).toHaveBeenCalledWith({ + conversation, + maxWaitMs: 0, + }); + expect(mocks.getSession).toHaveBeenCalledWith({ userId: 'acting-user', conversation, - threadContext: [expect.objectContaining({ text: 'Original report' })], - }), - ); - expect(mocks.reply).toHaveBeenCalledWith( - expect.objectContaining({ channel }), - ); - expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty('interaction'); - expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty( - 'replyToMessageId', - ); - expect(provider.editMessage).toHaveBeenCalledWith( - expect.objectContaining({ channelId: 'report-thread' }), - ); - expect(mocks.startTask).toHaveBeenCalledWith( - expect.objectContaining({ - channel, - launchOwnerUserId: 'acting-user', - requesterDiscordUserId: 'clicker', - metadata: expect.objectContaining({ - communicationChannelId: 'report-channel', - communicationThreadId: 'report-thread', - }), - queuedMessage: expect.objectContaining({ - channel: 'report-channel', - threadTs: 'report-thread', + }); + expect(mocks.fetchHistory).toHaveBeenCalledWith({ + provider, + channelId: 'report-thread', + parentChannelId: 'report-channel', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'acting-user', + conversation, + threadContext: [expect.objectContaining({ text: 'Original report' })], }), - fastAgentParent: { sessionId: 'fast-session-1', conversation }, - }), - ); - expect(mocks.releaseLock).toHaveBeenCalledOnce(); - }); + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ channel }), + ); + expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty('interaction'); + expect(mocks.reply.mock.calls[0]![0]).not.toHaveProperty( + 'replyToMessageId', + ); + expect(provider.editMessage).toHaveBeenCalledWith( + expect.objectContaining({ channelId: 'report-thread' }), + ); + expect(mocks.startTask).toHaveBeenCalledWith( + expect.objectContaining({ + channel, + launchOwnerUserId: 'acting-user', + requesterDiscordUserId: 'clicker', + metadata: expect.objectContaining({ + communicationChannelId: 'report-channel', + communicationThreadId: 'report-thread', + }), + queuedMessage: expect.objectContaining({ + channel: 'report-channel', + threadTs: 'report-thread', + userId: 'acting-user', + }), + fastAgentParent: { sessionId: 'fast-session-1', conversation }, + }), + ); + expect(mocks.releaseLock).toHaveBeenCalledOnce(); + }, + ); it('creates artifacts against the canonical Fast conversation', async () => { mocks.answerQuestion.mockImplementationOnce( diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 794f76d401..efead3f95e 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -103,6 +103,12 @@ vi.mock('../provider.js', () => { }); vi.mock('@roomote/sdk/server', () => ({ + createSourceControlConnectionAdapter: vi.fn(() => ({ + getSourceControlReadiness: vi.fn(), + requestSourceControlConnection: vi.fn(), + supersedeSourceControlConnectionRequests: vi.fn(), + })), + isSourceControlConnectionEnabled: vi.fn(async () => false), findDiscordMappedUserId: mocks.findMappedUserId, findDiscordInstallationByGuildId: mocks.findInstallation, consumeDiscordLinkCode: mocks.consumeLinkCode, diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 8b1c6b175f..02d42c3430 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -28,6 +28,8 @@ import { } from '@roomote/communication'; import { admitFastAgentHumanFollowUp, + createSourceControlConnectionAdapter, + isSourceControlConnectionEnabled, createFastAgentConversationArtifact, persistFastAgentInlineHumanTurn, recordFastAgentConversationMessageBestEffort, @@ -390,6 +392,9 @@ export async function processDiscordFastAgentMessage( entry.user !== input.sender.id, ), adapter: { + ...createSourceControlConnectionAdapter(), + sourceControlConnectionEnabled: + await isSourceControlConnectionEnabled(), createArtifact: (artifact) => createFastAgentConversationArtifact({ fastConversationId: session.id, diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts index d07bc57da9..e6ba484410 100644 --- a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts +++ b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts @@ -1,97 +1,260 @@ -const { - mockCompletePendingGitHubInstallation, - mockSendUserDirectMessage, - mockRequestBrainBackfill, -} = vi.hoisted(() => ({ - mockCompletePendingGitHubInstallation: vi.fn(), - mockSendUserDirectMessage: vi.fn(), - mockRequestBrainBackfill: vi.fn(async () => undefined), +import { + db, + eq, + githubInstallations, + githubInstallationFactory, + githubPendingInstallations, + githubUserMappings, + userFactory, + users, +} from '@roomote/db/server'; +import { encryptJSON } from '@roomote/db/encryption'; +const mocks = vi.hoisted(() => ({ + pending: vi.fn(), + sync: vi.fn(), + installation: vi.fn(), + notify: vi.fn(), + backfill: vi.fn(), + reconcile: vi.fn(), + syncStartedAt: vi.fn(async () => '2026-09-08T12:00:00.123456Z'), + configuredApp: null as null | { + value: string; + createdByUserId: string; + lastUpdatedByUserId: string | null; + }, })); - vi.mock('@roomote/github', () => ({ - completePendingGitHubInstallation: mockCompletePendingGitHubInstallation, + completePendingGitHubInstallation: mocks.pending, + getGitHubInstallation: mocks.installation, + syncGitHubInstallation: mocks.sync, })); - vi.mock('@roomote/sdk/server', () => ({ - sendUserDirectMessageBestEffort: mockSendUserDirectMessage, + sendUserDirectMessageBestEffort: mocks.notify, + reconcileSourceControlConnectionRequests: mocks.reconcile, + getSourceControlSyncStartedAt: mocks.syncStartedAt, })); - vi.mock('@roomote/sdk/server/request-instance-ping', () => ({ - requestBrainBackfill: mockRequestBrainBackfill, + requestBrainBackfill: mocks.backfill, })); - -vi.mock('@roomote/env', () => ({ +vi.mock('@roomote/env', async (original) => ({ + ...(await original()), + getEncryptionKey: () => 'installation-created-test-key', Env: { R_APP_URL: 'https://roomote.example.com' }, })); - +vi.mock('@roomote/db/server', async (original) => ({ + ...(await original()), + resolveDeploymentEnvVar: async () => '77', +})); import { handleInstallationCreated } from '../handleInstallationCreated'; import type { WebhookInstallationCreated } from '../types'; -const payload = { - installation: { id: 42 }, -} as unknown as WebhookInstallationCreated; - -describe('handleInstallationCreated', () => { - beforeEach(() => { +describe('installation.created authoritative synchronization', () => { + let admin: Awaited>; + let member: Awaited>; + let installationId: number; + let accountId: number; + let senderId: number; + const payload = () => + ({ + installation: { + id: installationId, + app_id: 77, + account: { id: accountId }, + }, + sender: { id: senderId }, + }) as WebhookInstallationCreated; + const pending = () => + db + .insert(githubPendingInstallations) + .values({ appId: accountId, requestedByUserId: admin.id, payload: {} }); + beforeEach(async () => { vi.clearAllMocks(); - mockSendUserDirectMessage.mockResolvedValue(['slack']); - }); - - it('notifies the requesting user after completing a pending installation', async () => { - mockCompletePendingGitHubInstallation.mockResolvedValue({ + admin = await userFactory.create({ role: 'admin' }); + member = await userFactory.create({ role: 'member' }); + installationId = Math.floor(Math.random() * 1e12); + accountId = installationId + 1; + senderId = installationId + 2; + mocks.configuredApp = null; + vi.spyOn(db.query.environmentVariables, 'findFirst').mockImplementation( + () => Promise.resolve(mocks.configuredApp) as never, + ); + mocks.installation.mockResolvedValue({ + id: installationId, + app_id: 77, + account: { id: accountId }, + suspended_at: null, + }); + mocks.sync.mockResolvedValue({ success: true, - githubInstallation: { accountLogin: 'acme-inc' }, + githubInstallation: { accountLogin: 'example' }, repositories: [], - requestedByUserId: 'user-1', }); - - const response = await handleInstallationCreated(payload); - - expect(response).toEqual({ status: 'ok' }); - expect(mockCompletePendingGitHubInstallation).toHaveBeenCalledWith(42); - expect(mockSendUserDirectMessage).toHaveBeenCalledWith({ - userId: 'user-1', - text: 'Your GitHub installation request for acme-inc was approved, and Roomote is now connected. Continue setup here: https://roomote.example.com/setup', - logContext: 'handleInstallationCreated', + mocks.pending.mockResolvedValue({ + success: true, + githubInstallation: { accountLogin: 'example' }, + repositories: [], + requestedByUserId: admin.id, }); }); - - it('does not notify when completion fails', async () => { - mockCompletePendingGitHubInstallation.mockResolvedValue({ - success: false, - error: 'sync failed', + afterEach(async () => { + vi.restoreAllMocks(); + await db + .delete(githubInstallations) + .where(eq(githubInstallations.installationId, installationId)); + await db + .delete(githubPendingInstallations) + .where(eq(githubPendingInstallations.appId, accountId)); + await db.delete(users).where(eq(users.id, admin.id)); + await db.delete(users).where(eq(users.id, member.id)); + }); + it('synchronizes a direct install without a pending request or personal link using the current App configuring admin', async () => { + mocks.configuredApp = { + value: encryptJSON('77'), + createdByUserId: admin.id, + lastUpdatedByUserId: null, + }; + expect(await handleInstallationCreated(payload())).toEqual({ + status: 'ok', }); - - const response = await handleInstallationCreated(payload); - - expect(response).toEqual({ status: 'ok' }); - expect(mockSendUserDirectMessage).not.toHaveBeenCalled(); + expect(mocks.sync).toHaveBeenCalledWith({ + userId: admin.id, + installationId, + }); + expect(mocks.pending).not.toHaveBeenCalled(); + expect(mocks.syncStartedAt).toHaveBeenCalledWith('github'); + expect(mocks.syncStartedAt.mock.invocationCallOrder[0]).toBeLessThan( + mocks.installation.mock.invocationCallOrder[0]!, + ); + expect(mocks.reconcile).toHaveBeenCalledWith( + { provider: 'github' }, + { + successfulSync: { + startedAt: '2026-09-08T12:00:00.123456Z', + repositoryFullNames: [], + }, + }, + ); + expect(mocks.reconcile.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.sync.mock.invocationCallOrder[0]!, + ); + expect(mocks.backfill).toHaveBeenCalledWith('github-installation-created'); + expect(mocks.notify).not.toHaveBeenCalled(); }); - - it('kicks the Memory backfill for pending and direct installs alike', async () => { - mockCompletePendingGitHubInstallation.mockResolvedValue({ - success: false, - error: 'no pending installation', + it('supports a verified linked admin sender when the App credentials were configured at runtime', async () => { + await db.insert(githubUserMappings).values({ + githubLogin: 'installer', + githubUserId: senderId, + userId: admin.id, }); - - await handleInstallationCreated(payload); - - expect(mockRequestBrainBackfill).toHaveBeenCalledWith( - 'github-installation-created', + expect((await handleInstallationCreated(payload())).status).toBe('ok'); + expect(mocks.sync).toHaveBeenCalledWith({ + userId: admin.id, + installationId, + }); + }); + it('reuses a known installation actor without inventing attribution on redelivery', async () => { + await githubInstallationFactory.create({ + installationId, + appId: 77, + installedByUserId: admin.id, + }); + expect((await handleInstallationCreated(payload())).status).toBe('ok'); + expect(mocks.sync).toHaveBeenCalledWith({ + userId: admin.id, + installationId, + }); + }); + it('retains pending-approval completion and notifies only its recorded requester', async () => { + await pending(); + expect(await handleInstallationCreated(payload())).toEqual({ + status: 'ok', + }); + expect(mocks.pending).toHaveBeenCalledWith(installationId); + expect(mocks.sync).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ userId: admin.id }), ); + expect(mocks.reconcile).toHaveBeenCalledTimes(1); }); - - it('still acks the webhook when completion throws', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockCompletePendingGitHubInstallation.mockRejectedValue( - new Error('Pending GitHub installation not found'), + it.each([false, true])( + 'never reconciles or backfills after sync failure (pending=%s)', + async (hasPending) => { + if (hasPending) await pending(); + else + mocks.configuredApp = { + value: encryptJSON('77'), + createdByUserId: admin.id, + lastUpdatedByUserId: null, + }; + mocks.pending.mockResolvedValue({ + success: false, + error: 'provider failure', + }); + mocks.sync.mockResolvedValue({ + success: false, + error: 'provider failure', + }); + expect(await handleInstallationCreated(payload())).toEqual({ + status: 'error', + message: 'installation_sync_failed', + }); + expect(mocks.reconcile).not.toHaveBeenCalled(); + expect(mocks.backfill).not.toHaveBeenCalled(); + expect(mocks.notify).not.toHaveBeenCalled(); + }, + ); + it('rejects a webhook for a different App before making provider calls', async () => { + const foreign = payload(); + foreign.installation.app_id = 88; + expect((await handleInstallationCreated(foreign)).message).toBe( + 'installation_app_mismatch', ); - - const response = await handleInstallationCreated(payload); - - expect(response).toEqual({ status: 'ok' }); - expect(mockSendUserDirectMessage).not.toHaveBeenCalled(); - - errorSpy.mockRestore(); + expect(mocks.installation).not.toHaveBeenCalled(); + expect(mocks.sync).not.toHaveBeenCalled(); + }); + it.each(['app', 'id', 'account', 'suspended'])( + 'rejects mismatched authoritative installation %s', + async (mismatch) => { + mocks.installation.mockResolvedValue({ + id: mismatch === 'id' ? installationId + 9 : installationId, + app_id: mismatch === 'app' ? 88 : 77, + account: { id: mismatch === 'account' ? accountId + 9 : accountId }, + suspended_at: mismatch === 'suspended' ? '2026-01-01' : null, + }); + expect((await handleInstallationCreated(payload())).message).toBe( + 'installation_unavailable', + ); + expect(mocks.pending).not.toHaveBeenCalled(); + expect(mocks.sync).not.toHaveBeenCalled(); + expect(mocks.reconcile).not.toHaveBeenCalled(); + }, + ); + it('fails closed when App-auth installation lookup rejects the id', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.installation.mockRejectedValue(new Error('Not Found')); + expect((await handleInstallationCreated(payload())).status).toBe('error'); + expect(mocks.sync).not.toHaveBeenCalled(); + expect(mocks.reconcile).not.toHaveBeenCalled(); }); + it.each(['unknown', 'member', 'deleted', 'other-app'])( + 'does not select an arbitrary admin when actor attribution is %s', + async (scenario) => { + if (scenario !== 'unknown') + mocks.configuredApp = { + value: encryptJSON(scenario === 'other-app' ? '88' : '77'), + createdByUserId: scenario === 'member' ? member.id : admin.id, + lastUpdatedByUserId: null, + }; + if (scenario === 'deleted') + await db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, admin.id)); + expect((await handleInstallationCreated(payload())).message).toBe( + 'installation_sync_actor_required', + ); + expect(mocks.sync).not.toHaveBeenCalled(); + expect(mocks.reconcile).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts index 5fa5fac325..23bd1ed57f 100644 --- a/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts +++ b/apps/api/src/handlers/github/__tests__/handleInstallationRepositoriesChange.test.ts @@ -1,4 +1,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +const { reconcile, syncStartedAt } = vi.hoisted(() => ({ + reconcile: vi.fn(async () => ({ ready: 0 })), + syncStartedAt: vi.fn(async () => '2026-09-08T12:00:00.123456Z'), +})); +vi.mock('@roomote/sdk/server', () => ({ + reconcileSourceControlConnectionRequests: reconcile, + getSourceControlSyncStartedAt: syncStartedAt, +})); const { mockFindFirst, mockSyncGitHubInstallation } = vi.hoisted(() => ({ mockFindFirst: vi.fn(), @@ -32,7 +40,10 @@ describe('handleInstallationRepositoriesChange', () => { mockSyncGitHubInstallation.mockResolvedValue({ success: true, githubInstallation: {}, - repositories: [{ id: 'repo-1' }, { id: 'repo-2' }], + repositories: [ + { id: 'repo-1', fullName: 'org/one' }, + { id: 'repo-2', fullName: 'org/two' }, + ], }); }); @@ -47,6 +58,19 @@ describe('handleInstallationRepositoriesChange', () => { }); expect(response.status).toBe('ok'); expect(response.metadata).toEqual({ repositoryCount: 2 }); + expect(syncStartedAt).toHaveBeenCalledWith('github'); + expect(syncStartedAt.mock.invocationCallOrder[0]).toBeLessThan( + mockSyncGitHubInstallation.mock.invocationCallOrder[0]!, + ); + expect(reconcile).toHaveBeenCalledWith( + { provider: 'github' }, + { + successfulSync: { + startedAt: '2026-09-08T12:00:00.123456Z', + repositoryFullNames: ['org/one', 'org/two'], + }, + }, + ); }); it('short-circuits when the payload has no installation id', async () => { @@ -80,5 +104,6 @@ describe('handleInstallationRepositoriesChange', () => { expect(response.status).toBe('error'); expect(response.message).toContain('boom'); + expect(reconcile).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/handlers/github/handleInstallationCreated.ts b/apps/api/src/handlers/github/handleInstallationCreated.ts index 8036d02a13..eb8b31f96b 100644 --- a/apps/api/src/handlers/github/handleInstallationCreated.ts +++ b/apps/api/src/handlers/github/handleInstallationCreated.ts @@ -1,5 +1,26 @@ -import { completePendingGitHubInstallation } from '@roomote/github'; -import { sendUserDirectMessageBestEffort } from '@roomote/sdk/server'; +import { + completePendingGitHubInstallation, + getGitHubInstallation, + syncGitHubInstallation, +} from '@roomote/github'; +import { decryptSecrets } from '@roomote/db/encryption'; +import { + and, + db, + eq, + githubInstallations, + githubPendingInstallations, + githubUserMappings, + environmentVariables, + isNull, + resolveDeploymentEnvVar, + users, +} from '@roomote/db/server'; +import { + sendUserDirectMessageBestEffort, + reconcileSourceControlConnectionRequests, + getSourceControlSyncStartedAt, +} from '@roomote/sdk/server'; import { requestBrainBackfill } from '@roomote/sdk/server/request-instance-ping'; import { Env } from '@roomote/env'; @@ -17,9 +38,99 @@ export async function handleInstallationCreated( payload: WebhookInstallationCreated, ): Promise { try { - const result = await completePendingGitHubInstallation( - payload.installation.id, - ); + const startedAt = await getSourceControlSyncStartedAt('github'); + const installationId = payload.installation.id; + const appId = Number(await resolveDeploymentEnvVar('R_GITHUB_APP_ID')); + if ( + !Number.isSafeInteger(appId) || + appId <= 0 || + payload.installation.app_id !== appId + ) + return { status: 'error', message: 'installation_app_mismatch' }; + // Uses this deployment's App JWT, not an installation id/account supplied + // by the browser. GitHub rejects installations belonging to another App. + const installation = await getGitHubInstallation(installationId); + if ( + installation.id !== installationId || + installation.app_id !== appId || + !installation.account || + installation.account.id !== payload.installation.account?.id || + installation.suspended_at + ) + return { status: 'error', message: 'installation_unavailable' }; + + const pending = await db.query.githubPendingInstallations.findFirst({ + where: eq(githubPendingInstallations.appId, installation.account.id), + }); + let actorUserId = pending?.requestedByUserId; + if (!pending) { + const [existing, sender, configuredApp] = await Promise.all([ + db.query.githubInstallations.findFirst({ + where: and( + eq(githubInstallations.installationId, installationId), + eq(githubInstallations.appId, appId), + ), + columns: { installedByUserId: true }, + }), + payload.sender?.id + ? db.query.githubUserMappings.findFirst({ + where: eq(githubUserMappings.githubUserId, payload.sender.id), + columns: { userId: true }, + }) + : undefined, + db.query.environmentVariables.findFirst({ + where: eq(environmentVariables.name, 'R_GITHUB_APP_ID'), + columns: { + value: true, + lastUpdatedByUserId: true, + createdByUserId: true, + }, + }), + ]); + // For a new unlinked installer, the admin who configured this exact App + // supplies inventory attribution only. Continuation keeps its own actor. + const configuredBy = + configuredApp && + Number(await decryptSecrets(configuredApp.value)) === appId + ? (configuredApp.lastUpdatedByUserId ?? configuredApp.createdByUserId) + : undefined; + for (const candidate of [ + existing?.installedByUserId, + sender?.userId, + configuredBy, + ]) { + if (!candidate) continue; + const admin = await db.query.users.findFirst({ + where: and( + eq(users.id, candidate), + eq(users.role, 'admin'), + isNull(users.deletedAt), + ), + columns: { id: true }, + }); + if (admin) { + actorUserId = admin.id; + break; + } + } + } else { + const admin = await db.query.users.findFirst({ + where: and( + eq(users.id, actorUserId!), + eq(users.role, 'admin'), + isNull(users.deletedAt), + ), + columns: { id: true }, + }); + if (!admin) actorUserId = undefined; + } + if (!actorUserId) + return { status: 'error', message: 'installation_sync_actor_required' }; + const result = pending + ? await completePendingGitHubInstallation(installationId) + : await syncGitHubInstallation({ userId: actorUserId, installationId }); + if (!result.success) + return { status: 'error', message: 'installation_sync_failed' }; // The webhook is the universal completion point for new installations // (pending-approval and direct installs alike): repositories just became @@ -27,7 +138,21 @@ export async function handleInstallationCreated( // 15-minute schedules. void requestBrainBackfill('github-installation-created'); - if (result.success) { + await reconcileSourceControlConnectionRequests( + { provider: 'github' }, + { + successfulSync: { + startedAt, + repositoryFullNames: result.repositories.map( + (repository) => repository.fullName, + ), + }, + }, + ); + if ( + 'requestedByUserId' in result && + typeof result.requestedByUserId === 'string' + ) { // The requester was waiting on a GitHub org owner's approval; let them // know on whichever chat integrations they have linked. await sendUserDirectMessageBestEffort({ @@ -42,6 +167,7 @@ export async function handleInstallationCreated( console.error( `[handleInstallationCreated] Failed to complete pending GitHub installation: ${error instanceof Error ? error.message : String(error)}`, ); + return { status: 'error', message: 'installation_sync_failed' }; } return { status: 'ok' }; diff --git a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts index aa51b2ea36..c8e61e8626 100644 --- a/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts +++ b/apps/api/src/handlers/github/handleInstallationRepositoriesChange.ts @@ -1,5 +1,9 @@ import { db, eq, githubInstallations } from '@roomote/db/server'; import * as GitHub from '@roomote/github'; +import { + getSourceControlSyncStartedAt, + reconcileSourceControlConnectionRequests, +} from '@roomote/sdk/server'; import type { WebhookResponse } from '../../types'; @@ -33,6 +37,7 @@ export async function handleInstallationRepositoriesChange( return { status: 'ok', message: 'unknown_installation' }; } + const startedAt = await getSourceControlSyncStartedAt('github'); const result = await GitHub.syncGitHubInstallation({ userId: installation.installedByUserId, installationId, @@ -45,6 +50,17 @@ export async function handleInstallationRepositoriesChange( }; } + await reconcileSourceControlConnectionRequests( + { provider: 'github' }, + { + successfulSync: { + startedAt, + repositoryFullNames: result.repositories.map( + (repository) => repository.fullName, + ), + }, + }, + ); return { status: 'ok', message: `Resynced installation ${installationId}`, diff --git a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts index ffb7d90f9d..e6e416a8c2 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts @@ -1,4 +1,10 @@ const mocks = vi.hoisted(() => ({ + connectionAdapter: { + getSourceControlReadiness: vi.fn(), + requestSourceControlConnection: vi.fn(), + supersedeSourceControlConnectionRequests: vi.fn(), + }, + connectionEnabled: vi.fn(async () => false), acquireLock: vi.fn(), acquireRootBindingLock: vi.fn(), hasSession: vi.fn(), @@ -66,6 +72,8 @@ vi.mock('@roomote/cloud-agents', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter), + isSourceControlConnectionEnabled: mocks.connectionEnabled, findSlackConversationSubjectByUserId: vi.fn(async () => null), admitFastAgentHumanFollowUp: mocks.admitHumanFollowUp, createFastAgentConversationArtifact: mocks.createConversationArtifact, @@ -149,6 +157,41 @@ describe('processFastAgentMessage', () => { ); }); + it.each([[true], [false]])( + 'attaches trusted connection rollout %s and callbacks for the Slack actor', + async (enabled) => { + mocks.connectionEnabled.mockResolvedValueOnce(enabled); + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U123', + text: 'Connect repository', + ts: '100.001', + thread_ts: '100.000', + } as never, + slack: { + addReaction: vi.fn(), + removeReaction: vi.fn(), + fetchThreadMessages: vi.fn(async () => []), + } as never, + userId: 'origin-member', + teamId: 'T123', + isExistingConversation: true, + }); + expect(mocks.connectionEnabled).toHaveBeenCalledWith(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'origin-member', + adapter: expect.objectContaining({ + ...mocks.connectionAdapter, + sourceControlConnectionEnabled: enabled, + }), + }), + ); + }, + ); + it.each([ ['Roomote can you hear me?', 'Roomote can you hear me?'], ['Roomote, can you hear me?', 'Roomote, can you hear me?'], diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts index df7944565f..406fbb8fb0 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + connectionAdapter: { + getSourceControlReadiness: vi.fn(), + requestSourceControlConnection: vi.fn(), + supersedeSourceControlConnectionRequests: vi.fn(), + }, + connectionEnabled: vi.fn(async () => false), acquireLock: vi.fn(), answerQuestion: vi.fn(), createArtifact: vi.fn(), @@ -36,6 +42,8 @@ vi.mock('@roomote/communication', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + createSourceControlConnectionAdapter: vi.fn(() => mocks.connectionAdapter), + isSourceControlConnectionEnabled: mocks.connectionEnabled, findSlackConversationSubjectByUserId: vi.fn(async () => null), buildFastAgentArtifactCreator: vi.fn(() => mocks.createArtifact), findFastAgentSessionForProviderMessage: mocks.findSession, @@ -184,69 +192,86 @@ describe('Fast Slack reaction input', () => { ); }); - it('includes the Fast-authored message when a reaction can directly answer it', async () => { - const slack = { - getMessage: vi.fn(async () => ({ - text: 'React to this message with your favorite emoji.', - thread_ts: '100.000', - })), - normalizeIncomingText: vi.fn(async () => '@alice'), - updateMessage: vi.fn(), - }; - - await expect( - maybeRouteFastAgentReaction({ - context: { - teamId: 'T1', - slackInstallation: { botUserId: 'UROOMOTE' }, - slack, - } as never, - event: { - type: 'reaction_added', - user: 'UALICE', - reaction: 'sparkling_heart', - item: { type: 'message', channel: 'C1', ts: '101.000' }, - event_ts: '102.000', - }, - }), - ).resolves.toBe(true); + it.each([true, false])( + 'includes the reaction actor and connection wiring with rollout %s', + async (enabled) => { + mocks.connectionEnabled.mockResolvedValueOnce(enabled); + const slack = { + getMessage: vi.fn(async () => ({ + text: 'React to this message with your favorite emoji.', + thread_ts: '100.000', + })), + normalizeIncomingText: vi.fn(async () => '@alice'), + updateMessage: vi.fn(), + }; - await vi.waitFor(() => expect(mocks.answerQuestion).toHaveBeenCalledOnce()); - expect(mocks.createActivity).toHaveBeenCalledWith({ - slack: expect.anything(), - workspaceId: 'T1', - channel: 'C1', - threadTs: '100.000', - title: 'Investigate Slack agent status', - resolveTitle: expect.any(Function), - }); - expect(mocks.findSession).toHaveBeenCalledWith({ - provider: 'slack', - workspaceId: 'T1', - channelId: 'C1', - messageId: '101.000', - userId: 'user-1', - }); - expect(mocks.answerQuestion).toHaveBeenCalledWith( - expect.objectContaining({ - currentMessageId: 'slack-reaction:102.000', - senderExternalId: 'UALICE', - senderDisplayName: '@alice', - input: { - type: 'reaction', - externalInput: expect.objectContaining({ + await expect( + maybeRouteFastAgentReaction({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack, + } as never, + event: { type: 'reaction_added', - provider: 'slack', - reactions: [{ name: 'sparkling_heart' }], + user: 'UALICE', + reaction: 'sparkling_heart', + item: { type: 'message', channel: 'C1', ts: '101.000' }, + event_ts: '102.000', + }, + }), + ).resolves.toBe(true); + + await vi.waitFor(() => + expect(mocks.answerQuestion).toHaveBeenCalledOnce(), + ); + expect(mocks.connectionEnabled).toHaveBeenCalledWith(); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + senderExternalId: 'UALICE', + adapter: expect.objectContaining({ + ...mocks.connectionAdapter, + sourceControlConnectionEnabled: enabled, }), - }, - question: expect.stringContaining( - 'React to this message with your favorite emoji.', - ), - }), - ); - expect(mocks.postThreadMessage).not.toHaveBeenCalled(); - }); + }), + ); + expect(mocks.createActivity).toHaveBeenCalledWith({ + slack: expect.anything(), + workspaceId: 'T1', + channel: 'C1', + threadTs: '100.000', + title: 'Investigate Slack agent status', + resolveTitle: expect.any(Function), + }); + expect(mocks.findSession).toHaveBeenCalledWith({ + provider: 'slack', + workspaceId: 'T1', + channelId: 'C1', + messageId: '101.000', + userId: 'user-1', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + currentMessageId: 'slack-reaction:102.000', + senderExternalId: 'UALICE', + senderDisplayName: '@alice', + input: { + type: 'reaction', + externalInput: expect.objectContaining({ + type: 'reaction_added', + provider: 'slack', + reactions: [{ name: 'sparkling_heart' }], + }), + }, + question: expect.stringContaining( + 'React to this message with your favorite emoji.', + ), + }), + ); + expect(mocks.postThreadMessage).not.toHaveBeenCalled(); + }, + ); it.each([ ['', true], diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts index e7efdd1692..ea15939e0a 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts @@ -15,6 +15,8 @@ import { } from '@roomote/communication'; import { buildFastAgentArtifactCreator, + createSourceControlConnectionAdapter, + isSourceControlConnectionEnabled, findFastAgentSessionForProviderMessage, persistFastAgentInlineHumanTurn, recordFastAgentConversationMessageBestEffort, @@ -147,6 +149,9 @@ async function processFastAgentReaction(params: { ...(durableTurn ? { durableAdmission: { eventId: durableTurn.id } } : {}), ...(durableTurn?.resumed ? { resumedAfterInterruption: true } : {}), adapter: { + ...createSourceControlConnectionAdapter(), + sourceControlConnectionEnabled: + await isSourceControlConnectionEnabled(), ...(durableTurn ? { requestDurableResume: () => diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index ac72f36519..8df3f51d1b 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -25,6 +25,8 @@ import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents'; import { buildDataVisualizationBlocks } from '@roomote/types'; import { admitFastAgentHumanFollowUp, + createSourceControlConnectionAdapter, + isSourceControlConnectionEnabled, createFastAgentConversationArtifact, persistFastAgentInlineHumanTurn, wakeFastAgentParentEventAt, @@ -304,6 +306,9 @@ export async function processFastAgentMessage(params: { !directedAtRoomote, ...(roomoteSlackUserId ? { slackRoomoteUserId: roomoteSlackUserId } : {}), adapter: { + ...createSourceControlConnectionAdapter(), + sourceControlConnectionEnabled: + await isSourceControlConnectionEnabled(), createArtifact: (artifact) => createFastAgentConversationArtifact({ fastConversationId: session.id, diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 8809652286..184f883cb1 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -211,6 +211,13 @@ setup; ask Roomote to retry that item. Automation recommendations are analyzed after repository sync and appear only after at least one starter task launches. They remain optional. +On deployments with optional source-control setup enabled, inference and a +sandbox provider are sufficient to complete setup. Source control can be +connected later in **Settings > Source control** when you need repository work. +Repository starter tasks and repository recommendations still require connected, +synchronized repositories. The default setup flow continues to require source +control; disabling the optional flow does not reopen already completed setup. + After setup, run a small task that uses the first environment if you did not start one from the starter list. A healthy deployment should let you: diff --git a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx index 25e665a21d..fe371f5550 100644 --- a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx +++ b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx @@ -3,6 +3,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { z } from 'zod'; +import { useMutation } from '@tanstack/react-query'; +import { useTRPC } from '@/trpc/client'; +import { normalizeSourceControlOAuthReturnTarget } from '@/lib/server/source-control-oauth-redirect'; import { CircleX, CircleCheck, @@ -55,6 +58,27 @@ import { GitHubInstallRequestPending } from '@/components/github/GitHubInstallRe export default function Page() { const router = useRouter(); const params = useSearchParams(); + const trpc = useTRPC(); + const connectionCallback = useMutation( + trpc.sourceControl.githubConnectionCallback.mutationOptions({ + onSuccess: (result) => { + setIsLoading(false); + if (!result.success) { + setError(result.error); + return; + } + if ('installUrl' in result && result.installUrl) + window.location.assign(result.installUrl); + else router.push(result.returnTarget); + }, + onError: () => { + setIsLoading(false); + setError( + 'Connection attempt is no longer available. Return to the Session to try again.', + ); + }, + }), + ); const { authStatus, isSignedIn } = useUser(); const setupBootstrapOpen = useSetupBootstrapOpen(); @@ -84,20 +108,16 @@ export default function Page() { const redirect = decodedState?.redirect; - const isValidRedirect = - redirect && - redirect.startsWith('/') && - !redirect.startsWith('//') && - !redirect.includes('://'); + const isValidRedirect = normalizeSourceControlOAuthReturnTarget(redirect); const setupCompletedRedirect = - isValidRedirect && redirect.startsWith('/setup') && !setupBootstrapOpen + isValidRedirect && + isValidRedirect.startsWith('/setup') && + !setupBootstrapOpen ? '/settings/source-control' : null; - router.push( - setupCompletedRedirect ?? (isValidRedirect ? redirect : '/settings'), - ); + router.push(setupCompletedRedirect ?? isValidRedirect ?? '/settings'); }, [params, router, setupBootstrapOpen]); const finishAuthentication = useFinishAuthenticateGitHubAccount({ @@ -192,6 +212,19 @@ export default function Page() { if (error) { setIsLoading(false); setError(error); + } else if (decodedState?.connectionState) { + connectionCallback.mutate({ + state: decodedState.connectionState, + action: isAppManifestFlow + ? 'manifest' + : setupAction === 'request' + ? 'request' + : 'install', + ...(code ? { code } : {}), + ...(params.get('installation_id') + ? { installationId: Number(params.get('installation_id')) } + : {}), + }); } else if (isAppManifestFlow) { if (!code) { setIsLoading(false); @@ -244,6 +277,7 @@ export default function Page() { isSignedIn, params, syncInstall, + connectionCallback, ]); return ( diff --git a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx index 71787e592b..8bed1140ca 100644 --- a/apps/web/src/app/(centered)/github/callback/page.client.test.tsx +++ b/apps/web/src/app/(centered)/github/callback/page.client.test.tsx @@ -12,6 +12,20 @@ const mockSyncInstallMutate = vi.fn(); const mockFinishInstallMutate = vi.fn(); const mockFinishAppManifestMutate = vi.fn(); const mockFinishAuthenticationMutate = vi.fn(); +const mockConnectionCallback = vi.fn(); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sourceControl: { + githubConnectionCallback: { + mutationOptions: (options: object) => options, + }, + }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutate: mockConnectionCallback }), +})); let searchParams = new URLSearchParams(); let userState: @@ -59,6 +73,21 @@ vi.mock('@/hooks/github', () => ({ })); describe('GitHub callback page', () => { + it('routes bound Session callbacks through server verification, not the ordinary sync action', async () => { + searchParams = new URLSearchParams({ + installation_id: '123', + state: encodeRecord({ connectionState: 'sc.signed.state' }), + }); + render(); + await waitFor(() => + expect(mockConnectionCallback).toHaveBeenCalledWith({ + state: 'sc.signed.state', + action: 'install', + installationId: 123, + }), + ); + expect(mockSyncInstallMutate).not.toHaveBeenCalled(); + }); beforeEach(() => { vi.clearAllMocks(); searchParams = new URLSearchParams(); diff --git a/apps/web/src/app/(sandbox)/SandboxShell.tsx b/apps/web/src/app/(sandbox)/SandboxShell.tsx index bc6337ec31..aadce4f283 100644 --- a/apps/web/src/app/(sandbox)/SandboxShell.tsx +++ b/apps/web/src/app/(sandbox)/SandboxShell.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { usePathname } from 'next/navigation'; +import { usePathname, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; @@ -29,6 +29,9 @@ export function SandboxShell({ const router = useRouter(); const { authStatus, isSignedIn, user } = useUser(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const isConnectionReturn = + pathname.startsWith('/sessions/') && searchParams.has('connectionRequest'); const shouldRedirectToSignIn = requireAuth && authStatus === 'signed-out'; useRedirectToSignIn(shouldRedirectToSignIn); @@ -88,6 +91,9 @@ export function SandboxShell({ ); useEffect(() => { + // A handoff can be opened by an admin while setup changes in another tab. + // The request view still performs its own live authorization. + if (isConnectionReturn) return; // Wait for the setup-session lookup before routing. Otherwise a direct // visit to the in-progress setup session can briefly see no session ID // and be redirected to /setup before the lookup resolves. @@ -97,6 +103,7 @@ export function SandboxShell({ router.replace('/onboarding'); } }, [ + isConnectionReturn, isAllowedSetupSession, isOnboardingError, isSetupSessionLoading, diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx new file mode 100644 index 0000000000..c2cbb5833e --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.client.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { SessionConnectionRequest } from './SessionConnectionRequest'; + +const state = vi.hoisted(() => ({ + data: null as null | { + id: string; + url: string; + status: string; + reason: string; + provider: string; + repositoryFullName: string; + canConnect: boolean; + canCheck: boolean; + canCancel: boolean; + enabled?: boolean; + }, + check: vi.fn(), + cancel: vi.fn(), + search: '', +})); +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(state.search), +})); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sourceControl: { + connectionRequest: { + queryOptions: (input: object) => ({ + queryKey: ['connectionRequest', input], + }), + }, + checkConnectionRequest: { mutationOptions: () => ({ kind: 'check' }) }, + cancelConnectionRequest: { mutationOptions: () => ({ kind: 'cancel' }) }, + }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ + data: state.data, + refetch: vi.fn(), + isPending: false, + isError: false, + }), + useMutation: ({ kind }: { kind: string }) => ({ + mutate: kind === 'check' ? state.check : state.cancel, + isPending: false, + }), +})); +vi.mock('@/components/settings/SourceControl', () => ({ + SourceControl: ({ + connectionRequestId, + connectionReturnTarget, + }: { + connectionRequestId: string; + connectionReturnTarget: string; + }) => ( +
+ {connectionRequestId} {connectionReturnTarget} +
+ ), +})); + +describe('Session connection request', () => { + it('explains rollout disablement without offering a connection or check', () => { + Object.assign(state.data!, { + enabled: false, + canConnect: false, + canCheck: false, + }); + render(); + expect( + screen.getByText(/Session connection requests are disabled/), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Configure source control' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Check status' }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Cancel request' }), + ).toBeInTheDocument(); + }); + beforeEach(() => { + vi.clearAllMocks(); + state.search = 'connectionRequest=request-id&gitlab=connected'; + state.data = { + id: 'request-id', + url: '/sessions/session-id?connectionRequest=request-id', + status: 'pending', + reason: 'repository_unavailable', + provider: 'gitlab', + repositoryFullName: 'org/exact-target', + canConnect: true, + canCheck: true, + canCancel: true, + }; + }); + it('does not authorize or resume from connected query parameters; checks only on explicit input', () => { + render(); + expect(state.check).not.toHaveBeenCalled(); + expect(screen.getByText('org/exact-target')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Check status' })); + expect(state.check).toHaveBeenCalledWith({ + sessionId: 'session-id', + requestId: 'request-id', + }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel request' })); + expect(state.cancel).toHaveBeenCalledWith({ + sessionId: 'session-id', + requestId: 'request-id', + }); + }); + it('passes the stored request and canonical return into the trusted provider UI', () => { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'Configure source control' }), + ); + expect(screen.getByTestId('trusted-config')).toHaveTextContent( + 'request-id /sessions/session-id?connectionRequest=request-id', + ); + }); + it('does not expose admin configuration or cancellation to a transcript reader', () => { + Object.assign(state.data!, { + canConnect: false, + canCancel: false, + canCheck: false, + }); + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect( + screen.getByText(/deployment admin can connect/), + ).toBeInTheDocument(); + }); + it('reports terminal state without promising continuation', () => { + Object.assign(state.data!, { + status: 'superseded', + canConnect: false, + canCancel: false, + canCheck: false, + }); + render(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Connection request superseded.', + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + it('reports unavailable request without rendering controls', () => { + state.data = null; + render(); + expect(screen.getByRole('status')).toHaveTextContent( + 'not available in this Session', + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx new file mode 100644 index 0000000000..312f1dd18f --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionConnectionRequest.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useTRPC } from '@/trpc/client'; +import { SourceControl } from '@/components/settings/SourceControl'; +import { GitHubInstallRequestPending } from '@/components/github/GitHubInstallRequestPending'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + CardFooter, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + Skeleton, +} from '@/components/system'; + +const reasons: Record = { + not_connected: 'Source control is not connected.', + approval_pending: 'The GitHub installation is awaiting approval.', + sync_pending: 'Repository synchronization is pending.', + sync_failed: + 'Repository synchronization failed. Refresh the provider and check again.', + repository_unavailable: + 'The requested repository is not available. Check the provider grants and refresh its repositories.', + discovery_unavailable: + 'Source-control tools could not be reached. Check status to retry discovery; reconnecting may not be necessary.', + forbidden: 'The requester no longer has access.', + ready: 'Access was verified. The original Session continuation is queued.', +}; + +export function SessionConnectionRequest({ sessionId }: { sessionId: string }) { + const trpc = useTRPC(); + const search = useSearchParams(); + const requestId = search.get('connectionRequest') ?? undefined; + const [open, setOpen] = useState(false); + const query = useQuery({ + ...trpc.sourceControl.connectionRequest.queryOptions({ + sessionId, + requestId, + }), + refetchInterval: 5000, + }); + const options = { + onSuccess: () => { + void query.refetch(); + }, + onError: () => toast.error('Unable to update this connection request.'), + }; + const check = useMutation( + trpc.sourceControl.checkConnectionRequest.mutationOptions(options), + ); + const cancel = useMutation( + trpc.sourceControl.cancelConnectionRequest.mutationOptions(options), + ); + if (query.isError) + return

Unable to load the connection request.

; + if (query.isPending && requestId) + return ( + + + + + + + + + + + ); + const request = query.data; + if (!request) + return requestId && !query.isPending ? ( +

+ This connection request is not available in this Session. +

+ ) : null; + const pending = request.status === 'pending'; + return ( + + + Source-control access + + + {request.enabled === false ? ( +

+ Session connection requests are disabled. Source control can still + be configured in settings; this request will not continue + automatically. +

+ ) : null} + {request.provider && search.get(request.provider) === 'error' ? ( +

+ Authorization or synchronization failed. Check the provider + configuration and try again. +

+ ) : null} +

+ {request.repositoryFullName ?? + request.environmentId ?? + request.provider} +

+

+ {pending + ? (reasons[request.reason] ?? 'Access is not ready.') + : request.status === 'ready' + ? reasons.ready + : `Connection request ${request.status}.`} +

+ {pending && request.enabled !== false && !request.canConnect ? ( +

+ A deployment admin can connect source control using this Session + link. +

+ ) : null} +

+ Cancelling this request does not remove the deployment connection. +

+ {pending && + request.canConnect && + request.reason === 'approval_pending' ? ( + { + void query.refetch(); + }} + /> + ) : null} +
+ + {request.canConnect ? ( + + ) : null} + {request.canCheck ? ( + + ) : null} + {request.canCancel ? ( + + ) : null} + + {request.canConnect ? ( + + + + Connect source control + + Configure access for the repository requested by this Session. + + + + + + ) : null} +
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 0d83c3fad8..e3a1ef84d5 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -44,6 +44,9 @@ vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn() }), useSearchParams: () => new URLSearchParams(), + redirect: (target: string) => { + throw new Error(`NEXT_REDIRECT:${target}`); + }, notFound: () => { throw new Error('NEXT_NOT_FOUND'); }, @@ -108,6 +111,20 @@ vi.mock('@/components/sessions/SessionViewers', () => ({ import SessionDetailPage, { generateMetadata } from './page'; +it('preserves a connection request when an unauthenticated visitor opens its Session', async () => { + authorizeMock.mockResolvedValue({ success: false }); + const sessionId = '11111111-1111-4111-8111-111111111111'; + const connectionRequest = '22222222-2222-4222-8222-222222222222'; + await expect( + SessionDetailPage({ + params: Promise.resolve({ sessionId }), + searchParams: Promise.resolve({ connectionRequest }), + }), + ).rejects.toThrow( + `NEXT_REDIRECT:/sign-in?${new URLSearchParams({ redirect_url: `/sessions/${sessionId}?connectionRequest=${connectionRequest}` })}`, + ); +}); + describe('Session detail page', () => { beforeEach(() => { vi.clearAllMocks(); @@ -367,9 +384,7 @@ describe('Session detail page', () => { expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty( 'initialConversationResponding', ); - expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'timelineExtras', - ); + expect(transcriptMock.mock.calls[0]?.[0]).toHaveProperty('timelineExtras'); expect(transcriptMock.mock.calls[0]?.[0]).toHaveProperty('headerExtras'); const headerExtras = transcriptMock.mock.calls[0]?.[0].headerExtras; expect(isValidElement(headerExtras)).toBe(true); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index 7a50055c66..ef35e07628 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -1,6 +1,6 @@ import { cache } from 'react'; import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import { z } from 'zod'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; @@ -30,6 +30,7 @@ import { type SessionInfo, } from './SessionWorkspace'; import { SessionReadTracker } from './SessionReadTracker'; +import { SessionConnectionRequest } from './SessionConnectionRequest'; import { SetupAutomationRecommendationsCard } from './setup/SetupAutomationRecommendationsCard'; import { SetupSandboxCard } from './setup/SetupSandboxCard'; import { SetupSessionSourceControlCard } from './setup/SetupSourceControlCard'; @@ -38,46 +39,57 @@ import { SESSION_HEADER_TITLE_CLASS_NAME, } from './session-header-layout'; -const getSessionPageData = cache(async (sessionId: string) => { - const authorizedUser = await authorize(); - if (!authorizedUser.success) { - notFound(); - } - // Both lookup columns are uuid; a garbage route param would otherwise throw - // 22P02 in Postgres instead of 404ing. - if (!z.string().uuid().safeParse(sessionId).success) { - notFound(); - } +const getSessionPageData = cache( + async (sessionId: string, connectionRequest?: string) => { + const authorizedUser = await authorize(); + if (!authorizedUser.success) { + const target = `/sessions/${encodeURIComponent(sessionId)}${connectionRequest ? `?connectionRequest=${encodeURIComponent(connectionRequest)}` : ''}`; + redirect(`/sign-in?${new URLSearchParams({ redirect_url: target })}`); + } + // Both lookup columns are uuid; a garbage route param would otherwise throw + // 22P02 in Postgres instead of 404ing. + if (!z.string().uuid().safeParse(sessionId).success) { + notFound(); + } - // Old links may carry a fast-conversation id whose session row hasn't been - // backfilled yet; getSessionByIdCommand falls back by fastConversationId, - // and the fast lookup below covers a conversation with no session row. - const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); - const session = unifiedSession?.fastConversationId - ? await getFastSessionById( - authorizedUser, - unifiedSession.fastConversationId, - ) - : unifiedSession - ? null - : await getFastSessionById(authorizedUser, sessionId); + // Old links may carry a fast-conversation id whose session row hasn't been + // backfilled yet; getSessionByIdCommand falls back by fastConversationId, + // and the fast lookup below covers a conversation with no session row. + const unifiedSession = await getSessionByIdCommand( + authorizedUser, + sessionId, + ); + const session = unifiedSession?.fastConversationId + ? await getFastSessionById( + authorizedUser, + unifiedSession.fastConversationId, + ) + : unifiedSession + ? null + : await getFastSessionById(authorizedUser, sessionId); - if (!unifiedSession && !session) { - notFound(); - } + if (!unifiedSession && !session) { + notFound(); + } - return { authorizedUser, unifiedSession, session }; -}); + return { authorizedUser, unifiedSession, session }; + }, +); type SessionDetailPageProps = { params: Promise<{ sessionId: string }>; + searchParams?: Promise<{ connectionRequest?: string }>; }; export async function generateMetadata({ params, + searchParams, }: SessionDetailPageProps): Promise { const { sessionId } = await params; - const { unifiedSession, session } = await getSessionPageData(sessionId); + const { unifiedSession, session } = await getSessionPageData( + sessionId, + (await searchParams)?.connectionRequest, + ); const initialUserMessage = session?.messages.find( (message) => message.role === 'user', ); @@ -93,10 +105,13 @@ export async function generateMetadata({ export default async function SessionDetailPage({ params, + searchParams, }: SessionDetailPageProps) { const { sessionId } = await params; - const { authorizedUser, unifiedSession, session } = - await getSessionPageData(sessionId); + const { authorizedUser, unifiedSession, session } = await getSessionPageData( + sessionId, + (await searchParams)?.connectionRequest, + ); // The chip's "default" must reflect what Fast actually runs with: the // deployment's orchestration model, not the task launch default. const modelEnv: Record = @@ -180,9 +195,12 @@ export default async function SessionDetailPage({ headerActions={ } - {...(isSetupSession - ? { timelineExtras: setupTimelineExtras } - : {})} + timelineExtras={ + <> + + {setupTimelineExtras} + + } /> @@ -262,6 +280,7 @@ export default async function SessionDetailPage({ sessionReasoningEffort={session.reasoningEffort} defaultModelId={defaultModelId} defaultReasoningEffort={defaultReasoningEffort} + timelineExtras={} {...(session.userId ? { owner: { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx new file mode 100644 index 0000000000..f52e89057d --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.client.test.tsx @@ -0,0 +1,188 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +const { state } = vi.hoisted(() => ({ + state: { + optionalSourceControlEnabled: true, + repositoryCount: 0, + sourceControlSkipped: false, + skipFails: false, + invalidateQueries: vi.fn(), + }, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + setupNew: { + status: { queryOptions: () => ({}), queryKey: () => ['setup-status'] }, + saveSourceControlProviderChoice: { mutationOptions: () => ({}) }, + }, + setup: { + skipSourceControl: { + mutationOptions: (options: object) => ({ ...options, skip: true }), + }, + }, + }), +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ + data: { + optionalSourceControlEnabled: state.optionalSourceControlEnabled, + sourceControlSkipped: state.sourceControlSkipped, + setupNewState: { sourceControlProvider: null }, + sourceControlSetup: { + preselectedProvider: 'github', + providers: [ + { + provider: 'github', + label: 'GitHub', + connected: state.repositoryCount > 0, + repositoryCount: state.repositoryCount, + }, + ], + }, + }, + }), + useMutation: (options: { + skip?: boolean; + onSuccess?: () => void; + onError?: (error: Error) => void; + }) => ({ + mutate: () => { + if (!options.skip) return; + if (state.skipFails) { + options.onError?.(new Error('Skip failed')); + } else { + state.sourceControlSkipped = true; + options.onSuccess?.(); + } + }, + isPending: false, + }), + useQueryClient: () => ({ invalidateQueries: state.invalidateQueries }), +})); +vi.mock('./source-control-card-stage', () => ({ + getInitialSourceControlCardStage: () => 'provider', +})); +vi.mock('./SourceControlProviderPicker', () => ({ + SourceControlProviderPicker: () =>
Provider picker
, +})); +vi.mock('./SourceControlConfiguration', () => ({ + SourceControlConfiguration: () => null, +})); +vi.mock('./SourceControlConnection', () => ({ + SourceControlConnection: () => null, +})); +vi.mock('./SetupSessionActionCard', () => ({ + SetupSessionActionCardActions: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SetupSessionActionCard: ({ + title, + intro, + children, + }: { + title: string; + intro: string; + children: ReactNode; + }) => ( +
+

{title}

+

{intro}

+ {children} +
+ ), +})); + +import { SetupSessionSourceControlCard } from './SetupSourceControlCard'; + +describe('SetupSessionSourceControlCard', () => { + beforeEach(() => { + state.optionalSourceControlEnabled = true; + state.repositoryCount = 0; + state.sourceControlSkipped = false; + state.skipFails = false; + state.invalidateQueries.mockReset(); + }); + + it('offers to skip source-control setup by default', () => { + render(); + expect( + screen.getByText('Where do you keep your code?'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Provider picker')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Skip for now' }), + ).toBeInTheDocument(); + }); + + it('hides the card when source-control setup is skipped', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + expect(screen.queryByText('Provider picker')).not.toBeInTheDocument(); + }); + + it('keeps the card hidden after a successful skip and a fresh mount', async () => { + const view = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + await waitFor(() => + expect(state.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['setup-status'], + }), + ); + view.unmount(); + render(); + expect(screen.queryByText('Provider picker')).not.toBeInTheDocument(); + }); + + it('does not show the card on a later visit with persisted skipped status', () => { + state.sourceControlSkipped = true; + render(); + expect(screen.queryByText('Provider picker')).not.toBeInTheDocument(); + }); + + it('keeps the card available when saving the skip fails', () => { + state.skipFails = true; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + expect(screen.getByText('Provider picker')).toBeInTheDocument(); + }); + + it('keeps required setup visible when optional source control is disabled', () => { + state.optionalSourceControlEnabled = false; + state.sourceControlSkipped = true; + render(); + expect(screen.getByText('Provider picker')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Skip for now' }), + ).not.toBeInTheDocument(); + }); + + it('offers connection as optional without claiming repository access', () => { + render(); + expect( + screen.getByText( + 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Provider picker')).toBeInTheDocument(); + }); + + it('hides the action once a connected provider has synchronized repositories', () => { + state.optionalSourceControlEnabled = true; + state.repositoryCount = 1; + render(); + expect(screen.queryByText('Provider picker')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx index c40a03108f..c4e5013c32 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/setup/SetupSourceControlCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { @@ -22,7 +22,10 @@ import { GitBranch, } from '@/components/system'; -import { SetupSessionActionCard } from './SetupSessionActionCard'; +import { + SetupSessionActionCard, + SetupSessionActionCardActions, +} from './SetupSessionActionCard'; import { SourceControlConfiguration } from './SourceControlConfiguration'; import { SourceControlConnection } from './SourceControlConnection'; import { SourceControlProviderPicker } from './SourceControlProviderPicker'; @@ -42,13 +45,16 @@ function SetupSessionSourceControlCardBody({ sourceControlSetup, explicitlySelectedProvider, sessionId, + optionalSourceControlEnabled, }: { sourceControlSetup: SetupSourceControlStatus; explicitlySelectedProvider: SourceControlProvider | null; sessionId: string; + optionalSourceControlEnabled: boolean; }) { const trpc = useTRPC(); const queryClient = useQueryClient(); + const router = useRouter(); const searchParams = useSearchParams(); const [stage, setStage] = useState(() => getInitialSourceControlCardStage( @@ -58,6 +64,19 @@ function SetupSessionSourceControlCardBody({ ), ); const [configOpen, setConfigOpen] = useState(false); + const [dismissed, setDismissed] = useState(false); + const skipSourceControl = useMutation( + trpc.setup.skipSourceControl.mutationOptions({ + onSuccess: () => { + setDismissed(true); + void queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + router.refresh(); + }, + onError: (error) => toast.error(error.message), + }), + ); const [activeProvider, setActiveProvider] = useState(null); const saveSourceControlProviderChoice = useMutation( @@ -92,15 +111,17 @@ function SetupSessionSourceControlCardBody({ ? searchParams.get('reason') || `${provider} authorization was cancelled.` : null; + if (dismissed) return null; + const cardTitle = stage === 'provider' - ? 'Connect source control' + ? 'Where do you keep your code?' : stage === 'config' ? `Set up ${providerLabel}` : `Authorize ${providerLabel}`; const cardIntro = stage === 'provider' - ? 'Connect the service that hosts your repositories so I can work on your code.' + ? 'Connect to your source control provider for me to work on your code. You can also do that later in Settings → Source control.' : stage === 'config' ? `Add the ${providerLabel} app credentials. The detailed setup opens in a separate dialog.` : `Give me access to ${providerLabel} and sync the repositories I can work with.`; @@ -117,13 +138,29 @@ function SetupSessionSourceControlCardBody({

) : null} {stage === 'provider' ? ( - - saveSourceControlProviderChoice.mutate({ provider: nextProvider }) - } - disabled={saveSourceControlProviderChoice.isPending} - /> + <> + + saveSourceControlProviderChoice.mutate({ provider: nextProvider }) + } + disabled={saveSourceControlProviderChoice.isPending} + /> + {optionalSourceControlEnabled ? ( + + + + ) : null} + ) : stage === 'config' ? ( <>