diff --git a/packages/cloud-agents/src/server/__tests__/commit-author.test.ts b/packages/cloud-agents/src/server/__tests__/commit-author.test.ts index c79ef3787..58aa4b7a3 100644 --- a/packages/cloud-agents/src/server/__tests__/commit-author.test.ts +++ b/packages/cloud-agents/src/server/__tests__/commit-author.test.ts @@ -1,5 +1,8 @@ +import type { DatabaseOrTransaction } from '@roomote/db/server'; + import { DEFAULT_ROOMOTE_COMMIT_AUTHOR, + resolveRunCommitAuthor, resolvePublicGitAuthor, type ResolvedTaskCommitAuthor, } from '../commit-author'; @@ -39,3 +42,68 @@ describe('resolvePublicGitAuthor', () => { }); }); }); + +describe('resolveRunCommitAuthor', () => { + it('uses the host-scoped provider identity as the PR assignee', async () => { + const findUser = vi.fn().mockResolvedValue({ + id: 'user-1', + name: 'Mona Lisa', + }); + const findSourceControlMapping = vi.fn().mockResolvedValue({ + externalAccountId: '42', + username: 'monalisa', + displayName: 'Mona Lisa', + }); + const tx = { + query: { + users: { findFirst: findUser }, + sourceControlUserMappings: { findFirst: findSourceControlMapping }, + }, + } as unknown as DatabaseOrTransaction; + + const result = await resolveRunCommitAuthor( + tx, + { taskId: 'task-1', actingUserId: 'user-1' }, + { provider: 'gitea', host: 'gitea.example.com' }, + ); + + expect(result).toMatchObject({ + publicDisplayName: '@monalisa', + githubLogin: null, + prAssigneeLogin: 'monalisa', + }); + expect(findSourceControlMapping).toHaveBeenCalledOnce(); + }); + + it('does not expose username-based assignees for unsupported providers', async () => { + const tx = { + query: { + users: { + findFirst: vi.fn().mockResolvedValue({ + id: 'user-1', + name: 'Mona Lisa', + }), + }, + sourceControlUserMappings: { + findFirst: vi.fn().mockResolvedValue({ + externalAccountId: '42', + username: 'monalisa', + displayName: 'Mona Lisa', + }), + }, + }, + } as unknown as DatabaseOrTransaction; + + const result = await resolveRunCommitAuthor( + tx, + { taskId: 'task-1', actingUserId: 'user-1' }, + { provider: 'gitlab', host: 'gitlab.example.com' }, + ); + + expect(result).toMatchObject({ + publicDisplayName: '@monalisa', + githubLogin: null, + prAssigneeLogin: null, + }); + }); +}); diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index 9f9b9467f..1ef3e4a7a 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -59,6 +59,7 @@ import { shouldCaptureTaskCreatedEvent, type FreshTaskLaunch, } from '../task-run-queue'; +import { resolveAggregateSourceControl } from '../cloud-agent-workflow'; import { LLM_TITLE_LOCKED_CHECKPOINT } from '../llm-task-title'; import { applyTaskModelSelectionToRun } from '../task-model-selection'; import { getPrSha } from '../workflows/utils'; @@ -2112,10 +2113,11 @@ describe('enqueueTask source-control provider stamping', () => { } }); - it('stamps gitlab on an environment-workspace launch for a gitlab-only deployment', async () => { + it('stamps the provider and host on a homogeneous environment-workspace launch', async () => { const userId = await createUser(); const repository = await repositoryFactory.create({ - sourceControlProvider: 'gitlab', + sourceControlProvider: 'gitea', + host: 'gitea.example.com', linkedByUserId: userId, fullName: 'group/project', isActive: true, @@ -2125,7 +2127,7 @@ describe('enqueueTask source-control provider stamping', () => { const environment = await environmentFactory.create({ createdByUserId: userId, config: { - name: 'GitLab environment', + name: 'Gitea environment', repositories: [{ repository: 'group/project' }], }, }); @@ -2140,11 +2142,10 @@ describe('enqueueTask source-control provider stamping', () => { task: standardTaskInput({ payload: { // environmentId makes this an environment workspace regardless of - // repo, so the provider must resolve via the environment-repository - // mapping (this repo is intentionally not in the repositories table). - repo: 'unmapped/repo', + // repo. The web UI uses the aggregate sentinel for these launches. + repo: ALL_REPOSITORIES, environmentId: environment.id, - description: 'Work in the gitlab environment', + description: 'Work in the Gitea environment', }, }), initiator: { kind: 'user', userId }, @@ -2160,11 +2161,64 @@ describe('enqueueTask source-control provider stamping', () => { expect( (persistedRun!.payload as { sourceControlProvider?: string }) .sourceControlProvider, - ).toBe('gitlab'); - expect( - (persistedRun!.payload as { repositoryProviders?: unknown }) - .repositoryProviders, - ).toBeUndefined(); + ).toBe('gitea'); + expect(persistedRun!.payload.sourceControlHost).toBe('gitea.example.com'); + expect(resolveAggregateSourceControl(persistedRun!.payload)).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + expect(persistedRun!.payload.repositoryProviders).toEqual({ + 'group/project': 'gitea', + }); + }); + + it('clears attribution for incomplete environment repository coverage', async () => { + const userId = await createUser(); + const repository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/environment-api', + isActive: true, + }); + createdRepositoryIds.push(repository.id); + + const environment = await environmentFactory.create({ + createdByUserId: userId, + config: { + name: 'Incomplete Gitea environment', + repositories: [ + { repository: 'group/environment-api' }, + { repository: 'group/environment-web' }, + ], + }, + }); + createdEnvironmentIds.push(environment.id); + + await db.insert(environmentRepositoryMappings).values({ + environmentId: environment.id, + repositoryId: repository.id, + }); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + description: 'Work in an incompletely mapped environment', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload.sourceControlProvider).toBeUndefined(); + expect(run.payload.sourceControlHost).toBeUndefined(); + expect(resolveAggregateSourceControl(run.payload)).toBeUndefined(); }); it('stamps a provider map and the first repository provider for a mixed environment', async () => { @@ -2274,6 +2328,85 @@ describe('enqueueTask source-control provider stamping', () => { }); }); + it('stamps complete provider coverage for homogeneous selected repositories', async () => { + const userId = await createUser(); + const apiRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/homogeneous-api', + isActive: true, + }); + const webRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/homogeneous-web', + isActive: true, + }); + createdRepositoryIds.push(apiRepository.id, webRepository.id); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: [ + 'group/homogeneous-api', + 'group/homogeneous-web', + ], + description: 'Work across homogeneous repositories', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload).toMatchObject({ + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + repositoryProviders: { + 'group/homogeneous-api': 'gitea', + 'group/homogeneous-web': 'gitea', + }, + }); + expect(resolveAggregateSourceControl(run.payload)).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('does not stamp a provider for incomplete selected repository coverage', async () => { + const userId = await createUser(); + const repository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + linkedByUserId: userId, + fullName: 'group/resolved-api', + isActive: true, + }); + createdRepositoryIds.push(repository.id); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: ['group/resolved-api', 'group/missing-web'], + description: 'Work across an incomplete repository selection', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload.repositoryProviders).toEqual({ + 'group/resolved-api': 'gitea', + }); + expect(run.payload.sourceControlProvider).toBeUndefined(); + }); + it('re-stamps a PR launch after auto-resolving a mixed environment', async () => { const userId = await createUser(); const primaryRepository = await repositoryFactory.create({ diff --git a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts index 4e8086e46..af72f050e 100644 --- a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts +++ b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts @@ -1,4 +1,45 @@ -import { resolveStandardTaskSurface } from '../cloud-agent-workflow'; +import { + resolveAggregateSourceControl, + resolveStandardTaskSurface, +} from '../cloud-agent-workflow'; + +describe('resolveAggregateSourceControl', () => { + it('preserves stamped provider and host for a homogeneous aggregate workspace', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + }), + ).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('fails closed for a mixed aggregate workspace', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/api': 'gitlab', + 'shared/app': 'gitea', + }, + }), + ).toBeUndefined(); + }); + + it('fails closed when a selected aggregate mapping is incomplete', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitea', + selectedRepositories: ['shared/api', 'shared/web'], + repositoryProviders: { + 'shared/api': 'gitea', + }, + }), + ).toBeUndefined(); + }); +}); describe('resolveStandardTaskSurface', () => { it('prefers Slack channel payload bindings', () => { diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 81a133b6f..5d714da3b 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -1,4 +1,5 @@ import { + ALL_REPOSITORIES, type TaskSpec, type TaskSurface, TaskPayloadKind, @@ -16,10 +17,11 @@ import { getSlackTeamDomainFromTaskPayload, getSlackTeamIdFromTaskPayload, getSlackThreadTsFromTaskPayload, - resolveSourceControlProviderFromPayload, + resolveSourceControlHostFromPayload, } from '@roomote/types'; import { type TaskRun, + type RepositorySourceControl, db, eq, tasks, @@ -27,6 +29,7 @@ import { DEFAULT_CONFLICT_RESOLVER_LABEL, getDeploymentPrAction, getReviewCodeAutomationSettings, + resolveRepositorySourceControl, resolveTelegramRuntimeCredentials, } from '@roomote/db/server'; import { Env } from '@roomote/env'; @@ -109,6 +112,51 @@ export function resolveStandardTaskSurface({ } } +export function resolveAggregateSourceControl({ + sourceControlProvider, + sourceControlHost, + repositoryProviders, + selectedRepositories, +}: Pick< + TaskSpec['payload'], + | 'sourceControlProvider' + | 'sourceControlHost' + | 'repositoryProviders' + | 'selectedRepositories' +>): RepositorySourceControl | undefined { + if (!sourceControlProvider) { + return undefined; + } + + const providers = repositoryProviders + ? new Set(Object.values(repositoryProviders)) + : null; + const selectedRepositoryNames = selectedRepositories + ? [...new Set(selectedRepositories)] + : []; + const hasCompleteSelection = + selectedRepositoryNames.length === 0 || + (Object.keys(repositoryProviders ?? {}).length === + selectedRepositoryNames.length && + selectedRepositoryNames.every((repository) => + Object.hasOwn(repositoryProviders ?? {}, repository), + )); + + if ( + !hasCompleteSelection || + (providers && + (providers.size !== 1 || !providers.has(sourceControlProvider))) + ) { + return undefined; + } + + const host = resolveSourceControlHostFromPayload({ sourceControlHost }); + return { + provider: sourceControlProvider, + ...(host ? { host } : {}), + }; +} + export async function generatePrompt({ taskRun, taskSpec, @@ -153,9 +201,18 @@ export async function generatePrompt({ surface: true, }, }); - const commitAuthor = taskRow - ? await resolveRunCommitAuthor(db, taskRun) - : DEFAULT_ROOMOTE_COMMIT_AUTHOR; + const targetSourceControl = + taskSpec.payload.repo === ALL_REPOSITORIES + ? resolveAggregateSourceControl(taskSpec.payload) + : await resolveRepositorySourceControl( + db, + taskSpec.payload.repo, + resolveSourceControlHostFromPayload(taskSpec.payload), + ); + const commitAuthor = + taskRow && targetSourceControl + ? await resolveRunCommitAuthor(db, taskRun, targetSourceControl) + : DEFAULT_ROOMOTE_COMMIT_AUTHOR; const { conflictResolverFrequency, conflictResolverLabel, @@ -415,9 +472,7 @@ export async function generatePrompt({ codeReviewsEnabled, codeReviewReviewOnCommit, codeReviewReviewDraftPrs, - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskSpec.payload, - ), + sourceControlProvider: targetSourceControl?.provider, prAction, }); diff --git a/packages/cloud-agents/src/server/commit-author.ts b/packages/cloud-agents/src/server/commit-author.ts index f5041d089..d31b47869 100644 --- a/packages/cloud-agents/src/server/commit-author.ts +++ b/packages/cloud-agents/src/server/commit-author.ts @@ -363,7 +363,7 @@ export async function resolveRunCommitAuthor( displayName, publicDisplayName: username ? `@${username}` : null, githubLogin: null, - prAssigneeLogin: null, + prAssigneeLogin: sourceControl.provider === 'gitea' ? username : null, gitAuthor: { name: displayName, email: commitEmail, diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 28553bb41..a54be98d2 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -69,6 +69,7 @@ import { recordSnapshotResumeEvent, resolveDefaultComputeProvider, resolveWorkspaceRepositoryProviders, + resolveWorkspaceSourceControlHost, sql, } from '@roomote/db/server'; import { type Redis, getRedis } from '@roomote/redis'; @@ -2042,14 +2043,43 @@ async function stampWorkspaceSourceControlProviders( payload: FreshTask['payload'], workspace: ReturnType, ): Promise { - const repositoryProviders = await resolveWorkspaceRepositoryProviders( - db, - workspace, - ); + const [repositoryProviders, workspaceHost] = await Promise.all([ + resolveWorkspaceRepositoryProviders(db, workspace), + resolveWorkspaceSourceControlHost(db, workspace), + ]); + const isAggregateWorkspace = + workspace.type === 'repository_set' || + workspace.type === 'all_repositories'; + const requiresCompleteCoverage = + isAggregateWorkspace || workspace.type === 'environment'; + const expectedRepositoryCount = + workspace.type === 'repository_set' + ? new Set(workspace.repositories).size + : undefined; + + if (requiresCompleteCoverage) { + payload.repositoryProviders = repositoryProviders; + } + + if ( + requiresCompleteCoverage && + (Object.keys(repositoryProviders).length === 0 || + (expectedRepositoryCount !== undefined && + Object.keys(repositoryProviders).length !== expectedRepositoryCount)) + ) { + payload.sourceControlProvider = undefined; + payload.sourceControlHost = undefined; + return; + } + const providers = Object.values(repositoryProviders); const spansProviders = new Set(providers).size > 1; - if (spansProviders) { + if (requiresCompleteCoverage && !spansProviders) { + payload.sourceControlHost = workspaceHost; + } + + if (spansProviders && !requiresCompleteCoverage) { payload.repositoryProviders = repositoryProviders; } diff --git a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts index 6714452a1..7bfd6073c 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts @@ -258,6 +258,48 @@ describe('request_user_input guidance in workflow prompts', () => { ); }); + it('uses the task provider label for linked assignee instructions', () => { + const { harnessInstructions } = standardTask({ + description: 'Implement a repository change', + repo: 'Roomote/example-app', + taskRunUrl: 'https://example.com/task/123', + sourceControlProvider: 'gitea', + attribution: { + ...matchedUserAttributionWithAssignee, + githubLogin: null, + publicDisplayName: '@monalisa', + prAssigneeLogin: 'monalisa', + }, + }); + + expect(harnessInstructions).toContain( + "because the creating user has linked Gitea login `monalisa`, the delegated PR-delivery skill must pass `assignees: ['monalisa']`", + ); + expect(harnessInstructions).not.toContain('linked GitHub login `monalisa`'); + }); + + it('preserves provider-aware attribution and assignment for all-repository tasks', () => { + const { harnessInstructions } = standardTask({ + description: 'Implement a repository change', + repo: ALL_REPOSITORIES, + repoFullNames: ['shared/api', 'shared/web'], + taskRunUrl: 'https://example.com/task/123', + sourceControlProvider: 'gitea', + attribution: { + ...matchedUserAttributionWithAssignee, + githubLogin: null, + publicDisplayName: '@monalisa', + prAssigneeLogin: 'monalisa', + }, + }); + + expect(harnessInstructions).toContain('Opened on behalf of Jane Doe.'); + expect(harnessInstructions).toContain( + "because the creating user has linked Gitea login `monalisa`, the delegated PR-delivery skill must pass `assignees: ['monalisa']`", + ); + expect(harnessInstructions).not.toContain('linked GitHub login `monalisa`'); + }); + it('uses a Slack conversation link for Slack-launched PR follow-up instructions when thread metadata is available', () => { const { harnessInstructions } = standardTask({ description: 'Implement a repository change', diff --git a/packages/cloud-agents/src/server/workflows/standardTask.ts b/packages/cloud-agents/src/server/workflows/standardTask.ts index 12943db0f..0dcda3fac 100644 --- a/packages/cloud-agents/src/server/workflows/standardTask.ts +++ b/packages/cloud-agents/src/server/workflows/standardTask.ts @@ -186,8 +186,11 @@ export function standardTask({ ); } if (attribution.prAssigneeLogin) { + const providerLabel = sourceControlProvider + ? getSourceControlProviderLabel(sourceControlProvider) + : 'GitHub'; delegatedPrMetadataInstructions.push( - `For this run, because the creating user has linked GitHub login \`${attribution.prAssigneeLogin}\`, the delegated PR-delivery skill must pass \`assignees: ['${attribution.prAssigneeLogin}']\` in its \`mcp__roomote__manage_source_control\` calls so the created or refreshed pull request is assigned to that user when the provider supports it.`, + `For this run, because the creating user has linked ${providerLabel} login \`${attribution.prAssigneeLogin}\`, the delegated PR-delivery skill must pass \`assignees: ['${attribution.prAssigneeLogin}']\` in its \`mcp__roomote__manage_source_control\` calls so the created or refreshed pull request is assigned to that user when the provider supports it.`, ); } } diff --git a/packages/db/src/lib/__tests__/source-control-provider.test.ts b/packages/db/src/lib/__tests__/source-control-provider.test.ts index fb294f457..e4f120470 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -1,6 +1,7 @@ // pnpm --filter @roomote/db exec vitest run src/lib/__tests__/source-control-provider.test.ts import type { DatabaseOrTransaction } from '../../db'; import { + resolveRepositorySourceControl, resolveWorkspaceRepositoryProviders, resolveWorkspaceSourceControlHost, resolveWorkspaceSourceControlProvider, @@ -53,6 +54,82 @@ const dbOrTx = { }, } as unknown as DatabaseOrTransaction; +describe('resolveRepositorySourceControl', () => { + beforeEach(() => { + mockRows = []; + mockWhere.mockReset(); + }); + + it('resolves the target repository instead of another workspace provider', async () => { + mockRows = [ + { + fullName: 'gitlab-org/api', + host: 'gitlab.example.com', + isActive: true, + sourceControlProvider: 'gitlab', + }, + { + fullName: 'gitea-org/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'gitea-org/app'), + ).resolves.toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('fails closed when the target repository is ambiguous across hosts', async () => { + mockRows = [ + { + fullName: 'shared/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/app', + host: 'github.com', + isActive: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'shared/app'), + ).resolves.toBeUndefined(); + }); + + it('uses the target host to resolve same-name repositories exactly', async () => { + mockRows = [ + { + fullName: 'shared/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/app', + host: 'github.com', + isActive: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'shared/app', 'gitea.example.com'), + ).resolves.toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); +}); + describe('resolveWorkspaceSourceControlProvider', () => { beforeEach(() => { mockRows = []; @@ -105,6 +182,24 @@ describe('resolveWorkspaceSourceControlProvider', () => { }); }); + it('returns no providers when environment mapping coverage is incomplete', async () => { + mockEnvironmentRepositories = ['group/web', 'octo/api']; + mockRows = [ + { + fullName: 'group/web', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'environment', + environmentId: 'env-1', + }), + ).resolves.toEqual({}); + }); + it('resolves the provider from a single repository workspace', async () => { mockRows = [ { @@ -165,6 +260,32 @@ describe('resolveWorkspaceSourceControlProvider', () => { ).resolves.toBeUndefined(); }); + it('returns undefined when all-repository resolution is incomplete', async () => { + mockRows = [ + { + fullName: 'shared/api', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/web', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/web', + host: 'github.com', + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveWorkspaceSourceControlProvider(dbOrTx, { + type: 'all_repositories', + }), + ).resolves.toBeUndefined(); + }); + it('returns undefined when no repository rows match', async () => { await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index c4ba63774..de0daab56 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -29,6 +29,11 @@ type RepositoryProviderRow = { sourceControlProvider: SourceControlProvider; }; +export type RepositorySourceControl = { + provider: SourceControlProvider; + host?: string; +}; + function selectRepositoryRows( rows: RepositoryProviderRow[], repositoryOrder: string[], @@ -123,6 +128,32 @@ async function resolveProvidersByFullNames( return toRepositoryProviderMap(rows, fullNames, sourceControlHost); } +/** Resolve the provider and host for one exact repository, or fail closed. */ +export async function resolveRepositorySourceControl( + dbOrTx: DatabaseOrTransaction, + fullName: string, + sourceControlHost?: string, +): Promise { + const rows = await dbOrTx + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(repositories) + .where(eq(repositories.fullName, fullName)); + const selected = selectRepositoryRows(rows, [fullName], sourceControlHost); + const repository = selected?.[0]; + + return repository + ? { + provider: repository.sourceControlProvider, + ...(repository.host ? { host: repository.host } : {}), + } + : undefined; +} + async function resolveEnvironmentProviders( dbOrTx: DatabaseOrTransaction, environmentId: string, @@ -159,10 +190,18 @@ async function resolveEnvironmentProviders( asc(environmentRepositoryMappings.id), ); - return toRepositoryProviderMap( - rows, - environment.config.repositories.map((repository) => repository.repository), - ); + const repositoryNames = [ + ...new Set( + environment.config.repositories.map( + (repository) => repository.repository, + ), + ), + ]; + const providers = toRepositoryProviderMap(rows, repositoryNames); + + return Object.keys(providers).length === repositoryNames.length + ? providers + : {}; } async function resolveAllRepositoriesProviders( @@ -179,10 +218,12 @@ async function resolveAllRepositoriesProviders( .where(eq(repositories.isActive, true)) .orderBy(asc(repositories.createdAt), asc(repositories.id)); - return toRepositoryProviderMap( - rows, - rows.map((row) => row.fullName), - ); + const repositoryNames = [...new Set(rows.map((row) => row.fullName))]; + const providers = toRepositoryProviderMap(rows, repositoryNames); + + return Object.keys(providers).length === repositoryNames.length + ? providers + : {}; } /** Resolve repository full names to providers in workspace order. */ 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 5e652b246..60cfb0e17 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 @@ -2136,6 +2136,7 @@ Done.`, draft: false, head: { ref: 'feature/x' }, base: { ref: 'develop' }, + assignees: [{ login: 'existing-reviewer' }], }, ]), ) @@ -2157,11 +2158,15 @@ Done.`, ...baseInput, repositoryFullName: 'acme/tools', sourceControlProvider: 'gitea' as const, + assignees: ['monalisa'], }, fetchImpl, }); expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({ method: 'PATCH' }); + expect( + JSON.parse((fetchImpl.mock.calls[1]?.[1] as { body: string }).body), + ).toMatchObject({ assignees: ['existing-reviewer', 'monalisa'] }); expect(result).toMatchObject({ action: 'updated', targetBranch: 'develop', diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts index 7c121bd1c..d642c4c8d 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts @@ -39,6 +39,11 @@ export const giteaPullRequestSchema = z draft: z.boolean().optional(), head: z.object({ ref: z.string().optional() }).optional(), base: z.object({ ref: z.string().optional() }).optional(), + assignees: z + .array( + z.object({ login: z.string().nullable().optional() }).passthrough(), + ) + .optional(), }) .passthrough(); const giteaPullRequestListSchema = z.array(giteaPullRequestSchema); 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 e66f5bf2c..b0f8f78e3 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 @@ -866,6 +866,17 @@ async function createOrUpdateGiteaPullRequest({ : createDraft, 'gitea', ); + const assignees = + input.assignees.length > 0 + ? [ + ...new Set([ + ...(existing?.assignees + ?.map((assignee) => assignee.login) + .filter((login): login is string => Boolean(login)) ?? []), + ...input.assignees, + ]), + ] + : []; const pullRequest = existing ? await requestJson({ fetchImpl, @@ -878,7 +889,11 @@ async function createOrUpdateGiteaPullRequest({ {}, ), tokenHeader: { name: 'Authorization', value: `token ${token}` }, - body: { title, body: input.body }, + body: { + title, + body: input.body, + ...(assignees.length > 0 ? { assignees } : {}), + }, schema: giteaPullRequestSchema, }) : await requestJson({ @@ -897,7 +912,7 @@ async function createOrUpdateGiteaPullRequest({ head: input.sourceBranch, title, body: input.body, - ...(input.assignees.length > 0 ? { assignees: input.assignees } : {}), + ...(assignees.length > 0 ? { assignees } : {}), }, schema: giteaPullRequestSchema, }); diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index c88409654..609484c28 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -904,7 +904,8 @@ const sharedTaskPayloadSchema = z.object({ /** * Source-control provider keyed by repository full name for workspaces that - * span multiple providers. Single-provider payloads omit this field. + * span multiple providers. Aggregate selections also include this map when + * homogeneous so downstream attribution can verify complete coverage. */ repositoryProviders: z.record(sourceControlProviderSchema).optional(),