diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 153beeb6e..bc7d5738d 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -23,6 +23,7 @@ const { mockDescribeAdoApiError, mockValidateGiteaToken, mockDeleteDeploymentEnvironmentVariables, + mockGetPersistedEnvironmentVariableNames, mockGetPersistedEnvironmentVariableValues, mockDeleteGitLabOAuthConnection, mockDeleteGiteaOAuthConnection, @@ -62,6 +63,7 @@ const { mockDescribeAdoApiError: vi.fn(), mockValidateGiteaToken: vi.fn(), mockDeleteDeploymentEnvironmentVariables: vi.fn(), + mockGetPersistedEnvironmentVariableNames: vi.fn(), mockGetPersistedEnvironmentVariableValues: vi.fn(), mockDeleteGitLabOAuthConnection: vi.fn(), mockDeleteGiteaOAuthConnection: vi.fn(), @@ -186,6 +188,8 @@ vi.mock('../environment-variables', () => ({ mockUpsertDeploymentEnvironmentVariables, deleteDeploymentEnvironmentVariables: mockDeleteDeploymentEnvironmentVariables, + getPersistedEnvironmentVariableNames: + mockGetPersistedEnvironmentVariableNames, getPersistedEnvironmentVariableValues: mockGetPersistedEnvironmentVariableValues, })); @@ -253,6 +257,9 @@ describe('source-control commands', () => { mockEnv.R_PUBLIC_URL = undefined; mockEnv.TRPC_URL = 'http://localhost:3000/trpc'; mockResolveDeploymentEnvVar.mockResolvedValue(null); + mockGetPersistedEnvironmentVariableNames.mockImplementation( + async () => mockPersistedEnvVarNames.names, + ); mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); mockRepositoryRows.rows = []; mockPersistedEnvVarNames.names = [ @@ -478,6 +485,20 @@ describe('source-control commands', () => { expect(mockTransaction).not.toHaveBeenCalled(); }); + it('recognizes persisted credentials stored under a provider alias', async () => { + mockPersistedEnvVarNames.names = ['GITLAB_TOKEN']; + + await expect( + clearSourceControlConfigCommand(buildMockAuth(), { provider: 'gitlab' }), + ).resolves.toMatchObject({ success: true, provider: 'gitlab' }); + + expect(mockDeleteDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['GITLAB_TOKEN']), + ); + expect(mockTransaction).toHaveBeenCalledOnce(); + }); + it('returns OAuth cleanup failures as warnings while removing local configuration', async () => { mockDeleteGitLabOAuthConnection.mockRejectedValueOnce( new Error('OAuth secret deletion failed.'), @@ -498,6 +519,47 @@ describe('source-control commands', () => { expect(mockTransaction).toHaveBeenCalledOnce(); }); + it('aggregates hook and OAuth cleanup warnings before removing local configuration', async () => { + mockRepositoryRows.rows = [ + { + id: 'gitlab-repository-id', + externalRepoId: '42', + fullName: 'acme/project', + permissions: {}, + }, + ]; + mockRemoveGitLabWebhooksForProjects.mockResolvedValue([ + { + repositoryFullName: 'acme/project', + status: 'failed', + error: 'Webhook deletion failed.', + }, + ]); + mockDeleteGitLabOAuthConnection.mockRejectedValueOnce( + new Error('OAuth secret deletion failed.'), + ); + + await expect( + clearSourceControlConfigCommand(buildMockAuth(), { provider: 'gitlab' }), + ).resolves.toEqual({ + success: true, + provider: 'gitlab', + warnings: [ + { + kind: 'webhook_cleanup', + repositoryId: 'gitlab-repository-id', + repositoryFullName: 'acme/project', + message: 'Webhook deletion failed.', + }, + { + kind: 'oauth_cleanup', + message: 'OAuth secret deletion failed.', + }, + ], + }); + expect(mockTransaction).toHaveBeenCalledOnce(); + }); + it('rejects non-admin configuration removal', async () => { await expect( clearSourceControlConfigCommand(buildMockAuth({ isAdmin: false }), { diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 62a5bd058..37635619a 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -7,7 +7,6 @@ import * as GitLab from '@roomote/gitlab'; import { buildSetupSourceControlStatus, getSetupSourceControlProvider, - isLoopbackHostname, NON_SECRET_SOURCE_CONTROL_ENV_VAR_NAMES, type SetupSourceControlProviderStatus, type SetupSourceControlStatus, @@ -16,16 +15,11 @@ import { type SourceControlTokenBackedProvider, } from '@roomote/types'; import { - and, - authAccounts, db, - eq, environmentRepositoryMappings, - environmentVariables, getDeploymentGitHubRoomoteMentionEnabled, getDeploymentPrAction, resolveDeploymentEnvVar, - repositories, setDeploymentPrAction, setDeploymentGitHubRoomoteMentionEnabled, type DatabaseOrTransaction, @@ -34,16 +28,19 @@ import { import type { UserAuthSuccess } from '@/types'; import { getRepositories } from '@/lib/server'; -import { Env } from '@/lib/server/env'; -import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; import { assertAdmin, deleteDeploymentEnvironmentVariables, + getPersistedEnvironmentVariableNames, getPersistedEnvironmentVariableValues, upsertDeploymentEnvironmentVariables, } from '../environment-variables'; -import { disableGitHubAppCommand } from '../github/mutations'; +import { clearSourceControlProviderConfig } from './provider-cleanup'; +import { + getAdoProjectId, + getSourceControlWebhookUrl, +} from './provider-helpers'; export async function getRepositoriesCommand( auth: UserAuthSuccess, @@ -220,24 +217,6 @@ async function configureScopedProviderWebhooks< }; } -function getSourceControlWebhookUrl( - provider: 'gitlab' | 'gitea' | 'bitbucket' | 'ado', -): string | null { - // Match GitHub webhook URL selection: prefer TRPC_URL, but fall back to - // getPublicAppUrl (R_PUBLIC_URL ?? R_APP_URL) when TRPC_URL is loopback so - // self-hosted fleets with a public edge still register reachable webhooks. - const trpcUrl = new URL(Env.TRPC_URL); - const webhookBaseUrl = isLoopbackHostname(trpcUrl.hostname) - ? getPublicAppUrl(Env) - : Env.TRPC_URL; - - if (isLoopbackHostname(new URL(webhookBaseUrl).hostname)) { - return null; - } - - return new URL(`/api/webhooks/${provider}`, webhookBaseUrl).toString(); -} - async function resolveOrCreateGitLabWebhookSecret( actorUserId: string, ): Promise { @@ -445,15 +424,6 @@ async function resolveOrCreateAdoWebhookSecret( return generatedSecret; } -function getAdoProjectId(permissions: unknown): string | null { - if (typeof permissions !== 'object' || permissions === null) { - return null; - } - - const projectId = (permissions as { projectId?: unknown }).projectId; - return typeof projectId === 'string' && projectId.trim() ? projectId : null; -} - async function configureAdoWebhooks( actorUserId: string, repositories: { @@ -657,16 +627,6 @@ export async function setGitHubRoomoteMentionCommand( }; } -async function getPersistedEnvironmentVariableNames( - executor: DatabaseOrTransaction = db, -): Promise { - const envVarRows = await executor - .select({ name: environmentVariables.name }) - .from(environmentVariables); - - return envVarRows.map((envVar) => envVar.name); -} - export async function saveSourceControlConfigValues(params: { executor: DatabaseOrTransaction; actorUserId: string; @@ -993,241 +953,10 @@ export async function saveSourceControlConfigCommand( }); } -type ClearSourceControlConfigWarning = { - kind: 'webhook_cleanup' | 'oauth_cleanup'; - repositoryId?: string; - repositoryFullName?: string; - message: string; -}; - -type ClearConfigRepository = { - id: string; - externalRepoId: string | null; - fullName: string; - permissions: unknown; -}; - -function cleanupWarning( - kind: ClearSourceControlConfigWarning['kind'], - error: unknown, - repository?: Pick, -): ClearSourceControlConfigWarning { - return { - kind, - ...(repository - ? { - repositoryId: repository.id, - repositoryFullName: repository.fullName, - } - : {}), - message: error instanceof Error ? error.message : String(error), - }; -} - -async function removeProviderHooks( - provider: Exclude, - providerRepositories: ClearConfigRepository[], -): Promise { - if (providerRepositories.length === 0) { - return []; - } - - const webhookUrl = getSourceControlWebhookUrl(provider); - if (!webhookUrl) { - return [ - cleanupWarning( - 'webhook_cleanup', - new Error( - 'No publicly reachable Roomote URL is configured, so external hooks could not be removed automatically.', - ), - ), - ]; - } - - try { - const results = await (async () => { - switch (provider) { - case 'gitlab': - return GitLab.removeGitLabWebhooksForProjects({ - projects: providerRepositories.flatMap((repository) => - repository.externalRepoId?.trim() - ? [ - { - projectId: repository.externalRepoId, - repositoryFullName: repository.fullName, - }, - ] - : [], - ), - webhookUrl, - }); - case 'gitea': - return Gitea.removeGiteaWebhooksForRepositories({ - repositories: providerRepositories.map((repository) => ({ - repositoryFullName: repository.fullName, - })), - webhookUrl, - }); - case 'bitbucket': - return Bitbucket.removeBitbucketWebhooksForRepositories({ - repositories: providerRepositories.map((repository) => ({ - repositoryFullName: repository.fullName, - })), - webhookUrl, - }); - case 'ado': - return Ado.removeAdoServiceHooksForRepositories({ - repositories: providerRepositories.flatMap((repository) => { - const repositoryId = repository.externalRepoId?.trim(); - const projectId = getAdoProjectId(repository.permissions); - return repositoryId && projectId - ? [ - { - repositoryFullName: repository.fullName, - repositoryId, - projectId, - }, - ] - : []; - }), - webhookUrl, - }); - } - })(); - - return results.flatMap((result) => { - if (result.status !== 'failed') { - return []; - } - const repository = providerRepositories.find( - (candidate) => candidate.fullName === result.repositoryFullName, - ); - return [ - cleanupWarning( - 'webhook_cleanup', - new Error(result.error ?? 'External hook cleanup failed.'), - repository, - ), - ]; - }); - } catch (error) { - return [cleanupWarning('webhook_cleanup', error)]; - } -} - -async function deleteProviderOAuthConnection( - provider: SourceControlProvider, -): Promise { - try { - switch (provider) { - case 'gitlab': - await GitLab.deleteGitLabOAuthConnection(); - GitLab.clearGitLabDeploymentUserCache(); - break; - case 'gitea': - await Gitea.deleteGiteaOAuthConnection(); - Gitea.clearGiteaDeploymentUserCache(); - break; - case 'bitbucket': - await Bitbucket.deleteBitbucketOAuthConnection(); - Bitbucket.clearBitbucketDeploymentUserCache(); - break; - case 'github': - case 'ado': - break; - } - return []; - } catch (error) { - return [cleanupWarning('oauth_cleanup', error)]; - } -} - export async function clearSourceControlConfigCommand( auth: UserAuthSuccess, input: { provider: SourceControlProvider }, ) { assertAdmin(auth); - - const provider = getSetupSourceControlProvider(input.provider); - const envVarNames = [ - ...new Set(provider.fields.flatMap((field) => field.acceptedEnvVarNames)), - ]; - if (input.provider === 'gitlab') { - envVarNames.push('GITLAB_TOKEN'); - } else if (input.provider === 'gitea') { - envVarNames.push('GITEA_TOKEN'); - } else if (input.provider === 'bitbucket') { - envVarNames.push( - 'BITBUCKET_OAUTH', - 'BITBUCKET_TOKEN', - 'BITBUCKET_USERNAME', - ); - } - - const persistedEnvVarNames = new Set( - await getPersistedEnvironmentVariableNames(), - ); - if (!envVarNames.some((name) => persistedEnvVarNames.has(name))) { - return { - success: true as const, - provider: input.provider, - warnings: [], - }; - } - - const [providerRepositories, persistedValues] = await Promise.all([ - db.query.repositories.findMany({ - where: eq(repositories.sourceControlProvider, input.provider), - columns: { - id: true, - externalRepoId: true, - fullName: true, - permissions: true, - }, - }), - input.provider === 'ado' - ? getPersistedEnvironmentVariableValues(['ADO_LINKED_ACCOUNT_ID']) - : Promise.resolve({} as Record), - ]); - - if (input.provider === 'github') { - const disableResult = await disableGitHubAppCommand(auth); - if (!disableResult.success) { - throw new Error(disableResult.error); - } - } - - const warnings = - input.provider === 'github' - ? [] - : await removeProviderHooks(input.provider, providerRepositories); - warnings.push(...(await deleteProviderOAuthConnection(input.provider))); - - const adoLinkedAccountId = persistedValues['ADO_LINKED_ACCOUNT_ID']; - const now = new Date(); - - await db.transaction(async (tx) => { - await deleteDeploymentEnvironmentVariables(tx, envVarNames); - await tx - .update(repositories) - .set({ isActive: false, updatedAt: now }) - .where(eq(repositories.sourceControlProvider, input.provider)); - - if (input.provider === 'ado' && adoLinkedAccountId) { - await tx - .delete(authAccounts) - .where( - and( - eq(authAccounts.providerId, 'ado'), - eq(authAccounts.accountId, adoLinkedAccountId), - ), - ); - } - }); - - return { - success: true as const, - provider: input.provider, - warnings, - }; + return clearSourceControlProviderConfig(auth, input.provider); } diff --git a/apps/web/src/trpc/commands/source-control/provider-cleanup.ts b/apps/web/src/trpc/commands/source-control/provider-cleanup.ts new file mode 100644 index 000000000..28b89beac --- /dev/null +++ b/apps/web/src/trpc/commands/source-control/provider-cleanup.ts @@ -0,0 +1,312 @@ +import * as Ado from '@roomote/ado'; +import * as Bitbucket from '@roomote/bitbucket'; +import * as Gitea from '@roomote/gitea'; +import * as GitLab from '@roomote/gitlab'; +import { + getSetupSourceControlProvider, + type SourceControlProvider, +} from '@roomote/types'; +import { + and, + authAccounts, + db, + eq, + repositories, + type DatabaseOrTransaction, +} from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { + deleteDeploymentEnvironmentVariables, + getPersistedEnvironmentVariableNames, + getPersistedEnvironmentVariableValues, +} from '../environment-variables'; +import { disableGitHubAppCommand } from '../github/mutations'; +import { + getAdoProjectId, + getSourceControlWebhookUrl, +} from './provider-helpers'; + +type ClearSourceControlConfigWarning = { + kind: 'webhook_cleanup' | 'oauth_cleanup'; + repositoryId?: string; + repositoryFullName?: string; + message: string; +}; + +type ClearConfigRepository = { + id: string; + externalRepoId: string | null; + fullName: string; + permissions: unknown; +}; + +type HookCleanupResult = { + status: string; + repositoryFullName: string; + error?: string; +}; + +type ProviderCleanupState = { + linkedAccountId?: string; +}; + +type ProviderCleanup = { + envVarAliases: readonly string[]; + disconnect?: (auth: UserAuthSuccess) => Promise; + removeHooks?: ( + repositories: ClearConfigRepository[], + webhookUrl: string, + ) => Promise; + removeOAuthConnection?: () => Promise; + loadState?: () => Promise; + clearLocalState?: ( + tx: DatabaseOrTransaction, + state: ProviderCleanupState, + ) => Promise; +}; + +const providerCleanupRegistry: Record = + { + github: { + envVarAliases: [], + disconnect: async (auth) => { + const result = await disableGitHubAppCommand(auth); + if (!result.success) { + throw new Error(result.error); + } + }, + }, + gitlab: { + envVarAliases: ['GITLAB_TOKEN'], + removeHooks: (providerRepositories, webhookUrl) => + GitLab.removeGitLabWebhooksForProjects({ + projects: providerRepositories.flatMap((repository) => + repository.externalRepoId?.trim() + ? [ + { + projectId: repository.externalRepoId, + repositoryFullName: repository.fullName, + }, + ] + : [], + ), + webhookUrl, + }), + removeOAuthConnection: async () => { + await GitLab.deleteGitLabOAuthConnection(); + GitLab.clearGitLabDeploymentUserCache(); + }, + }, + gitea: { + envVarAliases: ['GITEA_TOKEN'], + removeHooks: (providerRepositories, webhookUrl) => + Gitea.removeGiteaWebhooksForRepositories({ + repositories: providerRepositories.map((repository) => ({ + repositoryFullName: repository.fullName, + })), + webhookUrl, + }), + removeOAuthConnection: async () => { + await Gitea.deleteGiteaOAuthConnection(); + Gitea.clearGiteaDeploymentUserCache(); + }, + }, + bitbucket: { + envVarAliases: [ + 'BITBUCKET_OAUTH', + 'BITBUCKET_TOKEN', + 'BITBUCKET_USERNAME', + ], + removeHooks: (providerRepositories, webhookUrl) => + Bitbucket.removeBitbucketWebhooksForRepositories({ + repositories: providerRepositories.map((repository) => ({ + repositoryFullName: repository.fullName, + })), + webhookUrl, + }), + removeOAuthConnection: async () => { + await Bitbucket.deleteBitbucketOAuthConnection(); + Bitbucket.clearBitbucketDeploymentUserCache(); + }, + }, + ado: { + envVarAliases: [], + removeHooks: (providerRepositories, webhookUrl) => + Ado.removeAdoServiceHooksForRepositories({ + repositories: providerRepositories.flatMap((repository) => { + const repositoryId = repository.externalRepoId?.trim(); + const projectId = getAdoProjectId(repository.permissions); + return repositoryId && projectId + ? [ + { + repositoryFullName: repository.fullName, + repositoryId, + projectId, + }, + ] + : []; + }), + webhookUrl, + }), + loadState: async () => { + const values = await getPersistedEnvironmentVariableValues([ + 'ADO_LINKED_ACCOUNT_ID', + ]); + return { linkedAccountId: values['ADO_LINKED_ACCOUNT_ID'] }; + }, + clearLocalState: async (tx, state) => { + if (!state.linkedAccountId) { + return; + } + + await tx + .delete(authAccounts) + .where( + and( + eq(authAccounts.providerId, 'ado'), + eq(authAccounts.accountId, state.linkedAccountId), + ), + ); + }, + }, + }; + +function cleanupWarning( + kind: ClearSourceControlConfigWarning['kind'], + error: unknown, + repository?: Pick, +): ClearSourceControlConfigWarning { + return { + kind, + ...(repository + ? { + repositoryId: repository.id, + repositoryFullName: repository.fullName, + } + : {}), + message: error instanceof Error ? error.message : String(error), + }; +} + +async function removeProviderHooks( + provider: Exclude, + providerCleanup: ProviderCleanup, + providerRepositories: ClearConfigRepository[], +): Promise { + if (!providerCleanup.removeHooks || providerRepositories.length === 0) { + return []; + } + + const webhookUrl = getSourceControlWebhookUrl(provider); + if (!webhookUrl) { + return [ + cleanupWarning( + 'webhook_cleanup', + new Error( + 'No publicly reachable Roomote URL is configured, so external hooks could not be removed automatically.', + ), + ), + ]; + } + + try { + const results = await providerCleanup.removeHooks( + providerRepositories, + webhookUrl, + ); + return results.flatMap((result) => { + if (result.status !== 'failed') { + return []; + } + const repository = providerRepositories.find( + (candidate) => candidate.fullName === result.repositoryFullName, + ); + return [ + cleanupWarning( + 'webhook_cleanup', + new Error(result.error ?? 'External hook cleanup failed.'), + repository, + ), + ]; + }); + } catch (error) { + return [cleanupWarning('webhook_cleanup', error)]; + } +} + +async function removeProviderOAuthConnection( + providerCleanup: ProviderCleanup, +): Promise { + if (!providerCleanup.removeOAuthConnection) { + return []; + } + + try { + await providerCleanup.removeOAuthConnection(); + return []; + } catch (error) { + return [cleanupWarning('oauth_cleanup', error)]; + } +} + +export async function clearSourceControlProviderConfig( + auth: UserAuthSuccess, + provider: SourceControlProvider, +) { + const providerSetup = getSetupSourceControlProvider(provider); + const providerCleanup = providerCleanupRegistry[provider]; + const envVarNames = [ + ...new Set([ + ...providerSetup.fields.flatMap((field) => field.acceptedEnvVarNames), + ...providerCleanup.envVarAliases, + ]), + ]; + + const persistedEnvVarNames = new Set( + await getPersistedEnvironmentVariableNames(), + ); + if (!envVarNames.some((name) => persistedEnvVarNames.has(name))) { + return { success: true as const, provider, warnings: [] }; + } + + const [providerRepositories, state] = await Promise.all([ + db.query.repositories.findMany({ + where: eq(repositories.sourceControlProvider, provider), + columns: { + id: true, + externalRepoId: true, + fullName: true, + permissions: true, + }, + }), + providerCleanup.loadState?.() ?? Promise.resolve({}), + ]); + + await providerCleanup.disconnect?.(auth); + + const warnings: ClearSourceControlConfigWarning[] = []; + if (provider !== 'github') { + warnings.push( + ...(await removeProviderHooks( + provider, + providerCleanup, + providerRepositories, + )), + ); + } + warnings.push(...(await removeProviderOAuthConnection(providerCleanup))); + + const now = new Date(); + await db.transaction(async (tx) => { + await deleteDeploymentEnvironmentVariables(tx, envVarNames); + await tx + .update(repositories) + .set({ isActive: false, updatedAt: now }) + .where(eq(repositories.sourceControlProvider, provider)); + await providerCleanup.clearLocalState?.(tx, state); + }); + + return { success: true as const, provider, warnings }; +} diff --git a/apps/web/src/trpc/commands/source-control/provider-helpers.ts b/apps/web/src/trpc/commands/source-control/provider-helpers.ts new file mode 100644 index 000000000..04a5da1fd --- /dev/null +++ b/apps/web/src/trpc/commands/source-control/provider-helpers.ts @@ -0,0 +1,31 @@ +import { isLoopbackHostname } from '@roomote/types'; + +import { Env } from '@/lib/server/env'; +import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; + +export function getSourceControlWebhookUrl( + provider: 'gitlab' | 'gitea' | 'bitbucket' | 'ado', +): string | null { + // Match GitHub webhook URL selection: prefer TRPC_URL, but fall back to + // getPublicAppUrl (R_PUBLIC_URL ?? R_APP_URL) when TRPC_URL is loopback so + // self-hosted fleets with a public edge still register reachable webhooks. + const trpcUrl = new URL(Env.TRPC_URL); + const webhookBaseUrl = isLoopbackHostname(trpcUrl.hostname) + ? getPublicAppUrl(Env) + : Env.TRPC_URL; + + if (isLoopbackHostname(new URL(webhookBaseUrl).hostname)) { + return null; + } + + return new URL(`/api/webhooks/${provider}`, webhookBaseUrl).toString(); +} + +export function getAdoProjectId(permissions: unknown): string | null { + if (typeof permissions !== 'object' || permissions === null) { + return null; + } + + const projectId = (permissions as { projectId?: unknown }).projectId; + return typeof projectId === 'string' && projectId.trim() ? projectId : null; +}