diff --git a/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts b/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts new file mode 100644 index 000000000..b24ddcf67 --- /dev/null +++ b/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts @@ -0,0 +1,23 @@ +import { MULTI_INSTALLATION_ENVIRONMENT_REPOSITORIES_ERROR } from '@roomote/types'; + +import { getEnvironmentRepositoryConfigError } from '../createEnvironment'; + +describe('getEnvironmentRepositoryConfigError', () => { + it('allows GitHub and GitLab repositories in the same environment', () => { + expect( + getEnvironmentRepositoryConfigError([ + { fullName: 'acme/frontend', installationId: 'github-installation' }, + { fullName: 'acme/backend', installationId: null }, + ]), + ).toBeNull(); + }); + + it('rejects repositories from two GitHub App installations', () => { + expect( + getEnvironmentRepositoryConfigError([ + { fullName: 'acme/frontend', installationId: 'github-installation-1' }, + { fullName: 'other/backend', installationId: 'github-installation-2' }, + ]), + ).toBe(MULTI_INSTALLATION_ENVIRONMENT_REPOSITORIES_ERROR); + }); +}); diff --git a/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts b/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts new file mode 100644 index 000000000..eabe469a9 --- /dev/null +++ b/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts @@ -0,0 +1,106 @@ +import { Hono } from 'hono'; + +import type { Variables } from '../../../types'; +import type { McpAuth } from '../../mcp/middleware'; +import { manageSourceControl } from '../manageSourceControl'; + +const { + mockAssertTaskRunTokenTargetExists, + mockClaimLatestUserMessageForReplyQuote, + mockFindTaskRunForSourceControlMutation, + mockManageSourceControlIssueForTaskRun, +} = vi.hoisted(() => ({ + mockAssertTaskRunTokenTargetExists: vi.fn(), + mockClaimLatestUserMessageForReplyQuote: vi.fn(), + mockFindTaskRunForSourceControlMutation: vi.fn(), + mockManageSourceControlIssueForTaskRun: vi.fn(), +})); + +vi.mock('@roomote/communication/messages', () => ({ + claimLatestUserMessageForReplyQuote: mockClaimLatestUserMessageForReplyQuote, + completeClaimedLatestUserMessageForReplyQuote: vi.fn(), + restoreClaimedLatestUserMessageForReplyQuote: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', async (importOriginal) => ({ + ...(await importOriginal()), + findTaskRunForSourceControlMutation: mockFindTaskRunForSourceControlMutation, + manageSourceControlIssueForTaskRun: mockManageSourceControlIssueForTaskRun, +})); + +vi.mock('../../mcp/proxy-utils', async (importOriginal) => ({ + ...(await importOriginal()), + assertTaskRunTokenTargetExists: mockAssertTaskRunTokenTargetExists, +})); + +function createApp() { + const app = new Hono<{ + Variables: Variables & { mcpAuth: McpAuth }; + }>(); + const mcpAuth = { + authContext: { + runId: 123, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }, + userId: 'user-1', + } as McpAuth; + + app.use('*', async (c, next) => { + c.set('mcpAuth', mcpAuth); + await next(); + }); + app.post('/:taskId/source_control', manageSourceControl); + + return app; +} + +describe('manageSourceControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAssertTaskRunTokenTargetExists.mockResolvedValue(undefined); + mockFindTaskRunForSourceControlMutation.mockResolvedValue({ + id: 123, + payload: { + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + }); + mockManageSourceControlIssueForTaskRun.mockResolvedValue({ + success: true, + action: 'create_issue_comment', + provider: 'gitlab', + repositoryFullName: 'acme/backend', + number: 1082, + commentId: '9', + warnings: [], + }); + }); + + it('does not apply GitHub reply quoting to a GitLab target in a GitHub-primary task', async () => { + const response = await createApp().request('/task-1/source_control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'create_issue_comment', + repositoryFullName: 'acme/backend', + issueNumber: 1082, + body: 'Fixed in the latest branch.', + }), + }); + + expect(response.status).toBe(200); + expect(mockClaimLatestUserMessageForReplyQuote).not.toHaveBeenCalled(); + expect(mockManageSourceControlIssueForTaskRun).toHaveBeenCalledWith({ + taskRun: expect.objectContaining({ id: 123 }), + input: expect.objectContaining({ + repositoryFullName: 'acme/backend', + body: 'Fixed in the latest branch.', + }), + }); + }); +}); diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index 1ee30b6ab..48d680ea1 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -6,13 +6,12 @@ import { completeClaimedLatestUserMessageForReplyQuote, restoreClaimedLatestUserMessageForReplyQuote, } from '@roomote/communication/messages'; -import { resolveSourceControlProviderFromPayload } from '@roomote/types'; - import { createOrUpdateSourceControlPullRequestForTaskRun, findTaskRunForSourceControlMutation, manageSourceControlIssueForTaskRun, readSourceControlPullRequestForTaskRun, + resolveSourceControlProviderForRepositoryFromPayload, sourceControlIssueInputSchema, sourceControlPullRequestMutationInputSchema, sourceControlPullRequestReadInputSchema, @@ -93,11 +92,21 @@ export async function manageSourceControl( runId: auth.authContext.runId, taskId, }); - const isGitHubTask = - resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'; + const payload = + taskRun.payload && + typeof taskRun.payload === 'object' && + !Array.isArray(taskRun.payload) + ? (taskRun.payload as Record) + : {}; + const targetProvider = + input.sourceControlProvider ?? + resolveSourceControlProviderForRepositoryFromPayload( + payload, + input.repositoryFullName, + ); const bodyInput = 'body' in input ? input : null; const shouldQuote = - isGitHubTask && + targetProvider === 'github' && (input.action === 'reply_to_pull_request_comment' || input.action === 'create_pull_request_comment' || input.action === 'create_issue_comment') && diff --git a/apps/docs/environments.mdx b/apps/docs/environments.mdx index eead94d4b..218eff010 100644 --- a/apps/docs/environments.mdx +++ b/apps/docs/environments.mdx @@ -37,6 +37,9 @@ environment and add any setup guidance you already know. Roomote then starts an environment setup task that figures out how to run the app locally and prepare the workspace for future tasks. +An environment can combine repositories from different source-control +providers, such as GitHub and GitLab. + The setup task is meant to produce a working environment Roomote can reuse. If it cannot finish, adjust the input and try again from **Settings > Environments**. diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index e10413712..f8e16baab 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -466,6 +466,24 @@ describe('environment repository validation', () => { expect(mockGetBranches).not.toHaveBeenCalled(); }); + it('uses provider-neutral guidance when repository access fails', async () => { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ where: async () => [] }), + }); + mockCheckRepoAccess.mockResolvedValue(false); + + const result = await validateConfigCommand(buildMockAuth(), { + config: { + name: 'GitLab Test', + repositories: [{ repository: 'acme/backend' }], + }, + }); + + expect(result.errors).toEqual([ + "Repository 'acme/backend' is not accessible. Ensure it is connected through its source-control provider.", + ]); + }); + it('continues warning when a GitHub branch is missing', async () => { mockDbSelect.mockReturnValueOnce({ from: () => ({ diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 4802f39ac..4e390949f 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -1249,7 +1249,7 @@ export async function validateConfigCommand( if (!hasAccess) { errors.push( - `Repository '${repo.repository}' is not accessible. Ensure it is installed via the GitHub App.`, + `Repository '${repo.repository}' is not accessible. Ensure it is connected through its source-control provider.`, ); return; // skip branch check if repo itself is inaccessible } diff --git a/apps/worker/src/commands/__tests__/snapshot.test.ts b/apps/worker/src/commands/__tests__/snapshot.test.ts index 9b7f4af19..559d1c3b8 100644 --- a/apps/worker/src/commands/__tests__/snapshot.test.ts +++ b/apps/worker/src/commands/__tests__/snapshot.test.ts @@ -3,6 +3,7 @@ import { RunStatus } from '@roomote/types'; const { mockTaskRunsUpdate, mockFetchSnapshotEnv, + mockFindTaskRun, mockFindEnvironment, mockDone, mockUpdateSnapshotStatus, @@ -16,6 +17,7 @@ const { } = vi.hoisted(() => ({ mockTaskRunsUpdate: vi.fn(), mockFetchSnapshotEnv: vi.fn(), + mockFindTaskRun: vi.fn(), mockFindEnvironment: vi.fn(), mockDone: vi.fn(), mockUpdateSnapshotStatus: vi.fn(), @@ -33,6 +35,7 @@ vi.mock('@roomote/sdk/client', () => ({ taskRuns: { update: mockTaskRunsUpdate, fetchSnapshotEnv: mockFetchSnapshotEnv, + findFirstById: mockFindTaskRun, done: mockDone, }, environments: { @@ -91,6 +94,13 @@ describe('snapshot', () => { repositories: [{ repository: 'Roomote/example-app' }], }, }); + mockFindTaskRun.mockResolvedValue({ + payload: { + repositoryProviders: { + 'Roomote/example-app': 'gitlab', + }, + }, + }); mockWorkerEnvFromProcessEnv.mockReturnValue({}); mockCreateStartupLogger.mockReturnValue({ userLog: { log: vi.fn() } }); mockDone.mockResolvedValue(undefined); @@ -120,6 +130,15 @@ describe('snapshot', () => { environmentId: 'env-1', snapshotStatus: 'failed', }); + expect(mockSetup).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: expect.objectContaining({ + repositoryProviders: { + 'Roomote/example-app': 'gitlab', + }, + }), + }), + ); const injectCallOrder = mockInjectEnvVars.mock.invocationCallOrder[0]; const findEnvironmentCallOrder = mockFindEnvironment.mock.invocationCallOrder[0]; diff --git a/apps/worker/src/commands/setup/__tests__/workspace.test.ts b/apps/worker/src/commands/setup/__tests__/workspace.test.ts index ceedbb19c..b26c73ef7 100644 --- a/apps/worker/src/commands/setup/__tests__/workspace.test.ts +++ b/apps/worker/src/commands/setup/__tests__/workspace.test.ts @@ -140,6 +140,73 @@ describe('initializeRepositories', () => { ); }); + it('resolves repository providers from the map before the scalar fallback', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockImplementation(async (repo) => `/tmp/${repo}`); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'repository_set', + repositories: ['acme/github-app', 'acme/gitlab-app'], + }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + sourceControlProvider: 'github', + repositoryProviders: { + 'acme/gitlab-app': 'gitlab', + }, + }); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + {}, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + + it('uses a mapped provider for a single-repository workspace', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockResolvedValue('/tmp/acme/app'); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'repository', + repository: 'acme/app', + }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + repositoryProviders: { 'acme/app': 'gitlab' }, + }); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + it('continues scoped multi-repo workspace setup when at least one selected repository prepares successfully', async () => { const logger = createLogger(); vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( @@ -237,6 +304,44 @@ describe('initializeRepositories', () => { ); }); + it('applies mapped providers to repositories discovered for all-repositories workspaces', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + mockListRepositories.mockResolvedValue([ + { fullName: 'acme/github-app' }, + { fullName: 'acme/gitlab-app' }, + ]); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockImplementation(async (repo) => `/tmp/${repo}`); + + await initializeRepositories(createLogger(), { + workspace: { type: 'all_repositories' }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + repositoryProviders: { 'acme/gitlab-app': 'gitlab' }, + }); + + expect(mockListRepositories).toHaveBeenCalledTimes(1); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + {}, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + it('fails all-repositories workspace setup when no repositories can be prepared', async () => { vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( undefined, @@ -420,6 +525,49 @@ describe('initializeRepositories', () => { {}, ); }); + + it('passes repository provider overrides to environment preparation', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareEnvironmentRepositoriesSpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareEnvironmentRepositories') + .mockResolvedValue({ repoPaths: {} }); + vi.spyOn( + WorkspaceManager.prototype, + 'installWorkspaceToolVersions', + ).mockResolvedValue(undefined); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'environment', + environmentId: 'env_123', + environmentConfig: { + name: 'Mixed Providers', + repositories: [ + { repository: 'acme/github-app' }, + { repository: 'acme/gitlab-app' }, + ], + }, + } as WorkspaceConfig, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + sourceControlProvider: 'github', + repositoryProviders: { + 'acme/gitlab-app': 'gitlab', + }, + }); + + expect(prepareEnvironmentRepositoriesSpy).toHaveBeenCalledWith( + expect.any(Object), + false, + false, + expect.any(Object), + { + repositoryProviders: { 'acme/gitlab-app': 'gitlab' }, + }, + ); + }); }); describe('initializeSystemServices', () => { diff --git a/apps/worker/src/commands/setup/workspace/repositories.ts b/apps/worker/src/commands/setup/workspace/repositories.ts index c25d05196..242f4a3dd 100644 --- a/apps/worker/src/commands/setup/workspace/repositories.ts +++ b/apps/worker/src/commands/setup/workspace/repositories.ts @@ -70,14 +70,26 @@ export async function initializeRepositories( gitAuthorName, gitAuthorEmail, sourceControlProvider, + repositoryProviders, }: PrepareWorkspaceOptions, ): Promise { const resolvedSourceControlProvider = sourceControlProvider ?? DEFAULT_SOURCE_CONTROL_PROVIDER; - const sourceControlPrepareOptions = - resolvedSourceControlProvider === DEFAULT_SOURCE_CONTROL_PROVIDER + const resolveRepositoryProvider = (repository: string) => + repositoryProviders?.[repository] ?? resolvedSourceControlProvider; + const sourceControlPrepareOptions = (repository: string) => { + const repositoryProvider = resolveRepositoryProvider(repository); + + return repositoryProvider === DEFAULT_SOURCE_CONTROL_PROVIDER + ? {} + : { sourceControlProvider: repositoryProvider }; + }; + const environmentSourceControlPrepareOptions = { + ...(resolvedSourceControlProvider === DEFAULT_SOURCE_CONTROL_PROVIDER ? {} - : { sourceControlProvider: resolvedSourceControlProvider }; + : { sourceControlProvider: resolvedSourceControlProvider }), + ...(repositoryProviders ? { repositoryProviders } : {}), + }; const { workspaceRoot, workspaceManager } = createWorkspaceManager( envVars, logger, @@ -104,7 +116,7 @@ export async function initializeRepositories( sourceBranch: workspace.sourceBranch, sourceSha: workspace.sourceSha, }, - sourceControlPrepareOptions, + environmentSourceControlPrepareOptions, ), ); @@ -167,7 +179,7 @@ export async function initializeRepositories( preserveGitState, cleanupLegacyPaths, { - ...sourceControlPrepareOptions, + ...sourceControlPrepareOptions(repo.fullName), }, ), ); @@ -317,7 +329,7 @@ export async function initializeRepositories( workspace.sha, preserveGitState, cleanupLegacyPaths, - sourceControlPrepareOptions, + sourceControlPrepareOptions(workspace.repository), ), ); } catch (error) { diff --git a/apps/worker/src/commands/setup/workspace/types.ts b/apps/worker/src/commands/setup/workspace/types.ts index 64750c34e..d1ba1208f 100644 --- a/apps/worker/src/commands/setup/workspace/types.ts +++ b/apps/worker/src/commands/setup/workspace/types.ts @@ -66,6 +66,7 @@ export interface PrepareWorkspaceOptions { preserveGitState?: boolean; cleanupLegacyPaths?: boolean; sourceControlProvider?: SourceControlProvider; + repositoryProviders?: Record; serviceContext?: ServiceContext; gitAuthorName?: string; gitAuthorEmail?: string; diff --git a/apps/worker/src/commands/snapshot.ts b/apps/worker/src/commands/snapshot.ts index d65989536..f5ad71d92 100644 --- a/apps/worker/src/commands/snapshot.ts +++ b/apps/worker/src/commands/snapshot.ts @@ -17,6 +17,7 @@ import { import { setup } from './setup'; import { injectEnvVars } from './utils/env-vars'; +import { resolveRepositoryProvidersFromPayload } from './utils/repository-providers'; import { scrubSandboxSecretsBeforeSnapshot } from './utils/scrub-sandbox-secrets'; import { findRuntimeEnvironmentConfig } from './utils/workspace-config'; @@ -82,6 +83,7 @@ export async function snapshot({ await injectEnvVars(envVars, undefined, { sourceControlToken }); const environmentConfig = await findRuntimeEnvironmentConfig(environmentId); + const taskRun = await sdk.taskRuns.findFirstById(runId); if (!environmentConfig) { throw new Error(`Environment not found`); @@ -99,6 +101,9 @@ export async function snapshot({ taskRunType: TaskPayloadKind.SnapshotEnvironment, sourceControlProvider: sourceControlToken?.provider ?? DEFAULT_SOURCE_CONTROL_PROVIDER, + repositoryProviders: resolveRepositoryProvidersFromPayload( + taskRun?.payload, + ), }, logger: startupLogger, workerEnv, diff --git a/apps/worker/src/commands/utils/execute-task-run.test.ts b/apps/worker/src/commands/utils/execute-task-run.test.ts index 75b0cc0d1..a390b635a 100644 --- a/apps/worker/src/commands/utils/execute-task-run.test.ts +++ b/apps/worker/src/commands/utils/execute-task-run.test.ts @@ -178,6 +178,9 @@ describe('executeTaskRun', () => { harness: 'opencode-server', payload: { repo: 'owner/repo', + repositoryProviders: { + 'owner/repo': 'gitlab', + }, }, }, envVars: { @@ -234,6 +237,9 @@ describe('executeTaskRun', () => { expect(setupArgs.workspace.envVars).toMatchObject({ FOO: 'bar', }); + expect(setupArgs.workspace.repositoryProviders).toEqual({ + 'owner/repo': 'gitlab', + }); expect(typeof setupArgs.recordPhase).toBe('function'); expect(sdkTaskRunsStampMilestoneMock).toHaveBeenCalledWith({ runId: 42, diff --git a/apps/worker/src/commands/utils/execute-task-run.ts b/apps/worker/src/commands/utils/execute-task-run.ts index f08420aa7..1f92976d8 100644 --- a/apps/worker/src/commands/utils/execute-task-run.ts +++ b/apps/worker/src/commands/utils/execute-task-run.ts @@ -50,6 +50,7 @@ import { import { BackgroundEnvironmentSetupController } from './background-environment-setup-controller'; import { injectEnvVars, writeBashrc } from './env-vars'; +import { resolveRepositoryProvidersFromPayload } from './repository-providers'; import { buildServiceContextForPreviewProxy } from './service-context'; import { finalizeJob, handleTaskRunError } from './task-run-lifecycle'; @@ -539,6 +540,9 @@ export async function executeTaskRun({ sourceControlProvider: resolveSourceControlProviderFromPayload( currentTaskRun.payload, ), + repositoryProviders: resolveRepositoryProvidersFromPayload( + currentTaskRun.payload, + ), gitAuthorName: jobContext.gitAuthor?.name, gitAuthorEmail: jobContext.gitAuthor?.email, }, diff --git a/apps/worker/src/commands/utils/repository-providers.ts b/apps/worker/src/commands/utils/repository-providers.ts new file mode 100644 index 000000000..7dd160673 --- /dev/null +++ b/apps/worker/src/commands/utils/repository-providers.ts @@ -0,0 +1,15 @@ +import type { SourceControlProvider } from '@roomote/types'; + +export function resolveRepositoryProvidersFromPayload( + payload: unknown, +): Record | undefined { + if (!payload || typeof payload !== 'object') { + return undefined; + } + + return ( + payload as { + repositoryProviders?: Record; + } + ).repositoryProviders; +} diff --git a/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts b/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts index a940a202e..2ba043abe 100644 --- a/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts +++ b/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts @@ -30,6 +30,12 @@ vi.mock('os', async (importOriginal) => { }); const COMMON_ENV_PATH = '/home/testuser/.roomote/env.sh'; +const TOKEN_ENV_PATHS = [ + '/home/testuser/.roomote/gh-token-env.sh', + '/home/testuser/.roomote/gitlab-token-env.sh', + '/home/testuser/.roomote/gitea-token-env.sh', + '/home/testuser/.roomote/ado-token-env.sh', +]; const EXPECTED_REMOVED_PATHS = [ '/home/testuser/.roomote/gh-token', @@ -89,6 +95,14 @@ describe('scrubSandboxSecretsBeforeSnapshot', () => { } }); + it('recreates every provider token env script before snapshotting', async () => { + await scrubSandboxSecretsBeforeSnapshot(); + + for (const path of TOKEN_ENV_PATHS) { + expect(findWrite(path)).toBeDefined(); + } + }); + it('respects XDG_DATA_HOME when locating OpenCode credential files', async () => { process.env.XDG_DATA_HOME = '/custom/data'; diff --git a/apps/worker/src/workspace/__tests__/tool-versions.test.ts b/apps/worker/src/workspace/__tests__/tool-versions.test.ts index 604a0f9c1..dbb26226b 100644 --- a/apps/worker/src/workspace/__tests__/tool-versions.test.ts +++ b/apps/worker/src/workspace/__tests__/tool-versions.test.ts @@ -876,6 +876,54 @@ describe('WorkspaceManager tool versions', () => { }, ); }); + + it('chooses the source control provider separately for each repository', async () => { + const prepareRepositorySpy = vi + .spyOn(manager, 'prepareRepository') + .mockImplementation(async (repository) => `/workspace/${repository}`); + + await manager.prepareEnvironmentRepositories( + { + name: 'Mixed Providers', + repositories: [ + { repository: 'acme/github-app' }, + { repository: 'acme/gitlab-app' }, + ], + }, + false, + false, + undefined, + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/gitlab-app': 'gitlab' }, + }, + ); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + { + sourceControlProvider: 'github', + setDefaultRemote: false, + toolVersionsConfig: undefined, + }, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { + sourceControlProvider: 'gitlab', + setDefaultRemote: false, + toolVersionsConfig: undefined, + }, + ); + }); }); describe('installWorkspaceToolVersions', () => { diff --git a/apps/worker/src/workspace/workspace-manager.ts b/apps/worker/src/workspace/workspace-manager.ts index daa4074a3..5f1757899 100644 --- a/apps/worker/src/workspace/workspace-manager.ts +++ b/apps/worker/src/workspace/workspace-manager.ts @@ -766,7 +766,9 @@ export class WorkspaceManager { sourceBranch?: string; sourceSha?: string; }, - options: Pick = {}, + options: Pick & { + repositoryProviders?: Record; + } = {}, ): Promise<{ repoPaths: Record; }> { @@ -794,7 +796,9 @@ export class WorkspaceManager { preserveGitState, cleanupLegacyPaths, { - sourceControlProvider: options.sourceControlProvider, + sourceControlProvider: + options.repositoryProviders?.[repoConfig.repository] ?? + options.sourceControlProvider, toolVersionsConfig: repoConfig.tool_versions, setDefaultRemote: false, }, diff --git a/packages/ado/src/api.ts b/packages/ado/src/api.ts index 3aa16975b..4b7a37227 100644 --- a/packages/ado/src/api.ts +++ b/packages/ado/src/api.ts @@ -1482,6 +1482,18 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { async function resolveAdoRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { + const filterForAdo = (repositoryNames: string[]) => { + const repositoryProviders = ( + taskRun.payload as { repositoryProviders?: Record } + ).repositoryProviders; + + return repositoryNames.filter( + (repositoryName) => + repositoryProviders?.[repositoryName] === undefined || + repositoryProviders[repositoryName] === ADO_PROVIDER, + ); + }; + if (taskRun.payload.environmentId) { const environment = await db.query.environments.findFirst({ where: eq(environments.id, taskRun.payload.environmentId), @@ -1493,9 +1505,11 @@ async function resolveAdoRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterForAdo( + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -1506,7 +1520,7 @@ async function resolveAdoRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterForAdo(selectedRepositories); } } diff --git a/packages/bitbucket/src/api.ts b/packages/bitbucket/src/api.ts index ca757695d..69f09fa1d 100644 --- a/packages/bitbucket/src/api.ts +++ b/packages/bitbucket/src/api.ts @@ -754,6 +754,18 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { async function resolveBitbucketRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { + const filterForBitbucket = (repositoryNames: string[]) => { + const repositoryProviders = ( + taskRun.payload as { repositoryProviders?: Record } + ).repositoryProviders; + + return repositoryNames.filter( + (repositoryName) => + repositoryProviders?.[repositoryName] === undefined || + repositoryProviders[repositoryName] === BITBUCKET_PROVIDER, + ); + }; + if (taskRun.payload.environmentId) { const environment = await db.query.environments.findFirst({ where: eq(environments.id, taskRun.payload.environmentId), @@ -765,9 +777,11 @@ async function resolveBitbucketRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterForBitbucket( + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -778,7 +792,7 @@ async function resolveBitbucketRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterForBitbucket(selectedRepositories); } } diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 31410af03..3c2732423 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -481,6 +481,21 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { return [...new Set(repositoryNames.filter(Boolean))]; } +function filterRepositorySelectionForGitea( + taskRun: TaskRun, + repositoryNames: string[], +): string[] { + const repositoryProviders = ( + taskRun.payload as { repositoryProviders?: Record } + ).repositoryProviders; + + return repositoryNames.filter( + (repositoryName) => + repositoryProviders?.[repositoryName] === undefined || + repositoryProviders[repositoryName] === GITEA_PROVIDER, + ); +} + async function resolveGiteaRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { @@ -495,9 +510,12 @@ async function resolveGiteaRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterRepositorySelectionForGitea( + taskRun, + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -508,7 +526,7 @@ async function resolveGiteaRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterRepositorySelectionForGitea(taskRun, selectedRepositories); } } diff --git a/packages/github/src/__tests__/task-run-token.test.ts b/packages/github/src/__tests__/task-run-token.test.ts index a9cc2647f..e50db6bfb 100644 --- a/packages/github/src/__tests__/task-run-token.test.ts +++ b/packages/github/src/__tests__/task-run-token.test.ts @@ -42,6 +42,7 @@ vi.mock('@roomote/db/server', () => ({ repositories: { fullName: 'repositories.fullName', isActive: 'repositories.isActive', + sourceControlProvider: 'repositories.sourceControlProvider', }, })); @@ -123,6 +124,64 @@ describe('createTaskRunGitHubToken', () => { }); }); + it('ignores selected repositories mapped to another provider', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'ExampleOrg/example-backend', + installationId: 'install-exampleorg', + githubRepoId: 101, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: 'ExampleOrg/example-backend', + selectedRepositories: ['ExampleOrg/example-backend', 'group/project'], + repositoryProviders: { + 'ExampleOrg/example-backend': 'github', + 'group/project': 'gitlab', + }, + } as TaskRun['payload']), + ), + ).resolves.toBe('ghs_test_token'); + + expect(mockCreateGitHubToken).toHaveBeenCalledWith({ + type: 'installationId', + installationId: 'install-exampleorg', + repositoryIds: [101], + }); + }); + + it('still rejects unknown selected repository names', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'ExampleOrg/example-backend', + installationId: 'install-exampleorg', + githubRepoId: 101, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: '__all_repositories__', + selectedRepositories: [ + 'ExampleOrg/example-backend', + 'group/project', + 'unknown/repository', + ], + repositoryProviders: { + 'ExampleOrg/example-backend': 'github', + 'group/project': 'gitlab', + }, + } as TaskRun['payload']), + ), + ).rejects.toThrow( + 'Selected repositories not found for task run 123: unknown/repository', + ); + }); + it('uses the environment repositories installation for environment tasks', async () => { mockFindEnvironmentFirst.mockResolvedValue({ id: '14f1f7c4-b126-4b3f-a6a8-e37f7d299f4d', diff --git a/packages/github/src/api.ts b/packages/github/src/api.ts index e4f4ed76c..d866ca97c 100644 --- a/packages/github/src/api.ts +++ b/packages/github/src/api.ts @@ -43,7 +43,22 @@ async function createTokenForRepositoryNames({ missingMessagePrefix: string; spanningMessagePrefix: string; }): Promise { - const uniqueRepositoryNames = [...new Set(repositoryNames.filter(Boolean))]; + const repositoryProviders = ( + taskRun.payload as { + repositoryProviders?: Record; + } + ).repositoryProviders; + const uniqueRepositoryNames = [ + ...new Set( + repositoryNames.filter( + (repositoryName) => + repositoryName && + (repositoryProviders?.[repositoryName] === undefined || + repositoryProviders[repositoryName] === + DEFAULT_SOURCE_CONTROL_PROVIDER), + ), + ), + ]; const selectedRepoRows = await db.query.repositories.findMany({ where: and( diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 454ae20a4..06a95afb2 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -530,6 +530,57 @@ describe('createTaskRunScopedGitLabTokens', () => { ); }); + it('ignores selected repositories mapped to another provider', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 999, + token: 'glptt_repo_scoped', + username: 'oauth2', + }), + { status: 201 }, + ), + ); + + const result = await createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: 'group/project', + selectedRepositories: ['group/project', 'ExampleOrg/example-backend'], + repositoryProviders: { + 'group/project': 'gitlab', + 'ExampleOrg/example-backend': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']), + { fetchImpl: fetchMock }, + ); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]?.repositoryFullName).toBe('group/project'); + }); + + it('still rejects unknown selected repository names', async () => { + await expect( + createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: 'group/project', + selectedRepositories: [ + 'group/project', + 'ExampleOrg/example-backend', + 'unknown/repository', + ], + repositoryProviders: { + 'group/project': 'gitlab', + 'ExampleOrg/example-backend': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']), + ), + ).rejects.toThrow( + 'Selected GitLab repositories not found for task run 123: unknown/repository', + ); + }); + it('mints scoped tokens against a self-managed GITLAB_BASE_URL with the self-managed credential host', async () => { process.env.GITLAB_BASE_URL = 'https://gitlab.example.com'; diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index f35f6ad3d..fac3662dc 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -913,6 +913,21 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { return [...new Set(repositoryNames.filter(Boolean))]; } +function filterRepositorySelectionForGitLab( + taskRun: TaskRun, + repositoryNames: string[], +): string[] { + const repositoryProviders = ( + taskRun.payload as { repositoryProviders?: Record } + ).repositoryProviders; + + return repositoryNames.filter( + (repositoryName) => + repositoryProviders?.[repositoryName] === undefined || + repositoryProviders[repositoryName] === GITLAB_PROVIDER, + ); +} + async function resolveGitLabRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { @@ -927,9 +942,12 @@ async function resolveGitLabRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterRepositorySelectionForGitLab( + taskRun, + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -940,7 +958,7 @@ async function resolveGitLabRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterRepositorySelectionForGitLab(taskRun, selectedRepositories); } } diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 1fba1362a..a7666126f 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -255,6 +255,7 @@ export { type SourceControlPullRequestMutationInput, type SourceControlPullRequestMutationResult, } from './lib/pull-requests/source-control-pull-requests'; +export { resolveSourceControlProviderForRepositoryFromPayload } from './lib/pull-requests/source-control-pull-request-shared'; export { readSourceControlPullRequestForTaskRun, sourceControlPullRequestReadInputSchema, diff --git a/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts b/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts index ec52a4843..c4927c67c 100644 --- a/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts +++ b/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts @@ -147,6 +147,60 @@ describe('manageSourceControlIssueForTaskRun', () => { }); }); + it('routes a GitLab issue in a GitHub-primary mixed task', async () => { + mockResolveRepositoryRow.mockResolvedValue({ + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '123', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + mockResolveGitLabProviderContext.mockResolvedValue({ + projectId: '123', + token: 'server-side-token', + apiBaseUrl: 'https://gitlab.com/api/v4', + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + iid: 9, + title: 'Broken checkout', + state: 'opened', + web_url: 'https://gitlab.com/acme/backend/-/issues/9', + author: { username: 'alice' }, + labels: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const mixedTaskRun = taskRun('github'); + mixedTaskRun.payload = { + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']; + + const result = await manageSourceControlIssueForTaskRun({ + taskRun: mixedTaskRun, + input: { + action: 'get_issue', + repositoryFullName: 'acme/backend', + issueNumber: 9, + }, + fetchImpl, + }); + + expect(mockResolveRepositoryRow).toHaveBeenCalledWith({ + provider: 'gitlab', + repositoryFullName: 'acme/backend', + host: undefined, + }); + expect(result).toMatchObject({ provider: 'gitlab', number: 9 }); + }); + it('posts a Gitea issue comment with server-resolved credentials', async () => { mockResolveRepositoryRow.mockResolvedValue({ id: 'repo-2', diff --git a/packages/sdk/src/server/lib/issues/source-control-issues.ts b/packages/sdk/src/server/lib/issues/source-control-issues.ts index 86a3322c1..7eb333844 100644 --- a/packages/sdk/src/server/lib/issues/source-control-issues.ts +++ b/packages/sdk/src/server/lib/issues/source-control-issues.ts @@ -2,13 +2,13 @@ import { type TaskRun } from '@roomote/db/server'; import { getSourceControlProviderLabel, resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, } from '@roomote/types'; import { assertRepositoryInTaskRunScope, getPayloadRecord, resolveRepositoryRow, + resolveSourceControlProviderForRepositoryFromPayload, type FetchImpl, } from '../pull-requests/source-control-pull-request-shared'; import { getIssueProviderOperations } from './source-control-issue-providers'; @@ -39,7 +39,10 @@ export async function manageSourceControlIssueForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payload = getPayloadRecord(taskRun.payload); - const payloadProvider = resolveSourceControlProviderFromPayload(payload); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payload, + input.repositoryFullName, + ); const provider = input.sourceControlProvider ?? payloadProvider; if (provider !== payloadProvider) { diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts index c00fe2064..a65884519 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts @@ -223,7 +223,7 @@ describe('readSourceControlPullRequestForTaskRun', () => { }); }); - it('reads GitLab merge request details through the deployment token', async () => { + it('reads a GitLab merge request in a GitHub-primary mixed task', async () => { mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, externalRepoId: '101', @@ -253,14 +253,15 @@ describe('readSourceControlPullRequestForTaskRun', () => { const result = await readSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'get_pull_request', repositoryFullName: 'acme/backend', prNumber: 42, - sourceControlProvider: 'gitlab', }, fetchImpl, }); @@ -1163,15 +1164,17 @@ describe('readSourceControlPullRequestForTaskRun', () => { ).rejects.toThrow('prNumber is required for get_pull_request.'); }); - it('rejects reads whose provider does not match the task payload', async () => { + it('rejects an explicit provider that conflicts with the repository map', async () => { const fetchImpl = vi.fn(); await expect( readSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'get_pull_request', repositoryFullName: 'acme/backend', diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts index 0a75ad389..dc8e6dfb2 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts @@ -4,7 +4,36 @@ vi.mock('@roomote/gitlab', () => ({ isGitLabOAuthAccessToken: (token: string) => token === 'oauth-token', })); -import { buildGitLabTokenHeader } from '../source-control-pull-request-shared'; +import { + buildGitLabTokenHeader, + resolveSourceControlProviderForRepositoryFromPayload, +} from '../source-control-pull-request-shared'; + +describe('resolveSourceControlProviderForRepositoryFromPayload', () => { + it('prefers the target repository provider over the task primary provider', () => { + expect( + resolveSourceControlProviderForRepositoryFromPayload( + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + 'acme/backend', + ), + ).toBe('gitlab'); + }); + + it('falls back to the task primary provider when the repository is unmapped', () => { + expect( + resolveSourceControlProviderForRepositoryFromPayload( + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + 'acme/frontend', + ), + ).toBe('github'); + }); +}); describe('buildGitLabTokenHeader', () => { it('uses the Bearer authorization header for OAuth tokens', () => { diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts index b4c1d9766..5884c1ec7 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts @@ -128,7 +128,7 @@ describe('writeSourceControlPullRequestForTaskRun', () => { ); }); - it('replies to a GitLab discussion through the deployment token', async () => { + it('replies to a GitLab discussion in a GitHub-primary mixed task', async () => { mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, externalRepoId: '101', @@ -141,16 +141,17 @@ describe('writeSourceControlPullRequestForTaskRun', () => { const result = await writeSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'reply_to_pull_request_comment', repositoryFullName: 'acme/backend', prNumber: 42, threadId: 'abc123', body: 'Thanks, fixed.', - sourceControlProvider: 'gitlab', }, fetchImpl, }); diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index 9ed53fef6..765008932 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -192,7 +192,7 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { ); }); - it('creates a GitLab merge request through the deployment token', async () => { + it('creates a GitLab merge request in a GitHub-primary mixed task', async () => { mockGetDeploymentPrAction.mockResolvedValue('create'); mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, @@ -214,9 +214,11 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { const result = await createOrUpdateSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'create_or_update_pull_request', repositoryFullName: 'acme/backend', @@ -226,7 +228,6 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { body: 'Body', labels: ['roomote'], assignees: [], - sourceControlProvider: 'gitlab', }, fetchImpl, }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts index 0c6e3500c..9336f5e35 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts @@ -5,7 +5,6 @@ import { buildPullRequestUrl, getSourceControlProviderLabel, resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type SourceControlProvider, } from '@roomote/types'; @@ -26,6 +25,7 @@ import { isDraftTitle, isGitLabDraft, resolveRepositoryRow, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -609,8 +609,10 @@ export async function readSourceControlPullRequestForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); const provider = input.sourceControlProvider ?? payloadProvider; diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts index 3c311281a..31df99915 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts @@ -10,6 +10,8 @@ import { ALL_REPOSITORIES, environmentConfigSchema, getSourceControlProviderLabel, + normalizeSourceControlProvider, + resolveSourceControlProviderFromPayload, type SourceControlProvider, } from '@roomote/types'; import { isGitLabOAuthAccessToken } from '@roomote/gitlab'; @@ -35,6 +37,29 @@ export type RepositoryRow = { htmlUrl: string; }; +export function resolveSourceControlProviderForRepositoryFromPayload( + payload: Record, + repositoryFullName: string, +): SourceControlProvider { + const repositoryProviders = payload.repositoryProviders; + + if ( + repositoryProviders && + typeof repositoryProviders === 'object' && + !Array.isArray(repositoryProviders) + ) { + const repositoryProvider = (repositoryProviders as Record)[ + repositoryFullName + ]; + + if (repositoryProvider !== undefined) { + return normalizeSourceControlProvider(repositoryProvider); + } + } + + return resolveSourceControlProviderFromPayload(payload); +} + /** * Shared provider-resolution and name/url plumbing for the provider-neutral * source-control pull-request surface. HTTP transport lives in diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts index ec93eac48..55d5ef039 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts @@ -4,7 +4,6 @@ import { type TaskRun } from '@roomote/db/server'; import { getSourceControlProviderLabel, resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type SourceControlProvider, } from '@roomote/types'; @@ -28,6 +27,7 @@ import { formatResponseBody, getPayloadRecord, resolveRepositoryRow, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -233,8 +233,10 @@ export async function writeSourceControlPullRequestForTaskRun({ assertWriteInputFields(input); const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); const provider = input.sourceControlProvider ?? payloadProvider; diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index 7563e4116..4d7370512 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -23,7 +23,6 @@ import { normalizePrBodyAttributionAppMention, prActions, resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type PrAction, type SourceControlProvider, @@ -57,6 +56,7 @@ import { isDraftTitle, isGitLabDraft, resolveRepositoryRow, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -182,8 +182,10 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); const provider = input.sourceControlProvider ?? payloadProvider; diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index 341fc57f5..2ff60d25b 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -8,6 +8,7 @@ const { mockCreateTaskRunScopedGitLabTokens, mockCreateTaskRunGiteaCredentials, mockCreateTaskRunAdoCredentials, + mockCreateTaskRunBitbucketCredentials, mockResolveSandboxModelRuntimeEnv, mockTaskRunsFindFirst, mockNotifySourceRunOnSettle, @@ -19,6 +20,7 @@ const { mockCreateTaskRunScopedGitLabTokens: vi.fn(), mockCreateTaskRunGiteaCredentials: vi.fn(), mockCreateTaskRunAdoCredentials: vi.fn(), + mockCreateTaskRunBitbucketCredentials: vi.fn(), mockResolveSandboxModelRuntimeEnv: vi.fn(), mockTaskRunsFindFirst: vi.fn(), mockNotifySourceRunOnSettle: vi.fn(), @@ -86,6 +88,11 @@ vi.mock('@roomote/ado', () => ({ mockCreateTaskRunAdoCredentials(...args), })); +vi.mock('@roomote/bitbucket', () => ({ + createTaskRunBitbucketCredentials: (...args: unknown[]) => + mockCreateTaskRunBitbucketCredentials(...args), +})); + vi.mock('@roomote/cloud-agents/server', () => ({ releaseTaskRun: vi.fn(), })); @@ -175,6 +182,9 @@ describe('createSourceControlTokenForTaskRun', () => { }, ], }); + mockCreateTaskRunBitbucketCredentials.mockResolvedValue({ + credentials: [], + }); }); it('creates GitHub token metadata by default', async () => { @@ -392,6 +402,88 @@ describe('createSourceControlTokenForTaskRun', () => { expect(mockCreateTaskRunScopedGitLabTokens).toHaveBeenCalled(); }); + it('mints mapped providers primary-first and merges aggregate metadata', async () => { + const taskRun = makeTaskRun({ + repo: 'owner/repo', + selectedRepositories: ['owner/repo', 'group/project'], + repositoryProviders: { + 'group/project': 'gitlab', + 'owner/repo': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']); + + const result = await createSourceControlTokenForTaskRun(taskRun, '[test]', { + maxRetries: 1, + }); + + expect(result).toEqual({ + provider: 'github', + token: 'ghs_app_token', + envVar: 'GH_TOKEN', + envVars: { GH_TOKEN: 'ghs_app_token' }, + gitCredentials: [ + { + host: 'gitlab.com', + repositoryFullName: 'group/project', + username: 'oauth2', + token: 'glptt_scoped_token', + }, + ], + gitProxyCredentials: [], + source: 'app', + expiresAt: null, + artifactsPatch: { + gitlabScopedProjectTokens: [ + { + repositoryFullName: 'group/project', + projectId: '101', + tokenId: 202, + }, + ], + }, + }); + expect( + mockCreateTaskRunWorkerGitHubToken.mock.invocationCallOrder[0], + ).toBeLessThan( + mockCreateTaskRunScopedGitLabTokens.mock.invocationCallOrder[0]!, + ); + }); + + it('retries the whole mapped-provider operation and returns no partial token', async () => { + mockCreateTaskRunScopedGitLabTokens.mockRejectedValue( + new Error('GitLab unavailable'), + ); + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'owner/repo', + repositoryProviders: { + 'owner/repo': 'github', + 'group/project': 'gitlab', + }, + description: 'Work across providers', + } as TaskRun['payload']), + '[test]', + { maxRetries: 2, baseDelayMs: 0 }, + ); + + expect(result).toBeNull(); + expect(mockCreateTaskRunWorkerGitHubToken).toHaveBeenCalledTimes(2); + expect(mockCreateTaskRunScopedGitLabTokens).toHaveBeenCalledTimes(2); + } finally { + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + } + }); + it('returns null when GitLab token is missing', async () => { mockCreateTaskRunScopedGitLabTokens.mockRejectedValueOnce( new Error('GITLAB_TOKEN is required for GitLab source control jobs.'), @@ -519,6 +611,23 @@ describe('redactSourceControlProviderEnvVars', () => { expect(redactSourceControlProviderEnvVars(envVars, 'github')).toBe(envVars); }); + + it('redacts every non-GitHub deployment token for mixed-provider jobs', () => { + expect( + redactSourceControlProviderEnvVars( + { + GH_TOKEN: 'operator-github-token', + GITLAB_TOKEN: 'glpat_deployment_token', + BITBUCKET_OAUTH: 'bitbucket_deployment_token', + OPENAI_API_KEY: 'sk-test', + }, + ['github', 'gitlab', 'bitbucket'], + ), + ).toEqual({ + GH_TOKEN: 'operator-github-token', + OPENAI_API_KEY: 'sk-test', + }); + }); }); describe('redactControlPlaneEnvVars', () => { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts index d7290f758..b3149a3e9 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts @@ -24,6 +24,7 @@ const { mockRecordTaskRunLifecycleEvent, mockRecordSnapshotResumeEvent, mockResolveSlackTaskRunRouting, + mockResolveTaskRunSourceControlProviders, onBootstrapFailureMock, } = vi.hoisted(() => ({ mockDbTransaction: vi.fn(), @@ -48,6 +49,7 @@ const { mockRecordTaskRunLifecycleEvent: vi.fn(), mockRecordSnapshotResumeEvent: vi.fn(), mockResolveSlackTaskRunRouting: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), onBootstrapFailureMock: vi.fn(), })); @@ -88,6 +90,8 @@ vi.mock('../dequeue-helpers', () => ({ reportBootstrapFailure: (...args: unknown[]) => mockReportBootstrapFailure(...args), resolveGitAuthor: (...args: unknown[]) => mockResolveGitAuthor(...args), + resolveTaskRunSourceControlProviders: (...args: unknown[]) => + mockResolveTaskRunSourceControlProviders(...args), })); vi.mock('../slack-task-run-routing', () => ({ @@ -149,6 +153,7 @@ describe('dequeueResumeTaskRun', () => { mockEq.mockReturnValue('eq-clause'); mockClaimJobById.mockReturnValue('claim-query'); mockFetchEnvVars.mockResolvedValue({ ORG_ENV: '1' }); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); mockResolveGitAuthor.mockResolvedValue({ name: 'Roomote', email: 'roomote@example.com', @@ -217,6 +222,18 @@ describe('dequeueResumeTaskRun', () => { it("returns the task's harnessSessionId for snapshot resume", async () => { const resumeRun = makeSnapshotResumeRun(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'gl-token', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'gh-token' }, + source: 'app', + expiresAt: null, + }); mockTxExecute.mockResolvedValue([{ id: resumeRun.id }]); mockTxFindFirstTaskRuns.mockResolvedValueOnce(resumeRun); @@ -225,6 +242,14 @@ describe('dequeueResumeTaskRun', () => { }); expect(result?.harnessSessionId).toBe('session-canonical'); + expect(result?.gitHubToken).toBe('gh-token'); + expect(mockFetchEnvVars).toHaveBeenCalledWith(expect.anything(), { + sourceControlProvider: ['gitlab', 'github'], + }); + expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith( + { ORG_ENV: '1' }, + { sourceControlProvider: ['gitlab', 'github'] }, + ); expect(result?.harnessInstructions).toBe('preserved instructions'); expect(result?.sourceSelectedRepositories).toEqual([ 'acme/api', diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts index 5f8849341..c294261c3 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts @@ -25,6 +25,7 @@ const { mockRecordTaskRunLifecycleEvent, mockGetRedis, mockResolveSlackTaskRunRouting, + mockResolveTaskRunSourceControlProviders, onBootstrapFailureMock, } = vi.hoisted(() => ({ mockDbTransaction: vi.fn(), @@ -50,6 +51,7 @@ const { mockRecordTaskRunLifecycleEvent: vi.fn(), mockGetRedis: vi.fn(() => 'redis-client'), mockResolveSlackTaskRunRouting: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), onBootstrapFailureMock: vi.fn(), })); @@ -99,6 +101,8 @@ vi.mock('../dequeue-helpers', () => ({ reportBootstrapFailure: (...args: unknown[]) => mockReportBootstrapFailure(...args), resolveGitAuthor: (...args: unknown[]) => mockResolveGitAuthor(...args), + resolveTaskRunSourceControlProviders: (...args: unknown[]) => + mockResolveTaskRunSourceControlProviders(...args), })); import { dequeueTaskRun } from '../dequeue-task-run'; @@ -217,6 +221,7 @@ describe('dequeueTaskRun', () => { mockSql.mockReturnValue('claim-query'); mockClaimJobById.mockReturnValue('claim-query'); mockFetchEnvVars.mockResolvedValue({ ORG_ENV: '1' }); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); mockResolveGitAuthor.mockResolvedValue({ name: 'Roomote', email: 'roomote@example.com', @@ -301,6 +306,18 @@ describe('dequeueTaskRun', () => { it('treats StandardTask jobs without identity metadata as runnable', async () => { const taskRun = makeStandardTaskRun(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'gl-token', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'gh-token' }, + source: 'app', + expiresAt: null, + }); mockTxExecute.mockResolvedValue([{ id: taskRun.id }]); mockTxFindFirstTaskRuns.mockResolvedValue(taskRun); @@ -319,6 +336,13 @@ describe('dequeueTaskRun', () => { expect(result?.prompt).toBe('prompt'); expect(result?.harnessInstructions).toBe('instructions'); expect(result?.requestedWorkKind).toBe('unknown'); + expect(mockFetchEnvVars).toHaveBeenCalledWith(expect.anything(), { + sourceControlProvider: ['gitlab', 'github'], + }); + expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith( + { ORG_ENV: '1' }, + { sourceControlProvider: ['gitlab', 'github'] }, + ); expect(result?.task).toMatchObject({ id: 'task-101', title: 'Task 101', diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts index b48f67639..27b1f0662 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts @@ -6,10 +6,12 @@ const { mockFindFirst, mockFetchResolvedRuntimeEnvVars, mockCreateSourceControlTokenForTaskRun, + mockResolveTaskRunSourceControlProviders, } = vi.hoisted(() => ({ mockFindFirst: vi.fn(), mockFetchResolvedRuntimeEnvVars: vi.fn(), mockCreateSourceControlTokenForTaskRun: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -31,6 +33,8 @@ vi.mock('@roomote/db/server', () => ({ vi.mock('../dequeue-helpers', () => ({ fetchResolvedRuntimeEnvVars: mockFetchResolvedRuntimeEnvVars, createSourceControlTokenForTaskRun: mockCreateSourceControlTokenForTaskRun, + resolveTaskRunSourceControlProviders: + mockResolveTaskRunSourceControlProviders, })); import { fetchSnapshotEnv } from '../fetch-snapshot-env'; @@ -62,6 +66,7 @@ function makeGitHubToken(token: string) { describe('fetchSnapshotEnv', () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); }); // ── Happy path: deployment-scoped env vars ─────────────────────────── @@ -84,7 +89,7 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); expect(result).toEqual({ - envVars: { MY_SECRET: 'value123' }, + envVars: { MY_SECRET: 'value123', GH_TOKEN: 'ghs_token_abc' }, gitHubToken: 'ghs_token_abc', sourceControlToken: token, taskId: 'task_123', @@ -96,7 +101,7 @@ describe('fetchSnapshotEnv', () => { // Verify the gateway-aware resolution was used (so snapshot env withholds // gateway-served provider keys, like the task dequeue path). expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith(undefined, { - sourceControlProvider: 'github', + sourceControlProvider: ['github'], }); // Verify createSourceControlTokenForTaskRun was called with the task run. @@ -126,14 +131,14 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); expect(result).toEqual({ - envVars: {}, + envVars: { GH_TOKEN: 'ghs_job_token' }, gitHubToken: 'ghs_job_token', sourceControlToken: token, taskId: 'task_123', }); expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith(undefined, { - sourceControlProvider: 'github', + sourceControlProvider: ['github'], }); }); @@ -174,11 +179,41 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); - expect(result.envVars).toEqual({}); + expect(result.envVars).toEqual({ GH_TOKEN: 'ghs_token_xyz' }); expect(result.gitHubToken).toBe('ghs_token_xyz'); expect(result.taskId).toBe('task_123'); }); + it('derives gitHubToken from merged source-control env vars', async () => { + const auth: AuthTokenContext = { + userId: 'user_456', + tokenType: 'auth', + version: 1, + }; + mockFindFirst.mockResolvedValue(makeTaskRun()); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockFetchResolvedRuntimeEnvVars.mockResolvedValue({ MY_SECRET: 'value' }); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'glptt_primary', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'ghs_merged' }, + source: 'app', + expiresAt: null, + }); + + const result = await fetchSnapshotEnv(auth, { runId: 42 }); + + expect(result.gitHubToken).toBe('ghs_merged'); + expect(result.envVars).toEqual({ + MY_SECRET: 'value', + GH_TOKEN: 'ghs_merged', + }); + }); + // ── Source-control token creation fails ───────────────────────────── it('throws when createSourceControlTokenForTaskRun returns null', async () => { diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index a0a8f8f20..cbdf75e0e 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -9,6 +9,7 @@ import { RunStatus, buildSourceControlTokenMetadata, getSourceControlProviderLabel, + normalizeSourceControlProvider, resolveTaskWorkspace, resolveSourceControlProviderFromPayload, type SourceControlProvider, @@ -80,22 +81,28 @@ export function redactControlPlaneEnvVars( export function redactSourceControlProviderEnvVars( envVars: Record, - sourceControlProvider?: SourceControlProvider, + sourceControlProvider?: SourceControlProvider | SourceControlProvider[], ): Record { - if (sourceControlProvider === 'github') { - return envVars; - } - - const providerTokenEnvVars = - sourceControlProvider === 'gitlab' + const providers = Array.isArray(sourceControlProvider) + ? sourceControlProvider + : sourceControlProvider + ? [sourceControlProvider] + : []; + const providerTokenEnvVars = providers.flatMap((provider) => + provider === 'gitlab' ? ['GITLAB_TOKEN'] - : sourceControlProvider === 'gitea' + : provider === 'gitea' ? ['GITEA_TOKEN'] - : sourceControlProvider === 'bitbucket' - ? [] - : sourceControlProvider === 'ado' + : provider === 'bitbucket' + ? ['BITBUCKET_OAUTH'] + : provider === 'ado' ? ['ADO_TOKEN'] - : []; + : [], + ); + + if (providerTokenEnvVars.length === 0) { + return envVars; + } const shouldRedact = providerTokenEnvVars.some( (envVar) => envVars[envVar] !== undefined, ); @@ -133,7 +140,7 @@ export function claimJobById(runId: number) { export async function fetchEnvVars( tx: Parameters[0]>[0], options?: { - sourceControlProvider?: SourceControlProvider; + sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; }, ): Promise> { const encryptedEnvVars = await tx.query.environmentVariables.findMany(); @@ -243,7 +250,7 @@ function redactModelRuntimeManagedEnvVars( export async function fetchResolvedRuntimeEnvVars( deploymentEnvVars?: Record, options?: { - sourceControlProvider?: SourceControlProvider; + sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; }, ): Promise> { const envVars = @@ -406,23 +413,44 @@ export type SourceControlRuntimeToken = SourceControlTokenMetadata & { }; /** - * Resolve the provider for a run's source-control token. Prefers the explicit - * payload stamp; when absent, resolves from the synced repositories the - * workspace references, so non-GitHub deployments work even when a launch - * site forgot to stamp the payload. Falls back to the GitHub default only - * when the workspace repositories are unknown or span providers. + * Resolve the ordered providers for a run's source-control tokens. A repository + * map is authoritative and keeps the primary repository's provider first. + * Legacy payloads retain the existing scalar, workspace, and default fallback. */ -async function resolveTaskRunSourceControlProvider( +export async function resolveTaskRunSourceControlProviders( taskRun: Pick, -): Promise { - const payload = taskRun.payload as { sourceControlProvider?: unknown }; + dbOrTx: Parameters[0] = db, +): Promise { + const payload = taskRun.payload as { + repo?: string; + repositoryProviders?: Record; + sourceControlProvider?: unknown; + }; + + if ( + payload.repositoryProviders && + Object.keys(payload.repositoryProviders).length > 0 + ) { + const orderedEntries = Object.entries(payload.repositoryProviders); + const primaryProvider = payload.repo + ? orderedEntries.find(([repository]) => repository === payload.repo)?.[1] + : undefined; + const providers = [ + ...(primaryProvider === undefined ? [] : [primaryProvider]), + ...orderedEntries + .filter(([repository]) => repository !== payload.repo) + .map(([, provider]) => provider), + ].map(normalizeSourceControlProvider); + + return [...new Set(providers)]; + } if ( payload.sourceControlProvider !== undefined && payload.sourceControlProvider !== null && payload.sourceControlProvider !== '' ) { - return resolveSourceControlProviderFromPayload(payload); + return [resolveSourceControlProviderFromPayload(payload)]; } // No explicit stamp: resolve from the workspace's synced repositories via the @@ -431,12 +459,12 @@ async function resolveTaskRunSourceControlProvider( // GitHub default that resolveSourceControlProviderFromPayload applies. const workspace = resolveTaskWorkspace(taskRun.payload); const resolvedProvider = await resolveWorkspaceSourceControlProvider( - db, + dbOrTx, workspace, ); if (resolvedProvider) { - return resolvedProvider; + return [resolvedProvider]; } // The GitHub default is wrong whenever the workspace actually spans @@ -445,17 +473,16 @@ async function resolveTaskRunSourceControlProvider( // scopes into per-provider runs; log loudly so the surface that forgot is // diagnosable from the run's cancellation. console.warn( - `[resolveTaskRunSourceControlProvider] Task run ${taskRun.id} has no sourceControlProvider stamp and its ${workspace.type} workspace resolves to no single provider; falling back to the GitHub default. The launch site should stamp the payload or split multi-provider scopes into per-provider runs.`, + `[resolveTaskRunSourceControlProviders] Task run ${taskRun.id} has no sourceControlProvider stamp and its ${workspace.type} workspace resolves to no single provider; falling back to the GitHub default. The launch site should stamp the payload or split multi-provider scopes into per-provider runs.`, ); - return resolveSourceControlProviderFromPayload(taskRun.payload); + return [resolveSourceControlProviderFromPayload(taskRun.payload)]; } async function createProviderToken( taskRun: TaskRun, + provider: SourceControlProvider, ): Promise { - const provider = await resolveTaskRunSourceControlProvider(taskRun); - switch (provider) { case 'github': { const token = await createTaskRunWorkerGitHubToken(taskRun); @@ -536,6 +563,36 @@ async function createProviderToken( } } +function mergeProviderTokens( + tokens: SourceControlRuntimeToken[], +): SourceControlRuntimeToken { + const [primaryToken, ...secondaryTokens] = tokens; + + if (!primaryToken) { + throw new Error('No source control providers resolved for task run.'); + } + + return secondaryTokens.reduce( + (merged, token) => ({ + ...merged, + envVars: { ...merged.envVars, ...token.envVars }, + gitCredentials: [ + ...(merged.gitCredentials ?? []), + ...(token.gitCredentials ?? []), + ], + gitProxyCredentials: [ + ...(merged.gitProxyCredentials ?? []), + ...(token.gitProxyCredentials ?? []), + ], + artifactsPatch: { + ...(merged.artifactsPatch ?? {}), + ...(token.artifactsPatch ?? {}), + }, + }), + primaryToken, + ); +} + /** * Creates a source-control token for the task run with retry logic. * Retries up to {@link SOURCE_CONTROL_TOKEN_MAX_RETRIES} times with @@ -550,12 +607,18 @@ export async function createSourceControlTokenForTaskRun( baseDelayMs = SOURCE_CONTROL_TOKEN_BASE_DELAY_MS, } = {}, ): Promise { - const provider = await resolveTaskRunSourceControlProvider(taskRun); - const label = getSourceControlProviderLabel(provider); + const providers = await resolveTaskRunSourceControlProviders(taskRun); + const label = providers.map(getSourceControlProviderLabel).join(' + '); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - return await createProviderToken(taskRun); + const tokens: SourceControlRuntimeToken[] = []; + + for (const provider of providers) { + tokens.push(await createProviderToken(taskRun, provider)); + } + + return mergeProviderTokens(tokens); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts index 42432b6ae..df77e8c46 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts @@ -25,6 +25,7 @@ import { claimJobById, fetchEnvVars, fetchResolvedRuntimeEnvVars, + resolveTaskRunSourceControlProviders, cancelAndReleaseTaskRun, createSourceControlTokenForTaskRun, type SourceControlRuntimeToken, @@ -135,6 +136,9 @@ export const dequeueResumeTaskRun = async ( sourceRepo?: string; sourceEnvironmentId?: string; sourceSelectedRepositories?: string[]; + sourceControlProviders: Awaited< + ReturnType + >; }; const result: TransactionResult = await db.transaction(async (tx) => { @@ -283,10 +287,12 @@ export const dequeueResumeTaskRun = async ( ); // Fetch environment variables + const sourceControlProviders = await resolveTaskRunSourceControlProviders( + taskRun, + tx, + ); const envVars = await fetchEnvVars(tx, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + sourceControlProvider: sourceControlProviders, }); const settings = await tx.query.deploymentSettings.findFirst({ columns: { @@ -342,6 +348,7 @@ export const dequeueResumeTaskRun = async ( sourceRepo, sourceEnvironmentId, sourceSelectedRepositories, + sourceControlProviders, }; }); @@ -410,14 +417,13 @@ export const dequeueResumeTaskRun = async ( return undefined; } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const gitHubToken = sourceControlToken.envVars.GH_TOKEN ?? ''; let resolvedEnvVars: Record; try { resolvedEnvVars = await fetchResolvedRuntimeEnvVars(result.envVars, { - sourceControlProvider: sourceControlToken.provider, + sourceControlProvider: result.sourceControlProviders, }); } catch (error) { const message = diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts index ac5ea054e..990c37b6a 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts @@ -24,6 +24,7 @@ import { type GitAuthor, fetchEnvVars, fetchResolvedRuntimeEnvVars, + resolveTaskRunSourceControlProviders, cancelAndReleaseTaskRun, createSourceControlTokenForTaskRun, type SourceControlRuntimeToken, @@ -281,6 +282,9 @@ export const dequeueTaskRun = async ( envVars: Record; orgAgentInstructions?: string; gitAuthor: GitAuthor; + sourceControlProviders: Awaited< + ReturnType + >; }; // Phase 1: Transaction — claim the run and fetch all data needed for @@ -309,10 +313,12 @@ export const dequeueTaskRun = async ( const task = taskRun.task; + const sourceControlProviders = await resolveTaskRunSourceControlProviders( + taskRun, + tx, + ); const envVars = await fetchEnvVars(tx, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + sourceControlProvider: sourceControlProviders, }); const settings = await tx.query.deploymentSettings.findFirst({ columns: { @@ -386,6 +392,7 @@ export const dequeueTaskRun = async ( envVars, orgAgentInstructions: settings?.globalAgentInstructions ?? undefined, gitAuthor, + sourceControlProviders, }; }); @@ -455,8 +462,7 @@ export const dequeueTaskRun = async ( return undefined; } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const gitHubToken = sourceControlToken.envVars.GH_TOKEN ?? ''; const sourceControlArtifacts = sourceControlToken.artifactsPatch ?? {}; let prompt: string; @@ -531,7 +537,7 @@ export const dequeueTaskRun = async ( }, fn: async () => await fetchResolvedRuntimeEnvVars(txResult.envVars, { - sourceControlProvider: sourceControlToken.provider, + sourceControlProvider: txResult.sourceControlProviders, }), }); } catch (error) { diff --git a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts index f2c6186df..13aa5a4d3 100644 --- a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts +++ b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts @@ -1,5 +1,4 @@ import { - resolveSourceControlProviderFromPayload, type AuthTokenContext, type RunTokenContext, type SourceControlTokenMetadata, @@ -9,6 +8,7 @@ import { db, taskRuns, eq } from '@roomote/db/server'; import { fetchResolvedRuntimeEnvVars, createSourceControlTokenForTaskRun, + resolveTaskRunSourceControlProviders, } from './dequeue-helpers'; /** @@ -40,10 +40,10 @@ export async function fetchSnapshotEnv( // dequeue so gateway-covered provider keys are withheld here too; otherwise // a snapshot taken with the flag on would bake raw provider keys into the // snapshot's shell env and the persisted image. - const envVars = await fetchResolvedRuntimeEnvVars(undefined, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + const sourceControlProviders = + await resolveTaskRunSourceControlProviders(taskRun); + const deploymentEnvVars = await fetchResolvedRuntimeEnvVars(undefined, { + sourceControlProvider: sourceControlProviders, }); const sourceControlToken = await createSourceControlTokenForTaskRun( @@ -76,8 +76,8 @@ export async function fetchSnapshotEnv( .where(eq(taskRuns.id, taskRun.id)); } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const envVars = { ...deploymentEnvVars, ...sourceControlToken.envVars }; + const gitHubToken = envVars.GH_TOKEN ?? ''; return { envVars, gitHubToken, sourceControlToken, taskId: taskRun.taskId }; }