Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,18 @@ 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' });
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 });
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/code-reviews/action-required.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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()}`;
Expand Down Expand Up @@ -725,6 +731,97 @@ 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('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)
.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,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,
Expand Down Expand Up @@ -557,6 +558,12 @@ export async function prepareReviewPayload(
projectId,
});
} catch (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.`
);
}
if (pratError instanceof GitLabProjectAccessTokenPermissionError) {
throw new Error(
`Cannot create Project Access Token for GitLab code review. ` +
Expand Down
Loading
Loading