-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathgitlab-service.ts
More file actions
1009 lines (889 loc) · 31.4 KB
/
gitlab-service.ts
File metadata and controls
1009 lines (889 loc) · 31.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import 'server-only';
import { db } from '@/lib/drizzle';
import type { PlatformIntegration } from '@kilocode/db/schema';
import { platform_integrations } from '@kilocode/db/schema';
import { eq, and } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
import type { Owner } from '@/lib/integrations/core/types';
import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants';
import { updateRepositoriesForIntegration } from '@/lib/integrations/db/platform-integrations';
import { resetCodeReviewConfigForOwner } from '@/lib/agent-config/db/agent-configs';
import {
fetchGitLabProjects,
fetchGitLabBranches,
refreshGitLabOAuthToken,
isTokenExpired,
calculateTokenExpiry,
createProjectAccessToken,
findKiloProjectAccessToken,
rotateProjectAccessToken,
revokeProjectAccessToken,
calculateProjectAccessTokenExpiry,
isProjectAccessTokenExpiringSoon,
validateProjectAccessToken,
validatePersonalAccessToken,
type GitLabProjectAccessToken,
type GitLabPATValidationResult,
GitLabProjectAccessTokenPermissionError,
} from '@/lib/integrations/platforms/gitlab/adapter';
import { randomBytes } from 'crypto';
import { logExceptInTest } from '@/lib/utils.server';
import {
DEFAULT_GITLAB_INSTANCE_URL,
GitLabInstanceUrlError,
normalizeGitLabInstanceUrl,
} from '@/lib/integrations/platforms/gitlab/instance-url';
/**
* GitLab Integration Service
*
* Provides business logic for GitLab OAuth integrations.
* Handles token refresh, repository listing, and integration management.
*/
/**
* Normalizes a GitLab instance URL for comparison.
* Strips trailing slashes, lowercases, and treats undefined/empty as gitlab.com.
*/
export function normalizeInstanceUrl(url?: string): string {
return normalizeGitLabInstanceUrl(url || DEFAULT_GITLAB_INSTANCE_URL);
}
/**
* Returns true if the GitLab instance URL has changed between
* the existing integration and the new connection.
*/
export function instanceUrlChanged(existingUrl: string | undefined, newUrl: string): boolean {
const normalizedNewUrl = normalizeInstanceUrl(newUrl);
try {
return normalizeInstanceUrl(existingUrl) !== normalizedNewUrl;
} catch {
return true;
}
}
/**
* Get GitLab integration for an owner
*/
export async function getGitLabIntegration(owner: Owner): Promise<PlatformIntegration | null> {
const ownershipCondition =
owner.type === 'user'
? eq(platform_integrations.owned_by_user_id, owner.id)
: eq(platform_integrations.owned_by_organization_id, owner.id);
const [integration] = await db
.select()
.from(platform_integrations)
.where(and(ownershipCondition, eq(platform_integrations.platform, PLATFORM.GITLAB)))
.limit(1);
return integration || null;
}
/**
* Get a valid access token for a GitLab integration
*
* @param integration - The GitLab integration record
* @returns Valid access token
*/
export async function getValidGitLabToken(integration: PlatformIntegration): Promise<string> {
const metadata = integration.metadata as GitLabIntegrationMetadata | null;
if (!metadata?.access_token) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'GitLab integration missing access token',
});
}
// PAT tokens don't expire in the same way as OAuth tokens
// They have a fixed expiration date set at creation
if (metadata.auth_type === 'pat') {
// For PAT, we can't refresh - just return the token
// The user will need to create a new PAT if it expires
return metadata.access_token;
}
// OAuth token refresh logic
if (metadata.token_expires_at && isTokenExpired(metadata.token_expires_at)) {
if (!metadata.refresh_token) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'GitLab token expired and no refresh token available. Please reconnect.',
});
}
const instanceUrl = normalizeInstanceUrl(metadata.gitlab_instance_url);
const customCredentials =
metadata.client_id && metadata.client_secret
? { clientId: metadata.client_id, clientSecret: metadata.client_secret }
: undefined;
const newTokens = await refreshGitLabOAuthToken(
metadata.refresh_token,
instanceUrl,
customCredentials
);
const newExpiresAt = calculateTokenExpiry(newTokens.created_at, newTokens.expires_in);
await db
.update(platform_integrations)
.set({
metadata: {
...metadata,
access_token: newTokens.access_token,
refresh_token: newTokens.refresh_token,
token_expires_at: newExpiresAt,
},
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integration.id));
return newTokens.access_token;
}
return metadata.access_token;
}
/**
* List repositories accessible by a GitLab integration
* Returns cached repositories by default, fetches fresh from GitLab when forceRefresh is true
*/
export async function listGitLabRepositories(
owner: Owner,
integrationId: string,
forceRefresh: boolean = false
) {
const ownershipCondition =
owner.type === 'user'
? eq(platform_integrations.owned_by_user_id, owner.id)
: eq(platform_integrations.owned_by_organization_id, owner.id);
const [integration] = await db
.select()
.from(platform_integrations)
.where(
and(
eq(platform_integrations.id, integrationId),
ownershipCondition,
eq(platform_integrations.platform, PLATFORM.GITLAB)
)
)
.limit(1);
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'GitLab integration not found',
});
}
// If forceRefresh, no cached repos, or never synced before, fetch from GitLab and update cache
if (forceRefresh || !integration.repositories?.length || !integration.repositories_synced_at) {
const accessToken = await getValidGitLabToken(integration);
const metadata = integration.metadata as { gitlab_instance_url?: string } | null;
const instanceUrl = normalizeInstanceUrl(metadata?.gitlab_instance_url);
const repos = await fetchGitLabProjects(accessToken, instanceUrl);
await updateRepositoriesForIntegration(integrationId, repos);
return {
repositories: repos,
syncedAt: new Date().toISOString(),
};
}
// Return cached repos
return {
repositories: integration.repositories,
syncedAt: integration.repositories_synced_at,
};
}
/**
* List branches for a GitLab project
* Always fetches fresh from GitLab (no caching)
*/
export async function listGitLabBranches(
owner: Owner,
integrationId: string,
projectPath: string // e.g., "group/project" or project ID
) {
const ownershipCondition =
owner.type === 'user'
? eq(platform_integrations.owned_by_user_id, owner.id)
: eq(platform_integrations.owned_by_organization_id, owner.id);
const [integration] = await db
.select()
.from(platform_integrations)
.where(
and(
eq(platform_integrations.id, integrationId),
ownershipCondition,
eq(platform_integrations.platform, PLATFORM.GITLAB)
)
)
.limit(1);
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'GitLab integration not found',
});
}
const accessToken = await getValidGitLabToken(integration);
const metadata = integration.metadata as { gitlab_instance_url?: string } | null;
const instanceUrl = normalizeInstanceUrl(metadata?.gitlab_instance_url);
const branches = await fetchGitLabBranches(accessToken, projectPath, instanceUrl);
return {
branches: branches.map(b => ({
name: b.name,
isDefault: b.default,
})),
};
}
/**
* Disconnect GitLab integration for an owner
*
* Instead of deleting the integration record, we mark it as disconnected.
* This preserves the webhook_secret, configured_webhooks, and project_tokens
* so that when the user reconnects (via OAuth or PAT), existing webhook
* configurations continue to work.
*/
export async function disconnectGitLabIntegration(owner: Owner) {
const ownershipCondition =
owner.type === 'user'
? eq(platform_integrations.owned_by_user_id, owner.id)
: eq(platform_integrations.owned_by_organization_id, owner.id);
// Get the integration
const [integration] = await db
.select()
.from(platform_integrations)
.where(
and(
ownershipCondition,
eq(platform_integrations.platform, PLATFORM.GITLAB),
eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE)
)
)
.limit(1);
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'GitLab integration not found',
});
}
// Mark as disconnected instead of deleting
// This preserves webhook_secret, configured_webhooks, and project_tokens
// so reconnecting (via OAuth or PAT) will keep existing webhook configurations working
const existingMetadata = (integration.metadata || {}) as GitLabIntegrationMetadata;
// Clear sensitive tokens but preserve webhook configuration
const updatedMetadata: GitLabIntegrationMetadata = {
// Clear tokens
access_token: undefined,
refresh_token: undefined,
token_expires_at: undefined,
// Preserve instance URL for reconnection
gitlab_instance_url: existingMetadata.gitlab_instance_url,
// Clear OAuth credentials
client_id: undefined,
client_secret: undefined,
// PRESERVE webhook secret so existing webhooks continue to work
webhook_secret: existingMetadata.webhook_secret,
// Clear auth type (will be set on reconnect)
auth_type: undefined,
// PRESERVE configured webhooks
configured_webhooks: existingMetadata.configured_webhooks,
// PRESERVE project tokens (they're still valid on GitLab)
project_tokens: existingMetadata.project_tokens,
};
await db
.update(platform_integrations)
.set({
integration_status: INTEGRATION_STATUS.SUSPENDED,
metadata: updatedMetadata,
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integration.id));
logExceptInTest(
'[disconnectGitLabIntegration] Integration suspended (preserved webhook config)',
{
integrationId: integration.id,
preservedWebhookSecret: !!existingMetadata.webhook_secret,
preservedWebhooks: Object.keys(existingMetadata.configured_webhooks || {}).length,
preservedProjectTokens: Object.keys(existingMetadata.project_tokens || {}).length,
}
);
return { success: true };
}
/**
* Regenerate webhook secret for a GitLab integration
* This is useful when the user has lost the webhook secret and needs to reconfigure
* their GitLab webhook settings
*/
export async function regenerateWebhookSecret(owner: Owner): Promise<{ webhookSecret: string }> {
const ownershipCondition =
owner.type === 'user'
? eq(platform_integrations.owned_by_user_id, owner.id)
: eq(platform_integrations.owned_by_organization_id, owner.id);
// Get the integration
const [integration] = await db
.select()
.from(platform_integrations)
.where(
and(
ownershipCondition,
eq(platform_integrations.platform, PLATFORM.GITLAB),
eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE)
)
)
.limit(1);
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'GitLab integration not found',
});
}
// Generate new webhook secret
const newWebhookSecret = randomBytes(32).toString('hex');
// Update the metadata with the new webhook secret
const existingMetadata = (integration.metadata || {}) as Record<string, unknown>;
const updatedMetadata = {
...existingMetadata,
webhook_secret: newWebhookSecret,
};
await db
.update(platform_integrations)
.set({
metadata: updatedMetadata,
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integration.id));
return { webhookSecret: newWebhookSecret };
}
// ============================================================================
// Project Access Token (PrAT) Management
// ============================================================================
/**
* Stored Project Access Token metadata
* This is stored per-project in the integration metadata
*/
export type StoredProjectAccessToken = {
/** GitLab token ID (for rotation/revocation) */
token_id: number;
/** The actual token value (should be encrypted in production) */
token: string;
/** Expiration date in YYYY-MM-DD format */
expires_at: string;
/** When the token was created */
created_at: string;
/** Token name for identification */
name: string;
};
/**
* GitLab integration metadata type with PrAT support
*/
export type GitLabIntegrationMetadata = {
access_token?: string;
refresh_token?: string;
token_expires_at?: string;
gitlab_instance_url?: string;
client_id?: string;
client_secret?: string;
webhook_secret?: string;
auth_type?: 'oauth' | 'pat';
/** Configured webhooks per project */
configured_webhooks?: Record<
string,
{
hook_id: number;
created_at: string;
}
>;
/** Project Access Tokens per project (keyed by project ID) */
project_tokens?: Record<string, StoredProjectAccessToken>;
};
/**
* Default name for Kilo Code Review Bot tokens
*/
const KILO_BOT_TOKEN_NAME = 'Kilo Code Review Bot';
/**
* Gets or creates a Project Access Token for a GitLab project
*
* This function:
* 1. Checks if a PrAT already exists for the project in metadata
* 2. If exists and not expiring soon, returns it
* 3. If exists but expiring soon, rotates it
* 4. If doesn't exist, creates a new one
*
* @param integration - The GitLab integration record
* @param projectId - GitLab project ID
* @returns The Project Access Token to use for API calls
*/
export async function getOrCreateProjectAccessToken(
integration: PlatformIntegration,
projectId: string | number
): Promise<string> {
const metadata = integration.metadata as GitLabIntegrationMetadata | null;
if (!metadata?.access_token) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'GitLab integration missing access token',
});
}
const instanceUrl = normalizeInstanceUrl(metadata.gitlab_instance_url);
const projectIdStr = String(projectId);
// Check if we already have a stored token for this project
const storedToken = metadata.project_tokens?.[projectIdStr];
if (storedToken) {
// Check if token is expiring soon (within 7 days)
const isExpiringSoon = isProjectAccessTokenExpiringSoon(storedToken.expires_at, 7);
if (!isExpiringSoon) {
// Validate the token is still valid on GitLab (might have been manually revoked)
const isValid = await validateProjectAccessToken(storedToken.token, instanceUrl);
if (isValid) {
logExceptInTest('[getOrCreateProjectAccessToken] Using existing token', {
projectId,
tokenId: storedToken.token_id,
expiresAt: storedToken.expires_at,
});
return storedToken.token;
}
// Token is invalid (revoked), remove from storage and create a new one
logExceptInTest('[getOrCreateProjectAccessToken] Stored token is invalid, creating new one', {
projectId,
tokenId: storedToken.token_id,
});
// Remove the invalid token from storage and skip to creating a new one
await removeInvalidStoredToken(integration.id, projectIdStr, metadata);
// Don't try to rotate - fall through to create new token below
} else {
// Token is expiring soon, try to rotate it
logExceptInTest('[getOrCreateProjectAccessToken] Token expiring soon, rotating', {
projectId,
tokenId: storedToken.token_id,
expiresAt: storedToken.expires_at,
});
try {
// Get a valid user token for the rotation API call
const userToken = await getValidGitLabToken(integration);
const newExpiresAt = calculateProjectAccessTokenExpiry(365);
const rotatedToken = await rotateProjectAccessToken(
userToken,
projectId,
storedToken.token_id,
newExpiresAt,
instanceUrl
);
// Token value is only returned on rotation
if (!rotatedToken.token) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'GitLab did not return token value after rotation',
});
}
// Update stored token
await updateStoredProjectAccessToken(integration.id, projectIdStr, {
token_id: rotatedToken.id,
token: rotatedToken.token,
expires_at: rotatedToken.expires_at,
created_at: new Date().toISOString(),
name: rotatedToken.name,
});
logExceptInTest('[getOrCreateProjectAccessToken] Token rotated successfully', {
projectId,
newTokenId: rotatedToken.id,
newExpiresAt: rotatedToken.expires_at,
});
return rotatedToken.token;
} catch (error) {
// If rotation fails, try to create a new token
logExceptInTest('[getOrCreateProjectAccessToken] Rotation failed, creating new token', {
projectId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
// No existing token or rotation failed, create a new one
logExceptInTest('[getOrCreateProjectAccessToken] Creating new token', {
projectId,
});
const userToken = await getValidGitLabToken(integration);
const expiresAt = calculateProjectAccessTokenExpiry(365);
try {
const newToken = await createProjectAccessToken(
userToken,
projectId,
KILO_BOT_TOKEN_NAME,
expiresAt,
['api', 'self_rotate'], // api for full access, self_rotate for token rotation
30, // Developer access level
instanceUrl
);
// Token value is only returned on creation
if (!newToken.token) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'GitLab did not return token value after creation',
});
}
// Store the new token
await updateStoredProjectAccessToken(integration.id, projectIdStr, {
token_id: newToken.id,
token: newToken.token,
expires_at: newToken.expires_at,
created_at: new Date().toISOString(),
name: newToken.name,
});
logExceptInTest('[getOrCreateProjectAccessToken] Token created successfully', {
projectId,
tokenId: newToken.id,
expiresAt: newToken.expires_at,
});
return newToken.token;
} catch (error) {
if (error instanceof GitLabProjectAccessTokenPermissionError) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `Cannot create bot token for project ${projectId}. You need Maintainer role or higher.`,
cause: error,
});
}
throw error;
}
}
/**
* Removes an invalid stored token from the integration metadata
* Called when a stored token is found to be invalid (e.g., manually revoked on GitLab)
*/
async function removeInvalidStoredToken(
integrationId: string,
projectId: string,
metadata: GitLabIntegrationMetadata
): Promise<void> {
const projectTokens = { ...metadata.project_tokens };
delete projectTokens[projectId];
await db
.update(platform_integrations)
.set({
metadata: {
...metadata,
project_tokens: projectTokens,
},
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integrationId));
logExceptInTest('[removeInvalidStoredToken] Removed invalid token from storage', {
integrationId,
projectId,
});
}
/**
* Updates the stored Project Access Token for a project in the integration metadata
*/
async function updateStoredProjectAccessToken(
integrationId: string,
projectId: string,
tokenData: StoredProjectAccessToken
): Promise<void> {
// Get current metadata
const [integration] = await db
.select()
.from(platform_integrations)
.where(eq(platform_integrations.id, integrationId))
.limit(1);
if (!integration) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Integration not found',
});
}
const metadata = (integration.metadata || {}) as GitLabIntegrationMetadata;
const projectTokens = metadata.project_tokens || {};
// Update the token for this project
projectTokens[projectId] = tokenData;
// Save back to database
await db
.update(platform_integrations)
.set({
metadata: {
...metadata,
project_tokens: projectTokens,
},
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integrationId));
}
/**
* Removes the stored Project Access Token for a project
* Called when a project is removed from code reviews
*/
export async function removeStoredProjectAccessToken(
integration: PlatformIntegration,
projectId: string | number
): Promise<void> {
const metadata = integration.metadata as GitLabIntegrationMetadata | null;
const projectIdStr = String(projectId);
if (!metadata?.project_tokens?.[projectIdStr]) {
// No token stored, nothing to do
return;
}
const storedToken = metadata.project_tokens[projectIdStr];
const instanceUrl = normalizeInstanceUrl(metadata.gitlab_instance_url);
// Try to revoke the token in GitLab
try {
const userToken = await getValidGitLabToken(integration);
await revokeProjectAccessToken(userToken, projectId, storedToken.token_id, instanceUrl);
logExceptInTest('[removeStoredProjectAccessToken] Token revoked in GitLab', {
projectId,
tokenId: storedToken.token_id,
});
} catch (error) {
// Log but don't fail - the token might already be revoked
logExceptInTest('[removeStoredProjectAccessToken] Failed to revoke token in GitLab', {
projectId,
tokenId: storedToken.token_id,
error: error instanceof Error ? error.message : String(error),
});
}
// Remove from metadata
const projectTokens = { ...metadata.project_tokens };
delete projectTokens[projectIdStr];
await db
.update(platform_integrations)
.set({
metadata: {
...metadata,
project_tokens: projectTokens,
},
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, integration.id));
logExceptInTest('[removeStoredProjectAccessToken] Token removed from metadata', {
projectId,
});
}
/**
* Gets the stored Project Access Token for a project (if exists)
* Returns null if no token is stored
*/
export function getStoredProjectAccessToken(
integration: PlatformIntegration,
projectId: string | number
): StoredProjectAccessToken | null {
const metadata = integration.metadata as GitLabIntegrationMetadata | null;
const projectIdStr = String(projectId);
return metadata?.project_tokens?.[projectIdStr] || null;
}
/**
* Checks if a Project Access Token exists and is valid for a project
*/
export function hasValidProjectAccessToken(
integration: PlatformIntegration,
projectId: string | number
): boolean {
const storedToken = getStoredProjectAccessToken(integration, projectId);
if (!storedToken) {
return false;
}
// Check if token is not expiring within 1 day
return !isProjectAccessTokenExpiringSoon(storedToken.expires_at, 1);
}
/**
* Finds an existing Kilo bot token on GitLab and imports it into metadata
* Useful for recovering from lost metadata or migrating existing tokens
*/
export async function importExistingProjectAccessToken(
integration: PlatformIntegration,
projectId: string | number
): Promise<GitLabProjectAccessToken | null> {
const metadata = integration.metadata as GitLabIntegrationMetadata | null;
if (!metadata?.access_token) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'GitLab integration missing access token',
});
}
const instanceUrl = normalizeInstanceUrl(metadata.gitlab_instance_url);
const userToken = await getValidGitLabToken(integration);
// Find existing Kilo token on GitLab
const existingToken = await findKiloProjectAccessToken(
userToken,
projectId,
KILO_BOT_TOKEN_NAME,
instanceUrl
);
if (existingToken) {
logExceptInTest('[importExistingProjectAccessToken] Found existing token on GitLab', {
projectId,
tokenId: existingToken.id,
expiresAt: existingToken.expires_at,
});
// Note: We can't get the token value from the API, only on creation
// So we can only store the metadata, not the actual token
// The caller will need to rotate the token to get a new value
}
return existingToken;
}
// ============================================================================
// Personal Access Token (PAT) Connection
// ============================================================================
/**
* Re-export validatePersonalAccessToken for use in tRPC router
*/
export { validatePersonalAccessToken, type GitLabPATValidationResult };
/**
* Connects GitLab using a Personal Access Token
*
* This is an alternative to OAuth for users who prefer PAT-based auth.
* The PAT is used for:
* - Account connection and identity verification
* - Listing accessible repositories
* - Creating webhooks (requires Maintainer role)
* - Creating Project Access Tokens for code reviews
*
* Code reviews use Project Access Tokens (PrAT) so comments appear as a bot.
*
* 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.
*
* @param owner - User or organization owner
* @param token - Personal Access Token
* @param instanceUrl - GitLab instance URL
*/
export async function connectWithPAT(
owner: Owner,
token: string,
instanceUrl: string = 'https://gitlab.com'
): Promise<{
success: boolean;
integration: {
id: string;
accountLogin: string;
accountId: string;
instanceUrl: string;
};
warnings?: string[];
}> {
let normalizedInstanceUrl: string;
try {
normalizedInstanceUrl = normalizeInstanceUrl(instanceUrl);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
error instanceof GitLabInstanceUrlError ? error.message : 'Invalid GitLab instance URL',
});
}
// 1. Validate the PAT
const validation = await validatePersonalAccessToken(token, normalizedInstanceUrl);
if (!validation.valid || !validation.user) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: validation.error || 'Invalid Personal Access Token',
});
}
// 2. Check for existing integration - update it instead of creating new
const existingIntegration = await getGitLabIntegration(owner);
if (existingIntegration) {
const existingMetadata = (existingIntegration.metadata || {}) as GitLabIntegrationMetadata;
// Detect if the GitLab instance URL changed (e.g. gitlab.com → self-hosted)
const isInstanceChange = instanceUrlChanged(
existingMetadata.gitlab_instance_url,
normalizedInstanceUrl
);
if (isInstanceChange) {
logExceptInTest('[connectWithPAT] Instance URL changed — clearing stale config', {
integrationId: existingIntegration.id,
oldInstanceUrl: existingMetadata.gitlab_instance_url,
newInstanceUrl: normalizedInstanceUrl,
});
}
const updatedMetadata: GitLabIntegrationMetadata = {
access_token: token,
gitlab_instance_url: normalizedInstanceUrl,
auth_type: 'pat',
// If instance changed: generate fresh webhook secret, clear webhooks & tokens
// If same instance: preserve existing config for continuity
webhook_secret: isInstanceChange
? randomBytes(32).toString('hex')
: existingMetadata.webhook_secret || randomBytes(32).toString('hex'),
configured_webhooks: isInstanceChange ? undefined : existingMetadata.configured_webhooks,
project_tokens: isInstanceChange ? undefined : existingMetadata.project_tokens,
};
await db
.update(platform_integrations)
.set({
integration_type: 'pat',
platform_installation_id: String(validation.user.id),
platform_account_id: String(validation.user.id),
platform_account_login: validation.user.username,
scopes: validation.tokenInfo?.scopes ?? ['api'],
integration_status: INTEGRATION_STATUS.ACTIVE,
metadata: updatedMetadata,
updated_at: new Date().toISOString(),
})
.where(eq(platform_integrations.id, existingIntegration.id));
// If instance changed, reset the code review agent config
// (selected repos and manually added repos belong to the old instance)
if (isInstanceChange) {
await resetCodeReviewConfigForOwner(owner, PLATFORM.GITLAB);
}
logExceptInTest('[connectWithPAT] Integration updated', {
integrationId: existingIntegration.id,
userId: validation.user.id,
username: validation.user.username,
instanceUrl: normalizedInstanceUrl,
authType: 'pat',
instanceChanged: isInstanceChange,
preservedWebhookSecret: !isInstanceChange && !!existingMetadata.webhook_secret,
preservedWebhooks: isInstanceChange
? 0
: Object.keys(existingMetadata.configured_webhooks || {}).length,
});
// Fetch and cache repositories
const repos = await fetchGitLabProjects(token, normalizedInstanceUrl);
await updateRepositoriesForIntegration(existingIntegration.id, repos);
return {
success: true,
integration: {
id: existingIntegration.id,
accountLogin: validation.user.username,
accountId: String(validation.user.id),
instanceUrl: normalizedInstanceUrl,
},
warnings: validation.warnings,
};
}
// 3. No existing integration - create new one with fresh webhook secret
const webhookSecret = randomBytes(32).toString('hex');
// 4. Prepare metadata
const metadata: GitLabIntegrationMetadata = {
access_token: token,
// No refresh_token for PAT (PATs don't refresh)
gitlab_instance_url: normalizedInstanceUrl,
webhook_secret: webhookSecret,
auth_type: 'pat',
};
// 5. Create integration
const [integration] = await db
.insert(platform_integrations)
.values({
owned_by_user_id: owner.type === 'user' ? owner.id : null,
owned_by_organization_id: owner.type === 'org' ? owner.id : null,
platform: PLATFORM.GITLAB,
integration_type: 'pat',
platform_installation_id: String(validation.user.id), // Use GitLab user ID as "installation" ID
platform_account_id: String(validation.user.id),
platform_account_login: validation.user.username,
permissions: null, // PAT doesn't have granular permissions like GitHub Apps
scopes: validation.tokenInfo?.scopes ?? ['api'],
repository_access: 'all', // PAT grants access to all user's projects
integration_status: INTEGRATION_STATUS.ACTIVE,
metadata,
installed_at: new Date().toISOString(),
})
.returning();
logExceptInTest('[connectWithPAT] Integration created', {
integrationId: integration.id,
userId: validation.user.id,
username: validation.user.username,
instanceUrl: normalizedInstanceUrl,
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,
});
return {
success: true,