From 6ba6382c20aed99f339bd1f5e2eb4b76001e1909 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:54:30 +0000 Subject: [PATCH 1/9] fix: isolate PR assignees by source-control provider --- .../server/__tests__/commit-author.test.ts | 36 +++++++++++++++++++ .../src/server/cloud-agent-workflow.ts | 13 ++++--- .../cloud-agents/src/server/commit-author.ts | 2 +- .../requestUserInputGuidance.test.ts | 20 +++++++++++ .../src/server/workflows/standardTask.ts | 5 ++- 5 files changed, 70 insertions(+), 6 deletions(-) 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..55fdc5f00 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,36 @@ 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(); + }); +}); diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 81a133b6f..e9a15df0a 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -16,6 +16,7 @@ import { getSlackTeamDomainFromTaskPayload, getSlackTeamIdFromTaskPayload, getSlackThreadTsFromTaskPayload, + resolveSourceControlHostFromPayload, resolveSourceControlProviderFromPayload, } from '@roomote/types'; import { @@ -153,8 +154,14 @@ export async function generatePrompt({ surface: true, }, }); + const sourceControlProvider = resolveSourceControlProviderFromPayload( + taskSpec.payload, + ); const commitAuthor = taskRow - ? await resolveRunCommitAuthor(db, taskRun) + ? await resolveRunCommitAuthor(db, taskRun, { + provider: sourceControlProvider, + host: resolveSourceControlHostFromPayload(taskSpec.payload), + }) : DEFAULT_ROOMOTE_COMMIT_AUTHOR; const { conflictResolverFrequency, @@ -415,9 +422,7 @@ export async function generatePrompt({ codeReviewsEnabled, codeReviewReviewOnCommit, codeReviewReviewDraftPrs, - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskSpec.payload, - ), + sourceControlProvider, prAction, }); diff --git a/packages/cloud-agents/src/server/commit-author.ts b/packages/cloud-agents/src/server/commit-author.ts index f5041d089..80f77b979 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: username, gitAuthor: { name: displayName, email: commitEmail, 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..c9c0371c1 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,26 @@ 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('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.`, ); } } From f9f054278405c7b2190400a5a71c69f9e2dffd56 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:29:34 +0000 Subject: [PATCH 2/9] fix: resolve PR attribution from target repository --- .../src/server/cloud-agent-workflow.ts | 20 ++++--- .../__tests__/source-control-provider.test.ts | 53 +++++++++++++++++++ .../db/src/lib/source-control-provider.ts | 30 +++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index e9a15df0a..634bc6d4d 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -16,8 +16,6 @@ import { getSlackTeamDomainFromTaskPayload, getSlackTeamIdFromTaskPayload, getSlackThreadTsFromTaskPayload, - resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, } from '@roomote/types'; import { type TaskRun, @@ -28,6 +26,7 @@ import { DEFAULT_CONFLICT_RESOLVER_LABEL, getDeploymentPrAction, getReviewCodeAutomationSettings, + resolveRepositorySourceControl, resolveTelegramRuntimeCredentials, } from '@roomote/db/server'; import { Env } from '@roomote/env'; @@ -154,15 +153,14 @@ export async function generatePrompt({ surface: true, }, }); - const sourceControlProvider = resolveSourceControlProviderFromPayload( - taskSpec.payload, + const targetSourceControl = await resolveRepositorySourceControl( + db, + taskSpec.payload.repo, ); - const commitAuthor = taskRow - ? await resolveRunCommitAuthor(db, taskRun, { - provider: sourceControlProvider, - host: resolveSourceControlHostFromPayload(taskSpec.payload), - }) - : DEFAULT_ROOMOTE_COMMIT_AUTHOR; + const commitAuthor = + taskRow && targetSourceControl + ? await resolveRunCommitAuthor(db, taskRun, targetSourceControl) + : DEFAULT_ROOMOTE_COMMIT_AUTHOR; const { conflictResolverFrequency, conflictResolverLabel, @@ -422,7 +420,7 @@ export async function generatePrompt({ codeReviewsEnabled, codeReviewReviewOnCommit, codeReviewReviewDraftPrs, - sourceControlProvider, + sourceControlProvider: targetSourceControl?.provider, prAction, }); 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..095a36cf9 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,58 @@ 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(); + }); +}); + describe('resolveWorkspaceSourceControlProvider', () => { beforeEach(() => { mockRows = []; diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index c4ba63774..e119cf51f 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,31 @@ 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, +): 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]); + const repository = selected?.[0]; + + return repository + ? { + provider: repository.sourceControlProvider, + ...(repository.host ? { host: repository.host } : {}), + } + : undefined; +} + async function resolveEnvironmentProviders( dbOrTx: DatabaseOrTransaction, environmentId: string, From fafb443c1fdf0e9dccad22dc22da2e10019906ef Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:23:27 +0000 Subject: [PATCH 3/9] fix: preserve host-aware aggregate attribution --- .../resolveStandardTaskSurface.test.ts | 31 ++++++++++++- .../src/server/cloud-agent-workflow.ts | 45 +++++++++++++++++-- .../requestUserInputGuidance.test.ts | 22 +++++++++ .../__tests__/source-control-provider.test.ts | 24 ++++++++++ .../db/src/lib/source-control-provider.ts | 3 +- 5 files changed, 119 insertions(+), 6 deletions(-) diff --git a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts index 4e8086e46..06e5bd739 100644 --- a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts +++ b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts @@ -1,4 +1,33 @@ -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(); + }); +}); 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 634bc6d4d..91b358bcd 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,9 +17,11 @@ import { getSlackTeamDomainFromTaskPayload, getSlackTeamIdFromTaskPayload, getSlackThreadTsFromTaskPayload, + resolveSourceControlHostFromPayload, } from '@roomote/types'; import { type TaskRun, + type RepositorySourceControl, db, eq, tasks, @@ -109,6 +112,36 @@ export function resolveStandardTaskSurface({ } } +export function resolveAggregateSourceControl({ + sourceControlProvider, + sourceControlHost, + repositoryProviders, +}: Pick< + TaskSpec['payload'], + 'sourceControlProvider' | 'sourceControlHost' | 'repositoryProviders' +>): RepositorySourceControl | undefined { + if (!sourceControlProvider) { + return undefined; + } + + const providers = repositoryProviders + ? new Set(Object.values(repositoryProviders)) + : null; + + if ( + 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,10 +186,14 @@ export async function generatePrompt({ surface: true, }, }); - const targetSourceControl = await resolveRepositorySourceControl( - db, - taskSpec.payload.repo, - ); + 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) 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 c9c0371c1..7bfd6073c 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts @@ -278,6 +278,28 @@ describe('request_user_input guidance in workflow prompts', () => { 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/db/src/lib/__tests__/source-control-provider.test.ts b/packages/db/src/lib/__tests__/source-control-provider.test.ts index 095a36cf9..20584fd57 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -104,6 +104,30 @@ describe('resolveRepositorySourceControl', () => { 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', () => { diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index e119cf51f..e0b37de03 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -132,6 +132,7 @@ async function resolveProvidersByFullNames( export async function resolveRepositorySourceControl( dbOrTx: DatabaseOrTransaction, fullName: string, + sourceControlHost?: string, ): Promise { const rows = await dbOrTx .select({ @@ -142,7 +143,7 @@ export async function resolveRepositorySourceControl( }) .from(repositories) .where(eq(repositories.fullName, fullName)); - const selected = selectRepositoryRows(rows, [fullName]); + const selected = selectRepositoryRows(rows, [fullName], sourceControlHost); const repository = selected?.[0]; return repository From 4189d3a3825ef2222e02ed044f0a8a0e27dc2df5 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:32:52 +0000 Subject: [PATCH 4/9] fix: fail closed on incomplete repository selections --- .../src/server/__tests__/enqueue-task.test.ts | 72 +++++++++++++++++++ .../resolveStandardTaskSurface.test.ts | 12 ++++ .../src/server/cloud-agent-workflow.ts | 21 +++++- .../cloud-agents/src/server/task-run-queue.ts | 25 ++++++- .../__tests__/source-control-provider.test.ts | 26 +++++++ .../db/src/lib/source-control-provider.ts | 10 +-- packages/types/src/task-runs.ts | 3 +- 7 files changed, 160 insertions(+), 9 deletions(-) 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..7fca4e435 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -2274,6 +2274,78 @@ 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', + linkedByUserId: userId, + fullName: 'group/homogeneous-api', + isActive: true, + }); + const webRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + 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', + repositoryProviders: { + 'group/homogeneous-api': 'gitea', + 'group/homogeneous-web': 'gitea', + }, + }); + }); + + 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 06e5bd739..af72f050e 100644 --- a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts +++ b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts @@ -27,6 +27,18 @@ describe('resolveAggregateSourceControl', () => { }), ).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', () => { diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 91b358bcd..5d714da3b 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -116,9 +116,13 @@ export function resolveAggregateSourceControl({ sourceControlProvider, sourceControlHost, repositoryProviders, + selectedRepositories, }: Pick< TaskSpec['payload'], - 'sourceControlProvider' | 'sourceControlHost' | 'repositoryProviders' + | 'sourceControlProvider' + | 'sourceControlHost' + | 'repositoryProviders' + | 'selectedRepositories' >): RepositorySourceControl | undefined { if (!sourceControlProvider) { return undefined; @@ -127,10 +131,21 @@ export function resolveAggregateSourceControl({ 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 ( - providers && - (providers.size !== 1 || !providers.has(sourceControlProvider)) + !hasCompleteSelection || + (providers && + (providers.size !== 1 || !providers.has(sourceControlProvider))) ) { return undefined; } diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 28553bb41..568bcb300 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2046,10 +2046,33 @@ async function stampWorkspaceSourceControlProviders( db, workspace, ); + const isAggregateWorkspace = + workspace.type === 'repository_set' || + workspace.type === 'all_repositories'; + const expectedRepositoryCount = + workspace.type === 'repository_set' + ? new Set(workspace.repositories).size + : undefined; + + if (isAggregateWorkspace) { + payload.repositoryProviders = repositoryProviders; + } + + if ( + isAggregateWorkspace && + (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 (spansProviders && !isAggregateWorkspace) { payload.repositoryProviders = repositoryProviders; } 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 20584fd57..4b857f7ad 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -242,6 +242,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 e0b37de03..559d7a6a0 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -210,10 +210,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/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(), From b610823188089384478e5e4e7db7ba3de6d2296a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:55:40 +0000 Subject: [PATCH 5/9] fix: stamp homogeneous aggregate source-control hosts --- .../src/server/__tests__/enqueue-task.test.ts | 8 ++++++++ packages/cloud-agents/src/server/task-run-queue.ts | 13 +++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) 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 7fca4e435..e4e9cb79c 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'; @@ -2278,12 +2279,14 @@ describe('enqueueTask source-control provider stamping', () => { 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, @@ -2309,11 +2312,16 @@ describe('enqueueTask source-control provider stamping', () => { 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 () => { diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 568bcb300..31560a05a 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,10 +2043,10 @@ 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'; @@ -2072,6 +2073,10 @@ async function stampWorkspaceSourceControlProviders( const providers = Object.values(repositoryProviders); const spansProviders = new Set(providers).size > 1; + if (isAggregateWorkspace && !spansProviders) { + payload.sourceControlHost = workspaceHost; + } + if (spansProviders && !isAggregateWorkspace) { payload.repositoryProviders = repositoryProviders; } From 4fcc866ee1cb6e0eded905c1f0c1d299290f5fa7 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 21 Aug 2026 15:12:36 -0500 Subject: [PATCH 6/9] fix: complete Gitea PR assignee handling --- .../server/__tests__/commit-author.test.ts | 32 +++++++++++++++++++ .../src/server/__tests__/enqueue-task.test.ts | 21 +++++++----- .../cloud-agents/src/server/commit-author.ts | 2 +- .../cloud-agents/src/server/task-run-queue.ts | 5 ++- .../source-control-pull-requests.test.ts | 5 +++ ...urce-control-pull-request-branch-lookup.ts | 5 +++ .../source-control-pull-requests.ts | 19 +++++++++-- 7 files changed, 77 insertions(+), 12 deletions(-) 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 55fdc5f00..58aa4b7a3 100644 --- a/packages/cloud-agents/src/server/__tests__/commit-author.test.ts +++ b/packages/cloud-agents/src/server/__tests__/commit-author.test.ts @@ -74,4 +74,36 @@ describe('resolveRunCommitAuthor', () => { }); 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 e4e9cb79c..71a388aae 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -2113,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, @@ -2126,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' }], }, }); @@ -2141,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 }, @@ -2161,7 +2161,12 @@ describe('enqueueTask source-control provider stamping', () => { expect( (persistedRun!.payload as { sourceControlProvider?: string }) .sourceControlProvider, - ).toBe('gitlab'); + ).toBe('gitea'); + expect(persistedRun!.payload.sourceControlHost).toBe('gitea.example.com'); + expect(resolveAggregateSourceControl(persistedRun!.payload)).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); expect( (persistedRun!.payload as { repositoryProviders?: unknown }) .repositoryProviders, diff --git a/packages/cloud-agents/src/server/commit-author.ts b/packages/cloud-agents/src/server/commit-author.ts index 80f77b979..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: username, + 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 31560a05a..adeb17df1 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2073,7 +2073,10 @@ async function stampWorkspaceSourceControlProviders( const providers = Object.values(repositoryProviders); const spansProviders = new Set(providers).size > 1; - if (isAggregateWorkspace && !spansProviders) { + if ( + (isAggregateWorkspace || workspace.type === 'environment') && + !spansProviders + ) { payload.sourceControlHost = workspaceHost; } 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, }); From fd4fedf53d888cb0d44243d84e7f2ce13a9c89ab Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:43:45 +0000 Subject: [PATCH 7/9] fix: reject incomplete environment attribution --- .../src/server/__tests__/enqueue-task.test.ts | 49 +++++++++++++++++++ .../cloud-agents/src/server/task-run-queue.ts | 4 +- .../__tests__/source-control-provider.test.ts | 18 +++++++ .../db/src/lib/source-control-provider.ts | 16 ++++-- 4 files changed, 82 insertions(+), 5 deletions(-) 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 71a388aae..99719230e 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -2173,6 +2173,55 @@ describe('enqueueTask source-control provider stamping', () => { ).toBeUndefined(); }); + 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 () => { const userId = await createUser(); const primaryRepository = await repositoryFactory.create({ diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index adeb17df1..f4f04076b 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2050,6 +2050,8 @@ async function stampWorkspaceSourceControlProviders( 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 @@ -2060,7 +2062,7 @@ async function stampWorkspaceSourceControlProviders( } if ( - isAggregateWorkspace && + requiresCompleteCoverage && (Object.keys(repositoryProviders).length === 0 || (expectedRepositoryCount !== undefined && Object.keys(repositoryProviders).length !== expectedRepositoryCount)) 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 4b857f7ad..e4f120470 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -182,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 = [ { diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index 559d7a6a0..de0daab56 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -190,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( From e416b6a10d27d5e8ad679ab05a54d0c5c3900730 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:43:56 +0000 Subject: [PATCH 8/9] fix: fail closed on incomplete environment mappings --- .../src/server/__tests__/enqueue-task.test.ts | 20 ++++++++------- .../cloud-agents/src/server/task-run-queue.ts | 25 +++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) 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 99719230e..36bc5d0ae 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -2167,19 +2167,18 @@ describe('enqueueTask source-control provider stamping', () => { provider: 'gitea', host: 'gitea.example.com', }); - expect( - (persistedRun!.payload as { repositoryProviders?: unknown }) - .repositoryProviders, - ).toBeUndefined(); + expect(persistedRun!.payload.repositoryProviders).toEqual({ + 'group/project': 'gitea', + }); }); - it('clears attribution for incomplete environment repository coverage', async () => { + it('fails closed when an environment repository mapping is incomplete', async () => { const userId = await createUser(); const repository = await repositoryFactory.create({ sourceControlProvider: 'gitea', host: 'gitea.example.com', linkedByUserId: userId, - fullName: 'group/environment-api', + fullName: 'group/mapped-project', isActive: true, }); createdRepositoryIds.push(repository.id); @@ -2189,8 +2188,8 @@ describe('enqueueTask source-control provider stamping', () => { config: { name: 'Incomplete Gitea environment', repositories: [ - { repository: 'group/environment-api' }, - { repository: 'group/environment-web' }, + { repository: 'group/mapped-project' }, + { repository: 'group/missing-project' }, ], }, }); @@ -2208,7 +2207,7 @@ describe('enqueueTask source-control provider stamping', () => { environmentId: environment.id, sourceControlProvider: 'gitea', sourceControlHost: 'gitea.example.com', - description: 'Work in an incompletely mapped environment', + description: 'Work in the incomplete Gitea environment', }, }), initiator: { kind: 'user', userId }, @@ -2217,6 +2216,9 @@ describe('enqueueTask source-control provider stamping', () => { trigger: 'manual', }); + expect(run.payload.repositoryProviders).toEqual({ + 'group/mapped-project': 'gitea', + }); expect(run.payload.sourceControlProvider).toBeUndefined(); expect(run.payload.sourceControlHost).toBeUndefined(); expect(resolveAggregateSourceControl(run.payload)).toBeUndefined(); diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index f4f04076b..991f2a9f7 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2043,9 +2043,15 @@ async function stampWorkspaceSourceControlProviders( payload: FreshTask['payload'], workspace: ReturnType, ): Promise { - const [repositoryProviders, workspaceHost] = await Promise.all([ + const [repositoryProviders, workspaceHost, environment] = await Promise.all([ resolveWorkspaceRepositoryProviders(db, workspace), resolveWorkspaceSourceControlHost(db, workspace), + workspace.type === 'environment' + ? db.query.environments.findFirst({ + where: eq(environments.id, workspace.environmentId), + columns: { config: true }, + }) + : null, ]); const isAggregateWorkspace = workspace.type === 'repository_set' || @@ -2055,9 +2061,15 @@ async function stampWorkspaceSourceControlProviders( const expectedRepositoryCount = workspace.type === 'repository_set' ? new Set(workspace.repositories).size - : undefined; + : workspace.type === 'environment' + ? new Set( + environment?.config.repositories.map( + (repository) => repository.repository, + ) ?? [], + ).size + : undefined; - if (isAggregateWorkspace) { + if (requiresCompleteCoverage) { payload.repositoryProviders = repositoryProviders; } @@ -2075,14 +2087,11 @@ async function stampWorkspaceSourceControlProviders( const providers = Object.values(repositoryProviders); const spansProviders = new Set(providers).size > 1; - if ( - (isAggregateWorkspace || workspace.type === 'environment') && - !spansProviders - ) { + if (requiresCompleteCoverage && !spansProviders) { payload.sourceControlHost = workspaceHost; } - if (spansProviders && !isAggregateWorkspace) { + if (spansProviders && !requiresCompleteCoverage) { payload.repositoryProviders = repositoryProviders; } From 2cd50a2281027f2bf669169034eb4f6672366d1e Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:47:38 +0000 Subject: [PATCH 9/9] refactor: reuse environment coverage resolution --- .../src/server/__tests__/enqueue-task.test.ts | 13 +++++-------- .../cloud-agents/src/server/task-run-queue.ts | 16 ++-------------- 2 files changed, 7 insertions(+), 22 deletions(-) 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 36bc5d0ae..1ef3e4a7a 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -2172,13 +2172,13 @@ describe('enqueueTask source-control provider stamping', () => { }); }); - it('fails closed when an environment repository mapping is incomplete', async () => { + 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/mapped-project', + fullName: 'group/environment-api', isActive: true, }); createdRepositoryIds.push(repository.id); @@ -2188,8 +2188,8 @@ describe('enqueueTask source-control provider stamping', () => { config: { name: 'Incomplete Gitea environment', repositories: [ - { repository: 'group/mapped-project' }, - { repository: 'group/missing-project' }, + { repository: 'group/environment-api' }, + { repository: 'group/environment-web' }, ], }, }); @@ -2207,7 +2207,7 @@ describe('enqueueTask source-control provider stamping', () => { environmentId: environment.id, sourceControlProvider: 'gitea', sourceControlHost: 'gitea.example.com', - description: 'Work in the incomplete Gitea environment', + description: 'Work in an incompletely mapped environment', }, }), initiator: { kind: 'user', userId }, @@ -2216,9 +2216,6 @@ describe('enqueueTask source-control provider stamping', () => { trigger: 'manual', }); - expect(run.payload.repositoryProviders).toEqual({ - 'group/mapped-project': 'gitea', - }); expect(run.payload.sourceControlProvider).toBeUndefined(); expect(run.payload.sourceControlHost).toBeUndefined(); expect(resolveAggregateSourceControl(run.payload)).toBeUndefined(); diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 991f2a9f7..a54be98d2 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -2043,15 +2043,9 @@ async function stampWorkspaceSourceControlProviders( payload: FreshTask['payload'], workspace: ReturnType, ): Promise { - const [repositoryProviders, workspaceHost, environment] = await Promise.all([ + const [repositoryProviders, workspaceHost] = await Promise.all([ resolveWorkspaceRepositoryProviders(db, workspace), resolveWorkspaceSourceControlHost(db, workspace), - workspace.type === 'environment' - ? db.query.environments.findFirst({ - where: eq(environments.id, workspace.environmentId), - columns: { config: true }, - }) - : null, ]); const isAggregateWorkspace = workspace.type === 'repository_set' || @@ -2061,13 +2055,7 @@ async function stampWorkspaceSourceControlProviders( const expectedRepositoryCount = workspace.type === 'repository_set' ? new Set(workspace.repositories).size - : workspace.type === 'environment' - ? new Set( - environment?.config.repositories.map( - (repository) => repository.repository, - ) ?? [], - ).size - : undefined; + : undefined; if (requiresCompleteCoverage) { payload.repositoryProviders = repositoryProviders;