Skip to content
Open
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
3 changes: 3 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `GOOGLE_SHEETS_SPREADSHEET_ID` - ID of the Google Sheet used for specific app integrations. [SERVER]
- `GITLAB_CLIENT_ID` - GitLab OAuth app client ID. `[PUBLIC]`
- `GITLAB_CLIENT_SECRET` - GitLab OAuth app client secret. `[SECRET]`
- `BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_KEY_ID` - Active platform-credential envelope key ID. The legacy name is shared by Bitbucket and encrypted GitLab credentials. [SERVER]
- `BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PUBLIC_KEY` - Base64-encoded active platform-credential RSA public key; available to web and `git-token-service`. The legacy name is shared by Bitbucket and GitLab. `[SECRET]`
- `BITBUCKET_OAUTH_CREDENTIAL_ACTIVE_PRIVATE_KEY` - Base64-encoded active platform-credential RSA private key; available only to `git-token-service`. The legacy name is shared by Bitbucket and GitLab. `[SECRET]`
- `LINKEDIN_CLIENT_ID` - LinkedIn OAuth app client ID. `[PUBLIC]`
- `LINKEDIN_CLIENT_SECRET` - LinkedIn OAuth app client secret. `[SECRET]`
- `DISCORD_CLIENT_ID` - Discord OAuth app client ID. `[PUBLIC]`
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/api/gastown/git-credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export async function POST(request: NextRequest) {
);
}

const credentials = await resolveGitCredentialsFromIntegration(platformIntegrationId);
const credentials = await resolveGitCredentialsFromIntegration(
platformIntegrationId,
authResult.kiloUserId
);
if (!credentials) {
return NextResponse.json(
{ error: 'Could not resolve credentials for this integration' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({

jest.mock('@/lib/integrations/gitlab-service', () => ({
getValidGitLabToken: jest.fn<() => Promise<string>>().mockResolvedValue('mock-token'),
getStoredProjectAccessToken: jest.fn<() => null>().mockReturnValue(null),
getValidGitLabProjectAccessToken: jest
.fn<() => Promise<string>>()
.mockResolvedValue('mock-token'),
}));

jest.mock('@sentry/nextjs', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ import {
import type { GitLabCommitStatusState } from '@/lib/integrations/platforms/gitlab/adapter';
import { getIntegrationById } from '@/lib/integrations/db/platform-integrations';
import {
getValidGitLabProjectAccessToken,
getValidGitLabToken,
getStoredProjectAccessToken,
} from '@/lib/integrations/gitlab-service';
import { captureException, captureMessage } from '@sentry/nextjs';
import { CALLBACK_TOKEN_SECRET } from '@/lib/config.server';
Expand Down Expand Up @@ -865,14 +865,28 @@ async function upsertModelNotFoundSummary(

/**
* Resolves a GitLab access token for a review's project.
* Prefers a stored Project Access Token; falls back to the user's OAuth token.
* Uses the exact project credential when a project ID is present.
*/
async function resolveGitLabAccessToken(
integration: PlatformIntegration,
projectId: number | null
): Promise<string> {
const storedPrat = projectId ? getStoredProjectAccessToken(integration, projectId) : null;
return storedPrat ? storedPrat.token : await getValidGitLabToken(integration);
let userId: string;
let organizationId: string | undefined;
if (integration.owned_by_organization_id) {
organizationId = integration.owned_by_organization_id;
const botUserId = await getBotUserId(organizationId, 'code-review');
if (!botUserId) throw new Error('GitLab organization has no configured acting user');
userId = botUserId;
} else if (integration.owned_by_user_id) {
userId = integration.owned_by_user_id;
} else {
throw new Error('GitLab integration has no owner');
}
const actor = { userId, ...(organizationId ? { organizationId } : {}) };
return projectId
? await getValidGitLabProjectAccessToken(integration, projectId, actor)
: await getValidGitLabToken(integration, actor);
}

/**
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createHmac } from 'crypto';
import { captureException } from '@sentry/nextjs';
import type { PlatformIntegration } from '@kilocode/db';
import z from 'zod';
import { getBotUserId } from '@/lib/bot-users/bot-user-service';

/**
* Derive a per-request callback token so the dedicated callback HMAC secret
Expand Down Expand Up @@ -136,10 +137,15 @@ export default async function spawnCloudAgentSession(
const kilocodeOrganizationId = owner.type === 'org' ? owner.id : undefined;

if (args.gitlabProject) {
const gitLabActorUserId =
owner.type === 'org' ? await getBotUserId(owner.id, 'code-review') : owner.id;
if (!gitLabActorUserId) {
return { response: 'Error: No acting user is configured for this GitLab organization.' };
}
// GitLab path: get token + instance URL, build clone URL, use gitUrl/gitToken
const gitlabToken =
owner.type === 'org'
? await getGitLabTokenForOrganization(owner.id)
? await getGitLabTokenForOrganization(owner.id, gitLabActorUserId)
: await getGitLabTokenForUser(owner.id);

if (!gitlabToken) {
Expand Down
54 changes: 43 additions & 11 deletions apps/web/src/lib/cloud-agent/gitlab-integration-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { buildGitLabCloneUrl } from './gitlab-integration-helpers';

// Define mock functions at module level with proper typing
const mockGetGitLabIntegration = jest.fn<(owner: Owner) => Promise<PlatformIntegration | null>>();
const mockGetValidGitLabToken = jest.fn<(integration: PlatformIntegration) => Promise<string>>();
const mockGetValidGitLabToken =
jest.fn<
(
integration: PlatformIntegration,
actor: { userId: string; organizationId?: string }
) => Promise<string>
>();
const mockGetIntegrationForOrganization =
jest.fn<(organizationId: string, platform: string) => Promise<PlatformIntegration | null>>();
const mockGetIntegrationForOwner =
Expand Down Expand Up @@ -207,7 +213,9 @@ describe('gitlab-integration-helpers', () => {
const result = await getGitLabTokenForUser('user-123');

expect(result).toBe('valid-token-123');
expect(mockGetValidGitLabToken).toHaveBeenCalledWith(mockIntegration);
expect(mockGetValidGitLabToken).toHaveBeenCalledWith(mockIntegration, {
userId: 'user-123',
});
});

it('should throw TRPCError when token retrieval fails', async () => {
Expand All @@ -231,7 +239,7 @@ describe('gitlab-integration-helpers', () => {
mockGetIntegrationForOrganization.mockResolvedValue(null);

const { getGitLabTokenForOrganization } = await import('./gitlab-integration-helpers');
const result = await getGitLabTokenForOrganization('org-123');
const result = await getGitLabTokenForOrganization('org-123', 'actor-123');

expect(result).toBeUndefined();
expect(mockGetIntegrationForOrganization).toHaveBeenCalledWith('org-123', 'gitlab');
Expand All @@ -246,10 +254,13 @@ describe('gitlab-integration-helpers', () => {
mockGetValidGitLabToken.mockResolvedValue('org-valid-token-456');

const { getGitLabTokenForOrganization } = await import('./gitlab-integration-helpers');
const result = await getGitLabTokenForOrganization('org-123');
const result = await getGitLabTokenForOrganization('org-123', 'actor-123');

expect(result).toBe('org-valid-token-456');
expect(mockGetValidGitLabToken).toHaveBeenCalledWith(mockIntegration);
expect(mockGetValidGitLabToken).toHaveBeenCalledWith(mockIntegration, {
userId: 'actor-123',
organizationId: 'org-123',
});
});

it('should throw TRPCError when token retrieval fails', async () => {
Expand All @@ -262,7 +273,7 @@ describe('gitlab-integration-helpers', () => {

const { getGitLabTokenForOrganization } = await import('./gitlab-integration-helpers');

await expect(getGitLabTokenForOrganization('org-123')).rejects.toThrow(
await expect(getGitLabTokenForOrganization('org-123', 'actor-123')).rejects.toThrow(
'Failed to authenticate with GitLab integration'
);
});
Expand Down Expand Up @@ -353,7 +364,11 @@ describe('gitlab-integration-helpers', () => {

const { validateGitLabRepoAccessForOrganization } =
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization('org-123', 'org/project');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/project'
);

expect(result).toBe(true);
});
Expand All @@ -368,7 +383,11 @@ describe('gitlab-integration-helpers', () => {

const { validateGitLabRepoAccessForOrganization } =
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization('org-123', 'org/nonexistent');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/nonexistent'
);

expect(result).toBe(false);
});
Expand All @@ -383,7 +402,11 @@ describe('gitlab-integration-helpers', () => {

const { validateGitLabRepoAccessForOrganization } =
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization('org-123', 'org/project');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/project'
);

expect(result).toBe(true);
});
Expand All @@ -393,7 +416,11 @@ describe('gitlab-integration-helpers', () => {

const { validateGitLabRepoAccessForOrganization } =
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization('org-123', 'org/project');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/project'
);

expect(result).toBe(false);
});
Expand All @@ -408,7 +435,11 @@ describe('gitlab-integration-helpers', () => {

const { validateGitLabRepoAccessForOrganization } =
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization('org-123', 'org/project');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/project'
);

expect(result).toBe(false);
});
Expand All @@ -427,6 +458,7 @@ describe('gitlab-integration-helpers', () => {
await import('./gitlab-integration-helpers');
const result = await validateGitLabRepoAccessForOrganization(
'org-123',
'actor-123',
'org/subgroup/project'
);

Expand Down
21 changes: 15 additions & 6 deletions apps/web/src/lib/cloud-agent/gitlab-integration-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ type GitLabMetadata = {
* Automatically refreshes the token if expired
*/
export async function getGitLabTokenForOrganization(
organizationId: string
organizationId: string,
actorUserId: string
): Promise<string | undefined> {
const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB);

Expand All @@ -68,7 +69,10 @@ export async function getGitLabTokenForOrganization(
}

try {
const token = await getValidGitLabToken(integration);
const token = await getValidGitLabToken(integration, {
userId: actorUserId,
organizationId,
});
return token;
} catch (_error) {
throw new TRPCError({
Expand All @@ -90,7 +94,7 @@ export async function getGitLabTokenForUser(userId: string): Promise<string | un
}

try {
const token = await getValidGitLabToken(integration);
const token = await getValidGitLabToken(integration, { userId });
return token;
} catch (_error) {
throw new TRPCError({
Expand All @@ -106,6 +110,7 @@ export async function getGitLabTokenForUser(userId: string): Promise<string | un
*/
export async function fetchGitLabRepositoriesForOrganization(
organizationId: string,
actorUserId: string,
forceRefresh: boolean = false
): Promise<GitLabRepositoriesResult> {
const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB);
Expand All @@ -121,7 +126,10 @@ export async function fetchGitLabRepositoriesForOrganization(
const cachedRepositories = requireNumericPlatformRepositories(integration.repositories);
// If forceRefresh or no cached repos, fetch from GitLab and update cache
if (forceRefresh || !cachedRepositories?.length) {
const accessToken = await getValidGitLabToken(integration);
const accessToken = await getValidGitLabToken(integration, {
userId: actorUserId,
organizationId,
});
const repositories = await fetchGitLabProjects(accessToken, instanceUrl);
await updateRepositoriesForIntegration(integration.id, repositories);
return {
Expand Down Expand Up @@ -168,7 +176,7 @@ export async function fetchGitLabRepositoriesForUser(
const cachedRepositories = requireNumericPlatformRepositories(integration.repositories);
// If forceRefresh or no cached repos, fetch from GitLab and update cache
if (forceRefresh || !cachedRepositories?.length) {
const accessToken = await getValidGitLabToken(integration);
const accessToken = await getValidGitLabToken(integration, { userId });
const repositories = await fetchGitLabProjects(accessToken, instanceUrl);
await updateRepositoriesForIntegration(integration.id, repositories);
return {
Expand Down Expand Up @@ -228,10 +236,11 @@ export async function validateGitLabRepoAccessForUser(
*/
export async function validateGitLabRepoAccessForOrganization(
organizationId: string,
actorUserId: string,
projectPath: string
): Promise<boolean> {
try {
const result = await fetchGitLabRepositoriesForOrganization(organizationId, false);
const result = await fetchGitLabRepositoriesForOrganization(organizationId, actorUserId, false);

if (!result.integrationInstalled || !result.repositories.length) {
return false;
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/code-reviews/manual-code-review-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ async function resolveConnectedGitLabSource(
const instanceUrl = getGitLabIntegrationInstanceUrl(integration);
if (parsed.origin !== instanceUrl) continue;

const accessToken = await getValidGitLabToken(integration);
const accessToken = await getValidGitLabToken(integration, {
userId: owner.userId,
...(owner.type === 'org' ? { organizationId: owner.id } : {}),
});
const rawMergeRequest = await fetchGitLabMergeRequest({
accessToken,
projectId: parsed.projectPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,10 @@ export async function prepareReviewPayload(
}

try {
gitlabToken = await getOrCreateProjectAccessToken(integration, projectId);
gitlabToken = await getOrCreateProjectAccessToken(integration, projectId, {
userId: owner.userId,
...(owner.type === 'org' ? { organizationId: owner.id } : {}),
});
logExceptInTest('[prepareReviewPayload] Using PrAT for code review', {
reviewId,
repoFullName: review.repo_full_name,
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/lib/gastown/git-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export type ResolvedGitCredentials = {
* Returns null if the integration is missing, inactive, or has no usable credentials.
*/
export async function resolveGitCredentialsFromIntegration(
platformIntegrationId: string
platformIntegrationId: string,
actorUserId: string
): Promise<ResolvedGitCredentials | null> {
const [integration] = await db
.select()
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function resolveGitCredentialsFromIntegration(

if (integration.platform === 'gitlab') {
try {
const token = await getValidGitLabToken(integration);
const token = await getValidGitLabToken(integration, { userId: actorUserId });
const metadata = integration.metadata as GitLabMetadata | null;
const instanceUrl = metadata?.gitlab_instance_url;

Expand Down Expand Up @@ -169,8 +170,9 @@ function detectPlatformFromGitUrl(gitUrl: string): string | undefined {
* meaning the existing town config credentials should be used as-is.
*/
export async function refreshGitCredentials(
platformIntegrationId: string | undefined | null
platformIntegrationId: string | undefined | null,
actorUserId: string
): Promise<ResolvedGitCredentials | null> {
if (!platformIntegrationId) return null;
return resolveGitCredentialsFromIntegration(platformIntegrationId);
return resolveGitCredentialsFromIntegration(platformIntegrationId, actorUserId);
}
Loading
Loading