From 6a7c9d18312beffe18e5103cb8fa4c15d8e790b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:15:47 +0000 Subject: [PATCH 1/5] fix(gitlab): report connect and authentication failures accurately The PAT connect commits the integration and credential before it loads the repository list, so a repository-list refusal used to surface as a failed connection even though the connection was stored. The sync failure is now a warning on the successful result and the existing refresh path fills the cache later, and the settings toast shows the warning. Webhook and project access token calls classified a 401 together with a 403 as a Maintainer-role refusal. A 401 means GitLab rejected the credential, so it now throws its own error kind that webhook sync, project token creation and review preparation route to a reconnect message, while a 403 keeps the role guidance. --- .../integrations/GitLabIntegrationDetails.tsx | 5 +- .../lib/code-reviews/action-required.test.ts | 5 + .../triggers/prepare-review-payload.test.ts | 69 +++++++++ .../triggers/prepare-review-payload.ts | 17 +++ .../gitlab-service.credentials.test.ts | 131 ++++++++++++++++++ .../src/lib/integrations/gitlab-service.ts | 72 ++++++++-- .../platforms/gitlab/adapter.test.ts | 76 ++++++++++ .../integrations/platforms/gitlab/adapter.ts | 105 ++++++++++++-- .../platforms/gitlab/webhook-sync.test.ts | 75 ++++++++++ .../platforms/gitlab/webhook-sync.ts | 6 +- 10 files changed, 533 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.test.ts diff --git a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx index 182d03e1b9b..1ce71e75a57 100644 --- a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx @@ -157,8 +157,9 @@ export function GitLabIntegrationDetails({ // Connect with PAT mutation const { mutate: connectWithPATMutate, isPending: isConnectingWithPAT } = useMutation( trpc.gitlab.connectWithPAT.mutationOptions({ - onSuccess: () => { - toast.success('GitLab connected successfully!'); + onSuccess: result => { + const warning = result.warnings?.join(' '); + toast.success('GitLab connected successfully!', warning ? { description: warning } : {}); // Reset PAT form setPatToken(''); setPATValidation({ status: 'idle' }); diff --git a/apps/web/src/lib/code-reviews/action-required.test.ts b/apps/web/src/lib/code-reviews/action-required.test.ts index 93c00d12b06..d7467f49d63 100644 --- a/apps/web/src/lib/code-reviews/action-required.test.ts +++ b/apps/web/src/lib/code-reviews/action-required.test.ts @@ -129,6 +129,11 @@ describe('classifyCodeReviewActionRequiredFailure', () => { it('does not classify unrelated auth, rate-limit, or BYOK quota failures', () => { expect(classifyCodeReviewActionRequiredFailure('GitHub returned 401 Unauthorized')).toBeNull(); expect(classifyCodeReviewActionRequiredFailure('GitHub returned 403 Forbidden')).toBeNull(); + expect( + classifyCodeReviewActionRequiredFailure( + 'GitLab rejected the connected credential while preparing this review of owner/repo. Reconnect the GitLab integration, then retry.' + ) + ).toBeNull(); expect(classifyCodeReviewActionRequiredFailure('Rate limit exceeded: 429')).toBeNull(); expect( classifyCodeReviewActionRequiredFailure('[BYOK] Your account quota is exhausted.') diff --git a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts index 51ae8382f8e..87d0b5f2ed7 100644 --- a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts +++ b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts @@ -39,6 +39,7 @@ jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ fetchGitLabRootTextFileAtRef: (...args: unknown[]) => mockFetchGitLabRootTextFileAtRef(...args), fetchGitLabRepositorySize: (...args: unknown[]) => mockFetchGitLabRepositorySize(...args), GitLabProjectAccessTokenPermissionError: class GitLabProjectAccessTokenPermissionError extends Error {}, + GitLabAuthenticationError: class GitLabAuthenticationError extends Error {}, })); jest.mock('@/lib/integrations/gitlab-service', () => ({ @@ -75,6 +76,11 @@ import { type User, } from '@kilocode/db/schema'; import { eq, or } from 'drizzle-orm'; +import { TRPCError } from '@trpc/server'; +import { + GitLabAuthenticationError, + GitLabProjectAccessTokenPermissionError, +} from '@/lib/integrations/platforms/gitlab/adapter'; import { prepareReviewPayload } from './prepare-review-payload'; const REPO = `test-org/prepare-review-payload-${Date.now()}`; @@ -725,6 +731,69 @@ describe('prepareReviewPayload', () => { ).rejects.toThrow('is missing its integration'); }); + it('reports a rejected GitLab credential as a reconnect problem', async () => { + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + defineReview(testUser.id, gitlabIntegration.id, { + platform: 'gitlab', + platform_project_id: 456, + pr_url: `https://gitlab.example.com/${REPO}/-/merge_requests/123`, + }) + ) + .returning(); + // The service wraps the adapter refusal, so the cause carries the kind. + mockGetOrCreateProjectAccessToken.mockRejectedValueOnce( + new TRPCError({ + code: 'UNAUTHORIZED', + message: + 'GitLab rejected the connected credential. Reconnect the GitLab integration and try again.', + cause: new GitLabAuthenticationError(456, 401, 'GitLab rejected the credential.'), + }) + ); + + const failure = await prepareReviewPayload({ + reviewId: review.id, + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + agentConfig: { config: baseAgentConfig }, + platform: 'gitlab', + }).catch((error: unknown) => error); + + expect((failure as Error).message).toBe( + `GitLab rejected the connected credential while preparing this review of ${REPO}. Reconnect the GitLab integration, then retry.` + ); + expect((failure as Error).message).not.toContain('Maintainer'); + }); + + it('keeps Maintainer-role guidance when GitLab refuses the role', async () => { + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + defineReview(testUser.id, gitlabIntegration.id, { + platform: 'gitlab', + platform_project_id: 456, + pr_url: `https://gitlab.example.com/${REPO}/-/merge_requests/123`, + }) + ) + .returning(); + mockGetOrCreateProjectAccessToken.mockRejectedValueOnce( + new GitLabProjectAccessTokenPermissionError( + 456, + 403, + 'Insufficient permissions to create Project Access Token for project 456. Requires Maintainer role or higher.' + ) + ); + + await expect( + prepareReviewPayload({ + reviewId: review.id, + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + agentConfig: { config: baseAgentConfig }, + platform: 'gitlab', + }) + ).rejects.toThrow('You need Maintainer role or higher'); + }); + it('prepares a fresh tokenless Bitbucket review from exact organization integration identity', async () => { const [review] = await db .insert(cloud_agent_code_reviews) diff --git a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts index a84933ddb62..c67c950c1db 100644 --- a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts +++ b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts @@ -39,6 +39,7 @@ import { fetchMRInlineComments, getMRHeadCommit, getMRDiffRefs, + GitLabAuthenticationError, GitLabProjectAccessTokenPermissionError, fetchGitLabRootTextFileAtRef, fetchGitLabRepositorySize, @@ -173,6 +174,16 @@ export type CodeReviewPayload = { reviewAgents: ReviewAgentsConfig; }; +/** + * A GitLab credential rejection (HTTP 401), either from the adapter directly or + * wrapped by `getOrCreateProjectAccessToken`. It is not a project-permission + * problem: the integration must be reconnected. + */ +function isGitLabAuthenticationFailure(error: unknown): boolean { + if (error instanceof GitLabAuthenticationError) return true; + return error instanceof Error && error.cause instanceof GitLabAuthenticationError; +} + /** * Prepare complete payload for code review * Does all the heavy lifting: DB queries, token generation, prompt generation @@ -557,6 +568,12 @@ export async function prepareReviewPayload( projectId, }); } catch (pratError) { + if (isGitLabAuthenticationFailure(pratError)) { + throw new Error( + `GitLab rejected the connected credential while preparing this review of ` + + `${review.repo_full_name}. Reconnect the GitLab integration, then retry.` + ); + } if (pratError instanceof GitLabProjectAccessTokenPermissionError) { throw new Error( `Cannot create Project Access Token for GitLab code review. ` + diff --git a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts index 616a3a6e582..2ebad1bf7e2 100644 --- a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts +++ b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts @@ -19,6 +19,10 @@ import { buildGitLabProjectAccessTokenAad, } from '@kilocode/worker-utils/gitlab-credential'; import type * as GitLabAdapter from '@/lib/integrations/platforms/gitlab/adapter'; +import { + GitLabAuthenticationError, + GitLabProjectAccessTokenPermissionError, +} from '@/lib/integrations/platforms/gitlab/adapter'; import type * as GitLabService from './gitlab-service'; import type { fetchGitLabCredential as FetchGitLabCredential } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; @@ -960,4 +964,131 @@ describe('GitLab encrypted credential persistence', () => { expect(disconnectedIntegration.metadata).not.toHaveProperty('access_token'); expect(disconnectedIntegration.metadata).not.toHaveProperty('project_tokens'); }); + + it('keeps a new connection when the repository list cannot be loaded', async () => { + const user = await insertTestUser(); + mockFetchGitLabProjects.mockRejectedValueOnce(new Error('GitLab projects fetch failed: 500')); + + const result = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-primary-token', + 'https://gitlab.com', + user.id + ); + + const [integration] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, result.integration.id)); + const credentials = await db + .select() + .from(platform_access_token_credentials) + .where(eq(platform_access_token_credentials.platform_integration_id, integration.id)); + + expect(result.success).toBe(true); + expect(result.integration.id).toBe(integration.id); + expect(result.warnings).toEqual([ + 'Connected, but the repository list could not be loaded. Refresh the project list from the GitLab integration page.', + ]); + expect(integration.integration_status).toBe('active'); + expect(integration.repositories).toBeNull(); + expect(integration.repositories_synced_at).toBeNull(); + expect(credentials).toHaveLength(1); + }); + + it('keeps an existing connection and its webhook secret when the repository list cannot be loaded', async () => { + const user = await insertTestUser(); + const initial = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-initial-token', + 'https://gitlab.com', + user.id + ); + const [before] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, initial.integration.id)); + mockFetchGitLabProjects.mockRejectedValueOnce(new Error('GitLab projects fetch failed: 502')); + + const result = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-replacement-token', + 'https://gitlab.com', + user.id + ); + + const [after] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, initial.integration.id)); + + expect(result.success).toBe(true); + expect(result.warnings).toEqual([ + 'Connected, but the repository list could not be loaded. Refresh the project list from the GitLab integration page.', + ]); + expect(after.integration_status).toBe('active'); + // The cache from the earlier successful sync survives the failed refresh. + expect(after.repositories).toEqual(before.repositories); + expect(after.repositories_synced_at).toEqual(before.repositories_synced_at); + expect((after.metadata as Record).webhook_secret).toBe( + (before.metadata as Record).webhook_secret + ); + }); + + it('reports a rejected project token creation as a reconnect error', async () => { + const user = await insertTestUser(); + const connected = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-primary-token', + 'https://gitlab.com', + user.id + ); + const [integration] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, connected.integration.id)); + mockCreateProjectAccessToken.mockRejectedValueOnce( + new GitLabAuthenticationError( + 42, + 401, + 'GitLab rejected the credential while creating a Project Access Token for project 42 (HTTP 401). Reconnect the GitLab integration and try again.' + ) + ); + + await expect( + getOrCreateProjectAccessToken(integration, 42, { userId: user.id }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: + 'GitLab rejected the connected credential. Reconnect the GitLab integration and try again.', + }); + }); + + it('reports a refused project token role as a permission error', async () => { + const user = await insertTestUser(); + const connected = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-primary-token', + 'https://gitlab.com', + user.id + ); + const [integration] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, connected.integration.id)); + mockCreateProjectAccessToken.mockRejectedValueOnce( + new GitLabProjectAccessTokenPermissionError( + 42, + 403, + 'Insufficient permissions to create Project Access Token for project 42. Requires Maintainer role or higher.' + ) + ); + + await expect( + getOrCreateProjectAccessToken(integration, 42, { userId: user.id }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: expect.stringContaining('You need Maintainer role or higher'), + }); + }); }); diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index c60becdd090..c094c34afbe 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -25,6 +25,7 @@ import { validatePersonalAccessToken, type GitLabProjectAccessToken, type GitLabPATValidationResult, + GitLabAuthenticationError, GitLabProjectAccessTokenPermissionError, } from '@/lib/integrations/platforms/gitlab/adapter'; import { randomBytes, randomUUID } from 'crypto'; @@ -727,6 +728,14 @@ export async function getOrCreateProjectAccessToken( return newToken.token; } catch (error) { + if (error instanceof GitLabAuthenticationError) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: + 'GitLab rejected the connected credential. Reconnect the GitLab integration and try again.', + cause: error, + }); + } if (error instanceof GitLabProjectAccessTokenPermissionError) { throw new TRPCError({ code: 'FORBIDDEN', @@ -985,6 +994,40 @@ export async function importExistingProjectAccessToken( */ export { validatePersonalAccessToken, type GitLabPATValidationResult }; +const GITLAB_REPOSITORY_SYNC_WARNING = + 'Connected, but the repository list could not be loaded. Refresh the project list from the GitLab integration page.'; + +/** + * Caches the connected account's repositories after the integration row and + * credential are committed. + * + * A provider refusal here says nothing about the connection itself, so it is + * reported as a warning on the successful result instead of failing the + * connect. `repositories_synced_at` stays null, so opening the repository list + * refetches it (see `listGitLabRepositories`). + */ +async function cacheConnectedGitLabRepositories( + integrationId: string, + token: string, + instanceUrl: string +): Promise { + try { + const repos = await fetchGitLabProjects(token, instanceUrl); + await updateRepositoriesForIntegration(integrationId, repos); + logExceptInTest('[connectWithPAT] Repositories cached', { + integrationId, + repoCount: repos.length, + }); + return undefined; + } catch (error) { + logExceptInTest('[connectWithPAT] Repository sync failed after commit', { + integrationId, + error: error instanceof Error ? error.message : String(error), + }); + return GITLAB_REPOSITORY_SYNC_WARNING; + } +} + /** * Connects GitLab using a Personal Access Token * @@ -1204,9 +1247,13 @@ export async function connectWithPAT( : countMetadataObjectEntries(existingMetadata.configured_webhooks), }); - // Fetch and cache repositories - const repos = await fetchGitLabProjects(token, normalizedInstanceUrl); - await updateRepositoriesForIntegration(existingIntegration.id, repos); + // Fetch and cache repositories. The connection is already committed, so a + // repository-list failure is a warning from here on, never a failed connect. + const syncWarning = await cacheConnectedGitLabRepositories( + existingIntegration.id, + token, + normalizedInstanceUrl + ); return { success: true, @@ -1216,7 +1263,7 @@ export async function connectWithPAT( accountId: String(validatedUser.id), instanceUrl: normalizedInstanceUrl, }, - warnings: validation.warnings, + warnings: [...(validation.warnings ?? []), ...(syncWarning ? [syncWarning] : [])], }; } @@ -1296,14 +1343,13 @@ export async function connectWithPAT( authType: 'pat', }); - // 6. Fetch and cache repositories - const repos = await fetchGitLabProjects(token, normalizedInstanceUrl); - await updateRepositoriesForIntegration(integration.id, repos); - - logExceptInTest('[connectWithPAT] Repositories cached', { - integrationId: integration.id, - repoCount: repos.length, - }); + // 6. Fetch and cache repositories. The integration is committed above, so a + // repository-list failure is a warning from here on, never a failed connect. + const syncWarning = await cacheConnectedGitLabRepositories( + integration.id, + token, + normalizedInstanceUrl + ); return { success: true, @@ -1313,6 +1359,6 @@ export async function connectWithPAT( accountId: String(validatedUser.id), instanceUrl: normalizedInstanceUrl, }, - warnings: validation.warnings, + warnings: [...(validation.warnings ?? []), ...(syncWarning ? [syncWarning] : [])], }; } diff --git a/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts b/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts index d87ad3214bb..62b4a86dd45 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts @@ -15,10 +15,19 @@ import { validateGitLabInstance, validatePersonalAccessToken, createProjectWebhook, + createProjectAccessToken, deleteProjectWebhook, fetchGitLabProjects, fetchGitLabRootTextFileAtRef, fetchGitLabRepositorySize, + GitLabAuthenticationError, + GitLabProjectAccessTokenPermissionError, + GitLabWebhookPermissionError, + listProjectAccessTokens, + listProjectWebhooks, + revokeProjectAccessToken, + rotateProjectAccessToken, + updateProjectWebhook, } from './adapter'; // Mock fetch globally @@ -681,3 +690,70 @@ describe('validatePersonalAccessToken', () => { expect(mockFetch).not.toHaveBeenCalled(); }); }); + +describe('webhook and project access token refusals', () => { + const webhookOperations = [ + ['listProjectWebhooks', () => listProjectWebhooks('token', 123)], + [ + 'createProjectWebhook', + () => createProjectWebhook('token', 123, 'https://example.com/webhook', 'webhook-secret'), + ], + [ + 'updateProjectWebhook', + () => updateProjectWebhook('token', 123, 456, 'https://example.com/webhook', 'secret'), + ], + ] as const; + + const projectAccessTokenOperations = [ + [ + 'createProjectAccessToken', + () => createProjectAccessToken('token', 123, 'Kilo bot', '2027-01-01'), + ], + ['listProjectAccessTokens', () => listProjectAccessTokens('token', 123)], + ['rotateProjectAccessToken', () => rotateProjectAccessToken('token', 123, 456, '2027-01-01')], + ['revokeProjectAccessToken', () => revokeProjectAccessToken('token', 123, 456)], + ] as const; + + const allOperations = [...webhookOperations, ...projectAccessTokenOperations] as const; + + function mockRefusal(status: number) { + mockFetch.mockResolvedValueOnce({ + ok: false, + status, + headers: new Headers(), + text: async () => `refused: ${status}`, + json: async () => ({}), + }); + } + + beforeEach(() => { + mockFetch.mockReset(); + }); + + it.each(allOperations)( + '%s reports a 401 as an authentication failure that needs a reconnect', + async (_name, run) => { + mockRefusal(401); + + const refusal = await run().catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(GitLabAuthenticationError); + expect(refusal).toMatchObject({ statusCode: 401, projectId: 123 }); + expect((refusal as Error).message).toContain('Reconnect the GitLab integration'); + expect((refusal as Error).message).not.toContain('Maintainer'); + } + ); + + it.each(allOperations)('%s keeps a 403 as a permission failure', async (name, run) => { + const expectedError = webhookOperations.some(([operation]) => operation === name) + ? GitLabWebhookPermissionError + : GitLabProjectAccessTokenPermissionError; + mockRefusal(403); + + const refusal = await run().catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(expectedError); + expect(refusal).toMatchObject({ statusCode: 403, projectId: 123 }); + expect((refusal as Error).message).toContain('Requires Maintainer role or higher'); + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/gitlab/adapter.ts b/apps/web/src/lib/integrations/platforms/gitlab/adapter.ts index 7d3000d878c..7ae7a3fdb07 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/adapter.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/adapter.ts @@ -573,6 +573,22 @@ export function verifyGitLabWebhookToken(token: string, expectedToken?: string): } } +/** + * GitLab rejected the credential itself (HTTP 401), before any role or + * permission check. Callers must send the user to reconnect the integration; + * role guidance cannot fix it. + */ +export class GitLabAuthenticationError extends Error { + constructor( + public projectId: string | number, + readonly statusCode: number, + message: string + ) { + super(message); + this.name = 'GitLabAuthenticationError'; + } +} + /** * Custom error class for webhook permission issues * Thrown when user doesn't have Maintainer+ role on a project @@ -617,7 +633,8 @@ export type GitLabWebhook = { * @param accessToken - OAuth access token * @param projectId - GitLab project ID or path (URL-encoded) * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) - * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project (HTTP 403) */ export async function listProjectWebhooks( accessToken: string, @@ -644,7 +661,15 @@ export async function listProjectWebhooks( projectId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while listing webhooks for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabWebhookPermissionError( projectId, response.status, @@ -666,7 +691,8 @@ export async function listProjectWebhooks( * @param webhookUrl - URL to receive webhook events * @param webhookSecret - Secret token for webhook verification * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) - * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project (HTTP 403) */ export async function createProjectWebhook( accessToken: string, @@ -716,7 +742,15 @@ export async function createProjectWebhook( projectId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while creating a webhook for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabWebhookPermissionError( projectId, response.status, @@ -747,7 +781,8 @@ export async function createProjectWebhook( * @param webhookUrl - URL to receive webhook events * @param webhookSecret - Secret token for webhook verification * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) - * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabWebhookPermissionError} When user doesn't have Maintainer+ role on the project (HTTP 403) */ export async function updateProjectWebhook( accessToken: string, @@ -799,7 +834,15 @@ export async function updateProjectWebhook( hookId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while updating a webhook for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabWebhookPermissionError( projectId, response.status, @@ -1740,7 +1783,8 @@ export class GitLabProjectAccessTokenPermissionError extends Error { * @param scopes - Token scopes (default: ['api', 'self_rotate']) * @param accessLevel - Access level for the token (default: DEVELOPER = 30) * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) - * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role (HTTP 403) * * @see https://docs.gitlab.com/ee/api/project_access_tokens.html#create-a-project-access-token */ @@ -1781,7 +1825,15 @@ export async function createProjectAccessToken( projectId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while creating a Project Access Token for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabProjectAccessTokenPermissionError( projectId, response.status, @@ -1819,7 +1871,8 @@ export async function createProjectAccessToken( * @param accessToken - OAuth access token (requires Maintainer+ role) * @param projectId - GitLab project ID or path (URL-encoded) * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) - * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role (HTTP 403) * * @see https://docs.gitlab.com/ee/api/project_access_tokens.html#list-project-access-tokens */ @@ -1848,7 +1901,15 @@ export async function listProjectAccessTokens( projectId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while listing Project Access Tokens for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabProjectAccessTokenPermissionError( projectId, response.status, @@ -1931,6 +1992,8 @@ export async function getProjectAccessToken( * @param tokenId - ID of the Project Access Token to rotate * @param expiresAt - New expiration date in YYYY-MM-DD format (max 1 year from now) * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role (HTTP 403) * * @see https://docs.gitlab.com/ee/api/project_access_tokens.html#rotate-a-project-access-token */ @@ -1970,7 +2033,15 @@ export async function rotateProjectAccessToken( tokenId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while rotating a Project Access Token for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabProjectAccessTokenPermissionError( projectId, response.status, @@ -2005,6 +2076,8 @@ export async function rotateProjectAccessToken( * @param projectId - GitLab project ID or path (URL-encoded) * @param tokenId - ID of the Project Access Token to revoke * @param instanceUrl - GitLab instance URL (defaults to gitlab.com) + * @throws {GitLabAuthenticationError} When GitLab rejects the credential (HTTP 401) + * @throws {GitLabProjectAccessTokenPermissionError} When user doesn't have Maintainer+ role (HTTP 403) * * @see https://docs.gitlab.com/ee/api/project_access_tokens.html#revoke-a-project-access-token */ @@ -2037,7 +2110,15 @@ export async function revokeProjectAccessToken( tokenId, }); - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { + throw new GitLabAuthenticationError( + projectId, + 401, + `GitLab rejected the credential while revoking a Project Access Token for project ${projectId} (HTTP 401). Reconnect the GitLab integration and try again.` + ); + } + + if (response.status === 403) { throw new GitLabProjectAccessTokenPermissionError( projectId, response.status, diff --git a/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.test.ts b/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.test.ts new file mode 100644 index 00000000000..29864b61a29 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.test.ts @@ -0,0 +1,75 @@ +import { + createProjectWebhook, + findKiloWebhook, + GitLabAuthenticationError, + GitLabWebhookPermissionError, +} from './adapter'; +import { syncWebhooksForRepositories } from './webhook-sync'; + +jest.mock('./adapter', () => ({ + ...jest.requireActual('./adapter'), + findKiloWebhook: jest.fn(), + createProjectWebhook: jest.fn(), + updateProjectWebhook: jest.fn(), + deleteProjectWebhook: jest.fn(), +})); + +const mockFindKiloWebhook = jest.mocked(findKiloWebhook); +const mockCreateProjectWebhook = jest.mocked(createProjectWebhook); + +describe('syncWebhooksForRepositories', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFindKiloWebhook.mockResolvedValue(null); + }); + + it('asks the user to reconnect when GitLab rejects the credential', async () => { + mockCreateProjectWebhook.mockRejectedValueOnce( + new GitLabAuthenticationError( + 123, + 401, + 'GitLab rejected the credential while creating a webhook for project 123 (HTTP 401). Reconnect the GitLab integration and try again.' + ) + ); + + const { result, updatedWebhooks } = await syncWebhooksForRepositories( + 'token', + 'webhook-secret', + [123], + [], + {} + ); + + expect(result.created).toEqual([]); + expect(result.errors).toEqual([ + { + projectId: 123, + operation: 'create', + error: + 'GitLab rejected the connected credential: reconnect the GitLab integration, then retry. You can still configure the webhook manually in GitLab.', + }, + ]); + expect(updatedWebhooks).toEqual({}); + }); + + it('keeps Maintainer-role guidance when GitLab refuses the role', async () => { + mockCreateProjectWebhook.mockRejectedValueOnce( + new GitLabWebhookPermissionError( + 123, + 403, + 'Insufficient permissions to create webhook for project 123. Requires Maintainer role or higher.' + ) + ); + + const { result } = await syncWebhooksForRepositories('token', 'webhook-secret', [123], [], {}); + + expect(result.errors).toEqual([ + { + projectId: 123, + operation: 'create', + error: + 'Permission denied: You need Maintainer role or higher on this project to configure webhooks automatically. You can still configure the webhook manually in GitLab.', + }, + ]); + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.ts b/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.ts index ad3bb163385..d511b956f38 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/webhook-sync.ts @@ -5,6 +5,7 @@ import { deleteProjectWebhook, findKiloWebhook, updateProjectWebhook, + GitLabAuthenticationError, GitLabWebhookPermissionError, } from './adapter'; @@ -123,7 +124,9 @@ export async function syncWebhooksForRepositories( } } catch (error) { let errorMessage: string; - if (error instanceof GitLabWebhookPermissionError) { + if (error instanceof GitLabAuthenticationError) { + errorMessage = `GitLab rejected the connected credential: reconnect the GitLab integration, then retry. You can still configure the webhook manually in GitLab.`; + } else if (error instanceof GitLabWebhookPermissionError) { errorMessage = `Permission denied: You need Maintainer role or higher on this project to configure webhooks automatically. You can still configure the webhook manually in GitLab.`; } else { errorMessage = error instanceof Error ? error.message : String(error); @@ -139,6 +142,7 @@ export async function syncWebhooksForRepositories( projectId, error: errorMessage, isPermissionError: error instanceof GitLabWebhookPermissionError, + isAuthenticationError: error instanceof GitLabAuthenticationError, }); } } From 26d47b46e28e00ba65be8bfb12876be0c30f8828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:28:40 +0000 Subject: [PATCH 2/5] fix(gitlab): refresh the settings view in place after a PAT connect The document reload discarded the connect toast before it could be read. Refresh the installation query instead, so the success message with its warning stays visible and the connected view replaces the form. --- .../components/integrations/GitLabIntegrationDetails.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx index 1ce71e75a57..4dcb7c6ecbe 100644 --- a/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/GitLabIntegrationDetails.tsx @@ -164,8 +164,11 @@ export function GitLabIntegrationDetails({ setPatToken(''); setPATValidation({ status: 'idle' }); setConnectionMethod('oauth'); - // Reload the page to refresh the installation data - window.location.reload(); + // Refresh the installation in place. A reload here would replace the + // document and discard the toast before it can be read. + void queryClient.invalidateQueries({ + queryKey: trpc.gitlab.getInstallation.queryKey(input), + }); }, onError: err => { toast.error('Failed to connect', { description: err.message }); From 243247991dacd1f8f8002c32b47e0ec7f8734045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:50:18 +0000 Subject: [PATCH 3/5] fix(gitlab): route broker reconnect refusals to the reconnect message A credential-broker reconnect refusal is the same credential problem as a provider 401, so review preparation now treats both through one predicate: the credential is unusable until the owner reconnects, and role guidance cannot fix it. A failed repository sync also drops the cached list when the connect moved to a different GitLab instance, so projects from the previous instance are not shown against the new credential. --- .../triggers/prepare-review-payload.test.ts | 28 ++++++++++++++ .../triggers/prepare-review-payload.ts | 14 +------ .../gitlab-service.credentials.test.ts | 38 +++++++++++++++++++ .../src/lib/integrations/gitlab-service.ts | 35 ++++++++++++++--- .../platforms/gitlab/credential-errors.ts | 26 +++++++++++++ 5 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/lib/integrations/platforms/gitlab/credential-errors.ts diff --git a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts index 87d0b5f2ed7..fde65680cce 100644 --- a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts +++ b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.test.ts @@ -765,6 +765,34 @@ describe('prepareReviewPayload', () => { expect((failure as Error).message).not.toContain('Maintainer'); }); + it('reports a broker reconnect refusal as a reconnect problem', async () => { + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + defineReview(testUser.id, gitlabIntegration.id, { + platform: 'gitlab', + platform_project_id: 456, + pr_url: `https://gitlab.example.com/${REPO}/-/merge_requests/123`, + }) + ) + .returning(); + mockGetOrCreateProjectAccessToken.mockRejectedValueOnce( + new TRPCError({ + code: 'UNAUTHORIZED', + message: 'GitLab integration must be reconnected', + }) + ); + + await expect( + prepareReviewPayload({ + reviewId: review.id, + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + agentConfig: { config: baseAgentConfig }, + platform: 'gitlab', + }) + ).rejects.toThrow('Reconnect the GitLab integration, then retry.'); + }); + it('keeps Maintainer-role guidance when GitLab refuses the role', async () => { const [review] = await db .insert(cloud_agent_code_reviews) diff --git a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts index c67c950c1db..040e4a03b94 100644 --- a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts +++ b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts @@ -39,7 +39,6 @@ import { fetchMRInlineComments, getMRHeadCommit, getMRDiffRefs, - GitLabAuthenticationError, GitLabProjectAccessTokenPermissionError, fetchGitLabRootTextFileAtRef, fetchGitLabRepositorySize, @@ -48,6 +47,7 @@ import { getOrCreateProjectAccessToken, type GitLabIntegrationMetadata, } from '@/lib/integrations/gitlab-service'; +import { isGitLabReconnectRequiredError } from '@/lib/integrations/platforms/gitlab/credential-errors'; import type { ExistingReviewState, PreviousReviewStatus, @@ -174,16 +174,6 @@ export type CodeReviewPayload = { reviewAgents: ReviewAgentsConfig; }; -/** - * A GitLab credential rejection (HTTP 401), either from the adapter directly or - * wrapped by `getOrCreateProjectAccessToken`. It is not a project-permission - * problem: the integration must be reconnected. - */ -function isGitLabAuthenticationFailure(error: unknown): boolean { - if (error instanceof GitLabAuthenticationError) return true; - return error instanceof Error && error.cause instanceof GitLabAuthenticationError; -} - /** * Prepare complete payload for code review * Does all the heavy lifting: DB queries, token generation, prompt generation @@ -568,7 +558,7 @@ export async function prepareReviewPayload( projectId, }); } catch (pratError) { - if (isGitLabAuthenticationFailure(pratError)) { + if (isGitLabReconnectRequiredError(pratError)) { throw new Error( `GitLab rejected the connected credential while preparing this review of ` + `${review.repo_full_name}. Reconnect the GitLab integration, then retry.` diff --git a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts index 2ebad1bf7e2..65769d05b4f 100644 --- a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts +++ b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts @@ -1035,6 +1035,44 @@ describe('GitLab encrypted credential persistence', () => { ); }); + it('discards the previous instance cache when the repository list cannot be loaded', async () => { + const user = await insertTestUser(); + mockFetchGitLabProjects.mockResolvedValueOnce([ + { id: 123, name: 'repo', full_name: 'group/sub/repo', private: true }, + ]); + const initial = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-initial-token', + 'https://gitlab.com', + user.id + ); + const [before] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, initial.integration.id)); + mockFetchGitLabProjects.mockRejectedValueOnce(new Error('GitLab projects fetch failed: 500')); + + const result = await connectWithPAT( + { type: 'user', id: user.id }, + 'glpat-replacement-token', + 'https://gitlab.example.com', + user.id + ); + + const [after] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, initial.integration.id)); + + expect(before.repositories).toHaveLength(1); + expect(result.success).toBe(true); + expect(result.warnings).toEqual([ + 'Connected, but the repository list could not be loaded. Refresh the project list from the GitLab integration page.', + ]); + expect(after.repositories).toBeNull(); + expect(after.repositories_synced_at).toBeNull(); + }); + it('reports a rejected project token creation as a reconnect error', async () => { const user = await insertTestUser(); const connected = await connectWithPAT( diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index c094c34afbe..f76dc2c2bbd 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -35,6 +35,7 @@ import { GitLabInstanceUrlError, normalizeGitLabInstanceUrl, } from '@/lib/integrations/platforms/gitlab/instance-url'; +import { GITLAB_RECONNECT_REQUIRED_MESSAGE } from '@/lib/integrations/platforms/gitlab/credential-errors'; import { mutateGitLabMetadataInTransaction, readGitLabMetadataInTransaction, @@ -231,7 +232,7 @@ function requireAvailableGitLabCredential( case 'reconnect_required': throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'GitLab integration must be reconnected', + message: GITLAB_RECONNECT_REQUIRED_MESSAGE, }); case 'temporarily_unavailable': throw new TRPCError({ @@ -1003,13 +1004,19 @@ const GITLAB_REPOSITORY_SYNC_WARNING = * * A provider refusal here says nothing about the connection itself, so it is * reported as a warning on the successful result instead of failing the - * connect. `repositories_synced_at` stays null, so opening the repository list - * refetches it (see `listGitLabRepositories`). + * connect. The cache then keeps its last successful sync, or stays empty for a + * new connection, so opening the repository list refetches it (see + * `listGitLabRepositories`). + * + * `discardOnFailure` is for an instance change: the cached rows belong to the + * previous GitLab instance, so they are dropped rather than shown against a + * credential that cannot reach them. */ async function cacheConnectedGitLabRepositories( integrationId: string, token: string, - instanceUrl: string + instanceUrl: string, + discardOnFailure: boolean ): Promise { try { const repos = await fetchGitLabProjects(token, instanceUrl); @@ -1023,7 +1030,21 @@ async function cacheConnectedGitLabRepositories( logExceptInTest('[connectWithPAT] Repository sync failed after commit', { integrationId, error: error instanceof Error ? error.message : String(error), + discardOnFailure, }); + if (discardOnFailure) { + try { + await db + .update(platform_integrations) + .set({ repositories: null, repositories_synced_at: null }) + .where(eq(platform_integrations.id, integrationId)); + } catch (clearError) { + logExceptInTest('[connectWithPAT] Failed to discard stale repositories', { + integrationId, + error: clearError instanceof Error ? clearError.message : String(clearError), + }); + } + } return GITLAB_REPOSITORY_SYNC_WARNING; } } @@ -1252,7 +1273,8 @@ export async function connectWithPAT( const syncWarning = await cacheConnectedGitLabRepositories( existingIntegration.id, token, - normalizedInstanceUrl + normalizedInstanceUrl, + isInstanceChange ); return { @@ -1348,7 +1370,8 @@ export async function connectWithPAT( const syncWarning = await cacheConnectedGitLabRepositories( integration.id, token, - normalizedInstanceUrl + normalizedInstanceUrl, + false ); return { diff --git a/apps/web/src/lib/integrations/platforms/gitlab/credential-errors.ts b/apps/web/src/lib/integrations/platforms/gitlab/credential-errors.ts new file mode 100644 index 00000000000..2bd5807b154 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/gitlab/credential-errors.ts @@ -0,0 +1,26 @@ +import { TRPCError } from '@trpc/server'; +import { GitLabAuthenticationError } from './adapter'; + +/** + * The credential broker's definitive refusal: the stored integration credential + * cannot be used until the owner reconnects. + */ +export const GITLAB_RECONNECT_REQUIRED_MESSAGE = 'GitLab integration must be reconnected'; + +/** + * True when the error says the stored GitLab credential cannot be used as-is: + * a provider 401 (directly, or wrapped by a service call) or the credential + * broker's definitive reconnect refusal. Role guidance cannot fix either case, + * so callers route them to a reconnect message. + */ +export function isGitLabReconnectRequiredError(error: unknown): boolean { + if (error instanceof GitLabAuthenticationError) return true; + if (error instanceof TRPCError) { + if (error.code !== 'UNAUTHORIZED') return false; + return ( + error.message === GITLAB_RECONNECT_REQUIRED_MESSAGE || + error.cause instanceof GitLabAuthenticationError + ); + } + return error instanceof Error && error.cause instanceof GitLabAuthenticationError; +} From 8c441ac405d728303ed748fb5c4389f6b0304d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:56:39 +0000 Subject: [PATCH 4/5] docs(gitlab): state the repository cache behaviour after a failed sync A same-instance reconnect keeps its last list until the user refreshes it; only an empty cache is refetched on the next read. --- apps/web/src/lib/integrations/gitlab-service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index f76dc2c2bbd..2140f7808ba 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -1004,9 +1004,10 @@ const GITLAB_REPOSITORY_SYNC_WARNING = * * A provider refusal here says nothing about the connection itself, so it is * reported as a warning on the successful result instead of failing the - * connect. The cache then keeps its last successful sync, or stays empty for a - * new connection, so opening the repository list refetches it (see - * `listGitLabRepositories`). + * connect. A new connection, or one whose previous list was dropped, then has + * an empty cache, so the next repository-list read refetches it (see + * `listGitLabRepositories`); a same-instance reconnect keeps its last + * successful list until the user refreshes it. * * `discardOnFailure` is for an instance change: the cached rows belong to the * previous GitLab instance, so they are dropped rather than shown against a From 7ba7613917edc43d9757d2098f2396e6d4a616ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:45:18 +0000 Subject: [PATCH 5/5] fix(gitlab): restore credential health during PAT reconnect --- .../gitlab-service.credentials.test.ts | 47 +++++++++++++++++++ .../src/lib/integrations/gitlab-service.ts | 4 ++ 2 files changed, 51 insertions(+) diff --git a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts index 65769d05b4f..5e4caa50ad9 100644 --- a/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts +++ b/apps/web/src/lib/integrations/gitlab-service.credentials.test.ts @@ -1035,6 +1035,53 @@ describe('GitLab encrypted credential persistence', () => { ); }); + it('restores credential health on reconnect even when repository sync fails', async () => { + const user = await insertTestUser(); + const owner = { type: 'user', id: user.id } as const; + const initial = await connectWithPAT( + owner, + 'glpat-initial-token', + 'https://gitlab.com', + user.id + ); + await db + .update(platform_integrations) + .set({ + integration_status: 'suspended', + auth_invalid_at: new Date().toISOString(), + auth_invalid_reason: 'authorizing_user_deleted', + }) + .where(eq(platform_integrations.id, initial.integration.id)); + await db + .delete(platform_access_token_credentials) + .where(eq(platform_access_token_credentials.platform_integration_id, initial.integration.id)); + mockFetchGitLabProjects.mockRejectedValueOnce(new Error('GitLab projects fetch failed: 502')); + + const result = await connectWithPAT( + owner, + 'glpat-replacement-token', + 'https://gitlab.com', + user.id + ); + const [integration] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, initial.integration.id)); + const [credential] = await db + .select() + .from(platform_access_token_credentials) + .where(eq(platform_access_token_credentials.platform_integration_id, initial.integration.id)); + + expect(result.success).toBe(true); + expect(result.warnings).toEqual([ + 'Connected, but the repository list could not be loaded. Refresh the project list from the GitLab integration page.', + ]); + expect(integration.integration_status).toBe('active'); + expect(integration.auth_invalid_at).toBeNull(); + expect(integration.auth_invalid_reason).toBeNull(); + expect(credential.authorized_by_user_id).toBe(user.id); + }); + it('discards the previous instance cache when the repository list cannot be loaded', async () => { const user = await insertTestUser(); mockFetchGitLabProjects.mockResolvedValueOnce([ diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index 2140f7808ba..b12b866e3f8 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -1065,6 +1065,8 @@ async function cacheConnectedGitLabRepositories( * If an existing integration exists, this function will update it instead of * creating a new one. This preserves webhook secrets and configured webhooks * so existing webhook configurations continue to work. + * Replacing a credential clears its authentication invalidation in the same + * transaction; connection health does not depend on the repository-cache sync. * * @param owner - User or organization owner * @param token - Personal Access Token @@ -1177,6 +1179,8 @@ export async function connectWithPAT( platform_account_login: validatedUser.username, scopes: validation.tokenInfo?.scopes ?? ['api'], integration_status: INTEGRATION_STATUS.ACTIVE, + auth_invalid_at: null, + auth_invalid_reason: null, updated_at: new Date().toISOString(), }) .where(eq(platform_integrations.id, existingIntegration.id));