feat: Trigger projects export and import on push and pull (#20459)

This commit is contained in:
Irénée
2025-10-21 16:36:01 +01:00
committed by GitHub
parent dc1437fd00
commit 333ce1f105
15 changed files with 694 additions and 171 deletions
@@ -11,6 +11,7 @@ import {
WorkflowEntity,
type WorkflowRepository,
} from '@n8n/db';
import { In } from '@n8n/typeorm';
import * as fastGlob from 'fast-glob';
import { mock } from 'jest-mock-extended';
import { type InstanceSettings } from 'n8n-core';
@@ -368,114 +369,6 @@ describe('SourceControlImportService', () => {
});
});
describe('getRemoteFoldersAndMappingsFromFile', () => {
it('should parse folders and mappings file correctly', async () => {
globMock.mockResolvedValue(['/mock/folders.json']);
const now = new Date();
const mockFoldersData: {
folders: ExportableFolder[];
} = {
folders: [
{
id: 'folder1',
name: 'folder 1',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
],
};
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toEqual(mockFoldersData.folders);
});
it('should return empty folders and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toHaveLength(0);
});
it('should return only folder that belong to a project that belongs to the user', async () => {
globMock.mockResolvedValue(['/mock/folders.json']);
const now = new Date();
const foldersToFind: ExportableFolder[] = [
{
id: 'folder1',
name: 'folder 1',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder3',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder4',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project3',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
];
const mockFoldersData: {
folders: ExportableFolder[];
} = {
folders: [
{
id: 'folder0',
name: 'folder 0',
parentFolderId: null,
homeProjectId: 'project0',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
...foldersToFind,
{
id: 'folder2',
name: 'folder 2',
parentFolderId: null,
homeProjectId: 'project2',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
],
};
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
Object.assign(new Project(), {
id: 'project1',
}),
Object.assign(new Project(), {
id: 'project3',
}),
]);
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
const result = await service.getRemoteFoldersAndMappingsFromFile(globalMemberContext);
expect(result.folders).toEqual(foldersToFind);
});
});
describe('getLocalVersionIdsFromDb', () => {
const now = new Date();
jest.useFakeTimers({ now });
@@ -497,26 +390,156 @@ describe('SourceControlImportService', () => {
});
});
describe('getLocalFoldersAndMappingsFromDb', () => {
it('should return data from DB', async () => {
// Arrange
describe('folders', () => {
describe('getRemoteFoldersAndMappingsFromFile', () => {
it('should parse folders and mappings file correctly', async () => {
globMock.mockResolvedValue(['/mock/folders.json']);
folderRepository.find.mockResolvedValue([
mock({ createdAt: new Date(), updatedAt: new Date() }),
]);
workflowRepository.find.mockResolvedValue([mock()]);
const now = new Date();
// Act
const mockFoldersData: {
folders: ExportableFolder[];
} = {
folders: [
{
id: 'folder1',
name: 'folder 1',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
],
};
const result = await service.getLocalFoldersAndMappingsFromDb(globalAdminContext);
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
// Assert
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toHaveLength(1);
expect(result.folders[0]).toHaveProperty('id');
expect(result.folders[0]).toHaveProperty('name');
expect(result.folders[0]).toHaveProperty('parentFolderId');
expect(result.folders[0]).toHaveProperty('homeProjectId');
expect(result.folders).toEqual(mockFoldersData.folders);
});
it('should return empty folders and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toHaveLength(0);
});
it('should return only folder that belong to a project that belongs to the user', async () => {
globMock.mockResolvedValue(['/mock/folders.json']);
const now = new Date();
const foldersToFind: ExportableFolder[] = [
{
id: 'folder1',
name: 'folder 1',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder3',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder4',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project3',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
];
const mockFoldersData: {
folders: ExportableFolder[];
} = {
folders: [
{
id: 'folder0',
name: 'folder 0',
parentFolderId: null,
homeProjectId: 'project0',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
...foldersToFind,
{
id: 'folder2',
name: 'folder 2',
parentFolderId: null,
homeProjectId: 'project2',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
],
};
sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
Object.assign(new Project(), {
id: 'project1',
}),
Object.assign(new Project(), {
id: 'project3',
}),
]);
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
const result = await service.getRemoteFoldersAndMappingsFromFile(globalMemberContext);
expect(result.folders).toEqual(foldersToFind);
});
});
describe('getLocalFoldersAndMappingsFromDb', () => {
it('should return data from DB', async () => {
// Arrange
folderRepository.find.mockResolvedValue([
mock({ createdAt: new Date(), updatedAt: new Date() }),
]);
workflowRepository.find.mockResolvedValue([mock()]);
// Act
const result = await service.getLocalFoldersAndMappingsFromDb(globalAdminContext);
// Assert
expect(result.folders).toHaveLength(1);
expect(result.folders[0]).toHaveProperty('id');
expect(result.folders[0]).toHaveProperty('name');
expect(result.folders[0]).toHaveProperty('parentFolderId');
expect(result.folders[0]).toHaveProperty('homeProjectId');
});
});
describe('deleteFoldersNotInWorkfolder', () => {
it('should call folderRepository.delete with correct ids', async () => {
const candidates = [
mock<SourceControlledFile>({ id: 'folder1' }),
mock<SourceControlledFile>({ id: 'folder2' }),
mock<SourceControlledFile>({ id: 'folder3' }),
];
await service.deleteFoldersNotInWorkfolder(candidates as any);
expect(folderRepository.delete).toHaveBeenCalledWith({
id: In(['folder1', 'folder2', 'folder3']),
});
});
it('should not call folderRepository.delete if candidates is empty', async () => {
await service.deleteFoldersNotInWorkfolder([]);
expect(folderRepository.delete).not.toHaveBeenCalled();
});
});
});
@@ -873,5 +896,26 @@ describe('SourceControlImportService', () => {
});
});
});
describe('deleteTeamProjectsNotInWorkfolder', () => {
it('should delete candidate files', async () => {
const candidates = [
mock<SourceControlledFile>({ id: 'project-1' }),
mock<SourceControlledFile>({ id: 'project-2' }),
];
await service.deleteTeamProjectsNotInWorkfolder(candidates);
expect(projectRepository.delete).toHaveBeenCalledWith({
id: In(['project-1', 'project-2']),
});
});
it('should handle empty candidates array', async () => {
await service.deleteTeamProjectsNotInWorkfolder([]);
expect(projectRepository.delete).not.toHaveBeenCalled();
});
});
});
});
@@ -17,6 +17,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { EventService } from '@/events/event.service';
import type { SourceControlGitService } from '../source-control-git.service.ee';
import * as sourceControlHelper from '../source-control-helper.ee';
import type { SourceControlImportService } from '../source-control-import.service.ee';
import { SourceControlPreferencesService } from '../source-control-preferences.service.ee';
import { SourceControlStatusService } from '../source-control-status.service.ee';
@@ -90,6 +91,8 @@ describe('getStatus', () => {
// repositories
tagRepository.find.mockResolvedValue([]);
folderRepository.find.mockResolvedValue([]);
jest.spyOn(sourceControlHelper, 'sourceControlFoldersExistCheck').mockReturnValue(true);
});
it('ensure updatedAt field for last deleted tag', async () => {
@@ -238,13 +241,27 @@ describe('getStatus', () => {
],
});
// Define a project that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteProjectsFromFiles.mockResolvedValue([]);
// Define a project that only exists locally and another that exists both locally and remotely.
const project1 = mock<ExportableProjectWithFileName>({
id: 'project-id-1',
name: 'Project 1 Remote',
owner: {
type: 'team',
teamId: 'team-id-1',
teamName: 'Team 1',
},
});
sourceControlImportService.getRemoteProjectsFromFiles.mockResolvedValue([
mock<ExportableProjectWithFileName>({ ...project1 }),
]);
sourceControlImportService.getLocalTeamProjectsFromDb.mockResolvedValue([
mock<ExportableProjectWithFileName>(),
mock<ExportableProjectWithFileName>({
...project1,
name: 'Project 1 Local',
}),
mock<ExportableProjectWithFileName>({
id: 'project-id-2',
}),
]);
// ACT
@@ -269,8 +286,8 @@ describe('getStatus', () => {
fail('Expected pushResult to be an array.');
}
expect(pullResult).toHaveLength(6);
expect(pushResult).toHaveLength(6);
expect(pullResult).toHaveLength(7);
expect(pushResult).toHaveLength(7);
expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false);
@@ -287,8 +304,23 @@ describe('getStatus', () => {
expect(pullResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'folders')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'project')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'project')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'project' && i.id === 'project-id-2')).toHaveProperty(
'conflict',
true,
);
expect(pushResult.find((i) => i.type === 'project' && i.id === 'project-id-2')).toHaveProperty(
'conflict',
false,
);
expect(pullResult.find((i) => i.type === 'project' && i.id === 'project-id-1')).toHaveProperty(
'conflict',
true,
);
expect(pushResult.find((i) => i.type === 'project' && i.id === 'project-id-1')).toHaveProperty(
'conflict',
true,
);
});
it('should throw `ForbiddenError` if direction is pull and user is not allowed to globally pull', async () => {
@@ -704,6 +736,52 @@ describe('getStatus', () => {
);
});
});
it('should not mark projects for deletion when projects folder does not exist (backward compatibility)', async () => {
// ARRANGE
const user = mockUsers.globalAdmin;
const localProjects = [
{
id: 'local-project-1',
name: 'Local Project 1',
description: 'Local project description',
icon: null,
type: 'team' as const,
owner: {
type: 'team' as const,
teamId: 'local-project-1',
teamName: 'Local Project 1',
},
filename: '/mock/n8n/git/projects/local-project-1.json',
},
];
setupProjectMocks({
remote: [],
local: localProjects,
});
// Override the default mock: folder doesn't exist (backward compatibility scenario)
jest.spyOn(sourceControlHelper, 'sourceControlFoldersExistCheck').mockReturnValue(false);
// ACT
const result = await sourceControlStatusService.getStatus(user, {
direction: 'pull',
verbose: false,
preferLocalVersion: false,
});
// ASSERT
if (!Array.isArray(result)) {
fail('Expected result to be an array.');
}
// Should NOT include any project deletion entries
const projectDeletions = result.filter(
(file) => file.type === 'project' && file.status === 'deleted',
);
expect(projectDeletions).toHaveLength(0);
});
});
describe('workflows', () => {
@@ -1,19 +1,20 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { isContainedWithin } from '@n8n/backend-common';
import { type User, type WorkflowEntity, GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, User, type WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import type { PushResult } from 'simple-git';
import type { SourceControlGitService } from '../source-control-git.service.ee';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { EventService } from '@/events/event.service';
import type { SourceControlExportService } from '../source-control-export.service.ee';
import type { SourceControlGitService } from '../source-control-git.service.ee';
import type { SourceControlImportService } from '../source-control-import.service.ee';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import type { SourceControlExportService } from '../source-control-export.service.ee';
import type { ExportResult } from '../types/export-result';
// Mock the status service to avoid complex dependency issues
const mockStatusService = {
@@ -57,6 +58,210 @@ describe('SourceControlService', () => {
});
describe('pushWorkfolder', () => {
it('should push the workfolder', async () => {
const mockExportResult = mock<ExportResult>();
// Arrange
const user = Object.assign(new User(), {
role: GLOBAL_ADMIN_ROLE,
});
const mockPushResult = mock<PushResult>();
const now = new Date().toISOString();
// Prepare a set of files of all types, some deleted, some not
const files: SourceControlledFile[] = [
{
file: 'workflow-1.json',
id: 'wf-1',
name: 'Workflow 1',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'credential-1.json',
id: 'cred-1',
name: 'Credential 1',
type: 'credential',
status: 'created',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'project-1.json',
id: 'proj-1',
name: 'Project 1',
type: 'project',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'folders.json',
id: 'folders',
name: 'Folders',
type: 'folders',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'variables.json',
id: 'variables',
name: 'Variables',
type: 'variables',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'tags.json',
id: 'tags',
name: 'Tags',
type: 'tags',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: now,
},
// Deleted resources
{
file: 'workflow-2.json',
id: 'wf-2',
name: 'Workflow 2',
type: 'workflow',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'credential-2.json',
id: 'cred-2',
name: 'Credential 2',
type: 'credential',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: now,
},
{
file: 'project-2.json',
id: 'proj-2',
name: 'Project 2',
type: 'project',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: now,
},
];
// The status service should return all these files as allowed
mockStatusService.getStatus.mockResolvedValueOnce(files);
// Mock all export and delete methods
sourceControlExportService.exportWorkflowsToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
sourceControlExportService.exportCredentialsToWorkFolder.mockResolvedValueOnce({
count: 1,
missingIds: [],
folder: '',
files: [],
});
sourceControlExportService.exportTeamProjectsToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
sourceControlExportService.exportTagsToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
(isContainedWithin as jest.Mock).mockReturnValue(true);
gitService.push.mockResolvedValueOnce(mockPushResult);
const commitMessage = 'Test commit message';
// Act
const result = await sourceControlService.pushWorkfolder(user, {
fileNames: files.map((f) => ({
file: f.file,
id: f.id,
name: f.name,
type: f.type,
status: f.status,
location: f.location,
conflict: f.conflict,
updatedAt: f.updatedAt,
})),
commitMessage,
});
// Assert
// All export methods for non-deleted resources should be called
expect(sourceControlExportService.exportWorkflowsToWorkFolder).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'wf-1' })]),
);
expect(sourceControlExportService.exportCredentialsToWorkFolder).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'cred-1' })]),
);
expect(sourceControlExportService.exportTeamProjectsToWorkFolder).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'proj-1' })]),
);
expect(sourceControlExportService.exportTagsToWorkFolder).toHaveBeenCalled();
expect(sourceControlExportService.exportFoldersToWorkFolder).toHaveBeenCalled();
expect(sourceControlExportService.exportVariablesToWorkFolder).toHaveBeenCalled();
// Deleted resources should be passed to rmFilesFromExportFolder
expect(sourceControlExportService.rmFilesFromExportFolder).toHaveBeenCalledWith(
new Set([
`${preferencesService.gitFolder}/workflow-2.json`,
`${preferencesService.gitFolder}/credential-2.json`,
`${preferencesService.gitFolder}/project-2.json`,
]),
);
// Git operations should be called
expect(gitService.stage).toHaveBeenCalledWith(
new Set([
`${preferencesService.gitFolder}/workflow-1.json`,
`${preferencesService.gitFolder}/credential-1.json`,
`${preferencesService.gitFolder}/project-1.json`,
`${preferencesService.gitFolder}/folders.json`,
`${preferencesService.gitFolder}/variables.json`,
`${preferencesService.gitFolder}/tags.json`,
]),
new Set([
`${preferencesService.gitFolder}/workflow-2.json`,
`${preferencesService.gitFolder}/credential-2.json`,
`${preferencesService.gitFolder}/project-2.json`,
]),
);
expect(gitService.commit).toHaveBeenCalledWith(commitMessage);
expect(gitService.push).toHaveBeenCalledWith({
branch: 'main', // default branch
force: false,
});
// The result should include the status and push result
expect(result).toMatchObject({
statusCode: 200,
});
});
it('should throw an error if file path validation fails', async () => {
const user = mock<User>();
(isContainedWithin as jest.Mock).mockReturnValueOnce(false);
@@ -78,12 +283,15 @@ describe('SourceControlService', () => {
],
}),
).rejects.toThrow('File path /etc/passwd is invalid');
expect(gitService.stage).not.toHaveBeenCalled();
expect(gitService.commit).not.toHaveBeenCalled();
expect(gitService.push).not.toHaveBeenCalled();
});
it('should include the tags file even if not explicitly specified', async () => {
// ARRANGE
const user = mock<User>();
const mockPushResult = mock<PushResult>();
const mockFile: SourceControlledFile = {
file: 'some-workflow.json',
id: 'test',
@@ -103,7 +311,10 @@ describe('SourceControlService', () => {
files: [],
});
eventService.emit.mockReturnValueOnce(true);
const mockPushResult = mock<PushResult>();
gitService.push.mockResolvedValueOnce(mockPushResult);
(isContainedWithin as jest.Mock).mockReturnValueOnce(true);
const expectedTagsPath = `${preferencesService.gitFolder}/tags.json`;
@@ -1071,9 +1071,25 @@ export class SourceControlImportService {
}
async deleteFoldersNotInWorkfolder(candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.folderRepository.delete(candidate.id);
if (candidates.length === 0) {
return;
}
const candidateIds = candidates.map((c) => c.id);
await this.folderRepository.delete({
id: In(candidateIds),
});
}
async deleteTeamProjectsNotInWorkfolder(candidates: SourceControlledFile[]) {
if (candidates.length === 0) {
return;
}
const candidateIds = candidates.map((c) => c.id);
await this.projectRepository.delete({
id: In(candidateIds),
});
}
/**
@@ -1,6 +1,6 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { type TagEntity, FolderRepository, TagRepository, type User, Variables } from '@n8n/db';
import { FolderRepository, type TagEntity, TagRepository, type User, Variables } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { UserError } from 'n8n-workflow';
@@ -693,9 +693,16 @@ export class SourceControlStatusService {
(remote) => !outOfScopeProjects.some((outOfScope) => outOfScope.id === remote.id),
);
const projectsMissingInRemote = projectsLocal.filter(
// BACKWARD COMPATIBILITY: When there are no remote projects we can't safely delete local projects
// because we don't know if it's the first pull or if all team projects have been removed
// As a downside this means that it's not possible to delete all team projects via source control sync
const areRemoteProjectsEmpty = projectsRemote.length === 0;
let projectsMissingInRemote = projectsLocal.filter(
(local) => !projectsRemote.some((remote) => remote.id === local.id),
);
if (options.direction === 'pull' && areRemoteProjectsEmpty) {
projectsMissingInRemote = [];
}
const projectsModifiedInEither: ExportableProjectWithFileName[] = [];
@@ -28,8 +28,13 @@ import {
} from './source-control-helper.ee';
import { SourceControlImportService } from './source-control-import.service.ee';
import { SourceControlPreferencesService } from './source-control-preferences.service.ee';
import { SourceControlStatusService } from './source-control-status.service.ee';
import {
filterByType,
getDeletedResources,
getNonDeletedResources,
} from './source-control-resource-helper';
import { SourceControlScopedService } from './source-control-scoped.service';
import { SourceControlStatusService } from './source-control-status.service.ee';
import type { ImportResult } from './types/import-result';
import { SourceControlContext } from './types/source-control-context';
import type { SourceControlGetStatus } from './types/source-control-get-status';
@@ -38,13 +43,6 @@ import type { SourceControlPreferences } from './types/source-control-preference
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import {
filterByType,
getDeletedResources,
getNonDeletedResources,
} from './source-control-resource-helper';
import { IWorkflowToImport } from '@/interfaces';
@Service()
@@ -297,7 +295,7 @@ export class SourceControlService {
we keep track of them in a single file unlike workflows and credentials
*/
filesToPush
.filter((f) => ['workflow', 'credential'].includes(f.type))
.filter((f) => ['workflow', 'credential', 'project'].includes(f.type))
.forEach((e) => {
if (e.status !== 'deleted') {
filesToBePushed.add(e.file);
@@ -323,6 +321,9 @@ export class SourceControlService {
});
}
const projectsToBeExported = getNonDeletedResources(filesToPush, 'project');
await this.sourceControlExportService.exportTeamProjectsToWorkFolder(projectsToBeExported);
// The tags file is always re-generated and exported to make sure the workflow-tag mappings are up to date
filesToBePushed.add(getTagsPath(this.gitFolder));
await this.sourceControlExportService.exportTagsToWorkFolder(context);
@@ -365,10 +366,6 @@ export class SourceControlService {
};
}
private getConflicts(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((file) => file.conflict || file.status === 'modified');
}
async pullWorkfolder(
user: User,
options: PullWorkFolderRequestDto,
@@ -382,7 +379,10 @@ export class SourceControlService {
})) as SourceControlledFile[];
if (options.force !== true) {
const possibleConflicts = this.getConflicts(statusResult);
const possibleConflicts = statusResult.filter(
(file) => file.conflict || file.status === 'modified',
);
if (possibleConflicts?.length > 0) {
await this.gitService.resetBranch();
return {
@@ -392,7 +392,10 @@ export class SourceControlService {
}
}
// Make sure the folders get processed first as the workflows depend on them
// IMPORTANT: Make sure the projects and folders get processed first as the workflows depend on them
const projectsToBeImported = getNonDeletedResources(statusResult, 'project');
await this.sourceControlImportService.importTeamProjectsFromWorkFolder(projectsToBeImported);
const foldersToBeImported = getNonDeletedResources(statusResult, 'folders')[0];
if (foldersToBeImported) {
await this.sourceControlImportService.importFoldersFromWorkFolder(user, foldersToBeImported);
@@ -408,6 +411,7 @@ export class SourceControlService {
user,
workflowsToBeDeleted,
);
const credentialsToBeImported = getNonDeletedResources(statusResult, 'credential');
await this.sourceControlImportService.importCredentialsFromWorkFolder(
credentialsToBeImported,
@@ -436,6 +440,9 @@ export class SourceControlService {
const foldersToBeDeleted = getDeletedResources(statusResult, 'folders');
await this.sourceControlImportService.deleteFoldersNotInWorkfolder(foldersToBeDeleted);
const projectsToBeDeleted = getDeletedResources(statusResult, 'project');
await this.sourceControlImportService.deleteTeamProjectsNotInWorkfolder(projectsToBeDeleted);
// #region Tracking Information
this.eventService.emit(
'source-control-user-finished-pull-ui',
@@ -874,11 +874,19 @@ describe('SourceControlService', () => {
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
const projectFiles = result.statusResult
.filter((change) => change.type === 'project' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(8);
expect(credentialFiles).toHaveLength(2);
expect(projectFiles).toHaveLength(2);
expect(gitService.push).toBeCalled();
expect(fsWriteFile).toBeCalledTimes(workflowFiles.length + credentialFiles.length + 2); // folders + tags
expect(fsWriteFile).toBeCalledTimes(
workflowFiles.length + credentialFiles.length + projectFiles.length + 2,
); // folders + tags
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(
@@ -908,14 +916,22 @@ describe('SourceControlService', () => {
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
const projectFiles = result.statusResult
.filter((change) => change.type === 'project' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(8);
expect(credentialFiles).toHaveLength(2);
const numberFilesToWrite = workflowFiles.length + credentialFiles.length + 2; // folders + tags
expect(projectFiles).toHaveLength(2);
const numberFilesToWrite =
workflowFiles.length + credentialFiles.length + projectFiles.length + 2; // folders + tags + projects
const filesToWrite =
allChanges.filter(
(change) =>
(change.type === 'workflow' || change.type === 'credential') &&
(change.type === 'workflow' ||
change.type === 'credential' ||
change.type === 'project') &&
change.status !== 'deleted',
).length + 2; // folders + tags
@@ -924,6 +940,7 @@ describe('SourceControlService', () => {
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(projectFiles));
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_FOLDERS_EXPORT_FILE)]),
);
@@ -961,11 +978,18 @@ describe('SourceControlService', () => {
const credentialFiles = result.statusResult
.filter((change) => change.type === 'credential' && change.status !== 'deleted')
.map((change) => change.file);
const projectFiles = result.statusResult
.filter((change) => change.type === 'project' && change.status !== 'deleted')
.map((change) => change.file);
expect(workflowFiles).toHaveLength(2);
expect(credentialFiles).toHaveLength(1);
expect(projectFiles).toHaveLength(1);
expect(fsWriteFile).toBeCalledTimes(workflowFiles.length + credentialFiles.length + 2); // folders + tags
// folders + tags + projects (1)
expect(fsWriteFile).toBeCalledTimes(
workflowFiles.length + credentialFiles.length + projectFiles.length + 2,
);
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(workflowFiles));
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(credentialFiles));
expect(Object.keys(updatedFiles)).toEqual(
@@ -974,6 +998,7 @@ describe('SourceControlService', () => {
expect(Object.keys(updatedFiles)).toEqual(
expect.arrayContaining([expect.stringMatching(SOURCE_CONTROL_TAGS_EXPORT_FILE)]),
);
expect(Object.keys(updatedFiles)).toEqual(expect.arrayContaining(projectFiles));
});
it('should throw ForbiddenError when trying to push workflows out of scope', async () => {
@@ -125,6 +125,7 @@
"generic.list.clearSelection": "Clear selection",
"generic.list.selected": "{count} row selected | {count} rows selected",
"generic.project": "Project",
"generic.projects": "Projects",
"generic.your": "Your",
"generic.apiKey": "API Key",
"about.aboutN8n": "About n8n",
@@ -1,15 +1,16 @@
<script lang="ts" setup>
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
import { useProjectsStore } from '../projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
import { useUsersStore } from '@/features/settings/users/users.store';
import type { ProjectListItem } from '../projects.types';
import { useSettingsStore } from '@/stores/settings.store';
import type { IMenuItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeMount } from 'vue';
import { computed, onBeforeMount, onBeforeUnmount } from 'vue';
import { useProjectsStore } from '../projects.store';
import type { ProjectListItem } from '../projects.types';
import { N8nButton, N8nMenuItem, N8nTooltip, N8nHeading } from '@n8n/design-system';
import { N8nButton, N8nHeading, N8nMenuItem, N8nTooltip } from '@n8n/design-system';
type Props = {
collapsed: boolean;
@@ -86,8 +87,18 @@ const activeTabId = computed(() => {
);
});
async function onSourceControlPull() {
// Update myProjects for the sidebar display
await projectsStore.getMyProjects();
}
onBeforeMount(async () => {
await usersStore.fetchUsers();
sourceControlEventBus.on('pull', onSourceControlPull);
});
onBeforeUnmount(() => {
sourceControlEventBus.off('pull', onSourceControlPull);
});
</script>
@@ -298,4 +298,83 @@ describe('SourceControlPullModal', () => {
const listItems = getAllByTestId('pull-modal-item');
expect(listItems.length).toBeGreaterThan(0);
});
it('should display projects in the otherFiles section', () => {
const status: SourceControlledFile[] = [
{
id: 'project-1',
name: 'Team Project 1',
type: 'project',
status: 'created',
location: 'remote',
conflict: false,
file: '/projects/project-1.json',
updatedAt: '2025-01-09T13:12:24.586Z',
owner: {
type: 'team',
projectId: 'project-1',
projectName: 'Team Project 1',
},
},
];
const { getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
expect(getByText(/Projects \(1\)/)).toBeInTheDocument();
});
it('should show correct project count in summary', () => {
const status: SourceControlledFile[] = [
{
id: 'project-1',
name: 'Team Project 1',
type: 'project',
status: 'created',
location: 'remote',
conflict: false,
file: '/projects/project-1.json',
updatedAt: '2025-01-09T13:12:24.586Z',
owner: {
type: 'team',
projectId: 'project-1',
projectName: 'Team Project 1',
},
},
{
id: 'project-2',
name: 'Team Project 2',
type: 'project',
status: 'modified',
location: 'remote',
conflict: true,
file: '/projects/project-2.json',
updatedAt: '2025-01-09T13:12:24.586Z',
owner: {
type: 'team',
projectId: 'project-2',
projectName: 'Team Project 2',
},
},
];
const { getByText } = renderModal({
pinia,
props: {
data: {
eventBus,
status,
},
},
});
expect(getByText(/Projects \(2\)/)).toBeInTheDocument();
});
});
@@ -192,6 +192,10 @@ const otherFiles = computed(() => {
if (folders) {
others.push.apply(others, folders);
}
const projects = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.project];
if (projects) {
others.push.apply(others, projects);
}
return others;
});
@@ -399,7 +403,10 @@ onMounted(() => {
Tags ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.tags]?.length || 0 }}),
</template>
<template v-if="groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.folders]?.length">
Folders ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.folders]?.length || 0 }})
Folders ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.folders]?.length || 0 }}),
</template>
<template v-if="groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.project]?.length">
Projects ({{ groupedFilesByType[SOURCE_CONTROL_FILE_TYPE.project]?.length || 0 }})
</template>
</N8nText>
</div>
@@ -313,6 +313,16 @@ describe('SourceControlPushModal', () => {
file: '/Users/raul/.n8n/git/folders.json',
updatedAt: '2024-12-04T11:29:22.095Z',
},
{
id: 'project-1',
name: 'Team Project 1',
type: 'project',
status: 'created',
location: 'local',
conflict: false,
file: '/projects/project-1.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
];
sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
@@ -342,10 +352,11 @@ describe('SourceControlPushModal', () => {
expect(getByRole('alert').textContent).toContain(
[
'Changes to variables, tags and folders',
'Changes to variables, tags, folders and projects',
'Variables : at least one new or modified.',
'Tags : at least one new or modified.',
'Folders : at least one new or modified. ',
'Folders : at least one new or modified.',
'Projects : at least one new or modified.',
].join(' '),
);
@@ -138,6 +138,7 @@ type Changes = {
workflow: SourceControlledFileWithProject[];
currentWorkflow?: SourceControlledFileWithProject;
folders: SourceControlledFileWithProject[];
projects: SourceControlledFileWithProject[];
};
const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?: string): Changes =>
@@ -185,6 +186,11 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.project) {
acc.projects.push({ ...file, project });
return acc;
}
return acc;
},
{
@@ -193,6 +199,7 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
credential: [],
workflow: [],
folders: [],
projects: [],
currentWorkflow: undefined,
},
);
@@ -221,6 +228,13 @@ const userNotices = computed(() => {
});
}
if (changes.value.projects.length) {
messages.push({
title: 'Projects',
content: 'at least one new or modified',
});
}
return messages;
});
const workflowId = computed(
@@ -368,6 +382,7 @@ const isSubmitDisabled = computed(() => {
changes.value.tags.length +
changes.value.variables.length +
changes.value.folders.length +
changes.value.projects.length +
selectedWorkflows.size;
return toBePushed <= 0;
@@ -463,6 +478,10 @@ const successNotificationMessage = () => {
messages.push(i18n.baseText('generic.tag_plural'));
}
if (changes.value.projects.length) {
messages.push(i18n.baseText('generic.projects'));
}
return [
concatenateWithAnd(messages),
i18n.baseText('settings.sourceControl.modals.push.success.description'),
@@ -474,6 +493,7 @@ async function commitAndPush() {
.concat(changes.value.variables)
.concat(changes.value.credential.filter((file) => selectedCredentials.has(file.id)))
.concat(changes.value.folders)
.concat(changes.value.projects)
.concat(changes.value.workflow.filter((file) => selectedWorkflows.has(file.id)));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
close();
@@ -920,7 +940,7 @@ onMounted(async () => {
<template #footer>
<N8nNotice v-if="userNotices.length" :compact="false" class="mt-0">
<N8nText bold size="medium">Changes to variables, tags and folders </N8nText>
<N8nText bold size="medium">Changes to variables, tags, folders and projects </N8nText>
<br />
<template v-for="{ title, content } in userNotices" :key="title">
<N8nText bold size="small"> {{ title }}</N8nText>
@@ -98,6 +98,7 @@ const pullMessage = ({
variables,
workflow,
folders,
project,
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
const messages: string[] = [];
@@ -131,6 +132,10 @@ const pullMessage = ({
messages.push(i18n.baseText('generic.folders_plural'));
}
if (project?.length) {
messages.push(i18n.baseText('generic.projects'));
}
return [
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
'were pulled',
@@ -151,14 +156,14 @@ export const notifyUserAboutPullWorkFolderOutcome = async (
return;
}
const { credential, tags, variables, workflow, folders } = groupBy(files, 'type');
const { credential, tags, variables, workflow, folders, project } = groupBy(files, 'type');
const toastMessages = [
...(variables?.length ? [createVariablesToast(router)] : []),
...(credential?.length ? [createCredentialsToast(router)] : []),
{
title: i18n.baseText('settings.sourceControl.pull.success.title'),
message: pullMessage({ credential, tags, variables, workflow, folders }),
message: pullMessage({ credential, tags, variables, workflow, folders, project }),
type: 'success' as const,
},
];
@@ -1450,6 +1450,7 @@ async function onSourceControlPull() {
environmentsStore.fetchAllVariables(),
tagsStore.fetchAll(),
loadCredentials(),
projectsStore.getAvailableProjects(),
]);
if (workflowId.value && !uiStore.stateIsDirty) {